@cosmicdrift/kumiko-framework 0.156.2 → 0.157.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/{raw-table.integration.test.ts → store-table.integration.test.ts} +10 -10
  3. package/src/api/__tests__/batch.integration.test.ts +68 -0
  4. package/src/db/__tests__/collect-table-metas.test.ts +6 -6
  5. package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
  6. package/src/db/__tests__/feature-table-sources.test.ts +7 -7
  7. package/src/db/collect-table-metas.ts +4 -4
  8. package/src/db/entity-table-meta.ts +5 -4
  9. package/src/db/feature-table-sources.ts +3 -3
  10. package/src/db/queries/shadow-swap.ts +1 -1
  11. package/src/engine/__tests__/boot-validator.test.ts +96 -0
  12. package/src/engine/__tests__/{raw-table.test.ts → store-table.test.ts} +59 -42
  13. package/src/engine/boot-validator/action-wiring.ts +67 -17
  14. package/src/engine/define-feature.ts +1 -1
  15. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
  16. package/src/engine/feature-ast/extractors/index.ts +1 -1
  17. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  18. package/src/engine/feature-ast/parse.ts +3 -3
  19. package/src/engine/feature-builder-state.ts +3 -3
  20. package/src/engine/feature-ui-extensions.ts +54 -33
  21. package/src/engine/registry-facade.ts +3 -3
  22. package/src/engine/registry-ingest.ts +6 -6
  23. package/src/engine/registry-state.ts +3 -3
  24. package/src/engine/types/feature.ts +17 -17
  25. package/src/engine/types/index.ts +3 -3
  26. package/src/files/__tests__/file-ref-entity.test.ts +8 -0
  27. package/src/files/file-ref-entity.ts +2 -2
  28. package/src/migrations/pending-rebuilds.ts +1 -1
  29. package/src/search/__tests__/reindex-entity.integration.test.ts +121 -0
  30. package/src/search/index.ts +6 -0
  31. package/src/search/reindex-entity.ts +148 -0
  32. package/src/stack/push-entity-projection-tables.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.156.2",
3
+ "version": "0.157.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>",
@@ -193,7 +193,7 @@
193
193
  "zod": "^4.4.3"
194
194
  },
195
195
  "devDependencies": {
196
- "@cosmicdrift/kumiko-dispatcher-live": "0.156.2",
196
+ "@cosmicdrift/kumiko-dispatcher-live": "0.157.0",
197
197
  "bun-types": "^1.3.13",
198
198
  "pino-pretty": "^13.1.3"
199
199
  },
@@ -1,5 +1,5 @@
1
- // Integration test for r.rawTable() — proves the full boot path:
2
- // defineFeature declares a raw table → setupTestStack auto-pushes it →
1
+ // Integration test for r.storeTable() — proves the full boot path:
2
+ // defineFeature declares a store table → setupTestStack auto-pushes it →
3
3
  // INSERT/SELECT against the real DB roundtrip. Plan reference:
4
4
  // kumiko-platform/docs/plans/architecture/table-ddl-guard.md (Stufe 3).
5
5
 
@@ -9,7 +9,7 @@ import { asRawClient, insertOne, selectMany } from "../db/query";
9
9
  import { defineFeature } from "../engine";
10
10
  import { setupTestStack, type TestStack, unsafePushTables } from "../stack";
11
11
 
12
- // External-system payload cache — the textbook r.rawTable() use case:
12
+ // External-system payload cache — the textbook r.storeTable() use case:
13
13
  // write-only by an integration handler, read-only by a query, never
14
14
  // event-sourced (the data isn't a domain fact, it's a side-effect
15
15
  // snapshot).
@@ -23,7 +23,7 @@ const stripeWebhookCacheMeta = defineUnmanagedTable({
23
23
  });
24
24
 
25
25
  const webhookCacheFeature = defineFeature("webhook-cache", (r) => {
26
- r.rawTable(stripeWebhookCacheMeta, {
26
+ r.storeTable(stripeWebhookCacheMeta, {
27
27
  reason: "external Stripe webhook payload cache — write-only by webhook handler",
28
28
  });
29
29
  });
@@ -38,7 +38,7 @@ afterAll(async () => {
38
38
  await stack.cleanup();
39
39
  });
40
40
 
41
- describe("r.rawTable — DB roundtrip via setupTestStack", () => {
41
+ describe("r.storeTable — DB roundtrip via setupTestStack", () => {
42
42
  test("table is auto-pushed and accepts INSERT + SELECT", async () => {
43
43
  const eventId = "evt_test_123";
44
44
  const payload = JSON.stringify({ type: "invoice.paid", amount: 4200 });
@@ -52,8 +52,8 @@ describe("r.rawTable — DB roundtrip via setupTestStack", () => {
52
52
  expect(rows[0]?.receivedAt).toBeInstanceOf(Temporal.Instant);
53
53
  });
54
54
 
55
- test("registry exposes the raw table with its reason and featureName", () => {
56
- const all = stack.registry.getAllRawTables();
55
+ test("registry exposes the store table with its reason and featureName", () => {
56
+ const all = stack.registry.getAllStoreTables();
57
57
  const entry = all.get("rt_int_stripe_webhook_cache");
58
58
  expect(entry).toBeDefined();
59
59
  expect(entry?.featureName).toBe("webhook-cache");
@@ -62,9 +62,9 @@ describe("r.rawTable — DB roundtrip via setupTestStack", () => {
62
62
  });
63
63
 
64
64
  test("INSERT bypasses the event store — no kumiko_events row produced", async () => {
65
- // Proves that the rawTable lives outside the event-sourcing graph:
65
+ // Proves that the storeTable lives outside the event-sourcing graph:
66
66
  // a write to it shouldn't append anything to kumiko_events. If a
67
- // future regression accidentally routed raw-table writes through
67
+ // future regression accidentally routed store-table writes through
68
68
  // the executor, a row would show up here.
69
69
  const before = await asRawClient(stack.db).unsafe<{ count: string }>(
70
70
  `SELECT COUNT(*)::text AS count FROM kumiko_events`,
@@ -84,7 +84,7 @@ describe("r.rawTable — DB roundtrip via setupTestStack", () => {
84
84
  expect(afterCount).toBe(beforeCount);
85
85
  });
86
86
 
87
- test("a second push on the same rawTable is idempotent — CREATE IF NOT EXISTS", async () => {
87
+ test("a second push on the same storeTable is idempotent — CREATE IF NOT EXISTS", async () => {
88
88
  // unsafePushTables uses CREATE TABLE IF NOT EXISTS — idempotent by design.
89
89
  await expect(
90
90
  unsafePushTables(stack.db, { idem: stripeWebhookCacheMeta }),
@@ -15,6 +15,7 @@ import {
15
15
  type SaveContext,
16
16
  } from "../../engine";
17
17
  import { UnprocessableError, writeFailure } from "../../errors";
18
+ import { RedisKeys } from "../../pipeline/redis-keys";
18
19
  import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
19
20
 
20
21
  // Entity: a simple "item" with name + counter
@@ -370,6 +371,73 @@ describe("POST /api/batch", () => {
370
371
  expect(inTxHookLog).toHaveLength(1);
371
372
  expect(afterCommitHookLog).toHaveLength(1);
372
373
  });
374
+
375
+ test("idempotency: failed batch is cached — retry returns same error without re-exec", async () => {
376
+ const requestId = "batch-rid-fail-cache";
377
+ const commands = [
378
+ { type: "batch:write:item:create", payload: { name: "should-rollback" } },
379
+ { type: "batch:write:item:fail", payload: { name: "stop" } },
380
+ ];
381
+
382
+ const first = await stack.http.batch(commands, admin, requestId);
383
+ const firstBody = await first.json();
384
+ expect(first.status).toBe(422);
385
+ expect(firstBody.isSuccess).toBe(false);
386
+ expect(firstBody.failedIndex).toBe(1);
387
+ expect(inTxHookLog.map((h) => h.name)).toEqual(["should-rollback"]);
388
+ expect(await selectMany(stack.db, itemTable)).toHaveLength(0);
389
+
390
+ inTxHookLog.length = 0;
391
+ afterCommitHookLog.length = 0;
392
+
393
+ const second = await stack.http.batch(commands, admin, requestId);
394
+ const secondBody = await second.json();
395
+ expect(second.status).toBe(422);
396
+ expect(secondBody.isSuccess).toBe(false);
397
+ expect(secondBody.failedIndex).toBe(firstBody.failedIndex);
398
+ // HTTP re-serializes the cached failure with a fresh requestId/timestamp —
399
+ // pin the stable business fields, not the transport envelope.
400
+ expect(secondBody.error.code).toBe(firstBody.error.code);
401
+ expect(secondBody.error.details).toEqual(firstBody.error.details);
402
+ // Cached path must not re-run commands (hooks stay empty, DB empty)
403
+ expect(inTxHookLog).toHaveLength(0);
404
+ expect(afterCommitHookLog).toHaveLength(0);
405
+ expect(await selectMany(stack.db, itemTable)).toHaveLength(0);
406
+ });
407
+
408
+ test("idempotency: corrupted cache entry is treated as miss and re-runs", async () => {
409
+ const requestId = "batch-rid-corrupt";
410
+ await stack.redis.redis.set(`${RedisKeys.idempotency}${requestId}`, "{not-json", "EX", 60);
411
+
412
+ const res = await stack.http.batch(
413
+ [{ type: "batch:write:item:create", payload: { name: "after-corrupt" } }],
414
+ admin,
415
+ requestId,
416
+ );
417
+ const body = await res.json();
418
+ expect(res.status).toBe(200);
419
+ expect(body.isSuccess).toBe(true);
420
+ expect(await selectMany(stack.db, itemTable)).toHaveLength(1);
421
+ expect(inTxHookLog.map((h) => h.name)).toEqual(["after-corrupt"]);
422
+ });
423
+
424
+ test("mid-batch handler throw: rolls back prior writes, afterCommit does not fire", async () => {
425
+ const res = await stack.http.batch(
426
+ [
427
+ { type: "batch:write:item:create", payload: { name: "before-throw" } },
428
+ { type: "batch:write:item:throw", payload: { name: "boom" } },
429
+ { type: "batch:write:item:create", payload: { name: "never" } },
430
+ ],
431
+ admin,
432
+ );
433
+ const body = await res.json();
434
+ expect(body.isSuccess).toBe(false);
435
+ expect(body.failedIndex).toBe(1);
436
+ expect(inTxHookLog.map((h) => h.name)).toEqual(["before-throw"]);
437
+ expect(afterCommitHookLog).toEqual([]);
438
+ expect(await selectMany(stack.db, itemTable)).toHaveLength(0);
439
+ expect(await selectMany(stack.db, auditTable)).toHaveLength(0);
440
+ });
373
441
  });
374
442
 
375
443
  describe("POST /api/write (single write runs in its own transaction)", () => {
@@ -20,7 +20,7 @@ const counterTable = table("read_unit_counters", {
20
20
  }) as unknown as SchemaTable;
21
21
 
22
22
  describe("collectTableMetas (#255)", () => {
23
- test("collects entity, projection, MSP and rawTable tables — same sources as the test-stack auto-push", () => {
23
+ test("collects entity, projection, MSP and storeTable tables — same sources as the test-stack auto-push", () => {
24
24
  const feature = defineFeature("test", (r) => {
25
25
  r.entity("unit", exampleEntity());
26
26
  r.projection({
@@ -39,16 +39,16 @@ describe("collectTableMetas (#255)", () => {
39
39
  });
40
40
  // side-effect-only MSP — no table, must be skipped without throwing
41
41
  r.multiStreamProjection({ name: "unit-notify", apply: { "unit.created": async () => {} } });
42
- r.rawTable(
42
+ r.storeTable(
43
43
  defineUnmanagedTable({
44
44
  tableName: "unit_cache",
45
45
  columns: [{ name: "id", pgType: "uuid", notNull: true, primaryKey: true }],
46
46
  }),
47
47
  { reason: "test fixture" },
48
48
  );
49
- r.rawTable(
49
+ r.storeTable(
50
50
  defineUnmanagedTable({
51
- tableName: "read_unit_log",
51
+ tableName: "store_unit_log",
52
52
  columns: [{ name: "id", pgType: "serial", notNull: true, primaryKey: true }],
53
53
  }),
54
54
  { reason: "test fixture" },
@@ -59,8 +59,8 @@ describe("collectTableMetas (#255)", () => {
59
59
  expect(names).toContain("read_units"); // entity
60
60
  expect(names).toContain("read_unit_counters"); // r.projection
61
61
  expect(names).toContain("read_unit_audit"); // r.multiStreamProjection
62
- expect(names).toContain("unit_cache"); // r.rawTable
63
- expect(names).toContain("read_unit_log"); // r.rawTable (second registration)
62
+ expect(names).toContain("unit_cache"); // r.storeTable
63
+ expect(names).toContain("store_unit_log"); // r.storeTable (second registration)
64
64
  expect(names).toHaveLength(5);
65
65
  });
66
66
 
@@ -0,0 +1,50 @@
1
+ // #1210: buildEntityTableMeta() hardcoded source: "managed", so unmanaged
2
+ // direct-write stores (store_user_sessions, store_api_tokens, mail sync/seen
3
+ // cursors) were misclassified as rebuildable event-sourced projections — a
4
+ // destructive column change would DROP+rebuild-from-events tables that have
5
+ // no events, wiping live data. The options.source escape hatch must produce
6
+ // a byte-identical column/piiSubjectFields shape (only source differs), so
7
+ // the migration diff for an existing table stays empty when flipping it.
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { createEntity, createTextField } from "../../engine";
11
+ import { buildEntityTableMeta } from "../entity-table-meta";
12
+ import { diffSnapshots, snapshotFromMetas } from "../migrate-generator";
13
+
14
+ const entity = createEntity({
15
+ table: "source-probe",
16
+ fields: {
17
+ userId: createTextField({ required: true }),
18
+ ip: createTextField({ userOwned: { ownerField: "userId" } }),
19
+ },
20
+ });
21
+
22
+ describe("buildEntityTableMeta — options.source (#1210)", () => {
23
+ test("defaults to managed when omitted", () => {
24
+ expect(buildEntityTableMeta("source-probe", entity).source).toBe("managed");
25
+ });
26
+
27
+ test("options.source: 'unmanaged' changes only source, columns + piiSubjectFields stay identical", () => {
28
+ const managed = buildEntityTableMeta("source-probe", entity);
29
+ const unmanaged = buildEntityTableMeta("source-probe", entity, { source: "unmanaged" });
30
+
31
+ expect(managed.source).toBe("managed");
32
+ expect(unmanaged.source).toBe("unmanaged");
33
+ expect(unmanaged.columns).toEqual(managed.columns);
34
+ expect(unmanaged.indexes).toEqual(managed.indexes);
35
+ expect(unmanaged.piiSubjectFields).toEqual(managed.piiSubjectFields);
36
+ expect(unmanaged.piiSubjectFields).toEqual(["ip"]);
37
+ });
38
+
39
+ test("flipping an existing table's meta to unmanaged produces an empty migration diff", () => {
40
+ const prevSnapshot = snapshotFromMetas([buildEntityTableMeta("source-probe", entity)]);
41
+ const nextSnapshot = snapshotFromMetas([
42
+ buildEntityTableMeta("source-probe", entity, { source: "unmanaged" }),
43
+ ]);
44
+
45
+ const diff = diffSnapshots(prevSnapshot, nextSnapshot);
46
+ expect(diff.newTables).toEqual([]);
47
+ expect(diff.droppedTables).toEqual([]);
48
+ expect(diff.changedTables).toEqual([]);
49
+ });
50
+ });
@@ -1,10 +1,10 @@
1
- // Regression test for #255-class drift: rawTables must appear in
1
+ // Regression test for #255-class drift: storeTables must appear in
2
2
  // enumerateFeatureTableSources so setupTestStack's auto-push and
3
3
  // collectTableMetas see the exact same table set. Before this fix,
4
4
  // unmanaged tables were only handled by collectTableMetas directly, so any
5
5
  // app relying on setupTestStack's ephemeral DB (Playwright/e2e) never got
6
- // its raw tables created — e.g. the bundled "sessions" feature's
7
- // read_user_sessions, breaking every login in an ephemeral test stack.
6
+ // its store tables created — e.g. the bundled "sessions" feature's
7
+ // store_user_sessions, breaking every login in an ephemeral test stack.
8
8
 
9
9
  import { describe, expect, test } from "bun:test";
10
10
  import { defineFeature } from "../../engine/define-feature";
@@ -16,10 +16,10 @@ const probeMeta = defineUnmanagedTable({
16
16
  columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
17
17
  });
18
18
 
19
- describe("enumerateFeatureTableSources — rawTables", () => {
20
- test("includes a feature's rawTable as a table source", () => {
19
+ describe("enumerateFeatureTableSources — storeTables", () => {
20
+ test("includes a feature's storeTable as a table source", () => {
21
21
  const feature = defineFeature("probe", (r) => {
22
- r.rawTable(probeMeta, { reason: "direct-write store" });
22
+ r.storeTable(probeMeta, { reason: "direct-write store" });
23
23
  });
24
24
  const sources = enumerateFeatureTableSources(feature);
25
25
  const entry = sources.find((s) => s.origin.includes("ftst_probe"));
@@ -27,7 +27,7 @@ describe("enumerateFeatureTableSources — rawTables", () => {
27
27
  expect(entry?.table).toBe(probeMeta);
28
28
  });
29
29
 
30
- test("a feature with no rawTable contributes nothing extra", () => {
30
+ test("a feature with no storeTable contributes nothing extra", () => {
31
31
  const feature = defineFeature("plain", () => {});
32
32
  expect(enumerateFeatureTableSources(feature)).toEqual([]);
33
33
  });
@@ -1,7 +1,7 @@
1
1
  // collectTableMetas — kanonische ENTITY_METAS-Quelle für `kumiko schema
2
2
  // generate`. Erfasst dieselben Tabellen-Quellen wie der setupTestStack-
3
- // auto-push (entities, rawTables, projections, multiStreamProjections) —
4
- // die frühere Template-Variante sammelte nur entities + rawTables, wodurch
3
+ // auto-push (entities, storeTables, projections, multiStreamProjections) —
4
+ // die frühere Template-Variante sammelte nur entities + storeTables, wodurch
5
5
  // projection-only-Tabellen (z.B. billing-foundation read_subscriptions) nie
6
6
  // in Migrations landeten und der erste Prod-Write crashte (#255).
7
7
 
@@ -59,11 +59,11 @@ export function collectTableMetas(
59
59
  metas.push(meta);
60
60
  byName.set(meta.tableName, { meta, origin: `entity "${name}" (${feature.name})` });
61
61
  }
62
- for (const entry of Object.values(feature.rawTables)) {
62
+ for (const entry of Object.values(feature.storeTables)) {
63
63
  metas.push(entry.meta);
64
64
  byName.set(entry.meta.tableName, {
65
65
  meta: entry.meta,
66
- origin: `rawTable "${entry.name}" (${feature.name})`,
66
+ origin: `storeTable "${entry.name}" (${feature.name})`,
67
67
  });
68
68
  }
69
69
  }
@@ -95,7 +95,7 @@ export type EntityTableMeta = {
95
95
  // discriminator nutzen um Warnungen zu rendern ("X tables are unmanaged").
96
96
  readonly source: "managed" | "unmanaged";
97
97
  // PII-Subject-annotated field names (pii/userOwned/tenantOwned). Set by
98
- // buildEntityTableMeta so the registry can reject r.rawTable stores
98
+ // buildEntityTableMeta so the registry can reject r.storeTable stores
99
99
  // whose direct writes would skip the executor's encryption (#820).
100
100
  readonly piiSubjectFields?: readonly string[];
101
101
  };
@@ -296,6 +296,7 @@ export function resolveTableName(
296
296
  export type BuildEntityTableMetaOptions = {
297
297
  readonly featureName?: string;
298
298
  readonly relations?: EntityRelations;
299
+ readonly source?: "managed" | "unmanaged";
299
300
  };
300
301
 
301
302
  export function buildEntityTableMeta(
@@ -415,7 +416,7 @@ export function buildEntityTableMeta(
415
416
  tableName,
416
417
  columns,
417
418
  indexes,
418
- source: "managed",
419
+ source: options?.source ?? "managed",
419
420
  ...(piiSubjectFields.length > 0 && { piiSubjectFields }),
420
421
  };
421
422
  }
@@ -431,8 +432,8 @@ export function buildEntityTableMeta(
431
432
  // jede neue defineUnmanagedTable-Stelle prüfen.
432
433
  //
433
434
  // Heutige use-cases im framework:
434
- // - `read_delivery_attempts` — id kommt aus dem Aggregate-Stream
435
- // - `read_job_run_logs` — child-table, serial PK, kein tenant-scope
435
+ // - `store_delivery_attempts` — id kommt aus dem Aggregate-Stream
436
+ // - `store_job_run_logs` — child-table, serial PK, kein tenant-scope
436
437
  export type UnmanagedTableInput = {
437
438
  readonly tableName: string;
438
439
  readonly columns: readonly ColumnMeta[];
@@ -1,5 +1,5 @@
1
1
  // Single enumeration of every table-bearing registration on a feature
2
- // (r.projection, r.multiStreamProjection with table, r.rawTable).
2
+ // (r.projection, r.multiStreamProjection with table, r.storeTable).
3
3
  // Consumed by BOTH the setupTestStack auto-push and collectTableMetas —
4
4
  // one list, so test-DB-push and `kumiko schema generate` cannot drift
5
5
  // apart again (#255).
@@ -29,8 +29,8 @@ export function enumerateFeatureTableSources(
29
29
  origin: `multiStreamProjection "${name}" (${feature.name})`,
30
30
  });
31
31
  }
32
- for (const [name, raw] of Object.entries(feature.rawTables)) {
33
- sources.push({ table: raw.meta, origin: `rawTable "${name}" (${feature.name})` });
32
+ for (const [name, raw] of Object.entries(feature.storeTables)) {
33
+ sources.push({ table: raw.meta, origin: `storeTable "${name}" (${feature.name})` });
34
34
  }
35
35
  return sources;
36
36
  }
@@ -207,7 +207,7 @@ export async function assertNoUnreachableLiveRows(
207
207
  `projection-rebuild "${projectionName}": ${countLabel} live rows in "${tableName}" have no ` +
208
208
  `event in the projection's source streams and cannot be reconstructed by replay — the swap ` +
209
209
  `would silently drop them (ids: ${ids.join(", ")}). A handler direct-inserted these rows ` +
210
- `without emitting a .created event. Fix: register the table with r.rawTable(meta, ` +
210
+ `without emitting a .created event. Fix: register the table with r.storeTable(meta, ` +
211
211
  `{ reason }) to opt out of rebuild, or emit the missing events. See ` +
212
212
  `docs/reference/entity-write-patterns.md. Rebuild aborted; live table untouched.`,
213
213
  );
@@ -1297,6 +1297,102 @@ describe("boot-validator", () => {
1297
1297
  });
1298
1298
  });
1299
1299
 
1300
+ // --- function-renderer check on the non-entityEdit/entityList screen
1301
+ // types that share the same EditLayout/ListColumnSpec shapes
1302
+ // (action-wiring.ts's validateEditLayoutNoFunctions/validateColumnsNoFunctions) ---
1303
+ describe("function-renderer check on other EditLayout/columns screens", () => {
1304
+ test("actionForm field renderer as a function → Throw", () => {
1305
+ const features = [
1306
+ defineFeature("shop", (r) => {
1307
+ r.screen({
1308
+ id: "restock",
1309
+ type: "actionForm",
1310
+ handler: "shop:write:restock",
1311
+ fields: { qty: { type: "number" } } as never,
1312
+ layout: {
1313
+ sections: [
1314
+ { fields: [{ field: "qty", renderer: (v: unknown) => String(v) }] as never },
1315
+ ],
1316
+ },
1317
+ });
1318
+ }),
1319
+ ];
1320
+ expect(() => validateBoot(features)).toThrow(
1321
+ /\(actionForm\) field "qty" renderer is a function/,
1322
+ );
1323
+ });
1324
+
1325
+ test("configEdit field renderer as a function → Throw", () => {
1326
+ const features = [
1327
+ defineFeature("shop", (r) => {
1328
+ r.config({
1329
+ keys: { "site-name": createTenantConfig("text", { default: "" }) },
1330
+ });
1331
+ r.screen({
1332
+ id: "settings",
1333
+ type: "configEdit",
1334
+ scope: "tenant",
1335
+ configKeys: { siteName: "shop:config:site-name" },
1336
+ fields: { siteName: { type: "text" } } as never,
1337
+ layout: {
1338
+ sections: [
1339
+ {
1340
+ fields: [{ field: "siteName", renderer: (v: unknown) => String(v) }] as never,
1341
+ },
1342
+ ],
1343
+ },
1344
+ });
1345
+ }),
1346
+ ];
1347
+ expect(() => validateBoot(features)).toThrow(
1348
+ /\(configEdit\) field "siteName" renderer is a function/,
1349
+ );
1350
+ });
1351
+
1352
+ test("projectionDetail field renderer as a function → Throw", () => {
1353
+ const features = [
1354
+ defineFeature("shop", (r) => {
1355
+ r.screen({
1356
+ id: "order-detail",
1357
+ type: "projectionDetail",
1358
+ query: "shop:query:order-detail",
1359
+ layout: {
1360
+ sections: [
1361
+ { fields: [{ field: "total", renderer: (v: unknown) => String(v) }] as never },
1362
+ ],
1363
+ },
1364
+ });
1365
+ }),
1366
+ ];
1367
+ expect(() => validateBoot(features)).toThrow(
1368
+ /\(projectionDetail\) field "total" renderer is a function/,
1369
+ );
1370
+ });
1371
+
1372
+ test("dashboard list-panel column renderer as a function → Throw", () => {
1373
+ const features = [
1374
+ defineFeature("shop", (r) => {
1375
+ r.screen({
1376
+ id: "overview",
1377
+ type: "dashboard",
1378
+ panels: [
1379
+ {
1380
+ kind: "list",
1381
+ id: "recent-orders",
1382
+ label: "Recent Orders",
1383
+ query: "shop:query:recent-orders",
1384
+ columns: [{ field: "total", renderer: (v: unknown) => String(v) }] as never,
1385
+ },
1386
+ ],
1387
+ });
1388
+ }),
1389
+ ];
1390
+ expect(() => validateBoot(features)).toThrow(
1391
+ /\(dashboard-list\) column "total" renderer is a function/,
1392
+ );
1393
+ });
1394
+ });
1395
+
1300
1396
  // --- entityList virtual (labeled) columns ---
1301
1397
  // A column whose `field` is not an entity field is allowed IF it carries a
1302
1398
  // `label` — a presentational column drawn by a columnRenderer component (e.g.