@cosmicdrift/kumiko-framework 0.63.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 +1 -1
- package/src/__tests__/reference-data.integration.test.ts +35 -3
- package/src/__tests__/schema-cli.integration.test.ts +59 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +4 -0
- package/src/db/__tests__/assert-exists-in.integration.test.ts +104 -0
- package/src/db/__tests__/eagerload.integration.test.ts +148 -0
- package/src/engine/__tests__/define-workflow.test.ts +57 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +51 -0
- package/src/event-store/__tests__/row-to-stored-event.test.ts +68 -0
- package/src/schema-cli.ts +74 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|
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");
|