@open-mercato/shared 0.6.4-develop.4210.1.d412061cfe → 0.6.4-develop.4236.1.9fa6806b34
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/commands/flush.js +17 -12
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/crud/custom-field-definition-index.js +146 -0
- package/dist/lib/crud/custom-field-definition-index.js.map +7 -0
- package/dist/lib/crud/custom-fields.js +19 -102
- package/dist/lib/crud/custom-fields.js.map +2 -2
- package/dist/lib/crud/factory.js +95 -68
- package/dist/lib/crud/factory.js.map +3 -3
- package/dist/lib/query/engine.js +35 -1
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/commands/__tests__/flush.test.ts +78 -2
- package/src/lib/commands/flush.ts +72 -19
- package/src/lib/crud/__tests__/crud-factory.test.ts +99 -0
- package/src/lib/crud/__tests__/custom-field-definition-index.test.ts +136 -0
- package/src/lib/crud/custom-field-definition-index.ts +233 -0
- package/src/lib/crud/custom-fields.ts +24 -146
- package/src/lib/crud/factory.ts +92 -55
- package/src/lib/query/__tests__/engine.test.ts +80 -0
- package/src/lib/query/engine.ts +57 -4
- package/src/lib/query/types.ts +9 -0
|
@@ -517,6 +517,86 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
517
517
|
expect(hasInFilter).toBe(true)
|
|
518
518
|
})
|
|
519
519
|
|
|
520
|
+
test('exposes resolved customFieldDefinitions when includeCustomFields is true (issue #2133)', async () => {
|
|
521
|
+
const fakeDb = createFakeKysely()
|
|
522
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
523
|
+
const res = await engine.query('auth:user', {
|
|
524
|
+
includeCustomFields: true,
|
|
525
|
+
fields: ['id'],
|
|
526
|
+
organizationId: '1',
|
|
527
|
+
tenantId: 't1',
|
|
528
|
+
page: { page: 1, pageSize: 10 },
|
|
529
|
+
})
|
|
530
|
+
expect(res.customFieldDefinitions).toBeDefined()
|
|
531
|
+
expect(res.customFieldDefinitions!.entityIds).toEqual(['auth:user'])
|
|
532
|
+
expect(res.customFieldDefinitions!.tenantId).toBe('t1')
|
|
533
|
+
expect(res.customFieldDefinitions!.organizationIds).toEqual(['1'])
|
|
534
|
+
expect(Array.from(res.customFieldDefinitions!.index.keys()).sort()).toEqual(['industry', 'vip'])
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
test('exposed customFieldDefinitions index drops soft-deleted and foreign-org defs', async () => {
|
|
538
|
+
const fakeDb = createFakeKysely({
|
|
539
|
+
custom_field_defs: [
|
|
540
|
+
{ key: 'kept', entity_id: 'auth:user', is_active: true, config_json: '{}', kind: 'text', organization_id: null, tenant_id: 't1', updated_at: null, deleted_at: null },
|
|
541
|
+
{ key: 'foreign', entity_id: 'auth:user', is_active: true, config_json: '{}', kind: 'text', organization_id: 'other-org', tenant_id: 't1', updated_at: null, deleted_at: null },
|
|
542
|
+
{ key: 'gone', entity_id: 'auth:user', is_active: true, config_json: '{}', kind: 'text', organization_id: null, tenant_id: 't1', updated_at: null, deleted_at: '2026-01-01T00:00:00.000Z' },
|
|
543
|
+
],
|
|
544
|
+
custom_field_values: [],
|
|
545
|
+
})
|
|
546
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
547
|
+
const res = await engine.query('auth:user', {
|
|
548
|
+
includeCustomFields: true,
|
|
549
|
+
fields: ['id'],
|
|
550
|
+
organizationId: 'allowed-org',
|
|
551
|
+
tenantId: 't1',
|
|
552
|
+
page: { page: 1, pageSize: 10 },
|
|
553
|
+
})
|
|
554
|
+
expect(Array.from(res.customFieldDefinitions!.index.keys())).toEqual(['kept'])
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
test('customFieldDefinitions entityIds include base entity plus custom field sources', async () => {
|
|
558
|
+
const fakeDb = createFakeKysely({
|
|
559
|
+
custom_field_defs: [
|
|
560
|
+
{ key: 'birthday', entity_id: 'customers:customer_person_profile', is_active: true, config_json: '{}', kind: 'text', organization_id: null, tenant_id: 't1', updated_at: null, deleted_at: null },
|
|
561
|
+
],
|
|
562
|
+
custom_field_values: [],
|
|
563
|
+
customer_entities: [],
|
|
564
|
+
customer_people: [],
|
|
565
|
+
})
|
|
566
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
567
|
+
const res = await engine.query('customers:customer_entity', {
|
|
568
|
+
tenantId: 't1',
|
|
569
|
+
includeCustomFields: true,
|
|
570
|
+
fields: ['id'],
|
|
571
|
+
customFieldSources: [
|
|
572
|
+
{
|
|
573
|
+
entityId: 'customers:customer_person_profile',
|
|
574
|
+
table: 'customer_people',
|
|
575
|
+
alias: 'person_profile',
|
|
576
|
+
recordIdColumn: 'id',
|
|
577
|
+
join: { fromField: 'id', toField: 'entity_id' },
|
|
578
|
+
},
|
|
579
|
+
],
|
|
580
|
+
page: { page: 1, pageSize: 10 },
|
|
581
|
+
})
|
|
582
|
+
expect(res.customFieldDefinitions!.entityIds.sort()).toEqual([
|
|
583
|
+
'customers:customer_entity',
|
|
584
|
+
'customers:customer_person_profile',
|
|
585
|
+
])
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
test('does not expose customFieldDefinitions when includeCustomFields is a key list', async () => {
|
|
589
|
+
const fakeDb = createFakeKysely()
|
|
590
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
591
|
+
const res = await engine.query('auth:user', {
|
|
592
|
+
includeCustomFields: ['vip'],
|
|
593
|
+
fields: ['id', 'cf:vip'],
|
|
594
|
+
tenantId: 't1',
|
|
595
|
+
page: { page: 1, pageSize: 10 },
|
|
596
|
+
})
|
|
597
|
+
expect(res.customFieldDefinitions).toBeUndefined()
|
|
598
|
+
})
|
|
599
|
+
|
|
520
600
|
test('sorts encrypted base fields after decryption before pagination', async () => {
|
|
521
601
|
const fakeDb = createFakeKysely({
|
|
522
602
|
customer_entities: [
|
package/src/lib/query/engine.ts
CHANGED
|
@@ -14,6 +14,12 @@ import {
|
|
|
14
14
|
import { resolveSearchConfig } from '../search/config'
|
|
15
15
|
import { tokenizeText } from '../search/tokenize'
|
|
16
16
|
import { runBeforeQueryPipeline, runAfterQueryPipeline, type QueryExtensionContext } from './query-extension-runner'
|
|
17
|
+
import {
|
|
18
|
+
buildCustomFieldDefinitionIndexFromRows,
|
|
19
|
+
resolveCfDefIndexOrgCandidates,
|
|
20
|
+
type CustomFieldDefinitionRow,
|
|
21
|
+
type ResolvedCustomFieldDefinitions,
|
|
22
|
+
} from '../crud/custom-field-definition-index'
|
|
17
23
|
import { resolveEncryptedSortFields, sortRowsInMemory } from './encrypted-sort'
|
|
18
24
|
|
|
19
25
|
type AnyDb = Kysely<any>
|
|
@@ -530,6 +536,9 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
530
536
|
: []
|
|
531
537
|
const cfKeys = new Set<string>()
|
|
532
538
|
const keySource = new Map<string, ResolvedCustomFieldSource>()
|
|
539
|
+
// Custom-field definition index threaded onto the result so the CRUD factory
|
|
540
|
+
// can decorate list rows without reloading definitions from the DB (#2133).
|
|
541
|
+
let resolvedCustomFieldDefinitions: ResolvedCustomFieldDefinitions | undefined
|
|
533
542
|
// Explicit in fields/filters
|
|
534
543
|
for (const f of (opts.fields || [])) {
|
|
535
544
|
if (typeof f === 'string' && f.startsWith('cf:')) cfKeys.add(f.slice(3))
|
|
@@ -544,21 +553,59 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
544
553
|
entityIdList.forEach((id, idx) => entityOrder.set(id, idx))
|
|
545
554
|
const rows = await db
|
|
546
555
|
.selectFrom('custom_field_defs' as any)
|
|
547
|
-
.select([
|
|
556
|
+
.select([
|
|
557
|
+
'key' as any,
|
|
558
|
+
'entity_id' as any,
|
|
559
|
+
'config_json' as any,
|
|
560
|
+
'kind' as any,
|
|
561
|
+
'organization_id' as any,
|
|
562
|
+
'tenant_id' as any,
|
|
563
|
+
'updated_at' as any,
|
|
564
|
+
'deleted_at' as any,
|
|
565
|
+
])
|
|
548
566
|
.where('entity_id' as any, 'in', entityIdList)
|
|
549
567
|
.where('is_active' as any, '=', true)
|
|
550
568
|
.where((eb: any) => eb.or([
|
|
551
569
|
eb('tenant_id' as any, '=', tenantId),
|
|
552
570
|
eb('tenant_id' as any, 'is', null),
|
|
553
571
|
]))
|
|
554
|
-
.execute() as Array<{
|
|
555
|
-
|
|
572
|
+
.execute() as Array<{
|
|
573
|
+
key: string
|
|
574
|
+
entity_id: string
|
|
575
|
+
config_json: unknown
|
|
576
|
+
kind: string
|
|
577
|
+
organization_id: string | null
|
|
578
|
+
tenant_id: string | null
|
|
579
|
+
updated_at: Date | string | number | null
|
|
580
|
+
deleted_at: Date | string | number | null
|
|
581
|
+
}>
|
|
582
|
+
// Build the decoration index from the same rows, scoped exactly like the
|
|
583
|
+
// factory's loadCustomFieldDefinitionIndex (tenant + is_active already
|
|
584
|
+
// applied in SQL; org + soft-delete applied in the shared builder).
|
|
585
|
+
const orgCandidates = resolveCfDefIndexOrgCandidates(opts.organizationIds, opts.organizationId ?? null)
|
|
586
|
+
const definitionRows: CustomFieldDefinitionRow[] = rows.map((row) => ({
|
|
587
|
+
key: String(row.key),
|
|
588
|
+
entityId: String(row.entity_id),
|
|
589
|
+
kind: row.kind == null ? null : String(row.kind),
|
|
590
|
+
configJson: row.config_json,
|
|
591
|
+
organizationId: row.organization_id == null ? null : String(row.organization_id),
|
|
592
|
+
tenantId: row.tenant_id == null ? null : String(row.tenant_id),
|
|
593
|
+
deletedAt: row.deleted_at ?? null,
|
|
594
|
+
updatedAt: row.updated_at ?? null,
|
|
595
|
+
}))
|
|
596
|
+
resolvedCustomFieldDefinitions = {
|
|
597
|
+
index: buildCustomFieldDefinitionIndexFromRows(definitionRows, { organizationIds: orgCandidates }),
|
|
598
|
+
entityIds: entityIdList,
|
|
599
|
+
tenantId: tenantId ?? null,
|
|
600
|
+
organizationIds: orgCandidates,
|
|
601
|
+
}
|
|
602
|
+
type ScoredCustomFieldRow = {
|
|
556
603
|
key: string
|
|
557
604
|
entityId: string
|
|
558
605
|
kind: string
|
|
559
606
|
config: Record<string, unknown>
|
|
560
607
|
}
|
|
561
|
-
const sorted:
|
|
608
|
+
const sorted: ScoredCustomFieldRow[] = rows.map((row) => {
|
|
562
609
|
const raw = row.config_json
|
|
563
610
|
let cfg: Record<string, any> = {}
|
|
564
611
|
if (raw && typeof raw === 'string') {
|
|
@@ -848,6 +895,12 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
848
895
|
) as QueryResult<T>
|
|
849
896
|
}
|
|
850
897
|
|
|
898
|
+
// Attach after the extension pipeline so the field always survives even if a
|
|
899
|
+
// subscriber replaces the whole result object.
|
|
900
|
+
if (resolvedCustomFieldDefinitions) {
|
|
901
|
+
queryResult.customFieldDefinitions = resolvedCustomFieldDefinitions
|
|
902
|
+
}
|
|
903
|
+
|
|
851
904
|
return queryResult
|
|
852
905
|
}
|
|
853
906
|
|
package/src/lib/query/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
2
2
|
import type { Profiler } from '../profiler'
|
|
3
|
+
import type { ResolvedCustomFieldDefinitions } from '../crud/custom-field-definition-index'
|
|
3
4
|
|
|
4
5
|
export type FilterOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'like' | 'ilike' | 'exists'
|
|
5
6
|
|
|
@@ -147,6 +148,14 @@ export type QueryResult<T = any> = {
|
|
|
147
148
|
pageSize: number
|
|
148
149
|
total: number
|
|
149
150
|
meta?: QueryResultMeta
|
|
151
|
+
/**
|
|
152
|
+
* Custom-field definitions the engine resolved while building this result
|
|
153
|
+
* (only present when `includeCustomFields: true`). Lets the CRUD factory
|
|
154
|
+
* decorate list rows without reloading definitions from the DB (issue #2133).
|
|
155
|
+
* Internal contract — additive and optional; callers must treat absence as a
|
|
156
|
+
* cue to load definitions themselves.
|
|
157
|
+
*/
|
|
158
|
+
customFieldDefinitions?: ResolvedCustomFieldDefinitions
|
|
150
159
|
}
|
|
151
160
|
|
|
152
161
|
export interface QueryEngine {
|