Merge pull request #4 from hcengineering/sync-foundations

Sync with foundations and update versions
This commit is contained in:
Artyom Savchenko
2026-02-22 15:13:22 +07:00
committed by GitHub
32 changed files with 1282 additions and 580 deletions
+2 -2
View File
@@ -19,8 +19,8 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 22
- name: Verify Change Logs
run: node common/scripts/install-run-rush.js change --verify
# - name: Verify Change Logs
# run: node common/scripts/install-run-rush.js change --verify
- name: Rush Install
run: node common/scripts/install-run-rush.js install
- name: Rush validate
+424 -245
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -21,14 +21,14 @@
"dependencies": {
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"esbuild": "^0.25.9",
"esbuild-plugin-copy": "^2.1.1",
"esbuild": "^0.25.10",
"esbuild-plugin-copy": "~2.1.1",
"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",
"prettier": "^3.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3"
},
"bin": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hcengineering/scripts",
"version": "0.7.14",
"version": "0.7.15",
"scripts": {
"format": "echo \"No format specified\"",
"coverage:merge": "node merge-coverage.js",
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@hcengineering/server-client",
"version": "0.7.17",
"version": "0.7.18",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
@@ -26,24 +26,24 @@
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"@types/uuid": "^8.3.1",
"@types/ws": "^8.5.11",
"@types/ws": "^8.5.12",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"@types/node": "^22.15.29",
"@types/node": "^22.18.1",
"eslint-plugin-svelte": "^2.35.1"
},
"dependencies": {
"@hcengineering/platform": "^0.7.19",
"@hcengineering/core": "^0.7.23",
"@hcengineering/client-resources": "^0.7.18",
"@hcengineering/client": "^0.7.18",
"@hcengineering/account-client": "^0.7.20",
"@hcengineering/server-core": "workspace:^0.7.18",
"@hcengineering/server-token": "^0.7.17",
"@hcengineering/platform": "^0.7.20",
"@hcengineering/core": "^0.7.26",
"@hcengineering/client-resources": "^0.7.19",
"@hcengineering/client": "^0.7.19",
"@hcengineering/account-client": "^0.7.25",
"@hcengineering/server-core": "workspace:^0.7.19",
"@hcengineering/server-token": "^0.7.18",
"ws": "^8.18.2"
},
"repository": "https://github.com/hcengineering/huly.server",
+22 -2
View File
@@ -46,6 +46,8 @@ export class BlobClient {
): Promise<void> {
let written = 0
const chunkSize = 50 * 1024 * 1024
let emptyChunkRetries = 0
const maxEmptyChunkRetries = 3
// Use ranges to iterave through file with retry if required.
while (written < size) {
@@ -65,6 +67,24 @@ export class BlobClient {
})
const chunk = Buffer.concat(chunks)
// Check for empty chunk to prevent infinite loop
if (chunk.length === 0) {
emptyChunkRetries++
if (emptyChunkRetries >= maxEmptyChunkRetries) {
ctx.error('Empty chunk received multiple times, aborting', { name, written, size, emptyChunkRetries })
await new Promise<void>((resolve) => {
writable.end(resolve)
})
throw new Error(
`Empty chunk received ${emptyChunkRetries} times for blob ${name} at offset ${written}/${size}`
)
}
ctx.warn('Empty chunk received, retrying', { name, written, size, retry: emptyChunkRetries })
await new Promise<void>((resolve) => setTimeout(resolve, 100 * emptyChunkRetries))
continue
}
emptyChunkRetries = 0 // Reset on successful non-empty chunk
await new Promise<void>((resolve, reject) => {
writable.write(chunk, (err) => {
if (err != null) {
@@ -86,13 +106,13 @@ export class BlobClient {
ctx.info('No such key', { name })
return
}
if (i > 4) {
if (i >= 4) {
await new Promise<void>((resolve) => {
writable.end(resolve)
})
throw err
}
await new Promise<void>((resolve) => setTimeout(resolve, 10))
await new Promise<void>((resolve) => setTimeout(resolve, 100 * (i + 1)))
// retry
}
}
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@hcengineering/collaboration",
"version": "0.7.17",
"version": "0.7.18",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
@@ -19,7 +19,7 @@
},
"devDependencies": {
"@hcengineering/platform-rig": "^0.7.19",
"@types/node": "^22.15.29",
"@types/node": "^22.18.1",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.1.1",
@@ -27,7 +27,7 @@
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
@@ -35,10 +35,10 @@
"eslint-plugin-svelte": "^2.35.1"
},
"dependencies": {
"@hcengineering/core": "^0.7.23",
"@hcengineering/server-core": "workspace:^0.7.18",
"@hcengineering/text": "^0.7.18",
"@hcengineering/text-ydoc": "^0.7.18",
"@hcengineering/core": "^0.7.26",
"@hcengineering/server-core": "workspace:^0.7.19",
"@hcengineering/text": "^0.7.19",
"@hcengineering/text-ydoc": "^0.7.19",
"base64-js": "^1.5.1",
"yjs": "^13.6.27"
},
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "@hcengineering/server-core",
"version": "0.7.18",
"version": "0.7.19",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
@@ -19,7 +19,7 @@
},
"devDependencies": {
"@hcengineering/platform-rig": "^0.7.19",
"@types/node": "^22.15.29",
"@types/node": "^22.18.1",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.1.1",
@@ -27,7 +27,7 @@
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
@@ -36,13 +36,13 @@
"eslint-plugin-svelte": "^2.35.1"
},
"dependencies": {
"@hcengineering/analytics": "^0.7.17",
"@hcengineering/core": "^0.7.23",
"@hcengineering/platform": "^0.7.19",
"@hcengineering/query": "^0.7.17",
"@hcengineering/rpc": "^0.7.17",
"@hcengineering/server-token": "^0.7.17",
"@hcengineering/storage": "^0.7.17",
"@hcengineering/analytics": "^0.7.19",
"@hcengineering/core": "^0.7.26",
"@hcengineering/platform": "^0.7.20",
"@hcengineering/query": "^0.7.18",
"@hcengineering/rpc": "^0.7.18",
"@hcengineering/server-token": "^0.7.18",
"@hcengineering/storage": "^0.7.18",
"fast-equals": "^5.2.2",
"uuid": "^8.3.2"
},
@@ -448,7 +448,7 @@ export function runSharedIntegrationTests (adapterName: string, getContext: () =
}
)
expect(r.length).toEqual(1)
expect(r[0].$associations?.[association._id][0]?._id).toEqual(secondTask)
expect(r[0].$associations?.[`${association._id}_b`][0]?._id).toEqual(secondTask)
})
})
+1
View File
@@ -349,6 +349,7 @@ export interface IndexedDoc {
searchShortTitle_fields?: any[]
searchIcon_fields?: any[]
fulltextSummary?: string
baseId?: Ref<Doc>
[key: string]: any
}
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@hcengineering/datalake",
"version": "0.7.17",
"version": "0.7.18",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
@@ -26,20 +26,20 @@
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"@types/node": "^22.15.29",
"@types/node": "^22.18.1",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"ts-node": "^10.8.0",
"ts-node": "^10.9.2",
"eslint-plugin-svelte": "^2.35.1"
},
"dependencies": {
"@hcengineering/core": "^0.7.23",
"@hcengineering/platform": "^0.7.19",
"@hcengineering/server-core": "workspace:^0.7.18",
"@hcengineering/server-token": "^0.7.17"
"@hcengineering/core": "^0.7.26",
"@hcengineering/platform": "^0.7.20",
"@hcengineering/server-core": "workspace:^0.7.19",
"@hcengineering/server-token": "^0.7.18"
},
"repository": "https://github.com/hcengineering/huly.server",
"publishConfig": {
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@hcengineering/elastic",
"version": "0.7.17",
"version": "0.7.18",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
@@ -44,10 +44,10 @@
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-n": "^15.4.0",
"eslint": "^8.54.0",
"ts-node": "^10.8.0",
"ts-node": "^10.9.2",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
@@ -55,11 +55,11 @@
"eslint-plugin-svelte": "^2.35.1"
},
"dependencies": {
"@hcengineering/core": "^0.7.23",
"@hcengineering/platform": "^0.7.19",
"@hcengineering/server-core": "workspace:^0.7.18",
"@hcengineering/core": "^0.7.26",
"@hcengineering/platform": "^0.7.20",
"@hcengineering/server-core": "workspace:^0.7.19",
"@elastic/elasticsearch": "^7.17.14",
"@hcengineering/analytics": "^0.7.17"
"@hcengineering/analytics": "^0.7.19"
},
"repository": "https://github.com/hcengineering/huly.server",
"publishConfig": {
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@hcengineering/hulylake",
"version": "0.7.17",
"version": "0.7.18",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
@@ -26,21 +26,21 @@
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"@types/node": "^22.15.29",
"@types/node": "^22.18.1",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"ts-node": "^10.8.0",
"ts-node": "^10.9.2",
"eslint-plugin-svelte": "^2.35.1"
},
"dependencies": {
"@hcengineering/core": "^0.7.23",
"@hcengineering/platform": "^0.7.19",
"@hcengineering/server-core": "workspace:^0.7.18",
"@hcengineering/server-token": "^0.7.17",
"@hcengineering/hulylake-client": "^0.7.17"
"@hcengineering/core": "^0.7.26",
"@hcengineering/platform": "^0.7.20",
"@hcengineering/server-core": "workspace:^0.7.19",
"@hcengineering/server-token": "^0.7.18",
"@hcengineering/hulylake-client": "^0.7.18"
},
"repository": "https://github.com/hcengineering/huly.server",
"publishConfig": {
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@hcengineering/kafka",
"version": "0.7.19",
"version": "0.7.20",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
@@ -26,19 +26,19 @@
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"@types/node": "^22.15.29",
"@types/node": "^22.18.1",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"eslint-plugin-svelte": "^2.35.1"
},
"dependencies": {
"@hcengineering/core": "^0.7.23",
"@hcengineering/platform": "^0.7.19",
"@hcengineering/server-core": "workspace:^0.7.18",
"@hcengineering/storage": "^0.7.17",
"@hcengineering/core": "^0.7.26",
"@hcengineering/platform": "^0.7.20",
"@hcengineering/server-core": "workspace:^0.7.19",
"@hcengineering/storage": "^0.7.18",
"kafkajs": "^2.2.4"
},
"repository": "https://github.com/hcengineering/huly.server",
+9 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@hcengineering/middleware",
"version": "0.7.22",
"version": "0.7.23",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
@@ -26,20 +26,21 @@
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"@types/node": "^22.15.29",
"@types/node": "^22.18.1",
"eslint-plugin-svelte": "^2.35.1"
},
"dependencies": {
"@hcengineering/core": "^0.7.23",
"@hcengineering/platform": "^0.7.19",
"@hcengineering/server-core": "workspace:^0.7.18",
"@hcengineering/query": "^0.7.17",
"@hcengineering/analytics": "^0.7.17",
"@hcengineering/core": "^0.7.26",
"@hcengineering/contact": "^0.7.0",
"@hcengineering/platform": "^0.7.20",
"@hcengineering/server-core": "workspace:^0.7.19",
"@hcengineering/query": "^0.7.18",
"@hcengineering/analytics": "^0.7.19",
"fast-equals": "^5.2.2"
},
"repository": "https://github.com/hcengineering/huly.server",
+13 -1
View File
@@ -63,6 +63,14 @@ export class ApplyTxMiddleware extends BaseMiddleware implements Middleware {
}
applyResult.serverTime = Date.now() - st
} else {
ctx.warn('TxApplyIf failed', {
scope: applyIf.scope,
reason: passed.reason,
measureName: applyIf.measureName,
matchCount: applyIf.match?.length ?? 0,
notMatchCount: applyIf.notMatch?.length ?? 0,
txCount: applyIf.txes.length
})
result.push({
success: false
})
@@ -92,6 +100,7 @@ export class ApplyTxMiddleware extends BaseMiddleware implements Middleware {
): Promise<{
onEnd: () => void
passed: boolean
reason?: string
}> {
if (applyIf.scope == null) {
return { passed: true, onEnd: () => {} }
@@ -115,11 +124,13 @@ export class ApplyTxMiddleware extends BaseMiddleware implements Middleware {
})
)
let passed = true
let reason: string | undefined
if (applyIf.match != null) {
for (const { _class, query } of applyIf.match) {
const res = await this.provideFindAll(ctx, _class, query, { limit: 1 })
if (res.length === 0) {
passed = false
reason = `match query failed: class=${_class}, query=${JSON.stringify(query)}`
break
}
}
@@ -129,10 +140,11 @@ export class ApplyTxMiddleware extends BaseMiddleware implements Middleware {
const res = await this.provideFindAll(ctx, _class, query, { limit: 1 })
if (res.length > 0) {
passed = false
reason = `notMatch query failed: class=${_class}, query=${JSON.stringify(query)} (found ${res.length} matching document(s))`
break
}
}
}
return { passed, onEnd }
return { passed, onEnd, reason }
}
}
+20 -12
View File
@@ -5,6 +5,7 @@ import {
type TxMiddlewareResult
} from '@hcengineering/server-core'
import core, {
type Account,
AccountRole,
type Doc,
hasAccountRole,
@@ -19,6 +20,7 @@ import core, {
type TxUpdateDoc
} from '@hcengineering/core'
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
import contact, { type Person } from '@hcengineering/contact'
export class GuestPermissionsMiddleware extends BaseMiddleware implements Middleware {
static async create (
@@ -40,44 +42,44 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle
}
for (const tx of txes) {
this.processTx(ctx, tx)
await this.processTx(ctx, tx)
}
return await this.provideTx(ctx, txes)
}
private processTx (ctx: MeasureContext<SessionData>, tx: Tx): void {
private async processTx (ctx: MeasureContext<SessionData>, tx: Tx): Promise<void> {
const h = this.context.hierarchy
if (tx._class === core.class.TxApplyIf) {
const applyTx = tx as TxApplyIf
for (const t of applyTx.txes) {
this.processTx(ctx, t)
await this.processTx(ctx, t)
}
return
}
if (TxProcessor.isExtendsCUD(tx._class)) {
const socialIds = ctx.contextData.account.socialIds
const { account } = ctx.contextData
const cudTx = tx as TxCUD<Doc>
const isSpace = h.isDerived(cudTx.objectClass, core.class.Space)
if (isSpace) {
if (this.isForbiddenSpaceTx(cudTx as TxCUD<Space>, socialIds)) {
if (await this.isForbiddenSpaceTx(ctx, cudTx as TxCUD<Space>, account)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
} else if (cudTx.space !== core.space.DerivedTx && this.isForbiddenTx(cudTx, socialIds)) {
} else if (cudTx.space !== core.space.DerivedTx && (await this.isForbiddenTx(ctx, cudTx, account))) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
}
}
private isForbiddenTx (tx: TxCUD<Doc>, socialIds: PersonId[]): boolean {
private async isForbiddenTx (ctx: MeasureContext, tx: TxCUD<Doc>, account: Account): Promise<boolean> {
if (tx._class === core.class.TxMixin) return false
return !this.hasMixinAccessLevel(tx, socialIds)
return !(await this.hasMixinAccessLevel(ctx, tx, account))
}
private isForbiddenSpaceTx (tx: TxCUD<Space>, socialIds: PersonId[]): boolean {
private async isForbiddenSpaceTx (ctx: MeasureContext, tx: TxCUD<Space>, account: Account): Promise<boolean> {
if (tx._class === core.class.TxRemoveDoc) return true
if (tx._class === core.class.TxCreateDoc) {
return !this.hasMixinAccessLevel(tx, socialIds)
return !(await this.hasMixinAccessLevel(ctx, tx, account))
}
if (tx._class === core.class.TxUpdateDoc) {
const updateTx = tx as TxUpdateDoc<Space>
@@ -93,7 +95,7 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle
return false
}
private hasMixinAccessLevel (tx: TxCUD<Doc>, socialIds: PersonId[]): boolean {
private async hasMixinAccessLevel (ctx: MeasureContext, tx: TxCUD<Doc>, account: Account): Promise<boolean> {
const h = this.context.hierarchy
const accessLevelMixin = h.classHierarchyMixin(tx.objectClass, core.mixin.TxAccessLevel)
if (accessLevelMixin === undefined) return false
@@ -104,9 +106,15 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle
return accessLevelMixin.removeAccessLevel === AccountRole.Guest
}
if (tx._class === core.class.TxUpdateDoc) {
if (accessLevelMixin.isIdentity === true && socialIds.includes(tx.objectId as unknown as PersonId)) {
if (accessLevelMixin.isIdentity === true && account.socialIds.includes(tx.objectId as unknown as PersonId)) {
return true
}
if (accessLevelMixin.isIdentity === true && h.isDerived(tx.objectClass, contact.class.Person)) {
const person = (await this.findAll(ctx, tx.objectClass, { _id: tx.objectId }, { limit: 1 }))[0] as
| Person
| undefined
return person?.personUuid === account.uuid
}
return accessLevelMixin.updateAccessLevel === AccountRole.Guest
}
return false
+1
View File
@@ -43,3 +43,4 @@ export * from './pluginConfig'
export * from './userStatus'
export * from './findSecurity'
export * from './normalizeTx'
export * from './versioning'
+101 -11
View File
@@ -37,7 +37,7 @@ import core, {
} from '@hcengineering/core'
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
import { type Middleware, type TxMiddlewareResult, type PipelineContext } from '@hcengineering/server-core'
import contact from '@hcengineering/contact'
import { BaseMiddleware } from '@hcengineering/server-core'
/**
@@ -45,6 +45,7 @@ import { BaseMiddleware } from '@hcengineering/server-core'
*/
export class SpacePermissionsMiddleware extends BaseMiddleware implements Middleware {
private whitelistSpaces = new Set<Ref<Space>>()
private readonly restrictedSpaces = new Set<Ref<Space>>()
private assignmentBySpace: Record<Ref<Space>, RolesAssignment> = {}
private permissionsBySpace: Record<Ref<Space>, Record<AccountUuid, Set<Permission>>> = {}
private typeBySpace: Record<Ref<Space>, Ref<SpaceType>> = {}
@@ -121,6 +122,12 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
return
}
if (space.restricted === true) {
this.restrictedSpaces.add(space._id)
} else {
this.restrictedSpaces.delete(space._id)
}
this.typeBySpace[space._id] = space.type
const asMixin: RolesAssignment = this.context.hierarchy.as(
@@ -157,15 +164,21 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
*
* Checks if the required permission is present in the space for the given context
*/
private checkPermission (ctx: MeasureContext<SessionData>, space: Ref<TypedSpace>, tx: TxCUD<Doc>): boolean {
private checkPermission (
ctx: MeasureContext<SessionData>,
space: Ref<TypedSpace>,
tx: TxCUD<Doc>,
isSpace: boolean
): boolean {
const account = ctx.contextData.account
if (account.primarySocialId === core.account.System) return true
const permissions = this.permissionsBySpace[space]?.[account.uuid] ?? []
let withoutMatch: Permission | undefined
for (const permission of permissions) {
if (permission.txClass === undefined || permission.txClass !== tx._class) continue
if (!isTxClassMatched(tx, permission)) continue
if (
permission.objectClass !== undefined &&
!this.context.hierarchy.isDerived(tx.objectClass, permission.objectClass)
!this.context.hierarchy.isDerived(getTxObjectClass(tx), permission.objectClass)
) {
continue
}
@@ -185,6 +198,36 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
return withoutMatch.forbid !== undefined ? !withoutMatch.forbid : true
}
if (isSpace || !this.restrictedSpaces.has(space)) {
return true
}
const attachedDocAncestors = this.context.hierarchy.getAncestors(core.class.AttachedDoc)
const ancestors = this.context.hierarchy.getAncestors(getTxObjectClass(tx))
const targetAncestors = ancestors.filter((a) => !attachedDocAncestors.includes(a))
const allPermissions = this.context.modelDb.findAllSync(core.class.Permission, {
objectClass: { $in: targetAncestors }
})
for (const permission of allPermissions) {
if (!isTxClassMatched(tx, permission)) continue
if (
permission.objectClass !== undefined &&
!this.context.hierarchy.isDerived(getTxObjectClass(tx), permission.objectClass)
) {
continue
}
if (permission.txMatch === undefined) {
return false
} else {
const checkMatch = matchQuery([tx], permission.txMatch, tx._class, this.context.hierarchy, true)
if (checkMatch.length === 0) {
continue
}
return false
}
}
return true
}
@@ -273,6 +316,7 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
delete this.typeBySpace[tx.objectId]
this.whitelistSpaces.delete(tx.objectId)
this.restrictedSpaces.delete(tx.objectId)
}
private isSpaceTxCUD (tx: TxCUD<Doc>): tx is TxCUD<Space> {
@@ -337,9 +381,8 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
if (this.isSpaceTxCUD(tx)) {
if (tx._class === core.class.TxCreateDoc) {
this.handleCreate(tx)
// } else if (tx._class === core.class.TxUpdateDoc) {
// Roles assignment in spaces are managed through the space type mixin
// so nothing to handle here
} else if (tx._class === core.class.TxUpdateDoc) {
this.handleSpaceUpdate(tx)
} else if (tx._class === core.class.TxMixin) {
this.handleMixin(tx)
} else if (tx._class === core.class.TxRemoveDoc) {
@@ -350,6 +393,21 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
this.handlePermissionsUpdatesFromRoleTx(ctx, tx)
}
private handleSpaceUpdate (tx: TxCUD<Space>): void {
if (!this.isTypedSpaceClass(tx.objectClass)) {
return
}
const updateTx = tx as TxUpdateDoc<TypedSpace>
if (updateTx.operations.restricted !== undefined) {
if (updateTx.operations.restricted) {
this.restrictedSpaces.add(tx.objectId)
} else {
this.restrictedSpaces.delete(tx.objectId)
}
}
}
private processPermissionsUpdatesFromTx (ctx: MeasureContext, tx: Tx): void {
if (!TxProcessor.isExtendsCUD(tx._class)) {
return
@@ -362,8 +420,8 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
async tx (ctx: MeasureContext<SessionData>, txes: Tx[]): Promise<TxMiddlewareResult> {
await this.init(ctx)
for (const tx of txes) {
this.processPermissionsUpdatesFromTx(ctx, tx)
this.checkPermissions(ctx, tx)
this.processPermissionsUpdatesFromTx(ctx, tx)
}
const res = await this.provideTx(ctx, txes)
for (const txd of ctx.contextData.broadcast.txes) {
@@ -388,18 +446,50 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
this.checkSpacePermissions(ctx, cudTx, cudTx.objectSpace)
if (isSpace) {
this.checkSpacePermissions(ctx, cudTx, cudTx.objectId as Ref<Space>)
this.checkSpaceTypePermissions(ctx, cudTx as TxCUD<Space>)
this.checkSpacePermissions(ctx, cudTx, cudTx.objectId as Ref<Space>, true)
}
}
private checkSpacePermissions (ctx: MeasureContext, cudTx: TxCUD<Doc>, targetSpaceId: Ref<Space>): void {
private checkSpaceTypePermissions (ctx: MeasureContext, cudTx: TxCUD<Space>): void {
const account = ctx.contextData.account
const h = this.context.hierarchy
if (account.primarySocialId === core.account.System) return
if (h.isDerived(cudTx.objectClass, contact.class.PersonSpace)) {
this.throwForbidden()
}
}
private checkSpacePermissions (
ctx: MeasureContext,
cudTx: TxCUD<Doc>,
targetSpaceId: Ref<Space>,
isSpace: boolean = false
): void {
if (this.whitelistSpaces.has(targetSpaceId)) {
return
}
// NOTE: move this checking logic later to be defined in some server plugins?
// so they can contribute checks into the middleware for their custom permissions?
if (!this.checkPermission(ctx, targetSpaceId as Ref<TypedSpace>, cudTx)) {
if (!this.checkPermission(ctx, targetSpaceId as Ref<TypedSpace>, cudTx, isSpace)) {
this.throwForbidden()
}
}
}
function isMixinUpdateTx (tx: Tx): boolean {
return tx._class === core.class.TxMixin && Object.keys((tx as TxMixin<Doc, Doc>).attributes).length > 0
}
function isTxClassMatched (tx: Tx, permission: Permission): boolean {
if (permission.txClass === tx._class) return true
if (permission.txMatch === undefined && isMixinUpdateTx(tx)) {
return permission.txClass === core.class.TxUpdateDoc
}
return false
}
function getTxObjectClass (tx: TxCUD<Doc>): Ref<Class<Doc>> {
return tx._class === core.class.TxMixin ? (tx as TxMixin<Doc, Doc>).mixin : tx.objectClass
}
+59 -10
View File
@@ -484,10 +484,7 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar
const space = this.spacesMap.get(tx.objectSpace)
if (space === undefined) return undefined
// For all other spaces broadcast to space members + guests that are collaborators for objects with collab security enabled
let collabTargets: AccountUuid[] = []
const collabSec = getClassCollaborators(this.context.modelDb, this.context.hierarchy, cud.objectClass)
if (collabSec?.provideSecurity === true) {
const getCollabTargets = async (_id: Ref<Doc>): Promise<AccountUuid[]> => {
const guests = new Set<AccountUuid>()
for (const val of ctx.contextData.socialStringsToUsers.values()) {
if ([AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(val.role)) {
@@ -495,11 +492,30 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar
}
}
const collaboratorObjs = (await this.next?.findAll(ctx, core.class.Collaborator, {
attachedTo: cud.objectId
attachedTo: _id
})) as Collaborator[]
collabTargets = collaboratorObjs.map((it) => it.collaborator).filter((it) => guests.has(it))
return collaboratorObjs.map((it) => it.collaborator).filter((it) => guests.has(it))
}
// For all other spaces broadcast to space members
// + guests that are collaborators for objects with collab security enabled
// + guests that are collaborators for attached objects with collab security enabled
let collabTargets: AccountUuid[] = []
const collabSec = getClassCollaborators(this.context.modelDb, this.context.hierarchy, cud.objectClass)
if (collabSec?.provideSecurity === true) {
collabTargets = await getCollabTargets(cud.objectId)
} else if (cud.attachedTo != null && cud.attachedToClass != null) {
const attachedCollabSec = getClassCollaborators(
this.context.modelDb,
this.context.hierarchy,
cud.attachedToClass
)
if (attachedCollabSec?.provideSecurity === true) {
collabTargets = await getCollabTargets(cud.attachedTo)
}
}
const spaceTargets = space.members.length === 0 ? [] : this.getTargets(space?.members)
const target = [...collabTargets, ...spaceTargets]
@@ -672,7 +688,7 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar
findResult.lookupMap
)
}
if (!isOwner(account, ctx) && account.role !== AccountRole.DocGuest) {
if (account.role !== AccountRole.DocGuest) {
if (options?.lookup !== undefined) {
for (const object of findResult) {
if (object.$lookup !== undefined) {
@@ -721,20 +737,53 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar
if (Object.keys(lookup).length === 0) return
const account = ctx.contextData.account
if (isSystem(account, ctx)) return
const owner = isOwner(account, ctx)
const h = this.context.hierarchy
const allowedSpaces = new Set(this.getAllAllowedSpaces(account, true, showArchived))
for (const key in lookup) {
const val = lookup[key]
if (Array.isArray(val)) {
const arr: AttachedDoc[] = []
for (const value of val) {
if (allowedSpaces.has(value.space)) {
const isSpace = '_class' in value && h.isDerived(value._class, core.class.Space)
const availableForOwner = owner && isSpace
const availableSpace = isSpace && allowedSpaces.has(value._id)
const availableDoc = !isSpace && allowedSpaces.has(value.space)
if (availableForOwner || availableSpace || availableDoc) {
arr.push(value)
}
}
lookup[key] = arr as any
} else if (val !== undefined) {
if (!allowedSpaces.has(val.space)) {
lookup[key] = undefined
const isSpace = '_class' in val && h.isDerived(val._class, core.class.Space)
const availableForOwner = owner && isSpace
const availableSpace = isSpace && allowedSpaces.has(val._id as Ref<Space>)
const availableDoc = !isSpace && allowedSpaces.has(val.space)
if (!availableForOwner && !availableSpace && !availableDoc) {
// allow attached lookups for guests when collaborator security is enabled
// do not check if collaborator of the doc because it's being checked on the storage (DB) level
// as otherwise there will be no doc here at all
if (key === 'attachedTo' && ctx.contextData.modelDb?.hierarchy != null) {
const attachedVal = val as AttachedDoc
if (attachedVal.attachedToClass == null) {
lookup[key] = undefined
continue
}
const collabSec = getClassCollaborators(
ctx.contextData.modelDb,
ctx.contextData.modelDb.hierarchy,
attachedVal.attachedToClass
)
const collabSecEnabled =
collabSec?.provideSecurity === true &&
[AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(account.role)
if (!collabSecEnabled) {
lookup[key] = undefined
}
} else {
lookup[key] = undefined
}
}
}
}
+154
View File
@@ -0,0 +1,154 @@
//
// 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 core, {
type Class,
clone,
type Doc,
type DocumentQuery,
type FindResult,
type MeasureContext,
type Ref,
type SessionData,
SortingOrder,
type Tx,
type TxApplyIf,
type TxCreateDoc,
TxFactory,
TxProcessor,
type VersionableDoc
} from '@hcengineering/core'
import {
BaseMiddleware,
type ServerFindOptions,
type Middleware,
type PipelineContext,
type TxMiddlewareResult
} from '@hcengineering/server-core'
/**
* @public
*/
export class VersioningMiddleware extends BaseMiddleware implements Middleware {
private constructor (context: PipelineContext, next?: Middleware) {
super(context, next)
}
static async create (
ctx: MeasureContext,
context: PipelineContext,
next: Middleware | undefined
): Promise<VersioningMiddleware> {
return new VersioningMiddleware(context, next)
}
override async findAll<T extends Doc>(
ctx: MeasureContext<SessionData>,
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: ServerFindOptions<T>
): Promise<FindResult<T>> {
if (
this.isVerionableClass(_class) &&
query.isLatest === undefined &&
query._id === undefined &&
query.baseId === undefined
) {
const newQuery = clone(query)
newQuery.isLatest = true
const findResult = await this.provideFindAll(ctx, _class, newQuery, options)
return findResult
} else {
return await this.provideFindAll(ctx, _class, query, options)
}
}
async tx (ctx: MeasureContext<SessionData>, txes: Tx[]): Promise<TxMiddlewareResult> {
let nestedTxes: Tx[] = []
for (const tx of txes) {
if (tx._class === core.class.TxCreateDoc) {
const childTxes = await this.setVersionData(ctx, tx as TxCreateDoc<VersionableDoc>)
if (childTxes !== undefined && childTxes.length > 0) {
nestedTxes = nestedTxes.concat(childTxes)
}
}
if (tx._class === core.class.TxApplyIf) {
for (const _tx of (tx as TxApplyIf).txes) {
if (_tx._class === core.class.TxCreateDoc) {
const childTxes = await this.setVersionData(ctx, _tx as TxCreateDoc<VersionableDoc>)
if (childTxes !== undefined && childTxes.length > 0) {
nestedTxes = nestedTxes.concat(childTxes)
}
}
}
}
}
const res = await this.provideTx(ctx, txes)
if (nestedTxes.length > 0) {
await this.provideTx(ctx, nestedTxes)
}
return res
}
private async setVersionData (
ctx: MeasureContext<SessionData>,
tx: TxCreateDoc<VersionableDoc>
): Promise<Tx[] | undefined> {
const isVersionedClass = this.isVerionableClass(tx.objectClass)
if (!isVersionedClass) return []
const doc = TxProcessor.createDoc2Doc(tx)
const isNew = doc.baseId === doc._id || doc.baseId === undefined
tx.attributes.isLatest = true
if (isNew) {
tx.attributes.version = 1
tx.attributes.baseId = tx.objectId
tx.attributes.docCreatedBy = tx.createdBy ?? tx.modifiedBy
} else {
const base = await this.provideFindAll(
ctx,
tx.objectClass,
{ baseId: doc.baseId },
{ sort: { version: SortingOrder.Descending } }
)
const latest = base.find((p) => p.isLatest === true) ?? base[0]
if (latest === undefined) throw new Error('No base object found for the new version')
tx.attributes.version = (latest.version ?? 1) + 1
tx.attributes.docCreatedBy = latest.docCreatedBy
const txes: Tx[] = []
const factory = new TxFactory(core.account.System, true)
for (const prev of base) {
if (prev.isLatest === true) {
txes.push(
factory.createTxUpdateDoc(prev._class, prev.space, prev._id, {
isLatest: false,
readonly: true
})
)
}
}
return txes
}
}
private isVerionableClass (_class: Ref<Class<Doc>>): boolean {
try {
return this.context.hierarchy.classHierarchyMixin(_class, core.mixin.VersionableClass) !== undefined
} catch {
return false
}
}
}
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@hcengineering/minio",
"version": "0.7.17",
"version": "0.7.18",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
@@ -26,18 +26,18 @@
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"@types/node": "^22.15.29",
"@types/node": "^22.18.1",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"eslint-plugin-svelte": "^2.35.1"
},
"dependencies": {
"@hcengineering/core": "^0.7.23",
"@hcengineering/platform": "^0.7.19",
"@hcengineering/server-core": "workspace:^0.7.18",
"@hcengineering/core": "^0.7.26",
"@hcengineering/platform": "^0.7.20",
"@hcengineering/server-core": "workspace:^0.7.19",
"minio": "^8.0.5"
},
"repository": "https://github.com/hcengineering/huly.server",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@hcengineering/mongo",
"version": "0.7.17",
"version": "0.7.18",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
@@ -26,18 +26,18 @@
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"@types/node": "^22.15.29",
"@types/node": "^22.18.1",
"eslint-plugin-svelte": "^2.35.1"
},
"dependencies": {
"@hcengineering/core": "^0.7.23",
"@hcengineering/platform": "^0.7.19",
"@hcengineering/server-core": "workspace:^0.7.18",
"@hcengineering/core": "^0.7.26",
"@hcengineering/platform": "^0.7.20",
"@hcengineering/server-core": "workspace:^0.7.19",
"mongodb": "^6.16.0",
"bson": "^6.10.3"
},
+72 -1
View File
@@ -323,7 +323,78 @@ describe('mongo operations', () => {
}
)
expect(r.length).toEqual(1)
expect((r[0].$associations?.[association._id][0] as unknown as Task)?._id).toEqual(secondTask)
expect((r[0].$associations?.[association._id + '_b'][0] as unknown as Task)?._id).toEqual(secondTask)
})
it('check deep associations', async () => {
const association = await operations.findOne(core.class.Association, {})
if (association == null) {
throw new Error('Association not found')
}
const zeroTask = await operations.createDoc(taskPlugin.class.Task, '' as Ref<Space>, {
name: 'my-task',
description: 'Descr',
rate: 20
})
const firstTask = await operations.createDoc(taskPlugin.class.Task, '' as Ref<Space>, {
name: 'my-task',
description: 'Descr',
rate: 20
})
const secondTask = await operations.createDoc(taskPlugin.class.Task, '' as Ref<Space>, {
name: 'my-task2',
description: 'Descr',
rate: 20
})
const secondATask = await operations.createDoc(taskPlugin.class.Task, '' as Ref<Space>, {
name: 'my-task22',
description: 'Descr',
rate: 20
})
const thirdTask = await operations.createDoc(taskPlugin.class.Task, '' as Ref<Space>, {
name: 'my-task3',
description: 'Descr',
rate: 20
})
await operations.createDoc(core.class.Relation, '' as Ref<Space>, {
docA: firstTask,
docB: secondTask,
association: association._id
})
await operations.createDoc(core.class.Relation, '' as Ref<Space>, {
docA: firstTask,
docB: secondATask,
association: association._id
})
await operations.createDoc(core.class.Relation, '' as Ref<Space>, {
docA: secondTask,
docB: thirdTask,
association: association._id
})
const r = await client.findAll(
taskPlugin.class.Task,
{ _id: { $in: [zeroTask, firstTask] } },
{
associations: [[association._id, 1, [[association._id, 1]]]]
}
)
expect(r.length).toEqual(2)
expect(r[1].$associations?.[`${association._id}_b`]).toHaveLength(2)
expect((r[1].$associations?.[`${association._id}_b`][0] as unknown as Task)?._id).toEqual(secondTask)
expect(r[1].$associations?.[`${association._id}_b`][1]?.$associations?.[`${association._id}_b`]).toHaveLength(0)
expect(
(r[1].$associations?.[`${association._id}_b`][0]?.$associations?.[`${association._id}_b`][0] as unknown as Task)
?._id
).toEqual(thirdTask)
})
// Run shared integration tests
+48 -16
View File
@@ -467,19 +467,21 @@ abstract class MongoAdapterBase implements DbAdapter {
return result
}
private getAssociations (associations: AssociationQuery[]): LookupStep[] {
private getAssociations (associations: AssociationQuery[], parentId: string = ''): LookupStep[] {
const res: LookupStep[] = []
for (const association of associations) {
const assoc = this.modelDb.findObject(association[0])
const _id = association[0]
const assoc = this.modelDb.findObject(_id)
if (assoc === undefined) continue
const isReverse = association[1] === -1
const _class = !isReverse ? assoc.classB : assoc.classA
const fullId = _id + (isReverse ? '_a' : '_b')
const targetDomain = this.hierarchy.getDomain(_class)
if (targetDomain === DOMAIN_MODEL) continue
const as = association[0] + '_hidden_association'
const as = parentId + fullId + '_hidden_association'
res.push({
from: DOMAIN_RELATION,
localField: '_id',
localField: `${parentId !== '' ? parentId + '.' : ''}_id`,
foreignField: isReverse ? 'docB' : 'docA',
as
})
@@ -487,8 +489,11 @@ abstract class MongoAdapterBase implements DbAdapter {
from: targetDomain,
localField: as + '.' + (isReverse ? 'docA' : 'docB'),
foreignField: '_id',
as: association[0] + '_association'
as: parentId + fullId + '_association'
})
if (association[2] !== undefined) {
res.push(...this.getAssociations(association[2], parentId + fullId + '_association'))
}
}
return res
}
@@ -518,19 +523,36 @@ abstract class MongoAdapterBase implements DbAdapter {
}
}
private fillAssociationsValue (associations: AssociationQuery[], object: any): Record<string, Doc[]> {
private fillAssociationsValue (
source: any,
associations: AssociationQuery[],
parentKey: string,
targetId: Ref<Doc>
): Record<string, Doc[]> {
const res: Record<string, Doc[]> = {}
for (const association of associations) {
const assocKey = association[0] + '_hidden_association'
const data = object[assocKey]
const _id = association[0]
const isReverse = association[1] === -1
// const key = _id + (isReverse ? '.a' : '.b')
const fullId = _id + (isReverse ? '_a' : '_b')
const assocKey = parentKey + fullId + '_hidden_association'
const data = source[assocKey]
if (data !== undefined && Array.isArray(data)) {
const filtered = new Set(
data.filter((it) => it.association === association[0]).map((it) => (association[1] === 1 ? it.docB : it.docA))
data
.filter((it) => it.association === _id && (isReverse ? it.docB : it.docA) === targetId)
.map((it) => (!isReverse ? it.docB : it.docA))
)
const fullKey = association[0] + '_association'
const arr = object[fullKey]
const fullKey = parentKey + fullId + '_association'
const arr = source[fullKey] as WithLookup<Doc>[]
if (arr !== undefined && Array.isArray(arr)) {
res[association[0]] = arr.filter((it) => filtered.has(it._id))
const objects = arr.filter((it) => filtered.has(it._id))
if (association[2] !== undefined) {
for (const obj of objects) {
this.fillAssociations(obj, association[2], fullKey, source)
}
}
res[fullId] = objects
}
}
}
@@ -718,10 +740,7 @@ abstract class MongoAdapterBase implements DbAdapter {
}
}
if (options.associations !== undefined && options.associations.length > 0) {
row.$associations = this.fillAssociationsValue(options.associations, row)
for (const [, v] of Object.entries(row.$associations)) {
this.stripHash(v)
}
this.fillAssociations(row, options.associations)
}
this.clearExtraLookups(row)
}
@@ -756,6 +775,19 @@ abstract class MongoAdapterBase implements DbAdapter {
return toFindResult(this.stripHash(result) as T[], total)
}
private fillAssociations<T extends Doc>(
obj: WithLookup<T>,
associations: AssociationQuery[],
parentId: string = '',
source?: Doc
): WithLookup<T> {
obj.$associations = this.fillAssociationsValue(source ?? obj, associations, parentId, obj._id)
for (const [, v] of Object.entries(obj.$associations)) {
this.stripHash(v)
}
return obj
}
private translateKey<T extends Doc>(
key: string,
clazz: Ref<Class<T>>,
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@hcengineering/postgres",
"version": "0.7.22",
"version": "0.7.23",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
@@ -26,20 +26,20 @@
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"@types/node": "^22.15.29",
"@types/node": "^22.18.1",
"eslint-plugin-svelte": "^2.35.1"
},
"dependencies": {
"postgres": "^3.4.7",
"@hcengineering/core": "^0.7.23",
"@hcengineering/platform": "^0.7.19",
"@hcengineering/server-core": "workspace:^0.7.18",
"@hcengineering/postgres-base": "^0.7.17"
"@hcengineering/core": "^0.7.26",
"@hcengineering/platform": "^0.7.20",
"@hcengineering/server-core": "workspace:^0.7.19",
"@hcengineering/postgres-base": "^0.7.18"
},
"repository": "https://github.com/hcengineering/huly.server",
"publishConfig": {
@@ -385,6 +385,57 @@ describe('postgres operations', () => {
}
)
expect(r.length).toEqual(1)
expect((r[0].$associations?.[association._id][0] as unknown as Task)?._id).toEqual(secondTask)
expect((r[0].$associations?.[association._id + '_b'][0] as unknown as Task)?._id).toEqual(secondTask)
})
it('check deep associations', async () => {
const association = await operations.findOne(core.class.Association, {})
if (association == null) {
throw new Error('Association not found')
}
const firstTask = await operations.createDoc(taskPlugin.class.Task, '' as Ref<Space>, {
name: 'my-task',
description: 'Descr',
rate: 20
})
const secondTask = await operations.createDoc(taskPlugin.class.Task, '' as Ref<Space>, {
name: 'my-task2',
description: 'Descr',
rate: 20
})
const thirdTask = await operations.createDoc(taskPlugin.class.Task, '' as Ref<Space>, {
name: 'my-task2',
description: 'Descr',
rate: 20
})
await operations.createDoc(core.class.Relation, '' as Ref<Space>, {
docA: firstTask,
docB: secondTask,
association: association._id
})
await operations.createDoc(core.class.Relation, '' as Ref<Space>, {
docA: secondTask,
docB: thirdTask,
association: association._id
})
const r = await client.findAll(
taskPlugin.class.Task,
{ _id: firstTask },
{
associations: [[association._id, 1, [[association._id, 1]]]]
}
)
expect(r.length).toEqual(1)
expect((r[0].$associations?.[`${association._id}_b`][0] as unknown as Task)?._id).toEqual(secondTask)
expect(
(r[0].$associations?.[`${association._id}_b`][0]?.$associations?.[`${association._id}_b`][0] as unknown as Task)
?._id
).toEqual(thirdTask)
})
})
+178 -157
View File
@@ -100,7 +100,6 @@ import {
DBCollectionHelper,
type DBDoc,
escape,
filterProjection,
inferType,
isDataField,
isOwner,
@@ -109,7 +108,8 @@ import {
parseDoc,
parseDocWithProjection,
parseUpdate,
simpleEscape
simpleEscape,
toWithLookup
} from './utils'
async function * createCursorGenerator (
client: postgres.Sql,
@@ -296,7 +296,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
async rawFindAll<T extends Doc>(_domain: Domain, query: DocumentQuery<T>, options?: FindOptions<T>): Promise<T[]> {
const domain = translateDomain(_domain)
const vars = new ValuesVariables()
const select = `SELECT ${this.getProjection(vars, domain, options?.projection, [], options?.associations)} FROM ${domain}`
const select = `SELECT ${this.getProjection(vars, domain, options?.projection, [])} FROM ${domain}`
const sqlChunks: string[] = []
sqlChunks.push(`WHERE ${this.buildRawQuery(vars, domain, query, options)}`)
if (options?.sort !== undefined) {
@@ -472,7 +472,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
const projection = this.localizeProjection(_class, options?.projection ?? undefined)
const select = `SELECT ${this.getProjection(vars, domain, projection, joins, options?.associations)} FROM ${domain}`
const select = `SELECT ${this.getProjection(vars, domain, projection, joins)} FROM ${domain}`
if (joins.length > 0) {
sqlChunks.push(this.buildJoinString(vars, joins))
@@ -554,7 +554,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
total
)
} else {
const res = this.parseLookup<T>(result, joins, projection, domain)
const res = await this.parseLookup<T>(ctx, result, joins, projection, options.associations, domain)
return toFindResult(res, total)
}
})) as FindResult<T>
@@ -638,153 +638,208 @@ abstract class PostgresAdapterBase implements DbAdapter {
const res = `EXISTS (SELECT 1 FROM ${translateDomain(DOMAIN_SPACE)} sec WHERE sec._id = ${domain}.${key} AND sec."workspaceId" = ${vars.add(this.workspaceId, '::uuid')} AND ${q})`
const collabSec = getClassCollaborators(this.modelDb, this.hierarchy, _class)
if (collabSec?.provideSecurity === true && [AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(acc.role)) {
const collab = `OR EXISTS (SELECT 1 FROM ${translateDomain(DOMAIN_COLLABORATOR)} collab_sec WHERE collab_sec."workspaceId" = ${vars.add(this.workspaceId, '::uuid')} AND collab_sec."attachedTo" = ${domain}._id AND collab_sec.collaborator = '${acc.uuid}')`
return `AND (${res} ${collab})`
let collabRes = ''
if ([AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(acc.role)) {
if (collabSec?.provideSecurity === true) {
collabRes += ` OR EXISTS (SELECT 1 FROM ${translateDomain(DOMAIN_COLLABORATOR)} collab_sec WHERE collab_sec."workspaceId" = ${vars.add(this.workspaceId, '::uuid')} AND collab_sec."attachedTo" = ${domain}._id AND collab_sec.collaborator = '${acc.uuid}')`
}
if (collabSec?.provideAttachedSecurity === true) {
collabRes += ` OR EXISTS (SELECT 1 FROM ${translateDomain(DOMAIN_COLLABORATOR)} collab_sec WHERE collab_sec."workspaceId" = ${vars.add(this.workspaceId, '::uuid')} AND collab_sec."attachedTo" = ${domain}."attachedTo" AND collab_sec.collaborator = '${acc.uuid}')`
}
}
return `AND (${res})`
return `AND (${res}${collabRes})`
}
}
}
private parseLookup<T extends Doc>(
private async parseLookup<T extends Doc>(
ctx: MeasureContext<SessionData>,
rows: any[],
joins: JoinProps[],
projection: Projection<T> | undefined,
associations: AssociationQuery[] | undefined,
domain: string
): WithLookup<T>[] {
): Promise<WithLookup<T>[]> {
const map = new Map<Ref<T>, WithLookup<T>>()
const modelJoins: JoinProps[] = []
const reverseJoins: JoinProps[] = []
const simpleJoins: JoinProps[] = []
for (const join of joins) {
if (join.table === DOMAIN_MODEL) {
modelJoins.push(join)
} else if (join.isReverse) {
reverseJoins.push(join)
} else if (join.path !== '') {
simpleJoins.push(join)
}
if (join.table === DOMAIN_MODEL) modelJoins.push(join)
else if (join.isReverse) reverseJoins.push(join)
else if (join.path !== '') simpleJoins.push(join)
}
for (const row of rows) {
/* eslint-disable @typescript-eslint/consistent-type-assertions */
let doc: WithLookup<T> = map.get(row._id) ?? ({ _id: row._id, $lookup: {}, $associations: {} } as WithLookup<T>)
const associations: Record<string, any> = doc.$associations as Record<string, any>
const lookup: Record<string, any> = doc.$lookup as Record<string, any>
let joinIndex: number | undefined
let skip = false
try {
const schema = getSchema(domain)
for (const column in row) {
if (column.startsWith('reverse_lookup_')) {
if (row[column] != null) {
const join = reverseJoins.find((j) => j.toAlias.toLowerCase() === column)
if (join === undefined) {
continue
}
const res = this.getLookupValue(join.path, lookup, false)
if (res === undefined) continue
const { obj, key } = res
const doc = toWithLookup<T>(parseDoc(row, getSchema(row._class)))
const parsed = row[column].map((p: any) => parseDoc(p, schema))
obj[key] = parsed
}
} else if (column.startsWith('lookup_')) {
const keys = column.split('_')
let key = keys[keys.length - 1]
if (keys[keys.length - 2] === '') {
key = '_' + key
}
const lookup = doc.$lookup as Record<string, any>
if (key === 'workspaceId') {
continue
}
this.parseLookupColumns(row, simpleJoins, reverseJoins, lookup, domain)
if (key === '_id') {
joinIndex = joinIndex === undefined ? 0 : ++joinIndex
if (row[column] === null) {
skip = true
continue
}
skip = false
}
if (skip) {
continue
}
const join = simpleJoins[joinIndex ?? 0]
if (join === undefined) {
continue
}
const res = this.getLookupValue(join.path, lookup)
if (res === undefined) continue
const { obj, key: p } = res
if (key === 'data') {
obj[p] = { ...obj[p], ...row[column] }
} else {
if (key === 'createdOn' || key === 'modifiedOn') {
const val = Number.parseInt(row[column])
obj[p][key] = Number.isNaN(val) ? null : val
} else if (key === '%hash%') {
continue
} else if (key === 'attachedTo' && row[column] === 'NULL') {
continue
} else {
obj[p][key] = row[column] === 'NULL' ? null : row[column]
}
}
} else if (column.startsWith('assoc_')) {
if (row[column] == null) continue
const keys = column.split('_')
const key = keys[keys.length - 1]
const associationDomain = keys[1]
const associationSchema = getSchema(associationDomain)
const parsed = row[column].map((p: any) => parseDoc(p, associationSchema))
associations[key] = parsed
} else {
joinIndex = undefined
if (!map.has(row._id)) {
if (column === 'workspaceId') {
continue
}
if (column === 'data') {
let data = row[column]
data = filterProjection(data, projection)
doc = { ...doc, ...data }
} else {
if (column === 'createdOn' || column === 'modifiedOn') {
const val = Number.parseInt(row[column])
;(doc as any)[column] = Number.isNaN(val) ? null : val
} else if (column === '%hash%') {
// Ignore
} else {
;(doc as any)[column] = row[column] === 'NULL' ? null : row[column]
}
}
}
}
}
} catch (err) {
console.log(err)
throw err
}
for (const modelJoin of modelJoins) {
const res = this.getLookupValue(modelJoin.path, lookup)
if (res === undefined) continue
const { obj, key } = res
const val = this.getModelLookupValue<T>(doc, modelJoin, [...simpleJoins, ...modelJoins])
if (val !== undefined && modelJoin.toClass !== undefined) {
const res = this.modelDb.findAllSync(modelJoin.toClass, {
const res2 = this.modelDb.findAllSync(modelJoin.toClass, {
[modelJoin.toField]: val
})
obj[key] = modelJoin.isReverse ? res : res[0]
obj[key] = modelJoin.isReverse ? res2 : res2[0]
}
}
map.set(row._id, doc)
}
return Array.from(map.values())
if (associations !== undefined && map.size > 0) {
await this.fetchAssociations(ctx, map, associations)
}
return [...map.values()]
}
async fetchAssociations (
ctx: MeasureContext<SessionData>,
parentMap: Map<string, WithLookup<Doc>>,
associations: AssociationQuery[]
): Promise<void> {
for (const association of associations) {
const [assocId, dir, nested] = association
const isReverse = dir === -1
const keyA = isReverse ? 'docB' : 'docA'
const keyB = isReverse ? 'docA' : 'docB'
const assoc = this.modelDb.findObject(assocId)
if (assoc === undefined) {
continue
}
const _class = isReverse ? assoc.classA : assoc.classB
const domain = this.hierarchy.findDomain(_class)
if (domain === undefined) continue
const tagetDomain = translateDomain(domain)
const vars = new ValuesVariables()
const wsId = vars.add(this.workspaceId, '::uuid')
const parentIds = vars.add(Array.from(parentMap.keys()), '::text[]')
const assocIdVar = vars.add(assocId)
const rows = await this.mgr.retry(ctx.id, this.mgrId, async (connection) => {
return await connection.execute(
`
SELECT assoc.*, r."${keyA}" as parent_id
FROM ${tagetDomain} AS assoc
JOIN ${translateDomain(DOMAIN_RELATION)} AS r
ON r."${keyB}" = assoc."_id"
WHERE r."${keyA}" = ANY(${parentIds})
AND r.association = ${assocIdVar}
AND r."workspaceId" = ${wsId}
AND assoc."workspaceId" = ${wsId}
`,
vars.getValues()
)
})
const key = `${assocId}_${!isReverse ? 'b' : 'a'}`
const nextParentMap = new Map<string, WithLookup<Doc>>()
for (const row of rows) {
const parentId = row.parent_id
const parsed = nextParentMap.get(row._id) ?? parseDoc(row, getSchema(row._class))
const parent = parentMap.get(parentId)
if (parent === undefined) continue
if (parent.$associations === undefined) {
parent.$associations = {}
}
if (parent.$associations[key] === undefined) parent.$associations[key] = []
parent.$associations[key].push(parsed)
if (!nextParentMap.has(parsed._id)) {
nextParentMap.set(parsed._id, parsed)
}
}
if (nested !== undefined && nested.length > 0 && nextParentMap.size > 0) {
await this.fetchAssociations(ctx, nextParentMap, nested)
}
}
}
private parseLookupColumns (
row: any,
simpleJoins: JoinProps[],
reverseJoins: JoinProps[],
lookup: Record<string, any>,
domain: string
): void {
const schema = getSchema(domain)
let joinIndex: number | undefined
let skip = false
for (const column in row) {
if (column.startsWith('reverse_lookup_')) {
const join = reverseJoins.find((j) => j.toAlias.toLowerCase() === column)
if (join === undefined || row[column] == null) continue
const parsed = row[column].map((p: any) => parseDoc(p, schema))
const res = this.getLookupValue(join.path, lookup, false)
if (res === undefined) continue
const { obj, key } = res
obj[key] = parsed
continue
}
if (column.startsWith('lookup_')) {
const keys = column.split('_')
let key = keys[keys.length - 1]
if (keys[keys.length - 2] === '') {
key = '_' + key
}
if (key === 'workspaceId') continue
if (key === '_id') {
joinIndex = joinIndex === undefined ? 0 : joinIndex + 1
if (row[column] == null) {
skip = true
continue
}
skip = false
}
if (skip) continue
const join = simpleJoins[joinIndex ?? 0]
if (join === undefined) continue
const res = this.getLookupValue(join.path, lookup)
if (res === undefined) continue
const { obj, key: p } = res
if (key === 'data') {
obj[p] = { ...obj[p], ...row[column] }
} else if (key === 'createdOn' || key === 'modifiedOn') {
const val = parseInt(row[column])
obj[p][key] = Number.isNaN(val) ? null : val
} else if (key === '%hash%') {
// ignore
} else if (key === 'attachedTo' && row[column] === 'NULL') {
// ignore
} else {
obj[p][key] = row[column] === 'NULL' ? null : row[column]
}
}
}
}
private getLookupValue (
@@ -1399,36 +1454,6 @@ abstract class PostgresAdapterBase implements DbAdapter {
return res
}
getAssociationsProjections (vars: ValuesVariables, baseDomain: string, associations: AssociationQuery[]): string[] {
const res: string[] = []
for (const association of associations) {
const _id = escape(association[0])
const assoc = this.modelDb.findObject(_id)
if (assoc === undefined) {
continue
}
const isReverse = association[1] === -1
const _class = isReverse ? assoc.classA : assoc.classB
const domain = this.hierarchy.findDomain(_class)
if (domain === undefined) continue
const tagetDomain = translateDomain(domain)
const keyA = isReverse ? 'docB' : 'docA'
const keyB = isReverse ? 'docA' : 'docB'
const wsId = vars.add(this.workspaceId, '::uuid')
res.push(
`(SELECT jsonb_agg(assoc.*)
FROM ${tagetDomain} AS assoc
JOIN ${translateDomain(DOMAIN_RELATION)} as relation
ON relation."${keyB}" = assoc."_id"
AND relation."workspaceId" = ${wsId}
WHERE relation."${keyA}" = ${translateDomain(baseDomain)}."_id"
AND relation.association = '${_id}'
AND assoc."workspaceId" = ${wsId}) AS assoc_${tagetDomain}_${_id}`
)
}
return res
}
@withContext('get-domain-hash')
async getDomainHash (ctx: MeasureContext, domain: Domain): Promise<string> {
return await calcHashHash(ctx, domain, this)
@@ -1442,10 +1467,9 @@ abstract class PostgresAdapterBase implements DbAdapter {
vars: ValuesVariables,
baseDomain: string,
projection: Projection<T> | undefined,
joins: JoinProps[],
associations: AssociationQuery[] | undefined
joins: JoinProps[]
): string | '*' {
if (projection === undefined && joins.length === 0 && associations === undefined) return `${baseDomain}.*`
if (projection === undefined && joins.length === 0) return `${baseDomain}.*`
const res: string[] = []
let dataAdded = false
if (projection === undefined) {
@@ -1472,9 +1496,6 @@ abstract class PostgresAdapterBase implements DbAdapter {
for (const join of joins) {
res.push(...this.getProjectionsAliases(vars, join))
}
if (associations !== undefined) {
res.push(...this.getAssociationsProjections(vars, baseDomain, associations))
}
return res.join(', ')
}
+12
View File
@@ -28,6 +28,7 @@ import core, {
type Projection,
type Ref,
systemAccountUuid,
type WithLookup,
type WorkspaceUuid
} from '@hcengineering/core'
import { type DomainHelperOperations } from '@hcengineering/server-core'
@@ -473,6 +474,17 @@ export function parseDocWithProjection<T extends Doc> (
return res
}
export function toWithLookup<T extends Doc> (doc: T): WithLookup<T> {
const res = doc as WithLookup<T>
if (res.$associations === undefined) {
res.$associations = {}
}
if (res.$lookup === undefined) {
res.$lookup = {}
}
return res
}
export function parseDoc<T extends Doc> (doc: DBDoc, schema: Schema): T {
const { workspaceId, data, ...rest } = doc
for (const key in rest) {
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@hcengineering/s3",
"version": "0.7.17",
"version": "0.7.18",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
@@ -26,19 +26,19 @@
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"@types/node": "^22.15.29",
"@types/node": "^22.18.1",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"eslint-plugin-svelte": "^2.35.1"
},
"dependencies": {
"@hcengineering/core": "^0.7.23",
"@hcengineering/platform": "^0.7.19",
"@hcengineering/server-core": "workspace:^0.7.18",
"@hcengineering/storage": "^0.7.17",
"@hcengineering/core": "^0.7.26",
"@hcengineering/platform": "^0.7.20",
"@hcengineering/server-core": "workspace:^0.7.19",
"@hcengineering/storage": "^0.7.18",
"@aws-sdk/client-s3": "^3.738.0",
"@aws-sdk/s3-request-presigner": "^3.738.0",
"@aws-sdk/lib-storage": "^3.738.0",
+14 -14
View File
@@ -1,6 +1,6 @@
{
"name": "@hcengineering/server-storage",
"version": "0.7.17",
"version": "0.7.18",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
@@ -39,16 +39,16 @@
"devDependencies": {
"cross-env": "~7.0.3",
"@hcengineering/platform-rig": "^0.7.19",
"@types/node": "^22.15.29",
"@types/node": "^22.18.1",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-n": "^15.4.0",
"eslint": "^8.54.0",
"ts-node": "^10.8.0",
"ts-node": "^10.9.2",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
@@ -56,16 +56,16 @@
"eslint-plugin-svelte": "^2.35.1"
},
"dependencies": {
"@hcengineering/core": "^0.7.23",
"@hcengineering/platform": "^0.7.19",
"@hcengineering/server-core": "workspace:^0.7.18",
"@hcengineering/minio": "workspace:^0.7.17",
"@hcengineering/s3": "workspace:^0.7.17",
"@hcengineering/datalake": "workspace:^0.7.17",
"@hcengineering/hulylake": "workspace:^0.7.17",
"@hcengineering/storage": "^0.7.17",
"@hcengineering/analytics": "^0.7.17",
"@hcengineering/server-token": "^0.7.17"
"@hcengineering/core": "^0.7.26",
"@hcengineering/platform": "^0.7.20",
"@hcengineering/server-core": "workspace:^0.7.19",
"@hcengineering/minio": "workspace:^0.7.18",
"@hcengineering/s3": "workspace:^0.7.18",
"@hcengineering/datalake": "workspace:^0.7.18",
"@hcengineering/hulylake": "workspace:^0.7.18",
"@hcengineering/storage": "^0.7.18",
"@hcengineering/analytics": "^0.7.19",
"@hcengineering/server-token": "^0.7.18"
},
"repository": "https://github.com/hcengineering/huly.server",
"publishConfig": {
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "@hcengineering/server",
"version": "0.7.18",
"version": "0.7.19",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
@@ -20,7 +20,7 @@
"devDependencies": {
"cross-env": "~7.0.3",
"@hcengineering/platform-rig": "^0.7.19",
"@types/node": "^22.15.29",
"@types/node": "^22.18.1",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.1.1",
@@ -28,7 +28,7 @@
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
@@ -36,13 +36,13 @@
"eslint-plugin-svelte": "^2.35.1"
},
"dependencies": {
"@hcengineering/account-client": "^0.7.20",
"@hcengineering/analytics": "^0.7.17",
"@hcengineering/core": "^0.7.23",
"@hcengineering/platform": "^0.7.19",
"@hcengineering/rpc": "^0.7.17",
"@hcengineering/server-core": "workspace:^0.7.18",
"@hcengineering/server-token": "^0.7.17",
"@hcengineering/account-client": "^0.7.25",
"@hcengineering/analytics": "^0.7.19",
"@hcengineering/core": "^0.7.26",
"@hcengineering/platform": "^0.7.20",
"@hcengineering/rpc": "^0.7.18",
"@hcengineering/server-core": "workspace:^0.7.19",
"@hcengineering/server-token": "^0.7.18",
"utf-8-validate": "^6.0.4"
},
"repository": "https://github.com/hcengineering/huly.server",