@cosmicdrift/kumiko-framework 0.105.2 → 0.109.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 (85) 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__/pat-scope.test.ts +36 -0
  8. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  9. package/src/api/auth-middleware.ts +65 -1
  10. package/src/api/auth-routes.ts +11 -0
  11. package/src/api/index.ts +3 -1
  12. package/src/api/jwt.ts +1 -4
  13. package/src/api/pat-scope.ts +14 -0
  14. package/src/api/request-context.ts +3 -0
  15. package/src/api/request-id-middleware.ts +2 -0
  16. package/src/api/routes.ts +22 -0
  17. package/src/api/server.ts +29 -1
  18. package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
  19. package/src/bun-db/__tests__/query-guards.test.ts +3 -2
  20. package/src/bun-db/__tests__/write-brand.test.ts +48 -0
  21. package/src/bun-db/query.ts +40 -9
  22. package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
  23. package/src/db/__tests__/eagerload.integration.test.ts +2 -2
  24. package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
  25. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
  26. package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
  27. package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
  28. package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
  29. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
  30. package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
  31. package/src/db/apply-entity-event.ts +19 -8
  32. package/src/db/event-store-executor.ts +91 -8
  33. package/src/db/queries/shadow-swap.ts +1 -1
  34. package/src/db/query.ts +1 -0
  35. package/src/db/table-builder.ts +23 -1
  36. package/src/db/tenant-db.ts +6 -0
  37. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
  38. package/src/engine/__tests__/boot-validator.test.ts +210 -0
  39. package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
  40. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  41. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  42. package/src/engine/__tests__/projection-helpers.test.ts +2 -2
  43. package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
  44. package/src/engine/boot-validator/entity-handler.ts +45 -0
  45. package/src/engine/boot-validator/gdpr-storage.ts +2 -1
  46. package/src/engine/boot-validator/index.ts +14 -1
  47. package/src/engine/boot-validator/screens-nav.ts +90 -6
  48. package/src/engine/build-app-schema.ts +15 -7
  49. package/src/engine/define-feature.ts +17 -0
  50. package/src/engine/define-handler.ts +16 -2
  51. package/src/engine/entity-handlers.ts +32 -13
  52. package/src/engine/index.ts +6 -1
  53. package/src/engine/projection-helpers.ts +8 -5
  54. package/src/engine/registry.ts +47 -2
  55. package/src/engine/schema-builder.ts +3 -1
  56. package/src/engine/soft-delete-cleanup.ts +41 -4
  57. package/src/engine/steps/unsafe-projection-delete.ts +5 -1
  58. package/src/engine/tier-resolver-extension.ts +11 -0
  59. package/src/engine/types/feature.ts +29 -21
  60. package/src/engine/types/fields.ts +12 -0
  61. package/src/engine/types/handlers.ts +13 -0
  62. package/src/engine/types/index.ts +2 -0
  63. package/src/engine/types/projection.ts +33 -5
  64. package/src/event-store/index.ts +8 -0
  65. package/src/event-store/rebuild-dead-letter.ts +111 -0
  66. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
  67. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  68. package/src/pipeline/dispatcher.ts +4 -10
  69. package/src/pipeline/msp-rebuild.ts +36 -3
  70. package/src/pipeline/projection-rebuild.ts +49 -3
  71. package/src/pipeline/projections-runner.ts +1 -1
  72. package/src/schema-cli.ts +24 -15
  73. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  74. package/src/secrets/types.ts +8 -1
  75. package/src/testing/db-cleanup.ts +4 -1
  76. package/src/testing/index.ts +1 -0
  77. package/src/testing/seed.ts +50 -0
  78. package/src/time/__tests__/boot-tz-warning.test.ts +7 -0
  79. package/src/time/__tests__/geo-tz.test.ts +9 -3
  80. package/src/time/__tests__/iana.test.ts +9 -0
  81. package/src/time/boot-tz-warning.ts +5 -1
  82. package/src/time/iana.ts +17 -15
  83. package/src/time/tz-context.ts +6 -1
  84. package/src/utils/__tests__/serialization.test.ts +6 -0
  85. package/src/utils/serialization.ts +10 -3
@@ -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
+ }
@@ -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",
@@ -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
+ });
@@ -1,7 +1,7 @@
1
1
  import { requestContext } from "../api/request-context";
2
2
  import type { DbConnection, DbRow, DbTx } from "../db/connection";
3
3
  import { selectRowForUpdateById } from "../db/queries/entity-read";
4
- import { selectMany, transaction } from "../db/query";
4
+ import { asEntityTableMeta, selectMany, transaction } from "../db/query";
5
5
  import { buildEntityTable, toSnakeCase } from "../db/table-builder";
6
6
  import { createTenantDb } from "../db/tenant-db";
7
7
  import { hasAccess } from "../engine/access";
@@ -717,12 +717,8 @@ export function createDispatcher(
717
717
  user,
718
718
  ip: reqCtx?.ip,
719
719
  });
720
- // skip: ip-bucketed handler called from a non-HTTP entry point (job, seed,
721
- // MSP-apply) no client IP to bucket on, nothing to enforce. Pass
722
- // through BEFORE requiring a resolver, so system/seed writes through
723
- // such a handler don't need a RateLimitResolver wired (the es-ops
724
- // seed dispatcher has none). L1/L2 middleware handle the HTTP-side
725
- // ip caps.
720
+ // skip: ip-bucket + no IP (non-HTTP entry point) pass through before
721
+ // requiring a resolver; HTTP path always has an IP + L1/L2 middleware.
726
722
  if (bucket.kind === "skip") return;
727
723
  if (!context.rateLimit) {
728
724
  throw new InternalError({
@@ -1162,9 +1158,7 @@ export function createDispatcher(
1162
1158
  // can false-pass; optimistic locking would catch it later, but with
1163
1159
  // a less specific error. Falls back to a plain SELECT if no tx is
1164
1160
  // active (tests without a DB connection).
1165
- const tableName = String(
1166
- (table as { [key: symbol]: unknown })[Symbol.for("kumiko:schema:Name")],
1167
- );
1161
+ const tableName = asEntityTableMeta(table)?.tableName ?? "";
1168
1162
  const rows = tx
1169
1163
  ? await selectRowForUpdateById(handlerContext.db, tableName, id)
1170
1164
  : await selectMany(handlerContext.db, table, { id });
@@ -11,11 +11,16 @@ import {
11
11
  rebuildMetaOrThrow,
12
12
  swapShadowIntoLive,
13
13
  } from "../db/queries/shadow-swap";
14
- import { selectMany } from "../db/query";
14
+ import { runInSavepoint, selectMany } from "../db/query";
15
15
  import type { Registry, TenantId } from "../engine/types";
16
16
  import { InternalError } from "../errors";
17
17
  import { eventsTable, type StoredEvent, upcastStoredEvent } from "../event-store";
18
18
  import { loadAggregate, loadAggregateAsOf } from "../event-store/event-store";
19
+ import {
20
+ createRebuildDeadLetterTable,
21
+ recordRebuildDeadLetters,
22
+ type SkippedApply,
23
+ } from "../event-store/rebuild-dead-letter";
19
24
  import { upcastStoredEvents } from "../event-store/upcaster";
20
25
  import { emitProjectionRebuild } from "../observability/standard-metrics";
21
26
  import type { Meter } from "../observability/types/metric";
@@ -123,12 +128,21 @@ export async function rebuildMultiStreamProjection(
123
128
 
124
129
  const meta = rebuildMetaOrThrow(msp.table, mspName);
125
130
 
131
+ // Declared per-projection (MspErrorMode): the rebuild policy falls back to
132
+ // continuous when omitted — previously declared but never honored here.
133
+ const skipApplyErrors =
134
+ (msp.errorMode?.rebuild ?? msp.errorMode?.continuous)?.skipApplyErrors === true;
135
+
126
136
  const startedAt = Date.now();
127
137
  let eventsProcessed = 0;
128
138
  let lastProcessedEventId = 0n;
139
+ const skipped: SkippedApply[] = [];
129
140
 
130
141
  try {
131
142
  await ensureRebuildSchema(db);
143
+ // Outside the rebuild tx, like the schema: idempotent DDL colliding
144
+ // inside the tx would roll the whole replay back.
145
+ if (skipApplyErrors) await createRebuildDeadLetterTable(db);
132
146
  await db.begin(async (tx: DbTx) => {
133
147
  await resetConsumerForMspRebuild(tx, mspName, SHARED_INSTANCE_SENTINEL);
134
148
  await selectConsumerForUpdate(tx, mspName, SHARED_INSTANCE_SENTINEL);
@@ -178,13 +192,31 @@ export async function rebuildMultiStreamProjection(
178
192
  });
179
193
  const applyFn = msp.apply[row.type];
180
194
  if (!applyFn) continue;
181
- const rebuildCtx = createRebuildCtx(registry, tx, row.tenantId);
182
- await applyFn(storedEvent, tx, rebuildCtx);
195
+ if (skipApplyErrors) {
196
+ // Driver-native savepoint: a throwing SQL statement would
197
+ // otherwise poison the rebuild tx (25P02) AND make the driver
198
+ // reject the whole begin() even after a caught error. Apply +
199
+ // ctx run on the savepoint-scoped handle.
200
+ try {
201
+ await runInSavepoint(tx, async (sp) => {
202
+ const rebuildCtx = createRebuildCtx(registry, sp as DbRunner, row.tenantId);
203
+ await applyFn(storedEvent, sp as DbRunner, rebuildCtx);
204
+ });
205
+ } catch (e) {
206
+ skipped.push({ event: storedEvent, error: e });
207
+ }
208
+ } else {
209
+ const rebuildCtx = createRebuildCtx(registry, tx, row.tenantId);
210
+ await applyFn(storedEvent, tx, rebuildCtx);
211
+ }
183
212
  eventsProcessed++;
184
213
  lastProcessedEventId = row.id;
185
214
  }
186
215
  }
187
216
 
217
+ if (skipped.length > 0) {
218
+ await recordRebuildDeadLetters(tx, mspName, skipped);
219
+ }
188
220
  await updateConsumerRebuildCursor(
189
221
  tx,
190
222
  mspName,
@@ -210,6 +242,7 @@ export async function rebuildMultiStreamProjection(
210
242
  const result: RebuildResult = {
211
243
  projection: mspName,
212
244
  eventsProcessed,
245
+ eventsSkipped: skipped.length,
213
246
  lastProcessedEventId,
214
247
  durationMs: Date.now() - startedAt,
215
248
  };
@@ -13,7 +13,7 @@ import {
13
13
  rebuildMetaOrThrow,
14
14
  swapShadowIntoLive,
15
15
  } from "../db/queries/shadow-swap";
16
- import { coerceRow, extractTableInfo, selectMany } from "../db/query";
16
+ import { coerceRow, extractTableInfo, runInSavepoint, selectMany } from "../db/query";
17
17
  import type { Registry, TenantId } from "../engine/types";
18
18
  import {
19
19
  eventsTable,
@@ -22,6 +22,11 @@ import {
22
22
  upcastStoredEvent,
23
23
  } from "../event-store";
24
24
  import type { EventMetadata } from "../event-store/event-store";
25
+ import {
26
+ createRebuildDeadLetterTable,
27
+ recordRebuildDeadLetters,
28
+ type SkippedApply,
29
+ } from "../event-store/rebuild-dead-letter";
25
30
  import { emitProjectionRebuild } from "../observability/standard-metrics";
26
31
  import type { Meter } from "../observability/types/metric";
27
32
  import { projectionStateTable } from "./projection-state";
@@ -119,7 +124,12 @@ function rowToStoredEvent(row: StoredEventRow): StoredEvent {
119
124
 
120
125
  export type RebuildResult = {
121
126
  readonly projection: string;
127
+ // Events consumed from the log — includes quarantined ones (the cursor
128
+ // advanced past them).
122
129
  readonly eventsProcessed: number;
130
+ // Poison events quarantined into kumiko_rebuild_dead_letters this run.
131
+ // Always 0 unless quarantine mode was active (#760).
132
+ readonly eventsSkipped: number;
123
133
  readonly lastProcessedEventId: bigint;
124
134
  readonly durationMs: number;
125
135
  };
@@ -145,6 +155,15 @@ type RebuildDeps = {
145
155
  // on the live table; if a long-running writer holds it past this, the rebuild
146
156
  // fails loud instead of hanging. Defaults to DEFAULT_FENCE_LOCK_TIMEOUT_MS.
147
157
  readonly fenceLockTimeoutMs?: number;
158
+ // Apply-error policy for THIS run. Default strict: the first throwing
159
+ // apply aborts the rebuild (tx rollback, status "failed") — a poison
160
+ // event makes the projection permanently un-rebuildable (#760).
161
+ // skipApplyErrors: true confines every apply to a savepoint; a throwing
162
+ // apply is rolled back, recorded into kumiko_rebuild_dead_letters and
163
+ // skipped, and the rebuild completes without its effect. Opt-in for
164
+ // operators unblocking a pinned rebuild — the projection then knowingly
165
+ // misses the quarantined events until they are repaired and replayed.
166
+ readonly errorPolicy?: { readonly skipApplyErrors?: boolean };
148
167
  // Test-only seam: fires once after the unlocked bulk drain and before the
149
168
  // cutover fence. Lets a concurrency test inject a committed write into the
150
169
  // replay window deterministically. The `__test_` prefix marks it test-only:
@@ -174,7 +193,7 @@ export async function rebuildProjection(
174
193
  const meta = rebuildMetaOrThrow(projection.table, projectionName);
175
194
 
176
195
  const sources = Array.isArray(projection.source) ? projection.source : [projection.source];
177
- const sourcesList = [...sources];
196
+ const sourcesList = [...sources, ...(projection.extraSources ?? [])];
178
197
  const subscribedList = Object.keys(projection.apply);
179
198
  // Upcasters run at read time: older stored payloads get walked through the
180
199
  // registered r.eventMigration chain until their shape matches the current
@@ -185,6 +204,11 @@ export async function rebuildProjection(
185
204
  const startedAt = Date.now();
186
205
  let eventsProcessed = 0;
187
206
  let lastProcessedEventId = 0n;
207
+ const skipApplyErrors = deps.errorPolicy?.skipApplyErrors === true;
208
+ // Quarantined events, collected in memory and written once after the
209
+ // replay settles — the #443 full-re-replay path would otherwise duplicate
210
+ // the dead-letter rows.
211
+ let skipped: SkippedApply[] = [];
188
212
 
189
213
  // One chronological batch of events after lastProcessedEventId, applied into
190
214
  // the shadow. Returns the batch size so the caller can detect the tail
@@ -208,7 +232,21 @@ export async function rebuildProjection(
208
232
  // skip: apply-key validation ensures every subscribed type has a handler;
209
233
  // defensive check against runtime-mutated registry
210
234
  if (!applyFn) continue;
211
- await applyFn(storedEvent, tx);
235
+ if (skipApplyErrors) {
236
+ // Driver-native savepoint: a throwing SQL statement would otherwise
237
+ // poison the rebuild tx (25P02) AND make the driver reject the whole
238
+ // begin() even after a caught error. The apply runs on the
239
+ // savepoint-scoped handle so its statements are confined.
240
+ try {
241
+ await runInSavepoint(tx, async (sp) => {
242
+ await applyFn(storedEvent, sp as DbTx, projection.table);
243
+ });
244
+ } catch (e) {
245
+ skipped.push({ event: storedEvent, error: e });
246
+ }
247
+ } else {
248
+ await applyFn(storedEvent, tx, projection.table);
249
+ }
212
250
  eventsProcessed++;
213
251
  lastProcessedEventId = row.id;
214
252
  }
@@ -217,6 +255,9 @@ export async function rebuildProjection(
217
255
 
218
256
  try {
219
257
  await ensureRebuildSchema(db);
258
+ // Outside the rebuild tx, like the schema: idempotent DDL colliding
259
+ // inside the tx would roll the whole replay back.
260
+ if (skipApplyErrors) await createRebuildDeadLetterTable(db);
220
261
  await db.begin(async (tx: DbTx) => {
221
262
  await markProjectionRebuilding(tx, projectionName);
222
263
  await buildShadowTable(tx, meta);
@@ -261,12 +302,16 @@ export async function rebuildProjection(
261
302
  deps.__test_onBuildShadowTable?.();
262
303
  lastProcessedEventId = 0n;
263
304
  eventsProcessed = 0;
305
+ skipped = [];
264
306
  while ((await drainBatch(tx)) === REBUILD_BATCH_SIZE) {
265
307
  // re-replay the full log into the fresh shadow
266
308
  }
267
309
  }
268
310
  }
269
311
 
312
+ if (skipped.length > 0) {
313
+ await recordRebuildDeadLetters(tx, projectionName, skipped);
314
+ }
270
315
  await finalizeProjectionRebuild(tx, projectionName, lastProcessedEventId);
271
316
  await swapShadowIntoLive(tx, meta.tableName);
272
317
  });
@@ -292,6 +337,7 @@ export async function rebuildProjection(
292
337
  const result: RebuildResult = {
293
338
  projection: projectionName,
294
339
  eventsProcessed,
340
+ eventsSkipped: skipped.length,
295
341
  lastProcessedEventId,
296
342
  durationMs: Date.now() - startedAt,
297
343
  };
@@ -51,6 +51,6 @@ export async function runProjectionsForEvent(
51
51
  const applyFn = proj.apply[event.type];
52
52
  // skip: this projection doesn't care about this event type
53
53
  if (!applyFn) continue;
54
- await applyFn(event, tx);
54
+ await applyFn(event, tx, proj.table);
55
55
  }
56
56
  }