@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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
568
|
-
|
|
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>,
|