@cosmicdrift/kumiko-framework 0.171.0 → 0.171.2
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 +3 -3
- package/src/__tests__/schema-cli.integration.test.ts +19 -0
- package/src/engine/__tests__/schema-builder.test.ts +50 -0
- package/src/engine/boot-validator/__tests__/access-roles.test.ts +84 -0
- package/src/engine/boot-validator/access-roles.ts +43 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/schema-builder.ts +8 -1
- package/src/schema-cli.ts +16 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.171.
|
|
3
|
+
"version": "0.171.2",
|
|
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.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.171.2",
|
|
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.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.171.2",
|
|
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)
|
|
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
|
-
|
|
291
|
-
|
|
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
|