@open-mercato/shared 0.6.4-develop.4217.1.c9aa050183 → 0.6.4-develop.4239.1.4a264a5828
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/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/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
|
@@ -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
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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 & {
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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
|
-
|
|
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
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
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
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
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: [
|
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 {
|