@cosmicdrift/kumiko-framework 0.326.1 → 0.328.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 (55) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/auth-middleware-transport.test.ts +23 -1
  3. package/src/api/__tests__/client-ip.test.ts +20 -0
  4. package/src/api/__tests__/origin-middleware.test.ts +65 -0
  5. package/src/api/__tests__/websocket-route.test.ts +552 -0
  6. package/src/api/auth-middleware.ts +29 -7
  7. package/src/api/client-ip.ts +15 -2
  8. package/src/api/index.ts +24 -1
  9. package/src/api/jwt.ts +5 -0
  10. package/src/api/origin-middleware.ts +27 -1
  11. package/src/api/server.ts +161 -2
  12. package/src/api/websocket-route.ts +327 -0
  13. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +94 -1
  14. package/src/bun-db/query.ts +160 -23
  15. package/src/changes.json +54 -0
  16. package/src/crypto/__tests__/system-event-pii.test.ts +77 -0
  17. package/src/crypto/event-pii.ts +20 -4
  18. package/src/crypto/system-event-pii.ts +57 -0
  19. package/src/db/__tests__/apply-entity-event-removed-column.integration.test.ts +109 -0
  20. package/src/db/queries/backfill-pii.ts +184 -85
  21. package/src/db/queries/pii-backfill-state.ts +33 -0
  22. package/src/engine/__tests__/build-app-schema.test.ts +26 -0
  23. package/src/engine/__tests__/describe-workflow-step-error.test.ts +17 -0
  24. package/src/engine/__tests__/steps-mail-send.test.ts +6 -5
  25. package/src/engine/__tests__/steps-webhook-send.test.ts +16 -8
  26. package/src/engine/build-app-schema.ts +2 -0
  27. package/src/engine/define-feature.ts +1 -0
  28. package/src/engine/feature-builder-state.ts +3 -0
  29. package/src/engine/feature-ui-extensions.ts +52 -0
  30. package/src/engine/index.ts +5 -0
  31. package/src/engine/steps/describe-workflow-step-error.ts +6 -0
  32. package/src/engine/steps/mail-send.ts +5 -1
  33. package/src/engine/steps/retry.ts +13 -1
  34. package/src/engine/steps/webhook-send.ts +6 -16
  35. package/src/engine/types/index.ts +8 -0
  36. package/src/event-store/index.ts +4 -0
  37. package/src/event-store/provenance-append.ts +1 -1
  38. package/src/event-store/transfer.ts +2 -2
  39. package/src/i18n/required-surface-keys.ts +8 -0
  40. package/src/jobs/__tests__/jobs.integration.test.ts +31 -0
  41. package/src/jobs/job-runner.ts +18 -0
  42. package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +45 -3
  43. package/src/migrations/__tests__/pii-event-backfill.integration.test.ts +457 -0
  44. package/src/migrations/index.ts +11 -0
  45. package/src/migrations/pending-rebuilds.ts +29 -3
  46. package/src/migrations/pii-event-backfill.ts +422 -0
  47. package/src/pipeline/append-event-core.ts +1 -8
  48. package/src/pipeline/irreversible-operation-gate.ts +4 -3
  49. package/src/pipeline/msp-rebuild.ts +7 -1
  50. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +65 -0
  51. package/src/search/__tests__/search-adapter.test.ts +15 -0
  52. package/src/search/in-memory-adapter.ts +6 -0
  53. package/src/search/meilisearch-adapter.ts +24 -0
  54. package/src/stack/test-stack.ts +26 -2
  55. package/src/ui-types/index.ts +1 -0
@@ -0,0 +1,457 @@
1
+ // runPiiEventBackfill: an annotation added AFTER events were stored gets those
2
+ // events re-encrypted on boot, once per annotation state (fingerprint), with
3
+ // cheap catch-up passes afterwards.
4
+
5
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
6
+ import {
7
+ configureBlindIndexKey,
8
+ configurePiiSubjectKms,
9
+ decryptPiiValueForSubject,
10
+ InMemoryKmsAdapter,
11
+ isPiiCiphertext,
12
+ type KmsContext,
13
+ type SubjectId,
14
+ } from "../../crypto";
15
+ import { resetBlindIndexKeyForTests } from "../../crypto/blind-index";
16
+ import { resetPiiSubjectKmsForTests } from "../../crypto/pii-field-encryption";
17
+ import { table as pgTable, text as pgText, uuid as pgUuid } from "../../db/dialect";
18
+ import { asRawClient } from "../../db/query";
19
+ import {
20
+ createEntity,
21
+ createRegistry,
22
+ createTextField,
23
+ defineEntityQueryHandler,
24
+ defineEntityWriteHandler,
25
+ defineFeature,
26
+ } from "../../engine";
27
+ import type { EntityDefinition, Registry, TenantId } from "../../engine/types";
28
+ import { createSnapshotsTable, saveSnapshot } from "../../event-store";
29
+ import { getConsumerState } from "../../pipeline";
30
+ import { resetEventStore, setupTestStack, type TestStack, TestUsers } from "../../stack";
31
+ import { createPendingRebuildsTable } from "../pending-rebuilds";
32
+ import { type PiiEventBackfillResult, runPiiEventBackfill } from "../pii-event-backfill";
33
+
34
+ const BIDX_KEY = Buffer.alloc(32, 7).toString("base64");
35
+ const CONTACT_TABLE = "read_pii_backfill_contacts";
36
+ const MSP_TABLE = "read_pii_backfill_contact_phones";
37
+ const MSP_NAME = "crm:projection:contact-phones";
38
+ const KMS_CTX: KmsContext = { requestId: "pii-event-backfill-test" };
39
+ const admin = TestUsers.admin;
40
+
41
+ const contactPhonesTable = pgTable(MSP_TABLE, {
42
+ id: pgUuid("id").primaryKey(),
43
+ tenantId: pgUuid("tenant_id").notNull(),
44
+ phone: pgText("phone"),
45
+ });
46
+
47
+ // MSP apply receives the raw event payload, so this table holds whatever the event held.
48
+ let failMspApply = false;
49
+
50
+ function buildContactEntity(phoneIsPii: boolean): EntityDefinition {
51
+ return createEntity({
52
+ table: CONTACT_TABLE,
53
+ fields: {
54
+ email: createTextField({ required: true, personal: "self", find: "exact" }),
55
+ phone: phoneIsPii
56
+ ? createTextField({ personal: "self", find: "none" })
57
+ : createTextField({ personal: false, reason: "test_fixture" }),
58
+ },
59
+ });
60
+ }
61
+
62
+ function buildCrmFeature(entity: EntityDefinition) {
63
+ return defineFeature("crm", (r) => {
64
+ r.entity("contact", entity);
65
+ r.writeHandler(
66
+ defineEntityWriteHandler("contact:create", entity, { access: { roles: ["Admin"] } }),
67
+ );
68
+ r.queryHandler(
69
+ defineEntityQueryHandler("contact:detail", entity, { access: { roles: ["Admin"] } }),
70
+ );
71
+ r.multiStreamProjection({
72
+ name: "contact-phones",
73
+ table: contactPhonesTable,
74
+ apply: {
75
+ "contact.created": async (event, tx) => {
76
+ if (failMspApply) throw new Error("msp apply failure (test)");
77
+ const payload = event.payload as { phone?: string };
78
+ await asRawClient(tx).unsafe(
79
+ `INSERT INTO "${MSP_TABLE}" (id, tenant_id, phone) VALUES ($1::uuid, $2::uuid, $3)
80
+ ON CONFLICT (id) DO UPDATE SET phone = EXCLUDED.phone`,
81
+ [event.aggregateId, event.tenantId, payload.phone ?? null],
82
+ );
83
+ },
84
+ },
85
+ });
86
+ });
87
+ }
88
+
89
+ const featureV1 = buildCrmFeature(buildContactEntity(false));
90
+ const featureV2 = buildCrmFeature(buildContactEntity(true));
91
+ const registryV1: Registry = createRegistry([featureV1]);
92
+
93
+ // Throws a plain error (not KeyErasedError) once `failAfterKeys` DEKs exist,
94
+ // i.e. an outage of the key store rather than a forgotten subject.
95
+ class FlakyKmsAdapter extends InMemoryKmsAdapter {
96
+ failAfterKeys = Number.POSITIVE_INFINITY;
97
+ private createdKeys = 0;
98
+
99
+ override async createKey(subject: SubjectId): Promise<void> {
100
+ if (this.createdKeys >= this.failAfterKeys) throw new Error("kms outage");
101
+ await super.createKey(subject);
102
+ this.createdKeys++;
103
+ }
104
+ }
105
+
106
+ let stack: TestStack;
107
+ let registryV2: Registry;
108
+ let kms: FlakyKmsAdapter;
109
+
110
+ type EventPayloadRow = { id: string; aggregate_id: string; payload: Record<string, unknown> };
111
+
112
+ beforeAll(async () => {
113
+ stack = await setupTestStack({ features: [featureV2] });
114
+ registryV2 = stack.registry;
115
+ await createSnapshotsTable(stack.db);
116
+ });
117
+
118
+ afterAll(async () => {
119
+ await stack.cleanup();
120
+ });
121
+
122
+ beforeEach(async () => {
123
+ const raw = asRawClient(stack.db);
124
+ await resetEventStore(stack, [CONTACT_TABLE, MSP_TABLE]);
125
+ failMspApply = false;
126
+ await raw.unsafe(`DROP TABLE IF EXISTS "kumiko_pii_backfill_state", "kumiko_pending_rebuilds"`);
127
+ resetPiiSubjectKmsForTests();
128
+ resetBlindIndexKeyForTests();
129
+ kms = new FlakyKmsAdapter();
130
+ });
131
+
132
+ afterEach(() => {
133
+ resetPiiSubjectKmsForTests();
134
+ resetBlindIndexKeyForTests();
135
+ });
136
+
137
+ function armKms(): void {
138
+ configureBlindIndexKey(BIDX_KEY);
139
+ configurePiiSubjectKms(kms);
140
+ }
141
+
142
+ function disarmKms(): void {
143
+ resetPiiSubjectKmsForTests();
144
+ resetBlindIndexKeyForTests();
145
+ }
146
+
147
+ async function createContact(index: number): Promise<{ id: string; email: string; phone: string }> {
148
+ const email = `contact${index}@example.com`;
149
+ const phone = `+49 30 000${index}`;
150
+ const { id } = await stack.http.writeOk<{ id: string }>(
151
+ "crm:write:contact:create",
152
+ { email, phone },
153
+ admin,
154
+ );
155
+ return { id, email, phone };
156
+ }
157
+
158
+ async function drainDispatcher(): Promise<void> {
159
+ await stack.eventDispatcher?.runOnce();
160
+ }
161
+
162
+ async function readMspRows(): Promise<ReadonlyArray<{ id: string; phone: string | null }>> {
163
+ return (await asRawClient(stack.db).unsafe(
164
+ `SELECT "id", "phone" FROM "${MSP_TABLE}" ORDER BY "phone"`,
165
+ )) as ReadonlyArray<{ id: string; phone: string | null }>;
166
+ }
167
+
168
+ async function countPendingRebuilds(): Promise<number> {
169
+ const rows = (await asRawClient(stack.db).unsafe(
170
+ `SELECT count(*)::int AS "n" FROM "kumiko_pending_rebuilds"`,
171
+ )) as ReadonlyArray<{ n: number }>;
172
+ return rows[0]?.n ?? 0;
173
+ }
174
+
175
+ async function readEvents(): Promise<readonly EventPayloadRow[]> {
176
+ return (await asRawClient(stack.db).unsafe(
177
+ `SELECT "id"::text AS "id", "aggregate_id", "payload" FROM "kumiko_events" ORDER BY "id"`,
178
+ )) as ReadonlyArray<EventPayloadRow>;
179
+ }
180
+
181
+ async function readPayloadsAsText(): Promise<string> {
182
+ const rows = (await asRawClient(stack.db).unsafe(
183
+ `SELECT "payload"::text AS "payload" FROM "kumiko_events" ORDER BY "id"`,
184
+ )) as ReadonlyArray<{ payload: string }>;
185
+ return rows.map((r) => r.payload).join("\n");
186
+ }
187
+
188
+ async function readState(): Promise<{ cursor: string; completed: boolean }[]> {
189
+ return (await asRawClient(stack.db).unsafe(
190
+ `SELECT "cursor_event_id"::text AS "cursor", "completed_at" IS NOT NULL AS "completed"
191
+ FROM "kumiko_pii_backfill_state"`,
192
+ )) as { cursor: string; completed: boolean }[];
193
+ }
194
+
195
+ async function snapshotCount(aggregateId: string): Promise<number> {
196
+ const rows = (await asRawClient(stack.db).unsafe(
197
+ `SELECT count(*)::int AS "n" FROM "kumiko_snapshots" WHERE "aggregate_id" = $1`,
198
+ [aggregateId],
199
+ )) as ReadonlyArray<{ n: number }>;
200
+ return rows[0]?.n ?? 0;
201
+ }
202
+
203
+ function requireContact<T extends { id: string }>(contacts: readonly T[], id: string): T {
204
+ const contact = contacts.find((c) => c.id === id);
205
+ if (!contact) throw new Error(`no contact ${id}`);
206
+ return contact;
207
+ }
208
+
209
+ function expectRan(
210
+ result: PiiEventBackfillResult,
211
+ ): Extract<PiiEventBackfillResult, { status: "ran" }> {
212
+ if (result.status !== "ran")
213
+ throw new Error(`expected status "ran", got skipped (${result.reason})`);
214
+ return result;
215
+ }
216
+
217
+ async function decryptField(value: unknown, field: string): Promise<string> {
218
+ if (typeof value !== "string") throw new Error(`${field} is not a string`);
219
+ return decryptPiiValueForSubject(kms, value, KMS_CTX, field);
220
+ }
221
+
222
+ describe("runPiiEventBackfill", () => {
223
+ test("a field annotated after the fact: full run encrypts old events, unchanged annotations stay put", async () => {
224
+ const contact = await createContact(1);
225
+
226
+ armKms();
227
+ const v1 = expectRan(await runPiiEventBackfill(stack.db, registryV1));
228
+ expect(v1.mode).toBe("full");
229
+ const afterV1 = (await readEvents())[0]?.payload;
230
+ expect(isPiiCiphertext(afterV1?.["email"])).toBe(true);
231
+ expect(afterV1?.["phone"]).toBe(contact.phone);
232
+
233
+ const v2 = expectRan(await runPiiEventBackfill(stack.db, registryV2));
234
+ expect(v2.mode).toBe("full");
235
+ expect(v2.failures).toEqual([]);
236
+ const afterV2 = (await readEvents())[0]?.payload;
237
+ expect(isPiiCiphertext(afterV2?.["phone"])).toBe(true);
238
+ expect(await decryptField(afterV2?.["phone"], "phone")).toBe(contact.phone);
239
+ expect(await decryptField(afterV2?.["email"], "email")).toBe(contact.email);
240
+ });
241
+
242
+ test("second run with the same annotations is a catch-up that changes nothing", async () => {
243
+ await createContact(1);
244
+ await createContact(2);
245
+ armKms();
246
+ expectRan(await runPiiEventBackfill(stack.db, registryV2));
247
+ const payloadsAfterFirst = await readPayloadsAsText();
248
+
249
+ const second = expectRan(await runPiiEventBackfill(stack.db, registryV2));
250
+
251
+ expect(second.mode).toBe("catch-up");
252
+ expect(second.updatedEvents).toBe(0);
253
+ expect(second.queuedTables).toEqual([]);
254
+ expect(second.rebuild).toBeNull();
255
+ expect(await readPayloadsAsText()).toBe(payloadsAfterFirst);
256
+ });
257
+
258
+ test("queued projection rebuild materializes ciphertext and blind index; HTTP read stays plaintext", async () => {
259
+ const contact = await createContact(1);
260
+ const raw = asRawClient(stack.db);
261
+ const before = (await raw.unsafe(`SELECT "email" FROM "${CONTACT_TABLE}"`)) as ReadonlyArray<{
262
+ email: string;
263
+ }>;
264
+ expect(before[0]?.email).toBe(contact.email);
265
+
266
+ armKms();
267
+ const result = expectRan(await runPiiEventBackfill(stack.db, registryV2));
268
+
269
+ expect([...result.queuedTables].sort()).toEqual([CONTACT_TABLE, MSP_TABLE].sort());
270
+ expect(result.rebuild?.failed).toEqual([]);
271
+ expect(result.rebuild?.rebuilt.length).toBe(2);
272
+ const rows = (await raw.unsafe(`SELECT * FROM "${CONTACT_TABLE}"`)) as ReadonlyArray<
273
+ Record<string, unknown>
274
+ >;
275
+ expect(rows).toHaveLength(1);
276
+ expect(isPiiCiphertext(rows[0]?.["email"])).toBe(true);
277
+ expect(isPiiCiphertext(rows[0]?.["phone"])).toBe(true);
278
+ expect(typeof rows[0]?.["email_bidx"]).toBe("string");
279
+ const pending = (await raw.unsafe(
280
+ `SELECT count(*)::int AS "n" FROM "kumiko_pending_rebuilds"`,
281
+ )) as ReadonlyArray<{ n: number }>;
282
+ expect(pending[0]?.n).toBe(0);
283
+
284
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
285
+ "crm:query:contact:detail",
286
+ { id: contact.id },
287
+ admin,
288
+ );
289
+ expect(detail["email"]).toBe(contact.email);
290
+ expect(detail["phone"]).toBe(contact.phone);
291
+ });
292
+
293
+ test("multi-stream projection with a table is rebuilt: plaintext becomes ciphertext, live consumer keeps going", async () => {
294
+ const first = await createContact(1);
295
+ await drainDispatcher();
296
+ expect((await readMspRows()).map((r) => r.phone)).toEqual([first.phone]);
297
+
298
+ armKms();
299
+ const result = expectRan(await runPiiEventBackfill(stack.db, registryV2));
300
+
301
+ expect(result.queuedTables).toContain(MSP_TABLE);
302
+ expect(result.rebuild?.failed).toEqual([]);
303
+ expect(result.rebuild?.rebuilt.map((r) => r.projection)).toContain(MSP_NAME);
304
+ const rows = await readMspRows();
305
+ expect(rows).toHaveLength(1);
306
+ expect(isPiiCiphertext(rows[0]?.phone)).toBe(true);
307
+ expect(await countPendingRebuilds()).toBe(0);
308
+ expect((await getConsumerState(stack.db, MSP_NAME))?.status).toBe("idle");
309
+
310
+ const second = await createContact(2);
311
+ await drainDispatcher();
312
+ const afterLive = await readMspRows();
313
+ expect(afterLive).toHaveLength(2);
314
+ expect(isPiiCiphertext(afterLive.find((r) => r.id === second.id)?.phone)).toBe(true);
315
+ });
316
+
317
+ test("failing multi-stream rebuild stays queued, keeps old rows and does not mark the consumer dead", async () => {
318
+ const contact = await createContact(1);
319
+ await drainDispatcher();
320
+ const cursorBefore = (await getConsumerState(stack.db, MSP_NAME))?.lastProcessedEventId;
321
+ expect(cursorBefore).toBeGreaterThan(0n);
322
+ armKms();
323
+ failMspApply = true;
324
+
325
+ const result = expectRan(await runPiiEventBackfill(stack.db, registryV2));
326
+
327
+ expect(result.rebuild?.failed.map((f) => f.projection)).toEqual([MSP_NAME]);
328
+ expect(result.rebuild?.failed[0]?.error).toContain("msp apply failure");
329
+ const pending = (await asRawClient(stack.db).unsafe(
330
+ `SELECT "table_name" FROM "kumiko_pending_rebuilds"`,
331
+ )) as ReadonlyArray<{ table_name: string }>;
332
+ expect(pending.map((p) => p.table_name)).toEqual([MSP_TABLE]);
333
+ const consumerAfter = await getConsumerState(stack.db, MSP_NAME);
334
+ expect(consumerAfter?.status).toBe("idle");
335
+ expect(consumerAfter?.lastProcessedEventId).toBe(cursorBefore);
336
+ expect((await readMspRows()).map((r) => r.phone)).toEqual([contact.phone]);
337
+ });
338
+
339
+ test("a rebuilding run leaves foreign and unmappable queue rows untouched", async () => {
340
+ await createContact(1);
341
+ armKms();
342
+ const raw = asRawClient(stack.db);
343
+ await createPendingRebuildsTable(stack.db);
344
+ await raw.unsafe(
345
+ `INSERT INTO "kumiko_pending_rebuilds" ("table_name", "migration_id") VALUES
346
+ ('read_schema_apply_table', '0042_schema_apply'),
347
+ ('read_unmounted_pii_table', 'pii-backfill:foreign')`,
348
+ );
349
+
350
+ const result = expectRan(await runPiiEventBackfill(stack.db, registryV2));
351
+
352
+ expect(result.rebuild?.rebuilt.length).toBe(2);
353
+ expect(result.rebuild?.skippedTables).toEqual(["read_unmounted_pii_table"]);
354
+ const rows = (await raw.unsafe(
355
+ `SELECT "table_name" FROM "kumiko_pending_rebuilds" ORDER BY "table_name"`,
356
+ )) as ReadonlyArray<{ table_name: string }>;
357
+ expect(rows.map((r) => r.table_name)).toEqual([
358
+ "read_schema_apply_table",
359
+ "read_unmounted_pii_table",
360
+ ]);
361
+ });
362
+
363
+ test("two parallel runs encrypt every value exactly once", async () => {
364
+ const contacts = [];
365
+ for (let i = 1; i <= 5; i++) contacts.push(await createContact(i));
366
+ armKms();
367
+
368
+ const results = await Promise.all([
369
+ runPiiEventBackfill(stack.db, registryV2, { batchSize: 2 }),
370
+ runPiiEventBackfill(stack.db, registryV2, { batchSize: 2 }),
371
+ ]);
372
+
373
+ for (const result of results) expect(expectRan(result).failures).toEqual([]);
374
+ const events = await readEvents();
375
+ expect(events).toHaveLength(5);
376
+ for (const event of events) {
377
+ const original = requireContact(contacts, event.aggregate_id);
378
+ expect(await decryptField(event.payload["email"], "email")).toBe(original.email);
379
+ expect(await decryptField(event.payload["phone"], "phone")).toBe(original.phone);
380
+ }
381
+ });
382
+
383
+ test("KMS outage mid-run: failures reported, processed events stay encrypted, state not advanced; a healthy rerun completes", async () => {
384
+ const contacts = [];
385
+ for (let i = 1; i <= 4; i++) contacts.push(await createContact(i));
386
+ for (const contact of [contacts[0], contacts[2]]) {
387
+ if (!contact) throw new Error("missing contact");
388
+ await saveSnapshot(stack.db, {
389
+ aggregateId: contact.id,
390
+ tenantId: admin.tenantId as TenantId,
391
+ aggregateType: "contact",
392
+ version: 1,
393
+ state: { email: contact.email, phone: contact.phone },
394
+ });
395
+ }
396
+ armKms();
397
+ kms.failAfterKeys = 2;
398
+
399
+ const failed = expectRan(await runPiiEventBackfill(stack.db, registryV2));
400
+
401
+ expect(failed.failures.length).toBeGreaterThan(0);
402
+ expect(failed.updatedEvents).toBe(2);
403
+ const events = await readEvents();
404
+ expect(events.map((e) => isPiiCiphertext(e.payload["email"]))).toEqual([
405
+ true,
406
+ true,
407
+ false,
408
+ false,
409
+ ]);
410
+ expect(await snapshotCount(contacts[0]?.id ?? "")).toBe(0);
411
+ expect(await snapshotCount(contacts[2]?.id ?? "")).toBe(1);
412
+ const state = await readState();
413
+ expect(state).toHaveLength(1);
414
+ expect(state[0]?.completed).toBe(false);
415
+ const firstFailedId = BigInt(failed.failures[0]?.eventId ?? "0");
416
+ expect(BigInt(state[0]?.cursor ?? "0")).toBeLessThan(firstFailedId);
417
+
418
+ kms.failAfterKeys = Number.POSITIVE_INFINITY;
419
+ const healed = expectRan(await runPiiEventBackfill(stack.db, registryV2));
420
+
421
+ expect(healed.mode).toBe("full");
422
+ expect(healed.failures).toEqual([]);
423
+ for (const event of await readEvents()) {
424
+ const original = requireContact(contacts, event.aggregate_id);
425
+ expect(await decryptField(event.payload["email"], "email")).toBe(original.email);
426
+ expect(await decryptField(event.payload["phone"], "phone")).toBe(original.phone);
427
+ }
428
+ expect((await readState())[0]?.completed).toBe(true);
429
+ });
430
+
431
+ test("catch-up encrypts a plaintext event written later by an old pod", async () => {
432
+ await createContact(1);
433
+ armKms();
434
+ expectRan(await runPiiEventBackfill(stack.db, registryV2));
435
+
436
+ disarmKms();
437
+ const late = await createContact(2);
438
+ armKms();
439
+ const result = expectRan(await runPiiEventBackfill(stack.db, registryV2));
440
+
441
+ expect(result.mode).toBe("catch-up");
442
+ expect(result.updatedEvents).toBe(1);
443
+ const lateEvent = (await readEvents()).find((e) => e.aggregate_id === late.id);
444
+ expect(await decryptField(lateEvent?.payload["email"], "email")).toBe(late.email);
445
+ expect(await decryptField(lateEvent?.payload["phone"], "phone")).toBe(late.phone);
446
+ });
447
+
448
+ test("no KMS configured: skipped, events untouched", async () => {
449
+ await createContact(1);
450
+ const before = await readPayloadsAsText();
451
+
452
+ const result = await runPiiEventBackfill(stack.db, registryV2);
453
+
454
+ expect(result).toEqual({ status: "skipped", reason: "no_kms" });
455
+ expect(await readPayloadsAsText()).toBe(before);
456
+ });
457
+ });
@@ -19,7 +19,18 @@ export {
19
19
  pendingRebuildsTable,
20
20
  queueRebuildsFromMarkers,
21
21
  type RunPendingRebuildsOptions,
22
+ rebuildProjectionOrMultiStream,
22
23
  runPendingRebuilds,
23
24
  } from "./pending-rebuilds";
25
+ // Boot-time backfill: re-encrypts plaintext events after a PII annotation was added.
26
+ export {
27
+ createPiiBackfillStateTable,
28
+ type PiiEventBackfillOptions,
29
+ type PiiEventBackfillResult,
30
+ type PiiRebuildRun,
31
+ piiAnnotationFingerprint,
32
+ piiBackfillStateTable,
33
+ runPiiEventBackfill,
34
+ } from "./pii-event-backfill";
24
35
  // tableName → projection-name, für den app-seitigen Projection-Rebuild.
25
36
  export { buildProjectionTableIndex } from "./projection-table-index";
@@ -19,7 +19,7 @@ import { tableExists } from "../db/schema-inspection";
19
19
  import type { Registry } from "../engine/types";
20
20
  import type { JobRunner } from "../jobs";
21
21
  import { createFallbackLogger } from "../logging/utils";
22
- import { type RebuildResult, rebuildProjection } from "../pipeline";
22
+ import { type RebuildResult, rebuildMultiStreamProjection, rebuildProjection } from "../pipeline";
23
23
  import { unsafePushTables } from "../stack";
24
24
  import { buildProjectionTableIndex } from "./projection-table-index";
25
25
 
@@ -115,6 +115,32 @@ export type RunPendingRebuildsOptions = {
115
115
  readonly thisRunTables?: readonly string[];
116
116
  };
117
117
 
118
+ // buildProjectionTableIndex maps both single-stream and multi-stream projection
119
+ // tables to their name, so the rebuild has to pick the matching path. Queue
120
+ // drains are automatic rebuilds: a failed MSP replay rolls back, stays queued
121
+ // and must not park the still-intact live consumer as dead.
122
+ export async function rebuildProjectionOrMultiStream(
123
+ name: string,
124
+ deps: {
125
+ readonly db: DbConnection;
126
+ readonly registry: Registry;
127
+ readonly signal?: AbortSignal;
128
+ // MSPs take their skip mode from the declaration (msp.errorMode), no override.
129
+ readonly errorPolicy?: { readonly skipApplyErrors?: boolean };
130
+ },
131
+ ): Promise<RebuildResult> {
132
+ const { db, registry, signal, errorPolicy } = deps;
133
+ if (registry.getAllMultiStreamProjections().has(name)) {
134
+ return rebuildMultiStreamProjection(name, { db, registry, markDeadOnFailure: false });
135
+ }
136
+ return rebuildProjection(name, {
137
+ db,
138
+ registry,
139
+ ...(signal && { signal }),
140
+ ...(errorPolicy && { errorPolicy }),
141
+ });
142
+ }
143
+
118
144
  /** Arbeitet die persistierte Queue ab: mappt Tabellen auf Projektionen,
119
145
  * rebuildet jede betroffene Projektion und räumt ihre Tabellen erst nach
120
146
  * ERFOLG aus der Queue. Fehlgeschlagene bleiben pending — der nächste
@@ -185,7 +211,7 @@ export async function runPendingRebuilds(
185
211
  const failed: { projection: string; error: string }[] = [];
186
212
  for (const [projection, tables] of byProjection) {
187
213
  try {
188
- const result = await rebuildProjection(projection, { db, registry });
214
+ const result = await rebuildProjectionOrMultiStream(projection, { db, registry });
189
215
  await clearPendingRebuilds(db, snapshotRows(tables));
190
216
  rebuilt.push({ projection, eventsProcessed: result.eventsProcessed });
191
217
  } catch (e) {
@@ -220,6 +246,6 @@ export async function enqueueProjectionRebuild(
220
246
  const bullJobId = await jobRunner.dispatch(PROJECTION_REBUILD_JOB, { projection });
221
247
  return { mode: "dispatched", bullJobId };
222
248
  }
223
- const result = await rebuildProjection(projection, { db, registry });
249
+ const result = await rebuildProjectionOrMultiStream(projection, { db, registry });
224
250
  return { mode: "inline", result };
225
251
  }