@cosmicdrift/kumiko-framework 0.205.0 → 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.205.0",
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.205.0",
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.205.0",
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
+ }
@@ -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
  });
@@ -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