@cosmicdrift/kumiko-framework 0.231.0 → 0.233.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.231.0",
3
+ "version": "0.233.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.231.0",
201
+ "@cosmicdrift/kumiko-types": "0.233.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.231.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.233.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -1,6 +1,6 @@
1
- // Typed Query-API über Bun.sql. KEIN drizzle intern. Identische Signatur
2
- // zur legacy `db/query-api.ts` — App-code-migration ist Import-Pfad-Wechsel,
3
- // kein call-site-Refactor.
1
+ // Typed query API over Bun.sql. No drizzle internally. Same signature as the
2
+ // legacy `db/query-api.ts` — app-code migration is just an import-path swap,
3
+ // not a call-site refactor.
4
4
  //
5
5
  // API:
6
6
  // selectMany<T>(db, table, where?, opts?) → readonly T[]
@@ -13,11 +13,11 @@
13
13
  // deleteManyBatched(db, table, where, { limit }) → { deleted, batches }
14
14
  // transaction<T>(db, fn) → T
15
15
  //
16
- // `table` kann sein:
16
+ // `table` can be either:
17
17
  // - EntityTableMeta (preferred, plain data)
18
- // - drizzle pgTable (legacy, hat Symbol-based tableName) — extracted via
19
- // drizzle's getTableName + getTableColumns (drizzle weiterhin als type-
20
- // reference, NICHT als runtime-API-call)
18
+ // - drizzle pgTable (legacy, has Symbol-based tableName) — extracted via
19
+ // drizzle's getTableName + getTableColumns (drizzle kept only as a type
20
+ // reference, never as a runtime API call)
21
21
 
22
22
  import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
23
23
  import type {
@@ -49,7 +49,7 @@ function snakeToCamel(key: string): string {
49
49
  import type { DbRunner } from "@cosmicdrift/kumiko-types/db-connection";
50
50
  import type { BunDbRunner } from "./connection";
51
51
 
52
- // Drizzle-pgTable-Inspection via raw Symbol-access (kein drizzle-orm import).
52
+ // Drizzle pgTable inspection via raw Symbol access (no drizzle-orm import).
53
53
  // table() (dialect) stores the canonical, shadow-proof EntityTableMeta under
54
54
  // `Symbol.for("kumiko:schema:Meta")`. The column handles on the table object are
55
55
  // enumerable props, so an entity field named `source`/`columns`/`tableName`/…
@@ -79,12 +79,12 @@ export function asEntityTableMeta(table: unknown): EntityTableMeta | undefined {
79
79
  return isEntityTableMeta(table) ? table : undefined;
80
80
  }
81
81
 
82
- // `db` Input akzeptiert drei Shapes:
83
- // 1. Bun.SQL connection (BunDbRunner) — neue Welt, native .unsafe + .begin
84
- // 2. drizzle DbConnection (postgres-js-Wrapper) — legacy compat,
85
- // raw postgres-js client liegt unter `db.$client.unsafe / .begin`
86
- // 3. drizzle tx-handle — client liegt unter `tx.session.client`
87
- // Shim extrahiert in allen Fällen ein `{unsafe, begin}`-Surface.
82
+ // `db` input accepts three shapes:
83
+ // 1. Bun.SQL connection (BunDbRunner) — new world, native .unsafe + .begin
84
+ // 2. drizzle DbConnection (postgres-js wrapper) — legacy compat,
85
+ // raw postgres-js client sits under `db.$client.unsafe / .begin`
86
+ // 3. drizzle tx-handle — client sits under `tx.session.client`
87
+ // The shim extracts a `{unsafe, begin}` surface in every case.
88
88
  type RawClient = {
89
89
  unsafe: <TRow = unknown>(sql: string, params?: readonly unknown[]) => Promise<readonly TRow[]>;
90
90
  begin: <T>(fn: (tx: unknown) => Promise<T>) => Promise<T>;
@@ -254,23 +254,24 @@ function isWhereOperator(v: unknown): v is WhereOperator {
254
254
  if (keys.length === 0) return false;
255
255
  return keys.every((k) => (WHERE_OPERATOR_KEYS as readonly string[]).includes(k));
256
256
  }
257
- // Akzeptiert EITHER. Beide haben einen tableName und field→column-mapping.
257
+ // Accepts EITHER. Both have a tableName and field→column mapping.
258
258
  // biome-ignore lint/suspicious/noExplicitAny: legacy drizzle pgTable surface
259
259
  type TableLike = EntityTableMeta | any;
260
260
 
261
- // Write-Param: alles was TableLike akzeptiert AUSSER einer gebrandeten
262
- // EntityTable (managed projection). Ein direkter Write auf eine Entity ist ein
263
- // Compile-Fehler — der einzige Schreibweg ist der Executor (Event ->
264
- // rebuild-safe). Unmanaged EntityTableMeta + die am Executor-Seam auf
265
- // TableColumns<any> eraste Table tragen den Brand nicht -> erlaubt. Test-Seeds
266
- // gehen über den sanktionierten testing-Seam (seedRow/…), der den Brand strippt.
261
+ // Write param: everything TableLike accepts EXCEPT a branded
262
+ // EntityTable (managed projection). A direct write to an Entity is a
263
+ // compile error — the only write path is the executor (event ->
264
+ // rebuild-safe). Unmanaged EntityTableMeta plus the table erased to
265
+ // TableColumns<any> at the executor seam don't carry the brand -> allowed.
266
+ // Test seeds go through the sanctioned testing seam (seedRow/…), which
267
+ // strips the brand.
267
268
  // Method-form writes (ctx.db.insertOne/…) keep an erased param and are covered
268
269
  // by the guard-direct-entity-writes AST guard instead (see tenant-db.ts).
269
270
  type WritableTable = EntityTableMeta & NotExecutorOnly;
270
271
 
271
272
  export type TableInfo = {
272
273
  readonly name: string;
273
- // field-name (camelCase oder snake_case) → snake_case column-name
274
+ // field name (camelCase or snake_case) → snake_case column name
274
275
  readonly columnOf: (field: string) => string;
275
276
  // pgType per column-name, for jsonb-cast detection
276
277
  readonly pgTypeOf: (column: string) => string | undefined;
@@ -366,8 +367,8 @@ function coerceBigIntFromDriver(value: bigint | string): bigint | number {
366
367
  function instantFromDriver(value: unknown): Temporal.Instant | null {
367
368
  if (value === null || value === undefined) return null;
368
369
  if (isTemporalInstant(value)) return value as Temporal.Instant;
369
- // Number-coercion via +date — equivalent to .getTime() ohne Date-API-Methode
370
- // (guard-no-date-api). Date → epochMilliseconds, dann zu Temporal.Instant.
370
+ // Number coercion via +date — equivalent to .getTime() without a Date-API
371
+ // method (guard-no-date-api). Date → epoch milliseconds, then to Temporal.Instant.
371
372
  if (value instanceof Date) return Temporal.Instant.fromEpochMilliseconds(+value);
372
373
  if (typeof value === "string") {
373
374
  try {
@@ -437,8 +438,8 @@ export function coerceRow<T extends Record<string, unknown>>(row: T, info: Table
437
438
  const out: Record<string, unknown> = {};
438
439
  let changed = false;
439
440
  for (const key of Object.keys(row)) {
440
- // Blind-Index-Spalten (#818) sind reine Lookup-Artefakte — der
441
- // deterministische HMAC darf nie an Caller/API-Responses leaken.
441
+ // Blind-index columns (#818) are pure lookup artifacts — the
442
+ // deterministic HMAC must never leak into caller/API responses.
442
443
  if ((key.endsWith("_bidx") || key.endsWith("Bidx")) && info.hasColumn(key)) {
443
444
  changed = true;
444
445
  continue;
@@ -505,11 +506,11 @@ function coerceRows<T extends Record<string, unknown>>(
505
506
  return rows.map((r) => coerceRow(r, info));
506
507
  }
507
508
 
508
- // Helper für jsonb-Werte: structured values binden als JS object/array mit
509
- // ::jsonb cast. JSON.stringify vor dem cast würde JSON-string-scalars erzeugen.
509
+ // Helper for jsonb values: structured values bind as a JS object/array with
510
+ // an ::jsonb cast. JSON.stringify before the cast would produce JSON-string scalars.
510
511
  // Plus Temporal.Instant → ISO string coercion for timestamptz columns.
511
- // SqlExpression (sql`now()`, sql`gen_random_uuid()`) wird als kind:"literal"
512
- // returned — Caller embedded das inline statt einen $N-placeholder zu setzen.
512
+ // SqlExpression (sql`now()`, sql`gen_random_uuid()`) is returned as
513
+ // kind:"literal" — the caller embeds it inline instead of setting a $N placeholder.
513
514
  type PreparedValue =
514
515
  | { readonly kind: "param"; readonly sql: string; readonly bound: unknown }
515
516
  | { readonly kind: "literal"; readonly literal: string };
@@ -693,12 +694,12 @@ function buildWhereClause(
693
694
  if (p.kind === "literal") {
694
695
  conditions.push(`${quoteIdent(col)} = ${p.literal}`);
695
696
  } else {
696
- // Blind-Index-OR-Rewrite (#818): existiert zur Spalte ein bidx-
697
- // Pendant (Suffix-Konvention `<col>_bidx`, framework-reserviert)
698
- // und ist ein Key konfiguriert, matcht Equality beide Arme —
699
- // Klartext-Alt-Rows über den Plaintext-Arm, verschlüsselte Rows
700
- // über den HMAC. Der `kumiko-pii:`-Prefix der Ciphertexte macht
701
- // einen Zufalls-Match des Plaintext-Arms praktisch unmöglich.
697
+ // Blind-index OR rewrite (#818): if the column has a bidx
698
+ // counterpart (suffix convention `<col>_bidx`, framework-reserved)
699
+ // and a key is configured, equality matches both arms —
700
+ // plaintext legacy rows via the plaintext arm, encrypted rows
701
+ // via the HMAC. The `kumiko-pii:` prefix on ciphertexts makes
702
+ // a random match on the plaintext arm practically impossible.
702
703
  const bidxKey = configuredBlindIndexKey();
703
704
  const bidxCol = `${col}_bidx`;
704
705
  if (
@@ -45,6 +45,18 @@ const personFeature = defineFeature("bidxtest", (r) => {
45
45
  });
46
46
  const personTable = buildEntityTable("person", personEntity);
47
47
 
48
+ // Lookupable entity whose read table is deliberately never created — used to
49
+ // prove the forget sweep skips missing relations (fw#2550).
50
+ const ghostEntity = createEntity({
51
+ table: "read_bidx_ghosts_missing",
52
+ fields: {
53
+ email: createTextField({ required: true, personal: "self", find: "exact" }),
54
+ },
55
+ });
56
+ const ghostFeature = defineFeature("bidxghost", (r) => {
57
+ r.entity("ghost", ghostEntity);
58
+ });
59
+
48
60
  let testDb: TestDb;
49
61
  let tdb: TenantDb;
50
62
  let kms: InMemoryKmsAdapter;
@@ -247,4 +259,24 @@ describe("blind-index rebuild + forget", () => {
247
259
  expect((await rawRow(String(created.data.id)))["email_bidx"]).toBeNull();
248
260
  expect(await fetchOne(tdb, personTable, { email: "marc@example.com" })).toBeUndefined();
249
261
  });
262
+
263
+ test("nullBlindIndexesForSubject skips missing projection tables (fw#2550)", async () => {
264
+ const created = await crud.create({ email: "marc@example.com" }, adminUser, tdb);
265
+ if (!created.isSuccess) throw new Error("create failed");
266
+ expect((await rawRow(String(created.data.id)))["email_bidx"]).not.toBeNull();
267
+
268
+ await kms.eraseKey({ kind: "user", userId: String(created.data.id) });
269
+ // Registry includes a lookupable ghost whose table was never migrated —
270
+ // pre-fix this UPDATE threw after eraseKey and left bidx linkable.
271
+ const registry = createRegistry([personFeature, ghostFeature]);
272
+ await expect(
273
+ nullBlindIndexesForSubject(
274
+ testDb.db,
275
+ registry.features,
276
+ subjectIdToKey({ kind: "user", userId: String(created.data.id) }),
277
+ ),
278
+ ).resolves.toBeUndefined();
279
+
280
+ expect((await rawRow(String(created.data.id)))["email_bidx"]).toBeNull();
281
+ });
250
282
  });
@@ -19,6 +19,7 @@ import { toSnakeCase } from "../utils/case";
19
19
  import type { DbRunner } from "./connection";
20
20
  import { resolveTableName } from "./entity-table-meta";
21
21
  import { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql";
22
+ import { tableExists } from "./schema-inspection";
22
23
 
23
24
  export async function nullBlindIndexesForSubject(
24
25
  db: DbRunner,
@@ -34,6 +35,11 @@ export async function nullBlindIndexesForSubject(
34
35
  // one (buildEntityTable with no featureName option), the sweep has to
35
36
  // hit the same names.
36
37
  const tableName = resolveTableName(entityName, entity, undefined);
38
+ // Skip entities whose projection was never migrated — same class as
39
+ // subjectRowExistsInTenant (fw#2348). A throw here runs AFTER eraseKey
40
+ // in forget-subject and would leave deterministic bidx columns still
41
+ // linkable (fw#2550).
42
+ if (!(await tableExists(db, tableName))) continue;
37
43
  for (const fieldName of lookupable) {
38
44
  const snake = toSnakeCase(fieldName);
39
45
  await executeRawQuery(
@@ -38,8 +38,8 @@ export type {
38
38
  UnmanagedTableInput,
39
39
  } from "./entity-table-meta-types";
40
40
 
41
- // Standard base-columns für event-sourced Read-Model-Tabellen. Spiegelt
42
- // `buildBaseColumns()` aus table-builder.ts (drizzle-Variante).
41
+ // Standard base columns for event-sourced read-model tables. Mirrors
42
+ // `buildBaseColumns()` from table-builder.ts (drizzle variant).
43
43
  function fullBaseColumns(idType: "uuid" | "serial", softDelete: boolean): readonly ColumnMeta[] {
44
44
  const idCol: ColumnMeta =
45
45
  idType === "uuid"
@@ -85,8 +85,8 @@ function fieldDefaultLiteral(field: FieldDefinition): string | undefined {
85
85
  return undefined;
86
86
  }
87
87
 
88
- // Spiegelt `fieldToColumns()` aus table-builder.ts (Drizzle-Variante).
89
- // Lock-step: jeder Field-Type produziert dieselben PG-Spalten wie heute.
88
+ // Mirrors `fieldToColumns()` from table-builder.ts (drizzle variant).
89
+ // Lock-step: every field type must produce the same PG columns as today.
90
90
  function fieldToColumnMeta(
91
91
  name: string,
92
92
  field: FieldDefinition,
@@ -105,9 +105,9 @@ function fieldToColumnMeta(
105
105
  ...(def !== undefined && { defaultSql: def }),
106
106
  },
107
107
  ];
108
- // lookupable → HMAC-Blind-Index-Pendant. Nullable ist Pflicht:
109
- // managedChangeRequiresRecreate würde eine NOT-NULL-Spalte ohne
110
- // Default auf Bestandstabellen als DROP+Rebuild diffen.
108
+ // lookupable → HMAC blind-index counterpart. Nullable is mandatory:
109
+ // managedChangeRequiresRecreate would diff a NOT-NULL column without
110
+ // a default on existing tables as DROP+rebuild.
111
111
  if (field.type === "text" && field.lookupable === true) {
112
112
  cols.push({ name: `${snake}_bidx`, pgType: "text", notNull: false });
113
113
  }
@@ -262,13 +262,13 @@ export function deriveEntityTableMeta(
262
262
  }
263
263
  const idType = entity.idType ?? "uuid";
264
264
 
265
- // Base-columns first, then user-fields. User-fields with the same
266
- // pg-name as a base-column OVERRIDE the base-column (last-wins, gleiches
267
- // Verhalten wie drizzle's `{ ...base, ...fields }` Spread im table-
268
- // builder). Use-case: user-session hat `tenantId` als field um access-
269
- // control aufzudrücken, fileRef hat `insertedAt` als field für sortable/
270
- // filterable-marker. Die DB-Spalte bleibt die gleiche, nur Application-
271
- // Metadata auf der Field-Seite ändert sich.
265
+ // Base columns first, then user-fields. User-fields with the same
266
+ // pg-name as a base-column OVERRIDE the base-column (last-wins, same
267
+ // behavior as drizzle's `{ ...base, ...fields }` spread in the table
268
+ // builder). Use case: user-session has `tenantId` as a field to enforce
269
+ // access control, fileRef has `insertedAt` as a field for sortable/
270
+ // filterable marking. The DB column stays the same, only the application
271
+ // metadata on the field side changes.
272
272
  const baseCols = fullBaseColumns(idType, entity.softDelete === true);
273
273
  const colByName = new Map<string, ColumnMeta>();
274
274
  for (const c of baseCols) colByName.set(c.name, c);
@@ -323,15 +323,15 @@ export function deriveEntityTableMeta(
323
323
  indexes.push({ name: `${tableName}_${snake}_idx`, columns: [snake] });
324
324
  }
325
325
 
326
- // lookupable-Felder: Index auf der bidx-Spalte — der OR-Rewrite der
327
- // Query-Compiler trifft sie bei jedem Equality-Lookup.
326
+ // lookupable fields: index on the bidx column — the query compiler's
327
+ // OR rewrite hits it on every equality lookup.
328
328
  for (const bidxSnake of bidxSnakeByFieldSnake.values()) {
329
329
  indexes.push({ name: `${tableName}_${bidxSnake}_idx`, columns: [bidxSnake] });
330
330
  }
331
331
 
332
- // Explizit deklarierte indexes (EntityIndexDef). `def.where` ist ein
333
- // SqlExpression (`sql\`…\`` aus @cosmicdrift/kumiko-framework/db) —
334
- // renderbar via `.text`. Unbekannte where-Shapes bleiben needsManualWhere.
332
+ // Explicitly declared indexes (EntityIndexDef). `def.where` is a
333
+ // SqlExpression (`sql\`…\`` from @cosmicdrift/kumiko-framework/db) —
334
+ // renderable via `.text`. Unknown where-shapes stay needsManualWhere.
335
335
  for (const def of (entity.indexes ?? []) as readonly EntityIndexDef[]) {
336
336
  const cols = def.columns.map(
337
337
  (fieldName) => fieldNameToSnake.get(fieldName) ?? toSnakeCase(fieldName),
@@ -346,10 +346,10 @@ export function deriveEntityTableMeta(
346
346
  ...(whereSql !== undefined && { whereSql }),
347
347
  ...(def.where !== undefined && whereSql === undefined && { needsManualWhere: true }),
348
348
  });
349
- // Unique-Index über lookupable-Spalten: partielles bidx-Pendant, damit
350
- // Uniqueness auch für verschlüsselte Rows greift. Das Original bleibt
351
- // für Klartext-Alt-Rows; partial (IS NOT NULL) weil bidx bei erased/
352
- // key-losen Rows NULL ist.
349
+ // Unique index over lookupable columns: partial bidx counterpart, so
350
+ // uniqueness also holds for encrypted rows. The original stays
351
+ // for plaintext legacy rows; partial (IS NOT NULL) because bidx is
352
+ // NULL for erased/key-less rows.
353
353
  if (def.unique === true && def.where === undefined) {
354
354
  const bidxCols = cols.map((c) => bidxSnakeByFieldSnake.get(c) ?? c);
355
355
  if (bidxCols.some((c, i) => c !== cols[i])) {
@@ -49,6 +49,48 @@ describe("buildAppSchema", () => {
49
49
  expect(fleet?.entities["vehicle"]).toBeDefined();
50
50
  });
51
51
 
52
+ // r.screen({ nav: {...} }) sugar must omit absent optional nav fields
53
+ // rather than writing them as explicit `undefined` — a plain key-per-
54
+ // field object literal makes `undefined` a real value in the schema,
55
+ // which findNonJsonSafePath flags as a boot-time warning even though
56
+ // JSON.stringify silently drops the key later. r.contentCollection()
57
+ // already gets this right via a conditional spread; this pins the same
58
+ // fix for r.screen().
59
+ test("r.screen({ nav }) sugar omits absent optional nav fields instead of writing them as undefined", () => {
60
+ const topLevelFeature = defineFeature("privacy", (r) => {
61
+ r.screen({
62
+ id: "privacy-center",
63
+ type: "custom",
64
+ renderer: { react: { __component: "PrivacyCenterScreen" } },
65
+ nav: { label: "Privacy" },
66
+ });
67
+ });
68
+ const nestedFeature = defineFeature("billing", (r) => {
69
+ r.screen({
70
+ id: "invoices",
71
+ type: "custom",
72
+ renderer: { react: { __component: "InvoicesScreen" } },
73
+ nav: { label: "Invoices", parent: "billing:nav:overview" },
74
+ });
75
+ });
76
+
77
+ const app = buildAppSchema(createRegistry([topLevelFeature, nestedFeature]));
78
+
79
+ const privacyFeature = app.features.find((f) => f.featureName === "privacy");
80
+ const topLevelNav = privacyFeature?.navs?.[0];
81
+ if (!topLevelNav) throw new Error("privacy feature's nav entry is missing");
82
+ expect("parent" in topLevelNav).toBe(false);
83
+ expect("icon" in topLevelNav).toBe(false);
84
+ expect("order" in topLevelNav).toBe(false);
85
+
86
+ const billingFeature = app.features.find((f) => f.featureName === "billing");
87
+ const nestedNav = billingFeature?.navs?.[0];
88
+ if (!nestedNav) throw new Error("billing feature's nav entry is missing");
89
+ expect(nestedNav.parent).toBe("billing:nav:overview");
90
+
91
+ expect(findNonJsonSafePath(app, "schema")).toBeNull();
92
+ });
93
+
52
94
  // #1059: r.translations({keys}) must flow into FeatureSchema.translations
53
95
  // BYTE-IDENTICAL — nav/screen labels resolve these keys verbatim via
54
96
  // t(label) client-side, so any re-prefixing (like the registry's internal
@@ -4,6 +4,7 @@ import {
4
4
  defineWorkflow,
5
5
  type WorkflowTrigger,
6
6
  } from "../define-workflow";
7
+ import { stepsPipeline } from "../pipeline";
7
8
  import type { PipelineDef } from "../types/step";
8
9
 
9
10
  const pipe = (build: PipelineDef["build"]): PipelineDef => ({ __kind: "pipeline", build });
@@ -17,7 +18,28 @@ describe("defineWorkflow", () => {
17
18
  expect(wf.__kind).toBe("workflow");
18
19
  expect(wf.name).toBe("onboard");
19
20
  expect(wf.trigger).toEqual(eventTrigger);
20
- expect(wf.pipelineDef).toBe(steps);
21
+ // pipelineDef is no longer `steps` by reference — defineWorkflow rebuilds
22
+ // it to carry the (possibly absent) branded awaits map — but the
23
+ // closure itself must be the exact same function.
24
+ expect(wf.pipelineDef.__kind).toBe("pipeline");
25
+ expect(wf.pipelineDef.build).toBe(steps.build);
26
+ expect(wf.awaits).toBeUndefined();
27
+ expect(wf.pipelineDef.awaits).toBeUndefined();
28
+ });
29
+
30
+ test("brands the declared awaits map onto both the definition and the pipelineDef", () => {
31
+ const wf = defineWorkflow({
32
+ name: "order-fulfillment",
33
+ trigger: eventTrigger,
34
+ awaits: { shipped: "order.shipped", cancelled: "order.cancelled" },
35
+ steps: stepsPipeline(() => []),
36
+ });
37
+
38
+ expect(wf.awaits).toEqual({ shipped: "order.shipped", cancelled: "order.cancelled" });
39
+ expect(wf.pipelineDef.awaits).toEqual({
40
+ shipped: "order.shipped",
41
+ cancelled: "order.cancelled",
42
+ });
21
43
  });
22
44
  });
23
45
 
@@ -0,0 +1,23 @@
1
+ // createNumberField({ unit }) passthrough — pins that NumberFieldDef.unit
2
+ // survives the factory's Partial<Omit<...>> spread, static string and
3
+ // sibling-field-reference form alike.
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { createNumberField } from "../factories";
7
+
8
+ describe("createNumberField — unit passthrough", () => {
9
+ test("static unit string is preserved", () => {
10
+ const f = createNumberField({ unit: "km" });
11
+ expect(f.unit).toBe("km");
12
+ });
13
+
14
+ test("sibling-field unit reference is preserved", () => {
15
+ const f = createNumberField({ unit: { field: "mileageUnit" } });
16
+ expect(f.unit).toEqual({ field: "mileageUnit" });
17
+ });
18
+
19
+ test("omitted unit stays undefined", () => {
20
+ const f = createNumberField();
21
+ expect(f.unit).toBeUndefined();
22
+ });
23
+ });
@@ -10,7 +10,12 @@ import {
10
10
  import { buildRetryStep, calculateBackoff } from "../steps/retry";
11
11
  import { buildWaitStep } from "../steps/wait";
12
12
  import { buildWaitForEventStep } from "../steps/wait-for-event";
13
- import type { EventMatch, PipelineCtx } from "../types/step";
13
+ import type { AwaitedEventType, EventMatch, PipelineCtx } from "../types/step";
14
+
15
+ // Test-only helper for the low-level step-builder unit tests below, which
16
+ // call buildWaitForEventStep directly instead of going through a
17
+ // defineWorkflow `awaits` declaration (the only legitimate branding route).
18
+ const asAwaited = (event: string): AwaitedEventType => event as AwaitedEventType;
14
19
 
15
20
  const mockUnsafeAppendEvent = mock();
16
21
 
@@ -91,7 +96,7 @@ describe("workflow.wait run", () => {
91
96
  describe("buildWaitForEventStep", () => {
92
97
  it("returns a StepInstance with kind workflow.waitForEvent", () => {
93
98
  const step = buildWaitForEventStep({
94
- event: "user.confirmed-email",
99
+ event: asAwaited("user.confirmed-email"),
95
100
  timeout: "P7D",
96
101
  });
97
102
  expect(step.kind).toBe("workflow.waitForEvent");
@@ -103,12 +108,19 @@ describe("buildWaitForEventStep", () => {
103
108
  expr: { kind: "atom", path: ["email"], op: { kind: "eq", value: "test@test.com" } },
104
109
  };
105
110
  const step = buildWaitForEventStep({
106
- event: "user.confirmed-email",
111
+ event: asAwaited("user.confirmed-email"),
107
112
  match,
108
113
  timeout: "P7D",
109
114
  });
110
115
  expect(step.kind).toBe("workflow.waitForEvent");
111
116
  });
117
+
118
+ it("@ts-expect-error: event must come from an awaits declaration, not a raw string", () => {
119
+ // @ts-expect-error — a raw string is not an AwaitedEventType; only
120
+ // defineWorkflow's `awaits` map can produce one (D4, workflow-resume-loop.md).
121
+ const step = buildWaitForEventStep({ event: "user.confirmed-email", timeout: "P7D" });
122
+ expect(step.kind).toBe("workflow.waitForEvent");
123
+ });
112
124
  });
113
125
 
114
126
  describe("workflow.waitForEvent run", () => {
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { createHash } from "node:crypto";
7
7
  import type { WriteEvent } from "./types/handlers";
8
- import type { PipelineDef } from "./types/step";
8
+ import type { AwaitedEventType, PipelineDef } from "./types/step";
9
9
 
10
10
  /**
11
11
  * Trigger configuration for a workflow. Determines what starts a run.
@@ -28,12 +28,27 @@ export type WorkflowTrigger =
28
28
  /**
29
29
  * Workflow definition — the result of defineWorkflow().
30
30
  */
31
- export type WorkflowDefinition<TPayload = unknown, TData = unknown> = {
31
+ export type WorkflowDefinition<
32
+ TPayload = unknown,
33
+ TData = unknown,
34
+ TAwaits extends Record<string, string> = Record<string, string>,
35
+ > = {
32
36
  readonly __kind: "workflow";
33
37
  readonly name: string;
34
38
  readonly trigger: WorkflowTrigger;
39
+ /**
40
+ * Declared expected events (D4, workflow-resume-loop.md) — the sole route
41
+ * a waitForEvent step can reach an event type. Read at registration time
42
+ * to build the static apply-map (Phase 3b); values are plain event-type
43
+ * strings, not the branded AwaitedEventType the pipeline closure sees.
44
+ */
45
+ readonly awaits?: TAwaits;
35
46
  /** The pipeline definition containing the step list closure. */
36
- readonly pipelineDef: PipelineDef<TPayload, TData>;
47
+ readonly pipelineDef: PipelineDef<
48
+ TPayload,
49
+ TData,
50
+ { readonly [K in keyof TAwaits]: AwaitedEventType }
51
+ >;
37
52
  /** Idempotency key for deduplication — prevents duplicate runs. */
38
53
  readonly idempotencyKey?: string | ((event: WriteEvent<TPayload>) => string);
39
54
  };
@@ -41,10 +56,15 @@ export type WorkflowDefinition<TPayload = unknown, TData = unknown> = {
41
56
  /**
42
57
  * Input shape for defineWorkflow() — the user-facing API.
43
58
  */
44
- export type WorkflowInput<TPayload = unknown, TData = unknown> = {
59
+ export type WorkflowInput<
60
+ TPayload = unknown,
61
+ TData = unknown,
62
+ TAwaits extends Record<string, string> = Record<string, string>,
63
+ > = {
45
64
  readonly name: string;
46
65
  readonly trigger: WorkflowTrigger;
47
- readonly steps: PipelineDef<TPayload, TData>;
66
+ readonly awaits?: TAwaits;
67
+ readonly steps: PipelineDef<TPayload, TData, { readonly [K in keyof TAwaits]: AwaitedEventType }>;
48
68
  readonly idempotencyKey?: string | ((event: WriteEvent<TPayload>) => string);
49
69
  readonly onError?: PipelineDef<unknown>;
50
70
  };
@@ -69,14 +89,22 @@ export type WorkflowInput<TPayload = unknown, TData = unknown> = {
69
89
  * });
70
90
  * ```
71
91
  */
72
- export function defineWorkflow<TPayload = unknown, TData = unknown>(
73
- input: WorkflowInput<TPayload, TData>,
74
- ): WorkflowDefinition<TPayload, TData> {
92
+ export function defineWorkflow<
93
+ TPayload = unknown,
94
+ TData = unknown,
95
+ const TAwaits extends Record<string, string> = Record<string, string>,
96
+ >(input: WorkflowInput<TPayload, TData, TAwaits>): WorkflowDefinition<TPayload, TData, TAwaits> {
97
+ // pipelineDef.awaits carries the same raw strings as the declaration
98
+ // below — buildPipelineSteps (pipeline.ts) is where they're branded into
99
+ // AwaitedEventType for the step closure, not here (see PipelineDef.awaits
100
+ // doc comment in types/step.ts for why the brand can't happen at this
101
+ // layer without breaking inference for an inline `stepsPipeline(...)`).
75
102
  return {
76
103
  __kind: "workflow",
77
104
  name: input.name,
78
105
  trigger: input.trigger,
79
- pipelineDef: input.steps,
106
+ awaits: input.awaits,
107
+ pipelineDef: { ...input.steps, awaits: input.awaits },
80
108
  idempotencyKey: input.idempotencyKey,
81
109
  };
82
110
  }
@@ -98,12 +126,21 @@ export function defineWorkflow<TPayload = unknown, TData = unknown>(
98
126
  * environments. Run-the-fingerprint-in-the-same-environment is the
99
127
  * contract; cross-env replay is out of scope.
100
128
  */
101
- export function computeDefinitionFingerprint(
102
- workflow: Pick<WorkflowDefinition, "name" | "trigger" | "pipelineDef">,
129
+ export function computeDefinitionFingerprint<
130
+ TPayload = unknown,
131
+ TData = unknown,
132
+ TAwaits extends Record<string, string> = Record<string, string>,
133
+ >(
134
+ workflow: Pick<WorkflowDefinition<TPayload, TData, TAwaits>, "name" | "trigger" | "pipelineDef">,
103
135
  ): string {
136
+ // `awaits` is part of the routing contract (D4, workflow-resume-loop.md):
137
+ // changing which events a run waits on changes the run's behavior just
138
+ // like changing the step source does, so a changed declaration must flip
139
+ // the fingerprint and trip the Q7 stale-definition check on resume.
104
140
  const material = JSON.stringify({
105
141
  name: workflow.name,
106
142
  trigger: workflow.trigger,
143
+ awaits: workflow.pipelineDef.awaits,
107
144
  source: workflow.pipelineDef.build.toString(),
108
145
  });
109
146
  return createHash("sha256").update(material).digest("hex");
@@ -360,12 +360,15 @@ export function buildUiExtensionsMethods<TName extends string>(
360
360
  // Sugar for the common "one nav entry pointing at this screen"
361
361
  // case — synthesizes id/screen from the screen's own id. Beyond
362
362
  // label/icon/parent/order, declare a standalone r.nav() instead.
363
+ // Optional fields stay absent rather than explicitly undefined —
364
+ // buildAppSchema's JSON-safety check flags undefined values. Same
365
+ // pattern as r.contentCollection() below.
363
366
  registerNav({
364
367
  id: definition.id,
365
368
  label: definition.nav.label,
366
- icon: definition.nav.icon,
367
- parent: definition.nav.parent,
368
- order: definition.nav.order,
369
+ ...(definition.nav.icon !== undefined && { icon: definition.nav.icon }),
370
+ ...(definition.nav.parent !== undefined && { parent: definition.nav.parent }),
371
+ ...(definition.nav.order !== undefined && { order: definition.nav.order }),
369
372
  screen: `${name}:screen:${definition.id}`,
370
373
  });
371
374
  }
@@ -470,6 +470,7 @@ export type {
470
470
  export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases } from "./types";
471
471
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers";
472
472
  export type {
473
+ AwaitedEventType,
473
474
  PipelineBuildCtx,
474
475
  PipelineCtx,
475
476
  PipelineDef,
@@ -31,7 +31,13 @@ import { buildWaitStep } from "./steps/wait";
31
31
  import { buildWaitForEventStep } from "./steps/wait-for-event";
32
32
  import { buildWebhookSendStep } from "./steps/webhook-send";
33
33
  import type { WriteEvent } from "./types/handlers";
34
- import type { PipelineBuildCtx, PipelineDef, StepBuilder, StepInstance } from "./types/step";
34
+ import type {
35
+ AwaitedEventType,
36
+ PipelineBuildCtx,
37
+ PipelineDef,
38
+ StepBuilder,
39
+ StepInstance,
40
+ } from "./types/step";
35
41
 
36
42
  const stepBuilder: StepBuilder = {
37
43
  step: {
@@ -64,9 +70,13 @@ const stepBuilder: StepBuilder = {
64
70
  },
65
71
  };
66
72
 
67
- export function stepsPipeline<TPayload = unknown, TData = unknown>(
68
- closure: (ctx: PipelineBuildCtx<TPayload>) => readonly StepInstance[],
69
- ): PipelineDef<TPayload, TData> {
73
+ export function stepsPipeline<
74
+ TPayload = unknown,
75
+ TData = unknown,
76
+ TAwaits extends Record<string, AwaitedEventType> = Record<string, AwaitedEventType>,
77
+ >(
78
+ closure: (ctx: PipelineBuildCtx<TPayload, TAwaits>) => readonly StepInstance[],
79
+ ): PipelineDef<TPayload, TData, TAwaits> {
70
80
  return {
71
81
  __kind: "pipeline",
72
82
  build: closure,
@@ -76,9 +86,26 @@ export function stepsPipeline<TPayload = unknown, TData = unknown>(
76
86
  // Internal: invoked by run-pipeline.ts to materialise the step list.
77
87
  // Not exported from the engine barrel — pipeline-internal plumbing.
78
88
  // @wrapper-known semantic-alias
79
- export function buildPipelineSteps<TPayload>(
80
- pipelineDef: PipelineDef<TPayload>,
89
+ //
90
+ // `awaits` comes off pipelineDef itself (set by defineWorkflow), not a
91
+ // function argument — every existing buildPipelineSteps caller (runner.ts,
92
+ // resume-run.write.ts, run-pipeline.ts, validate-projection-allowlist.ts)
93
+ // keeps working unchanged while workflow pipelines still get their real
94
+ // awaits map wired into the closure. The cast below is the one place a
95
+ // workflow's raw declared event-type strings become AwaitedEventType — safe
96
+ // because pipelineDef.awaits only ever holds what defineWorkflow's `awaits`
97
+ // carried in (D4, workflow-resume-loop.md), never an unchecked external
98
+ // value.
99
+ export function buildPipelineSteps<
100
+ TPayload,
101
+ TAwaits extends Record<string, AwaitedEventType> = Record<string, AwaitedEventType>,
102
+ >(
103
+ pipelineDef: PipelineDef<TPayload, unknown, TAwaits>,
81
104
  event: WriteEvent<TPayload>,
82
105
  ): readonly StepInstance[] {
83
- return pipelineDef.build({ event, r: stepBuilder });
106
+ return pipelineDef.build({
107
+ event,
108
+ r: stepBuilder,
109
+ awaits: (pipelineDef.awaits ?? {}) as TAwaits,
110
+ });
84
111
  }
@@ -12,9 +12,23 @@
12
12
  // EventMatch AST (not a closure) that is persisted into the waiting event's
13
13
  // payload; the Resume-Loop evaluates it via evaluateEventMatch against the
14
14
  // candidate event's payload.
15
+ //
16
+ // resultKey is `args.event` itself (the branded event-type string) rather
17
+ // than a new `name` argument — a workflow only ever has one `awaits` entry
18
+ // per event type, so `ctx.steps[awaits.someKey]` is already a unique,
19
+ // intention-revealing handle. resume-run.write.ts (framework#2513 Phase 3b)
20
+ // is the only producer of this key's value: the step itself always
21
+ // suspends (never returns a real result), so a subsequent step only ever
22
+ // sees the matched event's payload after a resume seeds it in.
15
23
 
16
24
  import { defineStep } from "../define-step";
17
- import type { EventMatch, PipelineCtx, StepInstance, StepResolver } from "../types/step";
25
+ import type {
26
+ AwaitedEventType,
27
+ EventMatch,
28
+ PipelineCtx,
29
+ StepInstance,
30
+ StepResolver,
31
+ } from "../types/step";
18
32
  import { addDuration } from "./_duration-utils";
19
33
  import { resolveOptional, resolveRequired } from "./_resolver-utils";
20
34
  import {
@@ -24,7 +38,7 @@ import {
24
38
  } from "./_step-dispatch-constants";
25
39
 
26
40
  type WaitForEventArgs = {
27
- readonly event: string;
41
+ readonly event: AwaitedEventType;
28
42
  readonly match?: StepResolver<EventMatch>;
29
43
  readonly timeout: StepResolver<string>;
30
44
  };
@@ -33,6 +47,7 @@ defineStep<WaitForEventArgs, undefined | typeof SUSPEND_SENTINEL>({
33
47
  kind: "workflow.waitForEvent",
34
48
  tier: 3,
35
49
  defaultFailureStrategy: "throw",
50
+ resultKey: (args) => args.event,
36
51
  run: async (args, ctx: PipelineCtx): Promise<undefined | typeof SUSPEND_SENTINEL> => {
37
52
  if (!ctx.workflow) {
38
53
  throw new Error(
@@ -925,6 +925,9 @@ describe("perTenant across multiple runner instances", () => {
925
925
  // A post-close 'error' here is otherwise unhandled and bun:test
926
926
  // attributes it to whichever test runs next (fw#1805).
927
927
  rawQueue.on("error", () => {});
928
+ // Snapshot of completed wrapper runs — declared outside try so the
929
+ // post-finally fan-out check can read it.
930
+ let drainedCalls = 0;
928
931
  try {
929
932
  // Both instances call start() → both call upsertJobScheduler() with
930
933
  // the SAME schedulerId against the SAME Redis. If that didn't dedup,
@@ -937,27 +940,38 @@ describe("perTenant across multiple runner instances", () => {
937
940
  // called upsertJobScheduler() against it. Two entries here means two
938
941
  // independent repeat timers, which would double-fire every tick.
939
942
  expect(await rawQueue.getJobSchedulersCount()).toBe(1);
940
- await waitFor(() => expect(wrapperCalls).toBeGreaterThanOrEqual(1), {
941
- delays: [2000, 3000, 5000],
942
- });
943
+ // Wait for the asserted drain state while workers are still up —
944
+ // stop() tears down consumers, so a post-finally sleep/assert races
945
+ // on unprocessed children (fw#2556: expected 4, got 2). Snapshot
946
+ // drainedCalls inside the predicate; do not assert a post-stop
947
+ // log-length ceiling — in-flight children (and sequential multi-
948
+ // runner stop) can still append after the snapshot.
949
+ await waitFor(
950
+ () => {
951
+ expect(wrapperCalls).toBeGreaterThanOrEqual(1);
952
+ expect(log.length).toBe(wrapperCalls * tenants.length);
953
+ drainedCalls = wrapperCalls;
954
+ },
955
+ { delays: [2000, 3000, 5000] },
956
+ );
943
957
  } finally {
944
- // Stop immediately after catching the first tick so no further ticks
945
- // can land mid-assertion — the invariant below still holds however
946
- // many ticks slipped in before stop() took effect.
947
958
  await runnerA.stop();
948
959
  await runnerB.stop();
949
960
  await rawQueue.close();
950
961
  }
951
- await sleep(300); // let any in-flight children land
952
962
 
953
963
  // Secondary sanity check only — this ratio holds whether the wrapper
954
964
  // fired once or twice per tick (wrapperCalls scales with it either
955
965
  // way), so it does not by itself prove the once-per-tick invariant.
956
966
  // It only confirms each wrapper run that did happen fanned out to
957
- // every tenant exactly once.
958
- expect(log.length).toBe(wrapperCalls * tenants.length);
959
- const seenTenants = log.map((e) => e.tenantId).sort();
960
- const expectedTenants = Array.from({ length: wrapperCalls }, () => tenants)
967
+ // every tenant exactly once. Slice to the drained prefix so a late
968
+ // tick that lands during stop() cannot break the multiset check.
969
+ expect(log.length).toBeGreaterThanOrEqual(drainedCalls * tenants.length);
970
+ const seenTenants = log
971
+ .slice(0, drainedCalls * tenants.length)
972
+ .map((e) => e.tenantId)
973
+ .sort();
974
+ const expectedTenants = Array.from({ length: drainedCalls }, () => tenants)
961
975
  .flat()
962
976
  .sort();
963
977
  expect(seenTenants).toEqual(expectedTenants);
@@ -1123,23 +1137,31 @@ describe("perTenant wrapper re-run for the same trigger", () => {
1123
1137
  queueNamePrefix,
1124
1138
  getActiveTenantIds,
1125
1139
  });
1140
+ let drainedCalls = 0;
1126
1141
  try {
1127
1142
  await runner.start();
1128
1143
  // Two distinct ticks get two distinct repeat-job ids (different
1129
1144
  // millis) — each must still produce its own full batch of children;
1130
1145
  // a wrongly cross-tick dedup would collapse the second tick's log
1131
1146
  // entries into the first.
1132
- await waitFor(() => expect(wrapperCalls).toBeGreaterThanOrEqual(2), {
1133
- delays: [1500, 2000, 3000],
1134
- });
1147
+ await waitFor(
1148
+ () => {
1149
+ expect(wrapperCalls).toBeGreaterThanOrEqual(2);
1150
+ expect(log.length).toBe(wrapperCalls * tenants.length);
1151
+ drainedCalls = wrapperCalls;
1152
+ },
1153
+ { delays: [1500, 2000, 3000] },
1154
+ );
1135
1155
  } finally {
1136
1156
  await runner.stop();
1137
1157
  }
1138
- await sleep(300); // let any in-flight children land
1139
1158
 
1140
- expect(log.length).toBe(wrapperCalls * tenants.length);
1141
- const seenTenants = log.map((e) => e.tenantId).sort();
1142
- const expectedTenants = Array.from({ length: wrapperCalls }, () => tenants)
1159
+ expect(log.length).toBeGreaterThanOrEqual(drainedCalls * tenants.length);
1160
+ const seenTenants = log
1161
+ .slice(0, drainedCalls * tenants.length)
1162
+ .map((e) => e.tenantId)
1163
+ .sort();
1164
+ const expectedTenants = Array.from({ length: drainedCalls }, () => tenants)
1143
1165
  .flat()
1144
1166
  .sort();
1145
1167
  expect(seenTenants).toEqual(expectedTenants);
@@ -10,7 +10,7 @@ import {
10
10
  subjectIdToKey,
11
11
  } from "../../crypto";
12
12
  import { asRawClient, buildEntityTable, createEventStoreExecutor, createTenantDb } from "../../db";
13
- import { createEntity, createTextField, defineFeature } from "../../engine";
13
+ import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
14
14
  import { createEventsTable } from "../../event-store";
15
15
  import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
16
16
  import { purgeSearchDocumentsForSubject } from "../purge-subject";
@@ -30,6 +30,16 @@ const contactFeature = defineFeature("search-pii-probe", (r) => {
30
30
  r.entity("contact", contactEntity);
31
31
  });
32
32
 
33
+ const ghostSearchEntity = createEntity({
34
+ table: "read_search_pii_ghosts_missing",
35
+ fields: {
36
+ label: createTextField({ required: true, maxLength: 100, personal: "self", find: "fuzzy" }),
37
+ },
38
+ });
39
+ const ghostSearchFeature = defineFeature("search-pii-ghost", (r) => {
40
+ r.entity("ghost", ghostSearchEntity);
41
+ });
42
+
33
43
  let stack: TestStack;
34
44
  let kms: InMemoryKmsAdapter;
35
45
  const admin = TestUsers.admin;
@@ -161,4 +171,34 @@ describe("searchable PII derived index (#1610)", () => {
161
171
  const after = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
162
172
  expect(after.some((h) => String(h.entityId) === id)).toBe(false);
163
173
  });
174
+
175
+ test("purgeSearchDocumentsForSubject skips missing projection tables (fw#2550)", async () => {
176
+ const plain = "MissingTableStillPurges1610";
177
+ const created = await executor().create({ label: plain, note: "n" }, admin, tenantDb());
178
+ if (!created.isSuccess) throw new Error("create failed");
179
+ const id = String(created.data.id);
180
+ await stack.eventDispatcher?.runOnce();
181
+ expect(
182
+ (await stack.search.search(admin.tenantId, plain, { filterType: "contact" })).some(
183
+ (h) => String(h.entityId) === id,
184
+ ),
185
+ ).toBe(true);
186
+
187
+ const subject = { kind: "user" as const, userId: id };
188
+ await kms.eraseKey(subject);
189
+ // Ghost searchable entity never got DDL — must not abort the purge.
190
+ const registry = createRegistry([contactFeature, ghostSearchFeature]);
191
+ await expect(
192
+ purgeSearchDocumentsForSubject(
193
+ stack.db,
194
+ registry.features,
195
+ stack.search,
196
+ subjectIdToKey(subject),
197
+ subject,
198
+ ),
199
+ ).resolves.toBeUndefined();
200
+
201
+ const after = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
202
+ expect(after.some((h) => String(h.entityId) === id)).toBe(false);
203
+ });
164
204
  });
@@ -15,6 +15,7 @@ import { collectSearchableSubjectFields } from "../crypto/subject-resolver";
15
15
  import type { DbRunner } from "../db/connection";
16
16
  import { resolveTableName } from "../db/entity-table-meta";
17
17
  import { executeRawQueryRead } from "../db/queries/raw-sql";
18
+ import { tableExists } from "../db/schema-inspection";
18
19
  import type { FeatureDefinition } from "../engine/types";
19
20
  import type { EntityDefinition } from "../engine/types/fields";
20
21
  import type { EntityId, TenantId } from "../engine/types/identifiers";
@@ -139,6 +140,10 @@ export async function purgeSearchDocumentsForSubject(
139
140
  const fields = collectSearchableSubjectFields(entity);
140
141
  if (fields.length === 0) continue;
141
142
  const tableName = resolveTableName(entityName, entity, undefined);
143
+ // Same post-eraseKey hazard as nullBlindIndexesForSubject (fw#2550):
144
+ // a mounted feature without its migration must not abort the purge
145
+ // (and the audit event after it).
146
+ if (!(await tableExists(db, tableName))) continue;
142
147
  const predicate = buildSubjectPredicate(entity, fields, likePattern, subject);
143
148
  const rows = await collectMatchingRowsForEntity(
144
149
  db,
@@ -60,4 +60,45 @@ describe("waitFor", () => {
60
60
  );
61
61
  expect(calls).toBe(2);
62
62
  });
63
+
64
+ test("polls a boolean predicate until it returns true", async () => {
65
+ let calls = 0;
66
+ await waitFor(
67
+ () => {
68
+ calls++;
69
+ return calls >= 3;
70
+ },
71
+ { delays: [1, 1, 1] },
72
+ );
73
+ expect(calls).toBe(3);
74
+ });
75
+
76
+ test("throws a descriptive error when a predicate stays false for the whole schedule", async () => {
77
+ let calls = 0;
78
+ await expect(
79
+ waitFor(
80
+ () => {
81
+ calls++;
82
+ return false;
83
+ },
84
+ { delays: [1, 1] },
85
+ ),
86
+ ).rejects.toThrow("waitFor: condition never became true within the delay schedule");
87
+ expect(calls).toBe(3);
88
+ });
89
+
90
+ test("returns immediately for a block-style fn that returns undefined without throwing", async () => {
91
+ let calls = 0;
92
+ const events = [1];
93
+ const started = Date.now();
94
+ await waitFor(
95
+ () => {
96
+ calls++;
97
+ expect(events).toHaveLength(1);
98
+ },
99
+ { delays: [2000] },
100
+ );
101
+ expect(calls).toBe(1);
102
+ expect(Date.now() - started).toBeLessThan(500);
103
+ });
63
104
  });
@@ -6,13 +6,18 @@
6
6
  * next try — so N delays yield N+1 attempts and the full backoff budget.
7
7
  * Throws the last assertion error if all attempts fail.
8
8
  *
9
- * Usage:
9
+ * Usage (assertion form):
10
10
  * await waitFor(() => {
11
11
  * expect(events).toHaveLength(1);
12
12
  * });
13
+ *
14
+ * Usage (predicate form — a literal `false` return means "not yet", anything
15
+ * else, including `undefined`, counts as success):
16
+ * await waitFor(() => events.length >= 4);
13
17
  */
14
18
  export async function waitFor(
15
- fn: () => void | Promise<void>,
19
+ // biome-ignore lint/suspicious/noConfusingVoidType: void must stay in the union so the existing expect(...) block form (implicit void return) still type-checks
20
+ fn: () => void | boolean | Promise<void | boolean>,
16
21
  options?: { delays?: number[] },
17
22
  ): Promise<void> {
18
23
  const delays = options?.delays ?? [250, 1000, 3000];
@@ -23,9 +28,11 @@ export async function waitFor(
23
28
 
24
29
  for (let i = 0; ; i++) {
25
30
  try {
26
- await fn();
27
- // skip: condition already true — no further polling
28
- return;
31
+ const result = await fn();
32
+ if (result !== false) {
33
+ // skip: condition already true — no further polling
34
+ return;
35
+ }
29
36
  } catch (err) {
30
37
  lastError = err;
31
38
  }
@@ -35,5 +42,8 @@ export async function waitFor(
35
42
  await new Promise((r) => setTimeout(r, delays[i]));
36
43
  }
37
44
 
38
- throw lastError;
45
+ if (lastError !== undefined) {
46
+ throw lastError;
47
+ }
48
+ throw new Error("waitFor: condition never became true within the delay schedule");
39
49
  }