@open-mercato/shared 0.6.5-develop.4534.1.b459babe6d → 0.6.5-develop.4544.1.71c003c861
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 +23 -1
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/crud/factory.js +16 -0
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/optimistic-lock-command.js +109 -0
- package/dist/lib/crud/optimistic-lock-command.js.map +7 -0
- package/dist/lib/crud/optimistic-lock-headers.js +15 -0
- package/dist/lib/crud/optimistic-lock-headers.js.map +7 -0
- package/dist/lib/crud/optimistic-lock-store.js +52 -0
- package/dist/lib/crud/optimistic-lock-store.js.map +7 -0
- package/dist/lib/crud/optimistic-lock.js +172 -0
- package/dist/lib/crud/optimistic-lock.js.map +7 -0
- package/dist/lib/di/container.js +18 -2
- package/dist/lib/di/container.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 +110 -9
- package/src/lib/commands/flush.ts +79 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +106 -0
- package/src/lib/crud/__tests__/optimistic-lock-command.test.ts +425 -0
- package/src/lib/crud/__tests__/optimistic-lock-store.test.ts +194 -0
- package/src/lib/crud/__tests__/optimistic-lock.test.ts +526 -0
- package/src/lib/crud/factory.ts +23 -0
- package/src/lib/crud/optimistic-lock-command.ts +305 -0
- package/src/lib/crud/optimistic-lock-headers.ts +30 -0
- package/src/lib/crud/optimistic-lock-store.ts +87 -0
- package/src/lib/crud/optimistic-lock.ts +379 -0
- package/src/lib/di/container.ts +17 -1
|
@@ -19,9 +19,12 @@ function createFakeEm(overrides?: { inTransaction?: boolean }): FakeEntityManage
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
describe('withAtomicFlush', () => {
|
|
22
|
-
it('runs phases in order and flushes
|
|
22
|
+
it('runs phases in order and flushes after each phase (SPEC-018 boundaries)', async () => {
|
|
23
23
|
const em = createFakeEm()
|
|
24
24
|
const calls: string[] = []
|
|
25
|
+
em.flush.mockImplementation(async () => {
|
|
26
|
+
calls.push('flush')
|
|
27
|
+
})
|
|
25
28
|
|
|
26
29
|
await withAtomicFlush(em as any, [
|
|
27
30
|
async () => {
|
|
@@ -37,14 +40,23 @@ describe('withAtomicFlush', () => {
|
|
|
37
40
|
},
|
|
38
41
|
])
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
expect(
|
|
43
|
+
// Each phase is flushed before the next begins — the interleaved-read guard.
|
|
44
|
+
expect(calls).toEqual([
|
|
45
|
+
'phase1-start',
|
|
46
|
+
'phase1-end',
|
|
47
|
+
'flush',
|
|
48
|
+
'phase2',
|
|
49
|
+
'flush',
|
|
50
|
+
'phase3',
|
|
51
|
+
'flush',
|
|
52
|
+
])
|
|
53
|
+
expect(em.flush).toHaveBeenCalledTimes(3)
|
|
42
54
|
expect(em.begin).not.toHaveBeenCalled()
|
|
43
55
|
expect(em.commit).not.toHaveBeenCalled()
|
|
44
56
|
expect(em.rollback).not.toHaveBeenCalled()
|
|
45
57
|
})
|
|
46
58
|
|
|
47
|
-
it('
|
|
59
|
+
it('flushes a phase before a later phase observes its mutation', async () => {
|
|
48
60
|
const em = createFakeEm()
|
|
49
61
|
const state: { value: number } = { value: 0 }
|
|
50
62
|
let observed: number | null = null
|
|
@@ -59,7 +71,8 @@ describe('withAtomicFlush', () => {
|
|
|
59
71
|
])
|
|
60
72
|
|
|
61
73
|
expect(observed).toBe(42)
|
|
62
|
-
|
|
74
|
+
// Two phases → flushed at each boundary.
|
|
75
|
+
expect(em.flush).toHaveBeenCalledTimes(2)
|
|
63
76
|
})
|
|
64
77
|
|
|
65
78
|
it('wraps phases in begin/commit when transaction option is true', async () => {
|
|
@@ -93,25 +106,31 @@ describe('withAtomicFlush', () => {
|
|
|
93
106
|
expect(em.rollback).toHaveBeenCalledTimes(1)
|
|
94
107
|
})
|
|
95
108
|
|
|
96
|
-
it('propagates a thrown error and
|
|
109
|
+
it('propagates a thrown error and stops at the failing phase (non-transactional, per-phase flush)', async () => {
|
|
97
110
|
const em = createFakeEm()
|
|
98
111
|
const failure = new Error('phase-failure')
|
|
112
|
+
let thirdPhaseRan = false
|
|
99
113
|
|
|
100
114
|
await expect(
|
|
101
115
|
withAtomicFlush(em as any, [
|
|
102
116
|
() => {
|
|
103
|
-
// ok
|
|
117
|
+
// ok — its changeset is flushed at the phase boundary before phase 2 runs
|
|
104
118
|
},
|
|
105
119
|
() => {
|
|
106
120
|
throw failure
|
|
107
121
|
},
|
|
108
122
|
() => {
|
|
109
|
-
|
|
123
|
+
thirdPhaseRan = true
|
|
110
124
|
},
|
|
111
125
|
]),
|
|
112
126
|
).rejects.toBe(failure)
|
|
113
127
|
|
|
114
|
-
|
|
128
|
+
// Non-transactional: the first phase flushed independently before phase 2
|
|
129
|
+
// threw; the failing phase's own flush and every later phase are skipped.
|
|
130
|
+
// (This independent-commit risk is exactly why mutating commands pass
|
|
131
|
+
// `{ transaction: true }`, where the whole sequence rolls back instead.)
|
|
132
|
+
expect(em.flush).toHaveBeenCalledTimes(1)
|
|
133
|
+
expect(thirdPhaseRan).toBe(false)
|
|
115
134
|
})
|
|
116
135
|
|
|
117
136
|
it('is a true no-op when phases is empty — no flush, no transaction', async () => {
|
|
@@ -220,6 +239,88 @@ describe('withAtomicFlush', () => {
|
|
|
220
239
|
expect(em.begin).toHaveBeenCalledWith(undefined)
|
|
221
240
|
})
|
|
222
241
|
|
|
242
|
+
describe('commit-boundary pending-changes guard', () => {
|
|
243
|
+
type UowEm = FakeEntityManager & {
|
|
244
|
+
getUnitOfWork: jest.Mock<{ computeChangeSets: jest.Mock; getChangeSets: jest.Mock }, []>
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function createUowEm(pendingChangeSets: unknown[], opts?: { inTransaction?: boolean }): UowEm {
|
|
248
|
+
const computeChangeSets = jest.fn()
|
|
249
|
+
const getChangeSets = jest.fn().mockReturnValue(pendingChangeSets)
|
|
250
|
+
return {
|
|
251
|
+
...createFakeEm(opts),
|
|
252
|
+
getUnitOfWork: jest.fn().mockReturnValue({ computeChangeSets, getChangeSets }),
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
it('flushes once more when a change set lingers past the last phase flush', async () => {
|
|
257
|
+
// One managed entity is still dirty at the commit boundary (a phase mutated
|
|
258
|
+
// after its own flush). The guard must persist it instead of letting the
|
|
259
|
+
// transaction commit the work-in-progress silently.
|
|
260
|
+
const em = createUowEm([{ entity: 'lingering' }], { inTransaction: false })
|
|
261
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
262
|
+
try {
|
|
263
|
+
await withAtomicFlush(em as any, [() => {}], { transaction: true, label: 'demo.command' })
|
|
264
|
+
} finally {
|
|
265
|
+
warn.mockRestore()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 1 per-phase flush + 1 defensive guard flush, all inside the same transaction.
|
|
269
|
+
expect(em.flush).toHaveBeenCalledTimes(2)
|
|
270
|
+
expect(em.commit).toHaveBeenCalledTimes(1)
|
|
271
|
+
expect(em.rollback).not.toHaveBeenCalled()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('warns (dev) and names the label when the guard has to act', async () => {
|
|
275
|
+
const em = createUowEm([{ a: 1 }, { b: 2 }])
|
|
276
|
+
const previousEnv = process.env.NODE_ENV
|
|
277
|
+
process.env.NODE_ENV = 'development'
|
|
278
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
279
|
+
let warnCallCount = 0
|
|
280
|
+
let warnMessage = ''
|
|
281
|
+
try {
|
|
282
|
+
await withAtomicFlush(em as any, [() => {}], { label: 'sales.update_shipment' })
|
|
283
|
+
// Capture BEFORE mockRestore — restore() resets mock.calls.
|
|
284
|
+
warnCallCount = warn.mock.calls.length
|
|
285
|
+
warnMessage = String(warn.mock.calls[0]?.[0] ?? '')
|
|
286
|
+
} finally {
|
|
287
|
+
warn.mockRestore()
|
|
288
|
+
process.env.NODE_ENV = previousEnv
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
expect(warnCallCount).toBe(1)
|
|
292
|
+
expect(warnMessage).toContain('sales.update_shipment')
|
|
293
|
+
expect(warnMessage).toContain('2 pending change-set(s)')
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('does NOT flush again when the UnitOfWork is clean at the boundary', async () => {
|
|
297
|
+
const em = createUowEm([])
|
|
298
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
299
|
+
try {
|
|
300
|
+
await withAtomicFlush(em as any, [() => {}, () => {}])
|
|
301
|
+
} finally {
|
|
302
|
+
warn.mockRestore()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 2 phases → 2 per-phase flushes; the clean guard adds nothing.
|
|
306
|
+
expect(em.flush).toHaveBeenCalledTimes(2)
|
|
307
|
+
expect(warn).not.toHaveBeenCalled()
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('never throws when the UnitOfWork probe itself fails', async () => {
|
|
311
|
+
const em: any = {
|
|
312
|
+
...createFakeEm(),
|
|
313
|
+
getUnitOfWork: jest.fn(() => {
|
|
314
|
+
throw new Error('uow unavailable')
|
|
315
|
+
}),
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
await expect(withAtomicFlush(em, [() => {}])).resolves.toBeUndefined()
|
|
319
|
+
// Probe failure → unknown → no defensive flush beyond the per-phase one.
|
|
320
|
+
expect(em.flush).toHaveBeenCalledTimes(1)
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
|
|
223
324
|
it('opens its own transaction when the EM does not implement isInTransaction (partial/mock EM)', async () => {
|
|
224
325
|
// Many command unit tests mock an EntityManager with begin/commit/rollback/flush
|
|
225
326
|
// but no isInTransaction. The re-entrancy probe must not throw on such EMs — it
|
|
@@ -19,11 +19,68 @@ export type AtomicFlushOptions = {
|
|
|
19
19
|
*/
|
|
20
20
|
isolationLevel?: IsolationLevel
|
|
21
21
|
/**
|
|
22
|
-
* Optional label for diagnostics.
|
|
22
|
+
* Optional label for diagnostics. Surfaced in the commit-boundary guard's
|
|
23
|
+
* dev warning when a pending change set had to be flushed defensively, so the
|
|
24
|
+
* offending command is identifiable.
|
|
23
25
|
*/
|
|
24
26
|
label?: string
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
type UnitOfWorkProbe = {
|
|
30
|
+
computeChangeSets?: () => void
|
|
31
|
+
getChangeSets?: () => ReadonlyArray<unknown>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type FlushGuardEntityManager = {
|
|
35
|
+
flush: () => Promise<void>
|
|
36
|
+
getUnitOfWork?: () => UnitOfWorkProbe | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Commit-boundary safety net.
|
|
41
|
+
*
|
|
42
|
+
* After every phase has run and flushed, this asserts the UnitOfWork holds NO
|
|
43
|
+
* pending change sets before the transaction commits. If it still does — a phase
|
|
44
|
+
* mutated a managed entity AFTER its own per-phase flush boundary (the exact
|
|
45
|
+
* shape that silently drops a scalar UPDATE under MikroORM v7) — the guard
|
|
46
|
+
* flushes those changes defensively so the write can never be lost, and warns in
|
|
47
|
+
* non-production so the latent ordering bug gets fixed at the source.
|
|
48
|
+
*
|
|
49
|
+
* Detection is best-effort and fail-safe: it only issues the extra flush when it
|
|
50
|
+
* can PROVE the UnitOfWork is dirty (`computeChangeSets()` → `getChangeSets()`
|
|
51
|
+
* non-empty). On EntityManagers that don't expose a UnitOfWork (partial/mock EMs
|
|
52
|
+
* in unit tests) it does nothing — the per-phase flushes already ran — so it
|
|
53
|
+
* never double-flushes a clean unit of work and never changes flush counts for
|
|
54
|
+
* callers that were already correct.
|
|
55
|
+
*/
|
|
56
|
+
async function flushPendingChangesGuard(
|
|
57
|
+
em: FlushGuardEntityManager,
|
|
58
|
+
label?: string,
|
|
59
|
+
): Promise<void> {
|
|
60
|
+
let pendingCount = -1
|
|
61
|
+
try {
|
|
62
|
+
const uow = typeof em.getUnitOfWork === 'function' ? em.getUnitOfWork() : undefined
|
|
63
|
+
if (uow && typeof uow.computeChangeSets === 'function' && typeof uow.getChangeSets === 'function') {
|
|
64
|
+
uow.computeChangeSets()
|
|
65
|
+
pendingCount = uow.getChangeSets().length
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Probing the UnitOfWork must never break a command; fall back to "unknown".
|
|
69
|
+
pendingCount = -1
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (pendingCount > 0) {
|
|
73
|
+
await em.flush()
|
|
74
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
75
|
+
const where = label ? ` (${label})` : ''
|
|
76
|
+
console.warn(
|
|
77
|
+
`[withAtomicFlush]${where}: ${pendingCount} pending change-set(s) remained at the commit boundary and were flushed defensively. ` +
|
|
78
|
+
'A phase mutated a managed entity after its flush boundary — split the mutation and any dependent read/sync into separate phases so the change is never at risk of being dropped.',
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
27
84
|
/**
|
|
28
85
|
* Wraps multiple mutation phases in a single atomic flush.
|
|
29
86
|
*
|
|
@@ -67,11 +124,31 @@ export async function withAtomicFlush(
|
|
|
67
124
|
): Promise<void> {
|
|
68
125
|
if (phases.length === 0) return
|
|
69
126
|
|
|
127
|
+
// SPEC-018: the phases ARE flush boundaries — flush AFTER EACH phase, not once
|
|
128
|
+
// at the end. A phase's scalar mutations must be persisted before the NEXT
|
|
129
|
+
// phase runs any query (em.find / findOne / nativeUpdate / a sync helper);
|
|
130
|
+
// otherwise the interleaved read resets MikroORM v7's identity-map changeset
|
|
131
|
+
// and the pending scalar UPDATE is silently dropped (the #2453 family). This
|
|
132
|
+
// is the framework-level guarantee that lets commands keep mutations and the
|
|
133
|
+
// reads that depend on them in separate phases without hand-rolled flushes.
|
|
134
|
+
//
|
|
135
|
+
// Atomicity is preserved: when `transaction: true` (or an ambient transaction
|
|
136
|
+
// is joined), each `em.flush()` only emits SQL inside the open transaction —
|
|
137
|
+
// the single commit/rollback below still spans every phase, so a later-phase
|
|
138
|
+
// failure rolls back all earlier phases. Without a transaction the helper
|
|
139
|
+
// keeps its documented "each phase flushes independently" behavior.
|
|
140
|
+
//
|
|
141
|
+
// Commit-boundary guarantee: after the last phase flush, `flushPendingChangesGuard`
|
|
142
|
+
// re-checks the UnitOfWork and flushes once more if ANY pending change set remains
|
|
143
|
+
// (a phase mutated state after its boundary). The transaction therefore can never
|
|
144
|
+
// commit with unflushed work — if a per-phase flush was missed "for some reason",
|
|
145
|
+
// the guard catches it inside the same transaction and warns in dev.
|
|
70
146
|
const runPhasesAndFlush = async () => {
|
|
71
147
|
for (const phase of phases) {
|
|
72
148
|
await phase()
|
|
149
|
+
await em.flush()
|
|
73
150
|
}
|
|
74
|
-
await em
|
|
151
|
+
await flushPendingChangesGuard(em as unknown as FlushGuardEntityManager, options?.label)
|
|
75
152
|
}
|
|
76
153
|
|
|
77
154
|
if (!options?.transaction) {
|
|
@@ -4,6 +4,11 @@ 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 {
|
|
8
|
+
clearOptimisticLockReadersForTests,
|
|
9
|
+
getAllOptimisticLockReaders,
|
|
10
|
+
registerOptimisticLockReaders,
|
|
11
|
+
} from '@open-mercato/shared/lib/crud/optimistic-lock-store'
|
|
7
12
|
import { loadCustomFieldDefinitionIndex } from '@open-mercato/shared/lib/crud/custom-fields'
|
|
8
13
|
import { z } from 'zod'
|
|
9
14
|
|
|
@@ -754,3 +759,104 @@ describe('CRUD Factory', () => {
|
|
|
754
759
|
expect(body._interceptor).toEqual({ ok: true, count: 1 })
|
|
755
760
|
})
|
|
756
761
|
})
|
|
762
|
+
|
|
763
|
+
describe('CRUD Factory — optimistic-lock auto-registration', () => {
|
|
764
|
+
beforeEach(() => {
|
|
765
|
+
clearOptimisticLockReadersForTests()
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
afterAll(() => {
|
|
769
|
+
clearOptimisticLockReadersForTests()
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
function makeMinimalRoute(opts: { eventsResource: string; entity: any }) {
|
|
773
|
+
return makeCrudRoute({
|
|
774
|
+
metadata: { GET: { requireAuth: true } },
|
|
775
|
+
orm: { entity: opts.entity, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId' },
|
|
776
|
+
events: { module: opts.eventsResource.split('.')[0], entity: opts.eventsResource.split('.')[1], persistent: false } as any,
|
|
777
|
+
list: { schema: z.object({}).passthrough() as any },
|
|
778
|
+
create: {
|
|
779
|
+
commandId: `${opts.eventsResource}.create`,
|
|
780
|
+
schema: z.object({}).passthrough() as any,
|
|
781
|
+
},
|
|
782
|
+
update: {
|
|
783
|
+
commandId: `${opts.eventsResource}.update`,
|
|
784
|
+
schema: z.object({ id: z.string() }).passthrough() as any,
|
|
785
|
+
},
|
|
786
|
+
del: {
|
|
787
|
+
commandId: `${opts.eventsResource}.delete`,
|
|
788
|
+
schema: z.object({ id: z.string() }).passthrough() as any,
|
|
789
|
+
},
|
|
790
|
+
})
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
it('auto-registers a reader for the route resourceKind at factory call time', () => {
|
|
794
|
+
expect(getAllOptimisticLockReaders()).toEqual({})
|
|
795
|
+
makeMinimalRoute({ eventsResource: 'example.todo', entity: Todo })
|
|
796
|
+
const all = getAllOptimisticLockReaders()
|
|
797
|
+
expect(Object.keys(all)).toContain('example.todo')
|
|
798
|
+
expect(typeof all['example.todo']).toBe('function')
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
it('does NOT override an existing hand-wired reader (IfAbsent semantics)', () => {
|
|
802
|
+
const handWired = async () => 'hand-wired'
|
|
803
|
+
registerOptimisticLockReaders({ 'example.todo': handWired })
|
|
804
|
+
makeMinimalRoute({ eventsResource: 'example.todo', entity: Todo })
|
|
805
|
+
expect(getAllOptimisticLockReaders()['example.todo']).toBe(handWired)
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
it('skips registration when the entity has no resolvable resourceKind', () => {
|
|
809
|
+
expect(getAllOptimisticLockReaders()).toEqual({})
|
|
810
|
+
// Route with no events.module + no command IDs → resourceKind falls back to 'resource'
|
|
811
|
+
makeCrudRoute({
|
|
812
|
+
metadata: { GET: { requireAuth: true } },
|
|
813
|
+
orm: { entity: Todo },
|
|
814
|
+
list: { schema: z.object({}).passthrough() as any },
|
|
815
|
+
} as any)
|
|
816
|
+
// 'resource' is filtered out by the auto-registration guard
|
|
817
|
+
expect(getAllOptimisticLockReaders()['resource']).toBeUndefined()
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
it('the registered reader projects only updatedAt and fails open on schema mismatch', async () => {
|
|
821
|
+
makeMinimalRoute({ eventsResource: 'example.todo', entity: Todo })
|
|
822
|
+
const reader = getAllOptimisticLockReaders()['example.todo']
|
|
823
|
+
expect(reader).toBeDefined()
|
|
824
|
+
let captured: { entity: unknown; filter: Record<string, unknown>; options?: Record<string, unknown> } | null = null
|
|
825
|
+
const fakeEm = {
|
|
826
|
+
async findOne(entity: unknown, filter: Record<string, unknown>, options?: Record<string, unknown>) {
|
|
827
|
+
captured = { entity, filter, options }
|
|
828
|
+
return { updatedAt: new Date('2026-05-26T07:30:00.000Z') }
|
|
829
|
+
},
|
|
830
|
+
} as never
|
|
831
|
+
const out = await reader!(fakeEm, {
|
|
832
|
+
resourceKind: 'example.todo',
|
|
833
|
+
resourceId: 'todo-1',
|
|
834
|
+
tenantId: 'tenant-1',
|
|
835
|
+
organizationId: 'org-1',
|
|
836
|
+
})
|
|
837
|
+
expect(out).toBe('2026-05-26T07:30:00.000Z')
|
|
838
|
+
expect(captured).not.toBeNull()
|
|
839
|
+
expect(captured!.entity).toBe(Todo)
|
|
840
|
+
expect(captured!.filter).toEqual({
|
|
841
|
+
id: 'todo-1',
|
|
842
|
+
tenantId: 'tenant-1',
|
|
843
|
+
organizationId: 'org-1',
|
|
844
|
+
deletedAt: null,
|
|
845
|
+
})
|
|
846
|
+
expect(captured!.options).toEqual({ fields: ['updatedAt'] })
|
|
847
|
+
|
|
848
|
+
// Fail-open contract: throwing findOne yields null, not a re-thrown error.
|
|
849
|
+
const throwingEm = {
|
|
850
|
+
async findOne() {
|
|
851
|
+
throw new Error('schema mismatch')
|
|
852
|
+
},
|
|
853
|
+
} as never
|
|
854
|
+
const safe = await reader!(throwingEm, {
|
|
855
|
+
resourceKind: 'example.todo',
|
|
856
|
+
resourceId: 'todo-1',
|
|
857
|
+
tenantId: 'tenant-1',
|
|
858
|
+
organizationId: 'org-1',
|
|
859
|
+
})
|
|
860
|
+
expect(safe).toBeNull()
|
|
861
|
+
})
|
|
862
|
+
})
|