@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.
Files changed (30) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/lib/commands/flush.js +23 -1
  3. package/dist/lib/commands/flush.js.map +2 -2
  4. package/dist/lib/crud/factory.js +16 -0
  5. package/dist/lib/crud/factory.js.map +2 -2
  6. package/dist/lib/crud/optimistic-lock-command.js +109 -0
  7. package/dist/lib/crud/optimistic-lock-command.js.map +7 -0
  8. package/dist/lib/crud/optimistic-lock-headers.js +15 -0
  9. package/dist/lib/crud/optimistic-lock-headers.js.map +7 -0
  10. package/dist/lib/crud/optimistic-lock-store.js +52 -0
  11. package/dist/lib/crud/optimistic-lock-store.js.map +7 -0
  12. package/dist/lib/crud/optimistic-lock.js +172 -0
  13. package/dist/lib/crud/optimistic-lock.js.map +7 -0
  14. package/dist/lib/di/container.js +18 -2
  15. package/dist/lib/di/container.js.map +2 -2
  16. package/dist/lib/version.js +1 -1
  17. package/dist/lib/version.js.map +1 -1
  18. package/package.json +2 -2
  19. package/src/lib/commands/__tests__/flush.test.ts +110 -9
  20. package/src/lib/commands/flush.ts +79 -2
  21. package/src/lib/crud/__tests__/crud-factory.test.ts +106 -0
  22. package/src/lib/crud/__tests__/optimistic-lock-command.test.ts +425 -0
  23. package/src/lib/crud/__tests__/optimistic-lock-store.test.ts +194 -0
  24. package/src/lib/crud/__tests__/optimistic-lock.test.ts +526 -0
  25. package/src/lib/crud/factory.ts +23 -0
  26. package/src/lib/crud/optimistic-lock-command.ts +305 -0
  27. package/src/lib/crud/optimistic-lock-headers.ts +30 -0
  28. package/src/lib/crud/optimistic-lock-store.ts +87 -0
  29. package/src/lib/crud/optimistic-lock.ts +379 -0
  30. 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 once at the end', async () => {
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
- expect(calls).toEqual(['phase1-start', 'phase1-end', 'phase2', 'phase3'])
41
- expect(em.flush).toHaveBeenCalledTimes(1)
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('lets a later phase observe state a prior phase mutated', async () => {
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
- expect(em.flush).toHaveBeenCalledTimes(1)
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 does NOT flush when a phase throws (non-transactional)', async () => {
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
- throw new Error('should-not-run')
123
+ thirdPhaseRan = true
110
124
  },
111
125
  ]),
112
126
  ).rejects.toBe(failure)
113
127
 
114
- expect(em.flush).not.toHaveBeenCalled()
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. Currently informational only.
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.flush()
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
+ })