@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,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";
@@ -17,7 +17,7 @@
17
17
  // produce, not something exercised end-to-end by any test today.
18
18
 
19
19
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
20
- import { z } from "zod";
20
+ import * as z from "zod";
21
21
  import { entityEventName } from "../../db";
22
22
  import { defineFeature } from "../../engine";
23
23
  import { createInMemoryFileProvider, type InMemoryFileProvider } from "../../files";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, spyOn, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import {
4
4
  createEntity,
5
5
  createRegistry,
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
12
12
  import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
13
- import { z } from "zod";
13
+ import * as z from "zod";
14
14
  import type { DbConnection, DbTx } from "../../db/connection";
15
15
  import { createEventStoreExecutor } from "../../db/event-store-executor";
16
16
  import { buildEntityTable } from "../../db/table-builder";
@@ -11,7 +11,7 @@
11
11
  // 5. ctx.loadAggregate reads the triggering stream's full history.
12
12
 
13
13
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
14
- import { z } from "zod";
14
+ import * as z from "zod";
15
15
  import { createEventStoreExecutor } from "../../db/event-store-executor";
16
16
  import { selectMany } from "../../db/query";
17
17
  import { buildEntityTable } from "../../db/table-builder";
@@ -17,7 +17,8 @@
17
17
 
18
18
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
19
19
  import { sql } from "@cosmicdrift/kumiko-framework/db";
20
- import { z } from "zod";
20
+ import * as z from "zod";
21
+ import type { DbConnection, DbTx } from "../../db/connection";
21
22
  import { integer as pgInteger, table as pgTable, uuid as pgUuid } from "../../db/dialect";
22
23
  import { createEventStoreExecutor } from "../../db/event-store-executor";
23
24
  import { asRawClient, selectMany, updateMany } from "../../db/query";
@@ -35,6 +36,8 @@ import {
35
36
  TestUsers,
36
37
  unsafeCreateEntityTable,
37
38
  } from "../../stack";
39
+ import { waitFor } from "../../testing";
40
+ import { SHARED_INSTANCE_SENTINEL } from "../event-consumer-state";
38
41
 
39
42
  // --- Fixtures: two aggregates feeding one MSP + two cornered MSPs ---
40
43
 
@@ -69,6 +72,10 @@ const sagaStateTable = pgTable("read_mspreb_saga_state", {
69
72
  id: pgUuid("id").primaryKey(),
70
73
  });
71
74
 
75
+ // Captured for the raw-SQL out-of-order-commit test below, which appends
76
+ // events directly (bypassing the write handler) to control commit timing.
77
+ let invoiceBilledEventName = "";
78
+
72
79
  const feature = defineFeature("mspreb", (r) => {
73
80
  r.entity("msp-reb-invoice", invoiceEntity);
74
81
  r.entity("msp-reb-payment", paymentEntity);
@@ -78,6 +85,7 @@ const feature = defineFeature("mspreb", (r) => {
78
85
  z.object({ customer: z.uuid(), cents: z.number().int() }),
79
86
  { piiFields: "none" },
80
87
  );
88
+ invoiceBilledEventName = invoiceBilled.name;
81
89
  const paymentReceived = r.defineEvent(
82
90
  "payment-received",
83
91
  z.object({ customer: z.uuid(), cents: z.number().int() }),
@@ -231,6 +239,47 @@ async function runFullDispatcher(): Promise<void> {
231
239
  await stack.eventDispatcher?.runOnce();
232
240
  }
233
241
 
242
+ // Appends an invoice-billed event directly (not via the write handler) so
243
+ // the caller controls exactly when its id is committed and visible. `version`
244
+ // must be distinct per call for the SAME aggregateId — a repeated version on
245
+ // an uncommitted row makes Postgres block the second INSERT until the first
246
+ // transaction resolves (unique-constraint ambiguity), which would deadlock
247
+ // against a caller still holding that first transaction open.
248
+ async function insertInvoiceBilledEvent(
249
+ db: DbConnection | DbTx,
250
+ customer: string,
251
+ cents: number,
252
+ version: number,
253
+ ): Promise<void> {
254
+ await asRawClient(db).unsafe(
255
+ `INSERT INTO "kumiko_events"
256
+ (aggregate_id, aggregate_type, tenant_id, version, type, payload, metadata, created_by)
257
+ VALUES ($1::uuid, 'msp-reb-invoice', $2::uuid, $3, $4, $5::jsonb, '{}'::jsonb, 'test')`,
258
+ [
259
+ customer,
260
+ admin.tenantId,
261
+ version,
262
+ invoiceBilledEventName,
263
+ JSON.stringify({ customer, cents }),
264
+ ],
265
+ );
266
+ }
267
+
268
+ async function readPendingGaps(
269
+ consumerName: string,
270
+ ): Promise<ReadonlyArray<{ from: string; to: string; xmax: string }>> {
271
+ const rows = (await asRawClient(stack.db).unsafe(
272
+ `SELECT "pending_gaps", jsonb_typeof("pending_gaps") AS kind FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2`,
273
+ [consumerName, SHARED_INSTANCE_SENTINEL],
274
+ )) as ReadonlyArray<{
275
+ pending_gaps: ReadonlyArray<{ from: string; to: string; xmax: string }>;
276
+ kind: string;
277
+ }>;
278
+ // A double-encoded write lands as a jsonb string scalar, not an array.
279
+ if (rows[0] && rows[0].kind !== "array") throw new Error(`pending_gaps is jsonb ${rows[0].kind}`);
280
+ return rows[0]?.pending_gaps ?? [];
281
+ }
282
+
234
283
  // --- Tests ---
235
284
 
236
285
  describe("rebuildMultiStreamProjection — rebuildable read-model", () => {
@@ -310,6 +359,72 @@ describe("rebuildMultiStreamProjection — rebuildable read-model", () => {
310
359
  const [row] = await selectMany(stack.db, balanceTable, { customer: carol });
311
360
  expect(row).toMatchObject({ invoicesCents: 42_00, paymentsCents: 0 });
312
361
  });
362
+
363
+ test("rebuild handoff seeds a pending gap for a lower-id write still open during replay, and the live dispatcher delivers it exactly once (#3231)", async () => {
364
+ const frank = "00000000-0000-4000-8000-00000000f106";
365
+ await updateMany(
366
+ stack.db,
367
+ eventConsumerStateTable,
368
+ { status: "disabled", updatedAt: sql`now()` },
369
+ { name: SAGA_MSP },
370
+ );
371
+
372
+ // Tx A grabs the LOW id first but holds its transaction open —
373
+ // uncommitted, so it's invisible to the rebuild's horizon read below.
374
+ let releaseA!: () => void;
375
+ const aGate = new Promise<void>((resolve) => {
376
+ releaseA = resolve;
377
+ });
378
+ let markAInserted!: () => void;
379
+ const aInserted = new Promise<void>((resolve) => {
380
+ markAInserted = resolve;
381
+ });
382
+ const aDone = stack.db
383
+ .begin(async (tx: DbTx) => {
384
+ await insertInvoiceBilledEvent(tx, frank, 1_00, 1);
385
+ markAInserted();
386
+ await aGate;
387
+ })
388
+ .catch(() => {});
389
+ await aInserted;
390
+
391
+ // Commits AFTER A grabbed its id, so it's the higher, only-visible id
392
+ // when the rebuild reads its horizon.
393
+ await insertInvoiceBilledEvent(stack.db, frank, 100_00, 2);
394
+
395
+ try {
396
+ const result = await rebuildMultiStreamProjection(BALANCE_MSP, {
397
+ db: stack.db,
398
+ registry: stack.registry,
399
+ });
400
+ // Only the visible (higher-id) event was replayed — A's row is still
401
+ // invisible, so it must not be double-counted once it does arrive.
402
+ const [afterRebuild] = await selectMany(stack.db, balanceTable, { customer: frank });
403
+ expect(afterRebuild).toMatchObject({ invoicesCents: 100_00 });
404
+ expect(result.lastProcessedEventId).toBeGreaterThan(0n);
405
+
406
+ const gapsAfterRebuild = await readPendingGaps(BALANCE_MSP);
407
+ expect(gapsAfterRebuild).not.toEqual([]);
408
+ } finally {
409
+ releaseA();
410
+ await aDone;
411
+ }
412
+
413
+ // A commits now — the live dispatcher must deliver it exactly once via
414
+ // the seeded pending gap, landing at 1_00 + 100_00, not double-applied.
415
+ await waitFor(
416
+ async () => {
417
+ await runFullDispatcher();
418
+ const [row] = await selectMany(stack.db, balanceTable, { customer: frank });
419
+ expect(row).toMatchObject({ invoicesCents: 101_00 });
420
+ expect(await readPendingGaps(BALANCE_MSP)).toEqual([]);
421
+ },
422
+ { delays: [20, 100, 500, 1000, 3000] },
423
+ );
424
+
425
+ const [finalRow] = await selectMany(stack.db, balanceTable, { customer: frank });
426
+ expect(finalRow).toMatchObject({ invoicesCents: 101_00 });
427
+ });
313
428
  });
314
429
 
315
430
  describe("rebuildMultiStreamProjection — guard rails", () => {
@@ -7,7 +7,7 @@
7
7
  // per MSP consumer, dead-letters on repeated handler failures.
8
8
 
9
9
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
10
- import { z } from "zod";
10
+ import * as z from "zod";
11
11
  import { integer as pgInteger, table as pgTable, uuid as pgUuid } from "../../db/dialect";
12
12
  import { createEventStoreExecutor } from "../../db/event-store-executor";
13
13
  import { asRawClient, selectMany } from "../../db/query";
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { PendingGapEntry } from "../event-consumer-state";
3
+ import { capPendingGapsBelowCursor, subtractSortedIdsFromRanges } from "../pending-gap-ranges";
4
+
5
+ function gap(from: string, to: string, xmax = "100"): PendingGapEntry {
6
+ return { from, to, xmax };
7
+ }
8
+
9
+ describe("subtractSortedIdsFromRanges", () => {
10
+ test("no ids leaves ranges unchanged", () => {
11
+ const ranges = [gap("10", "20")];
12
+ expect(subtractSortedIdsFromRanges(ranges, [])).toEqual([gap("10", "20")]);
13
+ });
14
+
15
+ test("id in the middle of a range splits it in two", () => {
16
+ const ranges = [gap("10", "20")];
17
+ expect(subtractSortedIdsFromRanges(ranges, [15n])).toEqual([gap("10", "14"), gap("16", "20")]);
18
+ });
19
+
20
+ test("ids at from- and to-boundary shrink the range from inside", () => {
21
+ const ranges = [gap("10", "20")];
22
+ expect(subtractSortedIdsFromRanges(ranges, [10n, 20n])).toEqual([gap("11", "19")]);
23
+ });
24
+
25
+ test("all ids of a range make it disappear", () => {
26
+ const ranges = [gap("10", "12")];
27
+ expect(subtractSortedIdsFromRanges(ranges, [10n, 11n, 12n])).toEqual([]);
28
+ });
29
+
30
+ test("ids between, before and after ranges affect nothing", () => {
31
+ const ranges = [gap("10", "20"), gap("30", "40")];
32
+ expect(subtractSortedIdsFromRanges(ranges, [5n, 25n, 45n])).toEqual([
33
+ gap("10", "20"),
34
+ gap("30", "40"),
35
+ ]);
36
+ });
37
+
38
+ test("multiple ranges with ids spread across them use a single pointer pass", () => {
39
+ const ranges = [gap("10", "20"), gap("30", "40"), gap("50", "60")];
40
+ const ids = [15n, 30n, 40n, 55n];
41
+ expect(subtractSortedIdsFromRanges(ranges, ids)).toEqual([
42
+ gap("10", "14"),
43
+ gap("16", "20"),
44
+ gap("31", "39"),
45
+ gap("50", "54"),
46
+ gap("56", "60"),
47
+ ]);
48
+ });
49
+
50
+ test("xmax is preserved on every surviving part", () => {
51
+ const ranges = [gap("10", "20", "999")];
52
+ expect(subtractSortedIdsFromRanges(ranges, [15n])).toEqual([
53
+ gap("10", "14", "999"),
54
+ gap("16", "20", "999"),
55
+ ]);
56
+ });
57
+ });
58
+
59
+ describe("capPendingGapsBelowCursor", () => {
60
+ test("cursor 0n caps everything away", () => {
61
+ const ranges = [gap("10", "20")];
62
+ expect(capPendingGapsBelowCursor(ranges, 0n)).toEqual([]);
63
+ });
64
+
65
+ test("range fully below cursor is unchanged", () => {
66
+ const ranges = [gap("10", "20")];
67
+ expect(capPendingGapsBelowCursor(ranges, 100n)).toEqual([gap("10", "20")]);
68
+ });
69
+
70
+ test("range straddling the cursor is truncated to cursor - 1", () => {
71
+ const ranges = [gap("10", "20")];
72
+ expect(capPendingGapsBelowCursor(ranges, 15n)).toEqual([gap("10", "14")]);
73
+ });
74
+
75
+ test("range with to == cursor is truncated to cursor - 1", () => {
76
+ const ranges = [gap("10", "20")];
77
+ expect(capPendingGapsBelowCursor(ranges, 20n)).toEqual([gap("10", "19")]);
78
+ });
79
+
80
+ test("range with from == cursor is dropped entirely", () => {
81
+ const ranges = [gap("20", "30")];
82
+ expect(capPendingGapsBelowCursor(ranges, 20n)).toEqual([]);
83
+ });
84
+
85
+ test("range with from > cursor is dropped entirely", () => {
86
+ const ranges = [gap("25", "30")];
87
+ expect(capPendingGapsBelowCursor(ranges, 20n)).toEqual([]);
88
+ });
89
+
90
+ test("xmax is preserved on surviving ranges", () => {
91
+ const ranges = [gap("10", "20", "777")];
92
+ expect(capPendingGapsBelowCursor(ranges, 15n)).toEqual([gap("10", "14", "777")]);
93
+ });
94
+ });
@@ -10,7 +10,7 @@
10
10
  // nicht createTestDispatcher.
11
11
 
12
12
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
- import { z } from "zod";
13
+ import * as z from "zod";
14
14
  import { createEntity, createTextField, defineFeature } from "../../engine";
15
15
  import type { PostQueryHookFn } from "../../engine/types";
16
16
  import { setupTestStack, type TestStack, TestUsers } from "../../stack";
@@ -2,12 +2,13 @@
2
2
  // so the static boot check passes and only the runtime gate can stop the write.
3
3
 
4
4
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
5
- import { z } from "zod";
5
+ import * as z from "zod";
6
6
  import type { SchemaTable } from "../../db";
7
+ import type { DbRunner } from "../../db/connection";
7
8
  import { createEventStoreExecutor } from "../../db/event-store-executor";
8
- import { asRawClient, selectMany } from "../../db/query";
9
+ import { asRawClient, runInSavepoint, selectMany } from "../../db/query";
9
10
  import { buildEntityTable } from "../../db/table-builder";
10
- import type { TenantDb } from "../../db/tenant-db";
11
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
11
12
  import { createEntity, createSystemUser, createTextField, defineFeature } from "../../engine";
12
13
  import { SYSTEM_ROLE } from "../../engine/system-user";
13
14
  import type { TenantId } from "../../engine/types";
@@ -273,6 +274,185 @@ const featureA = defineFeature("intakea", (r) => {
273
274
  },
274
275
  { access: { roles: ["anonymous", "Admin"] }, rateLimit: RATE_LIMIT },
275
276
  );
277
+
278
+ // (g) createTenantDb() built by the HANDLER ITSELF from ctx.db.unsafeRaw(reason) — the
279
+ // returned runner carries no TenantDb of its own, so without gate inheritance on the
280
+ // runner (tenant-db.ts's runnerPersonalDataGates) this fresh TenantDb would have no gate.
281
+ const UNSAFE_RAW_REASON =
282
+ "test: proves createTenantDb() built from ctx.db.unsafeRaw() inherits the caller's personal-data gate";
283
+
284
+ r.writeHandler(
285
+ "unsafe-raw-tenant-db-no-declare",
286
+ z.object({ note: z.string() }),
287
+ async (event, ctx) => {
288
+ const raw = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
289
+ await createTenantDb(raw, event.user.tenantId, "system").insertOne(
290
+ contactTable as unknown as SchemaTable,
291
+ { email: "leak-unsafe-raw@example.com", note: event.payload.note },
292
+ );
293
+ return { isSuccess: true as const, data: { ok: true as const } };
294
+ },
295
+ {
296
+ access: { roles: ["anonymous"] },
297
+ rateLimit: RATE_LIMIT,
298
+ escapeHatch: { reason: UNSAFE_RAW_REASON },
299
+ },
300
+ );
301
+
302
+ r.writeHandler(
303
+ "unsafe-raw-tenant-db-declare",
304
+ z.object({ note: z.string() }),
305
+ async (event, ctx) => {
306
+ const raw = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
307
+ await createTenantDb(raw, event.user.tenantId, "system").insertOne(
308
+ contactTable as unknown as SchemaTable,
309
+ { email: "leak-unsafe-raw-declared@example.com", note: event.payload.note },
310
+ );
311
+ return { isSuccess: true as const, data: { ok: true as const } };
312
+ },
313
+ {
314
+ access: { roles: ["anonymous"], personalData: "public-intake" },
315
+ rateLimit: RATE_LIMIT,
316
+ escapeHatch: { reason: UNSAFE_RAW_REASON },
317
+ },
318
+ );
319
+
320
+ // Nested tx: the handler already runs inside the request's own transaction, so its
321
+ // unsafeRaw runner is a tx handle (.savepoint, not .begin — see asRawClient's own
322
+ // comment). The savepoint-scoped tx a callback receives must inherit the same gate,
323
+ // otherwise a handler could dodge the gate by moving the write inside a savepoint.
324
+ r.writeHandler(
325
+ "unsafe-raw-savepoint-tenant-db-no-declare",
326
+ z.object({ note: z.string() }),
327
+ async (event, ctx) => {
328
+ const raw = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
329
+ await runInSavepoint(raw, async (sp) => {
330
+ await createTenantDb(sp as DbRunner, event.user.tenantId, "system").insertOne(
331
+ contactTable as unknown as SchemaTable,
332
+ { email: "leak-unsafe-raw-tx@example.com", note: event.payload.note },
333
+ );
334
+ });
335
+ return { isSuccess: true as const, data: { ok: true as const } };
336
+ },
337
+ {
338
+ access: { roles: ["anonymous"] },
339
+ rateLimit: RATE_LIMIT,
340
+ escapeHatch: { reason: UNSAFE_RAW_REASON },
341
+ },
342
+ );
343
+
344
+ r.writeHandler(
345
+ "unsafe-raw-savepoint-tenant-db-declare",
346
+ z.object({ note: z.string() }),
347
+ async (event, ctx) => {
348
+ const raw = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
349
+ await runInSavepoint(raw, async (sp) => {
350
+ await createTenantDb(sp as DbRunner, event.user.tenantId, "system").insertOne(
351
+ contactTable as unknown as SchemaTable,
352
+ { email: "leak-unsafe-raw-tx-declared@example.com", note: event.payload.note },
353
+ );
354
+ });
355
+ return { isSuccess: true as const, data: { ok: true as const } };
356
+ },
357
+ {
358
+ access: { roles: ["anonymous"], personalData: "public-intake" },
359
+ rateLimit: RATE_LIMIT,
360
+ escapeHatch: { reason: UNSAFE_RAW_REASON },
361
+ },
362
+ );
363
+
364
+ // Raw SQL through the gated proxy itself must stay ungated (decision: unsafeRaw's raw SQL
365
+ // is an escape hatch + audit trail, not something the personal-data gate blocks) — this
366
+ // just proves the proxy stays transparent for a direct tagged-template call.
367
+ r.writeHandler(
368
+ "unsafe-raw-tagged-query-declare",
369
+ z.object({ note: z.string() }),
370
+ async (event, ctx) => {
371
+ const raw = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
372
+ // @cast-boundary test-fixture — DbRunner's RawClient arm has no tagged-template call
373
+ // signature; the underlying driver instance (postgres-js/Bun.SQL) does.
374
+ const tagged = raw as unknown as (
375
+ strings: TemplateStringsArray,
376
+ ...values: unknown[]
377
+ ) => Promise<readonly Record<string, unknown>[]>;
378
+ const rows = await tagged`SELECT 1 AS one`;
379
+ if (rows[0]?.["one"] !== 1) throw new Error("tagged query through the proxy failed");
380
+ await createTenantDb(raw, event.user.tenantId, "system").insertOne(
381
+ contactTable as unknown as SchemaTable,
382
+ { email: "leak-unsafe-raw-tagged@example.com", note: event.payload.note },
383
+ );
384
+ return { isSuccess: true as const, data: { ok: true as const } };
385
+ },
386
+ {
387
+ access: { roles: ["anonymous"], personalData: "public-intake" },
388
+ rateLimit: RATE_LIMIT,
389
+ escapeHatch: { reason: UNSAFE_RAW_REASON },
390
+ },
391
+ );
392
+
393
+ // Authenticated session: same ctx.db.unsafeRaw() -> createTenantDb() path, but the root
394
+ // isn't anonymous, so ctx.db carries no personal-data gate to inherit — unaffected.
395
+ r.writeHandler(
396
+ "unsafe-raw-tenant-db-authenticated",
397
+ z.object({ note: z.string() }),
398
+ async (event, ctx) => {
399
+ const raw = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
400
+ await createTenantDb(raw, event.user.tenantId, "system").insertOne(
401
+ contactTable as unknown as SchemaTable,
402
+ { email: "leak-unsafe-raw-authenticated@example.com", note: event.payload.note },
403
+ );
404
+ return { isSuccess: true as const, data: { ok: true as const } };
405
+ },
406
+ { access: { roles: ["Admin"] }, escapeHatch: { reason: UNSAFE_RAW_REASON } },
407
+ );
408
+
409
+ // The CRUD executor writes through tenantDbRunner + assertPersonalDataWrite, not
410
+ // insertOne — a TenantDb inheriting its gate only from the runner (not from an explicit
411
+ // grants.personalDataGate) must gate the executor path too, not just direct insertOne.
412
+ r.writeHandler(
413
+ "unsafe-raw-executor-no-declare",
414
+ z.object({ note: z.string() }),
415
+ async (event, ctx) => {
416
+ const scopedDb = createTenantDb(
417
+ ctx.db.unsafeRaw(UNSAFE_RAW_REASON),
418
+ event.user.tenantId,
419
+ "system",
420
+ );
421
+ const crud = createEventStoreExecutor(contactTable, contactEntity, { entityName: "contact" });
422
+ return crud.create(
423
+ { email: "leak-unsafe-raw-executor@example.com", note: event.payload.note },
424
+ event.user,
425
+ scopedDb,
426
+ );
427
+ },
428
+ {
429
+ access: { roles: ["anonymous"] },
430
+ rateLimit: RATE_LIMIT,
431
+ escapeHatch: { reason: UNSAFE_RAW_REASON },
432
+ },
433
+ );
434
+
435
+ // Same inherited-gate TenantDb, but only a non-PII field — proves the proxy/executor
436
+ // combination still appends events and lets the projection run when there is nothing
437
+ // for the gate to block, not merely that it throws.
438
+ r.writeHandler(
439
+ "unsafe-raw-executor-non-pii-no-declare",
440
+ z.object({ note: z.string() }),
441
+ async (event, ctx) => {
442
+ const scopedDb = createTenantDb(
443
+ ctx.db.unsafeRaw(UNSAFE_RAW_REASON),
444
+ event.user.tenantId,
445
+ "system",
446
+ );
447
+ const crud = createEventStoreExecutor(probeTable, probeEntity, { entityName: "probe" });
448
+ return crud.create({ note: event.payload.note }, event.user, scopedDb);
449
+ },
450
+ {
451
+ access: { roles: ["anonymous"] },
452
+ rateLimit: RATE_LIMIT,
453
+ escapeHatch: { reason: UNSAFE_RAW_REASON },
454
+ },
455
+ );
276
456
  });
277
457
 
278
458
  describe("public-intake runtime gate", () => {
@@ -422,4 +602,83 @@ describe("public-intake runtime gate", () => {
422
602
  expect(res.status).toBe(200);
423
603
  expect(await rowCount()).toBe(1);
424
604
  });
605
+
606
+ test("(g) createTenantDb(ctx.db.unsafeRaw(reason), ...) — blocked without declaration", async () => {
607
+ const res = await stack.http.raw("POST", "/api/write", {
608
+ type: "intakea:write:unsafe-raw-tenant-db-no-declare",
609
+ payload: { note: "x" },
610
+ });
611
+ expect(res.status).toBe(403);
612
+ const body = (await res.json()) as { error: { details: { reason: string } } };
613
+ expect(body.error.details.reason).toBe("public_intake_required");
614
+ expect(await rowCount()).toBe(0);
615
+ });
616
+
617
+ test("(g) createTenantDb(ctx.db.unsafeRaw(reason), ...) — allowed once declared", async () => {
618
+ const res = await stack.http.raw("POST", "/api/write", {
619
+ type: "intakea:write:unsafe-raw-tenant-db-declare",
620
+ payload: { note: "x" },
621
+ });
622
+ expect(res.status).toBe(200);
623
+ expect(await rowCount()).toBe(1);
624
+ });
625
+
626
+ test("(g) createTenantDb(tx, ...) inside savepoint() — blocked without declaration", async () => {
627
+ const res = await stack.http.raw("POST", "/api/write", {
628
+ type: "intakea:write:unsafe-raw-savepoint-tenant-db-no-declare",
629
+ payload: { note: "x" },
630
+ });
631
+ expect(res.status).toBe(403);
632
+ const body = (await res.json()) as { error: { details: { reason: string } } };
633
+ expect(body.error.details.reason).toBe("public_intake_required");
634
+ expect(await rowCount()).toBe(0);
635
+ });
636
+
637
+ test("(g) createTenantDb(tx, ...) inside savepoint() — allowed once declared", async () => {
638
+ const res = await stack.http.raw("POST", "/api/write", {
639
+ type: "intakea:write:unsafe-raw-savepoint-tenant-db-declare",
640
+ payload: { note: "x" },
641
+ });
642
+ expect(res.status).toBe(200);
643
+ expect(await rowCount()).toBe(1);
644
+ });
645
+
646
+ test("(g) a tagged-template query through the gated unsafeRaw proxy still works", async () => {
647
+ const res = await stack.http.raw("POST", "/api/write", {
648
+ type: "intakea:write:unsafe-raw-tagged-query-declare",
649
+ payload: { note: "x" },
650
+ });
651
+ expect(res.status).toBe(200);
652
+ expect(await rowCount()).toBe(1);
653
+ });
654
+
655
+ test("(g) authenticated session on the same unsafeRaw->createTenantDb path — unaffected", async () => {
656
+ const res = await stack.http.write(
657
+ "intakea:write:unsafe-raw-tenant-db-authenticated",
658
+ { note: "x" },
659
+ TestUsers.admin,
660
+ );
661
+ expect(res.status).toBe(200);
662
+ expect(await rowCount()).toBe(1);
663
+ });
664
+
665
+ test("(g) createEventStoreExecutor.create through an inherited-gate TenantDb — blocked without declaration", async () => {
666
+ const res = await stack.http.raw("POST", "/api/write", {
667
+ type: "intakea:write:unsafe-raw-executor-no-declare",
668
+ payload: { note: "x" },
669
+ });
670
+ expect(res.status).toBe(403);
671
+ const body = (await res.json()) as { error: { details: { reason: string } } };
672
+ expect(body.error.details.reason).toBe("public_intake_required");
673
+ expect(await rowCount()).toBe(0);
674
+ });
675
+
676
+ test("(g) createEventStoreExecutor.create of a non-PII field through the same inherited-gate TenantDb — allowed", async () => {
677
+ const res = await stack.http.raw("POST", "/api/write", {
678
+ type: "intakea:write:unsafe-raw-executor-non-pii-no-declare",
679
+ payload: { note: "x" },
680
+ });
681
+ expect(res.status).toBe(200);
682
+ expect(await probeRowCount()).toBe(1);
683
+ });
425
684
  });
@@ -5,7 +5,7 @@
5
5
  // grant — r.systemScope() or a declared escapeHatch on the handler.
6
6
 
7
7
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
8
- import { z } from "zod";
8
+ import * as z from "zod";
9
9
  import {
10
10
  integer as pgInteger,
11
11
  table as pgTable,
@@ -4,7 +4,7 @@
4
4
  // rate-limit/__tests__/dispatcher-l3.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 { createSystemUser, defineFeature } from "../../engine";
9
9
  import type { RateLimitConfig, RateLimitDecision, RateLimitResolver } from "../../rate-limit";
10
10
  import { createTestUser, setupTestStack, type TestStack, testTenantId } from "../../stack";
@@ -15,7 +15,7 @@
15
15
  // - SQL error (aborts the tx → proves the savepoint is load-bearing)
16
16
 
17
17
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
- import { z } from "zod";
18
+ import * as z from "zod";
19
19
  import { integer as pgInteger, table as pgTable, uuid as pgUuid } from "../../db/dialect";
20
20
  import { createEventStoreExecutor } from "../../db/event-store-executor";
21
21
  import { asRawClient } from "../../db/query";
@@ -3,7 +3,7 @@
3
3
  // the search index. The event payload only carries the named event's own
4
4
  // field slice, so the consumer reads the live projection row instead.
5
5
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
6
- import { z } from "zod";
6
+ import * as z from "zod";
7
7
  import { asRawClient, buildEntityTable, createEventStoreExecutor, createTenantDb } from "../../db";
8
8
  import { createEntity, createTextField, defineFeature } from "../../engine";
9
9
  import {
@@ -2,7 +2,7 @@
2
2
  // r.systemScope() or { escapeHatch }. Real HTTP calls + setupTestStack — never createTestDispatcher.
3
3
 
4
4
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
5
- import { z } from "zod";
5
+ import * as z from "zod";
6
6
  import { createEventStoreExecutor } from "../../db/event-store-executor";
7
7
  import { buildEntityTable } from "../../db/table-builder";
8
8
  import {
@@ -21,7 +21,7 @@
21
21
  // may pick either writer as the winner.
22
22
 
23
23
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
24
- import { z } from "zod";
24
+ import * as z from "zod";
25
25
  import { createEventStoreExecutor } from "../../db/event-store-executor";
26
26
  import { asRawClient, selectMany } from "../../db/query";
27
27
  import { buildEntityTable } from "../../db/table-builder";