@cosmicdrift/kumiko-framework 0.334.0 → 0.336.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 (178) hide show
  1. package/dist/api/anonymous-live-entities.d.ts +2 -0
  2. package/dist/api/anonymous-live-entities.js +17 -0
  3. package/dist/api/auth-routes.js +19 -4
  4. package/dist/api/client-ip.js +3 -6
  5. package/dist/api/extra-route.js +3 -2
  6. package/dist/api/index.d.ts +2 -1
  7. package/dist/api/index.js +1 -0
  8. package/dist/api/pii-leak-guard.js +7 -4
  9. package/dist/api/redis-sse-broker.d.ts +2 -0
  10. package/dist/api/redis-sse-broker.js +6 -7
  11. package/dist/api/routes.js +16 -17
  12. package/dist/api/server.js +20 -10
  13. package/dist/api/sse-broker.d.ts +3 -0
  14. package/dist/api/sse-broker.js +14 -0
  15. package/dist/api/sse-route.d.ts +5 -2
  16. package/dist/api/sse-route.js +71 -11
  17. package/dist/bun-db/connection.js +0 -2
  18. package/dist/bun-db/index.d.ts +1 -1
  19. package/dist/bun-db/index.js +1 -1
  20. package/dist/bun-db/query.d.ts +1 -0
  21. package/dist/bun-db/query.js +15 -4
  22. package/dist/crypto/kek-source.js +40 -36
  23. package/dist/crypto/kms-adapter.js +2 -2
  24. package/dist/db/apply-entity-event.js +8 -2
  25. package/dist/db/blind-index-cleanup.js +32 -11
  26. package/dist/db/entity-table-meta.js +5 -3
  27. package/dist/db/event-store-executor-context.js +13 -19
  28. package/dist/db/event-store-executor-read.js +45 -38
  29. package/dist/db/event-store-executor-write.js +15 -17
  30. package/dist/db/migrate-generator.js +27 -28
  31. package/dist/db/pg-error.js +9 -5
  32. package/dist/db/queries/backfill-pii.js +15 -5
  33. package/dist/db/queries/shadow-swap.js +5 -4
  34. package/dist/db/table-builder.d.ts +1 -0
  35. package/dist/db/table-builder.js +7 -15
  36. package/dist/db/tenant-db.js +18 -2
  37. package/dist/derivatives/variant-key.js +3 -1
  38. package/dist/engine/boot-validator/access-declarations.js +12 -3
  39. package/dist/engine/boot-validator/entity-handler.js +13 -1
  40. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  41. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  42. package/dist/engine/boot-validator/index.js +6 -6
  43. package/dist/engine/boot-validator/nav.d.ts +2 -1
  44. package/dist/engine/boot-validator/nav.js +10 -20
  45. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  46. package/dist/engine/boot-validator/ownership.js +2 -1
  47. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  48. package/dist/engine/boot-validator/pii-retention.js +5 -2
  49. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  50. package/dist/engine/boot-validator/query-output-columns.js +53 -5
  51. package/dist/engine/boot-validator/screens.d.ts +2 -2
  52. package/dist/engine/boot-validator/screens.js +125 -46
  53. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  54. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  55. package/dist/engine/boot-validator/zod-shape.js +17 -4
  56. package/dist/engine/build-app-schema.js +65 -20
  57. package/dist/engine/build-config-feature-schema.js +8 -1
  58. package/dist/engine/changeset-changes.js +24 -10
  59. package/dist/engine/create-app.js +10 -1
  60. package/dist/engine/extension-names.d.ts +4 -0
  61. package/dist/engine/extension-names.js +4 -0
  62. package/dist/engine/feature-ast/extractors/events.js +11 -8
  63. package/dist/engine/feature-ast/extractors/handlers.js +39 -16
  64. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  65. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  66. package/dist/engine/feature-ast/parse.d.ts +1 -0
  67. package/dist/engine/feature-ast/parse.js +18 -3
  68. package/dist/engine/feature-ast/patch.js +80 -61
  69. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  70. package/dist/engine/feature-ast/patcher.js +2 -1
  71. package/dist/engine/feature-ast/render.js +1 -1
  72. package/dist/engine/feature-entity-handlers.d.ts +1 -0
  73. package/dist/engine/feature-entity-handlers.js +2 -0
  74. package/dist/engine/index.d.ts +4 -3
  75. package/dist/engine/index.js +3 -2
  76. package/dist/engine/ownership.d.ts +1 -0
  77. package/dist/engine/ownership.js +15 -2
  78. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  79. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  80. package/dist/engine/pattern-library/shared-fields.js +17 -0
  81. package/dist/engine/registry-facade.js +3 -0
  82. package/dist/engine/registry-validate.d.ts +1 -0
  83. package/dist/engine/registry-validate.js +14 -0
  84. package/dist/engine/registry.js +2 -1
  85. package/dist/engine/role-assignment.d.ts +7 -1
  86. package/dist/engine/role-assignment.js +27 -7
  87. package/dist/engine/schema-builder.d.ts +1 -0
  88. package/dist/engine/schema-builder.js +9 -2
  89. package/dist/engine/screen-helpers.js +15 -4
  90. package/dist/engine/steps/_event-match.js +34 -7
  91. package/dist/engine/steps/_read-source.js +8 -0
  92. package/dist/engine/system-user.d.ts +1 -0
  93. package/dist/engine/system-user.js +3 -0
  94. package/dist/engine/where-rule-lint.js +22 -4
  95. package/dist/env/dry-run.js +10 -3
  96. package/dist/env/index.js +21 -1
  97. package/dist/errors/reasons.d.ts +0 -1
  98. package/dist/errors/reasons.js +2 -5
  99. package/dist/event-store/event-store.js +10 -2
  100. package/dist/event-store/provenance-append.js +4 -1
  101. package/dist/files/file-routes.d.ts +0 -1
  102. package/dist/files/file-routes.js +22 -7
  103. package/dist/files/local-provider.js +13 -10
  104. package/dist/files/provider-resolver.js +20 -1
  105. package/dist/files/types.js +16 -1
  106. package/dist/i18n/request-locale.d.ts +3 -2
  107. package/dist/i18n/request-locale.js +16 -6
  108. package/dist/i18n/required-surface-keys.js +23 -35
  109. package/dist/i18n/settings-hub-keys.js +3 -0
  110. package/dist/jobs/index.d.ts +2 -2
  111. package/dist/jobs/index.js +1 -1
  112. package/dist/jobs/job-runner.d.ts +2 -0
  113. package/dist/jobs/job-runner.js +172 -59
  114. package/dist/migrations/index.d.ts +1 -1
  115. package/dist/migrations/index.js +1 -1
  116. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  117. package/dist/migrations/pending-rebuilds.js +23 -1
  118. package/dist/observability/escape-hatch-report.d.ts +1 -0
  119. package/dist/observability/escape-hatch-report.js +3 -0
  120. package/dist/observability/index.d.ts +2 -1
  121. package/dist/observability/index.js +2 -1
  122. package/dist/observability/standard-metrics.d.ts +4 -0
  123. package/dist/observability/standard-metrics.js +16 -0
  124. package/dist/pipeline/dispatch-query.d.ts +1 -1
  125. package/dist/pipeline/dispatch-query.js +2 -2
  126. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  127. package/dist/pipeline/dispatch-shared.js +14 -9
  128. package/dist/pipeline/dispatch-stream.js +2 -5
  129. package/dist/pipeline/dispatch-write.js +22 -5
  130. package/dist/pipeline/dispatcher.js +1 -2
  131. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  132. package/dist/pipeline/event-dispatcher-delivery.js +5 -0
  133. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  134. package/dist/pipeline/event-dispatcher.js +30 -12
  135. package/dist/pipeline/index.d.ts +1 -1
  136. package/dist/pipeline/index.js +1 -1
  137. package/dist/pipeline/member-read-only-transaction.js +1 -14
  138. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  139. package/dist/pipeline/msp-rebuild.js +1 -0
  140. package/dist/pipeline/system-hooks.d.ts +1 -1
  141. package/dist/pipeline/system-hooks.js +19 -20
  142. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  143. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  144. package/dist/pipeline/write-origin.js +13 -6
  145. package/dist/redis/pubsub-signal.d.ts +2 -0
  146. package/dist/redis/pubsub-signal.js +2 -2
  147. package/dist/schema-cli.js +4 -14
  148. package/dist/search/meilisearch-adapter.js +17 -6
  149. package/dist/search/purge-subject.js +8 -3
  150. package/dist/stack/drain-event-consumers.js +3 -0
  151. package/dist/stack/drain-jobs.d.ts +3 -1
  152. package/dist/stack/drain-jobs.js +55 -13
  153. package/dist/stack/table-helpers.js +10 -8
  154. package/dist/testing/closed-connection-error.js +2 -3
  155. package/dist/testing/wait-for.js +1 -0
  156. package/dist/ui-types/index.d.ts +1 -1
  157. package/dist/ui-types/index.js +1 -1
  158. package/dist/upgrade-cli.d.ts +1 -0
  159. package/dist/upgrade-cli.js +36 -15
  160. package/package.json +4 -4
  161. package/src/changes.json +264 -0
  162. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  163. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  164. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  165. package/src/scripts/codemod/pii-personal-migration.ts +111 -44
  166. package/src/scripts/codemod/unprocessable-error-details-reason.ts +136 -42
  167. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  168. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  169. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  170. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  171. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  172. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  173. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  174. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  175. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  176. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  177. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  178. package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
@@ -1,4 +1,4 @@
1
- import { Queue, Worker } from "bullmq";
1
+ import { Queue, UnrecoverableError, Worker } from "bullmq";
2
2
  import { Redis } from "ioredis";
3
3
  import { requestContext } from "../api/request-context.js";
4
4
  import { createTenantDb, createUncheckedSystemDb } from "../db/tenant-db.js";
@@ -10,8 +10,7 @@ import { InternalError } from "../errors/index.js";
10
10
  import { isKumikoError } from "../errors/kumiko-error.js";
11
11
  import { createFileContext } from "../files/file-handle.js";
12
12
  import { createFallbackLogger } from "../logging/index.js";
13
- import { createEscapeHatchReporter } from "../observability/escape-hatch-report.js";
14
- import { emitJobLastSuccess, emitJobQueueDepth, getFallbackTracer, } from "../observability/index.js";
13
+ import { createEscapeHatchReporter, emitJobLastSuccess, emitJobQueueDepth, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
15
14
  import { createDistributedLock } from "../pipeline/distributed-lock.js";
16
15
  import { RedisKeys } from "../pipeline/redis-keys.js";
17
16
  import { buildPersonalDataGate, isPersonalDataGated, parseWriteOrigin, } from "../pipeline/write-origin.js";
@@ -122,6 +121,14 @@ function createJobLogger(logs, baseContext = {}) {
122
121
  };
123
122
  return logger;
124
123
  }
124
+ // Single source of the stored subject string: the writer (jobSubjectKey) and
125
+ // the tenant failures query both go through it. Matching is exact — field names
126
+ // and JSON types must equal what the writer stored (`"1"` is not `1`).
127
+ export function serializeJobSubject(subject) {
128
+ return JSON.stringify(Object.keys(subject)
129
+ .sort()
130
+ .map((field) => [field, subject[field] ?? null]));
131
+ }
125
132
  // Stable identity for the declared payload fields: sorted keys, so two runs
126
133
  // with the same subject values produce the same string and the later one
127
134
  // replaces the earlier record. Non-primitives would serialize into something
@@ -129,15 +136,31 @@ function createJobLogger(logs, baseContext = {}) {
129
136
  function jobSubjectKey(jobName, payload, fields) {
130
137
  if (fields === undefined || fields.length === 0)
131
138
  return null;
132
- const entries = [];
133
- for (const field of [...fields].sort()) {
139
+ const subject = {};
140
+ for (const field of fields) {
134
141
  const value = payload[field] ?? null;
135
142
  if (value !== null && typeof value === "object") {
136
143
  throw new Error(`Job "${jobName}": tenantVisibleFailure.subjectFields["${field}"] must be a primitive, got ${typeof value}`);
137
144
  }
138
- entries.push([field, value]);
145
+ subject[field] = value;
139
146
  }
140
- return JSON.stringify(entries);
147
+ return serializeJobSubject(subject);
148
+ }
149
+ // Jobs that never touch ctx.db run fine on a runner built without one; a job
150
+ // that does gets a named error instead of a bare TypeError on undefined.
151
+ function missingDbProxy(jobName) {
152
+ // @cast-boundary db-operator — every access throws, so no TenantDb member is ever returned
153
+ return new Proxy({}, {
154
+ get() {
155
+ throw new InternalError({
156
+ message: `[jobs] job runner constructed without a db connection — ctx.db is unavailable for "${jobName}"`,
157
+ });
158
+ },
159
+ });
160
+ }
161
+ function priorAttemptsOf(data) {
162
+ const value = data["_priorAttempts"];
163
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 0;
141
164
  }
142
165
  // Only a translation key ever travels to the tenant: the thrown error's own
143
166
  // i18nKey when it carries one, otherwise the key declared at the job. The
@@ -202,6 +225,12 @@ function parseRedisOpts(url) {
202
225
  // redisOpts carries no connectTimeout/retry cap, so an unreachable Redis
203
226
  // would otherwise hang start() forever with no health endpoint to notice.
204
227
  const BOOT_REDIS_TIMEOUT_MS = 10_000;
228
+ // Shutdown must fit a k8s grace period even when Redis is down, so the
229
+ // readiness wait in stop() is capped well below the boot timeout.
230
+ const STOP_QUEUE_READY_TIMEOUT_MS = 1_000;
231
+ // A gate without an explicit `timeout` may legitimately run long (migration-style
232
+ // checks), so it is never rejected; a hang just must not stay silent.
233
+ const BOOT_GATE_SLOW_WARN_MS = 60_000;
205
234
  function timeoutReject(ms, message) {
206
235
  let timer;
207
236
  const promise = new Promise((_, reject) => {
@@ -224,10 +253,10 @@ const DEFAULT_JOB_BACKOFF_DELAY_MS = 1_000;
224
253
  // perTenant wrapper/children, sequential re-enqueue) — a job with `retries`
225
254
  // set must retry the same way regardless of how it got enqueued, or it
226
255
  // fails for good on the very first error on whichever path skips this.
227
- function buildRetryBullOpts(jobDef) {
256
+ function buildRetryBullOpts(jobDef, priorAttempts = 0) {
228
257
  const opts = {};
229
258
  if (jobDef.retries !== undefined)
230
- opts.attempts = jobDef.retries + 1;
259
+ opts.attempts = Math.max(1, jobDef.retries + 1 - priorAttempts);
231
260
  if (jobDef.backoff) {
232
261
  opts.backoff =
233
262
  typeof jobDef.backoff === "string"
@@ -282,6 +311,10 @@ export function createJobRunner(options) {
282
311
  // boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
283
312
  const tracer = context.tracer ?? getFallbackTracer();
284
313
  const errorLogger = createFallbackLogger("job-runner", context.log);
314
+ // The last-success gauge throws if its metric was never registered; the runner
315
+ // must not depend on buildServer having done it first. Idempotent.
316
+ if (context.meter)
317
+ registerStandardMetrics(context.meter);
285
318
  // Set at the top of stop() — a graceful shutdown closes the redis/BullMQ
286
319
  // clients itself, which fires the exact same 'error' listeners below with
287
320
  // an expected "Connection is closed." Downgrading to debug once stopping
@@ -318,7 +351,12 @@ export function createJobRunner(options) {
318
351
  registry,
319
352
  ...bridgeStub(),
320
353
  });
321
- // @cast-boundary engine-payload — generic query-handler return for typed convention
354
+ if (!Array.isArray(result) || result.some((id) => typeof id !== "string")) {
355
+ throw new InternalError({
356
+ message: `${ACTIVE_TENANT_IDS_QUERY_NAME} must return string[] of tenant ids`,
357
+ });
358
+ }
359
+ // @cast-boundary engine-payload — checked to be string[] above; TenantId is a brand
322
360
  return result;
323
361
  };
324
362
  }
@@ -455,6 +493,12 @@ export function createJobRunner(options) {
455
493
  }
456
494
  return false;
457
495
  }
496
+ async function reportPreRunFailure(jobName, bullJob, err) {
497
+ const message = err instanceof Error ? err.message : String(err);
498
+ await options.onJobFailed?.(jobName, bullJob.id ?? "unknown", message, [
499
+ { level: "error", message, timestamp: Temporal.Now.instant() },
500
+ ]);
501
+ }
458
502
  async function handleJob(bullJob) {
459
503
  const rawName = bullJob.name;
460
504
  // Handle perTenant dispatch jobs — fan out to one job per tenant. The
@@ -463,34 +507,51 @@ export function createJobRunner(options) {
463
507
  // (it's picked up by this runner's own worker).
464
508
  if (rawName.startsWith("_perTenant:")) {
465
509
  const actualName = rawName.slice("_perTenant:".length);
466
- if (!getActiveTenantIds) {
467
- throw new Error(`perTenant job "${actualName}" requires either options.getActiveTenantIds or the ` +
468
- `tenant feature mounted (it registers "${ACTIVE_TENANT_IDS_QUERY_NAME}", which the ` +
469
- "framework uses to resolve active tenants on its own)");
470
- }
471
- const actualDef = allJobs.get(actualName);
472
- if (!actualDef) {
473
- throw new Error(`Unknown job: ${actualName}`);
510
+ // These throws happen before runInSpan, so its onJobFailed never sees them
511
+ // and a drained test stack would stay green over a fan-out that never ran.
512
+ try {
513
+ if (!getActiveTenantIds) {
514
+ if (registry.getQueryHandler(ACTIVE_TENANT_IDS_QUERY_NAME)) {
515
+ throw new Error(`perTenant job "${actualName}": the tenant feature is mounted but the job runner has no ` +
516
+ "`context.db`: pass a DbConnection in the runner context or supply options.getActiveTenantIds");
517
+ }
518
+ throw new Error(`perTenant job "${actualName}" requires either options.getActiveTenantIds or the ` +
519
+ `tenant feature mounted (it registers "${ACTIVE_TENANT_IDS_QUERY_NAME}", which the ` +
520
+ "framework uses to resolve active tenants on its own)");
521
+ }
522
+ const actualDef = allJobs.get(actualName);
523
+ if (!actualDef) {
524
+ throw new Error(`Unknown job: ${actualName}`);
525
+ }
526
+ const tenantIds = await getActiveTenantIds();
527
+ const targetQueue = queues[laneForJob(actualDef)];
528
+ // wrapperJobId is only absent for a Job BullMQ somehow never assigned
529
+ // an id to (not observed in practice) — falling back to a constant
530
+ // there would collide every such run's children onto the same ids
531
+ // and silently swallow later runs as "duplicates", which is worse
532
+ // than the duplicate this is meant to prevent. Skip id-based dedup
533
+ // for that edge case instead: enqueue plainly, same as before.
534
+ const wrapperJobId = bullJob.id;
535
+ for (const tenantId of tenantIds) {
536
+ await targetQueue.add(actualName, { ...bullJob.data, _tenantId: tenantId }, {
537
+ ...buildRetryBullOpts(actualDef),
538
+ // Dedup over wrapper retries only holds as long as the children
539
+ // stay in Redis for the wrapper's whole retry window — see the
540
+ // COMPLETED_JOB_RETENTION_AGE_SEC invariant check above.
541
+ ...(wrapperJobId !== undefined
542
+ ? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
543
+ : {}),
544
+ });
545
+ }
474
546
  }
475
- const tenantIds = await getActiveTenantIds();
476
- const targetQueue = queues[laneForJob(actualDef)];
477
- // wrapperJobId is only absent for a Job BullMQ somehow never assigned
478
- // an id to (not observed in practice) — falling back to a constant
479
- // there would collide every such run's children onto the same ids
480
- // and silently swallow later runs as "duplicates", which is worse
481
- // than the duplicate this is meant to prevent. Skip id-based dedup
482
- // for that edge case instead: enqueue plainly, same as before.
483
- const wrapperJobId = bullJob.id;
484
- for (const tenantId of tenantIds) {
485
- await targetQueue.add(actualName, { ...bullJob.data, _tenantId: tenantId }, {
486
- ...buildRetryBullOpts(actualDef),
487
- // Dedup over wrapper retries only holds as long as the children
488
- // stay in Redis for the wrapper's whole retry window — see the
489
- // COMPLETED_JOB_RETENTION_AGE_SEC invariant check above.
490
- ...(wrapperJobId !== undefined
491
- ? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
492
- : {}),
493
- });
547
+ catch (err) {
548
+ // A retry may still succeed, and a succeeding wrapper reports no completion
549
+ // that could clear an earlier failure, so only the final attempt is reported.
550
+ const retries = allJobs.get(actualName)?.retries ?? 0;
551
+ if (bullJob.attemptsMade + 1 >= retries + 1) {
552
+ await reportPreRunFailure(rawName, bullJob, err);
553
+ }
554
+ throw err;
494
555
  }
495
556
  // skip: fan-out dispatcher job, per-tenant children enqueued
496
557
  return;
@@ -498,7 +559,9 @@ export function createJobRunner(options) {
498
559
  const jobName = rawName;
499
560
  const jobDef = allJobs.get(jobName);
500
561
  if (!jobDef) {
501
- throw new Error(`Unknown job: ${jobName}`);
562
+ const err = new Error(`Unknown job: ${jobName}`);
563
+ await reportPreRunFailure(jobName, bullJob, err);
564
+ throw err;
502
565
  }
503
566
  // Sequential gate: try to claim the per-name lock. If another worker
504
567
  // (or this worker on a different bullJob) holds it, re-enqueue with a
@@ -516,13 +579,12 @@ export function createJobRunner(options) {
516
579
  // same queue the worker just picked from (since only the consuming
517
580
  // lane runs handleJob at all), but route explicitly — no implicit
518
581
  // coupling to "whichever queue the caller happened to be on".
519
- // The re-enqueued job starts with a full retry budget; a remaining
520
- // budget is deliberately not carried over, since finalAttempt/
521
- // tenantVisibleFailure are computed from jobDef.retries against
522
- // attemptsMade, not against some inherited remainder.
523
- await queues[laneForJob(jobDef)].add(jobName, bullJob.data, {
582
+ // Attempts already spent travel in _priorAttempts so a lock conflict
583
+ // on a retry cannot hand out a fresh budget and exceed `retries`.
584
+ const priorAttempts = priorAttemptsOf(bullJob.data) + bullJob.attemptsMade;
585
+ await queues[laneForJob(jobDef)].add(jobName, { ...bullJob.data, _priorAttempts: priorAttempts }, {
524
586
  delay: SEQUENTIAL_RETRY_DELAY_MS,
525
- ...buildRetryBullOpts(jobDef),
587
+ ...buildRetryBullOpts(jobDef, priorAttempts),
526
588
  });
527
589
  // skip: lock taken, work re-enqueued with delay, current invocation done
528
590
  return;
@@ -537,7 +599,7 @@ export function createJobRunner(options) {
537
599
  // retry number, so audit queries distinguish fresh from retry runs
538
600
  // without peeking at BullMQ internals.
539
601
  const rawData = bullJob.data;
540
- const attempt = bullJob.attemptsMade + 1;
602
+ const attempt = bullJob.attemptsMade + 1 + priorAttemptsOf(rawData);
541
603
  const meta = {
542
604
  triggeredById: rawData["_triggeredById"], // @cast-boundary dynamic-key
543
605
  payload: rawData["_payload"], // @cast-boundary dynamic-key
@@ -558,16 +620,26 @@ export function createJobRunner(options) {
558
620
  // subject is read from the handler payload, so it is resolved here where
559
621
  // the payload is built, not in the callbacks.
560
622
  const tenantVisibleDecl = jobDef.tenantVisibleFailure;
561
- const tenantVisibleSubject = tenantVisibleDecl
562
- ? jobSubjectKey(jobName, payload, tenantVisibleDecl.subjectFields)
563
- : null;
623
+ // A bad subject config must surface as a failed run, so it is thrown from
624
+ // inside runInSpan below instead of here, before any run record exists.
625
+ let tenantVisibleSubject = null;
626
+ let subjectConfigError;
627
+ if (tenantVisibleDecl) {
628
+ try {
629
+ tenantVisibleSubject = jobSubjectKey(jobName, payload, tenantVisibleDecl.subjectFields);
630
+ }
631
+ catch (err) {
632
+ subjectConfigError = err instanceof Error ? err : new Error(String(err));
633
+ }
634
+ }
564
635
  // BullMQ stops retrying once attemptsMade reaches the configured attempts
565
636
  // (`retries + 1`), so this is the attempt whose failure is final.
566
- const finalAttempt = attempt >= (jobDef.retries ?? 0) + 1;
637
+ const finalAttempt = bullJob.retryable === false || attempt >= (jobDef.retries ?? 0) + 1;
567
638
  const outcomeMeta = (messageKey) => ({
568
639
  tenantId,
569
640
  finalAttempt,
570
- ...(tenantVisibleDecl && {
641
+ ...(tenantVisibleDecl &&
642
+ !subjectConfigError && {
571
643
  tenantVisible: { subject: tenantVisibleSubject, messageKey },
572
644
  }),
573
645
  });
@@ -658,8 +730,7 @@ export function createJobRunner(options) {
658
730
  let memberReader;
659
731
  const jobContext = {
660
732
  ...context,
661
- // Undefined only for a runner built without a db; JobContext requires db non-optional.
662
- db: jobDb, // @cast-boundary db-operator
733
+ db: jobDb ?? missingDbProxy(jobName),
663
734
  files,
664
735
  derivatives,
665
736
  ...(notify !== undefined && { notify }),
@@ -721,6 +792,10 @@ export function createJobRunner(options) {
721
792
  const jobCorrelationId = inheritedCorrelationId ?? jobRequestId;
722
793
  const runInSpan = async () => {
723
794
  try {
795
+ if (subjectConfigError) {
796
+ // Retrying cannot fix a declaration error.
797
+ throw new UnrecoverableError(subjectConfigError.message);
798
+ }
724
799
  if (writeOriginInvalid) {
725
800
  throw new InternalError({
726
801
  message: `Job "${jobName}" received an unparseable _writeOrigin — refusing to run without ` +
@@ -732,13 +807,22 @@ export function createJobRunner(options) {
732
807
  correlationId: jobCorrelationId,
733
808
  // #3043 — events a job writes carry the job as their origin.
734
809
  handler: jobName,
735
- feature: qnScope(jobName),
810
+ feature: registry.getJobFeature(jobName) ?? qnScope(jobName),
736
811
  writeOrigin: jobOrigin,
737
812
  }, () => jobDef.handler(payload, jobContext));
738
813
  // Stamped before the observer hook: a throwing onJobComplete must not
739
814
  // make a run that actually succeeded look dead to the liveness alert.
740
- if (context.meter)
741
- emitJobLastSuccess(context.meter, jobName);
815
+ if (context.meter) {
816
+ try {
817
+ emitJobLastSuccess(context.meter, jobName);
818
+ }
819
+ catch (err) {
820
+ errorLogger.warn("job last-success metric failed", {
821
+ job: jobName,
822
+ error: err instanceof Error ? err.message : String(err),
823
+ });
824
+ }
825
+ }
742
826
  const duration = Date.now() - startTime;
743
827
  await options.onJobComplete?.(jobName, jobId, duration, logs, outcomeMeta(null));
744
828
  }
@@ -794,8 +878,28 @@ export function createJobRunner(options) {
794
878
  continue;
795
879
  if (!jobDef.bootGate)
796
880
  continue;
881
+ const gateTimeout = jobDef.timeout !== undefined
882
+ ? timeoutReject(jobDef.timeout, `job-runner: boot gate "${name}" did not finish within ${jobDef.timeout}ms`)
883
+ : undefined;
884
+ const slowWarnTimer = gateTimeout === undefined
885
+ ? setTimeout(() => {
886
+ errorLogger.warn("boot gate still running, no timeout configured", {
887
+ gate: name,
888
+ afterMs: BOOT_GATE_SLOW_WARN_MS,
889
+ });
890
+ }, BOOT_GATE_SLOW_WARN_MS)
891
+ : undefined;
797
892
  try {
798
- await handleJob({ id: bootJobIdForJobName(name), name, data: {}, attemptsMade: 0 });
893
+ await Promise.race([
894
+ handleJob({
895
+ id: bootJobIdForJobName(name),
896
+ name,
897
+ data: {},
898
+ attemptsMade: 0,
899
+ retryable: false,
900
+ }),
901
+ ...(gateTimeout ? [gateTimeout.promise] : []),
902
+ ]);
799
903
  }
800
904
  catch (err) {
801
905
  // The worker is already consuming here; leaving its Redis connections
@@ -805,6 +909,11 @@ export function createJobRunner(options) {
805
909
  worker = null;
806
910
  throw err;
807
911
  }
912
+ finally {
913
+ gateTimeout?.cancel();
914
+ if (slowWarnTimer !== undefined)
915
+ clearTimeout(slowWarnTimer);
916
+ }
808
917
  }
809
918
  }
810
919
  const runnerApi = {
@@ -925,15 +1034,19 @@ export function createJobRunner(options) {
925
1034
  // rejects the in-flight INFO with "Connection is closed." on an emitter
926
1035
  // without listeners -> unhandled rejection (fw#1805). Wait for readiness
927
1036
  // first; a failed/timed-out wait must not block the close itself.
928
- const readinessTimeout = timeoutReject(bootRedisTimeoutMs, `job-runner: Queue not ready within ${bootRedisTimeoutMs}ms during stop()`);
1037
+ const stopReadyTimeoutMs = Math.min(bootRedisTimeoutMs, STOP_QUEUE_READY_TIMEOUT_MS);
1038
+ const readinessTimeout = timeoutReject(stopReadyTimeoutMs, `job-runner: Queue not ready within ${stopReadyTimeoutMs}ms during stop()`);
929
1039
  try {
930
1040
  await Promise.race([
931
1041
  Promise.all([queues.api.waitUntilReady(), queues.worker.waitUntilReady()]),
932
1042
  readinessTimeout.promise,
933
1043
  ]);
934
1044
  }
935
- catch {
936
- // close() below still has to run
1045
+ catch (err) {
1046
+ // close() below still has to run; stopping is already true, so debug matches the other shutdown logs
1047
+ errorLogger.debug("queue readiness wait failed during stop()", {
1048
+ error: err instanceof Error ? err.message : String(err),
1049
+ });
937
1050
  }
938
1051
  finally {
939
1052
  readinessTimeout.cancel();
@@ -1,4 +1,4 @@
1
1
  export { assertKumikoSchemaCurrent, type ChecksumMismatch, detectKumikoDrift, formatKumikoDriftReport, type KumikoDriftReport, SchemaDriftError, } from "./kumiko-drift.js";
2
- export { createPendingRebuildsTable, type EnqueueProjectionRebuildDeps, type EnqueueProjectionRebuildResult, enqueueProjectionRebuild, listPendingRebuilds, type PendingRebuildRun, PROJECTION_REBUILD_JOB, pendingRebuildsTable, queueRebuildsFromMarkers, type RunPendingRebuildsOptions, rebuildProjectionOrMultiStream, runPendingRebuilds, } from "./pending-rebuilds.js";
2
+ export { createPendingRebuildsTable, type EnqueueProjectionRebuildDeps, type EnqueueProjectionRebuildResult, enqueueProjectionRebuild, listPendingRebuilds, type PendingRebuildRun, PROJECTION_REBUILD_JOB, pendingRebuildsTable, queueRebuildsFromMarkers, type RunPendingRebuildsOptions, rebuildProjectionOrMultiStream, reportPendingRebuildRun, runPendingRebuilds, } from "./pending-rebuilds.js";
3
3
  export { createPiiBackfillStateTable, type PiiEventBackfillOptions, type PiiEventBackfillResult, type PiiRebuildRun, piiAnnotationFingerprint, piiBackfillStateTable, runPiiEventBackfill, } from "./pii-event-backfill.js";
4
4
  export { buildProjectionTableIndex } from "./projection-table-index.js";
@@ -1,7 +1,7 @@
1
1
  // Drizzle-free gate (kumiko/migrations system) — the canonical boot-gate.
2
2
  export { assertKumikoSchemaCurrent, detectKumikoDrift, formatKumikoDriftReport, SchemaDriftError, } from "./kumiko-drift.js";
3
3
  // Persistente Pending-Rebuild-Queue (survives Rebuild-Failures + Crashes).
4
- export { createPendingRebuildsTable, enqueueProjectionRebuild, listPendingRebuilds, PROJECTION_REBUILD_JOB, pendingRebuildsTable, queueRebuildsFromMarkers, rebuildProjectionOrMultiStream, runPendingRebuilds, } from "./pending-rebuilds.js";
4
+ export { createPendingRebuildsTable, enqueueProjectionRebuild, listPendingRebuilds, PROJECTION_REBUILD_JOB, pendingRebuildsTable, queueRebuildsFromMarkers, rebuildProjectionOrMultiStream, reportPendingRebuildRun, runPendingRebuilds, } from "./pending-rebuilds.js";
5
5
  // Boot-time backfill: re-encrypts plaintext events after a PII annotation was added.
6
6
  export { createPiiBackfillStateTable, piiAnnotationFingerprint, piiBackfillStateTable, runPiiEventBackfill, } from "./pii-event-backfill.js";
7
7
  // tableName → projection-name, für den app-seitigen Projection-Rebuild.
@@ -57,6 +57,10 @@ export declare function rebuildProjectionOrMultiStream(name: string, deps: {
57
57
  readonly skipApplyErrors?: boolean;
58
58
  };
59
59
  }): Promise<RebuildResult>;
60
+ /** Logs a finished queue drain and throws when any projection failed; the
61
+ * failed tables stay queued. Shared by every `schema apply` entry point so the
62
+ * report and the error text cannot drift apart. */
63
+ export declare function reportPendingRebuildRun(run: PendingRebuildRun, log: (line: string) => void): void;
60
64
  /** Arbeitet die persistierte Queue ab: mappt Tabellen auf Projektionen,
61
65
  * rebuildet jede betroffene Projektion und räumt ihre Tabellen erst nach
62
66
  * ERFOLG aus der Queue. Fehlgeschlagene bleiben pending — der nächste
@@ -71,7 +71,12 @@ export async function clearPendingRebuilds(db, rows) {
71
71
  export async function rebuildProjectionOrMultiStream(name, deps) {
72
72
  const { db, registry, signal, errorPolicy } = deps;
73
73
  if (registry.getAllMultiStreamProjections().has(name)) {
74
- return rebuildMultiStreamProjection(name, { db, registry, markDeadOnFailure: false });
74
+ return rebuildMultiStreamProjection(name, {
75
+ db,
76
+ registry,
77
+ markDeadOnFailure: false,
78
+ ...(signal && { signal }),
79
+ });
75
80
  }
76
81
  return rebuildProjection(name, {
77
82
  db,
@@ -80,6 +85,23 @@ export async function rebuildProjectionOrMultiStream(name, deps) {
80
85
  ...(errorPolicy && { errorPolicy }),
81
86
  });
82
87
  }
88
+ /** Logs a finished queue drain and throws when any projection failed; the
89
+ * failed tables stay queued. Shared by every `schema apply` entry point so the
90
+ * report and the error text cannot drift apart. */
91
+ export function reportPendingRebuildRun(run, log) {
92
+ if (run.rebuilt.length > 0) {
93
+ log(` Rebuild ${run.rebuilt.length} Projection(s)…`);
94
+ for (const r of run.rebuilt) {
95
+ log(` ↻ ${r.projection} (${r.eventsProcessed} events)`);
96
+ }
97
+ log("");
98
+ }
99
+ if (run.failed.length > 0) {
100
+ throw new Error(`Projection rebuild failed for: ${run.failed
101
+ .map((f) => `${f.projection} (${f.error})`)
102
+ .join("; ")}. Table(s) stay queued in kumiko_pending_rebuilds — retried on the next apply.`);
103
+ }
104
+ }
83
105
  /** Arbeitet die persistierte Queue ab: mappt Tabellen auf Projektionen,
84
106
  * rebuildet jede betroffene Projektion und räumt ihre Tabellen erst nach
85
107
  * ERFOLG aus der Queue. Fehlgeschlagene bleiben pending — der nächste
@@ -20,4 +20,5 @@ export declare function createEscapeHatchReporter(opts: {
20
20
  readonly log?: Logger;
21
21
  readonly window?: EscapeHatchReportWindow;
22
22
  }): EscapeHatchReporter;
23
+ export declare const UNATTRIBUTED_ACTOR = "system";
23
24
  export declare function fallbackEscapeHatchReporter(tenantId: TenantId): EscapeHatchReporter;
@@ -83,6 +83,9 @@ export function createEscapeHatchReporter(opts) {
83
83
  reportEscapeHatchUse({ handler: opts.handler, kind, reason, tenantId: opts.tenantId, actor: opts.actor, target }, { sink: opts.sink, log: opts.log });
84
84
  };
85
85
  }
86
+ // Actor for escape-hatch uses whose caller did not thread one through. Stays
87
+ // "system" so audit events persisted before the constant existed remain valid.
88
+ export const UNATTRIBUTED_ACTOR = "system";
86
89
  export function fallbackEscapeHatchReporter(tenantId) {
87
90
  return createEscapeHatchReporter({
88
91
  handler: "<unattributed>",
@@ -1,5 +1,6 @@
1
1
  export { type ConsoleProviderOptions, createConsoleProvider } from "./console-provider.js";
2
2
  export { observabilityContext } from "./context.js";
3
+ export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, type EscapeHatchReportWindow, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
3
4
  export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
4
5
  export { generateSpanId, generateTraceId } from "./ids.js";
5
6
  export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
@@ -11,5 +12,5 @@ export { type MetricEvent, type MetricEventHandler, RecordingMeter, } from "./re
11
12
  export { type RecordedSpan, RecordingTracer, type RecordingTracerOptions, serializeSpanContext, } from "./recording-tracer.js";
12
13
  export { wrapRedisClient } from "./redis-wrapper.js";
13
14
  export { DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, REDACTED, redactAttributes, redactHeaders, redactQueryString, redactValue, shouldRedactAttribute, } from "./sensitive-filter.js";
14
- export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
15
+ export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
15
16
  export type { Counter, Gauge, Histogram, Meter, MetricDefinition, MetricLabels, MetricsHandle, MetricType, ObservabilityOptions, ObservabilityProvider, SamplingConfig, SensitiveFilterConfig, SerializedTraceContext, Span, SpanAttributes, SpanAttributeValue, SpanKind, SpanStatus, StartSpanOptions, Tracer, } from "./types/index.js";
@@ -1,6 +1,7 @@
1
1
  // Public surface of the observability module.
2
2
  export { createConsoleProvider } from "./console-provider.js";
3
3
  export { observabilityContext } from "./context.js";
4
+ export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
4
5
  export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
5
6
  export { generateSpanId, generateTraceId } from "./ids.js";
6
7
  export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
@@ -12,4 +13,4 @@ export { RecordingMeter, } from "./recording-meter.js";
12
13
  export { RecordingTracer, serializeSpanContext, } from "./recording-tracer.js";
13
14
  export { wrapRedisClient } from "./redis-wrapper.js";
14
15
  export { DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, REDACTED, redactAttributes, redactHeaders, redactQueryString, redactValue, shouldRedactAttribute, } from "./sensitive-filter.js";
15
- export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
16
+ export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
@@ -32,6 +32,10 @@ export declare function emitEventConsumerRearmExhausted(meter: Meter, labels: {
32
32
  readonly consumer: string;
33
33
  readonly instanceId: string;
34
34
  }): void;
35
+ export declare function emitEventConsumerPassSkipped(meter: Meter, labels: {
36
+ readonly consumer: string;
37
+ readonly instanceId: string;
38
+ }, reason: "idle"): void;
35
39
  export declare function emitEventDispatcherListenConnected(meter: Meter, connected: boolean): void;
36
40
  export declare function emitEventConsumerPassOutcome(meter: Meter, labels: {
37
41
  readonly consumer: string;
@@ -93,6 +93,15 @@ export const STANDARD_METRIC_DEFS = [
93
93
  description: "Consumer transitioned to dead with its auto-rearm budget exhausted.",
94
94
  labels: ["consumer", "instance_id"],
95
95
  },
96
+ // An idle-gated consumer turn opens no events.consumer.pass span (the lock-free
97
+ // pre-check proves there is nothing to do), so this counter is the only trace of
98
+ // per-tick pass frequency for consumers that are idle or disabled.
99
+ {
100
+ name: "kumiko_event_consumer_pass_skipped_total",
101
+ type: "counter",
102
+ description: "Consumer turns skipped before opening a pass span because the idle pre-check proved nothing to deliver.",
103
+ labels: ["consumer", "instance_id", "reason"],
104
+ },
96
105
  // LISTEN-subscription health. 1 = active, 0 = dropped. Drops to 0 while
97
106
  // the dispatcher is running signal that delivery latency has regressed
98
107
  // from sub-millisecond (LISTEN) to pollIntervalMs (timer fallback); ops
@@ -203,6 +212,13 @@ export function emitEventConsumerRearmExhausted(meter, labels) {
203
212
  instance_id: labels.instanceId,
204
213
  });
205
214
  }
215
+ export function emitEventConsumerPassSkipped(meter, labels, reason) {
216
+ meter.counter("kumiko_event_consumer_pass_skipped_total").inc(1, {
217
+ consumer: labels.consumer,
218
+ instance_id: labels.instanceId,
219
+ reason,
220
+ });
221
+ }
206
222
  export function emitEventDispatcherListenConnected(meter, connected) {
207
223
  meter.gauge("kumiko_event_dispatcher_listen_connected").set(connected ? 1 : 0);
208
224
  }
@@ -1,4 +1,4 @@
1
1
  import type { DbTx } from "../db/connection.js";
2
2
  import type { SessionUser } from "../engine/types/index.js";
3
- import type { DispatchContext, WriteOrigin } from "./dispatch-shared.js";
3
+ import { type DispatchContext, type WriteOrigin } from "./dispatch-shared.js";
4
4
  export declare function executeQuery(ctx: DispatchContext, type: string, payload: unknown, user: SessionUser, origin: WriteOrigin, tx?: DbTx): Promise<unknown>;
@@ -2,7 +2,7 @@ import { hasAccess } from "../engine/access.js";
2
2
  import { filterReadFields } from "../engine/field-access.js";
3
3
  import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors/index.js";
4
4
  import { assertNoSecretLeak } from "../secrets/index.js";
5
- import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, runHandlerInstrumented, } from "./dispatch-shared.js";
5
+ import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, isMemberResolutionPrincipal, runHandlerInstrumented, } from "./dispatch-shared.js";
6
6
  import { runInMemberReadOnlyTransaction } from "./member-read-only-transaction.js";
7
7
  // Standalone query execution — used by the public dispatcher.query() and
8
8
  // by ctx.query/ctx.queryAs inside handlers. Runs the handler, applies
@@ -51,7 +51,7 @@ async function executeQueryInner(ctx, type, payload, user, origin, tx) {
51
51
  parsed.data !== null &&
52
52
  parsed.data["includeDeleted"] === true; // @cast-boundary validated-payload
53
53
  // A resolved member (ctx.queryAsMember) runs in a Postgres READ ONLY transaction, not just the ctx surface below.
54
- return user.origin === "member-resolution"
54
+ return isMemberResolutionPrincipal(user)
55
55
  ? runInMemberReadOnlyTransaction(ctx, tx, (readOnlyTx) => runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, readOnlyTx))
56
56
  : runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, tx);
57
57
  }
@@ -6,8 +6,7 @@ import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extensio
6
6
  import type { TenantId } from "../engine/types/identifiers.js";
7
7
  import type { AppContext, HandlerContext, JobRunnerRef, RateLimitDeclaration, Registry, SessionUser, WriteResult } from "../engine/types/index.js";
8
8
  import { type WriteErrorInfo } from "../errors/index.js";
9
- import { type EscapeHatchReportWindow } from "../observability/escape-hatch-report.js";
10
- import { type getFallbackMeter, getFallbackTracer } from "../observability/index.js";
9
+ import { type EscapeHatchReportWindow, type getFallbackMeter, getFallbackTracer } from "../observability/index.js";
11
10
  import { type AfterCommitHook } from "./dispatcher-utils.js";
12
11
  import type { IdempotencyGuard } from "./idempotency.js";
13
12
  import type { LifecycleHooks } from "./lifecycle-pipeline.js";
@@ -49,7 +48,7 @@ export type DispatchContext = {
49
48
  export declare function resolveDbSource(ctx: DispatchContext, tx: DbTx | undefined): DbConnection | DbTx | undefined;
50
49
  export declare function isMemberResolutionPrincipal(user: SessionUser): boolean;
51
50
  export declare function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext;
52
- export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean): Promise<HandlerContext>;
51
+ export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean, isPostCommit?: boolean): Promise<HandlerContext>;
53
52
  export declare function runHandlerInstrumented<T>(ctx: DispatchContext, type: string, operation: "query" | "write" | "stream", user: SessionUser, inner: () => Promise<T>): Promise<T>;
54
53
  export declare function runStreamInstrumented<T>(ctx: DispatchContext, type: string, user: SessionUser, inner: () => AsyncGenerator<T>): AsyncGenerator<T>;
55
54
  export declare function checkFeatureEnabled(ctx: DispatchContext, qualifiedHandler: string, tenantId: TenantId): Promise<import("../errors/index.js").FeatureDisabledError | undefined>;