@cosmicdrift/kumiko-framework 0.203.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.203.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.203.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.203.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
  },
@@ -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\//,
@@ -240,6 +240,30 @@ describe("validateBoot — action wiring (no function values)", () => {
240
240
  expect(() => validateBoot([feature])).toThrow(/column "amount" renderer is a function/);
241
241
  });
242
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
+
243
267
  test("projectionList rowAction payload as function → Throw", () => {
244
268
  const feature = defineFeature("shop", (r) => {
245
269
  r.screen({
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
2
3
  import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
3
4
  import { validateBoot as validateBootRaw } from "../boot-validator";
5
+ import { createTenantConfig } from "../config-helpers";
4
6
  import { defineFeature } from "../define-feature";
5
7
  import { createDerivedField, createEntity, createTextField } from "../factories";
6
8
  import { createRegistry } from "../registry";
@@ -165,6 +167,196 @@ describe("r.screen() — registration", () => {
165
167
  expect(() => validateBoot(features)).toThrow(/extension section/i);
166
168
  });
167
169
 
170
+ test("validateBoot rejects a projectionDetail relatedList section with an empty query (fw#2166)", () => {
171
+ const features = [
172
+ defineFeature("app", (r) => {
173
+ r.screen({
174
+ id: "x",
175
+ type: "projectionDetail",
176
+ query: "app:query:foo:detail",
177
+ layout: {
178
+ sections: [{ kind: "relatedList", title: "s", query: "", columns: ["name"] }],
179
+ },
180
+ });
181
+ }),
182
+ ];
183
+ expect(() => validateBoot(features)).toThrow(
184
+ /Screen "x".*relatedList.*empty or non-string query/,
185
+ );
186
+ });
187
+
188
+ test("validateBoot rejects a relatedList rowClick with no matching detailFor screen (fw#2166)", () => {
189
+ const features = [
190
+ defineFeature("app", (r) => {
191
+ r.screen({
192
+ id: "x",
193
+ type: "projectionDetail",
194
+ query: "app:query:foo:detail",
195
+ layout: {
196
+ sections: [
197
+ {
198
+ kind: "relatedList",
199
+ title: "s",
200
+ query: "app:query:foo:list",
201
+ columns: ["name"],
202
+ rowClick: { entity: "invoice" },
203
+ },
204
+ ],
205
+ },
206
+ });
207
+ }),
208
+ ];
209
+ expect(() => validateBoot(features)).toThrow(
210
+ /Add detailFor: "invoice" to the screen that shows it\./,
211
+ );
212
+ });
213
+
214
+ test("validateBoot accepts a relatedList rowClick with a detailFor screen in another feature (fw#2166)", () => {
215
+ const features = [
216
+ defineFeature("app", (r) => {
217
+ r.screen({
218
+ id: "x",
219
+ type: "projectionDetail",
220
+ query: "app:query:foo:detail",
221
+ layout: {
222
+ sections: [
223
+ {
224
+ kind: "relatedList",
225
+ title: "s",
226
+ query: "app:query:foo:list",
227
+ columns: ["name"],
228
+ rowClick: { entity: "invoice" },
229
+ },
230
+ ],
231
+ },
232
+ });
233
+ }),
234
+ defineFeature("billing", (r) => {
235
+ r.entity(
236
+ "invoice",
237
+ createEntity({ table: "invoices", fields: { name: createTextField() } }),
238
+ );
239
+ r.screen({
240
+ id: "invoice-detail",
241
+ type: "custom",
242
+ renderer: { react: { __component: "stub" } },
243
+ detailFor: "invoice",
244
+ });
245
+ }),
246
+ ];
247
+ expect(() => validateBoot(features)).not.toThrow();
248
+ });
249
+
250
+ test("validateBoot rejects a relatedList section in a wizard layout (fw#2166)", () => {
251
+ const features = [
252
+ defineFeature("app", (r) => {
253
+ r.screen({
254
+ id: "x",
255
+ type: "projectionDetail",
256
+ query: "app:query:foo:detail",
257
+ layout: {
258
+ mode: "wizard",
259
+ sections: [
260
+ { kind: "relatedList", title: "s", query: "app:query:foo:list", columns: ["name"] },
261
+ ],
262
+ },
263
+ });
264
+ }),
265
+ ];
266
+ expect(() => validateBoot(features)).toThrow(/relatedList.*wizard layout/);
267
+ });
268
+
269
+ test("validateBoot accepts a valid relatedList section without rowClick (fw#2166)", () => {
270
+ const features = [
271
+ defineFeature("app", (r) => {
272
+ r.screen({
273
+ id: "x",
274
+ type: "projectionDetail",
275
+ query: "app:query:foo:detail",
276
+ layout: {
277
+ sections: [
278
+ { kind: "relatedList", title: "s", query: "app:query:foo:list", columns: ["name"] },
279
+ ],
280
+ },
281
+ });
282
+ }),
283
+ ];
284
+ expect(() => validateBoot(features)).not.toThrow();
285
+ });
286
+
287
+ test("validateBoot rejects a relatedList section on entityEdit (fw#2166)", () => {
288
+ const features = [
289
+ defineFeature("shop", (r) => {
290
+ r.entity("product", productEntity());
291
+ r.screen({
292
+ id: "product-edit",
293
+ type: "entityEdit",
294
+ entity: "product",
295
+ layout: {
296
+ sections: [
297
+ { kind: "relatedList", title: "s", query: "shop:query:foo:list", columns: ["name"] },
298
+ ],
299
+ },
300
+ });
301
+ }),
302
+ ];
303
+ expect(() => validateBoot(features)).toThrow(
304
+ /\(entityEdit\) relatedList section "s".*projectionDetail-only/,
305
+ );
306
+ });
307
+
308
+ test("validateBoot rejects a relatedList section on configEdit (fw#2166)", () => {
309
+ const features = [
310
+ defineFeature("shop", (r) => {
311
+ r.config({
312
+ keys: { "site-name": createTenantConfig("text", { default: "" }) },
313
+ });
314
+ r.screen({
315
+ id: "settings",
316
+ type: "configEdit",
317
+ scope: "tenant",
318
+ configKeys: { siteName: "shop:config:site-name" },
319
+ fields: { siteName: { type: "text" } } as never,
320
+ layout: {
321
+ sections: [
322
+ { kind: "relatedList", title: "s", query: "shop:query:foo:list", columns: ["name"] },
323
+ ],
324
+ },
325
+ });
326
+ }),
327
+ ];
328
+ expect(() => validateBoot(features)).toThrow(
329
+ /\(configEdit\) relatedList section "s".*projectionDetail-only/,
330
+ );
331
+ });
332
+
333
+ test("validateBoot rejects a relatedList section on actionForm (fw#2166)", () => {
334
+ const features = [
335
+ defineFeature("shop", (r) => {
336
+ r.writeHandler(
337
+ "restock",
338
+ z.object({}),
339
+ async () => ({ isSuccess: true as const, data: null }),
340
+ { access: { roles: ["Admin"] } },
341
+ );
342
+ r.screen({
343
+ id: "restock",
344
+ type: "actionForm",
345
+ handler: "shop:write:restock",
346
+ fields: { qty: { type: "number" } } as never,
347
+ layout: {
348
+ sections: [
349
+ { kind: "relatedList", title: "s", query: "shop:query:foo:list", columns: ["name"] },
350
+ ],
351
+ },
352
+ });
353
+ }),
354
+ ];
355
+ expect(() => validateBoot(features)).toThrow(
356
+ /\(actionForm\) relatedList section "s".*projectionDetail-only/,
357
+ );
358
+ });
359
+
168
360
  test("stores an entityEdit screen with sections + conditional fields", () => {
169
361
  const feature = defineFeature("shop", (r) => {
170
362
  r.entity("product", productEntity());
@@ -1,4 +1,4 @@
1
- import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
1
+ import { isFieldsEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
2
2
  import type {
3
3
  EditFieldSpec,
4
4
  EditLayout,
@@ -112,7 +112,13 @@ function validateEditLayoutNoFunctions(
112
112
  layout: EditLayout,
113
113
  ): void {
114
114
  for (const section of layout.sections) {
115
- if (isExtensionEditSection(section)) continue;
115
+ // relatedList carries `columns` instead of `fields` — route it through
116
+ // the same column check entityList/projectionList top-level columns use.
117
+ if (section.kind === "relatedList") {
118
+ validateColumnsNoFunctions(featureName, screenId, screenType, section.columns);
119
+ continue;
120
+ }
121
+ if (!isFieldsEditSection(section)) continue;
116
122
  for (const fieldSpec of section.fields) {
117
123
  validateEditFieldNoFunctions(featureName, screenId, screenType, fieldSpec);
118
124
  }
@@ -381,6 +381,35 @@ export function validateScreens(
381
381
  `section to persist against.`,
382
382
  );
383
383
  }
384
+ if (section.kind === "relatedList") {
385
+ if (!section.query || typeof section.query !== "string") {
386
+ throw new Error(
387
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
388
+ `(relatedList) has empty or non-string query.`,
389
+ );
390
+ }
391
+ if (screen.layout.mode === "wizard") {
392
+ throw new Error(
393
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
394
+ `is kind "relatedList" in a wizard layout — a read-only list is not supported in a ` +
395
+ `stepped form. Remove mode: "wizard" or drop the relatedList section.`,
396
+ );
397
+ }
398
+ if (section.rowClick !== undefined) {
399
+ const targetEntity = section.rowClick.entity;
400
+ const hasDetailScreen = [...featureMap.values()].some((f) =>
401
+ Object.values(f.screens).some((s) => s.detailFor === targetEntity),
402
+ );
403
+ if (!hasDetailScreen) {
404
+ throw new Error(
405
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
406
+ `(relatedList) rowClick targets entity "${targetEntity}", but no screen declares ` +
407
+ `detailFor: "${targetEntity}". Add detailFor: "${targetEntity}" to the screen that shows it.`,
408
+ );
409
+ }
410
+ }
411
+ continue;
412
+ }
384
413
  if (section.fields.length === 0) {
385
414
  throw new Error(
386
415
  `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has a section "${section.title}" ` +
@@ -474,6 +503,13 @@ export function validateScreens(
474
503
  }
475
504
  continue;
476
505
  }
506
+ if (section.kind === "relatedList") {
507
+ throw new Error(
508
+ `[Feature ${feature.name}] Screen "${screenId}" (configEdit) relatedList section ` +
509
+ `"${section.title}" is not supported — relatedList is a projectionDetail-only ` +
510
+ `primitive (fw#2166).`,
511
+ );
512
+ }
477
513
  if (section.fields.length === 0) {
478
514
  throw new Error(
479
515
  `[Feature ${feature.name}] Screen "${screenId}" (configEdit) has a section "${section.title}" ` +
@@ -582,6 +618,13 @@ export function validateScreens(
582
618
  }
583
619
  continue;
584
620
  }
621
+ if (section.kind === "relatedList") {
622
+ throw new Error(
623
+ `[Feature ${feature.name}] Screen "${screenId}" (actionForm) relatedList section ` +
624
+ `"${section.title}" is not supported — relatedList is a projectionDetail-only ` +
625
+ `primitive (fw#2166).`,
626
+ );
627
+ }
585
628
  if (section.fields.length === 0) {
586
629
  throw new Error(
587
630
  `[Feature ${feature.name}] Screen "${screenId}" (actionForm) has a section "${section.title}" ` +
@@ -909,6 +952,13 @@ export function validateScreens(
909
952
  }
910
953
  continue;
911
954
  }
955
+ if (section.kind === "relatedList") {
956
+ throw new Error(
957
+ `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) relatedList section ` +
958
+ `"${section.title}" is not supported — relatedList is a projectionDetail-only ` +
959
+ `primitive (fw#2166).`,
960
+ );
961
+ }
912
962
  if (section.fields.length === 0) {
913
963
  throw new Error(
914
964
  `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) has a section "${section.title}" ` +
@@ -227,7 +227,12 @@ export { runsInLane } from "./run-in";
227
227
  export type { StepListOutcome } from "./run-pipeline";
228
228
  export { runPipeline, runStepList } from "./run-pipeline";
229
229
  export { buildInsertSchema, buildUpdateSchema, fieldToZod } from "./schema-builder";
230
- export { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "./screen-helpers";
230
+ export {
231
+ isExtensionEditSection,
232
+ isFieldsEditSection,
233
+ normalizeEditField,
234
+ normalizeListColumn,
235
+ } from "./screen-helpers";
231
236
  export type { TransitionGraph } from "./state-machine";
232
237
  export { defineTransitions, guardTransition } from "./state-machine";
233
238
  export {
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  EditExtensionSection,
3
3
  EditFieldSpec,
4
+ EditFieldsSection,
4
5
  EditSectionSpec,
5
6
  FieldCondition,
6
7
  FormatSpec,
@@ -11,6 +12,10 @@ export function isExtensionEditSection(section: EditSectionSpec): section is Edi
11
12
  return section.kind === "extension";
12
13
  }
13
14
 
15
+ export function isFieldsEditSection(section: EditSectionSpec): section is EditFieldsSection {
16
+ return section.kind === undefined || section.kind === "fields";
17
+ }
18
+
14
19
  // Type guard — narrows FieldRenderer to FormatSpec. Useful for renderer
15
20
  // authors who branch on the three FieldRenderer variants without manual
16
21
  // "format" in renderer checks.
@@ -153,6 +153,7 @@ export function requiredKeysFromScreen(
153
153
  pushKey(out, section.title);
154
154
  continue;
155
155
  }
156
+ if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
156
157
  pushKey(out, section.title);
157
158
  for (const f of section.fields) {
158
159
  const fieldName = editFieldName(f);
@@ -174,6 +175,7 @@ export function requiredKeysFromScreen(
174
175
  pushKey(out, section.title);
175
176
  continue;
176
177
  }
178
+ if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
177
179
  pushKey(out, section.title);
178
180
  for (const f of section.fields) {
179
181
  const fieldName = editFieldName(f);
@@ -195,6 +197,7 @@ export function requiredKeysFromScreen(
195
197
  pushKey(out, section.title);
196
198
  continue;
197
199
  }
200
+ if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
198
201
  pushKey(out, section.title);
199
202
  for (const f of section.fields) {
200
203
  const fieldName = editFieldName(f);
@@ -209,6 +212,14 @@ export function requiredKeysFromScreen(
209
212
  const detail = screen as ProjectionDetailScreenDefinition;
210
213
  for (const section of detail.layout.sections) {
211
214
  if (isExtensionEditSection(section)) continue; // rejected at boot, unreachable here
215
+ if (section.kind === "relatedList") {
216
+ pushKey(out, section.title);
217
+ for (const col of section.columns) {
218
+ const normalized = normalizeListColumn(col);
219
+ if (normalized.label !== undefined) pushKey(out, normalized.label);
220
+ }
221
+ continue;
222
+ }
212
223
  pushKey(out, section.title);
213
224
  for (const f of section.fields) {
214
225
  const fieldName = editFieldName(f);
@@ -30,7 +30,7 @@ import {
30
30
  type EntityEditScreenDefinition,
31
31
  type EntityListScreenDefinition,
32
32
  type FieldDefinition,
33
- isExtensionEditSection,
33
+ isFieldsEditSection,
34
34
  normalizeEditField,
35
35
  normalizeListColumn,
36
36
  parseQn,
@@ -243,7 +243,7 @@ function collectRequiredEditFields(
243
243
  ): string[] {
244
244
  const out: string[] = [];
245
245
  for (const section of screen.layout.sections) {
246
- if (isExtensionEditSection(section)) continue;
246
+ if (!isFieldsEditSection(section)) continue;
247
247
  for (const rawField of section.fields) {
248
248
  const { field } = normalizeEditField(rawField);
249
249
  const def = entity.fields[field];
@@ -281,7 +281,7 @@ function buildEditFillOps(
281
281
  // form; Felder ohne Fixture-Wert (file/image/…) werden übersprungen.
282
282
  const ops: EditFillOp[] = [];
283
283
  for (const section of screen.layout.sections) {
284
- if (isExtensionEditSection(section)) continue;
284
+ if (!isFieldsEditSection(section)) continue;
285
285
  for (const raw of section.fields) {
286
286
  const { field } = normalizeEditField(raw);
287
287
  const def = entity.fields[field];
@@ -329,7 +329,7 @@ function pickIdentifyingForEdit(
329
329
  : undefined;
330
330
 
331
331
  for (const section of editScreen.layout.sections) {
332
- if (isExtensionEditSection(section)) continue;
332
+ if (!isFieldsEditSection(section)) continue;
333
333
  for (const rawField of section.fields) {
334
334
  const { field } = normalizeEditField(rawField);
335
335
  if (entity.fields[field]?.type !== "text") continue;
@@ -28,6 +28,7 @@ export { parseRefTarget } from "../engine/parse-ref-target";
28
28
  export {
29
29
  evalFieldCondition,
30
30
  isExtensionEditSection,
31
+ isFieldsEditSection,
31
32
  isFormatSpec,
32
33
  normalizeEditField,
33
34
  normalizeListColumn,
@@ -71,6 +72,7 @@ export type {
71
72
  EditFieldSpec,
72
73
  EditFieldsSection,
73
74
  EditLayout,
75
+ EditRelatedListSection,
74
76
  EditSectionSpec,
75
77
  EntityEditScreenDefinition,
76
78
  EntityListScreenDefinition,