@cosmicdrift/kumiko-framework 0.105.1 → 0.108.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 (92) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/full-stack.integration.test.ts +3 -2
  3. package/src/__tests__/schema-cli.integration.test.ts +29 -0
  4. package/src/__tests__/transition-guard.integration.test.ts +2 -2
  5. package/src/api/__tests__/batch.integration.test.ts +3 -2
  6. package/src/api/__tests__/dispatcher-live.integration.test.ts +3 -2
  7. package/src/api/__tests__/jwt.test.ts +27 -0
  8. package/src/api/__tests__/pat-scope.test.ts +36 -0
  9. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  10. package/src/api/auth-middleware.ts +65 -1
  11. package/src/api/auth-routes.ts +11 -0
  12. package/src/api/index.ts +3 -1
  13. package/src/api/jwt.ts +5 -5
  14. package/src/api/pat-scope.ts +14 -0
  15. package/src/api/request-context.ts +3 -0
  16. package/src/api/request-id-middleware.ts +2 -0
  17. package/src/api/routes.ts +22 -0
  18. package/src/api/server.ts +29 -1
  19. package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
  20. package/src/bun-db/__tests__/query-guards.test.ts +3 -2
  21. package/src/bun-db/__tests__/write-brand.test.ts +48 -0
  22. package/src/bun-db/query.ts +40 -9
  23. package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
  24. package/src/db/__tests__/eagerload.integration.test.ts +2 -2
  25. package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
  26. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
  27. package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
  28. package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
  29. package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
  30. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
  31. package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
  32. package/src/db/apply-entity-event.ts +19 -8
  33. package/src/db/event-store-executor.ts +91 -8
  34. package/src/db/queries/shadow-swap.ts +1 -1
  35. package/src/db/query.ts +1 -0
  36. package/src/db/table-builder.ts +23 -1
  37. package/src/db/tenant-db.ts +6 -0
  38. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
  39. package/src/engine/__tests__/boot-validator.test.ts +210 -0
  40. package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
  41. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  42. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  43. package/src/engine/__tests__/projection-helpers.test.ts +2 -2
  44. package/src/engine/__tests__/required-surface-keys.test.ts +134 -1
  45. package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
  46. package/src/engine/boot-validator/entity-handler.ts +45 -0
  47. package/src/engine/boot-validator/gdpr-storage.ts +2 -1
  48. package/src/engine/boot-validator/index.ts +14 -1
  49. package/src/engine/boot-validator/screens-nav.ts +90 -6
  50. package/src/engine/build-app-schema.ts +15 -7
  51. package/src/engine/define-feature.ts +17 -0
  52. package/src/engine/define-handler.ts +16 -2
  53. package/src/engine/entity-handlers.ts +32 -13
  54. package/src/engine/extensions/user-data.ts +6 -0
  55. package/src/engine/index.ts +6 -1
  56. package/src/engine/projection-helpers.ts +8 -5
  57. package/src/engine/registry.ts +47 -2
  58. package/src/engine/schema-builder.ts +3 -1
  59. package/src/engine/soft-delete-cleanup.ts +41 -4
  60. package/src/engine/steps/unsafe-projection-delete.ts +5 -1
  61. package/src/engine/tier-resolver-extension.ts +11 -0
  62. package/src/engine/types/feature.ts +29 -21
  63. package/src/engine/types/fields.ts +12 -0
  64. package/src/engine/types/handlers.ts +13 -0
  65. package/src/engine/types/index.ts +2 -0
  66. package/src/engine/types/projection.ts +33 -5
  67. package/src/event-store/index.ts +8 -0
  68. package/src/event-store/rebuild-dead-letter.ts +111 -0
  69. package/src/files/__tests__/storage-tracking.integration.test.ts +8 -0
  70. package/src/files/file-routes.ts +1 -1
  71. package/src/pipeline/__tests__/dispatcher.test.ts +43 -0
  72. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
  73. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +24 -0
  74. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  75. package/src/pipeline/dispatcher.ts +4 -10
  76. package/src/pipeline/msp-rebuild.ts +36 -3
  77. package/src/pipeline/projection-rebuild.ts +55 -3
  78. package/src/pipeline/projections-runner.ts +1 -1
  79. package/src/schema-cli.ts +24 -15
  80. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  81. package/src/secrets/types.ts +8 -1
  82. package/src/testing/db-cleanup.ts +4 -1
  83. package/src/testing/index.ts +1 -0
  84. package/src/testing/seed.ts +50 -0
  85. package/src/time/__tests__/boot-tz-warning.test.ts +24 -2
  86. package/src/time/__tests__/geo-tz.test.ts +9 -3
  87. package/src/time/__tests__/iana.test.ts +9 -0
  88. package/src/time/boot-tz-warning.ts +5 -1
  89. package/src/time/iana.ts +17 -15
  90. package/src/time/tz-context.ts +6 -1
  91. package/src/utils/__tests__/serialization.test.ts +6 -0
  92. package/src/utils/serialization.ts +10 -3
@@ -199,6 +199,11 @@ export type NumberFieldDef = {
199
199
  readonly sensitive?: boolean;
200
200
  readonly default?: number;
201
201
  readonly access?: FieldAccess;
202
+ // Write-boundary constraints (Zod-level, no migration/storage impact — the
203
+ // Postgres column stays a plain numeric). Opt-in, so existing entities are
204
+ // unaffected.
205
+ readonly min?: number;
206
+ readonly integer?: boolean;
202
207
  } & PiiAnnotations;
203
208
 
204
209
  /**
@@ -557,6 +562,13 @@ export type DerivedFieldDef = {
557
562
 
558
563
  export type DerivedFieldsMap = Readonly<Record<string, DerivedFieldDef>>;
559
564
 
565
+ /** Client-facing projection of DerivedFieldDef — `derive` is server-only and
566
+ * not JSON-safe (would trip the output-walk guard), so the browser schema
567
+ * only ever carries `valueType`. A real `Pick`, not a same-shape cast: TS
568
+ * itself proves `derive` isn't there instead of a `{ valueType } as
569
+ * DerivedFieldDef` cast lying about a field that's actually missing. */
570
+ export type ClientDerivedFieldDef = Pick<DerivedFieldDef, "valueType">;
571
+
560
572
  // --- Entity ---
561
573
 
562
574
  // --- State Transitions ---
@@ -40,6 +40,19 @@ export type SessionUser = {
40
40
  // When present, middleware can validate that the sid is still alive before
41
41
  // accepting the request (session revocation).
42
42
  readonly sid?: string;
43
+ // Set ONLY when the request authenticated via a Personal Access Token
44
+ // (bearer, PAT_TOKEN_PREFIX). Absent for cookie/JWT logins, which stay
45
+ // unrestricted. `allowedQns` are the QN globs the token's granted scopes
46
+ // expand to; the API boundary (routes.ts) rejects any dispatch type not
47
+ // matched by one of them (fail-closed). `scopes` are the granted scope
48
+ // names, kept for audit/display only.
49
+ readonly pat?: {
50
+ // The token row id — the per-token key for PAT rate limiting. Not the
51
+ // secret; safe to carry on the principal.
52
+ readonly tokenId: string;
53
+ readonly scopes: readonly string[];
54
+ readonly allowedQns: readonly string[];
55
+ };
43
56
  };
44
57
 
45
58
  // --- Claim Keys (r.claimKey declarations) ---
@@ -83,6 +83,7 @@ export type {
83
83
  AnyFileFieldDef,
84
84
  BigIntFieldDef,
85
85
  BooleanFieldDef,
86
+ ClientDerivedFieldDef,
86
87
  DateFieldDef,
87
88
  DecimalFieldDef,
88
89
  DefaultCurrency,
@@ -195,6 +196,7 @@ export type { EntityId, TenantId } from "./identifiers";
195
196
  export { isSystemTenant, parseTenantId, SYSTEM_TENANT_ID } from "./identifiers";
196
197
  export type { NavDefinition } from "./nav";
197
198
  export type {
199
+ EntityProjectionExtension,
198
200
  MspErrorMode,
199
201
  MspErrorPolicy,
200
202
  MultiStreamApplyFn,
@@ -14,17 +14,23 @@ import type { RunIn } from "./config";
14
14
  export type ProjectionTable = TableColumns<any>;
15
15
 
16
16
  // Single-stream projection apply: runs inline in the write-TX of the event
17
- // it projects. Gets the event + TX-scoped DbRunner that's it. Inline
18
- // projections must not spawn further events (no ctx) because they run
19
- // inside the command's transaction and the framework guarantees a single
20
- // commit boundary per command.
17
+ // it projects. Gets the event, the TX-scoped DbRunner, and the projection's
18
+ // own `table` (already erased to ProjectionTable). Write through THAT table
19
+ // it is the only ES-blessed write into a managed projection outside the
20
+ // executor, and it is reachable only here (an arbitrary handler has no such
21
+ // arg), so the write-brand cannot be bypassed by closing over the branded
22
+ // table constant. Inline projections must not spawn further events (no ctx)
23
+ // because they run inside the command's transaction and the framework
24
+ // guarantees a single commit boundary per command.
21
25
  //
22
26
  // Generic über payload-shape. Default = Record<string, unknown> behält
23
27
  // rückwärtskompatibles Verhalten; Konkrete Apply-Handler annotieren
24
- // `SingleStreamApplyFn<MyPayload>` für typed event.payload-Access.
28
+ // `SingleStreamApplyFn<MyPayload>` für typed event.payload-Access. Der
29
+ // `table`-Param ist additiv — 2-arg-Applies (event, tx) bleiben gültig.
25
30
  export type SingleStreamApplyFn<TPayload = Record<string, unknown>> = (
26
31
  event: StoredEvent<TPayload>,
27
32
  tx: DbRunner,
33
+ table: ProjectionTable,
28
34
  ) => Promise<void>;
29
35
 
30
36
  // Multi-stream projection apply: runs asynchronously via the event-dispatcher
@@ -45,6 +51,11 @@ export type ProjectionDefinition = {
45
51
  // index projections so the executor doesn't scan all projections on every
46
52
  // write.
47
53
  readonly source: string | readonly string[];
54
+ // Additional aggregate-types whose events the rebuild replay must include
55
+ // beyond `source`. Fed by r.extendEntityProjection — an extension can react
56
+ // to events on foreign streams (e.g. "field-definition") while `source`
57
+ // keeps meaning "the owning entity" for consumers like soft-delete-cleanup.
58
+ readonly extraSources?: readonly string[];
48
59
  // Drizzle-table the projection materializes into. User owns the schema —
49
60
  // framework just guarantees the TX and event delivery.
50
61
  readonly table: ProjectionTable;
@@ -61,6 +72,23 @@ export type ProjectionDefinition = {
61
72
  readonly isImplicit?: boolean;
62
73
  };
63
74
 
75
+ // Extension merged into an entity's implicit projection at registry build.
76
+ // Lets a bundled feature that writes into the HOST entity's table via events
77
+ // (custom-fields pattern) hook those event types into the entity's rebuild
78
+ // replay — without it, a rebuild resets everything the extension wrote.
79
+ // Live delivery stays with the extension's own MSP: implicit projections are
80
+ // skipped by the inline runner, so the apply here runs ONLY during rebuild.
81
+ export type EntityProjectionExtension = {
82
+ // Aggregate-types beyond the entity's own stream whose events the rebuild
83
+ // must scan (e.g. "field-definition"). Omit when all extension events are
84
+ // appended on the host entity's stream.
85
+ readonly sources?: readonly string[];
86
+ // Keyed by fully-qualified event type. Must not collide with the entity's
87
+ // built-in lifecycle applies (<entity>.created/updated/...) or another
88
+ // extension — collisions fail at boot.
89
+ readonly apply: Readonly<Record<string, SingleStreamApplyFn>>;
90
+ };
91
+
64
92
  // Per-lifecycle error policy for a MultiStreamProjection. Mirrors Marten's
65
93
  // Projections.Errors / Projections.RebuildErrors split — a projection can
66
94
  // be lenient during steady-state delivery but strict during rebuild (or
@@ -24,6 +24,14 @@ export {
24
24
  streamAllEventsByType,
25
25
  } from "./event-store";
26
26
  export { createEventsTable, eventsTable } from "./events-schema";
27
+ export {
28
+ createRebuildDeadLetterTable,
29
+ listRebuildDeadLetters,
30
+ type RebuildDeadLetterRow,
31
+ rebuildDeadLetterTable,
32
+ recordRebuildDeadLetters,
33
+ type SkippedApply,
34
+ } from "./rebuild-dead-letter";
27
35
  export { toStoredEvent } from "./row-to-stored-event";
28
36
  export {
29
37
  createSnapshotsTable,
@@ -0,0 +1,111 @@
1
+ // Dead-letter storage for apply-handler failures during projection rebuild.
2
+ //
3
+ // Background: rebuildProjection / rebuildMultiStreamProjection replay the
4
+ // event log through apply handlers inside ONE transaction. A single event
5
+ // whose (possibly years-old) payload makes its apply throw rolls the whole
6
+ // replay back — the projection stays permanently un-rebuildable until the
7
+ // event is repaired by hand. The upcaster dead-letter only quarantines
8
+ // upcast-TRANSFORM failures, not apply failures (#760).
9
+ //
10
+ // Quarantine mode (errorPolicy.skipApplyErrors on RebuildDeps, or
11
+ // MspErrorMode.rebuild.skipApplyErrors on the MSP definition) confines each
12
+ // apply to a savepoint: a throwing apply is rolled back, captured into
13
+ // `kumiko_rebuild_dead_letters`, and the replay continues. Replay-after-fix
14
+ // is a separate ops step — same stance as the upcaster dead-letter.
15
+
16
+ import type { DbConnection, DbRunner } from "../db/connection";
17
+ import { bigint, index, jsonb, table as pgTable, text, timestamp, uuid } from "../db/dialect";
18
+ import { tableExists } from "../db/schema-inspection";
19
+ import { unsafePushTables } from "../stack";
20
+ import type { StoredEvent } from "./event-store";
21
+
22
+ export const rebuildDeadLetterTable = pgTable(
23
+ "kumiko_rebuild_dead_letters",
24
+ {
25
+ // Surrogate PK — the same event can land here across multiple rebuild
26
+ // attempts before the fix ships, without unique-violation noise.
27
+ id: bigint("id", { mode: "bigint" }).primaryKey().generatedAlwaysAsIdentity(),
28
+ projectionName: text("projection_name").notNull(),
29
+ // StoredEvent.id is surfaced as `string` (bigint serialised for JSON
30
+ // safety) — stored as text to keep the round-trip identity.
31
+ eventId: text("event_id").notNull(),
32
+ tenantId: uuid("tenant_id").notNull(),
33
+ aggregateId: text("aggregate_id").notNull(),
34
+ aggregateType: text("aggregate_type").notNull(),
35
+ eventType: text("event_type").notNull(),
36
+ errorMessage: text("error_message").notNull(),
37
+ payload: jsonb("payload").notNull(),
38
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
39
+ },
40
+ (t) => ({
41
+ projectionIdx: index("rebuild_dead_letters_projection_idx").on(t.projectionName),
42
+ createdAtIdx: index("rebuild_dead_letters_created_at_idx").on(t.createdAt),
43
+ }),
44
+ );
45
+
46
+ // Idempotent table-create. The rebuild runners call this before the rebuild
47
+ // tx whenever quarantine mode is active, so the opt-in works on databases
48
+ // provisioned before this table existed.
49
+ export async function createRebuildDeadLetterTable(db: DbConnection): Promise<void> {
50
+ // skip: table already exists — bootstrap called from multiple paths
51
+ if (await tableExists(db, "public.kumiko_rebuild_dead_letters")) return;
52
+ await unsafePushTables(db, { kumikoRebuildDeadLetters: rebuildDeadLetterTable });
53
+ }
54
+
55
+ export type SkippedApply = {
56
+ readonly event: StoredEvent;
57
+ readonly error: unknown;
58
+ };
59
+
60
+ // Bulk-write, called ONCE at the end of a quarantining rebuild (inside its
61
+ // tx). Unqualified insert — during the rebuild the search_path points at the
62
+ // shadow schema, and this table exists only in public, so it falls through.
63
+ export async function recordRebuildDeadLetters(
64
+ db: DbRunner,
65
+ projectionName: string,
66
+ skipped: readonly SkippedApply[],
67
+ ): Promise<void> {
68
+ const { insertMany } = await import("../bun-db/query");
69
+ await insertMany(
70
+ db,
71
+ rebuildDeadLetterTable,
72
+ skipped.map(({ event, error }) => ({
73
+ projectionName,
74
+ eventId: event.id,
75
+ tenantId: event.tenantId,
76
+ aggregateId: event.aggregateId,
77
+ aggregateType: event.aggregateType,
78
+ eventType: event.type,
79
+ errorMessage: error instanceof Error ? error.message : String(error),
80
+ payload: event.payload,
81
+ })),
82
+ );
83
+ }
84
+
85
+ export type RebuildDeadLetterRow = {
86
+ readonly id: bigint;
87
+ readonly projectionName: string;
88
+ readonly eventId: string;
89
+ readonly tenantId: string;
90
+ readonly aggregateId: string;
91
+ readonly aggregateType: string;
92
+ readonly eventType: string;
93
+ readonly errorMessage: string;
94
+ readonly payload: Record<string, unknown>;
95
+ readonly createdAt: Date;
96
+ };
97
+
98
+ // Ops-side triage query, optionally scoped to one projection.
99
+ export async function listRebuildDeadLetters(
100
+ db: DbConnection,
101
+ options: { projectionName?: string; limit?: number } = {},
102
+ ): Promise<readonly RebuildDeadLetterRow[]> {
103
+ const { selectMany } = await import("../bun-db/query");
104
+ const limit = options.limit ?? 100;
105
+ const where =
106
+ options.projectionName !== undefined ? { projectionName: options.projectionName } : undefined;
107
+ return selectMany<RebuildDeadLetterRow>(db, rebuildDeadLetterTable, where, {
108
+ orderBy: { col: "createdAt", direction: "desc" },
109
+ limit,
110
+ });
111
+ }
@@ -224,4 +224,12 @@ describe("file download roundtrip (GET /files/:id)", () => {
224
224
  await deleteFile(admin, id);
225
225
  expect((await download(admin, id)).status).toBe(404);
226
226
  });
227
+
228
+ test("a cross-tenant download is rejected (404, no existence oracle)", async () => {
229
+ const id = await upload(admin, "tenant-a-only.png", SMALL);
230
+ const res = await download(otherAdmin, id);
231
+ expect(res.status).toBe(404);
232
+ const body = (await res.json()) as { error?: string };
233
+ expect(body.error).toBe("not_found");
234
+ });
227
235
  });
@@ -150,8 +150,8 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
150
150
  // are orphaned in the provider; cleanup-jobs sweep those later. Losing a
151
151
  // row on append-failure is acceptable; corrupting a committed row with a
152
152
  // missing binary is not.
153
- const data = new Uint8Array(await file.arrayBuffer());
154
153
  const storageProvider = await options.resolveProvider(user.tenantId);
154
+ const data = new Uint8Array(await file.arrayBuffer());
155
155
  await storageProvider.write(storageKey, data, file.type);
156
156
 
157
157
  // Create via the standard entity executor: emits fileRef.created +
@@ -547,6 +547,49 @@ describe("write-handler shape guard", () => {
547
547
  });
548
548
  });
549
549
 
550
+ // --- Dispatch: geoTzProvider seam ---
551
+
552
+ describe("dispatcher context.geoTzProvider (680/1)", () => {
553
+ test("ctx.tz.fromCoordinates reaches the app-injected geoTzProvider through a real dispatch", async () => {
554
+ // All geo-tz.test.ts coverage calls createTzContext({ geoTz }) directly —
555
+ // none go through the dispatcher or read context.geoTzProvider. A typo in
556
+ // the property forwarding at dispatcher.ts would make the injection a
557
+ // silent no-op (provider configured, ctx.tz.fromCoordinates still throws).
558
+ const geoFeature = defineFeature("geo", (r) => {
559
+ r.queryHandler(
560
+ "resolve-zone",
561
+ z.object({ latitude: z.number(), longitude: z.number() }),
562
+ async (query, ctx) => ({
563
+ zone: await ctx.tz.fromCoordinates({
564
+ latitude: query.payload.latitude,
565
+ longitude: query.payload.longitude,
566
+ }),
567
+ }),
568
+ { access: { openToAll: true } },
569
+ );
570
+ });
571
+ const registry = createRegistry([geoFeature]);
572
+ const calls: Array<{ latitude: number; longitude: number }> = [];
573
+ const dispatcher = createDispatcher(registry, {
574
+ geoTzProvider: {
575
+ fromCoordinates: ({ latitude, longitude }) => {
576
+ calls.push({ latitude, longitude });
577
+ return "Europe/Berlin";
578
+ },
579
+ },
580
+ });
581
+
582
+ const result = await dispatcher.query(
583
+ "geo:query:resolve-zone",
584
+ { latitude: 52.52, longitude: 13.405 },
585
+ createTestUser(),
586
+ );
587
+
588
+ expect(result).toEqual({ zone: "Europe/Berlin" });
589
+ expect(calls).toEqual([{ latitude: 52.52, longitude: 13.405 }]);
590
+ });
591
+ });
592
+
550
593
  // --- Mock helpers ---
551
594
 
552
595
  function createMockIdempotencyGuard() {
@@ -9,10 +9,10 @@
9
9
  // Bun.SQL-only setup. KEIN postgres-js, KEIN setupTestStack.
10
10
 
11
11
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
12
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
12
13
  import { z } from "zod";
13
14
  import type { DbConnection, DbTx } from "../../db/connection";
14
15
  import { createEventStoreExecutor } from "../../db/event-store-executor";
15
- import { insertOne } from "../../db/query";
16
16
  import { buildEntityTable } from "../../db/table-builder";
17
17
  import { createEntity, createTextField, defineFeature } from "../../engine";
18
18
  import { append, loadAggregate as loadAggregateRaw } from "../../event-store";
@@ -219,7 +219,7 @@ describe("ctx.loadAggregate via queryHandler — Marten AggregateStreamAsync equ
219
219
  // produce garbage.
220
220
  const invoiceId = "00000000-0000-4000-8000-000000000042";
221
221
  await (stack.db as DbConnection).begin(async (tx: DbTx) => {
222
- await insertOne(tx, invoiceTable, {
222
+ await seedRow(tx, invoiceTable, {
223
223
  id: invoiceId,
224
224
  tenantId: admin.tenantId,
225
225
  customer: "LegacyCo",
@@ -750,6 +750,30 @@ describe("rebuildProjection — live-tail catch-up (#363 Phase 2)", () => {
750
750
  expect(await getCount(groupX)).toBe(1); // #443 fixed: fenced count re-check re-replayed the missed low-id event
751
751
  });
752
752
 
753
+ test("(656/1) a clean rebuild with no concurrency does not trigger the #443 recompute", async () => {
754
+ // The #443 shortfall-recompute rebuilds the shadow a 2nd time and re-replays
755
+ // the whole log — correct on the rare path it guards, but a silent perf
756
+ // regression if it fired on every rebuild. Its final values are identical
757
+ // either way (full re-replay is idempotent), so only a call-count seam can
758
+ // prove it stayed off the happy path.
759
+ const group = "00000000-0000-4000-8000-000000000203";
760
+ await appendCreatedEvent(group, "a");
761
+ await appendCreatedEvent(group, "b");
762
+
763
+ let shadowBuilds = 0;
764
+ const result = await rebuildProjection(qualifiedProjectionName, {
765
+ db: testDb.db,
766
+ registry,
767
+ __test_onBuildShadowTable: () => {
768
+ shadowBuilds++;
769
+ },
770
+ });
771
+
772
+ expect(shadowBuilds).toBe(1);
773
+ expect(result.eventsProcessed).toBe(2);
774
+ expect(await getCount(group)).toBe(2);
775
+ });
776
+
753
777
  test("the rebuild tx sees concurrently-committed rows (READ COMMITTED, not a frozen snapshot)", async () => {
754
778
  // The catch-up loop only works if each fresh SELECT in the rebuild tx sees
755
779
  // rows other connections committed since the previous batch. Under
@@ -0,0 +1,274 @@
1
+ // #760 — poison-event quarantine during projection rebuild.
2
+ //
3
+ // A single historical event whose apply throws used to abort the WHOLE
4
+ // rebuild (tx rollback, status failed) with no recovery path short of
5
+ // hand-editing the event. With quarantine mode the rebuild confines each
6
+ // apply to a savepoint: the poison event is skipped, recorded into
7
+ // kumiko_rebuild_dead_letters, and the replay completes.
8
+ //
9
+ // Covers both rebuild flavors:
10
+ // - single-stream: RebuildDeps.errorPolicy.skipApplyErrors (per run)
11
+ // - MSP: MspErrorMode.rebuild.skipApplyErrors (per definition — declared
12
+ // API that was previously never honored by rebuildMultiStreamProjection)
13
+ // and both poison flavors:
14
+ // - JS throw (no SQL executed)
15
+ // - SQL error (aborts the tx → proves the savepoint is load-bearing)
16
+
17
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
+ import { z } from "zod";
19
+ import { integer as pgInteger, table as pgTable, uuid as pgUuid } from "../../db/dialect";
20
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
21
+ import { asRawClient } from "../../db/query";
22
+ import { buildEntityTable } from "../../db/table-builder";
23
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
24
+ import { createEntity, createTextField, defineApply, defineFeature } from "../../engine";
25
+ import type { ProjectionDefinition } from "../../engine/types";
26
+ import { append, createEventsTable } from "../../event-store";
27
+ import { listRebuildDeadLetters } from "../../event-store/rebuild-dead-letter";
28
+ import {
29
+ createProjectionStateTable,
30
+ getConsumerState,
31
+ getProjectionState,
32
+ rebuildMultiStreamProjection,
33
+ rebuildProjection,
34
+ } from "../../pipeline";
35
+ import {
36
+ setupTestStack,
37
+ type TestStack,
38
+ TestUsers,
39
+ unsafeCreateEntityTable,
40
+ unsafePushTables,
41
+ } from "../../stack";
42
+
43
+ // --- Fixtures ---
44
+
45
+ const POISON_JS = "poison-js";
46
+ const POISON_SQL = "poison-sql";
47
+
48
+ const itemEntity = createEntity({
49
+ table: "read_poison_items",
50
+ fields: {
51
+ groupId: createTextField({ required: true }),
52
+ name: createTextField({ required: true }),
53
+ },
54
+ });
55
+ const itemTable = buildEntityTable("poison-item", itemEntity);
56
+
57
+ const counterTable = pgTable("read_poison_counter", {
58
+ groupId: pgUuid("group_id").primaryKey(),
59
+ tenantId: pgUuid("tenant_id").notNull(),
60
+ itemCount: pgInteger("item_count").notNull().default(0),
61
+ });
62
+
63
+ const mspCounterTable = pgTable("read_poison_msp_counter", {
64
+ groupId: pgUuid("group_id").primaryKey(),
65
+ tenantId: pgUuid("tenant_id").notNull(),
66
+ itemCount: pgInteger("item_count").notNull().default(0),
67
+ });
68
+
69
+ async function bump(tx: unknown, tableName: string, groupId: string, tenantId: string) {
70
+ await asRawClient(tx).unsafe(
71
+ `INSERT INTO "${tableName}" (group_id, tenant_id, item_count) VALUES ($1::uuid, $2::uuid, 1) ON CONFLICT (group_id) DO UPDATE SET item_count = "${tableName}".item_count + 1`,
72
+ [groupId, tenantId],
73
+ );
74
+ }
75
+
76
+ // Poison by payload marker. POISON_SQL runs a genuinely failing statement so
77
+ // the surrounding tx would be in 25P02 without the savepoint.
78
+ async function applyOrPoison(
79
+ tx: unknown,
80
+ tableName: string,
81
+ payload: { groupId: string; name: string },
82
+ tenantId: string,
83
+ ): Promise<void> {
84
+ if (payload.name === POISON_JS) throw new Error("intentional js poison");
85
+ if (payload.name === POISON_SQL) {
86
+ await asRawClient(tx).unsafe(
87
+ `INSERT INTO "${tableName}" (group_id, tenant_id, item_count) VALUES ('not-a-uuid', $1::uuid, 1)`,
88
+ [tenantId],
89
+ );
90
+ return;
91
+ }
92
+ await bump(tx, tableName, payload.groupId, tenantId);
93
+ }
94
+
95
+ type ItemPayload = { groupId: string; name: string };
96
+
97
+ const poisonProjection: ProjectionDefinition = {
98
+ name: "poison-counter",
99
+ source: "poison-item",
100
+ table: counterTable,
101
+ apply: {
102
+ "poison-item.created": defineApply<ItemPayload>(async (event, tx) => {
103
+ await applyOrPoison(tx, "read_poison_counter", event.payload, event.tenantId);
104
+ }),
105
+ },
106
+ };
107
+
108
+ const MSP_EVENT_SHORT = "poison-noted";
109
+
110
+ const feature = defineFeature("poisontest", (r) => {
111
+ r.entity("poison-item", itemEntity);
112
+ r.projection(poisonProjection);
113
+ const noted = r.defineEvent(MSP_EVENT_SHORT, z.object({ groupId: z.uuid(), name: z.string() }));
114
+ r.multiStreamProjection({
115
+ name: "poison-msp-counter",
116
+ table: mspCounterTable,
117
+ errorMode: { rebuild: { skipApplyErrors: true } },
118
+ apply: {
119
+ [noted.name]: async (event, tx) => {
120
+ const p = event.payload as ItemPayload; // @cast-boundary engine-payload
121
+ await applyOrPoison(tx, "read_poison_msp_counter", p, event.tenantId);
122
+ },
123
+ },
124
+ });
125
+ });
126
+
127
+ const admin = TestUsers.admin;
128
+ const PROJECTION = "poisontest:projection:poison-counter";
129
+ const MSP = "poisontest:projection:poison-msp-counter";
130
+ const MSP_EVENT = "poisontest:event:poison-noted";
131
+ const GROUP = "00000000-0000-4000-8000-00000000cafe";
132
+
133
+ let stack: TestStack;
134
+ let tdb: TenantDb;
135
+
136
+ const executor = createEventStoreExecutor(itemTable, itemEntity, { entityName: "poison-item" });
137
+
138
+ beforeAll(async () => {
139
+ stack = await setupTestStack({ features: [feature] });
140
+ await unsafeCreateEntityTable(stack.db, itemEntity, "poison-item");
141
+ await createEventsTable(stack.db);
142
+ await createProjectionStateTable(stack.db);
143
+ await unsafePushTables(stack.db, {
144
+ poisonCounter: counterTable,
145
+ poisonMspCounter: mspCounterTable,
146
+ });
147
+ tdb = createTenantDb(stack.db, admin.tenantId);
148
+ });
149
+
150
+ afterAll(async () => {
151
+ await stack.cleanup();
152
+ });
153
+
154
+ beforeEach(async () => {
155
+ await asRawClient(stack.db).unsafe(
156
+ `TRUNCATE kumiko_events, read_poison_items, read_poison_counter, read_poison_msp_counter, kumiko_projections RESTART IDENTITY CASCADE`,
157
+ );
158
+ await asRawClient(stack.db).unsafe(`DROP TABLE IF EXISTS kumiko_rebuild_dead_letters`);
159
+ if (stack.eventDispatcher) await stack.eventDispatcher.ensureRegistered();
160
+ });
161
+
162
+ async function createItem(name: string): Promise<void> {
163
+ await executor.create({ groupId: GROUP, name }, admin, tdb);
164
+ }
165
+
166
+ async function getCount(table: string): Promise<number | undefined> {
167
+ const rows = (await asRawClient(stack.db).unsafe(
168
+ `SELECT item_count FROM "${table}" WHERE group_id = $1::uuid`,
169
+ [GROUP],
170
+ )) as ReadonlyArray<{ item_count: number }>;
171
+ return rows[0]?.item_count;
172
+ }
173
+
174
+ describe("rebuildProjection — poison event, strict default (#760)", () => {
175
+ test("apply throw aborts the rebuild: status failed, no dead letters", async () => {
176
+ await createItem("good-1");
177
+ await createItem(POISON_JS);
178
+ await createItem("good-2");
179
+
180
+ await expect(
181
+ rebuildProjection(PROJECTION, { db: stack.db, registry: stack.registry }),
182
+ ).rejects.toThrow("intentional js poison");
183
+
184
+ const state = await getProjectionState(stack.db, PROJECTION);
185
+ expect(state?.status).toBe("failed");
186
+ // Strict mode never provisions/records dead letters.
187
+ const rows = (await asRawClient(stack.db).unsafe(
188
+ `SELECT to_regclass('public.kumiko_rebuild_dead_letters') AS t`,
189
+ )) as ReadonlyArray<{ t: string | null }>;
190
+ expect(rows[0]?.t).toBeNull();
191
+ });
192
+ });
193
+
194
+ describe("rebuildProjection — quarantine mode (#760)", () => {
195
+ test("js- and sql-poison events are skipped, recorded, and the rebuild completes", async () => {
196
+ await createItem("good-1");
197
+ await createItem(POISON_JS);
198
+ // sql-poison proves the savepoint: without it the failed statement puts
199
+ // the whole rebuild tx into 25P02 and every later apply fails too.
200
+ await createItem(POISON_SQL);
201
+ await createItem("good-2");
202
+
203
+ const result = await rebuildProjection(PROJECTION, {
204
+ db: stack.db,
205
+ registry: stack.registry,
206
+ errorPolicy: { skipApplyErrors: true },
207
+ });
208
+
209
+ expect(result.eventsSkipped).toBe(2);
210
+ expect(result.eventsProcessed).toBe(4);
211
+ expect(await getCount("read_poison_counter")).toBe(2);
212
+
213
+ const state = await getProjectionState(stack.db, PROJECTION);
214
+ expect(state?.status).toBe("idle");
215
+
216
+ const deadLetters = await listRebuildDeadLetters(stack.db, { projectionName: PROJECTION });
217
+ expect(deadLetters).toHaveLength(2);
218
+ const messages = deadLetters.map((d) => d.errorMessage).sort();
219
+ expect(messages[0]).toContain("poison");
220
+ expect(deadLetters.every((d) => d.eventType === "poison-item.created")).toBe(true);
221
+ expect(deadLetters.every((d) => d.aggregateType === "poison-item")).toBe(true);
222
+ });
223
+
224
+ test("clean run in quarantine mode: zero skipped, no dead letters", async () => {
225
+ await createItem("good-1");
226
+ await createItem("good-2");
227
+
228
+ const result = await rebuildProjection(PROJECTION, {
229
+ db: stack.db,
230
+ registry: stack.registry,
231
+ errorPolicy: { skipApplyErrors: true },
232
+ });
233
+
234
+ expect(result.eventsSkipped).toBe(0);
235
+ expect(await getCount("read_poison_counter")).toBe(2);
236
+ expect(await listRebuildDeadLetters(stack.db, { projectionName: PROJECTION })).toHaveLength(0);
237
+ });
238
+ });
239
+
240
+ describe("rebuildMultiStreamProjection — errorMode.rebuild.skipApplyErrors (#760)", () => {
241
+ async function appendMspEvent(name: string, expectedVersion: number): Promise<void> {
242
+ await append(stack.db, {
243
+ aggregateId: GROUP,
244
+ aggregateType: "poison-note",
245
+ tenantId: admin.tenantId,
246
+ expectedVersion,
247
+ type: MSP_EVENT,
248
+ payload: { groupId: GROUP, name },
249
+ metadata: { userId: admin.id },
250
+ });
251
+ }
252
+
253
+ test("declared rebuild policy is honored: poison skipped + recorded, cursor advanced", async () => {
254
+ await appendMspEvent("good-1", 0);
255
+ await appendMspEvent(POISON_SQL, 1);
256
+ await appendMspEvent("good-2", 2);
257
+
258
+ const result = await rebuildMultiStreamProjection(MSP, {
259
+ db: stack.db,
260
+ registry: stack.registry,
261
+ });
262
+
263
+ expect(result.eventsSkipped).toBe(1);
264
+ expect(result.eventsProcessed).toBe(3);
265
+ expect(await getCount("read_poison_msp_counter")).toBe(2);
266
+
267
+ const deadLetters = await listRebuildDeadLetters(stack.db, { projectionName: MSP });
268
+ expect(deadLetters).toHaveLength(1);
269
+ expect(deadLetters[0]?.eventType).toBe(MSP_EVENT);
270
+
271
+ const consumer = await getConsumerState(stack.db, MSP);
272
+ expect(consumer?.lastProcessedEventId).toBe(result.lastProcessedEventId);
273
+ });
274
+ });