@cosmicdrift/kumiko-framework 0.250.0 → 0.251.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.250.0",
3
+ "version": "0.251.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.250.0",
201
+ "@cosmicdrift/kumiko-types": "0.251.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.250.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.251.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -362,6 +362,190 @@ describe("r.screen() — registration", () => {
362
362
  expect(() => validateBoot(features)).not.toThrow();
363
363
  });
364
364
 
365
+ describe("relatedList search + facets (fw#2740)", () => {
366
+ test("rejects searchable: true when the bound query's schema has no search parameter", () => {
367
+ const features = [
368
+ defineFeature("app", (r) => {
369
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
370
+ access: { openToAll: true },
371
+ });
372
+ r.queryHandler("foo:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
373
+ access: { openToAll: true },
374
+ });
375
+ r.screen({
376
+ id: "x",
377
+ type: "projectionDetail",
378
+ query: "app:query:foo:detail",
379
+ layout: {
380
+ sections: [
381
+ {
382
+ kind: "relatedList",
383
+ title: "s",
384
+ query: "app:query:foo:list",
385
+ columns: ["name"],
386
+ searchable: true,
387
+ },
388
+ ],
389
+ },
390
+ });
391
+ }),
392
+ ];
393
+ expect(() => validateBoot(features)).toThrow(/searchable: true.*"search"/);
394
+ });
395
+
396
+ test("accepts searchable: true when the bound query's schema accepts search", () => {
397
+ const features = [
398
+ defineFeature("app", (r) => {
399
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
400
+ access: { openToAll: true },
401
+ });
402
+ r.queryHandler(
403
+ "foo:list",
404
+ z.object({ search: z.string().optional() }),
405
+ async () => ({ rows: [], nextCursor: null }),
406
+ { access: { openToAll: true } },
407
+ );
408
+ r.screen({
409
+ id: "x",
410
+ type: "projectionDetail",
411
+ query: "app:query:foo:detail",
412
+ layout: {
413
+ sections: [
414
+ {
415
+ kind: "relatedList",
416
+ title: "s",
417
+ query: "app:query:foo:list",
418
+ columns: ["name"],
419
+ searchable: true,
420
+ },
421
+ ],
422
+ },
423
+ });
424
+ }),
425
+ ];
426
+ expect(() => validateBoot(features)).not.toThrow();
427
+ });
428
+
429
+ test("rejects facets when the bound query's schema has no filters parameter", () => {
430
+ const features = [
431
+ defineFeature("app", (r) => {
432
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
433
+ access: { openToAll: true },
434
+ });
435
+ r.queryHandler("foo:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
436
+ access: { openToAll: true },
437
+ });
438
+ r.screen({
439
+ id: "x",
440
+ type: "projectionDetail",
441
+ query: "app:query:foo:detail",
442
+ layout: {
443
+ sections: [
444
+ {
445
+ kind: "relatedList",
446
+ title: "s",
447
+ query: "app:query:foo:list",
448
+ columns: ["name", "status"],
449
+ facets: [
450
+ {
451
+ field: "status",
452
+ type: "select",
453
+ label: "Status",
454
+ options: [{ value: "active", label: "Active" }],
455
+ },
456
+ ],
457
+ },
458
+ ],
459
+ },
460
+ });
461
+ }),
462
+ ];
463
+ expect(() => validateBoot(features)).toThrow(/declares facets but query.*"filters"/);
464
+ });
465
+
466
+ test("rejects a facet whose field is not a declared column", () => {
467
+ const features = [
468
+ defineFeature("app", (r) => {
469
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
470
+ access: { openToAll: true },
471
+ });
472
+ r.queryHandler(
473
+ "foo:list",
474
+ z.object({ filters: z.array(z.object({})).optional() }),
475
+ async () => ({ rows: [], nextCursor: null }),
476
+ { access: { openToAll: true } },
477
+ );
478
+ r.screen({
479
+ id: "x",
480
+ type: "projectionDetail",
481
+ query: "app:query:foo:detail",
482
+ layout: {
483
+ sections: [
484
+ {
485
+ kind: "relatedList",
486
+ title: "s",
487
+ query: "app:query:foo:list",
488
+ columns: ["name"],
489
+ facets: [
490
+ {
491
+ field: "status",
492
+ type: "select",
493
+ label: "Status",
494
+ options: [{ value: "active", label: "Active" }],
495
+ },
496
+ ],
497
+ },
498
+ ],
499
+ },
500
+ });
501
+ }),
502
+ ];
503
+ expect(() => validateBoot(features)).toThrow(
504
+ /relatedList section "s".*facet references field "status" which is not a declared column/,
505
+ );
506
+ });
507
+
508
+ test("accepts valid facets when the bound query's schema accepts filters", () => {
509
+ const features = [
510
+ defineFeature("app", (r) => {
511
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
512
+ access: { openToAll: true },
513
+ });
514
+ r.queryHandler(
515
+ "foo:list",
516
+ z.object({ filters: z.array(z.object({})).optional() }),
517
+ async () => ({ rows: [], nextCursor: null }),
518
+ { access: { openToAll: true } },
519
+ );
520
+ r.screen({
521
+ id: "x",
522
+ type: "projectionDetail",
523
+ query: "app:query:foo:detail",
524
+ layout: {
525
+ sections: [
526
+ {
527
+ kind: "relatedList",
528
+ title: "s",
529
+ query: "app:query:foo:list",
530
+ columns: ["name", "status"],
531
+ facets: [
532
+ {
533
+ field: "status",
534
+ type: "select",
535
+ label: "Status",
536
+ options: [{ value: "active", label: "Active" }],
537
+ },
538
+ ],
539
+ },
540
+ ],
541
+ },
542
+ });
543
+ }),
544
+ ];
545
+ expect(() => validateBoot(features)).not.toThrow();
546
+ });
547
+ });
548
+
365
549
  test("validateBoot rejects a relatedList section on entityEdit (fw#2166)", () => {
366
550
  const features = [
367
551
  defineFeature("shop", (r) => {
@@ -46,7 +46,10 @@ import {
46
46
  } from "./nav";
47
47
  import { collectClaimKeys, validateOwnershipRules } from "./ownership";
48
48
  import { validatePiiAndRetention } from "./pii-retention";
49
- import { validateProjectionListScreens } from "./projection-list-screens";
49
+ import {
50
+ validateProjectionListScreens,
51
+ validateRelatedListSectionQueries,
52
+ } from "./projection-list-screens";
50
53
  import { validateQueryOutputColumns } from "./query-output-columns";
51
54
  import { validateQueryRefs } from "./query-refs";
52
55
  import {
@@ -224,6 +227,7 @@ export function validateBoot(
224
227
  // assume every `query` string already resolves to a registered handler.
225
228
  validateQueryOutputColumns(features);
226
229
  validateProjectionListScreens(features);
230
+ validateRelatedListSectionQueries(features);
227
231
  validateExtensionPreSaveWiring(features);
228
232
  validateGdprStoragePersistence(features);
229
233
  validateFeatureBootChecks(features);
@@ -121,3 +121,45 @@ export function validateProjectionListScreens(features: readonly FeatureDefiniti
121
121
  }
122
122
  }
123
123
  }
124
+
125
+ // relatedList sections declare search/facets the same way a projectionList
126
+ // screen does, and hit the same 422 footgun: definePagedQueryHandler doesn't
127
+ // auto-merge params into the handler's own Zod schema (fw#2165/#2224), so an
128
+ // undeclared param would fail every query instead of failing at boot.
129
+ // Reachable on projectionDetail only (screens.ts rejects relatedList
130
+ // elsewhere), but the layout walk stays uniform so lifting that restriction
131
+ // doesn't quietly skip these checks.
132
+ export function validateRelatedListSectionQueries(features: readonly FeatureDefinition[]): void {
133
+ const queryHandlers = buildQueryHandlerMap(features);
134
+ for (const feature of features) {
135
+ for (const screen of Object.values(feature.screens)) {
136
+ if (
137
+ screen.type !== "projectionDetail" &&
138
+ screen.type !== "entityEdit" &&
139
+ screen.type !== "actionForm" &&
140
+ screen.type !== "configEdit"
141
+ ) {
142
+ continue;
143
+ }
144
+ for (const section of screen.layout.sections) {
145
+ if (section.kind !== "relatedList") continue;
146
+ const prefix = `[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) relatedList section "${section.title}"`;
147
+ const schema = queryHandlers.get(section.query)?.schema;
148
+ if (section.searchable === true && !schemaAccepts(schema, "search")) {
149
+ throw new Error(
150
+ `${prefix}: searchable: true but query "${section.query}" has no "search" parameter in its Zod schema`,
151
+ );
152
+ }
153
+ if (
154
+ section.facets !== undefined &&
155
+ section.facets.length > 0 &&
156
+ !schemaAccepts(schema, "filters")
157
+ ) {
158
+ throw new Error(
159
+ `${prefix}: declares facets but query "${section.query}" has no "filters" parameter in its Zod schema — add filters: z.array(z.object({ field: z.string(), op: z.literal("in"), value: z.unknown() })).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
160
+ );
161
+ }
162
+ }
163
+ }
164
+ }
165
+ }
@@ -25,6 +25,8 @@ import type {
25
25
  EditFieldSpec,
26
26
  EditLayout,
27
27
  FieldCondition,
28
+ ListColumnSpec,
29
+ ListFacetSpec,
28
30
  RowAction,
29
31
  RowActionNavigateBase,
30
32
  RowFieldExtractor,
@@ -559,37 +561,12 @@ export function validateScreens(
559
561
  `filter.value to be a readonly array.`,
560
562
  );
561
563
  }
562
- // Facets (fw#2224) — unlike filter, a field inventory IS available
563
- // here: the declared columns. A facet on a field with no column is
564
- // almost always a typo (the user never sees the field anywhere), so
565
- // this is hard-checked rather than just documented.
566
564
  if (screen.facets !== undefined) {
567
- const columnFieldNames = new Set(
568
- screen.columns.map((col) => normalizeListColumn(col).field),
565
+ validateListFacets(
566
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionList)`,
567
+ screen.facets,
568
+ screen.columns,
569
569
  );
570
- const seenFacetFields = new Set<string>();
571
- for (const facet of screen.facets) {
572
- if (seenFacetFields.has(facet.field)) {
573
- throw new Error(
574
- `[Feature ${feature.name}] Screen "${screenId}" (projectionList) declares facet ` +
575
- `"${facet.field}" more than once.`,
576
- );
577
- }
578
- seenFacetFields.add(facet.field);
579
- if (!columnFieldNames.has(facet.field)) {
580
- throw new Error(
581
- `[Feature ${feature.name}] Screen "${screenId}" (projectionList) facet references field ` +
582
- `"${facet.field}" which is not a declared column. Known columns: ` +
583
- `${[...columnFieldNames].sort().join(", ")}`,
584
- );
585
- }
586
- if (facet.type === "select" && facet.options.length === 0) {
587
- throw new Error(
588
- `[Feature ${feature.name}] Screen "${screenId}" (projectionList) facet "${facet.field}" ` +
589
- `(type "select") has an empty options list — declare at least one option.`,
590
- );
591
- }
592
- }
593
570
  }
594
571
  if (screen.rowActions !== undefined) {
595
572
  for (const action of screen.rowActions) {
@@ -863,6 +840,13 @@ export function validateScreens(
863
840
  );
864
841
  }
865
842
  }
843
+ if (section.facets !== undefined) {
844
+ validateListFacets(
845
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) relatedList section "${section.title}"`,
846
+ section.facets,
847
+ section.columns,
848
+ );
849
+ }
866
850
  continue;
867
851
  }
868
852
  if (isWriteFormEditSection(section)) {
@@ -1805,6 +1789,38 @@ export function validateColumnRendererForm(
1805
1789
  }
1806
1790
  }
1807
1791
 
1792
+ // Facets (fw#2224) — unlike filter, a field inventory IS available here: the
1793
+ // declared columns. A facet on a field with no column is almost always a
1794
+ // typo (the user never sees the field anywhere), so this is hard-checked
1795
+ // rather than just documented. Shared by projectionList and relatedList
1796
+ // (fw#2740) — `prefix` carries the caller's own screen/section message lead-in.
1797
+ function validateListFacets(
1798
+ prefix: string,
1799
+ facets: readonly ListFacetSpec[],
1800
+ columns: readonly ListColumnSpec[],
1801
+ ): void {
1802
+ const columnFieldNames = new Set(columns.map((col) => normalizeListColumn(col).field));
1803
+ const seenFacetFields = new Set<string>();
1804
+ for (const facet of facets) {
1805
+ if (seenFacetFields.has(facet.field)) {
1806
+ throw new Error(`${prefix} declares facet "${facet.field}" more than once.`);
1807
+ }
1808
+ seenFacetFields.add(facet.field);
1809
+ if (!columnFieldNames.has(facet.field)) {
1810
+ throw new Error(
1811
+ `${prefix} facet references field "${facet.field}" which is not a declared column. ` +
1812
+ `Known columns: ${[...columnFieldNames].sort().join(", ")}`,
1813
+ );
1814
+ }
1815
+ if (facet.type === "select" && facet.options.length === 0) {
1816
+ throw new Error(
1817
+ `${prefix} facet "${facet.field}" (type "select") has an empty options list — ` +
1818
+ `declare at least one option.`,
1819
+ );
1820
+ }
1821
+ }
1822
+ }
1823
+
1808
1824
  export function findEntityFeature(
1809
1825
  entityName: string,
1810
1826
  featureMap: ReadonlyMap<string, FeatureDefinition>,