@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.
@@ -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: [
@@ -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(['key' as any, 'entity_id' as any, 'config_json' as any, 'kind' as any])
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<{ key: string; entity_id: string; config_json: unknown; kind: string }>
555
- type CustomFieldDefinitionRow = {
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: CustomFieldDefinitionRow[] = rows.map((row) => {
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
 
@@ -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 {