@cosmicdrift/kumiko-framework 0.61.0 → 0.64.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.61.0",
3
+ "version": "0.64.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>",
@@ -1,9 +1,10 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { type BunTestDb, createTestDb } from "../bun-db/__tests__/bun-test-db";
3
- import { integer, table as pgTable, serial, text } from "../db/dialect";
3
+ import { integer, table as pgTable, serial, text, timestamp } from "../db/dialect";
4
4
  import { selectMany } from "../db/query";
5
5
  import { seedReferenceData } from "../db/reference-data";
6
6
  import type { ReferenceDataDef } from "../engine/types";
7
+ import { SYSTEM_TENANT_ID } from "../engine/types";
7
8
  import { unsafePushTables } from "../stack";
8
9
  import { ensureTemporalPolyfill } from "../time/polyfill";
9
10
 
@@ -22,6 +23,14 @@ const statusTable = pgTable("ref_statuses", {
22
23
  sortOrder: integer("sort_order").default(0),
23
24
  });
24
25
 
26
+ const scopedTable = pgTable("ref_scoped", {
27
+ key: text("key").primaryKey(),
28
+ label: text("label").notNull(),
29
+ tenantId: text("tenant_id"),
30
+ version: integer("version"),
31
+ insertedAt: timestamp("inserted_at"),
32
+ });
33
+
25
34
  // --- Test state ---
26
35
 
27
36
  let testDb: BunTestDb;
@@ -29,7 +38,7 @@ let testDb: BunTestDb;
29
38
  beforeAll(async () => {
30
39
  await ensureTemporalPolyfill();
31
40
  testDb = await createTestDb();
32
- await unsafePushTables(testDb.db, { countryTable, statusTable });
41
+ await unsafePushTables(testDb.db, { countryTable, statusTable, scopedTable });
33
42
  });
34
43
 
35
44
  afterAll(async () => {
@@ -48,9 +57,10 @@ async function readStatuses() {
48
57
  }
49
58
 
50
59
  describe("seedReferenceData", () => {
51
- const tables = new Map<string, typeof countryTable | typeof statusTable>([
60
+ const tables = new Map<string, typeof countryTable | typeof statusTable | typeof scopedTable>([
52
61
  ["country", countryTable],
53
62
  ["status", statusTable],
63
+ ["scoped", scopedTable],
54
64
  ]);
55
65
 
56
66
  test("inserts initial reference data", async () => {
@@ -197,6 +207,28 @@ describe("seedReferenceData", () => {
197
207
  expect(result).toEqual({ inserted: 0, updated: 0 });
198
208
  });
199
209
 
210
+ test("stamps framework columns (tenantId/version/insertedAt) on insert when the table has them", async () => {
211
+ const defs: ReferenceDataDef[] = [
212
+ {
213
+ entityName: "scoped",
214
+ data: [{ key: "k1", label: "One" }],
215
+ },
216
+ ];
217
+
218
+ const result = await seedReferenceData(defs, tables, testDb.db);
219
+ expect(result).toEqual({ inserted: 1, updated: 0 });
220
+
221
+ const rows = await selectMany(testDb.db, scopedTable);
222
+ expect(rows).toHaveLength(1);
223
+ expect(rows[0]).toMatchObject({
224
+ key: "k1",
225
+ label: "One",
226
+ tenantId: SYSTEM_TENANT_ID,
227
+ version: 1,
228
+ });
229
+ expect(rows[0]?.insertedAt).toBeTruthy();
230
+ });
231
+
200
232
  test("skips empty data arrays", async () => {
201
233
  const defs: ReferenceDataDef[] = [
202
234
  {
@@ -147,6 +147,65 @@ describe("runSchemaCli — no-DB paths", () => {
147
147
  });
148
148
  });
149
149
 
150
+ describe("runSchemaCli — validate (static CI gate, no DB)", () => {
151
+ let appCwd: string;
152
+ beforeEach(() => {
153
+ appCwd = freshAppCwd();
154
+ });
155
+
156
+ test("missing schema.ts exits 1", async () => {
157
+ const cap = captureOut();
158
+ const code = await runSchemaCli(["validate"], appCwd, cap.out);
159
+ expect(code).toBe(1);
160
+ expect(cap.err.join("\n")).toContain("kumiko/schema.ts");
161
+ });
162
+
163
+ test("entity with no generated migration → schema drift, exit 1 (the tags-class bug)", async () => {
164
+ writeSchemaFile(appCwd, "read_widgets");
165
+ const cap = captureOut();
166
+ const code = await runSchemaCli(["validate"], appCwd, cap.out);
167
+ expect(code).toBe(1);
168
+ expect(cap.err.join("\n")).toContain("schema drift");
169
+ expect(cap.err.join("\n")).toContain("read_widgets");
170
+ expect(cap.err.join("\n")).toContain("kumiko-schema generate");
171
+ });
172
+
173
+ test("after generate (in sync) → exit 0, reports migrations match", async () => {
174
+ writeSchemaFile(appCwd, "read_widgets");
175
+ await runSchemaCli(["generate", "init"], appCwd, captureOut().out);
176
+ const cap = captureOut();
177
+ const code = await runSchemaCli(["validate"], appCwd, cap.out);
178
+ expect(code).toBe(0);
179
+ expect(cap.log.join("\n")).toContain("migrations match");
180
+ });
181
+
182
+ test("no FEATURES export → validateBoot skipped (drift still checked)", async () => {
183
+ writeSchemaFile(appCwd, "read_widgets");
184
+ await runSchemaCli(["generate", "init"], appCwd, captureOut().out);
185
+ const cap = captureOut();
186
+ const code = await runSchemaCli(["validate"], appCwd, cap.out);
187
+ expect(code).toBe(0);
188
+ expect(cap.log.join("\n")).toContain("boot: skipped");
189
+ });
190
+
191
+ test("FEATURES present (empty) → validateBoot runs and passes", async () => {
192
+ writeFileSync(
193
+ join(appCwd, "kumiko/schema.ts"),
194
+ `export const ENTITY_METAS = [
195
+ { tableName: "read_widgets", source: "unmanaged", indexes: [],
196
+ columns: [{ name: "id", pgType: "uuid", notNull: true, primaryKey: true, defaultSql: "gen_random_uuid()" }] },
197
+ ];
198
+ export const FEATURES = [];
199
+ `,
200
+ );
201
+ await runSchemaCli(["generate", "init"], appCwd, captureOut().out);
202
+ const cap = captureOut();
203
+ const code = await runSchemaCli(["validate"], appCwd, cap.out);
204
+ expect(code).toBe(0);
205
+ expect(cap.log.join("\n")).toContain("feature configuration is valid");
206
+ });
207
+ });
208
+
150
209
  describe("runSchemaCli — DB-backed paths", () => {
151
210
  let testDb: BunTestDb;
152
211
  let dbUrl: string;
@@ -259,6 +259,10 @@ describe("auth-routes cookieDomain", () => {
259
259
  });
260
260
  expect(res.status).toBe(200);
261
261
  expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(/Domain=example\.eu/i);
262
+ // Das CSRF-Cookie wird via denselben common-Optionen rotiert wie das Auth-
263
+ // Cookie. Ohne Domain-Attribut bliebe es host-only neben dem Domain-Auth-
264
+ // Cookie → der Double-Submit-Check läse umgebungsabhängig das falsche.
265
+ expect(getSetCookieRaw(res, CSRF_COOKIE_NAME)).toMatch(/Domain=example\.eu/i);
262
266
  });
263
267
 
264
268
  test("logout löscht beide Varianten: mit Domain UND host-only", async () => {
@@ -0,0 +1,104 @@
1
+ // Coverage-Lücke (9% u+i, 0 Tests): assertExistsIn ist der referenzielle
2
+ // Existenz-Check (FK-Ersatz im ES-Modell), den Write-Handler vor dem Schreiben
3
+ // nutzen. Gibt er faelschlich null (= existiert) zurueck, schreibt der Handler
4
+ // eine dangling/cross-tenant Reference. Schwerpunkt: Tenant-Isolation.
5
+
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { insertMany } from "../../bun-db";
8
+ import { createEntity, createTextField } from "../../engine";
9
+ import { NotFoundError } from "../../errors";
10
+ import { setupTestStack, type TestStack, testTenantId, unsafeCreateEntityTable } from "../../stack";
11
+ import { assertExistsIn } from "../assert-exists-in";
12
+ import { buildEntityTable } from "../table-builder";
13
+ import { createTenantDb } from "../tenant-db";
14
+
15
+ const orderEntity = createEntity({
16
+ table: "ax_orders",
17
+ fields: { name: createTextField({ required: true }) },
18
+ });
19
+ const orderTable = buildEntityTable("order", orderEntity);
20
+
21
+ const tenantA = testTenantId(71);
22
+ const tenantB = testTenantId(72);
23
+ const ID_A = "11111111-1111-4111-8111-1111110000a1";
24
+ const ID_B = "22222222-2222-4222-8222-2222220000b2";
25
+ const MISSING = "99999999-9999-4999-8999-999999990000";
26
+
27
+ let stack: TestStack;
28
+
29
+ beforeAll(async () => {
30
+ stack = await setupTestStack({ features: [] });
31
+ await unsafeCreateEntityTable(stack.db, orderEntity);
32
+ await insertMany(stack.db, orderTable, [
33
+ { id: ID_A, tenantId: tenantA, name: "A-Order" },
34
+ { id: ID_B, tenantId: tenantB, name: "B-Order" },
35
+ ]);
36
+ });
37
+
38
+ afterAll(async () => {
39
+ await stack.cleanup();
40
+ });
41
+
42
+ describe("assertExistsIn — DbConnection + explizite tenantId", () => {
43
+ test("existierende Row → null", async () => {
44
+ const r = await assertExistsIn(stack.db, orderTable, {
45
+ field: "id",
46
+ value: ID_A,
47
+ tenantId: tenantA,
48
+ });
49
+ expect(r).toBeNull();
50
+ });
51
+
52
+ test("fehlende Row → NotFoundError", async () => {
53
+ const r = await assertExistsIn(stack.db, orderTable, {
54
+ field: "id",
55
+ value: MISSING,
56
+ tenantId: tenantA,
57
+ });
58
+ expect(r).toBeInstanceOf(NotFoundError);
59
+ });
60
+
61
+ test("ISOLATION: fremder Tenant → NotFoundError (existiert, aber nicht im Scope)", async () => {
62
+ const r = await assertExistsIn(stack.db, orderTable, {
63
+ field: "id",
64
+ value: ID_B, // gehört tenantB
65
+ tenantId: tenantA,
66
+ });
67
+ expect(r).toBeInstanceOf(NotFoundError);
68
+ });
69
+ });
70
+
71
+ describe("assertExistsIn — TenantDb auto-filter", () => {
72
+ test("eigene Row → null", async () => {
73
+ const dbA = createTenantDb(stack.db, tenantA, "tenant");
74
+ expect(await assertExistsIn(dbA, orderTable, { field: "id", value: ID_A })).toBeNull();
75
+ });
76
+
77
+ test("ISOLATION: fremde Row via TenantDb → NotFoundError", async () => {
78
+ const dbA = createTenantDb(stack.db, tenantA, "tenant");
79
+ const r = await assertExistsIn(dbA, orderTable, { field: "id", value: ID_B });
80
+ expect(r).toBeInstanceOf(NotFoundError);
81
+ });
82
+ });
83
+
84
+ describe("assertExistsIn — Fehler-Benennung + where", () => {
85
+ test("expliziter entityName override gewinnt", async () => {
86
+ const r = await assertExistsIn(stack.db, orderTable, {
87
+ field: "id",
88
+ value: MISSING,
89
+ tenantId: tenantA,
90
+ entityName: "Bestellung",
91
+ });
92
+ expect(r?.message).toContain("Bestellung");
93
+ });
94
+
95
+ test("zusätzliches where narrowt → existierende id + falscher name = NotFound", async () => {
96
+ const r = await assertExistsIn(stack.db, orderTable, {
97
+ field: "id",
98
+ value: ID_A,
99
+ tenantId: tenantA,
100
+ where: { name: "nope" },
101
+ });
102
+ expect(r).toBeInstanceOf(NotFoundError);
103
+ });
104
+ });
@@ -0,0 +1,148 @@
1
+ // Coverage-Lücke (0 Test, 12% u+i): Server-Side-Eagerload für Reference-Felder.
2
+ // Schwerpunkt: Tenant-Isolation — ein Cross-Tenant-Ref darf NIE aufgelöst
3
+ // werden (TenantDb filtert), sonst leakt eagerload fremde Rows nach _refs.
4
+
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { insertMany } from "../../bun-db";
7
+ import { createEntity, createTextField } from "../../engine";
8
+ import type { EntityDefinition } from "../../engine/types";
9
+ import { setupTestStack, type TestStack, testTenantId, unsafeCreateEntityTable } from "../../stack";
10
+ import {
11
+ collectReferenceFields,
12
+ type EagerloadedRow,
13
+ enrichRowWithReferences,
14
+ enrichWithReferences,
15
+ } from "../eagerload";
16
+ import { buildEntityTable } from "../table-builder";
17
+ import { createTenantDb } from "../tenant-db";
18
+
19
+ const authorEntity = createEntity({
20
+ table: "el_authors",
21
+ fields: { name: createTextField({ required: true }) },
22
+ });
23
+ const postEntity = createEntity({
24
+ table: "el_posts",
25
+ fields: {
26
+ title: createTextField({ required: true }),
27
+ author: { type: "reference", entity: "author" },
28
+ tags: { type: "reference", entity: "author", multiple: true },
29
+ },
30
+ });
31
+ const authorTable = buildEntityTable("author", authorEntity);
32
+
33
+ const resolve = (name: string): EntityDefinition | undefined =>
34
+ name === "author" ? authorEntity : undefined;
35
+
36
+ const tenantA = testTenantId(91);
37
+ const tenantB = testTenantId(92);
38
+
39
+ const A1 = "11111111-1111-4111-8111-111111111111";
40
+ const A2 = "22222222-2222-4222-8222-222222222222";
41
+ const BX = "33333333-3333-4333-8333-333333333333";
42
+ const NOPE = "55555555-5555-4555-8555-555555555555";
43
+
44
+ // _refs-Werte sind dynamisch gekeyt → bracket-access; Helper kapseln den Cast.
45
+ const single = (r: EagerloadedRow | undefined, f: string) =>
46
+ r?._refs?.[f] as Record<string, unknown> | undefined;
47
+ const many = (r: EagerloadedRow | undefined, f: string) =>
48
+ r?._refs?.[f] as ReadonlyArray<Record<string, unknown>> | undefined;
49
+
50
+ let stack: TestStack;
51
+ let dbA: ReturnType<typeof createTenantDb>;
52
+
53
+ beforeAll(async () => {
54
+ stack = await setupTestStack({ features: [] });
55
+ await unsafeCreateEntityTable(stack.db, authorEntity);
56
+ await unsafeCreateEntityTable(stack.db, postEntity);
57
+ dbA = createTenantDb(stack.db, tenantA, "tenant");
58
+
59
+ await insertMany(stack.db, authorTable, [
60
+ { id: A1, tenantId: tenantA, name: "Ada" },
61
+ { id: A2, tenantId: tenantA, name: "Linus" },
62
+ { id: BX, tenantId: tenantB, name: "Foreign" },
63
+ ]);
64
+ });
65
+
66
+ afterAll(async () => {
67
+ await stack.cleanup();
68
+ });
69
+
70
+ async function enrichA(row: Record<string, unknown>): Promise<EagerloadedRow | undefined> {
71
+ const [out] = (await enrichWithReferences([row], postEntity, resolve, dbA)) as EagerloadedRow[];
72
+ return out;
73
+ }
74
+
75
+ describe("collectReferenceFields", () => {
76
+ test("extrahiert reference-Felder, parst cross-feature-Prefix, flaggt multiple", () => {
77
+ const e = createEntity({
78
+ table: "x",
79
+ fields: {
80
+ title: createTextField(),
81
+ author: { type: "reference", entity: "users:user" },
82
+ tags: { type: "reference", entity: "tag", multiple: true },
83
+ },
84
+ });
85
+ expect(collectReferenceFields(e)).toEqual([
86
+ { fieldName: "author", refEntityName: "user", multiple: false },
87
+ { fieldName: "tags", refEntityName: "tag", multiple: true },
88
+ ]);
89
+ });
90
+
91
+ test("keine reference-Felder → leer", () => {
92
+ expect(collectReferenceFields(authorEntity)).toEqual([]);
93
+ });
94
+ });
95
+
96
+ describe("enrichWithReferences", () => {
97
+ test("löst single-ref zur Row auf", async () => {
98
+ const row = await enrichA({ id: "p1", author: A1 });
99
+ expect(single(row, "author")?.["name"]).toBe("Ada");
100
+ });
101
+
102
+ test("löst multiple-ref zu einem Array in Reihenfolge auf", async () => {
103
+ const row = await enrichA({ id: "p1", tags: [A2, A1] });
104
+ expect(many(row, "tags")?.map((t) => t["name"])).toEqual(["Linus", "Ada"]);
105
+ });
106
+
107
+ test("null/leerer ref-Wert → _refs[field] undefined", async () => {
108
+ const row = await enrichA({ id: "p1", author: null });
109
+ expect(single(row, "author")).toBeUndefined();
110
+ });
111
+
112
+ test("TENANT-ISOLATION: cross-tenant-ref wird NICHT aufgelöst", async () => {
113
+ // bx gehört tenantB; dbA ist auf tenantA gescoped → der Lookup filtert
114
+ // ihn raus, _refs bleibt undefined (Renderer fällt auf die UUID zurück).
115
+ const row = await enrichA({ id: "p1", author: BX });
116
+ expect(single(row, "author")).toBeUndefined();
117
+ });
118
+
119
+ test("dangling UUID (kein Row) → undefined, kein Crash", async () => {
120
+ const row = await enrichA({ id: "p1", author: NOPE });
121
+ expect(single(row, "author")).toBeUndefined();
122
+ });
123
+
124
+ test("unbekannte ref-Entity (resolve→undefined) → undefined, kein Crash", async () => {
125
+ const [row] = (await enrichWithReferences(
126
+ [{ id: "p1", author: A1 }],
127
+ postEntity,
128
+ () => undefined,
129
+ dbA,
130
+ )) as EagerloadedRow[];
131
+ expect(single(row, "author")).toBeUndefined();
132
+ });
133
+
134
+ test("keine reference-Felder → flache Kopie ohne Lookup", async () => {
135
+ const out = await enrichWithReferences([{ id: "x1", name: "n" }], authorEntity, resolve, dbA);
136
+ expect(out).toEqual([{ id: "x1", name: "n" }]);
137
+ });
138
+
139
+ test("enrichRowWithReferences (single-row-Variante) stempelt _refs", async () => {
140
+ const row = (await enrichRowWithReferences(
141
+ { id: "p1", author: A1 },
142
+ postEntity,
143
+ resolve,
144
+ dbA,
145
+ )) as EagerloadedRow;
146
+ expect(single(row, "author")?.["name"]).toBe("Ada");
147
+ });
148
+ });
@@ -61,7 +61,7 @@ function navById(id: string): NavDefinition | undefined {
61
61
  }
62
62
  function configScreen(id: string): ConfigEditScreenDefinition {
63
63
  const s: ScreenDefinition | undefined = schema.screens.find((x) => x.id === id);
64
- if (!s || s.type !== "configEdit") throw new Error(`no configEdit screen "${id}"`);
64
+ if (s?.type !== "configEdit") throw new Error(`no configEdit screen "${id}"`);
65
65
  return s;
66
66
  }
67
67
 
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ computeDefinitionFingerprint,
4
+ defineWorkflow,
5
+ type WorkflowTrigger,
6
+ } from "../define-workflow";
7
+ import type { PipelineDef } from "../types/step";
8
+
9
+ const pipe = (build: PipelineDef["build"]): PipelineDef => ({ __kind: "pipeline", build });
10
+ const eventTrigger: WorkflowTrigger = { kind: "event", eventType: "user.signed-up" };
11
+
12
+ describe("defineWorkflow", () => {
13
+ test("maps input into a WorkflowDefinition", () => {
14
+ const steps = pipe(() => []);
15
+ const wf = defineWorkflow({ name: "onboard", trigger: eventTrigger, steps });
16
+
17
+ expect(wf.__kind).toBe("workflow");
18
+ expect(wf.name).toBe("onboard");
19
+ expect(wf.trigger).toEqual(eventTrigger);
20
+ expect(wf.pipelineDef).toBe(steps);
21
+ });
22
+ });
23
+
24
+ describe("computeDefinitionFingerprint", () => {
25
+ const base = { name: "wf", trigger: eventTrigger, pipelineDef: pipe(() => []) };
26
+
27
+ test("is deterministic and a sha256 hex digest", () => {
28
+ const a = computeDefinitionFingerprint(base);
29
+ const b = computeDefinitionFingerprint({ ...base, pipelineDef: pipe(() => []) });
30
+
31
+ expect(a).toMatch(/^[0-9a-f]{64}$/);
32
+ expect(a).toBe(b);
33
+ });
34
+
35
+ test("changes when the closure source changes — the in-flight drift detector", () => {
36
+ const before = computeDefinitionFingerprint(base);
37
+ // runtime-distinct body (`as never` + comments are stripped by the
38
+ // transpiler, so the difference must survive into emitted source).
39
+ const after = computeDefinitionFingerprint({
40
+ ...base,
41
+ pipelineDef: pipe(() => [{} as never]),
42
+ });
43
+
44
+ expect(after).not.toBe(before);
45
+ });
46
+
47
+ test("changes when name or trigger changes", () => {
48
+ const fp = computeDefinitionFingerprint(base);
49
+ expect(computeDefinitionFingerprint({ ...base, name: "other" })).not.toBe(fp);
50
+ expect(
51
+ computeDefinitionFingerprint({
52
+ ...base,
53
+ trigger: { kind: "cron", schedule: "0 0 * * *" },
54
+ }),
55
+ ).not.toBe(fp);
56
+ });
57
+ });
@@ -95,7 +95,7 @@ describe("locatedTimestamp(name) Helper", () => {
95
95
  for (const name of ["a", "x_y", "long_field_name"]) {
96
96
  const fields = locatedTimestamp(name);
97
97
  const at = fields[`${name}At`];
98
- if (!at || at.type !== "timestamp") throw new Error("at field missing");
98
+ if (at?.type !== "timestamp") throw new Error("at field missing");
99
99
  expect(at.locatedBy).toBe(`${name}Tz`);
100
100
  }
101
101
  });
@@ -0,0 +1,51 @@
1
+ // Coverage-Lücke (0% unit + 0% integration): findTierResolverUsage ist der
2
+ // geteilte Pickup-Helper für runDevApp + runProdApp. Findet er die Usage NICHT,
3
+ // bleibt effectiveFeatures undefined → tier-gating still aus (alle Features on).
4
+ // Genau dieser found-Pfad war in keinem Test exekutiert.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { createEntity, defineFeature } from "../index";
8
+ import { findTierResolverUsage, TENANT_TIER_RESOLVER_EXT } from "../tier-resolver-extension";
9
+
10
+ function tierResolverFeature(name: string) {
11
+ return defineFeature(name, (r) => {
12
+ r.extendsRegistrar(TENANT_TIER_RESOLVER_EXT, { onRegister: () => {} });
13
+ r.entity("dummy", createEntity({ table: "Dummies", fields: {} }));
14
+ r.useExtension(TENANT_TIER_RESOLVER_EXT, "dummy");
15
+ });
16
+ }
17
+
18
+ function plainFeature(name: string) {
19
+ return defineFeature(name, (r) => {
20
+ r.entity("thing", createEntity({ table: "Things", fields: {} }));
21
+ });
22
+ }
23
+
24
+ describe("findTierResolverUsage", () => {
25
+ test("findet die tenantTierResolver-Usage", () => {
26
+ const usage = findTierResolverUsage([tierResolverFeature("tier-stub")]);
27
+ expect(usage?.extensionName).toBe(TENANT_TIER_RESOLVER_EXT);
28
+ });
29
+
30
+ test("findet die Usage auch wenn sie nicht im ersten Feature liegt", () => {
31
+ const usage = findTierResolverUsage([plainFeature("a"), tierResolverFeature("tier-stub")]);
32
+ expect(usage?.extensionName).toBe(TENANT_TIER_RESOLVER_EXT);
33
+ });
34
+
35
+ test("returnt undefined wenn keine Feature die Extension nutzt", () => {
36
+ expect(findTierResolverUsage([plainFeature("a"), plainFeature("b")])).toBeUndefined();
37
+ });
38
+
39
+ test("ignoriert andere Extension-Usages", () => {
40
+ const other = defineFeature("other", (r) => {
41
+ r.extendsRegistrar("somethingElse", { onRegister: () => {} });
42
+ r.entity("d", createEntity({ table: "Ds", fields: {} }));
43
+ r.useExtension("somethingElse", "d");
44
+ });
45
+ expect(findTierResolverUsage([other])).toBeUndefined();
46
+ });
47
+
48
+ test("leere Feature-Liste → undefined", () => {
49
+ expect(findTierResolverUsage([])).toBeUndefined();
50
+ });
51
+ });
@@ -129,7 +129,7 @@ export function validateProjectionAllowlist(features: readonly FeatureDefinition
129
129
  for (const f of features) {
130
130
  for (const [handlerName, handler] of Object.entries(f.writeHandlers)) {
131
131
  const perform = (handler as { readonly perform?: PipelineDef }).perform;
132
- if (!perform || perform.__kind !== "pipeline") continue;
132
+ if (perform?.__kind !== "pipeline") continue;
133
133
 
134
134
  let steps: readonly StepInstance[];
135
135
  try {
@@ -0,0 +1,68 @@
1
+ // Coverage-Lücke (unit + integration): toStoredEvent wird auf dem Event-Load-
2
+ // Pfad ausgeführt, aber kein Test asserted das Mapping. Regression-Guard: ein
3
+ // fallengelassenes/falsches Feld wäre stiller Datenverlust beim Replay.
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import type { TenantId } from "../../engine/types";
7
+ import type { EventMetadata, StoredEvent } from "../event-store";
8
+ import { toStoredEvent } from "../row-to-stored-event";
9
+
10
+ const metadata: EventMetadata = {
11
+ userId: "user-1",
12
+ requestId: "req-1",
13
+ correlationId: "corr-1",
14
+ causationId: "cause-1",
15
+ };
16
+
17
+ const row = {
18
+ id: 42n,
19
+ aggregateId: "agg-1",
20
+ aggregateType: "credit",
21
+ tenantId: "tenant-1" as TenantId,
22
+ version: 3,
23
+ type: "credit.created",
24
+ eventVersion: 2,
25
+ payload: { amount: 100 },
26
+ metadata,
27
+ createdAt: Temporal.Instant.from("2026-01-01T00:00:00Z"),
28
+ createdBy: "user-1",
29
+ };
30
+
31
+ describe("toStoredEvent", () => {
32
+ test("stringifiziert die bigint-id", () => {
33
+ expect(toStoredEvent(row).id).toBe("42");
34
+ });
35
+
36
+ test("mappt jedes Feld werttreu durch", () => {
37
+ const ev = toStoredEvent(row);
38
+ expect(ev.aggregateId).toBe("agg-1");
39
+ expect(ev.aggregateType).toBe("credit");
40
+ expect(ev.tenantId).toBe("tenant-1" as TenantId);
41
+ expect(ev.version).toBe(3);
42
+ expect(ev.type).toBe("credit.created");
43
+ expect(ev.eventVersion).toBe(2);
44
+ expect(ev.payload).toEqual({ amount: 100 });
45
+ expect(ev.metadata).toBe(metadata);
46
+ expect(ev.createdAt).toBe(row.createdAt);
47
+ expect(ev.createdBy).toBe("user-1");
48
+ });
49
+
50
+ test("Feld-Vollständigkeit: das Mapping deckt genau die StoredEvent-Keys ab", () => {
51
+ // Pin gegen versehentliches Weglassen eines Feldes im Mapping. Muss mit
52
+ // der StoredEvent-Definition (event-store.ts) synchron bleiben.
53
+ const expectedKeys: ReadonlyArray<keyof StoredEvent> = [
54
+ "id",
55
+ "aggregateId",
56
+ "aggregateType",
57
+ "tenantId",
58
+ "version",
59
+ "type",
60
+ "eventVersion",
61
+ "payload",
62
+ "metadata",
63
+ "createdAt",
64
+ "createdBy",
65
+ ];
66
+ expect(Object.keys(toStoredEvent(row)).sort()).toEqual([...expectedKeys].sort());
67
+ });
68
+ });
@@ -107,7 +107,7 @@ class NoopMeter implements Meter {
107
107
 
108
108
  counter(name: string): Counter {
109
109
  const def = this.defs.get(name);
110
- if (!def || def.type !== "counter") {
110
+ if (def?.type !== "counter") {
111
111
  throw new Error(`[Kumiko Observability] Counter "${name}" not registered or wrong type.`);
112
112
  }
113
113
  return this.counterInstance;
@@ -115,7 +115,7 @@ class NoopMeter implements Meter {
115
115
 
116
116
  histogram(name: string): Histogram {
117
117
  const def = this.defs.get(name);
118
- if (!def || def.type !== "histogram") {
118
+ if (def?.type !== "histogram") {
119
119
  throw new Error(`[Kumiko Observability] Histogram "${name}" not registered or wrong type.`);
120
120
  }
121
121
  return this.histogramInstance;
@@ -123,7 +123,7 @@ class NoopMeter implements Meter {
123
123
 
124
124
  gauge(name: string): Gauge {
125
125
  const def = this.defs.get(name);
126
- if (!def || def.type !== "gauge") {
126
+ if (def?.type !== "gauge") {
127
127
  throw new Error(`[Kumiko Observability] Gauge "${name}" not registered or wrong type.`);
128
128
  }
129
129
  return this.gaugeInstance;
package/src/schema-cli.ts CHANGED
@@ -24,6 +24,8 @@ import {
24
24
  writeRebuildMarker,
25
25
  writeSnapshotJson,
26
26
  } from "./db";
27
+ import { validateBoot } from "./engine/boot-validator";
28
+ import type { FeatureDefinition } from "./engine/types/feature";
27
29
  import { createEventsTable } from "./event-store";
28
30
  import { createEventConsumerStateTable, createProjectionStateTable } from "./pipeline";
29
31
 
@@ -132,6 +134,77 @@ export async function runSchemaCli(
132
134
  return 0;
133
135
  }
134
136
 
137
+ case "validate": {
138
+ // Static, DB-free boot-blocking checks for CI — catches "this won't boot"
139
+ // before deploy. Two layers, no database:
140
+ // 1. schema drift: would `generate` write a migration? (= an entity was
141
+ // added/changed but never generated → missing table → prod 500)
142
+ // 2. boot validity: validateBoot over the composed FEATURES (QN/screen/
143
+ // nav/role refs). Runs only if kumiko/schema.ts exports FEATURES.
144
+ // The DB-level gate (assertKumikoSchemaCurrent) stays at boot/deploy.
145
+ if (!existsSync(schemaFile)) {
146
+ out.err(` ${schemaFile} fehlt.`);
147
+ out.err(" App-Convention: kumiko/schema.ts mit");
148
+ out.err(" export const ENTITY_METAS: EntityTableMeta[] = [...]");
149
+ return 1;
150
+ }
151
+ const mod = (await import(schemaFile)) as {
152
+ ENTITY_METAS?: unknown;
153
+ FEATURES?: unknown;
154
+ };
155
+ if (!Array.isArray(mod.ENTITY_METAS)) {
156
+ out.err(
157
+ ` Schema file ${schemaFile} muss \`export const ENTITY_METAS: EntityTableMeta[]\` haben.`,
158
+ );
159
+ return 1;
160
+ }
161
+ let ok = true;
162
+
163
+ // 1. Schema drift — compute the diff, never write.
164
+ const metas = mod.ENTITY_METAS as Parameters<typeof renderTablesDdl>[0];
165
+ const snapshotPath = join(migrationsDir, SNAPSHOT_FILENAME);
166
+ const prevSnapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
167
+ const drift = generateMigration({
168
+ metas,
169
+ prevSnapshot,
170
+ name: "validate",
171
+ sequenceNumber: nextSequenceNumber(migrationsDir),
172
+ });
173
+ const pendingTables = [
174
+ ...drift.diff.newTables.map((t) => t.tableName),
175
+ ...drift.diff.changedTables.map((t) => t.tableName),
176
+ ...drift.diff.droppedTables,
177
+ ];
178
+ if (pendingTables.length === 0) {
179
+ out.log(" ✓ schema: migrations match the entity definitions");
180
+ } else {
181
+ ok = false;
182
+ out.err(" ✗ schema drift: entity definitions are ahead of kumiko/migrations.");
183
+ out.err(
184
+ ` pending — new: ${drift.diff.newTables.length}, changed: ${drift.diff.changedTables.length}, dropped: ${drift.diff.droppedTables.length}`,
185
+ );
186
+ out.err(` tables: ${pendingTables.join(", ")}`);
187
+ out.err(" Fix: `kumiko-schema generate <name>`, then commit the migration.");
188
+ }
189
+
190
+ // 2. Boot validity — needs the composed feature set.
191
+ if (Array.isArray(mod.FEATURES)) {
192
+ try {
193
+ validateBoot(mod.FEATURES as readonly FeatureDefinition[]);
194
+ out.log(" ✓ boot: feature configuration is valid");
195
+ } catch (e) {
196
+ ok = false;
197
+ out.err(` ✗ boot: ${e instanceof Error ? e.message : String(e)}`);
198
+ }
199
+ } else {
200
+ out.log(
201
+ " · boot: skipped — add `export const FEATURES = composeFeatures(APP_FEATURES, { includeBundled: true })` to kumiko/schema.ts to enable validateBoot.",
202
+ );
203
+ }
204
+
205
+ return ok ? 0 : 1;
206
+ }
207
+
135
208
  case "apply": {
136
209
  const dbUrl = process.env["DATABASE_URL"];
137
210
  if (!dbUrl) {
@@ -243,6 +316,7 @@ export async function runSchemaCli(
243
316
  out.log("");
244
317
  out.log(" Subcommands:");
245
318
  out.log(" generate <name> Schreibe neue Migration aus EntityTableMeta-Diff");
319
+ out.log(" validate Static CI-Gate (kein DB): schema-drift + validateBoot");
246
320
  out.log(" apply Applied pending checked-in SQL-Files");
247
321
  out.log(" baseline Markiere checked-in Migrations als applied (kein SQL-Run)");
248
322
  out.log(" status Liste applied vs pending");