Merge 9522 9513 (#8103)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
Co-authored-by: Denis Bykhov <bykhov.denis@gmail.com>
Co-authored-by: Alexander Onnikov <aonnikov@hardcoreeng.com>
Co-authored-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
Andrey Sobolev
2025-02-27 16:57:36 +07:00
committed by GitHub
co-authored by Denis Bykhov Alexander Onnikov Victor Ilyushchenko
parent 9eba1cdf07
commit 05f1d8395a
97 changed files with 1745 additions and 714 deletions
+5
View File
@@ -485,6 +485,11 @@ jobs:
cd ./ws-tests
export DO_CLEAN=true
./prepare.sh
- name: Run API tests
run: |
cd ./ws-tests/api-tests
node ../../common/scripts/install-run-rush.js validate --to @hcengineering/api-tests
node ../../common/scripts/install-run-rushx.js api-test --verbose
- name: Install Playwright
run: |
cd ./ws-tests/sanity
+3 -3
View File
@@ -77,11 +77,11 @@
"args": ["src/__start.ts"],
"env": {
"FULLTEXT_URL": "http://localhost:4710",
// "DB_URL": "mongodb://localhost:27018",
"DB_URL": "mongodb://localhost:27018",
// "DB_URL": "postgresql://postgres:example@localhost:5432",
"DB_URL": "postgresql://root@huly.local:26258/defaultdb?sslmode=disable",
// "DB_URL": "postgresql://root@huly.local:26258/defaultdb?sslmode=disable",
// "GREEN_URL": "http://huly.local:6767?token=secret",
"SERVER_PORT": "3335",
"SERVER_PORT": "3334",
"METRICS_CONSOLE": "false",
"METRICS_FILE": "${workspaceRoot}/metrics.txt", // Show metrics in console evert 30 seconds.,
"STORAGE_CONFIG": "minio|localhost?accessKey=minioadmin&secretKey=minioadmin",
+53 -10
View File
@@ -109,6 +109,9 @@ importers:
'@rush-temp/api-client':
specifier: file:./projects/api-client.tgz
version: file:projects/api-client.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(esbuild@0.24.2)(utf-8-validate@6.0.4)
'@rush-temp/api-tests':
specifier: file:./projects/api-tests.tgz
version: file:projects/api-tests.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))
'@rush-temp/attachment':
specifier: file:./projects/attachment.tgz
version: file:projects/attachment.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@20.11.19)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))
@@ -4012,6 +4015,10 @@ packages:
resolution: {integrity: sha512-SMKCkubeb8nE1zsYu0fsutpc9LJKiNOG0bn9NP+FxlSU4NGb8++EpZfb650lGYdvg2MljvrQD+kqYQDt1IRd1A==, tarball: file:projects/api-client.tgz}
version: 0.0.0
'@rush-temp/api-tests@file:projects/api-tests.tgz':
resolution: {integrity: sha512-gleyNro85I24sFWGaNjqqz1AF2c8sugQ4vZX/UD/OVu+It6mI96P2Zd8pDl9FqPaIUvtYng8ePj0ThjwI5uGFw==, tarball: file:projects/api-tests.tgz}
version: 0.0.0
'@rush-temp/attachment-assets@file:projects/attachment-assets.tgz':
resolution: {integrity: sha512-C4ZvrB9y7H0bh1vbkqmKw9tIrwFAHUG7P6kPI+tjRPRKQNGAn61eiVSTpM8BCGNY/mFC5b+uMy0us0UaqvPy+A==, tarball: file:projects/attachment-assets.tgz}
version: 0.0.0
@@ -4353,11 +4360,11 @@ packages:
version: 0.0.0
'@rush-temp/mail-resources@file:projects/mail-resources.tgz':
resolution: {integrity: sha512-MArPZYwi2jollp/LHmrsx6wqyZzX/2XwjptpOg/10kADjpwo6wShL95ABMx5sCZo3+8paGXU1PipjnikbpNc0w==, tarball: file:projects/mail-resources.tgz}
resolution: {integrity: sha512-495MjItvq3ep8/hMbrw8ITDPZQ8oNC+5h1ZLTU0uCLUAOVgu1BFVpRtBZ8nFAIxK2Aks5JpKg9No2070QDjDaQ==, tarball: file:projects/mail-resources.tgz}
version: 0.0.0
'@rush-temp/mail@file:projects/mail.tgz':
resolution: {integrity: sha512-0UzQEN733mDUZKfsGvNq8ikfsbVjQgTFOzP7/+buwIfWgeZBqVCoCDJrk90ZQ38yp8dMgdWZ+OymfTZ4WNlb7g==, tarball: file:projects/mail.tgz}
resolution: {integrity: sha512-Ld099uwXEeUid9s0GYHmgUKkctWnDc6D+yppNRmJhnf7VWtInqIPM2S3vl1kQi7pj0LwUY8ZKQ8B6m7Wxgj1Pw==, tarball: file:projects/mail.tgz}
version: 0.0.0
'@rush-temp/middleware@file:projects/middleware.tgz':
@@ -4461,7 +4468,7 @@ packages:
version: 0.0.0
'@rush-temp/model-mail@file:projects/model-mail.tgz':
resolution: {integrity: sha512-/GxdWziahn6TAFJ4/9V7YPatdGo68lWecWkOyMVdBtPFl9VcPsxYKt3cqWz9qk2G6UxV2k2bmI0qt8kg1anxUA==, tarball: file:projects/model-mail.tgz}
resolution: {integrity: sha512-ZJ0BKaCme1heHlFvWPZO419uFncRPU832X/ce17INounlmwjTR8O/V6DBbeSn+VPJnhVq9LIZurd7REYrP+bjA==, tarball: file:projects/model-mail.tgz}
version: 0.0.0
'@rush-temp/model-my-space@file:projects/model-my-space.tgz':
@@ -4769,7 +4776,7 @@ packages:
version: 0.0.0
'@rush-temp/pod-calendar@file:projects/pod-calendar.tgz':
resolution: {integrity: sha512-gUk3jshnHJ0CUucxZ1Stk8oQ2DTdSVfJzFHIl/EBUISbRv/YySoXesQCnqfn1kVpqOBZdIvI/N4NRAL/yREbWw==, tarball: file:projects/pod-calendar.tgz}
resolution: {integrity: sha512-ZZGuUNNBPQ8AuNUoq5jWopHtN7BExJBYPaEv+CULuyunTRzYli3xe/AKDM6pYQ/gR4ePMrPjnqcOeC3MBCeXUA==, tarball: file:projects/pod-calendar.tgz}
version: 0.0.0
'@rush-temp/pod-collaborator@file:projects/pod-collaborator.tgz':
@@ -4801,7 +4808,7 @@ packages:
version: 0.0.0
'@rush-temp/pod-server@file:projects/pod-server.tgz':
resolution: {integrity: sha512-U7wNYw+h8a6hEV6fwXYJFStTk5noW4LAsL1G4JCJjyQ7PVzlmXZqGncFe+bq5EqgYgbOewZ2tR+T8rB8HWi9Cw==, tarball: file:projects/pod-server.tgz}
resolution: {integrity: sha512-MJ5TQobQy4kv67qy7qpQgz056fhaZw8Zs/NaXniFafG/xjstgWeIJCXNHDkKvdBm+wT6EdHgNLIN3BaRpMSDsA==, tarball: file:projects/pod-server.tgz}
version: 0.0.0
'@rush-temp/pod-ses@file:projects/pod-ses.tgz':
@@ -4989,7 +4996,7 @@ packages:
version: 0.0.0
'@rush-temp/server-calendar-resources@file:projects/server-calendar-resources.tgz':
resolution: {integrity: sha512-92TFFDbAxCmUu4V3IaNDOLhbEEqTCB5dRohzpV1nzmroO8r+JneZjdV1j8OPHJF65ml0uqciDFPOct+kMzx5fg==, tarball: file:projects/server-calendar-resources.tgz}
resolution: {integrity: sha512-DMDh8Zn9OPu/Sg3ChbNu6iCcoVhvlqqd61hCTSpSlxxRJ+IdKQGlsKexccEgB87IgiYqAgM5AQMK05WaOaR+oQ==, tarball: file:projects/server-calendar-resources.tgz}
version: 0.0.0
'@rush-temp/server-calendar@file:projects/server-calendar.tgz':
@@ -5245,7 +5252,7 @@ packages:
version: 0.0.0
'@rush-temp/server-ws@file:projects/server-ws.tgz':
resolution: {integrity: sha512-Sev25LbpscG4MEnofW4g16Wsd4CzBMuFd1WqiVKwCOBPQEFxrH6zQ9wt20gHcjKacPZfn4GMFWuHew0H/ThuqQ==, tarball: file:projects/server-ws.tgz}
resolution: {integrity: sha512-0Jjc3+GOrp80rAP/J2kmp3AXBYF9fHOmISUbnmAorBQaj/I+H2vLmUSGBOunFFmRoU4RBfF2pWNjw3PvVjE9oA==, tarball: file:projects/server-ws.tgz}
version: 0.0.0
'@rush-temp/server@file:projects/server.tgz':
@@ -5433,7 +5440,7 @@ packages:
version: 0.0.0
'@rush-temp/ui@file:projects/ui.tgz':
resolution: {integrity: sha512-RoUDfXdl8E3vT/wG4bVrY9FsfUPeqG9Bck6lQsnKj9kk/Q6gG7a4xl3qEkwk+qLU6qH6QD7lkUiaEbm4PXh5MA==, tarball: file:projects/ui.tgz}
resolution: {integrity: sha512-Qnk2xLA3fSpGq0eNpEGm8DWiuRkDbF0B86v+GDmHbRmNOs9uxIy1sUEEPUIgNx1wmlHPrMh+vP+2v+VFzeUYUQ==, tarball: file:projects/ui.tgz}
version: 0.0.0
'@rush-temp/uploader-assets@file:projects/uploader-assets.tgz':
@@ -5453,7 +5460,7 @@ packages:
version: 0.0.0
'@rush-temp/view-resources@file:projects/view-resources.tgz':
resolution: {integrity: sha512-QbVy5YO1NViW1f65Z7SqHS8XPq5IBtsy1uwVYZK6La5zBXjjJA3BgN960sYc1eWUoKIz3naElQUKzXMC1lYK9w==, tarball: file:projects/view-resources.tgz}
resolution: {integrity: sha512-7AFAEQlszTmRCzi9xfKegIhjzl76W8lLW4/LLygoH7a8JZPqoJhP1/l99QhNwa0tpUsXFTMg1EYMucFMrXhMdQ==, tarball: file:projects/view-resources.tgz}
version: 0.0.0
'@rush-temp/view@file:projects/view.tgz':
@@ -16490,6 +16497,41 @@ snapshots:
- supports-color
- utf-8-validate
'@rush-temp/api-tests@file:projects/api-tests.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))':
dependencies:
'@types/body-parser': 1.19.5
'@types/compression': 1.7.5
'@types/cors': 2.8.17
'@types/express': 4.17.21
'@types/jest': 29.5.12
'@types/morgan': 1.9.9
'@types/node': 20.11.19
'@types/ws': 8.5.11
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.7.3)
'@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3)
bufferutil: 4.0.8
eslint: 8.56.0
eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1(eslint@8.56.0))(eslint-plugin-n@15.7.0(eslint@8.56.0))(eslint-plugin-promise@6.1.1(eslint@8.56.0))(eslint@8.56.0)(typescript@5.7.3)
eslint-plugin-import: 2.29.1(eslint@8.56.0)
eslint-plugin-n: 15.7.0(eslint@8.56.0)
eslint-plugin-promise: 6.1.1(eslint@8.56.0)
jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))
prettier: 3.2.5
snappyjs: 0.7.0
ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)))(typescript@5.7.3)
typescript: 5.7.3
utf-8-validate: 6.0.4
ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4)
transitivePeerDependencies:
- '@babel/core'
- '@jest/types'
- babel-jest
- babel-plugin-macros
- esbuild
- node-notifier
- supports-color
- ts-node
'@rush-temp/attachment-assets@file:projects/attachment-assets.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))':
dependencies:
'@types/jest': 29.5.12
@@ -24623,6 +24665,7 @@ snapshots:
'@types/cors': 2.8.17
'@types/express': 4.17.21
'@types/jest': 29.5.12
'@types/morgan': 1.9.9
'@types/node': 20.11.19
'@types/ws': 8.5.11
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3)
@@ -24637,6 +24680,7 @@ snapshots:
eslint-plugin-promise: 6.1.1(eslint@8.56.0)
express: 4.21.2
jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))
morgan: 1.10.0
prettier: 3.2.5
snappy: 7.2.2
ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)))(typescript@5.3.3)
@@ -26324,7 +26368,6 @@ snapshots:
eslint-plugin-promise: 6.1.1(eslint@8.56.0)
eslint-plugin-svelte: 2.35.1(eslint@8.56.0)(svelte@4.2.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))
fast-equals: 5.2.2
hls.js: 1.5.20
jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))
prettier: 3.2.5
prettier-plugin-svelte: 3.2.2(prettier@3.2.5)(svelte@4.2.19)
+67 -36
View File
@@ -138,6 +138,9 @@ export async function benchmark (
}
}
})
worker.on('error', (err) => {
console.error('worker error', err)
})
})
const m = newMetrics()
@@ -150,6 +153,9 @@ export async function benchmark (
moment: number
mem: number
memTotal: number
memRSS: number
memFree: number
memArrays: number
cpu: number
requestTime: number
operations: number
@@ -161,6 +167,9 @@ export async function benchmark (
moment: 'Moment Time',
mem: 'Mem',
memTotal: 'Mem total',
memRSS: 'Mem RSS',
memFree: 'Mem Free',
memArrays: 'Mem Arrays',
cpu: 'CPU',
requestTime: 'Request time',
operations: 'OPS',
@@ -173,6 +182,9 @@ export async function benchmark (
let cpu: number = 0
let memUsed: number = 0
let memTotal: number = 0
let memRSS: number = 0
const memFree: number = 0
let memArrays: number = 0
let elapsed = 0
let requestTime: number = 0
let operations = 0
@@ -207,6 +219,7 @@ export async function benchmark (
}
}
if (!found) {
console.log('no measurements found for path', path, p)
return null
}
}
@@ -214,47 +227,60 @@ export async function benchmark (
}
let timer: any
let p: Promise<void> | undefined
if (isMainThread && monitorConnection !== undefined) {
timer = setInterval(() => {
const st = platformNow()
try {
const fetchUrl = endpoint.replace('ws:/', 'http:/') + '/api/v1/statistics?token=' + token
void fetch(fetchUrl)
.then((res) => {
void res
.json()
.then((json) => {
memUsed = json.statistics.memoryUsed
memTotal = json.statistics.memoryTotal
cpu = json.statistics.cpuUsage
// operations = 0
requestTime = 0
// transfer = 0
const r = extract(
json.metrics as Metrics,
'🧲 session',
'client',
'handleRequest',
'process',
'find-all'
)
operations = (r?.operations ?? 0) - oldOperations
oldOperations = r?.operations ?? 0
requestTime = (r?.value ?? 0) / (((r?.operations as number) ?? 0) + 1)
const tr = extract(json.metrics as Metrics, '🧲 session', '#send-data')
transfer = (tr?.value ?? 0) - oldTransfer
oldTransfer = tr?.value ?? 0
})
.catch((err) => {
console.log(err)
})
})
.catch((err) => {
console.log(err)
const fetchUrl = endpoint.replace('ws:/', 'http:/') + '/api/v1/statistics'
if (p === undefined) {
p = fetch(fetchUrl, {
headers: {
Authorization: 'Bearer ' + token
},
keepalive: true
})
.then((res) => {
void res
.json()
.then((json) => {
memUsed = json.statistics.memoryUsed
memTotal = json.statistics.memoryTotal
memRSS = json.statistics.memoryRSS
memArrays = json.statistics.memoryArrayBuffers
cpu = json.statistics.cpuUsage
// operations = 0
requestTime = 0
// transfer = 0
const r = extract(
json.metrics as Metrics,
'🧲 session',
'client',
'handleRequest',
'process',
'find-all'
)
operations = (r?.operations ?? 0) - oldOperations
oldOperations = r?.operations ?? 0
requestTime = (r?.value ?? 0) / (((r?.operations as number) ?? 0) + 1)
const tr = extract(json.metrics as Metrics, '🧲 session', 'client', '#send-data')
transfer = (tr?.value ?? 0) - oldTransfer
oldTransfer = tr?.value ?? 0
p = undefined
})
.catch((err) => {
console.log(err)
p = undefined
})
})
.catch((err) => {
console.log(err)
p = undefined
})
}
} catch (err) {
console.log(err)
}
@@ -288,7 +314,10 @@ export async function benchmark (
moment,
average: Math.round(opTime / (ops + 1)),
mem: memUsed,
memRSS,
memTotal,
memFree,
memArrays,
cpu,
requestTime,
operations,
@@ -363,7 +392,9 @@ export function benchmarkWorker (): void {
if (!isMainThread) {
parentPort?.on('message', (msg: StartMessage) => {
console.log('starting worker', msg.workId)
void perform(msg)
void perform(msg).catch((err) => {
console.error('failed to perform', err)
})
})
}
+12 -4
View File
@@ -105,10 +105,6 @@ export function createModel (builder: Builder): void {
titleProvider: documents.function.ControlledDocumentTitleProvider
})
builder.mixin(documents.class.DocumentMeta, core.class.Class, view.mixin.ObjectTitle, {
titleProvider: documents.function.ControlledDocumentTitleProvider
})
builder.mixin(documents.class.DocumentApprovalRequest, core.class.Class, view.mixin.ObjectPresenter, {
presenter: documents.component.DocumentApprovalRequestPresenter
})
@@ -406,6 +402,10 @@ export function createModel (builder: Builder): void {
presenter: documents.component.DocumentPresenter
})
builder.mixin(documents.class.ControlledDocument, core.class.Class, view.mixin.LinkProvider, {
encode: documents.function.GetControlledDocumentLinkFragment
})
builder.mixin(documents.class.Document, core.class.Class, view.mixin.IgnoreActions, {
actions: [view.action.Delete]
})
@@ -741,6 +741,14 @@ export function createModel (builder: Builder): void {
provider: documents.function.DocumentIdentifierProvider
})
builder.mixin(documents.class.ControlledDocument, core.class.Class, view.mixin.ReferenceObjectProvider, {
provider: documents.function.ControlledDocumentReferenceObjectProvider
})
builder.mixin(documents.class.ProjectDocument, core.class.Class, view.mixin.ReferenceObjectProvider, {
provider: documents.function.ProjectDocumentReferenceObjectProvider
})
createAction(
builder,
{
@@ -26,6 +26,8 @@ import {
type Class,
type Data,
type Doc,
type DocIndexState,
DOMAIN_DOC_INDEX_STATE,
DOMAIN_SEQUENCE,
DOMAIN_TX,
generateId,
@@ -51,6 +53,7 @@ import { DOMAIN_ATTACHMENT } from '@hcengineering/model-attachment'
import core from '@hcengineering/model-core'
import tags from '@hcengineering/tags'
import { compareDocumentVersions } from '@hcengineering/controlled-documents/src'
import { makeRank } from '@hcengineering/rank'
import documents, { DOMAIN_DOCUMENTS } from './index'
@@ -402,6 +405,57 @@ async function migrateProjectMetaRank (client: MigrationClient): Promise<void> {
await client.bulk(DOMAIN_DOCUMENTS, operations)
}
async function migrateDocumentMetaInternalCode (client: MigrationClient): Promise<void> {
const docMetas = await client.find<DocumentMeta>(DOMAIN_DOCUMENTS, {
_class: documents.class.DocumentMeta
})
let docs = await client.find<ControlledDocument>(DOMAIN_DOCUMENTS, {
_class: documents.class.ControlledDocument
})
docs = docs.slice().sort(compareDocumentVersions).reverse()
const docMap = new Map<Ref<DocumentMeta>, ControlledDocument>()
for (const doc of docs) {
const curr = docMap.get(doc.attachedTo)
const metaId = doc.attachedTo
const shouldBind =
curr === undefined ||
doc.state === DocumentState.Effective ||
(doc.state === DocumentState.Archived && curr.state !== DocumentState.Effective)
if (shouldBind) docMap.set(metaId, doc)
}
const operations: { filter: MigrationDocumentQuery<DocumentMeta>, update: MigrateUpdate<DocumentMeta> }[] = []
const updatedIds = new Set<Ref<DocumentMeta>>()
for (const meta of docMetas) {
const doc = docMap.get(meta._id)
if (doc === undefined) continue
const title = `${doc.code} ${doc.title}`
if (meta.title === title) continue
operations.push({
filter: { _id: meta._id },
update: { $set: { title } }
})
updatedIds.add(meta._id)
}
await client.bulk(DOMAIN_DOCUMENTS, operations)
await client.update<DocIndexState>(
DOMAIN_DOC_INDEX_STATE,
{
_id: { $in: Array.from(updatedIds) as any },
objectClass: documents.class.DocumentMeta
},
{ $set: { needIndex: true } }
)
}
export const documentsOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {
await tryMigrate(client, documentsId, [
@@ -427,6 +481,10 @@ export const documentsOperation: MigrateOperation = {
)
await client.move(DOMAIN_DOCUMENTS, { _class: core.class.Sequence }, DOMAIN_SEQUENCE)
}
},
{
state: 'migrateDocumentMetaInternalCode',
func: migrateDocumentMetaInternalCode
}
])
},
@@ -53,6 +53,12 @@ export default mergeIds(documentsId, documents, {
DocumentIdentifierProvider: '' as Resource<
<T extends Doc>(client: Client, ref: Ref<T>, doc?: T) => Promise<string>
>,
ControlledDocumentReferenceObjectProvider: '' as Resource<
<T extends Doc>(client: Client, ref: Ref<T>, doc?: T) => Promise<Doc>
>,
ProjectDocumentReferenceObjectProvider: '' as Resource<
<T extends Doc>(client: Client, ref: Ref<T>, doc?: T) => Promise<Doc>
>,
Comment: '' as Resource<TextActionFunction>,
IsCommentVisible: '' as Resource<TextActionVisibleFunction>
},
+7
View File
@@ -89,6 +89,7 @@ import {
type ViewletDescriptor,
type ViewletPreference,
type ObjectIdentifier,
type ReferenceObjectProvider,
type ObjectIcon,
type ObjectTooltip,
type AttrPresenter,
@@ -253,6 +254,11 @@ export class TObjectIdentifier extends TClass implements ObjectIdentifier {
provider!: Resource<<T extends Doc>(client: Client, ref: Ref<T>, doc?: T) => Promise<string>>
}
@Mixin(view.mixin.ReferenceObjectProvider, core.class.Class)
export class TReferenceObjectProvider extends TClass implements ReferenceObjectProvider {
provider!: Resource<<T extends Doc>(client: Client, ref: Ref<T>, doc?: T) => Promise<Doc | undefined>>
}
@Mixin(view.mixin.ObjectTooltip, core.class.Class)
export class TObjectTooltip extends TClass implements ObjectTooltip {
provider!: Resource<(client: Client, doc?: Doc | null) => Promise<LabelAndProps | undefined>>
@@ -465,6 +471,7 @@ export function createModel (builder: Builder): void {
TAggregation,
TGroupping,
TObjectIdentifier,
TReferenceObjectProvider,
TObjectTooltip,
TObjectIcon,
TAttrPresenter,
+11 -4
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { getClient as getAccountClient } from '@hcengineering/account-client'
import { type WorkspaceLoginInfo, getClient as getAccountClient } from '@hcengineering/account-client'
import client, { clientId } from '@hcengineering/client'
import {
type Class,
@@ -288,11 +288,18 @@ class PlatformClientImpl implements PlatformClient {
}
}
async function getWorkspaceToken (
export interface WorkspaceToken {
endpoint: string
token: string
workspaceId: WorkspaceUuid
info: WorkspaceLoginInfo
}
export async function getWorkspaceToken (
url: string,
options: ConnectOptions,
config?: ServerConfig
): Promise<{ endpoint: string, token: string, workspaceId: string }> {
): Promise<WorkspaceToken> {
config ??= await loadServerConfig(url)
let token: string | undefined
@@ -314,5 +321,5 @@ async function getWorkspaceToken (
throw new Error('Workspace not found')
}
return { endpoint: ws.endpoint, token: ws.token, workspaceId: ws.workspace }
return { endpoint: ws.endpoint, token: ws.token, workspaceId: ws.workspace, info: ws }
}
+1
View File
@@ -18,3 +18,4 @@ export * from './markup/types'
export * from './socket'
export * from './types'
export * from './rest'
export * from './config'
-130
View File
@@ -1,130 +0,0 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import {
type Account,
type Class,
type Doc,
type DocumentQuery,
type FindOptions,
type FindResult,
type Ref,
type Storage,
type Tx,
type TxResult,
type WithLookup,
concatLink
} from '@hcengineering/core'
import { PlatformError, unknownError } from '@hcengineering/platform'
import { uncompress } from 'snappyjs'
export interface RestClient extends Storage {
getAccount: () => Promise<Account>
findOne: <T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
) => Promise<WithLookup<T> | undefined>
}
export async function createRestClient (endpoint: string, workspaceId: string, token: string): Promise<RestClient> {
return new RestClientImpl(endpoint, workspaceId, token)
}
class RestClientImpl implements RestClient {
constructor (
readonly endpoint: string,
readonly workspace: string,
readonly token: string
) {}
async findAll<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<FindResult<T>> {
const params = new URLSearchParams()
params.append('class', _class)
if (query !== undefined && Object.keys(query).length > 0) {
params.append('query', JSON.stringify(query))
}
if (options !== undefined && Object.keys(options).length > 0) {
params.append('options', JSON.stringify(options))
}
const response = await fetch(concatLink(this.endpoint, `/api/v1/find-all/${this.workspace}?${params.toString()}`), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + this.token,
'accept-encoding': 'snappy, gzip'
},
keepalive: true
})
if (!response.ok) {
throw new PlatformError(unknownError(response.statusText))
}
const encoding = response.headers.get('content-encoding')
if (encoding === 'snappy') {
const buffer = await response.arrayBuffer()
const decompressed = uncompress(buffer)
const decoder = new TextDecoder()
const jsonString = decoder.decode(decompressed)
return JSON.parse(jsonString) as FindResult<T>
}
return (await response.json()) as FindResult<T>
}
async getAccount (): Promise<Account> {
const response = await fetch(concatLink(this.endpoint, `/api/v1/account/${this.workspace}`), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + this.token
},
keepalive: true
})
if (!response.ok) {
throw new PlatformError(unknownError(response.statusText))
}
return (await response.json()) as Account
}
async findOne<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<WithLookup<T> | undefined> {
return (await this.findAll(_class, query, { ...options, limit: 1 })).shift()
}
async tx (tx: Tx): Promise<TxResult> {
const response = await fetch(concatLink(this.endpoint, `/api/v1/tx/${this.workspace}`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + this.token
},
keepalive: true,
body: JSON.stringify(tx)
})
if (!response.ok) {
throw new PlatformError(unknownError(response.statusText))
}
return (await response.json()) as TxResult
}
}
+18
View File
@@ -0,0 +1,18 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
export { createRestClient } from './rest'
export { createRestTxOperations } from './tx'
export * from './types'
+199
View File
@@ -0,0 +1,199 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import {
type Account,
type Class,
type Doc,
type DocumentQuery,
type FindOptions,
type FindResult,
Hierarchy,
MeasureMetricsContext,
ModelDb,
type Ref,
type SearchOptions,
type SearchQuery,
type SearchResult,
type Tx,
type TxResult,
type WithLookup,
buildModel,
concatLink
} from '@hcengineering/core'
import { PlatformError, unknownError } from '@hcengineering/platform'
import type { RestClient } from './types'
import { extractJson, withRetry } from './utils'
export function createRestClient (endpoint: string, workspaceId: string, token: string): RestClient {
return new RestClientImpl(endpoint, workspaceId, token)
}
export class RestClientImpl implements RestClient {
endpoint: string
constructor (
endpoint: string,
readonly workspace: string,
readonly token: string
) {
this.endpoint = endpoint.replace('ws', 'http')
}
jsonHeaders (): Record<string, string> {
return {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + this.token,
'accept-encoding': 'snappy, gzip'
}
}
requestInit (): RequestInit {
return {
method: 'GET',
keepalive: true,
headers: this.jsonHeaders()
}
}
async findAll<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<FindResult<T>> {
const params = new URLSearchParams()
params.append('class', _class)
if (query !== undefined && Object.keys(query).length > 0) {
params.append('query', JSON.stringify(query))
}
if (options !== undefined && Object.keys(options).length > 0) {
params.append('options', JSON.stringify(options))
}
const requestUrl = concatLink(this.endpoint, `/api/v1/find-all/${this.workspace}?${params.toString()}`)
const result = await withRetry(async () => {
const response = await fetch(requestUrl, this.requestInit())
if (!response.ok) {
throw new PlatformError(unknownError(response.statusText))
}
return await extractJson<FindResult<T>>(response)
})
if (result.lookupMap !== undefined) {
// We need to extract lookup map to document lookups
for (const d of result) {
if (d.$lookup !== undefined) {
for (const [k, v] of Object.entries(d.$lookup)) {
if (!Array.isArray(v)) {
d.$lookup[k] = result.lookupMap[v as any]
} else {
d.$lookup[k] = v.map((it) => result.lookupMap?.[it])
}
}
}
}
delete result.lookupMap
}
// We need to revert deleted query simple values.
// We need to get rid of simple query parameters matched in documents
for (const doc of result) {
if (doc._class == null) {
doc._class = _class
}
for (const [k, v] of Object.entries(query)) {
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
if (doc[k] == null) {
doc[k] = v
}
}
}
}
return result
}
async getAccount (): Promise<Account> {
const requestUrl = concatLink(this.endpoint, `/api/v1/account/${this.workspace}`)
const response = await fetch(requestUrl, this.requestInit())
if (!response.ok) {
throw new PlatformError(unknownError(response.statusText))
}
return await extractJson<Account>(response)
}
async getModel (): Promise<{ hierarchy: Hierarchy, model: ModelDb }> {
const requestUrl = concatLink(this.endpoint, `/api/v1/load-model/${this.workspace}`)
const response = await fetch(requestUrl, this.requestInit())
if (!response.ok) {
throw new PlatformError(unknownError(response.statusText))
}
const modelResponse: Tx[] = await extractJson<Tx[]>(response)
const hierarchy = new Hierarchy()
const model = new ModelDb(hierarchy)
const ctx = new MeasureMetricsContext('loadModel', {})
buildModel(ctx, modelResponse, (txes: Tx[]) => txes, hierarchy, model)
return { hierarchy, model }
}
async findOne<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<WithLookup<T> | undefined> {
return (await this.findAll(_class, query, { ...options, limit: 1 })).shift()
}
async tx (tx: Tx): Promise<TxResult> {
const requestUrl = concatLink(this.endpoint, `/api/v1/tx/${this.workspace}`)
const response = await fetch(requestUrl, {
method: 'POST',
headers: this.jsonHeaders(),
keepalive: true,
body: JSON.stringify(tx)
})
if (!response.ok) {
throw new PlatformError(unknownError(response.statusText))
}
return await extractJson<TxResult>(response)
}
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
const params = new URLSearchParams()
params.append('query', query.query)
if (query.classes != null && Object.keys(query.classes).length > 0) {
params.append('classes', JSON.stringify(query.classes))
}
if (query.spaces != null && Object.keys(query.spaces).length > 0) {
params.append('spaces', JSON.stringify(query.spaces))
}
if (options.limit != null) {
params.append('limit', `${options.limit}`)
}
const requestUrl = concatLink(this.endpoint, `/api/v1/search-fulltext/${this.workspace}`)
const response = await fetch(requestUrl, {
method: 'GET',
headers: this.jsonHeaders(),
keepalive: true
})
if (!response.ok) {
throw new PlatformError(unknownError(response.statusText))
}
return await extractJson<TxResult>(response)
}
}
+101
View File
@@ -0,0 +1,101 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import {
type Account,
type Class,
type Client,
type Doc,
type DocumentQuery,
type FindOptions,
type FindResult,
Hierarchy,
ModelDb,
type Ref,
type SearchOptions,
type SearchQuery,
type SearchResult,
toFindResult,
type Tx,
TxOperations,
type TxResult,
type WithLookup
} from '@hcengineering/core'
import { RestClientImpl } from './rest'
export async function createRestTxOperations (
endpoint: string,
workspaceId: string,
token: string
): Promise<TxOperations> {
const restClient = new RestClientImpl(endpoint, workspaceId, token)
const account = await restClient.getAccount()
const { hierarchy, model } = await restClient.getModel()
return new TxOperations(new RestTxClient(restClient, hierarchy, model, account), account.socialIds[0])
}
class RestTxClient implements Client {
constructor (
readonly client: RestClientImpl,
readonly hierarchy: Hierarchy,
readonly model: ModelDb,
readonly account: Account
) {}
close (): Promise<void> {
return Promise.resolve()
}
async findAll<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<FindResult<T>> {
const data = await this.client.findAll(_class, query, options)
const result = data.map((v) => {
return this.hierarchy.updateLookupMixin(_class, v, options)
})
return toFindResult(result, data.total)
}
async findOne<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<WithLookup<T> | undefined> {
const v = await this.client.findOne(_class, query, options)
if (v === undefined) {
return
}
return this.hierarchy.updateLookupMixin(_class, v, options)
}
getHierarchy: () => Hierarchy = () => this.hierarchy
getModel: () => ModelDb = () => this.model
async getAccount (): Promise<Account> {
return this.account
}
async tx (tx: Tx): Promise<TxResult> {
return await this.client.tx(tx)
}
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
return await this.client.searchFulltext(query, options)
}
}
+39
View File
@@ -0,0 +1,39 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import {
type Account,
type Class,
type Doc,
type DocumentQuery,
type FindOptions,
type Hierarchy,
type ModelDb,
type Ref,
type Storage,
type WithLookup
} from '@hcengineering/core'
export interface RestClient extends Storage {
getAccount: () => Promise<Account>
findOne: <T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
) => Promise<WithLookup<T> | undefined>
getModel: () => Promise<{ hierarchy: Hierarchy, model: ModelDb }>
}
+42
View File
@@ -0,0 +1,42 @@
import { uncompress } from 'snappyjs'
export async function withRetry<T> (fn: () => Promise<T>): Promise<T> {
const maxRetries = 3
let lastError: any
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn()
} catch (err: any) {
lastError = err
if (attempt === maxRetries - 1) {
console.error('Failed to execute query', err)
throw lastError
}
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100))
}
}
throw lastError
}
function rpcJSONReceiver (key: string, value: any): any {
if (typeof value === 'object' && value !== null) {
if (value.dataType === 'TotalArray') {
return Object.assign(value.value, { total: value.total, lookupMap: value.lookupMap })
}
}
return value
}
export async function extractJson<T> (response: Response): Promise<any> {
const encoding = response.headers.get('content-encoding')
if (encoding === 'snappy') {
const buffer = await response.arrayBuffer()
const decompressed = uncompress(buffer)
const decoder = new TextDecoder()
const jsonString = decoder.decode(decompressed)
return JSON.parse(jsonString, rpcJSONReceiver) as T
}
const jsonString = await response.text()
return JSON.parse(jsonString, rpcJSONReceiver) as T
}
+1 -1
View File
@@ -393,7 +393,7 @@ async function loadModel (
return { mode: 'addition', current: current.transactions, addition: result.transactions }
}
function buildModel (
export function buildModel (
ctx: MeasureContext,
transactions: Tx[],
modelFilter: ModelFilter | undefined,
@@ -90,7 +90,7 @@
const employeeRef = (existingPerson?._id as Ref<Employee>) ?? id
await client.createMixin(id, contact.class.Person, contact.space.Contacts, contact.mixin.Employee, {
active: false
active: true
})
await client.addCollection(
@@ -42,7 +42,7 @@
function isEditable (owner: boolean, object: Person): boolean {
if (owner) return true
if (!h.hasMixin(object, contact.mixin.Employee)) return true
return hasAccountRole(account, AccountRole.Maintainer) && !h.as(object, contact.mixin.Employee).active
return hasAccountRole(account, AccountRole.Maintainer)
}
$: editable = !readonly && isEditable(owner, object)
+2 -6
View File
@@ -381,7 +381,7 @@ export async function getAllEmployeesPrimarySocialStrings (client: Client): Prom
export async function ensureEmployee (
ctx: MeasureContext,
me: Account,
client: Client,
client: Pick<Client, 'findOne' | 'findAll' | 'tx'>,
socialIds: SocialId[],
getGlobalPerson: () => Promise<GlobalPerson | undefined>
): Promise<Ref<Employee> | null> {
@@ -439,11 +439,7 @@ export async function ensureEmployee (
if (me.role !== AccountRole.Guest) {
const employee = await client.findOne(contact.mixin.Employee, { _id: personRef as Ref<Employee> })
if (
employee === undefined ||
!client.getHierarchy().hasMixin(employee, contact.mixin.Employee) ||
!employee.active
) {
if (employee === undefined || !Hierarchy.hasMixin(employee, contact.mixin.Employee) || !employee.active) {
await ctx.with('create-employee', {}, async () => {
if (personRef === undefined) {
// something went wrong
@@ -300,7 +300,9 @@
"Obsolete": "Zastaralé",
"MakeDocumentObsolete": "Označit jako zastaralé",
"MakeDocumentObsoleteDialog": "Označit {count, plural, one {dokument jako zastaralý} other {dokumenty jako zastaralé}}",
"MakeDocumentObsoleteConfirm": "Opravdu chcete označit následující dokumenty jako zastaralé: {titles}?"
"MakeDocumentObsoleteConfirm": "Opravdu chcete označit následující dokumenty jako zastaralé: {titles}?",
"LatestVersionHint": "nejnovější"
},
"controlledDocStates": {
"Empty": "",
@@ -307,7 +307,9 @@
"Obsolete": "Veraltet",
"MakeDocumentObsolete": "Als veraltet markieren",
"MakeDocumentObsoleteDialog": "{count, plural, one {Dokument als veraltet markieren} other {Dokumente als veraltet markieren}}",
"MakeDocumentObsoleteConfirm": "Möchten Sie die folgenden Dokumente wirklich als veraltet markieren: {titles}?"
"MakeDocumentObsoleteConfirm": "Möchten Sie die folgenden Dokumente wirklich als veraltet markieren: {titles}?",
"LatestVersionHint": "neueste"
},
"controlledDocStates": {
"Empty": "",
@@ -309,7 +309,9 @@
"CreateFolder": "Create new folder",
"RenameFolder": "Rename folder",
"CreateChildFolder": "Create child folder"
"CreateChildFolder": "Create child folder",
"LatestVersionHint": "latest"
},
"controlledDocStates": {
"Empty": "",
@@ -267,7 +267,9 @@
"Obsolete": "Obsolète",
"MakeDocumentObsolete": "Marquer comme obsolète",
"MakeDocumentObsoleteDialog": "Marquer {count, plural, one {le document comme obsolète} other {les documents comme obsolètes}}",
"MakeDocumentObsoleteConfirm": "Voulez-vous vraiment marquer les documents suivants comme obsolètes : {titles} ?"
"MakeDocumentObsoleteConfirm": "Voulez-vous vraiment marquer les documents suivants comme obsolètes : {titles} ?",
"LatestVersionHint": "dernier"
},
"controlledDocStates": {
"Empty": "",
@@ -265,7 +265,9 @@
"Obsolete": "Obsoleto",
"MakeDocumentObsolete": "Segna come obsoleto",
"MakeDocumentObsoleteDialog": "Segna {count, plural, one {il documento come obsoleto} other {i documenti come obsoleti}}",
"MakeDocumentObsoleteConfirm": "Vuoi davvero segnare i seguenti documenti come obsoleti: {titles}?"
"MakeDocumentObsoleteConfirm": "Vuoi davvero segnare i seguenti documenti come obsoleti: {titles}?",
"LatestVersionHint": "ultimo"
},
"controlledDocStates": {
"Empty": "",
@@ -309,7 +309,9 @@
"Obsolete": "Устаревший",
"MakeDocumentObsolete": "Пометить как устаревшее",
"MakeDocumentObsoleteDialog": "Пометить {count, plural, one {документ как устаревший} other {документы как устаревшие}}",
"MakeDocumentObsoleteConfirm": "Вы действительно хотите пометить следующие документы как устаревшие: {titles}?"
"MakeDocumentObsoleteConfirm": "Вы действительно хотите пометить следующие документы как устаревшие: {titles}?",
"LatestVersionHint": "последняя"
},
"controlledDocStates": {
"Empty": "",
@@ -306,7 +306,9 @@
"Obsolete": "已过时",
"MakeDocumentObsolete": "标记为过时",
"MakeDocumentObsoleteDialog": "标记 {count, plural, one {文档为过时} other {文档为过时}}",
"MakeDocumentObsoleteConfirm": "您确定要将以下文档标记为过时吗:{titles}?"
"MakeDocumentObsoleteConfirm": "您确定要将以下文档标记为过时吗:{titles}?",
"LatestVersionHint": "最新"
},
"controlledDocStates": {
"Empty": "",
@@ -49,10 +49,12 @@
$documentComments as documentComments,
documentCommentsDisplayRequested,
documentCommentsHighlightUpdated,
documentCommentsLocationNavigateRequested
documentCommentsLocationNavigateRequested,
$documentReleasedVersions as documentReleasedVersions
} from '../../stores/editors/document'
import DocumentTitle from './DocumentTitle.svelte'
import DocumentPrintTitlePage from '../print/DocumentPrintTitlePage.svelte'
import { syncDocumentMetaTitle } from '../../utils'
const client = getClient()
const hierarchy = client.getHierarchy()
@@ -111,14 +113,17 @@
unsubscribeNavigateToLocation()
})
const handleUpdateTitle = () => {
const handleUpdateTitle = async () => {
if (!$controlledDocument || !title) {
return
}
const titleTrimmed = title.trim()
if (titleTrimmed.length > 0 && titleTrimmed !== $controlledDocument.title) {
client.update($controlledDocument, { title: titleTrimmed })
await client.update($controlledDocument, { title: titleTrimmed })
if ($documentReleasedVersions.length === 0 && $controlledDocument.state === DocumentState.Draft) {
await syncDocumentMetaTitle(client, $controlledDocument.attachedTo, $controlledDocument.code, titleTrimmed)
}
}
}
@@ -15,14 +15,15 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import documents, { Document } from '@hcengineering/controlled-documents'
import documents, { ControlledDocument } from '@hcengineering/controlled-documents'
import presentation, { createQuery, getClient } from '@hcengineering/presentation'
import { Button, EditBox, Label } from '@hcengineering/ui'
import IconWarning from '../../icons/IconWarning.svelte'
import documentsRes from '../../../plugin'
import { syncDocumentMetaTitle } from '../../../utils'
export let object: Document
export let object: ControlledDocument
const client = getClient()
const dispatch = createEventDispatcher()
@@ -59,6 +60,7 @@
}
await client.update(object, { code })
await syncDocumentMetaTitle(client, object.attachedTo, code, object.title)
dispatch('close')
}
</script>
@@ -105,7 +105,10 @@ import {
createTemplate,
deleteFolder,
documentIdentifierProvider,
controlledDocumentReferenceObjectProvider,
projectDocumentReferenceObjectProvider,
getAllDocumentStates,
getControlledDocumentLinkFragment,
getControlledDocumentTitle,
getDocumentMetaLinkFragment,
getVisibleFilters,
@@ -448,6 +451,7 @@ export default async (): Promise<Resources> => ({
DocumentStateSort: sortDocumentStates,
GetAllDocumentStates: getAllDocumentStates,
GetDocumentMetaLinkFragment: getDocumentMetaLinkFragment,
GetControlledDocumentLinkFragment: getControlledDocumentLinkFragment,
CanDeleteDocument: canDeleteDocument,
CanArchiveDocument: canArchiveDocument,
CanMakeDocumentObsolete: canMakeDocumentObsolete,
@@ -455,6 +459,8 @@ export default async (): Promise<Resources> => ({
CanOpenDocument: canOpenDocument,
CanPrintDocument: canPrintDocument,
DocumentIdentifierProvider: documentIdentifierProvider,
ControlledDocumentReferenceObjectProvider: controlledDocumentReferenceObjectProvider,
ProjectDocumentReferenceObjectProvider: projectDocumentReferenceObjectProvider,
ControlledDocumentTitleProvider: getControlledDocumentTitle,
Comment: comment,
IsCommentVisible: isCommentVisible
@@ -212,7 +212,9 @@ export default mergeIds(documentsId, documents, {
CreateDocumentFailed: '' as IntlString,
CreateDocumentTemplateFailed: '' as IntlString,
TryAgain: '' as IntlString
TryAgain: '' as IntlString,
LatestVersionHint: '' as IntlString
},
controlledDocStates: {
Empty: '' as IntlString,
@@ -240,6 +242,7 @@ export default mergeIds(documentsId, documents, {
GetAllDocumentStates: '' as Resource<() => Promise<DocumentState[]>>,
GetVisibleFilters: '' as Resource<(filters: KeyFilter[], space?: Ref<Space>) => Promise<KeyFilter[]>>,
GetDocumentMetaLinkFragment: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>,
GetControlledDocumentLinkFragment: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>,
CanDeleteDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanArchiveDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanMakeDocumentObsolete: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
@@ -17,6 +17,7 @@ import { type Employee, type Person, getCurrentEmployee } from '@hcengineering/c
import documents, {
type ControlledDocument,
type Document,
type DocumentBundle,
type DocumentCategory,
type DocumentComment,
type DocumentMeta,
@@ -28,12 +29,12 @@ import documents, {
type ProjectDocument,
type ProjectMeta,
ControlledDocumentState,
type DocumentBundle,
DocumentState,
ProjectDocumentTree,
compareDocumentVersions,
emptyBundle,
getDocumentName,
getFirstRank,
ProjectDocumentTree
getFirstRank
} from '@hcengineering/controlled-documents'
import core, {
type Class,
@@ -191,6 +192,18 @@ export async function getDocumentMetaLinkFragment (document: Doc): Promise<Locat
return getProjectDocumentLink(targetDocument, project)
}
export async function getControlledDocumentLinkFragment (document: ControlledDocument): Promise<Location> {
const client = getClient()
const targetDocument = await client.findOne(documents.class.ProjectDocument, { document: document._id })
if (targetDocument === undefined) {
throw new Error('Cannot resolve a ProjectDocument for document ' + document._id)
}
const project = targetDocument.project ?? documents.ids.NoProject
return getProjectDocumentLink(document, project)
}
export interface TeamPopupData {
controlledDoc: ControlledDocument
requestClass: Ref<Class<DocumentRequest>>
@@ -703,6 +716,41 @@ export async function documentIdentifierProvider (client: Client, ref: Ref<Docum
return document.code
}
export async function controlledDocumentReferenceObjectProvider (
client: Client,
ref: Ref<ControlledDocument>,
doc?: ControlledDocument
): Promise<Doc | undefined> {
const document = doc ?? (await client.findOne(documents.class.ControlledDocument, { _id: ref }))
if (document === undefined) return
const meta = await client.findOne(documents.class.DocumentMeta, { _id: document.attachedTo })
if (meta === undefined) return
let documentSeq: ControlledDocument[] = await client.findAll(documents.class.ControlledDocument, {
attachedTo: meta._id
})
const allowStates = [DocumentState.Draft, DocumentState.Effective]
documentSeq = documentSeq.filter((d) => allowStates.includes(d.state)).sort(compareDocumentVersions)
const effIndex = documentSeq.findIndex((d) => d.state === DocumentState.Effective)
const docIndex = documentSeq.findIndex((d) => d._id === document._id)
return docIndex >= 0 && (docIndex <= effIndex || effIndex < 0) ? meta : document
}
export async function projectDocumentReferenceObjectProvider (
client: Client,
ref: Ref<ProjectDocument>,
doc?: ProjectDocument
): Promise<Doc | undefined> {
const prjdoc = doc ?? (await client.findOne(documents.class.ProjectDocument, { _id: ref }))
if (prjdoc === undefined) return
return await controlledDocumentReferenceObjectProvider(client, prjdoc.document as Ref<ControlledDocument>)
}
export function documentCompareFn (doc1: Document, doc2: Document): number {
return doc1.major - doc2.major !== 0 ? doc1.major - doc2.major : doc1.minor - doc2.minor
}
@@ -720,7 +768,7 @@ export async function getControlledDocumentTitle (
if (object === undefined) return ''
return object.title
return object.title + ` (${getDocumentVersionString(object)})`
}
export async function createChildDocument (doc: ProjectDocument): Promise<void> {
@@ -907,3 +955,15 @@ export class DocumentHiearchyQuery {
export function createDocumentHierarchyQuery (): DocumentHiearchyQuery {
return new DocumentHiearchyQuery()
}
export async function syncDocumentMetaTitle (
client: Client & TxOperations,
_id: Ref<DocumentMeta>,
code: string,
title: string
): Promise<void> {
const meta = await client.findOne(documents.class.DocumentMeta, { _id })
if (meta !== undefined) {
await client.update(meta, { title: `${code} ${title}` })
}
}
+1 -1
View File
@@ -166,7 +166,7 @@ export async function createControlledDocMetadata (
space,
{
documents: 0,
title: `${prefix}-${seqNumber} ${specTitle}`
title: `${specCode} ${specTitle}`
},
metaId
)
+17 -8
View File
@@ -156,17 +156,25 @@ export function isFolder (doc: ProjectDocument | undefined): boolean {
return doc !== undefined && doc.document === documents.ids.Folder
}
function getDocumentSortSequence (doc: ControlledDocument | undefined): number[] {
return doc !== undefined ? [doc.seqNumber, doc.major, doc.minor, doc.createdOn ?? 0] : [0, 0, 0, 0]
}
export function compareDocumentVersions (
doc1: ControlledDocument | undefined,
doc2: ControlledDocument | undefined
): number {
const s0 = getDocumentSortSequence(doc1)
const s1 = getDocumentSortSequence(doc2)
return s0.reduce((r, v, i) => (r !== 0 ? r : s1[i] - v), 0)
}
function extractPresentableStateFromDocumentBundle (bundle: DocumentBundle, prjmeta: ProjectMeta): DocumentBundle {
bundle = { ...bundle }
const person = getCurrentEmployee()
const documentById = toIdMap(bundle.ControlledDocument)
const getSortSequence = (prjdoc: ProjectDocument): number[] => {
const doc = documentById.get(prjdoc.document as Ref<ControlledDocument>)
return doc !== undefined ? [doc.seqNumber, doc.major, doc.minor, doc.createdOn ?? 0] : [0, 0, 0, 0]
}
const prjdoc = bundle.ProjectDocument.filter((prjdoc) => {
if (prjdoc.attachedTo !== prjmeta._id) return false
if (isFolder(prjdoc)) return true
@@ -174,9 +182,10 @@ function extractPresentableStateFromDocumentBundle (bundle: DocumentBundle, prjm
const isPublicState = doc?.state === DocumentState.Effective || doc?.state === DocumentState.Archived
return doc !== undefined && (isPublicState || isCollaborator(doc, person))
}).sort((a, b) => {
const s0 = getSortSequence(a)
const s1 = getSortSequence(b)
return s0.reduce((r, v, i) => (r !== 0 ? r : s1[i] - v), 0)
return compareDocumentVersions(
documentById.get(a.document as Ref<ControlledDocument>),
documentById.get(b.document as Ref<ControlledDocument>)
)
})[0]
const doc = prjdoc !== undefined ? documentById.get(prjdoc.document as Ref<ControlledDocument>) : undefined
+2 -1
View File
@@ -65,6 +65,7 @@
"PasswordMinDigits": "{count, plural, =1 {Heslo musí obsahovat alespoň # číslo} other {Heslo musí obsahovat alespoň # čísel}}",
"PasswordMinUpperChars": "{count, plural, =1 {Heslo musí obsahovat alespoň # velké písmeno} other {Heslo musí obsahovat alespoň # velkých písmen}}",
"PasswordMinLowerChars": "{count, plural, =1 {Heslo musí obsahovat alespoň # malé písmeno} other {Heslo musí obsahovat alespoň # malých písmen}}",
"WorkspaceIsArchived": "Pracovní prostor je archivován kvůli nečinnosti. Kontaktujte nás prosím pro obnovení..."
"WorkspaceIsArchived": "Pracovní prostor je archivován kvůli nečinnosti.",
"RestoreArchivedWorkspace": "Obnovit pracovní prostor."
}
}
+2 -1
View File
@@ -65,6 +65,7 @@
"PasswordMinDigits": "{count, plural, =1 {Das Passwort muss mindestens # Zahl enthalten} other {Das Passwort muss mindestens # Zahlen enthalten}}",
"PasswordMinUpperChars": "{count, plural, =1 {Das Passwort muss mindestens # Großbuchstaben enthalten} other {Das Passwort muss mindestens # Großbuchstaben enthalten}}",
"PasswordMinLowerChars": "{count, plural, =1 {Das Passwort muss mindestens # Kleinbuchstaben enthalten} other {Das Passwort muss mindestens # Kleinbuchstaben enthalten}}",
"WorkspaceArchivedDesc": "Workspace wurde wegen Inaktivität archiviert. Bitte kontaktieren Sie uns zur Wiederherstellung..."
"WorkspaceArchivedDesc": "Workspace wurde wegen Inaktivität archiviert.",
"RestoreArchivedWorkspace": "Workspace wiederherstellen."
}
}
+2 -1
View File
@@ -64,6 +64,7 @@
"PasswordMinDigits": "{count, plural, =1 {Password must contain at least # number} other {Password must contain at least # numbers}}",
"PasswordMinUpperChars": "{count, plural, =1 {Password must contain at least # uppercase letter} other {Password must contain at least # uppercase letters}}",
"PasswordMinLowerChars": "{count, plural, =1 {Password must contain at least # lowercase letter} other {Password must contain at least # lowercase letters}}",
"WorkspaceArchivedDesc": "Workspace is archived because of being unused, Please contact us to restore..."
"WorkspaceArchivedDesc": "Workspace is archived because of being unused.",
"RestoreArchivedWorkspace": "Unarchive"
}
}
+2 -1
View File
@@ -64,6 +64,7 @@
"PasswordMinDigits": "{count, plural, =1 {La contraseña debe contener al menos # número} other {La contraseña debe contener al menos # números}}",
"PasswordMinUpperChars": "{count, plural, =1 {La contraseña debe contener al menos # letra mayúscula} other {La contraseña debe contener al menos # letras mayúsculas}}",
"PasswordMinLowerChars": "{count, plural, =1 {La contraseña debe contener al menos # letra minúscula} other {La contraseña debe contener al menos # letras minúsculas}}",
"WorkspaceArchivedDesc": "El espacio de trabajo está archivado por no estar en uso, por favor contáctenos para restaurarlo..."
"WorkspaceArchivedDesc": "El espacio de trabajo está archivado por no estar en uso.",
"RestoreArchivedWorkspace": "Restaurar"
}
}
+2 -1
View File
@@ -64,6 +64,7 @@
"PasswordMinDigits": "{count, plural, =1 {Le mot de passe doit contenir au moins # chiffre} other {Le mot de passe doit contenir au moins # chiffres}}",
"PasswordMinUpperChars": "{count, plural, =1 {Le mot de passe doit contenir au moins # lettre majuscule} other {Le mot de passe doit contenir au moins # lettres majuscules}}",
"PasswordMinLowerChars": "{count, plural, =1 {Le mot de passe doit contenir au moins # lettre minuscule} other {Le mot de passe doit contenir au moins # lettres minuscules}}",
"WorkspaceArchivedDesc": "L'espace de travail est archivé en raison de son inactivité, veuillez nous contacter pour le restaurer..."
"WorkspaceArchivedDesc": "L'espace de travail est archivé en raison de son inactivité.",
"RestoreArchivedWorkspace": "Restaurer"
}
}
+2 -1
View File
@@ -64,6 +64,7 @@
"PasswordMinDigits": "{count, plural, =1 {La password deve contenere almeno # numero} other {La password deve contenere almeno # numeri}}",
"PasswordMinUpperChars": "{count, plural, =1 {La password deve contenere almeno # lettera maiuscola} other {La password deve contenere almeno # lettere maiuscole}}",
"PasswordMinLowerChars": "{count, plural, =1 {La password deve contenere almeno # lettera minuscola} other {La password deve contenere almeno # lettere minuscole}}",
"WorkspaceArchivedDesc": "Il workspace è stato archiviato perché inutilizzato. Si prega di contattarci per ripristinarlo..."
"WorkspaceArchivedDesc": "Il workspace è stato archiviato perché inutilizzato.",
"RestoreArchivedWorkspace": "Un archiviato workspace"
}
}
+2 -1
View File
@@ -64,6 +64,7 @@
"PasswordMinDigits": "{count, plural, =1 {A senha deve conter pelo menos # número} other {A senha deve conter pelo menos # números}}",
"PasswordMinUpperChars": "{count, plural, =1 {A senha deve conter pelo menos # letra maiúscula} other {A senha deve conter pelo menos # letras maiúsculas}}",
"PasswordMinLowerChars": "{count, plural, =1 {A senha deve conter pelo menos # letra minúscula} other {A senha deve conter pelo menos # letras minúsculas}}",
"WorkspaceArchivedDesc": "O espaço de trabalho está arquivado por estar inativo, por favor, entre em contato conosco para restaurá-lo..."
"WorkspaceArchivedDesc": "O espaço de trabalho está arquivado por estar inativo.",
"RestoreArchivedWorkspace": "Restaurar"
}
}
+2 -1
View File
@@ -64,6 +64,7 @@
"PasswordMinDigits": "{count, plural, =1 {Пароль должен содержать минимум # цифру} other {Пароль должен содержать минимум # цифр}}",
"PasswordMinUpperChars": "{count, plural, =1 {Пароль должен содержать минимум # заглавную букву} other {Пароль должен содержать минимум # заглавных букв}}",
"PasswordMinLowerChars": "{count, plural, =1 {Пароль должен содержать минимум # строчную букву} other {Пароль должен содержать минимум # строчных букв}}",
"WorkspaceArchivedDesc": "Рабочее пространство архивировано из-за неиспользования, пожалуйста, свяжитесь с нами для восстановления..."
"WorkspaceArchivedDesc": "Рабочее пространство архивировано из-за неиспользования.",
"RestoreArchivedWorkspace": "Восстановить"
}
}
+2 -1
View File
@@ -64,6 +64,7 @@
"PasswordMinDigits": "{count, plural, =1 {密码至少需要 # 个数字} other {密码至少需要 # 个数字}}",
"PasswordMinUpperChars": "{count, plural, =1 {密码至少需要 # 个大写字母} other {密码至少需要 # 个大写字母}}",
"PasswordMinLowerChars": "{count, plural, =1 {密码至少需要 # 个小写字母} other {密码至少需要 # 个小写字母}}",
"WorkspaceArchivedDesc": "工作区因未使用而归档,请与我们联系以恢复..."
"WorkspaceArchivedDesc": "工作区已被归档,因为未使用。",
"RestoreArchivedWorkspace": "解包"
}
}
@@ -14,8 +14,8 @@
// limitations under the License.
-->
<script lang="ts">
import { WorkspaceInfoWithStatus, isArchivingMode } from '@hcengineering/core'
import { LoginInfo } from '@hcengineering/login'
import { isActiveMode, isArchivingMode, isRestoringMode, isUpgradingMode } from '@hcengineering/core'
import { LoginInfo, WorkspaceInfoWithStatus } from '@hcengineering/login'
import { OK, Severity, Status } from '@hcengineering/platform'
import presentation, { MessageBox, NavLink, isAdminUser, reduceCalls } from '@hcengineering/presentation'
import {
@@ -79,20 +79,28 @@
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [loginStatus, result] = await selectWorkspace(workspaceUrl)
if (result != null) {
const [, wsResult] = await fetchWorkspace()
if (isArchivingMode(wsResult?.mode)) {
showPopup(MessageBox, {
label: login.string.SelectWorkspace,
message: login.string.WorkspaceArchivedDesc,
canSubmit: false,
params: {},
action: async () => {}
})
status = loginStatus
return
}
if (isArchivingMode(result?.mode) && result?.workspaceId !== undefined) {
const workspaceId = result?.workspaceId
showPopup(MessageBox, {
label: login.string.SelectWorkspace,
message: login.string.WorkspaceArchivedDesc,
canSubmit: true,
params: {},
okLabel: login.string.RestoreArchivedWorkspace,
action: async () => {
if (await unArchive(workspaceId, result.token)) {
workspaces = await getWorkspaces()
let info = workspaces.filter((it) => it.workspaceId === workspaceId).shift()
while (isRestoringMode(info?.mode) || isUpgradingMode(info?.mode)) {
await new Promise<void>((resolve) => setTimeout(resolve, 5000))
workspaces = await getWorkspaces()
info = workspaces.filter((it) => it.workspaceId === workspaceId).shift()
}
}
}
})
status = loginStatus
return
}
status = loginStatus
@@ -168,7 +176,7 @@
{#if isArchivingMode(workspace.mode)}
- <Label label={presentation.string.Archived} />
{/if}
{#if workspace.mode !== 'active' && workspace.mode !== 'archived'}
{#if !isActiveMode(workspace.mode) && !isArchivingMode(workspace.mode)}
({workspace.processingProgress}%)
{/if}
</span>
+9 -22
View File
@@ -19,15 +19,18 @@ import InviteLink from './components/InviteLink.svelte'
import LoginApp from './components/LoginApp.svelte'
import {
changePassword,
getWorkspaces,
leaveWorkspace,
selectWorkspace,
sendInvite,
resendInvite,
fetchWorkspace,
getInviteLink,
getPerson,
getInviteLink
selectWorkspace,
getWorkspaces,
sendInvite,
leaveWorkspace,
resendInvite
} from './utils'
import { type Pages, pages } from '@hcengineering/login'
export { pages, type Pages }
/*!
* Anticrm Platform™ Login Plugin
* © 2020, 2021 Anticrm Platform Contributors.
@@ -53,21 +56,6 @@ export default async () => ({
}
})
export const pages = [
'login',
'signup',
'createWorkspace',
'password',
'recovery',
'selectWorkspace',
'admin',
'join',
'confirm',
'confirmationSend',
'auth',
'login-password'
] as const
export enum OtpLoginSteps {
Email = 'email',
Otp = 'otp'
@@ -77,7 +65,6 @@ export enum LoginMethods {
Password = 'password',
Otp = 'otp'
}
export type Pages = (typeof pages)[number]
export interface BottomAction {
i18n: IntlString
+15 -6
View File
@@ -13,19 +13,19 @@
// limitations under the License.
//
import type { AccountClient, LoginInfo, OtpInfo, RegionInfo, WorkspaceLoginInfo } from '@hcengineering/account-client'
import { getClient as getAccountClientRaw } from '@hcengineering/account-client'
import { Analytics } from '@hcengineering/analytics'
import {
AccountRole,
concatLink,
type Person,
parseSocialIdString,
type Doc,
type Person,
type Ref,
type WorkspaceInfoWithStatus,
type WorkspaceUserOperation,
parseSocialIdString
type WorkspaceUserOperation
} from '@hcengineering/core'
import type { LoginInfo, OtpInfo, WorkspaceLoginInfo, AccountClient, RegionInfo } from '@hcengineering/account-client'
import { getClient as getAccountClientRaw } from '@hcengineering/account-client'
import { loginId } from '@hcengineering/login'
import platform, {
OK,
@@ -47,7 +47,7 @@ import {
setMetadataLocalStorage,
type Location
} from '@hcengineering/ui'
import { workbenchId, logIn } from '@hcengineering/workbench'
import { logIn, workbenchId } from '@hcengineering/workbench'
import { LoginEvents } from './analytics'
import { type Pages } from './index'
@@ -390,6 +390,15 @@ export async function fetchWorkspace (): Promise<[Status, WorkspaceInfoWithStatu
}
}
export async function unArchive (workspaceId: string, token: string): Promise<boolean> {
try {
return await getAccountClient(token).performWorkspaceOperation(workspaceId, 'unarchive')
} catch (err: any) {
Analytics.handleError(err)
return false
}
}
export async function getPerson (): Promise<[Status, Person | null]> {
const token = getMetadata(presentation.metadata.Token)
if (token === undefined) {
+18 -1
View File
@@ -26,6 +26,22 @@ export type { LoginInfo, WorkspaceLoginInfo, OtpInfo, RegionInfo } from '@hcengi
*/
export const loginId = 'login' as Plugin
export const pages = [
'login',
'signup',
'createWorkspace',
'password',
'recovery',
'selectWorkspace',
'admin',
'join',
'confirm',
'confirmationSend',
'auth',
'login-password'
] as const
export type Pages = (typeof pages)[number]
export default plugin(loginId, {
metadata: {
AccountsUrl: '' as Asset,
@@ -60,7 +76,8 @@ export default plugin(loginId, {
PasswordMinUpperChars: '' as IntlString<{ count: number }>,
PasswordMinLowerChars: '' as IntlString<{ count: number }>,
WorkspaceArchived: '' as IntlString,
WorkspaceArchivedDesc: '' as IntlString
WorkspaceArchivedDesc: '' as IntlString,
RestoreArchivedWorkspace: '' as IntlString
},
function: {
SendInvite: '' as Resource<(email: string, role: AccountRole) => Promise<void>>,
@@ -18,7 +18,7 @@
import presentation, { SearchResult, reduceCalls, searchFor, type SearchItem } from '@hcengineering/presentation'
import { Label, ListView, resizeObserver } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { getReferenceLabel } from './extension/reference'
import { getReferenceLabel, getReferenceObject } from './extension/reference'
export let query: string = ''
@@ -31,11 +31,12 @@
let selection = 0
async function handleSelectItem (item: SearchResultDoc): Promise<void> {
const label = await getReferenceLabel(item.doc._class, item.doc._id)
const obj = (await getReferenceObject(item.doc._class, item.doc._id)) ?? item.doc
const label = await getReferenceLabel(obj._class, obj._id)
dispatch('close', {
id: item.id,
id: obj._id,
label,
objectclass: item.doc._class
objectclass: obj._class
})
}
@@ -344,10 +344,30 @@ export async function getReferenceLabel<T extends Doc> (
return label
}
export async function getReferenceObject<T extends Doc> (
objectclass: Ref<Class<T>>,
id: Ref<T>,
doc?: T
): Promise<Doc | undefined> {
const client = getClient()
const hierarchy = client.getHierarchy()
const referenceObjectProvider = hierarchy.classHierarchyMixin(
objectclass as Ref<Class<Doc>>,
view.mixin.ReferenceObjectProvider
)
const referenceObjectProviderFn =
referenceObjectProvider !== undefined ? await getResource(referenceObjectProvider.provider) : undefined
return await referenceObjectProviderFn?.(client, id, doc)
}
export async function getReferenceFromUrl (urlString: string): Promise<ReferenceNodeProps | undefined> {
const target = await getTargetObjectFromUrl(urlString)
let target = await getTargetObjectFromUrl(urlString)
if (target === undefined) return
target = (await getReferenceObject(target._class, target._id)) ?? target
const label = await getReferenceLabel(target._class, target._id)
if (label === '') return
+13
View File
@@ -39,6 +39,7 @@ import { type IntlString } from '@hcengineering/platform'
import { createQuery, getClient, onClient } from '@hcengineering/presentation'
import task, { getStatusIndex, makeRank, type TaskType, type ProjectType } from '@hcengineering/task'
import {
selectedTaskTypeStore,
activeProjects as taskActiveProjects,
taskTypeStore,
typesOfJoinedProjectsStore
@@ -162,6 +163,8 @@ export async function issueStatusSort (
const joinedTaskTypes = Array.from(taskTypes.values()).filter(
(taskType) => joinedProjectsTypes.includes(taskType.parent) && taskType.ofClass === tracker.class.Issue
)
const taskTypeId = get(selectedTaskTypeStore) ?? (joinedTaskTypes.length === 1 ? joinedTaskTypes[0]?._id : undefined)
const taskType = taskTypeId !== undefined ? taskTypes.get(taskTypeId) : undefined
const statuses = get(statusStore).byId
// TODO: How we track category updates.
@@ -174,6 +177,11 @@ export async function issueStatusSort (
listIssueKanbanStatusOrder.indexOf(aVal?.category as Ref<StatusCategory>) -
listIssueKanbanStatusOrder.indexOf(bVal?.category as Ref<StatusCategory>)
if (res === 0) {
if (taskType != null) {
const aIndex = taskType.statuses.findIndex((p) => p === a)
const bIndex = taskType.statuses.findIndex((p) => p === b)
return aIndex - bIndex
}
if (type != null) {
const aIndex = getStatusIndex(type, taskTypes, a)
const bIndex = getStatusIndex(type, taskTypes, b)
@@ -193,6 +201,11 @@ export async function issueStatusSort (
listIssueStatusOrder.indexOf(aVal?.category as Ref<StatusCategory>) -
listIssueStatusOrder.indexOf(bVal?.category as Ref<StatusCategory>)
if (res === 0) {
if (taskType != null) {
const aIndex = taskType.statuses.findIndex((p) => p === a)
const bIndex = taskType.statuses.findIndex((p) => p === b)
return aIndex - bIndex
}
if (type != null) {
const aIndex = getStatusIndex(type, taskTypes, a)
const bIndex = getStatusIndex(type, taskTypes, b)
+2
View File
@@ -52,6 +52,7 @@ import {
ObjectTitle,
ObjectTooltip,
ObjectValidator,
ReferenceObjectProvider,
AttrPresenter,
PreviewPresenter,
SpaceHeader,
@@ -96,6 +97,7 @@ const view = plugin(viewId, {
ObjectFactory: '' as Ref<Mixin<ObjectFactory>>,
ObjectTitle: '' as Ref<Mixin<ObjectTitle>>,
ObjectIdentifier: '' as Ref<Mixin<ObjectIdentifier>>,
ReferenceObjectProvider: '' as Ref<Mixin<ReferenceObjectProvider>>,
ObjectTooltip: '' as Ref<Mixin<ObjectTooltip>>,
SpaceHeader: '' as Ref<Mixin<SpaceHeader>>,
SpaceName: '' as Ref<Mixin<SpaceName>>,
+7
View File
@@ -290,6 +290,13 @@ export interface ObjectIdentifier extends Class<Doc> {
provider: Resource<<T extends Doc>(client: Client, ref: Ref<T>, doc?: T) => Promise<string>>
}
/**
* @public
*/
export interface ReferenceObjectProvider extends Class<Doc> {
provider: Resource<<T extends Doc>(client: Client, ref: Ref<T>, doc?: T) => Promise<Doc | undefined>>
}
/**
* @public
*/
+14 -12
View File
@@ -1,22 +1,23 @@
import { getClient as getAccountClient } from '@hcengineering/account-client'
import { Analytics } from '@hcengineering/analytics'
import client from '@hcengineering/client'
import { ensureEmployee, setCurrentEmployee } from '@hcengineering/contact'
import core, {
ClientConnectEvent,
concatLink,
isWorkspaceCreating,
metricsToString,
pickPrimarySocialId,
setCurrentAccount,
versionToString,
type SocialId,
type Account,
type Client,
type Person as GlobalPerson,
type MeasureMetricsContext,
type Version,
pickPrimarySocialId,
type Person as GlobalPerson
type SocialId,
type Version
} from '@hcengineering/core'
import { setCurrentEmployee, ensureEmployee } from '@hcengineering/contact'
import login, { loginId } from '@hcengineering/login'
import login, { loginId, type Pages } from '@hcengineering/login'
import { broadcastEvent, getMetadata, getResource, OK, setMetadata, translateCB } from '@hcengineering/platform'
import presentation, {
loadServerConfig,
@@ -35,8 +36,7 @@ import {
setMetadataLocalStorage,
themeStore
} from '@hcengineering/ui'
import { getClient as getAccountClient } from '@hcengineering/account-client'
import { writable, get } from 'svelte/store'
import { get, writable } from 'svelte/store'
import plugin from './plugin'
import { logOut, workspaceCreating } from './utils'
@@ -228,13 +228,15 @@ export async function connect (title: string): Promise<Client | undefined> {
},
onArchived: () => {
translateCB(plugin.string.WorkspaceIsArchived, {}, get(themeStore).language, (r) => {
versionError.set(r)
setTimeout(() => {
location.reload()
}, 5000)
const selectWorkspace: Pages = 'selectWorkspace'
navigate({
path: [loginId, selectWorkspace],
query: {}
})
})
},
onMigration: () => {
// TODO: Rework maitenance mode as well
translateCB(plugin.string.WorkspaceIsMigrating, {}, get(themeStore).language, (r) => {
versionError.set(r)
setTimeout(() => {
+1 -1
View File
@@ -19,7 +19,7 @@ import Koa from 'koa'
import bodyParser from 'koa-bodyparser'
import Router from 'koa-router'
const serviceTimeout = 30000
const serviceTimeout = 5 * 60000
interface ServiceStatisticsEx extends ServiceStatistics {
lastUpdate: number // Last updated
+5
View File
@@ -455,6 +455,11 @@
"projectFolder": "packages/api-client",
"shouldPublish": false
},
{
"packageName": "@hcengineering/api-tests",
"projectFolder": "ws-tests/api-tests",
"shouldPublish": false
},
{
"packageName": "@hcengineering/importer",
"projectFolder": "packages/importer",
@@ -1,25 +1,26 @@
//
// Copyright © 2023-2024 Hardcore Engineering Inc.
//
import { Person, pickPrimarySocialId, type Employee } from '@hcengineering/contact'
import core, {
AccountRole,
combineAttributes,
DocumentQuery,
includesAny,
PersonId,
Ref,
SortingOrder,
Tx,
TxCreateDoc,
TxFactory,
TxUpdateDoc,
type Timestamp,
type RolesAssignment,
AccountRole,
type TxCUD,
type Doc,
PersonId,
combineAttributes,
includesAny,
TxCreateDoc
type RolesAssignment,
type Timestamp,
type TxCUD
} from '@hcengineering/core'
import { Person, pickPrimarySocialId, type Employee } from '@hcengineering/contact'
import { getSocialStrings, getEmployees, getSocialStringsByPersons } from '@hcengineering/server-contact'
import { NotificationType } from '@hcengineering/notification'
import { getEmployees, getSocialStrings, getSocialStringsByPersons } from '@hcengineering/server-contact'
import { TriggerControl } from '@hcengineering/server-core'
import documents, {
@@ -36,7 +37,6 @@ import documents, {
} from '@hcengineering/controlled-documents'
import { RequestStatus } from '@hcengineering/request'
import training, { TrainingState, type TrainingRequest } from '@hcengineering/training'
import { NotificationType } from '@hcengineering/notification'
async function getDocs (
control: TriggerControl,
+6 -3
View File
@@ -201,7 +201,7 @@ export function serveAccount (measureCtx: MeasureContext, brandings: BrandingMap
router.get('/api/v1/statistics', (req, res) => {
try {
const token = req.query.token as string
const token = (req.query.token as string) ?? extractToken(req.headers)
const payload = decodeToken(token)
const admin = payload.extra?.admin === 'true'
const data: Record<string, any> = {
@@ -209,8 +209,11 @@ export function serveAccount (measureCtx: MeasureContext, brandings: BrandingMap
statistics: {}
}
data.statistics.totalClients = 0
data.statistics.memoryUsed = Math.round((process.memoryUsage().heapUsed / 1024 / 1024) * 100) / 100
data.statistics.memoryTotal = Math.round((process.memoryUsage().heapTotal / 1024 / 1024) * 100) / 100
const mem = process.memoryUsage()
data.statistics.memoryUsed = Math.round((mem.heapUsed / 1024 / 1024) * 100) / 100
data.statistics.memoryTotal = Math.round((mem.heapTotal / 1024 / 1024) * 100) / 100
data.statistics.memoryRSS = Math.round((mem.rss / 1024 / 1024) * 100) / 100
data.statistics.memoryArrayBuffers = Math.round((mem.arrayBuffers / 1024 / 1024) * 100) / 100
data.statistics.cpuUsage = Math.round(os.loadavg()[0] * 100) / 100
data.statistics.freeMem = Math.round((os.freemem() / 1024 / 1024) * 100) / 100
data.statistics.totalMem = Math.round((os.totalmem() / 1024 / 1024) * 100) / 100
+4 -2
View File
@@ -1013,10 +1013,12 @@ export async function performWorkspaceOperation (
}
): Promise<boolean> {
const { workspaceId, event, params } = parameters
const { extra } = decodeTokenVerbose(ctx, token)
const { extra, workspace } = decodeTokenVerbose(ctx, token)
if (extra?.admin !== 'true') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
if (event !== 'unarchive' || workspaceId !== workspace) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
}
const workspaceUuids = Array.isArray(workspaceId) ? workspaceId : [workspaceId]
+9 -1
View File
@@ -57,7 +57,7 @@ export abstract class BaseMiddleware implements Middleware {
return this.provideFindAll(ctx, _class, query, options)
}
loadModel (
provideLoadModel (
ctx: MeasureContext<SessionData>,
lastModelTx: Timestamp,
hash?: string
@@ -65,6 +65,14 @@ export abstract class BaseMiddleware implements Middleware {
return this.next?.loadModel(ctx, lastModelTx, hash) ?? emptyModelResult
}
loadModel (
ctx: MeasureContext<SessionData>,
lastModelTx: Timestamp,
hash?: string
): Promise<Tx[] | LoadModelResponse> {
return this.provideLoadModel(ctx, lastModelTx, hash)
}
provideGroupBy<T, P extends Doc>(
ctx: MeasureContext<SessionData>,
domain: Domain,
+16 -2
View File
@@ -7,6 +7,8 @@ import os from 'os'
export interface MemoryStatistics {
memoryUsed: number
memoryTotal: number
memoryArrayBuffers: number
memoryRSS: number
freeMem: number
totalMem: number
@@ -56,6 +58,7 @@ export function getMemoryInfo (): MemoryStatistics {
memoryUsed: Math.round((memU.heapUsed / 1024 / 1024) * 100) / 100,
memoryRSS: Math.round((memU.rss / 1024 / 1024) * 100) / 100,
memoryTotal: Math.round((memU.heapTotal / 1024 / 1024) * 100) / 100,
memoryArrayBuffers: Math.round((memU.arrayBuffers / 1024 / 1024) * 100) / 100,
freeMem: Math.round((os.freemem() / 1024 / 1024) * 100) / 100,
totalMem: Math.round((os.totalmem() / 1024 / 1024) * 100) / 100
}
@@ -102,6 +105,8 @@ export function initStatisticsContext (
}
let oldMetricsValue = ''
const serviceId = encodeURIComponent(os.hostname() + '-' + serviceName)
let prev: Promise<void> | undefined
const handleError = (err: any): void => {
errorToSend++
if (errorToSend % 2 === 0) {
@@ -109,6 +114,7 @@ export function initStatisticsContext (
console.error(err)
}
}
prev = undefined
}
const intTimer = setInterval(() => {
@@ -127,6 +133,10 @@ export function initStatisticsContext (
}
}
}
if (prev !== undefined) {
// In case of high load, skip
return
}
if (statsUrl !== undefined) {
const token = generateToken(systemAccountUuid, undefined, { service: 'true' })
const data: ServiceStatistics = {
@@ -139,7 +149,7 @@ export function initStatisticsContext (
const statData = JSON.stringify(data)
void fetch(
prev = fetch(
concatLink(statsUrl, '/api/v1/statistics') + `/?token=${encodeURIComponent(token)}&name=${serviceId}`,
{
method: 'PUT',
@@ -148,7 +158,11 @@ export function initStatisticsContext (
},
body: statData
}
).catch(handleError)
)
.catch(handleError)
.then(() => {
prev = undefined
})
}
} catch (err: any) {
handleError(err)
+14 -11
View File
@@ -555,7 +555,7 @@ export interface Session {
getUserSocialIds: () => PersonId[]
loadModel: (ctx: ClientSessionCtx, lastModelTx: Timestamp, hash?: string) => Promise<void>
loadModelRaw: (ctx: ClientSessionCtx, lastModelTx: Timestamp, hash?: string) => Promise<LoadModelResponse | Tx[]>
getRawAccount: () => Account
findAll: <T extends Doc>(
ctx: ClientSessionCtx,
@@ -570,6 +570,7 @@ export interface Session {
options?: FindOptions<T>
) => Promise<FindResult<T>>
searchFulltext: (ctx: ClientSessionCtx, query: SearchQuery, options: SearchOptions) => Promise<void>
searchFulltextRaw: (ctx: ClientSessionCtx, query: SearchQuery, options: SearchOptions) => Promise<SearchResult>
tx: (ctx: ClientSessionCtx, tx: Tx) => Promise<void>
txRaw: (
@@ -599,13 +600,15 @@ export interface ConnectionSocket {
id: string
isClosed: boolean
close: () => void
send: (ctx: MeasureContext, msg: Response<any>, binary: boolean, compression: boolean) => void
send: (ctx: MeasureContext, msg: Response<any>, binary: boolean, compression: boolean) => Promise<void>
sendPong: () => void
data: () => Record<string, any>
readRequest: (buffer: Buffer, binary: boolean) => Request<any>
isBackpressure: () => boolean // In bytes
backpressure: (ctx: MeasureContext) => Promise<void>
checkState: () => boolean
}
@@ -713,21 +716,21 @@ export interface SessionManager {
request: Request<any>,
workspace: WorkspaceUuid
) => Promise<void>
createOpContext: (
ctx: MeasureContext,
pipeline: Pipeline,
requestId: Request<any>['id'],
service: Session,
ws: ConnectionSocket
) => ClientSessionCtx
handleRPC: <S extends Session>(
requestCtx: MeasureContext,
service: S,
ws: ConnectionSocket,
operation: (ctx: ClientSessionCtx) => Promise<void>
) => Promise<void>
createOpContext: (
ctx: MeasureContext,
sendCtx: MeasureContext,
pipeline: Pipeline,
requestId: Request<any>['id'],
service: Session,
ws: ConnectionSocket
) => ClientSessionCtx
}
/**
+7 -6
View File
@@ -179,8 +179,8 @@ async function getFile (
etag: stat.etag,
'last-modified': new Date(stat.modifiedOn).toISOString(),
'cache-control': cacheControlValue,
Connection: 'keep-alive',
'Keep-Alive': 'timeout=5'
connection: 'keep-alive',
'keep-alive': 'timeout=5, max=1000'
})
res.end()
return
@@ -192,8 +192,8 @@ async function getFile (
etag: stat.etag,
'last-modified': new Date(stat.modifiedOn).toISOString(),
'cache-control': cacheControlValue,
Connection: 'keep-alive',
'Keep-Alive': 'timeout=5'
connection: 'keep-alive',
'keep-alive': 'timeout=5, max=1000'
})
res.end()
return
@@ -212,8 +212,8 @@ async function getFile (
Etag: stat.etag,
'Last-Modified': new Date(stat.modifiedOn).toISOString(),
'Cache-Control': cacheControlValue,
Connection: 'keep-alive',
'Keep-Alive': 'timeout=5'
connection: 'keep-alive',
'keep-alive': 'timeout=5, max=1000'
})
dataStream.pipe(res)
@@ -479,6 +479,7 @@ export function start (
if (req.method === 'HEAD') {
res.writeHead(200, {
'accept-ranges': 'bytes',
connection: 'keep-alive',
'Keep-Alive': 'timeout=5',
'content-length': blobInfo.size,
'content-security-policy': "default-src 'none';",
+7 -1
View File
@@ -18,6 +18,7 @@ import {
type Doc,
type DocumentQuery,
type Domain,
type FindOptions,
type FindResult,
type MeasureContext,
type Ref,
@@ -44,6 +45,11 @@ export class DomainFindMiddleware extends BaseMiddleware implements Middleware {
return middleware
}
toPrintableOptions (options?: ServerFindOptions<Doc>): FindOptions<Doc> {
const { ctx, allowedSpaces, associations, ...opt } = options ?? {}
return opt
}
findAll<T extends Doc>(
ctx: MeasureContext,
_class: Ref<Class<T>>,
@@ -65,7 +71,7 @@ export class DomainFindMiddleware extends BaseMiddleware implements Middleware {
(ctx) => {
return this.adapterManager.getAdapter(domain, false).findAll(ctx, _class, query, options)
},
{ _class, query, options }
{ _class, query, options: this.toPrintableOptions(options) }
)
}
+20
View File
@@ -14,12 +14,19 @@
//
import core, {
type Class,
type Doc,
type DocumentQuery,
type FindOptions,
type FindResult,
type LoadModelResponse,
type MeasureContext,
type Ref,
type SessionData,
type Timestamp,
type Tx,
type TxCUD,
DOMAIN_MODEL,
DOMAIN_TX,
withContext
} from '@hcengineering/core'
@@ -81,6 +88,19 @@ export class ModelMiddleware extends BaseMiddleware implements Middleware {
return allUserTxes.filter((it) => isUserTx(it) && !isAccountTx(it as TxCUD<Doc>))
}
findAll<T extends Doc>(
ctx: MeasureContext<SessionData>,
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<FindResult<T>> {
const d = this.context.hierarchy.findDomain(_class)
if (d === DOMAIN_MODEL) {
return this.context.modelDb.findAll(_class, query, options)
}
return this.provideFindAll(ctx, _class, query, options)
}
async init (ctx: MeasureContext): Promise<void> {
if (this.context.adapterManager == null) {
throw new PlatformError(unknownError('Adapter manager should be configured'))
+66 -56
View File
@@ -17,19 +17,23 @@ import {
type Class,
type Doc,
DocumentQuery,
FindOptions,
type Domain,
FindResult,
type LoadModelResponse,
type MeasureContext,
Ref
Ref,
type SearchOptions,
type SearchQuery,
type SearchResult,
type SessionData,
type Timestamp,
type Tx
} from '@hcengineering/core'
import { BaseMiddleware, Middleware, ServerFindOptions, type PipelineContext } from '@hcengineering/server-core'
import { deepEqual } from 'fast-equals'
import { BaseMiddleware, Middleware, type PipelineContext, ServerFindOptions } from '@hcengineering/server-core'
interface Query {
_class: Ref<Class<Doc>>
query: DocumentQuery<Doc>
result: FindResult<Doc> | Promise<FindResult<Doc>> | undefined
options?: FindOptions<Doc>
key: string
result: object | Promise<object> | undefined
callbacks: number
max: number
}
@@ -37,27 +41,20 @@ interface Query {
* @public
*/
export class QueryJoiner {
private readonly queries: Map<Ref<Class<Doc>>, Query[]> = new Map<Ref<Class<Doc>>, Query[]>()
private readonly queries: Map<string, Query> = new Map<string, Query>()
constructor (readonly _findAll: Middleware['findAll']) {}
async findAll<T extends Doc>(
ctx: MeasureContext,
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: ServerFindOptions<T>
): Promise<FindResult<T>> {
async query<T>(ctx: MeasureContext, key: string, retrieve: (ctx: MeasureContext) => Promise<T>): Promise<T> {
// Will find a query or add + 1 to callbacks
const q = this.findQuery(_class, query, options) ?? this.createQuery(_class, query, options)
const q = this.getQuery(key)
try {
if (q.result === undefined) {
q.result = this._findAll(ctx, _class, query, options)
q.result = retrieve(ctx)
}
if (q.result instanceof Promise) {
q.result = await q.result
}
return q.result as FindResult<T>
return q.result as T
} finally {
q.callbacks--
@@ -65,46 +62,27 @@ export class QueryJoiner {
}
}
private findQuery<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Query | undefined {
const queries = this.queries.get(_class)
if (queries === undefined) return
for (const q of queries) {
if (!deepEqual(query, q.query) || !deepEqual(options, q.options)) {
continue
private getQuery (key: string): Query {
const query = this.queries.get(key)
if (query === undefined) {
const q: Query = {
key,
result: undefined,
callbacks: 1,
max: 1
}
q.callbacks++
q.max++
this.queries.set(key, q)
return q
}
}
private createQuery<T extends Doc>(_class: Ref<Class<T>>, query: DocumentQuery<T>, options?: FindOptions<T>): Query {
const queries = this.queries.get(_class) ?? []
const q: Query = {
_class,
query,
result: undefined,
options: options as FindOptions<Doc>,
callbacks: 1,
max: 1
}
queries.push(q)
this.queries.set(_class, queries)
return q
query.callbacks++
query.max++
return query
}
private removeFromQueue (q: Query): void {
if (q.callbacks === 0) {
const queries = this.queries.get(q._class) ?? []
this.queries.set(
q._class,
queries.filter((it) => it !== q)
)
this.queries.delete(q.key)
}
}
}
@@ -117,8 +95,16 @@ export class QueryJoinMiddleware extends BaseMiddleware implements Middleware {
private constructor (context: PipelineContext, next?: Middleware) {
super(context, next)
this.joiner = new QueryJoiner((ctx, _class, query, options) => {
return this.provideFindAll(ctx, _class, query, options)
this.joiner = new QueryJoiner()
}
loadModel (
ctx: MeasureContext<SessionData>,
lastModelTx: Timestamp,
hash?: string
): Promise<Tx[] | LoadModelResponse> {
return this.joiner.query(ctx, `model-${lastModelTx}${hash ?? ''}`, async (ctx) => {
return await this.provideLoadModel(ctx, lastModelTx, hash)
})
}
@@ -136,7 +122,31 @@ export class QueryJoinMiddleware extends BaseMiddleware implements Middleware {
query: DocumentQuery<T>,
options?: ServerFindOptions<T>
): Promise<FindResult<T>> {
// Will find a query or add + 1 to callbacks
return this.joiner.findAll(ctx, _class, query, options)
const opt = { ...options }
delete opt.ctx
return this.joiner.query(
ctx,
`findAll-${_class}-${JSON.stringify(query)}-${JSON.stringify(options)}`,
async (ctx) => {
return await this.provideFindAll(ctx, _class, query, options)
}
)
}
groupBy<T, P extends Doc>(
ctx: MeasureContext<SessionData>,
domain: Domain,
field: string,
query?: DocumentQuery<P>
): Promise<Map<T, number>> {
return this.joiner.query(ctx, `groupBy-${domain}-${field}-${JSON.stringify(query ?? {})})`, async (ctx) => {
return await this.provideGroupBy(ctx, domain, field, query)
})
}
searchFulltext (ctx: MeasureContext<SessionData>, query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
return this.joiner.query(ctx, `searchFulltext-${JSON.stringify(query)}-${JSON.stringify(options)}`, async (ctx) => {
return await this.provideSearchFulltext(ctx, query, options)
})
}
}
+3 -2
View File
@@ -44,6 +44,7 @@ import core, {
clone,
generateId,
shouldShowArchived,
systemAccountUuid,
toFindResult,
type PersonUuid,
type SessionData
@@ -345,8 +346,8 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar
const users = new Set(
socialStrings.map((s) => socialStringsToUsers.get(s)).filter((u) => u !== undefined) as string[]
)
// Do we need to add system account to targets?
// res.push(systemAccountUuid)
// We need to add system account for targets for integrations to work properly
users.add(systemAccountUuid)
return Array.from(users)
}
@@ -12,14 +12,13 @@ describe('test query joiner', () => {
})
return toFindResult([])
}
const joiner = new QueryJoiner(findT)
const joiner = new QueryJoiner()
const ctx = new MeasureMetricsContext('test', {})
const p1 = joiner.findAll(ctx, core.class.Class, {})
const p2 = joiner.findAll(ctx, core.class.Class, {})
const p1 = joiner.query(ctx, core.class.Class, (ctx) => findT(ctx, core.class.Class, {}))
const p2 = joiner.query(ctx, core.class.Class, (ctx) => findT(ctx, core.class.Class, {}))
await Promise.all([p1, p2])
expect(count).toBe(1)
expect((joiner as any).queries.size).toBe(1)
expect((joiner as any).queries.get(core.class.Class).length).toBe(0)
expect((joiner as any).queries.size).toBe(0)
})
})
+2
View File
@@ -30,6 +30,8 @@ export function createMongoDestroyAdapter (url: string): WorkspaceDestroyAdapter
const db = getWorkspaceMongoDB(dbClient, dataId ?? workspace)
await db.dropDatabase()
})
} catch (err) {
console.error('Failed to delete workspace', err)
} finally {
client.close()
}
+6 -6
View File
@@ -53,7 +53,7 @@ export interface HelloResponse extends Response<any> {
useCompression?: boolean
}
function replacer (key: string, value: any): any {
export function rpcJSONReplacer (key: string, value: any): any {
if (Array.isArray(value) && ((value as any).total !== undefined || (value as any).lookupMap !== undefined)) {
return {
dataType: 'TotalArray',
@@ -66,7 +66,7 @@ function replacer (key: string, value: any): any {
}
}
function receiver (key: string, value: any): any {
export function rpcJSONReceiver (key: string, value: any): any {
if (typeof value === 'object' && value !== null) {
if (value.dataType === 'TotalArray') {
return Object.assign(value.value, { total: value.total, lookupMap: value.lookupMap })
@@ -99,7 +99,7 @@ export class RPCHandler {
packr = new Packr({ structuredClone: true, bundleStrings: true, copyBuffers: false })
protoSerialize (object: object, binary: boolean): any {
if (!binary) {
return JSON.stringify(object, replacer)
return JSON.stringify(object, rpcJSONReplacer)
}
return new Uint8Array(this.packr.pack(object))
}
@@ -112,7 +112,7 @@ export class RPCHandler {
_data = decoder.decode(_data)
}
try {
return JSON.parse(_data.toString(), receiver)
return JSON.parse(_data.toString(), rpcJSONReceiver)
} catch (err: any) {
if (((err.message as string) ?? '').includes('Unexpected token')) {
return this.packr.unpack(new Uint8Array(data))
@@ -129,7 +129,7 @@ export class RPCHandler {
*/
serialize (object: Request<any> | Response<any>, binary: boolean): any {
if ((object as any).result !== undefined) {
;(object as any).result = replacer('result', (object as any).result)
;(object as any).result = rpcJSONReplacer('result', (object as any).result)
}
return this.protoSerialize(object, binary)
}
@@ -142,7 +142,7 @@ export class RPCHandler {
readResponse<D>(response: any, binary: boolean): Response<D> {
const data = this.protoDeserialize(response, binary)
if (data.result !== undefined) {
data.result = receiver('result', data.result)
data.result = rpcJSONReceiver('result', data.result)
}
return data
}
+7 -2
View File
@@ -42,6 +42,8 @@ export async function getFile (
res.writeHead(200, {
'Content-Type': stat.contentType,
Etag: stat.etag,
connection: 'keep-alive',
'keep-alive': 'timeout=5, max=1000',
'Last-Modified': new Date(stat.modifiedOn).toISOString(),
'Cache-Control': cacheControlNoCache
})
@@ -118,7 +120,9 @@ export async function getFileRange (
if (start >= size) {
res.cork(() => {
res.writeHead(416, {
'Content-Range': `bytes */${size}`
'Content-Range': `bytes */${size}`,
connection: 'keep-alive',
'keep-alive': 'timeout=5, max=1000'
})
res.end()
})
@@ -139,9 +143,10 @@ export async function getFileRange (
await new Promise<void>((resolve, reject) => {
res.cork(() => {
res.writeHead(206, {
Connection: 'keep-alive',
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
connection: 'keep-alive',
'keep-alive': 'timeout=5, max=1000',
// 'Content-Length': end - start + 1,
'Content-Type': stat.contentType,
Etag: stat.etag,
+15 -2
View File
@@ -23,12 +23,14 @@ import {
type Domain,
type FindOptions,
type FindResult,
type LoadModelResponse,
type MeasureContext,
type PersonId,
type PersonUuid,
type Ref,
type SearchOptions,
type SearchQuery,
type SearchResult,
type SessionData,
type Timestamp,
type Tx,
@@ -115,6 +117,11 @@ export class ClientSession implements Session {
await ctx.sendResponse(ctx.requestId, result)
}
async loadModelRaw (ctx: ClientSessionCtx, lastModelTx: Timestamp, hash?: string): Promise<LoadModelResponse | Tx[]> {
this.includeSessionContext(ctx)
return await ctx.ctx.with('load-model', {}, (_ctx) => ctx.pipeline.loadModel(_ctx, lastModelTx, hash))
}
includeSessionContext (ctx: ClientSessionCtx): void {
const dataId = this.workspace.workspaceDataId ?? (this.workspace.workspaceUuid as unknown as WorkspaceDataId)
const contextData = new SessionDataImpl(
@@ -165,6 +172,12 @@ export class ClientSession implements Session {
await ctx.sendResponse(ctx.requestId, await ctx.pipeline.searchFulltext(ctx.ctx, query, options))
}
async searchFulltextRaw (ctx: ClientSessionCtx, query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
this.lastRequest = Date.now()
this.includeSessionContext(ctx)
return await ctx.pipeline.searchFulltext(ctx.ctx, query, options)
}
async txRaw (
ctx: ClientSessionCtx,
tx: Tx
@@ -236,7 +249,7 @@ export class ClientSession implements Session {
}
}
const bevent = createBroadcastEvent(Array.from(classes))
socket.send(
void socket.send(
ctx,
{
result: [bevent]
@@ -245,7 +258,7 @@ export class ClientSession implements Session {
this.useCompression
)
} else {
socket.send(ctx, { result: tx }, this.binaryMode, this.useCompression)
void socket.send(ctx, { result: tx }, this.binaryMode, this.useCompression)
}
}
+29 -17
View File
@@ -188,7 +188,14 @@ export class TSessionManager implements SessionManager {
if (this.ticks % (60 * ticksPerSecond) === workspace.tickHash) {
try {
// update account lastVisit every minute per every workspace.∏
void this.getWorkspaceInfo(workspace.token).catch(() => {
let connected: boolean = false
for (const val of workspace.sessions.values()) {
if (val.session.getUser() !== systemAccountUuid) {
connected = true
break
}
}
void this.getWorkspaceInfo(workspace.token, connected).catch(() => {
// Ignore
})
} catch (err: any) {
@@ -242,7 +249,7 @@ export class TSessionManager implements SessionManager {
// And ping other wize
s[1].session.lastPing = now
if (s[1].socket.checkState()) {
s[1].socket.send(
void s[1].socket.send(
workspace.context,
{ result: pingConst },
s[1].session.binaryMode,
@@ -296,9 +303,9 @@ export class TSessionManager implements SessionManager {
return this.sessionFactory(token, workspace, account)
}
async getWorkspaceInfo (token: string): Promise<WorkspaceInfoWithStatus | undefined> {
async getWorkspaceInfo (token: string, updateLastVisit = true): Promise<WorkspaceInfoWithStatus | undefined> {
try {
return await getAccountClient(this.accountsUrl, token).getWorkspaceInfo(true)
return await getAccountClient(this.accountsUrl, token).getWorkspaceInfo(updateLastVisit)
} catch (err: any) {
if (err?.cause?.code === 'ECONNRESET' || err?.cause?.code === 'ECONNREFUSED') {
return undefined
@@ -545,7 +552,7 @@ export class TSessionManager implements SessionManager {
}
if (this.timeMinutes > 0) {
ws.send(ctx, { result: this.createMaintenanceWarning() }, session.binaryMode, session.useCompression)
void ws.send(ctx, { result: this.createMaintenanceWarning() }, session.binaryMode, session.useCompression)
}
return { session, context: workspace.context, workspaceId: workspaceInfo.uuid }
}
@@ -917,7 +924,7 @@ export class TSessionManager implements SessionManager {
}
private sendUpgrade (ctx: MeasureContext, webSocket: ConnectionSocket, binary: boolean, compression: boolean): void {
webSocket.send(
void webSocket.send(
ctx,
{
result: {
@@ -980,6 +987,7 @@ export class TSessionManager implements SessionManager {
createOpContext (
ctx: MeasureContext,
sendCtx: MeasureContext,
pipeline: Pipeline,
requestId: Request<any>['id'],
service: Session,
@@ -991,7 +999,7 @@ export class TSessionManager implements SessionManager {
pipeline,
requestId,
sendResponse: (reqId, msg) =>
sendResponse(ctx, service, ws, {
sendResponse(sendCtx, service, ws, {
id: reqId,
result: msg,
time: platformNowDiff(st),
@@ -1003,7 +1011,7 @@ export class TSessionManager implements SessionManager {
},
socialStringsToUsers: this.getActiveSocialStringsToUsersMap(service.workspace.workspaceUuid),
sendError: (reqId, msg, error: Status) =>
sendResponse(ctx, service, ws, {
sendResponse(sendCtx, service, ws, {
id: reqId,
result: msg,
error,
@@ -1055,7 +1063,7 @@ export class TSessionManager implements SessionManager {
requestCtx.measure('msg-receive-delta', delta)
}
if (service.workspace.closing !== undefined) {
ws.send(
await ws.send(
ctx,
{
id: request.id,
@@ -1084,7 +1092,7 @@ export class TSessionManager implements SessionManager {
id: request.id,
result: done
}
ws.send(ctx, forceCloseResponse, service.binaryMode, service.useCompression)
await ws.send(ctx, forceCloseResponse, service.binaryMode, service.useCompression)
return
}
@@ -1105,16 +1113,20 @@ export class TSessionManager implements SessionManager {
try {
const params = [...request.params]
if (ws.isBackpressure()) {
await ws.backpressure(ctx)
}
await ctx.with('🧨 process', {}, (callTx) =>
f.apply(service, [this.createOpContext(callTx, pipeline, request.id, service, ws), ...params])
f.apply(service, [this.createOpContext(callTx, userCtx, pipeline, request.id, service, ws), ...params])
)
} catch (err: any) {
Analytics.handleError(err)
if (LOGGING_ENABLED) {
this.ctx.error('error handle request', { error: err, request })
}
ws.send(
ctx,
await ws.send(
userCtx,
{
id: request.id,
error: unknownError(err),
@@ -1159,15 +1171,15 @@ export class TSessionManager implements SessionManager {
service.workspace.pipeline instanceof Promise ? await service.workspace.pipeline : service.workspace.pipeline
try {
const uctx = this.createOpContext(ctx, pipeline, reqId, service, ws)
const uctx = this.createOpContext(ctx, userCtx, pipeline, reqId, service, ws)
await operation(uctx)
} catch (err: any) {
Analytics.handleError(err)
if (LOGGING_ENABLED) {
this.ctx.error('error handle request', { error: err })
}
ws.send(
ctx,
await ws.send(
userCtx,
{
id: reqId,
error: unknownError(err),
@@ -1225,7 +1237,7 @@ export class TSessionManager implements SessionManager {
account: service.getRawAccount(),
useCompression: service.useCompression
}
ws.send(requestCtx, helloResponse, false, false)
await ws.send(requestCtx, helloResponse, false, false)
// We do not need to wait for set-status, just return session to client
const _workspace = service.workspace
+2
View File
@@ -42,6 +42,8 @@ export function getStatistics (ctx: MeasureContext, sessions: SessionManager, ad
const memU = process.memoryUsage()
data.statistics.memoryUsed = Math.round(((memU.heapUsed + memU.rss) / 1024 / 1024) * 100) / 100
data.statistics.memoryTotal = Math.round((memU.heapTotal / 1024 / 1024) * 100) / 100
data.statistics.memoryRSS = Math.round((memU.rss / 1024 / 1024) * 100) / 100
data.statistics.memoryArrayBuffers = Math.round((memU.arrayBuffers / 1024 / 1024) * 100) / 100
data.statistics.cpuUsage = Math.round(os.loadavg()[0] * 100) / 100
data.statistics.freeMem = Math.round((os.freemem() / 1024 / 1024) * 100) / 100
data.statistics.totalMem = Math.round((os.totalmem() / 1024 / 1024) * 100) / 100
+1 -2
View File
@@ -85,6 +85,5 @@ export function sendResponse (
socket: ConnectionSocket,
resp: Response<any>
): Promise<void> {
socket.send(ctx, resp, session.binaryMode, session.useCompression)
return Promise.resolve()
return socket.send(ctx, resp, session.binaryMode, session.useCompression)
}
+4 -2
View File
@@ -36,7 +36,8 @@
"prettier": "^3.1.0",
"ts-jest": "^29.1.1",
"typescript": "^5.3.3",
"@types/body-parser": "~1.19.2"
"@types/body-parser": "~1.19.2",
"@types/morgan": "~1.9.9"
},
"dependencies": {
"@hcengineering/analytics": "^0.6.0",
@@ -54,6 +55,7 @@
"ws": "^8.18.0",
"body-parser": "^1.20.2",
"snappy": "^7.2.2",
"@hcengineering/api-client": "^0.6.0"
"@hcengineering/api-client": "^0.6.0",
"morgan": "^1.10.0"
}
}
-194
View File
@@ -1,194 +0,0 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
// import { generateToken } from '@hcengineering/server-token'
// import { createRestClient, type RestClient } from '@hcengineering/api-client'
// import core, {
// type Class,
// type Doc,
// type DocumentQuery,
// type Domain,
// type FindOptions,
// type FindResult,
// generateId,
// Hierarchy,
// type MeasureContext,
// MeasureMetricsContext,
// ModelDb,
// type PersonId,
// type PersonUuid,
// type Ref,
// type Space,
// toFindResult,
// type Tx,
// type TxCreateDoc,
// type TxResult,
// type WorkspaceUuid
// } from '@hcengineering/core'
// import { ClientSession, startSessionManager } from '@hcengineering/server'
// import { createDummyStorageAdapter } from '@hcengineering/server-core'
// import { generateToken } from '@hcengineering/server-token'
// import { startHttpServer } from '../server_http'
// import { genMinModel } from './minmodel'
describe.skip('rest-server', () => {
// async function getModelDb (): Promise<{ modelDb: ModelDb, hierarchy: Hierarchy, txes: Tx[] }> {
// const txes = genMinModel()
// const hierarchy = new Hierarchy()
// for (const tx of txes) {
// hierarchy.tx(tx)
// }
// const modelDb = new ModelDb(hierarchy)
// for (const tx of txes) {
// await modelDb.tx(tx)
// }
// return { modelDb, hierarchy, txes }
// }
// let shutdown: () => Promise<void>
// const port: number = 3330
// beforeAll(async () => {
// ;({ shutdown } = startSessionManager(new MeasureMetricsContext('test', {}), {
// pipelineFactory: async () => {
// const { modelDb, hierarchy, txes } = await getModelDb()
// return {
// hierarchy,
// modelDb,
// context: {
// workspace: {
// url: 'test-ws',
// uuid: 'test-ws' as WorkspaceUuid
// },
// hierarchy,
// modelDb,
// lastTx: generateId(),
// lastHash: generateId(),
// contextVars: {},
// branding: null
// },
// handleBroadcast: async (ctx) => {},
// findAll: async <T extends Doc>(
// ctx: MeasureContext,
// _class: Ref<Class<T>>,
// query: DocumentQuery<T>,
// options?: FindOptions<T>
// ): Promise<FindResult<T>> => toFindResult(await modelDb.findAll(_class, query, options)),
// tx: async (ctx: MeasureContext, tx: Tx[]): Promise<[TxResult, Tx[], string[] | undefined]> => [
// await modelDb.tx(...tx),
// [],
// undefined
// ],
// groupBy: async () => new Map(),
// close: async () => {},
// domains: async () => hierarchy.domains(),
// find: (ctx: MeasureContext, domain: Domain) => ({
// next: async (ctx: MeasureContext) => undefined,
// close: async (ctx: MeasureContext) => {}
// }),
// load: async (ctx: MeasureContext, domain: Domain, docs: Ref<Doc>[]) => [],
// upload: async (ctx: MeasureContext, domain: Domain, docs: Doc[]) => {},
// clean: async (ctx: MeasureContext, domain: Domain, docs: Ref<Doc>[]) => {},
// searchFulltext: async (ctx, query, options) => {
// return { docs: [] }
// },
// loadModel: async (ctx, lastModelTx, hash) => ({
// full: true,
// hash: generateId(),
// transactions: txes
// })
// }
// },
// sessionFactory: (token, workspace, account) => new ClientSession(token, workspace, account, true),
// port,
// brandingMap: {},
// serverFactory: startHttpServer,
// accountsUrl: '',
// externalStorage: createDummyStorageAdapter()
// }))
// })
// afterAll(async () => {
// await shutdown()
// })
// async function connect (): Promise<RestClient> {
// const token: string = generateToken('user1@site.com' as PersonUuid, 'test-ws' as WorkspaceUuid)
// return await createRestClient(`http://localhost:${port}`, 'test-ws', token)
// }
// it('get account', async () => {
// const conn = await connect()
// const account = await conn.getAccount()
// expect(account.uuid).toBe('user1@site.com')
// expect(account.role).toBe('OWNER')
// expect(account.primarySocialId).toBe('user1@site.com')
// })
// it('find spaces', async () => {
// const conn = await connect()
// const spaces = await conn.findAll(core.class.Space, {})
// expect(spaces.length).toBe(2)
// expect(spaces[0].name).toBe('Sp1')
// expect(spaces[1].name).toBe('Sp2')
// })
// it('find avg', async () => {
// const conn = await connect()
// let ops = 0
// let total = 0
// const attempts = 1000
// for (let i = 0; i < attempts; i++) {
// const st = performance.now()
// const spaces = await conn.findAll(core.class.Space, {})
// expect(spaces.length).toBe(2)
// expect(spaces[0].name).toBe('Sp1')
// expect(spaces[1].name).toBe('Sp2')
// const ed = performance.now()
// ops++
// total += ed - st
// }
// const avg = total / ops
// // console.log('ops:', ops, 'total:', total, 'avg:', )
// expect(ops).toEqual(attempts)
// expect(avg).toBeLessThan(5) // 5ms max per operation
// })
it.skip('add space', async () => {
// const conn = await connect()
// const tx: TxCreateDoc<Space> = {
// _class: core.class.TxCreateDoc,
// space: core.space.Tx,
// _id: generateId(),
// objectSpace: core.space.Model,
// modifiedBy: 'user1@site.com' as PersonId,
// modifiedOn: Date.now(),
// attributes: {
// name: 'Sp3',
// description: '',
// private: false,
// archived: false,
// members: [],
// autoJoin: false
// },
// objectClass: core.class.Space,
// objectId: generateId()
// }
// await conn.tx(tx)
// const spaces = await conn.findAll(core.class.Space, {})
// expect(spaces.length).toBe(3)
})
})
+6 -5
View File
@@ -46,6 +46,7 @@ import { startHttpServer } from '../server_http'
import { genMinModel } from './minmodel'
describe('server', () => {
const port = 10000
const handler = new RPCHandler()
async function getModelDb (): Promise<{ modelDb: ModelDb, hierarchy: Hierarchy }> {
const txes = genMinModel()
@@ -96,7 +97,7 @@ describe('server', () => {
}
},
sessionFactory: (token, workspace, account) => new ClientSession(token, workspace, account, true),
port: 3335,
port,
brandingMap: {},
serverFactory: startHttpServer,
accountsUrl: '',
@@ -105,7 +106,7 @@ describe('server', () => {
function connect (): WebSocket {
const token: string = generateToken('' as PersonUuid, 'latest' as WorkspaceUuid)
return new WebSocket(`ws://localhost:3335/${token}`)
return new WebSocket(`ws://localhost:${port}/${token}`)
}
afterAll(async () => {
@@ -123,7 +124,7 @@ describe('server', () => {
})
it('should not connect to server without token', (done) => {
const conn = new WebSocket('ws://localhost:3335/xyz')
const conn = new WebSocket(`ws://localhost:${port}/xyz`)
conn.on('error', () => {
conn.close(1000)
})
@@ -207,7 +208,7 @@ describe('server', () => {
}
},
sessionFactory: (token, workspace, account) => new ClientSession(token, workspace, account, true),
port: 3336,
port: port + 1,
brandingMap: {},
serverFactory: startHttpServer,
accountsUrl: '',
@@ -215,7 +216,7 @@ describe('server', () => {
})
async function findClose (token: string, timeoutPromise: Promise<void>, code: number): Promise<string> {
const newConn = new WebSocket(`ws://localhost:3336/${token}?sessionId=s1`)
const newConn = new WebSocket(`ws://localhost:${port + 1}/${token}?sessionId=s1`)
await Promise.race([
timeoutPromise,
+120 -40
View File
@@ -1,4 +1,13 @@
import type { Class, Doc, MeasureContext, Ref } from '@hcengineering/core'
import core, {
TxProcessor,
type Class,
type Doc,
type MeasureContext,
type Ref,
type SearchOptions,
type SearchQuery,
type TxCUD
} from '@hcengineering/core'
import type {
ClientSessionCtx,
ConnectionSocket,
@@ -8,6 +17,8 @@ import type {
} from '@hcengineering/server-core'
import { decodeToken } from '@hcengineering/server-token'
import { rpcJSONReplacer } from '@hcengineering/rpc'
import { createHash } from 'crypto'
import { type Express, type Response as ExpressResponse, type Request } from 'express'
import type { OutgoingHttpHeaders } from 'http2'
import { compress } from 'snappy'
@@ -26,18 +37,37 @@ const sendError = (res: ExpressResponse, code: number, data: any): void => {
res.writeHead(code, {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'keep-alive': 'timeout=5, max=1000'
})
res.end(JSON.stringify(data))
}
async function sendJson (req: Request, res: ExpressResponse, result: any): Promise<void> {
async function sendJson (
req: Request,
res: ExpressResponse,
result: any,
extraHeaders?: OutgoingHttpHeaders
): Promise<void> {
// Calculate ETag
let body: any = JSON.stringify(result, rpcJSONReplacer)
const etag = createHash('sha1').update(body).digest('hex')
const headers: OutgoingHttpHeaders = {
...(extraHeaders ?? {}),
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
'keep-alive': 'timeout=5, max=1000'
connection: 'keep-alive',
'keep-alive': 'timeout=5, max=1000',
ETag: etag
}
// Check if the ETag matches
if (req.headers['if-none-match'] === etag) {
res.writeHead(304, headers)
res.end()
return
}
let body: any = JSON.stringify(result)
const contentEncodings: string[] =
typeof req.headers['accept-encoding'] === 'string'
@@ -61,7 +91,7 @@ async function sendJson (req: Request, res: ExpressResponse, result: any): Promi
break
}
}
headers['content-length'] = body.length
res.writeHead(200, headers)
res.end(body)
}
@@ -79,41 +109,42 @@ export function registerRPC (
res: ExpressResponse,
operation: (ctx: ClientSessionCtx, session: Session) => Promise<void>
): Promise<void> {
if (req.params.workspaceId === undefined || req.params.workspaceId === '') {
res.writeHead(400, {})
res.end('Missing workspace')
return
}
let token = req.headers.authorization as string
if (token === null) {
sendError(res, 401, { message: 'Missing Authorization header' })
return
}
const workspaceId = decodeURIComponent(req.params.workspaceId)
token = token.split(' ')[1]
const decodedToken = decodeToken(token)
if (workspaceId !== decodedToken.workspace) {
sendError(res, 401, { message: 'Invalid workspace' })
return
}
let transactorRpc = rpcSessions.get(token)
if (transactorRpc === undefined) {
const cs: ConnectionSocket = createClosingSocket(token, rpcSessions)
const s = await sessions.addSession(ctx, cs, decodedToken, token, pipelineFactory, token)
if (!('session' in s)) {
sendError(res, 401, {
message: 'Failed to create session',
mode: 'specialError' in s ? s.specialError ?? '' : 'upgrading'
})
try {
if (req.params.workspaceId === undefined || req.params.workspaceId === '') {
res.writeHead(400, {})
res.end('Missing workspace')
return
}
transactorRpc = { session: s.session, client: cs, workspaceId: s.workspaceId }
rpcSessions.set(token, transactorRpc)
}
try {
let token = req.headers.authorization as string
if (token === null) {
sendError(res, 401, { message: 'Missing Authorization header' })
return
}
const workspaceId = decodeURIComponent(req.params.workspaceId)
token = token.split(' ')[1]
const decodedToken = decodeToken(token)
if (workspaceId !== decodedToken.workspace) {
sendError(res, 401, { message: 'Invalid workspace', workspace: decodedToken.workspace })
return
}
let transactorRpc = rpcSessions.get(token)
if (transactorRpc === undefined) {
const cs: ConnectionSocket = createClosingSocket(token, rpcSessions)
const s = await sessions.addSession(ctx, cs, decodedToken, token, pipelineFactory, token)
if (!('session' in s)) {
sendError(res, 401, {
message: 'Failed to create session',
mode: 'specialError' in s ? s.specialError ?? '' : 'upgrading'
})
return
}
transactorRpc = { session: s.session, client: cs, workspaceId: s.workspaceId }
rpcSessions.set(token, transactorRpc)
}
const rpc = transactorRpc
await sessions.handleRPC(ctx, rpc.session, rpc.client, async (ctx) => {
await operation(ctx, rpc.session)
@@ -126,7 +157,11 @@ export function registerRPC (
app.get('/api/v1/ping/:workspaceId', (req, res) => {
void withSession(req, res, async (ctx, session) => {
await session.ping(ctx)
await sendJson(req, res, { pong: true })
await sendJson(req, res, {
pong: true,
lastTx: ctx.pipeline.context.lastTx,
lastHash: ctx.pipeline.context.lastHash
})
})
})
@@ -164,6 +199,49 @@ export function registerRPC (
await sendJson(req, res, result)
})
})
app.get('/api/v1/load-model/:workspaceId', (req, res) => {
void withSession(req, res, async (ctx, session) => {
const lastModelTx = parseInt((req.query.lastModelTx as string) ?? '0')
const lastHash = req.query.lastHash as string
const result = await session.loadModelRaw(ctx, lastModelTx, lastHash)
const txes = Array.isArray(result) ? result : result.transactions
// we need to filter only hierarchy related txes.
const allowedClasess: Ref<Class<Doc>>[] = [
core.class.Class,
core.class.Attribute,
core.class.Mixin,
core.class.Type,
core.class.Status,
core.class.Permission,
core.class.Space,
core.class.Tx
]
const h = ctx.pipeline.context.hierarchy
const filtered = txes.filter(
(it) =>
TxProcessor.isExtendsCUD(it._class) &&
allowedClasess.some((cl) => h.isDerived((it as TxCUD<Doc>).objectClass, cl))
)
await sendJson(req, res, filtered)
})
})
app.get('/api/v1/search-fulltext/:workspaceId', (req, res) => {
void withSession(req, res, async (ctx, session) => {
const query: SearchQuery = {
query: req.query.query as string,
classes: req.query.classes !== undefined ? JSON.parse(req.query.classes as string) : undefined,
spaces: req.query.spaces !== undefined ? JSON.parse(req.query.spaces as string) : undefined
}
const options: SearchOptions = {
limit: req.query.limit !== undefined ? parseInt(req.query.limit as string) : undefined
}
const result = await session.searchFulltextRaw(ctx, query, options)
await sendJson(req, res, result)
})
})
}
function createClosingSocket (rawToken: string, rpcSessions: Map<string, RPCClientInfo>): ConnectionSocket {
@@ -173,7 +251,9 @@ function createClosingSocket (rawToken: string, rpcSessions: Map<string, RPCClie
close: () => {
rpcSessions.delete(rawToken)
},
send: (ctx, msg, binary, compression) => {},
send: async (ctx, msg, binary, compression) => {},
isBackpressure: () => false,
backpressure: async (ctx) => {},
sendPong: () => {},
data: () => ({}),
readRequest: (buffer, binary) => ({ method: '', params: [], id: -1, time: Date.now() }),
+69 -25
View File
@@ -21,6 +21,7 @@ import {
import { Analytics } from '@hcengineering/analytics'
import {
generateId,
platformNow,
systemAccountUuid,
type MeasureContext,
type Tx,
@@ -57,13 +58,13 @@ import os from 'os'
import { WebSocketServer, type RawData, type WebSocket } from 'ws'
import 'bufferutil'
import morgan from 'morgan'
import { compress } from 'snappy'
import 'utf-8-validate'
import { registerRPC } from './rpc'
import { retrieveJson } from './utils'
let profiling = false
const rpcHandler = new RPCHandler()
import { setImmediate } from 'timers/promises'
export type RequestHandler = (req: Request, res: ExpressResponse, next?: NextFunction) => Promise<void>
@@ -77,6 +78,10 @@ const catchError = (fn: RequestHandler) => (req: Request, res: ExpressResponse,
})()
}
let profiling = false
const rpcHandler = new RPCHandler()
const backpressureSize = 100 * 1024
/**
* @public
* @param sessionFactory -
@@ -121,10 +126,29 @@ export function startHttpServer (
const app = express()
app.use(cors())
const childLogger = ctx.logger.childLogger?.('requests', {
enableConsole: 'true'
})
const requests = ctx.newChild('requests', {}, {}, childLogger)
class MyStream {
write (text: string): void {
requests.info(text)
}
}
const myStream = new MyStream()
app.use(morgan('short', { stream: myStream }))
const getUsers = (): any => Array.from(sessions.sessions.entries()).map(([k, v]) => v.session.getUser())
app.get('/api/v1/version', (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.writeHead(200, {
'Content-Type': 'application/json',
Connection: 'keep-alive',
'keep-alive': 'timeout=5, max=1000'
})
res.end(
JSON.stringify({
version: process.env.MODEL_VERSION
@@ -134,7 +158,7 @@ export function startHttpServer (
app.get('/api/v1/statistics', (req, res) => {
try {
const token = req.query.token as string
const token = (req.query.token as string) ?? (req.headers.authorization ?? '').split(' ')[1]
const payload = decodeToken(token)
const admin = payload.extra?.admin === 'true'
const jsonData = {
@@ -144,7 +168,11 @@ export function startHttpServer (
profiling
}
const json = JSON.stringify(jsonData)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.writeHead(200, {
'Content-Type': 'application/json',
Connection: 'keep-alive',
'keep-alive': 'timeout=5, max=1000'
})
res.end(json)
} catch (err: any) {
Analytics.handleError(err)
@@ -173,7 +201,7 @@ export function startHttpServer (
})
app.put('/api/v1/manage', (req, res) => {
try {
const token = req.query.token as string
const token = (req.query.token as string) ?? (req.headers.authorization ?? '').split(' ')[1]
const payload = decodeToken(token)
if (payload.extra?.admin !== 'true' && payload.account !== systemAccountUuid) {
console.warn('Non admin attempt to maintenance action', { payload })
@@ -362,8 +390,6 @@ export function startHttpServer (
registerRPC(app, sessions, ctx, pipelineFactory)
registerRPC(app, sessions, ctx, pipelineFactory)
app.put('/api/v1/broadcast', (req, res) => {
try {
const token = req.query.token as string
@@ -435,7 +461,7 @@ export function startHttpServer (
void webSocketData.session.then((s) => {
if ('error' in s) {
if (s.specialError === 'archived') {
cs.send(
void cs.send(
ctx,
{
id: -1,
@@ -448,7 +474,7 @@ export function startHttpServer (
false
)
} else if (s.specialError === 'migration') {
cs.send(
void cs.send(
ctx,
{
id: -1,
@@ -461,7 +487,7 @@ export function startHttpServer (
false
)
} else {
cs.send(
void cs.send(
ctx,
{ id: -1, error: unknownStatus(s.error.message ?? 'Unknown error'), terminate: s.terminate },
false,
@@ -474,7 +500,7 @@ export function startHttpServer (
}, 1000)
}
if ('upgrade' in s) {
cs.send(ctx, { id: -1, result: { state: 'upgrading', stats: (s as any).upgradeInfo } }, false, false)
void cs.send(ctx, { id: -1, result: { state: 'upgrading', stats: (s as any).upgradeInfo } }, false, false)
setTimeout(() => {
cs.close()
}, 5000)
@@ -619,6 +645,17 @@ function createWebsocketClientSocket (
ws.close()
ws.terminate()
},
isBackpressure: () => ws.bufferedAmount > backpressureSize,
backpressure: async (ctx) => {
if (ws.bufferedAmount < backpressureSize) {
return
}
await ctx.with('backpressure', {}, async () => {
while (ws.bufferedAmount > backpressureSize) {
await setImmediate()
}
})
},
checkState: () => {
if (ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) {
ws.terminate()
@@ -639,28 +676,35 @@ function createWebsocketClientSocket (
}
ws.send(pongConst)
},
send: (ctx: MeasureContext, msg, binary, _compression) => {
send: async (ctx: MeasureContext, msg, binary, _compression): Promise<void> => {
const smsg = rpcHandler.serialize(msg, binary)
if (ws.readyState !== ws.OPEN || cs.isClosed) {
return
}
const handleErr = (err?: Error): void => {
if (err != null) {
if (!`${err.message}`.includes('WebSocket is not open')) {
ctx.error('send error', { err })
Analytics.handleError(err)
}
}
// We need to be sure all data is send before we will send more.
if (cs.isBackpressure()) {
await cs.backpressure(ctx)
}
let sendMsg = smsg
if (_compression) {
void compress(smsg).then((msg: any) => {
ws.send(msg, { binary: true }, handleErr)
})
} else {
ws.send(smsg, { binary: true }, handleErr)
sendMsg = await compress(smsg)
}
const st = platformNow()
await new Promise<void>((resolve) => {
const handleErr = (err?: Error): void => {
ctx.measure('msg-send-delta', platformNow() - st)
if (err != null) {
if (!`${err.message}`.includes('WebSocket is not open')) {
ctx.error('send error', { err })
Analytics.handleError(err)
}
}
resolve() // In any case we need to resolve.
}
ws.send(sendMsg, { binary: true }, handleErr)
})
}
}
return cs
+38 -12
View File
@@ -64,7 +64,8 @@ export class CalendarClient {
private readonly user: User,
private readonly mongo: Db,
client: Client,
private readonly workspace: WorkspaceClient
private readonly workspace: WorkspaceClient,
stayAlive: boolean = false
) {
this.client = new TxOperations(client, this.user.userId)
this.systemTxOp = new TxOperations(client, core.account.System)
@@ -100,7 +101,8 @@ export class CalendarClient {
user: User | Token,
mongo: Db,
client: Client,
workspace: WorkspaceClient
workspace: WorkspaceClient,
stayAlive: boolean = false
): Promise<CalendarClient> {
const calendarClient = new CalendarClient(user, mongo, client, workspace)
if (isToken(user)) {
@@ -423,6 +425,7 @@ export class CalendarClient {
if (res.data.nextSyncToken != null) {
await this.setEventHistoryId(calendarId, res.data.nextSyncToken)
}
// if resync
} catch (err: any) {
if (err?.response?.status === 410) {
await this.eventsSync(calendarId)
@@ -458,7 +461,7 @@ export class CalendarClient {
private async updateExtEvent (event: calendar_v3.Schema$Event, current: Event): Promise<void> {
this.updateTimer()
if (event.status === 'cancelled' && current._class !== calendar.class.ReccuringInstance) {
await this.client.remove(current)
await this.systemTxOp.remove(current)
return
}
const data: Partial<AttachedData<Event>> = await this.parseUpdateData(event)
@@ -473,7 +476,7 @@ export class CalendarClient {
current as ReccuringInstance
)
if (Object.keys(diff).length > 0) {
await this.client.update(current, diff)
await this.systemTxOp.update(current, diff)
}
} else {
if (event.recurrence != null) {
@@ -488,12 +491,12 @@ export class CalendarClient {
current as ReccuringEvent
)
if (Object.keys(diff).length > 0) {
await this.client.update(current, diff)
await this.systemTxOp.update(current, diff)
}
} else {
const diff = this.getDiff(data, current)
if (Object.keys(diff).length > 0) {
await this.client.update(current, diff)
await this.systemTxOp.update(current, diff)
}
}
}
@@ -1026,46 +1029,69 @@ export class CalendarClient {
return res
}
private applyUpdate (event: calendar_v3.Schema$Event, current: Event, me: string): calendar_v3.Schema$Event {
private applyUpdate (
event: calendar_v3.Schema$Event,
current: Event,
me: string
): calendar_v3.Schema$Event | undefined {
let res: boolean = false
if (current.title !== event.summary) {
event.summary = current.title
res = true
}
if (current.visibility !== undefined) {
const newVisibility = current.visibility === 'public' ? 'public' : 'private'
if (newVisibility !== event.visibility) {
event.visibility = newVisibility
res = true
}
if (current.visibility === 'freeBusy') {
if (current.visibility === 'freeBusy' && event?.extendedProperties?.private?.visibility !== 'freeBusy') {
event.extendedProperties = {
...event.extendedProperties,
private: {
visibility: 'freeBusy'
}
}
res = true
}
}
const description = htmlToMarkup(event.description ?? '')
if (current.description !== description) {
res = true
event.description = description
}
if (current.location !== event.location) {
res = true
event.location = current.location
}
const attendees = this.getAttendees(current, me)
if (attendees.length > 0 && event.attendees !== undefined) {
for (const attendee of attendees) {
if (event.attendees.findIndex((p) => p.email === attendee) === -1) {
res = true
event.attendees.push({ email: attendee })
}
}
}
event.start = convertDate(current.date, event.start?.date !== undefined, getTimezone(current))
event.end = convertDate(current.dueDate, event.end?.date !== undefined, getTimezone(current))
const newStart = convertDate(current.date, event.start?.date !== undefined, getTimezone(current))
if (!deepEqual(newStart, event.start)) {
res = true
event.start = newStart
}
const newEnd = convertDate(current.dueDate, event.end?.date !== undefined, getTimezone(current))
if (!deepEqual(newEnd, event.end)) {
res = true
event.end = newEnd
}
if (current._class === calendar.class.ReccuringEvent) {
const rec = current as ReccuringEvent
event.recurrence = encodeReccuring(rec.rules, rec.rdate, rec.exdate)
const newRec = encodeReccuring(rec.rules, rec.rdate, rec.exdate)
if (!deepEqual(newRec, event.recurrence)) {
res = true
event.recurrence = newRec
}
}
return event
return res ? event : undefined
}
private getAttendees (event: Event, me: string): string[] {
@@ -74,7 +74,6 @@ export function parseRecurrenceStrings (recurrenceStrings: string[]): ReccuringD
}
}
}
console.log('parseRecurrenceStrings', recurrenceStrings, JSON.stringify(res))
return res
}
+1 -2
View File
@@ -83,8 +83,7 @@ test.describe('contact tests', () => {
await contractPage.fillEmailInput(mail)
await contractPage.clickCreateButton()
await contractPage.waitForFormAntiCardDetached()
// employee already inactive
// await contractPage.kickEmployee(first, last)
await contractPage.kickEmployee(first, last)
// In non refactored code, the last assert just checks if the employee does exist, not if it has inactive status
await contractPage.expectKickEmployeeShowsInactiveStatus(first, last)
})
@@ -14,7 +14,7 @@ export class SelectWorkspacePage extends CommonPage {
buttonCreateWorkspace = (): Locator => this.page.locator('button > span', { hasText: 'Create workspace' })
buttonWorkspaceName = (): Locator => this.page.locator('input')
buttonCreateNewWorkspace = (): Locator => this.page.locator('div.form-row button')
workspaceButtonByName = (workspace: string): Locator => this.buttonWorkspace().filter({ hasText: workspace })
workspaceButtonByName = (workspace: string): Locator => this.buttonWorkspace().filter({ hasText: workspace }).first()
createAnotherWorkspace = (): Locator => this.page.getByRole('link', { name: 'Create workspace' })
workspaceLogo = (): Locator => this.page.getByText('N', { exact: true })
workspaceList = (workspaceName: string): Locator => this.page.getByRole('button', { name: workspaceName })
+15 -4
View File
@@ -312,7 +312,7 @@ export class Transactor extends DurableObject<Env> {
throw session.error
}
if ('upgrade' in session) {
cs.send(
await cs.send(
this.measureCtx,
{ id: -1, result: { state: 'upgrading', stats: (session as any).upgradeInfo } },
false,
@@ -364,6 +364,8 @@ export class Transactor extends DurableObject<Env> {
}
return true
},
backpressure: async (ctx) => {},
isBackpressure: () => false,
readRequest: (buffer: Buffer, binary: boolean) => {
if (buffer.length === pingConst.length) {
if (buffer.toString() === pingConst) {
@@ -373,7 +375,7 @@ export class Transactor extends DurableObject<Env> {
return rpcHandler.readRequest(buffer, binary)
},
data: () => data,
send: (ctx: MeasureContext, msg, binary, _compression) => {
send: async (ctx: MeasureContext, msg, binary, _compression) => {
let smsg = rpcHandler.serialize(msg, binary)
ctx.measure('send-data', smsg.length)
@@ -455,7 +457,9 @@ export class Transactor extends DurableObject<Env> {
data: () => {
return {}
},
send: (ctx: MeasureContext, msg, binary, compression) => {},
isBackpressure: () => false,
backpressure: async (ctx) => {},
send: async (ctx: MeasureContext, msg, binary, compression) => {},
sendPong: () => {}
}
return cs
@@ -491,7 +495,14 @@ export class Transactor extends DurableObject<Env> {
const session = await this.makeRpcSession(rawToken, cs)
const pipeline =
session.workspace.pipeline instanceof Promise ? await session.workspace.pipeline : session.workspace.pipeline
const opContext = this.sessionManager.createOpContext(this.measureCtx, pipeline, undefined, session, cs)
const opContext = this.sessionManager.createOpContext(
this.measureCtx,
this.measureCtx,
pipeline,
undefined,
session,
cs
)
session.includeSessionContext(opContext)
return pipeline
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/node/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
+2
View File
@@ -0,0 +1,2 @@
v*.zip
src/uws
+4
View File
@@ -0,0 +1,4 @@
*
!/lib/**
!CHANGELOG.md
/lib/**/__tests__/
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig",
"rigProfile": "node"
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ["./src"],
coverageReporters: ["text-summary", "html"]
}
+60
View File
@@ -0,0 +1,60 @@
{
"name": "@hcengineering/api-tests",
"version": "0.6.0",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
"author": "Anticrm Platform Contributors",
"template": "@hcengineering/node-package-ws",
"license": "EPL-2.0",
"scripts": {
"build": "compile",
"build:watch": "compile",
"test": "echo 'run api-test' for API-tests",
"api-test": "jest --passWithNoTests --silent --forceExit",
"format": "format src",
"_phase:build": "compile transpile src",
"_phase:test": "echo 'run api-test' for API-tests",
"_phase:format": "format src",
"_phase:validate": "compile validate"
},
"devDependencies": {
"@hcengineering/platform-rig": "^0.6.0",
"@types/compression": "~1.7.2",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"@types/jest": "^29.5.5",
"@types/node": "~20.11.16",
"@types/ws": "^8.5.11",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"eslint": "^8.54.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-n": "^15.4.0",
"eslint-plugin-promise": "^6.1.1",
"jest": "^29.7.0",
"prettier": "^3.1.0",
"ts-jest": "^29.1.1",
"typescript": "^5.3.3",
"@types/body-parser": "~1.19.2",
"@types/morgan": "~1.9.9"
},
"dependencies": {
"@hcengineering/analytics": "^0.6.0",
"@hcengineering/core": "^0.6.32",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/rpc": "^0.6.5",
"@hcengineering/server-token": "^0.6.11",
"bufferutil": "^4.0.8",
"utf-8-validate": "^6.0.4",
"ws": "^8.18.0",
"snappyjs": "^0.7.0",
"@hcengineering/api-client": "^0.6.0",
"@hcengineering/account-client": "^0.6.0",
"@hcengineering/tracker": "^0.6.24",
"@hcengineering/task": "^0.6.20",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/chunter": "^0.6.20"
}
}
@@ -0,0 +1,232 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import {
createRestClient,
createRestTxOperations,
getWorkspaceToken,
loadServerConfig,
type RestClient,
type WorkspaceToken
} from '@hcengineering/api-client'
import core, {
generateId,
MeasureMetricsContext,
pickPrimarySocialId,
type SocialId,
type Space,
type TxCreateDoc,
type TxOperations
} from '@hcengineering/core'
import { getClient as getAccountClient } from '@hcengineering/account-client'
import chunter from '@hcengineering/chunter'
import contact, { ensureEmployee } from '@hcengineering/contact'
describe('rest-api-server', () => {
const testCtx = new MeasureMetricsContext('test', {})
const wsName = 'api-tests'
let apiWorkspace1: WorkspaceToken
let apiWorkspace2: WorkspaceToken
beforeAll(async () => {
const config = await loadServerConfig('http://localhost:8083')
apiWorkspace1 = await getWorkspaceToken(
'http://localhost:8083',
{
email: 'user1',
password: '1234',
workspace: wsName
},
config
)
apiWorkspace2 = await getWorkspaceToken(
'http://localhost:8083',
{
email: 'user1',
password: '1234',
workspace: wsName + '-cr'
},
config
)
const account = getAccountClient(config.ACCOUNTS_URL, apiWorkspace1.token)
const person = await account.getPerson()
const socialIds: SocialId[] = await account.getSocialIds()
// Ensure employee is created
await ensureEmployee(
testCtx,
{
uuid: apiWorkspace1.info.account,
role: apiWorkspace1.info.role,
primarySocialId: pickPrimarySocialId(socialIds).key,
socialIds: socialIds.map((si) => si.key)
},
connect(),
socialIds,
async () => person
)
await ensureEmployee(
testCtx,
{
uuid: apiWorkspace2.info.account,
role: apiWorkspace2.info.role,
primarySocialId: pickPrimarySocialId(socialIds).key,
socialIds: socialIds.map((si) => si.key)
},
connect(apiWorkspace2),
socialIds,
async () => person
)
})
function connect (ws?: WorkspaceToken): RestClient {
const tok = ws ?? apiWorkspace1
return createRestClient(tok.endpoint, tok.workspaceId, tok.token)
}
async function connectTx (ws?: WorkspaceToken): Promise<TxOperations> {
const tok = ws ?? apiWorkspace1
return await createRestTxOperations(tok.endpoint, tok.workspaceId, tok.token)
}
it('get account', async () => {
const conn = connect()
const account = await conn.getAccount()
expect(account.primarySocialId).toBe('email:user1')
expect(account.role).toBe('USER')
// expect(account.space).toBe(core.space.Model)
// expect(account.modifiedBy).toBe(core.account.System)
// expect(account.createdBy).toBe(core.account.System)
// expect(typeof account.modifiedOn).toBe('number')
// expect(typeof account.createdOn).toBe('number')
})
it('find spaces', async () => {
const conn = connect()
const spaces = await conn.findAll(core.class.Space, {})
expect(spaces.length).toBeGreaterThanOrEqual(20)
const personSpace = spaces.find((it) => it.name === 'Pesonal space' && it.private)
expect(personSpace).not.toBeNull()
})
it('find spaces limit', async () => {
const conn = connect()
const spaces = await conn.findAll(core.class.Space, {}, { limit: 5 })
expect(spaces.length).toBe(5)
})
it('find spaces by-name', async () => {
const conn = connect()
const spaces = await conn.findAll(
contact.class.PersonSpace,
{ name: 'Personal space' },
{
lookup: {
person: contact.class.Person
}
}
)
expect(spaces.length).toBe(1)
expect(spaces[0].name).toBe('Personal space')
expect(spaces[0].$lookup?.person?.name).toBe('Appleseed,John')
})
it('find channels', async () => {
const conn = connect()
const spaces = await conn.findAll(chunter.class.Channel, {})
expect(spaces.length).toBeGreaterThanOrEqual(2)
expect(spaces.find((it) => it._id === 'chunter:space:General')).not.toBeNull()
})
it('find avg', async () => {
const conn = connect()
await checkFindPerformance(conn) // 5ms max per operation
})
it('find avg-europe', async () => {
const conn = connect(apiWorkspace2)
await checkFindPerformance(conn) // 5ms max per operation
})
it('add space', async () => {
const conn = connect()
const account = await conn.getAccount()
const spaceName = generateId()
const tx: TxCreateDoc<Space> = {
_class: core.class.TxCreateDoc,
space: core.space.Tx,
_id: generateId(),
objectSpace: core.space.Model,
modifiedBy: account.primarySocialId,
modifiedOn: Date.now(),
attributes: {
name: spaceName,
description: '',
private: false,
archived: false,
members: [],
autoJoin: false
},
objectClass: core.class.Space,
objectId: generateId()
}
await conn.tx(tx)
const spaces = await conn.findAll(core.class.Space, {})
expect(spaces.filter((it) => it.name === spaceName).length).toBe(1)
})
it('get-model', async () => {
const conn = connect()
const { hierarchy, model } = await conn.getModel()
const dsc = hierarchy.getDescendants(core.class.Space)
expect(dsc.length).toBe(32)
expect(model.getObject(core.class.Space)).not.toBeNull()
})
it('tx-client', async () => {
const conn = await connectTx()
const employee = await conn.findAll(contact.mixin.Employee, {}, { limit: 5 })
expect(employee.length).toBeGreaterThanOrEqual(1)
expect(employee[0].active).toBe(true)
})
})
async function checkFindPerformance (conn: RestClient): Promise<void> {
let ops = 0
let total = 0
const attempts = 1000
for (let i = 0; i < attempts; i++) {
const st = performance.now()
const spaces = await conn.findAll(core.class.Space, {})
expect(spaces.length).toBeGreaterThanOrEqual(21)
const ed = performance.now()
ops++
total += ed - st
}
const avg = total / ops
// console.log('ops:', ops, 'total:', total, 'avg:', )
expect(ops).toEqual(attempts)
expect(avg).toBeLessThan(5)
}
View File
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/node/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declarationDir": "./types",
"tsBuildInfoFile": ".build/build.tsbuildinfo"
}
}
+5
View File
@@ -22,3 +22,8 @@ fi
./tool.sh create-account admin -f Super -l Admin -p 1234
./tool.sh create-account user1 -f John -l Appleseed -p 1234
./tool.sh create-account user2 -f Kainin -l Dirak -p 1234
./tool.sh create-workspace api-tests email:user1
./tool-europe.sh create-workspace api-tests-cr email:user1 --region 'europe'
./tool.sh assign-workspace user1 api-tests
./tool.sh assign-workspace user1 api-tests-cr
@@ -86,8 +86,8 @@ test.describe('Workspace Archive tests', () => {
await page2.locator(`[id="${workspaceInfo.workspace}"]`).getByText('archived').waitFor()
})
await test.step('Check workspace is archived', async () => {
await page.reload()
await page.getByText('Workspace is archived').waitFor()
await page.reload() // Will redirect to select workspace page
await page.getByText('archived').waitFor()
})
await test.step('Restore workspace', async () => {
await page2.locator(`[id="${workspaceInfo.workspace}"]`).getByRole('button', { name: 'Unarchive' }).click()
@@ -98,6 +98,8 @@ test.describe('Workspace Archive tests', () => {
await test.step('Check workspace is active again', async () => {
await page.reload()
await selectWorkspacePage.selectWorkspace(workspaceInfo.workspaceName)
const issuesDetailsPage = new IssuesDetailsPage(page)
await issuesDetailsPage.checkIssue(newIssue)
})
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
export MODEL_VERSION=$(node ../common/scripts/show_version.js)
export MINIO_ACCESS_KEY=minioadmin
export MINIO_SECRET_KEY=minioadmin
export MINIO_ENDPOINT=localhost:9002
export ACCOUNTS_URL=http://localhost:3003
export TRANSACTOR_URL=ws://localhost:3334
# export ACCOUNT_DB_URL=postgresql://root@localhost:26258/defaultdb?sslmode=disable
export ACCOUNT_DB_URL=mongodb://localhost:27018
export MONGO_URL=mongodb://localhost:27018
export ELASTIC_URL=http://localhost:9201
export SERVER_SECRET=secret
export DB_URL=postgresql://root@localhost:26258/defaultdb?sslmode=disable
export REGION_INFO="|America;europe|" # Europe without name will not be available for creation of new workspaces.
export TRANSACTOR_URL="ws://transactor:3334;ws://localhost:3334,ws://transactor-europe:3335;ws://localhost:3335;europe,"
node ${TOOL_OPTIONS} --max-old-space-size=8096 ../dev/tool/bundle/bundle.js $@
+2 -2
View File
@@ -11,6 +11,6 @@ export ACCOUNT_DB_URL=mongodb://localhost:27018
export MONGO_URL=mongodb://localhost:27018
export ELASTIC_URL=http://localhost:9201
export SERVER_SECRET=secret
export DB_URL=postgresql://root@localhost:26258/defaultdb?sslmode=disable
export DB_URL=$MONGO_URL
node ${TOOL_OPTIONS} ../dev/tool/bundle/bundle.js $@
node ${TOOL_OPTIONS} --max-old-space-size=8096 ../dev/tool/bundle/bundle.js $@