@cosmicdrift/kumiko-framework 0.63.0 → 0.65.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 +2 -2
- package/src/__tests__/reference-data.integration.test.ts +35 -3
- package/src/__tests__/schema-cli-rebuild.integration.test.ts +176 -0
- 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/db/event-store-executor.ts +2 -6
- package/src/engine/__tests__/build-app-schema-settings-hub.test.ts +49 -1
- package/src/engine/__tests__/build-config-feature-schema.test.ts +46 -0
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +47 -0
- package/src/engine/__tests__/define-workflow.test.ts +57 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +51 -0
- package/src/engine/build-app-schema.ts +37 -5
- package/src/engine/build-config-feature-schema.ts +5 -1
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/extractors/round5.ts +15 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/event-store/__tests__/row-to-stored-event.test.ts +68 -0
- package/src/pipeline/projection-rebuild.ts +2 -2
- package/src/schema-cli.ts +133 -1
- package/src/utils/__tests__/compare.test.ts +20 -0
- package/src/utils/__tests__/serialization.test.ts +24 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.65.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,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.65.0",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
|
@@ -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
|
{
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// `schema apply` rebuilds the projections a freshly applied migration touched —
|
|
2
|
+
// the projection-rebuild step the per-app bin/kumiko.ts files duplicate today,
|
|
3
|
+
// folded into runSchemaCli behind the optional `features` option.
|
|
4
|
+
//
|
|
5
|
+
// Honest wiring test: seed one event, apply a migration carrying a hand-written
|
|
6
|
+
// .rebuild.json marker, assert the projection was actually replayed (the row
|
|
7
|
+
// reconstructed from the event log — not just a log line).
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { writeRebuildMarker } from "../db";
|
|
14
|
+
import { integer, table as pgTable, uuid } from "../db/dialect";
|
|
15
|
+
import { createEventStoreExecutor } from "../db/event-store-executor";
|
|
16
|
+
import { asRawClient, selectMany } from "../db/query";
|
|
17
|
+
import { buildEntityTable } from "../db/table-builder";
|
|
18
|
+
import { createTenantDb, type TenantDb } from "../db/tenant-db";
|
|
19
|
+
import { createEntity, createTextField, defineApply, defineFeature } from "../engine";
|
|
20
|
+
import type { ProjectionDefinition } from "../engine/types";
|
|
21
|
+
import { createEventsTable } from "../event-store";
|
|
22
|
+
import { createProjectionStateTable } from "../pipeline";
|
|
23
|
+
import { runSchemaCli, type SchemaCliOut } from "../schema-cli";
|
|
24
|
+
import {
|
|
25
|
+
createTestDb,
|
|
26
|
+
type TestDb,
|
|
27
|
+
TestUsers,
|
|
28
|
+
unsafeCreateEntityTable,
|
|
29
|
+
unsafePushTables,
|
|
30
|
+
} from "../stack";
|
|
31
|
+
import { ensureTemporalPolyfill } from "../time/polyfill";
|
|
32
|
+
|
|
33
|
+
const itemEntity = createEntity({
|
|
34
|
+
table: "read_apply_items",
|
|
35
|
+
fields: {
|
|
36
|
+
groupId: createTextField({ required: true }),
|
|
37
|
+
name: createTextField({ required: true }),
|
|
38
|
+
},
|
|
39
|
+
softDelete: true,
|
|
40
|
+
});
|
|
41
|
+
const itemTable = buildEntityTable("apply-item", itemEntity);
|
|
42
|
+
|
|
43
|
+
const COUNTER_TABLE = "read_apply_items_per_group";
|
|
44
|
+
const counterTable = pgTable(COUNTER_TABLE, {
|
|
45
|
+
groupId: uuid("group_id").primaryKey(),
|
|
46
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
47
|
+
itemCount: integer("item_count").notNull().default(0),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
type ItemCreated = { groupId: string };
|
|
51
|
+
const counterProjection: ProjectionDefinition = {
|
|
52
|
+
name: "items-per-group",
|
|
53
|
+
source: "apply-item",
|
|
54
|
+
table: counterTable,
|
|
55
|
+
apply: {
|
|
56
|
+
"apply-item.created": defineApply<ItemCreated>(async (event, tx) => {
|
|
57
|
+
await asRawClient(tx).unsafe(
|
|
58
|
+
`INSERT INTO "${COUNTER_TABLE}" (group_id, tenant_id, item_count) VALUES ($1::uuid, $2::uuid, 1) ON CONFLICT (group_id) DO UPDATE SET item_count = ${COUNTER_TABLE}.item_count + 1`,
|
|
59
|
+
[event.payload.groupId, event.tenantId],
|
|
60
|
+
);
|
|
61
|
+
}),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const feature = defineFeature("applyrebuildtest", (r) => {
|
|
66
|
+
r.entity("apply-item", itemEntity);
|
|
67
|
+
r.projection(counterProjection);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const admin = TestUsers.admin;
|
|
71
|
+
const executor = createEventStoreExecutor(itemTable, itemEntity, { entityName: "apply-item" });
|
|
72
|
+
|
|
73
|
+
function captureOut(): { out: SchemaCliOut; log: string[]; err: string[] } {
|
|
74
|
+
const log: string[] = [];
|
|
75
|
+
const err: string[] = [];
|
|
76
|
+
return { out: { log: (l) => log.push(l), err: (l) => err.push(l) }, log, err };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Writes an app workspace with a single trivial migration + an optional
|
|
80
|
+
// hand-written rebuild marker, bypassing the generate→managed-diff machinery
|
|
81
|
+
// (tested elsewhere) to exercise only the apply→marker→rebuild wiring.
|
|
82
|
+
function writeMigration(migrationId: string, rebuildTables: readonly string[] | null): string {
|
|
83
|
+
const appCwd = join(
|
|
84
|
+
tmpdir(),
|
|
85
|
+
`kumiko-apply-rebuild-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
86
|
+
);
|
|
87
|
+
const migrationsDir = join(appCwd, "kumiko/migrations");
|
|
88
|
+
mkdirSync(migrationsDir, { recursive: true });
|
|
89
|
+
writeFileSync(join(migrationsDir, `${migrationId}.sql`), "SELECT 1;\n");
|
|
90
|
+
if (rebuildTables) {
|
|
91
|
+
writeRebuildMarker(migrationsDir, `${migrationId}.sql`, rebuildTables);
|
|
92
|
+
}
|
|
93
|
+
return appCwd;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let testDb: TestDb;
|
|
97
|
+
let tdb: TenantDb;
|
|
98
|
+
let prevDbUrl: string | undefined;
|
|
99
|
+
|
|
100
|
+
beforeAll(async () => {
|
|
101
|
+
await ensureTemporalPolyfill();
|
|
102
|
+
testDb = await createTestDb();
|
|
103
|
+
await unsafeCreateEntityTable(testDb.db, itemEntity, "apply-item");
|
|
104
|
+
await createEventsTable(testDb.db);
|
|
105
|
+
await createProjectionStateTable(testDb.db);
|
|
106
|
+
await unsafePushTables(testDb.db, { applyItemsPerGroup: counterTable });
|
|
107
|
+
tdb = createTenantDb(testDb.db, admin.tenantId);
|
|
108
|
+
prevDbUrl = process.env["DATABASE_URL"];
|
|
109
|
+
const baseUrl =
|
|
110
|
+
process.env["TEST_DATABASE_URL"] ??
|
|
111
|
+
process.env["DATABASE_URL"] ??
|
|
112
|
+
"postgresql://kumiko:kumiko@localhost:15432/kumiko_test";
|
|
113
|
+
process.env["DATABASE_URL"] = baseUrl.replace(/\/[^/]+$/, `/${testDb.dbName}`);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterAll(async () => {
|
|
117
|
+
if (prevDbUrl !== undefined) process.env["DATABASE_URL"] = prevDbUrl;
|
|
118
|
+
else delete process.env["DATABASE_URL"];
|
|
119
|
+
await testDb?.cleanup();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
beforeEach(async () => {
|
|
123
|
+
await asRawClient(testDb.db).unsafe(
|
|
124
|
+
`TRUNCATE kumiko_events, read_apply_items, ${COUNTER_TABLE}, kumiko_projections RESTART IDENTITY CASCADE`,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
async function counterFor(groupId: string): Promise<number | undefined> {
|
|
129
|
+
const [row] = await selectMany(testDb.db, counterTable, { groupId });
|
|
130
|
+
return row?.itemCount;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe("runSchemaCli apply — projection rebuild", () => {
|
|
134
|
+
const group = "00000000-0000-4000-8000-0000000000a1";
|
|
135
|
+
|
|
136
|
+
test("with features: applied migration's marker triggers a real rebuild", async () => {
|
|
137
|
+
await executor.create({ groupId: group, name: "x" }, admin, tdb);
|
|
138
|
+
// Pipeline not wired on this append → projection is empty until rebuild.
|
|
139
|
+
expect(await counterFor(group)).toBeUndefined();
|
|
140
|
+
|
|
141
|
+
const appCwd = writeMigration("0001_touch_counter", [COUNTER_TABLE]);
|
|
142
|
+
const cap = captureOut();
|
|
143
|
+
const code = await runSchemaCli(["apply"], appCwd, cap.out, { features: [feature] });
|
|
144
|
+
|
|
145
|
+
expect(code).toBe(0);
|
|
146
|
+
expect(cap.log.join("\n")).toContain("Rebuild 1 Projection");
|
|
147
|
+
expect(cap.log.join("\n")).toContain("(1 events");
|
|
148
|
+
// The row was reconstructed from the seeded event — not a no-op loop.
|
|
149
|
+
expect(await counterFor(group)).toBe(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("without features: marker present but no rebuild (dev path)", async () => {
|
|
153
|
+
await executor.create({ groupId: group, name: "x" }, admin, tdb);
|
|
154
|
+
|
|
155
|
+
const appCwd = writeMigration("0002_touch_counter", [COUNTER_TABLE]);
|
|
156
|
+
const cap = captureOut();
|
|
157
|
+
const code = await runSchemaCli(["apply"], appCwd, cap.out);
|
|
158
|
+
|
|
159
|
+
expect(code).toBe(0);
|
|
160
|
+
expect(cap.log.join("\n")).not.toContain("Rebuild");
|
|
161
|
+
// No rebuild ran → projection stays empty.
|
|
162
|
+
expect(await counterFor(group)).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("with features but no marker: apply succeeds, no rebuild", async () => {
|
|
166
|
+
await executor.create({ groupId: group, name: "x" }, admin, tdb);
|
|
167
|
+
|
|
168
|
+
const appCwd = writeMigration("0003_no_marker", null);
|
|
169
|
+
const cap = captureOut();
|
|
170
|
+
const code = await runSchemaCli(["apply"], appCwd, cap.out, { features: [feature] });
|
|
171
|
+
|
|
172
|
+
expect(code).toBe(0);
|
|
173
|
+
expect(cap.log.join("\n")).not.toContain("Rebuild");
|
|
174
|
+
expect(await counterFor(group)).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -930,12 +930,8 @@ export function createEventStoreExecutor(
|
|
|
930
930
|
const tableInfo = extractTableInfo(table);
|
|
931
931
|
const rows = rawRows.map((r) => coerceRow(rehydrateCompoundTypes(r, entity), tableInfo));
|
|
932
932
|
|
|
933
|
-
// list rows
|
|
934
|
-
//
|
|
935
|
-
// it's display-only and must never be used as an optimistic-lock base;
|
|
936
|
-
// edit flows reload via detail(), which reconciles the stream version.
|
|
937
|
-
// Sourcing a lock base from a list row would reintroduce the #336
|
|
938
|
-
// version_conflict. (#336)
|
|
933
|
+
// list rows carry the READ-ROW version (display-only), never an optimistic-lock
|
|
934
|
+
// base — edit flows reload via detail(), which reconciles the stream version.
|
|
939
935
|
if (entityCache && entityName && rows.length > 0) {
|
|
940
936
|
await entityCache.mset(
|
|
941
937
|
user.tenantId,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { validateBoot } from "../boot-validator";
|
|
3
3
|
import { buildAppSchema, findNonJsonSafePath } from "../build-app-schema";
|
|
4
4
|
import { SETTINGS_HUB_FEATURE, SETTINGS_HUB_WORKSPACE } from "../build-config-feature-schema";
|
|
@@ -199,3 +199,51 @@ describe("buildAppSchema — Settings-Hub inline placement", () => {
|
|
|
199
199
|
expect(() => validateBoot([typo])).toThrow(/references nav "config:nav:audience-systemm"/);
|
|
200
200
|
});
|
|
201
201
|
});
|
|
202
|
+
|
|
203
|
+
describe("buildAppSchema — dangling audience-ref dev-warning (#408/3)", () => {
|
|
204
|
+
// billing registriert nur system+tenant-Keys → audience-user wird NIE
|
|
205
|
+
// generiert. Eine Workspace, die config:nav:audience-user referenziert, boot't
|
|
206
|
+
// (Boot-Validator exempt't die Audience-QNs), aber der Eintrag rendert
|
|
207
|
+
// unsichtbar — der Dev muss eine Warnung sehen, nicht still nichts.
|
|
208
|
+
function warnsFor(scopes: string[]): string[] {
|
|
209
|
+
const prevEnv = process.env.NODE_ENV;
|
|
210
|
+
process.env.NODE_ENV = "development";
|
|
211
|
+
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
212
|
+
try {
|
|
213
|
+
buildAppSchema(createRegistry([placingShell(...scopes), billing]));
|
|
214
|
+
return warn.mock.calls.map((c) => String(c[0]));
|
|
215
|
+
} finally {
|
|
216
|
+
warn.mockRestore();
|
|
217
|
+
if (prevEnv === undefined) {
|
|
218
|
+
delete process.env.NODE_ENV;
|
|
219
|
+
} else {
|
|
220
|
+
process.env.NODE_ENV = prevEnv;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
test("referencing a never-generated audience warns (dangling-ref)", () => {
|
|
226
|
+
const messages = warnsFor(["user"]);
|
|
227
|
+
expect(
|
|
228
|
+
messages.some((m) => m.includes("config:nav:audience-user") && m.includes("nie generiert")),
|
|
229
|
+
).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("referencing a generated audience does NOT trigger the dangling warning", () => {
|
|
233
|
+
// audience-tenant IS generated (billing has tenant keys) → kein dangling.
|
|
234
|
+
const messages = warnsFor(["tenant"]);
|
|
235
|
+
expect(messages.some((m) => m.includes("nie generiert"))).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("authoring warnings are suppressed under NODE_ENV=test — no CI-log noise (#408/1)", () => {
|
|
239
|
+
// bun:test setzt NODE_ENV=test; eine un-platzierte Audience (tenant bleibt
|
|
240
|
+
// bei placingShell("system") übrig) darf KEIN console.warn ins Log spülen.
|
|
241
|
+
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
242
|
+
try {
|
|
243
|
+
buildAppSchema(createRegistry([placingShell("system"), billing]));
|
|
244
|
+
expect(warn).not.toHaveBeenCalled();
|
|
245
|
+
} finally {
|
|
246
|
+
warn.mockRestore();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
});
|