@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.
- 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
|
@@ -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
|
+
}
|