@open-mercato/core 0.10.1-develop.7752.1.fd7c06569b → 0.10.1-develop.7756.1.8f5dec1b96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/helpers/integration/dbFixtures.js +1 -1
- package/dist/helpers/integration/dbFixtures.js.map +1 -1
- package/dist/modules/auth/api/roles/acl/route.js +2 -2
- package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
- package/dist/modules/auth/api/roles/route.js +1 -1
- package/dist/modules/auth/api/roles/route.js.map +2 -2
- package/dist/modules/auth/api/users/acl/route.js +10 -2
- package/dist/modules/auth/api/users/acl/route.js.map +2 -2
- package/dist/modules/auth/commands/acl.js +2 -2
- package/dist/modules/auth/commands/acl.js.map +2 -2
- package/dist/modules/auth/commands/roles.js +1 -1
- package/dist/modules/auth/commands/roles.js.map +2 -2
- package/dist/modules/auth/commands/users.js +7 -3
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/auth/data/entities.js.map +2 -2
- package/dist/modules/auth/lib/executionPrincipal.js +2 -2
- package/dist/modules/auth/lib/executionPrincipal.js.map +2 -2
- package/dist/modules/auth/lib/grantChecks.js +38 -11
- package/dist/modules/auth/lib/grantChecks.js.map +2 -2
- package/dist/modules/auth/lib/setup-app.js +2 -2
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/auth/migrations/Migration20260828120000_auth.js +45 -0
- package/dist/modules/auth/migrations/Migration20260828120000_auth.js.map +7 -0
- package/dist/modules/auth/services/rbacService.js +138 -59
- package/dist/modules/auth/services/rbacService.js.map +3 -3
- package/dist/modules/entities/cli.js +12 -1
- package/dist/modules/entities/cli.js.map +2 -2
- package/dist/modules/entities/lib/helpers.js +13 -0
- package/dist/modules/entities/lib/helpers.js.map +2 -2
- package/dist/modules/entities/lib/rotate-custom-entity-documents.js +157 -0
- package/dist/modules/entities/lib/rotate-custom-entity-documents.js.map +7 -0
- package/dist/modules/notifications/lib/notificationRecipients.js +2 -2
- package/dist/modules/notifications/lib/notificationRecipients.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +179 -11
- package/dist/modules/query_index/lib/engine.js.map +3 -3
- package/package.json +7 -7
- package/src/helpers/integration/dbFixtures.ts +1 -1
- package/src/modules/auth/api/roles/acl/route.ts +2 -2
- package/src/modules/auth/api/roles/route.ts +1 -1
- package/src/modules/auth/api/users/acl/route.ts +10 -2
- package/src/modules/auth/commands/acl.ts +2 -2
- package/src/modules/auth/commands/roles.ts +1 -1
- package/src/modules/auth/commands/users.ts +7 -3
- package/src/modules/auth/data/entities.ts +12 -0
- package/src/modules/auth/lib/executionPrincipal.ts +2 -2
- package/src/modules/auth/lib/grantChecks.ts +115 -11
- package/src/modules/auth/lib/setup-app.ts +2 -2
- package/src/modules/auth/migrations/Migration20260828120000_auth.ts +70 -0
- package/src/modules/auth/services/rbacService.ts +161 -76
- package/src/modules/entities/cli.ts +12 -1
- package/src/modules/entities/lib/helpers.ts +17 -0
- package/src/modules/entities/lib/rotate-custom-entity-documents.ts +200 -0
- package/src/modules/notifications/lib/notificationRecipients.ts +6 -0
- package/src/modules/query_index/lib/engine.ts +187 -9
|
@@ -64,9 +64,12 @@ export async function getRecipientUserIdsForRole(
|
|
|
64
64
|
const builder: any = db
|
|
65
65
|
const userRoles = await builder
|
|
66
66
|
.selectFrom('user_roles')
|
|
67
|
+
.innerJoin('roles', 'user_roles.role_id', 'roles.id')
|
|
67
68
|
.innerJoin('users', 'user_roles.user_id', 'users.id')
|
|
68
69
|
.where('user_roles.role_id', '=', roleId)
|
|
69
70
|
.where('user_roles.deleted_at', 'is', null)
|
|
71
|
+
.where('roles.deleted_at', 'is', null)
|
|
72
|
+
.where('roles.tenant_id', '=', tenantId)
|
|
70
73
|
.where('users.deleted_at', 'is', null)
|
|
71
74
|
.where('users.tenant_id', '=', tenantId)
|
|
72
75
|
.select('users.id as user_id')
|
|
@@ -101,10 +104,13 @@ export async function getRecipientUserIdsForFeature(
|
|
|
101
104
|
|
|
102
105
|
const roleAcls = await builder
|
|
103
106
|
.selectFrom('role_acls')
|
|
107
|
+
.innerJoin('roles', 'role_acls.role_id', 'roles.id')
|
|
104
108
|
.innerJoin('user_roles', 'role_acls.role_id', 'user_roles.role_id')
|
|
105
109
|
.innerJoin('users', 'user_roles.user_id', 'users.id')
|
|
106
110
|
.where('role_acls.tenant_id', '=', tenantId)
|
|
107
111
|
.where('role_acls.deleted_at', 'is', null)
|
|
112
|
+
.where('roles.deleted_at', 'is', null)
|
|
113
|
+
.where('roles.tenant_id', '=', tenantId)
|
|
108
114
|
.where('user_roles.deleted_at', 'is', null)
|
|
109
115
|
.where('users.deleted_at', 'is', null)
|
|
110
116
|
.where('users.tenant_id', '=', tenantId)
|
|
@@ -17,6 +17,8 @@ import { readCoverageSnapshot, refreshCoverageSnapshot } from './coverage'
|
|
|
17
17
|
import { createProfiler, shouldEnableProfiler, type Profiler } from '@open-mercato/shared/lib/profiler'
|
|
18
18
|
import type { VectorIndexService } from '@open-mercato/search/vector'
|
|
19
19
|
import { decryptIndexDocCustomFields } from '@open-mercato/shared/lib/encryption/indexDoc'
|
|
20
|
+
import { decryptCustomEntityDocument, isCustomEntityDocumentCiphertext } from '@open-mercato/shared/lib/encryption/customEntityDoc'
|
|
21
|
+
import type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
|
|
20
22
|
import {
|
|
21
23
|
DECLINED_ENCRYPTED_SORT_LOG_MESSAGE,
|
|
22
24
|
DECRYPT_REFUSAL_LOG_MESSAGE,
|
|
@@ -72,6 +74,85 @@ import { createBoundedTtlMemo } from '@open-mercato/shared/lib/query/bounded-ttl
|
|
|
72
74
|
|
|
73
75
|
const logger = createLogger('query_index').child({ component: 'engine' })
|
|
74
76
|
|
|
77
|
+
function customDocumentValue(row: Record<string, unknown>, field: string): unknown {
|
|
78
|
+
const bare = field.replace(/^cf[:_]/, '')
|
|
79
|
+
return row[field] ?? row[`cf_${bare.replace(/[^a-zA-Z0-9_]/g, '_')}`] ?? row[`cf:${bare}`] ?? row[bare]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function assertCustomDocumentPlaintext(actual: unknown): void {
|
|
83
|
+
if (isCustomEntityDocumentCiphertext(actual) || (Array.isArray(actual) && actual.some(isCustomEntityDocumentCiphertext))) {
|
|
84
|
+
throw new Error('[internal] Cannot compare custom entity ciphertext without its tenant key')
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function matchesCustomDocumentPattern(value: string, pattern: string, ignoreCase: boolean): boolean {
|
|
89
|
+
const source = Array.from(value)
|
|
90
|
+
const authored = Array.from(pattern)
|
|
91
|
+
const normalize = (character: string) => ignoreCase ? character.toLowerCase() : character
|
|
92
|
+
const tokens: Array<{ kind: 'literal' | 'one' | 'many'; value: string }> = []
|
|
93
|
+
for (let index = 0; index < authored.length; index += 1) {
|
|
94
|
+
const character = authored[index]!
|
|
95
|
+
if (character === '\\') {
|
|
96
|
+
const literal = authored[++index]
|
|
97
|
+
if (literal === undefined) throw new Error('[internal] Invalid custom entity LIKE escape')
|
|
98
|
+
tokens.push({ kind: 'literal', value: normalize(literal) })
|
|
99
|
+
} else {
|
|
100
|
+
tokens.push({ kind: character === '%' ? 'many' : character === '_' ? 'one' : 'literal', value: normalize(character) })
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
let sourceIndex = 0
|
|
104
|
+
let tokenIndex = 0
|
|
105
|
+
let wildcardIndex = -1
|
|
106
|
+
let retryIndex = 0
|
|
107
|
+
while (sourceIndex < source.length) {
|
|
108
|
+
const token = tokens[tokenIndex]
|
|
109
|
+
if (token?.kind === 'one' || (token?.kind === 'literal' && token.value === normalize(source[sourceIndex]!))) {
|
|
110
|
+
sourceIndex += 1
|
|
111
|
+
tokenIndex += 1
|
|
112
|
+
} else if (token?.kind === 'many') {
|
|
113
|
+
wildcardIndex = tokenIndex++
|
|
114
|
+
retryIndex = sourceIndex
|
|
115
|
+
} else if (wildcardIndex >= 0) {
|
|
116
|
+
tokenIndex = wildcardIndex + 1
|
|
117
|
+
sourceIndex = ++retryIndex
|
|
118
|
+
} else {
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
while (tokens[tokenIndex]?.kind === 'many') tokenIndex += 1
|
|
123
|
+
return tokenIndex === tokens.length
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function matchesCustomDocumentFilter(row: Record<string, unknown>, filter: Filter, actual = customDocumentValue(row, filter.field)): boolean {
|
|
127
|
+
const expected: unknown = filter.value
|
|
128
|
+
assertCustomDocumentPlaintext(actual)
|
|
129
|
+
const equal = (left: unknown, right: unknown) => left == null || right == null
|
|
130
|
+
? left == null && right == null
|
|
131
|
+
: String(left) === String(right)
|
|
132
|
+
const values = filter.field.startsWith('cf:') && Array.isArray(actual) ? actual : [actual]
|
|
133
|
+
const included = (candidates: unknown[]) => values.some((value) => candidates.some((candidate) => equal(value, candidate)))
|
|
134
|
+
switch (filter.op) {
|
|
135
|
+
case 'exists': return Boolean(expected) === (actual != null)
|
|
136
|
+
case 'eq': return values.some((value) => equal(value, expected))
|
|
137
|
+
case 'ne': return actual != null && !values.some((value) => equal(value, expected))
|
|
138
|
+
case 'in': return included(Array.isArray(expected) ? expected : [])
|
|
139
|
+
case 'nin': return actual != null && !included(Array.isArray(expected) ? expected : [])
|
|
140
|
+
case 'overlap': return Array.isArray(actual) && normalizeOverlapValues(expected).some((value) => actual.map(String).includes(value))
|
|
141
|
+
case 'noverlap': return Array.isArray(actual) && !normalizeOverlapValues(expected).some((value) => actual.map(String).includes(value))
|
|
142
|
+
case 'gt': case 'gte': case 'lt': case 'lte': {
|
|
143
|
+
if (actual == null || expected == null) return false
|
|
144
|
+
const left = typeof actual === 'number' ? actual : String(actual)
|
|
145
|
+
const right = typeof actual === 'number' ? Number(expected) : String(expected)
|
|
146
|
+
const order = typeof left === 'number' && typeof right === 'number' ? left - right : String(left).localeCompare(String(right))
|
|
147
|
+
return filter.op === 'gt' ? order > 0 : filter.op === 'gte' ? order >= 0 : filter.op === 'lt' ? order < 0 : order <= 0
|
|
148
|
+
}
|
|
149
|
+
case 'like': case 'ilike': {
|
|
150
|
+
return values.some((value) => value != null && matchesCustomDocumentPattern(String(value), String(expected ?? ''), filter.op === 'ilike'))
|
|
151
|
+
}
|
|
152
|
+
default: throw new Error('[internal] Unsupported custom entity filter operator')
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
75
156
|
/**
|
|
76
157
|
* Plain-row shape of the `custom_field_defs` columns the key and kind lookups read. The
|
|
77
158
|
* scope columns are load-bearing: a key can carry an organization-specific definition that
|
|
@@ -2232,6 +2313,26 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
2232
2313
|
if (!opts.tenantId) throw new Error('QueryEngine: tenantId is required')
|
|
2233
2314
|
|
|
2234
2315
|
const normalizedFilters = normalizeFilters(opts.filters)
|
|
2316
|
+
const encryptionService = resolveDecryptEnabled(opts) ? this.getEncryptionService() : null
|
|
2317
|
+
const documentEncryptionService = encryptionService && 'getDek' in encryptionService
|
|
2318
|
+
&& typeof encryptionService.getDek === 'function' && typeof encryptionService.isEnabled === 'function'
|
|
2319
|
+
? encryptionService as TenantDataEncryptionService
|
|
2320
|
+
: null
|
|
2321
|
+
const definitions = encryptionService ? await this.resolveCustomFieldDefs([entity], opts.tenantId, true) : []
|
|
2322
|
+
const resolveKinds = createCustomFieldKindMapResolver(buildCustomFieldKindIndex(definitions))
|
|
2323
|
+
const encryptedKeys = new Set(definitions.filter((definition) => {
|
|
2324
|
+
const config = definition.configJson
|
|
2325
|
+
return config != null && typeof config === 'object' && 'encrypted' in config && Boolean(config.encrypted)
|
|
2326
|
+
}).map((definition) => definition.key))
|
|
2327
|
+
const isProtected = (field: string) => encryptedKeys.has(field.replace(/^cf[:_]/, ''))
|
|
2328
|
+
const plaintextFilters = normalizedFilters.some((filter) => isProtected(filter.field))
|
|
2329
|
+
const plaintextSort = (opts.sort ?? []).some((sort) => isProtected(sort.field))
|
|
2330
|
+
const scanPlaintext = plaintextFilters || plaintextSort
|
|
2331
|
+
const deferredFilters = plaintextFilters ? normalizedFilters.filter((filter) => filter.orGroup || isProtected(filter.field)) : []
|
|
2332
|
+
const jsonComparisonAliases = new Map(Array.from(new Set(deferredFilters
|
|
2333
|
+
.filter((filter) => !isProtected(filter.field) && !filter.field.startsWith('cf:')
|
|
2334
|
+
&& !this.resolveCustomEntityColumn(alias, filter.field) && (filter.op === 'overlap' || filter.op === 'noverlap'))
|
|
2335
|
+
.map((filter) => filter.field)), (field, index) => [field, `__om_ce_filter_${index}`]))
|
|
2235
2336
|
const searchConfig = resolveSearchConfig()
|
|
2236
2337
|
const searchEnabled = await this.searchAvailability().staticEnabled()
|
|
2237
2338
|
// Same gate as `query()` (#4723): without a like/ilike filter the probe's answer is never read.
|
|
@@ -2258,6 +2359,7 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
2258
2359
|
}
|
|
2259
2360
|
if (!opts.withDeleted) next = next.where(`${alias}.deleted_at`, 'is', null)
|
|
2260
2361
|
for (const filter of normalizedFilters) {
|
|
2362
|
+
if (plaintextFilters && (filter.orGroup || isProtected(filter.field))) continue
|
|
2261
2363
|
if (filter.field.startsWith('cf:')) {
|
|
2262
2364
|
next = this.applyCfFilterFromAlias(next, alias, entity, filter.field, filter.op, filter.value, searchRuntime)
|
|
2263
2365
|
continue
|
|
@@ -2317,20 +2419,31 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
2317
2419
|
for (const k of opts.includeCustomFields) cfKeys.add(k)
|
|
2318
2420
|
}
|
|
2319
2421
|
|
|
2422
|
+
const projectedFields = new Set((opts.fields?.length ? opts.fields : ['id']).map((field) => field.startsWith('cf:') ? this.sanitize(field) : field))
|
|
2423
|
+
for (const key of cfKeys) projectedFields.add(this.sanitize(`cf:${key}`))
|
|
2424
|
+
const hiddenFields = new Set<string>()
|
|
2425
|
+
const comparisonFields = scanPlaintext
|
|
2426
|
+
? Array.from(new Set([...deferredFilters.map((filter) => filter.field), ...(opts.sort ?? []).map((sort) => sort.field)]))
|
|
2427
|
+
: []
|
|
2428
|
+
for (const field of comparisonFields) {
|
|
2429
|
+
const outputKey = field.startsWith('cf:') ? this.sanitize(field) : field
|
|
2430
|
+
if (!projectedFields.has(outputKey)) hiddenFields.add(outputKey)
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2320
2433
|
const applySelection = (q: AnyBuilder): AnyBuilder => {
|
|
2321
2434
|
let next = q
|
|
2322
|
-
const requested = (
|
|
2435
|
+
const requested = Array.from(new Set([...(opts.fields?.length ? opts.fields : ['id']), ...comparisonFields]))
|
|
2323
2436
|
for (const field of requested) {
|
|
2324
2437
|
const f = String(field)
|
|
2325
2438
|
if (f.startsWith('cf:')) {
|
|
2326
2439
|
const aliasName = this.sanitize(f)
|
|
2327
2440
|
next = next.select(this.jsonbSqlAlias(alias, f).as(aliasName))
|
|
2328
|
-
} else if (f
|
|
2329
|
-
next = next.select(`${alias}
|
|
2330
|
-
} else if (f === 'created_at' || f === 'updated_at' || f === 'deleted_at') {
|
|
2331
|
-
next = next.select(`${alias}.${f} as ${f}`)
|
|
2441
|
+
} else if (this.resolveCustomEntityColumn(alias, f)) {
|
|
2442
|
+
next = next.select(`${this.resolveCustomEntityColumn(alias, f)} as ${f}`)
|
|
2332
2443
|
} else {
|
|
2333
|
-
const expr =
|
|
2444
|
+
const expr = isProtected(f)
|
|
2445
|
+
? sql<unknown>`(${sql.ref(alias + '.doc')} -> ${f})`
|
|
2446
|
+
: sql<string | null>`(${sql.ref(alias + '.doc')} ->> ${f})`
|
|
2334
2447
|
next = next.select(expr.as(f))
|
|
2335
2448
|
}
|
|
2336
2449
|
}
|
|
@@ -2339,6 +2452,12 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
2339
2452
|
const aliasName = this.sanitize(`cf:${key}`)
|
|
2340
2453
|
next = next.select(this.jsonbSqlAlias(alias, `cf:${key}`).as(aliasName))
|
|
2341
2454
|
}
|
|
2455
|
+
for (const [field, comparisonAlias] of jsonComparisonAliases) {
|
|
2456
|
+
next = next.select(sql<unknown>`(${sql.ref(alias + '.doc')} -> ${field})`.as(comparisonAlias))
|
|
2457
|
+
}
|
|
2458
|
+
if (encryptionService) {
|
|
2459
|
+
next = next.select([`${alias}.tenant_id as __om_ce_tenant_id`, `${alias}.organization_id as __om_ce_organization_id`])
|
|
2460
|
+
}
|
|
2342
2461
|
return next
|
|
2343
2462
|
}
|
|
2344
2463
|
|
|
@@ -2364,6 +2483,63 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
2364
2483
|
const page = opts.page?.page ?? 1
|
|
2365
2484
|
const pageSize = opts.page?.pageSize ?? 20
|
|
2366
2485
|
|
|
2486
|
+
const decryptRow = async (item: Record<string, unknown>): Promise<Record<string, unknown>> => {
|
|
2487
|
+
let next = item
|
|
2488
|
+
if (encryptionService) {
|
|
2489
|
+
const hasOrganizationScope = Object.prototype.hasOwnProperty.call(item, '__om_ce_organization_id')
|
|
2490
|
+
const decision = resolveDecryptScope({
|
|
2491
|
+
rowTenantId: (Object.prototype.hasOwnProperty.call(item, '__om_ce_tenant_id') ? item.__om_ce_tenant_id : item.tenant_id ?? null) as string | null,
|
|
2492
|
+
rowOrganizationId: (hasOrganizationScope ? item.__om_ce_organization_id : item.organization_id ?? null) as string | null,
|
|
2493
|
+
callerTenantId: opts.tenantId,
|
|
2494
|
+
callerOrganizationId: hasOrganizationScope ? null : opts.organizationId ?? null,
|
|
2495
|
+
})
|
|
2496
|
+
if (decision.decrypt) {
|
|
2497
|
+
const fields = Object.entries(resolveKinds(decision.organizationId, decision.tenantId)).map(([key, kind]) => ({ key, kind, encrypted: encryptedKeys.has(key) }))
|
|
2498
|
+
next = await decryptCustomEntityDocument(item, fields, decision.tenantId, documentEncryptionService)
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
const projected = { ...next }
|
|
2502
|
+
delete projected.__om_ce_tenant_id
|
|
2503
|
+
delete projected.__om_ce_organization_id
|
|
2504
|
+
return projected
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
if (scanPlaintext) {
|
|
2508
|
+
const maxRows = resolveEncryptedSortMaxRows()
|
|
2509
|
+
let candidateQuery = applySelection(applyScope(db.selectFrom(`custom_entities_storage as ${alias}`)))
|
|
2510
|
+
if (maxRows !== null) candidateQuery = candidateQuery.limit(maxRows + 1)
|
|
2511
|
+
const rows: Record<string, unknown>[] = await candidateQuery.execute()
|
|
2512
|
+
if (maxRows !== null && rows.length > maxRows) throw new Error('[internal] Custom entity encrypted query exceeds the candidate row limit')
|
|
2513
|
+
let candidates = await mapWithConcurrency(rows, DECRYPT_CONCURRENCY, decryptRow)
|
|
2514
|
+
if (plaintextFilters) {
|
|
2515
|
+
const common = deferredFilters.filter((filter) => !filter.orGroup)
|
|
2516
|
+
const groups = new Map<string, typeof normalizedFilters>()
|
|
2517
|
+
for (const filter of normalizedFilters) {
|
|
2518
|
+
if (filter.orGroup) groups.set(filter.orGroup, [...(groups.get(filter.orGroup) ?? []), filter])
|
|
2519
|
+
}
|
|
2520
|
+
const matches = (row: Record<string, unknown>, filter: Filter) => {
|
|
2521
|
+
const comparisonAlias = jsonComparisonAliases.get(filter.field)
|
|
2522
|
+
return matchesCustomDocumentFilter(row, filter, comparisonAlias ? row[comparisonAlias] : customDocumentValue(row, filter.field))
|
|
2523
|
+
}
|
|
2524
|
+
candidates = candidates.filter((row) => common.every((filter) => matches(row, filter))
|
|
2525
|
+
&& (!groups.size || Array.from(groups.values()).some((group) => group.every((filter) => matches(row, filter)))))
|
|
2526
|
+
}
|
|
2527
|
+
for (const row of candidates) {
|
|
2528
|
+
for (const sort of opts.sort ?? []) {
|
|
2529
|
+
if (isProtected(sort.field)) assertCustomDocumentPlaintext(customDocumentValue(row, sort.field))
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
candidates = sortRowsInMemory(candidates, (opts.sort ?? []).map((sort) => ({ ...sort, field: sort.field.startsWith('cf:') ? this.sanitize(sort.field) : sort.field })))
|
|
2533
|
+
const total = candidates.length
|
|
2534
|
+
const items = candidates.slice((page - 1) * pageSize, page * pageSize).map((row) => {
|
|
2535
|
+
const projected = { ...row }
|
|
2536
|
+
for (const field of hiddenFields) delete projected[field]
|
|
2537
|
+
for (const comparisonAlias of jsonComparisonAliases.values()) delete projected[comparisonAlias]
|
|
2538
|
+
return projected
|
|
2539
|
+
})
|
|
2540
|
+
return { items: items as T[], page, pageSize, total }
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2367
2543
|
// Single-table count: `applyScope` adds only WHERE predicates (cf filters
|
|
2368
2544
|
// included — no join), and doc storage holds one row per record within a
|
|
2369
2545
|
// scope, so `count(*)` needs no DISTINCT and — when the cap is active — a
|
|
@@ -2389,8 +2565,9 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
2389
2565
|
dataQuery = applySelection(dataQuery)
|
|
2390
2566
|
dataQuery = applySort(dataQuery)
|
|
2391
2567
|
dataQuery = dataQuery.limit(pageSize).offset((page - 1) * pageSize)
|
|
2392
|
-
const
|
|
2393
|
-
const
|
|
2568
|
+
const rows: Record<string, unknown>[] = await dataQuery.execute()
|
|
2569
|
+
const items = encryptionService ? await mapWithConcurrency(rows, DECRYPT_CONCURRENCY, decryptRow) : rows
|
|
2570
|
+
const result: QueryResult<T> = { items: items as T[], page, pageSize, total }
|
|
2394
2571
|
if (listCountCapWarning) result.meta = { listCountCapWarning }
|
|
2395
2572
|
return result
|
|
2396
2573
|
}
|
|
@@ -2411,12 +2588,13 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
2411
2588
|
private async resolveCustomFieldDefs(
|
|
2412
2589
|
entityIds: string[],
|
|
2413
2590
|
tenantId: string | null,
|
|
2591
|
+
fresh = false,
|
|
2414
2592
|
): Promise<CustomFieldDefRow[]> {
|
|
2415
2593
|
if (!entityIds.length) return []
|
|
2416
2594
|
const cacheKey = this.customFieldKeysCacheKey(entityIds, tenantId)
|
|
2417
2595
|
const now = Date.now()
|
|
2418
2596
|
const cached = this.customFieldDefsCache.get(cacheKey)
|
|
2419
|
-
if (cached && cached.expiresAt > now) return cached.value.slice()
|
|
2597
|
+
if (!fresh && cached && cached.expiresAt > now) return cached.value.slice()
|
|
2420
2598
|
|
|
2421
2599
|
const db = this.getDb() as any
|
|
2422
2600
|
const rows = await db
|