@cosmicdrift/kumiko-framework 0.305.0 → 0.307.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 (87) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/redis-sse-broker.integration.test.ts +66 -0
  3. package/src/api/__tests__/server-boot-guards.test.ts +1 -0
  4. package/src/api/__tests__/server-error-logging.test.ts +71 -0
  5. package/src/api/__tests__/sse-broker.test.ts +49 -0
  6. package/src/api/redis-sse-broker.ts +17 -3
  7. package/src/api/request-context.ts +29 -4
  8. package/src/api/routes.ts +26 -1
  9. package/src/api/sse-broker.ts +29 -11
  10. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  11. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  12. package/src/bun-db/query.ts +42 -18
  13. package/src/changes.json +92 -0
  14. package/src/db/__tests__/pg-error.test.ts +14 -0
  15. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  16. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  17. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  18. package/src/db/index.ts +1 -1
  19. package/src/db/pg-error.ts +13 -0
  20. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  21. package/src/db/queries/event-consumer.ts +57 -3
  22. package/src/db/queries/event-store.ts +69 -0
  23. package/src/db/tenant-db.ts +133 -18
  24. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  25. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  26. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  27. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  28. package/src/engine/extension-names.ts +55 -25
  29. package/src/engine/extensions/storage-provider.ts +14 -41
  30. package/src/engine/extensions/tenant-data.ts +4 -0
  31. package/src/engine/extensions/tenant-resource.ts +40 -0
  32. package/src/engine/extensions/user-data.ts +8 -7
  33. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  34. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  35. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  36. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  37. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  38. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  39. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  40. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  41. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  42. package/src/engine/feature-ast/index.ts +11 -1
  43. package/src/engine/feature-ast/patch.ts +338 -5
  44. package/src/engine/feature-ast/patcher.ts +2 -2
  45. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  46. package/src/engine/feature-ast/patterns.ts +22 -15
  47. package/src/engine/feature-ast/render.ts +1 -0
  48. package/src/engine/feature-ui-extensions.ts +8 -7
  49. package/src/engine/index.ts +21 -5
  50. package/src/engine/types/extension-options-map.ts +1 -0
  51. package/src/engine/types/index.ts +6 -0
  52. package/src/event-store/__tests__/event-attribution.integration.test.ts +53 -3
  53. package/src/event-store/admin-api.ts +5 -0
  54. package/src/event-store/event-store.ts +16 -7
  55. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
  56. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  57. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  58. package/src/jobs/__tests__/jobs.integration.test.ts +3 -3
  59. package/src/jobs/job-runner.ts +211 -19
  60. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  61. package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
  62. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  63. package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
  64. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
  65. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
  66. package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
  67. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  68. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  69. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +261 -2
  70. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  71. package/src/pipeline/dispatch-batch.ts +102 -29
  72. package/src/pipeline/dispatch-stream.ts +7 -3
  73. package/src/pipeline/dispatcher-utils.ts +21 -2
  74. package/src/pipeline/dispatcher.ts +71 -6
  75. package/src/pipeline/event-consumer-state.ts +26 -0
  76. package/src/pipeline/event-dispatcher-admin.ts +32 -5
  77. package/src/pipeline/event-dispatcher-delivery.ts +109 -57
  78. package/src/pipeline/event-dispatcher.ts +167 -50
  79. package/src/pipeline/idempotency.ts +16 -0
  80. package/src/pipeline/pending-gap-ranges.ts +72 -0
  81. package/src/pipeline/system-hooks.ts +8 -1
  82. package/src/pipeline/system-identity-switch.ts +22 -4
  83. package/src/pipeline/write-origin.ts +31 -10
  84. package/src/stack/test-stack.ts +1 -1
  85. package/src/testing/closed-connection-error.ts +62 -0
  86. package/src/testing/index.ts +1 -0
  87. package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
@@ -1,3 +1,4 @@
1
+ import type { WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
1
2
  import { type JobsOptions, Queue, Worker } from "bullmq";
2
3
  import { Redis } from "ioredis";
3
4
  import { requestContext } from "../api/request-context";
@@ -18,6 +19,7 @@ import {
18
19
  SYSTEM_TENANT_ID,
19
20
  type TenantId,
20
21
  } from "../engine/types";
22
+ import { InternalError } from "../errors";
21
23
  import { isKumikoError } from "../errors/kumiko-error";
22
24
  import { createFileContext } from "../files/file-handle";
23
25
  import { createFallbackLogger } from "../logging";
@@ -33,8 +35,22 @@ import {
33
35
  import { createEscapeHatchReporter } from "../observability/escape-hatch-report";
34
36
  import { createDistributedLock, type DistributedLock } from "../pipeline/distributed-lock";
35
37
  import { RedisKeys } from "../pipeline/redis-keys";
38
+ import {
39
+ buildPersonalDataGate,
40
+ isPersonalDataGated,
41
+ parseWriteOrigin,
42
+ } from "../pipeline/write-origin";
36
43
  import { bridgeStub } from "../testing/handler-context";
37
44
 
45
+ // A payload's own `_writeOrigin` is never trusted; only the ambient gated origin is stamped.
46
+ function stampGatedWriteOrigin(data: Record<string, unknown>): void {
47
+ delete data["_writeOrigin"];
48
+ const origin = requestContext.get()?.writeOrigin;
49
+ if (origin && isPersonalDataGated(origin)) {
50
+ data["_writeOrigin"] = origin;
51
+ }
52
+ }
53
+
38
54
  // Queue-name convention: <prefix>-<lane>. The prefix is fixed in prod
39
55
  // ("kumiko-jobs") — it must match between enqueuers and consumers, and an
40
56
  // accidental drift would silently drop jobs. Tests override via
@@ -85,8 +101,10 @@ function legacySchedulerIdForJobName(jobName: string): string {
85
101
  // for the same trigger (retry after failure, a Redis drop, an instance
86
102
  // restarting mid-run) re-derives the *same* child ids, and BullMQ's
87
103
  // existing-jobId add() no-ops the second batch instead of creating
88
- // duplicates — the same dedup-on-id mechanism bootJobIdForJobName already
89
- // relies on above. Same colon-in-BullMQ-id hazard as schedulerIdForJobName
104
+ // duplicates — safe only as long as the child job hash outlives the
105
+ // wrapper's retry window, enforced at construction against
106
+ // COMPLETED_JOB_RETENTION_AGE_SEC (see the invariant check in
107
+ // createJobRunner). Same colon-in-BullMQ-id hazard as schedulerIdForJobName
90
108
  // (fw#1603/#1604) — the wrapper id already contains ":", so strip
91
109
  // separators from both halves before joining instead of interpolating raw.
92
110
  function perTenantChildJobId(wrapperJobId: string, tenantId: string): string {
@@ -225,6 +243,13 @@ export type JobRunnerOptions = {
225
243
  // before failing boot. Defaults to BOOT_REDIS_TIMEOUT_MS; tests shrink it
226
244
  // to keep an unreachable-Redis assertion fast.
227
245
  bootRedisTimeoutMs?: number | undefined;
246
+ // Override how long completed/failed jobs stay in Redis before BullMQ's
247
+ // lazy queue-wide sweep evicts them. Defaults to
248
+ // COMPLETED_JOB_RETENTION_AGE_SEC / FAILED_JOB_RETENTION_AGE_SEC; tests
249
+ // shrink both to exercise the sweep without a real 24h/7d wait.
250
+ jobRetention?:
251
+ | { completedAgeSec?: number | undefined; failedAgeSec?: number | undefined }
252
+ | undefined;
228
253
  getActiveTenantIds?: () => Promise<TenantId[]>;
229
254
  onJobStart?: (jobName: string, jobId: string, meta: JobMeta) => void;
230
255
  onJobComplete?: (
@@ -323,9 +348,10 @@ function timeoutReject(
323
348
  // waiting.
324
349
  const DEFAULT_JOB_BACKOFF_DELAY_MS = 1_000;
325
350
 
326
- // Shared by dispatch() and handleEvent() — an event-triggered job must retry
327
- // on failure the same way a directly-dispatched one does; a duplicated
328
- // inline computation in handleEvent previously dropped both options.
351
+ // Shared by every enqueue path (dispatch(), handleEvent(), cron, runOnBoot,
352
+ // perTenant wrapper/children, sequential re-enqueue) — a job with `retries`
353
+ // set must retry the same way regardless of how it got enqueued, or it
354
+ // fails for good on the very first error on whichever path skips this.
329
355
  function buildRetryBullOpts(jobDef: JobDefinition): Pick<JobsOptions, "attempts" | "backoff"> {
330
356
  const opts: Pick<JobsOptions, "attempts" | "backoff"> = {};
331
357
  if (jobDef.retries !== undefined) opts.attempts = jobDef.retries + 1;
@@ -341,6 +367,34 @@ function buildRetryBullOpts(jobDef: JobDefinition): Pick<JobsOptions, "attempts"
341
367
  return opts;
342
368
  }
343
369
 
370
+ // BullMQ sweeps queue-wide: any job finishing with keepJobs set can evict
371
+ // OTHER jobs in the same completed/failed zset (moveToFinished lua,
372
+ // removeJobsByMaxAge/removeJobsByMaxCount), not just the finishing job, and
373
+ // the sweep only runs lazily on a later finish into that set. Per-job
374
+ // retention is therefore meaningless on a shared queue — only queue-wide,
375
+ // age-only retention (no count) is safe: a count would evict perTenant
376
+ // children still inside their wrapper's retry window, and boot jobs. The
377
+ // completed age must stay above every perTenant wrapper's own worst-case
378
+ // retry window (see maxRetryWindowMs and the invariant check in
379
+ // createJobRunner below), because child dedup (perTenantChildJobId) relies
380
+ // on the children still existing in Redis when a wrapper retry re-derives
381
+ // their ids. The margin here is generous — queue wait time isn't bounded by
382
+ // backoff — to cover realistic windows. Audit trail lives in read_job_runs,
383
+ // not BullMQ (fw#3199).
384
+ const COMPLETED_JOB_RETENTION_AGE_SEC = 86_400; // 24h
385
+ const FAILED_JOB_RETENTION_AGE_SEC = 604_800; // 7d
386
+
387
+ // Total wall-clock time BullMQ can hold a perTenant wrapper across all its
388
+ // retries, consistent with buildRetryBullOpts's own backoff computation.
389
+ function maxRetryWindowMs(jobDef: JobDefinition): number {
390
+ if (!jobDef.backoff || jobDef.retries === undefined) return 0;
391
+ const delayMs =
392
+ (typeof jobDef.backoff === "string" ? undefined : jobDef.backoff.delayMs) ??
393
+ DEFAULT_JOB_BACKOFF_DELAY_MS;
394
+ const type = typeof jobDef.backoff === "string" ? jobDef.backoff : jobDef.backoff.type;
395
+ return type === "exponential" ? delayMs * (2 ** jobDef.retries - 1) : jobDef.retries * delayMs;
396
+ }
397
+
344
398
  export function createJobRunner(options: JobRunnerOptions): JobRunner {
345
399
  const { registry, context, redisUrl, consumerLane } = options;
346
400
  const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
@@ -397,6 +451,49 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
397
451
 
398
452
  const allJobs = registry.getAllJobs();
399
453
 
454
+ function positiveIntRetentionSec(
455
+ value: number | undefined,
456
+ fallback: number,
457
+ label: string,
458
+ ): number {
459
+ const resolved = value ?? fallback;
460
+ if (!Number.isInteger(resolved) || resolved <= 0) {
461
+ throw new Error(
462
+ `job-runner: jobRetention.${label} must be a positive integer, got ${resolved}`,
463
+ );
464
+ }
465
+ return resolved;
466
+ }
467
+ const completedAgeSec = positiveIntRetentionSec(
468
+ options.jobRetention?.completedAgeSec,
469
+ COMPLETED_JOB_RETENTION_AGE_SEC,
470
+ "completedAgeSec",
471
+ );
472
+ const failedAgeSec = positiveIntRetentionSec(
473
+ options.jobRetention?.failedAgeSec,
474
+ FAILED_JOB_RETENTION_AGE_SEC,
475
+ "failedAgeSec",
476
+ );
477
+
478
+ // perTenant child dedup (perTenantChildJobId) only holds as long as the
479
+ // children are still in Redis when a wrapper retry re-derives their ids,
480
+ // which requires the completed-job retention to outlast the wrapper's own
481
+ // worst-case retry window. Enforced here, before any Redis connection
482
+ // opens below, so a misconfigured job fails boot loudly instead of
483
+ // silently losing dedup in prod.
484
+ for (const [name, jobDef] of allJobs) {
485
+ if (!jobDef.perTenant) continue;
486
+ const windowMs = maxRetryWindowMs(jobDef);
487
+ if (windowMs >= completedAgeSec * 1000) {
488
+ throw new Error(
489
+ `job-runner: perTenant job "${name}" has a retry window of ${windowMs}ms, which is >= ` +
490
+ `the completed-job retention (${completedAgeSec}s). Child dedup relies on children ` +
491
+ "staying in Redis for the whole retry window — lower retries/backoff or raise " +
492
+ "jobRetention.completedAgeSec.",
493
+ );
494
+ }
495
+ }
496
+
400
497
  // Resolve the lane for a job — "worker" is the default because that's the
401
498
  // sensible prod lane (heavy async off the request path). Jobs that opted
402
499
  // into "api" must have been validated at registry boot already.
@@ -440,9 +537,22 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
440
537
  // queue matching the target job's runIn. Client-creation is cheap (shared
441
538
  // ioredis connection via bullmq), so this doesn't scale with number of
442
539
  // processes.
540
+ // Queue-wide, age-only retention (see COMPLETED_JOB_RETENTION_AGE_SEC
541
+ // above for why) — merged into every add()/addBulk()/upsertJobScheduler()
542
+ // template on both lanes.
543
+ const jobRetentionOpts: Pick<JobsOptions, "removeOnComplete" | "removeOnFail"> = {
544
+ removeOnComplete: { age: completedAgeSec },
545
+ removeOnFail: { age: failedAgeSec },
546
+ };
443
547
  const queues: Readonly<Record<JobRunIn, Queue>> = {
444
- api: new Queue(queueNameFor(queueNamePrefix, "api"), { connection: redisOpts }),
445
- worker: new Queue(queueNameFor(queueNamePrefix, "worker"), { connection: redisOpts }),
548
+ api: new Queue(queueNameFor(queueNamePrefix, "api"), {
549
+ connection: redisOpts,
550
+ defaultJobOptions: jobRetentionOpts,
551
+ }),
552
+ worker: new Queue(queueNameFor(queueNamePrefix, "worker"), {
553
+ connection: redisOpts,
554
+ defaultJobOptions: jobRetentionOpts,
555
+ }),
446
556
  };
447
557
  // Same unhandled-'error'-crash hazard as lockRedis above, just via
448
558
  // BullMQ's internal ioredis client (fw#1805).
@@ -536,9 +646,15 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
536
646
  await targetQueue.add(
537
647
  actualName,
538
648
  { ...bullJob.data, _tenantId: tenantId },
539
- wrapperJobId !== undefined
540
- ? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
541
- : undefined,
649
+ {
650
+ ...buildRetryBullOpts(actualDef),
651
+ // Dedup over wrapper retries only holds as long as the children
652
+ // stay in Redis for the wrapper's whole retry window — see the
653
+ // COMPLETED_JOB_RETENTION_AGE_SEC invariant check above.
654
+ ...(wrapperJobId !== undefined
655
+ ? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
656
+ : {}),
657
+ },
542
658
  );
543
659
  }
544
660
  // skip: fan-out dispatcher job, per-tenant children enqueued
@@ -567,8 +683,13 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
567
683
  // same queue the worker just picked from (since only the consuming
568
684
  // lane runs handleJob at all), but route explicitly — no implicit
569
685
  // coupling to "whichever queue the caller happened to be on".
686
+ // The re-enqueued job starts with a full retry budget; a remaining
687
+ // budget is deliberately not carried over, since finalAttempt/
688
+ // tenantVisibleFailure are computed from jobDef.retries against
689
+ // attemptsMade, not against some inherited remainder.
570
690
  await queues[laneForJob(jobDef)].add(jobName, bullJob.data, {
571
691
  delay: SEQUENTIAL_RETRY_DELAY_MS,
692
+ ...buildRetryBullOpts(jobDef),
572
693
  });
573
694
  // skip: lock taken, work re-enqueued with delay, current invocation done
574
695
  return;
@@ -626,6 +747,21 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
626
747
  // multi-trigger dispatch; exposed as jobContext.triggerName so handlers
627
748
  // don't dig through the raw payload themselves.
628
749
  const triggerName = rawData["_triggerName"] as string | undefined; // @cast-boundary dynamic-key
750
+
751
+ // Absent = legacy or ungated root; present but invalid fails the run closed.
752
+ const rawWriteOrigin = rawData["_writeOrigin"]; // @cast-boundary dynamic-key
753
+ let jobOrigin: WriteOrigin | undefined;
754
+ let writeOriginInvalid = false;
755
+ if (rawWriteOrigin !== undefined) {
756
+ const parsed = parseWriteOrigin(rawWriteOrigin);
757
+ if (parsed) {
758
+ jobOrigin = { ...parsed, viaJob: jobName };
759
+ } else {
760
+ writeOriginInvalid = true;
761
+ }
762
+ }
763
+ const jobPersonalDataGate = jobOrigin ? buildPersonalDataGate(registry, jobOrigin) : undefined;
764
+
629
765
  // Mirror dispatch-shared.ts buildHandlerContext: ctx.files must resolve
630
766
  // through the same _fileProviderResolver for jobs as for write-handlers,
631
767
  // otherwise event-triggered jobs silently get an unresolved ctx.files.
@@ -644,8 +780,19 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
644
780
  // systemScope() status (pre-existing, not something this change alters)
645
781
  // — isSystemJob below is what actually keeps ctx.systemDb off a
646
782
  // non-system job; it is the only thing standing between this db and an
647
- // unchecked cross-tenant escape hatch for such a job.
648
- const tenantScopedDb = configDb ? createTenantDb(configDb, tenantId, "system") : undefined;
783
+ // unchecked cross-tenant escape hatch for such a job. Gated like jobDb so
784
+ // ctx.systemDb, built from it, is gated too.
785
+ const tenantScopedDb = configDb
786
+ ? createTenantDb(
787
+ configDb,
788
+ tenantId,
789
+ "system",
790
+ undefined,
791
+ undefined,
792
+ undefined,
793
+ jobPersonalDataGate ? { personalDataGate: jobPersonalDataGate } : undefined,
794
+ )
795
+ : undefined;
649
796
  const isSystemJob = registry.isJobSystemScoped(jobName);
650
797
  // One reporter for ctx.systemDb and ctx.db.unsafeRaw() so both dedupe in the same window.
651
798
  const reportEscapeHatch = createEscapeHatchReporter({
@@ -663,6 +810,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
663
810
  ? createTenantDb(configDb, tenantId, "tenant", context.tracer, context.meter, undefined, {
664
811
  unsafeRaw: jobDef.escapeHatch,
665
812
  report: reportEscapeHatch,
813
+ ...(jobPersonalDataGate && { personalDataGate: jobPersonalDataGate }),
666
814
  })
667
815
  : undefined;
668
816
  const config =
@@ -713,7 +861,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
713
861
  "JobContext.write called before dispatcher attached — call attachDispatcher() first",
714
862
  );
715
863
  }
716
- return dispatchWriteRef.write(jobSystemUser, qn, payload);
864
+ return dispatchWriteRef.write(jobSystemUser, qn, payload, jobOrigin);
717
865
  },
718
866
  writeAs: (user: SessionUser, qn: string, payload: unknown) => {
719
867
  if (!dispatchWriteRef) {
@@ -721,7 +869,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
721
869
  "JobContext.writeAs called before dispatcher attached — call attachDispatcher() first",
722
870
  );
723
871
  }
724
- return dispatchWriteRef.write(user, qn, payload);
872
+ return dispatchWriteRef.write(user, qn, payload, jobOrigin);
725
873
  },
726
874
  queryAs: (user: SessionUser, qn: string, payload: unknown) => {
727
875
  if (!dispatchWriteRef) {
@@ -729,7 +877,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
729
877
  "JobContext.queryAs called before dispatcher attached — call attachDispatcher() first",
730
878
  );
731
879
  }
732
- return dispatchWriteRef.queryAs(user, qn, payload);
880
+ return dispatchWriteRef.queryAs(user, qn, payload, jobOrigin);
733
881
  },
734
882
  queryAsMember: (userId: string, qn: string, payload: unknown) => {
735
883
  if (!dispatchWriteRef) {
@@ -761,6 +909,13 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
761
909
 
762
910
  const runInSpan = async (): Promise<void> => {
763
911
  try {
912
+ if (writeOriginInvalid) {
913
+ throw new InternalError({
914
+ message:
915
+ `Job "${jobName}" received an unparseable _writeOrigin — refusing to run without ` +
916
+ "a trustworthy anonymous-root gate.",
917
+ });
918
+ }
764
919
  await requestContext.run(
765
920
  {
766
921
  requestId: jobRequestId,
@@ -768,6 +923,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
768
923
  // #3043 — events a job writes carry the job as their origin.
769
924
  handler: jobName,
770
925
  feature: qnScope(jobName),
926
+ writeOrigin: jobOrigin,
771
927
  },
772
928
  () => jobDef.handler(payload, jobContext),
773
929
  );
@@ -914,20 +1070,48 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
914
1070
  {
915
1071
  name: jobDef.perTenant ? `_perTenant:${name}` : name,
916
1072
  data: {},
1073
+ // Queue-level defaultJobOptions (jobRetentionOpts) covers
1074
+ // retention; a count here would sweep queue-wide again and
1075
+ // evict perTenant children and boot jobs.
917
1076
  opts: {
918
- removeOnComplete: { count: 100 },
919
- removeOnFail: { count: 50 },
1077
+ ...buildRetryBullOpts(jobDef),
920
1078
  },
921
1079
  },
922
1080
  );
923
1081
  }
924
1082
  }
925
1083
 
1084
+ // Persistent marker outside the swept completed/failed sets: a plain
1085
+ // Redis hash at one key per consumer queue, fields = boot job ids
1086
+ // already enqueued (BullMQ's IRedisClient has no set commands, only
1087
+ // hash/hexists — a hash with dummy values does the same job). The job
1088
+ // hash itself (removeOnComplete/-Fail: age-bound) is not a safe dedup
1089
+ // target any more — retention now evicts it eventually, which would
1090
+ // otherwise re-run a boot job "once per dataset" every time it ages
1091
+ // out. Order matters: HEXISTS check, then add(), then HSET.
1092
+ // Concurrent starts still dedupe on the existing job hash from add()'s
1093
+ // own jobId no-op; a crash between add() and HSET dedupes again on the
1094
+ // *next* boot (the job hash is still there) instead of losing the
1095
+ // boot job forever, which HSET-first would risk if the process died
1096
+ // before add() ran.
1097
+ const bootEnqueuedKey = consumerQueue.toKey("kumiko-boot-enqueued");
926
1098
  for (const [name, jobDef] of allJobs) {
927
1099
  if (laneForJob(jobDef) !== consumerLane) continue;
928
1100
  if (jobDef.runOnBoot) {
929
1101
  const bootName = jobDef.perTenant ? `_perTenant:${name}` : name;
930
- await consumerQueue.add(bootName, {}, { jobId: bootJobIdForJobName(name) });
1102
+ const bootJobId = bootJobIdForJobName(name);
1103
+ const client = await consumerQueue.client;
1104
+ const alreadyEnqueued = await client.hexists(bootEnqueuedKey, bootJobId);
1105
+ if (alreadyEnqueued) continue;
1106
+ await consumerQueue.add(
1107
+ bootName,
1108
+ {},
1109
+ {
1110
+ jobId: bootJobId,
1111
+ ...buildRetryBullOpts(jobDef),
1112
+ },
1113
+ );
1114
+ await client.hset(bootEnqueuedKey, { [bootJobId]: 1 });
931
1115
  }
932
1116
  }
933
1117
 
@@ -974,7 +1158,13 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
974
1158
 
975
1159
  // perTenant: dispatch the fan-out wrapper instead
976
1160
  if (jobDef.perTenant) {
977
- const job = await targetQueue.add(`_perTenant:${jobName}`, payload ?? {});
1161
+ const perTenantData: Record<string, unknown> = { ...(payload ?? {}) };
1162
+ stampGatedWriteOrigin(perTenantData);
1163
+ const job = await targetQueue.add(
1164
+ `_perTenant:${jobName}`,
1165
+ perTenantData,
1166
+ buildRetryBullOpts(jobDef),
1167
+ );
978
1168
  return job.id ?? "unknown";
979
1169
  }
980
1170
 
@@ -1047,6 +1237,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
1047
1237
  // stamp the same correlation as the HTTP request that scheduled it.
1048
1238
  const reqCtx = requestContext.get();
1049
1239
  if (reqCtx?.correlationId) data["_correlationId"] = reqCtx.correlationId;
1240
+ stampGatedWriteOrigin(data);
1050
1241
 
1051
1242
  const job = await targetQueue.add(jobName, data, bullOpts);
1052
1243
  return job.id ?? "unknown";
@@ -1091,6 +1282,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
1091
1282
  continue;
1092
1283
  }
1093
1284
  }
1285
+ stampGatedWriteOrigin(data);
1094
1286
  // Route to the job's declared lane, not a fixed queue — that's
1095
1287
  // the whole reason both queues are held.
1096
1288
  await queues[laneForJob(jobDef)].add(name, data, buildRetryBullOpts(jobDef));
@@ -177,35 +177,52 @@ const bridgeFeature = defineFeature("ctxbridge", (r) => {
177
177
  },
178
178
  );
179
179
 
180
- // Records whether ctx.db threw (should, once the request signal is
181
- // aborted) and whether the ctx.dbOutsideTransaction insert still landed
182
- // (should — durability writes must survive a client disconnect).
183
- //
184
- // The throwing side reads via ctx.db.selectMany, not the event-store
185
- // executor's create() — the latter writes through db.raw (bypassing
186
- // TenantDb's withDbSpan/signal check entirely), so it wouldn't exercise
187
- // the signal wiring this test is meant to prove. insertOne isn't an
188
- // option either: bagTable is executor-managed (WritableTable rejects its
189
- // EXECUTOR_ONLY brand) — direct writes would drift it past its event
190
- // stream. selectMany has no such restriction (reads keep the plain
191
- // SchemaTable param) and still goes through the same signal check.
180
+ // Proves runBatch strips the request signal: both inserts land and
181
+ // ctx.signal is undefined even though the request was pre-aborted.
192
182
  r.writeHandler(
193
183
  "bag:create-signal-probe",
194
184
  z.object({ label: z.string() }),
195
185
  async (event, ctx) => {
196
186
  const crud = createEventStoreExecutor(bagTable, bagEntity, { entityName: "bag" });
197
- let dbThrewAbortError = false;
198
- try {
199
- await ctx.db?.selectMany(bagTable, {});
200
- } catch (err) {
201
- dbThrewAbortError = err instanceof Error && err.name === "AbortError";
202
- }
187
+ await ctx.db?.selectMany(bagTable, {});
188
+ await crud.create({ label: `${event.payload.label}-inside-tx` }, event.user, ctx.db);
203
189
  const outsideTx = ctx.dbOutsideTransaction;
204
190
  if (!outsideTx) {
205
191
  throw new Error("bag:create-signal-probe requires ctx.dbOutsideTransaction");
206
192
  }
207
193
  await crud.create({ label: `${event.payload.label}-outside-tx` }, event.user, outsideTx);
208
- return { isSuccess: true as const, data: { dbThrewAbortError } };
194
+ return { isSuccess: true as const, data: { signalSeen: ctx.signal !== undefined } };
195
+ },
196
+ { access: { roles: ["Admin"] } },
197
+ );
198
+
199
+ // Query path counterpart — ctx.db.selectMany hits signal.throwIfAborted().
200
+ r.queryHandler(
201
+ "bag:list-signal-probe",
202
+ z.object({}),
203
+ async (_query, ctx) => selectMany(ctx.db, bagTable),
204
+ { access: { roles: ["Admin"] } },
205
+ );
206
+
207
+ // Negative control: never touches ctx.db, so a pre-aborted signal must still 500.
208
+ r.queryHandler(
209
+ "bag:query-boom",
210
+ z.object({}),
211
+ async () => {
212
+ throw new Error("unrelated boom");
213
+ },
214
+ { access: { roles: ["Admin"] } },
215
+ );
216
+
217
+ // Pre-write ctx.db read — crud.create's own insert writes via db.raw and
218
+ // doesn't check the signal, so this is what makes the test discriminating.
219
+ r.writeHandler(
220
+ "bag:create-plain",
221
+ z.object({ label: z.string() }),
222
+ async (event, ctx) => {
223
+ await ctx.db?.selectMany(bagTable, {});
224
+ const crud = createEventStoreExecutor(bagTable, bagEntity, { entityName: "bag" });
225
+ return crud.create(event.payload, event.user, ctx.db);
209
226
  },
210
227
  { access: { roles: ["Admin"] } },
211
228
  );
@@ -366,7 +383,7 @@ describe("ctx.dbOutsideTransaction", () => {
366
383
  expect(labels).toEqual(["probe-outside-tx"]);
367
384
  });
368
385
 
369
- test("an already-aborted request signal fails ctx.db but not ctx.dbOutsideTransaction", async () => {
386
+ test("an already-aborted request signal still commits both ctx.db and ctx.dbOutsideTransaction writes", async () => {
370
387
  const controller = new AbortController();
371
388
  controller.abort();
372
389
  const token = await stack.jwt.sign(admin);
@@ -385,16 +402,86 @@ describe("ctx.dbOutsideTransaction", () => {
385
402
 
386
403
  const body = (await res.json()) as {
387
404
  isSuccess: boolean;
388
- data?: { dbThrewAbortError: boolean };
405
+ data?: { signalSeen: boolean };
389
406
  };
390
407
  expect(body.isSuccess).toBe(true);
391
- expect(body.data?.dbThrewAbortError).toBe(true);
408
+ expect(body.data?.signalSeen).toBe(false);
392
409
 
393
- // Only the outside-tx insert landed — ctx.db's insert threw before it
394
- // could write, and there was nothing to roll back for it.
410
+ // Both inserts landed — the client's disconnect doesn't touch the write.
395
411
  const bags = await selectMany(stack.db, bagTable);
396
- const labels = (bags as Array<Record<string, unknown>>).map((row) => row["label"]);
397
- expect(labels).toEqual(["signal-probe-outside-tx"]);
412
+ const labels = (bags as Array<Record<string, unknown>>).map((row) => row["label"]).sort();
413
+ expect(labels).toEqual(["signal-probe-inside-tx", "signal-probe-outside-tx"]);
414
+ });
415
+
416
+ test("an already-aborted signal doesn't stop idempotent retries from committing", async () => {
417
+ const controller = new AbortController();
418
+ controller.abort();
419
+ const token = await stack.jwt.sign(admin);
420
+ const requestId = "retry-under-abort-1";
421
+
422
+ const requestOptions = {
423
+ method: "POST",
424
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
425
+ body: JSON.stringify({
426
+ type: "ctxbridge:write:bag:create-plain",
427
+ payload: { label: "retried" },
428
+ requestId,
429
+ }),
430
+ signal: controller.signal,
431
+ } as const;
432
+
433
+ const first = await stack.app.request(
434
+ new Request("http://test.local/api/write", requestOptions),
435
+ );
436
+ const second = await stack.app.request(
437
+ new Request("http://test.local/api/write", requestOptions),
438
+ );
439
+
440
+ expect((await first.json()).isSuccess).toBe(true);
441
+ expect((await second.json()).isSuccess).toBe(true);
442
+
443
+ const bags = await selectMany(stack.db, bagTable);
444
+ expect(bags).toHaveLength(1);
445
+ });
446
+ });
447
+
448
+ describe("query dispatch surfaces a client abort as 499, not a server fault", () => {
449
+ test("a pre-aborted signal 499s instead of 500ing", async () => {
450
+ const controller = new AbortController();
451
+ controller.abort();
452
+ const token = await stack.jwt.sign(admin);
453
+
454
+ const res = await stack.app.request(
455
+ new Request("http://test.local/api/query", {
456
+ method: "POST",
457
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
458
+ body: JSON.stringify({
459
+ type: "ctxbridge:query:bag:list-signal-probe",
460
+ payload: {},
461
+ }),
462
+ signal: controller.signal,
463
+ }),
464
+ );
465
+ expect(res.status).toBe(499);
466
+ });
467
+
468
+ test("a pre-aborted signal still 500s when the handler fails for an unrelated reason", async () => {
469
+ const controller = new AbortController();
470
+ controller.abort();
471
+ const token = await stack.jwt.sign(admin);
472
+
473
+ const res = await stack.app.request(
474
+ new Request("http://test.local/api/query", {
475
+ method: "POST",
476
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
477
+ body: JSON.stringify({
478
+ type: "ctxbridge:query:bag:query-boom",
479
+ payload: {},
480
+ }),
481
+ signal: controller.signal,
482
+ }),
483
+ );
484
+ expect(res.status).toBe(500);
398
485
  });
399
486
  });
400
487
 
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createSystemUser } from "../../engine/system-user";
3
3
  import { InternalError } from "../../errors";
4
+ import { VersionConflictError as EventStoreVersionConflictError } from "../../event-store/errors";
4
5
  import {
5
6
  describeShape,
6
7
  dispatcherSpanAttributes,
@@ -115,6 +116,13 @@ describe("wrapToKumiko", () => {
115
116
  expect(wrapped.code).toBe("internal_error");
116
117
  expect(wrapped.cause).toBeInstanceOf(TypeError);
117
118
  });
119
+
120
+ test("maps an event-store version conflict to a 409 version_conflict", () => {
121
+ const wrapped = wrapToKumiko(new EventStoreVersionConflictError("agg-1", 3));
122
+ expect(wrapped.code).toBe("version_conflict");
123
+ expect(wrapped.httpStatus).toBe(409);
124
+ expect(wrapped.details).toMatchObject({ entityId: "agg-1", expectedVersion: 3 });
125
+ });
118
126
  });
119
127
 
120
128
  describe("extractNestedSpecs", () => {
@@ -1082,5 +1082,10 @@ function createMockIdempotencyGuard() {
1082
1082
  pendingTokens.delete(key);
1083
1083
  results.set(key, JSON.stringify(result));
1084
1084
  },
1085
+ async release(tenantId: string, userId: string, requestId: string, token: string) {
1086
+ const key = `${tenantId}:${userId}:${requestId}`;
1087
+ if (pendingTokens.get(key) !== token) return;
1088
+ pendingTokens.delete(key);
1089
+ },
1085
1090
  };
1086
1091
  }