@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,109 @@
1
+ // A historical `<entity>.updated` event may carry a field that has since been
2
+ // removed from the entity and its table. Replaying it during a projection
3
+ // rebuild must drop the unknown column instead of failing the whole rebuild.
4
+
5
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
6
+ import { createEntity, createTextField, defineFeature } from "../../engine";
7
+ import { append } from "../../event-store";
8
+ import { createProjectionStateTable, rebuildProjection } from "../../pipeline";
9
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
10
+ import { createEventStoreExecutor } from "../event-store-executor";
11
+ import { asRawClient } from "../query";
12
+ import { buildEntityTable } from "../table-builder";
13
+ import { createTenantDb, type TenantDb } from "../tenant-db";
14
+
15
+ const noteEntity = createEntity({
16
+ table: "read_removed_column_notes",
17
+ fields: {
18
+ title: createTextField({ personal: false, reason: "test_fixture", required: true }),
19
+ },
20
+ });
21
+ const noteTable = buildEntityTable("removed-note", noteEntity);
22
+
23
+ const feature = defineFeature("removedcol", (r) => {
24
+ r.entity("removed-note", noteEntity);
25
+ });
26
+
27
+ const admin = TestUsers.admin;
28
+ const PROJECTION = "removedcol:projection:removed-note-entity";
29
+ const AGGREGATE_ID = "00000000-0000-4000-8000-0000000000aa";
30
+
31
+ let stack: TestStack;
32
+ let tdb: TenantDb;
33
+
34
+ beforeAll(async () => {
35
+ stack = await setupTestStack({ features: [feature] });
36
+ await unsafeCreateEntityTable(stack.db, noteEntity, "removed-note");
37
+ await createProjectionStateTable(stack.db);
38
+ tdb = createTenantDb(stack.db, admin.tenantId);
39
+ });
40
+
41
+ afterAll(async () => {
42
+ await stack.cleanup();
43
+ });
44
+
45
+ beforeEach(async () => {
46
+ await asRawClient(stack.db).unsafe(
47
+ `TRUNCATE kumiko_events, read_removed_column_notes, kumiko_projections RESTART IDENTITY CASCADE`,
48
+ );
49
+ });
50
+
51
+ async function appendHistoricalEvents(): Promise<void> {
52
+ await append(stack.db, {
53
+ aggregateId: AGGREGATE_ID,
54
+ aggregateType: "removed-note",
55
+ tenantId: admin.tenantId,
56
+ expectedVersion: 0,
57
+ type: "removed-note.created",
58
+ payload: { title: "old", legacyNote: "legacy" },
59
+ metadata: { userId: admin.id },
60
+ });
61
+ await append(stack.db, {
62
+ aggregateId: AGGREGATE_ID,
63
+ aggregateType: "removed-note",
64
+ tenantId: admin.tenantId,
65
+ expectedVersion: 1,
66
+ type: "removed-note.updated",
67
+ payload: {
68
+ changes: { title: "new", legacyNote: "gone" },
69
+ previous: { title: "old", legacyNote: "legacy" },
70
+ },
71
+ metadata: { userId: admin.id },
72
+ });
73
+ }
74
+
75
+ async function readRow(): Promise<{ title: string; version: number } | undefined> {
76
+ const rows = (await asRawClient(stack.db).unsafe(
77
+ `SELECT title, version FROM read_removed_column_notes WHERE id = $1::uuid`,
78
+ [AGGREGATE_ID],
79
+ )) as ReadonlyArray<{ title: string; version: number }>;
80
+ return rows[0];
81
+ }
82
+
83
+ describe("applyEntityEvent — event field no longer on the entity", () => {
84
+ test("rebuild replays created + updated events that carry a removed field", async () => {
85
+ await appendHistoricalEvents();
86
+
87
+ const result = await rebuildProjection(PROJECTION, { db: stack.db, registry: stack.registry });
88
+
89
+ expect(result.eventsProcessed).toBe(2);
90
+ expect(await readRow()).toMatchObject({ title: "new", version: 2 });
91
+ });
92
+
93
+ test("live update after the rebuild still works", async () => {
94
+ await appendHistoricalEvents();
95
+ await rebuildProjection(PROJECTION, { db: stack.db, registry: stack.registry });
96
+
97
+ const executor = createEventStoreExecutor(noteTable, noteEntity, {
98
+ entityName: "removed-note",
99
+ });
100
+ const updated = await executor.update(
101
+ { id: AGGREGATE_ID, version: 2, changes: { title: "live" } },
102
+ admin,
103
+ tdb,
104
+ );
105
+
106
+ expect(updated.isSuccess).toBe(true);
107
+ expect(await readRow()).toMatchObject({ title: "live", version: 3 });
108
+ });
109
+ });
@@ -37,8 +37,8 @@
37
37
  // Both stages are opt-in and off by default; without them an unresolvable
38
38
  // subject still fails loud into `failures`, as before.
39
39
  //
40
- // Snapshots of touched aggregates are dropped (they may cache plaintext);
41
- // the next snapshotting load recreates them. AFTER a run, rebuild the
40
+ // Snapshots of touched aggregates are dropped per batch (they may cache
41
+ // plaintext); the next snapshotting load recreates them. AFTER a run, rebuild the
42
42
  // affected projections — applyEntityEvent materializes ciphertext AND the
43
43
  // blind-index columns, which keeps equality lookups (login by email) alive.
44
44
 
@@ -50,6 +50,7 @@ import {
50
50
  KeyErasedError,
51
51
  KeyNotFoundError,
52
52
  type KmsContext,
53
+ type LocalKeyKmsAdapter,
53
54
  type SubjectId,
54
55
  } from "../../crypto/kms-adapter";
55
56
  import {
@@ -67,7 +68,7 @@ import {
67
68
  import type { EntityDefinition, Registry, TenantId } from "../../engine/types";
68
69
  import type { DbRunner } from "../connection";
69
70
  import { resolveTableName } from "../entity-table-meta";
70
- import { isUndefinedTable } from "../pg-error";
71
+ import { tableExists } from "../schema-inspection";
71
72
  import { toSnakeCase } from "../table-builder";
72
73
 
73
74
  const LIFECYCLE_VERBS = ["created", "updated", "deleted", "restored", "forgotten"] as const;
@@ -92,6 +93,19 @@ export type PiiBackfillResult = {
92
93
  readonly failures: readonly PiiBackfillFailure[];
93
94
  };
94
95
 
96
+ export type PiiBackfillScanCache = {
97
+ forgottenAggregates?: ReadonlySet<string>;
98
+ };
99
+
100
+ export type PiiBackfillBatchResult = PiiBackfillResult & {
101
+ // Last scanned event id; null when the batch was empty.
102
+ readonly lastEventId: string | null;
103
+ readonly scannedAll: boolean;
104
+ readonly firstFailedEventId: string | null;
105
+ readonly touchedAggregateTypes: readonly string[];
106
+ readonly touchedEventTypes: readonly string[];
107
+ };
108
+
95
109
  export type PiiBackfillOptions = {
96
110
  readonly batchSize?: number;
97
111
  // Scan + count only, write nothing — including the subject KMS: outcomes
@@ -105,6 +119,12 @@ export type PiiBackfillOptions = {
105
119
  readonly eraseUnresolvableSubjects?: boolean;
106
120
  };
107
121
 
122
+ export type PiiBackfillBatchOptions = PiiBackfillOptions & {
123
+ readonly afterEventId?: bigint | string;
124
+ // Caller-held so the forgotten-set full scan is shared across batches.
125
+ readonly scanCache?: PiiBackfillScanCache;
126
+ };
127
+
108
128
  type EventRow = {
109
129
  readonly id: bigint | string;
110
130
  readonly aggregate_id: string;
@@ -116,36 +136,94 @@ type EventRow = {
116
136
 
117
137
  type FieldOutcome = "unchanged" | "encrypted" | "erased";
118
138
 
119
- export async function backfillEventPiiEncryption(
120
- db: DbRunner,
121
- registry: Registry,
122
- options: PiiBackfillOptions = {},
123
- ): Promise<PiiBackfillResult> {
139
+ type PiiTargets = {
140
+ readonly entityTargets: ReadonlyMap<
141
+ string,
142
+ { readonly entity: EntityDefinition; readonly piiFields: readonly string[] }
143
+ >;
144
+ readonly eventCatalog: ReturnType<typeof configuredEventPiiCatalog>;
145
+ };
146
+
147
+ function collectPiiTargets(registry: Registry): PiiTargets {
148
+ const entityTargets = new Map<
149
+ string,
150
+ { readonly entity: EntityDefinition; readonly piiFields: readonly string[] }
151
+ >();
152
+ for (const [name, entity] of registry.getAllEntities()) {
153
+ const piiFields = collectPiiSubjectFields(entity);
154
+ if (piiFields.length > 0) entityTargets.set(name, { entity, piiFields });
155
+ }
156
+ return { entityTargets, eventCatalog: configuredEventPiiCatalog() };
157
+ }
158
+
159
+ function requireLocalKeySubjectKms(caller: string): LocalKeyKmsAdapter {
124
160
  const kms = configuredPiiSubjectKms();
125
161
  if (!kms) {
126
162
  throw new Error(
127
- "backfillEventPiiEncryption requires a configured subject KMS — boot with " +
163
+ `${caller} requires a configured subject KMS — boot with ` +
128
164
  "runProdApp({ kms }) / configurePiiSubjectKms(adapter) before running the backfill.",
129
165
  );
130
166
  }
131
167
  if (!isLocalKeyKmsAdapter(kms)) {
132
- throw new Error("backfillEventPiiEncryption requires a local-key KMS adapter");
168
+ throw new Error(`${caller} requires a local-key KMS adapter`);
133
169
  }
134
- const subjectKms = kms;
170
+ return kms;
171
+ }
172
+
173
+ export async function backfillEventPiiEncryption(
174
+ db: DbRunner,
175
+ registry: Registry,
176
+ options: PiiBackfillOptions = {},
177
+ ): Promise<PiiBackfillResult> {
178
+ requireLocalKeySubjectKms("backfillEventPiiEncryption");
179
+ const total = {
180
+ scannedEvents: 0,
181
+ updatedEvents: 0,
182
+ encryptedFields: 0,
183
+ erasedFields: 0,
184
+ ownerFromProjection: 0,
185
+ erasedUnresolvable: 0,
186
+ deletedSnapshots: 0,
187
+ };
188
+ const failures: PiiBackfillFailure[] = [];
189
+ const scanCache: PiiBackfillScanCache = {};
190
+ let afterEventId: string = "0";
191
+
192
+ for (;;) {
193
+ const batch = await backfillEventPiiEncryptionBatch(db, registry, {
194
+ ...options,
195
+ afterEventId,
196
+ scanCache,
197
+ });
198
+ total.scannedEvents += batch.scannedEvents;
199
+ total.updatedEvents += batch.updatedEvents;
200
+ total.encryptedFields += batch.encryptedFields;
201
+ total.erasedFields += batch.erasedFields;
202
+ total.ownerFromProjection += batch.ownerFromProjection;
203
+ total.erasedUnresolvable += batch.erasedUnresolvable;
204
+ total.deletedSnapshots += batch.deletedSnapshots;
205
+ failures.push(...batch.failures);
206
+ if (batch.lastEventId === null || batch.scannedAll) break;
207
+ afterEventId = batch.lastEventId;
208
+ }
209
+ return { ...total, failures };
210
+ }
211
+
212
+ // Processes exactly one batch. Snapshots of the batch's touched aggregates are
213
+ // dropped inside the same call so an abort between batches never leaves a
214
+ // snapshot that caches plaintext for an already-encrypted stream.
215
+ export async function backfillEventPiiEncryptionBatch(
216
+ db: DbRunner,
217
+ registry: Registry,
218
+ options: PiiBackfillBatchOptions = {},
219
+ ): Promise<PiiBackfillBatchResult> {
220
+ const subjectKms = requireLocalKeySubjectKms("backfillEventPiiEncryptionBatch");
135
221
  const batchSize = options.batchSize ?? 500;
136
222
  const raw = asRawClient(db);
137
223
  const kmsCtx: KmsContext = { requestId: "pii-backfill" };
224
+ const scanCache = options.scanCache ?? {};
138
225
 
139
- const entityTargets = new Map<
140
- string,
141
- { readonly entity: EntityDefinition; readonly piiFields: readonly string[] }
142
- >();
143
- for (const [name, entity] of registry.getAllEntities()) {
144
- const piiFields = collectPiiSubjectFields(entity);
145
- if (piiFields.length > 0) entityTargets.set(name, { entity, piiFields });
146
- }
147
- const eventCatalog = configuredEventPiiCatalog();
148
-
226
+ const { entityTargets, eventCatalog } = collectPiiTargets(registry);
149
227
  const aggregateTypes = [...entityTargets.keys()];
150
228
  const catalogTypes = [...eventCatalog.keys()];
151
229
  const result = {
@@ -158,65 +236,65 @@ export async function backfillEventPiiEncryption(
158
236
  deletedSnapshots: 0,
159
237
  failures: [] as PiiBackfillFailure[],
160
238
  };
161
- if (aggregateTypes.length === 0 && catalogTypes.length === 0) return result;
162
-
163
- // Pre-KMS forgets left no key tombstone — the *.forgotten event on the
164
- // stream is the only durable marker. Collect once; aggregate_id doubles
165
- // as the user id for user-subject lookups.
166
- const forgottenRows = (await raw.unsafe(
167
- `SELECT DISTINCT "aggregate_id" FROM "kumiko_events" WHERE "type" LIKE '%.forgotten'`,
168
- )) as ReadonlyArray<{ aggregate_id: string }>;
169
- const forgottenAggregates = new Set(forgottenRows.map((r) => r.aggregate_id));
170
-
239
+ const emptyBatch = (): PiiBackfillBatchResult => ({
240
+ ...result,
241
+ lastEventId: null,
242
+ scannedAll: true,
243
+ firstFailedEventId: null,
244
+ touchedAggregateTypes: [],
245
+ touchedEventTypes: [],
246
+ });
247
+ if (aggregateTypes.length === 0 && catalogTypes.length === 0) return emptyBatch();
248
+
249
+ const rows = (await raw.unsafe(
250
+ `SELECT "id", "aggregate_id", "aggregate_type", "tenant_id", "type", "payload"
251
+ FROM "kumiko_events"
252
+ WHERE ("aggregate_type" = ANY($1::text[]) OR "type" = ANY($2::text[])) AND "id" > $3::bigint
253
+ ORDER BY "id" ASC
254
+ LIMIT $4`,
255
+ [aggregateTypes, catalogTypes, String(options.afterEventId ?? 0), batchSize],
256
+ )) as ReadonlyArray<EventRow>;
257
+ const last = rows[rows.length - 1];
258
+ if (last === undefined) return emptyBatch();
259
+
260
+ const projectionOwnersByType = await loadProjectionOwners(rows);
171
261
  const touchedAggregates = new Set<string>();
172
- let cursor = "0";
173
-
174
- for (;;) {
175
- const rows = (await raw.unsafe(
176
- `SELECT "id", "aggregate_id", "aggregate_type", "tenant_id", "type", "payload"
177
- FROM "kumiko_events"
178
- WHERE ("aggregate_type" = ANY($1::text[]) OR "type" = ANY($2::text[])) AND "id" > $3::bigint
179
- ORDER BY "id" ASC
180
- LIMIT $4`,
181
- [aggregateTypes, catalogTypes, cursor, batchSize],
182
- )) as ReadonlyArray<EventRow>;
183
- if (rows.length === 0) break;
184
-
185
- const projectionOwnersByType = await loadProjectionOwners(rows);
186
-
187
- for (const row of rows) {
188
- result.scannedEvents++;
189
- try {
190
- const outcome = await transformEvent(row, projectionOwnersByType.get(row.aggregate_type));
191
- if (outcome === null) continue;
192
- result.encryptedFields += outcome.encrypted;
193
- result.erasedFields += outcome.erased;
194
- result.ownerFromProjection += outcome.ownerFromProjection;
195
- result.erasedUnresolvable += outcome.erasedUnresolvable;
196
- if (!options.dryRun) {
197
- await raw.unsafe(`UPDATE "kumiko_events" SET "payload" = $1::jsonb WHERE "id" = $2`, [
198
- outcome.payload,
199
- row.id,
200
- ]);
201
- }
202
- result.updatedEvents++;
203
- touchedAggregates.add(row.aggregate_id);
204
- } catch (e) {
205
- const reason = e instanceof Error ? e.message : String(e);
206
- result.failures.push({
207
- eventId: String(row.id),
208
- reason:
209
- e instanceof SubjectResolutionError
210
- ? `${reason} (retry with { resolveOwnerFromProjection: true } to resolve the owner ` +
211
- "from the entity's projection table, and/or { eraseUnresolvableSubjects: true } to " +
212
- "erase fields whose subject stays unresolvable)"
213
- : reason,
214
- });
262
+ const touchedAggregateTypes = new Set<string>();
263
+ const touchedEventTypes = new Set<string>();
264
+ let firstFailedEventId: string | null = null;
265
+
266
+ for (const row of rows) {
267
+ result.scannedEvents++;
268
+ try {
269
+ const outcome = await transformEvent(row, projectionOwnersByType.get(row.aggregate_type));
270
+ if (outcome === null) continue;
271
+ result.encryptedFields += outcome.encrypted;
272
+ result.erasedFields += outcome.erased;
273
+ result.ownerFromProjection += outcome.ownerFromProjection;
274
+ result.erasedUnresolvable += outcome.erasedUnresolvable;
275
+ if (!options.dryRun) {
276
+ await raw.unsafe(`UPDATE "kumiko_events" SET "payload" = $1::jsonb WHERE "id" = $2`, [
277
+ outcome.payload,
278
+ row.id,
279
+ ]);
215
280
  }
281
+ result.updatedEvents++;
282
+ touchedAggregates.add(row.aggregate_id);
283
+ touchedAggregateTypes.add(row.aggregate_type);
284
+ touchedEventTypes.add(row.type);
285
+ } catch (e) {
286
+ const reason = e instanceof Error ? e.message : String(e);
287
+ firstFailedEventId ??= String(row.id);
288
+ result.failures.push({
289
+ eventId: String(row.id),
290
+ reason:
291
+ e instanceof SubjectResolutionError
292
+ ? `${reason} (retry with { resolveOwnerFromProjection: true } to resolve the owner ` +
293
+ "from the entity's projection table, and/or { eraseUnresolvableSubjects: true } to " +
294
+ "erase fields whose subject stays unresolvable)"
295
+ : reason,
296
+ });
216
297
  }
217
- const last = rows[rows.length - 1];
218
- if (last === undefined) break;
219
- cursor = String(last.id);
220
298
  }
221
299
 
222
300
  // Snapshots may cache the plaintext state of touched aggregates.
@@ -228,7 +306,28 @@ export async function backfillEventPiiEncryption(
228
306
  result.deletedSnapshots = deleted.length;
229
307
  }
230
308
 
231
- return result;
309
+ return {
310
+ ...result,
311
+ lastEventId: String(last.id),
312
+ scannedAll: rows.length < batchSize,
313
+ firstFailedEventId,
314
+ touchedAggregateTypes: [...touchedAggregateTypes],
315
+ touchedEventTypes: [...touchedEventTypes],
316
+ };
317
+
318
+ // Full-scan of the store — only paid once a plaintext field actually needs
319
+ // encrypting, so a catch-up run over already-encrypted events never runs it.
320
+ async function loadForgottenAggregates(): Promise<ReadonlySet<string>> {
321
+ if (scanCache.forgottenAggregates) return scanCache.forgottenAggregates;
322
+ // Pre-KMS forgets left no key tombstone — the *.forgotten event on the
323
+ // stream is the only durable marker. aggregate_id doubles as the user id
324
+ // for user-subject lookups.
325
+ const forgottenRows = (await raw.unsafe(
326
+ `SELECT DISTINCT "aggregate_id" FROM "kumiko_events" WHERE "type" LIKE '%.forgotten'`,
327
+ )) as ReadonlyArray<{ aggregate_id: string }>;
328
+ scanCache.forgottenAggregates = new Set(forgottenRows.map((r) => r.aggregate_id));
329
+ return scanCache.forgottenAggregates;
330
+ }
232
331
 
233
332
  async function loadProjectionOwners(
234
333
  rows: readonly EventRow[],
@@ -249,7 +348,10 @@ export async function backfillEventPiiEncryption(
249
348
  if (!target) continue;
250
349
  const tableName = resolveTableName(aggregateType, target.entity, undefined);
251
350
  const ownersById = new Map<string, Record<string, unknown>>();
252
- try {
351
+ // Existence check instead of catching undefined_table: a caught SQL error
352
+ // aborts the surrounding transaction (25P02). Entity never mounted/rebuilt
353
+ // → stage 3 still gets a chance; this batch just contributes no owners.
354
+ if (await tableExists(db, `public.${tableName}`)) {
253
355
  const projectionRows = (await raw.unsafe(
254
356
  `SELECT * FROM ${quoteIdent(tableName)} WHERE "id" = ANY($1::uuid[])`,
255
357
  [[...ids]],
@@ -260,10 +362,6 @@ export async function backfillEventPiiEncryption(
260
362
  ownersById.set(id, projectionRowToCamel(target.entity, projectionRow));
261
363
  }
262
364
  }
263
- } catch (e) {
264
- // Entity never mounted/rebuilt (no projection table yet) — stage 3
265
- // still gets a chance; this batch just contributes no owners.
266
- if (!isUndefinedTable(e)) throw e;
267
365
  }
268
366
  byType.set(aggregateType, ownersById);
269
367
  }
@@ -367,7 +465,7 @@ export async function backfillEventPiiEncryption(
367
465
  if (value === null || value === undefined) return "unchanged";
368
466
  if (typeof value !== "string") return "unchanged";
369
467
  if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL) return "unchanged";
370
- if (isForgottenSubject(subject, row.aggregate_id)) {
468
+ if (await isForgottenSubject(subject, row.aggregate_id)) {
371
469
  section[field] = PII_ERASED_SENTINEL;
372
470
  return "erased";
373
471
  }
@@ -400,7 +498,8 @@ export async function backfillEventPiiEncryption(
400
498
  }
401
499
  }
402
500
 
403
- function isForgottenSubject(subject: SubjectId, aggregateId: string): boolean {
501
+ async function isForgottenSubject(subject: SubjectId, aggregateId: string): Promise<boolean> {
502
+ const forgottenAggregates = await loadForgottenAggregates();
404
503
  if (forgottenAggregates.has(aggregateId)) return true;
405
504
  return subject.kind === "user" && forgottenAggregates.has(subject.userId);
406
505
  }
@@ -0,0 +1,33 @@
1
+ import type { AnyDb } from "../query";
2
+ import { asRawClient } from "../query";
3
+
4
+ export type PiiBackfillStateRow = { readonly cursor: string; readonly completed: boolean };
5
+
6
+ export async function readPiiBackfillState(
7
+ db: AnyDb,
8
+ fingerprint: string,
9
+ ): Promise<PiiBackfillStateRow | null> {
10
+ const rows = (await asRawClient(db).unsafe(
11
+ `SELECT "cursor_event_id"::text AS "cursor", "completed_at" IS NOT NULL AS "completed"
12
+ FROM "kumiko_pii_backfill_state" WHERE "fingerprint" = $1`,
13
+ [fingerprint],
14
+ )) as ReadonlyArray<PiiBackfillStateRow>;
15
+ return rows[0] ?? null;
16
+ }
17
+
18
+ export async function persistPiiBackfillState(
19
+ db: AnyDb,
20
+ fingerprint: string,
21
+ cursor: bigint,
22
+ markCompleted: boolean,
23
+ ): Promise<void> {
24
+ await asRawClient(db).unsafe(
25
+ `INSERT INTO "kumiko_pii_backfill_state" ("fingerprint", "cursor_event_id", "completed_at", "updated_at")
26
+ VALUES ($1, $2::bigint, CASE WHEN $3::boolean THEN now() ELSE NULL END, now())
27
+ ON CONFLICT ("fingerprint") DO UPDATE SET
28
+ "cursor_event_id" = GREATEST("kumiko_pii_backfill_state"."cursor_event_id", EXCLUDED."cursor_event_id"),
29
+ "completed_at" = COALESCE("kumiko_pii_backfill_state"."completed_at", EXCLUDED."completed_at"),
30
+ "updated_at" = now()`,
31
+ [fingerprint, cursor.toString(), markCompleted],
32
+ );
33
+ }
@@ -392,6 +392,32 @@ describe("buildAppSchema", () => {
392
392
  expect(fields["categories"]?.["maxRows"]).toBeUndefined();
393
393
  });
394
394
 
395
+ test("Select: optionTones überleben die Projection", () => {
396
+ const entity = {
397
+ fields: {
398
+ status: {
399
+ type: "select",
400
+ options: ["open", "closed"],
401
+ optionTones: { closed: "bad" },
402
+ },
403
+ area: { type: "select", options: ["a", "b"] },
404
+ },
405
+ } as unknown as EntityDefinition;
406
+
407
+ const f = defineFeature("ent", (r) => {
408
+ r.entity("thing", entity);
409
+ });
410
+ const app = buildAppSchema(createRegistry([f]));
411
+ const fields = (
412
+ app.features[0]!.entities["thing"] as unknown as {
413
+ fields: Record<string, Record<string, unknown>>;
414
+ }
415
+ ).fields;
416
+
417
+ expect(fields["status"]?.["optionTones"]).toEqual({ closed: "bad" });
418
+ expect(fields["area"]?.["optionTones"]).toBeUndefined();
419
+ });
420
+
395
421
  test("MultiSelectFieldDef: alle Keys sind in projectField bewusst eingeordnet", () => {
396
422
  // Bounded completeness check, scoped to MultiSelectFieldDef's own key set
397
423
  // (packages/types/src/fields.ts) — NOT a general FieldDefinition lockstep.
@@ -0,0 +1,17 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { describeWorkflowStepError } from "../steps/describe-workflow-step-error";
3
+
4
+ describe("describeWorkflowStepError", () => {
5
+ test("uses the error class name and never the message", () => {
6
+ const text = describeWorkflowStepError(new TypeError("send to jane.doe@example.com failed"));
7
+ expect(text).toBe("workflow step failed (TypeError)");
8
+ expect(text).not.toContain("jane.doe@example.com");
9
+ });
10
+
11
+ test("non-Error throwables are described as unknown and not stringified", () => {
12
+ expect(describeWorkflowStepError("jane.doe@example.com")).toBe(
13
+ "workflow step failed (unknown error)",
14
+ );
15
+ expect(describeWorkflowStepError(undefined)).toBe("workflow step failed (unknown error)");
16
+ });
17
+ });
@@ -84,8 +84,9 @@ describe("mail.send run", () => {
84
84
  expect(eventArg.aggregateType).toBe(STEP_DISPATCH_AGGREGATE_TYPE);
85
85
  expect(eventArg.type).toBe(STEP_DISPATCH_REQUESTED_TYPE);
86
86
  expect(eventArg.payload.stepKind).toBe("mail.send");
87
- expect(eventArg.payload.spec).toMatchObject({
88
- to: "user@example.com",
87
+ expect(eventArg.payload).toEqual({
88
+ stepKind: "mail.send",
89
+ to: JSON.stringify("user@example.com"),
89
90
  subject: "Test",
90
91
  body: "Body text",
91
92
  });
@@ -119,10 +120,10 @@ describe("mail.send run", () => {
119
120
  );
120
121
 
121
122
  const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
122
- expect(eventArg.payload.spec.from).toBe("system@example.com");
123
+ expect(eventArg.payload.from).toBe("system@example.com");
123
124
  });
124
125
 
125
- it("omits from from the spec when not provided", async () => {
126
+ it("omits from from the payload when not provided", async () => {
126
127
  const stepDef = getStep("mail.send");
127
128
 
128
129
  await stepDef!.run(
@@ -131,6 +132,6 @@ describe("mail.send run", () => {
131
132
  );
132
133
 
133
134
  const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
134
- expect(eventArg.payload.spec.from).toBeUndefined();
135
+ expect(eventArg.payload.from).toBeUndefined();
135
136
  });
136
137
  });
@@ -73,8 +73,9 @@ describe("webhook.send run", () => {
73
73
  expect(eventArg.aggregateType).toBe(STEP_DISPATCH_AGGREGATE_TYPE);
74
74
  expect(eventArg.type).toBe(STEP_DISPATCH_REQUESTED_TYPE);
75
75
  expect(eventArg.payload.stepKind).toBe("webhook.send");
76
- expect(eventArg.payload.spec.url).toBe("https://hooks.example/test");
77
- expect(eventArg.payload.spec.body).toEqual({ event: "incident-opened", id: "abc" });
76
+ expect(eventArg.payload.url).toBe("https://hooks.example/test");
77
+ expect(JSON.parse(eventArg.payload.bodyJson)).toEqual({ event: "incident-opened", id: "abc" });
78
+ expect(eventArg.payload.headersJson).toBe("{}");
78
79
  });
79
80
 
80
81
  it("resolves function-based url and body resolvers", async () => {
@@ -96,10 +97,8 @@ describe("webhook.send run", () => {
96
97
  expect(mockUnsafeAppendEvent).toHaveBeenCalledWith(
97
98
  expect.objectContaining({
98
99
  payload: expect.objectContaining({
99
- spec: expect.objectContaining({
100
- url: "https://hooks.example/dynamic",
101
- body: { key: "value" },
102
- }),
100
+ url: "https://hooks.example/dynamic",
101
+ bodyJson: JSON.stringify({ key: "value" }),
103
102
  }),
104
103
  }),
105
104
  );
@@ -111,7 +110,7 @@ describe("webhook.send run", () => {
111
110
  await stepDef!.run({ url: "https://hooks.example/test", mode: "deferred" }, mockCtx);
112
111
 
113
112
  const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
114
- expect(eventArg.payload.spec.method).toBe("POST");
113
+ expect(eventArg.payload.method).toBe("POST");
115
114
  });
116
115
 
117
116
  it("defaults retry to 3x exponential when not specified", async () => {
@@ -123,6 +122,15 @@ describe("webhook.send run", () => {
123
122
  expect(eventArg.payload.retry).toEqual({ times: 3, backoff: "exponential" });
124
123
  });
125
124
 
125
+ it("omits bodyJson when no body is given", async () => {
126
+ const stepDef = getStep("webhook.send");
127
+
128
+ await stepDef!.run({ url: "https://hooks.example/test", mode: "deferred" }, mockCtx);
129
+
130
+ const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
131
+ expect("bodyJson" in eventArg.payload).toBe(false);
132
+ });
133
+
126
134
  it("passes auth config through when provided", async () => {
127
135
  const stepDef = getStep("webhook.send");
128
136
  const auth = { kind: "bearer" as const, secret: "WEBHOOK_TOKEN" };
@@ -130,6 +138,6 @@ describe("webhook.send run", () => {
130
138
  await stepDef!.run({ url: "https://hooks.example/secured", mode: "deferred", auth }, mockCtx);
131
139
 
132
140
  const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
133
- expect(eventArg.payload.spec.auth).toEqual(auth);
141
+ expect(eventArg.payload.auth).toEqual(auth);
134
142
  });
135
143
  });
@@ -450,6 +450,8 @@ function projectField(fieldDef: FieldDefinition): FieldDefinition {
450
450
  if (isJsonSafeValue(def["default"])) out["default"] = def["default"];
451
451
  // Select: options-Liste ist plain JSON, durchschicken.
452
452
  if (Array.isArray(def["options"])) out["options"] = def["options"];
453
+ if (isPlainObject(def["optionTones"]) && isJsonSafeValue(def["optionTones"]))
454
+ out["optionTones"] = def["optionTones"];
453
455
  // Reference: entity-Target + labelField + multiple müssen zum Renderer.
454
456
  // Der ReferenceInput baut die Options-Query aus refEntity/refFeature und
455
457
  // resolved das Label über labelField — ohne diese Properties fällt das
@@ -160,6 +160,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
160
160
  contentCollections: state.contentCollections,
161
161
  workspaces: state.workspaces,
162
162
  httpRoutes: state.httpRoutes,
163
+ webSocketRoutes: state.webSocketRoutes,
163
164
  storeTables: state.storeTables,
164
165
  ...(state.treeActions !== undefined && { treeActions: state.treeActions }),
165
166
  ...(state.envSchema !== undefined && { envSchema: state.envSchema }),