@cosmicdrift/kumiko-framework 0.334.0 → 0.335.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 (157) hide show
  1. package/dist/api/auth-routes.js +19 -4
  2. package/dist/api/client-ip.js +3 -6
  3. package/dist/api/extra-route.js +3 -2
  4. package/dist/api/pii-leak-guard.js +7 -4
  5. package/dist/api/redis-sse-broker.d.ts +2 -0
  6. package/dist/api/redis-sse-broker.js +6 -7
  7. package/dist/api/routes.js +16 -17
  8. package/dist/api/server.js +14 -7
  9. package/dist/api/sse-broker.d.ts +3 -0
  10. package/dist/api/sse-broker.js +14 -0
  11. package/dist/api/sse-route.d.ts +1 -1
  12. package/dist/api/sse-route.js +39 -6
  13. package/dist/bun-db/connection.js +0 -2
  14. package/dist/bun-db/index.d.ts +1 -1
  15. package/dist/bun-db/index.js +1 -1
  16. package/dist/bun-db/query.d.ts +1 -0
  17. package/dist/bun-db/query.js +15 -4
  18. package/dist/crypto/kek-source.js +40 -36
  19. package/dist/crypto/kms-adapter.js +2 -2
  20. package/dist/db/apply-entity-event.js +8 -2
  21. package/dist/db/blind-index-cleanup.js +11 -1
  22. package/dist/db/entity-table-meta.js +5 -3
  23. package/dist/db/event-store-executor-context.js +13 -19
  24. package/dist/db/event-store-executor-read.js +39 -36
  25. package/dist/db/event-store-executor-write.js +15 -17
  26. package/dist/db/migrate-generator.js +27 -28
  27. package/dist/db/pg-error.js +9 -5
  28. package/dist/db/queries/backfill-pii.js +15 -5
  29. package/dist/db/queries/shadow-swap.js +5 -4
  30. package/dist/db/table-builder.d.ts +1 -0
  31. package/dist/db/table-builder.js +7 -15
  32. package/dist/db/tenant-db.js +18 -2
  33. package/dist/derivatives/variant-key.js +3 -1
  34. package/dist/engine/boot-validator/access-declarations.js +12 -3
  35. package/dist/engine/boot-validator/entity-handler.js +13 -1
  36. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  37. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  38. package/dist/engine/boot-validator/index.js +6 -6
  39. package/dist/engine/boot-validator/nav.d.ts +2 -1
  40. package/dist/engine/boot-validator/nav.js +10 -20
  41. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  42. package/dist/engine/boot-validator/ownership.js +2 -1
  43. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  44. package/dist/engine/boot-validator/pii-retention.js +5 -2
  45. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  46. package/dist/engine/boot-validator/query-output-columns.js +41 -5
  47. package/dist/engine/boot-validator/screens.d.ts +2 -2
  48. package/dist/engine/boot-validator/screens.js +103 -33
  49. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  50. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  51. package/dist/engine/boot-validator/zod-shape.js +17 -4
  52. package/dist/engine/build-app-schema.js +62 -19
  53. package/dist/engine/build-config-feature-schema.js +8 -1
  54. package/dist/engine/changeset-changes.js +24 -10
  55. package/dist/engine/feature-ast/extractors/events.js +11 -8
  56. package/dist/engine/feature-ast/extractors/handlers.js +38 -16
  57. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  58. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  59. package/dist/engine/feature-ast/parse.d.ts +1 -0
  60. package/dist/engine/feature-ast/parse.js +18 -3
  61. package/dist/engine/feature-ast/patch.js +80 -61
  62. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  63. package/dist/engine/feature-ast/patcher.js +2 -1
  64. package/dist/engine/feature-ast/render.js +1 -1
  65. package/dist/engine/index.d.ts +4 -3
  66. package/dist/engine/index.js +3 -2
  67. package/dist/engine/ownership.d.ts +1 -0
  68. package/dist/engine/ownership.js +15 -2
  69. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  70. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  71. package/dist/engine/pattern-library/shared-fields.js +17 -0
  72. package/dist/engine/registry-facade.js +3 -0
  73. package/dist/engine/role-assignment.d.ts +7 -1
  74. package/dist/engine/role-assignment.js +27 -7
  75. package/dist/engine/schema-builder.d.ts +1 -0
  76. package/dist/engine/schema-builder.js +9 -2
  77. package/dist/engine/screen-helpers.js +15 -4
  78. package/dist/engine/steps/_event-match.js +22 -3
  79. package/dist/engine/steps/_read-source.js +8 -0
  80. package/dist/engine/where-rule-lint.js +22 -4
  81. package/dist/env/index.js +21 -1
  82. package/dist/errors/reasons.d.ts +0 -1
  83. package/dist/errors/reasons.js +2 -5
  84. package/dist/event-store/event-store.js +6 -1
  85. package/dist/event-store/provenance-append.js +4 -1
  86. package/dist/files/file-routes.d.ts +0 -1
  87. package/dist/files/file-routes.js +22 -7
  88. package/dist/files/local-provider.js +13 -10
  89. package/dist/files/provider-resolver.js +20 -1
  90. package/dist/files/types.js +16 -1
  91. package/dist/i18n/request-locale.d.ts +3 -2
  92. package/dist/i18n/request-locale.js +16 -6
  93. package/dist/i18n/required-surface-keys.js +23 -35
  94. package/dist/i18n/settings-hub-keys.js +3 -0
  95. package/dist/jobs/index.d.ts +2 -2
  96. package/dist/jobs/index.js +1 -1
  97. package/dist/jobs/job-runner.d.ts +2 -0
  98. package/dist/jobs/job-runner.js +150 -59
  99. package/dist/migrations/index.d.ts +1 -1
  100. package/dist/migrations/index.js +1 -1
  101. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  102. package/dist/migrations/pending-rebuilds.js +23 -1
  103. package/dist/observability/escape-hatch-report.d.ts +1 -0
  104. package/dist/observability/escape-hatch-report.js +3 -0
  105. package/dist/observability/index.d.ts +1 -0
  106. package/dist/observability/index.js +1 -0
  107. package/dist/pipeline/dispatch-query.d.ts +1 -1
  108. package/dist/pipeline/dispatch-query.js +2 -2
  109. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  110. package/dist/pipeline/dispatch-shared.js +14 -9
  111. package/dist/pipeline/dispatch-stream.js +2 -5
  112. package/dist/pipeline/dispatch-write.js +22 -5
  113. package/dist/pipeline/dispatcher.js +1 -2
  114. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  115. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  116. package/dist/pipeline/event-dispatcher.js +26 -11
  117. package/dist/pipeline/index.d.ts +1 -1
  118. package/dist/pipeline/index.js +1 -1
  119. package/dist/pipeline/member-read-only-transaction.js +1 -14
  120. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  121. package/dist/pipeline/msp-rebuild.js +1 -0
  122. package/dist/pipeline/system-hooks.d.ts +1 -1
  123. package/dist/pipeline/system-hooks.js +9 -7
  124. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  125. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  126. package/dist/redis/pubsub-signal.d.ts +2 -0
  127. package/dist/redis/pubsub-signal.js +2 -2
  128. package/dist/schema-cli.js +4 -14
  129. package/dist/search/meilisearch-adapter.js +17 -6
  130. package/dist/search/purge-subject.js +8 -3
  131. package/dist/stack/drain-jobs.js +24 -12
  132. package/dist/stack/table-helpers.js +10 -8
  133. package/dist/testing/closed-connection-error.js +2 -3
  134. package/dist/testing/wait-for.js +1 -0
  135. package/dist/ui-types/index.d.ts +1 -1
  136. package/dist/ui-types/index.js +1 -1
  137. package/dist/upgrade-cli.d.ts +1 -0
  138. package/dist/upgrade-cli.js +36 -15
  139. package/package.json +4 -4
  140. package/src/changes.json +206 -0
  141. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  142. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  143. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  144. package/src/scripts/codemod/pii-personal-migration.ts +56 -19
  145. package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
  146. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  147. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  148. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  149. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  150. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  151. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  152. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  153. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  154. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  155. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  156. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  157. 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,9 @@ 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;
205
231
  function timeoutReject(ms, message) {
206
232
  let timer;
207
233
  const promise = new Promise((_, reject) => {
@@ -224,10 +250,10 @@ const DEFAULT_JOB_BACKOFF_DELAY_MS = 1_000;
224
250
  // perTenant wrapper/children, sequential re-enqueue) — a job with `retries`
225
251
  // set must retry the same way regardless of how it got enqueued, or it
226
252
  // fails for good on the very first error on whichever path skips this.
227
- function buildRetryBullOpts(jobDef) {
253
+ function buildRetryBullOpts(jobDef, priorAttempts = 0) {
228
254
  const opts = {};
229
255
  if (jobDef.retries !== undefined)
230
- opts.attempts = jobDef.retries + 1;
256
+ opts.attempts = Math.max(1, jobDef.retries + 1 - priorAttempts);
231
257
  if (jobDef.backoff) {
232
258
  opts.backoff =
233
259
  typeof jobDef.backoff === "string"
@@ -282,6 +308,10 @@ export function createJobRunner(options) {
282
308
  // boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
283
309
  const tracer = context.tracer ?? getFallbackTracer();
284
310
  const errorLogger = createFallbackLogger("job-runner", context.log);
311
+ // The last-success gauge throws if its metric was never registered; the runner
312
+ // must not depend on buildServer having done it first. Idempotent.
313
+ if (context.meter)
314
+ registerStandardMetrics(context.meter);
285
315
  // Set at the top of stop() — a graceful shutdown closes the redis/BullMQ
286
316
  // clients itself, which fires the exact same 'error' listeners below with
287
317
  // an expected "Connection is closed." Downgrading to debug once stopping
@@ -318,7 +348,12 @@ export function createJobRunner(options) {
318
348
  registry,
319
349
  ...bridgeStub(),
320
350
  });
321
- // @cast-boundary engine-payload — generic query-handler return for typed convention
351
+ if (!Array.isArray(result) || result.some((id) => typeof id !== "string")) {
352
+ throw new InternalError({
353
+ message: `${ACTIVE_TENANT_IDS_QUERY_NAME} must return string[] of tenant ids`,
354
+ });
355
+ }
356
+ // @cast-boundary engine-payload — checked to be string[] above; TenantId is a brand
322
357
  return result;
323
358
  };
324
359
  }
@@ -455,6 +490,12 @@ export function createJobRunner(options) {
455
490
  }
456
491
  return false;
457
492
  }
493
+ async function reportPreRunFailure(jobName, bullJob, err) {
494
+ const message = err instanceof Error ? err.message : String(err);
495
+ await options.onJobFailed?.(jobName, bullJob.id ?? "unknown", message, [
496
+ { level: "error", message, timestamp: Temporal.Now.instant() },
497
+ ]);
498
+ }
458
499
  async function handleJob(bullJob) {
459
500
  const rawName = bullJob.name;
460
501
  // Handle perTenant dispatch jobs — fan out to one job per tenant. The
@@ -463,34 +504,51 @@ export function createJobRunner(options) {
463
504
  // (it's picked up by this runner's own worker).
464
505
  if (rawName.startsWith("_perTenant:")) {
465
506
  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}`);
507
+ // These throws happen before runInSpan, so its onJobFailed never sees them
508
+ // and a drained test stack would stay green over a fan-out that never ran.
509
+ try {
510
+ if (!getActiveTenantIds) {
511
+ if (registry.getQueryHandler(ACTIVE_TENANT_IDS_QUERY_NAME)) {
512
+ throw new Error(`perTenant job "${actualName}": the tenant feature is mounted but the job runner has no ` +
513
+ "`context.db`: pass a DbConnection in the runner context or supply options.getActiveTenantIds");
514
+ }
515
+ throw new Error(`perTenant job "${actualName}" requires either options.getActiveTenantIds or the ` +
516
+ `tenant feature mounted (it registers "${ACTIVE_TENANT_IDS_QUERY_NAME}", which the ` +
517
+ "framework uses to resolve active tenants on its own)");
518
+ }
519
+ const actualDef = allJobs.get(actualName);
520
+ if (!actualDef) {
521
+ throw new Error(`Unknown job: ${actualName}`);
522
+ }
523
+ const tenantIds = await getActiveTenantIds();
524
+ const targetQueue = queues[laneForJob(actualDef)];
525
+ // wrapperJobId is only absent for a Job BullMQ somehow never assigned
526
+ // an id to (not observed in practice) — falling back to a constant
527
+ // there would collide every such run's children onto the same ids
528
+ // and silently swallow later runs as "duplicates", which is worse
529
+ // than the duplicate this is meant to prevent. Skip id-based dedup
530
+ // for that edge case instead: enqueue plainly, same as before.
531
+ const wrapperJobId = bullJob.id;
532
+ for (const tenantId of tenantIds) {
533
+ await targetQueue.add(actualName, { ...bullJob.data, _tenantId: tenantId }, {
534
+ ...buildRetryBullOpts(actualDef),
535
+ // Dedup over wrapper retries only holds as long as the children
536
+ // stay in Redis for the wrapper's whole retry window — see the
537
+ // COMPLETED_JOB_RETENTION_AGE_SEC invariant check above.
538
+ ...(wrapperJobId !== undefined
539
+ ? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
540
+ : {}),
541
+ });
542
+ }
474
543
  }
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
- });
544
+ catch (err) {
545
+ // A retry may still succeed, and a succeeding wrapper reports no completion
546
+ // that could clear an earlier failure, so only the final attempt is reported.
547
+ const retries = allJobs.get(actualName)?.retries ?? 0;
548
+ if (bullJob.attemptsMade + 1 >= retries + 1) {
549
+ await reportPreRunFailure(rawName, bullJob, err);
550
+ }
551
+ throw err;
494
552
  }
495
553
  // skip: fan-out dispatcher job, per-tenant children enqueued
496
554
  return;
@@ -498,7 +556,9 @@ export function createJobRunner(options) {
498
556
  const jobName = rawName;
499
557
  const jobDef = allJobs.get(jobName);
500
558
  if (!jobDef) {
501
- throw new Error(`Unknown job: ${jobName}`);
559
+ const err = new Error(`Unknown job: ${jobName}`);
560
+ await reportPreRunFailure(jobName, bullJob, err);
561
+ throw err;
502
562
  }
503
563
  // Sequential gate: try to claim the per-name lock. If another worker
504
564
  // (or this worker on a different bullJob) holds it, re-enqueue with a
@@ -516,13 +576,12 @@ export function createJobRunner(options) {
516
576
  // same queue the worker just picked from (since only the consuming
517
577
  // lane runs handleJob at all), but route explicitly — no implicit
518
578
  // 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, {
579
+ // Attempts already spent travel in _priorAttempts so a lock conflict
580
+ // on a retry cannot hand out a fresh budget and exceed `retries`.
581
+ const priorAttempts = priorAttemptsOf(bullJob.data) + bullJob.attemptsMade;
582
+ await queues[laneForJob(jobDef)].add(jobName, { ...bullJob.data, _priorAttempts: priorAttempts }, {
524
583
  delay: SEQUENTIAL_RETRY_DELAY_MS,
525
- ...buildRetryBullOpts(jobDef),
584
+ ...buildRetryBullOpts(jobDef, priorAttempts),
526
585
  });
527
586
  // skip: lock taken, work re-enqueued with delay, current invocation done
528
587
  return;
@@ -537,7 +596,7 @@ export function createJobRunner(options) {
537
596
  // retry number, so audit queries distinguish fresh from retry runs
538
597
  // without peeking at BullMQ internals.
539
598
  const rawData = bullJob.data;
540
- const attempt = bullJob.attemptsMade + 1;
599
+ const attempt = bullJob.attemptsMade + 1 + priorAttemptsOf(rawData);
541
600
  const meta = {
542
601
  triggeredById: rawData["_triggeredById"], // @cast-boundary dynamic-key
543
602
  payload: rawData["_payload"], // @cast-boundary dynamic-key
@@ -558,16 +617,26 @@ export function createJobRunner(options) {
558
617
  // subject is read from the handler payload, so it is resolved here where
559
618
  // the payload is built, not in the callbacks.
560
619
  const tenantVisibleDecl = jobDef.tenantVisibleFailure;
561
- const tenantVisibleSubject = tenantVisibleDecl
562
- ? jobSubjectKey(jobName, payload, tenantVisibleDecl.subjectFields)
563
- : null;
620
+ // A bad subject config must surface as a failed run, so it is thrown from
621
+ // inside runInSpan below instead of here, before any run record exists.
622
+ let tenantVisibleSubject = null;
623
+ let subjectConfigError;
624
+ if (tenantVisibleDecl) {
625
+ try {
626
+ tenantVisibleSubject = jobSubjectKey(jobName, payload, tenantVisibleDecl.subjectFields);
627
+ }
628
+ catch (err) {
629
+ subjectConfigError = err instanceof Error ? err : new Error(String(err));
630
+ }
631
+ }
564
632
  // BullMQ stops retrying once attemptsMade reaches the configured attempts
565
633
  // (`retries + 1`), so this is the attempt whose failure is final.
566
- const finalAttempt = attempt >= (jobDef.retries ?? 0) + 1;
634
+ const finalAttempt = bullJob.retryable === false || attempt >= (jobDef.retries ?? 0) + 1;
567
635
  const outcomeMeta = (messageKey) => ({
568
636
  tenantId,
569
637
  finalAttempt,
570
- ...(tenantVisibleDecl && {
638
+ ...(tenantVisibleDecl &&
639
+ !subjectConfigError && {
571
640
  tenantVisible: { subject: tenantVisibleSubject, messageKey },
572
641
  }),
573
642
  });
@@ -658,8 +727,7 @@ export function createJobRunner(options) {
658
727
  let memberReader;
659
728
  const jobContext = {
660
729
  ...context,
661
- // Undefined only for a runner built without a db; JobContext requires db non-optional.
662
- db: jobDb, // @cast-boundary db-operator
730
+ db: jobDb ?? missingDbProxy(jobName),
663
731
  files,
664
732
  derivatives,
665
733
  ...(notify !== undefined && { notify }),
@@ -721,6 +789,10 @@ export function createJobRunner(options) {
721
789
  const jobCorrelationId = inheritedCorrelationId ?? jobRequestId;
722
790
  const runInSpan = async () => {
723
791
  try {
792
+ if (subjectConfigError) {
793
+ // Retrying cannot fix a declaration error.
794
+ throw new UnrecoverableError(subjectConfigError.message);
795
+ }
724
796
  if (writeOriginInvalid) {
725
797
  throw new InternalError({
726
798
  message: `Job "${jobName}" received an unparseable _writeOrigin — refusing to run without ` +
@@ -732,13 +804,22 @@ export function createJobRunner(options) {
732
804
  correlationId: jobCorrelationId,
733
805
  // #3043 — events a job writes carry the job as their origin.
734
806
  handler: jobName,
735
- feature: qnScope(jobName),
807
+ feature: registry.getJobFeature(jobName) ?? qnScope(jobName),
736
808
  writeOrigin: jobOrigin,
737
809
  }, () => jobDef.handler(payload, jobContext));
738
810
  // Stamped before the observer hook: a throwing onJobComplete must not
739
811
  // make a run that actually succeeded look dead to the liveness alert.
740
- if (context.meter)
741
- emitJobLastSuccess(context.meter, jobName);
812
+ if (context.meter) {
813
+ try {
814
+ emitJobLastSuccess(context.meter, jobName);
815
+ }
816
+ catch (err) {
817
+ errorLogger.warn("job last-success metric failed", {
818
+ job: jobName,
819
+ error: err instanceof Error ? err.message : String(err),
820
+ });
821
+ }
822
+ }
742
823
  const duration = Date.now() - startTime;
743
824
  await options.onJobComplete?.(jobName, jobId, duration, logs, outcomeMeta(null));
744
825
  }
@@ -795,7 +876,13 @@ export function createJobRunner(options) {
795
876
  if (!jobDef.bootGate)
796
877
  continue;
797
878
  try {
798
- await handleJob({ id: bootJobIdForJobName(name), name, data: {}, attemptsMade: 0 });
879
+ await handleJob({
880
+ id: bootJobIdForJobName(name),
881
+ name,
882
+ data: {},
883
+ attemptsMade: 0,
884
+ retryable: false,
885
+ });
799
886
  }
800
887
  catch (err) {
801
888
  // The worker is already consuming here; leaving its Redis connections
@@ -925,15 +1012,19 @@ export function createJobRunner(options) {
925
1012
  // rejects the in-flight INFO with "Connection is closed." on an emitter
926
1013
  // without listeners -> unhandled rejection (fw#1805). Wait for readiness
927
1014
  // 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()`);
1015
+ const stopReadyTimeoutMs = Math.min(bootRedisTimeoutMs, STOP_QUEUE_READY_TIMEOUT_MS);
1016
+ const readinessTimeout = timeoutReject(stopReadyTimeoutMs, `job-runner: Queue not ready within ${stopReadyTimeoutMs}ms during stop()`);
929
1017
  try {
930
1018
  await Promise.race([
931
1019
  Promise.all([queues.api.waitUntilReady(), queues.worker.waitUntilReady()]),
932
1020
  readinessTimeout.promise,
933
1021
  ]);
934
1022
  }
935
- catch {
936
- // close() below still has to run
1023
+ catch (err) {
1024
+ // close() below still has to run; stopping is already true, so debug matches the other shutdown logs
1025
+ errorLogger.debug("queue readiness wait failed during stop()", {
1026
+ error: err instanceof Error ? err.message : String(err),
1027
+ });
937
1028
  }
938
1029
  finally {
939
1030
  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";
@@ -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";
@@ -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>;
@@ -11,13 +11,13 @@ import { loadAggregateWithSnapshot, saveSnapshot, } from "../event-store/snapsho
11
11
  import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster.js";
12
12
  import { createFileContext } from "../files/file-handle.js";
13
13
  import { DEFAULT_LOCALE, isValidLocaleTag } from "../i18n/request-locale.js";
14
- import { createEscapeHatchReporter, } from "../observability/escape-hatch-report.js";
15
- import { createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
14
+ import { createEscapeHatchReporter, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
16
15
  import { buildBucketKey } from "../rate-limit/index.js";
17
16
  import { createTzContext, isValidIanaTimeZone } from "../time/index.js";
18
17
  import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership.js";
19
18
  import { appendDomainEventCore } from "./append-event-core.js";
20
19
  import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolver.js";
20
+ import { runBatch, unwrapSingle } from "./dispatch-batch.js";
21
21
  import { executeQuery } from "./dispatch-query.js";
22
22
  import { executeWrite } from "./dispatch-write.js";
23
23
  import { dispatcherSpanAttributes, isFailedWriteResult, } from "./dispatcher-utils.js";
@@ -135,7 +135,7 @@ export function applyMemberResolutionReadOnly(handlerContext) {
135
135
  ...(handlerContext.notify && { notify: denyMemberResolutionWrite }),
136
136
  };
137
137
  }
138
- export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted) {
138
+ export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, isPostCommit) {
139
139
  const { registry, appContext: context, effectiveFeatures, jobRunner, lifecycle } = ctx;
140
140
  const isSystem = registry.isHandlerSystemScoped(type);
141
141
  // The outer dispatcher receives a DbConnection from the server/stack;
@@ -241,6 +241,13 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
241
241
  // Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
242
242
  // queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
243
243
  const bridgeSink = afterCommitHooks ?? [];
244
+ // Post-commit hooks have no transaction left, so a bare executeWrite would
245
+ // auto-commit event append, projections and inTransaction hooks separately
246
+ // and its afterCommit hooks would land in an already-flushed sink. A
247
+ // batch-of-one gives the write its own transaction and its own flush.
248
+ const writeAsUser = async (asUser, targetType, payload) => isPostCommit
249
+ ? unwrapSingle(await runBatch(ctx, [{ type: targetType, payload }], asUser, undefined, origin))
250
+ : executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink);
244
251
  const scheduleAfterCommit = (hook) => {
245
252
  bridgeSink.push(hook);
246
253
  };
@@ -248,7 +255,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
248
255
  const identitySwitch = createGatedIdentitySwitch(`handler "${type}"`, user, hasIdentitySwitchGrant, {
249
256
  // Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
250
257
  queryAs: (asUser, targetType, payload) => executeQuery(ctx, targetType, payload, asUser, origin, tx), // @wrapper-known semantic-alias
251
- writeAs: (asUser, targetType, payload) => executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink),
258
+ writeAs: writeAsUser,
252
259
  }, identitySwitchAudit);
253
260
  // Lazy — creates the reader (which fails closed on SYSTEM_TENANT_ID) only
254
261
  // on first actual use, not on every HandlerContext build.
@@ -289,10 +296,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
289
296
  const bridge = {
290
297
  query: (targetType, payload) => executeQuery(ctx, targetType, payload, user, origin, tx), // @wrapper-known semantic-alias
291
298
  queryAs: identitySwitch.queryAs,
292
- write: async (targetType, payload) => {
293
- const res = await executeWrite(ctx, targetType, payload, user, origin, tx, bridgeSink);
294
- return res;
295
- },
299
+ write: (targetType, payload) => writeAsUser(user, targetType, payload),
296
300
  writeAs: identitySwitch.writeAs,
297
301
  // Strict + unsafe share the same runtime — only the type-surface
298
302
  // differs. The strict signature is what's exposed to typed callers;
@@ -545,9 +549,10 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
545
549
  tenantTz = cachedTz.value;
546
550
  }
547
551
  else {
552
+ const generationAtRead = ctx.tenantTimezoneCache.generation();
548
553
  tenantTz = await config(TENANT_TIMEZONE_CONFIG_KEY);
549
554
  if (tx === undefined) {
550
- ctx.tenantTimezoneCache.set(user.tenantId, typeof tenantTz === "string" ? tenantTz : undefined);
555
+ ctx.tenantTimezoneCache.setIfFresh(user.tenantId, typeof tenantTz === "string" ? tenantTz : undefined, generationAtRead);
551
556
  }
552
557
  }
553
558
  }
@@ -1,3 +1,4 @@
1
+ import { accessInvalidationCredentialFor } from "../api/sse-broker.js";
1
2
  import { hasAccess } from "../engine/access.js";
2
3
  import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, validationErrorFromZod, } from "../errors/index.js";
3
4
  import { assertNoSecretLeak } from "../secrets/index.js";
@@ -39,13 +40,9 @@ async function* executeStreamInner(ctx, type, payload, user, origin) {
39
40
  const invalidated = new Promise((resolve) => {
40
41
  resolveInvalidated = resolve;
41
42
  });
42
- const streamCredential = {
43
- ...(user.sid !== undefined && { sid: user.sid }),
44
- ...(user.pat?.tokenId !== undefined && { patTokenId: user.pat.tokenId }),
45
- };
46
43
  const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(user.id, () => {
47
44
  resolveInvalidated?.();
48
- }, streamCredential);
45
+ }, accessInvalidationCredentialFor(user));
49
46
  let iterator;
50
47
  // When access is revoked mid-pull, `iterator.next()` is still in flight.
51
48
  // Awaiting `iterator.return()` in that state deadlocks async generators in