@cosmicdrift/kumiko-framework 0.202.0 → 0.204.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.204.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.204.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.204.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
@@ -48,7 +48,22 @@ export const RAW_SQL_ALLOWLIST: ReadonlyArray<RegExp> = [
48
48
  /\/packages\/framework\/src\/db\/sql-inventory\.ts$/,
49
49
  /\/packages\/framework\/src\/bun-db\/query\.ts$/,
50
50
  /\/packages\/framework\/src\/testing\//,
51
- /\/packages\/bundled-features\/src\/[^/]+\/db\/queries\//,
51
+ // ponytail: explicit enumeration — blanket regex auto-allowed any new .unsafe()
52
+ /\/bundled-features\/src\/billing-foundation\/db\/queries\/subscription-projection\.ts$/,
53
+ /\/bundled-features\/src\/config\/db\/queries\/resolver\.ts$/,
54
+ /\/bundled-features\/src\/custom-fields\/db\/queries\/field-access\.ts$/,
55
+ /\/bundled-features\/src\/custom-fields\/db\/queries\/projection\.ts$/,
56
+ /\/bundled-features\/src\/custom-fields\/db\/queries\/quota\.ts$/,
57
+ /\/bundled-features\/src\/custom-fields\/db\/queries\/retention\.ts$/,
58
+ /\/bundled-features\/src\/custom-fields\/db\/queries\/user-data-rights\.ts$/,
59
+ /\/bundled-features\/src\/delivery\/db\/queries\/preferences\.ts$/,
60
+ /\/bundled-features\/src\/form-draft\/db\/queries\/cleanup\.ts$/,
61
+ /\/bundled-features\/src\/form-draft\/db\/queries\/draft-count\.ts$/,
62
+ /\/bundled-features\/src\/form-draft\/db\/queries\/owned-file-refs\.ts$/,
63
+ /\/bundled-features\/src\/inbound-mail-foundation\/db\/queries\/inbound-projections\.ts$/,
64
+ /\/bundled-features\/src\/secrets\/db\/queries\/read\.ts$/,
65
+ /\/bundled-features\/src\/sessions\/db\/queries\/cleanup\.ts$/,
66
+ /\/bundled-features\/src\/user\/db\/queries\/stream-tenant-backfill\.ts$/,
52
67
  /\/packages\/framework\/src\/engine\/steps\/unsafe-projection-/,
53
68
  /\/samples\/(apps|recipes)\/[^/]+\/src\/db\/queries\//,
54
69
  /\/bin\/commands\//,
@@ -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() } }));
@@ -218,6 +240,30 @@ describe("validateBoot — action wiring (no function values)", () => {
218
240
  expect(() => validateBoot([feature])).toThrow(/column "amount" renderer is a function/);
219
241
  });
220
242
 
243
+ test("projectionDetail relatedList column renderer as function → Throw (fw#2166)", () => {
244
+ const feature = defineFeature("shop", (r) => {
245
+ r.screen({
246
+ id: "order-detail",
247
+ type: "projectionDetail",
248
+ query: "shop:query:order:detail",
249
+ layout: {
250
+ sections: [
251
+ {
252
+ kind: "relatedList",
253
+ title: "Payments",
254
+ query: "shop:query:order:payments",
255
+ columns: [
256
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
257
+ { field: "amount", renderer: ((v: unknown) => String(v)) as any },
258
+ ],
259
+ },
260
+ ],
261
+ },
262
+ });
263
+ });
264
+ expect(() => validateBoot([feature])).toThrow(/column "amount" renderer is a function/);
265
+ });
266
+
221
267
  test("projectionList rowAction payload as function → Throw", () => {
222
268
  const feature = defineFeature("shop", (r) => {
223
269
  r.screen({
@@ -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", () => {