@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.
@@ -4,8 +4,17 @@ jest.mock('@open-mercato/cache', () => ({
4
4
 
5
5
  import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
6
6
  import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
7
+ import { loadCustomFieldDefinitionIndex } from '@open-mercato/shared/lib/crud/custom-fields'
7
8
  import { z } from 'zod'
8
9
 
10
+ // Keep the real custom-field helpers but spy on the definition loader so we can
11
+ // assert the factory skips the second DB round-trip when the query engine has
12
+ // already resolved definitions (issue #2133).
13
+ jest.mock('@open-mercato/shared/lib/crud/custom-fields', () => {
14
+ const actual = jest.requireActual('@open-mercato/shared/lib/crud/custom-fields')
15
+ return { ...actual, loadCustomFieldDefinitionIndex: jest.fn(async () => new Map()) }
16
+ })
17
+
9
18
  // ---- Mocks ----
10
19
  const mockEventBus = { emitEvent: jest.fn() }
11
20
  const defaultOrganizationId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
@@ -25,6 +34,16 @@ let crudMutationGuardService: { validateMutation: jest.Mock; afterMutationSucces
25
34
  let mockOrganizationScopeOverride: MockOrganizationScope | null
26
35
 
27
36
  const em = {
37
+ transactional: async (cb: () => any) => {
38
+ const snapshot = Object.fromEntries(Object.entries(db).map(([key, value]) => [key, { ...value }]))
39
+ try {
40
+ return await cb()
41
+ } catch (error) {
42
+ for (const key of Object.keys(db)) delete db[key]
43
+ Object.assign(db, snapshot)
44
+ throw error
45
+ }
46
+ },
28
47
  create: (_cls: any, data: any) => ({ ...data, id: `id-${idSeq++}` }),
29
48
  persist(entity: Rec) {
30
49
  db[entity.id] = { ...(db[entity.id] || {} as any), ...entity }
@@ -241,6 +260,64 @@ describe('CRUD Factory', () => {
241
260
  }))
242
261
  })
243
262
 
263
+ const makeDecoratedRoute = () => makeCrudRoute({
264
+ metadata: { GET: { requireAuth: true } },
265
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
266
+ indexer: { entityType: 'example.todo' },
267
+ list: {
268
+ schema: querySchema,
269
+ entityId: 'example.todo',
270
+ fields: ['id', 'title'],
271
+ buildFilters: () => ({} as any),
272
+ decorateCustomFields: { entityIds: 'example.todo' },
273
+ },
274
+ })
275
+
276
+ const colorDefinitionIndex = () => new Map([
277
+ ['color', [{ key: 'color', label: 'Color', kind: 'text', multi: false, dictionaryId: null, organizationId: null, tenantId: null, priority: 0, updatedAt: 0 }]],
278
+ ])
279
+
280
+ it('reuses query engine custom-field definitions and skips the second DB load (#2133)', async () => {
281
+ const loadIndexMock = loadCustomFieldDefinitionIndex as unknown as jest.Mock
282
+ const cfRoute = makeDecoratedRoute()
283
+ queryEngine.query.mockResolvedValueOnce({
284
+ items: [{ id: 'id-1', title: 'A', cf_color: 'blue', organization_id: defaultOrganizationId, tenant_id: defaultTenantId }],
285
+ total: 1,
286
+ customFieldDefinitions: {
287
+ index: colorDefinitionIndex(),
288
+ entityIds: ['example.todo'],
289
+ tenantId: defaultTenantId,
290
+ organizationIds: [defaultOrganizationId],
291
+ },
292
+ })
293
+
294
+ const res = await cfRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'))
295
+ expect(res.status).toBe(200)
296
+ const body = await res.json()
297
+ expect(loadIndexMock).not.toHaveBeenCalled()
298
+ expect(body.items[0].customValues).toEqual({ color: 'blue' })
299
+ })
300
+
301
+ it('falls back to loading definitions when the engine index does not cover the scope', async () => {
302
+ const loadIndexMock = loadCustomFieldDefinitionIndex as unknown as jest.Mock
303
+ loadIndexMock.mockResolvedValueOnce(colorDefinitionIndex())
304
+ const cfRoute = makeDecoratedRoute()
305
+ queryEngine.query.mockResolvedValueOnce({
306
+ items: [{ id: 'id-1', title: 'A', cf_color: 'blue', organization_id: defaultOrganizationId, tenant_id: defaultTenantId }],
307
+ total: 1,
308
+ customFieldDefinitions: {
309
+ index: new Map(),
310
+ entityIds: ['example.todo'],
311
+ tenantId: defaultTenantId,
312
+ organizationIds: ['some-other-org'],
313
+ },
314
+ })
315
+
316
+ const res = await cfRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'))
317
+ expect(res.status).toBe(200)
318
+ expect(loadIndexMock).toHaveBeenCalledTimes(1)
319
+ })
320
+
244
321
  it('GET applies ids query filter in query engine path', async () => {
245
322
  const idA = '550e8400-e29b-41d4-a716-446655440001'
246
323
  const idB = '550e8400-e29b-41d4-a716-446655440002'
@@ -472,6 +549,28 @@ describe('CRUD Factory', () => {
472
549
  expect(db[created.id].title).toBe('X2')
473
550
  })
474
551
 
552
+ it('POST rolls back the created entity when the custom field write fails', async () => {
553
+ setRecordCustomFields.mockImplementationOnce(async () => { throw new Error('cf write failed') })
554
+ const res = await route.POST(new Request('http://x/api/example/todos', { method: 'POST', body: JSON.stringify({ title: 'Atomic', is_done: true, cf_priority: 3 }), headers: { 'content-type': 'application/json' } }))
555
+ expect(res.status).toBe(500)
556
+ // Entity write was rolled back together with the failed custom field write
557
+ expect(Object.values(db)).toHaveLength(0)
558
+ // No created event/index is emitted for a rolled-back create
559
+ expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
560
+ })
561
+
562
+ it('PUT rolls back the entity update when the custom field write fails', async () => {
563
+ const created = em.create(Todo, { title: 'Before', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
564
+ created.id = '123e4567-e89b-12d3-a456-426614174003'
565
+ await em.persist(created).flush()
566
+ setRecordCustomFields.mockImplementationOnce(async () => { throw new Error('cf write failed') })
567
+ const res = await route.PUT(new Request('http://x/api/example/todos', { method: 'PUT', body: JSON.stringify({ id: created.id, title: 'After', cf_priority: 5 }), headers: { 'content-type': 'application/json' } }))
568
+ expect(res.status).toBe(500)
569
+ // The scalar update was rolled back together with the failed custom field write
570
+ expect(db[created.id].title).toBe('Before')
571
+ expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
572
+ })
573
+
475
574
  it('DELETE soft-deletes entity and emits deleted event', async () => {
476
575
  const created = em.create(Todo, { title: 'Y', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
477
576
  created.id = '123e4567-e89b-12d3-a456-426614174002'
@@ -0,0 +1,136 @@
1
+ import {
2
+ buildCustomFieldDefinitionIndexFromRows,
3
+ canReuseCustomFieldDefinitions,
4
+ resolveCfDefIndexOrgCandidates,
5
+ type CustomFieldDefinitionRow,
6
+ } from '../custom-field-definition-index'
7
+
8
+ const row = (overrides: Partial<CustomFieldDefinitionRow> & Pick<CustomFieldDefinitionRow, 'key' | 'entityId'>): CustomFieldDefinitionRow => ({
9
+ kind: 'text',
10
+ configJson: {},
11
+ organizationId: null,
12
+ tenantId: null,
13
+ deletedAt: null,
14
+ updatedAt: null,
15
+ ...overrides,
16
+ })
17
+
18
+ describe('buildCustomFieldDefinitionIndexFromRows', () => {
19
+ it('groups summaries by normalized key and summarizes config', () => {
20
+ const index = buildCustomFieldDefinitionIndexFromRows([
21
+ row({ key: 'Color', entityId: 'demo:entity', configJson: { label: 'Colour', multi: true, priority: 2 }, kind: 'select' }),
22
+ ])
23
+ expect(Array.from(index.keys())).toEqual(['color'])
24
+ const summaries = index.get('color')!
25
+ expect(summaries).toHaveLength(1)
26
+ expect(summaries[0]).toMatchObject({ key: 'Color', label: 'Colour', kind: 'select', multi: true, priority: 2 })
27
+ })
28
+
29
+ it('sorts summaries within a key by priority, then recency, then key', () => {
30
+ const index = buildCustomFieldDefinitionIndexFromRows([
31
+ row({ key: 'color', entityId: 'demo:entity', tenantId: 't1', organizationId: 'o1', configJson: { priority: 5 } }),
32
+ row({ key: 'color', entityId: 'demo:entity', tenantId: 't1', organizationId: 'o2', configJson: { priority: 1 } }),
33
+ ], { organizationIds: ['o1', 'o2'] })
34
+ const summaries = index.get('color')!
35
+ expect(summaries.map((s) => s.organizationId)).toEqual(['o2', 'o1'])
36
+ })
37
+
38
+ it('excludes soft-deleted rows', () => {
39
+ const index = buildCustomFieldDefinitionIndexFromRows([
40
+ row({ key: 'color', entityId: 'demo:entity', deletedAt: new Date('2026-01-01T00:00:00Z') }),
41
+ row({ key: 'size', entityId: 'demo:entity' }),
42
+ ])
43
+ expect(Array.from(index.keys()).sort()).toEqual(['size'])
44
+ })
45
+
46
+ it('keeps null-org rows and rows whose org is a candidate, drops foreign-org rows', () => {
47
+ const index = buildCustomFieldDefinitionIndexFromRows([
48
+ row({ key: 'global_field', entityId: 'demo:entity', organizationId: null }),
49
+ row({ key: 'scoped_field', entityId: 'demo:entity', organizationId: 'org-allowed' }),
50
+ row({ key: 'foreign_field', entityId: 'demo:entity', organizationId: 'org-other' }),
51
+ ], { organizationIds: ['org-allowed'] })
52
+ expect(Array.from(index.keys()).sort()).toEqual(['global_field', 'scoped_field'])
53
+ })
54
+
55
+ it('drops all explicit-org rows when no candidates are supplied', () => {
56
+ const index = buildCustomFieldDefinitionIndexFromRows([
57
+ row({ key: 'global_field', entityId: 'demo:entity', organizationId: null }),
58
+ row({ key: 'scoped_field', entityId: 'demo:entity', organizationId: 'org-allowed' }),
59
+ ], { organizationIds: [] })
60
+ expect(Array.from(index.keys()).sort()).toEqual(['global_field'])
61
+ })
62
+
63
+ it('filters by fieldset membership', () => {
64
+ const index = buildCustomFieldDefinitionIndexFromRows([
65
+ row({ key: 'a', entityId: 'demo:entity', configJson: { fieldset: 'pack' } }),
66
+ row({ key: 'b', entityId: 'demo:entity', configJson: { fieldsets: ['pack', 'other'] } }),
67
+ row({ key: 'c', entityId: 'demo:entity', configJson: { fieldset: 'other' } }),
68
+ ], { fieldset: 'pack' })
69
+ expect(Array.from(index.keys()).sort()).toEqual(['a', 'b'])
70
+ })
71
+ })
72
+
73
+ describe('resolveCfDefIndexOrgCandidates', () => {
74
+ it('prefers explicit organization ids and drops blanks', () => {
75
+ expect(resolveCfDefIndexOrgCandidates(['o1', '', null, 'o2'], 'fallback')).toEqual(['o1', 'o2'])
76
+ })
77
+
78
+ it('falls back to the singleton when no explicit ids are given', () => {
79
+ expect(resolveCfDefIndexOrgCandidates(null, 'fallback')).toEqual(['fallback'])
80
+ expect(resolveCfDefIndexOrgCandidates([], 'fallback')).toEqual(['fallback'])
81
+ })
82
+
83
+ it('returns an empty list when neither ids nor fallback resolve', () => {
84
+ expect(resolveCfDefIndexOrgCandidates(null, null)).toEqual([])
85
+ expect(resolveCfDefIndexOrgCandidates([], undefined)).toEqual([])
86
+ })
87
+ })
88
+
89
+ describe('canReuseCustomFieldDefinitions', () => {
90
+ const resolved = {
91
+ index: new Map(),
92
+ entityIds: ['demo:a', 'demo:b'],
93
+ tenantId: 't1',
94
+ organizationIds: ['o1'],
95
+ }
96
+
97
+ it('reuses when entity set, tenant, and org candidates all match (order-insensitive)', () => {
98
+ expect(canReuseCustomFieldDefinitions(resolved, {
99
+ entityIds: ['demo:b', 'demo:a'],
100
+ tenantId: 't1',
101
+ organizationIds: ['o1'],
102
+ })).toBe(true)
103
+ })
104
+
105
+ it('does not reuse when tenant differs', () => {
106
+ expect(canReuseCustomFieldDefinitions(resolved, {
107
+ entityIds: ['demo:a', 'demo:b'],
108
+ tenantId: 't2',
109
+ organizationIds: ['o1'],
110
+ })).toBe(false)
111
+ })
112
+
113
+ it('does not reuse when entity set differs', () => {
114
+ expect(canReuseCustomFieldDefinitions(resolved, {
115
+ entityIds: ['demo:a'],
116
+ tenantId: 't1',
117
+ organizationIds: ['o1'],
118
+ })).toBe(false)
119
+ })
120
+
121
+ it('does not reuse when org candidates differ', () => {
122
+ expect(canReuseCustomFieldDefinitions(resolved, {
123
+ entityIds: ['demo:a', 'demo:b'],
124
+ tenantId: 't1',
125
+ organizationIds: ['o2'],
126
+ })).toBe(false)
127
+ })
128
+
129
+ it('does not reuse when nothing was precomputed', () => {
130
+ expect(canReuseCustomFieldDefinitions(undefined, {
131
+ entityIds: ['demo:a'],
132
+ tenantId: 't1',
133
+ organizationIds: ['o1'],
134
+ })).toBe(false)
135
+ })
136
+ })
@@ -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
+ }