@cosmicdrift/kumiko-framework 0.171.0 → 0.171.1

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.171.0",
3
+ "version": "0.171.1",
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>",
@@ -182,7 +182,7 @@
182
182
  "./package.json": "./package.json"
183
183
  },
184
184
  "dependencies": {
185
- "@cosmicdrift/kumiko-types": "0.171.0",
185
+ "@cosmicdrift/kumiko-types": "0.171.1",
186
186
  "bullmq": "^5.76.7",
187
187
  "bun-types": "^1.3.13",
188
188
  "hono": "^4.12.27",
@@ -198,7 +198,7 @@
198
198
  "zod": "^4.4.3"
199
199
  },
200
200
  "devDependencies": {
201
- "@cosmicdrift/kumiko-dispatcher-live": "0.171.0",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.171.1",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -200,6 +200,25 @@ CREATE TABLE IF NOT EXISTS "read_widgets_v2" ("id" uuid PRIMARY KEY);
200
200
  expect(cap.err.join("\n")).toContain("read_widgets");
201
201
  });
202
202
 
203
+ test("migration creates a table with no snapshot entry → unexpected-table hint points at r.storeTable, not hand-fix", async () => {
204
+ writeSchemaFile(appCwd, "read_widgets");
205
+ await runSchemaCli(["generate", "init"], appCwd, captureOut().out);
206
+ writeFileSync(
207
+ join(appCwd, "kumiko/migrations/0002_raw.sql"),
208
+ `-- hand-written table outside the entity system, never registered via r.storeTable
209
+ CREATE TABLE IF NOT EXISTS "marketing_waitlist" ("id" uuid PRIMARY KEY);
210
+ `,
211
+ );
212
+ const cap = captureOut();
213
+ const code = await runSchemaCli(["validate"], appCwd, cap.out);
214
+ expect(code).toBe(1);
215
+ const err = cap.err.join("\n");
216
+ expect(err).toContain("marketing_waitlist");
217
+ expect(err).toContain("Fix (unexpected-table)");
218
+ expect(err).toContain("r.storeTable");
219
+ expect(err).not.toContain("Fix (missing-table/column-drift)");
220
+ });
221
+
203
222
  test("no FEATURES export → validateBoot skipped (drift still checked)", async () => {
204
223
  writeSchemaFile(appCwd, "read_widgets");
205
224
  await runSchemaCli(["generate", "init"], appCwd, captureOut().out);
@@ -419,6 +419,36 @@ describe("buildInsertSchema", () => {
419
419
  const schema = buildInsertSchema(entity);
420
420
  expect(schema.safeParse({ locale: "" }).success).toBe(false);
421
421
  });
422
+
423
+ // #1702: same ""-problem as #1674, but for a select WITH a default —
424
+ // an untouched <select> sends "" and the default only kicks in for
425
+ // undefined. "" maps to the default (a defaulted field is never unset).
426
+ test("optional select with default accepts empty string and falls back to the default", () => {
427
+ const entity = createEntity({
428
+ table: "Test",
429
+ fields: {
430
+ locale: createSelectField({ options: ["de", "en", "fr"] as const, default: "de" }),
431
+ },
432
+ });
433
+ const schema = buildInsertSchema(entity);
434
+ const result = schema.safeParse({ locale: "" });
435
+ expect(result.success).toBe(true);
436
+ if (result.success) {
437
+ expect(result.data["locale"]).toBe("de");
438
+ }
439
+ });
440
+
441
+ test("optional select with default still validates a real value", () => {
442
+ const entity = createEntity({
443
+ table: "Test",
444
+ fields: {
445
+ locale: createSelectField({ options: ["de", "en"] as const, default: "de" }),
446
+ },
447
+ });
448
+ const schema = buildInsertSchema(entity);
449
+ expect(schema.safeParse({ locale: "en" }).success).toBe(true);
450
+ expect(schema.safeParse({ locale: "xx" }).success).toBe(false);
451
+ });
422
452
  });
423
453
 
424
454
  // --- Update schema (all partial) ---
@@ -489,4 +519,24 @@ describe("buildUpdateSchema", () => {
489
519
  expect(Object.hasOwn(result.data, "locale")).toBe(false);
490
520
  }
491
521
  });
522
+
523
+ // Update schemas strip defaults deliberately (buildUpdateSchema never
524
+ // applies them — omitting a field must leave it untouched). So "" maps
525
+ // to the same explicit clear-to-null as the no-default case; only the
526
+ // insert path falls back to the default (#1702).
527
+ test("optional select with default on update: empty string is a clear-to-null, not the default", () => {
528
+ const entity = createEntity({
529
+ table: "Test",
530
+ fields: {
531
+ locale: createSelectField({ options: ["de", "en"] as const, default: "de" }),
532
+ },
533
+ });
534
+
535
+ const schema = buildUpdateSchema(entity);
536
+ const result = schema.safeParse({ locale: "" });
537
+ expect(result.success).toBe(true);
538
+ if (result.success) {
539
+ expect(result.data["locale"]).toBeNull();
540
+ }
541
+ });
492
542
  });
@@ -0,0 +1,84 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+ import type { FeatureDefinition } from "../../types";
3
+ import { warnOnUniqueAccessRoles } from "../access-roles";
4
+
5
+ function fakeFeature(overrides: Partial<FeatureDefinition> & { name: string }): FeatureDefinition {
6
+ return {
7
+ writeHandlers: {},
8
+ queryHandlers: {},
9
+ streamHandlers: {},
10
+ ...overrides,
11
+ } as unknown as FeatureDefinition;
12
+ }
13
+
14
+ describe("warnOnUniqueAccessRoles", () => {
15
+ let warnSpy: ReturnType<typeof spyOn<Console, "warn">>;
16
+
17
+ beforeEach(() => {
18
+ warnSpy = spyOn(console, "warn");
19
+ });
20
+
21
+ afterEach(() => {
22
+ warnSpy.mockRestore();
23
+ });
24
+
25
+ test("warns when a role is used by exactly one handler", () => {
26
+ const features = [
27
+ fakeFeature({
28
+ name: "users",
29
+ writeHandlers: {
30
+ "create-user": { name: "create-user", access: { roles: ["Admin"] } },
31
+ } as unknown as FeatureDefinition["writeHandlers"],
32
+ }),
33
+ ];
34
+
35
+ warnOnUniqueAccessRoles(features);
36
+
37
+ expect(warnSpy).toHaveBeenCalledTimes(1);
38
+ const msg = warnSpy.mock.calls[0]![0] as string;
39
+ expect(msg).toContain("Admin");
40
+ expect(msg).toContain("users:write:create-user");
41
+ });
42
+
43
+ test("does NOT warn when the same role is used by two different handlers", () => {
44
+ const features = [
45
+ fakeFeature({
46
+ name: "users",
47
+ writeHandlers: {
48
+ "create-user": { name: "create-user", access: { roles: ["Admin"] } },
49
+ } as unknown as FeatureDefinition["writeHandlers"],
50
+ }),
51
+ fakeFeature({
52
+ name: "billing",
53
+ queryHandlers: {
54
+ "list-invoices": { name: "list-invoices", access: { roles: ["Admin"] } },
55
+ } as unknown as FeatureDefinition["queryHandlers"],
56
+ }),
57
+ ];
58
+
59
+ warnOnUniqueAccessRoles(features);
60
+
61
+ expect(warnSpy).not.toHaveBeenCalled();
62
+ });
63
+
64
+ test("does NOT warn for built-in roles 'all' or 'system' even when used by one handler", () => {
65
+ const features = [
66
+ fakeFeature({
67
+ name: "misc",
68
+ writeHandlers: {
69
+ "open-endpoint": { name: "open-endpoint", access: { roles: ["all"] } },
70
+ } as unknown as FeatureDefinition["writeHandlers"],
71
+ }),
72
+ fakeFeature({
73
+ name: "admin-tools",
74
+ streamHandlers: {
75
+ "audit-log": { name: "audit-log", access: { roles: ["system"] } },
76
+ } as unknown as FeatureDefinition["streamHandlers"],
77
+ }),
78
+ ];
79
+
80
+ warnOnUniqueAccessRoles(features);
81
+
82
+ expect(warnSpy).not.toHaveBeenCalled();
83
+ });
84
+ });
@@ -0,0 +1,43 @@
1
+ import type { FeatureDefinition } from "../types";
2
+
3
+ const BUILTIN_ROLES = new Set(["all", "system"]);
4
+
5
+ export function warnOnUniqueAccessRoles(features: readonly FeatureDefinition[]): void {
6
+ // role → set of distinct handler identifiers using it
7
+ const roleHandlers = new Map<string, Set<string>>();
8
+
9
+ for (const f of features) {
10
+ const handlerGroups = [
11
+ { type: "write", defs: f.writeHandlers },
12
+ { type: "query", defs: f.queryHandlers },
13
+ { type: "stream", defs: f.streamHandlers },
14
+ ] as const;
15
+
16
+ for (const { type, defs } of handlerGroups) {
17
+ for (const [handlerName, def] of Object.entries(defs)) {
18
+ if (!def.access || !("roles" in def.access)) continue;
19
+ const identifier = `${f.name}:${type}:${handlerName}`;
20
+ for (const role of def.access.roles) {
21
+ let handlers = roleHandlers.get(role);
22
+ if (!handlers) {
23
+ handlers = new Set();
24
+ roleHandlers.set(role, handlers);
25
+ }
26
+ handlers.add(identifier);
27
+ }
28
+ }
29
+ }
30
+ }
31
+
32
+ for (const [role, handlers] of roleHandlers) {
33
+ if (BUILTIN_ROLES.has(role)) continue;
34
+ if (handlers.size !== 1) continue;
35
+ const [identifier] = handlers;
36
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
37
+ console.warn(
38
+ `[kumiko:boot] Access role "${role}" is only used by one handler (${identifier}). ` +
39
+ `This is often a typo — the role is unknown to every other handler in the boot scan. ` +
40
+ `If this is intentional, ignore this warning.`,
41
+ );
42
+ }
43
+ }
@@ -1,6 +1,7 @@
1
1
  import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
2
2
  import { QnTypes, qualifyEntityName } from "../qualified-name";
3
3
  import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
4
+ import { warnOnUniqueAccessRoles } from "./access-roles";
4
5
  import { validateActionWiring, validateFieldWiring } from "./action-wiring";
5
6
  import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
6
7
  import { validateFeatureBootChecks } from "./boot-check";
@@ -212,4 +213,5 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
212
213
 
213
214
  validateConfigReads(features, allConfigKeys);
214
215
  warnOnToggleableDependencies(features, featureMap);
216
+ warnOnUniqueAccessRoles(features);
215
217
  }
@@ -81,7 +81,14 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
81
81
  const [first, ...rest] = field.options;
82
82
  if (!first) return z.string();
83
83
  const enumSchema = z.enum([first, ...rest]);
84
- if (field.default !== undefined) return enumSchema.default(field.default);
84
+ if (field.default !== undefined)
85
+ // Untouched <select> sends "" too; with a default that maps to the
86
+ // default (same semantics as undefined) instead of the invalid-value
87
+ // rejection from #1702. A field with a default is never "unset".
88
+ return z.preprocess(
89
+ (value) => (value === "" ? field.default : value),
90
+ enumSchema.default(field.default),
91
+ );
85
92
  if (field.required) return enumSchema;
86
93
  // Optional select without a default: an untouched HTML <select> submits
87
94
  // "" for its placeholder option. Treat that as "unset" (null) instead of
package/src/schema-cli.ts CHANGED
@@ -287,9 +287,22 @@ export async function runSchemaCli(
287
287
  for (const m of mismatches) {
288
288
  out.err(` ${m.tableName} (${m.kind}): ${m.detail}`);
289
289
  }
290
- out.err(
291
- " Fix: a migration file's body doesn't match what it (or the snapshot) claims — hand-fix the file, or ship a corrective migration if it's already applied in prod.",
292
- );
290
+ if (mismatches.some((m) => m.kind === "unexpected-table")) {
291
+ out.err(
292
+ " Fix (unexpected-table): register the raw/hand-written table via `table()` " +
293
+ "(or `defineUnmanagedTable()`) from `@cosmicdrift/kumiko-framework/db`, then " +
294
+ "`r.storeTable(meta, { reason: ... })` inside a feature — this adds it to " +
295
+ "ENTITY_METAS and the snapshot without going through r.entity(). See the " +
296
+ "bundled `jobs` feature's job-run-log store table for the pattern.",
297
+ );
298
+ }
299
+ if (mismatches.some((m) => m.kind !== "unexpected-table")) {
300
+ out.err(
301
+ " Fix (missing-table/column-drift): a migration file's body doesn't match " +
302
+ "what it (or the snapshot) claims — hand-fix the file, or ship a corrective " +
303
+ "migration if it's already applied in prod.",
304
+ );
305
+ }
293
306
  }
294
307
  } catch (e) {
295
308
  // replayMigrationsDir fail-loud's on a table-DDL statement it can't