@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,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 two cases: deleting derived data
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), and pipelines started by a risk "high" handler or a
21
- // cron scan (forget cleanup, retention, tenant destroy). A deferred context can't return the
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
- await markConsumerRebuildFailed(db, mspName, SHARED_INSTANCE_SENTINEL, message);
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;
@@ -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
- onJobComplete: (jobName, jobId) => jobFailureTracker.onJobComplete(jobName, jobId),
394
- onJobFailed: (jobName, jobId, error) => jobFailureTracker.onJobFailed(jobName, jobId, error),
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
 
@@ -54,6 +54,7 @@ export type {
54
54
  ImagesFieldDef,
55
55
  NumberFieldDef,
56
56
  SelectFieldDef,
57
+ SelectOptionTone,
57
58
  TextFieldDef,
58
59
  } from "../engine/types/fields";
59
60
  export type {