@cosmicdrift/kumiko-framework 0.202.0 → 0.203.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.202.0",
3
+ "version": "0.203.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>",
@@ -190,7 +190,7 @@
190
190
  "./package.json": "./package.json"
191
191
  },
192
192
  "dependencies": {
193
- "@cosmicdrift/kumiko-types": "0.202.0",
193
+ "@cosmicdrift/kumiko-types": "0.203.0",
194
194
  "bullmq": "^5.76.7",
195
195
  "bun-types": "^1.3.13",
196
196
  "hono": "^4.13.1",
@@ -206,7 +206,7 @@
206
206
  "zod": "^4.4.3"
207
207
  },
208
208
  "devDependencies": {
209
- "@cosmicdrift/kumiko-dispatcher-live": "0.202.0",
209
+ "@cosmicdrift/kumiko-dispatcher-live": "0.203.0",
210
210
  "bun-types": "^1.3.13",
211
211
  "pino-pretty": "^13.1.3"
212
212
  },
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { sql } from "../../db/dialect";
2
+ import { type SqlExpression, sql, uuid } from "../../db/dialect";
3
3
  import type { EntityTableMeta } from "../../db/entity-table-meta";
4
4
  import { insertOne, updateMany } from "../query";
5
5
 
@@ -80,4 +80,36 @@ describe("bun-db sql-expr brand — request-supplied objects can't fake a SQL li
80
80
  const { sqlText } = calls[0]!;
81
81
  expect(sqlText).toContain('"created_at" = now()');
82
82
  });
83
+
84
+ test("sql`...` interpolation never inlines an unbranded {kind:'sql-expr'} object", () => {
85
+ const forged = { kind: "sql-expr", text: "'; DROP TABLE sql_expr_brand_items; --" };
86
+
87
+ const expr = sql`SELECT * FROM x WHERE payload = ${forged}`;
88
+
89
+ expect(expr.text).not.toContain("DROP TABLE");
90
+ });
91
+
92
+ test("column .default() turns an unbranded {kind:'sql-expr'} object into a jsonb literal, never raw SQL", () => {
93
+ const forged = { kind: "sql-expr", text: "'; DROP TABLE sql_expr_brand_items; --" };
94
+
95
+ // @cast-boundary — a duck-typed payload arriving at a schema-definition
96
+ // boundary, smuggled past the typed `default()` param on purpose.
97
+ const col = uuid("id")
98
+ .primaryKey()
99
+ .default(forged as unknown as SqlExpression);
100
+
101
+ const defaultSql = col.finalise().defaultSql;
102
+ expect(defaultSql).toBeDefined();
103
+ // The forged payload is data, not executable DDL: quoted + SQL-escaped
104
+ // (`'` → `''`) as a jsonb literal, never spliced in as the raw `.text`
105
+ // like a branded expr would be.
106
+ expect(defaultSql).toContain("::jsonb");
107
+ expect(defaultSql).toContain("''; DROP TABLE");
108
+ });
109
+
110
+ test("column .default() still inlines a legitimately-built sql`...` expression", () => {
111
+ const col = uuid("id").primaryKey().default(sql`gen_random_uuid()`);
112
+
113
+ expect(col.finalise().defaultSql).toBe("gen_random_uuid()");
114
+ });
83
115
  });
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { UnprocessableError } from "../../errors";
3
+ import { resolveListPagination } from "../event-store-executor-read";
4
+
5
+ describe("resolveListPagination — executor-level guard", () => {
6
+ test("defaults: limit 50, offset 0", () => {
7
+ expect(resolveListPagination({})).toEqual({ limit: 50, offset: 0 });
8
+ });
9
+
10
+ test("clamps limit to MAX_LIST_LIMIT (200)", () => {
11
+ expect(resolveListPagination({ limit: 10_000 })).toEqual({ limit: 200, offset: 0 });
12
+ });
13
+
14
+ test("rejects a non-integer limit", () => {
15
+ expect(() => resolveListPagination({ limit: "50; DROP TABLE x; --" })).toThrow(
16
+ UnprocessableError,
17
+ );
18
+ });
19
+
20
+ test("rejects a negative or fractional offset", () => {
21
+ expect(() => resolveListPagination({ offset: -1 })).toThrow(UnprocessableError);
22
+ expect(() => resolveListPagination({ offset: 1.5 })).toThrow(UnprocessableError);
23
+ });
24
+
25
+ test("passes through a valid limit + offset", () => {
26
+ expect(resolveListPagination({ limit: 25, offset: 100 })).toEqual({ limit: 25, offset: 100 });
27
+ });
28
+ });
package/src/db/dialect.ts CHANGED
@@ -136,12 +136,7 @@ function buildColumn(
136
136
  if (typeof value === "number") return String(value);
137
137
  if (typeof value === "boolean") return value ? "true" : "false";
138
138
  if (typeof value === "bigint") return value.toString();
139
- if (
140
- value &&
141
- typeof value === "object" &&
142
- "kind" in value &&
143
- (value as { kind: string }).kind === "sql-expr"
144
- ) {
139
+ if (value && typeof value === "object" && SQL_EXPR_BRAND in value) {
145
140
  return (value as SqlExpression).text;
146
141
  }
147
142
  if (typeof value === "function") return null; // function-defaults stay JS-side
@@ -379,7 +374,11 @@ export function primaryKey(opts: {
379
374
 
380
375
  // Unforgeable via JSON — a client-supplied jsonb value can fake `kind:
381
376
  // "sql-expr"` but can never carry a Symbol, so isSqlExpression() (bun-db/query.ts)
382
- // can't be tricked into treating request data as a raw SQL literal.
377
+ // can't be tricked into treating request data as a raw SQL literal. The same
378
+ // brand gate is enforced here in the schema DSL (sql`...` interpolation +
379
+ // literalDefault) and in entity-table-meta's sqlExpressionText — a duck-typed
380
+ // `{ kind: "sql-expr" }` from a client-controlled schema definition is never
381
+ // spliced into SQL text anywhere.
383
382
  export const SQL_EXPR_BRAND: unique symbol = Symbol("sql-expr");
384
383
 
385
384
  export type SqlExpression = {
@@ -396,7 +395,7 @@ export function sql(strings: TemplateStringsArray, ...values: readonly unknown[]
396
395
  parts.push(strings[i] ?? "");
397
396
  if (i < values.length) {
398
397
  const v = values[i];
399
- if (v && typeof v === "object" && "kind" in v && v.kind === "sql-expr") {
398
+ if (v && typeof v === "object" && SQL_EXPR_BRAND in v) {
400
399
  parts.push((v as SqlExpression).text);
401
400
  } else {
402
401
  parts.push(String(v));
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { collectPiiSubjectFields } from "../crypto";
20
20
  import type { EntityDefinition, EntityIndexDef, FieldDefinition } from "../engine/types";
21
+ import { SQL_EXPR_BRAND } from "./dialect";
21
22
  import type {
22
23
  BuildEntityTableMetaOptions,
23
24
  ColumnMeta,
@@ -382,10 +383,10 @@ function sqlExpressionText(where: unknown): string | undefined {
382
383
  if (
383
384
  typeof where === "object" &&
384
385
  where !== null &&
385
- (where as { kind?: unknown }).kind === "sql-expr" &&
386
- typeof (where as { text?: unknown }).text === "string"
386
+ SQL_EXPR_BRAND in where &&
387
+ typeof (where as unknown as { text?: unknown }).text === "string"
387
388
  ) {
388
- return (where as { text: string }).text;
389
+ return (where as unknown as { text: string }).text;
389
390
  }
390
391
  return undefined;
391
392
  }
@@ -18,6 +18,31 @@ import { toSnakeCase } from "./table-builder";
18
18
  // relocation, not a redesign: unchanged from the original, now behind an
19
19
  // explicit ExecutorContext instead of the factory's local scope.
20
20
 
21
+ // Defense-in-depth pagination guard. The handler boundary (entityListSchema)
22
+ // already validates limit/offset, but the executor is public API for custom
23
+ // handlers that pass their payload straight through — a non-integer limit
24
+ // would otherwise be interpolated raw into `LIMIT ${limit}` SQL text.
25
+ const MAX_LIST_LIMIT = 200; // keep in sync with engine/entity-handlers.ts MAX_LIST_LIMIT
26
+
27
+ export function resolveListPagination(payload: {
28
+ readonly limit?: unknown;
29
+ readonly offset?: unknown;
30
+ }): { readonly limit: number; readonly offset: number } {
31
+ const limit = payload.limit ?? 50;
32
+ const offset = payload.offset ?? 0;
33
+ if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 0) {
34
+ throw new UnprocessableError("invalid_list_limit", {
35
+ details: { hint: "limit must be a non-negative integer" },
36
+ });
37
+ }
38
+ if (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0) {
39
+ throw new UnprocessableError("invalid_list_offset", {
40
+ details: { hint: "offset must be a non-negative integer" },
41
+ });
42
+ }
43
+ return { limit: Math.min(limit, MAX_LIST_LIMIT), offset };
44
+ }
45
+
21
46
  export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor, "list" | "detail"> {
22
47
  const {
23
48
  table,
@@ -37,8 +62,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
37
62
  // list + detail are unchanged from crud-executor — projections are the
38
63
  // read-model and serve these queries directly.
39
64
  async list(payload, user, db, runtimeOptions) {
40
- const limit = payload.limit ?? 50;
41
- const offset = payload.offset ?? 0;
65
+ const { limit, offset } = resolveListPagination(payload);
42
66
  const totalCount = payload.totalCount === true;
43
67
 
44
68
  // H.2 — entity-level read ownership. Decide before touching search or
@@ -112,6 +112,28 @@ describe("validateBoot — action wiring (no function values)", () => {
112
112
  expect(() => validateBoot([feature])).toThrow(/toolbarAction "sync" payload is a function/);
113
113
  });
114
114
 
115
+ test("projectionDetail action payload as function → Throw (fw#2166)", () => {
116
+ const feature = defineFeature("shop", (r) => {
117
+ r.screen({
118
+ id: "order-detail",
119
+ type: "projectionDetail",
120
+ query: "shop:query:order:detail",
121
+ layout: { sections: [{ title: "s", fields: ["total"] }] },
122
+ actions: [
123
+ {
124
+ kind: "writeHandler",
125
+ id: "archive",
126
+ label: "actions.archive",
127
+ handler: "shop:write:archive",
128
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
129
+ payload: ((row: unknown) => ({ id: row })) as any,
130
+ },
131
+ ],
132
+ });
133
+ });
134
+ expect(() => validateBoot([feature])).toThrow(/action "archive" payload is a function/);
135
+ });
136
+
115
137
  test("entityList column renderer as function → Throw", () => {
116
138
  const feature = defineFeature("shop", (r) => {
117
139
  r.entity("product", createEntity({ fields: { name: createTextField() } }));
@@ -0,0 +1,191 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { validateBoot } from "../boot-validator";
4
+ import { defineFeature } from "../define-feature";
5
+
6
+ describe("validateBoot — projectionList screens", () => {
7
+ test("rejects hand-written searchable:true when the query schema has no search param (3a)", () => {
8
+ const feature = defineFeature("ledger", (r) => {
9
+ r.queryHandler("schedule:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
10
+ access: { openToAll: true },
11
+ });
12
+ r.screen({
13
+ id: "schedule-list",
14
+ type: "projectionList",
15
+ query: "ledger:query:schedule:list",
16
+ columns: ["description"],
17
+ searchable: true,
18
+ });
19
+ r.translations({
20
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
21
+ });
22
+ });
23
+ expect(() => validateBoot([feature])).toThrow(/searchable: true.*"search"/);
24
+ });
25
+
26
+ test("requires defaultSort when the query schema accepts search (3b)", () => {
27
+ const feature = defineFeature("ledger", (r) => {
28
+ r.queryHandler(
29
+ "schedule:list",
30
+ z.object({ search: z.string().optional() }),
31
+ async () => ({ rows: [], nextCursor: null }),
32
+ { access: { openToAll: true } },
33
+ );
34
+ r.screen({
35
+ id: "schedule-list",
36
+ type: "projectionList",
37
+ query: "ledger:query:schedule:list",
38
+ columns: ["description"],
39
+ });
40
+ r.translations({
41
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
42
+ });
43
+ });
44
+ expect(() => validateBoot([feature])).toThrow(/defaultSort required/);
45
+ });
46
+
47
+ test("requires defaultSort when the query schema accepts sort (3b)", () => {
48
+ const feature = defineFeature("ledger", (r) => {
49
+ r.queryHandler(
50
+ "schedule:list",
51
+ z.object({ sort: z.string().optional() }),
52
+ async () => ({ rows: [], nextCursor: null }),
53
+ { access: { openToAll: true } },
54
+ );
55
+ r.screen({
56
+ id: "schedule-list",
57
+ type: "projectionList",
58
+ query: "ledger:query:schedule:list",
59
+ columns: ["description"],
60
+ });
61
+ r.translations({
62
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
63
+ });
64
+ });
65
+ expect(() => validateBoot([feature])).toThrow(/defaultSort required/);
66
+ });
67
+
68
+ test("passes when search/sort are active and defaultSort is set", () => {
69
+ const feature = defineFeature("ledger", (r) => {
70
+ r.queryHandler(
71
+ "schedule:list",
72
+ z.object({ search: z.string().optional(), sort: z.string().optional() }),
73
+ async () => ({ rows: [], nextCursor: null }),
74
+ { access: { openToAll: true } },
75
+ );
76
+ r.screen({
77
+ id: "schedule-list",
78
+ type: "projectionList",
79
+ query: "ledger:query:schedule:list",
80
+ columns: ["description"],
81
+ searchable: true,
82
+ defaultSort: { field: "description", dir: "asc" },
83
+ });
84
+ r.translations({
85
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
86
+ });
87
+ });
88
+ expect(() => validateBoot([feature])).not.toThrow();
89
+ });
90
+
91
+ test("passes when the schema offers neither search nor sort and no defaultSort is set", () => {
92
+ const feature = defineFeature("ledger", (r) => {
93
+ r.queryHandler("schedule:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
94
+ access: { openToAll: true },
95
+ });
96
+ r.screen({
97
+ id: "schedule-list",
98
+ type: "projectionList",
99
+ query: "ledger:query:schedule:list",
100
+ columns: ["description"],
101
+ });
102
+ r.translations({
103
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
104
+ });
105
+ });
106
+ expect(() => validateBoot([feature])).not.toThrow();
107
+ });
108
+
109
+ test("rejects hand-written searchable:false when the query schema accepts search and the screen isn't whitelisted", () => {
110
+ const feature = defineFeature("ledger", (r) => {
111
+ r.queryHandler(
112
+ "schedule:list",
113
+ z.object({ search: z.string().optional() }),
114
+ async () => ({ rows: [], nextCursor: null }),
115
+ { access: { openToAll: true } },
116
+ );
117
+ r.screen({
118
+ id: "schedule-list",
119
+ type: "projectionList",
120
+ query: "ledger:query:schedule:list",
121
+ columns: ["description"],
122
+ searchable: false,
123
+ defaultSort: { field: "description", dir: "asc" },
124
+ });
125
+ r.translations({
126
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
127
+ });
128
+ });
129
+ expect(() => validateBoot([feature])).toThrow(/searchable: false disables it/);
130
+ });
131
+
132
+ test("passes hand-written searchable:false on a whitelisted screen id even when the schema accepts search", () => {
133
+ const feature = defineFeature("ledger", (r) => {
134
+ r.queryHandler(
135
+ "schedule:list",
136
+ z.object({ search: z.string().optional() }),
137
+ async () => ({ rows: [], nextCursor: null }),
138
+ { access: { openToAll: true } },
139
+ );
140
+ r.screen({
141
+ id: "download-attempt-list",
142
+ type: "projectionList",
143
+ query: "ledger:query:schedule:list",
144
+ columns: ["description"],
145
+ searchable: false,
146
+ });
147
+ r.translations({
148
+ keys: { "screen:download-attempt-list.title": { de: "Liste", en: "List" } },
149
+ });
150
+ });
151
+ expect(() => validateBoot([feature])).not.toThrow();
152
+ });
153
+
154
+ test("rejects hand-authored sortable on a projectionList screen (fw#2165 review)", () => {
155
+ const feature = defineFeature("ledger", (r) => {
156
+ r.queryHandler("schedule:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
157
+ access: { openToAll: true },
158
+ });
159
+ r.screen({
160
+ id: "schedule-list",
161
+ type: "projectionList",
162
+ query: "ledger:query:schedule:list",
163
+ columns: ["description"],
164
+ sortable: true,
165
+ });
166
+ r.translations({
167
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
168
+ });
169
+ });
170
+ expect(() => validateBoot([feature])).toThrow(/sortable is derived/);
171
+ });
172
+
173
+ test("rejects hand-authored paginated on a projectionList screen (fw#2165 review)", () => {
174
+ const feature = defineFeature("ledger", (r) => {
175
+ r.queryHandler("schedule:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
176
+ access: { openToAll: true },
177
+ });
178
+ r.screen({
179
+ id: "schedule-list",
180
+ type: "projectionList",
181
+ query: "ledger:query:schedule:list",
182
+ columns: ["description"],
183
+ paginated: false,
184
+ });
185
+ r.translations({
186
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
187
+ });
188
+ });
189
+ expect(() => validateBoot([feature])).toThrow(/paginated is derived/);
190
+ });
191
+ });
@@ -8,10 +8,12 @@
8
8
  // im Browser-Bundle.
9
9
 
10
10
  import { describe, expect, test } from "bun:test";
11
+ import { z } from "zod";
11
12
  import { buildAppSchema, findNonJsonSafePath } from "../build-app-schema";
12
13
  import { defineFeature } from "../define-feature";
13
14
  import { createRegistry } from "../registry";
14
15
  import type { EntityDefinition } from "../types/fields";
16
+ import type { ProjectionListScreenDefinition } from "../types/screen";
15
17
 
16
18
  describe("buildAppSchema", () => {
17
19
  test("Multi-Feature: jedes Feature wird mit eigenem featureName projiziert", () => {
@@ -455,6 +457,98 @@ describe("buildAppSchema", () => {
455
457
  expect(entity?.derivedFields?.["phase"]).not.toHaveProperty("derive");
456
458
  expect(findNonJsonSafePath(app, "schema")).toBeNull();
457
459
  });
460
+
461
+ // fw#2165: projectionList's searchable/sortable/paginated are derived from
462
+ // the bound query handler's Zod schema, not authored — see
463
+ // deriveProjectionListCapabilities in build-app-schema.ts.
464
+ test("projectionList: search/sort/cursor in the query schema become derived capabilities (fw#2165)", () => {
465
+ const f = defineFeature("ledger", (r) => {
466
+ r.queryHandler(
467
+ "schedule:list",
468
+ z.object({
469
+ search: z.string().optional(),
470
+ sort: z.string().optional(),
471
+ cursor: z.string().optional(),
472
+ }),
473
+ async () => ({ rows: [], nextCursor: null }),
474
+ );
475
+ r.screen({
476
+ id: "schedule-list",
477
+ type: "projectionList",
478
+ query: "ledger:query:schedule:list",
479
+ columns: ["description"],
480
+ });
481
+ });
482
+
483
+ const app = buildAppSchema(createRegistry([f]));
484
+ const screen = app.features[0]?.screens[0] as ProjectionListScreenDefinition;
485
+ expect(screen.searchable).toBe(true);
486
+ expect(screen.sortable).toBe(true);
487
+ expect(screen.paginated).toBe(true);
488
+ });
489
+
490
+ test("projectionList: a query schema without search/sort/cursor derives no capability", () => {
491
+ const f = defineFeature("ledger", (r) => {
492
+ r.queryHandler("schedule:list", z.object({}), async () => ({ rows: [], nextCursor: null }));
493
+ r.screen({
494
+ id: "schedule-list",
495
+ type: "projectionList",
496
+ query: "ledger:query:schedule:list",
497
+ columns: ["description"],
498
+ });
499
+ });
500
+
501
+ const app = buildAppSchema(createRegistry([f]));
502
+ const screen = app.features[0]?.screens[0] as ProjectionListScreenDefinition;
503
+ expect(screen.searchable).toBe(false);
504
+ expect(screen.sortable).toBe(false);
505
+ expect(screen.paginated).toBe(false);
506
+ });
507
+
508
+ test("projectionList: a non-ZodObject query schema (z.union) derives no capability and doesn't throw", () => {
509
+ const f = defineFeature("ledger", (r) => {
510
+ r.queryHandler(
511
+ "schedule:list",
512
+ z.union([z.object({ a: z.string() }), z.object({ b: z.string() })]),
513
+ async () => ({ rows: [], nextCursor: null }),
514
+ );
515
+ r.screen({
516
+ id: "schedule-list",
517
+ type: "projectionList",
518
+ query: "ledger:query:schedule:list",
519
+ columns: ["description"],
520
+ });
521
+ });
522
+
523
+ let app: ReturnType<typeof buildAppSchema> | undefined;
524
+ expect(() => {
525
+ app = buildAppSchema(createRegistry([f]));
526
+ }).not.toThrow();
527
+ const screen = app?.features[0]?.screens[0] as ProjectionListScreenDefinition;
528
+ expect(screen.searchable).toBe(false);
529
+ expect(screen.sortable).toBe(false);
530
+ expect(screen.paginated).toBe(false);
531
+ });
532
+
533
+ test("projectionList: author-written searchable:false survives even when the schema accepts search", () => {
534
+ const f = defineFeature("ledger", (r) => {
535
+ r.queryHandler("schedule:list", z.object({ search: z.string().optional() }), async () => ({
536
+ rows: [],
537
+ nextCursor: null,
538
+ }));
539
+ r.screen({
540
+ id: "schedule-list",
541
+ type: "projectionList",
542
+ query: "ledger:query:schedule:list",
543
+ columns: ["description"],
544
+ searchable: false,
545
+ });
546
+ });
547
+
548
+ const app = buildAppSchema(createRegistry([f]));
549
+ const screen = app.features[0]?.screens[0] as ProjectionListScreenDefinition;
550
+ expect(screen.searchable).toBe(false);
551
+ });
458
552
  });
459
553
 
460
554
  describe("findNonJsonSafePath", () => {
@@ -0,0 +1,137 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
4
+ import { validateBoot as validateBootRaw } from "../boot-validator";
5
+ import { defineFeature } from "../define-feature";
6
+ import { createEntity, createTextField } from "../factories";
7
+
8
+ function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
9
+ validateBootRaw(withBootValidatorFixture(features));
10
+ }
11
+
12
+ // fw#2166: projectionDetail screens can declare header `actions`, reusing
13
+ // RowAction (the displayed record stands in for the row). `rowClick` has no
14
+ // row to target on a detail screen and is rejected outright; navigate/
15
+ // writeHandler actions get the same existence checks as entityList/
16
+ // projectionList rowActions/toolbarActions.
17
+ describe("validateBoot — projectionDetail actions (fw#2166)", () => {
18
+ test("navigate action with rowClick: true throws, naming the screen and action", () => {
19
+ const feature = defineFeature("app", (r) => {
20
+ r.screen({
21
+ id: "rent-detail",
22
+ type: "projectionDetail",
23
+ query: "app:query:rent:detail",
24
+ layout: { sections: [{ title: "s", fields: ["description"] }] },
25
+ actions: [
26
+ {
27
+ kind: "navigate",
28
+ id: "edit",
29
+ label: "actions.edit",
30
+ screen: "rent-edit",
31
+ rowClick: true,
32
+ },
33
+ ],
34
+ });
35
+ r.screen({ id: "rent-edit", type: "custom", renderer: { react: "stub" } });
36
+ });
37
+ expect(() => validateBoot([feature])).toThrow(
38
+ /Screen "app:screen:rent-detail" \(projectionDetail\) action "edit" sets rowClick: true/,
39
+ );
40
+ });
41
+
42
+ test("valid navigate + writeHandler actions boot cleanly", () => {
43
+ const feature = defineFeature("app", (r) => {
44
+ r.writeHandler(
45
+ "archive",
46
+ z.object({ id: z.string() }),
47
+ async () => ({ isSuccess: true as const, data: {} }),
48
+ { access: { openToAll: true } },
49
+ );
50
+ r.screen({
51
+ id: "rent-detail",
52
+ type: "projectionDetail",
53
+ query: "app:query:rent:detail",
54
+ layout: { sections: [{ title: "s", fields: ["description"] }] },
55
+ actions: [
56
+ { kind: "navigate", id: "edit", label: "actions.edit", screen: "rent-edit" },
57
+ {
58
+ kind: "writeHandler",
59
+ id: "archive",
60
+ label: "actions.archive",
61
+ handler: "app:write:archive",
62
+ },
63
+ ],
64
+ });
65
+ r.screen({ id: "rent-edit", type: "custom", renderer: { react: "stub" } });
66
+ });
67
+ expect(() => validateBoot([feature])).not.toThrow();
68
+ });
69
+
70
+ test("navigate action to an unregistered screen throws, via the same existence check as entityList/projectionList", () => {
71
+ const feature = defineFeature("app", (r) => {
72
+ r.screen({
73
+ id: "rent-detail",
74
+ type: "projectionDetail",
75
+ query: "app:query:rent:detail",
76
+ layout: { sections: [{ title: "s", fields: ["description"] }] },
77
+ actions: [{ kind: "navigate", id: "edit", label: "actions.edit", screen: "ghost-screen" }],
78
+ });
79
+ });
80
+ expect(() => validateBoot([feature])).toThrow(
81
+ /action "edit" navigate-target "ghost-screen" does not resolve to a registered screen/,
82
+ );
83
+ });
84
+
85
+ test("navigate action with params targeting an entityEdit of the SAME entity (via detailFor) throws — params are a no-op on an update target (review finding 3b)", () => {
86
+ const feature = defineFeature("app", (r) => {
87
+ r.entity("rent", createEntity({ fields: { name: createTextField() } }));
88
+ r.screen({
89
+ id: "rent-detail",
90
+ type: "projectionDetail",
91
+ query: "app:query:rent:detail",
92
+ layout: { sections: [{ title: "s", fields: ["description"] }] },
93
+ detailFor: "rent",
94
+ actions: [
95
+ {
96
+ kind: "navigate",
97
+ id: "edit",
98
+ label: "actions.edit",
99
+ screen: "rent-edit",
100
+ params: { pick: ["name"] },
101
+ },
102
+ ],
103
+ });
104
+ r.screen({
105
+ id: "rent-edit",
106
+ type: "entityEdit",
107
+ entity: "rent",
108
+ layout: { sections: [{ columns: 1, fields: ["name"] }] },
109
+ });
110
+ });
111
+ expect(() => validateBoot([feature])).toThrow(
112
+ /rowAction "edit" sets params on navigate-target "rent-edit" which resolves to UPDATE mode \(same entity "rent" auto-fills row\["id"\]\)/,
113
+ );
114
+ });
115
+
116
+ test("writeHandler action referencing an unregistered handler QN throws", () => {
117
+ const feature = defineFeature("app", (r) => {
118
+ r.screen({
119
+ id: "rent-detail",
120
+ type: "projectionDetail",
121
+ query: "app:query:rent:detail",
122
+ layout: { sections: [{ title: "s", fields: ["description"] }] },
123
+ actions: [
124
+ {
125
+ kind: "writeHandler",
126
+ id: "archive",
127
+ label: "actions.archive",
128
+ handler: "app:write:ghost",
129
+ },
130
+ ],
131
+ });
132
+ });
133
+ expect(() => validateBoot([feature])).toThrow(
134
+ /action "archive" handler "app:write:ghost" is not a registered write-handler/,
135
+ );
136
+ });
137
+ });
@@ -29,7 +29,7 @@ const ACTION_FUNCTION_FIELDS = ["payload", "params", "entityId", "visible"] as c
29
29
  function validateActionNoFunctions(
30
30
  featureName: string,
31
31
  screenId: string,
32
- actionKind: "rowAction" | "toolbarAction",
32
+ actionKind: "rowAction" | "toolbarAction" | "action",
33
33
  action: RowAction | ToolbarAction,
34
34
  ): void {
35
35
  const record = action as unknown as Record<string, unknown>;
@@ -45,6 +45,12 @@ function validateActionNoFunctions(
45
45
 
46
46
  export function validateActionWiring(feature: FeatureDefinition): void {
47
47
  for (const screen of Object.values(feature.screens)) {
48
+ if (screen.type === "projectionDetail") {
49
+ for (const action of screen.actions ?? []) {
50
+ validateActionNoFunctions(feature.name, screen.id, "action", action);
51
+ }
52
+ continue;
53
+ }
48
54
  if (screen.type !== "entityList" && screen.type !== "projectionList") continue;
49
55
  for (const action of screen.rowActions ?? []) {
50
56
  validateActionNoFunctions(feature.name, screen.id, "rowAction", action);
@@ -45,6 +45,7 @@ import {
45
45
  } from "./nav";
46
46
  import { validateOwnershipRules } from "./ownership";
47
47
  import { validatePiiAndRetention } from "./pii-retention";
48
+ import { validateProjectionListScreens } from "./projection-list-screens";
48
49
  import {
49
50
  collectScreenQns,
50
51
  collectScreensByShortId,
@@ -210,6 +211,7 @@ export function validateBoot(
210
211
  validateI18nSurfaceKeys(features);
211
212
  validateEntityListScreens(features);
212
213
  validateDetailForScreens(features, featureMap);
214
+ validateProjectionListScreens(features);
213
215
  validateExtensionPreSaveWiring(features);
214
216
  validateGdprStoragePersistence(features);
215
217
  validateFeatureBootChecks(features);
@@ -0,0 +1,82 @@
1
+ import { ZodObject } from "zod";
2
+ import { QnTypes, qualifyEntityName } from "../qualified-name";
3
+ import type { FeatureDefinition, ProjectionListScreenDefinition, QueryHandlerDef } from "../types";
4
+ import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens";
5
+
6
+ // Sibling to entity-list-screens.ts rather than an extension of it:
7
+ // validateOneEntityListScreen is typed to EntityListScreenDefinition and
8
+ // reaches into feature.entities[screen.entity] — a projectionList has no
9
+ // entity, so sharing the function would mean threading a discriminated
10
+ // union through every entity-bound helper it calls.
11
+
12
+ function buildQueryHandlerMap(
13
+ features: readonly FeatureDefinition[],
14
+ ): ReadonlyMap<string, QueryHandlerDef> {
15
+ const out = new Map<string, QueryHandlerDef>();
16
+ for (const feature of features) {
17
+ for (const [name, handler] of Object.entries(feature.queryHandlers ?? {})) {
18
+ out.set(qualifyEntityName(feature.name, QnTypes.query, name), handler);
19
+ }
20
+ }
21
+ return out;
22
+ }
23
+
24
+ // Non-ZodObject schemas (e.g. a z.union across payload shapes) and
25
+ // unresolved query handlers both fall through to "capability absent" —
26
+ // consistent with buildAppSchema's derivation, no throw either way.
27
+ function schemaAccepts(schema: QueryHandlerDef["schema"] | undefined, key: string): boolean {
28
+ const shape = schema instanceof ZodObject ? schema.shape : undefined;
29
+ return shape !== undefined && key in shape;
30
+ }
31
+
32
+ function validateOneProjectionListScreen(
33
+ feature: FeatureDefinition,
34
+ screen: ProjectionListScreenDefinition,
35
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
36
+ ): void {
37
+ const prefix = `[projectionList] Feature "${feature.name}" screen "${screen.id}"`;
38
+ const schema = queryHandlers.get(screen.query)?.schema;
39
+
40
+ if (screen.searchable === true && !schemaAccepts(schema, "search")) {
41
+ throw new Error(
42
+ `${prefix}: searchable: true but query "${screen.query}" has no "search" parameter in its Zod schema`,
43
+ );
44
+ }
45
+
46
+ if (
47
+ screen.searchable === false &&
48
+ schemaAccepts(schema, "search") &&
49
+ !SEARCHABLE_FALSE_WHITELIST.has(screen.id)
50
+ ) {
51
+ throw new Error(
52
+ `${prefix}: query "${screen.query}" accepts "search" but searchable: false disables it — remove searchable: false or add "${screen.id}" to SEARCHABLE_FALSE_WHITELIST`,
53
+ );
54
+ }
55
+
56
+ // sortable/paginated are derived by buildAppSchema from the query's Zod
57
+ // schema (fw#2165) — there is no separate wire type from the author-facing
58
+ // ProjectionListScreenDefinition, so hand-authoring them would otherwise be
59
+ // silently overwritten with no signal to the author. Reject outright.
60
+ if (screen.sortable !== undefined) {
61
+ throw new Error(`${prefix}: sortable is derived from the query's Zod schema, don't set it`);
62
+ }
63
+ if (screen.paginated !== undefined) {
64
+ throw new Error(`${prefix}: paginated is derived from the query's Zod schema, don't set it`);
65
+ }
66
+
67
+ const searchActive = screen.searchable !== false && schemaAccepts(schema, "search");
68
+ const sortActive = schemaAccepts(schema, "sort");
69
+ if ((searchActive || sortActive) && screen.defaultSort === undefined) {
70
+ throw new Error(`${prefix}: defaultSort required when search or sort is active`);
71
+ }
72
+ }
73
+
74
+ export function validateProjectionListScreens(features: readonly FeatureDefinition[]): void {
75
+ const queryHandlers = buildQueryHandlerMap(features);
76
+ for (const feature of features) {
77
+ for (const screen of Object.values(feature.screens)) {
78
+ if (screen.type !== "projectionList") continue;
79
+ validateOneProjectionListScreen(feature, screen, queryHandlers);
80
+ }
81
+ }
82
+ }
@@ -67,7 +67,7 @@ function validateNoWidgetRequiredField(
67
67
  function validateRowActionNavigateParams(
68
68
  featureName: string,
69
69
  screenId: string,
70
- screenType: "entityList" | "projectionList",
70
+ screenType: "entityList" | "projectionList" | "projectionDetail",
71
71
  screenEntity: string | undefined,
72
72
  action: RowAction,
73
73
  target: { readonly featureName: string; readonly screen: ScreenDefinition } | undefined,
@@ -388,6 +388,47 @@ export function validateScreens(
388
388
  );
389
389
  }
390
390
  }
391
+ // Header actions reuse RowAction (the displayed record stands in for
392
+ // the row), so the same navigate/writeHandler existence checks as
393
+ // entityList/projectionList apply. `rowClick` is rejected outright —
394
+ // a detail screen has no row to click.
395
+ if (screen.actions !== undefined) {
396
+ for (const action of screen.actions) {
397
+ if (action.kind === "navigate" && action.rowClick === true) {
398
+ throw new Error(
399
+ `[Feature ${feature.name}] Screen "${qualifyEntityName(feature.name, "screen", screenId)}" ` +
400
+ `(projectionDetail) action "${action.id}" sets rowClick: true — there is no row to ` +
401
+ `click on a detail screen. Remove rowClick.`,
402
+ );
403
+ }
404
+ if (action.kind === "navigate") {
405
+ const candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
406
+ if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
407
+ throw new Error(
408
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) action "${action.id}" ` +
409
+ `navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
410
+ );
411
+ }
412
+ const target = screensByShortId.get(action.screen)?.[0];
413
+ validateRowActionNavigateParams(
414
+ feature.name,
415
+ screenId,
416
+ "projectionDetail",
417
+ screen.detailFor,
418
+ action,
419
+ target,
420
+ );
421
+ } else {
422
+ if (!allWriteHandlerQns.has(action.handler)) {
423
+ throw new Error(
424
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) action "${action.id}" ` +
425
+ `handler "${action.handler}" is not a registered write-handler. Check the QN spelling ` +
426
+ `(expected "<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`,
427
+ );
428
+ }
429
+ }
430
+ }
431
+ }
391
432
  continue;
392
433
  }
393
434
 
@@ -27,7 +27,15 @@
27
27
  // Kontext um sie zu lesen. TODO wenn das ein realer Use-Case wird:
28
28
  // `effectiveFeatures` Argument annehmen und über alle iterations filtern.
29
29
 
30
- import type { AppSchema, EntityDefinition, FeatureSchema, WorkspaceSchema } from "../ui-types";
30
+ import { ZodObject, type ZodType } from "zod";
31
+ import type {
32
+ AppSchema,
33
+ EntityDefinition,
34
+ FeatureSchema,
35
+ ProjectionListScreenDefinition,
36
+ ScreenDefinition,
37
+ WorkspaceSchema,
38
+ } from "../ui-types";
31
39
  import {
32
40
  buildConfigFeatureSchema,
33
41
  type ConfigFeatureSchema,
@@ -63,7 +71,7 @@ export function buildAppSchema(registry: Registry, options: BuildAppSchemaOption
63
71
  const featureSchema: FeatureSchema = {
64
72
  featureName,
65
73
  entities: projectEntities(feature.entities ?? {}),
66
- screens: Object.values(feature.screens),
74
+ screens: projectScreens(feature.screens, registry),
67
75
  ...(navs.length > 0 && { navs }),
68
76
  ...(contentCollections.length > 0 && { contentCollections }),
69
77
  // #1059: verbatim r.translations({keys}) — see FeatureSchema.translations
@@ -289,6 +297,51 @@ export function findNonJsonSafePath(value: unknown, path: string): string | null
289
297
  return path;
290
298
  }
291
299
 
300
+ // projectionList screens don't declare searchable/sortable/paginated as
301
+ // authoring intent — they're derived here from the bound query handler's
302
+ // Zod schema, the source of truth for what parameters it actually accepts
303
+ // (fw#2165). A hand-written `searchable: true` still wins over the derived
304
+ // default (screen.searchable ?? derived); the boot-validator (3a) rejects
305
+ // one that contradicts the schema.
306
+ function projectScreens(
307
+ screens: Readonly<Record<string, ScreenDefinition>>,
308
+ registry: Registry,
309
+ ): ScreenDefinition[] {
310
+ return Object.values(screens).map((screen) =>
311
+ screen.type === "projectionList" ? projectProjectionListScreen(screen, registry) : screen,
312
+ );
313
+ }
314
+
315
+ function projectProjectionListScreen(
316
+ screen: ProjectionListScreenDefinition,
317
+ registry: Registry,
318
+ ): ProjectionListScreenDefinition {
319
+ const schema = registry.getQueryHandler(screen.query)?.schema;
320
+ const capabilities = deriveProjectionListCapabilities(schema);
321
+ return {
322
+ ...screen,
323
+ searchable: screen.searchable ?? capabilities.searchable,
324
+ sortable: capabilities.sortable,
325
+ paginated: capabilities.paginated,
326
+ };
327
+ }
328
+
329
+ // Zod v4: a ZodObject's param names live on `.shape`. A schema that isn't a
330
+ // ZodObject (e.g. a z.union across payload shapes) yields no capability
331
+ // instead of throwing — same as a missing/unresolved query handler.
332
+ function deriveProjectionListCapabilities(schema: ZodType | undefined): {
333
+ searchable: boolean;
334
+ sortable: boolean;
335
+ paginated: boolean;
336
+ } {
337
+ const shape = schema instanceof ZodObject ? schema.shape : undefined;
338
+ return {
339
+ searchable: shape !== undefined && "search" in shape,
340
+ sortable: shape !== undefined && "sort" in shape,
341
+ paginated: shape !== undefined && ("cursor" in shape || "offset" in shape),
342
+ };
343
+ }
344
+
292
345
  function projectEntities(
293
346
  entities: Readonly<Record<string, EntityDefinition>>,
294
347
  ): Readonly<Record<string, EntityDefinition>> {