@open-mercato/shared 0.8.1-develop.7297.1.67605e74f9 → 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
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/catalog-visibility/unionScopes.ts"],
4
+ "sourcesContent": ["import type { AssortmentScope, EffectiveAssortmentScope } from './types'\n\n/**\n * Combines N independent sources' own scopes into one OR-list.\n * THE CRITICAL CORRECTNESS REQUIREMENT (a first draft got this wrong \u2014 R2 defect):\n * do NOT merge every source's categoryIds/tagIds into one flat AssortmentScope object.\n * That computes AND across sources' dimensions instead of OR. Each source becomes its\n * own OR-branch in the returned list, UNMODIFIED \u2014 nothing about one source's scope\n * changes because another source exists. The returned list is a fresh array, never the\n * caller's own.\n */\nexport function unionScopes(scopes: Array<AssortmentScope | null>): EffectiveAssortmentScope {\n if (scopes.length === 0) return null\n if (scopes.some((s) => s === null)) return null\n return scopes.filter((scope): scope is AssortmentScope => scope !== null)\n}\n"],
5
+ "mappings": "AAWO,SAAS,YAAY,QAAiE;AAC3F,MAAI,OAAO,WAAW,EAAG,QAAO;AAChC,MAAI,OAAO,KAAK,CAAC,MAAM,MAAM,IAAI,EAAG,QAAO;AAC3C,SAAO,OAAO,OAAO,CAAC,UAAoC,UAAU,IAAI;AAC1E;",
6
+ "names": []
7
+ }
@@ -18,7 +18,32 @@ import {
18
18
  import { CommandInterceptorError } from "./errors.js";
19
19
  import { isReadProjectionAlwaysConsistent } from "@open-mercato/shared/lib/data/consistency";
20
20
  import { createLogger } from "../logger/index.js";
21
+ import { withAtomicFlush } from "./flush.js";
22
+ import {
23
+ getTransactionLifetime,
24
+ onTransactionLifetimeComplete,
25
+ ownsTransactionLifetime
26
+ } from "./transaction-lifetime.js";
21
27
  const logger = createLogger("shared").child({ component: "commands" });
28
+ function isInTransaction(em) {
29
+ const probe = em.isInTransaction;
30
+ return typeof probe === "function" && probe.call(em);
31
+ }
32
+ function resolveAtomicReplayTransaction(ctx) {
33
+ const em = ctx.transactionalEm ?? ctx.container.resolve("em");
34
+ const joinsAmbient = isInTransaction(em);
35
+ if (joinsAmbient && !ownsTransactionLifetime(em, ctx.transactionLifetime)) {
36
+ throw new Error("[internal] Ambient atomic replay requires its outer transaction lifetime owner");
37
+ }
38
+ return { em, joinsAmbient };
39
+ }
40
+ function bindReplayTransactionContext(ctx, em, expectedLifetime) {
41
+ const transactionLifetime = getTransactionLifetime(em);
42
+ if (!transactionLifetime || expectedLifetime && transactionLifetime !== expectedLifetime) {
43
+ throw new Error("[internal] Atomic replay transaction lifetime owner changed before replay");
44
+ }
45
+ return { ...ctx, transactionalEm: em, transactionLifetime };
46
+ }
22
47
  const SKIPPED_ACTION_LOG_RESOURCE_KINDS = /* @__PURE__ */ new Set([
23
48
  "audit_logs.access",
24
49
  "audit_logs.action",
@@ -176,117 +201,227 @@ function extractAliasList(source) {
176
201
  class CommandBus {
177
202
  async execute(commandId, options) {
178
203
  const handler = await this.resolveHandler(commandId);
204
+ const replayLogEntry = options.redoLogEntry ?? null;
205
+ const isAtomicReplay = replayLogEntry !== null && handler.atomicReplay === true;
179
206
  const allInterceptors = getAllCommandInterceptorInstances();
180
207
  let interceptorMetadata = /* @__PURE__ */ new Map();
181
208
  let effectiveOptions = options;
182
- const userFeatures = allInterceptors.length ? await this.resolveUserFeaturesForInterceptors(options.ctx) : [];
183
- if (allInterceptors.length) {
209
+ let userFeatures = [];
210
+ const runBeforeExecuteInterceptors = async (baseOptions) => {
211
+ const resolvedUserFeatures = allInterceptors.length ? await this.resolveUserFeaturesForInterceptors(baseOptions.ctx) : [];
212
+ if (!allInterceptors.length) {
213
+ return {
214
+ effectiveOptions: baseOptions,
215
+ metadata: /* @__PURE__ */ new Map(),
216
+ userFeatures: resolvedUserFeatures
217
+ };
218
+ }
184
219
  const interceptorCtx = {
185
220
  commandId,
186
- auth: options.ctx.auth ?? null,
187
- selectedOrganizationId: options.ctx.selectedOrganizationId ?? options.ctx.auth?.orgId ?? null,
188
- container: options.ctx.container
221
+ auth: baseOptions.ctx.auth ?? null,
222
+ selectedOrganizationId: baseOptions.ctx.selectedOrganizationId ?? baseOptions.ctx.auth?.orgId ?? null,
223
+ container: baseOptions.ctx.container
189
224
  };
190
225
  const beforeResult = await runCommandInterceptorsBefore(
191
226
  allInterceptors,
192
227
  commandId,
193
- options.input,
228
+ baseOptions.input,
194
229
  interceptorCtx,
195
- userFeatures
230
+ resolvedUserFeatures
196
231
  );
197
232
  if (!beforeResult.ok) {
198
233
  const blocked = beforeResult.error;
199
234
  throw new CommandInterceptorError(blocked.message, { status: blocked.status, body: blocked.body });
200
235
  }
201
- interceptorMetadata = beforeResult.metadataByInterceptor;
202
- if (beforeResult.modifiedInput) {
203
- effectiveOptions = {
204
- ...options,
205
- input: { ...options.input, ...beforeResult.modifiedInput }
206
- };
207
- }
208
- }
209
- const snapshots = await this.prepareSnapshots(handler, effectiveOptions);
210
- const redoLogEntry = effectiveOptions.redoLogEntry ?? null;
211
- const result = redoLogEntry && typeof handler.redo === "function" ? await handler.redo({ input: effectiveOptions.input, ctx: effectiveOptions.ctx, logEntry: redoLogEntry }) : await handler.execute(effectiveOptions.input, effectiveOptions.ctx);
212
- const afterSnapshot = await this.captureAfter(handler, effectiveOptions, result);
213
- const snapshotsWithAfter = { ...snapshots, after: afterSnapshot };
214
- const logMeta = await this.buildLog(handler, effectiveOptions, result, snapshotsWithAfter);
215
- let mergedMeta = this.mergeMetadata(effectiveOptions.metadata, logMeta);
216
- let interceptorContextMerged = {};
217
- for (const meta of interceptorMetadata.values()) {
218
- const logContextRecord = asRecord(asRecord(meta)?.logContext);
219
- if (!logContextRecord) continue;
220
- interceptorContextMerged = {
221
- ...interceptorContextMerged,
222
- ...logContextRecord
223
- };
224
- }
225
- const baseContext = asRecord(effectiveOptions.metadata?.context) ?? {};
226
- const logMetaContext = asRecord(logMeta?.context) ?? {};
227
- if (Object.keys(interceptorContextMerged).length > 0 || Object.keys(baseContext).length > 0 || Object.keys(logMetaContext).length > 0) {
228
- mergedMeta = mergedMeta ?? {};
229
- mergedMeta.context = {
230
- ...baseContext,
231
- ...interceptorContextMerged,
232
- ...logMetaContext
236
+ const preparedOptions = beforeResult.modifiedInput ? {
237
+ ...baseOptions,
238
+ input: { ...baseOptions.input, ...beforeResult.modifiedInput }
239
+ } : baseOptions;
240
+ return {
241
+ effectiveOptions: preparedOptions,
242
+ metadata: beforeResult.metadataByInterceptor,
243
+ userFeatures: resolvedUserFeatures
233
244
  };
245
+ };
246
+ if (replayLogEntry && !isAtomicReplay) {
247
+ await this.runReplayTransactionGuard(options.ctx, "redo", replayLogEntry);
248
+ if (handler.authorizeReplay) {
249
+ await handler.authorizeReplay({
250
+ operation: "redo",
251
+ input: options.input,
252
+ ctx: options.ctx,
253
+ logEntry: replayLogEntry
254
+ });
255
+ }
234
256
  }
235
- const undoable = this.isUndoable(handler);
236
- if (undoable) {
237
- mergedMeta = mergedMeta ?? {};
238
- if (!mergedMeta.undoToken) mergedMeta.undoToken = defaultUndoToken();
239
- if (mergedMeta.actorUserId === void 0) mergedMeta.actorUserId = effectiveOptions.ctx.auth?.sub ?? null;
257
+ if (!isAtomicReplay) {
258
+ const prepared = await runBeforeExecuteInterceptors(options);
259
+ effectiveOptions = prepared.effectiveOptions;
260
+ interceptorMetadata = prepared.metadata;
261
+ userFeatures = prepared.userFeatures;
240
262
  }
241
- if (afterSnapshot !== void 0 && afterSnapshot !== null) {
242
- if (!mergedMeta) {
243
- mergedMeta = { snapshotAfter: afterSnapshot };
244
- } else if (!mergedMeta.snapshotAfter) {
245
- mergedMeta.snapshotAfter = afterSnapshot;
263
+ const executeCore = async (coreOptions) => {
264
+ const sourceLog = coreOptions.redoLogEntry ?? null;
265
+ const snapshots = await this.prepareSnapshots(handler, coreOptions);
266
+ const result2 = sourceLog && typeof handler.redo === "function" ? await handler.redo({ input: coreOptions.input, ctx: coreOptions.ctx, logEntry: sourceLog }) : await handler.execute(coreOptions.input, coreOptions.ctx);
267
+ const afterSnapshot = await this.captureAfter(handler, coreOptions, result2);
268
+ const snapshotsWithAfter = { ...snapshots, after: afterSnapshot };
269
+ const logMeta = await this.buildLog(handler, coreOptions, result2, snapshotsWithAfter);
270
+ let mergedMeta2 = this.mergeMetadata(coreOptions.metadata, logMeta);
271
+ let interceptorContextMerged = {};
272
+ for (const meta of interceptorMetadata.values()) {
273
+ const logContextRecord = asRecord(asRecord(meta)?.logContext);
274
+ if (!logContextRecord) continue;
275
+ interceptorContextMerged = {
276
+ ...interceptorContextMerged,
277
+ ...logContextRecord
278
+ };
246
279
  }
247
- }
248
- if (snapshots.before) {
249
- if (!mergedMeta) {
250
- mergedMeta = { snapshotBefore: snapshots.before };
251
- } else if (!mergedMeta.snapshotBefore) {
252
- mergedMeta.snapshotBefore = snapshots.before;
280
+ const baseContext = asRecord(coreOptions.metadata?.context) ?? {};
281
+ const logMetaContext = asRecord(logMeta?.context) ?? {};
282
+ if (Object.keys(interceptorContextMerged).length > 0 || Object.keys(baseContext).length > 0 || Object.keys(logMetaContext).length > 0) {
283
+ mergedMeta2 = mergedMeta2 ?? {};
284
+ mergedMeta2.context = {
285
+ ...baseContext,
286
+ ...interceptorContextMerged,
287
+ ...logMetaContext
288
+ };
253
289
  }
254
- }
255
- if (mergedMeta?.snapshotBefore !== void 0 && mergedMeta?.snapshotAfter !== void 0) {
256
- const currentChanges = mergedMeta.changes;
257
- const shouldInfer = currentChanges === void 0 || currentChanges === null || typeof currentChanges === "object" && !Array.isArray(currentChanges) && Object.keys(currentChanges).length === 0;
258
- if (shouldInfer) {
259
- const inferred = deriveChangesFromSnapshots(mergedMeta.snapshotBefore, mergedMeta.snapshotAfter);
260
- if (inferred) mergedMeta.changes = inferred;
290
+ const undoable = this.isUndoable(handler);
291
+ if (undoable && mergedMeta2?.replayable !== false) {
292
+ mergedMeta2 = mergedMeta2 ?? {};
293
+ if (!mergedMeta2.undoToken) mergedMeta2.undoToken = defaultUndoToken();
294
+ if (mergedMeta2.actorUserId === void 0) mergedMeta2.actorUserId = coreOptions.ctx.auth?.sub ?? null;
295
+ } else if (mergedMeta2?.replayable === false) {
296
+ mergedMeta2.undoToken = null;
261
297
  }
262
- }
263
- const logEntry = await this.persistLog(commandId, effectiveOptions, mergedMeta);
264
- let finalResult = result;
265
- if (allInterceptors.length) {
266
- const interceptorCtx = {
267
- commandId,
268
- auth: effectiveOptions.ctx.auth ?? null,
269
- selectedOrganizationId: effectiveOptions.ctx.selectedOrganizationId ?? effectiveOptions.ctx.auth?.orgId ?? null,
270
- container: effectiveOptions.ctx.container
271
- };
272
- const afterResult = await runCommandInterceptorsAfter(
273
- allInterceptors,
274
- commandId,
275
- effectiveOptions.input,
276
- result,
277
- interceptorCtx,
278
- userFeatures,
279
- interceptorMetadata
280
- );
281
- if (afterResult.modifiedResult && typeof result === "object" && result) {
282
- finalResult = { ...result, ...afterResult.modifiedResult };
298
+ if (afterSnapshot !== void 0 && afterSnapshot !== null) {
299
+ if (!mergedMeta2) {
300
+ mergedMeta2 = { snapshotAfter: afterSnapshot };
301
+ } else if (!mergedMeta2.snapshotAfter) {
302
+ mergedMeta2.snapshotAfter = afterSnapshot;
303
+ }
304
+ }
305
+ if (snapshots.before) {
306
+ if (!mergedMeta2) {
307
+ mergedMeta2 = { snapshotBefore: snapshots.before };
308
+ } else if (!mergedMeta2.snapshotBefore) {
309
+ mergedMeta2.snapshotBefore = snapshots.before;
310
+ }
283
311
  }
312
+ if (mergedMeta2?.snapshotBefore !== void 0 && mergedMeta2?.snapshotAfter !== void 0) {
313
+ const currentChanges = mergedMeta2.changes;
314
+ const shouldInfer = currentChanges === void 0 || currentChanges === null || typeof currentChanges === "object" && !Array.isArray(currentChanges) && Object.keys(currentChanges).length === 0;
315
+ if (shouldInfer) {
316
+ const inferred = deriveChangesFromSnapshots(mergedMeta2.snapshotBefore, mergedMeta2.snapshotAfter);
317
+ if (inferred) mergedMeta2.changes = inferred;
318
+ }
319
+ }
320
+ const logEntry2 = await this.persistLog(commandId, coreOptions, mergedMeta2);
321
+ return { result: result2, mergedMeta: mergedMeta2, logEntry: logEntry2 };
322
+ };
323
+ let replaySourceFinalized = false;
324
+ let coreResult;
325
+ const atomicSourceLog = options.redoLogEntry ?? null;
326
+ let ambientReplay = null;
327
+ let replayTransactionLifetime = null;
328
+ if (atomicSourceLog && isAtomicReplay) {
329
+ ambientReplay = resolveAtomicReplayTransaction(options.ctx);
330
+ const replayEm = ambientReplay.em;
331
+ await withAtomicFlush(replayEm, [async () => {
332
+ const replayCtx = bindReplayTransactionContext(
333
+ options.ctx,
334
+ replayEm,
335
+ ambientReplay?.joinsAmbient ? options.ctx.transactionLifetime : void 0
336
+ );
337
+ replayTransactionLifetime = replayCtx.transactionLifetime ?? null;
338
+ const transactionalBaseOptions = {
339
+ ...options,
340
+ ctx: replayCtx
341
+ };
342
+ if (!atomicSourceLog.id) throw new Error("[internal] Atomic redo source log id is required");
343
+ if (handler.stabilizeReplay) {
344
+ await handler.stabilizeReplay({
345
+ operation: "redo",
346
+ input: transactionalBaseOptions.input,
347
+ ctx: transactionalBaseOptions.ctx,
348
+ logEntry: atomicSourceLog
349
+ });
350
+ }
351
+ await this.runReplayTransactionGuard(
352
+ transactionalBaseOptions.ctx,
353
+ "redo",
354
+ atomicSourceLog,
355
+ replayEm
356
+ );
357
+ const prepared = await runBeforeExecuteInterceptors(transactionalBaseOptions);
358
+ effectiveOptions = prepared.effectiveOptions;
359
+ interceptorMetadata = prepared.metadata;
360
+ userFeatures = prepared.userFeatures;
361
+ const service = effectiveOptions.ctx.container.resolve("actionLogService");
362
+ if (handler.authorizeReplay) {
363
+ await handler.authorizeReplay({
364
+ operation: "redo",
365
+ input: effectiveOptions.input,
366
+ ctx: effectiveOptions.ctx,
367
+ logEntry: atomicSourceLog
368
+ });
369
+ }
370
+ const claimed = await service.claimForRedo(atomicSourceLog.id, replayEm);
371
+ if (!claimed) throw new Error("[internal] Redo source already consumed");
372
+ coreResult = await executeCore(effectiveOptions);
373
+ }], { transaction: true, label: `${commandId}.redo` });
374
+ replaySourceFinalized = !ambientReplay.joinsAmbient;
375
+ } else {
376
+ coreResult = await executeCore(effectiveOptions);
284
377
  }
285
- if (!effectiveOptions.skipCacheInvalidation) {
286
- await this.invalidateCacheAfterExecute(commandId, effectiveOptions, finalResult, mergedMeta);
378
+ const { result, mergedMeta, logEntry } = coreResult;
379
+ const runPostCommit = async () => {
380
+ let finalResult = result;
381
+ if (allInterceptors.length) {
382
+ const interceptorCtx = {
383
+ commandId,
384
+ auth: effectiveOptions.ctx.auth ?? null,
385
+ selectedOrganizationId: effectiveOptions.ctx.selectedOrganizationId ?? effectiveOptions.ctx.auth?.orgId ?? null,
386
+ container: effectiveOptions.ctx.container
387
+ };
388
+ const afterResult = await runCommandInterceptorsAfter(
389
+ allInterceptors,
390
+ commandId,
391
+ effectiveOptions.input,
392
+ result,
393
+ interceptorCtx,
394
+ userFeatures,
395
+ interceptorMetadata
396
+ );
397
+ if (afterResult.modifiedResult && typeof result === "object" && result) {
398
+ finalResult = { ...result, ...afterResult.modifiedResult };
399
+ }
400
+ }
401
+ if (!effectiveOptions.skipCacheInvalidation) {
402
+ await this.invalidateCacheAfterExecute(commandId, effectiveOptions, finalResult, mergedMeta);
403
+ }
404
+ await this.flushCrudSideEffects(effectiveOptions.ctx.container, effectiveOptions.ctx?.bulkImport);
405
+ return finalResult;
406
+ };
407
+ const executionResult = {
408
+ result,
409
+ logEntry,
410
+ replaySourceFinalized
411
+ };
412
+ if (ambientReplay?.joinsAmbient) {
413
+ if (!replayTransactionLifetime || !ownsTransactionLifetime(ambientReplay.em, replayTransactionLifetime)) {
414
+ throw new Error("[internal] Ambient atomic replay lost its outer transaction lifetime owner");
415
+ }
416
+ onTransactionLifetimeComplete(ambientReplay.em, async (outcome) => {
417
+ if (outcome !== "committed") return;
418
+ executionResult.result = await runPostCommit();
419
+ executionResult.replaySourceFinalized = true;
420
+ });
421
+ return executionResult;
287
422
  }
288
- await this.flushCrudSideEffects(effectiveOptions.ctx.container, effectiveOptions.ctx?.bulkImport);
289
- return { result: finalResult, logEntry };
423
+ executionResult.result = await runPostCommit();
424
+ return executionResult;
290
425
  }
291
426
  async undo(undoToken, ctx) {
292
427
  const service = ctx.container.resolve("actionLogService");
@@ -296,39 +431,121 @@ class CommandBus {
296
431
  if (!handler.undo || this.isUndoable(handler) === false) {
297
432
  throw new Error(`Command ${log.commandId} is not undoable`);
298
433
  }
299
- const claimed = await service.claimForUndo(log.id);
300
- if (!claimed) throw new Error("Undo token already consumed");
301
- try {
302
- const allInterceptors = getAllCommandInterceptorInstances();
303
- let undoInterceptorMetadata = /* @__PURE__ */ new Map();
304
- const userFeatures = allInterceptors.length ? await this.resolveUserFeaturesForInterceptors(ctx) : [];
305
- if (allInterceptors.length) {
306
- const undoCtx = { input: log.commandPayload, logEntry: log, undoToken };
307
- const interceptorCtx = {
308
- commandId: log.commandId,
309
- auth: ctx.auth ?? null,
310
- selectedOrganizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
311
- container: ctx.container
434
+ const isAtomicReplay = handler.atomicReplay === true;
435
+ const allInterceptors = getAllCommandInterceptorInstances();
436
+ let undoInterceptorMetadata = /* @__PURE__ */ new Map();
437
+ let userFeatures = [];
438
+ const runBeforeUndoInterceptors = async (replayCtx) => {
439
+ const resolvedUserFeatures = allInterceptors.length ? await this.resolveUserFeaturesForInterceptors(replayCtx) : [];
440
+ if (!allInterceptors.length) {
441
+ return {
442
+ metadata: /* @__PURE__ */ new Map(),
443
+ userFeatures: resolvedUserFeatures
312
444
  };
313
- const beforeResult = await runCommandInterceptorsBeforeUndo(
314
- allInterceptors,
315
- log.commandId,
316
- undoCtx,
317
- interceptorCtx,
318
- userFeatures
319
- );
320
- if (!beforeResult.ok) {
321
- const blocked = beforeResult.error;
322
- throw new CommandInterceptorError(blocked.message, { status: blocked.status, body: blocked.body });
323
- }
324
- undoInterceptorMetadata = beforeResult.metadataByInterceptor;
325
445
  }
446
+ const undoCtx = { input: log.commandPayload, logEntry: log, undoToken };
447
+ const interceptorCtx = {
448
+ commandId: log.commandId,
449
+ auth: replayCtx.auth ?? null,
450
+ selectedOrganizationId: replayCtx.selectedOrganizationId ?? replayCtx.auth?.orgId ?? null,
451
+ container: replayCtx.container
452
+ };
453
+ const beforeResult = await runCommandInterceptorsBeforeUndo(
454
+ allInterceptors,
455
+ log.commandId,
456
+ undoCtx,
457
+ interceptorCtx,
458
+ resolvedUserFeatures
459
+ );
460
+ if (!beforeResult.ok) {
461
+ const blocked = beforeResult.error;
462
+ throw new CommandInterceptorError(blocked.message, { status: blocked.status, body: blocked.body });
463
+ }
464
+ return {
465
+ metadata: beforeResult.metadataByInterceptor,
466
+ userFeatures: resolvedUserFeatures
467
+ };
468
+ };
469
+ if (!isAtomicReplay) {
470
+ await this.runReplayTransactionGuard(ctx, "undo", log);
471
+ if (handler.authorizeReplay) {
472
+ await handler.authorizeReplay({
473
+ operation: "undo",
474
+ input: log.commandPayload,
475
+ ctx,
476
+ logEntry: log
477
+ });
478
+ }
479
+ }
480
+ const claimUndo = async (replayEm) => {
481
+ const claimed = replayEm ? await service.claimForUndo(log.id, replayEm) : await service.claimForUndo(log.id);
482
+ if (!claimed) throw new Error("[internal] Undo token already consumed");
483
+ };
484
+ const finishUndo = async (replayCtx, replayEm) => {
326
485
  await handler.undo({
327
486
  input: log.commandPayload,
328
- ctx,
487
+ ctx: replayCtx,
329
488
  logEntry: log
330
489
  });
331
- await service.markUndone(log.id, this.buildUndoTraceLog(log, ctx));
490
+ const traceLog = this.buildUndoTraceLog(log, replayCtx);
491
+ if (replayEm) {
492
+ await service.markUndone(log.id, traceLog, replayEm);
493
+ } else {
494
+ await service.markUndone(log.id, traceLog);
495
+ }
496
+ };
497
+ let ambientReplay = null;
498
+ let replayTransactionLifetime = null;
499
+ if (isAtomicReplay) {
500
+ ambientReplay = resolveAtomicReplayTransaction(ctx);
501
+ const replayEm = ambientReplay.em;
502
+ await withAtomicFlush(replayEm, [async () => {
503
+ const replayCtx = bindReplayTransactionContext(
504
+ ctx,
505
+ replayEm,
506
+ ambientReplay?.joinsAmbient ? ctx.transactionLifetime : void 0
507
+ );
508
+ replayTransactionLifetime = replayCtx.transactionLifetime ?? null;
509
+ if (handler.stabilizeReplay) {
510
+ await handler.stabilizeReplay({
511
+ operation: "undo",
512
+ input: log.commandPayload,
513
+ ctx: replayCtx,
514
+ logEntry: log
515
+ });
516
+ }
517
+ await this.runReplayTransactionGuard(replayCtx, "undo", log, replayEm);
518
+ const prepared = await runBeforeUndoInterceptors(replayCtx);
519
+ undoInterceptorMetadata = prepared.metadata;
520
+ userFeatures = prepared.userFeatures;
521
+ if (handler.authorizeReplay) {
522
+ await handler.authorizeReplay({
523
+ operation: "undo",
524
+ input: log.commandPayload,
525
+ ctx: replayCtx,
526
+ logEntry: log
527
+ });
528
+ }
529
+ await claimUndo(replayEm);
530
+ await finishUndo(replayCtx, replayEm);
531
+ }], {
532
+ transaction: true,
533
+ label: `${log.commandId}.undo`
534
+ });
535
+ } else {
536
+ try {
537
+ await claimUndo();
538
+ const prepared = await runBeforeUndoInterceptors(ctx);
539
+ undoInterceptorMetadata = prepared.metadata;
540
+ userFeatures = prepared.userFeatures;
541
+ await finishUndo(ctx);
542
+ } catch (err) {
543
+ await service.releaseUndoClaim(log.id).catch(() => {
544
+ });
545
+ throw err;
546
+ }
547
+ }
548
+ const runPostCommit = async () => {
332
549
  if (allInterceptors.length) {
333
550
  const undoCtx = { input: log.commandPayload, logEntry: log, undoToken };
334
551
  const interceptorCtx = {
@@ -348,11 +565,17 @@ class CommandBus {
348
565
  }
349
566
  await this.invalidateCacheAfterUndo(log, ctx);
350
567
  await this.flushCrudSideEffects(ctx.container);
351
- } catch (err) {
352
- await service.releaseUndoClaim(log.id).catch(() => {
568
+ };
569
+ if (ambientReplay?.joinsAmbient) {
570
+ if (!replayTransactionLifetime || !ownsTransactionLifetime(ambientReplay.em, replayTransactionLifetime)) {
571
+ throw new Error("[internal] Ambient atomic replay lost its outer transaction lifetime owner");
572
+ }
573
+ onTransactionLifetimeComplete(ambientReplay.em, async (outcome) => {
574
+ if (outcome === "committed") await runPostCommit();
353
575
  });
354
- throw err;
576
+ return;
355
577
  }
578
+ await runPostCommit();
356
579
  }
357
580
  buildUndoTraceLog(log, ctx) {
358
581
  const snapshotBefore = log.snapshotAfter ?? null;
@@ -385,18 +608,44 @@ class CommandBus {
385
608
  }
386
609
  async resolveUserFeaturesForInterceptors(ctx) {
387
610
  if (!ctx.auth) return [];
611
+ let rbac;
612
+ try {
613
+ rbac = ctx.container.resolve("rbacService");
614
+ } catch {
615
+ return [];
616
+ }
617
+ const scope = {
618
+ tenantId: ctx.auth.tenantId,
619
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId
620
+ };
621
+ if (ctx.transactionalEm) {
622
+ if (!rbac?.getGrantedFeaturesWithEntityManager) return [];
623
+ return await rbac.getGrantedFeaturesWithEntityManager(
624
+ ctx.transactionalEm,
625
+ ctx.auth.sub,
626
+ scope
627
+ );
628
+ }
388
629
  try {
389
- const rbac = ctx.container.resolve("rbacService");
390
630
  if (rbac?.getGrantedFeatures) {
391
- return await rbac.getGrantedFeatures(ctx.auth.sub, {
392
- tenantId: ctx.auth.tenantId,
393
- organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId
394
- });
631
+ return await rbac.getGrantedFeatures(ctx.auth.sub, scope);
395
632
  }
396
633
  } catch {
397
634
  }
398
635
  return [];
399
636
  }
637
+ async runReplayTransactionGuard(ctx, operation, logEntry, transactionalEm) {
638
+ if (!ctx.replayTransactionGuard) return;
639
+ if (transactionalEm) {
640
+ await ctx.replayTransactionGuard({ operation, logEntry, transactionalEm });
641
+ return;
642
+ }
643
+ const requestEm = ctx.container.resolve("em");
644
+ const guardEm = typeof requestEm.fork === "function" ? requestEm.fork() : requestEm;
645
+ await withAtomicFlush(guardEm, [async () => {
646
+ await ctx.replayTransactionGuard({ operation, logEntry, transactionalEm: guardEm });
647
+ }], { transaction: true, label: `${logEntry.commandId}.${operation}.request-guard` });
648
+ }
400
649
  async resolveHandler(commandId) {
401
650
  const handler = commandRegistry.get(commandId) ?? await commandRegistry.load(commandId);
402
651
  if (!handler) {
@@ -436,6 +685,7 @@ class CommandBus {
436
685
  if (!primary && !secondary) return null;
437
686
  return {
438
687
  skipLog: secondary?.skipLog ?? primary?.skipLog ?? false,
688
+ replayable: secondary?.replayable === false || primary?.replayable === false ? false : secondary?.replayable ?? primary?.replayable,
439
689
  tenantId: secondary?.tenantId ?? primary?.tenantId ?? null,
440
690
  organizationId: secondary?.organizationId ?? primary?.organizationId ?? null,
441
691
  actorUserId: secondary?.actorUserId ?? primary?.actorUserId ?? null,
@@ -448,6 +698,9 @@ class CommandBus {
448
698
  relatedResourceKind: secondary?.relatedResourceKind ?? primary?.relatedResourceKind ?? null,
449
699
  relatedResourceId: secondary?.relatedResourceId ?? primary?.relatedResourceId ?? null,
450
700
  undoToken: secondary?.undoToken ?? primary?.undoToken ?? null,
701
+ // A redacted redo input always wins: it exists precisely because the raw
702
+ // input carries a secret that must not reach `action_logs`.
703
+ redoInput: secondary?.redoInput !== void 0 ? secondary.redoInput : primary?.redoInput,
451
704
  payload: secondary?.payload ?? primary?.payload ?? null,
452
705
  snapshotBefore: secondary?.snapshotBefore ?? primary?.snapshotBefore ?? null,
453
706
  snapshotAfter: secondary?.snapshotAfter ?? primary?.snapshotAfter ?? null,
@@ -505,9 +758,14 @@ class CommandBus {
505
758
  const baseContext = asRecord(payload.context) ?? {};
506
759
  payload.context = { ...baseContext, source: runAs.source };
507
760
  }
508
- const redoEnvelope = wrapRedoPayload("commandPayload" in payload ? payload.commandPayload : void 0, options.input);
509
- payload.commandPayload = redoEnvelope;
510
- return await service.log(payload);
761
+ if (metadata.replayable === false) {
762
+ delete payload.commandPayload;
763
+ } else {
764
+ const redoInput = metadata.redoInput !== void 0 ? metadata.redoInput : options.input;
765
+ const redoEnvelope = wrapRedoPayload("commandPayload" in payload ? payload.commandPayload : void 0, redoInput);
766
+ payload.commandPayload = redoEnvelope;
767
+ }
768
+ return options.ctx.transactionalEm ? await service.log(payload, options.ctx.transactionalEm) : await service.log(payload);
511
769
  }
512
770
  isUndoable(handler) {
513
771
  return handler.isUndoable !== false && typeof handler.undo === "function";