@cosmicdrift/kumiko-framework 0.225.0 → 0.227.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.
@@ -354,16 +354,15 @@ describe("buildAppSchema", () => {
354
354
  // Bounded completeness check, scoped to MultiSelectFieldDef's own key set
355
355
  // (packages/types/src/fields.ts) — NOT a general FieldDefinition lockstep.
356
356
  // A full-union version would have to classify every property of all 20
357
- // FieldDefinition variants as forwarded or server-only, and several are
358
- // known-forwarded-but-missing today (e.g. image's `capture`, embedded's
359
- // `schema`/`derived`/`totals`/`minItems`/`maxItems`, date's
360
- // `min`/`max`/`locale`, longText's `multiline`) — declaring those
361
- // "server-only" here would be a false claim, and fixing them is out of
362
- // scope for fw#2494 (see report for the full gap list). This check only
363
- // covers the type this fix actually touches.
357
+ // FieldDefinition variants as forwarded or server-only. fw#2497 closed the
358
+ // gaps fw#2494 flagged for other variants (image's `capture`, embedded's
359
+ // `derived`/`totals`/`totalsMatch`/`minItems`/`maxItems`, date's
360
+ // `min`/`max`/`locale`, longText's `multiline`, array/object `default`) —
361
+ // see the dedicated tests below. This check only covers the type this fix
362
+ // actually touches.
364
363
  //
365
364
  // The `_allKeysClassified` assignment below is the actual guard: if
366
- // MultiSelectFieldDef ever gains a key that isn't in one of the three
365
+ // MultiSelectFieldDef ever gains a key that isn't in one of the two
367
366
  // lists, `Exclude<keyof MultiSelectFieldDef, Classified>` stops being
368
367
  // `never` and the assignment fails to typecheck — `bun run typecheck`
369
368
  // (part of `bun run kumiko check`) catches it even without touching this
@@ -376,6 +375,9 @@ describe("buildAppSchema", () => {
376
375
  "display",
377
376
  "columns",
378
377
  "maxRows",
378
+ "default", // array-valued for multiSelect — projectField's
379
+ // isJsonSafeValue() (fw#2497) now recurses into arrays/plain
380
+ // objects instead of only string/number/boolean/null.
379
381
  ] as const;
380
382
 
381
383
  const SERVER_ONLY_KEYS = [
@@ -390,20 +392,7 @@ describe("buildAppSchema", () => {
390
392
  "subjectRef", // GDPR-hook-coverage marker, not a renderer concern
391
393
  ] as const;
392
394
 
393
- // Keys that ARE client-relevant in principle but aren't forwarded today —
394
- // pre-existing gaps, each with its own reason, none introduced by fw#2494.
395
- const KNOWN_GAP_KEYS = [
396
- "default", // array-valued for multiSelect, but projectField's isLiteral()
397
- // only accepts string/number/boolean/null — so array defaults are
398
- // silently dropped, never a real server-only property. Verified
399
- // empirically: a multiSelect field with `default: ["a"]` projects to
400
- // no `default` key at all. Separate pre-existing bug, out of scope here.
401
- ] as const;
402
-
403
- type Classified =
404
- | (typeof FORWARDED_KEYS)[number]
405
- | (typeof SERVER_ONLY_KEYS)[number]
406
- | (typeof KNOWN_GAP_KEYS)[number];
395
+ type Classified = (typeof FORWARDED_KEYS)[number] | (typeof SERVER_ONLY_KEYS)[number];
407
396
  const _allKeysClassified: Exclude<keyof MultiSelectFieldDef, Classified> extends never
408
397
  ? true
409
398
  : never = true;
@@ -415,6 +404,7 @@ describe("buildAppSchema", () => {
415
404
  required: true,
416
405
  filterable: true,
417
406
  options: ["a", "b"],
407
+ default: ["a"],
418
408
  display: "checkboxes",
419
409
  columns: 2,
420
410
  maxRows: 4,
@@ -449,6 +439,209 @@ describe("buildAppSchema", () => {
449
439
  }
450
440
  });
451
441
 
442
+ test("text/longText: multiline überlebt die Projection (fw#2497)", () => {
443
+ // Regression: without `multiline` in the client schema, DefaultInput
444
+ // always renders a single-line <input> — the textarea row count never
445
+ // arrives.
446
+ const entity = {
447
+ fields: {
448
+ notes: { type: "text", multiline: { rows: 6 } },
449
+ bio: { type: "text", multiline: true },
450
+ title: { type: "text" },
451
+ body: { type: "longText", multiline: true },
452
+ },
453
+ } as unknown as EntityDefinition;
454
+
455
+ const f = defineFeature("ent", (r) => {
456
+ r.entity("thing", entity);
457
+ });
458
+ const app = buildAppSchema(createRegistry([f]));
459
+ const fields = (
460
+ app.features[0]!.entities["thing"] as unknown as {
461
+ fields: Record<string, Record<string, unknown>>;
462
+ }
463
+ ).fields;
464
+
465
+ expect(fields["notes"]?.["multiline"]).toEqual({ rows: 6 });
466
+ expect(fields["bio"]?.["multiline"]).toBe(true);
467
+ expect(fields["body"]?.["multiline"]).toBe(true);
468
+ // Fields without multiline don't carry the key (no false-litter).
469
+ expect(fields["title"]?.["multiline"]).toBeUndefined();
470
+ });
471
+
472
+ test("number/date/timestamp/locatedTimestamp: min/max/locale überleben die Projection (fw#2497)", () => {
473
+ // Regression: input bounds and locale overrides never arrived at the
474
+ // renderer, so the browser's own range validation and date-picker
475
+ // formatting were always off.
476
+ const entity = {
477
+ fields: {
478
+ quantity: { type: "number", min: 0, max: 100 },
479
+ birthday: { type: "date", min: "1900-01-01", max: "2026-08-28", locale: "de-DE" },
480
+ startedAt: {
481
+ type: "timestamp",
482
+ min: "2020-01-01T00:00:00Z",
483
+ max: "2030-01-01T00:00:00Z",
484
+ locale: "de-DE",
485
+ },
486
+ pickupAt: {
487
+ type: "locatedTimestamp",
488
+ min: "2020-01-01T00:00:00",
489
+ max: "2030-01-01T00:00:00",
490
+ locale: "de-DE",
491
+ },
492
+ },
493
+ } as unknown as EntityDefinition;
494
+
495
+ const f = defineFeature("ent", (r) => {
496
+ r.entity("thing", entity);
497
+ });
498
+ const app = buildAppSchema(createRegistry([f]));
499
+ const fields = (
500
+ app.features[0]!.entities["thing"] as unknown as {
501
+ fields: Record<string, Record<string, unknown>>;
502
+ }
503
+ ).fields;
504
+
505
+ expect(fields["quantity"]?.["min"]).toBe(0);
506
+ expect(fields["quantity"]?.["max"]).toBe(100);
507
+ expect(fields["birthday"]?.["min"]).toBe("1900-01-01");
508
+ expect(fields["birthday"]?.["max"]).toBe("2026-08-28");
509
+ expect(fields["birthday"]?.["locale"]).toBe("de-DE");
510
+ expect(fields["startedAt"]?.["min"]).toBe("2020-01-01T00:00:00Z");
511
+ expect(fields["startedAt"]?.["locale"]).toBe("de-DE");
512
+ expect(fields["pickupAt"]?.["max"]).toBe("2030-01-01T00:00:00");
513
+ expect(fields["pickupAt"]?.["locale"]).toBe("de-DE");
514
+ });
515
+
516
+ test("image: capture überlebt die Projection (fw#2497)", () => {
517
+ // Regression: `capture` decides whether a mobile file picker opens the
518
+ // rear or front camera — never arrived, so it never applied.
519
+ const entity = {
520
+ fields: {
521
+ idPhoto: { type: "image", capture: "environment" },
522
+ avatar: { type: "image" },
523
+ },
524
+ } as unknown as EntityDefinition;
525
+
526
+ const f = defineFeature("ent", (r) => {
527
+ r.entity("thing", entity);
528
+ });
529
+ const app = buildAppSchema(createRegistry([f]));
530
+ const fields = (
531
+ app.features[0]!.entities["thing"] as unknown as {
532
+ fields: Record<string, Record<string, unknown>>;
533
+ }
534
+ ).fields;
535
+
536
+ expect(fields["idPhoto"]?.["capture"]).toBe("environment");
537
+ expect(fields["avatar"]?.["capture"]).toBeUndefined();
538
+ });
539
+
540
+ test("embedded: minItems/maxItems/derived/totals/totalsMatch überleben die Projection (fw#2497)", () => {
541
+ // Regression: `totals` carries "Renderer metadata: numeric sub-field
542
+ // names to sum in a totals row" in its own doc-comment but was never
543
+ // forwarded — same for the other embedded-list renderer hints.
544
+ const entity = {
545
+ fields: {
546
+ lines: {
547
+ type: "embedded",
548
+ multiple: true,
549
+ schema: {
550
+ qty: { type: "number" },
551
+ amount: { type: "money" },
552
+ },
553
+ minItems: 1,
554
+ maxItems: 20,
555
+ derived: { amount: { op: "multiply", from: ["qty", "unitPrice"] } },
556
+ totals: ["amount"],
557
+ totalsMatch: { amount: "invoiceTotal" },
558
+ },
559
+ },
560
+ } as unknown as EntityDefinition;
561
+
562
+ const f = defineFeature("ent", (r) => {
563
+ r.entity("thing", entity);
564
+ });
565
+ const app = buildAppSchema(createRegistry([f]));
566
+ const fields = (
567
+ app.features[0]!.entities["thing"] as unknown as {
568
+ fields: Record<string, Record<string, unknown>>;
569
+ }
570
+ ).fields;
571
+
572
+ expect(fields["lines"]?.["minItems"]).toBe(1);
573
+ expect(fields["lines"]?.["maxItems"]).toBe(20);
574
+ expect(fields["lines"]?.["derived"]).toEqual({
575
+ amount: { op: "multiply", from: ["qty", "unitPrice"] },
576
+ });
577
+ expect(fields["lines"]?.["totals"]).toEqual(["amount"]);
578
+ expect(fields["lines"]?.["totalsMatch"]).toEqual({ amount: "invoiceTotal" });
579
+ });
580
+
581
+ test("embedded: derived/totalsMatch mit Function eine Ebene tief bleiben blockiert (fw#2497)", () => {
582
+ // Regression: a shallow `isPlainObject`/`Array.isArray` check alone
583
+ // would let a function survive one level deep — `derived`/`totals`/
584
+ // `totalsMatch`/`multiline` must go through isJsonSafeValue() too, not
585
+ // just `default`.
586
+ const entity = {
587
+ fields: {
588
+ lines: {
589
+ type: "embedded",
590
+ multiple: true,
591
+ schema: { qty: { type: "number" } },
592
+ derived: { amount: { op: "multiply", from: () => ["qty"] } },
593
+ totals: [() => "amount"],
594
+ totalsMatch: { amount: () => "invoiceTotal" },
595
+ },
596
+ multi: { type: "text", multiline: { rows: () => 4 } },
597
+ },
598
+ } as unknown as EntityDefinition;
599
+
600
+ const f = defineFeature("ent", (r) => {
601
+ r.entity("thing", entity);
602
+ });
603
+ const app = buildAppSchema(createRegistry([f]));
604
+ const fields = (
605
+ app.features[0]!.entities["thing"] as unknown as {
606
+ fields: Record<string, Record<string, unknown>>;
607
+ }
608
+ ).fields;
609
+
610
+ expect(fields["lines"]?.["derived"]).toBeUndefined();
611
+ expect(fields["lines"]?.["totals"]).toBeUndefined();
612
+ expect(fields["lines"]?.["totalsMatch"]).toBeUndefined();
613
+ expect(fields["multi"]?.["multiline"]).toBeUndefined();
614
+ });
615
+
616
+ test("default: JSON-safe Arrays/Objects überleben die Projection, Nicht-JSON-Werte bleiben blockiert (fw#2497)", () => {
617
+ // isJsonSafeValue() now recurses into arrays/plain objects instead of
618
+ // only accepting string/number/boolean/null — but the defense-in-depth
619
+ // against smuggled function/class-instance defaults must still hold.
620
+ const entity = {
621
+ fields: {
622
+ tags: { type: "multiSelect", options: ["a", "b"], default: ["a"] },
623
+ nested: { type: "text", default: { rows: [1, "x"], flag: true } },
624
+ brokenFn: { type: "text", default: () => "x" },
625
+ brokenClass: { type: "text", default: new Date() },
626
+ },
627
+ } as unknown as EntityDefinition;
628
+
629
+ const f = defineFeature("ent", (r) => {
630
+ r.entity("thing", entity);
631
+ });
632
+ const app = buildAppSchema(createRegistry([f]));
633
+ const fields = (
634
+ app.features[0]!.entities["thing"] as unknown as {
635
+ fields: Record<string, Record<string, unknown>>;
636
+ }
637
+ ).fields;
638
+
639
+ expect(fields["tags"]?.["default"]).toEqual(["a"]);
640
+ expect(fields["nested"]?.["default"]).toEqual({ rows: [1, "x"], flag: true });
641
+ expect(fields["brokenFn"]?.["default"]).toBeUndefined();
642
+ expect(fields["brokenClass"]?.["default"]).toBeUndefined();
643
+ });
644
+
452
645
  test("AppSchema ist via JSON.stringify roundtrip-sicher", () => {
453
646
  // Echter Smoke-Test des Vertrags — wenn jemand in den project-
454
647
  // Helper eine Function reinschmuggelt, würde das hier brennen.
@@ -0,0 +1,141 @@
1
+ // fw#2490: `multiSelect` fields accept `filterable: true` at boot but the
2
+ // executor's screen-filter WHERE builder only ever emitted scalar equality
3
+ // (`col = $1`) against the jsonb-array column — Postgres rejects that as
4
+ // "operator does not exist: jsonb = text" the first time a filter actually
5
+ // runs. This proves the fix over real HTTP: eq/ne/in on a filterable
6
+ // multiSelect field must use jsonb containment (`@>`), not scalar equality.
7
+
8
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
+ import { deriveEntityTableMeta } from "../../db/entity-table-meta";
10
+ import { asRawClient, selectMany } from "../../db/query";
11
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
12
+ import { defineFeature } from "../define-feature";
13
+ import { createEntity, createMultiSelectField, createTextField } from "../factories";
14
+
15
+ const equipmentEntity = createEntity({
16
+ table: "ms_filter_equipment",
17
+ fields: {
18
+ name: createTextField({ required: true }),
19
+ tags: createMultiSelectField({
20
+ options: ["vip", "urgent", "loaner"] as const,
21
+ filterable: true,
22
+ }),
23
+ },
24
+ });
25
+
26
+ const LIST_QN = "checklist:query:equipment:list";
27
+
28
+ const checklistFeature = defineFeature("checklist", (r) => {
29
+ r.crud("equipment", equipmentEntity, {
30
+ write: { access: { roles: ["Admin"] } },
31
+ read: { access: { openToAll: true } },
32
+ });
33
+ });
34
+
35
+ describe("multiSelect filterable — jsonb containment, not scalar equality (fw#2490)", () => {
36
+ let stack: TestStack;
37
+
38
+ beforeAll(async () => {
39
+ stack = await setupTestStack({ features: [checklistFeature] });
40
+ await unsafeCreateEntityTable(stack.db, equipmentEntity);
41
+ });
42
+
43
+ afterAll(async () => {
44
+ await stack.cleanup();
45
+ });
46
+
47
+ beforeEach(async () => {
48
+ await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
49
+ await asRawClient(stack.db).unsafe('DELETE FROM "ms_filter_equipment"');
50
+ });
51
+
52
+ async function seed(): Promise<void> {
53
+ const CREATE = "checklist:write:equipment:create";
54
+ await stack.http.write(CREATE, { name: "Drill", tags: ["vip", "urgent"] }, TestUsers.admin);
55
+ await stack.http.write(CREATE, { name: "Ladder", tags: ["loaner"] }, TestUsers.admin);
56
+ await stack.http.write(CREATE, { name: "Saw", tags: [] }, TestUsers.admin);
57
+ }
58
+
59
+ test("op:eq on a multiSelect field returns rows whose array contains the value", async () => {
60
+ await seed();
61
+ const result = await stack.http.queryOk<{
62
+ readonly rows: readonly { readonly name: string; readonly tags: readonly string[] }[];
63
+ }>(LIST_QN, { limit: 50, filter: { field: "tags", op: "eq", value: "vip" } }, TestUsers.admin);
64
+
65
+ expect(result.rows).toHaveLength(1);
66
+ expect(result.rows[0]?.name).toBe("Drill");
67
+ });
68
+
69
+ test("op:eq with an array value returns rows whose array contains all listed values", async () => {
70
+ await seed();
71
+ const result = await stack.http.queryOk<{
72
+ readonly rows: readonly { readonly name: string }[];
73
+ }>(
74
+ LIST_QN,
75
+ { limit: 50, filter: { field: "tags", op: "eq", value: ["vip", "urgent"] } },
76
+ TestUsers.admin,
77
+ );
78
+
79
+ expect(result.rows.map((r) => r.name)).toEqual(["Drill"]);
80
+ });
81
+
82
+ test("op:ne on a multiSelect field returns rows whose array does not contain the value", async () => {
83
+ await seed();
84
+ const result = await stack.http.queryOk<{
85
+ readonly rows: readonly { readonly name: string }[];
86
+ }>(LIST_QN, { limit: 50, filter: { field: "tags", op: "ne", value: "vip" } }, TestUsers.admin);
87
+
88
+ expect(result.rows.map((r) => r.name).sort()).toEqual(["Ladder", "Saw"]);
89
+ });
90
+
91
+ test("op:in on a multiSelect field returns rows whose array contains any listed value", async () => {
92
+ await seed();
93
+ const result = await stack.http.queryOk<{
94
+ readonly rows: readonly { readonly name: string }[];
95
+ }>(
96
+ LIST_QN,
97
+ { limit: 50, filters: [{ field: "tags", op: "in", value: ["urgent", "loaner"] }] },
98
+ TestUsers.admin,
99
+ );
100
+
101
+ expect(result.rows.map((r) => r.name).sort()).toEqual(["Drill", "Ladder"]);
102
+ });
103
+
104
+ test("op:lt on a multiSelect field is unsatisfiable — empty result, no crash", async () => {
105
+ await seed();
106
+ const result = await stack.http.queryOk<{
107
+ readonly rows: readonly { readonly name: string }[];
108
+ }>(LIST_QN, { limit: 50, filter: { field: "tags", op: "lt", value: "vip" } }, TestUsers.admin);
109
+
110
+ expect(result.rows).toHaveLength(0);
111
+ });
112
+
113
+ // The issue's own diagnosis (fw#2490) points at buildWhereClause in
114
+ // bun-db/query.ts, not the screen-filter path above — that generic query
115
+ // API is what direct app/handler code hits when it filters a multiSelect
116
+ // column without going through a screen filter. Same jsonb-array bug,
117
+ // separate WHERE-builder, must be covered independently.
118
+ describe("generic selectMany() query API (bun-db/query.ts buildWhereClause)", () => {
119
+ const meta = deriveEntityTableMeta("equipment", equipmentEntity);
120
+
121
+ test("scalar equality uses jsonb containment, not `=`", async () => {
122
+ await seed();
123
+ const rows = await selectMany<{ name: string }>(stack.db, meta, { tags: "vip" });
124
+ expect(rows.map((r) => r.name).sort()).toEqual(["Drill"]);
125
+ });
126
+
127
+ test("`ne` returns rows whose array does not contain the value", async () => {
128
+ await seed();
129
+ const rows = await selectMany<{ name: string }>(stack.db, meta, { tags: { ne: "vip" } });
130
+ expect(rows.map((r) => r.name).sort()).toEqual(["Ladder", "Saw"]);
131
+ });
132
+
133
+ test("`in` returns rows whose array contains any listed value", async () => {
134
+ await seed();
135
+ const rows = await selectMany<{ name: string }>(stack.db, meta, {
136
+ tags: { in: ["urgent", "loaner"] },
137
+ });
138
+ expect(rows.map((r) => r.name).sort()).toEqual(["Drill", "Ladder"]);
139
+ });
140
+ });
141
+ });
@@ -149,7 +149,31 @@ describe("r.screen() — registration", () => {
149
149
  expect(() => validateBoot(features)).toThrow(/zero fields/i);
150
150
  });
151
151
 
152
- test("validateBoot rejects a projectionDetail with an extension section (no entity to persist against)", () => {
152
+ test("validateBoot rejects a projectionDetail extension section with contributesToFormSubmit: true (read-only screen, solon#264)", () => {
153
+ const features = [
154
+ defineFeature("app", (r) => {
155
+ r.screen({
156
+ id: "x",
157
+ type: "projectionDetail",
158
+ query: "app:query:foo:detail",
159
+ layout: {
160
+ sections: [
161
+ {
162
+ kind: "extension",
163
+ title: "s",
164
+ component: { react: { __component: "c" } },
165
+ entityName: "lease",
166
+ contributesToFormSubmit: true,
167
+ },
168
+ ],
169
+ },
170
+ });
171
+ }),
172
+ ];
173
+ expect(() => validateBoot(features)).toThrow(/no form submit to contribute to/i);
174
+ });
175
+
176
+ test("validateBoot rejects a projectionDetail extension section without entityName", () => {
153
177
  const features = [
154
178
  defineFeature("app", (r) => {
155
179
  r.screen({
@@ -164,7 +188,49 @@ describe("r.screen() — registration", () => {
164
188
  });
165
189
  }),
166
190
  ];
167
- expect(() => validateBoot(features)).toThrow(/extension section/i);
191
+ expect(() => validateBoot(features)).toThrow(/has no entityName/i);
192
+ });
193
+
194
+ test("validateBoot rejects a projectionDetail extension section with no component", () => {
195
+ const features = [
196
+ defineFeature("app", (r) => {
197
+ r.screen({
198
+ id: "x",
199
+ type: "projectionDetail",
200
+ query: "app:query:foo:detail",
201
+ layout: {
202
+ sections: [{ kind: "extension", title: "s", component: {}, entityName: "lease" }],
203
+ },
204
+ });
205
+ }),
206
+ ];
207
+ expect(() => validateBoot(features)).toThrow(/has no component/i);
208
+ });
209
+
210
+ test("validateBoot boots a projectionDetail extension section without contributesToFormSubmit (self-persisting, solon#264)", () => {
211
+ const features = [
212
+ defineFeature("app", (r) => {
213
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
214
+ access: { openToAll: true },
215
+ });
216
+ r.screen({
217
+ id: "x",
218
+ type: "projectionDetail",
219
+ query: "app:query:foo:detail",
220
+ layout: {
221
+ sections: [
222
+ {
223
+ kind: "extension",
224
+ title: "s",
225
+ component: { react: { __component: "c" } },
226
+ entityName: "lease",
227
+ },
228
+ ],
229
+ },
230
+ });
231
+ }),
232
+ ];
233
+ expect(() => validateBoot(features)).not.toThrow();
168
234
  });
169
235
 
170
236
  test("validateBoot rejects a projectionDetail relatedList section with an empty query (fw#2166)", () => {
@@ -46,6 +46,7 @@ import {
46
46
  import { validateOwnershipRules } from "./ownership";
47
47
  import { validatePiiAndRetention } from "./pii-retention";
48
48
  import { validateProjectionListScreens } from "./projection-list-screens";
49
+ import { validateQueryOutputColumns } from "./query-output-columns";
49
50
  import { validateQueryRefs } from "./query-refs";
50
51
  import {
51
52
  collectScreenQns,
@@ -222,6 +223,9 @@ export function validateBoot(
222
223
  // misleading "no search parameter in its Zod schema" error instead of
223
224
  // the clear typo message below.
224
225
  validateQueryRefs(features);
226
+ // Must also run after validateQueryRefs — column/field checks below
227
+ // assume every `query` string already resolves to a registered handler.
228
+ validateQueryOutputColumns(features);
225
229
  validateProjectionListScreens(features);
226
230
  validateExtensionPreSaveWiring(features);
227
231
  validateGdprStoragePersistence(features);
@@ -1,7 +1,7 @@
1
- import { ZodObject } from "zod";
2
1
  import { QnTypes, qualifyEntityName } from "../qualified-name";
3
2
  import type { FeatureDefinition, ProjectionListScreenDefinition, QueryHandlerDef } from "../types";
4
3
  import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens";
4
+ import { getZodObjectShape } from "./zod-shape";
5
5
 
6
6
  // Sibling to entity-list-screens.ts rather than an extension of it:
7
7
  // validateOneEntityListScreen is typed to EntityListScreenDefinition and
@@ -27,7 +27,7 @@ export function buildQueryHandlerMap(
27
27
  // unresolved query handlers both fall through to "capability absent" —
28
28
  // consistent with buildAppSchema's derivation, no throw either way.
29
29
  function schemaAccepts(schema: QueryHandlerDef["schema"] | undefined, key: string): boolean {
30
- const shape = schema instanceof ZodObject ? schema.shape : undefined;
30
+ const shape = getZodObjectShape(schema);
31
31
  return shape !== undefined && key in shape;
32
32
  }
33
33