@open-mercato/shared 0.6.4-develop.4217.1.c9aa050183 → 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.
@@ -6,6 +6,17 @@ import type { TenantDataEncryptionService } from '../encryption/tenantDataEncryp
6
6
  import { decryptCustomFieldValue, resolveTenantEncryptionService } from '../encryption/customFieldValues'
7
7
  import { parseBooleanToken } from '../boolean'
8
8
  import { extractCustomFieldEntries } from './custom-fields-client'
9
+ import {
10
+ buildCustomFieldDefinitionIndexFromRows,
11
+ normalizeDefinitionKey,
12
+ normalizeFieldsetFilter,
13
+ selectDefinitionForRecord,
14
+ type CustomFieldDefinitionIndex,
15
+ type CustomFieldDefinitionRow,
16
+ type CustomFieldDefinitionSummary,
17
+ } from './custom-field-definition-index'
18
+
19
+ export type { CustomFieldDefinitionSummary, CustomFieldDefinitionIndex } from './custom-field-definition-index'
9
20
 
10
21
  export type CustomFieldSelectors = {
11
22
  keys: string[]
@@ -18,20 +29,6 @@ export type SplitCustomFieldPayload = {
18
29
  custom: Record<string, unknown>
19
30
  }
20
31
 
21
- export type CustomFieldDefinitionSummary = {
22
- key: string
23
- label: string | null
24
- kind: string | null
25
- multi: boolean
26
- dictionaryId?: string | null
27
- organizationId?: string | null
28
- tenantId?: string | null
29
- priority: number
30
- updatedAt: number
31
- }
32
-
33
- export type CustomFieldDefinitionIndex = Map<string, CustomFieldDefinitionSummary[]>
34
-
35
32
  export type CustomFieldDisplayEntry = {
36
33
  key: string
37
34
  label: string | null
@@ -93,22 +90,6 @@ export function extractAllCustomFieldEntries(item: Record<string, unknown>): Rec
93
90
  return extractCustomFieldEntries(item)
94
91
  }
95
92
 
96
- function normalizeFieldsetFilter(input?: string | string[] | null): Set<string | null> | null {
97
- if (input == null) return null
98
- const values = Array.isArray(input) ? input : [input]
99
- const normalized = new Set<string | null>()
100
- for (const raw of values) {
101
- if (raw == null) continue
102
- const trimmed = String(raw).trim()
103
- if (!trimmed) {
104
- normalized.add(null)
105
- } else {
106
- normalized.add(trimmed)
107
- }
108
- }
109
- return normalized.size ? normalized : null
110
- }
111
-
112
93
  export async function buildCustomFieldFiltersFromQuery(opts: {
113
94
  entityId?: EntityId
114
95
  entityIds?: EntityId[]
@@ -250,93 +231,6 @@ export function extractCustomFieldValuesFromPayload(raw: Record<string, unknown>
250
231
  return splitCustomFieldPayload(raw).custom
251
232
  }
252
233
 
253
- function normalizeDefinitionKey(key: unknown): string {
254
- if (typeof key !== 'string') return ''
255
- const trimmed = key.trim()
256
- return trimmed.length ? trimmed.toLowerCase() : ''
257
- }
258
-
259
- function normalizeDefinitionConfig(raw: unknown): Record<string, any> {
260
- if (!raw) return {}
261
- if (typeof raw === 'string') {
262
- try {
263
- const parsed = JSON.parse(raw)
264
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
265
- return { ...(parsed as Record<string, any>) }
266
- }
267
- return {}
268
- } catch {
269
- return {}
270
- }
271
- }
272
- if (typeof raw === 'object' && !Array.isArray(raw)) {
273
- return { ...(raw as Record<string, any>) }
274
- }
275
- return {}
276
- }
277
-
278
- function summarizeDefinition(def: CustomFieldDef): CustomFieldDefinitionSummary | null {
279
- const normalizedKey = normalizeDefinitionKey(def.key)
280
- if (!normalizedKey) return null
281
- const cfg = normalizeDefinitionConfig((def as any).configJson)
282
- const label =
283
- typeof cfg.label === 'string' && cfg.label.trim().length
284
- ? cfg.label.trim()
285
- : def.key
286
- const dictionaryId =
287
- typeof cfg.dictionaryId === 'string' && cfg.dictionaryId.trim().length
288
- ? cfg.dictionaryId.trim()
289
- : null
290
- const multi =
291
- cfg.multi !== undefined ? Boolean(cfg.multi) : false
292
- const priority =
293
- typeof cfg.priority === 'number' ? cfg.priority : 0
294
- const updatedAt =
295
- def.updatedAt instanceof Date
296
- ? def.updatedAt.getTime()
297
- : new Date(def.updatedAt as any).getTime()
298
- return {
299
- key: def.key,
300
- label,
301
- kind: typeof def.kind === 'string' ? def.kind : null,
302
- multi,
303
- dictionaryId,
304
- organizationId: def.organizationId ?? null,
305
- tenantId: def.tenantId ?? null,
306
- priority,
307
- updatedAt: Number.isNaN(updatedAt) ? 0 : updatedAt,
308
- }
309
- }
310
-
311
- function sortDefinitionSummaries(defs: CustomFieldDefinitionSummary[]): CustomFieldDefinitionSummary[] {
312
- return [...defs].sort((a, b) => {
313
- const priorityDiff = (a.priority ?? 0) - (b.priority ?? 0)
314
- if (priorityDiff !== 0) return priorityDiff
315
- const updatedDiff = (b.updatedAt ?? 0) - (a.updatedAt ?? 0)
316
- if (updatedDiff !== 0) return updatedDiff
317
- return a.key.localeCompare(b.key)
318
- })
319
- }
320
-
321
- function selectDefinitionForRecord(
322
- defs: CustomFieldDefinitionSummary[],
323
- organizationId: string | null,
324
- tenantId: string | null,
325
- ): CustomFieldDefinitionSummary | null {
326
- if (!defs.length) return null
327
- const prioritizedForOrg = defs.filter(
328
- (def) => def.organizationId && organizationId && def.organizationId === organizationId,
329
- )
330
- if (prioritizedForOrg.length) return sortDefinitionSummaries(prioritizedForOrg)[0]
331
- const prioritizedForTenant = defs.filter(
332
- (def) => def.tenantId && tenantId && def.tenantId === tenantId && !def.organizationId,
333
- )
334
- if (prioritizedForTenant.length) return sortDefinitionSummaries(prioritizedForTenant)[0]
335
- const global = defs.filter((def) => !def.organizationId)
336
- if (global.length) return sortDefinitionSummaries(global)[0]
337
- return sortDefinitionSummaries(defs)[0] ?? null
338
- }
339
-
340
234
  type LoadCustomFieldDefinitionIndexOptions = {
341
235
  em: EntityManager
342
236
  entityIds: string | string[]
@@ -463,36 +357,20 @@ async function loadCustomFieldDefinitionIndexFresh(
463
357
  $and: scopeClauses,
464
358
  }
465
359
  const defs = await em.find(CustomFieldDef, where as any)
466
- const fieldsetFilter = normalizeFieldsetFilter(opts.fieldset)
467
- const index: CustomFieldDefinitionIndex = new Map()
468
- defs.forEach((def) => {
469
- if (fieldsetFilter) {
470
- const config = normalizeDefinitionConfig((def as any).configJson)
471
- const fieldsets = Array.isArray(config.fieldsets)
472
- ? config.fieldsets
473
- .filter((entry: unknown): entry is string => typeof entry === 'string')
474
- .map((entry: string) => entry.trim())
475
- .filter((entry: string) => entry.length > 0)
476
- : []
477
- const fieldset = typeof config.fieldset === 'string' && config.fieldset.trim().length > 0
478
- ? config.fieldset.trim()
479
- : null
480
- const matches = fieldsets.length > 0
481
- ? fieldsets.some((entry: string) => fieldsetFilter.has(entry))
482
- : fieldsetFilter.has(fieldset)
483
- if (!matches) return
484
- }
485
- const summary = summarizeDefinition(def)
486
- if (!summary) return
487
- const normalizedKey = normalizeDefinitionKey(summary.key)
488
- if (!normalizedKey) return
489
- if (!index.has(normalizedKey)) index.set(normalizedKey, [])
490
- index.get(normalizedKey)!.push(summary)
491
- })
492
- index.forEach((entries, key) => {
493
- index.set(key, sortDefinitionSummaries(entries))
360
+ const rows: CustomFieldDefinitionRow[] = defs.map((def) => ({
361
+ key: def.key,
362
+ entityId: String((def as any).entityId),
363
+ kind: typeof def.kind === 'string' ? def.kind : null,
364
+ configJson: (def as any).configJson,
365
+ organizationId: def.organizationId ?? null,
366
+ tenantId: def.tenantId ?? null,
367
+ deletedAt: (def as any).deletedAt ?? null,
368
+ updatedAt: (def as any).updatedAt ?? null,
369
+ }))
370
+ return buildCustomFieldDefinitionIndexFromRows(rows, {
371
+ organizationIds: orgCandidates,
372
+ fieldset: opts.fieldset,
494
373
  })
495
- return index
496
374
  }
497
375
 
498
376
  export async function loadCustomFieldDefinitionIndex(opts: LoadCustomFieldDefinitionIndexOptions & {
@@ -33,6 +33,12 @@ import {
33
33
  applyCustomFieldsNormalization,
34
34
  loadCustomFieldDefinitionIndex,
35
35
  } from './custom-fields'
36
+ import {
37
+ canReuseCustomFieldDefinitions,
38
+ resolveCfDefIndexOrgCandidates,
39
+ type CustomFieldDefinitionIndex,
40
+ type ResolvedCustomFieldDefinitions,
41
+ } from './custom-field-definition-index'
36
42
  import { serializeExport, normalizeExportFormat, defaultExportFilename, ensureColumns, type CrudExportFormat, type PreparedExport } from './exporters'
37
43
  import { CrudHttpError, isCrudHttpError } from './errors'
38
44
  import type { CommandBus, CommandLogMetadata } from '@open-mercato/shared/lib/commands'
@@ -956,7 +962,11 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
956
962
  return null
957
963
  }
958
964
 
959
- const decorateItemsWithCustomFields = async (items: any[], ctx: CrudCtx): Promise<any[]> => {
965
+ const decorateItemsWithCustomFields = async (
966
+ items: any[],
967
+ ctx: CrudCtx,
968
+ precomputedDefinitions?: ResolvedCustomFieldDefinitions,
969
+ ): Promise<any[]> => {
960
970
  if (!listCustomFieldDecorator || !Array.isArray(items) || items.length === 0) return items
961
971
  const entityIds = Array.isArray(listCustomFieldDecorator.entityIds)
962
972
  ? listCustomFieldDecorator.entityIds
@@ -976,19 +986,33 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
976
986
  Array.isArray(ctx.organizationIds) && ctx.organizationIds.length
977
987
  ? ctx.organizationIds
978
988
  : [ctx.selectedOrganizationId ?? null]
979
- let cfDefCache: CacheStrategy | null = null
980
- try {
981
- cfDefCache = ctx.container.resolve('cache') as CacheStrategy
982
- } catch {}
983
- const definitionIndex = await loadCustomFieldDefinitionIndex({
984
- em,
985
- entityIds,
986
- tenantId: ctx.auth?.tenantId ?? null,
987
- organizationIds,
988
- cache: cfDefCache ?? null,
989
- requestScope: ctx,
989
+ const tenantId = ctx.auth?.tenantId ?? null
990
+ // Reuse the index the query engine already resolved for this same scope
991
+ // (#2133) instead of issuing a second `custom_field_defs` round-trip.
992
+ const reusable = canReuseCustomFieldDefinitions(precomputedDefinitions, {
993
+ entityIds: entityIds.map(String),
994
+ tenantId,
995
+ organizationIds: resolveCfDefIndexOrgCandidates(ctx.organizationIds, ctx.selectedOrganizationId ?? null),
990
996
  })
991
- cfProfiler.mark('definitions_loaded', { definitionCount: definitionIndex.size })
997
+ let definitionIndex: CustomFieldDefinitionIndex
998
+ if (reusable && precomputedDefinitions) {
999
+ definitionIndex = precomputedDefinitions.index
1000
+ cfProfiler.mark('definitions_reused', { definitionCount: definitionIndex.size })
1001
+ } else {
1002
+ let cfDefCache: CacheStrategy | null = null
1003
+ try {
1004
+ cfDefCache = ctx.container.resolve('cache') as CacheStrategy
1005
+ } catch {}
1006
+ definitionIndex = await loadCustomFieldDefinitionIndex({
1007
+ em,
1008
+ entityIds,
1009
+ tenantId,
1010
+ organizationIds,
1011
+ cache: cfDefCache ?? null,
1012
+ requestScope: ctx,
1013
+ })
1014
+ cfProfiler.mark('definitions_loaded', { definitionCount: definitionIndex.size })
1015
+ }
992
1016
  const decoratedItems = items.map((raw) => {
993
1017
  if (!raw || typeof raw !== 'object') return raw
994
1018
  const item = raw as Record<string, unknown>
@@ -1572,7 +1596,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1572
1596
  const rawItems = res.items || []
1573
1597
  let transformedItems = rawItems.map(i => (opts.list!.transformItem ? opts.list!.transformItem(i) : i))
1574
1598
  profiler.mark('transform_complete', { itemCount: transformedItems.length })
1575
- transformedItems = await decorateItemsWithCustomFields(transformedItems, ctx)
1599
+ transformedItems = await decorateItemsWithCustomFields(transformedItems, ctx, res.customFieldDefinitions)
1576
1600
  profiler.mark('custom_fields_complete', { itemCount: transformedItems.length })
1577
1601
 
1578
1602
  if (opts.list?.entityId && request) {
@@ -1630,7 +1654,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1630
1654
  const nextItemsRaw = nextRes.items || []
1631
1655
  if (!nextItemsRaw.length) break
1632
1656
  let nextTransformed = nextItemsRaw.map(i => (opts.list!.transformItem ? opts.list!.transformItem(i) : i))
1633
- nextTransformed = await decorateItemsWithCustomFields(nextTransformed, ctx)
1657
+ nextTransformed = await decorateItemsWithCustomFields(nextTransformed, ctx, nextRes.customFieldDefinitions)
1634
1658
  const nextExportItems = exportFullRequested
1635
1659
  ? nextItemsRaw.map(normalizeFullRecordForExport)
1636
1660
  : nextTransformed
@@ -2094,25 +2118,31 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2094
2118
  if (!ctx.auth.tenantId) return json({ error: 'Tenant context is required' }, { status: 400 })
2095
2119
  entityData[ormCfg.tenantField] = ctx.auth.tenantId
2096
2120
  }
2097
- const entity = await de.createOrmEntity({ entity: ormCfg.entity, data: entityData })
2098
-
2099
- // Custom fields
2100
- if (createConfig.customFields && (createConfig.customFields as any).enabled) {
2101
- const cfc = createConfig.customFields as Exclude<CustomFieldsConfig, false>
2102
- const values = cfc.map
2103
- ? cfc.map(body)
2104
- : (cfc.pickPrefixed ? extractCustomFieldValuesFromPayload(body as Record<string, unknown>) : {})
2105
- if (values && Object.keys(values).length > 0) {
2106
- const de = (ctx.container.resolve('dataEngine') as DataEngine)
2107
- await de.setCustomFields({
2108
- entityId: cfc.entityId as any,
2109
- recordId: String((entity as any)[ormCfg.idField!]),
2110
- organizationId: targetOrgId,
2111
- tenantId: ctx.auth.tenantId!,
2112
- values,
2113
- })
2121
+ const em = (ctx.container.resolve('em') as EntityManager)
2122
+ const writeTenantId = ctx.auth.tenantId!
2123
+ const entity = await em.transactional(async () => {
2124
+ const created = await de.createOrmEntity({ entity: ormCfg.entity, data: entityData })
2125
+
2126
+ // Custom fields
2127
+ if (createConfig.customFields && (createConfig.customFields as any).enabled) {
2128
+ const cfc = createConfig.customFields as Exclude<CustomFieldsConfig, false>
2129
+ const values = cfc.map
2130
+ ? cfc.map(body)
2131
+ : (cfc.pickPrefixed ? extractCustomFieldValuesFromPayload(body as Record<string, unknown>) : {})
2132
+ if (values && Object.keys(values).length > 0) {
2133
+ const de = (ctx.container.resolve('dataEngine') as DataEngine)
2134
+ await de.setCustomFields({
2135
+ entityId: cfc.entityId as any,
2136
+ recordId: String((created as any)[ormCfg.idField!]),
2137
+ organizationId: targetOrgId,
2138
+ tenantId: writeTenantId,
2139
+ values,
2140
+ })
2141
+ }
2114
2142
  }
2115
- }
2143
+
2144
+ return created
2145
+ })
2116
2146
 
2117
2147
  await opts.hooks?.afterCreate?.(entity, { ...ctx, input: input as any })
2118
2148
 
@@ -2414,31 +2444,38 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2414
2444
  softDeleteField: ormCfg.softDeleteField,
2415
2445
  }
2416
2446
  )
2417
- const entity = await de.updateOrmEntity({
2418
- entity: ormCfg.entity,
2419
- where,
2420
- apply: (e: any) => updateConfig.applyToEntity(e, input as any, ctx),
2447
+ const em = (ctx.container.resolve('em') as EntityManager)
2448
+ const writeTenantId = ctx.auth.tenantId!
2449
+ const entity = await em.transactional(async () => {
2450
+ const updated = await de.updateOrmEntity({
2451
+ entity: ormCfg.entity,
2452
+ where,
2453
+ apply: (e: any) => updateConfig.applyToEntity(e, input as any, ctx),
2454
+ })
2455
+ if (!updated) return null
2456
+
2457
+ // Custom fields
2458
+ if (updateConfig.customFields && (updateConfig.customFields as any).enabled) {
2459
+ const cfc = updateConfig.customFields as Exclude<CustomFieldsConfig, false>
2460
+ const values = cfc.map
2461
+ ? cfc.map(body)
2462
+ : (cfc.pickPrefixed ? extractCustomFieldValuesFromPayload(body as Record<string, unknown>) : {})
2463
+ if (values && Object.keys(values).length > 0) {
2464
+ const de = (ctx.container.resolve('dataEngine') as DataEngine)
2465
+ await de.setCustomFields({
2466
+ entityId: cfc.entityId as any,
2467
+ recordId: String((updated as any)[ormCfg.idField!]),
2468
+ organizationId: targetOrgId,
2469
+ tenantId: writeTenantId,
2470
+ values,
2471
+ })
2472
+ }
2473
+ }
2474
+
2475
+ return updated
2421
2476
  })
2422
2477
  if (!entity) return json({ error: 'Not found' }, { status: 404 })
2423
2478
 
2424
- // Custom fields
2425
- if (updateConfig.customFields && (updateConfig.customFields as any).enabled) {
2426
- const cfc = updateConfig.customFields as Exclude<CustomFieldsConfig, false>
2427
- const values = cfc.map
2428
- ? cfc.map(body)
2429
- : (cfc.pickPrefixed ? extractCustomFieldValuesFromPayload(body as Record<string, unknown>) : {})
2430
- if (values && Object.keys(values).length > 0) {
2431
- const de = (ctx.container.resolve('dataEngine') as DataEngine)
2432
- await de.setCustomFields({
2433
- entityId: cfc.entityId as any,
2434
- recordId: String((entity as any)[ormCfg.idField!]),
2435
- organizationId: targetOrgId,
2436
- tenantId: ctx.auth.tenantId!,
2437
- values,
2438
- })
2439
- }
2440
- }
2441
-
2442
2479
  await opts.hooks?.afterUpdate?.(entity, { ...ctx, input: input as any })
2443
2480
 
2444
2481
  // Guard afterSuccess callbacks (multi)
@@ -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 {