@cosmicdrift/kumiko-framework 0.353.0 → 0.354.1

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 (165) hide show
  1. package/dist/api/index.d.ts +1 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/redis-sse-broker.d.ts +1 -0
  4. package/dist/api/redis-sse-broker.js +16 -10
  5. package/dist/api/request-context.d.ts +1 -0
  6. package/dist/api/request-context.js +5 -0
  7. package/dist/api/routes.js +4 -2
  8. package/dist/api/server.js +28 -3
  9. package/dist/api/sse-broker.d.ts +7 -3
  10. package/dist/api/sse-route.js +2 -6
  11. package/dist/api/token-expiry-timer.d.ts +1 -0
  12. package/dist/api/token-expiry-timer.js +10 -0
  13. package/dist/bun-db/query.js +29 -2
  14. package/dist/crypto/event-pii.js +4 -2
  15. package/dist/crypto/kms-wiring.js +31 -5
  16. package/dist/db/eagerload.d.ts +8 -3
  17. package/dist/db/eagerload.js +85 -81
  18. package/dist/db/event-store-executor-context.d.ts +12 -0
  19. package/dist/db/event-store-executor-context.js +59 -18
  20. package/dist/db/event-store-executor-read.js +81 -31
  21. package/dist/db/event-store-executor-write.js +54 -3
  22. package/dist/db/index.d.ts +1 -1
  23. package/dist/db/migrate-generator.js +1 -1
  24. package/dist/db/queries/backfill-pii.d.ts +1 -0
  25. package/dist/db/queries/backfill-pii.js +11 -6
  26. package/dist/db/queries/event-consumer.d.ts +2 -3
  27. package/dist/db/queries/event-consumer.js +54 -43
  28. package/dist/db/queries/event-store.d.ts +5 -0
  29. package/dist/db/queries/event-store.js +51 -3
  30. package/dist/db/queries/pii-backfill-state.d.ts +8 -1
  31. package/dist/db/queries/pii-backfill-state.js +16 -5
  32. package/dist/db/queries/shadow-swap.js +32 -3
  33. package/dist/db/render-ddl.js +6 -6
  34. package/dist/db/table-builder.js +6 -7
  35. package/dist/db/tenant-db.d.ts +5 -2
  36. package/dist/db/tenant-db.js +47 -7
  37. package/dist/derivatives/variant-key.d.ts +1 -0
  38. package/dist/derivatives/variant-key.js +3 -0
  39. package/dist/engine/boot-validator/access-declarations.js +5 -0
  40. package/dist/engine/boot-validator/config-deps.js +3 -0
  41. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  42. package/dist/engine/boot-validator/entity-handler.js +40 -0
  43. package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
  44. package/dist/engine/boot-validator/handler-field-references.js +25 -0
  45. package/dist/engine/boot-validator/index.js +6 -1
  46. package/dist/engine/boot-validator/nav.js +3 -1
  47. package/dist/engine/boot-validator/ownership.d.ts +2 -1
  48. package/dist/engine/boot-validator/ownership.js +32 -1
  49. package/dist/engine/boot-validator/pii-retention.js +8 -1
  50. package/dist/engine/boot-validator/query-output-columns.js +9 -9
  51. package/dist/engine/boot-validator/screens.js +98 -7
  52. package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
  53. package/dist/engine/boot-validator/transfer-graph.js +8 -63
  54. package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
  55. package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
  56. package/dist/engine/build-app-schema.js +3 -0
  57. package/dist/engine/changeset-changes.d.ts +1 -0
  58. package/dist/engine/changeset-changes.js +49 -33
  59. package/dist/engine/define-feature.js +11 -0
  60. package/dist/engine/entity-handlers.d.ts +3 -3
  61. package/dist/engine/entity-handlers.js +55 -34
  62. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  63. package/dist/engine/escape-hatch-reason.js +8 -0
  64. package/dist/engine/extension-names.d.ts +8 -9
  65. package/dist/engine/extension-names.js +8 -9
  66. package/dist/engine/extensions/tenant-data.d.ts +3 -1
  67. package/dist/engine/extensions/tenant-resource.d.ts +9 -1
  68. package/dist/engine/factories.d.ts +1 -1
  69. package/dist/engine/feature-changelog.d.ts +2 -0
  70. package/dist/engine/feature-changelog.js +6 -0
  71. package/dist/engine/feature-config-events-jobs.js +41 -4
  72. package/dist/engine/handler-field-references.d.ts +3 -0
  73. package/dist/engine/handler-field-references.js +31 -0
  74. package/dist/engine/i18n-key.js +2 -3
  75. package/dist/engine/index.d.ts +8 -2
  76. package/dist/engine/index.js +6 -1
  77. package/dist/engine/ownership.js +3 -4
  78. package/dist/engine/project-app-schema-for-roles.js +3 -1
  79. package/dist/engine/registry-ingest.d.ts +1 -0
  80. package/dist/engine/registry-ingest.js +8 -3
  81. package/dist/engine/run-pipeline.js +12 -1
  82. package/dist/engine/schema-builder.js +35 -2
  83. package/dist/engine/screen-helpers.d.ts +13 -0
  84. package/dist/engine/screen-helpers.js +31 -0
  85. package/dist/engine/soft-delete-cleanup.js +3 -2
  86. package/dist/engine/steps/_read-source.d.ts +4 -0
  87. package/dist/engine/steps/_read-source.js +22 -0
  88. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  89. package/dist/engine/steps/read-find-many.js +2 -1
  90. package/dist/engine/steps/read-find-one.js +2 -1
  91. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  92. package/dist/engine/steps/workflow-step-error.js +13 -0
  93. package/dist/engine/transfer-adjacency.d.ts +24 -0
  94. package/dist/engine/transfer-adjacency.js +107 -0
  95. package/dist/entrypoint/index.d.ts +2 -0
  96. package/dist/entrypoint/index.js +2 -0
  97. package/dist/env/index.js +45 -23
  98. package/dist/errors/classes.d.ts +8 -0
  99. package/dist/errors/classes.js +13 -0
  100. package/dist/errors/index.d.ts +2 -1
  101. package/dist/errors/index.js +2 -1
  102. package/dist/errors/reasons.d.ts +1 -0
  103. package/dist/errors/reasons.js +3 -0
  104. package/dist/errors/subject-erased.d.ts +2 -0
  105. package/dist/errors/subject-erased.js +9 -0
  106. package/dist/event-store/admin-api.js +14 -10
  107. package/dist/event-store/event-store.d.ts +1 -0
  108. package/dist/event-store/event-store.js +14 -9
  109. package/dist/event-store/events-schema.js +2 -1
  110. package/dist/event-store/index.d.ts +1 -1
  111. package/dist/event-store/index.js +1 -1
  112. package/dist/files/file-handle.d.ts +1 -1
  113. package/dist/files/file-handle.js +30 -6
  114. package/dist/files/file-routes.js +10 -4
  115. package/dist/files/resolve-content-type.d.ts +2 -0
  116. package/dist/files/resolve-content-type.js +8 -0
  117. package/dist/i18n/required-surface-keys.d.ts +2 -0
  118. package/dist/i18n/required-surface-keys.js +8 -0
  119. package/dist/jobs/index.d.ts +1 -1
  120. package/dist/jobs/job-runner.d.ts +13 -0
  121. package/dist/jobs/job-runner.js +128 -22
  122. package/dist/migrations/pii-event-backfill.js +46 -25
  123. package/dist/observability/index.d.ts +1 -1
  124. package/dist/observability/index.js +1 -1
  125. package/dist/observability/standard-metrics.d.ts +4 -0
  126. package/dist/observability/standard-metrics.js +12 -0
  127. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  128. package/dist/pipeline/dispatch-shared.js +74 -9
  129. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  130. package/dist/pipeline/dispatch-stream.js +19 -12
  131. package/dist/pipeline/dispatcher.d.ts +3 -1
  132. package/dist/pipeline/dispatcher.js +10 -15
  133. package/dist/pipeline/event-consumer-state.js +0 -26
  134. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  135. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  136. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  137. package/dist/pipeline/event-dispatcher.js +48 -7
  138. package/dist/pipeline/index.d.ts +2 -0
  139. package/dist/pipeline/index.js +1 -0
  140. package/dist/pipeline/member-read-only-transaction.js +25 -5
  141. package/dist/pipeline/msp-rebuild.js +3 -3
  142. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  143. package/dist/pipeline/pending-gap-ranges.js +22 -0
  144. package/dist/pipeline/system-hooks.js +4 -4
  145. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  146. package/dist/pipeline/system-identity-switch.js +5 -4
  147. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  148. package/dist/redis/cache-sync-topics.d.ts +1 -0
  149. package/dist/redis/cache-sync-topics.js +1 -0
  150. package/dist/redis/client.d.ts +1 -0
  151. package/dist/redis/client.js +7 -0
  152. package/dist/redis/index.d.ts +1 -1
  153. package/dist/redis/index.js +1 -1
  154. package/dist/redis/pubsub-signal.d.ts +1 -0
  155. package/dist/redis/pubsub-signal.js +3 -0
  156. package/dist/search/meilisearch-adapter.js +34 -4
  157. package/dist/stack/test-stack.d.ts +4 -1
  158. package/dist/stack/test-stack.js +9 -2
  159. package/dist/testing/e2e-generator.js +7 -2
  160. package/dist/ui-types/index.d.ts +3 -3
  161. package/dist/ui-types/index.js +2 -2
  162. package/dist/upgrade-cli.js +7 -2
  163. package/package.json +12 -4
  164. package/src/changes.json +209 -0
  165. package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
@@ -134,14 +134,14 @@ export function serializeJobSubject(subject) {
134
134
  // with the same subject values produce the same string and the later one
135
135
  // replaces the earlier record. Non-primitives would serialize into something
136
136
  // no caller can reconstruct for a lookup, so they fail the run loudly.
137
- function jobSubjectKey(jobName, payload, fields) {
137
+ function jobSubjectKey(jobName, payload, fields, declaration) {
138
138
  if (fields === undefined || fields.length === 0)
139
139
  return null;
140
140
  const subject = {};
141
141
  for (const field of fields) {
142
142
  const value = payload[field] ?? null;
143
143
  if (value !== null && typeof value === "object") {
144
- throw new Error(`Job "${jobName}": tenantVisibleFailure.subjectFields["${field}"] must be a primitive, got ${typeof value}`);
144
+ throw new Error(`Job "${jobName}": ${declaration}.subjectFields["${field}"] must be a primitive, got ${typeof value}`);
145
145
  }
146
146
  subject[field] = value;
147
147
  }
@@ -516,12 +516,72 @@ export function createJobRunner(options) {
516
516
  }
517
517
  return false;
518
518
  }
519
+ // Only err.message, never payload or the logs array: job payloads can carry PII.
520
+ function logFailedAttempt(entry) {
521
+ errorLogger.error("job failed", {
522
+ job: entry.job,
523
+ jobId: entry.jobId,
524
+ attempt: entry.attempt,
525
+ // BullMQ never retries an UnrecoverableError, whatever attempts remain.
526
+ final: entry.finalAttempt || entry.err instanceof UnrecoverableError,
527
+ error: entry.err instanceof Error ? entry.err.message : String(entry.err),
528
+ });
529
+ }
519
530
  async function reportPreRunFailure(jobName, bullJob, err) {
520
531
  const message = err instanceof Error ? err.message : String(err);
521
532
  await options.onJobFailed?.(jobName, bullJob.id ?? "unknown", message, [
522
533
  { level: "error", message, timestamp: Temporal.Now.instant() },
523
534
  ]);
524
535
  }
536
+ function handlerPayloadOf(data) {
537
+ const payload = {};
538
+ for (const [k, v] of Object.entries(data)) {
539
+ if (!k.startsWith("_"))
540
+ payload[k] = v;
541
+ }
542
+ return payload;
543
+ }
544
+ function tenantIdOfJobData(data) {
545
+ return (data["_tenantId"] ?? // @cast-boundary dynamic-key
546
+ data["tenantId"] ?? // @cast-boundary dynamic-key
547
+ SYSTEM_TENANT_ID);
548
+ }
549
+ // Tenant-visible queued state. A callback or subject problem must never fail
550
+ // the enqueue; a bad subject config surfaces as a failed run instead.
551
+ async function announceQueued(jobName, jobDef, bullJobId, data) {
552
+ const declaration = jobDef.tenantVisibleRun;
553
+ // skip: job did not opt in, no logger wired, or BullMQ returned no id
554
+ if (!declaration || !options.onJobQueued || bullJobId === undefined)
555
+ return;
556
+ const tenantId = tenantIdOfJobData(data);
557
+ // skip: tenant-less run — no tenant-scoped query could ever read it
558
+ if (tenantId === SYSTEM_TENANT_ID)
559
+ return;
560
+ try {
561
+ const subject = jobSubjectKey(jobName, handlerPayloadOf(data), declaration.subjectFields, "tenantVisibleRun");
562
+ await options.onJobQueued(jobName, bullJobId, { tenantId, subject });
563
+ }
564
+ catch (err) {
565
+ errorLogger.warn("tenant-visible queued state not recorded", {
566
+ job: jobName,
567
+ error: err instanceof Error ? err.message : String(err),
568
+ });
569
+ }
570
+ }
571
+ async function announceDropped(jobName, jobDef, bullJobId) {
572
+ // skip: job did not opt in or no logger wired
573
+ if (!jobDef.tenantVisibleRun || !options.onJobDropped)
574
+ return;
575
+ try {
576
+ await options.onJobDropped(jobName, bullJobId);
577
+ }
578
+ catch (err) {
579
+ errorLogger.warn("tenant-visible queued state not cleared", {
580
+ job: jobName,
581
+ error: err instanceof Error ? err.message : String(err),
582
+ });
583
+ }
584
+ }
525
585
  async function handleJob(bullJob) {
526
586
  const rawName = bullJob.name;
527
587
  // Handle perTenant dispatch jobs — fan out to one job per tenant. The
@@ -556,7 +616,8 @@ export function createJobRunner(options) {
556
616
  // for that edge case instead: enqueue plainly, same as before.
557
617
  const wrapperJobId = bullJob.id;
558
618
  for (const tenantId of tenantIds) {
559
- await targetQueue.add(actualName, { ...bullJob.data, _tenantId: tenantId }, {
619
+ const childData = { ...bullJob.data, _tenantId: tenantId };
620
+ const child = await targetQueue.add(actualName, childData, {
560
621
  ...buildRetryBullOpts(actualDef),
561
622
  // Dedup over wrapper retries only holds as long as the children
562
623
  // stay in Redis for the wrapper's whole retry window — see the
@@ -565,13 +626,22 @@ export function createJobRunner(options) {
565
626
  ? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
566
627
  : {}),
567
628
  });
629
+ await announceQueued(actualName, actualDef, child.id, childData);
568
630
  }
569
631
  }
570
632
  catch (err) {
571
633
  // A retry may still succeed, and a succeeding wrapper reports no completion
572
634
  // that could clear an earlier failure, so only the final attempt is reported.
573
635
  const retries = allJobs.get(actualName)?.retries ?? 0;
574
- if (bullJob.attemptsMade + 1 >= retries + 1) {
636
+ const finalAttempt = bullJob.attemptsMade + 1 >= retries + 1;
637
+ logFailedAttempt({
638
+ job: rawName,
639
+ jobId: bullJob.id ?? "unknown",
640
+ attempt: bullJob.attemptsMade + 1,
641
+ finalAttempt,
642
+ err,
643
+ });
644
+ if (finalAttempt) {
575
645
  await reportPreRunFailure(rawName, bullJob, err);
576
646
  }
577
647
  throw err;
@@ -583,6 +653,14 @@ export function createJobRunner(options) {
583
653
  const jobDef = allJobs.get(jobName);
584
654
  if (!jobDef) {
585
655
  const err = new Error(`Unknown job: ${jobName}`);
656
+ // Final regardless of attempts left: a retry cannot make the job exist.
657
+ logFailedAttempt({
658
+ job: jobName,
659
+ jobId: bullJob.id ?? "unknown",
660
+ attempt: bullJob.attemptsMade + 1,
661
+ finalAttempt: true,
662
+ err,
663
+ });
586
664
  await reportPreRunFailure(jobName, bullJob, err);
587
665
  throw err;
588
666
  }
@@ -605,10 +683,15 @@ export function createJobRunner(options) {
605
683
  // Attempts already spent travel in _priorAttempts so a lock conflict
606
684
  // on a retry cannot hand out a fresh budget and exceed `retries`.
607
685
  const priorAttempts = priorAttemptsOf(bullJob.data) + bullJob.attemptsMade;
608
- await queues[laneForJob(jobDef)].add(jobName, { ...bullJob.data, _priorAttempts: priorAttempts }, {
686
+ const requeuedData = { ...bullJob.data, _priorAttempts: priorAttempts };
687
+ const requeued = await queues[laneForJob(jobDef)].add(jobName, requeuedData, {
609
688
  delay: SEQUENTIAL_RETRY_DELAY_MS,
610
689
  ...buildRetryBullOpts(jobDef, priorAttempts),
611
690
  });
691
+ if (bullJob.id !== undefined && bullJob.id !== requeued.id) {
692
+ await announceDropped(jobName, jobDef, bullJob.id);
693
+ }
694
+ await announceQueued(jobName, jobDef, requeued.id, requeuedData);
612
695
  // skip: lock taken, work re-enqueued with delay, current invocation done
613
696
  return;
614
697
  }
@@ -629,27 +712,31 @@ export function createJobRunner(options) {
629
712
  attempt,
630
713
  };
631
714
  // Build handler payload (without internal meta fields)
632
- const payload = {};
633
- for (const [k, v] of Object.entries(rawData)) {
634
- if (!k.startsWith("_"))
635
- payload[k] = v;
636
- }
715
+ const payload = handlerPayloadOf(rawData);
637
716
  // Determine tenantId and triggeredBy from meta
638
- const tenantId = rawData["_tenantId"] ?? // @cast-boundary dynamic-key
639
- payload["tenantId"] ?? // @cast-boundary dynamic-key
640
- SYSTEM_TENANT_ID;
717
+ const tenantId = tenantIdOfJobData(rawData);
641
718
  const triggeredById = rawData["_triggeredById"] ?? null; // @cast-boundary dynamic-key
642
719
  // Tenant-visible failure record (JobDefinition.tenantVisibleFailure). The
643
720
  // subject is read from the handler payload, so it is resolved here where
644
721
  // the payload is built, not in the callbacks.
645
722
  const tenantVisibleDecl = jobDef.tenantVisibleFailure;
723
+ const tenantVisibleRunDecl = jobDef.tenantVisibleRun;
646
724
  // A bad subject config must surface as a failed run, so it is thrown from
647
725
  // inside runInSpan below instead of here, before any run record exists.
648
726
  let tenantVisibleSubject = null;
727
+ let tenantVisibleRunSubject = null;
649
728
  let subjectConfigError;
650
- if (tenantVisibleDecl) {
729
+ if (tenantVisibleRunDecl) {
651
730
  try {
652
- tenantVisibleSubject = jobSubjectKey(jobName, payload, tenantVisibleDecl.subjectFields);
731
+ tenantVisibleRunSubject = jobSubjectKey(jobName, payload, tenantVisibleRunDecl.subjectFields, "tenantVisibleRun");
732
+ }
733
+ catch (err) {
734
+ subjectConfigError = err instanceof Error ? err : new Error(String(err));
735
+ }
736
+ }
737
+ if (tenantVisibleDecl && !subjectConfigError) {
738
+ try {
739
+ tenantVisibleSubject = jobSubjectKey(jobName, payload, tenantVisibleDecl.subjectFields, "tenantVisibleFailure");
653
740
  }
654
741
  catch (err) {
655
742
  subjectConfigError = err instanceof Error ? err : new Error(String(err));
@@ -658,13 +745,16 @@ export function createJobRunner(options) {
658
745
  // BullMQ stops retrying once attemptsMade reaches the configured attempts
659
746
  // (`retries + 1`), so this is the attempt whose failure is final.
660
747
  const finalAttempt = bullJob.retryable === false || attempt >= (jobDef.retries ?? 0) + 1;
661
- const outcomeMeta = (messageKey) => ({
748
+ const outcomeMeta = (messageKey, unrecoverable = false) => ({
662
749
  tenantId,
663
- finalAttempt,
750
+ // BullMQ never retries an UnrecoverableError, whatever attempts are left.
751
+ finalAttempt: finalAttempt || unrecoverable,
664
752
  ...(tenantVisibleDecl &&
665
753
  !subjectConfigError && {
666
754
  tenantVisible: { subject: tenantVisibleSubject, messageKey },
667
755
  }),
756
+ ...(tenantVisibleRunDecl &&
757
+ !subjectConfigError && { tenantVisibleRun: { subject: tenantVisibleRunSubject } }),
668
758
  });
669
759
  // Carry `_triggerName` from rawData when set — handleEvent injects it on
670
760
  // multi-trigger dispatch; exposed as jobContext.triggerName so handlers
@@ -693,7 +783,9 @@ export function createJobRunner(options) {
693
783
  // through the same _fileProviderResolver for jobs as for write-handlers,
694
784
  // otherwise event-triggered jobs silently get an unresolved ctx.files.
695
785
  const fileResolver = context._fileProviderResolver;
696
- const files = fileResolver ? createFileContext(() => fileResolver(tenantId)) : context.files;
786
+ const files = fileResolver
787
+ ? createFileContext(() => fileResolver(tenantId), tenantId)
788
+ : context.files;
697
789
  const jobSystemUser = createSystemUser(tenantId);
698
790
  // Same buildHandlerContext parity as ctx.files above: ctx.notify/ctx.config
699
791
  // must resolve for jobs the same way they do for write-handlers, or an
@@ -813,6 +905,9 @@ export function createJobRunner(options) {
813
905
  return memberReader(userId, qn, payload);
814
906
  },
815
907
  };
908
+ if (tenantVisibleRunDecl && !subjectConfigError) {
909
+ meta.tenantVisibleRun = { tenantId, subject: tenantVisibleRunSubject };
910
+ }
816
911
  await options.onJobStart?.(jobName, jobId, meta);
817
912
  // Cross-process trace continuation: if the enqueuing code captured a
818
913
  // parent span, start the job.execute span as its child. Works for event
@@ -865,7 +960,8 @@ export function createJobRunner(options) {
865
960
  catch (err) {
866
961
  const errorMsg = err instanceof Error ? err.message : String(err);
867
962
  logs.push({ level: "error", message: errorMsg, timestamp: Temporal.Now.instant() });
868
- await options.onJobFailed?.(jobName, jobId, errorMsg, logs, outcomeMeta(tenantFailureMessageKey(err, tenantVisibleDecl)));
963
+ logFailedAttempt({ job: jobName, jobId, attempt, finalAttempt, err });
964
+ await options.onJobFailed?.(jobName, jobId, errorMsg, logs, outcomeMeta(tenantFailureMessageKey(err, tenantVisibleDecl), err instanceof UnrecoverableError));
869
965
  throw err;
870
966
  }
871
967
  };
@@ -938,8 +1034,7 @@ export function createJobRunner(options) {
938
1034
  ]);
939
1035
  }
940
1036
  catch (err) {
941
- // The worker is already consuming here; leaving its Redis connections
942
- // open would keep the event loop alive and turn the aborted boot into
1037
+ // Leaving the worker's Redis connections open would keep the event loop alive and turn the aborted boot into
943
1038
  // a hanging process instead of a non-zero exit.
944
1039
  await worker?.close();
945
1040
  worker = null;
@@ -965,6 +1060,9 @@ export function createJobRunner(options) {
965
1060
  worker = new Worker(queueNameFor(queueNamePrefix, consumerLane), handleJob, {
966
1061
  connection: redisOpts,
967
1062
  concurrency: 5,
1063
+ // Not consuming until the boot gates passed: a job already queued from a
1064
+ // previous run must not execute against an unmet boot precondition.
1065
+ autorun: false,
968
1066
  });
969
1067
  worker.on("error", (err) => {
970
1068
  const log = stopping ? errorLogger.debug : errorLogger.error;
@@ -989,6 +1087,11 @@ export function createJobRunner(options) {
989
1087
  // Ahead of cron/runOnBoot wiring on purpose: a failed gate must not
990
1088
  // leave recurring schedulers behind.
991
1089
  await runBootGates(consumerLane);
1090
+ // Floating on purpose: run() resolves only when the worker closes.
1091
+ void worker.run().catch((err) => {
1092
+ const log = stopping ? errorLogger.debug : errorLogger.error;
1093
+ log("worker run loop failed", { error: err instanceof Error ? err.message : String(err) });
1094
+ });
992
1095
  // Only schedule cron + boot for jobs that belong to this lane. Jobs
993
1096
  // assigned to the other lane get their cron/boot wiring from the
994
1097
  // runner running on that lane. Running both here would double-fire.
@@ -1142,6 +1245,7 @@ export function createJobRunner(options) {
1142
1245
  for (const j of waiting) {
1143
1246
  if (j.name === jobName && j.id) {
1144
1247
  await j.remove();
1248
+ await announceDropped(jobName, jobDef, j.id);
1145
1249
  }
1146
1250
  }
1147
1251
  break;
@@ -1182,6 +1286,7 @@ export function createJobRunner(options) {
1182
1286
  data["_correlationId"] = reqCtx.correlationId;
1183
1287
  stampGatedWriteOrigin(data);
1184
1288
  const job = await targetQueue.add(jobName, data, bullOpts);
1289
+ await announceQueued(jobName, jobDef, job.id, data);
1185
1290
  return job.id ?? "unknown";
1186
1291
  },
1187
1292
  async handleEvent(eventName, payload, user, triggerEvent) {
@@ -1230,7 +1335,8 @@ export function createJobRunner(options) {
1230
1335
  stampGatedWriteOrigin(data);
1231
1336
  // Route to the job's declared lane, not a fixed queue — that's
1232
1337
  // the whole reason both queues are held.
1233
- await queues[laneForJob(jobDef)].add(name, data, buildRetryBullOpts(jobDef));
1338
+ const job = await queues[laneForJob(jobDef)].add(name, data, buildRetryBullOpts(jobDef));
1339
+ await announceQueued(name, jobDef, job.id, data);
1234
1340
  }
1235
1341
  },
1236
1342
  attachDispatcher(ref) {
@@ -14,6 +14,7 @@ import { bigint, instant, table as pgTable, sql, text } from "../db/dialect.js";
14
14
  import { extractTableName } from "../db/index.js";
15
15
  import { acquireNamespacedAdvisoryLock } from "../db/queries/advisory-lock.js";
16
16
  import { backfillEventPiiEncryptionBatch, } from "../db/queries/backfill-pii.js";
17
+ import { alterTableAddColumn } from "../db/queries/ddl.js";
17
18
  import { persistPiiBackfillState, readPiiBackfillState, } from "../db/queries/pii-backfill-state.js";
18
19
  import { upsertOnConflict } from "../db/query.js";
19
20
  import { tableExists } from "../db/schema-inspection.js";
@@ -37,10 +38,12 @@ export const piiBackfillStateTable = pgTable("kumiko_pii_backfill_state", {
37
38
  updatedAt: instant("updated_at", { precision: 3 }).notNull().default(sql `now()`),
38
39
  });
39
40
  export async function createPiiBackfillStateTable(db) {
40
- // skip: table already exists — boot runs on every replica
41
- if (await tableExists(db, "public.kumiko_pii_backfill_state"))
42
- return;
43
- await unsafePushTables(db, { kumikoPiiBackfillState: piiBackfillStateTable });
41
+ if (!(await tableExists(db, "public.kumiko_pii_backfill_state"))) {
42
+ await unsafePushTables(db, { kumikoPiiBackfillState: piiBackfillStateTable });
43
+ }
44
+ // Added after the table's first deploy; IF NOT EXISTS keeps concurrent replica boots safe.
45
+ await alterTableAddColumn(db, "kumiko_pii_backfill_state", "failed_event_ids", "bigint[]", " DEFAULT '{}'::bigint[]", " NOT NULL",
46
+ /* ifNotExists */ true);
44
47
  }
45
48
  // Replicas boot concurrently: two of them can both pass the tableExists gate and
46
49
  // one CREATE then fails. The table existing afterwards means the other won.
@@ -107,17 +110,19 @@ function initialScanCursor(state) {
107
110
  // One transaction per batch: the advisory lock serializes pods, and the payload
108
111
  // is re-read inside it so a value is never encrypted twice and two backfills
109
112
  // never mint the same DEK. Returns null when another pod already finished a full run.
110
- async function runOneBatch(ctx, progress) {
113
+ async function runOneBatch(ctx, progress, retryEventIds) {
111
114
  return ctx.db.begin(async (tx) => {
112
115
  await acquireNamespacedAdvisoryLock(tx, PII_EVENT_BACKFILL_LOCK_NAMESPACE, PII_EVENT_BACKFILL_LOCK_KEY);
113
116
  const state = await readPiiBackfillState(tx, ctx.fingerprint);
114
- if (ctx.mode === "full" && state?.completed === true)
117
+ if (retryEventIds === undefined && ctx.mode === "full" && state?.completed === true) {
115
118
  return null;
119
+ }
116
120
  const startCursor = ctx.mode === "full" && state
117
121
  ? maxBigInt(progress.scanCursor, BigInt(state.cursor))
118
122
  : progress.scanCursor;
119
123
  const batch = await backfillEventPiiEncryptionBatch(tx, ctx.registry, {
120
124
  afterEventId: startCursor,
125
+ ...(retryEventIds && { eventIds: retryEventIds }),
121
126
  batchSize: ctx.batchSize,
122
127
  resolveOwnerFromProjection: true,
123
128
  scanCache: progress.scanCache,
@@ -128,10 +133,15 @@ async function runOneBatch(ctx, progress) {
128
133
  : [];
129
134
  if (tables.length > 0)
130
135
  await queueRebuildTables(tx, tables, ctx.migrationId);
131
- if (!progress.failureSeen) {
132
- const completesRun = batch.scannedAll && batch.failures.length === 0;
133
- await persistPiiBackfillState(tx, ctx.fingerprint, persistableCursor(batch, startCursor), completesRun);
134
- }
136
+ // The cursor moves past failed events: they are recorded here and retried on their own,
137
+ // so one permanently failing event no longer forces a rescan from it on every boot.
138
+ const failedIds = batch.failures.map((failure) => failure.eventId);
139
+ await persistPiiBackfillState(tx, ctx.fingerprint, {
140
+ cursor: retryEventIds ? 0n : advancedCursor(batch, startCursor),
141
+ markCompleted: retryEventIds === undefined && batch.scannedAll,
142
+ addFailedEventIds: failedIds,
143
+ removeFailedEventIds: retryEventIds?.filter((id) => !failedIds.includes(id)) ?? [],
144
+ });
135
145
  return { batch, tables };
136
146
  });
137
147
  }
@@ -141,14 +151,25 @@ function recordBatch(progress, step) {
141
151
  progress.totals.updatedEvents += batch.updatedEvents;
142
152
  progress.totals.encryptedFields += batch.encryptedFields;
143
153
  progress.totals.erasedFields += batch.erasedFields;
144
- progress.failures.push(...batch.failures);
154
+ for (const failure of batch.failures) {
155
+ // The catch-up overlap and the per-event retry can hit the same failed event twice in one run.
156
+ if (progress.failedIdsSeen.has(failure.eventId))
157
+ continue;
158
+ progress.failedIdsSeen.add(failure.eventId);
159
+ progress.failures.push(failure);
160
+ }
145
161
  for (const table of step.tables)
146
162
  progress.queuedTables.add(table);
147
- if (batch.failures.length > 0)
148
- progress.failureSeen = true;
149
163
  }
150
164
  // Returns true when the run was aborted between batches.
151
- async function scanAllBatches(ctx, progress, signal) {
165
+ async function scanAllBatches(ctx, progress, signal, retryEventIds) {
166
+ for (let from = 0; from < retryEventIds.length; from += ctx.batchSize) {
167
+ if (signal?.aborted)
168
+ return true;
169
+ const step = await runOneBatch(ctx, progress, retryEventIds.slice(from, from + ctx.batchSize));
170
+ if (step !== null)
171
+ recordBatch(progress, step);
172
+ }
152
173
  for (;;) {
153
174
  if (signal?.aborted)
154
175
  return true;
@@ -163,7 +184,9 @@ async function scanAllBatches(ctx, progress, signal) {
163
184
  }
164
185
  function reportRunOutcome(log, progress) {
165
186
  if (progress.failures.length > 0) {
166
- log.error(`${progress.failures.length} event(s) could not be PII-encrypted and will be retried on the next boot`, {
187
+ log.error(`${progress.failures.length} event(s) could not be PII-encrypted; the scan moved past them and ` +
188
+ "only these events are retried on each boot. Fix the cause, or erase subjects that stay unresolvable via " +
189
+ "backfillEventPiiEncryption(..., { eraseUnresolvableSubjects: true })", {
167
190
  failureCount: progress.failures.length,
168
191
  eventIds: progress.failures.slice(0, MAX_LOGGED_FAILURE_IDS).map((f) => f.eventId),
169
192
  });
@@ -180,24 +203,27 @@ export async function runPiiEventBackfill(db, registry, options = {}) {
180
203
  const fingerprint = piiAnnotationFingerprint(registry);
181
204
  await ensureInfraTable(db, "kumiko_pii_backfill_state", createPiiBackfillStateTable);
182
205
  await ensureInfraTable(db, "kumiko_pending_rebuilds", createPendingRebuildsTable);
183
- const { mode, scanCursor } = initialScanCursor(await readPiiBackfillState(db, fingerprint));
206
+ const initialState = await readPiiBackfillState(db, fingerprint);
207
+ const { mode, scanCursor } = initialScanCursor(initialState);
184
208
  const ctx = {
185
209
  db,
186
210
  registry,
187
211
  fingerprint,
188
- migrationId: `${PII_BACKFILL_MIGRATION_PREFIX}${fingerprint.slice(0, 12)}`,
212
+ // The per-run suffix keeps a peer replica's re-queue of the same table (new
213
+ // migration_id via the upsert) from being deleted by this run's clear.
214
+ migrationId: `${PII_BACKFILL_MIGRATION_PREFIX}${fingerprint.slice(0, 12)}:${crypto.randomUUID()}`,
189
215
  mode,
190
216
  batchSize: options.batchSize ?? DEFAULT_BATCH_SIZE,
191
217
  };
192
218
  const progress = {
193
219
  scanCursor,
194
- failureSeen: false,
195
220
  scanCache: {},
196
221
  totals: { scannedEvents: 0, updatedEvents: 0, encryptedFields: 0, erasedFields: 0 },
197
222
  failures: [],
198
223
  queuedTables: new Set(),
224
+ failedIdsSeen: new Set(),
199
225
  };
200
- const aborted = await scanAllBatches(ctx, progress, options.signal);
226
+ const aborted = await scanAllBatches(ctx, progress, options.signal, initialState?.failedEventIds ?? []);
201
227
  reportRunOutcome(log, progress);
202
228
  const rebuild = aborted ? null : await rebuildQueuedTables(db, registry, options.signal, log);
203
229
  const queuedTables = [...progress.queuedTables];
@@ -218,12 +244,7 @@ export async function runPiiEventBackfill(db, registry, options = {}) {
218
244
  rebuild,
219
245
  };
220
246
  }
221
- // Never advance past a failed event so the next boot retries it.
222
- function persistableCursor(batch, startCursor) {
223
- if (batch.firstFailedEventId !== null) {
224
- const beforeFailure = BigInt(batch.firstFailedEventId) - 1n;
225
- return beforeFailure > startCursor ? beforeFailure : startCursor;
226
- }
247
+ function advancedCursor(batch, startCursor) {
227
248
  return batch.lastEventId === null ? startCursor : BigInt(batch.lastEventId);
228
249
  }
229
250
  function maxBigInt(a, b) {
@@ -12,5 +12,5 @@ export { type MetricEvent, type MetricEventHandler, RecordingMeter, } from "./re
12
12
  export { type RecordedSpan, RecordingTracer, type RecordingTracerOptions, serializeSpanContext, } from "./recording-tracer.js";
13
13
  export { wrapRedisClient } from "./redis-wrapper.js";
14
14
  export { DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, REDACTED, redactAttributes, redactHeaders, redactQueryString, redactValue, shouldRedactAttribute, } from "./sensitive-filter.js";
15
- export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
15
+ export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerPendingGaps, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
16
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";
@@ -13,4 +13,4 @@ export { RecordingMeter, } from "./recording-meter.js";
13
13
  export { RecordingTracer, serializeSpanContext, } from "./recording-tracer.js";
14
14
  export { wrapRedisClient } from "./redis-wrapper.js";
15
15
  export { DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, REDACTED, redactAttributes, redactHeaders, redactQueryString, redactValue, shouldRedactAttribute, } from "./sensitive-filter.js";
16
- export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
16
+ export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerPendingGaps, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
@@ -28,6 +28,10 @@ export declare function emitEventConsumerLag(meter: Meter, labels: {
28
28
  readonly consumer: string;
29
29
  readonly instanceId: string;
30
30
  }, lagEvents: number): void;
31
+ export declare function emitEventConsumerPendingGaps(meter: Meter, labels: {
32
+ readonly consumer: string;
33
+ readonly instanceId: string;
34
+ }, gapRanges: number): void;
31
35
  export declare function emitEventConsumerRearmExhausted(meter: Meter, labels: {
32
36
  readonly consumer: string;
33
37
  readonly instanceId: string;
@@ -61,6 +61,12 @@ export const STANDARD_METRIC_DEFS = [
61
61
  // per-instance consumers (one cursor per dispatcher). Without this,
62
62
  // Prometheus collapses per-instance shards into last-writer-wins and
63
63
  // per-instance lag is invisible to alerting.
64
+ {
65
+ name: "kumiko_event_consumer_pending_gaps",
66
+ type: "gauge",
67
+ description: "Number of unresolved id ranges below the consumer's cursor (ids not yet visible or proven rolled back).",
68
+ labels: ["consumer", "instance_id"],
69
+ },
64
70
  {
65
71
  name: "kumiko_event_consumer_lag_events",
66
72
  type: "gauge",
@@ -212,6 +218,12 @@ export function emitEventConsumerLag(meter, labels, lagEvents) {
212
218
  instance_id: labels.instanceId,
213
219
  });
214
220
  }
221
+ export function emitEventConsumerPendingGaps(meter, labels, gapRanges) {
222
+ meter.gauge("kumiko_event_consumer_pending_gaps").set(gapRanges, {
223
+ consumer: labels.consumer,
224
+ instance_id: labels.instanceId,
225
+ });
226
+ }
215
227
  // Caller (event-dispatcher.ts) is responsible for calling this exactly
216
228
  // once per (consumer, instanceId) transition into exhausted-dead — not
217
229
  // once per poll pass while it stays dead.
@@ -46,7 +46,9 @@ export type DispatchContext = {
46
46
  tracer: ReturnType<typeof getFallbackTracer>;
47
47
  meter: ReturnType<typeof getFallbackMeter>;
48
48
  membershipQuery: string;
49
+ memberReadTimeoutMs: number;
49
50
  };
51
+ export declare const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10000;
50
52
  export declare function resolveDbSource(ctx: DispatchContext, tx: DbTx | undefined): DbConnection | DbTx | undefined;
51
53
  export declare function isMemberResolutionPrincipal(user: SessionUser): boolean;
52
54
  export declare function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext;