@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.
@@ -0,0 +1,233 @@
1
+ // Core-free building blocks for the custom-field definition index.
2
+ //
3
+ // This module intentionally has ZERO dependency on `@open-mercato/core` (no ORM
4
+ // entity imports) so that infrastructure code such as the query engine can build
5
+ // the same definition index that `custom-fields.ts` produces via MikroORM, without
6
+ // pulling a domain package into the query layer.
7
+
8
+ export type CustomFieldDefinitionSummary = {
9
+ key: string
10
+ label: string | null
11
+ kind: string | null
12
+ multi: boolean
13
+ dictionaryId?: string | null
14
+ organizationId?: string | null
15
+ tenantId?: string | null
16
+ priority: number
17
+ updatedAt: number
18
+ }
19
+
20
+ export type CustomFieldDefinitionIndex = Map<string, CustomFieldDefinitionSummary[]>
21
+
22
+ // Plain-row representation of a `custom_field_defs` record. Both the ORM-backed
23
+ // loader (`custom-fields.ts`) and the Kysely-backed query engine map their native
24
+ // rows into this shape before building an index, so the two paths stay in lockstep.
25
+ export type CustomFieldDefinitionRow = {
26
+ key: string
27
+ entityId: string
28
+ kind: string | null
29
+ configJson: unknown
30
+ organizationId: string | null
31
+ tenantId: string | null
32
+ deletedAt: Date | string | number | null
33
+ updatedAt: Date | string | number | null
34
+ }
35
+
36
+ // The resolved definition index the query engine threads onto its result so the
37
+ // CRUD factory can decorate list rows without reloading definitions (issue #2133).
38
+ export type ResolvedCustomFieldDefinitions = {
39
+ index: CustomFieldDefinitionIndex
40
+ entityIds: string[]
41
+ tenantId: string | null
42
+ organizationIds: string[]
43
+ }
44
+
45
+ export function normalizeDefinitionKey(key: unknown): string {
46
+ if (typeof key !== 'string') return ''
47
+ const trimmed = key.trim()
48
+ return trimmed.length ? trimmed.toLowerCase() : ''
49
+ }
50
+
51
+ export function normalizeDefinitionConfig(raw: unknown): Record<string, any> {
52
+ if (!raw) return {}
53
+ if (typeof raw === 'string') {
54
+ try {
55
+ const parsed = JSON.parse(raw)
56
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
57
+ return { ...(parsed as Record<string, any>) }
58
+ }
59
+ return {}
60
+ } catch {
61
+ return {}
62
+ }
63
+ }
64
+ if (typeof raw === 'object' && !Array.isArray(raw)) {
65
+ return { ...(raw as Record<string, any>) }
66
+ }
67
+ return {}
68
+ }
69
+
70
+ export function normalizeFieldsetFilter(input?: string | string[] | null): Set<string | null> | null {
71
+ if (input == null) return null
72
+ const values = Array.isArray(input) ? input : [input]
73
+ const normalized = new Set<string | null>()
74
+ for (const raw of values) {
75
+ if (raw == null) continue
76
+ const trimmed = String(raw).trim()
77
+ if (!trimmed) {
78
+ normalized.add(null)
79
+ } else {
80
+ normalized.add(trimmed)
81
+ }
82
+ }
83
+ return normalized.size ? normalized : null
84
+ }
85
+
86
+ function toTimeMs(value: Date | string | number | null | undefined): number {
87
+ if (value == null) return 0
88
+ if (value instanceof Date) return value.getTime()
89
+ const parsed = new Date(value as any).getTime()
90
+ return Number.isNaN(parsed) ? 0 : parsed
91
+ }
92
+
93
+ export function summarizeDefinitionRow(row: CustomFieldDefinitionRow): CustomFieldDefinitionSummary | null {
94
+ const normalizedKey = normalizeDefinitionKey(row.key)
95
+ if (!normalizedKey) return null
96
+ const cfg = normalizeDefinitionConfig(row.configJson)
97
+ const label =
98
+ typeof cfg.label === 'string' && cfg.label.trim().length
99
+ ? cfg.label.trim()
100
+ : row.key
101
+ const dictionaryId =
102
+ typeof cfg.dictionaryId === 'string' && cfg.dictionaryId.trim().length
103
+ ? cfg.dictionaryId.trim()
104
+ : null
105
+ const multi = cfg.multi !== undefined ? Boolean(cfg.multi) : false
106
+ const priority = typeof cfg.priority === 'number' ? cfg.priority : 0
107
+ return {
108
+ key: row.key,
109
+ label,
110
+ kind: typeof row.kind === 'string' ? row.kind : null,
111
+ multi,
112
+ dictionaryId,
113
+ organizationId: row.organizationId ?? null,
114
+ tenantId: row.tenantId ?? null,
115
+ priority,
116
+ updatedAt: toTimeMs(row.updatedAt),
117
+ }
118
+ }
119
+
120
+ export function sortDefinitionSummaries(defs: CustomFieldDefinitionSummary[]): CustomFieldDefinitionSummary[] {
121
+ return [...defs].sort((a, b) => {
122
+ const priorityDiff = (a.priority ?? 0) - (b.priority ?? 0)
123
+ if (priorityDiff !== 0) return priorityDiff
124
+ const updatedDiff = (b.updatedAt ?? 0) - (a.updatedAt ?? 0)
125
+ if (updatedDiff !== 0) return updatedDiff
126
+ return a.key.localeCompare(b.key)
127
+ })
128
+ }
129
+
130
+ export function selectDefinitionForRecord(
131
+ defs: CustomFieldDefinitionSummary[],
132
+ organizationId: string | null,
133
+ tenantId: string | null,
134
+ ): CustomFieldDefinitionSummary | null {
135
+ if (!defs.length) return null
136
+ const prioritizedForOrg = defs.filter(
137
+ (def) => def.organizationId && organizationId && def.organizationId === organizationId,
138
+ )
139
+ if (prioritizedForOrg.length) return sortDefinitionSummaries(prioritizedForOrg)[0]
140
+ const prioritizedForTenant = defs.filter(
141
+ (def) => def.tenantId && tenantId && def.tenantId === tenantId && !def.organizationId,
142
+ )
143
+ if (prioritizedForTenant.length) return sortDefinitionSummaries(prioritizedForTenant)[0]
144
+ const global = defs.filter((def) => !def.organizationId)
145
+ if (global.length) return sortDefinitionSummaries(global)[0]
146
+ return sortDefinitionSummaries(defs)[0] ?? null
147
+ }
148
+
149
+ // Resolve the effective list of organization candidates for a definition index
150
+ // lookup, mirroring `loadCustomFieldDefinitionIndex`: when explicit org ids are
151
+ // present they win, otherwise the fallback (selected org) is used. Null/empty
152
+ // entries are dropped — the null-org branch is always allowed by the index filter.
153
+ export function resolveCfDefIndexOrgCandidates(
154
+ organizationIds: Array<string | null | undefined> | null | undefined,
155
+ fallbackOrganizationId: string | null | undefined,
156
+ ): string[] {
157
+ const source = Array.isArray(organizationIds) && organizationIds.length
158
+ ? organizationIds
159
+ : [fallbackOrganizationId ?? null]
160
+ return source
161
+ .map((id) => (typeof id === 'string' ? id.trim() : id))
162
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
163
+ }
164
+
165
+ function matchesFieldset(configJson: unknown, fieldsetFilter: Set<string | null>): boolean {
166
+ const config = normalizeDefinitionConfig(configJson)
167
+ const fieldsets = Array.isArray(config.fieldsets)
168
+ ? config.fieldsets
169
+ .filter((entry: unknown): entry is string => typeof entry === 'string')
170
+ .map((entry: string) => entry.trim())
171
+ .filter((entry: string) => entry.length > 0)
172
+ : []
173
+ const fieldset = typeof config.fieldset === 'string' && config.fieldset.trim().length > 0
174
+ ? config.fieldset.trim()
175
+ : null
176
+ return fieldsets.length > 0
177
+ ? fieldsets.some((entry: string) => fieldsetFilter.has(entry))
178
+ : fieldsetFilter.has(fieldset)
179
+ }
180
+
181
+ // Build the grouped + sorted definition index from plain rows. Callers must
182
+ // pre-filter rows by tenant + is_active (both the ORM loader and the query engine
183
+ // already do so in SQL); this function additionally applies the org candidate
184
+ // filter, the soft-delete guard, and the optional fieldset filter so its output is
185
+ // byte-for-byte identical to the ORM-backed loader for the same logical scope.
186
+ export function buildCustomFieldDefinitionIndexFromRows(
187
+ rows: CustomFieldDefinitionRow[],
188
+ opts: { organizationIds?: string[] | null; fieldset?: string | string[] | null } = {},
189
+ ): CustomFieldDefinitionIndex {
190
+ const orgCandidates = opts.organizationIds ?? []
191
+ const fieldsetFilter = normalizeFieldsetFilter(opts.fieldset)
192
+ const index: CustomFieldDefinitionIndex = new Map()
193
+ for (const row of rows) {
194
+ if (row.deletedAt != null) continue
195
+ const org = row.organizationId ?? null
196
+ if (org !== null && !(orgCandidates.length > 0 && orgCandidates.includes(org))) continue
197
+ if (fieldsetFilter && !matchesFieldset(row.configJson, fieldsetFilter)) continue
198
+ const summary = summarizeDefinitionRow(row)
199
+ if (!summary) continue
200
+ const normalizedKey = normalizeDefinitionKey(summary.key)
201
+ if (!normalizedKey) continue
202
+ if (!index.has(normalizedKey)) index.set(normalizedKey, [])
203
+ index.get(normalizedKey)!.push(summary)
204
+ }
205
+ index.forEach((entries, key) => {
206
+ index.set(key, sortDefinitionSummaries(entries))
207
+ })
208
+ return index
209
+ }
210
+
211
+ function sameStringSet(a: readonly string[], b: readonly string[]): boolean {
212
+ const left = new Set(a)
213
+ const right = new Set(b)
214
+ if (left.size !== right.size) return false
215
+ for (const value of left) {
216
+ if (!right.has(value)) return false
217
+ }
218
+ return true
219
+ }
220
+
221
+ // Decide whether a precomputed definition index from a QueryEngine result can be
222
+ // reused for a decoration request. Reuse is only safe when the engine resolved
223
+ // definitions for exactly the same entity-id set, tenant, and org candidates.
224
+ export function canReuseCustomFieldDefinitions(
225
+ resolved: ResolvedCustomFieldDefinitions | null | undefined,
226
+ request: { entityIds: string[]; tenantId: string | null; organizationIds: string[] },
227
+ ): boolean {
228
+ if (!resolved) return false
229
+ if ((resolved.tenantId ?? null) !== (request.tenantId ?? null)) return false
230
+ if (!sameStringSet(resolved.entityIds.map(String), request.entityIds.map(String))) return false
231
+ if (!sameStringSet(resolved.organizationIds, request.organizationIds)) return false
232
+ return true
233
+ }
@@ -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)