@cosmicdrift/kumiko-framework 0.364.0 → 0.365.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 (150) hide show
  1. package/AGENTS.md +36 -5
  2. package/agents/examples/anonymous-access-multitenant.md +9 -6
  3. package/agents/examples/anonymous-access.md +14 -26
  4. package/agents/examples/auth-mfa.md +5 -9
  5. package/agents/examples/custom-fields-basic.md +7 -6
  6. package/agents/examples/custom-handlers.md +72 -25
  7. package/agents/examples/dashboard-stats.md +229 -0
  8. package/agents/examples/encrypted-tenant-config.md +17 -8
  9. package/agents/examples/error-contract.md +1 -5
  10. package/agents/examples/feature-toggles.md +28 -17
  11. package/agents/examples/ledger-banking.md +7 -6
  12. package/agents/examples/ledger-invoicing.md +7 -6
  13. package/agents/examples/legal-pages.md +2 -2
  14. package/agents/examples/managed-pages.md +2 -2
  15. package/agents/examples/notes-history-basic.md +7 -6
  16. package/agents/examples/ownership.md +30 -22
  17. package/agents/examples/pdf-audit-export.md +87 -51
  18. package/agents/examples/session-revocation.md +1 -10
  19. package/agents/examples/tags-basic.md +7 -7
  20. package/agents/examples/user-data-rights.md +25 -17
  21. package/agents/examples/workflow-engine.md +2 -2
  22. package/agents/examples.md +2 -1
  23. package/agents/features.md +3 -3
  24. package/dist/api/auth-routes.d.ts +0 -1
  25. package/dist/api/auth-routes.js +44 -43
  26. package/dist/api/index.d.ts +1 -1
  27. package/dist/api/index.js +1 -1
  28. package/dist/api/server.d.ts +2 -2
  29. package/dist/api/server.js +21 -4
  30. package/dist/bun-db/connection.js +3 -16
  31. package/dist/bun-db/index.d.ts +1 -1
  32. package/dist/bun-db/index.js +1 -1
  33. package/dist/bun-db/query.d.ts +7 -0
  34. package/dist/bun-db/query.js +50 -18
  35. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  36. package/dist/crypto/owner-transition-rekey.js +7 -3
  37. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  38. package/dist/crypto/pii-field-encryption.js +9 -2
  39. package/dist/crypto/subject-resolver.d.ts +1 -0
  40. package/dist/crypto/subject-resolver.js +7 -0
  41. package/dist/db/api.d.ts +4 -3
  42. package/dist/db/apply-entity-event.js +26 -2
  43. package/dist/db/blind-index-cleanup.js +2 -1
  44. package/dist/db/bun-provider.js +9 -18
  45. package/dist/db/dialect.d.ts +1 -1
  46. package/dist/db/dialect.js +2 -0
  47. package/dist/db/eagerload.js +2 -1
  48. package/dist/db/event-store-executor-context.d.ts +1 -0
  49. package/dist/db/event-store-executor-context.js +1 -0
  50. package/dist/db/event-store-executor-write.d.ts +1 -1
  51. package/dist/db/event-store-executor-write.js +70 -47
  52. package/dist/db/index.d.ts +1 -0
  53. package/dist/db/index.js +1 -0
  54. package/dist/db/migrate-runner.d.ts +1 -0
  55. package/dist/db/migrate-runner.js +56 -31
  56. package/dist/db/queries/backfill-pii.js +63 -25
  57. package/dist/db/queries/event-consumer.js +2 -1
  58. package/dist/db/queries/event-store-transfer.js +15 -4
  59. package/dist/db/queries/event-store.js +11 -10
  60. package/dist/db/queries/pii-backfill-state.js +3 -2
  61. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  62. package/dist/db/queries/postgres-array-literal.js +6 -0
  63. package/dist/db/queries/projection-rebuild.js +3 -2
  64. package/dist/db/queries/shadow-swap.d.ts +1 -1
  65. package/dist/db/queries/shadow-swap.js +12 -8
  66. package/dist/db/query.d.ts +1 -1
  67. package/dist/db/query.js +1 -1
  68. package/dist/db/retired-framework-tables.js +22 -4
  69. package/dist/db/table-builder.js +6 -2
  70. package/dist/db/tenant-db.d.ts +2 -0
  71. package/dist/db/tenant-db.js +20 -1
  72. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  73. package/dist/derivatives/delete-stored-file.js +9 -0
  74. package/dist/derivatives/index.d.ts +1 -1
  75. package/dist/derivatives/index.js +1 -1
  76. package/dist/engine/boot-validator/entity-handler.js +3 -0
  77. package/dist/engine/boot-validator/query-refs.js +14 -2
  78. package/dist/engine/dedupe-features.js +31 -4
  79. package/dist/engine/entity-handlers.js +14 -7
  80. package/dist/engine/index.d.ts +1 -1
  81. package/dist/engine/index.js +1 -1
  82. package/dist/engine/screen-helpers.d.ts +1 -0
  83. package/dist/engine/screen-helpers.js +2 -2
  84. package/dist/engine/steps/_read-source.js +5 -2
  85. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  86. package/dist/engine/tier-resolver-extension.js +6 -0
  87. package/dist/entrypoint/index.d.ts +2 -2
  88. package/dist/errors/docs-url.js +6 -6
  89. package/dist/errors/index.d.ts +1 -1
  90. package/dist/errors/index.js +1 -1
  91. package/dist/errors/subject-erased.d.ts +1 -0
  92. package/dist/errors/subject-erased.js +6 -0
  93. package/dist/event-store/admin-api.js +3 -2
  94. package/dist/event-store/errors.d.ts +9 -0
  95. package/dist/event-store/errors.js +20 -0
  96. package/dist/event-store/event-store.d.ts +6 -0
  97. package/dist/event-store/event-store.js +28 -6
  98. package/dist/event-store/index.d.ts +1 -1
  99. package/dist/event-store/index.js +1 -1
  100. package/dist/files/create-file-ref.d.ts +2 -1
  101. package/dist/files/create-file-ref.js +16 -2
  102. package/dist/files/dispose-removed-file-refs.js +21 -6
  103. package/dist/files/feature.d.ts +1 -0
  104. package/dist/files/feature.js +2 -1
  105. package/dist/files/file-handle.js +4 -0
  106. package/dist/files/file-routes.js +9 -5
  107. package/dist/files/index.d.ts +1 -1
  108. package/dist/files/index.js +1 -1
  109. package/dist/files/purge-expired-file-ref-bytes.js +2 -3
  110. package/dist/files/resolve-content-type.js +40 -4
  111. package/dist/files/storage-tracking.js +5 -4
  112. package/dist/files/types.js +5 -8
  113. package/dist/files/zip-entry-names.d.ts +1 -0
  114. package/dist/files/zip-entry-names.js +46 -0
  115. package/dist/jobs/job-runner.d.ts +1 -0
  116. package/dist/jobs/job-runner.js +116 -33
  117. package/dist/observability/escape-hatch-report.d.ts +1 -0
  118. package/dist/observability/escape-hatch-report.js +31 -2
  119. package/dist/observability/index.d.ts +1 -1
  120. package/dist/observability/index.js +1 -1
  121. package/dist/observability/metrics-handle.d.ts +1 -0
  122. package/dist/observability/metrics-handle.js +7 -0
  123. package/dist/observability/standard-metrics.js +4 -4
  124. package/dist/pipeline/dispatch-query.d.ts +2 -1
  125. package/dist/pipeline/dispatch-query.js +6 -6
  126. package/dist/pipeline/dispatch-shared.d.ts +1 -1
  127. package/dist/pipeline/dispatch-shared.js +41 -30
  128. package/dist/pipeline/dispatch-stream.js +10 -4
  129. package/dist/pipeline/dispatcher.js +3 -1
  130. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  131. package/dist/pipeline/event-dispatcher.js +11 -9
  132. package/dist/pipeline/event-retention.d.ts +1 -0
  133. package/dist/pipeline/event-retention.js +7 -1
  134. package/dist/pipeline/notify-scope.js +3 -1
  135. package/dist/pipeline/pre-transaction-reservations.js +2 -1
  136. package/dist/schema-cli.js +12 -1
  137. package/dist/stack/db.d.ts +2 -2
  138. package/dist/stack/test-stack.js +2 -1
  139. package/dist/testing/index.d.ts +1 -0
  140. package/dist/testing/index.js +1 -0
  141. package/dist/testing/multipart-helper.d.ts +6 -0
  142. package/dist/testing/multipart-helper.js +1 -1
  143. package/dist/testing/zip-fixture.d.ts +1 -0
  144. package/dist/testing/zip-fixture.js +33 -0
  145. package/dist/ui-types/card-column-roles.js +4 -2
  146. package/dist/ui-types/index.d.ts +1 -1
  147. package/dist/ui-types/index.js +1 -1
  148. package/dist/upgrade-cli.js +46 -13
  149. package/package.json +4 -4
  150. package/src/changes.json +305 -0
@@ -12,7 +12,7 @@ import { InternalError } from "../errors/index.js";
12
12
  import { isKumikoError } from "../errors/kumiko-error.js";
13
13
  import { createFileContext } from "../files/file-handle.js";
14
14
  import { createFallbackLogger } from "../logging/index.js";
15
- import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
15
+ import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, createMetricsForFactory, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
16
16
  import { createDistributedLock } from "../pipeline/distributed-lock.js";
17
17
  import { RedisKeys } from "../pipeline/redis-keys.js";
18
18
  import { createGatedIdentitySwitch } from "../pipeline/system-identity-switch.js";
@@ -82,6 +82,18 @@ export const ORPHAN_SCHEDULER_GRACE_MS = 60 * 60 * 1000;
82
82
  function schedulerSeenKey(queue) {
83
83
  return queue.toKey("kumiko-scheduler-seen");
84
84
  }
85
+ // "Seen" has to mean a live runner holds the scheduler, so runners re-stamp
86
+ // their own ids periodically; a single stamp at start() would let a runner
87
+ // restarting during a mixed-version window longer than the grace period delete
88
+ // a scheduler a healthy newer pod still owns.
89
+ async function markSchedulersSeen(queue, schedulerIds) {
90
+ // skip: no cron scheduler on this lane, nothing to stamp
91
+ if (schedulerIds.size === 0)
92
+ return;
93
+ const stamp = String(Date.now());
94
+ const entries = Object.fromEntries([...schedulerIds].map((id) => [id, stamp]));
95
+ await (await queue.client).hset(schedulerSeenKey(queue), entries);
96
+ }
85
97
  // A cron job dropped from the app (or an older scheduler-id format) leaves its
86
98
  // scheduler in Redis, where it keeps firing jobs the worker no longer knows
87
99
  // ("Unknown job"). Best-effort: a Redis hiccup here must not stop the start.
@@ -364,6 +376,7 @@ export function createJobRunner(options) {
364
376
  const { registry, context, redisUrl, consumerLane } = options;
365
377
  const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
366
378
  const bootRedisTimeoutMs = options.bootRedisTimeoutMs ?? BOOT_REDIS_TIMEOUT_MS;
379
+ const schedulerSeenRefreshMs = options.schedulerSeenRefreshMs ?? ORPHAN_SCHEDULER_GRACE_MS / 4;
367
380
  const redisOpts = parseRedisOpts(redisUrl);
368
381
  // Use the context's tracer when present (observability-provider injected at
369
382
  // boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
@@ -524,6 +537,11 @@ export function createJobRunner(options) {
524
537
  }
525
538
  let worker = null;
526
539
  let queueDepthTimer = null;
540
+ let schedulerSeenTimer = null;
541
+ let schedulerStampPending = false;
542
+ // A timed-out gate is abandoned, not aborted (the job context carries no
543
+ // AbortSignal); stop() waits for it so it cannot write after the shutdown.
544
+ const abandonedBootGates = new Set();
527
545
  // Forward reference to the runner's own API, exposed on the job-handler ctx
528
546
  // so a handler can dispatch a follow-up job (job→job chaining, e.g.
529
547
  // delivery.render → delivery.send). Assigned just before return; reads happen
@@ -943,6 +961,7 @@ export function createJobRunner(options) {
943
961
  attempt,
944
962
  finalAttempt,
945
963
  log: createJobLogger(logs),
964
+ metricsFor: createMetricsForFactory(context.meter),
946
965
  ...(triggerName !== undefined && { triggerName }),
947
966
  ...(triggerEvent !== undefined && { triggerEvent }),
948
967
  write: (qn, payload) => {
@@ -1078,17 +1097,18 @@ export function createJobRunner(options) {
1078
1097
  });
1079
1098
  }, BOOT_GATE_SLOW_WARN_MS)
1080
1099
  : undefined;
1100
+ const gateRun = handleJob({
1101
+ id: bootJobIdForJobName(name),
1102
+ name,
1103
+ data: {},
1104
+ attemptsMade: 0,
1105
+ retryable: false,
1106
+ });
1107
+ const gateSettled = gateRun.then(() => undefined, () => undefined);
1108
+ abandonedBootGates.add(gateSettled);
1109
+ void gateSettled.then(() => abandonedBootGates.delete(gateSettled));
1081
1110
  try {
1082
- await Promise.race([
1083
- handleJob({
1084
- id: bootJobIdForJobName(name),
1085
- name,
1086
- data: {},
1087
- attemptsMade: 0,
1088
- retryable: false,
1089
- }),
1090
- ...(gateTimeout ? [gateTimeout.promise] : []),
1091
- ]);
1111
+ await Promise.race([gateRun, ...(gateTimeout ? [gateTimeout.promise] : [])]);
1092
1112
  }
1093
1113
  catch (err) {
1094
1114
  // Leaving the worker's Redis connections open would keep the event loop alive and turn the aborted boot into
@@ -1104,6 +1124,74 @@ export function createJobRunner(options) {
1104
1124
  }
1105
1125
  }
1106
1126
  }
1127
+ function cronSchedulerIdsForLane(lane) {
1128
+ const ids = new Set();
1129
+ for (const [name, jobDef] of allJobs) {
1130
+ if (laneForJob(jobDef) === lane && "cron" in jobDef.trigger) {
1131
+ ids.add(schedulerIdForJobName(name));
1132
+ }
1133
+ }
1134
+ return ids;
1135
+ }
1136
+ async function upsertCronSchedulers(queue, lane) {
1137
+ for (const [name, jobDef] of allJobs) {
1138
+ if (laneForJob(jobDef) !== lane)
1139
+ continue;
1140
+ if ("cron" in jobDef.trigger) {
1141
+ await queue.upsertJobScheduler(schedulerIdForJobName(name), cronRepeatOptions(jobDef.trigger), {
1142
+ name: jobDef.perTenant ? `_perTenant:${name}` : name,
1143
+ data: {},
1144
+ // Queue-level defaultJobOptions (jobRetentionOpts) covers
1145
+ // retention; a count here would sweep queue-wide again and
1146
+ // evict perTenant children and boot jobs.
1147
+ opts: {
1148
+ ...buildRetryBullOpts(jobDef),
1149
+ },
1150
+ });
1151
+ }
1152
+ }
1153
+ }
1154
+ // After a failed initial stamp an older runner may have removed our
1155
+ // schedulers as unseen, so the next tick stamps and re-upserts before the
1156
+ // flag clears.
1157
+ async function refreshSchedulerSeen(queue, lane, schedulerIds) {
1158
+ await markSchedulersSeen(queue, schedulerIds);
1159
+ if (schedulerStampPending) {
1160
+ await upsertCronSchedulers(queue, lane);
1161
+ schedulerStampPending = false;
1162
+ }
1163
+ }
1164
+ function startSchedulerSeenRefresh(queue, lane, schedulerIds) {
1165
+ if (schedulerIds.size === 0)
1166
+ return null;
1167
+ const timer = setInterval(() => {
1168
+ refreshSchedulerSeen(queue, lane, schedulerIds).catch((err) => {
1169
+ errorLogger.warn("scheduler seen refresh failed", {
1170
+ error: err instanceof Error ? err.message : String(err),
1171
+ });
1172
+ });
1173
+ }, schedulerSeenRefreshMs);
1174
+ timer.unref();
1175
+ return timer;
1176
+ }
1177
+ // Bounded: a gate that never returns must not block shutdown as well.
1178
+ async function settleAbandonedBootGates(maxWaitMs) {
1179
+ // skip: no timed-out gate left behind
1180
+ if (abandonedBootGates.size === 0)
1181
+ return;
1182
+ const wait = timeoutReject(maxWaitMs, "job-runner: abandoned boot gate still running at stop()");
1183
+ try {
1184
+ await Promise.race([Promise.all(abandonedBootGates), wait.promise]);
1185
+ }
1186
+ catch (err) {
1187
+ errorLogger.warn("abandoned boot gate did not finish before stop()", {
1188
+ error: err instanceof Error ? err.message : String(err),
1189
+ });
1190
+ }
1191
+ finally {
1192
+ wait.cancel();
1193
+ }
1194
+ }
1107
1195
  const runnerApi = {
1108
1196
  async start() {
1109
1197
  // skip: enqueuer-only runner — no BullMQ worker, no cron schedules,
@@ -1152,28 +1240,18 @@ export function createJobRunner(options) {
1152
1240
  // Only schedule cron + boot for jobs that belong to this lane. Jobs
1153
1241
  // assigned to the other lane get their cron/boot wiring from the
1154
1242
  // runner running on that lane. Running both here would double-fire.
1155
- const expectedSchedulerIds = new Set();
1156
- for (const [name, jobDef] of allJobs) {
1157
- if (laneForJob(jobDef) !== consumerLane)
1158
- continue;
1159
- if ("cron" in jobDef.trigger) {
1160
- const schedulerId = schedulerIdForJobName(name);
1161
- expectedSchedulerIds.add(schedulerId);
1162
- await consumerQueue.upsertJobScheduler(schedulerId, cronRepeatOptions(jobDef.trigger), {
1163
- name: jobDef.perTenant ? `_perTenant:${name}` : name,
1164
- data: {},
1165
- // Queue-level defaultJobOptions (jobRetentionOpts) covers
1166
- // retention; a count here would sweep queue-wide again and
1167
- // evict perTenant children and boot jobs.
1168
- opts: {
1169
- ...buildRetryBullOpts(jobDef),
1170
- },
1171
- });
1172
- await (await consumerQueue.client).hset(schedulerSeenKey(consumerQueue), {
1173
- [schedulerId]: String(Date.now()),
1174
- });
1175
- }
1176
- }
1243
+ const expectedSchedulerIds = cronSchedulerIdsForLane(consumerLane);
1244
+ // Before the upserts: a seen entry without a scheduler is harmless, a
1245
+ // scheduler a concurrent older runner finds without one gets deleted.
1246
+ // Best-effort like the periodic refresh: a failed stamp must not stop the start.
1247
+ await markSchedulersSeen(consumerQueue, expectedSchedulerIds).catch((err) => {
1248
+ schedulerStampPending = true;
1249
+ errorLogger.warn("scheduler seen stamp failed", {
1250
+ error: err instanceof Error ? err.message : String(err),
1251
+ });
1252
+ });
1253
+ await upsertCronSchedulers(consumerQueue, consumerLane);
1254
+ schedulerSeenTimer = startSchedulerSeenRefresh(consumerQueue, consumerLane, expectedSchedulerIds);
1177
1255
  await removeOrphanJobSchedulers(consumerQueue, expectedSchedulerIds, consumerLane, {
1178
1256
  info: (msg, data) => context.log ? context.log.info(msg, data) : errorLogger.debug(msg, data),
1179
1257
  warn: errorLogger.warn,
@@ -1221,6 +1299,10 @@ export function createJobRunner(options) {
1221
1299
  clearInterval(queueDepthTimer);
1222
1300
  queueDepthTimer = null;
1223
1301
  }
1302
+ if (schedulerSeenTimer) {
1303
+ clearInterval(schedulerSeenTimer);
1304
+ schedulerSeenTimer = null;
1305
+ }
1224
1306
  if (worker) {
1225
1307
  await worker.close();
1226
1308
  worker = null;
@@ -1230,6 +1312,7 @@ export function createJobRunner(options) {
1230
1312
  // without listeners -> unhandled rejection (fw#1805). Wait for readiness
1231
1313
  // first; a failed/timed-out wait must not block the close itself.
1232
1314
  const stopReadyTimeoutMs = Math.min(bootRedisTimeoutMs, STOP_QUEUE_READY_TIMEOUT_MS);
1315
+ await settleAbandonedBootGates(stopReadyTimeoutMs);
1233
1316
  const readinessTimeout = timeoutReject(stopReadyTimeoutMs, `job-runner: Queue not ready within ${stopReadyTimeoutMs}ms during stop()`);
1234
1317
  try {
1235
1318
  await Promise.race([
@@ -15,6 +15,7 @@ export type EscapeHatchProcessDedup = {
15
15
  };
16
16
  export declare function createEscapeHatchProcessDedup(kinds: readonly EscapeHatchKind[]): EscapeHatchProcessDedup;
17
17
  export declare function withEscapeHatchAuditScope<T>(run: () => Promise<T>): Promise<T>;
18
+ export declare function withEscapeHatchAuditScopeStream<T>(run: () => AsyncGenerator<T>): AsyncGenerator<T>;
18
19
  export declare function flushEscapeHatchAudits(): Promise<void>;
19
20
  export declare function reportEscapeHatchUse(event: EscapeHatchUseEvent, deps: {
20
21
  readonly sink?: EscapeHatchAuditSink;
@@ -40,9 +40,14 @@ const processPendingAudits = new Set();
40
40
  // The sink writes of the dispatch or job run currently executing.
41
41
  const scopePendingAudits = new AsyncLocalStorage();
42
42
  function trackPendingAudit(write) {
43
+ const scope = scopePendingAudits.getStore();
43
44
  processPendingAudits.add(write);
44
- scopePendingAudits.getStore()?.add(write);
45
- void write.finally(() => processPendingAudits.delete(write));
45
+ scope?.add(write);
46
+ // Settled writes leave the scope so a long-lived stream does not accumulate them.
47
+ void write.finally(() => {
48
+ processPendingAudits.delete(write);
49
+ scope?.delete(write);
50
+ });
46
51
  }
47
52
  // Awaits the audit writes started inside `run` once it has returned, so callers place it around
48
53
  // the transaction and the write is durable when the dispatch or run ends. Nested scopes belong
@@ -59,6 +64,30 @@ export async function withEscapeHatchAuditScope(run) {
59
64
  await Promise.allSettled([...pending]);
60
65
  }
61
66
  }
67
+ // Stream variant: the scope has to be entered on every pull because an async generator body
68
+ // runs in the caller's async context, and the wait happens once the stream ends or is closed.
69
+ export async function* withEscapeHatchAuditScopeStream(run) {
70
+ if (scopePendingAudits.getStore() !== undefined) {
71
+ yield* run();
72
+ // skip: an enclosing scope already waits for the pending audits
73
+ return;
74
+ }
75
+ const pending = new Set();
76
+ const source = run();
77
+ try {
78
+ while (true) {
79
+ const next = await scopePendingAudits.run(pending, () => source.next());
80
+ // skip: the source is exhausted
81
+ if (next.done)
82
+ return;
83
+ yield next.value;
84
+ }
85
+ }
86
+ finally {
87
+ await scopePendingAudits.run(pending, () => source.return(undefined));
88
+ await Promise.allSettled([...pending]);
89
+ }
90
+ }
62
91
  export async function flushEscapeHatchAudits() {
63
92
  while (processPendingAudits.size > 0) {
64
93
  await Promise.allSettled([...processPendingAudits]);
@@ -4,7 +4,7 @@ export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeH
4
4
  export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
5
5
  export { generateSpanId, generateTraceId } from "./ids.js";
6
6
  export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
7
- export { createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, createUnboundMetricsHandle, } from "./metrics-handle.js";
7
+ export { createMetricsForFactory, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, createUnboundMetricsHandle, } from "./metrics-handle.js";
8
8
  export { type ObservabilityWiring, prometheusMetricsEnvSchema, resolveObservabilityWiring, } from "./metrics-wiring.js";
9
9
  export { createNoopProvider } from "./noop-provider.js";
10
10
  export { createPrometheusMeter, type PrometheusMeter, type PrometheusMeterSnapshot, serializeOpenMetrics, } from "./prometheus-meter.js";
@@ -5,7 +5,7 @@ export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeH
5
5
  export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
6
6
  export { generateSpanId, generateTraceId } from "./ids.js";
7
7
  export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
8
- export { createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, createUnboundMetricsHandle, } from "./metrics-handle.js";
8
+ export { createMetricsForFactory, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, createUnboundMetricsHandle, } from "./metrics-handle.js";
9
9
  export { prometheusMetricsEnvSchema, resolveObservabilityWiring, } from "./metrics-wiring.js";
10
10
  export { createNoopProvider } from "./noop-provider.js";
11
11
  export { createPrometheusMeter, serializeOpenMetrics, } from "./prometheus-meter.js";
@@ -3,3 +3,4 @@ export declare function createMetricsHandle(meter: Meter, featureName: string):
3
3
  export declare function createSafeMetricsHandle(meter: Meter, featureName: string): MetricsHandle;
4
4
  export declare function createUnboundMetricsHandle(meter: Meter): MetricsHandle;
5
5
  export declare function createNoopMetricsHandle(): MetricsHandle;
6
+ export declare function createMetricsForFactory(meter: Meter | undefined): (featureName: string) => MetricsHandle;
@@ -100,3 +100,10 @@ export function createNoopMetricsHandle() {
100
100
  set(_name, _value, _labels) { },
101
101
  };
102
102
  }
103
+ // Shared factory behind ctx.metricsFor for handler and job contexts, so both
104
+ // keep the same no-op-on-unregistered contract (framework#1844).
105
+ export function createMetricsForFactory(meter) {
106
+ return meter
107
+ ? (featureName) => createSafeMetricsHandle(meter, featureName)
108
+ : () => createNoopMetricsHandle();
109
+ }
@@ -62,15 +62,15 @@ export const STANDARD_METRIC_DEFS = [
62
62
  // Prometheus collapses per-instance shards into last-writer-wins and
63
63
  // per-instance lag is invisible to alerting.
64
64
  {
65
- name: "kumiko_event_consumer_pending_gaps",
65
+ name: "kumiko_event_consumer_lag_events",
66
66
  type: "gauge",
67
- description: "Number of unresolved id ranges below the consumer's cursor (ids not yet visible or proven rolled back).",
67
+ description: "Number of events between the consumer's cursor and the events head.",
68
68
  labels: ["consumer", "instance_id"],
69
69
  },
70
70
  {
71
- name: "kumiko_event_consumer_lag_events",
71
+ name: "kumiko_event_consumer_pending_gaps",
72
72
  type: "gauge",
73
- description: "Number of events between the consumer's cursor and the events head.",
73
+ description: "Number of unresolved id ranges below the consumer's cursor (ids not yet visible or proven rolled back).",
74
74
  labels: ["consumer", "instance_id"],
75
75
  },
76
76
  {
@@ -1,4 +1,5 @@
1
1
  import type { DbTx } from "../db/connection.js";
2
2
  import type { SessionUser } from "../engine/types/index.js";
3
3
  import { type DispatchContext, type WriteOrigin } from "./dispatch-shared.js";
4
- export declare function executeQuery(ctx: DispatchContext, type: string, payload: unknown, user: SessionUser, origin: WriteOrigin, tx?: DbTx): Promise<unknown>;
4
+ import type { AfterCommitHook } from "./dispatcher-utils.js";
5
+ export declare function executeQuery(ctx: DispatchContext, type: string, payload: unknown, user: SessionUser, origin: WriteOrigin, tx?: DbTx, outerNotifySink?: AfterCommitHook[]): Promise<unknown>;
@@ -10,10 +10,10 @@ import { runInMemberReadOnlyTransaction } from "./member-read-only-transaction.j
10
10
  // Standalone query execution — used by the public dispatcher.query() and
11
11
  // by ctx.query/ctx.queryAs inside handlers. Runs the handler, applies
12
12
  // field-level read filters for the given user, logs the event.
13
- export async function executeQuery(ctx, type, payload, user, origin, tx) {
14
- return runHandlerInstrumented(ctx, type, "query", user, () => executeQueryInner(ctx, type, payload, user, origin, tx));
13
+ export async function executeQuery(ctx, type, payload, user, origin, tx, outerNotifySink) {
14
+ return runHandlerInstrumented(ctx, type, "query", user, () => executeQueryInner(ctx, type, payload, user, origin, tx, outerNotifySink));
15
15
  }
16
- async function executeQueryInner(ctx, type, payload, user, origin, tx) {
16
+ async function executeQueryInner(ctx, type, payload, user, origin, tx, outerNotifySink) {
17
17
  const { registry } = ctx;
18
18
  const handler = registry.getQueryHandler(type);
19
19
  if (!handler)
@@ -54,11 +54,11 @@ async function executeQueryInner(ctx, type, payload, user, origin, tx) {
54
54
  // A resolved member (ctx.queryAsMember) runs in a Postgres READ ONLY transaction, not just the ctx surface below.
55
55
  return isMemberResolutionPrincipal(user)
56
56
  ? runInMemberReadOnlyTransaction(ctx, tx, (readOnlyTx) => runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, readOnlyTx))
57
- : runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, tx);
57
+ : runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, tx, outerNotifySink);
58
58
  }
59
- async function runQueryHandler(ctx, type, handler, payload, includeDeleted, user, origin, tx) {
59
+ async function runQueryHandler(ctx, type, handler, payload, includeDeleted, user, origin, tx, outerNotifySink) {
60
60
  const { registry } = ctx;
61
- const handlerContext = await buildHandlerContext(ctx, type, user, origin, tx, undefined, includeDeleted);
61
+ const handlerContext = await buildHandlerContext(ctx, type, user, origin, tx, undefined, includeDeleted, undefined, outerNotifySink);
62
62
  let result = await handler.handler({ type, payload, user }, handlerContext);
63
63
  // postQuery-Hooks: fire BEFORE field-access-filter so hooks see raw data
64
64
  // and can merge custom-fields/computed-counts/tags/etc. Each hook is
@@ -57,7 +57,7 @@ export declare function perCommandNestedDenied(targetType: string): InternalErro
57
57
  export declare function denyTransactionBoundSurfaces(handlerContext: HandlerContext): HandlerContext;
58
58
  export declare function applyBeforeTransactionReadOnly(handlerContext: HandlerContext): HandlerContext;
59
59
  export type HandlerTransactionScope = "inTransaction" | "withoutTransaction" | "postCommit";
60
- export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean, transactionScope?: HandlerTransactionScope): Promise<HandlerContext>;
60
+ export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean, transactionScope?: HandlerTransactionScope, outerNotifySink?: AfterCommitHook[], readOnlyForTenant?: boolean): Promise<HandlerContext>;
61
61
  export declare function createHandlerBoundDigest(digest: (value: string) => string, handlerName: string): (value: string) => string;
62
62
  export declare function runHandlerInstrumented<T>(ctx: DispatchContext, type: string, operation: "query" | "write" | "stream", user: SessionUser, inner: () => Promise<T>): Promise<T>;
63
63
  export declare function runStreamInstrumented<T>(ctx: DispatchContext, type: string, user: SessionUser, inner: () => AsyncGenerator<T>): AsyncGenerator<T>;
@@ -9,13 +9,13 @@ import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index
9
9
  import { AccessDeniedError, FeatureDisabledError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, writeFailure, } from "../errors/index.js";
10
10
  import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
11
11
  import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
12
- import { getStreamVersion, loadAggregate, loadAggregateAsOf, } from "../event-store/event-store.js";
12
+ import { loadAggregate, loadAggregateAsOf, loadAggregateForWriting, } from "../event-store/event-store.js";
13
13
  import { effectiveSnapshotGeneration, loadAggregateWithSnapshot, saveSnapshot, } from "../event-store/snapshot.js";
14
14
  import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster.js";
15
15
  import { createFileContext } from "../files/file-handle.js";
16
16
  import { DEFAULT_LOCALE, isValidLocaleTag } from "../i18n/request-locale.js";
17
17
  import { createFallbackLogger } from "../logging/utils.js";
18
- import { createEscapeHatchReporter, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
18
+ import { createEscapeHatchReporter, createMetricsForFactory, createMetricsHandle, createNoopMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
19
19
  import { buildBucketKey, buildPayloadBucketKey, normalizePayloadBucketValue, } from "../rate-limit/index.js";
20
20
  import { createTzContext, isValidIanaTimeZone } from "../time/index.js";
21
21
  import { isPlainObject } from "../utils/is-plain-object.js";
@@ -257,10 +257,14 @@ export function applyBeforeTransactionReadOnly(handlerContext) {
257
257
  ...denyTransactionBoundSurfaces(handlerContext),
258
258
  dbOutsideTransaction: undefined,
259
259
  outsideTransaction: undefined,
260
- forTenant: undefined,
261
260
  };
262
261
  }
263
- export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, transactionScope = tx ? "inTransaction" : "withoutTransaction") {
262
+ export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, transactionScope = tx ? "inTransaction" : "withoutTransaction",
263
+ // A query bridge has no commit sink of its own, but ctx.notify inside it must still wait for
264
+ // the outer write's commit: the outer sink is lent to notify only, not to scheduleAfterCommit.
265
+ outerNotifySink,
266
+ // beforeTransaction phase: ctx.forTenant stays available but every target db denies writes.
267
+ readOnlyForTenant = false) {
264
268
  const { registry, appContext: context, effectiveFeatures, jobRunner, lifecycle } = ctx;
265
269
  const isSystem = registry.isHandlerSystemScoped(type);
266
270
  // The outer dispatcher receives a DbConnection from the server/stack;
@@ -358,23 +362,30 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
358
362
  const notifyInTransactionFactory = notifyFactory && tx !== undefined
359
363
  ? (notifyUser, notifyTenantId) => notifyFactory(notifyUser, notifyTenantId, notifyJobDispatcher, outsideTxSource, context._fileProviderResolver)
360
364
  : undefined;
365
+ const notifyAfterCommitFactory = notifyFactory
366
+ ? (notifyUser, notifyTenantId) => notifyFactory(notifyUser, notifyTenantId, notifyJobDispatcher, undefined, context._fileProviderResolver)
367
+ : undefined;
361
368
  const notify = buildNotify();
362
369
  function buildNotify() {
363
- if (!notifyFactory)
370
+ if (!notifyAfterCommitFactory)
364
371
  return undefined;
365
- const notifyAfterCommit = notifyFactory(user, user.tenantId, notifyJobDispatcher, undefined, context._fileProviderResolver);
372
+ const notifyAfterCommit = notifyAfterCommitFactory(user, user.tenantId);
366
373
  if (!notifyInTransactionFactory)
367
374
  return notifyAfterCommit;
368
375
  const notifyInTransaction = notifyInTransactionFactory(user, user.tenantId);
369
- // A rolled-back write must leave no notification behind.
370
- if (!hasLiveCommitSink)
376
+ // A rolled-back write must leave no notification behind. Without any commit to wait for (a
377
+ // post-commit context) delivery stays immediate.
378
+ const notifyCommitSink = hasLiveCommitSink ? bridgeSink : outerNotifySink;
379
+ if (notifyCommitSink === undefined)
371
380
  return notifyInTransaction;
372
381
  return createDeferringNotify({
373
382
  registry,
374
383
  tenantId: user.tenantId,
375
384
  notifyAfterCommit,
376
385
  notifyNow: notifyInTransaction,
377
- scheduleAfterCommit,
386
+ scheduleAfterCommit: (hook) => {
387
+ notifyCommitSink.push(hook);
388
+ },
378
389
  });
379
390
  }
380
391
  // Mirror notify: only built when the config feature wired its factory.
@@ -417,7 +428,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
417
428
  if (targetTenantId === user.tenantId && exposedDb) {
418
429
  // Only perCommand handlers lose forTenant writes; reservations keep their pre-transaction contract.
419
430
  const ownTenantDb = denyDbWrites && !isPerCommand
420
- ? buildTenantScopedDb(dbSource, reqCtx?.signal)
431
+ ? buildTenantScopedDb(dbSource, reqCtx?.signal, undefined, readOnlyForTenant)
421
432
  : exposedDb;
422
433
  return { db: ownTenantDb, streamTenantId: undefined };
423
434
  }
@@ -427,7 +438,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
427
438
  });
428
439
  }
429
440
  return {
430
- db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId, denyDbWrites && isPerCommand),
441
+ db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId, denyDbWrites && (isPerCommand || readOnlyForTenant)),
431
442
  streamTenantId: targetTenantId,
432
443
  };
433
444
  }
@@ -455,16 +466,13 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
455
466
  const metrics = meter && featureName ? createMetricsHandle(meter, featureName) : createNoopMetricsHandle();
456
467
  // ctx.metricsFor(featureName) — shared/library code binds to a feature
457
468
  // name of its own choosing instead of the dispatching handler's
458
- // (framework#1844). Unregistered names no-op rather than throw, see
459
- // createSafeMetricsHandle.
460
- const metricsFor = meter
461
- ? (targetFeatureName) => createSafeMetricsHandle(meter, targetFeatureName)
462
- : () => createNoopMetricsHandle();
469
+ // (framework#1844). Unregistered names no-op rather than throw.
470
+ const metricsFor = createMetricsForFactory(meter);
463
471
  // A failed inner write has already appended its event and projections when a later
464
472
  // step (e.g. an inTransaction hook) fails, and the outer handler may swallow the
465
473
  // failure result. The savepoint makes the failure undo exactly the inner write; the
466
474
  // inner afterCommit hooks only reach the outer sink if it succeeded.
467
- const executeNestedWriteInSavepoint = async (asUser, targetType, payload) => {
475
+ const runNestedWriteInSavepoint = async (asUser, targetType, payload) => {
468
476
  if (registry.getWriteHandler(targetType)?.transaction === "perCommand") {
469
477
  return writeFailure(perCommandNestedDenied(targetType));
470
478
  }
@@ -488,6 +496,15 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
488
496
  throw e;
489
497
  }
490
498
  };
499
+ // Savepoints on one connection are not serialized by the driver: two parallel nested writes would
500
+ // interleave their statements and a ROLLBACK TO of the first would also discard the second's rows.
501
+ // Writes nested one level deeper run on their own savepoint handle and therefore their own chain.
502
+ let nestedWriteChain = Promise.resolve();
503
+ const executeNestedWriteInSavepoint = (asUser, targetType, payload) => {
504
+ const run = nestedWriteChain.then(() => runNestedWriteInSavepoint(asUser, targetType, payload));
505
+ nestedWriteChain = run.catch(() => undefined);
506
+ return run;
507
+ };
491
508
  // Without an open transaction (post-commit hooks, stream/query handlers, perCommand handlers) a bare
492
509
  // executeWrite would auto-commit event append, projections and inTransaction hooks separately and its
493
510
  // afterCommit hooks would land in a sink nobody flushes. A batch-of-one gives the write its own
@@ -498,7 +515,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
498
515
  const identitySwitchAudit = { reason: identitySwitchGrantReason, report: reportEscapeHatch };
499
516
  const identitySwitch = createGatedIdentitySwitch(`handler "${type}"`, user, hasIdentitySwitchGrant, {
500
517
  // Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
501
- queryAs: (asUser, targetType, payload) => executeQuery(ctx, targetType, payload, asUser, origin, tx), // @wrapper-known semantic-alias
518
+ queryAs: (asUser, targetType, payload) => executeQuery(ctx, targetType, payload, asUser, origin, tx, hasLiveCommitSink ? bridgeSink : outerNotifySink), // @wrapper-known semantic-alias
502
519
  writeAs: writeAsUser,
503
520
  }, identitySwitchAudit);
504
521
  // Lazy — creates the reader (which fails closed on SYSTEM_TENANT_ID) only
@@ -542,7 +559,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
542
559
  return projTable === undefined || hasTenantColumn(projTable);
543
560
  });
544
561
  const bridge = {
545
- query: (targetType, payload) => executeQuery(ctx, targetType, payload, user, origin, tx), // @wrapper-known semantic-alias
562
+ query: (targetType, payload) => executeQuery(ctx, targetType, payload, user, origin, tx, hasLiveCommitSink ? bridgeSink : outerNotifySink), // @wrapper-known semantic-alias
546
563
  queryAs: identitySwitch.queryAs,
547
564
  write: (targetType, payload) => writeAsUser(user, targetType, payload),
548
565
  writeAs: identitySwitch.writeAs,
@@ -597,16 +614,9 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
597
614
  message: `ctx.fetchForWriting("${args.aggregateId}") requires a database connection — none is configured.`,
598
615
  });
599
616
  }
600
- // Stream-version authoritative (same policy as CRUD executor + Block 0).
601
- // A single SELECT MAX(version) is cheaper than loading the full stream
602
- // when the caller just wants to append — but most callers also want
603
- // the events (business-rule checks), so fetch both in parallel.
604
- const [storedEvents, fetchedVersion] = await Promise.all([
605
- loadAggregate(dbSource, args.aggregateId, user.tenantId, {
606
- aggregateType: args.aggregateType,
607
- }),
608
- getStreamVersion(dbSource, args.aggregateId, user.tenantId),
609
- ]);
617
+ // Stream-version authoritative (same policy as CRUD executor + Block 0), read together
618
+ // with the events so the pinned version covers exactly what the handler's rules saw.
619
+ const { events: storedEvents, streamVersion: fetchedVersion } = await loadAggregateForWriting(dbSource, args.aggregateId, user.tenantId, { aggregateType: args.aggregateType });
610
620
  const events = await upcastStoredEvents(storedEvents, registry.getEventUpcasters(), {
611
621
  db: dbSource,
612
622
  tenantId: user.tenantId,
@@ -831,11 +841,12 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
831
841
  // able to compute bucket keys.
832
842
  const { _rateLimitPayloadDigest: payloadDigest,
833
843
  // Bound to this call's tx: an outer context's factory must not leak into a tx-less one.
834
- _notifyInTransactionFactory: _omitOuterNotifyInTransactionFactory, ...contextForHandler } = context;
844
+ _notifyInTransactionFactory: _omitOuterNotifyInTransactionFactory, _notifyAfterCommitFactory: _omitOuterNotifyAfterCommitFactory, ...contextForHandler } = context;
835
845
  const handlerContext = {
836
846
  ...contextForHandler,
837
847
  ...(payloadDigest && { rateLimitDigest: createHandlerBoundDigest(payloadDigest, type) }),
838
848
  ...(notifyInTransactionFactory && { _notifyInTransactionFactory: notifyInTransactionFactory }),
849
+ ...(notifyAfterCommitFactory && { _notifyAfterCommitFactory: notifyAfterCommitFactory }),
839
850
  registry,
840
851
  db: exposedDb,
841
852
  dbOutsideTransaction,
@@ -2,7 +2,7 @@ import { accessInvalidationCredentialFor } from "../api/sse-broker.js";
2
2
  import { scheduleTokenExpiry } from "../api/token-expiry-timer.js";
3
3
  import { hasAccess } from "../engine/access.js";
4
4
  import { tenantOverrideDenied } from "../engine/cross-tenant.js";
5
- import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, validationErrorFromZod, } from "../errors/index.js";
5
+ import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, UnauthenticatedError, validationErrorFromZod, } from "../errors/index.js";
6
6
  import { assertNoSecretLeak } from "../secrets/index.js";
7
7
  import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, isMemberResolutionPrincipal, runStreamInstrumented, } from "./dispatch-shared.js";
8
8
  import { handlerAccessError } from "./handler-access-error.js";
@@ -69,10 +69,16 @@ async function* executeStreamInner(ctx, type, payload, user, origin, options) {
69
69
  abandonedForInvalidation = true;
70
70
  void nextPull.catch(() => { });
71
71
  void iterator.return?.(undefined)?.then(undefined, () => { });
72
+ // An expired token is a lapsed session (401, so clients show the
73
+ // session-end hint); revocation is a permission change (403).
74
+ if (outcome.reason === "expired") {
75
+ throw new UnauthenticatedError({
76
+ message: `token expired mid-stream for ${type}`,
77
+ details: { handler: type, reason: outcome.reason },
78
+ });
79
+ }
72
80
  throw new AccessDeniedError({
73
- message: outcome.reason === "expired"
74
- ? `token expired mid-stream for ${type}`
75
- : `access revoked mid-stream for ${type}`,
81
+ message: `access revoked mid-stream for ${type}`,
76
82
  details: { handler: type },
77
83
  });
78
84
  }
@@ -1,6 +1,7 @@
1
1
  import { runWithWriteOrigin } from "../api/request-context.js";
2
2
  import { TENANT_MEMBERSHIPS_QUERY } from "../engine/extension-names.js";
3
3
  import { InternalError, reraiseAsKumikoError } from "../errors/index.js";
4
+ import { withEscapeHatchAuditScopeStream } from "../observability/escape-hatch-report.js";
4
5
  import { createEscapeHatchReportWindow, getFallbackMeter, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
5
6
  import { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage } from "../redis/cache-sync-topics.js";
6
7
  import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership.js";
@@ -114,7 +115,8 @@ export function createDispatcher(registry, context, options = {}) {
114
115
  },
115
116
  stream: (typeOrRef, payload, user, options) => {
116
117
  const type = resolveType(typeOrRef);
117
- return executeStream(ctx, type, payload, user, rootWriteOrigin(registry, type, user), options);
118
+ const origin = rootWriteOrigin(registry, type, user);
119
+ return withEscapeHatchAuditScopeStream(() => executeStream(ctx, type, payload, user, origin, options));
118
120
  },
119
121
  async command(typeOrRef, payload, user) {
120
122
  const type = resolveType(typeOrRef);
@@ -1,4 +1,4 @@
1
- import type { PgClient } from "../db/connection.js";
1
+ import type { PgListenClient } from "../db/connection.js";
2
2
  import type { AppContext } from "../engine/types/index.js";
3
3
  import { type StoredEvent } from "../event-store/index.js";
4
4
  import { type Meter, type Tracer } from "../observability/index.js";
@@ -46,7 +46,7 @@ export type EventDispatcherOptions = {
46
46
  readonly tracer?: Tracer;
47
47
  readonly meter?: Meter;
48
48
  readonly instanceId?: string;
49
- readonly pgClient?: PgClient;
49
+ readonly pgClient?: PgListenClient;
50
50
  };
51
51
  export declare function createEventDispatcher(options: EventDispatcherOptions): EventDispatcher;
52
52
  export type { ConsumerProgress, ConsumerRecoveryState } from "./event-dispatcher-admin.js";