@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.
- package/package.json +4 -4
- package/src/api/__tests__/auth-middleware-transport.test.ts +23 -1
- package/src/api/__tests__/client-ip.test.ts +20 -0
- package/src/api/__tests__/origin-middleware.test.ts +65 -0
- package/src/api/__tests__/websocket-route.test.ts +552 -0
- package/src/api/auth-middleware.ts +29 -7
- package/src/api/client-ip.ts +15 -2
- package/src/api/index.ts +24 -1
- package/src/api/jwt.ts +5 -0
- package/src/api/origin-middleware.ts +27 -1
- package/src/api/server.ts +161 -2
- package/src/api/websocket-route.ts +327 -0
- package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +94 -1
- package/src/bun-db/query.ts +160 -23
- package/src/changes.json +54 -0
- package/src/crypto/__tests__/system-event-pii.test.ts +77 -0
- package/src/crypto/event-pii.ts +20 -4
- package/src/crypto/system-event-pii.ts +57 -0
- package/src/db/__tests__/apply-entity-event-removed-column.integration.test.ts +109 -0
- package/src/db/queries/backfill-pii.ts +184 -85
- package/src/db/queries/pii-backfill-state.ts +33 -0
- package/src/engine/__tests__/build-app-schema.test.ts +26 -0
- package/src/engine/__tests__/describe-workflow-step-error.test.ts +17 -0
- package/src/engine/__tests__/steps-mail-send.test.ts +6 -5
- package/src/engine/__tests__/steps-webhook-send.test.ts +16 -8
- package/src/engine/build-app-schema.ts +2 -0
- package/src/engine/define-feature.ts +1 -0
- package/src/engine/feature-builder-state.ts +3 -0
- package/src/engine/feature-ui-extensions.ts +52 -0
- package/src/engine/index.ts +5 -0
- package/src/engine/steps/describe-workflow-step-error.ts +6 -0
- package/src/engine/steps/mail-send.ts +5 -1
- package/src/engine/steps/retry.ts +13 -1
- package/src/engine/steps/webhook-send.ts +6 -16
- package/src/engine/types/index.ts +8 -0
- package/src/event-store/index.ts +4 -0
- package/src/event-store/provenance-append.ts +1 -1
- package/src/event-store/transfer.ts +2 -2
- package/src/i18n/required-surface-keys.ts +8 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +31 -0
- package/src/jobs/job-runner.ts +18 -0
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +45 -3
- package/src/migrations/__tests__/pii-event-backfill.integration.test.ts +457 -0
- package/src/migrations/index.ts +11 -0
- package/src/migrations/pending-rebuilds.ts +29 -3
- package/src/migrations/pii-event-backfill.ts +422 -0
- package/src/pipeline/append-event-core.ts +1 -8
- package/src/pipeline/irreversible-operation-gate.ts +4 -3
- package/src/pipeline/msp-rebuild.ts +7 -1
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +65 -0
- package/src/search/__tests__/search-adapter.test.ts +15 -0
- package/src/search/in-memory-adapter.ts +6 -0
- package/src/search/meilisearch-adapter.ts +24 -0
- package/src/stack/test-stack.ts +26 -2
- 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
|
|
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 {
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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(
|
|
168
|
+
throw new Error(`${caller} requires a local-key KMS adapter`);
|
|
133
169
|
}
|
|
134
|
-
|
|
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 =
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
)
|
|
169
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
88
|
-
|
|
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.
|
|
123
|
+
expect(eventArg.payload.from).toBe("system@example.com");
|
|
123
124
|
});
|
|
124
125
|
|
|
125
|
-
it("omits from from the
|
|
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.
|
|
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.
|
|
77
|
-
expect(eventArg.payload.
|
|
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
|
-
|
|
100
|
-
|
|
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.
|
|
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.
|
|
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 }),
|