mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-26 19:44:57 +02:00
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:
co-authored by
Denis Bykhov
Alexander Onnikov
Victor Ilyushchenko
parent
9eba1cdf07
commit
05f1d8395a
@@ -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
|
||||
|
||||
Vendored
+3
-3
@@ -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",
|
||||
|
||||
Generated
+53
-10
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -18,3 +18,4 @@ export * from './markup/types'
|
||||
export * from './socket'
|
||||
export * from './types'
|
||||
export * from './rest'
|
||||
export * from './config'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
+8
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -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}` })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ export async function createControlledDocMetadata (
|
||||
space,
|
||||
{
|
||||
documents: 0,
|
||||
title: `${prefix}-${seqNumber} ${specTitle}`
|
||||
title: `${specCode} ${specTitle}`
|
||||
},
|
||||
metaId
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"PasswordMinDigits": "{count, plural, =1 {Пароль должен содержать минимум # цифру} other {Пароль должен содержать минимум # цифр}}",
|
||||
"PasswordMinUpperChars": "{count, plural, =1 {Пароль должен содержать минимум # заглавную букву} other {Пароль должен содержать минимум # заглавных букв}}",
|
||||
"PasswordMinLowerChars": "{count, plural, =1 {Пароль должен содержать минимум # строчную букву} other {Пароль должен содержать минимум # строчных букв}}",
|
||||
"WorkspaceArchivedDesc": "Рабочее пространство архивировано из-за неиспользования, пожалуйста, свяжитесь с нами для восстановления..."
|
||||
"WorkspaceArchivedDesc": "Рабочее пространство архивировано из-за неиспользования.",
|
||||
"RestoreArchivedWorkspace": "Восстановить"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';",
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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() }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
extends: ['./node_modules/@hcengineering/platform-rig/profiles/node/eslint.config.json'],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: './tsconfig.json'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
v*.zip
|
||||
src/uws
|
||||
@@ -0,0 +1,4 @@
|
||||
*
|
||||
!/lib/**
|
||||
!CHANGELOG.md
|
||||
/lib/**/__tests__/
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
|
||||
"rigPackageName": "@hcengineering/platform-rig",
|
||||
"rigProfile": "node"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
|
||||
roots: ["./src"],
|
||||
coverageReporters: ["text-summary", "html"]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Executable
+19
@@ -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
@@ -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 $@
|
||||
Reference in New Issue
Block a user