@cosmicdrift/kumiko-framework 0.306.0 → 0.308.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/anonymous-access.integration.test.ts +1 -1
  3. package/src/__tests__/error-contract.integration.test.ts +1 -1
  4. package/src/__tests__/field-access.integration.test.ts +1 -1
  5. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +1 -1
  6. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +1 -1
  7. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +1 -1
  8. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +1 -1
  9. package/src/__tests__/full-stack.integration.test.ts +1 -1
  10. package/src/__tests__/transition-guard.integration.test.ts +1 -1
  11. package/src/api/__tests__/api.test.ts +1 -1
  12. package/src/api/__tests__/batch.integration.test.ts +1 -1
  13. package/src/api/__tests__/body-limit.test.ts +1 -1
  14. package/src/api/__tests__/dispatcher-live.integration.test.ts +1 -1
  15. package/src/api/__tests__/extra-routes.integration.test.ts +1 -1
  16. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +1 -1
  17. package/src/api/__tests__/nested-write-ownership.integration.test.ts +1 -1
  18. package/src/api/__tests__/nested-write.integration.test.ts +1 -1
  19. package/src/api/__tests__/pii-leak-guard.integration.test.ts +1 -1
  20. package/src/api/__tests__/redis-sse-broker.integration.test.ts +133 -0
  21. package/src/api/__tests__/request-locale.integration.test.ts +1 -1
  22. package/src/api/__tests__/server-boot-guards.test.ts +1 -1
  23. package/src/api/__tests__/server-error-logging.test.ts +1 -1
  24. package/src/api/__tests__/sse-broker.test.ts +144 -1
  25. package/src/api/__tests__/stream-escape-hatch.integration.test.ts +1 -1
  26. package/src/api/auth-routes.ts +1 -1
  27. package/src/api/redis-sse-broker.ts +57 -3
  28. package/src/api/request-context.ts +24 -0
  29. package/src/api/server.ts +11 -0
  30. package/src/api/sse-broker.ts +69 -12
  31. package/src/changes.json +63 -0
  32. package/src/compliance/override-schema.ts +1 -1
  33. package/src/crypto/__tests__/event-pii.test.ts +1 -1
  34. package/src/crypto/kms-adapter.ts +1 -1
  35. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +1 -1
  36. package/src/db/queries/event-consumer.ts +155 -9
  37. package/src/db/queries/event-store.ts +69 -0
  38. package/src/db/tenant-db.ts +43 -5
  39. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +1 -1
  40. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +1 -1
  41. package/src/engine/__tests__/boot-validator-dashboard.test.ts +1 -1
  42. package/src/engine/__tests__/boot-validator-nav-area.test.ts +1 -1
  43. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
  44. package/src/engine/__tests__/boot-validator-projection-list.test.ts +1 -1
  45. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +1 -1
  46. package/src/engine/__tests__/boot-validator-query-refs.test.ts +1 -1
  47. package/src/engine/__tests__/boot-validator-ref-entity.test.ts +1 -1
  48. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +1 -1
  49. package/src/engine/__tests__/boot-validator.test.ts +35 -1
  50. package/src/engine/__tests__/build-app-schema.test.ts +1 -1
  51. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +1 -1
  52. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +1 -1
  53. package/src/engine/__tests__/engine.test.ts +1 -1
  54. package/src/engine/__tests__/event-helpers.test.ts +1 -1
  55. package/src/engine/__tests__/event-migration-declarative.test.ts +1 -1
  56. package/src/engine/__tests__/extend-entity-projection.test.ts +1 -1
  57. package/src/engine/__tests__/extends-registrar.test.ts +1 -1
  58. package/src/engine/__tests__/hook-phases.test.ts +1 -1
  59. package/src/engine/__tests__/lifecycle-hooks.test.ts +1 -1
  60. package/src/engine/__tests__/pipeline-engine.test.ts +1 -1
  61. package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
  62. package/src/engine/__tests__/pipeline-observability.integration.test.ts +1 -1
  63. package/src/engine/__tests__/pipeline-performance.integration.test.ts +1 -1
  64. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +1 -1
  65. package/src/engine/__tests__/post-query-hook.test.ts +1 -1
  66. package/src/engine/__tests__/projection-detail-actions.test.ts +1 -1
  67. package/src/engine/__tests__/projection-detail-singleton.test.ts +1 -1
  68. package/src/engine/__tests__/projection-detail-tabs.test.ts +1 -1
  69. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +1 -1
  70. package/src/engine/__tests__/registry.test.ts +1 -1
  71. package/src/engine/__tests__/screen.test.ts +1 -1
  72. package/src/engine/__tests__/validate-projection-allowlist.test.ts +1 -1
  73. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +1 -1
  74. package/src/engine/boot-validator/__tests__/action-icon.test.ts +81 -0
  75. package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +1 -1
  76. package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +1 -1
  77. package/src/engine/boot-validator/__tests__/parent-ref.test.ts +1 -1
  78. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +1 -1
  79. package/src/engine/boot-validator/screens.ts +83 -0
  80. package/src/engine/define-handler.ts +2 -1
  81. package/src/engine/entity-handlers.ts +2 -1
  82. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +1 -1
  83. package/src/engine/feature-ast/pattern-change-schema.ts +1 -1
  84. package/src/engine/feature-builder-state.ts +2 -1
  85. package/src/engine/feature-config-events-jobs.ts +2 -1
  86. package/src/engine/feature-entity-handlers.ts +2 -1
  87. package/src/engine/schema-builder.ts +1 -1
  88. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
  89. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +1 -1
  90. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
  91. package/src/env/__tests__/compose-env-schema.test.ts +1 -1
  92. package/src/env/__tests__/dry-run.test.ts +1 -1
  93. package/src/env/__tests__/parse-env-dry-run.test.ts +1 -1
  94. package/src/env/_zod-introspect.ts +1 -1
  95. package/src/env/dry-run.ts +1 -1
  96. package/src/env/index.ts +1 -1
  97. package/src/errors/__tests__/classes.test.ts +1 -1
  98. package/src/es-ops/__tests__/runner.integration.test.ts +1 -1
  99. package/src/event-store/__tests__/backfill-pii.integration.test.ts +1 -1
  100. package/src/event-store/__tests__/event-attribution.integration.test.ts +54 -4
  101. package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
  102. package/src/event-store/admin-api.ts +5 -0
  103. package/src/event-store/event-store.ts +16 -7
  104. package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -1
  105. package/src/jobs/__tests__/job-event-trigger.integration.test.ts +1 -1
  106. package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +1 -1
  107. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
  108. package/src/jobs/__tests__/job-retention.integration.test.ts +1 -1
  109. package/src/jobs/__tests__/job-write-as.integration.test.ts +1 -1
  110. package/src/jobs/__tests__/jobs.integration.test.ts +1 -1
  111. package/src/jobs/job-runner.ts +61 -6
  112. package/src/observability/__tests__/metrics-wiring.test.ts +1 -1
  113. package/src/observability/__tests__/observability.integration.test.ts +1 -1
  114. package/src/observability/metrics-wiring.ts +1 -1
  115. package/src/pipeline/__tests__/archive-stream.integration.test.ts +1 -1
  116. package/src/pipeline/__tests__/causation-chain.integration.test.ts +1 -1
  117. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +1 -1
  118. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +1 -1
  119. package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
  120. package/src/pipeline/__tests__/dispatcher.test.ts +1 -1
  121. package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +1 -1
  122. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +1 -1
  123. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +1 -1
  124. package/src/pipeline/__tests__/event-dedup.integration.test.ts +1 -1
  125. package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +1 -1
  126. package/src/pipeline/__tests__/event-dispatcher-batch-delivery.test.ts +178 -0
  127. package/src/pipeline/__tests__/event-dispatcher-batch-handler.integration.test.ts +143 -0
  128. package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
  129. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
  130. package/src/pipeline/__tests__/event-dispatcher-idle.integration.test.ts +94 -0
  131. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
  132. package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
  133. package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +103 -3
  134. package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +1 -1
  135. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +1 -1
  136. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +1 -1
  137. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  138. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
  139. package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +1 -1
  140. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +116 -1
  141. package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +1 -1
  142. package/src/pipeline/__tests__/pending-gap-ranges.test.ts +94 -0
  143. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +1 -1
  144. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +262 -3
  145. package/src/pipeline/__tests__/query-projection.integration.test.ts +1 -1
  146. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +1 -1
  147. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +1 -1
  148. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +1 -1
  149. package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +1 -1
  150. package/src/pipeline/__tests__/try-append-event.integration.test.ts +1 -1
  151. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +1 -1
  152. package/src/pipeline/dispatch-batch.ts +49 -19
  153. package/src/pipeline/dispatch-stream.ts +12 -3
  154. package/src/pipeline/dispatcher-utils.ts +21 -2
  155. package/src/pipeline/dispatcher.ts +71 -6
  156. package/src/pipeline/event-consumer-state.ts +26 -0
  157. package/src/pipeline/event-dispatcher-admin.ts +32 -5
  158. package/src/pipeline/event-dispatcher-delivery.ts +212 -65
  159. package/src/pipeline/event-dispatcher.ts +253 -49
  160. package/src/pipeline/index.ts +1 -0
  161. package/src/pipeline/msp-rebuild.ts +39 -1
  162. package/src/pipeline/pending-gap-ranges.ts +120 -0
  163. package/src/pipeline/system-hooks.ts +226 -64
  164. package/src/pipeline/write-origin.ts +31 -10
  165. package/src/rate-limit/__tests__/dispatcher-l3.integration.test.ts +1 -1
  166. package/src/search/__tests__/derive-search-adapter-config.test.ts +65 -0
  167. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +91 -0
  168. package/src/search/__tests__/search-consumer-batch.integration.test.ts +306 -0
  169. package/src/search/derive-search-adapter-config.ts +27 -0
  170. package/src/search/index.ts +1 -0
  171. package/src/search/meilisearch-adapter.ts +96 -14
  172. package/src/secrets/__tests__/contains-secret.test.ts +1 -1
  173. package/src/stack/__tests__/request-helper.integration.test.ts +1 -1
  174. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
  175. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +1 -1
  176. package/src/stack/test-stack.ts +1 -1
  177. package/src/testing/__tests__/e2e-generator.test.ts +1 -1
  178. package/src/testing/e2e-generator.ts +1 -1
@@ -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
@@ -731,6 +747,21 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
731
747
  // multi-trigger dispatch; exposed as jobContext.triggerName so handlers
732
748
  // don't dig through the raw payload themselves.
733
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
+
734
765
  // Mirror dispatch-shared.ts buildHandlerContext: ctx.files must resolve
735
766
  // through the same _fileProviderResolver for jobs as for write-handlers,
736
767
  // otherwise event-triggered jobs silently get an unresolved ctx.files.
@@ -749,8 +780,19 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
749
780
  // systemScope() status (pre-existing, not something this change alters)
750
781
  // — isSystemJob below is what actually keeps ctx.systemDb off a
751
782
  // non-system job; it is the only thing standing between this db and an
752
- // unchecked cross-tenant escape hatch for such a job.
753
- 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;
754
796
  const isSystemJob = registry.isJobSystemScoped(jobName);
755
797
  // One reporter for ctx.systemDb and ctx.db.unsafeRaw() so both dedupe in the same window.
756
798
  const reportEscapeHatch = createEscapeHatchReporter({
@@ -768,6 +810,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
768
810
  ? createTenantDb(configDb, tenantId, "tenant", context.tracer, context.meter, undefined, {
769
811
  unsafeRaw: jobDef.escapeHatch,
770
812
  report: reportEscapeHatch,
813
+ ...(jobPersonalDataGate && { personalDataGate: jobPersonalDataGate }),
771
814
  })
772
815
  : undefined;
773
816
  const config =
@@ -818,7 +861,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
818
861
  "JobContext.write called before dispatcher attached — call attachDispatcher() first",
819
862
  );
820
863
  }
821
- return dispatchWriteRef.write(jobSystemUser, qn, payload);
864
+ return dispatchWriteRef.write(jobSystemUser, qn, payload, jobOrigin);
822
865
  },
823
866
  writeAs: (user: SessionUser, qn: string, payload: unknown) => {
824
867
  if (!dispatchWriteRef) {
@@ -826,7 +869,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
826
869
  "JobContext.writeAs called before dispatcher attached — call attachDispatcher() first",
827
870
  );
828
871
  }
829
- return dispatchWriteRef.write(user, qn, payload);
872
+ return dispatchWriteRef.write(user, qn, payload, jobOrigin);
830
873
  },
831
874
  queryAs: (user: SessionUser, qn: string, payload: unknown) => {
832
875
  if (!dispatchWriteRef) {
@@ -834,7 +877,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
834
877
  "JobContext.queryAs called before dispatcher attached — call attachDispatcher() first",
835
878
  );
836
879
  }
837
- return dispatchWriteRef.queryAs(user, qn, payload);
880
+ return dispatchWriteRef.queryAs(user, qn, payload, jobOrigin);
838
881
  },
839
882
  queryAsMember: (userId: string, qn: string, payload: unknown) => {
840
883
  if (!dispatchWriteRef) {
@@ -866,6 +909,13 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
866
909
 
867
910
  const runInSpan = async (): Promise<void> => {
868
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
+ }
869
919
  await requestContext.run(
870
920
  {
871
921
  requestId: jobRequestId,
@@ -873,6 +923,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
873
923
  // #3043 — events a job writes carry the job as their origin.
874
924
  handler: jobName,
875
925
  feature: qnScope(jobName),
926
+ writeOrigin: jobOrigin,
876
927
  },
877
928
  () => jobDef.handler(payload, jobContext),
878
929
  );
@@ -1107,9 +1158,11 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
1107
1158
 
1108
1159
  // perTenant: dispatch the fan-out wrapper instead
1109
1160
  if (jobDef.perTenant) {
1161
+ const perTenantData: Record<string, unknown> = { ...(payload ?? {}) };
1162
+ stampGatedWriteOrigin(perTenantData);
1110
1163
  const job = await targetQueue.add(
1111
1164
  `_perTenant:${jobName}`,
1112
- payload ?? {},
1165
+ perTenantData,
1113
1166
  buildRetryBullOpts(jobDef),
1114
1167
  );
1115
1168
  return job.id ?? "unknown";
@@ -1184,6 +1237,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
1184
1237
  // stamp the same correlation as the HTTP request that scheduled it.
1185
1238
  const reqCtx = requestContext.get();
1186
1239
  if (reqCtx?.correlationId) data["_correlationId"] = reqCtx.correlationId;
1240
+ stampGatedWriteOrigin(data);
1187
1241
 
1188
1242
  const job = await targetQueue.add(jobName, data, bullOpts);
1189
1243
  return job.id ?? "unknown";
@@ -1228,6 +1282,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
1228
1282
  continue;
1229
1283
  }
1230
1284
  }
1285
+ stampGatedWriteOrigin(data);
1231
1286
  // Route to the job's declared lane, not a fixed queue — that's
1232
1287
  // the whole reason both queues are held.
1233
1288
  await queues[laneForJob(jobDef)].add(name, data, buildRetryBullOpts(jobDef));
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { composeEnvSchema, readKumikoMeta } from "../../env";
4
4
  import { prometheusMetricsEnvSchema, resolveObservabilityWiring } from "../metrics-wiring";
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import type { TableColumns } from "../../db/dialect";
4
4
  import { buildEntityTable } from "../../db/table-builder";
5
5
  import { createRegistry, defineFeature } from "../../engine";
@@ -1,4 +1,4 @@
1
- import { z } from "zod";
1
+ import * as z from "zod";
2
2
  import { createNoopProvider } from "./noop-provider";
3
3
  import { createPrometheusMeter, type PrometheusMeter } from "./prometheus-meter";
4
4
  import type { ObservabilityProvider } from "./types";
@@ -6,7 +6,7 @@
6
6
  // streams never pay for extra metadata writes.
7
7
 
8
8
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
9
- import { z } from "zod";
9
+ import * as z from "zod";
10
10
  import { createEventStoreExecutor } from "../../db/event-store-executor";
11
11
  import { buildEntityTable } from "../../db/table-builder";
12
12
  import { createEntity, createTextField, defineFeature } from "../../engine";
@@ -16,7 +16,7 @@
16
16
  // msp-multi-hop.integration.ts.
17
17
 
18
18
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
19
- import { z } from "zod";
19
+ import * as z from "zod";
20
20
  import { requestContext } from "../../api/request-context";
21
21
  import { createEventStoreExecutor } from "../../db/event-store-executor";
22
22
  import { selectMany } from "../../db/query";
@@ -1,5 +1,5 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { createEventStoreExecutor } from "../../db/event-store-executor";
4
4
  import { asRawClient, selectMany } from "../../db/query";
5
5
  import { buildEntityTable } from "../../db/table-builder";
@@ -1,5 +1,5 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { defineFeature } from "../../engine";
4
4
  import { setupTestStack, type TestStack, TestUsers } from "../../stack";
5
5
 
@@ -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", () => {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import type { SseBroker } from "../../api/sse-broker";
4
4
  import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
5
5
  import type { TenantId } from "../../engine/types/identifiers";
@@ -10,7 +10,7 @@
10
10
  // projections-runner fires on appendEvent, dispatcher routes appendEvent to
11
11
  // the aggregate stream), any of the assertions below go red.
12
12
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
13
- import { z } from "zod";
13
+ import * as z from "zod";
14
14
  import {
15
15
  integer as pgInteger,
16
16
  table as pgTable,
@@ -4,7 +4,7 @@
4
4
  // system-identity-switch.integration.test.ts.
5
5
 
6
6
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
- import { z } from "zod";
7
+ import * as z from "zod";
8
8
  import { defineUnmanagedTable } from "../../db/entity-table-meta";
9
9
  import { executeRawQuery } from "../../db/queries/raw-sql";
10
10
  import { createSystemUser, defineFeature } from "../../engine";
@@ -3,7 +3,7 @@
3
3
  // Real HTTP calls + setupTestStack — never createTestDispatcher.
4
4
 
5
5
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
- import { z } from "zod";
6
+ import * as z from "zod";
7
7
  import { defineUnmanagedTable } from "../../db/entity-table-meta";
8
8
  import { defineFeature } from "../../engine";
9
9
  import { createTestUser, setupTestStack, type TestStack } from "../../stack";
@@ -1,5 +1,5 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { createEventStoreExecutor } from "../../db/event-store-executor";
4
4
  import { defineFeature, type SaveContext } from "../../engine";
5
5
  import {
@@ -11,7 +11,7 @@
11
11
  // 3. r.defineEvent returns `{ name: qualifiedName, schema }` — callers
12
12
  // pass `def.name` to ctx.appendEvent without building the qn manually.
13
13
  import { afterEach, beforeAll, describe, expect, test } from "bun:test";
14
- import { z } from "zod";
14
+ import * as z from "zod";
15
15
  import { selectMany } from "../../db/query";
16
16
  import { defineFeature } from "../../engine";
17
17
  import { eventsTable } from "../../event-store";
@@ -0,0 +1,178 @@
1
+ // #3227 — deliverEvents' batchHandler fast-path: batch success must land
2
+ // the exact outcome the per-event loop would produce, a batch throw falls
3
+ // back to per-event delivery without bumping attempts for the failed
4
+ // batch attempt itself, and pending-gap resolution still splits pending
5
+ // vs new ids the same way it does for the per-event path.
6
+
7
+ import { beforeAll, describe, expect, test } from "bun:test";
8
+ import type { AppContext } from "../../engine/types";
9
+ import { ensureTemporalPolyfill } from "../../time/polyfill";
10
+ import type { EventConsumer } from "../event-dispatcher";
11
+ import {
12
+ type ConsumerStateRow,
13
+ deliverEvents,
14
+ type StoredEventRow,
15
+ } from "../event-dispatcher-delivery";
16
+
17
+ beforeAll(async () => {
18
+ await ensureTemporalPolyfill();
19
+ });
20
+
21
+ function stubContext(): AppContext {
22
+ return {
23
+ db: {} as unknown as AppContext["db"],
24
+ redis: {} as unknown as AppContext["redis"],
25
+ registry: {} as unknown as AppContext["registry"],
26
+ } as AppContext;
27
+ }
28
+
29
+ function stubEvent(id: bigint): StoredEventRow {
30
+ return {
31
+ id,
32
+ aggregateId: "agg-1",
33
+ aggregateType: "widget",
34
+ tenantId: "tenant-1",
35
+ version: 1,
36
+ type: "widget.created",
37
+ eventVersion: 1,
38
+ payload: {},
39
+ metadata: { userId: "system" },
40
+ createdAt: Temporal.Now.instant(),
41
+ createdBy: "system",
42
+ };
43
+ }
44
+
45
+ function stubState(overrides: Partial<ConsumerStateRow> = {}): ConsumerStateRow {
46
+ return {
47
+ name: "consumer",
48
+ instanceId: "shared",
49
+ lastProcessedEventId: 0n,
50
+ status: "idle",
51
+ attempts: 0,
52
+ rearmCount: 0,
53
+ pendingGaps: [],
54
+ lastError: null,
55
+ updatedAt: Temporal.Now.instant(),
56
+ ...overrides,
57
+ };
58
+ }
59
+
60
+ describe("deliverEvents — batchHandler fast-path", () => {
61
+ test("batch success calls batchHandler once, never handler, and matches the per-event outcome", async () => {
62
+ const rows = [1n, 2n, 3n].map(stubEvent);
63
+ const batchCalls: string[][] = [];
64
+ let handlerCalls = 0;
65
+
66
+ const batchConsumer: EventConsumer = {
67
+ name: "search",
68
+ batchHandler: async (events) => {
69
+ batchCalls.push(events.map((e) => e.id));
70
+ },
71
+ handler: async () => {
72
+ handlerCalls += 1;
73
+ },
74
+ };
75
+ const perEventConsumer: EventConsumer = {
76
+ name: "search",
77
+ handler: async () => {
78
+ // no-op — succeeds for every event, same as the batch above
79
+ },
80
+ };
81
+
82
+ const batchOutcome = await deliverEvents(batchConsumer, rows, stubContext(), 10, stubState());
83
+ const perEventOutcome = await deliverEvents(
84
+ perEventConsumer,
85
+ rows,
86
+ stubContext(),
87
+ 10,
88
+ stubState(),
89
+ );
90
+
91
+ expect(batchCalls).toEqual([["1", "2", "3"]]);
92
+ expect(handlerCalls).toBe(0);
93
+ expect(batchOutcome).toEqual(perEventOutcome);
94
+ expect(batchOutcome.cursor).toBe(3n);
95
+ expect(batchOutcome.attempts).toBe(0);
96
+ expect(batchOutcome.lastError).toBeNull();
97
+ expect(batchOutcome.processed).toBe(3);
98
+ expect(batchOutcome.failed).toBe(0);
99
+ });
100
+
101
+ test("pending-gap ids resolve the same way as per-event delivery", async () => {
102
+ const rows = [4n, 7n, 11n, 12n].map(stubEvent);
103
+ const consumer: EventConsumer = {
104
+ name: "search",
105
+ batchHandler: async () => {
106
+ // succeeds
107
+ },
108
+ handler: async () => {
109
+ throw new Error("handler must not run on batch success");
110
+ },
111
+ };
112
+
113
+ const outcome = await deliverEvents(
114
+ consumer,
115
+ rows,
116
+ stubContext(),
117
+ 10,
118
+ stubState({ lastProcessedEventId: 10n }),
119
+ );
120
+
121
+ expect(outcome.resolvedPendingIds).toEqual([4n, 7n]);
122
+ expect(outcome.cursor).toBe(12n);
123
+ });
124
+
125
+ test("batch throws, one poison row: falls back to per-event delivery without bumping attempts for the batch failure itself", async () => {
126
+ const rows = [1n, 2n, 3n, 4n, 5n].map(stubEvent);
127
+ const handlerCalls: string[] = [];
128
+ const consumer: EventConsumer = {
129
+ name: "search",
130
+ errorPolicy: { maxAttempts: 2 },
131
+ batchHandler: async () => {
132
+ throw new Error("batch failed");
133
+ },
134
+ handler: async (event) => {
135
+ handlerCalls.push(event.id);
136
+ if (event.id === "3") throw new Error("poison event");
137
+ },
138
+ };
139
+
140
+ const first = await deliverEvents(consumer, rows, stubContext(), 10, stubState());
141
+ expect(first.cursor).toBe(2n);
142
+ expect(first.attempts).toBe(1);
143
+ expect(first.deadLettered).toBe(false);
144
+ expect(handlerCalls).toEqual(["1", "2", "3"]);
145
+
146
+ const remaining = [3n, 4n, 5n].map(stubEvent);
147
+ const second = await deliverEvents(
148
+ consumer,
149
+ remaining,
150
+ stubContext(),
151
+ 10,
152
+ stubState({ lastProcessedEventId: first.cursor, attempts: first.attempts }),
153
+ );
154
+ expect(second.attempts).toBe(2);
155
+ expect(second.deadLettered).toBe(true);
156
+ });
157
+
158
+ test("batch throws with skipApplyErrors: true skips the poison row and delivers the rest via handler", async () => {
159
+ const rows = [1n, 2n, 3n, 4n, 5n].map(stubEvent);
160
+ const handlerCalls: string[] = [];
161
+ const consumer: EventConsumer = {
162
+ name: "search",
163
+ errorPolicy: { skipApplyErrors: true },
164
+ batchHandler: async () => {
165
+ throw new Error("batch failed");
166
+ },
167
+ handler: async (event) => {
168
+ handlerCalls.push(event.id);
169
+ if (event.id === "3") throw new Error("poison event");
170
+ },
171
+ };
172
+
173
+ const outcome = await deliverEvents(consumer, rows, stubContext(), 10, stubState());
174
+
175
+ expect(outcome.cursor).toBe(5n);
176
+ expect(handlerCalls).toEqual(["1", "2", "3", "4", "5"]);
177
+ });
178
+ });
@@ -0,0 +1,143 @@
1
+ // #3227 — batchHandler wired into a real dispatcher pass: a successful
2
+ // batch is delivered in one call (handler never runs, cursor lands at the
3
+ // last event) and a batch+handler failure still dead-letters through the
4
+ // normal errorPolicy machinery, delivering the poisoned event via handler
5
+ // exactly once before it does.
6
+
7
+ import { afterEach, beforeAll, describe, expect, test } from "bun:test";
8
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
9
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
10
+ import { defineFeature } from "../../engine";
11
+ import type { StoredEvent } from "../../event-store";
12
+ import {
13
+ createEventDispatcher,
14
+ type EventConsumer,
15
+ type EventDispatcher,
16
+ getConsumerState,
17
+ } from "../../pipeline";
18
+ import {
19
+ resetEventStore,
20
+ setupTestStack,
21
+ type TestStack,
22
+ TestUsers,
23
+ unsafeCreateEntityTable,
24
+ } from "../../stack";
25
+ import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
26
+
27
+ const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
28
+ entityName: "widget",
29
+ });
30
+
31
+ const feature = defineFeature("batchhandlerintegration", (r) => {
32
+ r.entity("widget", sharedWidgetEntity);
33
+ });
34
+
35
+ const admin = TestUsers.admin;
36
+ let stack: TestStack;
37
+ let tdb: TenantDb;
38
+
39
+ beforeAll(async () => {
40
+ stack = await setupTestStack({ features: [feature], systemHooks: [] });
41
+ await unsafeCreateEntityTable(stack.db, sharedWidgetEntity, "widget");
42
+ tdb = createTenantDb(stack.db, admin.tenantId);
43
+ });
44
+
45
+ afterEach(async () => {
46
+ await resetEventStore(stack, ["read_widgets"]);
47
+ });
48
+
49
+ async function appendWidget(name: string): Promise<string> {
50
+ const created = await executor.create({ name }, admin, tdb);
51
+ if (!created.isSuccess) throw new Error("create failed");
52
+ return String(created.data.id);
53
+ }
54
+
55
+ function buildDispatcher(consumers: readonly EventConsumer[]): EventDispatcher {
56
+ return createEventDispatcher({
57
+ db: stack.db,
58
+ consumers,
59
+ context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
60
+ batchSize: 200,
61
+ // No timer/LISTEN involved — runOnce() below drives every pass
62
+ // explicitly, so pollIntervalMs never fires a competing pass.
63
+ pollIntervalMs: 60_000,
64
+ });
65
+ }
66
+
67
+ describe("event-dispatcher — batchHandler", () => {
68
+ test("a successful batch delivers all events in one call, handler stays unused, cursor lands on the last event", async () => {
69
+ const consumerName = "batchhandlerintegration:success";
70
+ const batchSizes: number[] = [];
71
+ const batchIds: bigint[] = [];
72
+ const handlerIds: string[] = [];
73
+
74
+ const consumer: EventConsumer = {
75
+ name: consumerName,
76
+ batchHandler: async (events) => {
77
+ batchSizes.push(events.length);
78
+ for (const event of events) batchIds.push(BigInt(event.id));
79
+ },
80
+ handler: async (event) => {
81
+ handlerIds.push(event.aggregateId);
82
+ },
83
+ };
84
+
85
+ const dispatcher = buildDispatcher([consumer]);
86
+ await dispatcher.ensureRegistered();
87
+
88
+ await appendWidget("one");
89
+ await appendWidget("two");
90
+ await appendWidget("three");
91
+
92
+ const result = await dispatcher.runOnce();
93
+
94
+ expect(batchSizes).toEqual([3]);
95
+ expect(handlerIds).toEqual([]);
96
+ expect(result.byConsumer[consumerName]).toEqual({ processed: 3, failed: 0 });
97
+
98
+ const state = await getConsumerState(stack.db, consumerName);
99
+ expect(state?.status).toBe("idle");
100
+ expect(state?.lastProcessedEventId).toBe(batchIds.at(-1));
101
+ });
102
+
103
+ test("batch throws, handler poisons the 2nd event: dead-letters at maxAttempts, first event delivered exactly once via handler", async () => {
104
+ const consumerName = "batchhandlerintegration:poison";
105
+ const handlerCalls: StoredEvent[] = [];
106
+ let poisonAggregateId: string | undefined;
107
+
108
+ const consumer: EventConsumer = {
109
+ name: consumerName,
110
+ errorPolicy: { maxAttempts: 2 },
111
+ batchHandler: async () => {
112
+ throw new Error("batch always fails in this test");
113
+ },
114
+ handler: async (event) => {
115
+ handlerCalls.push(event);
116
+ if (event.aggregateId === poisonAggregateId) {
117
+ throw new Error("poisoned event");
118
+ }
119
+ },
120
+ };
121
+
122
+ const dispatcher = buildDispatcher([consumer]);
123
+ await dispatcher.ensureRegistered();
124
+
125
+ await appendWidget("first");
126
+ poisonAggregateId = await appendWidget("second");
127
+
128
+ const firstPass = await dispatcher.runOnce();
129
+ expect(firstPass.byConsumer[consumerName]?.failed).toBeGreaterThan(0);
130
+
131
+ const secondPass = await dispatcher.runOnce();
132
+ expect(secondPass.byConsumer[consumerName]).toBeDefined();
133
+
134
+ const state = await getConsumerState(stack.db, consumerName);
135
+ expect(state?.status).toBe("dead");
136
+
137
+ const firstEventDeliveries = handlerCalls.filter(
138
+ (event) => event.aggregateId === handlerCalls[0]?.aggregateId,
139
+ );
140
+ expect(firstEventDeliveries).toHaveLength(1);
141
+ expect(state?.lastProcessedEventId).toBe(BigInt(handlerCalls[0]?.id ?? "0"));
142
+ });
143
+ });