@cosmicdrift/kumiko-framework 0.194.0 → 0.196.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.194.0",
3
+ "version": "0.196.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>",
@@ -186,7 +186,7 @@
186
186
  "./package.json": "./package.json"
187
187
  },
188
188
  "dependencies": {
189
- "@cosmicdrift/kumiko-types": "0.194.0",
189
+ "@cosmicdrift/kumiko-types": "0.196.0",
190
190
  "bullmq": "^5.76.7",
191
191
  "bun-types": "^1.3.13",
192
192
  "hono": "^4.13.1",
@@ -202,7 +202,7 @@
202
202
  "zod": "^4.4.3"
203
203
  },
204
204
  "devDependencies": {
205
- "@cosmicdrift/kumiko-dispatcher-live": "0.194.0",
205
+ "@cosmicdrift/kumiko-dispatcher-live": "0.196.0",
206
206
  "bun-types": "^1.3.13",
207
207
  "pino-pretty": "^13.1.3"
208
208
  },
@@ -8,7 +8,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
8
  import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
9
9
  import { z } from "zod";
10
10
  import { defineFeature, EXT_DERIVATIVE_RENDERER } from "../../engine";
11
- import { InternalError, writeFailure } from "../../errors";
11
+ import { InternalError, NotFoundError, writeFailure } from "../../errors";
12
12
  import { createFilesFeature } from "../../files/feature";
13
13
  import { createInMemoryFileProvider } from "../../files/in-memory-provider";
14
14
  import { createTestUser, setupTestStack, type TestStack, testTenantId } from "../../stack";
@@ -41,6 +41,37 @@ const derivativesTestFeature = defineFeature("derivativestest", (r) => {
41
41
  { access: { openToAll: true } },
42
42
  );
43
43
 
44
+ // Catches the error INSIDE the handler (not via HTTP serialization) so the
45
+ // test can assert `instanceof` on the concrete error class — the wire
46
+ // response only carries the serialized WireErrorInfo, which loses that.
47
+ r.writeHandler(
48
+ "probe-typed-error",
49
+ z.object({ fileRefId: z.string() }),
50
+ async (event, ctx) => {
51
+ if (!ctx.derivatives) {
52
+ return writeFailure(
53
+ new InternalError({ message: "no ctx.derivatives on write-handler ctx" }),
54
+ );
55
+ }
56
+ try {
57
+ await ctx.derivatives.variant(event.payload.fileRefId, { maxEdge: 100 }, "thumb");
58
+ return { isSuccess: true as const, data: { threw: false } };
59
+ } catch (err) {
60
+ return {
61
+ isSuccess: true as const,
62
+ data: {
63
+ threw: true,
64
+ isNotFoundError: err instanceof NotFoundError,
65
+ notFoundHttpStatus: err instanceof NotFoundError ? err.httpStatus : undefined,
66
+ isInternalError: err instanceof InternalError,
67
+ internalHttpStatus: err instanceof InternalError ? err.httpStatus : undefined,
68
+ },
69
+ };
70
+ }
71
+ },
72
+ { access: { openToAll: true } },
73
+ );
74
+
44
75
  r.job("record", { trigger: { manual: true }, runIn: "worker" }, async (payload, ctx) => {
45
76
  const fileRefId = (payload as { fileRefId: string }).fileRefId;
46
77
  if (!ctx.derivatives) {
@@ -70,10 +101,13 @@ afterAll(async () => {
70
101
  await stack.cleanup();
71
102
  });
72
103
 
73
- async function uploadFile(asUser = user): Promise<string> {
104
+ async function uploadFile(
105
+ asUser = user,
106
+ file = new File([Buffer.from([1, 2, 3])], "photo.jpg", { type: "image/jpeg" }),
107
+ ): Promise<string> {
74
108
  const token = await stack.jwt.sign(asUser);
75
109
  const fd = new FormData();
76
- fd.append("file", new File([Buffer.from([1, 2, 3])], "photo.jpg", { type: "image/jpeg" }));
110
+ fd.append("file", file);
77
111
  const { body, contentType } = await buildMultipartBody(fd);
78
112
  const res = await stack.app.request("/api/files", {
79
113
  method: "POST",
@@ -129,7 +163,7 @@ describe("ctx.derivatives — the mandatory id/tenant/isDeleted filters", () =>
129
163
  { fileRefId: "00000000-0000-4000-8000-999999999999" },
130
164
  user,
131
165
  );
132
- expect(err.httpStatus).toBeGreaterThanOrEqual(400);
166
+ expect(err.httpStatus).toBe(404);
133
167
  });
134
168
 
135
169
  test("a soft-deleted fileRef throws", async () => {
@@ -146,7 +180,7 @@ describe("ctx.derivatives — the mandatory id/tenant/isDeleted filters", () =>
146
180
  { fileRefId: fileId },
147
181
  user,
148
182
  );
149
- expect(err.httpStatus).toBeGreaterThanOrEqual(400);
183
+ expect(err.httpStatus).toBe(404);
150
184
  });
151
185
 
152
186
  test("a fileRef uploaded under a different tenant is not resolvable", async () => {
@@ -157,6 +191,39 @@ describe("ctx.derivatives — the mandatory id/tenant/isDeleted filters", () =>
157
191
  { fileRefId: foreignFileId },
158
192
  user,
159
193
  );
160
- expect(err.httpStatus).toBeGreaterThanOrEqual(400);
194
+ expect(err.httpStatus).toBe(404);
195
+ });
196
+ });
197
+
198
+ describe("ctx.derivatives — typed errors from variant()", () => {
199
+ test("an unknown fileRefId throws NotFoundError with httpStatus 404", async () => {
200
+ const result = await stack.http.writeOk<{
201
+ threw: boolean;
202
+ isNotFoundError: boolean;
203
+ notFoundHttpStatus?: number;
204
+ }>(
205
+ "derivativestest:write:probe-typed-error",
206
+ { fileRefId: "00000000-0000-4000-8000-999999999999" },
207
+ user,
208
+ );
209
+ expect(result.threw).toBe(true);
210
+ expect(result.isNotFoundError).toBe(true);
211
+ expect(result.notFoundHttpStatus).toBe(404);
212
+ });
213
+
214
+ test("no renderer registered for the mimeType throws InternalError with httpStatus 500", async () => {
215
+ const fileId = await uploadFile(
216
+ user,
217
+ new File([Buffer.from([1, 2, 3])], "doc.pdf", { type: "application/pdf" }),
218
+ );
219
+
220
+ const result = await stack.http.writeOk<{
221
+ threw: boolean;
222
+ isInternalError: boolean;
223
+ internalHttpStatus?: number;
224
+ }>("derivativestest:write:probe-typed-error", { fileRefId: fileId }, user);
225
+ expect(result.threw).toBe(true);
226
+ expect(result.isInternalError).toBe(true);
227
+ expect(result.internalHttpStatus).toBe(500);
161
228
  });
162
229
  });
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
3
3
  import type { Registry } from "../../engine/types";
4
+ import { InternalError } from "../../errors";
4
5
  import { createFileContext } from "../../files/file-handle";
5
6
  import { createInMemoryFileProvider } from "../../files/in-memory-provider";
6
7
  import { createDerivativesContext, resolveRenderer } from "../derivatives-context";
@@ -133,7 +134,7 @@ describe("createDerivativesContext — variant()", () => {
133
134
  expect(second.storageKey).not.toBe(first.storageKey);
134
135
  });
135
136
 
136
- test("no renderer registered for the mimeType throws, naming the known patterns", async () => {
137
+ test("no renderer registered for the mimeType throws InternalError, naming the known patterns in details", async () => {
137
138
  const provider = createInMemoryFileProvider();
138
139
  await provider.write("tenant/doc.pdf", new Uint8Array([1]));
139
140
  const files = createFileContext(() => Promise.resolve(provider));
@@ -141,7 +142,13 @@ describe("createDerivativesContext — variant()", () => {
141
142
  const db = fakeDbWithFileRef({ storageKey: "tenant/doc.pdf", mimeType: "application/pdf" });
142
143
  const ctx = createDerivativesContext({ files, registry, db, tenantId: TENANT_ID });
143
144
 
144
- await expect(ctx.variant(FILE_REF_ID, {}, "thumb")).rejects.toThrow(/image\/\*/);
145
+ // The renderer list moved out of the client-visible `message` into
146
+ // `details` — InternalError's serialize() drops `details` from the wire
147
+ // response, so it must not be the only place a caller can find it.
148
+ const err = await ctx.variant(FILE_REF_ID, {}, "thumb").catch((e) => e);
149
+ expect(err).toBeInstanceOf(InternalError);
150
+ expect((err as InternalError).httpStatus).toBe(500);
151
+ expect((err as InternalError).details).toEqual({ knownRenderers: "image/*" });
145
152
  });
146
153
 
147
154
  test("mimeType is consistent across a fresh render and a cache hit for the same spec", async () => {
@@ -6,6 +6,7 @@ import type {
6
6
  import { type AnyDb, fetchOne } from "../bun-db/query";
7
7
  import { EXT_DERIVATIVE_RENDERER } from "../engine/extension-names";
8
8
  import type { Registry, TenantId } from "../engine/types";
9
+ import { InternalError, NotFoundError } from "../errors";
9
10
  import type { FileContext } from "../files/file-handle";
10
11
  import { fileRefsTable } from "../files/file-ref-table";
11
12
  import { assertSafeStorageKey } from "../files/types";
@@ -94,12 +95,10 @@ export function createDerivativesContext(deps: DerivativesContextDeps): Derivati
94
95
  isDeleted: false,
95
96
  });
96
97
  if (!row) {
97
- throw new Error(`derivatives.variant: no fileRef found for id "${fileRefId}"`);
98
+ throw new NotFoundError("fileRef", fileRefId);
98
99
  }
99
100
  if (!isFileRefRow(row)) {
100
- throw new Error(
101
- `derivatives.variant: fileRef "${fileRefId}" is missing storageKey/mimeType`,
102
- );
101
+ throw new NotFoundError("fileRef", fileRefId);
103
102
  }
104
103
 
105
104
  const renderer = resolveRenderer(deps.registry, row.mimeType);
@@ -109,9 +108,10 @@ export function createDerivativesContext(deps: DerivativesContextDeps): Derivati
109
108
  .getExtensionUsages(EXT_DERIVATIVE_RENDERER)
110
109
  .map((u) => u.entityName)
111
110
  .join(", ") || "<none>";
112
- throw new Error(
113
- `derivatives.variant: no renderer registered for mimeType "${row.mimeType}". Known: ${known}.`,
114
- );
111
+ throw new InternalError({
112
+ message: `derivatives.variant: no renderer registered for mimeType "${row.mimeType}".`,
113
+ details: { knownRenderers: known },
114
+ });
115
115
  }
116
116
 
117
117
  const src = deps.files.ref(row.storageKey);
@@ -4,11 +4,12 @@
4
4
  // into validateColumnRendererForm, splitting them would create a
5
5
  // same-folder require cycle.
6
6
 
7
+ import { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
7
8
  import { rowMetaFieldNames } from "../../db/table-builder";
8
9
  import { isValidQn, qualifyEntityName } from "../qualified-name";
9
10
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
10
11
  import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
11
- import type { EntityDefinition, FeatureDefinition, FieldDefinition } from "../types";
12
+ import type { EntityDefinition, FeatureDefinition } from "../types";
12
13
  import type {
13
14
  DashboardCustomPanel,
14
15
  DashboardFilterDefinition,
@@ -24,16 +25,6 @@ import type {
24
25
  ToolbarAction,
25
26
  } from "../types/screen";
26
27
 
27
- // Mirrors FIELD_TYPES_WITHOUT_WIDGET in packages/renderer/src/app/form-schema.ts.
28
- // Can't import it directly — renderer depends on framework, not the reverse.
29
- // Keep both lists in sync when a field type gains or loses an auto-wired widget.
30
- const NO_WIDGET_FIELD_TYPES: ReadonlySet<FieldDefinition["type"]> = new Set([
31
- "jsonb",
32
- "embedded",
33
- "files",
34
- "images",
35
- ]);
36
-
37
28
  // A field type in NO_WIDGET_FIELD_TYPES renders read-only on the auto-wired
38
29
  // entityEdit path (#1925) — a required field the user can never fill would
39
30
  // block every save. Only the statically-resolvable case is caught here: a
@@ -48,7 +39,7 @@ function validateNoWidgetRequiredField(
48
39
  ): void {
49
40
  const fieldDef = entityDef.fields[fieldSpec.field];
50
41
  // skip: field doesn't exist or its type already has a widget — nothing to validate.
51
- if (fieldDef === undefined || !NO_WIDGET_FIELD_TYPES.has(fieldDef.type)) return;
42
+ if (fieldDef === undefined || !NO_WIDGET_FIELD_TYPES.includes(fieldDef.type)) return;
52
43
  // Embedded LIST fields (`multiple: true`) get their own EmbeddedListField
53
44
  // grid widget (#1838) — only plain (non-list) embedded has no widget.
54
45
  const isEmbeddedList = fieldDef.type === "embedded" && fieldDef.multiple === true;
@@ -124,7 +115,7 @@ function validateRowActionNavigateParams(
124
115
  function validateWizardLayout(
125
116
  featureName: string,
126
117
  screenId: string,
127
- screenType: "entityEdit" | "actionForm",
118
+ screenType: "entityEdit" | "actionForm" | "configEdit",
128
119
  layout: EditLayout,
129
120
  featureMap: ReadonlyMap<string, FeatureDefinition>,
130
121
  ): void {
@@ -458,6 +449,7 @@ export function validateScreens(
458
449
  }
459
450
  }
460
451
  }
452
+ validateWizardLayout(feature.name, screenId, "configEdit", screen.layout, featureMap);
461
453
  // configKeys: jeder fieldName muss einen Mapping-Eintrag haben,
462
454
  // jeder qualifizierte Key muss in der Registry existieren.
463
455
  for (const fname of fieldNames) {
@@ -66,8 +66,16 @@ describe("buildMetricName", () => {
66
66
  expect(buildMetricName("orders", "created_total")).toBe("kumiko_orders_created_total");
67
67
  });
68
68
 
69
- it("rejects non-snake_case feature name", () => {
70
- expect(() => buildMetricName("Orders", "created_total")).toThrow(/snake_case/);
69
+ it("rejects a feature name that stays invalid after kebab-normalization", () => {
70
+ // A single leading capital ("Orders") now normalizes cleanly via toKebab
71
+ // — this must still reject a space, which toKebab doesn't touch.
72
+ expect(() => buildMetricName("orders team", "created_total")).toThrow(/snake_case/);
73
+ });
74
+
75
+ it("normalizes camelCase feature names the same as their kebab-case equivalent", () => {
76
+ expect(buildMetricName("aiFoundation", "created_total")).toBe(
77
+ buildMetricName("ai-foundation", "created_total"),
78
+ );
71
79
  });
72
80
  });
73
81
 
@@ -1,3 +1,4 @@
1
+ import { toKebab } from "../engine/qualified-name";
1
2
  import { assertUnreachable } from "../utils";
2
3
  import type { MetricType } from "./types";
3
4
 
@@ -74,11 +75,11 @@ export function validateMetricName(name: string, type: MetricType): void {
74
75
  // path (registry-ingest.ts) and the read path (ctx.metrics / ctx.metricsFor),
75
76
  // instead of the kebab form being rejected outright (framework#1844).
76
77
  export function buildMetricName(featureName: string, shortName: string): string {
77
- const normalizedFeatureName = featureName.replace(/-/g, "_");
78
+ const normalizedFeatureName = toKebab(featureName).replace(/-/g, "_");
78
79
  if (!SNAKE_CASE.test(normalizedFeatureName)) {
79
80
  throw new Error(
80
- `[Kumiko Observability] Feature name "${featureName}" must be kebab-case or snake_case ` +
81
- `(a-z, 0-9, "-" or "_").`,
81
+ `[Kumiko Observability] Feature name "${featureName}" must be kebab-case, camelCase, ` +
82
+ `or snake_case (a-z, 0-9, "-" or "_").`,
82
83
  );
83
84
  }
84
85
  return `kumiko_${normalizedFeatureName}_${shortName}`;
@@ -149,6 +149,39 @@ const bridgeFeature = defineFeature("ctxbridge", (r) => {
149
149
  { access: { roles: ["Admin"] } },
150
150
  );
151
151
 
152
+ // Records whether ctx.db threw (should, once the request signal is
153
+ // aborted) and whether the ctx.dbOutsideTransaction insert still landed
154
+ // (should — durability writes must survive a client disconnect).
155
+ //
156
+ // The throwing side reads via ctx.db.selectMany, not the event-store
157
+ // executor's create() — the latter writes through db.raw (bypassing
158
+ // TenantDb's withDbSpan/signal check entirely), so it wouldn't exercise
159
+ // the signal wiring this test is meant to prove. insertOne isn't an
160
+ // option either: bagTable is executor-managed (WritableTable rejects its
161
+ // EXECUTOR_ONLY brand) — direct writes would drift it past its event
162
+ // stream. selectMany has no such restriction (reads keep the plain
163
+ // SchemaTable param) and still goes through the same signal check.
164
+ r.writeHandler(
165
+ "bag:create-signal-probe",
166
+ z.object({ label: z.string() }),
167
+ async (event, ctx) => {
168
+ const crud = createEventStoreExecutor(bagTable, bagEntity, { entityName: "bag" });
169
+ let dbThrewAbortError = false;
170
+ try {
171
+ await ctx.db?.selectMany(bagTable, {});
172
+ } catch (err) {
173
+ dbThrewAbortError = err instanceof Error && err.name === "AbortError";
174
+ }
175
+ const outsideTx = ctx.dbOutsideTransaction;
176
+ if (!outsideTx) {
177
+ throw new Error("bag:create-signal-probe requires ctx.dbOutsideTransaction");
178
+ }
179
+ await crud.create({ label: `${event.payload.label}-outside-tx` }, event.user, outsideTx);
180
+ return { isSuccess: true as const, data: { dbThrewAbortError } };
181
+ },
182
+ { access: { roles: ["Admin"] } },
183
+ );
184
+
152
185
  // afterCommit hook on bag — fires once per outer commit.
153
186
  r.hook("postSave", { allOf: bag }, async (result) => {
154
187
  afterCommitLog.push(`bag:${result.data["label"]}`);
@@ -266,4 +299,35 @@ describe("ctx.dbOutsideTransaction", () => {
266
299
  const labels = (bags as Array<Record<string, unknown>>).map((row) => row["label"]);
267
300
  expect(labels).toEqual(["probe-outside-tx"]);
268
301
  });
302
+
303
+ test("an already-aborted request signal fails ctx.db but not ctx.dbOutsideTransaction", async () => {
304
+ const controller = new AbortController();
305
+ controller.abort();
306
+ const token = await stack.jwt.sign(admin);
307
+
308
+ const res = await stack.app.request(
309
+ new Request("http://test.local/api/write", {
310
+ method: "POST",
311
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
312
+ body: JSON.stringify({
313
+ type: "ctxbridge:write:bag:create-signal-probe",
314
+ payload: { label: "signal-probe" },
315
+ }),
316
+ signal: controller.signal,
317
+ }),
318
+ );
319
+
320
+ const body = (await res.json()) as {
321
+ isSuccess: boolean;
322
+ data?: { dbThrewAbortError: boolean };
323
+ };
324
+ expect(body.isSuccess).toBe(true);
325
+ expect(body.data?.dbThrewAbortError).toBe(true);
326
+
327
+ // Only the outside-tx insert landed — ctx.db's insert threw before it
328
+ // could write, and there was nothing to roll back for it.
329
+ const bags = await selectMany(stack.db, bagTable);
330
+ const labels = (bags as Array<Record<string, unknown>>).map((row) => row["label"]);
331
+ expect(labels).toEqual(["signal-probe-outside-tx"]);
332
+ });
269
333
  });
@@ -170,24 +170,27 @@ export async function buildHandlerContext(
170
170
  // but at this point we're the root of the pipeline — cast is safe.
171
171
  const dbSource = resolveDbSource(ctx, tx);
172
172
  const reqCtx = requestContext.get();
173
- const buildTenantScopedDb = (source: DbConnection | DbTx) =>
173
+ const buildTenantScopedDb = (source: DbConnection | DbTx, signal: AbortSignal | undefined) =>
174
174
  createTenantDb(
175
175
  source,
176
176
  user.tenantId,
177
177
  isSystem ? "system" : "tenant",
178
178
  context.tracer,
179
179
  context.meter,
180
- // Propagate the request's AbortSignal so every TenantDb query
181
- // throws when the client has disconnected — handlers with many
182
- // sequential queries skip the rest of the chain instead of
183
- // burning DB-CPU for results no one reads.
184
- reqCtx?.signal,
180
+ signal,
185
181
  );
186
- const db = dbSource ? buildTenantScopedDb(dbSource) : undefined;
182
+ // Propagate the request's AbortSignal so every TenantDb query throws when
183
+ // the client has disconnected — handlers with many sequential queries skip
184
+ // the rest of the chain instead of burning DB-CPU for results no one reads.
185
+ const db = dbSource ? buildTenantScopedDb(dbSource, reqCtx?.signal) : undefined;
187
186
  // Unbound pool, tenant-scoped like `db` but never tx-bound — writes
188
- // through it survive a rollback of the handler's own transaction.
187
+ // through it survive a rollback of the handler's own transaction. No
188
+ // AbortSignal here: a client disconnect must not abort a durability write
189
+ // that is meant to outlive the request.
189
190
  const outsideTxSource = resolveDbSource(ctx, undefined);
190
- const dbOutsideTransaction = outsideTxSource ? buildTenantScopedDb(outsideTxSource) : undefined;
191
+ const dbOutsideTransaction = outsideTxSource
192
+ ? buildTenantScopedDb(outsideTxSource, undefined)
193
+ : undefined;
191
194
  const log = context.log?.child({
192
195
  handler: type,
193
196
  tenantId: user.tenantId,
@@ -20,6 +20,7 @@
20
20
  // When adding a symbol here, verify it's either a type or a pure
21
21
  // helper with no cross-module side-effects.
22
22
 
23
+ export { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
23
24
  export type { DerivedCellRoundingTarget } from "../engine/embedded-derived";
24
25
  export { computeDerivedCellValue, roundDerivedCellValue } from "../engine/embedded-derived";
25
26
  export type { ParsedRefTarget } from "../engine/parse-ref-target";