@cosmicdrift/kumiko-framework 0.230.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.230.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.230.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.230.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
  },
@@ -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 (
@@ -82,16 +82,6 @@ describe("KUMIKO_SUB_PROCESSORS", () => {
82
82
  "sccRequired": false,
83
83
  "status": "active",
84
84
  },
85
- {
86
- "appliesTo": [
87
- "all-tiers",
88
- ],
89
- "name": "Heinlein Hosting (Mailbox.org)",
90
- "optInOnly": false,
91
- "region": "EU (Germany)",
92
- "sccRequired": false,
93
- "status": "active",
94
- },
95
85
  {
96
86
  "appliesTo": [
97
87
  "business",
@@ -101,7 +91,7 @@ describe("KUMIKO_SUB_PROCESSORS", () => {
101
91
  "optInOnly": true,
102
92
  "region": "US",
103
93
  "sccRequired": true,
104
- "status": "planned",
94
+ "status": "active",
105
95
  },
106
96
  {
107
97
  "appliesTo": [
@@ -111,7 +101,17 @@ describe("KUMIKO_SUB_PROCESSORS", () => {
111
101
  "optInOnly": false,
112
102
  "region": "Global (US-headquartered)",
113
103
  "sccRequired": true,
114
- "status": "planned",
104
+ "status": "active",
105
+ },
106
+ {
107
+ "appliesTo": [
108
+ "all-tiers",
109
+ ],
110
+ "name": "ALL-INKL.COM – Neue Medien Münnich",
111
+ "optInOnly": false,
112
+ "region": "EU (Germany)",
113
+ "sccRequired": false,
114
+ "status": "active",
115
115
  },
116
116
  ]
117
117
  `);
@@ -101,15 +101,6 @@ export const KUMIKO_SUB_PROCESSORS: readonly SubProcessor[] = [
101
101
  appliesTo: ["standard", "business", "enterprise"],
102
102
  status: "active",
103
103
  },
104
- {
105
- name: "Heinlein Hosting (Mailbox.org)",
106
- purpose: "Marketing Email Delivery",
107
- region: "EU (Germany)",
108
- dpa: "https://mailbox.org/de/datenschutzerklaerung",
109
- addedAt: "2024-03-01",
110
- appliesTo: ["all-tiers"],
111
- status: "active",
112
- },
113
104
  {
114
105
  name: "Anthropic PBC",
115
106
  purpose: "AI Model Inference (L2 Composition Layer, AI-Foundation)",
@@ -120,7 +111,7 @@ export const KUMIKO_SUB_PROCESSORS: readonly SubProcessor[] = [
120
111
  sccRequired: true,
121
112
  optInOnly: true,
122
113
  hipaaBaaAvailable: true,
123
- status: "planned",
114
+ status: "active",
124
115
  },
125
116
  {
126
117
  name: "Stripe, Inc.",
@@ -130,7 +121,16 @@ export const KUMIKO_SUB_PROCESSORS: readonly SubProcessor[] = [
130
121
  addedAt: "2026-06-01",
131
122
  appliesTo: ["all-tiers"],
132
123
  sccRequired: true,
133
- status: "planned",
124
+ status: "active",
125
+ },
126
+ {
127
+ name: "ALL-INKL.COM – Neue Medien Münnich",
128
+ purpose: "Mailbox Hosting (support and contact mailboxes)",
129
+ region: "EU (Germany)",
130
+ dpa: "https://all-inkl.com/members/avv_muster_print.php",
131
+ addedAt: "2026-09-02",
132
+ appliesTo: ["all-tiers"],
133
+ status: "active",
134
134
  },
135
135
  ];
136
136
 
@@ -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