@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,422 @@
|
|
|
1
|
+
// Boot-time PII backfill for kumiko_events: when a field gains a PII annotation
|
|
2
|
+
// after events were already stored, the rebuild copies payloads unchanged, so
|
|
3
|
+
// old events stay plaintext until they are re-encrypted in place. This runs the
|
|
4
|
+
// batch primitive from backfill-pii.ts once per annotation state (fingerprint),
|
|
5
|
+
// then cheap catch-up passes, and rebuilds the projections whose source events
|
|
6
|
+
// changed (including multi-stream projections with their own table) so
|
|
7
|
+
// ciphertext and blind-index columns get materialized.
|
|
8
|
+
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { configuredEventPiiCatalog } from "../crypto/event-pii";
|
|
11
|
+
import { isLocalKeyKmsAdapter } from "../crypto/kms-adapter";
|
|
12
|
+
import { configuredPiiSubjectKms } from "../crypto/pii-field-encryption";
|
|
13
|
+
import { collectPiiSubjectFields } from "../crypto/subject-resolver";
|
|
14
|
+
import { extractTableName } from "../db";
|
|
15
|
+
import type { DbConnection, DbRunner } from "../db/connection";
|
|
16
|
+
import { bigint, instant, table as pgTable, sql, text } from "../db/dialect";
|
|
17
|
+
import { acquireNamespacedAdvisoryLock } from "../db/queries/advisory-lock";
|
|
18
|
+
import {
|
|
19
|
+
backfillEventPiiEncryptionBatch,
|
|
20
|
+
type PiiBackfillBatchResult,
|
|
21
|
+
type PiiBackfillFailure,
|
|
22
|
+
type PiiBackfillScanCache,
|
|
23
|
+
} from "../db/queries/backfill-pii";
|
|
24
|
+
import {
|
|
25
|
+
type PiiBackfillStateRow,
|
|
26
|
+
persistPiiBackfillState,
|
|
27
|
+
readPiiBackfillState,
|
|
28
|
+
} from "../db/queries/pii-backfill-state";
|
|
29
|
+
import { upsertOnConflict } from "../db/query";
|
|
30
|
+
import { tableExists } from "../db/schema-inspection";
|
|
31
|
+
import type { Registry } from "../engine/types";
|
|
32
|
+
import type { Logger } from "../logging/types";
|
|
33
|
+
import { createFallbackLogger } from "../logging/utils";
|
|
34
|
+
import { unsafePushTables } from "../stack";
|
|
35
|
+
import {
|
|
36
|
+
clearPendingRebuilds,
|
|
37
|
+
createPendingRebuildsTable,
|
|
38
|
+
listPendingRebuildRows,
|
|
39
|
+
pendingRebuildsTable,
|
|
40
|
+
rebuildProjectionOrMultiStream,
|
|
41
|
+
} from "./pending-rebuilds";
|
|
42
|
+
import { buildProjectionTableIndex } from "./projection-table-index";
|
|
43
|
+
|
|
44
|
+
const PII_EVENT_BACKFILL_LOCK_NAMESPACE = 0x70_69_69_62; // 'piib'
|
|
45
|
+
const PII_EVENT_BACKFILL_LOCK_KEY = "pii-event-backfill";
|
|
46
|
+
const PII_BACKFILL_MIGRATION_PREFIX = "pii-backfill:";
|
|
47
|
+
const DEFAULT_BATCH_SIZE = 500;
|
|
48
|
+
const MAX_LOGGED_FAILURE_IDS = 50;
|
|
49
|
+
|
|
50
|
+
// Events can commit with an id lower than an already-scanned one (late commits),
|
|
51
|
+
// and old pods in a rolling deploy keep writing the newly annotated field as
|
|
52
|
+
// plaintext. The transformation is idempotent, so overlap only costs reads.
|
|
53
|
+
const CATCH_UP_OVERLAP_EVENT_IDS = 1000n;
|
|
54
|
+
|
|
55
|
+
export const piiBackfillStateTable = pgTable("kumiko_pii_backfill_state", {
|
|
56
|
+
fingerprint: text("fingerprint").primaryKey(),
|
|
57
|
+
cursorEventId: bigint("cursor_event_id", { mode: "bigint" }).notNull().default(0),
|
|
58
|
+
completedAt: instant("completed_at", { precision: 3 }),
|
|
59
|
+
updatedAt: instant("updated_at", { precision: 3 }).notNull().default(sql`now()`),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export async function createPiiBackfillStateTable(db: DbConnection): Promise<void> {
|
|
63
|
+
// skip: table already exists — boot runs on every replica
|
|
64
|
+
if (await tableExists(db, "public.kumiko_pii_backfill_state")) return;
|
|
65
|
+
await unsafePushTables(db, { kumikoPiiBackfillState: piiBackfillStateTable });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Replicas boot concurrently: two of them can both pass the tableExists gate and
|
|
69
|
+
// one CREATE then fails. The table existing afterwards means the other won.
|
|
70
|
+
async function ensureInfraTable(
|
|
71
|
+
db: DbConnection,
|
|
72
|
+
tableName: string,
|
|
73
|
+
create: (db: DbConnection) => Promise<void>,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
try {
|
|
76
|
+
await create(db);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if (!(await tableExists(db, `public.${tableName}`))) throw e;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function piiAnnotationEntries(registry: Registry): readonly string[] {
|
|
83
|
+
const entries: string[] = [];
|
|
84
|
+
for (const [name, entity] of registry.getAllEntities()) {
|
|
85
|
+
for (const field of collectPiiSubjectFields(entity)) entries.push(`entity:${name}.${field}`);
|
|
86
|
+
}
|
|
87
|
+
for (const [eventType, fields] of configuredEventPiiCatalog()) {
|
|
88
|
+
for (const field of Object.keys(fields)) entries.push(`event:${eventType}.${field}`);
|
|
89
|
+
}
|
|
90
|
+
return entries.sort();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function piiAnnotationFingerprint(registry: Registry): string {
|
|
94
|
+
return createHash("sha256").update(piiAnnotationEntries(registry).join("\n")).digest("hex");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type PiiEventBackfillOptions = {
|
|
98
|
+
readonly signal?: AbortSignal;
|
|
99
|
+
readonly batchSize?: number;
|
|
100
|
+
readonly logger?: Pick<Logger, "error"> & Partial<Pick<Logger, "warn" | "debug">>;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type PiiRebuildRun = {
|
|
104
|
+
readonly rebuilt: readonly { readonly projection: string; readonly eventsProcessed: number }[];
|
|
105
|
+
// Stay queued and are retried on the next boot.
|
|
106
|
+
readonly failed: readonly { readonly projection: string; readonly error: string }[];
|
|
107
|
+
// Queued by this backfill but without a projection in this process; left queued.
|
|
108
|
+
readonly skippedTables: readonly string[];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type PiiEventBackfillResult =
|
|
112
|
+
| { readonly status: "skipped"; readonly reason: "no_kms" | "no_pii" }
|
|
113
|
+
| {
|
|
114
|
+
readonly status: "ran";
|
|
115
|
+
readonly mode: "full" | "catch-up";
|
|
116
|
+
readonly aborted: boolean;
|
|
117
|
+
readonly scannedEvents: number;
|
|
118
|
+
readonly updatedEvents: number;
|
|
119
|
+
readonly encryptedFields: number;
|
|
120
|
+
readonly erasedFields: number;
|
|
121
|
+
readonly failures: readonly PiiBackfillFailure[];
|
|
122
|
+
readonly queuedTables: readonly string[];
|
|
123
|
+
readonly rebuild: PiiRebuildRun | null;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
async function queueRebuildTables(
|
|
127
|
+
db: DbRunner,
|
|
128
|
+
tableNames: readonly string[],
|
|
129
|
+
migrationId: string,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
for (const tableName of tableNames) {
|
|
132
|
+
await upsertOnConflict(
|
|
133
|
+
db,
|
|
134
|
+
pendingRebuildsTable,
|
|
135
|
+
{ tableName, migrationId },
|
|
136
|
+
{ conflictKeys: ["tableName"], update: { migrationId } },
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function affectedProjectionTables(
|
|
142
|
+
registry: Registry,
|
|
143
|
+
touchedAggregateTypes: ReadonlySet<string>,
|
|
144
|
+
touchedEventTypes: ReadonlySet<string>,
|
|
145
|
+
): readonly string[] {
|
|
146
|
+
const tables = new Set<string>();
|
|
147
|
+
for (const [name, def] of registry.getAllProjections()) {
|
|
148
|
+
const sources = [
|
|
149
|
+
...(Array.isArray(def.source) ? def.source : [def.source]),
|
|
150
|
+
...(def.extraSources ?? []),
|
|
151
|
+
];
|
|
152
|
+
const isAffected =
|
|
153
|
+
sources.some((source) => touchedAggregateTypes.has(source)) ||
|
|
154
|
+
Object.keys(def.apply).some((eventType) => touchedEventTypes.has(eventType));
|
|
155
|
+
if (isAffected) tables.add(extractTableName(def.table, `pii-event-backfill(${name})`));
|
|
156
|
+
}
|
|
157
|
+
// MSP apply gets the raw event payloads, so their tables hold plaintext until rebuilt.
|
|
158
|
+
for (const [name, def] of registry.getAllMultiStreamProjections()) {
|
|
159
|
+
if (def.table === undefined) continue;
|
|
160
|
+
if (Object.keys(def.apply).some((eventType) => touchedEventTypes.has(eventType))) {
|
|
161
|
+
tables.add(extractTableName(def.table, `pii-event-backfill(${name})`));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return [...tables];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
type BatchStep = {
|
|
168
|
+
readonly batch: PiiBackfillBatchResult;
|
|
169
|
+
readonly tables: readonly string[];
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
type RunProgress = {
|
|
173
|
+
scanCursor: bigint;
|
|
174
|
+
failureSeen: boolean;
|
|
175
|
+
readonly scanCache: PiiBackfillScanCache;
|
|
176
|
+
readonly totals: {
|
|
177
|
+
scannedEvents: number;
|
|
178
|
+
updatedEvents: number;
|
|
179
|
+
encryptedFields: number;
|
|
180
|
+
erasedFields: number;
|
|
181
|
+
};
|
|
182
|
+
readonly failures: PiiBackfillFailure[];
|
|
183
|
+
readonly queuedTables: Set<string>;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
type RunContext = {
|
|
187
|
+
readonly db: DbConnection;
|
|
188
|
+
readonly registry: Registry;
|
|
189
|
+
readonly fingerprint: string;
|
|
190
|
+
readonly migrationId: string;
|
|
191
|
+
readonly mode: "full" | "catch-up";
|
|
192
|
+
readonly batchSize: number;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
function initialScanCursor(state: PiiBackfillStateRow | null): {
|
|
196
|
+
mode: "full" | "catch-up";
|
|
197
|
+
scanCursor: bigint;
|
|
198
|
+
} {
|
|
199
|
+
const cursor = BigInt(state?.cursor ?? "0");
|
|
200
|
+
if (state?.completed !== true) return { mode: "full", scanCursor: cursor };
|
|
201
|
+
return {
|
|
202
|
+
mode: "catch-up",
|
|
203
|
+
scanCursor: cursor > CATCH_UP_OVERLAP_EVENT_IDS ? cursor - CATCH_UP_OVERLAP_EVENT_IDS : 0n,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// One transaction per batch: the advisory lock serializes pods, and the payload
|
|
208
|
+
// is re-read inside it so a value is never encrypted twice and two backfills
|
|
209
|
+
// never mint the same DEK. Returns null when another pod already finished a full run.
|
|
210
|
+
async function runOneBatch(ctx: RunContext, progress: RunProgress): Promise<BatchStep | null> {
|
|
211
|
+
return ctx.db.begin(async (tx: DbRunner) => {
|
|
212
|
+
await acquireNamespacedAdvisoryLock(
|
|
213
|
+
tx,
|
|
214
|
+
PII_EVENT_BACKFILL_LOCK_NAMESPACE,
|
|
215
|
+
PII_EVENT_BACKFILL_LOCK_KEY,
|
|
216
|
+
);
|
|
217
|
+
const state = await readPiiBackfillState(tx, ctx.fingerprint);
|
|
218
|
+
if (ctx.mode === "full" && state?.completed === true) return null;
|
|
219
|
+
const startCursor =
|
|
220
|
+
ctx.mode === "full" && state
|
|
221
|
+
? maxBigInt(progress.scanCursor, BigInt(state.cursor))
|
|
222
|
+
: progress.scanCursor;
|
|
223
|
+
|
|
224
|
+
const batch = await backfillEventPiiEncryptionBatch(tx, ctx.registry, {
|
|
225
|
+
afterEventId: startCursor,
|
|
226
|
+
batchSize: ctx.batchSize,
|
|
227
|
+
resolveOwnerFromProjection: true,
|
|
228
|
+
scanCache: progress.scanCache,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const batchTouchedEventTypes = new Set(batch.touchedEventTypes);
|
|
232
|
+
const tables =
|
|
233
|
+
batch.updatedEvents > 0
|
|
234
|
+
? affectedProjectionTables(
|
|
235
|
+
ctx.registry,
|
|
236
|
+
new Set(batch.touchedAggregateTypes),
|
|
237
|
+
batchTouchedEventTypes,
|
|
238
|
+
)
|
|
239
|
+
: [];
|
|
240
|
+
if (tables.length > 0) await queueRebuildTables(tx, tables, ctx.migrationId);
|
|
241
|
+
|
|
242
|
+
if (!progress.failureSeen) {
|
|
243
|
+
const completesRun = batch.scannedAll && batch.failures.length === 0;
|
|
244
|
+
await persistPiiBackfillState(
|
|
245
|
+
tx,
|
|
246
|
+
ctx.fingerprint,
|
|
247
|
+
persistableCursor(batch, startCursor),
|
|
248
|
+
completesRun,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return { batch, tables };
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function recordBatch(progress: RunProgress, step: BatchStep): void {
|
|
256
|
+
const { batch } = step;
|
|
257
|
+
progress.totals.scannedEvents += batch.scannedEvents;
|
|
258
|
+
progress.totals.updatedEvents += batch.updatedEvents;
|
|
259
|
+
progress.totals.encryptedFields += batch.encryptedFields;
|
|
260
|
+
progress.totals.erasedFields += batch.erasedFields;
|
|
261
|
+
progress.failures.push(...batch.failures);
|
|
262
|
+
for (const table of step.tables) progress.queuedTables.add(table);
|
|
263
|
+
if (batch.failures.length > 0) progress.failureSeen = true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Returns true when the run was aborted between batches.
|
|
267
|
+
async function scanAllBatches(
|
|
268
|
+
ctx: RunContext,
|
|
269
|
+
progress: RunProgress,
|
|
270
|
+
signal: AbortSignal | undefined,
|
|
271
|
+
): Promise<boolean> {
|
|
272
|
+
for (;;) {
|
|
273
|
+
if (signal?.aborted) return true;
|
|
274
|
+
const step = await runOneBatch(ctx, progress);
|
|
275
|
+
if (step === null) return false;
|
|
276
|
+
recordBatch(progress, step);
|
|
277
|
+
if (step.batch.lastEventId === null || step.batch.scannedAll) return false;
|
|
278
|
+
progress.scanCursor = BigInt(step.batch.lastEventId);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function reportRunOutcome(
|
|
283
|
+
log: ReturnType<typeof createFallbackLogger>,
|
|
284
|
+
progress: RunProgress,
|
|
285
|
+
): void {
|
|
286
|
+
if (progress.failures.length > 0) {
|
|
287
|
+
log.error(
|
|
288
|
+
`${progress.failures.length} event(s) could not be PII-encrypted and will be retried on the next boot`,
|
|
289
|
+
{
|
|
290
|
+
failureCount: progress.failures.length,
|
|
291
|
+
eventIds: progress.failures.slice(0, MAX_LOGGED_FAILURE_IDS).map((f) => f.eventId),
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function runPiiEventBackfill(
|
|
298
|
+
db: DbConnection,
|
|
299
|
+
registry: Registry,
|
|
300
|
+
options: PiiEventBackfillOptions = {},
|
|
301
|
+
): Promise<PiiEventBackfillResult> {
|
|
302
|
+
const log = createFallbackLogger("pii-event-backfill", options.logger);
|
|
303
|
+
const kms = configuredPiiSubjectKms();
|
|
304
|
+
if (!kms || !isLocalKeyKmsAdapter(kms)) return { status: "skipped", reason: "no_kms" };
|
|
305
|
+
if (piiAnnotationEntries(registry).length === 0) {
|
|
306
|
+
return { status: "skipped", reason: "no_pii" };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const fingerprint = piiAnnotationFingerprint(registry);
|
|
310
|
+
await ensureInfraTable(db, "kumiko_pii_backfill_state", createPiiBackfillStateTable);
|
|
311
|
+
await ensureInfraTable(db, "kumiko_pending_rebuilds", createPendingRebuildsTable);
|
|
312
|
+
|
|
313
|
+
const { mode, scanCursor } = initialScanCursor(await readPiiBackfillState(db, fingerprint));
|
|
314
|
+
const ctx: RunContext = {
|
|
315
|
+
db,
|
|
316
|
+
registry,
|
|
317
|
+
fingerprint,
|
|
318
|
+
migrationId: `${PII_BACKFILL_MIGRATION_PREFIX}${fingerprint.slice(0, 12)}`,
|
|
319
|
+
mode,
|
|
320
|
+
batchSize: options.batchSize ?? DEFAULT_BATCH_SIZE,
|
|
321
|
+
};
|
|
322
|
+
const progress: RunProgress = {
|
|
323
|
+
scanCursor,
|
|
324
|
+
failureSeen: false,
|
|
325
|
+
scanCache: {},
|
|
326
|
+
totals: { scannedEvents: 0, updatedEvents: 0, encryptedFields: 0, erasedFields: 0 },
|
|
327
|
+
failures: [],
|
|
328
|
+
queuedTables: new Set(),
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const aborted = await scanAllBatches(ctx, progress, options.signal);
|
|
332
|
+
reportRunOutcome(log, progress);
|
|
333
|
+
|
|
334
|
+
const rebuild = aborted ? null : await rebuildQueuedTables(db, registry, options.signal, log);
|
|
335
|
+
const queuedTables = [...progress.queuedTables];
|
|
336
|
+
log.debug("PII event backfill finished", {
|
|
337
|
+
mode,
|
|
338
|
+
aborted,
|
|
339
|
+
...progress.totals,
|
|
340
|
+
failureCount: progress.failures.length,
|
|
341
|
+
queuedTables,
|
|
342
|
+
});
|
|
343
|
+
return {
|
|
344
|
+
status: "ran",
|
|
345
|
+
mode,
|
|
346
|
+
aborted,
|
|
347
|
+
...progress.totals,
|
|
348
|
+
failures: progress.failures,
|
|
349
|
+
queuedTables,
|
|
350
|
+
rebuild,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Never advance past a failed event so the next boot retries it.
|
|
355
|
+
function persistableCursor(batch: PiiBackfillBatchResult, startCursor: bigint): bigint {
|
|
356
|
+
if (batch.firstFailedEventId !== null) {
|
|
357
|
+
const beforeFailure = BigInt(batch.firstFailedEventId) - 1n;
|
|
358
|
+
return beforeFailure > startCursor ? beforeFailure : startCursor;
|
|
359
|
+
}
|
|
360
|
+
return batch.lastEventId === null ? startCursor : BigInt(batch.lastEventId);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function maxBigInt(a: bigint, b: bigint): bigint {
|
|
364
|
+
return a > b ? a : b;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Only our own queue rows: runPendingRebuilds would drain the whole queue and
|
|
368
|
+
// silently clear entries this process cannot map (a worker with a narrower
|
|
369
|
+
// feature set would swallow rebuilds queued by the API pod or by schema apply).
|
|
370
|
+
async function rebuildQueuedTables(
|
|
371
|
+
db: DbConnection,
|
|
372
|
+
registry: Registry,
|
|
373
|
+
signal: AbortSignal | undefined,
|
|
374
|
+
log: ReturnType<typeof createFallbackLogger>,
|
|
375
|
+
): Promise<PiiRebuildRun | null> {
|
|
376
|
+
const ownRows = (await listPendingRebuildRows(db)).filter((row) =>
|
|
377
|
+
row.migrationId.startsWith(PII_BACKFILL_MIGRATION_PREFIX),
|
|
378
|
+
);
|
|
379
|
+
if (ownRows.length === 0) return null;
|
|
380
|
+
|
|
381
|
+
const tableToProjection = buildProjectionTableIndex(registry);
|
|
382
|
+
const rowsByProjection = new Map<string, (typeof ownRows)[number][]>();
|
|
383
|
+
const skippedTables: string[] = [];
|
|
384
|
+
for (const row of ownRows) {
|
|
385
|
+
const projection = tableToProjection.get(row.tableName);
|
|
386
|
+
if (projection === undefined) {
|
|
387
|
+
skippedTables.push(row.tableName);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
rowsByProjection.set(projection, [...(rowsByProjection.get(projection) ?? []), row]);
|
|
391
|
+
}
|
|
392
|
+
if (skippedTables.length > 0) {
|
|
393
|
+
log.warn(
|
|
394
|
+
"Queued tables have no projection in this process and stay queued for a process that mounts them",
|
|
395
|
+
{ tables: skippedTables },
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const rebuilt: { projection: string; eventsProcessed: number }[] = [];
|
|
400
|
+
const failed: { projection: string; error: string }[] = [];
|
|
401
|
+
for (const [projection, rows] of rowsByProjection) {
|
|
402
|
+
if (signal?.aborted) break;
|
|
403
|
+
try {
|
|
404
|
+
const result = await rebuildProjectionOrMultiStream(projection, {
|
|
405
|
+
db,
|
|
406
|
+
registry,
|
|
407
|
+
...(signal && { signal }),
|
|
408
|
+
});
|
|
409
|
+
await clearPendingRebuilds(db, rows);
|
|
410
|
+
rebuilt.push({ projection, eventsProcessed: result.eventsProcessed });
|
|
411
|
+
} catch (e) {
|
|
412
|
+
failed.push({ projection, error: e instanceof Error ? e.message : String(e) });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (failed.length > 0) {
|
|
416
|
+
log.error(
|
|
417
|
+
"Projection rebuild after PII backfill failed; tables stay queued and are retried on the next boot",
|
|
418
|
+
{ failed },
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
return { rebuilt, failed, skippedTables };
|
|
422
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { requestContext } from "../api/request-context";
|
|
2
|
+
import { SYSTEM_EVENT_PREFIX } from "../crypto/system-event-pii";
|
|
2
3
|
import type { DbRunner } from "../db/connection";
|
|
3
4
|
import { qnScope, toKebab } from "../engine/qualified-name";
|
|
4
5
|
import type { AppendEventArgs, Registry, TenantId } from "../engine/types";
|
|
@@ -32,14 +33,6 @@ export type AppendDomainEventCoreDeps = {
|
|
|
32
33
|
readonly callerFeature?: string;
|
|
33
34
|
};
|
|
34
35
|
|
|
35
|
-
// System-event prefix: events under this namespace bypass the registry +
|
|
36
|
-
// ownership checks. Reserved for framework-internal coordination (step-
|
|
37
|
-
// engine deferred dispatch, lifecycle signals). The matching MSP filters
|
|
38
|
-
// on the literal type-string. Apps cannot write into "kumiko:system:*"
|
|
39
|
-
// directly — only framework step implementations call unsafeAppendEvent
|
|
40
|
-
// with these types.
|
|
41
|
-
export const SYSTEM_EVENT_PREFIX = "kumiko:system:";
|
|
42
|
-
|
|
43
36
|
export async function appendDomainEventCore(
|
|
44
37
|
deps: AppendDomainEventCoreDeps,
|
|
45
38
|
args: AppendEventArgs,
|
|
@@ -15,10 +15,11 @@ export function isIrreversibleEntityVerb(
|
|
|
15
15
|
|
|
16
16
|
// The gate reads the directly dispatched entry handler; a write dispatched inside a deferred
|
|
17
17
|
// context (afterCommit hook, job, consumer) gets its own entryHandler and is gated too. Direct
|
|
18
|
-
// executor/KMS calls without one stay ungated in exactly
|
|
18
|
+
// executor/KMS calls without one stay ungated in exactly three cases: deleting derived data
|
|
19
19
|
// rebuildable from a reversible source (document-ingest-foundation's forget-extract-with-file-ref
|
|
20
|
-
// consumer, re-created on fileRef.restored),
|
|
21
|
-
// cron scan (forget cleanup, retention, tenant destroy)
|
|
20
|
+
// consumer, re-created on fileRef.restored), pipelines started by a risk "high" handler or a
|
|
21
|
+
// cron scan (forget cleanup, retention, tenant destroy), and the step-dispatcher erasing the
|
|
22
|
+
// per-dispatch record key once the outcome is recorded. A deferred context can't return the
|
|
22
23
|
// reason to the agent synchronously, so the gate sits at the primitives.
|
|
23
24
|
function assertEntryHandlerHighRisk(args: {
|
|
24
25
|
reason: FrameworkReason;
|
|
@@ -101,6 +101,10 @@ export type MspRebuildDeps = {
|
|
|
101
101
|
readonly meter?: Meter;
|
|
102
102
|
// Test-hook — independent of `meter`, fires on success only.
|
|
103
103
|
readonly onMetrics?: (result: RebuildResult) => void;
|
|
104
|
+
// Default true. The rebuild TX rolls back completely (cursor reset included),
|
|
105
|
+
// so the live consumer is intact; an automatic background rebuild passes false
|
|
106
|
+
// to not park it as dead and logs the failure itself.
|
|
107
|
+
readonly markDeadOnFailure?: boolean;
|
|
104
108
|
};
|
|
105
109
|
|
|
106
110
|
function createRebuildCtx(
|
|
@@ -270,7 +274,9 @@ export async function rebuildMultiStreamProjection(
|
|
|
270
274
|
});
|
|
271
275
|
} catch (e) {
|
|
272
276
|
const message = e instanceof Error ? e.message : String(e);
|
|
273
|
-
|
|
277
|
+
if (deps.markDeadOnFailure !== false) {
|
|
278
|
+
await markConsumerRebuildFailed(db, mspName, SHARED_INSTANCE_SENTINEL, message);
|
|
279
|
+
}
|
|
274
280
|
if (deps.meter) {
|
|
275
281
|
emitProjectionRebuild(
|
|
276
282
|
deps.meter,
|
|
@@ -346,3 +346,68 @@ describe.skipIf(!MEILI_UP)("meilisearch adapter — lazy default config", () =>
|
|
|
346
346
|
expect(await index.getSearchableAttributes()).toEqual(["a"]);
|
|
347
347
|
});
|
|
348
348
|
});
|
|
349
|
+
|
|
350
|
+
describe.skipIf(!MEILI_UP)("meilisearch adapter — dropAllIndexes", () => {
|
|
351
|
+
const dropClient = new Meilisearch({ host: MEILI_URL, apiKey: MEILI_KEY });
|
|
352
|
+
const createdPrefixes: string[] = [];
|
|
353
|
+
|
|
354
|
+
function adapterWithFreshPrefix(): { adapter: SearchAdapter; prefix: string } {
|
|
355
|
+
const prefix = `test_drop_${uuid()}_`;
|
|
356
|
+
createdPrefixes.push(prefix);
|
|
357
|
+
const created = createMeilisearchAdapter({
|
|
358
|
+
url: MEILI_URL,
|
|
359
|
+
apiKey: MEILI_KEY,
|
|
360
|
+
indexPrefix: prefix,
|
|
361
|
+
});
|
|
362
|
+
created.setDefaultConfig?.({ searchableFields: ["firstName"] });
|
|
363
|
+
return { adapter: created, prefix };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function indexUids(prefix: string): Promise<string[]> {
|
|
367
|
+
const all = await dropClient.getIndexes({ limit: 1000 });
|
|
368
|
+
return all.results.map((i) => i.uid).filter((uid) => uid.startsWith(prefix));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
afterAll(async () => {
|
|
372
|
+
for (const prefix of createdPrefixes) {
|
|
373
|
+
for (const uid of await indexUids(prefix)) {
|
|
374
|
+
await dropClient.deleteIndex(uid).waitTask();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const doc = { entityType: "user", entityId: 1, weight: 1, fields: { firstName: "Dropme" } };
|
|
380
|
+
|
|
381
|
+
test("drops only this adapter's prefix and returns the count", async () => {
|
|
382
|
+
const { adapter: dropAdapter, prefix } = adapterWithFreshPrefix();
|
|
383
|
+
const { adapter: foreignAdapter, prefix: foreignPrefix } = adapterWithFreshPrefix();
|
|
384
|
+
await dropAdapter.index(uuid(), doc);
|
|
385
|
+
await dropAdapter.index(uuid(), doc);
|
|
386
|
+
await foreignAdapter.index(uuid(), doc);
|
|
387
|
+
|
|
388
|
+
expect(await dropAdapter.dropAllIndexes?.()).toBe(2);
|
|
389
|
+
|
|
390
|
+
expect(await indexUids(prefix)).toEqual([]);
|
|
391
|
+
expect(await indexUids(foreignPrefix)).toHaveLength(1);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("index and filtered search work again after a drop (configured state is reset)", async () => {
|
|
395
|
+
const { adapter: dropAdapter } = adapterWithFreshPrefix();
|
|
396
|
+
const tenant = uuid();
|
|
397
|
+
await dropAdapter.index(tenant, doc);
|
|
398
|
+
await dropAdapter.dropAllIndexes?.();
|
|
399
|
+
|
|
400
|
+
await dropAdapter.index(tenant, doc);
|
|
401
|
+
const results = await dropAdapter.search(tenant, "dropme", { filterType: "user" });
|
|
402
|
+
expect(results.map((r) => r.entityId)).toEqual([1]);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("refuses an empty prefix", async () => {
|
|
406
|
+
const noPrefix = createMeilisearchAdapter({
|
|
407
|
+
url: MEILI_URL,
|
|
408
|
+
apiKey: MEILI_KEY,
|
|
409
|
+
indexPrefix: "",
|
|
410
|
+
});
|
|
411
|
+
await expect(noPrefix.dropAllIndexes?.()).rejects.toThrow("empty prefix");
|
|
412
|
+
});
|
|
413
|
+
});
|
|
@@ -254,3 +254,18 @@ describe("limit", () => {
|
|
|
254
254
|
expect(results).toHaveLength(3);
|
|
255
255
|
});
|
|
256
256
|
});
|
|
257
|
+
|
|
258
|
+
describe("dropAllIndexes", () => {
|
|
259
|
+
test("empties every tenant and returns how many were dropped", async () => {
|
|
260
|
+
const otherTenant = "00000000-0000-4000-8000-000000000002";
|
|
261
|
+
const doc = { entityType: "user", entityId: 1, weight: 1, fields: { firstName: "Marc" } };
|
|
262
|
+
await adapter.index(TENANT, doc);
|
|
263
|
+
await adapter.index(otherTenant, doc);
|
|
264
|
+
expect(await adapter.search(TENANT, "marc")).toHaveLength(1);
|
|
265
|
+
|
|
266
|
+
expect(await adapter.dropAllIndexes?.()).toBe(2);
|
|
267
|
+
|
|
268
|
+
expect(await adapter.search(TENANT, "marc")).toEqual([]);
|
|
269
|
+
expect(await adapter.search(otherTenant, "marc")).toEqual([]);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -111,6 +111,12 @@ export function createInMemorySearchAdapter(): SearchAdapter {
|
|
|
111
111
|
}
|
|
112
112
|
},
|
|
113
113
|
|
|
114
|
+
async dropAllIndexes() {
|
|
115
|
+
const dropped = tenants.size;
|
|
116
|
+
tenants.clear();
|
|
117
|
+
return dropped;
|
|
118
|
+
},
|
|
119
|
+
|
|
114
120
|
async removeBatch(tenantId, items) {
|
|
115
121
|
const tenant = tenants.get(tenantId);
|
|
116
122
|
// skip: tenant has no in-memory index (never configured) — nothing to remove
|
|
@@ -29,6 +29,8 @@ async function awaitSucceededTask(
|
|
|
29
29
|
// would fail forever.
|
|
30
30
|
const REMOVE_TOLERATED_ERROR_CODES: readonly string[] = [ErrorStatusCode.INDEX_NOT_FOUND];
|
|
31
31
|
|
|
32
|
+
const INDEX_LIST_PAGE_SIZE = 100;
|
|
33
|
+
|
|
32
34
|
export type MeilisearchAdapterOptions = {
|
|
33
35
|
url: string;
|
|
34
36
|
apiKey: string;
|
|
@@ -143,6 +145,28 @@ export function createMeilisearchAdapter(options: MeilisearchAdapterOptions): Se
|
|
|
143
145
|
await awaitSucceededTask(index.addDocuments(payload, { primaryKey: "_id" }));
|
|
144
146
|
},
|
|
145
147
|
|
|
148
|
+
async dropAllIndexes() {
|
|
149
|
+
// An empty prefix matches every index, which would wipe the whole Meili instance.
|
|
150
|
+
if (prefix === "") {
|
|
151
|
+
throw new Error("refusing to drop Meilisearch indexes with an empty prefix");
|
|
152
|
+
}
|
|
153
|
+
const uids: string[] = [];
|
|
154
|
+
for (let offset = 0; ; offset += INDEX_LIST_PAGE_SIZE) {
|
|
155
|
+
const page = await client.getIndexes({ limit: INDEX_LIST_PAGE_SIZE, offset });
|
|
156
|
+
for (const index of page.results) {
|
|
157
|
+
if (index.uid.startsWith(prefix)) uids.push(index.uid);
|
|
158
|
+
}
|
|
159
|
+
if (page.results.length === 0 || offset + page.results.length >= page.total) break;
|
|
160
|
+
}
|
|
161
|
+
for (const uid of uids) {
|
|
162
|
+
await awaitSucceededTask(client.deleteIndex(uid), REMOVE_TOLERATED_ERROR_CODES);
|
|
163
|
+
}
|
|
164
|
+
// Otherwise ensureConfigured treats the dropped tenants as configured and the
|
|
165
|
+
// next index() recreates an unconfigured index ("not filterable" on filtered search).
|
|
166
|
+
configuredTenants.clear();
|
|
167
|
+
return uids.length;
|
|
168
|
+
},
|
|
169
|
+
|
|
146
170
|
async removeBatch(tenantId, items) {
|
|
147
171
|
// skip: empty batch — avoid an unnecessary Meilisearch round-trip
|
|
148
172
|
if (items.length === 0) return;
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -189,6 +189,14 @@ export type TestStackOptions = {
|
|
|
189
189
|
* query, same as prod. Only needed for suites that fan a job over
|
|
190
190
|
* tenants without mounting the tenant feature. */
|
|
191
191
|
getActiveTenantIds?: JobRunnerOptions["getActiveTenantIds"];
|
|
192
|
+
/** Run-log callbacks (job_runs / tenant job failures), built once registry
|
|
193
|
+
* and db exist. The framework can't import the bundled jobs feature, so the
|
|
194
|
+
* dev-server wrapper supplies prod's `jobRunLoggerCallbacks` here. They are
|
|
195
|
+
* awaited before drainJobs() observes the job's outcome. */
|
|
196
|
+
runLogger?: (deps: {
|
|
197
|
+
registry: Registry;
|
|
198
|
+
db: import("../db").DbConnection;
|
|
199
|
+
}) => Pick<JobRunnerOptions, "onJobStart" | "onJobComplete" | "onJobFailed"> | undefined;
|
|
192
200
|
};
|
|
193
201
|
/** Override the event dispatcher's polling-timer interval. Default 50ms.
|
|
194
202
|
* Tests that assert LISTEN/NOTIFY wake-up latency need this pushed far
|
|
@@ -378,6 +386,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
378
386
|
// existing, so the returned drainJobs() closure always has one to read.
|
|
379
387
|
const jobFailureTracker = createJobFailureTracker();
|
|
380
388
|
if (options.jobs && registry.getAllJobs().size > 0) {
|
|
389
|
+
const runLogger = options.jobs.runLogger?.({ registry, db: testDb.db });
|
|
381
390
|
jobRunner = createJobRunner({
|
|
382
391
|
registry,
|
|
383
392
|
context: { ...appContext, tracer: observability.tracer, meter: observability.meter },
|
|
@@ -390,8 +399,23 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
390
399
|
...(options.jobs.getActiveTenantIds !== undefined && {
|
|
391
400
|
getActiveTenantIds: options.jobs.getActiveTenantIds,
|
|
392
401
|
}),
|
|
393
|
-
|
|
394
|
-
|
|
402
|
+
...(runLogger?.onJobStart !== undefined && { onJobStart: runLogger.onJobStart }),
|
|
403
|
+
// Tracker fires in finally: drainJobs wakes on it, so the log row must
|
|
404
|
+
// be written first, and a throwing logger must not swallow the signal.
|
|
405
|
+
onJobComplete: async (jobName, jobId, ...rest) => {
|
|
406
|
+
try {
|
|
407
|
+
await runLogger?.onJobComplete?.(jobName, jobId, ...rest);
|
|
408
|
+
} finally {
|
|
409
|
+
jobFailureTracker.onJobComplete(jobName, jobId);
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
onJobFailed: async (jobName, jobId, error, ...rest) => {
|
|
413
|
+
try {
|
|
414
|
+
await runLogger?.onJobFailed?.(jobName, jobId, error, ...rest);
|
|
415
|
+
} finally {
|
|
416
|
+
jobFailureTracker.onJobFailed(jobName, jobId, error);
|
|
417
|
+
}
|
|
418
|
+
},
|
|
395
419
|
});
|
|
396
420
|
}
|
|
397
421
|
|