@cosmicdrift/kumiko-framework 0.231.0 → 0.232.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 +3 -3
- package/src/bun-db/query.ts +37 -36
- package/src/db/__tests__/blind-index.integration.test.ts +32 -0
- package/src/db/blind-index-cleanup.ts +6 -0
- package/src/db/entity-table-meta.ts +23 -23
- package/src/engine/__tests__/build-app-schema.test.ts +42 -0
- package/src/engine/__tests__/define-workflow.test.ts +23 -1
- package/src/engine/__tests__/steps-workflow.test.ts +15 -3
- package/src/engine/define-workflow.ts +48 -11
- package/src/engine/feature-ui-extensions.ts +6 -3
- package/src/engine/index.ts +1 -0
- package/src/engine/pipeline.ts +34 -7
- package/src/engine/steps/wait-for-event.ts +17 -2
- package/src/jobs/__tests__/jobs.integration.test.ts +40 -18
- package/src/search/__tests__/search-pii-derived-index.integration.test.ts +41 -1
- package/src/search/purge-subject.ts +5 -0
- package/src/testing/__tests__/wait-for.test.ts +41 -0
- package/src/testing/wait-for.ts +16 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.232.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.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.232.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.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.232.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
package/src/bun-db/query.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Typed
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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`
|
|
16
|
+
// `table` can be either:
|
|
17
17
|
// - EntityTableMeta (preferred, plain data)
|
|
18
|
-
// - drizzle pgTable (legacy,
|
|
19
|
-
// drizzle's getTableName + getTableColumns (drizzle
|
|
20
|
-
// reference,
|
|
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
|
|
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`
|
|
83
|
-
// 1. Bun.SQL connection (BunDbRunner) —
|
|
84
|
-
// 2. drizzle DbConnection (postgres-js
|
|
85
|
-
// raw postgres-js client
|
|
86
|
-
// 3. drizzle tx-handle — client
|
|
87
|
-
//
|
|
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
|
-
//
|
|
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
|
|
262
|
-
// EntityTable (managed projection).
|
|
263
|
-
//
|
|
264
|
-
// rebuild-safe). Unmanaged EntityTableMeta
|
|
265
|
-
// TableColumns<any>
|
|
266
|
-
//
|
|
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
|
|
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
|
|
370
|
-
// (guard-no-date-api). Date →
|
|
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-
|
|
441
|
-
//
|
|
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
|
|
509
|
-
// ::jsonb cast. JSON.stringify
|
|
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()`)
|
|
512
|
-
//
|
|
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-
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
//
|
|
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
|
|
42
|
-
// `buildBaseColumns()`
|
|
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
|
-
//
|
|
89
|
-
// Lock-step:
|
|
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-
|
|
109
|
-
// managedChangeRequiresRecreate
|
|
110
|
-
//
|
|
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
|
|
266
|
-
// pg-name as a base-column OVERRIDE the base-column (last-wins,
|
|
267
|
-
//
|
|
268
|
-
// builder). Use
|
|
269
|
-
// control
|
|
270
|
-
// filterable
|
|
271
|
-
//
|
|
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
|
|
327
|
-
//
|
|
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
|
-
//
|
|
333
|
-
// SqlExpression (`sql\`…\``
|
|
334
|
-
//
|
|
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
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
// key-
|
|
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
|
-
|
|
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
|
|
|
@@ -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<
|
|
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<
|
|
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<
|
|
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
|
|
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<
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/engine/index.ts
CHANGED
|
@@ -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,
|
package/src/engine/pipeline.ts
CHANGED
|
@@ -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 {
|
|
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<
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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({
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
941
|
-
|
|
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
|
-
|
|
959
|
-
|
|
960
|
-
const
|
|
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(
|
|
1133
|
-
|
|
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).
|
|
1141
|
-
const seenTenants = log
|
|
1142
|
-
|
|
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
|
});
|
package/src/testing/wait-for.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
45
|
+
if (lastError !== undefined) {
|
|
46
|
+
throw lastError;
|
|
47
|
+
}
|
|
48
|
+
throw new Error("waitFor: condition never became true within the delay schedule");
|
|
39
49
|
}
|