@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/lib/commands/flush.js +17 -12
- package/dist/lib/commands/flush.js.map +2 -2
- 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/commands/__tests__/flush.test.ts +78 -2
- package/src/lib/commands/flush.ts +72 -19
- 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
|
@@ -2,17 +2,19 @@ import { withAtomicFlush } from '../flush'
|
|
|
2
2
|
|
|
3
3
|
type FakeEntityManager = {
|
|
4
4
|
flush: jest.Mock<Promise<void>, []>
|
|
5
|
-
begin: jest.Mock<Promise<void>, []>
|
|
5
|
+
begin: jest.Mock<Promise<void>, [unknown?]>
|
|
6
6
|
commit: jest.Mock<Promise<void>, []>
|
|
7
7
|
rollback: jest.Mock<Promise<void>, []>
|
|
8
|
+
isInTransaction: jest.Mock<boolean, []>
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
function createFakeEm(): FakeEntityManager {
|
|
11
|
+
function createFakeEm(overrides?: { inTransaction?: boolean }): FakeEntityManager {
|
|
11
12
|
return {
|
|
12
13
|
flush: jest.fn().mockResolvedValue(undefined),
|
|
13
14
|
begin: jest.fn().mockResolvedValue(undefined),
|
|
14
15
|
commit: jest.fn().mockResolvedValue(undefined),
|
|
15
16
|
rollback: jest.fn().mockResolvedValue(undefined),
|
|
17
|
+
isInTransaction: jest.fn().mockReturnValue(overrides?.inTransaction ?? false),
|
|
16
18
|
}
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -163,4 +165,78 @@ describe('withAtomicFlush', () => {
|
|
|
163
165
|
|
|
164
166
|
expect(em.rollback).toHaveBeenCalledTimes(1)
|
|
165
167
|
})
|
|
168
|
+
|
|
169
|
+
it('joins an ambient transaction instead of clobbering it (re-entrancy)', async () => {
|
|
170
|
+
const em = createFakeEm({ inTransaction: true })
|
|
171
|
+
const phase = jest.fn()
|
|
172
|
+
|
|
173
|
+
await withAtomicFlush(em as any, [phase], { transaction: true })
|
|
174
|
+
|
|
175
|
+
// Must NOT open/commit a nested transaction — the outermost caller owns it.
|
|
176
|
+
expect(em.begin).not.toHaveBeenCalled()
|
|
177
|
+
expect(em.commit).not.toHaveBeenCalled()
|
|
178
|
+
expect(em.rollback).not.toHaveBeenCalled()
|
|
179
|
+
expect(phase).toHaveBeenCalledTimes(1)
|
|
180
|
+
expect(em.flush).toHaveBeenCalledTimes(1)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('propagates a phase error when joining an ambient transaction (no local rollback)', async () => {
|
|
184
|
+
const em = createFakeEm({ inTransaction: true })
|
|
185
|
+
const failure = new Error('nested-phase-failure')
|
|
186
|
+
|
|
187
|
+
await expect(
|
|
188
|
+
withAtomicFlush(em as any, [
|
|
189
|
+
() => {
|
|
190
|
+
throw failure
|
|
191
|
+
},
|
|
192
|
+
], { transaction: true }),
|
|
193
|
+
).rejects.toBe(failure)
|
|
194
|
+
|
|
195
|
+
// The enclosing transaction owns rollback; this call must not commit or rollback.
|
|
196
|
+
expect(em.begin).not.toHaveBeenCalled()
|
|
197
|
+
expect(em.commit).not.toHaveBeenCalled()
|
|
198
|
+
expect(em.rollback).not.toHaveBeenCalled()
|
|
199
|
+
expect(em.flush).not.toHaveBeenCalled()
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('forwards isolationLevel to begin when opening a top-level transaction', async () => {
|
|
203
|
+
const em = createFakeEm()
|
|
204
|
+
|
|
205
|
+
await withAtomicFlush(em as any, [() => {}], {
|
|
206
|
+
transaction: true,
|
|
207
|
+
isolationLevel: 'serializable' as any,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
expect(em.begin).toHaveBeenCalledTimes(1)
|
|
211
|
+
expect(em.begin).toHaveBeenCalledWith({ isolationLevel: 'serializable' })
|
|
212
|
+
expect(em.commit).toHaveBeenCalledTimes(1)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('does not pass options to begin when no isolationLevel is set', async () => {
|
|
216
|
+
const em = createFakeEm()
|
|
217
|
+
|
|
218
|
+
await withAtomicFlush(em as any, [() => {}], { transaction: true })
|
|
219
|
+
|
|
220
|
+
expect(em.begin).toHaveBeenCalledWith(undefined)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('opens its own transaction when the EM does not implement isInTransaction (partial/mock EM)', async () => {
|
|
224
|
+
// Many command unit tests mock an EntityManager with begin/commit/rollback/flush
|
|
225
|
+
// but no isInTransaction. The re-entrancy probe must not throw on such EMs — it
|
|
226
|
+
// treats the missing method as "not in a transaction" and opens its own.
|
|
227
|
+
const begin = jest.fn().mockResolvedValue(undefined)
|
|
228
|
+
const commit = jest.fn().mockResolvedValue(undefined)
|
|
229
|
+
const flush = jest.fn().mockResolvedValue(undefined)
|
|
230
|
+
const partialEm = { begin, commit, rollback: jest.fn(), flush }
|
|
231
|
+
const phase = jest.fn()
|
|
232
|
+
|
|
233
|
+
await expect(
|
|
234
|
+
withAtomicFlush(partialEm as any, [phase], { transaction: true }),
|
|
235
|
+
).resolves.toBeUndefined()
|
|
236
|
+
|
|
237
|
+
expect(begin).toHaveBeenCalledTimes(1)
|
|
238
|
+
expect(phase).toHaveBeenCalledTimes(1)
|
|
239
|
+
expect(flush).toHaveBeenCalledTimes(1)
|
|
240
|
+
expect(commit).toHaveBeenCalledTimes(1)
|
|
241
|
+
})
|
|
166
242
|
})
|
|
@@ -1,4 +1,28 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import type { IsolationLevel } from '@mikro-orm/core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Options controlling how {@link withAtomicFlush} executes its phases.
|
|
6
|
+
*/
|
|
7
|
+
export type AtomicFlushOptions = {
|
|
8
|
+
/**
|
|
9
|
+
* When true, the whole sequence runs inside a database transaction for
|
|
10
|
+
* all-or-nothing semantics. Default: false (a single `em.flush()` commits
|
|
11
|
+
* all phases at the end — no transaction).
|
|
12
|
+
*/
|
|
13
|
+
transaction?: boolean
|
|
14
|
+
/**
|
|
15
|
+
* Optional transaction isolation level, forwarded to `em.begin()`. Only
|
|
16
|
+
* honoured when this call opens a new top-level transaction (i.e.
|
|
17
|
+
* `transaction: true` and the EntityManager is not already inside a
|
|
18
|
+
* transaction). Ignored when joining an ambient transaction.
|
|
19
|
+
*/
|
|
20
|
+
isolationLevel?: IsolationLevel
|
|
21
|
+
/**
|
|
22
|
+
* Optional label for diagnostics. Currently informational only.
|
|
23
|
+
*/
|
|
24
|
+
label?: string
|
|
25
|
+
}
|
|
2
26
|
|
|
3
27
|
/**
|
|
4
28
|
* Wraps multiple mutation phases in a single atomic flush.
|
|
@@ -10,10 +34,24 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
10
34
|
* phases mutate, so closures over `em` stay valid.
|
|
11
35
|
*
|
|
12
36
|
* When `options.transaction` is true, the whole sequence runs
|
|
13
|
-
* inside a database transaction
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
37
|
+
* inside a database transaction for all-or-nothing semantics.
|
|
38
|
+
*
|
|
39
|
+
* ## Re-entrancy / composability
|
|
40
|
+
*
|
|
41
|
+
* `withAtomicFlush({ transaction: true })` is safe to nest. If the
|
|
42
|
+
* supplied `EntityManager` is **already inside a transaction**, this
|
|
43
|
+
* call does NOT open a second one (raw `em.begin()` would clobber the
|
|
44
|
+
* active `#transactionContext` and orphan the outer transaction — unlike
|
|
45
|
+
* `em.transactional()`, MikroORM's `em.begin()` does not check
|
|
46
|
+
* `isInTransaction()`). Instead it joins the ambient transaction: the
|
|
47
|
+
* phases run and flush within it, and the outermost caller owns the final
|
|
48
|
+
* `commit()` / `rollback()`. A phase error therefore rolls back the entire
|
|
49
|
+
* enclosing transaction (all-or-nothing across the whole nest).
|
|
50
|
+
*
|
|
51
|
+
* This mirrors the contract every command relies on: each command forks
|
|
52
|
+
* the request `EntityManager` first, so the common case opens a fresh
|
|
53
|
+
* top-level transaction; nesting only happens when one transactional unit
|
|
54
|
+
* is composed inside another on the same `em`.
|
|
17
55
|
*
|
|
18
56
|
* When `phases` is empty the call is a true no-op — no flush,
|
|
19
57
|
* no transaction. Callers that need an explicit commit should
|
|
@@ -25,7 +63,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
25
63
|
export async function withAtomicFlush(
|
|
26
64
|
em: EntityManager,
|
|
27
65
|
phases: Array<() => void | Promise<void>>,
|
|
28
|
-
options?:
|
|
66
|
+
options?: AtomicFlushOptions,
|
|
29
67
|
): Promise<void> {
|
|
30
68
|
if (phases.length === 0) return
|
|
31
69
|
|
|
@@ -36,21 +74,36 @@ export async function withAtomicFlush(
|
|
|
36
74
|
await em.flush()
|
|
37
75
|
}
|
|
38
76
|
|
|
39
|
-
if (options?.transaction) {
|
|
40
|
-
await
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
77
|
+
if (!options?.transaction) {
|
|
78
|
+
await runPhasesAndFlush()
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Re-entrancy guard: never open a nested transaction with raw begin/commit.
|
|
83
|
+
// If a transaction is already active on this EntityManager, join it — the
|
|
84
|
+
// outermost caller owns commit/rollback. A phase error propagates and rolls
|
|
85
|
+
// back the whole enclosing transaction.
|
|
86
|
+
//
|
|
87
|
+
// Guard the probe: real MikroORM EntityManagers always implement
|
|
88
|
+
// `isInTransaction()`, but partial / mock EMs may not. A missing method is
|
|
89
|
+
// treated as "not in a transaction", so this call opens its own top-level
|
|
90
|
+
// transaction via the begin/commit path below (which those EMs do support).
|
|
91
|
+
const isInTransaction = (em as { isInTransaction?: () => boolean }).isInTransaction
|
|
92
|
+
if (typeof isInTransaction === 'function' && isInTransaction.call(em)) {
|
|
93
|
+
await runPhasesAndFlush()
|
|
52
94
|
return
|
|
53
95
|
}
|
|
54
96
|
|
|
55
|
-
await
|
|
97
|
+
await em.begin(options.isolationLevel ? { isolationLevel: options.isolationLevel } : undefined)
|
|
98
|
+
try {
|
|
99
|
+
await runPhasesAndFlush()
|
|
100
|
+
await em.commit()
|
|
101
|
+
} catch (err) {
|
|
102
|
+
try {
|
|
103
|
+
await em.rollback()
|
|
104
|
+
} catch {
|
|
105
|
+
// rollback failure should not mask the original error; intentionally swallowed
|
|
106
|
+
}
|
|
107
|
+
throw err
|
|
108
|
+
}
|
|
56
109
|
}
|
|
@@ -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
|
+
})
|