@cosmicdrift/kumiko-framework 0.55.1 → 0.56.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.55.1",
3
+ "version": "0.56.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>",
@@ -181,8 +181,7 @@
181
181
  "zod": "^4.4.3"
182
182
  },
183
183
  "devDependencies": {
184
- "@cosmicdrift/kumiko-dispatcher-live": "0.50.0",
185
- "@types/uuid": "^11.0.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.55.1",
186
185
  "bun-types": "^1.3.13",
187
186
  "pino-pretty": "^13.1.3"
188
187
  },
@@ -51,7 +51,7 @@ export async function selectMembershipsOfUser(
51
51
 
52
52
  export async function selectAllTenants(db: AnyDb): Promise<readonly SeedTenantDbRow[]> {
53
53
  return (await asRawClient(db).unsafe(
54
- `SELECT id::text AS id, name, tenant_key
54
+ `SELECT id::text AS id, name, key AS tenant_key
55
55
  FROM read_tenants
56
56
  ORDER BY inserted_at`,
57
57
  )) as readonly SeedTenantDbRow[];
@@ -144,6 +144,43 @@ describe("buildSearchDocument — contributor precedence (base fields win)", ()
144
144
  expect(doc?.fields).toMatchObject({ title: "real-value", flatTags: "a,b" });
145
145
  expect(warnSpy).not.toHaveBeenCalled();
146
146
  });
147
+
148
+ test("collision dedup — same collision warns exactly once across calls", async () => {
149
+ const feature = defineFeature("test", (r) => {
150
+ const thing = r.entity(
151
+ "thing-dedup",
152
+ createEntity({
153
+ table: "things_dedup",
154
+ fields: { title: createTextField({ searchable: true }) },
155
+ }),
156
+ );
157
+ r.searchPayloadExtension(thing, () => ({ title: "from-contributor" }));
158
+ });
159
+ const registry = createRegistry([feature]);
160
+ await buildSearchDocument("thing-dedup", "t1", { title: "real-value" }, registry);
161
+ await buildSearchDocument("thing-dedup", "t1", { title: "real-value" }, registry);
162
+ expect(warnSpy).toHaveBeenCalledTimes(1);
163
+ });
164
+
165
+ test("contributor-vs-contributor collision warns with the right message", async () => {
166
+ const feature = defineFeature("test", (r) => {
167
+ const thing = r.entity(
168
+ "thing",
169
+ createEntity({ table: "things", fields: { title: createTextField({ searchable: true }) } }),
170
+ );
171
+ r.searchPayloadExtension(thing, () => ({ overlap: "first" }));
172
+ r.searchPayloadExtension(thing, () => ({ overlap: "second" }));
173
+ });
174
+ const registry = createRegistry([feature]);
175
+
176
+ const doc = await buildSearchDocument("thing", "t1", { title: "real-value" }, registry);
177
+ // Stammfield title → no collision. overlap is NOT a Stammfield, so the
178
+ // second contributor's "overlap" collides with the first one's → "earlier contributor key".
179
+ expect(doc?.fields["title"]).toBe("real-value");
180
+ expect(doc?.fields["overlap"]).toBe("first");
181
+ expect(warnSpy).toHaveBeenCalledTimes(1);
182
+ expect(warnSpy.mock.calls[0]?.[0]).toContain("earlier contributor key");
183
+ });
147
184
  });
148
185
 
149
186
  describe("Boot-Validation", () => {
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { validateAppCustomScreenWriteQns } from "../boot-validator/custom-screen-write-qns";
6
+
7
+ describe("validateAppCustomScreenWriteQns", () => {
8
+ test("throws on unknown dispatcher.write QN in app src", () => {
9
+ const appRoot = mkdtempSync(join(tmpdir(), "kumiko-boot-qns-"));
10
+ const srcDir = join(appRoot, "src", "screens");
11
+ mkdirSync(srcDir, { recursive: true });
12
+ writeFileSync(
13
+ join(srcDir, "list.tsx"),
14
+ `export function List() {
15
+ return null;
16
+ }
17
+ async function onDelete(dispatcher: { write: (t: string, p: unknown) => Promise<unknown> }) {
18
+ await dispatcher.write("shop:write:ghost-delete", { id: "1" });
19
+ }
20
+ `,
21
+ "utf-8",
22
+ );
23
+
24
+ const known = new Set(["shop:write:item:delete"]);
25
+ expect(() => validateAppCustomScreenWriteQns(appRoot, known)).toThrow(
26
+ /shop:write:ghost-delete/,
27
+ );
28
+ });
29
+
30
+ test("passes when QN is registered", () => {
31
+ const appRoot = mkdtempSync(join(tmpdir(), "kumiko-boot-qns-"));
32
+ const srcDir = join(appRoot, "src");
33
+ mkdirSync(srcDir, { recursive: true });
34
+ writeFileSync(
35
+ join(srcDir, "screen.tsx"),
36
+ `await dispatcher.write("shop:write:item:delete", { id: "1" });`,
37
+ "utf-8",
38
+ );
39
+
40
+ const known = new Set(["shop:write:item:delete"]);
41
+ expect(() => validateAppCustomScreenWriteQns(appRoot, known)).not.toThrow();
42
+ });
43
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ extractDispatcherWriteQnsFromSource,
4
+ validateDispatcherWriteQn,
5
+ WRITE_HANDLER_QN_FORMAT_RE,
6
+ } from "../write-handler-qn-extract";
7
+
8
+ describe("extractDispatcherWriteQnsFromSource", () => {
9
+ test("extracts string literals from dispatcher.write and .write calls", () => {
10
+ const source = `
11
+ await dispatcher.write("credit:write:create", { name: "x" });
12
+ await foo.write("tenant:write:update", payload);
13
+ `;
14
+ expect(extractDispatcherWriteQnsFromSource(source)).toEqual([
15
+ "credit:write:create",
16
+ "tenant:write:update",
17
+ ]);
18
+ });
19
+
20
+ test("skips dynamic QNs", () => {
21
+ const source = `await dispatcher.write(HANDLERS.delete, { id });`;
22
+ expect(extractDispatcherWriteQnsFromSource(source)).toEqual([]);
23
+ });
24
+ });
25
+
26
+ describe("validateDispatcherWriteQn", () => {
27
+ const known = new Set(["credit:write:credit:delete", "tenant:write:create"]);
28
+
29
+ test("accepts known QN", () => {
30
+ expect(validateDispatcherWriteQn("credit:write:credit:delete", known)).toEqual({ ok: true });
31
+ });
32
+
33
+ test("rejects invalid format", () => {
34
+ const result = validateDispatcherWriteQn("feautre-write-create", known);
35
+ expect(result.ok).toBe(false);
36
+ if (result.ok) throw new Error("unreachable");
37
+ expect(result.reason).toContain("invalid QN format");
38
+ });
39
+
40
+ test("rejects unknown QN when registry provided", () => {
41
+ const result = validateDispatcherWriteQn("credit:write:update", known);
42
+ expect(result.ok).toBe(false);
43
+ if (result.ok) throw new Error("unreachable");
44
+ expect(result.reason).toContain("unknown write handler");
45
+ });
46
+
47
+ test("format regex accepts 4-segment entity delete QNs", () => {
48
+ expect(WRITE_HANDLER_QN_FORMAT_RE.test("credit:write:credit:delete")).toBe(true);
49
+ });
50
+ });
@@ -0,0 +1,87 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import {
4
+ extractDispatcherWriteQnsFromSource,
5
+ validateDispatcherWriteQn,
6
+ } from "../write-handler-qn-extract";
7
+
8
+ const SKIP_SEGMENTS = new Set(["node_modules", ".kumiko", "dist", "dist-server", "__tests__"]);
9
+
10
+ function collectAppSourceFiles(dir: string, out: string[]): void {
11
+ let entries: string[];
12
+ try {
13
+ entries = readdirSync(dir);
14
+ } catch {
15
+ // skip: directory unreadable (permissions / race) — treat as empty
16
+ return;
17
+ }
18
+ for (const entry of entries) {
19
+ if (entry.startsWith(".")) continue;
20
+ if (SKIP_SEGMENTS.has(entry)) continue;
21
+ const full = join(dir, entry);
22
+ let stat: ReturnType<typeof statSync>;
23
+ try {
24
+ stat = statSync(full);
25
+ } catch {
26
+ continue;
27
+ }
28
+ if (stat.isDirectory()) {
29
+ collectAppSourceFiles(full, out);
30
+ } else if (
31
+ stat.isFile() &&
32
+ (entry.endsWith(".ts") || entry.endsWith(".tsx")) &&
33
+ !entry.endsWith(".d.ts") &&
34
+ !entry.endsWith(".test.ts") &&
35
+ !entry.endsWith(".test.tsx") &&
36
+ !entry.endsWith(".integration.ts") &&
37
+ !entry.endsWith(".integration.tsx")
38
+ ) {
39
+ out.push(full);
40
+ }
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Boot-time scan of app `src/**` for `dispatcher.write("…")` string literals.
46
+ * Fails fast before the first Custom-Screen click would 404.
47
+ *
48
+ * Only covers string literals — Handler-constant refs are caught by the CI
49
+ * guard (ts-morph) and compile-time TypedDispatcher.
50
+ */
51
+ export function validateAppCustomScreenWriteQns(
52
+ appRoot: string,
53
+ knownQns: ReadonlySet<string>,
54
+ ): void {
55
+ const srcDir = join(appRoot, "src");
56
+ // skip: apps without a src/ tree (server-only packages) — nothing to scan
57
+ if (!existsSync(srcDir)) return;
58
+
59
+ const files: string[] = [];
60
+ collectAppSourceFiles(srcDir, files);
61
+
62
+ const violations: Array<{ readonly file: string; readonly qn: string; readonly reason: string }> =
63
+ [];
64
+
65
+ for (const filePath of files) {
66
+ const source = readFileSync(filePath, "utf-8");
67
+ for (const qn of extractDispatcherWriteQnsFromSource(source)) {
68
+ const result = validateDispatcherWriteQn(qn, knownQns);
69
+ if (!result.ok) {
70
+ violations.push({
71
+ file: relative(appRoot, filePath),
72
+ qn,
73
+ reason: result.reason,
74
+ });
75
+ }
76
+ }
77
+ }
78
+
79
+ // skip: no invalid QNs found — boot continues
80
+ if (violations.length === 0) return;
81
+
82
+ const lines = violations.map((v) => ` - ${v.file}: "${v.qn}" — ${v.reason}`);
83
+ throw new Error(
84
+ `[kumiko:boot] ${violations.length} invalid dispatcher.write QN(s) in app source:\n${lines.join("\n")}\n` +
85
+ ` Check spelling against registered write handlers (expected "<feature>:write:<handler>").`,
86
+ );
87
+ }
@@ -39,6 +39,12 @@ import {
39
39
  validateWorkspaces,
40
40
  } from "./screens-nav";
41
41
 
42
+ export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns";
43
+ // Re-export: wird von run-dev-app.ts benötigt um Write-Handler-QNs
44
+ // an den Codegen zu übergeben. Nicht Teil von validateBoot, aber
45
+ // dieselbe Extraktionslogik.
46
+ export { collectWriteHandlerQns } from "./screens-nav";
47
+
42
48
  /**
43
49
  * Validates all feature configurations at boot time.
44
50
  * Throws on the first error found — fail fast.
@@ -1 +1,5 @@
1
- export { validateBoot } from "./boot-validator/index";
1
+ export {
2
+ collectWriteHandlerQns,
3
+ validateAppCustomScreenWriteQns,
4
+ validateBoot,
5
+ } from "./boot-validator/index";
@@ -7,6 +7,7 @@
7
7
  // lesen.
8
8
 
9
9
  import { compareByCodepoint } from "../utils";
10
+ import { qualifyEntityName } from "./qualified-name";
10
11
  import type { Registry } from "./types/feature";
11
12
 
12
13
  export type ManifestConfigKey = {
@@ -51,6 +52,11 @@ export type ManifestFeature = {
51
52
  readonly extensionsUsed: readonly ManifestExtension[];
52
53
  readonly configKeys: readonly ManifestConfigKey[];
53
54
  readonly secrets: readonly ManifestSecret[];
55
+ /** Alle registrierten Write-Handler-QNs dieses Features
56
+ * (z.B. "user:write:user:create", "auth-email-password:write:login").
57
+ * Von `collectWriteHandlerQns` abgeleitet — dient als Source-of-Truth
58
+ * für den Client-seitigen Typcheck von `dispatcher.write`-Calls. */
59
+ readonly writeHandlers: readonly string[];
54
60
  /** Optionaler Herkunfts-Tag (z.B. "enterprise") — gesetzt via Options. */
55
61
  readonly tier?: string;
56
62
  };
@@ -117,8 +123,14 @@ export function buildManifestFromRegistry(
117
123
  });
118
124
  }
119
125
 
126
+ const writeHandlerQns: string[] = [];
127
+ for (const handlerName of Object.keys(feature.writeHandlers)) {
128
+ writeHandlerQns.push(qualifyEntityName(feature.name, "write", handlerName));
129
+ }
130
+
120
131
  configKeys.sort((a, b) => compareByCodepoint(a.qualifiedName, b.qualifiedName));
121
132
  secrets.sort((a, b) => compareByCodepoint(a.qualifiedName, b.qualifiedName));
133
+ writeHandlerQns.sort(compareByCodepoint);
122
134
 
123
135
  manifestFeatures.push({
124
136
  name: feature.name,
@@ -135,6 +147,7 @@ export function buildManifestFromRegistry(
135
147
  })),
136
148
  configKeys,
137
149
  secrets,
150
+ writeHandlers: writeHandlerQns,
138
151
  ...(options.tier !== undefined && { tier: options.tier }),
139
152
  });
140
153
  }
@@ -1,7 +1,11 @@
1
1
  // Public API
2
2
 
3
3
  export { hasAccess } from "./access";
4
- export { validateBoot } from "./boot-validator";
4
+ export {
5
+ collectWriteHandlerQns,
6
+ validateAppCustomScreenWriteQns,
7
+ validateBoot,
8
+ } from "./boot-validator";
5
9
  export { buildAppSchema } from "./build-app-schema";
6
10
  export type { ConfigFeatureSchema } from "./build-config-feature-schema";
7
11
  export {
@@ -0,0 +1,47 @@
1
+ import { toKebab } from "./qualified-name";
2
+
3
+ /**
4
+ * Regex for a valid write-handler QN shape.
5
+ * 3 segments (feature:write:handler) or 4+ (feature:write:entity:verb).
6
+ */
7
+ export const WRITE_HANDLER_QN_FORMAT_RE =
8
+ /^[a-zA-Z][a-zA-Z0-9-]*:write:[a-zA-Z][a-zA-Z0-9-]*(:[a-zA-Z][a-zA-Z0-9-]*)*$/;
9
+
10
+ // Matches `dispatcher.write("qn", ...)` and `<expr>.write("qn", ...)` —
11
+ // same surface the CI guard scans. Dynamic QNs (variables, templates) are
12
+ // intentionally skipped (known limitation, documented in #403).
13
+ const DISPATCHER_WRITE_LITERAL_RE = /\.write\s*\(\s*["']([^"']+)["']/g;
14
+
15
+ /** Extract string-literal write-handler QNs from TS/TSX source text. */
16
+ export function extractDispatcherWriteQnsFromSource(source: string): readonly string[] {
17
+ const out = new Set<string>();
18
+ for (const match of source.matchAll(DISPATCHER_WRITE_LITERAL_RE)) {
19
+ const qn = match[1];
20
+ if (qn) out.add(qn);
21
+ }
22
+ return [...out];
23
+ }
24
+
25
+ export type WriteHandlerQnValidation =
26
+ | { readonly ok: true }
27
+ | { readonly ok: false; readonly reason: string };
28
+
29
+ /** Validate a single QN against optional known set (kebab-normalized). */
30
+ export function validateDispatcherWriteQn(
31
+ qn: string,
32
+ knownQns: ReadonlySet<string>,
33
+ ): WriteHandlerQnValidation {
34
+ if (!WRITE_HANDLER_QN_FORMAT_RE.test(qn)) {
35
+ return {
36
+ ok: false,
37
+ reason: `invalid QN format: "${qn}" — expected "<feature>:write:<handler>"`,
38
+ };
39
+ }
40
+ if (knownQns.size > 0 && !knownQns.has(toKebab(qn))) {
41
+ return {
42
+ ok: false,
43
+ reason: `unknown write handler: "${qn}" — not registered via r.writeHandler(...)`,
44
+ };
45
+ }
46
+ return { ok: true };
47
+ }
@@ -66,7 +66,7 @@ beforeAll(async () => {
66
66
  CREATE TABLE IF NOT EXISTS read_tenants (
67
67
  id uuid PRIMARY KEY,
68
68
  name text NOT NULL,
69
- tenant_key text NOT NULL,
69
+ key text NOT NULL,
70
70
  inserted_at timestamptz NOT NULL DEFAULT now()
71
71
  );
72
72
  `);
@@ -273,7 +273,7 @@ describe("SeedMigrationContext.findMembershipsOfUser (integration)", () => {
273
273
  describe("SeedMigrationContext.findTenants (integration)", () => {
274
274
  test("returnt alle Tenants sortiert nach inserted_at", async () => {
275
275
  await asRawClient(testDb.db).unsafe(`
276
- INSERT INTO read_tenants (id, name, tenant_key, inserted_at) VALUES
276
+ INSERT INTO read_tenants (id, name, key, inserted_at) VALUES
277
277
  ('00000000-0000-4000-8000-000000000002'::uuid, 'Beta', 'beta', '2026-01-02'),
278
278
  ('00000000-0000-4000-8000-000000000001'::uuid, 'Alpha', 'alpha', '2026-01-01')
279
279
  `);
@@ -365,7 +365,7 @@ describe("runPendingSeedMigrations: skippable + env-flag (integration)", () => {
365
365
  describe("SeedMigrationContext.db (escape-hatch, integration)", () => {
366
366
  test("ctx.db kann für eigene Lookups genutzt werden (read-only)", async () => {
367
367
  await asRawClient(testDb.db).unsafe(`
368
- INSERT INTO read_tenants (id, name, tenant_key) VALUES
368
+ INSERT INTO read_tenants (id, name, key) VALUES
369
369
  ('00000000-0000-4000-8000-000000000007'::uuid, 'Lucky', 'lucky')
370
370
  `);
371
371
  const ctx = createSeedMigrationContext({
@@ -373,7 +373,7 @@ describe("SeedMigrationContext.db (escape-hatch, integration)", () => {
373
373
  dbRunner: testDb.db,
374
374
  });
375
375
  const rows = (await asRawClient(ctx.db).unsafe(
376
- `SELECT name FROM read_tenants WHERE tenant_key = 'lucky'`,
376
+ `SELECT name FROM read_tenants WHERE key = 'lucky'`,
377
377
  )) as unknown as readonly { name: string }[];
378
378
  expect(rows[0]?.name).toBe("Lucky");
379
379
  });