@open-mercato/shared 0.6.6-develop.5834.1.dc9eb0615e → 0.6.6-develop.5876.1.6a45c370ac
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/lib/query/bounded-decrypt.js +18 -0
- package/dist/lib/query/bounded-decrypt.js.map +7 -0
- package/dist/lib/query/encrypted-sort.js +8 -0
- package/dist/lib/query/encrypted-sort.js.map +2 -2
- package/dist/lib/query/engine.js +389 -336
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/query/__tests__/bounded-decrypt.test.ts +49 -0
- package/src/lib/query/__tests__/engine.test.ts +159 -4
- package/src/lib/query/bounded-decrypt.ts +26 -0
- package/src/lib/query/encrypted-sort.ts +13 -0
- package/src/lib/query/engine.ts +496 -406
- package/src/lib/query/types.ts +8 -0
package/src/lib/query/engine.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { QueryEngine, QueryOptions, QueryResult, QueryCustomFieldSource, QueryExtensionsConfig, Sort } from './types'
|
|
1
|
+
import type { QueryEngine, QueryOptions, QueryResult, QueryResultMeta, EncryptedSortRowCapWarning, QueryCustomFieldSource, QueryExtensionsConfig, Sort } from './types'
|
|
2
2
|
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
4
|
import { type Kysely, sql, type RawBuilder } from 'kysely'
|
|
@@ -20,7 +20,10 @@ import {
|
|
|
20
20
|
type CustomFieldDefinitionRow,
|
|
21
21
|
type ResolvedCustomFieldDefinitions,
|
|
22
22
|
} from '../crud/custom-field-definition-index'
|
|
23
|
-
import { resolveEncryptedSortFields, sortRowsInMemory } from './encrypted-sort'
|
|
23
|
+
import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from './encrypted-sort'
|
|
24
|
+
import { mapWithConcurrency } from './bounded-decrypt'
|
|
25
|
+
|
|
26
|
+
const DECRYPT_CONCURRENCY = 8
|
|
24
27
|
|
|
25
28
|
type AnyDb = Kysely<any>
|
|
26
29
|
type AnyBuilder = any
|
|
@@ -261,7 +264,6 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
261
264
|
const table = resolveEntityTableName(this.em, entity)
|
|
262
265
|
const db = this.getDb()
|
|
263
266
|
|
|
264
|
-
let q: AnyBuilder = db.selectFrom(table as any)
|
|
265
267
|
const qualify = (col: string) => `${table}.${col}`
|
|
266
268
|
const orgScope = this.resolveOrganizationScope(opts)
|
|
267
269
|
this.searchAliasSeq = 0
|
|
@@ -274,18 +276,6 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
274
276
|
)
|
|
275
277
|
}
|
|
276
278
|
const skipAutoScope = opts.omitAutomaticTenantOrgScope === true
|
|
277
|
-
// Optional organization filter (when present in schema)
|
|
278
|
-
if (!skipAutoScope && orgScope && await this.columnExists(table, 'organization_id')) {
|
|
279
|
-
q = this.applyOrganizationScope(q, qualify('organization_id'), orgScope)
|
|
280
|
-
}
|
|
281
|
-
// Tenant guard (required) when present in schema
|
|
282
|
-
if (!skipAutoScope && await this.columnExists(table, 'tenant_id')) {
|
|
283
|
-
q = q.where(qualify('tenant_id'), '=', opts.tenantId)
|
|
284
|
-
}
|
|
285
|
-
// Default soft-delete guard: exclude rows with deleted_at when column exists
|
|
286
|
-
if (!opts.withDeleted && await this.columnExists(table, 'deleted_at')) {
|
|
287
|
-
q = q.where(qualify('deleted_at'), 'is', null)
|
|
288
|
-
}
|
|
289
279
|
|
|
290
280
|
const normalizedFilters = normalizeFilters(opts.filters)
|
|
291
281
|
const resolvedJoins = resolveJoins(
|
|
@@ -428,65 +418,6 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
428
418
|
const regularBaseFilters = baseFilters.filter((f) => !f.orGroup)
|
|
429
419
|
const orGroupFilters = baseFilters.filter((f) => f.orGroup)
|
|
430
420
|
|
|
431
|
-
for (const filter of regularBaseFilters) {
|
|
432
|
-
const fieldName = String(filter.field)
|
|
433
|
-
let qualified = filter.qualified ?? null
|
|
434
|
-
if (!qualified) {
|
|
435
|
-
const column = await this.resolveBaseColumn(table, fieldName)
|
|
436
|
-
if (!column) {
|
|
437
|
-
q = this.applyIndexDocFilter(q, {
|
|
438
|
-
entity: String(entity),
|
|
439
|
-
field: fieldName,
|
|
440
|
-
op: filter.op,
|
|
441
|
-
value: filter.value,
|
|
442
|
-
recordIdColumn,
|
|
443
|
-
tenantId: opts.tenantId ?? null,
|
|
444
|
-
organizationScope: orgScope,
|
|
445
|
-
withDeleted: opts.withDeleted === true,
|
|
446
|
-
searchActive,
|
|
447
|
-
searchConfig,
|
|
448
|
-
})
|
|
449
|
-
continue
|
|
450
|
-
}
|
|
451
|
-
qualified = qualify(column)
|
|
452
|
-
}
|
|
453
|
-
q = applyFilterOp(q, qualified, filter.op, filter.value, fieldName)
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// OR-grouped filters: AND within each group (one $or disjunct), OR between groups.
|
|
457
|
-
if (orGroupFilters.length > 0) {
|
|
458
|
-
const groups = new Map<string, typeof orGroupFilters>()
|
|
459
|
-
for (const f of orGroupFilters) {
|
|
460
|
-
const group = groups.get(f.orGroup!) ?? []
|
|
461
|
-
group.push(f)
|
|
462
|
-
groups.set(f.orGroup!, group)
|
|
463
|
-
}
|
|
464
|
-
const resolvedGroupFilters: Array<Array<{ qualified: string; op: string; value: unknown; fieldName: string }>> = []
|
|
465
|
-
for (const [, groupFilters] of groups) {
|
|
466
|
-
const resolved: Array<{ qualified: string; op: string; value: unknown; fieldName: string }> = []
|
|
467
|
-
for (const filter of groupFilters) {
|
|
468
|
-
const column = await this.resolveBaseColumn(table, String(filter.field))
|
|
469
|
-
if (column) {
|
|
470
|
-
resolved.push({
|
|
471
|
-
qualified: qualify(column),
|
|
472
|
-
op: filter.op,
|
|
473
|
-
value: filter.value,
|
|
474
|
-
fieldName: String(filter.field),
|
|
475
|
-
})
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
if (resolved.length > 0) resolvedGroupFilters.push(resolved)
|
|
479
|
-
}
|
|
480
|
-
if (resolvedGroupFilters.length > 0) {
|
|
481
|
-
q = q.where((eb: any) => eb.or(
|
|
482
|
-
resolvedGroupFilters.map((group) => {
|
|
483
|
-
const parts = group.map((rf) => this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value))
|
|
484
|
-
return parts.length === 1 ? parts[0] : eb.and(parts)
|
|
485
|
-
})
|
|
486
|
-
))
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
|
|
490
421
|
const applyAliasScopes = async (builder: AnyBuilder, aliasName: string): Promise<AnyBuilder> => {
|
|
491
422
|
const targetTable = aliasTables.get(aliasName)
|
|
492
423
|
if (!targetTable) return builder
|
|
@@ -499,19 +430,6 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
499
430
|
}
|
|
500
431
|
return next
|
|
501
432
|
}
|
|
502
|
-
q = await applyJoinFilters({
|
|
503
|
-
db,
|
|
504
|
-
baseTable: table,
|
|
505
|
-
builder: q,
|
|
506
|
-
joinMap,
|
|
507
|
-
joinFilters,
|
|
508
|
-
aliasTables,
|
|
509
|
-
qualifyBase: (column) => qualify(column),
|
|
510
|
-
applyAliasScope: (builder, alias) => applyAliasScopes(builder, alias),
|
|
511
|
-
applyFilterOp: (builder, column, op, value) => applyFilterOp(builder, column, op, value),
|
|
512
|
-
applyJoinFilterOp,
|
|
513
|
-
columnExists: (tbl, column) => this.columnExists(tbl, column),
|
|
514
|
-
})
|
|
515
433
|
|
|
516
434
|
const fallbackOrgId =
|
|
517
435
|
opts.organizationId
|
|
@@ -535,315 +453,439 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
535
453
|
)
|
|
536
454
|
const requiresPlaintextSort = encryptedSortFields.size > 0
|
|
537
455
|
|
|
538
|
-
// Selection (base columns only here; cf:* handled later)
|
|
539
|
-
if (opts.fields && opts.fields.length) {
|
|
540
|
-
const cols = new Set(opts.fields.filter((f) => !f.startsWith('cf:')))
|
|
541
|
-
if (requiresPlaintextSort) {
|
|
542
|
-
for (const field of encryptedSortFields) cols.add(field)
|
|
543
|
-
}
|
|
544
|
-
for (const c of cols) {
|
|
545
|
-
// Qualify and alias to base names to avoid ambiguity
|
|
546
|
-
q = q.select(sql.ref(qualify(c)).as(c))
|
|
547
|
-
}
|
|
548
|
-
} else {
|
|
549
|
-
// Default to selecting only base table columns to avoid ambiguity when joining
|
|
550
|
-
q = q.select(sql`${sql.ref(table)}.*`.as('__all'))
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// Resolve which custom fields to include
|
|
554
456
|
const tenantId = opts.tenantId
|
|
555
457
|
const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const requestedCustomFieldKeys = Array.isArray(opts.includeCustomFields)
|
|
564
|
-
? opts.includeCustomFields.map((key) => String(key))
|
|
565
|
-
: []
|
|
566
|
-
const cfKeys = new Set<string>()
|
|
567
|
-
const keySource = new Map<string, ResolvedCustomFieldSource>()
|
|
568
|
-
// Custom-field definition index threaded onto the result so the CRUD factory
|
|
569
|
-
// can decorate list rows without reloading definitions from the DB (#2133).
|
|
570
|
-
let resolvedCustomFieldDefinitions: ResolvedCustomFieldDefinitions | undefined
|
|
571
|
-
// Explicit in fields/filters
|
|
572
|
-
for (const f of (opts.fields || [])) {
|
|
573
|
-
if (typeof f === 'string' && f.startsWith('cf:')) cfKeys.add(f.slice(3))
|
|
574
|
-
}
|
|
575
|
-
for (const f of cfFilters) {
|
|
576
|
-
if (typeof f.field === 'string' && f.field.startsWith('cf:')) cfKeys.add(f.field.slice(3))
|
|
458
|
+
|
|
459
|
+
type BuiltQuery = {
|
|
460
|
+
builder: AnyBuilder
|
|
461
|
+
hasJoinedAggregates: boolean
|
|
462
|
+
cfJsonAliases: Set<string>
|
|
463
|
+
cfMultiAliasByAlias: Map<string, string>
|
|
464
|
+
resolvedCustomFieldDefinitions: ResolvedCustomFieldDefinitions | undefined
|
|
577
465
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
tenantId: row.tenant_id == null ? null : String(row.tenant_id),
|
|
622
|
-
deletedAt: row.deleted_at ?? null,
|
|
623
|
-
updatedAt: row.updated_at ?? null,
|
|
624
|
-
}))
|
|
625
|
-
resolvedCustomFieldDefinitions = {
|
|
626
|
-
index: buildCustomFieldDefinitionIndexFromRows(definitionRows, { organizationIds: orgCandidates }),
|
|
627
|
-
entityIds: entityIdList,
|
|
628
|
-
tenantId: tenantId ?? null,
|
|
629
|
-
organizationIds: orgCandidates,
|
|
466
|
+
|
|
467
|
+
// Builds the fully-scoped query from a fresh root. `projection: 'full'` reproduces
|
|
468
|
+
// today's complete selection (base fields + CF projections + extension joins).
|
|
469
|
+
// `projection: 'sortKeys'` selects only `id` + the sort columns — the slim phase-1
|
|
470
|
+
// candidate scan used when `requiresPlaintextSort`. Re-running the WHERE/JOIN logic
|
|
471
|
+
// twice is cheap: every `columnExists`/`tableExists` check is memoized on
|
|
472
|
+
// `this.columnCache`/`this.tableCache`, so the second pass hits no extra DB calls.
|
|
473
|
+
const buildQuery = async (projection: 'full' | 'sortKeys'): Promise<BuiltQuery> => {
|
|
474
|
+
const isSortKeysProjection = projection === 'sortKeys'
|
|
475
|
+
let q: AnyBuilder = db.selectFrom(table as any)
|
|
476
|
+
|
|
477
|
+
// Tenant/org/soft-delete scope
|
|
478
|
+
if (!skipAutoScope && orgScope && await this.columnExists(table, 'organization_id')) {
|
|
479
|
+
q = this.applyOrganizationScope(q, qualify('organization_id'), orgScope)
|
|
480
|
+
}
|
|
481
|
+
if (!skipAutoScope && await this.columnExists(table, 'tenant_id')) {
|
|
482
|
+
q = q.where(qualify('tenant_id'), '=', opts.tenantId)
|
|
483
|
+
}
|
|
484
|
+
if (!opts.withDeleted && await this.columnExists(table, 'deleted_at')) {
|
|
485
|
+
q = q.where(qualify('deleted_at'), 'is', null)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
for (const filter of regularBaseFilters) {
|
|
489
|
+
const fieldName = String(filter.field)
|
|
490
|
+
let qualified = filter.qualified ?? null
|
|
491
|
+
if (!qualified) {
|
|
492
|
+
const column = await this.resolveBaseColumn(table, fieldName)
|
|
493
|
+
if (!column) {
|
|
494
|
+
q = this.applyIndexDocFilter(q, {
|
|
495
|
+
entity: String(entity),
|
|
496
|
+
field: fieldName,
|
|
497
|
+
op: filter.op,
|
|
498
|
+
value: filter.value,
|
|
499
|
+
recordIdColumn,
|
|
500
|
+
tenantId: opts.tenantId ?? null,
|
|
501
|
+
organizationScope: orgScope,
|
|
502
|
+
withDeleted: opts.withDeleted === true,
|
|
503
|
+
searchActive,
|
|
504
|
+
searchConfig,
|
|
505
|
+
})
|
|
506
|
+
continue
|
|
507
|
+
}
|
|
508
|
+
qualified = qualify(column)
|
|
630
509
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
510
|
+
q = applyFilterOp(q, qualified, filter.op, filter.value, fieldName)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// OR-grouped filters: AND within each group (one $or disjunct), OR between groups.
|
|
514
|
+
if (orGroupFilters.length > 0) {
|
|
515
|
+
const groups = new Map<string, typeof orGroupFilters>()
|
|
516
|
+
for (const f of orGroupFilters) {
|
|
517
|
+
const group = groups.get(f.orGroup!) ?? []
|
|
518
|
+
group.push(f)
|
|
519
|
+
groups.set(f.orGroup!, group)
|
|
636
520
|
}
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
521
|
+
const resolvedGroupFilters: Array<Array<{ qualified: string; op: string; value: unknown; fieldName: string }>> = []
|
|
522
|
+
for (const [, groupFilters] of groups) {
|
|
523
|
+
const resolved: Array<{ qualified: string; op: string; value: unknown; fieldName: string }> = []
|
|
524
|
+
for (const filter of groupFilters) {
|
|
525
|
+
const column = await this.resolveBaseColumn(table, String(filter.field))
|
|
526
|
+
if (column) {
|
|
527
|
+
resolved.push({
|
|
528
|
+
qualified: qualify(column),
|
|
529
|
+
op: filter.op,
|
|
530
|
+
value: filter.value,
|
|
531
|
+
fieldName: String(filter.field),
|
|
532
|
+
})
|
|
533
|
+
}
|
|
644
534
|
}
|
|
645
|
-
|
|
535
|
+
if (resolved.length > 0) resolvedGroupFilters.push(resolved)
|
|
536
|
+
}
|
|
537
|
+
if (resolvedGroupFilters.length > 0) {
|
|
538
|
+
q = q.where((eb: any) => eb.or(
|
|
539
|
+
resolvedGroupFilters.map((group) => {
|
|
540
|
+
const parts = group.map((rf) => this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value))
|
|
541
|
+
return parts.length === 1 ? parts[0] : eb.and(parts)
|
|
542
|
+
})
|
|
543
|
+
))
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
q = await applyJoinFilters({
|
|
548
|
+
db,
|
|
549
|
+
baseTable: table,
|
|
550
|
+
builder: q,
|
|
551
|
+
joinMap,
|
|
552
|
+
joinFilters,
|
|
553
|
+
aliasTables,
|
|
554
|
+
qualifyBase: (column) => qualify(column),
|
|
555
|
+
applyAliasScope: (builder, alias) => applyAliasScopes(builder, alias),
|
|
556
|
+
applyFilterOp: (builder, column, op, value) => applyFilterOp(builder, column, op, value),
|
|
557
|
+
applyJoinFilterOp,
|
|
558
|
+
columnExists: (tbl, column) => this.columnExists(tbl, column),
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
// Selection (base columns only here; cf:* handled later)
|
|
562
|
+
if (isSortKeysProjection) {
|
|
563
|
+
q = q.select(sql.ref(qualify('id')).as('id'))
|
|
564
|
+
for (const s of resolvedSorts) {
|
|
565
|
+
if (!s.field.startsWith('cf:')) q = q.select(sql.ref(qualify(s.field)).as(s.field))
|
|
566
|
+
}
|
|
567
|
+
} else if (opts.fields && opts.fields.length) {
|
|
568
|
+
const cols = new Set(opts.fields.filter((f) => !f.startsWith('cf:')))
|
|
569
|
+
if (requiresPlaintextSort) {
|
|
570
|
+
for (const field of encryptedSortFields) cols.add(field)
|
|
571
|
+
}
|
|
572
|
+
for (const c of cols) {
|
|
573
|
+
// Qualify and alias to base names to avoid ambiguity
|
|
574
|
+
q = q.select(sql.ref(qualify(c)).as(c))
|
|
575
|
+
}
|
|
576
|
+
} else {
|
|
577
|
+
// Default to selecting only base table columns to avoid ambiguity when joining
|
|
578
|
+
q = q.select(sql`${sql.ref(table)}.*`.as('__all'))
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Resolve which custom fields to include
|
|
582
|
+
const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify)
|
|
583
|
+
q = cfSourcesResult.builder
|
|
584
|
+
const cfSources = cfSourcesResult.sources
|
|
585
|
+
const entityIdToSource = new Map<string, ResolvedCustomFieldSource>()
|
|
586
|
+
for (const source of cfSources) {
|
|
587
|
+
entityIdToSource.set(String(source.entityId), source)
|
|
588
|
+
}
|
|
589
|
+
const requestedCustomFieldKeys = (!isSortKeysProjection && Array.isArray(opts.includeCustomFields))
|
|
590
|
+
? opts.includeCustomFields.map((key) => String(key))
|
|
591
|
+
: []
|
|
592
|
+
const cfKeys = new Set<string>()
|
|
593
|
+
const keySource = new Map<string, ResolvedCustomFieldSource>()
|
|
594
|
+
// Custom-field definition index threaded onto the result so the CRUD factory
|
|
595
|
+
// can decorate list rows without reloading definitions from the DB (#2133).
|
|
596
|
+
// Output-only — never resolved for the slim sortKeys projection.
|
|
597
|
+
let resolvedCustomFieldDefinitions: ResolvedCustomFieldDefinitions | undefined
|
|
598
|
+
// Explicit in fields/filters
|
|
599
|
+
if (!isSortKeysProjection) {
|
|
600
|
+
for (const f of (opts.fields || [])) {
|
|
601
|
+
if (typeof f === 'string' && f.startsWith('cf:')) cfKeys.add(f.slice(3))
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
for (const f of cfFilters) {
|
|
605
|
+
if (typeof f.field === 'string' && f.field.startsWith('cf:')) cfKeys.add(f.field.slice(3))
|
|
606
|
+
}
|
|
607
|
+
if (!isSortKeysProjection && opts.includeCustomFields === true) {
|
|
608
|
+
if (entityIdToSource.size > 0) {
|
|
609
|
+
const entityIdList = Array.from(entityIdToSource.keys())
|
|
610
|
+
const entityOrder = new Map<string, number>()
|
|
611
|
+
entityIdList.forEach((id, idx) => entityOrder.set(id, idx))
|
|
612
|
+
const rows = await db
|
|
613
|
+
.selectFrom('custom_field_defs' as any)
|
|
614
|
+
.select([
|
|
615
|
+
'key' as any,
|
|
616
|
+
'entity_id' as any,
|
|
617
|
+
'config_json' as any,
|
|
618
|
+
'kind' as any,
|
|
619
|
+
'organization_id' as any,
|
|
620
|
+
'tenant_id' as any,
|
|
621
|
+
'updated_at' as any,
|
|
622
|
+
'deleted_at' as any,
|
|
623
|
+
])
|
|
624
|
+
.where('entity_id' as any, 'in', entityIdList)
|
|
625
|
+
.where('is_active' as any, '=', true)
|
|
626
|
+
.where((eb: any) => eb.or([
|
|
627
|
+
eb('tenant_id' as any, '=', tenantId),
|
|
628
|
+
eb('tenant_id' as any, 'is', null),
|
|
629
|
+
]))
|
|
630
|
+
.execute() as Array<{
|
|
631
|
+
key: string
|
|
632
|
+
entity_id: string
|
|
633
|
+
config_json: unknown
|
|
634
|
+
kind: string
|
|
635
|
+
organization_id: string | null
|
|
636
|
+
tenant_id: string | null
|
|
637
|
+
updated_at: Date | string | number | null
|
|
638
|
+
deleted_at: Date | string | number | null
|
|
639
|
+
}>
|
|
640
|
+
// Build the decoration index from the same rows, scoped exactly like the
|
|
641
|
+
// factory's loadCustomFieldDefinitionIndex (tenant + is_active already
|
|
642
|
+
// applied in SQL; org + soft-delete applied in the shared builder).
|
|
643
|
+
const orgCandidates = resolveCfDefIndexOrgCandidates(opts.organizationIds, opts.organizationId ?? null)
|
|
644
|
+
const definitionRows: CustomFieldDefinitionRow[] = rows.map((row) => ({
|
|
646
645
|
key: String(row.key),
|
|
647
646
|
entityId: String(row.entity_id),
|
|
648
|
-
kind: String(row.kind
|
|
649
|
-
|
|
647
|
+
kind: row.kind == null ? null : String(row.kind),
|
|
648
|
+
configJson: row.config_json,
|
|
649
|
+
organizationId: row.organization_id == null ? null : String(row.organization_id),
|
|
650
|
+
tenantId: row.tenant_id == null ? null : String(row.tenant_id),
|
|
651
|
+
deletedAt: row.deleted_at ?? null,
|
|
652
|
+
updatedAt: row.updated_at ?? null,
|
|
653
|
+
}))
|
|
654
|
+
resolvedCustomFieldDefinitions = {
|
|
655
|
+
index: buildCustomFieldDefinitionIndexFromRows(definitionRows, { organizationIds: orgCandidates }),
|
|
656
|
+
entityIds: entityIdList,
|
|
657
|
+
tenantId: tenantId ?? null,
|
|
658
|
+
organizationIds: orgCandidates,
|
|
650
659
|
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
660
|
+
type ScoredCustomFieldRow = {
|
|
661
|
+
key: string
|
|
662
|
+
entityId: string
|
|
663
|
+
kind: string
|
|
664
|
+
config: Record<string, unknown>
|
|
665
|
+
}
|
|
666
|
+
const sorted: ScoredCustomFieldRow[] = rows.map((row) => {
|
|
667
|
+
const raw = row.config_json
|
|
668
|
+
let cfg: Record<string, any> = {}
|
|
669
|
+
if (raw && typeof raw === 'string') {
|
|
670
|
+
try { cfg = JSON.parse(raw) } catch { cfg = {} }
|
|
671
|
+
} else if (raw && typeof raw === 'object') {
|
|
672
|
+
cfg = raw as Record<string, any>
|
|
673
|
+
}
|
|
674
|
+
return {
|
|
675
|
+
key: String(row.key),
|
|
676
|
+
entityId: String(row.entity_id),
|
|
677
|
+
kind: String(row.kind || ''),
|
|
678
|
+
config: cfg,
|
|
679
|
+
}
|
|
680
|
+
})
|
|
681
|
+
sorted.sort((a, b) => {
|
|
682
|
+
const ai = entityOrder.get(a.entityId) ?? Number.MAX_SAFE_INTEGER
|
|
683
|
+
const bi = entityOrder.get(b.entityId) ?? Number.MAX_SAFE_INTEGER
|
|
684
|
+
if (ai !== bi) return ai - bi
|
|
685
|
+
return a.key.localeCompare(b.key)
|
|
686
|
+
})
|
|
687
|
+
const selectedSources = new Map<string, { source: ResolvedCustomFieldSource; score: number; penalty: number; entityIndex: number }>()
|
|
688
|
+
for (const row of sorted) {
|
|
689
|
+
const source = entityIdToSource.get(row.entityId)
|
|
690
|
+
if (!source) continue
|
|
691
|
+
const cfg = row.config || {}
|
|
692
|
+
const entityIndex = entityOrder.get(row.entityId) ?? Number.MAX_SAFE_INTEGER
|
|
693
|
+
const scores = computeCustomFieldScore(cfg, row.kind, entityIndex)
|
|
694
|
+
const existing = selectedSources.get(row.key)
|
|
695
|
+
if (!existing || scores.base > existing.score || (scores.base === existing.score && (scores.penalty < existing.penalty || (scores.penalty === existing.penalty && scores.entityIndex < existing.entityIndex)))) {
|
|
696
|
+
selectedSources.set(row.key, { source, score: scores.base, penalty: scores.penalty, entityIndex: scores.entityIndex })
|
|
697
|
+
}
|
|
698
|
+
cfKeys.add(row.key)
|
|
699
|
+
}
|
|
700
|
+
for (const [key, entry] of selectedSources.entries()) {
|
|
701
|
+
keySource.set(key, entry.source)
|
|
668
702
|
}
|
|
669
|
-
cfKeys.add(row.key)
|
|
670
|
-
}
|
|
671
|
-
for (const [key, entry] of selectedSources.entries()) {
|
|
672
|
-
keySource.set(key, entry.source)
|
|
673
703
|
}
|
|
704
|
+
} else if (!isSortKeysProjection && requestedCustomFieldKeys.length > 0) {
|
|
705
|
+
for (const key of requestedCustomFieldKeys) cfKeys.add(key)
|
|
674
706
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
if (!source) continue
|
|
694
|
-
if (!keySource.has(row.key)) keySource.set(row.key, source)
|
|
707
|
+
const unresolvedKeys = Array.from(cfKeys).filter((key) => !keySource.has(key))
|
|
708
|
+
if (unresolvedKeys.length > 0 && entityIdToSource.size > 0) {
|
|
709
|
+
const rows = await db
|
|
710
|
+
.selectFrom('custom_field_defs' as any)
|
|
711
|
+
.select(['key' as any, 'entity_id' as any])
|
|
712
|
+
.where('entity_id' as any, 'in', Array.from(entityIdToSource.keys()))
|
|
713
|
+
.where('key' as any, 'in', unresolvedKeys)
|
|
714
|
+
.where('is_active' as any, '=', true)
|
|
715
|
+
.where((eb: any) => eb.or([
|
|
716
|
+
eb('tenant_id' as any, '=', tenantId),
|
|
717
|
+
eb('tenant_id' as any, 'is', null),
|
|
718
|
+
]))
|
|
719
|
+
.execute() as Array<{ key: string; entity_id: string }>
|
|
720
|
+
for (const row of rows) {
|
|
721
|
+
const source = entityIdToSource.get(String(row.entity_id))
|
|
722
|
+
if (!source) continue
|
|
723
|
+
if (!keySource.has(row.key)) keySource.set(row.key, source)
|
|
724
|
+
}
|
|
695
725
|
}
|
|
696
|
-
}
|
|
697
726
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
END`
|
|
739
|
-
cfValueExprByKey[key] = caseExpr
|
|
740
|
-
const alias = sanitize(`cf:${key}`)
|
|
741
|
-
// Project as aggregated to avoid duplicates when multi values exist
|
|
742
|
-
if ((opts.fields || []).includes(`cf:${key}`) || opts.includeCustomFields === true || (requestedCustomFieldKeys.length > 0 && requestedCustomFieldKeys.includes(key))) {
|
|
743
|
-
const multiAlias = `${alias}__is_multi`
|
|
744
|
-
const isMultiExpr = sql<boolean>`bool_or(coalesce((${sql.ref(`${defAlias}.config_json`)}->>'multi')::boolean, false))`
|
|
745
|
-
const aggregatedArray = sql<unknown>`array_remove(array_agg(DISTINCT ${caseExpr}), NULL)`
|
|
746
|
-
const projExpr = sql<unknown>`CASE WHEN ${isMultiExpr}
|
|
747
|
-
THEN to_jsonb(${aggregatedArray})
|
|
748
|
-
ELSE to_jsonb(max(${caseExpr}))
|
|
727
|
+
const cfValueExprByKey: Record<string, RawBuilder<string | null>> = {}
|
|
728
|
+
const cfSelectedAliases: string[] = []
|
|
729
|
+
const cfJsonAliases = new Set<string>()
|
|
730
|
+
const cfMultiAliasByAlias = new Map<string, string>()
|
|
731
|
+
for (const key of cfKeys) {
|
|
732
|
+
const source = keySource.get(key)
|
|
733
|
+
if (!source) continue
|
|
734
|
+
const entityIdForKey = source.entityId
|
|
735
|
+
const recordIdExpr = source.recordIdExpr
|
|
736
|
+
const sourceAliasSafe = sanitize(source.alias || 'src')
|
|
737
|
+
const keyAliasSafe = sanitize(key)
|
|
738
|
+
const defAlias = `cfd_${sourceAliasSafe}_${keyAliasSafe}`
|
|
739
|
+
const valAlias = `cfv_${sourceAliasSafe}_${keyAliasSafe}`
|
|
740
|
+
// Join definitions for kind resolution
|
|
741
|
+
q = q.leftJoin(`custom_field_defs as ${defAlias}` as any, (jb: any) =>
|
|
742
|
+
jb.on(`${defAlias}.entity_id`, '=', String(entityIdForKey))
|
|
743
|
+
.on(`${defAlias}.key`, '=', key)
|
|
744
|
+
.on(`${defAlias}.is_active`, '=', true)
|
|
745
|
+
.on((eb: any) => eb.or([
|
|
746
|
+
eb(`${defAlias}.tenant_id`, '=', tenantId),
|
|
747
|
+
eb(`${defAlias}.tenant_id`, 'is', null),
|
|
748
|
+
]))
|
|
749
|
+
)
|
|
750
|
+
// Join values with record match
|
|
751
|
+
q = q.leftJoin(`custom_field_values as ${valAlias}` as any, (jb: any) =>
|
|
752
|
+
jb.on(`${valAlias}.entity_id`, '=', String(entityIdForKey))
|
|
753
|
+
.on(`${valAlias}.field_key`, '=', key)
|
|
754
|
+
.onRef(`${valAlias}.record_id`, '=', recordIdExpr as any)
|
|
755
|
+
.on((eb: any) => eb.or([
|
|
756
|
+
eb(`${valAlias}.tenant_id`, '=', tenantId),
|
|
757
|
+
eb(`${valAlias}.tenant_id`, 'is', null),
|
|
758
|
+
]))
|
|
759
|
+
)
|
|
760
|
+
// Force a common SQL type across branches to avoid Postgres CASE type conflicts
|
|
761
|
+
const caseExpr = sql<string | null>`CASE ${sql.ref(`${defAlias}.kind`)}
|
|
762
|
+
WHEN 'integer' THEN (${sql.ref(`${valAlias}.value_int`)})::text
|
|
763
|
+
WHEN 'float' THEN (${sql.ref(`${valAlias}.value_float`)})::text
|
|
764
|
+
WHEN 'boolean' THEN (${sql.ref(`${valAlias}.value_bool`)})::text
|
|
765
|
+
WHEN 'multiline' THEN (${sql.ref(`${valAlias}.value_multiline`)})::text
|
|
766
|
+
ELSE (${sql.ref(`${valAlias}.value_text`)})::text
|
|
749
767
|
END`
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
768
|
+
cfValueExprByKey[key] = caseExpr
|
|
769
|
+
const alias = sanitize(`cf:${key}`)
|
|
770
|
+
// Project as aggregated to avoid duplicates when multi values exist
|
|
771
|
+
if (!isSortKeysProjection && ((opts.fields || []).includes(`cf:${key}`) || opts.includeCustomFields === true || (requestedCustomFieldKeys.length > 0 && requestedCustomFieldKeys.includes(key)))) {
|
|
772
|
+
const multiAlias = `${alias}__is_multi`
|
|
773
|
+
const isMultiExpr = sql<boolean>`bool_or(coalesce((${sql.ref(`${defAlias}.config_json`)}->>'multi')::boolean, false))`
|
|
774
|
+
const aggregatedArray = sql<unknown>`array_remove(array_agg(DISTINCT ${caseExpr}), NULL)`
|
|
775
|
+
const projExpr = sql<unknown>`CASE WHEN ${isMultiExpr}
|
|
776
|
+
THEN to_jsonb(${aggregatedArray})
|
|
777
|
+
ELSE to_jsonb(max(${caseExpr}))
|
|
778
|
+
END`
|
|
779
|
+
q = q.select(projExpr.as(alias))
|
|
780
|
+
q = q.select(isMultiExpr.as(multiAlias))
|
|
781
|
+
cfSelectedAliases.push(alias)
|
|
782
|
+
cfJsonAliases.add(alias)
|
|
783
|
+
cfMultiAliasByAlias.set(alias, multiAlias)
|
|
784
|
+
}
|
|
755
785
|
}
|
|
756
|
-
}
|
|
757
786
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
787
|
+
// Apply cf:* filters (on raw expressions)
|
|
788
|
+
for (const f of cfFilters) {
|
|
789
|
+
if (!f.field.startsWith('cf:')) continue
|
|
790
|
+
const key = f.field.slice(3)
|
|
791
|
+
const expr = cfValueExprByKey[key]
|
|
792
|
+
if (!expr) continue
|
|
793
|
+
if ((f.op === 'like' || f.op === 'ilike') && searchActive && typeof f.value === 'string') {
|
|
794
|
+
const tokens = tokenizeText(String(f.value), searchConfig)
|
|
795
|
+
const hashes = tokens.hashes
|
|
796
|
+
if (hashes.length) {
|
|
797
|
+
const result = this.applySearchTokens(q, {
|
|
798
|
+
entity: String(entity),
|
|
799
|
+
field: f.field,
|
|
800
|
+
hashes,
|
|
801
|
+
recordIdColumn,
|
|
802
|
+
tenantId: opts.tenantId ?? null,
|
|
803
|
+
organizationScope: orgScope,
|
|
804
|
+
tokens: tokens.tokens,
|
|
805
|
+
})
|
|
806
|
+
this.logSearchDebug('search:cf-filter', {
|
|
807
|
+
entity: String(entity),
|
|
808
|
+
field: f.field,
|
|
809
|
+
tokens: tokens.tokens,
|
|
810
|
+
hashes,
|
|
811
|
+
applied: result.applied,
|
|
812
|
+
tenantId: opts.tenantId ?? null,
|
|
813
|
+
organizationScope: orgScope,
|
|
814
|
+
})
|
|
815
|
+
if (result.applied) {
|
|
816
|
+
q = result.builder
|
|
817
|
+
continue
|
|
818
|
+
}
|
|
819
|
+
} else {
|
|
820
|
+
this.logSearchDebug('search:cf-skip-empty-hashes', {
|
|
821
|
+
entity: String(entity),
|
|
822
|
+
field: f.field,
|
|
823
|
+
value: f.value,
|
|
824
|
+
})
|
|
789
825
|
}
|
|
790
|
-
} else {
|
|
791
|
-
this.logSearchDebug('search:cf-skip-empty-hashes', {
|
|
792
|
-
entity: String(entity),
|
|
793
|
-
field: f.field,
|
|
794
|
-
value: f.value,
|
|
795
|
-
})
|
|
796
826
|
}
|
|
827
|
+
q = this.applyColumnOp(q, expr, f.op, f.value)
|
|
797
828
|
}
|
|
798
|
-
q = this.applyColumnOp(q, expr, f.op, f.value)
|
|
799
|
-
}
|
|
800
829
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
830
|
+
// Entity extensions joins (no selection yet; enables future filters/projections)
|
|
831
|
+
if (opts.includeExtensions) {
|
|
832
|
+
const { getModules } = await import('@open-mercato/shared/lib/i18n/server')
|
|
833
|
+
const allMods = getModules() as any[]
|
|
834
|
+
const allExts = allMods.flatMap((m) => (m as any).entityExtensions || [])
|
|
835
|
+
const exts = allExts.filter((e: any) => e.base === entity)
|
|
836
|
+
const chosen = Array.isArray(opts.includeExtensions)
|
|
837
|
+
? exts.filter((e: any) => (opts.includeExtensions as string[]).includes(e.extension))
|
|
838
|
+
: exts
|
|
839
|
+
for (const e of chosen) {
|
|
840
|
+
const [, extName] = (e.extension as string).split(':')
|
|
841
|
+
const extTable = extName.endsWith('s') ? extName : `${extName}s`
|
|
842
|
+
const alias = `ext_${sanitize(extName)}`
|
|
843
|
+
q = q.leftJoin(`${extTable} as ${alias}` as any, (jb: any) =>
|
|
844
|
+
jb.onRef(`${alias}.${e.join.extensionKey}`, '=', `${table}.${e.join.baseKey}`)
|
|
845
|
+
)
|
|
846
|
+
}
|
|
817
847
|
}
|
|
818
|
-
}
|
|
819
848
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
849
|
+
// Sorting: base fields and cf:* (use aggregated alias for cf)
|
|
850
|
+
for (const s of resolvedSorts) {
|
|
851
|
+
if (s.field.startsWith('cf:')) {
|
|
852
|
+
const key = s.field.slice(3)
|
|
853
|
+
const alias = sanitize(`cf:${key}`)
|
|
854
|
+
// Ensure included in projection to sort by
|
|
855
|
+
if (!cfSelectedAliases.includes(alias)) {
|
|
856
|
+
const expr = cfValueExprByKey[key]
|
|
857
|
+
if (expr) {
|
|
858
|
+
q = q.select(sql<string | null>`max(${expr})`.as(alias))
|
|
859
|
+
cfSelectedAliases.push(alias)
|
|
860
|
+
}
|
|
831
861
|
}
|
|
862
|
+
if (!requiresPlaintextSort) q = q.orderBy(alias, (s.dir ?? 'asc') as any)
|
|
863
|
+
} else {
|
|
864
|
+
if (!requiresPlaintextSort) q = q.orderBy(qualify(s.field), (s.dir ?? 'asc') as any)
|
|
832
865
|
}
|
|
833
|
-
if (!requiresPlaintextSort) q = q.orderBy(alias, (s.dir ?? 'asc') as any)
|
|
834
|
-
} else {
|
|
835
|
-
if (!requiresPlaintextSort) q = q.orderBy(qualify(s.field), (s.dir ?? 'asc') as any)
|
|
836
866
|
}
|
|
867
|
+
|
|
868
|
+
// Deduplicate if we joined CFs or extensions by grouping on base id
|
|
869
|
+
const hasJoinedAggregates = (opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? (opts.includeExtensions.length > 0) : true)) || Object.keys(cfValueExprByKey).length > 0
|
|
870
|
+
if (hasJoinedAggregates) {
|
|
871
|
+
q = q.groupBy(`${table}.id`)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
return { builder: q, hasJoinedAggregates, cfJsonAliases, cfMultiAliasByAlias, resolvedCustomFieldDefinitions }
|
|
837
875
|
}
|
|
838
876
|
|
|
839
877
|
// Pagination
|
|
840
878
|
const page = opts.page?.page ?? 1
|
|
841
879
|
const pageSize = opts.page?.pageSize ?? 20
|
|
842
|
-
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
880
|
+
|
|
881
|
+
const {
|
|
882
|
+
builder: qFull,
|
|
883
|
+
hasJoinedAggregates,
|
|
884
|
+
cfJsonAliases,
|
|
885
|
+
cfMultiAliasByAlias,
|
|
886
|
+
resolvedCustomFieldDefinitions,
|
|
887
|
+
} = await buildQuery('full')
|
|
888
|
+
|
|
847
889
|
// `count(distinct base.id)` is only required when a join can multiply base rows
|
|
848
890
|
// (CF/extension aggregates, explicit relation joins, or custom-field sources).
|
|
849
891
|
// Without such joins base.id is the unique PK, so `count(*)` is equivalent and
|
|
@@ -856,17 +898,41 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
856
898
|
? sql<string>`count(distinct ${sql.ref(`${table}.id`)})`
|
|
857
899
|
: sql<string>`count(*)`
|
|
858
900
|
const countBuilder = hasJoinedAggregates
|
|
859
|
-
?
|
|
860
|
-
:
|
|
901
|
+
? qFull.clearSelect().clearOrderBy().clearGroupBy().select(countExpr.as('count'))
|
|
902
|
+
: qFull.clearSelect().clearOrderBy().select(countExpr.as('count'))
|
|
861
903
|
const countRow = await countBuilder.executeTakeFirst() as { count: unknown } | undefined
|
|
862
904
|
const total = Number((countRow as any)?.count ?? 0)
|
|
863
|
-
const dataQuery = requiresPlaintextSort
|
|
864
|
-
? q
|
|
865
|
-
: q.limit(pageSize).offset((page - 1) * pageSize)
|
|
866
|
-
const items = await dataQuery.execute() as any[]
|
|
867
905
|
|
|
868
|
-
|
|
869
|
-
|
|
906
|
+
const svc = encryptionService
|
|
907
|
+
const decryptPayload =
|
|
908
|
+
svc?.decryptEntityPayload?.bind(svc) as
|
|
909
|
+
| ((
|
|
910
|
+
entityId: EntityId,
|
|
911
|
+
payload: Record<string, unknown>,
|
|
912
|
+
tenantId: string | null,
|
|
913
|
+
organizationId: string | null,
|
|
914
|
+
) => Promise<Record<string, unknown>>)
|
|
915
|
+
| null
|
|
916
|
+
|
|
917
|
+
const decryptRow = async (item: ResultRow): Promise<ResultRow> => {
|
|
918
|
+
if (!decryptPayload) return item
|
|
919
|
+
try {
|
|
920
|
+
const decrypted = await decryptPayload(
|
|
921
|
+
entity,
|
|
922
|
+
item,
|
|
923
|
+
(item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null) as string | null,
|
|
924
|
+
(item?.organization_id ?? item?.organizationId ?? fallbackOrgId ?? null) as string | null,
|
|
925
|
+
)
|
|
926
|
+
return { ...item, ...decrypted }
|
|
927
|
+
} catch (err) {
|
|
928
|
+
console.error('QueryEngine: error decrypting entity payload', err)
|
|
929
|
+
return item
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const normalizeCfJsonAliases = (rows: ResultRow[]) => {
|
|
934
|
+
if (cfJsonAliases.size === 0) return
|
|
935
|
+
for (const row of rows) {
|
|
870
936
|
for (const alias of cfJsonAliases) {
|
|
871
937
|
const multiAlias = cfMultiAliasByAlias.get(alias)
|
|
872
938
|
const isMulti = multiAlias ? Boolean(row[multiAlias]) : false
|
|
@@ -887,42 +953,66 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
887
953
|
}
|
|
888
954
|
}
|
|
889
955
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
}
|
|
917
|
-
|
|
956
|
+
let pagedItems: ResultRow[]
|
|
957
|
+
let encryptedSortRowCapWarning: EncryptedSortRowCapWarning | undefined
|
|
958
|
+
|
|
959
|
+
if (requiresPlaintextSort) {
|
|
960
|
+
// Phase 1: slim id+sort-columns candidate scan, bounded-concurrency decrypt,
|
|
961
|
+
// in-memory sort over the full candidate set, then slice to the page's ids.
|
|
962
|
+
const cap = resolveEncryptedSortMaxRows()
|
|
963
|
+
let qSort = (await buildQuery('sortKeys')).builder
|
|
964
|
+
if (cap !== null) {
|
|
965
|
+
qSort = qSort.limit(cap).orderBy(qualify('id'), 'asc' as any)
|
|
966
|
+
}
|
|
967
|
+
const candidateRows = await qSort.execute() as ResultRow[]
|
|
968
|
+
const decryptedCandidates = decryptPayload
|
|
969
|
+
? await mapWithConcurrency(candidateRows, DECRYPT_CONCURRENCY, decryptRow)
|
|
970
|
+
: candidateRows
|
|
971
|
+
const orderedCandidates = sortRowsInMemory(decryptedCandidates, resolvedSorts)
|
|
972
|
+
const pageIds = orderedCandidates
|
|
973
|
+
.slice((page - 1) * pageSize, page * pageSize)
|
|
974
|
+
.map((row) => row.id)
|
|
975
|
+
|
|
976
|
+
if (cap !== null && total > cap) {
|
|
977
|
+
encryptedSortRowCapWarning = {
|
|
978
|
+
entity,
|
|
979
|
+
sortFields: resolvedSorts.map((s) => s.field),
|
|
980
|
+
maxRows: cap,
|
|
981
|
+
totalMatched: total,
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Phase 2: fetch + decrypt full rows for just the page's ids, then reassemble
|
|
986
|
+
// in phase-1's order (SQL `WHERE id IN (...)` order is unspecified — never
|
|
987
|
+
// trust it for ordering).
|
|
988
|
+
if (pageIds.length === 0) {
|
|
989
|
+
pagedItems = []
|
|
990
|
+
} else {
|
|
991
|
+
const pageRows = await qFull.where(qualify('id'), 'in', pageIds).execute() as ResultRow[]
|
|
992
|
+
normalizeCfJsonAliases(pageRows)
|
|
993
|
+
const decryptedPageRows = decryptPayload
|
|
994
|
+
? await mapWithConcurrency(pageRows, DECRYPT_CONCURRENCY, decryptRow)
|
|
995
|
+
: pageRows
|
|
996
|
+
const byId = new Map(decryptedPageRows.map((row) => [String(row.id), row]))
|
|
997
|
+
pagedItems = pageIds
|
|
998
|
+
.map((id) => byId.get(String(id)))
|
|
999
|
+
.filter((row): row is ResultRow => row != null)
|
|
1000
|
+
}
|
|
1001
|
+
} else {
|
|
1002
|
+
const dataQuery = qFull.limit(pageSize).offset((page - 1) * pageSize)
|
|
1003
|
+
const items = await dataQuery.execute() as ResultRow[]
|
|
1004
|
+
normalizeCfJsonAliases(items)
|
|
1005
|
+
pagedItems = decryptPayload
|
|
1006
|
+
? await mapWithConcurrency(items, DECRYPT_CONCURRENCY, decryptRow)
|
|
1007
|
+
: items
|
|
918
1008
|
}
|
|
919
1009
|
|
|
920
|
-
|
|
921
|
-
? sortRowsInMemory(decryptedItems as Record<string, unknown>[], resolvedSorts)
|
|
922
|
-
.slice((page - 1) * pageSize, page * pageSize)
|
|
923
|
-
: decryptedItems
|
|
1010
|
+
let queryResult: QueryResult<T> = { items: pagedItems as unknown as T[], page, pageSize, total }
|
|
924
1011
|
|
|
925
|
-
|
|
1012
|
+
if (encryptedSortRowCapWarning) {
|
|
1013
|
+
const meta: QueryResultMeta = { encryptedSortRowCapWarning }
|
|
1014
|
+
queryResult.meta = meta
|
|
1015
|
+
}
|
|
926
1016
|
|
|
927
1017
|
// --- UMES query extension: after-query pipeline ---
|
|
928
1018
|
if (ext && extensionCtx) {
|