@cosmicdrift/kumiko-framework 0.204.1 → 0.206.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.204.1",
3
+ "version": "0.206.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.204.1",
193
+ "@cosmicdrift/kumiko-types": "0.206.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.204.1",
209
+ "@cosmicdrift/kumiko-dispatcher-live": "0.206.0",
210
210
  "bun-types": "^1.3.13",
211
211
  "pino-pretty": "^13.1.3"
212
212
  },
package/src/db/index.ts CHANGED
@@ -108,6 +108,7 @@ export {
108
108
  isUniqueViolation,
109
109
  type PgErrorInfo,
110
110
  } from "./pg-error";
111
+ export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock";
111
112
  export type { SelectOptions, WhereObject, WhereValue } from "./query-api";
112
113
  export {
113
114
  asRawClient,
@@ -0,0 +1,11 @@
1
+ import type { AnyDb } from "../query";
2
+ import { asRawClient } from "../query";
3
+
4
+ /** pg_advisory_xact_lock keyed on namespace+key hash — xact-scoped, auto-released at commit/rollback. */
5
+ export async function acquireNamespacedAdvisoryLock(
6
+ db: AnyDb,
7
+ namespace: number,
8
+ key: string,
9
+ ): Promise<void> {
10
+ await asRawClient(db).unsafe(`SELECT pg_advisory_xact_lock($1, hashtext($2))`, [namespace, key]);
11
+ }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
2
3
  import { requiredKeysFromScreen } from "../../i18n/required-surface-keys";
3
4
  import { validateBoot } from "../boot-validator";
4
5
  import { defineFeature } from "../define-feature";
@@ -17,6 +18,12 @@ function dashboardFeature(
17
18
  filter?: DashboardScreenDefinition["filter"],
18
19
  ) {
19
20
  return defineFeature("demo", (r) => {
21
+ r.queryHandler("incident:open-count", z.object({}), async () => ({ count: 3 }), {
22
+ access: { openToAll: true },
23
+ });
24
+ r.queryHandler("incident:latest", z.object({}), async () => ({ rows: [], nextCursor: null }), {
25
+ access: { openToAll: true },
26
+ });
20
27
  r.screen({
21
28
  id: "overview",
22
29
  type: "dashboard",
@@ -188,4 +188,54 @@ describe("validateBoot — projectionList screens", () => {
188
188
  });
189
189
  expect(() => validateBoot([feature])).toThrow(/paginated is derived/);
190
190
  });
191
+
192
+ // fw#2164 Nebenbefund: the "at most one rowClick:true" cap (already
193
+ // enforced for entityList) didn't run for projectionList — a boot-time
194
+ // gap, not a rendering one (the renderer only wires the first match, see
195
+ // ProjectionListBody), but two conflicting rowClick actions on the same
196
+ // screen should still fail loud instead of silently picking one.
197
+ describe("projectionList rowAction rowClick", () => {
198
+ function makeFeature(rowClickCount: number) {
199
+ return defineFeature("ledger", (r) => {
200
+ r.queryHandler(
201
+ "schedule:list",
202
+ z.object({}),
203
+ async () => ({ rows: [], nextCursor: null }),
204
+ {
205
+ access: { openToAll: true },
206
+ },
207
+ );
208
+ r.screen({ id: "schedule-detail", type: "custom", renderer: { react: "stub" } });
209
+ r.screen({
210
+ id: "schedule-list",
211
+ type: "projectionList",
212
+ query: "ledger:query:schedule:list",
213
+ columns: ["description"],
214
+ rowActions: Array.from({ length: rowClickCount }, (_, i) => ({
215
+ kind: "navigate" as const,
216
+ id: `open-${i}`,
217
+ label: "actions.open",
218
+ screen: "schedule-detail",
219
+ rowClick: true,
220
+ })),
221
+ });
222
+ r.translations({
223
+ keys: {
224
+ "screen:schedule-list.title": { de: "Liste", en: "List" },
225
+ "screen:schedule-detail.title": { de: "Detail", en: "Detail" },
226
+ },
227
+ });
228
+ });
229
+ }
230
+
231
+ test("one rowClick navigate action passes boot", () => {
232
+ expect(() => validateBoot([makeFeature(1)])).not.toThrow();
233
+ });
234
+
235
+ test("more than one rowClick action per list is rejected", () => {
236
+ expect(() => validateBoot([makeFeature(2)])).toThrow(
237
+ /at most one may fire on a row-body click/i,
238
+ );
239
+ });
240
+ });
191
241
  });
@@ -0,0 +1,151 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
4
+ import { validateBoot as validateBootRaw } from "../boot-validator";
5
+ import { defineFeature } from "../define-feature";
6
+
7
+ function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
8
+ validateBootRaw(withBootValidatorFixture(features));
9
+ }
10
+
11
+ // fw#2178: a screen's `query` (or a relatedList/dashboard-panel query) must
12
+ // resolve to a query-handler actually registered via r.queryHandler(...) —
13
+ // mirrors the existing rowAction/toolbarAction handler-existence check.
14
+ describe("validateBoot — query QN refs (fw#2178)", () => {
15
+ test("projectionList with a dead query QN throws (a)", () => {
16
+ const feature = defineFeature("ledger", (r) => {
17
+ r.screen({
18
+ id: "schedule-list",
19
+ type: "projectionList",
20
+ query: "ledger:query:schedule:ghost",
21
+ columns: ["description"],
22
+ });
23
+ });
24
+ expect(() => validateBoot([feature])).toThrow(
25
+ /query "ledger:query:schedule:ghost" is not a registered query-handler/,
26
+ );
27
+ });
28
+
29
+ // (b) targets projectionDetail rather than entityEdit: screens.ts
30
+ // unconditionally rejects a relatedList section on entityEdit/actionForm/
31
+ // configEdit with its own "projectionDetail-only primitive" error
32
+ // (fw#2166, screens.ts:955/:621/:506) — that fires first in the per-feature
33
+ // loop, before validateQueryRefs ever runs, so a dead QN there can't reach
34
+ // this check. projectionDetail is the one screen type where relatedList is
35
+ // actually reachable.
36
+ test("projectionDetail relatedList section with a dead query QN throws (b)", () => {
37
+ const feature = defineFeature("app", (r) => {
38
+ r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
39
+ access: { openToAll: true },
40
+ });
41
+ r.screen({
42
+ id: "rent-detail",
43
+ type: "projectionDetail",
44
+ query: "app:query:rent:detail",
45
+ layout: {
46
+ sections: [
47
+ {
48
+ kind: "relatedList",
49
+ title: "Payments",
50
+ query: "app:query:rent:payments-ghost",
51
+ columns: ["amount"],
52
+ },
53
+ ],
54
+ },
55
+ });
56
+ });
57
+ expect(() => validateBoot([feature])).toThrow(
58
+ /relatedList section "Payments" query "app:query:rent:payments-ghost" is not a registered query-handler/,
59
+ );
60
+ });
61
+
62
+ test("dashboard stat-group child with a dead query QN throws (c)", () => {
63
+ const feature = defineFeature("demo", (r) => {
64
+ r.screen({
65
+ id: "overview",
66
+ type: "dashboard",
67
+ panels: [
68
+ {
69
+ kind: "stat-group",
70
+ id: "net-worth",
71
+ label: "demo:dashboard:group:net-worth",
72
+ stats: [
73
+ {
74
+ kind: "stat",
75
+ id: "assets",
76
+ label: "demo:dashboard:panel:assets",
77
+ query: "demo:query:net-worth:assets-ghost",
78
+ valueField: "value",
79
+ },
80
+ ],
81
+ },
82
+ ],
83
+ });
84
+ r.translations({
85
+ keys: {
86
+ "demo:dashboard:group:net-worth": { de: "Net Worth", en: "Net Worth" },
87
+ "demo:dashboard:panel:assets": { de: "Assets", en: "Assets" },
88
+ },
89
+ });
90
+ });
91
+ expect(() => validateBoot([feature])).toThrow(
92
+ /stat-group "net-worth" child "assets" query "demo:query:net-worth:assets-ghost" is not a registered query-handler/,
93
+ );
94
+ });
95
+
96
+ test("dashboard filter optionsQuery with a dead query QN throws", () => {
97
+ const feature = defineFeature("demo", (r) => {
98
+ r.queryHandler("open-count", z.object({}), async () => ({ value: "0" }), {
99
+ access: { openToAll: true },
100
+ });
101
+ r.screen({
102
+ id: "overview",
103
+ type: "dashboard",
104
+ filter: {
105
+ id: "region",
106
+ label: "demo:dashboard:filter:region",
107
+ kind: "select",
108
+ optionsQuery: "demo:query:region:options-ghost",
109
+ },
110
+ panels: [
111
+ {
112
+ kind: "stat",
113
+ id: "open",
114
+ label: "demo:dashboard:panel:open",
115
+ query: "demo:query:open-count",
116
+ valueField: "value",
117
+ },
118
+ ],
119
+ });
120
+ r.translations({
121
+ keys: {
122
+ "demo:dashboard:panel:open": { de: "Offen", en: "Open" },
123
+ "demo:dashboard:filter:region": { de: "Region", en: "Region" },
124
+ },
125
+ });
126
+ });
127
+ expect(() => validateBoot([feature])).toThrow(
128
+ /filter "region" query "demo:query:region:options-ghost" is not a registered query-handler/,
129
+ );
130
+ });
131
+
132
+ test("a screen referencing a query registered by a different mounted feature does not throw (d)", () => {
133
+ const provider = defineFeature("catalog", (r) => {
134
+ r.queryHandler("items:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
135
+ access: { openToAll: true },
136
+ });
137
+ });
138
+ const consumer = defineFeature("storefront", (r) => {
139
+ r.screen({
140
+ id: "items",
141
+ type: "projectionList",
142
+ query: "catalog:query:items:list",
143
+ columns: ["name"],
144
+ });
145
+ r.translations({
146
+ keys: { "screen:items.title": { de: "Artikel", en: "Items" } },
147
+ });
148
+ });
149
+ expect(() => validateBoot([provider, consumer])).not.toThrow();
150
+ });
151
+ });
@@ -3838,6 +3838,9 @@ describe("boot-validator — config key backing × scope", () => {
3838
3838
  test("projectionList rowAction: navigate with params to an entityEdit-create target (no entityId) → no throw", () => {
3839
3839
  const feature = defineFeature("shop", (r) => {
3840
3840
  r.entity("product", createEntity({ fields: { name: createTextField() } }));
3841
+ r.queryHandler("products", z.object({}), async () => ({ rows: [], nextCursor: null }), {
3842
+ access: { openToAll: true },
3843
+ });
3841
3844
  r.screen({
3842
3845
  id: "product-projection",
3843
3846
  type: "projectionList",
@@ -41,6 +41,9 @@ describe("validateBoot — projectionDetail actions (fw#2166)", () => {
41
41
 
42
42
  test("valid navigate + writeHandler actions boot cleanly", () => {
43
43
  const feature = defineFeature("app", (r) => {
44
+ r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
45
+ access: { openToAll: true },
46
+ });
44
47
  r.writeHandler(
45
48
  "archive",
46
49
  z.object({ id: z.string() }),
@@ -214,6 +214,12 @@ describe("r.screen() — registration", () => {
214
214
  test("validateBoot accepts a relatedList rowClick with a detailFor screen in another feature (fw#2166)", () => {
215
215
  const features = [
216
216
  defineFeature("app", (r) => {
217
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
218
+ access: { openToAll: true },
219
+ });
220
+ r.queryHandler("foo:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
221
+ access: { openToAll: true },
222
+ });
217
223
  r.screen({
218
224
  id: "x",
219
225
  type: "projectionDetail",
@@ -269,6 +275,12 @@ describe("r.screen() — registration", () => {
269
275
  test("validateBoot accepts a valid relatedList section without rowClick (fw#2166)", () => {
270
276
  const features = [
271
277
  defineFeature("app", (r) => {
278
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
279
+ access: { openToAll: true },
280
+ });
281
+ r.queryHandler("foo:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
282
+ access: { openToAll: true },
283
+ });
272
284
  r.screen({
273
285
  id: "x",
274
286
  type: "projectionDetail",
@@ -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 { validateQueryRefs } from "./query-refs";
49
50
  import {
50
51
  collectScreenQns,
51
52
  collectScreensByShortId,
@@ -211,6 +212,11 @@ export function validateBoot(
211
212
  validateI18nSurfaceKeys(features);
212
213
  validateEntityListScreens(features);
213
214
  validateDetailForScreens(features, featureMap);
215
+ // Must run before validateProjectionListScreens: an unresolvable query
216
+ // there is silently treated as "capability absent" and surfaces as a
217
+ // misleading "no search parameter in its Zod schema" error instead of
218
+ // the clear typo message below.
219
+ validateQueryRefs(features);
214
220
  validateProjectionListScreens(features);
215
221
  validateExtensionPreSaveWiring(features);
216
222
  validateGdprStoragePersistence(features);
@@ -9,7 +9,9 @@ import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens";
9
9
  // entity, so sharing the function would mean threading a discriminated
10
10
  // union through every entity-bound helper it calls.
11
11
 
12
- function buildQueryHandlerMap(
12
+ // Exported for query-refs.ts (fw#2178) — one source for the QN-derivation
13
+ // logic instead of duplicating it in both boot-validators.
14
+ export function buildQueryHandlerMap(
13
15
  features: readonly FeatureDefinition[],
14
16
  ): ReadonlyMap<string, QueryHandlerDef> {
15
17
  const out = new Map<string, QueryHandlerDef>();
@@ -0,0 +1,129 @@
1
+ import type {
2
+ DashboardScreenDefinition,
3
+ EditLayout,
4
+ FeatureDefinition,
5
+ QueryHandlerDef,
6
+ ScreenDefinition,
7
+ } from "../types";
8
+ import { buildQueryHandlerMap } from "./projection-list-screens";
9
+
10
+ const NOT_REGISTERED_SUFFIX =
11
+ "is not a registered query-handler. Check the QN spelling (expected " +
12
+ '"<feature>:query:<short>") and that the handler is declared via r.queryHandler(...).';
13
+
14
+ function isNonEmptyQueryString(value: unknown): value is string {
15
+ return typeof value === "string" && value.length > 0;
16
+ }
17
+
18
+ function checkQueryRef(
19
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
20
+ query: string,
21
+ buildPrefix: () => string,
22
+ ): void {
23
+ // skip: empty/non-string queries get their own message in screens.ts;
24
+ // a registered query has nothing left to check.
25
+ if (!isNonEmptyQueryString(query) || queryHandlers.has(query)) return;
26
+ throw new Error(`${buildPrefix()} query "${query}" ${NOT_REGISTERED_SUFFIX}`);
27
+ }
28
+
29
+ // relatedList is currently only reachable on projectionDetail — screens.ts
30
+ // rejects it outright on entityEdit/actionForm/configEdit (fw#2166). Walking
31
+ // the shared EditLayout uniformly here (instead of excluding those three
32
+ // types) keeps this collector correct if that restriction is ever lifted.
33
+ function checkEditLayoutQueryRefs(
34
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
35
+ featureName: string,
36
+ screenId: string,
37
+ screenType: string,
38
+ layout: EditLayout,
39
+ ): void {
40
+ for (const section of layout.sections) {
41
+ if (section.kind !== "relatedList") continue;
42
+ checkQueryRef(
43
+ queryHandlers,
44
+ section.query,
45
+ () =>
46
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) relatedList section "${section.title}"`,
47
+ );
48
+ }
49
+ }
50
+
51
+ function checkDashboardQueryRefs(
52
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
53
+ featureName: string,
54
+ screenId: string,
55
+ screen: DashboardScreenDefinition,
56
+ ): void {
57
+ for (const panel of screen.panels) {
58
+ if (panel.kind === "custom") continue;
59
+ if (panel.kind === "stat-group") {
60
+ for (const stat of panel.stats) {
61
+ checkQueryRef(
62
+ queryHandlers,
63
+ stat.query,
64
+ () =>
65
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" child "${stat.id}"`,
66
+ );
67
+ }
68
+ continue;
69
+ }
70
+ checkQueryRef(
71
+ queryHandlers,
72
+ panel.query,
73
+ () => `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}"`,
74
+ );
75
+ }
76
+ if (screen.filter?.optionsQuery !== undefined) {
77
+ checkQueryRef(
78
+ queryHandlers,
79
+ screen.filter.optionsQuery,
80
+ () =>
81
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) filter "${screen.filter?.id}"`,
82
+ );
83
+ }
84
+ }
85
+
86
+ function checkScreenQueryRefs(
87
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
88
+ featureName: string,
89
+ screenId: string,
90
+ screen: ScreenDefinition,
91
+ ): void {
92
+ if (screen.type === "projectionList") {
93
+ checkQueryRef(
94
+ queryHandlers,
95
+ screen.query,
96
+ () => `[Feature ${featureName}] Screen "${screenId}" (projectionList)`,
97
+ );
98
+ } else if (screen.type === "projectionDetail") {
99
+ checkQueryRef(
100
+ queryHandlers,
101
+ screen.query,
102
+ () => `[Feature ${featureName}] Screen "${screenId}" (projectionDetail)`,
103
+ );
104
+ checkEditLayoutQueryRefs(
105
+ queryHandlers,
106
+ featureName,
107
+ screenId,
108
+ "projectionDetail",
109
+ screen.layout,
110
+ );
111
+ } else if (
112
+ screen.type === "entityEdit" ||
113
+ screen.type === "actionForm" ||
114
+ screen.type === "configEdit"
115
+ ) {
116
+ checkEditLayoutQueryRefs(queryHandlers, featureName, screenId, screen.type, screen.layout);
117
+ } else if (screen.type === "dashboard") {
118
+ checkDashboardQueryRefs(queryHandlers, featureName, screenId, screen);
119
+ }
120
+ }
121
+
122
+ export function validateQueryRefs(features: readonly FeatureDefinition[]): void {
123
+ const queryHandlers = buildQueryHandlerMap(features);
124
+ for (const feature of features) {
125
+ for (const [screenId, screen] of Object.entries(feature.screens)) {
126
+ checkScreenQueryRefs(queryHandlers, feature.name, screenId, screen);
127
+ }
128
+ }
129
+ }
@@ -25,6 +25,24 @@ import type {
25
25
  ToolbarAction,
26
26
  } from "../types/screen";
27
27
 
28
+ // entityList and projectionList both allow a rowAction to double as the
29
+ // row-body click target (rowClick: true, fw#1708/#2164) — at most one per
30
+ // screen, or the renderer can't tell which one should fire.
31
+ function validateAtMostOneRowClick(
32
+ featureName: string,
33
+ screenId: string,
34
+ screenType: "entityList" | "projectionList",
35
+ rowActions: readonly RowAction[],
36
+ ): void {
37
+ const rowClickActions = rowActions.filter((a) => a.kind === "navigate" && a.rowClick === true);
38
+ if (rowClickActions.length > 1) {
39
+ throw new Error(
40
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) has ${rowClickActions.length} ` +
41
+ "rowActions marked rowClick:true — at most one may fire on a row-body click.",
42
+ );
43
+ }
44
+ }
45
+
28
46
  // A field type in NO_WIDGET_FIELD_TYPES renders read-only on the auto-wired
29
47
  // entityEdit path (#1925) — a required field the user can never fill would
30
48
  // block every save. Only the statically-resolvable case is caught here: a
@@ -354,6 +372,7 @@ export function validateScreens(
354
372
  );
355
373
  }
356
374
  }
375
+ validateAtMostOneRowClick(feature.name, screenId, "projectionList", screen.rowActions);
357
376
  }
358
377
  continue;
359
378
  }
@@ -889,15 +908,7 @@ export function validateScreens(
889
908
  rowMeta,
890
909
  );
891
910
  }
892
- const rowClickActions = screen.rowActions.filter(
893
- (a) => a.kind === "navigate" && a.rowClick === true,
894
- );
895
- if (rowClickActions.length > 1) {
896
- throw new Error(
897
- `[Feature ${feature.name}] Screen "${screenId}" (entityList) has ${rowClickActions.length} ` +
898
- "rowActions marked rowClick:true — at most one may fire on a row-body click.",
899
- );
900
- }
911
+ validateAtMostOneRowClick(feature.name, screenId, "entityList", screen.rowActions);
901
912
  }
902
913
  // Tier 2.7e-2: toolbarActions — analog zu rowActions, aber bisher
903
914
  // ohne Validator. Typo'd navigate-targets und unregistrierte