@open-mercato/shared 0.8.1-develop.7296.1.2111d779db → 0.9.0

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 (137) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -0
  3. package/dist/lib/auth/jwt.js +4 -0
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/organizationScope.js +15 -1
  6. package/dist/lib/auth/organizationScope.js.map +2 -2
  7. package/dist/lib/availability/catalogOnlyProvider.js +77 -0
  8. package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
  9. package/dist/lib/availability/index.js +8 -0
  10. package/dist/lib/availability/index.js.map +7 -0
  11. package/dist/lib/availability/registry.js +47 -0
  12. package/dist/lib/availability/registry.js.map +7 -0
  13. package/dist/lib/availability/types.js +7 -0
  14. package/dist/lib/availability/types.js.map +7 -0
  15. package/dist/lib/catalog-visibility/index.js +11 -0
  16. package/dist/lib/catalog-visibility/index.js.map +7 -0
  17. package/dist/lib/catalog-visibility/intersectScopes.js +49 -0
  18. package/dist/lib/catalog-visibility/intersectScopes.js.map +7 -0
  19. package/dist/lib/catalog-visibility/matchesOne.js +27 -0
  20. package/dist/lib/catalog-visibility/matchesOne.js.map +7 -0
  21. package/dist/lib/catalog-visibility/matchesScope.js +9 -0
  22. package/dist/lib/catalog-visibility/matchesScope.js.map +7 -0
  23. package/dist/lib/catalog-visibility/types.js +1 -0
  24. package/dist/lib/catalog-visibility/types.js.map +7 -0
  25. package/dist/lib/catalog-visibility/unionScopes.js +9 -0
  26. package/dist/lib/catalog-visibility/unionScopes.js.map +7 -0
  27. package/dist/lib/commands/command-bus.js +383 -125
  28. package/dist/lib/commands/command-bus.js.map +3 -3
  29. package/dist/lib/commands/flush.js +16 -0
  30. package/dist/lib/commands/flush.js.map +2 -2
  31. package/dist/lib/commands/index.js +8 -0
  32. package/dist/lib/commands/index.js.map +2 -2
  33. package/dist/lib/commands/redo.js +2 -7
  34. package/dist/lib/commands/redo.js.map +2 -2
  35. package/dist/lib/commands/transaction-lifetime.js +67 -0
  36. package/dist/lib/commands/transaction-lifetime.js.map +7 -0
  37. package/dist/lib/commands/types.js.map +2 -2
  38. package/dist/lib/commands/undo.js +54 -2
  39. package/dist/lib/commands/undo.js.map +2 -2
  40. package/dist/lib/crud/enricher-runner.js +64 -9
  41. package/dist/lib/crud/enricher-runner.js.map +2 -2
  42. package/dist/lib/crud/factory.js +42 -18
  43. package/dist/lib/crud/factory.js.map +2 -2
  44. package/dist/lib/data/engine.js +12 -3
  45. package/dist/lib/data/engine.js.map +2 -2
  46. package/dist/lib/db/accentInsensitiveSearch.js +21 -0
  47. package/dist/lib/db/accentInsensitiveSearch.js.map +7 -0
  48. package/dist/lib/encryption/kms.js +1 -5
  49. package/dist/lib/encryption/kms.js.map +2 -2
  50. package/dist/lib/encryption/likeFilterWarning.js +3 -1
  51. package/dist/lib/encryption/likeFilterWarning.js.map +2 -2
  52. package/dist/lib/encryption/migration-backfill.js +69 -0
  53. package/dist/lib/encryption/migration-backfill.js.map +7 -0
  54. package/dist/lib/encryption/policyMemo.js +28 -0
  55. package/dist/lib/encryption/policyMemo.js.map +7 -0
  56. package/dist/lib/encryption/subscriber.js +30 -3
  57. package/dist/lib/encryption/subscriber.js.map +2 -2
  58. package/dist/lib/encryption/tenantDataEncryptionService.js +113 -200
  59. package/dist/lib/encryption/tenantDataEncryptionService.js.map +3 -3
  60. package/dist/lib/modules/api-route-identity.js +102 -0
  61. package/dist/lib/modules/api-route-identity.js.map +7 -0
  62. package/dist/lib/telemetry/runtime.js.map +2 -2
  63. package/dist/lib/version.js +1 -1
  64. package/dist/lib/version.js.map +1 -1
  65. package/dist/modules/events/factory.js +20 -0
  66. package/dist/modules/events/factory.js.map +2 -2
  67. package/dist/modules/registry.js +3 -1
  68. package/dist/modules/registry.js.map +2 -2
  69. package/package.json +12 -5
  70. package/src/lib/auth/__tests__/jwt.test.ts +12 -0
  71. package/src/lib/auth/__tests__/organizationScope.test.ts +27 -0
  72. package/src/lib/auth/jwt.ts +9 -0
  73. package/src/lib/auth/organizationScope.ts +39 -0
  74. package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +166 -0
  75. package/src/lib/availability/__tests__/registry.test.ts +130 -0
  76. package/src/lib/availability/catalogOnlyProvider.ts +152 -0
  77. package/src/lib/availability/index.ts +18 -0
  78. package/src/lib/availability/registry.ts +131 -0
  79. package/src/lib/availability/types.ts +86 -0
  80. package/src/lib/catalog-visibility/__tests__/intersectScopes.test.ts +168 -0
  81. package/src/lib/catalog-visibility/__tests__/matchesOne.test.ts +116 -0
  82. package/src/lib/catalog-visibility/__tests__/matchesScope.test.ts +35 -0
  83. package/src/lib/catalog-visibility/__tests__/testFixtures.ts +66 -0
  84. package/src/lib/catalog-visibility/__tests__/unionScopes.test.ts +62 -0
  85. package/src/lib/catalog-visibility/index.ts +5 -0
  86. package/src/lib/catalog-visibility/intersectScopes.ts +93 -0
  87. package/src/lib/catalog-visibility/matchesOne.ts +28 -0
  88. package/src/lib/catalog-visibility/matchesScope.ts +8 -0
  89. package/src/lib/catalog-visibility/types.ts +31 -0
  90. package/src/lib/catalog-visibility/unionScopes.ts +16 -0
  91. package/src/lib/commands/__tests__/command-bus.atomic-replay.test.ts +650 -0
  92. package/src/lib/commands/__tests__/command-bus.test.ts +175 -0
  93. package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +1 -1
  94. package/src/lib/commands/__tests__/redo.test.ts +4 -0
  95. package/src/lib/commands/__tests__/transaction-lifetime.test.ts +118 -0
  96. package/src/lib/commands/__tests__/undo.test.ts +90 -1
  97. package/src/lib/commands/command-bus.ts +463 -148
  98. package/src/lib/commands/flush.ts +16 -0
  99. package/src/lib/commands/index.ts +7 -0
  100. package/src/lib/commands/redo.ts +4 -8
  101. package/src/lib/commands/transaction-lifetime.ts +88 -0
  102. package/src/lib/commands/types.ts +91 -0
  103. package/src/lib/commands/undo.ts +79 -1
  104. package/src/lib/crud/__tests__/crud-factory.cache-tenant-level.test.ts +149 -0
  105. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +48 -6
  106. package/src/lib/crud/__tests__/crud-factory.test.ts +224 -27
  107. package/src/lib/crud/__tests__/enricher-runner.test.ts +270 -5
  108. package/src/lib/crud/enricher-runner.ts +83 -9
  109. package/src/lib/crud/factory.ts +46 -19
  110. package/src/lib/data/__tests__/engine.pin-organization-id.test.ts +102 -0
  111. package/src/lib/data/engine.ts +12 -0
  112. package/src/lib/db/__tests__/accentInsensitiveSearch.test.ts +51 -0
  113. package/src/lib/db/accentInsensitiveSearch.ts +70 -0
  114. package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +11 -9
  115. package/src/lib/encryption/__tests__/fixtures/policy-process-worker.ts +96 -0
  116. package/src/lib/encryption/__tests__/kms.test.ts +45 -3
  117. package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +43 -0
  118. package/src/lib/encryption/__tests__/migration-backfill.test.ts +57 -0
  119. package/src/lib/encryption/__tests__/process-policy-coherence.test.ts +128 -0
  120. package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +66 -1
  121. package/src/lib/encryption/__tests__/subscriber.test.ts +3 -0
  122. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +412 -51
  123. package/src/lib/encryption/kms.ts +1 -6
  124. package/src/lib/encryption/likeFilterWarning.ts +10 -2
  125. package/src/lib/encryption/migration-backfill.ts +90 -0
  126. package/src/lib/encryption/policyMemo.ts +51 -0
  127. package/src/lib/encryption/subscriber.ts +41 -4
  128. package/src/lib/encryption/tenantDataEncryptionService.ts +153 -219
  129. package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +20 -10
  130. package/src/lib/modules/api-route-identity.ts +116 -0
  131. package/src/lib/telemetry/runtime.ts +8 -0
  132. package/src/modules/__tests__/registry.test.ts +92 -0
  133. package/src/modules/events/__tests__/broadcast-coalescing-declaration.test.ts +53 -0
  134. package/src/modules/events/factory.ts +31 -0
  135. package/src/modules/events/types.ts +13 -0
  136. package/src/modules/notifications/types.ts +1 -0
  137. package/src/modules/registry.ts +4 -1
@@ -1,6 +1,11 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import type { IsolationLevel } from '@mikro-orm/core'
3
3
  import { createLogger } from '../logger'
4
+ import { getTelemetryRuntime } from '../telemetry/runtime'
5
+ import {
6
+ beginTransactionLifetime,
7
+ completeTransactionLifetime,
8
+ } from './transaction-lifetime'
4
9
 
5
10
  const logger = createLogger('shared').child({ component: 'commands' })
6
11
 
@@ -171,6 +176,7 @@ export async function withAtomicFlush(
171
176
  }
172
177
 
173
178
  await em.begin(options.isolationLevel ? { isolationLevel: options.isolationLevel } : undefined)
179
+ const transactionLifetime = beginTransactionLifetime(em)
174
180
  try {
175
181
  await runPhasesAndFlush()
176
182
  await em.commit()
@@ -180,6 +186,16 @@ export async function withAtomicFlush(
180
186
  } catch {
181
187
  // rollback failure should not mask the original error; intentionally swallowed
182
188
  }
189
+ try {
190
+ await completeTransactionLifetime(em, transactionLifetime, 'rolled_back')
191
+ } catch (completionErr) {
192
+ logger.error('withAtomicFlush: rollback completion failed', { label: options.label ?? null, err: completionErr })
193
+ getTelemetryRuntime()?.reportError(completionErr, {
194
+ module: 'shared',
195
+ code: 'shared.transaction_rollback_completion_failed',
196
+ })
197
+ }
183
198
  throw err
184
199
  }
200
+ await completeTransactionLifetime(em, transactionLifetime, 'committed')
185
201
  }
@@ -4,6 +4,13 @@ export { CommandBus } from './command-bus'
4
4
  export * from './customFieldSnapshots'
5
5
  export * from './undo'
6
6
  export * from './redo'
7
+ export {
8
+ getTransactionLifetime,
9
+ onTransactionLifetimeComplete,
10
+ ownsTransactionLifetime,
11
+ type TransactionLifetime,
12
+ type TransactionOutcome,
13
+ } from './transaction-lifetime'
7
14
  export {
8
15
  CommandInterceptorError,
9
16
  isCommandInterceptorError,
@@ -3,7 +3,7 @@ import type { CommandRuntimeContext, CommandUndoLogEntry } from './types'
3
3
  import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
4
4
  import type { CrudEventsConfig, CrudIndexerConfig } from '@open-mercato/shared/lib/crud/types'
5
5
  import { CrudHttpError, conflict, isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
6
- import { extractUndoPayload, type UndoPayload } from './undo'
6
+ import { extractUndoPayload, reviveSnapshotDates, type UndoPayload } from './undo'
7
7
  import { emitCrudSideEffects } from './helpers'
8
8
  import { withAtomicFlush } from './flush'
9
9
 
@@ -22,7 +22,8 @@ const DEFAULT_SNAPSHOT_DATE_FIELDS = ['createdAt', 'updatedAt', 'deletedAt'] as
22
22
 
23
23
  /**
24
24
  * Turn an after-snapshot into a create seed by shallow-cloning it and reviving the
25
- * declared date fields from ISO strings back to `Date`. Single-row snapshots are a
25
+ * declared date fields from ISO strings back to `Date` (via `reviveSnapshotDates`,
26
+ * which throws on an unparsable value). Single-row snapshots are a
26
27
  * faithful serialized row whose keys already equal entity property names, so the
27
28
  * snapshot doubles as the seed once dates are revived — no per-command mapping needed.
28
29
  */
@@ -30,12 +31,7 @@ export function reviveSnapshotSeed(
30
31
  snapshot: Record<string, unknown>,
31
32
  dateFields: readonly string[] = DEFAULT_SNAPSHOT_DATE_FIELDS,
32
33
  ): Record<string, unknown> {
33
- const seed: Record<string, unknown> = { ...snapshot }
34
- for (const field of dateFields) {
35
- const value = seed[field]
36
- if (typeof value === 'string') seed[field] = new Date(value)
37
- }
38
- return seed
34
+ return reviveSnapshotDates(snapshot, dateFields)
39
35
  }
40
36
 
41
37
  /**
@@ -0,0 +1,88 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { createLogger } from '../logger'
4
+ import { getTelemetryRuntime } from '../telemetry/runtime'
5
+
6
+ const logger = createLogger('shared').child({ component: 'transaction-lifetime' })
7
+
8
+ export type TransactionOutcome = 'committed' | 'rolled_back'
9
+
10
+ const transactionLifetimeBrand: unique symbol = Symbol('open-mercato.transaction-lifetime')
11
+
12
+ export type TransactionLifetime = {
13
+ readonly [transactionLifetimeBrand]: true
14
+ }
15
+
16
+ type ActiveTransactionLifetime = TransactionLifetime & {
17
+ completionCallbacks: Set<(outcome: TransactionOutcome) => void | Promise<void>>
18
+ }
19
+
20
+ const activeTransactionLifetimes = new AsyncLocalStorage<Map<object, ActiveTransactionLifetime>>()
21
+
22
+ export function beginTransactionLifetime(em: EntityManager): TransactionLifetime {
23
+ const key = em as object
24
+ const currentLifetimes = activeTransactionLifetimes.getStore()
25
+ if (currentLifetimes?.has(key)) {
26
+ throw new Error('[internal] Transaction lifetime already active for EntityManager')
27
+ }
28
+ const lifetime: ActiveTransactionLifetime = {
29
+ [transactionLifetimeBrand]: true,
30
+ completionCallbacks: new Set(),
31
+ }
32
+ const scopedLifetimes = new Map(currentLifetimes)
33
+ scopedLifetimes.set(key, lifetime)
34
+ activeTransactionLifetimes.enterWith(scopedLifetimes)
35
+ return lifetime
36
+ }
37
+
38
+ export function getTransactionLifetime(em: EntityManager): TransactionLifetime | null {
39
+ return activeTransactionLifetimes.getStore()?.get(em as object) ?? null
40
+ }
41
+
42
+ export function ownsTransactionLifetime(
43
+ em: EntityManager,
44
+ lifetime: TransactionLifetime | null | undefined,
45
+ ): lifetime is TransactionLifetime {
46
+ return lifetime != null && activeTransactionLifetimes.getStore()?.get(em as object) === lifetime
47
+ }
48
+
49
+ export function onTransactionLifetimeComplete(
50
+ em: EntityManager,
51
+ callback: (outcome: TransactionOutcome) => void | Promise<void>,
52
+ ): void {
53
+ const lifetime = activeTransactionLifetimes.getStore()?.get(em as object)
54
+ if (!lifetime) {
55
+ throw new Error('[internal] Transaction-owned state requires an active transaction lifetime')
56
+ }
57
+ lifetime.completionCallbacks.add(callback)
58
+ }
59
+
60
+ export async function completeTransactionLifetime(
61
+ em: EntityManager,
62
+ lifetimeToken: TransactionLifetime,
63
+ outcome: TransactionOutcome,
64
+ ): Promise<void> {
65
+ const key = em as object
66
+ const currentLifetimes = activeTransactionLifetimes.getStore()
67
+ const lifetime = currentLifetimes?.get(key)
68
+ if (!currentLifetimes || !lifetime || lifetime !== lifetimeToken) {
69
+ throw new Error('[internal] Transaction lifetime mismatch for EntityManager')
70
+ }
71
+ currentLifetimes.delete(key)
72
+ const failures: unknown[] = []
73
+ for (const callback of lifetime.completionCallbacks) {
74
+ try {
75
+ await callback(outcome)
76
+ } catch (err) {
77
+ failures.push(err)
78
+ }
79
+ }
80
+ for (const err of failures) {
81
+ logger.error('Transaction completion callback failed', { outcome, failures: failures.length, err })
82
+ getTelemetryRuntime()?.reportError(err, {
83
+ module: 'shared',
84
+ code: 'shared.transaction_completion_callback_failed',
85
+ attributes: { outcome },
86
+ })
87
+ }
88
+ }
@@ -4,6 +4,7 @@ import type { ZodTypeAny } from 'zod'
4
4
  import { randomUUID } from 'crypto'
5
5
  import type { AuthContext } from '../auth/server'
6
6
  import type { OrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'
7
+ import type { TransactionLifetime } from './transaction-lifetime'
7
8
 
8
9
  /**
9
10
  * Bulk-import / backfill deferral flags. When a command runs under a context that
@@ -26,6 +27,14 @@ export type BulkImportSuppression = {
26
27
  skipEvents?: boolean
27
28
  /** Advisory: handlers that fan out per-record notifications SHOULD honor this and skip them. */
28
29
  skipNotifications?: boolean
30
+ /**
31
+ * Skip a command's own full-collection derived-data rebuild (e.g. category tree
32
+ * `parentId`/`treePath`/`depth`/ancestor-descendant rewrite). The caller MUST run that rebuild
33
+ * itself exactly once after the batch, in a `finally` that covers the completed, cancelled and
34
+ * failed paths — otherwise every record written under this context is left with a stale
35
+ * derived shape.
36
+ */
37
+ skipDerivedRebuild?: boolean
29
38
  }
30
39
 
31
40
  export type CommandRuntimeContext = {
@@ -58,6 +67,27 @@ export type CommandRuntimeContext = {
58
67
  * surrounding work as a single atomic, single-locked operation.
59
68
  */
60
69
  transactionalEm?: EntityManager
70
+ /**
71
+ * Identifies the owner of an already-active {@link transactionalEm} lifetime.
72
+ * Atomic replay accepts an ambient EntityManager only when this is the exact
73
+ * token returned by `getTransactionLifetime(transactionalEm)` from the outer
74
+ * `withAtomicFlush` phase. The owner completes the token after its real commit
75
+ * or rollback, allowing replay leases and post-commit work to follow that
76
+ * boundary. Omit when the command bus opens and owns the transaction itself.
77
+ */
78
+ transactionLifetime?: TransactionLifetime
79
+ /**
80
+ * Optional request-level replay authorization that the command bus binds to
81
+ * its replay EntityManager. Atomic handlers run this guard in the same
82
+ * transaction as the source-log transition and domain mutation; legacy
83
+ * handlers run it in a dedicated read transaction before preserving their
84
+ * existing replay lifecycle.
85
+ */
86
+ replayTransactionGuard?: (args: {
87
+ operation: CommandReplayOperation
88
+ logEntry: CommandUndoLogEntry
89
+ transactionalEm: EntityManager
90
+ }) => Promise<void> | void
61
91
  /**
62
92
  * On-behalf-of attribution for non-human principals (Agent Identity &
63
93
  * On-Behalf-Of, Wave 4 P2). When an agent runs on behalf of a human, the
@@ -84,6 +114,22 @@ export type CommandRunAsContext = {
84
114
 
85
115
  export type CommandLogMetadata = {
86
116
  skipLog?: boolean
117
+ /**
118
+ * Per-execution replay policy. `false` records the audit entry without an undo
119
+ * token or command payload, so neither undo nor redo can replay sensitive input.
120
+ * Omitted preserves the command handler's existing replay behavior.
121
+ */
122
+ replayable?: boolean
123
+ /**
124
+ * Overrides the command input persisted in the audit entry's redo envelope.
125
+ * The default is the raw input, which for credential-bearing commands would put
126
+ * a plaintext secret at rest in `action_logs`. A handler that still wants its
127
+ * operation to be undoable supplies a redacted projection here instead of
128
+ * switching the whole entry to `replayable: false` — suppressing the undo token
129
+ * removes the operator's ability to revert a write that carries no secret of its
130
+ * own (undoing a user create only deletes a row).
131
+ */
132
+ redoInput?: unknown
87
133
  tenantId?: string | null
88
134
  organizationId?: string | null
89
135
  actorUserId?: string | null
@@ -106,6 +152,16 @@ export type CommandLogMetadata = {
106
152
  export type CommandExecuteResult<TResult> = {
107
153
  result: TResult
108
154
  logEntry: any | null
155
+ /**
156
+ * True when an atomic redo finalized its source action log in the same
157
+ * transaction as the domain mutation and the newly persisted log entry, and
158
+ * that transaction has committed. An ambient replay returns this as false
159
+ * while its caller-owned transaction is still active; the returned result
160
+ * object is updated to true by the owner's commit callback. It remains false
161
+ * on rollback. Callers that historically finalized redo themselves can use
162
+ * this additive signal to avoid a redundant post-commit write.
163
+ */
164
+ replaySourceFinalized?: boolean
109
165
  }
110
166
 
111
167
  /**
@@ -150,9 +206,35 @@ export type CommandLogBuilderArgs<TInput, TResult> = {
150
206
  }
151
207
  }
152
208
 
209
+ export type CommandReplayOperation = 'undo' | 'redo'
210
+
211
+ export type CommandReplayAuthorizationArgs<TInput> = {
212
+ operation: CommandReplayOperation
213
+ input: TInput
214
+ ctx: CommandRuntimeContext
215
+ logEntry: CommandUndoLogEntry
216
+ }
217
+
153
218
  export interface CommandHandler<TInput = unknown, TResult = unknown> {
154
219
  readonly id: string
155
220
  readonly isUndoable?: boolean
221
+ /**
222
+ * Opts replay into the command bus's transaction-bound lifecycle. The source
223
+ * action-log transition, handler mutation, and any new replay log share one
224
+ * EntityManager and commit or roll back together. Handlers that enable this
225
+ * MUST reuse `ctx.transactionalEm` for every replay-time database operation.
226
+ * Omitted preserves the legacy non-atomic replay path.
227
+ */
228
+ readonly atomicReplay?: boolean
229
+ /**
230
+ * Acquires every authorization-state lock needed by an atomic replay before
231
+ * request-level replay authorization and feature-gated interceptors run.
232
+ * Implementations MUST discover their complete actor/target/destination set
233
+ * before taking the first lock and MUST reuse `ctx.transactionalEm`.
234
+ * Omitted preserves the existing lifecycle for handlers that do not need
235
+ * domain-specific authorization stabilization.
236
+ */
237
+ stabilizeReplay?(params: CommandReplayAuthorizationArgs<TInput>): Promise<void> | void
156
238
  /**
157
239
  * Optional Zod schema describing the command's return value. Feeds the
158
240
  * workflows context ledger so downstream activities can reason about the
@@ -164,6 +246,15 @@ export interface CommandHandler<TInput = unknown, TResult = unknown> {
164
246
  execute(input: TInput, ctx: CommandRuntimeContext): Promise<TResult> | TResult
165
247
  buildLog?(args: CommandLogBuilderArgs<TInput, TResult>): Promise<CommandLogMetadata | null | undefined> | CommandLogMetadata | null | undefined
166
248
  captureAfter?(input: TInput, result: TResult, ctx: CommandRuntimeContext): Promise<unknown> | unknown
249
+ /**
250
+ * Re-authorizes a stored command against the actor and resource state that
251
+ * exist at replay time. Legacy handlers are checked before claiming an undo
252
+ * log or beginning redo processing. Atomic handlers are checked inside their
253
+ * replay transaction before the guarded source transition; their mutation
254
+ * path can repeat the check after taking domain locks. Throwing aborts replay
255
+ * without committed domain side effects.
256
+ */
257
+ authorizeReplay?(params: CommandReplayAuthorizationArgs<TInput>): Promise<void> | void
167
258
  undo?(params: { input: TInput; ctx: CommandRuntimeContext; logEntry: CommandUndoLogEntry }): Promise<void> | void
168
259
  /**
169
260
  * Optional redo handler. When defined, the command bus calls this instead of
@@ -30,7 +30,85 @@ function snapshotFallback<T>(logEntry: UndoLogEntry): T | null {
30
30
  return { before: before ?? null, after: after ?? null } as T
31
31
  }
32
32
 
33
- export function extractUndoPayload<T>(logEntry: UndoLogEntry | null | undefined): T | null {
33
+ export type UndoDateRevivalOptions = {
34
+ /** Keys revived from ISO strings to `Date` wherever they appear in the payload (any depth, arrays included). */
35
+ dateFields?: readonly string[]
36
+ /** Dot-separated paths from the payload root revived from ISO strings to `Date`; arrays on the way are walked element-wise. */
37
+ datePaths?: readonly string[]
38
+ }
39
+
40
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
41
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)
42
+ }
43
+
44
+ function reviveDateValue(value: unknown, field: string): unknown {
45
+ if (typeof value !== 'string') return value
46
+ const date = new Date(value)
47
+ if (Number.isNaN(date.getTime())) {
48
+ throw new Error(`[internal] Invalid ${field} snapshot date: ${value}`)
49
+ }
50
+ return date
51
+ }
52
+
53
+ /**
54
+ * Shallow-clone a snapshot record and revive the named date fields from ISO strings
55
+ * back to `Date`. `null`, `undefined` and existing `Date` values are kept as-is; an
56
+ * unparsable string throws so a corrupt snapshot fails loudly instead of writing a bad row.
57
+ */
58
+ export function reviveSnapshotDates<T extends Record<string, unknown>>(value: T, fields: readonly string[]): T {
59
+ const revived: Record<string, unknown> = { ...value }
60
+ for (const field of fields) {
61
+ if (field in revived) revived[field] = reviveDateValue(revived[field], field)
62
+ }
63
+ return revived as T
64
+ }
65
+
66
+ function reviveDateFieldsDeep(value: unknown, fields: ReadonlySet<string>): unknown {
67
+ if (Array.isArray(value)) return value.map((item) => reviveDateFieldsDeep(item, fields))
68
+ if (!isPlainRecord(value)) return value
69
+ return Object.fromEntries(
70
+ Object.entries(value).map(([key, entry]) => [
71
+ key,
72
+ fields.has(key) ? reviveDateValue(entry, key) : reviveDateFieldsDeep(entry, fields),
73
+ ]),
74
+ )
75
+ }
76
+
77
+ function reviveDatePath(value: unknown, segments: readonly string[]): unknown {
78
+ if (Array.isArray(value)) return value.map((item) => reviveDatePath(item, segments))
79
+ if (!isPlainRecord(value) || segments.length === 0) return value
80
+ const [head, ...rest] = segments
81
+ if (!(head in value)) return value
82
+ const entry = value[head]
83
+ return { ...value, [head]: rest.length === 0 ? reviveDateValue(entry, head) : reviveDatePath(entry, rest) }
84
+ }
85
+
86
+ function reviveUndoPayloadDates<T>(payload: T | null, options: UndoDateRevivalOptions | undefined): T | null {
87
+ if (payload === null || payload === undefined || !options) return payload
88
+ let revived: unknown = payload
89
+ if (options.dateFields && options.dateFields.length > 0) {
90
+ revived = reviveDateFieldsDeep(revived, new Set(options.dateFields))
91
+ }
92
+ for (const path of options.datePaths ?? []) {
93
+ const segments = path.split('.').filter((segment) => segment.length > 0)
94
+ if (segments.length > 0) revived = reviveDatePath(revived, segments)
95
+ }
96
+ return revived as T
97
+ }
98
+
99
+ /**
100
+ * Read the undo payload from a command log entry. Snapshots round-trip through `jsonb`,
101
+ * so `Date` values come back as ISO strings; pass `dateFields` / `datePaths` to revive
102
+ * them to `Date` (unparsable strings throw). Without options the raw payload is returned.
103
+ */
104
+ export function extractUndoPayload<T>(
105
+ logEntry: UndoLogEntry | null | undefined,
106
+ options?: UndoDateRevivalOptions,
107
+ ): T | null {
108
+ return reviveUndoPayloadDates(extractRawUndoPayload<T>(logEntry), options)
109
+ }
110
+
111
+ function extractRawUndoPayload<T>(logEntry: UndoLogEntry | null | undefined): T | null {
34
112
  if (!logEntry) return null
35
113
  const rawPayload = logEntry.commandPayload ?? logEntry.payload
36
114
  if (!rawPayload || typeof rawPayload !== 'object' || Array.isArray(rawPayload)) {
@@ -0,0 +1,149 @@
1
+ // Regression coverage for tenant-level (orgField: null) makeCrudRoute lists:
2
+ // a create invalidates the tenant-level `org:null` collection tag (the record
3
+ // carries no organization), so the cached list MUST be tagged with that same
4
+ // tag — tagging it with the caller's organization scope left the entry
5
+ // unflushable and served a stale list until TTL.
6
+
7
+ jest.mock('@open-mercato/cache', () => ({
8
+ runWithCacheTenant: async (_tenantId: string | null, fn: () => Promise<unknown>) => fn(),
9
+ }), { virtual: true })
10
+
11
+ import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
12
+ import { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'
13
+ import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
14
+ import { z } from 'zod'
15
+
16
+ const organizationId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
17
+ const tenantId = '123e4567-e89b-12d3-a456-426614174000'
18
+
19
+ let rows: Array<{ id: string; name: string }> = []
20
+
21
+ const queryEngine = {
22
+ query: jest.fn(async () => ({ items: rows.map((row) => ({ ...row, tenant_id: tenantId })), total: rows.length })),
23
+ }
24
+
25
+ const store = new Map<string, { value: unknown; tags: string[] }>()
26
+ const cache = {
27
+ get: jest.fn(async (key: string) => store.get(key)?.value ?? null),
28
+ set: jest.fn(async (key: string, value: unknown, options?: { tags?: string[] }) => {
29
+ store.set(key, { value, tags: options?.tags ?? [] })
30
+ }),
31
+ delete: jest.fn(async (key: string) => { store.delete(key) }),
32
+ deleteByTags: jest.fn(async (tags: string[]) => {
33
+ let removed = 0
34
+ for (const [key, entry] of Array.from(store.entries())) {
35
+ if (entry.tags.some((tag) => tags.includes(tag))) {
36
+ store.delete(key)
37
+ removed += 1
38
+ }
39
+ }
40
+ return removed
41
+ }),
42
+ }
43
+
44
+ const container = {
45
+ resolve: (name: string) => ({
46
+ em: {},
47
+ queryEngine,
48
+ cache,
49
+ accessLogService: { log: jest.fn(async () => {}) },
50
+ } as Record<string, unknown>)[name],
51
+ }
52
+
53
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
54
+ createRequestContainer: async () => container,
55
+ }))
56
+
57
+ jest.mock('@open-mercato/shared/lib/auth/server', () => {
58
+ const buildAuth = () => ({
59
+ sub: 'user-a',
60
+ orgId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
61
+ tenantId: '123e4567-e89b-12d3-a456-426614174000',
62
+ roles: ['admin'],
63
+ })
64
+ return {
65
+ getAuthFromCookies: async () => buildAuth(),
66
+ getAuthFromRequest: async () => buildAuth(),
67
+ }
68
+ })
69
+
70
+ jest.mock('@open-mercato/core/modules/directory/utils/organizationScope', () => ({
71
+ resolveOrganizationScopeForRequest: jest.fn(async () => ({
72
+ selectedId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
73
+ filterIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
74
+ allowedIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
75
+ tenantId: '123e4567-e89b-12d3-a456-426614174000',
76
+ })),
77
+ }))
78
+
79
+ jest.mock('@open-mercato/core/modules/entities/lib/helpers', () => ({
80
+ setRecordCustomFields: jest.fn(async () => {}),
81
+ }))
82
+
83
+ class TenantGroup {}
84
+
85
+ const querySchema = z.object({
86
+ page: z.coerce.number().default(1),
87
+ pageSize: z.coerce.number().default(50),
88
+ })
89
+
90
+ const route = makeCrudRoute({
91
+ metadata: { GET: { requireAuth: true } },
92
+ orm: { entity: TenantGroup, idField: 'id', orgField: null, tenantField: 'tenantId', softDeleteField: 'deletedAt' },
93
+ events: { module: 'example', entity: 'tenant_group' },
94
+ indexer: { entityType: 'example:tenant_group' },
95
+ list: {
96
+ schema: querySchema,
97
+ entityId: 'example:tenant_group',
98
+ fields: ['id', 'name'],
99
+ transformItem: (item: { id: string; name: string }) => ({ id: item.id, name: item.name }),
100
+ },
101
+ })
102
+
103
+ const url = 'http://x/api/example/tenant-groups?page=1&pageSize=10'
104
+
105
+ describe('CRUD Factory — tenant-level (orgField: null) list cache invalidation', () => {
106
+ const previousCacheFlag = process.env.ENABLE_CRUD_API_CACHE
107
+
108
+ beforeAll(() => {
109
+ process.env.ENABLE_CRUD_API_CACHE = 'true'
110
+ })
111
+
112
+ afterAll(() => {
113
+ if (previousCacheFlag === undefined) delete process.env.ENABLE_CRUD_API_CACHE
114
+ else process.env.ENABLE_CRUD_API_CACHE = previousCacheFlag
115
+ })
116
+
117
+ beforeEach(() => {
118
+ jest.clearAllMocks()
119
+ store.clear()
120
+ rows = []
121
+ registerApiInterceptors([])
122
+ })
123
+
124
+ it('tags the cached list with the tenant-level collection tag, not the caller org', async () => {
125
+ await route.GET(new Request(url))
126
+ const [entry] = Array.from(store.values())
127
+ expect(entry.tags).toContain(`crud:example.tenant.group:tenant:${tenantId}:org:null:collection`)
128
+ expect(entry.tags).not.toContain(`crud:example.tenant.group:tenant:${tenantId}:org:${organizationId}:collection`)
129
+ })
130
+
131
+ it('serves the new row after a create invalidates the tenant-level collection', async () => {
132
+ const before = await route.GET(new Request(url))
133
+ expect(before.headers.get('x-om-cache')).toBe('miss')
134
+ expect((await before.json()).items).toEqual([])
135
+
136
+ rows = [{ id: 'group-1', name: 'Wholesale' }]
137
+ await invalidateCrudCache(
138
+ container as never,
139
+ 'example.tenant.group',
140
+ { id: 'group-1', organizationId: null, tenantId },
141
+ tenantId,
142
+ 'created',
143
+ )
144
+
145
+ const after = await route.GET(new Request(url))
146
+ expect(after.headers.get('x-om-cache')).toBe('miss')
147
+ expect((await after.json()).items).toEqual([{ id: 'group-1', name: 'Wholesale' }])
148
+ })
149
+ })
@@ -19,6 +19,12 @@ const defaultTenantId = '123e4567-e89b-12d3-a456-426614174000'
19
19
 
20
20
  let mockAuthSub = 'user-a'
21
21
  let mockAuthKeyId: string | null = null
22
+ let mockOrganizationScope = {
23
+ selectedId: defaultOrganizationId,
24
+ filterIds: [defaultOrganizationId] as string[] | null,
25
+ allowedIds: [defaultOrganizationId] as string[] | null,
26
+ tenantId: defaultTenantId,
27
+ }
22
28
 
23
29
  const em = {}
24
30
 
@@ -70,12 +76,7 @@ jest.mock('@open-mercato/shared/lib/auth/server', () => {
70
76
  })
71
77
 
72
78
  jest.mock('@open-mercato/core/modules/directory/utils/organizationScope', () => ({
73
- resolveOrganizationScopeForRequest: jest.fn(async () => ({
74
- selectedId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
75
- filterIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
76
- allowedIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
77
- tenantId: '123e4567-e89b-12d3-a456-426614174000',
78
- })),
79
+ resolveOrganizationScopeForRequest: jest.fn(async () => mockOrganizationScope),
79
80
  }))
80
81
 
81
82
  jest.mock('@open-mercato/core/modules/entities/lib/helpers', () => ({
@@ -105,6 +106,20 @@ const route = makeCrudRoute({
105
106
  },
106
107
  })
107
108
 
109
+ const scopeIdentityRoute = makeCrudRoute({
110
+ metadata: { GET: { requireAuth: true } },
111
+ orm: { entity: Todo, idField: 'id', orgField: null, tenantField: null, softDeleteField: null },
112
+ indexer: { entityType: 'example.cache_identity' },
113
+ list: {
114
+ schema: querySchema,
115
+ entityId: 'example.cache_identity',
116
+ fields: ['id', 'title'],
117
+ sortFieldMap: { id: 'id', title: 'title' },
118
+ buildFilters: () => ({} as any),
119
+ transformItem: (item: any) => ({ id: item.id, title: item.title }),
120
+ },
121
+ })
122
+
108
123
  const url = 'http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'
109
124
 
110
125
  describe('CRUD Factory — list cache is partitioned per caller identity', () => {
@@ -124,6 +139,12 @@ describe('CRUD Factory — list cache is partitioned per caller identity', () =>
124
139
  store.clear()
125
140
  mockAuthSub = 'user-a'
126
141
  mockAuthKeyId = null
142
+ mockOrganizationScope = {
143
+ selectedId: defaultOrganizationId,
144
+ filterIds: [defaultOrganizationId],
145
+ allowedIds: [defaultOrganizationId],
146
+ tenantId: defaultTenantId,
147
+ }
127
148
  registerApiInterceptors([])
128
149
  })
129
150
 
@@ -168,4 +189,25 @@ describe('CRUD Factory — list cache is partitioned per caller identity', () =>
168
189
  const apiKeyKey = Array.from(store.keys())[0] as string
169
190
  expect(apiKeyKey).toContain('user:api-key-9')
170
191
  })
192
+
193
+ it('encodes explicit empty and finite scopes as different cache keys', async () => {
194
+ await scopeIdentityRoute.GET(new Request(url))
195
+ const finiteKey = Array.from(store.keys())[0] as string
196
+
197
+ mockOrganizationScope = {
198
+ selectedId: defaultOrganizationId,
199
+ filterIds: [],
200
+ allowedIds: [defaultOrganizationId],
201
+ tenantId: defaultTenantId,
202
+ }
203
+ const emptyResponse = await scopeIdentityRoute.GET(new Request(url))
204
+ const keys = Array.from(store.keys())
205
+
206
+ expect(emptyResponse.headers.get('x-om-cache')).toBe('miss')
207
+ expect(keys).toHaveLength(2)
208
+ expect(finiteKey).toContain(`scope:${defaultOrganizationId}`)
209
+ expect(keys[1]).toContain('scope:empty')
210
+ expect(keys[1]).not.toBe(finiteKey)
211
+ expect(queryEngine.query).toHaveBeenCalledTimes(2)
212
+ })
171
213
  })