@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,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/commands/types.ts"],
4
- "sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { ZodTypeAny } from 'zod'\nimport { randomUUID } from 'crypto'\nimport type { AuthContext } from '../auth/server'\nimport type { OrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'\n\n/**\n * Bulk-import / backfill deferral flags. When a command runs under a context that\n * carries this, the command bus and data engine suppress the heavy per-record side\n * effects flagged below so a large backfill can defer them to a single batched pass.\n *\n * IMPORTANT \u2014 the caller owns restoring whatever it suppresses. With `skipReindex`\n * the `query_index` projection (and its search tokens) is stale for every record\n * written under this context until the caller runs a batched `query_index rebuild`\n * for the affected entity types at end-of-run.\n *\n * Concurrency: these flags are read from the context and threaded as a local\n * parameter through the side-effect flush \u2014 no shared engine state is mutated \u2014 so\n * two commands running concurrently with different flags never clobber each other.\n */\nexport type BulkImportSuppression = {\n /** Skip the inline `query_index.upsert_one` / `delete_one` reindex (rebuild after the run). */\n skipReindex?: boolean\n /** Skip the per-record `<module>.<entity>.<action>` domain event emission. */\n skipEvents?: boolean\n /** Advisory: handlers that fan out per-record notifications SHOULD honor this and skip them. */\n skipNotifications?: boolean\n}\n\nexport type CommandRuntimeContext = {\n container: AwilixContainer\n auth: AuthContext | null\n organizationScope: OrganizationScope | null\n selectedOrganizationId: string | null\n organizationIds: string[] | null\n request?: Request\n syncOrigin?: string | null\n /**\n * See {@link BulkImportSuppression}. Set by bulk backfill callers to defer heavy\n * per-record side effects (reindex, events, notifications). The caller MUST rebuild\n * the `query_index` for the affected entity types after the run when `skipReindex`\n * is set. Unset for normal (interactive) writes \u2014 they get all side effects.\n */\n bulkImport?: BulkImportSuppression\n /**\n * Marks a trusted server-side invocation (CLI seeding, tenant setup) that runs\n * without an authenticated end-user actor. Commands that gate writes behind a\n * privileged actor (e.g. super-admin-only platform tables) may treat this as\n * an explicit system grant. HTTP request paths MUST NOT set this \u2014 they always\n * carry a real `auth` actor, so a present-but-unprivileged actor stays denied.\n */\n systemActor?: boolean\n /**\n * When set, command handlers that support it MUST run their writes within this\n * existing transactional EntityManager (reusing its row locks) instead of\n * opening their own transaction. Lets a caller compose a command with its own\n * surrounding work as a single atomic, single-locked operation.\n */\n transactionalEm?: EntityManager\n /**\n * On-behalf-of attribution for non-human principals (Agent Identity &\n * On-Behalf-Of, Wave 4 P2). When an agent runs on behalf of a human, the\n * orchestrator's `runAs` wrapper sets this so every `ActionLog` the command\n * path writes records `actorUserId = runAs.actorUserId` (the agent principal's\n * `auth.User` id), `onBehalfOfUserId = runAs.onBehalfOfUserId` (the invoking\n * human, or null for system-invoked agents), and `sourceKey = runAs.source`\n * (`'agent'`). Additive + optional: callers that omit it keep the existing\n * `ctx.auth.sub`-derived attribution unchanged. This threads agent attribution\n * through the SAME audited Command/CRUD path as a human action \u2014 not a parallel\n * audit path.\n */\n runAs?: CommandRunAsContext\n}\n\nexport type CommandRunAsContext = {\n /** The actor stamped on every ActionLog this context produces (agent `auth.User` id). */\n actorUserId: string\n /** The human (or system) principal the actor acts on behalf of; null when system-invoked. */\n onBehalfOfUserId?: string | null\n /** The audit source key for the attributed writes; `'agent'` for agent runs. */\n source: 'agent'\n}\n\nexport type CommandLogMetadata = {\n skipLog?: boolean\n tenantId?: string | null\n organizationId?: string | null\n actorUserId?: string | null\n onBehalfOfUserId?: string | null\n actionLabel?: string | null\n resourceKind?: string | null\n resourceId?: string | null\n parentResourceKind?: string | null\n parentResourceId?: string | null\n undoToken?: string | null\n payload?: unknown\n snapshotBefore?: unknown\n snapshotAfter?: unknown\n relatedResourceKind?: string | null\n relatedResourceId?: string | null\n changes?: Record<string, unknown> | null\n context?: Record<string, unknown> | null\n}\n\nexport type CommandExecuteResult<TResult> = {\n result: TResult\n logEntry: any | null\n}\n\n/**\n * Shape of the persisted action log handed to a command's `undo()` handler.\n *\n * IMPORTANT: there is intentionally **no `payload` field**. `buildLog()` returns\n * a `payload` in its metadata, but the command bus persists that under\n * `commandPayload` (column `command_payload`, wrapped in a redo envelope) \u2014 the\n * stored row never has a top-level `payload`. Reading `logEntry.payload` in an\n * undo handler is therefore always `undefined` and silently no-ops the undo\n * (issue #2504). Always read the undo snapshot through\n * `extractUndoPayload(logEntry)` from `@open-mercato/shared/lib/commands/undo`,\n * which unwraps `commandPayload`/snapshots correctly. Omitting `payload` here\n * makes the footgun a compile-time error instead of a runtime silent failure.\n */\nexport type CommandUndoLogEntry = {\n id?: string\n commandId?: string\n commandPayload?: unknown | null\n snapshotBefore?: unknown | null\n snapshotAfter?: unknown | null\n resourceKind?: string | null\n resourceId?: string | null\n undoToken?: string | null\n actionLabel?: string | null\n tenantId?: string | null\n organizationId?: string | null\n actorUserId?: string | null\n changesJson?: Record<string, unknown> | null\n contextJson?: Record<string, unknown> | null\n createdAt?: Date | string\n updatedAt?: Date | string\n}\n\nexport type CommandLogBuilderArgs<TInput, TResult> = {\n input: TInput\n result: TResult\n ctx: CommandRuntimeContext\n snapshots: {\n before?: unknown\n after?: unknown\n }\n}\n\nexport interface CommandHandler<TInput = unknown, TResult = unknown> {\n readonly id: string\n readonly isUndoable?: boolean\n /**\n * Optional Zod schema describing the command's return value. Feeds the\n * workflows context ledger so downstream activities can reason about the\n * shape a command produces; when absent the ledger renders the output as\n * unknown.\n */\n readonly outputSchema?: ZodTypeAny\n prepare?(input: TInput, ctx: CommandRuntimeContext): Promise<{ before?: unknown } | null> | { before?: unknown } | null\n execute(input: TInput, ctx: CommandRuntimeContext): Promise<TResult> | TResult\n buildLog?(args: CommandLogBuilderArgs<TInput, TResult>): Promise<CommandLogMetadata | null | undefined> | CommandLogMetadata | null | undefined\n captureAfter?(input: TInput, result: TResult, ctx: CommandRuntimeContext): Promise<unknown> | unknown\n undo?(params: { input: TInput; ctx: CommandRuntimeContext; logEntry: CommandUndoLogEntry }): Promise<void> | void\n /**\n * Optional redo handler. When defined, the command bus calls this instead of\n * `execute()` while replaying a previously undone action (the redo route passes\n * `redoLogEntry` in the execution options). It receives the source action log so\n * it can re-materialize the original record **reusing its id** \u2014 for a create\n * command this restores the soft-deleted row (or re-creates it from the\n * `snapshotAfter`) instead of minting a new id, keeping undo/redo snapshots and\n * references stable (issue #2506, invariant I6). Handlers without `redo` keep the\n * legacy behavior of replaying `execute(__redoInput)`.\n */\n redo?(params: { input: TInput; ctx: CommandRuntimeContext; logEntry: CommandUndoLogEntry }): Promise<TResult> | TResult\n}\n\nexport type CommandExecutionOptions<TInput> = {\n input: TInput\n ctx: CommandRuntimeContext\n metadata?: CommandLogMetadata | null\n skipCacheInvalidation?: boolean\n /**\n * When set, marks this execution as a redo of a previously undone action. If the\n * resolved handler defines a `redo` method, the command bus calls\n * `handler.redo({ input, ctx, logEntry })` instead of `handler.execute(...)`. The\n * rest of the pipeline (snapshots, buildLog, undo-token minting, persistence,\n * cache invalidation, side effects) is identical, so the fresh log entry \u2014 and\n * the `x-om-operation` header derived from it \u2014 automatically carry the restored\n * resourceId. Ignored when the handler has no `redo` method (legacy replay path).\n */\n redoLogEntry?: CommandUndoLogEntry | null\n}\n\nexport function defaultUndoToken(): string {\n return randomUUID()\n}\n"],
5
- "mappings": "AAGA,SAAS,kBAAkB;AAkMpB,SAAS,mBAA2B;AACzC,SAAO,WAAW;AACpB;",
4
+ "sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { ZodTypeAny } from 'zod'\nimport { randomUUID } from 'crypto'\nimport type { AuthContext } from '../auth/server'\nimport type { OrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport type { TransactionLifetime } from './transaction-lifetime'\n\n/**\n * Bulk-import / backfill deferral flags. When a command runs under a context that\n * carries this, the command bus and data engine suppress the heavy per-record side\n * effects flagged below so a large backfill can defer them to a single batched pass.\n *\n * IMPORTANT \u2014 the caller owns restoring whatever it suppresses. With `skipReindex`\n * the `query_index` projection (and its search tokens) is stale for every record\n * written under this context until the caller runs a batched `query_index rebuild`\n * for the affected entity types at end-of-run.\n *\n * Concurrency: these flags are read from the context and threaded as a local\n * parameter through the side-effect flush \u2014 no shared engine state is mutated \u2014 so\n * two commands running concurrently with different flags never clobber each other.\n */\nexport type BulkImportSuppression = {\n /** Skip the inline `query_index.upsert_one` / `delete_one` reindex (rebuild after the run). */\n skipReindex?: boolean\n /** Skip the per-record `<module>.<entity>.<action>` domain event emission. */\n skipEvents?: boolean\n /** Advisory: handlers that fan out per-record notifications SHOULD honor this and skip them. */\n skipNotifications?: boolean\n /**\n * Skip a command's own full-collection derived-data rebuild (e.g. category tree\n * `parentId`/`treePath`/`depth`/ancestor-descendant rewrite). The caller MUST run that rebuild\n * itself exactly once after the batch, in a `finally` that covers the completed, cancelled and\n * failed paths \u2014 otherwise every record written under this context is left with a stale\n * derived shape.\n */\n skipDerivedRebuild?: boolean\n}\n\nexport type CommandRuntimeContext = {\n container: AwilixContainer\n auth: AuthContext | null\n organizationScope: OrganizationScope | null\n selectedOrganizationId: string | null\n organizationIds: string[] | null\n request?: Request\n syncOrigin?: string | null\n /**\n * See {@link BulkImportSuppression}. Set by bulk backfill callers to defer heavy\n * per-record side effects (reindex, events, notifications). The caller MUST rebuild\n * the `query_index` for the affected entity types after the run when `skipReindex`\n * is set. Unset for normal (interactive) writes \u2014 they get all side effects.\n */\n bulkImport?: BulkImportSuppression\n /**\n * Marks a trusted server-side invocation (CLI seeding, tenant setup) that runs\n * without an authenticated end-user actor. Commands that gate writes behind a\n * privileged actor (e.g. super-admin-only platform tables) may treat this as\n * an explicit system grant. HTTP request paths MUST NOT set this \u2014 they always\n * carry a real `auth` actor, so a present-but-unprivileged actor stays denied.\n */\n systemActor?: boolean\n /**\n * When set, command handlers that support it MUST run their writes within this\n * existing transactional EntityManager (reusing its row locks) instead of\n * opening their own transaction. Lets a caller compose a command with its own\n * surrounding work as a single atomic, single-locked operation.\n */\n transactionalEm?: EntityManager\n /**\n * Identifies the owner of an already-active {@link transactionalEm} lifetime.\n * Atomic replay accepts an ambient EntityManager only when this is the exact\n * token returned by `getTransactionLifetime(transactionalEm)` from the outer\n * `withAtomicFlush` phase. The owner completes the token after its real commit\n * or rollback, allowing replay leases and post-commit work to follow that\n * boundary. Omit when the command bus opens and owns the transaction itself.\n */\n transactionLifetime?: TransactionLifetime\n /**\n * Optional request-level replay authorization that the command bus binds to\n * its replay EntityManager. Atomic handlers run this guard in the same\n * transaction as the source-log transition and domain mutation; legacy\n * handlers run it in a dedicated read transaction before preserving their\n * existing replay lifecycle.\n */\n replayTransactionGuard?: (args: {\n operation: CommandReplayOperation\n logEntry: CommandUndoLogEntry\n transactionalEm: EntityManager\n }) => Promise<void> | void\n /**\n * On-behalf-of attribution for non-human principals (Agent Identity &\n * On-Behalf-Of, Wave 4 P2). When an agent runs on behalf of a human, the\n * orchestrator's `runAs` wrapper sets this so every `ActionLog` the command\n * path writes records `actorUserId = runAs.actorUserId` (the agent principal's\n * `auth.User` id), `onBehalfOfUserId = runAs.onBehalfOfUserId` (the invoking\n * human, or null for system-invoked agents), and `sourceKey = runAs.source`\n * (`'agent'`). Additive + optional: callers that omit it keep the existing\n * `ctx.auth.sub`-derived attribution unchanged. This threads agent attribution\n * through the SAME audited Command/CRUD path as a human action \u2014 not a parallel\n * audit path.\n */\n runAs?: CommandRunAsContext\n}\n\nexport type CommandRunAsContext = {\n /** The actor stamped on every ActionLog this context produces (agent `auth.User` id). */\n actorUserId: string\n /** The human (or system) principal the actor acts on behalf of; null when system-invoked. */\n onBehalfOfUserId?: string | null\n /** The audit source key for the attributed writes; `'agent'` for agent runs. */\n source: 'agent'\n}\n\nexport type CommandLogMetadata = {\n skipLog?: boolean\n /**\n * Per-execution replay policy. `false` records the audit entry without an undo\n * token or command payload, so neither undo nor redo can replay sensitive input.\n * Omitted preserves the command handler's existing replay behavior.\n */\n replayable?: boolean\n /**\n * Overrides the command input persisted in the audit entry's redo envelope.\n * The default is the raw input, which for credential-bearing commands would put\n * a plaintext secret at rest in `action_logs`. A handler that still wants its\n * operation to be undoable supplies a redacted projection here instead of\n * switching the whole entry to `replayable: false` \u2014 suppressing the undo token\n * removes the operator's ability to revert a write that carries no secret of its\n * own (undoing a user create only deletes a row).\n */\n redoInput?: unknown\n tenantId?: string | null\n organizationId?: string | null\n actorUserId?: string | null\n onBehalfOfUserId?: string | null\n actionLabel?: string | null\n resourceKind?: string | null\n resourceId?: string | null\n parentResourceKind?: string | null\n parentResourceId?: string | null\n undoToken?: string | null\n payload?: unknown\n snapshotBefore?: unknown\n snapshotAfter?: unknown\n relatedResourceKind?: string | null\n relatedResourceId?: string | null\n changes?: Record<string, unknown> | null\n context?: Record<string, unknown> | null\n}\n\nexport type CommandExecuteResult<TResult> = {\n result: TResult\n logEntry: any | null\n /**\n * True when an atomic redo finalized its source action log in the same\n * transaction as the domain mutation and the newly persisted log entry, and\n * that transaction has committed. An ambient replay returns this as false\n * while its caller-owned transaction is still active; the returned result\n * object is updated to true by the owner's commit callback. It remains false\n * on rollback. Callers that historically finalized redo themselves can use\n * this additive signal to avoid a redundant post-commit write.\n */\n replaySourceFinalized?: boolean\n}\n\n/**\n * Shape of the persisted action log handed to a command's `undo()` handler.\n *\n * IMPORTANT: there is intentionally **no `payload` field**. `buildLog()` returns\n * a `payload` in its metadata, but the command bus persists that under\n * `commandPayload` (column `command_payload`, wrapped in a redo envelope) \u2014 the\n * stored row never has a top-level `payload`. Reading `logEntry.payload` in an\n * undo handler is therefore always `undefined` and silently no-ops the undo\n * (issue #2504). Always read the undo snapshot through\n * `extractUndoPayload(logEntry)` from `@open-mercato/shared/lib/commands/undo`,\n * which unwraps `commandPayload`/snapshots correctly. Omitting `payload` here\n * makes the footgun a compile-time error instead of a runtime silent failure.\n */\nexport type CommandUndoLogEntry = {\n id?: string\n commandId?: string\n commandPayload?: unknown | null\n snapshotBefore?: unknown | null\n snapshotAfter?: unknown | null\n resourceKind?: string | null\n resourceId?: string | null\n undoToken?: string | null\n actionLabel?: string | null\n tenantId?: string | null\n organizationId?: string | null\n actorUserId?: string | null\n changesJson?: Record<string, unknown> | null\n contextJson?: Record<string, unknown> | null\n createdAt?: Date | string\n updatedAt?: Date | string\n}\n\nexport type CommandLogBuilderArgs<TInput, TResult> = {\n input: TInput\n result: TResult\n ctx: CommandRuntimeContext\n snapshots: {\n before?: unknown\n after?: unknown\n }\n}\n\nexport type CommandReplayOperation = 'undo' | 'redo'\n\nexport type CommandReplayAuthorizationArgs<TInput> = {\n operation: CommandReplayOperation\n input: TInput\n ctx: CommandRuntimeContext\n logEntry: CommandUndoLogEntry\n}\n\nexport interface CommandHandler<TInput = unknown, TResult = unknown> {\n readonly id: string\n readonly isUndoable?: boolean\n /**\n * Opts replay into the command bus's transaction-bound lifecycle. The source\n * action-log transition, handler mutation, and any new replay log share one\n * EntityManager and commit or roll back together. Handlers that enable this\n * MUST reuse `ctx.transactionalEm` for every replay-time database operation.\n * Omitted preserves the legacy non-atomic replay path.\n */\n readonly atomicReplay?: boolean\n /**\n * Acquires every authorization-state lock needed by an atomic replay before\n * request-level replay authorization and feature-gated interceptors run.\n * Implementations MUST discover their complete actor/target/destination set\n * before taking the first lock and MUST reuse `ctx.transactionalEm`.\n * Omitted preserves the existing lifecycle for handlers that do not need\n * domain-specific authorization stabilization.\n */\n stabilizeReplay?(params: CommandReplayAuthorizationArgs<TInput>): Promise<void> | void\n /**\n * Optional Zod schema describing the command's return value. Feeds the\n * workflows context ledger so downstream activities can reason about the\n * shape a command produces; when absent the ledger renders the output as\n * unknown.\n */\n readonly outputSchema?: ZodTypeAny\n prepare?(input: TInput, ctx: CommandRuntimeContext): Promise<{ before?: unknown } | null> | { before?: unknown } | null\n execute(input: TInput, ctx: CommandRuntimeContext): Promise<TResult> | TResult\n buildLog?(args: CommandLogBuilderArgs<TInput, TResult>): Promise<CommandLogMetadata | null | undefined> | CommandLogMetadata | null | undefined\n captureAfter?(input: TInput, result: TResult, ctx: CommandRuntimeContext): Promise<unknown> | unknown\n /**\n * Re-authorizes a stored command against the actor and resource state that\n * exist at replay time. Legacy handlers are checked before claiming an undo\n * log or beginning redo processing. Atomic handlers are checked inside their\n * replay transaction before the guarded source transition; their mutation\n * path can repeat the check after taking domain locks. Throwing aborts replay\n * without committed domain side effects.\n */\n authorizeReplay?(params: CommandReplayAuthorizationArgs<TInput>): Promise<void> | void\n undo?(params: { input: TInput; ctx: CommandRuntimeContext; logEntry: CommandUndoLogEntry }): Promise<void> | void\n /**\n * Optional redo handler. When defined, the command bus calls this instead of\n * `execute()` while replaying a previously undone action (the redo route passes\n * `redoLogEntry` in the execution options). It receives the source action log so\n * it can re-materialize the original record **reusing its id** \u2014 for a create\n * command this restores the soft-deleted row (or re-creates it from the\n * `snapshotAfter`) instead of minting a new id, keeping undo/redo snapshots and\n * references stable (issue #2506, invariant I6). Handlers without `redo` keep the\n * legacy behavior of replaying `execute(__redoInput)`.\n */\n redo?(params: { input: TInput; ctx: CommandRuntimeContext; logEntry: CommandUndoLogEntry }): Promise<TResult> | TResult\n}\n\nexport type CommandExecutionOptions<TInput> = {\n input: TInput\n ctx: CommandRuntimeContext\n metadata?: CommandLogMetadata | null\n skipCacheInvalidation?: boolean\n /**\n * When set, marks this execution as a redo of a previously undone action. If the\n * resolved handler defines a `redo` method, the command bus calls\n * `handler.redo({ input, ctx, logEntry })` instead of `handler.execute(...)`. The\n * rest of the pipeline (snapshots, buildLog, undo-token minting, persistence,\n * cache invalidation, side effects) is identical, so the fresh log entry \u2014 and\n * the `x-om-operation` header derived from it \u2014 automatically carry the restored\n * resourceId. Ignored when the handler has no `redo` method (legacy replay path).\n */\n redoLogEntry?: CommandUndoLogEntry | null\n}\n\nexport function defaultUndoToken(): string {\n return randomUUID()\n}\n"],
5
+ "mappings": "AAGA,SAAS,kBAAkB;AA6RpB,SAAS,mBAA2B;AACzC,SAAO,WAAW;AACpB;",
6
6
  "names": []
7
7
  }
@@ -5,7 +5,58 @@ function snapshotFallback(logEntry) {
5
5
  if (before === null && after === null) return null;
6
6
  return { before: before ?? null, after: after ?? null };
7
7
  }
8
- function extractUndoPayload(logEntry) {
8
+ function isPlainRecord(value) {
9
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date);
10
+ }
11
+ function reviveDateValue(value, field) {
12
+ if (typeof value !== "string") return value;
13
+ const date = new Date(value);
14
+ if (Number.isNaN(date.getTime())) {
15
+ throw new Error(`[internal] Invalid ${field} snapshot date: ${value}`);
16
+ }
17
+ return date;
18
+ }
19
+ function reviveSnapshotDates(value, fields) {
20
+ const revived = { ...value };
21
+ for (const field of fields) {
22
+ if (field in revived) revived[field] = reviveDateValue(revived[field], field);
23
+ }
24
+ return revived;
25
+ }
26
+ function reviveDateFieldsDeep(value, fields) {
27
+ if (Array.isArray(value)) return value.map((item) => reviveDateFieldsDeep(item, fields));
28
+ if (!isPlainRecord(value)) return value;
29
+ return Object.fromEntries(
30
+ Object.entries(value).map(([key, entry]) => [
31
+ key,
32
+ fields.has(key) ? reviveDateValue(entry, key) : reviveDateFieldsDeep(entry, fields)
33
+ ])
34
+ );
35
+ }
36
+ function reviveDatePath(value, segments) {
37
+ if (Array.isArray(value)) return value.map((item) => reviveDatePath(item, segments));
38
+ if (!isPlainRecord(value) || segments.length === 0) return value;
39
+ const [head, ...rest] = segments;
40
+ if (!(head in value)) return value;
41
+ const entry = value[head];
42
+ return { ...value, [head]: rest.length === 0 ? reviveDateValue(entry, head) : reviveDatePath(entry, rest) };
43
+ }
44
+ function reviveUndoPayloadDates(payload, options) {
45
+ if (payload === null || payload === void 0 || !options) return payload;
46
+ let revived = payload;
47
+ if (options.dateFields && options.dateFields.length > 0) {
48
+ revived = reviveDateFieldsDeep(revived, new Set(options.dateFields));
49
+ }
50
+ for (const path of options.datePaths ?? []) {
51
+ const segments = path.split(".").filter((segment) => segment.length > 0);
52
+ if (segments.length > 0) revived = reviveDatePath(revived, segments);
53
+ }
54
+ return revived;
55
+ }
56
+ function extractUndoPayload(logEntry, options) {
57
+ return reviveUndoPayloadDates(extractRawUndoPayload(logEntry), options);
58
+ }
59
+ function extractRawUndoPayload(logEntry) {
9
60
  if (!logEntry) return null;
10
61
  const rawPayload = logEntry.commandPayload ?? logEntry.payload;
11
62
  if (!rawPayload || typeof rawPayload !== "object" || Array.isArray(rawPayload)) {
@@ -26,6 +77,7 @@ function extractUndoPayload(logEntry) {
26
77
  return snapshotFallback(logEntry);
27
78
  }
28
79
  export {
29
- extractUndoPayload
80
+ extractUndoPayload,
81
+ reviveSnapshotDates
30
82
  };
31
83
  //# sourceMappingURL=undo.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/commands/undo.ts"],
4
- "sourcesContent": ["type UndoSnapshot = {\n before?: unknown | null\n after?: unknown | null\n}\n\nexport type UndoPayload<T> = {\n before?: T | null\n after?: T | null\n}\n\nexport type UndoEnvelope<T> = {\n undo?: T\n value?: { undo?: T }\n __redoInput?: unknown\n [key: string]: unknown\n}\n\ntype UndoLogEntry = {\n commandPayload?: unknown | null\n payload?: unknown | null\n snapshotBefore?: unknown | null\n snapshotAfter?: unknown | null\n} & UndoSnapshot\n\nfunction snapshotFallback<T>(logEntry: UndoLogEntry): T | null {\n const before = logEntry.snapshotBefore\n const after = logEntry.snapshotAfter\n if (before === undefined && after === undefined) return null\n if (before === null && after === null) return null\n return { before: before ?? null, after: after ?? null } as T\n}\n\nexport function extractUndoPayload<T>(logEntry: UndoLogEntry | null | undefined): T | null {\n if (!logEntry) return null\n const rawPayload = logEntry.commandPayload ?? logEntry.payload\n if (!rawPayload || typeof rawPayload !== 'object' || Array.isArray(rawPayload)) {\n return snapshotFallback(logEntry)\n }\n const payload = rawPayload as UndoEnvelope<T>\n if (payload.undo) return payload.undo\n if (payload.value && typeof payload.value === 'object' && payload.value.undo) {\n return payload.value.undo as T\n }\n for (const [key, value] of Object.entries(payload)) {\n if (key === '__redoInput') continue\n if (value && typeof value === 'object' && 'undo' in value) {\n const undo = (value as { undo?: T }).undo\n if (undo !== undefined) return undo ?? null\n }\n }\n return snapshotFallback(logEntry)\n}\n"],
5
- "mappings": "AAwBA,SAAS,iBAAoB,UAAkC;AAC7D,QAAM,SAAS,SAAS;AACxB,QAAM,QAAQ,SAAS;AACvB,MAAI,WAAW,UAAa,UAAU,OAAW,QAAO;AACxD,MAAI,WAAW,QAAQ,UAAU,KAAM,QAAO;AAC9C,SAAO,EAAE,QAAQ,UAAU,MAAM,OAAO,SAAS,KAAK;AACxD;AAEO,SAAS,mBAAsB,UAAqD;AACzF,MAAI,CAAC,SAAU,QAAO;AACtB,QAAM,aAAa,SAAS,kBAAkB,SAAS;AACvD,MAAI,CAAC,cAAc,OAAO,eAAe,YAAY,MAAM,QAAQ,UAAU,GAAG;AAC9E,WAAO,iBAAiB,QAAQ;AAAA,EAClC;AACA,QAAM,UAAU;AAChB,MAAI,QAAQ,KAAM,QAAO,QAAQ;AACjC,MAAI,QAAQ,SAAS,OAAO,QAAQ,UAAU,YAAY,QAAQ,MAAM,MAAM;AAC5E,WAAO,QAAQ,MAAM;AAAA,EACvB;AACA,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,OAAO,GAAG;AAClD,QAAI,QAAQ,cAAe;AAC3B,QAAI,SAAS,OAAO,UAAU,YAAY,UAAU,OAAO;AACzD,YAAM,OAAQ,MAAuB;AACrC,UAAI,SAAS,OAAW,QAAO,QAAQ;AAAA,IACzC;AAAA,EACF;AACA,SAAO,iBAAiB,QAAQ;AAClC;",
4
+ "sourcesContent": ["type UndoSnapshot = {\n before?: unknown | null\n after?: unknown | null\n}\n\nexport type UndoPayload<T> = {\n before?: T | null\n after?: T | null\n}\n\nexport type UndoEnvelope<T> = {\n undo?: T\n value?: { undo?: T }\n __redoInput?: unknown\n [key: string]: unknown\n}\n\ntype UndoLogEntry = {\n commandPayload?: unknown | null\n payload?: unknown | null\n snapshotBefore?: unknown | null\n snapshotAfter?: unknown | null\n} & UndoSnapshot\n\nfunction snapshotFallback<T>(logEntry: UndoLogEntry): T | null {\n const before = logEntry.snapshotBefore\n const after = logEntry.snapshotAfter\n if (before === undefined && after === undefined) return null\n if (before === null && after === null) return null\n return { before: before ?? null, after: after ?? null } as T\n}\n\nexport type UndoDateRevivalOptions = {\n /** Keys revived from ISO strings to `Date` wherever they appear in the payload (any depth, arrays included). */\n dateFields?: readonly string[]\n /** Dot-separated paths from the payload root revived from ISO strings to `Date`; arrays on the way are walked element-wise. */\n datePaths?: readonly string[]\n}\n\nfunction isPlainRecord(value: unknown): value is Record<string, unknown> {\n return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)\n}\n\nfunction reviveDateValue(value: unknown, field: string): unknown {\n if (typeof value !== 'string') return value\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) {\n throw new Error(`[internal] Invalid ${field} snapshot date: ${value}`)\n }\n return date\n}\n\n/**\n * Shallow-clone a snapshot record and revive the named date fields from ISO strings\n * back to `Date`. `null`, `undefined` and existing `Date` values are kept as-is; an\n * unparsable string throws so a corrupt snapshot fails loudly instead of writing a bad row.\n */\nexport function reviveSnapshotDates<T extends Record<string, unknown>>(value: T, fields: readonly string[]): T {\n const revived: Record<string, unknown> = { ...value }\n for (const field of fields) {\n if (field in revived) revived[field] = reviveDateValue(revived[field], field)\n }\n return revived as T\n}\n\nfunction reviveDateFieldsDeep(value: unknown, fields: ReadonlySet<string>): unknown {\n if (Array.isArray(value)) return value.map((item) => reviveDateFieldsDeep(item, fields))\n if (!isPlainRecord(value)) return value\n return Object.fromEntries(\n Object.entries(value).map(([key, entry]) => [\n key,\n fields.has(key) ? reviveDateValue(entry, key) : reviveDateFieldsDeep(entry, fields),\n ]),\n )\n}\n\nfunction reviveDatePath(value: unknown, segments: readonly string[]): unknown {\n if (Array.isArray(value)) return value.map((item) => reviveDatePath(item, segments))\n if (!isPlainRecord(value) || segments.length === 0) return value\n const [head, ...rest] = segments\n if (!(head in value)) return value\n const entry = value[head]\n return { ...value, [head]: rest.length === 0 ? reviveDateValue(entry, head) : reviveDatePath(entry, rest) }\n}\n\nfunction reviveUndoPayloadDates<T>(payload: T | null, options: UndoDateRevivalOptions | undefined): T | null {\n if (payload === null || payload === undefined || !options) return payload\n let revived: unknown = payload\n if (options.dateFields && options.dateFields.length > 0) {\n revived = reviveDateFieldsDeep(revived, new Set(options.dateFields))\n }\n for (const path of options.datePaths ?? []) {\n const segments = path.split('.').filter((segment) => segment.length > 0)\n if (segments.length > 0) revived = reviveDatePath(revived, segments)\n }\n return revived as T\n}\n\n/**\n * Read the undo payload from a command log entry. Snapshots round-trip through `jsonb`,\n * so `Date` values come back as ISO strings; pass `dateFields` / `datePaths` to revive\n * them to `Date` (unparsable strings throw). Without options the raw payload is returned.\n */\nexport function extractUndoPayload<T>(\n logEntry: UndoLogEntry | null | undefined,\n options?: UndoDateRevivalOptions,\n): T | null {\n return reviveUndoPayloadDates(extractRawUndoPayload<T>(logEntry), options)\n}\n\nfunction extractRawUndoPayload<T>(logEntry: UndoLogEntry | null | undefined): T | null {\n if (!logEntry) return null\n const rawPayload = logEntry.commandPayload ?? logEntry.payload\n if (!rawPayload || typeof rawPayload !== 'object' || Array.isArray(rawPayload)) {\n return snapshotFallback(logEntry)\n }\n const payload = rawPayload as UndoEnvelope<T>\n if (payload.undo) return payload.undo\n if (payload.value && typeof payload.value === 'object' && payload.value.undo) {\n return payload.value.undo as T\n }\n for (const [key, value] of Object.entries(payload)) {\n if (key === '__redoInput') continue\n if (value && typeof value === 'object' && 'undo' in value) {\n const undo = (value as { undo?: T }).undo\n if (undo !== undefined) return undo ?? null\n }\n }\n return snapshotFallback(logEntry)\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,12 +1,69 @@
1
1
  import { getEnrichersForEntity } from "./enricher-registry.js";
2
2
  import { logEnricherTiming } from "../umes/enricher-timing.js";
3
3
  import { createLogger } from "../logger/index.js";
4
+ import { getTelemetryRuntime } from "../telemetry/runtime.js";
4
5
  import { authorizeFeatures } from "../../security/featurePolicy.js";
5
6
  const logger = createLogger("shared").child({ component: "umes" });
6
7
  const DEFAULT_TIMEOUT = 2e3;
7
8
  const SLOW_WARN_MS = 100;
8
9
  const SLOW_ERROR_MS = 500;
10
+ const LOG_THROTTLE_INTERVAL_MS = 3e4;
9
11
  const DEFAULT_CACHE_TTL_MS = 6e4;
12
+ const ENRICHER_DURATION_METRIC = "om.enricher.duration";
13
+ const slowLogStateByEnricher = /* @__PURE__ */ new Map();
14
+ const metricFailureLoggedAtByEnricher = /* @__PURE__ */ new Map();
15
+ function logMetricRecordingFailure(enricherId, err, failedAt) {
16
+ const lastLoggedAt = metricFailureLoggedAtByEnricher.get(enricherId);
17
+ if (lastLoggedAt !== void 0 && failedAt - lastLoggedAt < LOG_THROTTLE_INTERVAL_MS) return;
18
+ metricFailureLoggedAtByEnricher.set(enricherId, failedAt);
19
+ logger.warn("Enricher duration metric recording failed", {
20
+ enricherId,
21
+ metric: ENRICHER_DURATION_METRIC,
22
+ err
23
+ });
24
+ }
25
+ function recordEnricherDuration(enricherId, moduleId, targetEntity, elapsedMs, finishedAt) {
26
+ logEnricherTiming(enricherId, moduleId, targetEntity, elapsedMs);
27
+ try {
28
+ getTelemetryRuntime()?.recordHistogram?.(
29
+ ENRICHER_DURATION_METRIC,
30
+ elapsedMs / 1e3,
31
+ { "enricher.id": enricherId },
32
+ "s"
33
+ );
34
+ } catch (err) {
35
+ logMetricRecordingFailure(enricherId, err, finishedAt);
36
+ }
37
+ }
38
+ function logSlowEnricher(enricherId, elapsedMs, finishedAt) {
39
+ if (elapsedMs <= SLOW_WARN_MS) return;
40
+ if (elapsedMs <= SLOW_ERROR_MS) {
41
+ logger.debug("Enricher exceeded slow threshold", {
42
+ enricherId,
43
+ elapsedMs,
44
+ thresholdMs: SLOW_WARN_MS
45
+ });
46
+ return;
47
+ }
48
+ const state = slowLogStateByEnricher.get(enricherId);
49
+ if (state && finishedAt - state.lastLoggedAt < LOG_THROTTLE_INTERVAL_MS) {
50
+ state.suppressedCount += 1;
51
+ state.maxElapsedMs = Math.max(state.maxElapsedMs, elapsedMs);
52
+ return;
53
+ }
54
+ logger.error("Enricher exceeded slow threshold", {
55
+ enricherId,
56
+ elapsedMs,
57
+ thresholdMs: SLOW_ERROR_MS,
58
+ suppressedCount: state?.suppressedCount ?? 0,
59
+ maxElapsedMs: Math.max(state?.maxElapsedMs ?? 0, elapsedMs)
60
+ });
61
+ slowLogStateByEnricher.set(enricherId, {
62
+ lastLoggedAt: finishedAt,
63
+ suppressedCount: 0,
64
+ maxElapsedMs: 0
65
+ });
66
+ }
10
67
  function timeoutPromise(ms) {
11
68
  return new Promise(
12
69
  (_, reject) => setTimeout(() => reject(new Error(`Enricher timed out after ${ms}ms`)), ms)
@@ -193,13 +250,10 @@ async function applyResponseEnrichers(items, targetEntity, context, preFilteredE
193
250
  `Enricher ${enricher.id} must implement enrichMany() for list endpoints`
194
251
  );
195
252
  }
196
- const elapsedMs = Date.now() - startTime;
197
- if (elapsedMs > SLOW_ERROR_MS) {
198
- logger.error("Enricher exceeded slow threshold", { enricherId: enricher.id, elapsedMs, thresholdMs: SLOW_ERROR_MS });
199
- } else if (elapsedMs > SLOW_WARN_MS) {
200
- logger.warn("Enricher exceeded slow threshold", { enricherId: enricher.id, elapsedMs, thresholdMs: SLOW_WARN_MS });
201
- }
202
- logEnricherTiming(enricher.id, entry.moduleId, targetEntity, elapsedMs);
253
+ const finishedAt = Date.now();
254
+ const elapsedMs = finishedAt - startTime;
255
+ logSlowEnricher(enricher.id, elapsedMs, finishedAt);
256
+ recordEnricherDuration(enricher.id, entry.moduleId, targetEntity, elapsedMs, finishedAt);
203
257
  currentItems = result;
204
258
  if (shouldUseCache && cacheKey) {
205
259
  const envelope = buildCacheEnvelope(inputItems, result);
@@ -272,8 +326,9 @@ async function applyResponseEnricherToRecord(record, targetEntity, context, preF
272
326
  enricher.enrichOne(currentRecord, enricherContext),
273
327
  timeoutPromise(timeout)
274
328
  ]);
275
- const elapsedMs = Date.now() - startTime;
276
- logEnricherTiming(enricher.id, entry.moduleId, targetEntity, elapsedMs);
329
+ const finishedAt = Date.now();
330
+ const elapsedMs = finishedAt - startTime;
331
+ recordEnricherDuration(enricher.id, entry.moduleId, targetEntity, elapsedMs, finishedAt);
277
332
  currentRecord = result;
278
333
  if (shouldUseCache && cacheKey) {
279
334
  const envelope = buildCacheEnvelope([inputRecord], [result]);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/crud/enricher-runner.ts"],
4
- "sourcesContent": ["/**\n * Response Enricher Runner\n *\n * Executes response enrichers against API response payloads.\n * Handles timeout, fallback, ACL feature gating, and error isolation.\n */\n\nimport type {\n EnricherContext,\n EnricherRegistryEntry,\n EnrichmentResult,\n ResponseEnricher,\n SingleEnrichmentResult,\n} from './response-enricher'\nimport { getEnrichersForEntity } from './enricher-registry'\nimport { logEnricherTiming } from '../umes/enricher-timing'\nimport { createLogger } from '../logger'\nimport { authorizeFeatures } from '../../security/featurePolicy'\n\nconst logger = createLogger('shared').child({ component: 'umes' })\n\nconst DEFAULT_TIMEOUT = 2000\nconst SLOW_WARN_MS = 100\nconst SLOW_ERROR_MS = 500\nconst DEFAULT_CACHE_TTL_MS = 60_000\n\nfunction timeoutPromise(ms: number): Promise<never> {\n return new Promise((_, reject) =>\n setTimeout(() => reject(new Error(`Enricher timed out after ${ms}ms`)), ms),\n )\n}\n\nfunction hasRequiredFeatures(\n enricher: ResponseEnricher,\n userFeatures: string[] | undefined,\n): boolean {\n if (!enricher.features || enricher.features.length === 0) return true\n if (!userFeatures) return false\n return authorizeFeatures(enricher.features, { grantedFeatures: userFeatures })\n}\n\nfunction filterByACLAndTenant(\n entries: EnricherRegistryEntry[],\n context: EnricherContext,\n): EnricherRegistryEntry[] {\n return entries.filter((entry) => {\n const enricher = entry.enricher\n if (!hasRequiredFeatures(enricher, context.userFeatures)) return false\n if (enricher.disabledTenantIds?.includes(context.tenantId)) return false\n return true\n })\n}\n\nfunction getActiveEnrichers(\n targetEntity: string,\n context: EnricherContext,\n): EnricherRegistryEntry[] {\n const entries = getEnrichersForEntity(targetEntity)\n return filterByACLAndTenant(entries, context)\n}\n\n/**\n * Plan describing whether (and how) a CRUD list cache may embed enricher output.\n */\nexport type ListCacheEnricherPlan = {\n /**\n * Stable signature of the active, cache-embeddable enrichers in registry\n * (priority) order. Included in the CRUD list cache key so a cached enriched\n * payload is only ever served back to a request whose entitlements select the\n * exact same enricher set. Empty string when nothing is embeddable \u2014 keeping\n * the cache key identical to the pre-enricher shape for unaffected routes.\n */\n signature: string\n /**\n * True only when there is at least one active enricher for the context AND\n * every active enricher opted into `cacheableOnListHit`. When true, the\n * enriched list payload may be stored in the cache and served on a hit without\n * re-running enrichers. When false, enrichers MUST re-run on every request so\n * the response reflects live data (cross-module reads, wall-clock values, etc.)\n * and no live enrichment is embedded in the shared cache entry.\n */\n skipEnrichersOnCacheHit: boolean\n}\n\n/**\n * Resolve, for the given context, whether the CRUD list cache may embed enricher\n * output and the cache-key signature to partition by when it can.\n *\n * The enriched payload is only embeddable (and the cache hit allowed to skip\n * enrichment) when every active enricher is `cacheableOnListHit` \u2014 i.e. its\n * output is a pure function of the cached record and invalidated together with\n * it. If any active enricher reads data the list cache does not invalidate on,\n * the route falls back to caching the pre-enrichment payload and re-running\n * enrichers on every request.\n */\nexport function resolveListCacheEnricherPlan(\n targetEntity: string,\n context: EnricherContext,\n): ListCacheEnricherPlan {\n const active = getActiveEnrichers(targetEntity, context)\n if (active.length === 0) return { signature: '', skipEnrichersOnCacheHit: false }\n const allCacheable = active.every((entry) => entry.enricher.cacheableOnListHit === true)\n if (!allCacheable) return { signature: '', skipEnrichersOnCacheHit: false }\n return {\n signature: active.map((entry) => entry.enricher.id).join(','),\n skipEnrichersOnCacheHit: true,\n }\n}\n\ntype CacheLike = {\n get: (key: string) => Promise<unknown>\n set: (key: string, value: unknown, options?: { ttl?: number; tags?: string[] }) => Promise<unknown>\n}\n\nfunction resolveCache(context: EnricherContext): CacheLike | null {\n const container = context.container as { resolve?: (name: string) => unknown } | undefined\n if (!container?.resolve) return null\n try {\n const cache = container.resolve('cache') as CacheLike\n if (cache && typeof cache.get === 'function' && typeof cache.set === 'function') {\n return cache\n }\n } catch {\n // ignore cache resolution failures\n }\n try {\n const cacheService = container.resolve('cacheService') as CacheLike\n if (cacheService && typeof cacheService.get === 'function' && typeof cacheService.set === 'function') {\n return cacheService\n }\n } catch {\n // ignore cache service resolution failures\n }\n return null\n}\n\nfunction buildCacheKey(\n enricher: ResponseEnricher,\n context: EnricherContext,\n targetEntity: string,\n mode: 'one' | 'many',\n recordIds: string[],\n): string {\n const sortedIds = [...recordIds].sort((a, b) => a.localeCompare(b))\n return `umes:enricher:${enricher.id}:entity:${targetEntity}:tenant:${context.tenantId}:org:${context.organizationId}:mode:${mode}:ids:${JSON.stringify(sortedIds)}`\n}\n\nconst UNKNOWN_RECORD_ID = 'unknown'\n\nfunction extractRecordId(record: Record<string, unknown>): string {\n const idValue = record.id\n if (typeof idValue === 'string' && idValue.trim().length > 0) return idValue.trim()\n if (typeof idValue === 'number') return String(idValue)\n return UNKNOWN_RECORD_ID\n}\n\nfunction getEnricherCacheTtl(enricher: ResponseEnricher): number {\n const ttl = enricher.cache?.ttl\n if (typeof ttl === 'number' && Number.isFinite(ttl) && ttl > 0) {\n return ttl\n }\n return DEFAULT_CACHE_TTL_MS\n}\n\nfunction getEnricherCacheTags(enricher: ResponseEnricher, context: EnricherContext): string[] {\n const tags = new Set<string>([\n `tenant:${context.tenantId}`,\n `organization:${context.organizationId}`,\n `enricher:${enricher.id}`,\n ])\n for (const tag of enricher.cache?.tags ?? []) {\n if (!tag || tag.trim().length === 0) continue\n tags.add(tag)\n }\n return Array.from(tags)\n}\n\nasync function readEnricherCache<T>(\n cache: CacheLike | null,\n key: string,\n): Promise<T | null> {\n if (!cache) return null\n try {\n const value = await cache.get(key)\n return value == null ? null : (value as T)\n } catch {\n return null\n }\n}\n\n/**\n * The cache write was skipped because no safe envelope could be built. Logged\n * rather than swallowed: from outside the runner a silently-skipped write is\n * indistinguishable from a broken cache, and \"this enricher is not purely\n * additive\" is the answer an author needs to see.\n */\nfunction logSkippedCacheWrite(enricher: ResponseEnricher): void {\n logger.debug('Skipped enricher cache write \u2014 enrichment is not purely additive or lacks usable record ids', {\n enricherId: enricher.id,\n })\n}\n\nasync function writeEnricherCache(\n cache: CacheLike | null,\n key: string,\n value: unknown,\n ttl: number,\n tags: string[],\n): Promise<void> {\n if (!cache) return\n try {\n await cache.set(key, value, { ttl, tags })\n } catch {\n // ignore cache write failures\n }\n}\n\n/**\n * Cached read-through payload: the fields each enricher ADDED, keyed by record id.\n *\n * Caching whole records would replace the freshly-read record with the snapshot\n * taken at write time, so an edit to a base field (a product's name, an order's\n * status) would not surface until the entry expired, and a cached array would\n * also carry \u2014 and therefore overwrite \u2014 whatever the previous enricher in the\n * chain contributed. The additive delta is a pure function of the enricher, the\n * tenant/organization scope and the record ids, which is exactly what the cache\n * key already encodes, so it is the only part of the result that is safe to reuse.\n */\ntype EnricherCacheEnvelope = {\n version: 1\n deltas: Record<string, Record<string, unknown>>\n}\n\nconst ENRICHER_CACHE_VERSION = 1\n\nfunction isEnricherCacheEnvelope(value: unknown): value is EnricherCacheEnvelope {\n if (typeof value !== 'object' || value === null) return false\n const candidate = value as { version?: unknown; deltas?: unknown }\n if (candidate.version !== ENRICHER_CACHE_VERSION) return false\n return typeof candidate.deltas === 'object' && candidate.deltas !== null\n}\n\n/**\n * The keys an enricher added to a record, or `null` when the enrichment was not\n * purely additive \u2014 it changed or dropped a key that was already there. A\n * non-additive enricher is never cached: replaying only its added keys onto a\n * later record would silently lose the change it made to the existing ones.\n *\n * Comparison is by identity at the top level only, so an enricher that mutates a\n * nested object in place is indistinguishable from one that left the record\n * alone: its nested change is absent from the delta and therefore lost on a\n * later hit. That fails safe \u2014 the served record is under-enriched, never\n * stale-wrong \u2014 and a deep clone of every record on every enriched response is\n * not worth paying for the case.\n */\nfunction computeAdditiveDelta(\n input: Record<string, unknown>,\n output: Record<string, unknown>,\n): Record<string, unknown> | null {\n const delta: Record<string, unknown> = {}\n for (const key of Object.keys(input)) {\n if (!Object.prototype.hasOwnProperty.call(output, key)) return null\n if (output[key] !== input[key]) return null\n }\n for (const key of Object.keys(output)) {\n if (Object.prototype.hasOwnProperty.call(input, key)) continue\n delta[key] = output[key]\n }\n return delta\n}\n\n/**\n * Build the cacheable envelope for a batch, or `null` when it cannot be built\n * safely \u2014 an unusable record id, a duplicate id (the deltas would collide), or\n * a non-additive enrichment. Every failure mode skips the cache write and leaves\n * the enricher running on every request, which is the pre-cache behavior.\n */\nfunction buildCacheEnvelope<T extends Record<string, unknown>>(\n inputs: T[],\n outputs: T[],\n): EnricherCacheEnvelope | null {\n if (inputs.length !== outputs.length) return null\n const deltas: Record<string, Record<string, unknown>> = {}\n for (let index = 0; index < inputs.length; index += 1) {\n const recordId = extractRecordId(inputs[index])\n if (recordId === UNKNOWN_RECORD_ID) return null\n if (Object.prototype.hasOwnProperty.call(deltas, recordId)) return null\n const delta = computeAdditiveDelta(inputs[index], outputs[index])\n if (!delta) return null\n deltas[recordId] = delta\n }\n return { version: ENRICHER_CACHE_VERSION, deltas }\n}\n\n/**\n * Merge a cached envelope onto freshly-read records. Returns `null` \u2014 a miss \u2014\n * when the envelope does not cover every record, so a partially-cached batch\n * re-runs the enricher rather than returning some records unenriched.\n */\nfunction applyCacheEnvelope<T extends Record<string, unknown>>(\n envelope: EnricherCacheEnvelope,\n records: T[],\n): T[] | null {\n const merged: T[] = []\n for (const record of records) {\n const recordId = extractRecordId(record)\n if (recordId === UNKNOWN_RECORD_ID) return null\n const delta = envelope.deltas[recordId]\n if (!delta || typeof delta !== 'object') return null\n merged.push({ ...record, ...delta } as T)\n }\n return merged\n}\n\n/**\n * Apply response enrichers to a list of records.\n *\n * Runs AFTER CrudHooks.afterList, BEFORE HTTP response serialization.\n * Each enricher runs independently \u2014 a failed non-critical enricher is skipped.\n */\nexport async function applyResponseEnrichers<T extends Record<string, unknown>>(\n items: T[],\n targetEntity: string,\n context: EnricherContext,\n preFilteredEntries?: EnricherRegistryEntry[],\n): Promise<EnrichmentResult<T>> {\n const enricherContext: EnricherContext = { ...context, targetEntity }\n const activeEntries = preFilteredEntries\n ? filterByACLAndTenant(preFilteredEntries, context)\n : getActiveEnrichers(targetEntity, context)\n\n if (activeEntries.length === 0) {\n return { items, _meta: { enrichedBy: [] } }\n }\n\n const enrichedBy: string[] = []\n const enricherErrors: string[] = []\n let currentItems = items\n const cache = resolveCache(context)\n\n for (const entry of activeEntries) {\n const enricher = entry.enricher\n const timeout = enricher.timeout ?? DEFAULT_TIMEOUT\n const startTime = Date.now()\n\n try {\n let result: T[]\n const recordIds = currentItems.map((item) => extractRecordId(item))\n const shouldUseCache = enricher.cache?.strategy === 'read-through'\n const cacheKey = shouldUseCache\n ? buildCacheKey(enricher, context, targetEntity, 'many', recordIds)\n : null\n // Snapshot BEFORE enrichment: the contract does not forbid an enricher\n // from mutating the records it was handed, and comparing a mutated record\n // against itself would yield an empty delta \u2014 caching \"this enricher adds\n // nothing\" and serving unenriched records for the rest of the TTL.\n const inputItems = shouldUseCache ? currentItems.map((item) => ({ ...item })) : currentItems\n if (shouldUseCache && cacheKey) {\n const cached = await readEnricherCache<unknown>(cache, cacheKey)\n if (isEnricherCacheEnvelope(cached)) {\n const merged = applyCacheEnvelope(cached, currentItems)\n if (merged) {\n currentItems = merged\n enrichedBy.push(enricher.id)\n continue\n }\n }\n }\n\n if (enricher.enrichMany) {\n result = await Promise.race([\n enricher.enrichMany(currentItems, enricherContext) as Promise<T[]>,\n timeoutPromise(timeout),\n ])\n } else {\n throw new Error(\n `Enricher ${enricher.id} must implement enrichMany() for list endpoints`,\n )\n }\n\n const elapsedMs = Date.now() - startTime\n if (elapsedMs > SLOW_ERROR_MS) {\n logger.error('Enricher exceeded slow threshold', { enricherId: enricher.id, elapsedMs, thresholdMs: SLOW_ERROR_MS })\n } else if (elapsedMs > SLOW_WARN_MS) {\n logger.warn('Enricher exceeded slow threshold', { enricherId: enricher.id, elapsedMs, thresholdMs: SLOW_WARN_MS })\n }\n logEnricherTiming(enricher.id, entry.moduleId, targetEntity, elapsedMs)\n\n currentItems = result\n if (shouldUseCache && cacheKey) {\n const envelope = buildCacheEnvelope(inputItems, result)\n if (envelope) {\n await writeEnricherCache(\n cache,\n cacheKey,\n envelope,\n getEnricherCacheTtl(enricher),\n getEnricherCacheTags(enricher, context),\n )\n } else {\n logSkippedCacheWrite(enricher)\n }\n }\n enrichedBy.push(enricher.id)\n } catch (err) {\n if (enricher.critical) {\n throw err\n }\n\n logger.warn('Enricher failed', { enricherId: enricher.id, err })\n enricherErrors.push(enricher.id)\n\n if (enricher.fallback) {\n currentItems = currentItems.map((item) => ({\n ...item,\n ...enricher.fallback,\n })) as T[]\n }\n }\n }\n\n return {\n items: currentItems,\n _meta: {\n enrichedBy,\n ...(enricherErrors.length > 0 ? { enricherErrors } : {}),\n },\n }\n}\n\n/**\n * Apply response enrichers to a single record.\n *\n * Used for detail endpoints (GET /:id), POST, and PUT responses.\n */\nexport async function applyResponseEnricherToRecord<T extends Record<string, unknown>>(\n record: T,\n targetEntity: string,\n context: EnricherContext,\n preFilteredEntries?: EnricherRegistryEntry[],\n): Promise<SingleEnrichmentResult<T>> {\n const enricherContext: EnricherContext = { ...context, targetEntity }\n const activeEntries = preFilteredEntries\n ? filterByACLAndTenant(preFilteredEntries, context)\n : getActiveEnrichers(targetEntity, context)\n\n if (activeEntries.length === 0) {\n return { record, _meta: { enrichedBy: [] } }\n }\n\n const enrichedBy: string[] = []\n const enricherErrors: string[] = []\n let currentRecord = record\n const cache = resolveCache(context)\n\n for (const entry of activeEntries) {\n const enricher = entry.enricher\n const timeout = enricher.timeout ?? DEFAULT_TIMEOUT\n const startTime = Date.now()\n\n try {\n const recordId = extractRecordId(currentRecord)\n const shouldUseCache = enricher.cache?.strategy === 'read-through'\n const cacheKey = shouldUseCache\n ? buildCacheKey(enricher, context, targetEntity, 'one', [recordId])\n : null\n // Snapshot before enrichment \u2014 see the list path for why.\n const inputRecord = shouldUseCache ? ({ ...currentRecord } as T) : currentRecord\n if (shouldUseCache && cacheKey) {\n const cached = await readEnricherCache<unknown>(cache, cacheKey)\n if (isEnricherCacheEnvelope(cached)) {\n const merged = applyCacheEnvelope(cached, [currentRecord])\n if (merged) {\n currentRecord = merged[0]\n enrichedBy.push(enricher.id)\n continue\n }\n }\n }\n const result = await Promise.race([\n enricher.enrichOne(currentRecord, enricherContext) as Promise<T>,\n timeoutPromise(timeout),\n ])\n\n const elapsedMs = Date.now() - startTime\n logEnricherTiming(enricher.id, entry.moduleId, targetEntity, elapsedMs)\n\n currentRecord = result\n if (shouldUseCache && cacheKey) {\n const envelope = buildCacheEnvelope([inputRecord], [result])\n if (envelope) {\n await writeEnricherCache(\n cache,\n cacheKey,\n envelope,\n getEnricherCacheTtl(enricher),\n getEnricherCacheTags(enricher, context),\n )\n } else {\n logSkippedCacheWrite(enricher)\n }\n }\n enrichedBy.push(enricher.id)\n } catch (err) {\n if (enricher.critical) {\n throw err\n }\n\n logger.warn('Enricher failed', { enricherId: enricher.id, err })\n enricherErrors.push(enricher.id)\n\n if (enricher.fallback) {\n currentRecord = { ...currentRecord, ...enricher.fallback } as T\n }\n }\n }\n\n return {\n record: currentRecord,\n _meta: {\n enrichedBy,\n ...(enricherErrors.length > 0 ? { enricherErrors } : {}),\n },\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Response Enricher Runner\n *\n * Executes response enrichers against API response payloads.\n * Handles timeout, fallback, ACL feature gating, and error isolation.\n */\n\nimport type {\n EnricherContext,\n EnricherRegistryEntry,\n EnrichmentResult,\n ResponseEnricher,\n SingleEnrichmentResult,\n} from './response-enricher'\nimport { getEnrichersForEntity } from './enricher-registry'\nimport { logEnricherTiming } from '../umes/enricher-timing'\nimport { createLogger } from '../logger'\nimport { getTelemetryRuntime } from '../telemetry/runtime'\nimport { authorizeFeatures } from '../../security/featurePolicy'\n\nconst logger = createLogger('shared').child({ component: 'umes' })\n\nconst DEFAULT_TIMEOUT = 2000\nconst SLOW_WARN_MS = 100\nconst SLOW_ERROR_MS = 500\nconst LOG_THROTTLE_INTERVAL_MS = 30_000\nconst DEFAULT_CACHE_TTL_MS = 60_000\nconst ENRICHER_DURATION_METRIC = 'om.enricher.duration'\n\ntype SlowLogState = {\n lastLoggedAt: number\n suppressedCount: number\n maxElapsedMs: number\n}\n\nconst slowLogStateByEnricher = new Map<string, SlowLogState>()\nconst metricFailureLoggedAtByEnricher = new Map<string, number>()\n\nfunction logMetricRecordingFailure(enricherId: string, err: unknown, failedAt: number): void {\n const lastLoggedAt = metricFailureLoggedAtByEnricher.get(enricherId)\n if (lastLoggedAt !== undefined && failedAt - lastLoggedAt < LOG_THROTTLE_INTERVAL_MS) return\n metricFailureLoggedAtByEnricher.set(enricherId, failedAt)\n logger.warn('Enricher duration metric recording failed', {\n enricherId,\n metric: ENRICHER_DURATION_METRIC,\n err,\n })\n}\n\nfunction recordEnricherDuration(\n enricherId: string,\n moduleId: string,\n targetEntity: string,\n elapsedMs: number,\n finishedAt: number,\n): void {\n logEnricherTiming(enricherId, moduleId, targetEntity, elapsedMs)\n try {\n getTelemetryRuntime()?.recordHistogram?.(\n ENRICHER_DURATION_METRIC,\n elapsedMs / 1000,\n { 'enricher.id': enricherId },\n 's',\n )\n } catch (err) {\n logMetricRecordingFailure(enricherId, err, finishedAt)\n }\n}\n\nfunction logSlowEnricher(enricherId: string, elapsedMs: number, finishedAt: number): void {\n if (elapsedMs <= SLOW_WARN_MS) return\n\n if (elapsedMs <= SLOW_ERROR_MS) {\n logger.debug('Enricher exceeded slow threshold', {\n enricherId,\n elapsedMs,\n thresholdMs: SLOW_WARN_MS,\n })\n return\n }\n\n const state = slowLogStateByEnricher.get(enricherId)\n if (state && finishedAt - state.lastLoggedAt < LOG_THROTTLE_INTERVAL_MS) {\n state.suppressedCount += 1\n state.maxElapsedMs = Math.max(state.maxElapsedMs, elapsedMs)\n return\n }\n\n logger.error('Enricher exceeded slow threshold', {\n enricherId,\n elapsedMs,\n thresholdMs: SLOW_ERROR_MS,\n suppressedCount: state?.suppressedCount ?? 0,\n maxElapsedMs: Math.max(state?.maxElapsedMs ?? 0, elapsedMs),\n })\n slowLogStateByEnricher.set(enricherId, {\n lastLoggedAt: finishedAt,\n suppressedCount: 0,\n maxElapsedMs: 0,\n })\n}\n\nfunction timeoutPromise(ms: number): Promise<never> {\n return new Promise((_, reject) =>\n setTimeout(() => reject(new Error(`Enricher timed out after ${ms}ms`)), ms),\n )\n}\n\nfunction hasRequiredFeatures(\n enricher: ResponseEnricher,\n userFeatures: string[] | undefined,\n): boolean {\n if (!enricher.features || enricher.features.length === 0) return true\n if (!userFeatures) return false\n return authorizeFeatures(enricher.features, { grantedFeatures: userFeatures })\n}\n\nfunction filterByACLAndTenant(\n entries: EnricherRegistryEntry[],\n context: EnricherContext,\n): EnricherRegistryEntry[] {\n return entries.filter((entry) => {\n const enricher = entry.enricher\n if (!hasRequiredFeatures(enricher, context.userFeatures)) return false\n if (enricher.disabledTenantIds?.includes(context.tenantId)) return false\n return true\n })\n}\n\nfunction getActiveEnrichers(\n targetEntity: string,\n context: EnricherContext,\n): EnricherRegistryEntry[] {\n const entries = getEnrichersForEntity(targetEntity)\n return filterByACLAndTenant(entries, context)\n}\n\n/**\n * Plan describing whether (and how) a CRUD list cache may embed enricher output.\n */\nexport type ListCacheEnricherPlan = {\n /**\n * Stable signature of the active, cache-embeddable enrichers in registry\n * (priority) order. Included in the CRUD list cache key so a cached enriched\n * payload is only ever served back to a request whose entitlements select the\n * exact same enricher set. Empty string when nothing is embeddable \u2014 keeping\n * the cache key identical to the pre-enricher shape for unaffected routes.\n */\n signature: string\n /**\n * True only when there is at least one active enricher for the context AND\n * every active enricher opted into `cacheableOnListHit`. When true, the\n * enriched list payload may be stored in the cache and served on a hit without\n * re-running enrichers. When false, enrichers MUST re-run on every request so\n * the response reflects live data (cross-module reads, wall-clock values, etc.)\n * and no live enrichment is embedded in the shared cache entry.\n */\n skipEnrichersOnCacheHit: boolean\n}\n\n/**\n * Resolve, for the given context, whether the CRUD list cache may embed enricher\n * output and the cache-key signature to partition by when it can.\n *\n * The enriched payload is only embeddable (and the cache hit allowed to skip\n * enrichment) when every active enricher is `cacheableOnListHit` \u2014 i.e. its\n * output is a pure function of the cached record and invalidated together with\n * it. If any active enricher reads data the list cache does not invalidate on,\n * the route falls back to caching the pre-enrichment payload and re-running\n * enrichers on every request.\n */\nexport function resolveListCacheEnricherPlan(\n targetEntity: string,\n context: EnricherContext,\n): ListCacheEnricherPlan {\n const active = getActiveEnrichers(targetEntity, context)\n if (active.length === 0) return { signature: '', skipEnrichersOnCacheHit: false }\n const allCacheable = active.every((entry) => entry.enricher.cacheableOnListHit === true)\n if (!allCacheable) return { signature: '', skipEnrichersOnCacheHit: false }\n return {\n signature: active.map((entry) => entry.enricher.id).join(','),\n skipEnrichersOnCacheHit: true,\n }\n}\n\ntype CacheLike = {\n get: (key: string) => Promise<unknown>\n set: (key: string, value: unknown, options?: { ttl?: number; tags?: string[] }) => Promise<unknown>\n}\n\nfunction resolveCache(context: EnricherContext): CacheLike | null {\n const container = context.container as { resolve?: (name: string) => unknown } | undefined\n if (!container?.resolve) return null\n try {\n const cache = container.resolve('cache') as CacheLike\n if (cache && typeof cache.get === 'function' && typeof cache.set === 'function') {\n return cache\n }\n } catch {\n // ignore cache resolution failures\n }\n try {\n const cacheService = container.resolve('cacheService') as CacheLike\n if (cacheService && typeof cacheService.get === 'function' && typeof cacheService.set === 'function') {\n return cacheService\n }\n } catch {\n // ignore cache service resolution failures\n }\n return null\n}\n\nfunction buildCacheKey(\n enricher: ResponseEnricher,\n context: EnricherContext,\n targetEntity: string,\n mode: 'one' | 'many',\n recordIds: string[],\n): string {\n const sortedIds = [...recordIds].sort((a, b) => a.localeCompare(b))\n return `umes:enricher:${enricher.id}:entity:${targetEntity}:tenant:${context.tenantId}:org:${context.organizationId}:mode:${mode}:ids:${JSON.stringify(sortedIds)}`\n}\n\nconst UNKNOWN_RECORD_ID = 'unknown'\n\nfunction extractRecordId(record: Record<string, unknown>): string {\n const idValue = record.id\n if (typeof idValue === 'string' && idValue.trim().length > 0) return idValue.trim()\n if (typeof idValue === 'number') return String(idValue)\n return UNKNOWN_RECORD_ID\n}\n\nfunction getEnricherCacheTtl(enricher: ResponseEnricher): number {\n const ttl = enricher.cache?.ttl\n if (typeof ttl === 'number' && Number.isFinite(ttl) && ttl > 0) {\n return ttl\n }\n return DEFAULT_CACHE_TTL_MS\n}\n\nfunction getEnricherCacheTags(enricher: ResponseEnricher, context: EnricherContext): string[] {\n const tags = new Set<string>([\n `tenant:${context.tenantId}`,\n `organization:${context.organizationId}`,\n `enricher:${enricher.id}`,\n ])\n for (const tag of enricher.cache?.tags ?? []) {\n if (!tag || tag.trim().length === 0) continue\n tags.add(tag)\n }\n return Array.from(tags)\n}\n\nasync function readEnricherCache<T>(\n cache: CacheLike | null,\n key: string,\n): Promise<T | null> {\n if (!cache) return null\n try {\n const value = await cache.get(key)\n return value == null ? null : (value as T)\n } catch {\n return null\n }\n}\n\n/**\n * The cache write was skipped because no safe envelope could be built. Logged\n * rather than swallowed: from outside the runner a silently-skipped write is\n * indistinguishable from a broken cache, and \"this enricher is not purely\n * additive\" is the answer an author needs to see.\n */\nfunction logSkippedCacheWrite(enricher: ResponseEnricher): void {\n logger.debug('Skipped enricher cache write \u2014 enrichment is not purely additive or lacks usable record ids', {\n enricherId: enricher.id,\n })\n}\n\nasync function writeEnricherCache(\n cache: CacheLike | null,\n key: string,\n value: unknown,\n ttl: number,\n tags: string[],\n): Promise<void> {\n if (!cache) return\n try {\n await cache.set(key, value, { ttl, tags })\n } catch {\n // ignore cache write failures\n }\n}\n\n/**\n * Cached read-through payload: the fields each enricher ADDED, keyed by record id.\n *\n * Caching whole records would replace the freshly-read record with the snapshot\n * taken at write time, so an edit to a base field (a product's name, an order's\n * status) would not surface until the entry expired, and a cached array would\n * also carry \u2014 and therefore overwrite \u2014 whatever the previous enricher in the\n * chain contributed. The additive delta is a pure function of the enricher, the\n * tenant/organization scope and the record ids, which is exactly what the cache\n * key already encodes, so it is the only part of the result that is safe to reuse.\n */\ntype EnricherCacheEnvelope = {\n version: 1\n deltas: Record<string, Record<string, unknown>>\n}\n\nconst ENRICHER_CACHE_VERSION = 1\n\nfunction isEnricherCacheEnvelope(value: unknown): value is EnricherCacheEnvelope {\n if (typeof value !== 'object' || value === null) return false\n const candidate = value as { version?: unknown; deltas?: unknown }\n if (candidate.version !== ENRICHER_CACHE_VERSION) return false\n return typeof candidate.deltas === 'object' && candidate.deltas !== null\n}\n\n/**\n * The keys an enricher added to a record, or `null` when the enrichment was not\n * purely additive \u2014 it changed or dropped a key that was already there. A\n * non-additive enricher is never cached: replaying only its added keys onto a\n * later record would silently lose the change it made to the existing ones.\n *\n * Comparison is by identity at the top level only, so an enricher that mutates a\n * nested object in place is indistinguishable from one that left the record\n * alone: its nested change is absent from the delta and therefore lost on a\n * later hit. That fails safe \u2014 the served record is under-enriched, never\n * stale-wrong \u2014 and a deep clone of every record on every enriched response is\n * not worth paying for the case.\n */\nfunction computeAdditiveDelta(\n input: Record<string, unknown>,\n output: Record<string, unknown>,\n): Record<string, unknown> | null {\n const delta: Record<string, unknown> = {}\n for (const key of Object.keys(input)) {\n if (!Object.prototype.hasOwnProperty.call(output, key)) return null\n if (output[key] !== input[key]) return null\n }\n for (const key of Object.keys(output)) {\n if (Object.prototype.hasOwnProperty.call(input, key)) continue\n delta[key] = output[key]\n }\n return delta\n}\n\n/**\n * Build the cacheable envelope for a batch, or `null` when it cannot be built\n * safely \u2014 an unusable record id, a duplicate id (the deltas would collide), or\n * a non-additive enrichment. Every failure mode skips the cache write and leaves\n * the enricher running on every request, which is the pre-cache behavior.\n */\nfunction buildCacheEnvelope<T extends Record<string, unknown>>(\n inputs: T[],\n outputs: T[],\n): EnricherCacheEnvelope | null {\n if (inputs.length !== outputs.length) return null\n const deltas: Record<string, Record<string, unknown>> = {}\n for (let index = 0; index < inputs.length; index += 1) {\n const recordId = extractRecordId(inputs[index])\n if (recordId === UNKNOWN_RECORD_ID) return null\n if (Object.prototype.hasOwnProperty.call(deltas, recordId)) return null\n const delta = computeAdditiveDelta(inputs[index], outputs[index])\n if (!delta) return null\n deltas[recordId] = delta\n }\n return { version: ENRICHER_CACHE_VERSION, deltas }\n}\n\n/**\n * Merge a cached envelope onto freshly-read records. Returns `null` \u2014 a miss \u2014\n * when the envelope does not cover every record, so a partially-cached batch\n * re-runs the enricher rather than returning some records unenriched.\n */\nfunction applyCacheEnvelope<T extends Record<string, unknown>>(\n envelope: EnricherCacheEnvelope,\n records: T[],\n): T[] | null {\n const merged: T[] = []\n for (const record of records) {\n const recordId = extractRecordId(record)\n if (recordId === UNKNOWN_RECORD_ID) return null\n const delta = envelope.deltas[recordId]\n if (!delta || typeof delta !== 'object') return null\n merged.push({ ...record, ...delta } as T)\n }\n return merged\n}\n\n/**\n * Apply response enrichers to a list of records.\n *\n * Runs AFTER CrudHooks.afterList, BEFORE HTTP response serialization.\n * Each enricher runs independently \u2014 a failed non-critical enricher is skipped.\n */\nexport async function applyResponseEnrichers<T extends Record<string, unknown>>(\n items: T[],\n targetEntity: string,\n context: EnricherContext,\n preFilteredEntries?: EnricherRegistryEntry[],\n): Promise<EnrichmentResult<T>> {\n const enricherContext: EnricherContext = { ...context, targetEntity }\n const activeEntries = preFilteredEntries\n ? filterByACLAndTenant(preFilteredEntries, context)\n : getActiveEnrichers(targetEntity, context)\n\n if (activeEntries.length === 0) {\n return { items, _meta: { enrichedBy: [] } }\n }\n\n const enrichedBy: string[] = []\n const enricherErrors: string[] = []\n let currentItems = items\n const cache = resolveCache(context)\n\n for (const entry of activeEntries) {\n const enricher = entry.enricher\n const timeout = enricher.timeout ?? DEFAULT_TIMEOUT\n const startTime = Date.now()\n\n try {\n let result: T[]\n const recordIds = currentItems.map((item) => extractRecordId(item))\n const shouldUseCache = enricher.cache?.strategy === 'read-through'\n const cacheKey = shouldUseCache\n ? buildCacheKey(enricher, context, targetEntity, 'many', recordIds)\n : null\n // Snapshot BEFORE enrichment: the contract does not forbid an enricher\n // from mutating the records it was handed, and comparing a mutated record\n // against itself would yield an empty delta \u2014 caching \"this enricher adds\n // nothing\" and serving unenriched records for the rest of the TTL.\n const inputItems = shouldUseCache ? currentItems.map((item) => ({ ...item })) : currentItems\n if (shouldUseCache && cacheKey) {\n const cached = await readEnricherCache<unknown>(cache, cacheKey)\n if (isEnricherCacheEnvelope(cached)) {\n const merged = applyCacheEnvelope(cached, currentItems)\n if (merged) {\n currentItems = merged\n enrichedBy.push(enricher.id)\n continue\n }\n }\n }\n\n if (enricher.enrichMany) {\n result = await Promise.race([\n enricher.enrichMany(currentItems, enricherContext) as Promise<T[]>,\n timeoutPromise(timeout),\n ])\n } else {\n throw new Error(\n `Enricher ${enricher.id} must implement enrichMany() for list endpoints`,\n )\n }\n\n const finishedAt = Date.now()\n const elapsedMs = finishedAt - startTime\n logSlowEnricher(enricher.id, elapsedMs, finishedAt)\n recordEnricherDuration(enricher.id, entry.moduleId, targetEntity, elapsedMs, finishedAt)\n\n currentItems = result\n if (shouldUseCache && cacheKey) {\n const envelope = buildCacheEnvelope(inputItems, result)\n if (envelope) {\n await writeEnricherCache(\n cache,\n cacheKey,\n envelope,\n getEnricherCacheTtl(enricher),\n getEnricherCacheTags(enricher, context),\n )\n } else {\n logSkippedCacheWrite(enricher)\n }\n }\n enrichedBy.push(enricher.id)\n } catch (err) {\n if (enricher.critical) {\n throw err\n }\n\n logger.warn('Enricher failed', { enricherId: enricher.id, err })\n enricherErrors.push(enricher.id)\n\n if (enricher.fallback) {\n currentItems = currentItems.map((item) => ({\n ...item,\n ...enricher.fallback,\n })) as T[]\n }\n }\n }\n\n return {\n items: currentItems,\n _meta: {\n enrichedBy,\n ...(enricherErrors.length > 0 ? { enricherErrors } : {}),\n },\n }\n}\n\n/**\n * Apply response enrichers to a single record.\n *\n * Used for detail endpoints (GET /:id), POST, and PUT responses.\n */\nexport async function applyResponseEnricherToRecord<T extends Record<string, unknown>>(\n record: T,\n targetEntity: string,\n context: EnricherContext,\n preFilteredEntries?: EnricherRegistryEntry[],\n): Promise<SingleEnrichmentResult<T>> {\n const enricherContext: EnricherContext = { ...context, targetEntity }\n const activeEntries = preFilteredEntries\n ? filterByACLAndTenant(preFilteredEntries, context)\n : getActiveEnrichers(targetEntity, context)\n\n if (activeEntries.length === 0) {\n return { record, _meta: { enrichedBy: [] } }\n }\n\n const enrichedBy: string[] = []\n const enricherErrors: string[] = []\n let currentRecord = record\n const cache = resolveCache(context)\n\n for (const entry of activeEntries) {\n const enricher = entry.enricher\n const timeout = enricher.timeout ?? DEFAULT_TIMEOUT\n const startTime = Date.now()\n\n try {\n const recordId = extractRecordId(currentRecord)\n const shouldUseCache = enricher.cache?.strategy === 'read-through'\n const cacheKey = shouldUseCache\n ? buildCacheKey(enricher, context, targetEntity, 'one', [recordId])\n : null\n // Snapshot before enrichment \u2014 see the list path for why.\n const inputRecord = shouldUseCache ? ({ ...currentRecord } as T) : currentRecord\n if (shouldUseCache && cacheKey) {\n const cached = await readEnricherCache<unknown>(cache, cacheKey)\n if (isEnricherCacheEnvelope(cached)) {\n const merged = applyCacheEnvelope(cached, [currentRecord])\n if (merged) {\n currentRecord = merged[0]\n enrichedBy.push(enricher.id)\n continue\n }\n }\n }\n const result = await Promise.race([\n enricher.enrichOne(currentRecord, enricherContext) as Promise<T>,\n timeoutPromise(timeout),\n ])\n\n const finishedAt = Date.now()\n const elapsedMs = finishedAt - startTime\n recordEnricherDuration(enricher.id, entry.moduleId, targetEntity, elapsedMs, finishedAt)\n\n currentRecord = result\n if (shouldUseCache && cacheKey) {\n const envelope = buildCacheEnvelope([inputRecord], [result])\n if (envelope) {\n await writeEnricherCache(\n cache,\n cacheKey,\n envelope,\n getEnricherCacheTtl(enricher),\n getEnricherCacheTags(enricher, context),\n )\n } else {\n logSkippedCacheWrite(enricher)\n }\n }\n enrichedBy.push(enricher.id)\n } catch (err) {\n if (enricher.critical) {\n throw err\n }\n\n logger.warn('Enricher failed', { enricherId: enricher.id, err })\n enricherErrors.push(enricher.id)\n\n if (enricher.fallback) {\n currentRecord = { ...currentRecord, ...enricher.fallback } as T\n }\n }\n }\n\n return {\n record: currentRecord,\n _meta: {\n enrichedBy,\n ...(enricherErrors.length > 0 ? { enricherErrors } : {}),\n },\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -48,6 +48,7 @@ import { createProfiler, shouldEnableProfiler } from "@open-mercato/shared/lib/p
48
48
  import { getTranslationOverlayPlugin } from "@open-mercato/shared/lib/localization/overlay-plugin";
49
49
  import { applyResponseEnrichers, applyResponseEnricherToRecord, resolveListCacheEnricherPlan } from "./enricher-runner.js";
50
50
  import { runApiInterceptorsAfter, runApiInterceptorsBefore } from "./interceptor-runner.js";
51
+ import { resolveApiInterceptorRoutePath } from "../modules/api-route-identity.js";
51
52
  import { mergeIdFilter, parseIdsParam, isIdsParamProvided } from "./ids.js";
52
53
  import { buildQueryParams } from "./query-params.js";
53
54
  import { mergeAdvancedFilters } from "./advanced-filter-integration.js";
@@ -422,16 +423,6 @@ function snapshotEntity(entity) {
422
423
  if (!entity || typeof entity !== "object") return void 0;
423
424
  return safeClone(entity);
424
425
  }
425
- function normalizeInterceptorRoutePath(request) {
426
- try {
427
- const pathname = new URL(request.url).pathname;
428
- if (pathname.startsWith("/api/")) return pathname.slice(5);
429
- if (pathname === "/api") return "";
430
- return pathname.replace(/^\/+/, "");
431
- } catch {
432
- return "";
433
- }
434
- }
435
426
  function toInterceptorHeaders(headers) {
436
427
  const output = {};
437
428
  headers.forEach((value, key) => {
@@ -604,7 +595,7 @@ function safeClone(value) {
604
595
  }
605
596
  }
606
597
  function collectScopeOrganizationIds(ctx) {
607
- if (Array.isArray(ctx.organizationIds) && ctx.organizationIds.length > 0) {
598
+ if (Array.isArray(ctx.organizationIds)) {
608
599
  return Array.from(new Set(ctx.organizationIds));
609
600
  }
610
601
  const fallback = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null;
@@ -625,7 +616,7 @@ function serializeSearchParams(params) {
625
616
  function buildCrudCacheKey(resource, request, ctx, enricherSignature = "") {
626
617
  const url = new URL(request.url);
627
618
  const scopeIds = collectScopeOrganizationIds(ctx);
628
- const scopeSegment = scopeIds.length ? scopeIds.map((id) => normalizeTagSegment(id)).sort((a, b) => a.localeCompare(b)).join(",") : "none";
619
+ const scopeSegment = scopeIds.length ? scopeIds.map((id) => normalizeTagSegment(id)).sort((a, b) => a.localeCompare(b)).join(",") : "empty";
629
620
  const segments = [
630
621
  "crud",
631
622
  normalizeTagSegment(resource),
@@ -891,12 +882,20 @@ function makeCrudRoute(opts) {
891
882
  if (!interceptorContext) {
892
883
  return { errorResponse: null, requestPayload, metadataByInterceptor: {} };
893
884
  }
885
+ const routePath = resolveApiInterceptorRoutePath(args.request);
886
+ if (routePath === null) {
887
+ return {
888
+ errorResponse: json({ error: "Bad request" }, { status: 400 }),
889
+ requestPayload,
890
+ metadataByInterceptor: {}
891
+ };
892
+ }
894
893
  const contextWithHeaders = {
895
894
  ...interceptorContext,
896
895
  extensionHeaders: parseExtensionHeaders(requestPayload.headers)
897
896
  };
898
897
  const result = await runApiInterceptorsBefore({
899
- routePath: normalizeInterceptorRoutePath(args.request),
898
+ routePath,
900
899
  method: args.method,
901
900
  request: requestPayload,
902
901
  context: contextWithHeaders
@@ -909,8 +908,12 @@ function makeCrudRoute(opts) {
909
908
  async function applyInterceptorsAfter(args) {
910
909
  const interceptorContext = await buildInterceptorContext(args.ctx);
911
910
  if (!interceptorContext) return { ok: true, statusCode: args.statusCode, body: args.body, headers: args.headers ?? {} };
911
+ const routePath = resolveApiInterceptorRoutePath(args.request);
912
+ if (routePath === null) {
913
+ return { ok: false, statusCode: 400, body: { error: "Bad request" }, headers: {} };
914
+ }
912
915
  const result = await runApiInterceptorsAfter({
913
- routePath: normalizeInterceptorRoutePath(args.request),
916
+ routePath,
914
917
  method: args.method,
915
918
  request: args.requestPayload,
916
919
  response: {
@@ -972,10 +975,13 @@ function makeCrudRoute(opts) {
972
975
  tenantId: scopedTenantId ?? null,
973
976
  orgId: scopedOrgId ?? null
974
977
  } : null;
975
- const fallbackOrgId = scopedOrgId ?? rawAuth?.orgId ?? null;
978
+ const fallbackOrgId = scope ? scopedOrgId : rawAuth?.orgId ?? null;
979
+ const hasExplicitEmptyScope = Boolean(scope && (Array.isArray(scope.filterIds) && scope.filterIds.length === 0 || Array.isArray(scope.allowedIds) && scope.allowedIds.length === 0));
976
980
  const rawScopeIds = scope?.filterIds;
977
981
  const scopedIds = Array.isArray(rawScopeIds) ? rawScopeIds.filter((id) => typeof id === "string" && id.length > 0) : null;
978
- if (!scope) {
982
+ if (hasExplicitEmptyScope) {
983
+ organizationIds = [];
984
+ } else if (!scope) {
979
985
  organizationIds = fallbackOrgId ? [fallbackOrgId] : null;
980
986
  } else if (scopedIds === null) {
981
987
  organizationIds = scope.allowedIds === null ? null : fallbackOrgId ? [fallbackOrgId] : null;
@@ -986,7 +992,7 @@ function makeCrudRoute(opts) {
986
992
  let canUseFallback = false;
987
993
  if (allowedIds === null) {
988
994
  canUseFallback = true;
989
- } else if (allowedIds.includes(fallbackOrgId) || allowedIds.length === 0) {
995
+ } else if (allowedIds.includes(fallbackOrgId)) {
990
996
  canUseFallback = true;
991
997
  }
992
998
  if (canUseFallback) {
@@ -1060,6 +1066,24 @@ function makeCrudRoute(opts) {
1060
1066
  profiler.mark("query_parsed");
1061
1067
  let validated = opts.list.schema.parse(rawQueryParams);
1062
1068
  profiler.mark("query_validated");
1069
+ if (ormCfg.orgField && Array.isArray(ctx.organizationIds) && ctx.organizationIds.length === 0) {
1070
+ profiler.mark("scope_blocked");
1071
+ logForbidden({
1072
+ resourceKind,
1073
+ action: "list",
1074
+ reason: "organization_scope_empty",
1075
+ userId: ctx.auth?.sub ?? null,
1076
+ tenantId: ctx.auth?.tenantId ?? null,
1077
+ organizationIds: ctx.organizationIds
1078
+ });
1079
+ const page = Number(validated.page ?? 1) || 1;
1080
+ const pageSize = Math.min(
1081
+ Math.max(Number(validated.pageSize ?? 50) || 50, 1),
1082
+ 100
1083
+ );
1084
+ finishProfile({ result: "scope_blocked", itemCount: 0, total: 0 });
1085
+ return json({ items: [], total: 0, page, pageSize, totalPages: 0 });
1086
+ }
1063
1087
  const beforeInterceptors = await applyInterceptorsBefore({
1064
1088
  ctx,
1065
1089
  request,
@@ -1118,7 +1142,7 @@ function makeCrudRoute(opts) {
1118
1142
  if (Array.isArray(payload2)) return;
1119
1143
  const items = Array.isArray(payload2.items) ? payload2.items : [];
1120
1144
  const tags = /* @__PURE__ */ new Set();
1121
- const scopeOrgIds = collectScopeOrganizationIds(ctx);
1145
+ const scopeOrgIds = ormCfg.orgField ? collectScopeOrganizationIds(ctx) : [null];
1122
1146
  const crudSegment = deriveCrudSegmentTag(resourceKind, request);
1123
1147
  for (const target of resourceTargets) {
1124
1148
  for (const tag of buildCollectionTags(target, tenantForScope, scopeOrgIds)) {