@cosmicdrift/kumiko-framework 0.20.0 → 0.21.1
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/LICENSE +1 -1
- package/package.json +3 -3
- package/src/compliance/sub-processors.ts +1 -1
- package/src/engine/types/handlers.ts +1 -1
- package/src/errors/__tests__/classes.test.ts +5 -5
- package/src/errors/kumiko-error.ts +1 -1
- package/src/migrations/index.ts +2 -29
- package/src/migrations/projection-table-index.ts +35 -0
- package/src/pipeline/msp-rebuild.ts +1 -1
- package/src/db/queries/schema-drift.ts +0 -35
- package/src/migrations/__tests__/compare-snapshots.test.ts +0 -150
- package/src/migrations/__tests__/detect-drift.integration.test.ts +0 -328
- package/src/migrations/__tests__/detect-projections-to-rebuild.integration.test.ts +0 -134
- package/src/migrations/__tests__/rebuild-marker.test.ts +0 -79
- package/src/migrations/projection-detection.ts +0 -160
- package/src/migrations/rebuild-marker.ts +0 -64
- package/src/migrations/schema-drift.ts +0 -379
|
@@ -1,328 +0,0 @@
|
|
|
1
|
-
// Integration-Test für detectDrift — vergleicht Journal vs.
|
|
2
|
-
// __drizzle_migrations und expected-Tables vs. Reality. Production-
|
|
3
|
-
// Behavior: assertSchemaCurrent ist der Boot-Gate, jeder False-Positive
|
|
4
|
-
// hier blockiert Container-Starts; jeder False-Negative lässt
|
|
5
|
-
// Schema-Drift unentdeckt durch.
|
|
6
|
-
|
|
7
|
-
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { tmpdir } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
12
|
-
import { asRawClient } from "../../db/query";
|
|
13
|
-
import { ensureTemporalPolyfill } from "../../time/polyfill";
|
|
14
|
-
import { detectDrift } from "../schema-drift";
|
|
15
|
-
|
|
16
|
-
let testDb: BunTestDb;
|
|
17
|
-
let migrationsDir: string;
|
|
18
|
-
|
|
19
|
-
beforeAll(async () => {
|
|
20
|
-
await ensureTemporalPolyfill();
|
|
21
|
-
testDb = await createTestDb();
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
afterAll(async () => {
|
|
25
|
-
await testDb.cleanup();
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
beforeEach(() => {
|
|
29
|
-
migrationsDir = mkdtempSync(join(tmpdir(), "kumiko-drift-"));
|
|
30
|
-
mkdirSync(join(migrationsDir, "meta"), { recursive: true });
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
afterEach(() => {
|
|
34
|
-
rmSync(migrationsDir, { recursive: true, force: true });
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
function writeJournal(entries: { idx: number; tag: string }[]): void {
|
|
38
|
-
const journal = {
|
|
39
|
-
version: "7",
|
|
40
|
-
dialect: "postgresql",
|
|
41
|
-
entries: entries.map((e) => ({
|
|
42
|
-
idx: e.idx,
|
|
43
|
-
version: "7",
|
|
44
|
-
when: 1700000000000 + e.idx,
|
|
45
|
-
tag: e.tag,
|
|
46
|
-
breakpoints: true,
|
|
47
|
-
})),
|
|
48
|
-
};
|
|
49
|
-
writeFileSync(join(migrationsDir, "meta/_journal.json"), JSON.stringify(journal));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
type SnapshotColumn = {
|
|
53
|
-
readonly name: string;
|
|
54
|
-
readonly type: string;
|
|
55
|
-
readonly primaryKey?: boolean;
|
|
56
|
-
readonly notNull?: boolean;
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
function writeSnapshot(
|
|
60
|
-
idx: number,
|
|
61
|
-
tables: Array<{ name: string; columns?: Record<string, SnapshotColumn> }>,
|
|
62
|
-
): void {
|
|
63
|
-
const out: Record<string, unknown> = {};
|
|
64
|
-
for (const t of tables) {
|
|
65
|
-
out[`public.${t.name}`] = {
|
|
66
|
-
schema: "",
|
|
67
|
-
name: t.name,
|
|
68
|
-
columns: t.columns ?? {
|
|
69
|
-
id: { name: "id", type: "uuid", primaryKey: true, notNull: true },
|
|
70
|
-
},
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
writeFileSync(
|
|
74
|
-
join(migrationsDir, "meta", `${String(idx).padStart(4, "0")}_snapshot.json`),
|
|
75
|
-
JSON.stringify({ tables: out }),
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function writeSnapshotSimple(idx: number, tableNames: string[]): void {
|
|
80
|
-
writeSnapshot(
|
|
81
|
-
idx,
|
|
82
|
-
tableNames.map((name) => ({ name })),
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function ensureDrizzleMigrationsTable(): Promise<void> {
|
|
87
|
-
await asRawClient(testDb.db).unsafe(`CREATE SCHEMA IF NOT EXISTS drizzle`);
|
|
88
|
-
await asRawClient(testDb.db).unsafe(`
|
|
89
|
-
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
|
90
|
-
id serial PRIMARY KEY,
|
|
91
|
-
hash text NOT NULL,
|
|
92
|
-
created_at bigint
|
|
93
|
-
)
|
|
94
|
-
`);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function dropDrizzleMigrationsTable(): Promise<void> {
|
|
98
|
-
await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS drizzle.__drizzle_migrations`);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async function insertAppliedMigration(hash: string): Promise<void> {
|
|
102
|
-
await asRawClient(testDb.db).unsafe(
|
|
103
|
-
`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)`,
|
|
104
|
-
[hash, Date.now()],
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
describe("detectDrift", () => {
|
|
109
|
-
beforeEach(async () => {
|
|
110
|
-
await dropDrizzleMigrationsTable();
|
|
111
|
-
// Cleanup test tables that might still exist from earlier runs
|
|
112
|
-
await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS drift_test_users`);
|
|
113
|
-
await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS drift_test_orders`);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("frische DB ohne __drizzle_migrations + 1 Migration im Journal → 1 pending + table missing", async () => {
|
|
117
|
-
writeJournal([{ idx: 0, tag: "0000_init" }]);
|
|
118
|
-
writeSnapshotSimple(0, ["drift_test_users"]);
|
|
119
|
-
|
|
120
|
-
const report = await detectDrift(testDb.db, migrationsDir);
|
|
121
|
-
expect(report.ok).toBe(false);
|
|
122
|
-
expect(report.pendingMigrations).toHaveLength(1);
|
|
123
|
-
expect(report.pendingMigrations[0]?.tag).toBe("0000_init");
|
|
124
|
-
expect(report.missingTables).toEqual(["drift_test_users"]);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test("alle Migrations applied + alle Tabellen existieren → ok", async () => {
|
|
128
|
-
writeJournal([{ idx: 0, tag: "0000_init" }]);
|
|
129
|
-
writeSnapshotSimple(0, ["drift_test_users"]);
|
|
130
|
-
await asRawClient(testDb.db).unsafe(`CREATE TABLE drift_test_users (id uuid PRIMARY KEY)`);
|
|
131
|
-
await ensureDrizzleMigrationsTable();
|
|
132
|
-
await insertAppliedMigration("hash-0000");
|
|
133
|
-
|
|
134
|
-
const report = await detectDrift(testDb.db, migrationsDir);
|
|
135
|
-
expect(report.ok).toBe(true);
|
|
136
|
-
expect(report.pendingMigrations).toHaveLength(0);
|
|
137
|
-
expect(report.missingTables).toHaveLength(0);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test("partial applied: Journal hat 2, applied hat 1 → 1 pending", async () => {
|
|
141
|
-
writeJournal([
|
|
142
|
-
{ idx: 0, tag: "0000_init" },
|
|
143
|
-
{ idx: 1, tag: "0001_add_orders" },
|
|
144
|
-
]);
|
|
145
|
-
writeSnapshotSimple(1, ["drift_test_users", "drift_test_orders"]);
|
|
146
|
-
await asRawClient(testDb.db).unsafe(`CREATE TABLE drift_test_users (id uuid PRIMARY KEY)`);
|
|
147
|
-
await asRawClient(testDb.db).unsafe(`CREATE TABLE drift_test_orders (id uuid PRIMARY KEY)`);
|
|
148
|
-
await ensureDrizzleMigrationsTable();
|
|
149
|
-
await insertAppliedMigration("hash-0000"); // nur eine applied
|
|
150
|
-
|
|
151
|
-
const report = await detectDrift(testDb.db, migrationsDir);
|
|
152
|
-
expect(report.ok).toBe(false);
|
|
153
|
-
expect(report.pendingMigrations).toHaveLength(1);
|
|
154
|
-
expect(report.pendingMigrations[0]?.tag).toBe("0001_add_orders");
|
|
155
|
-
expect(report.missingTables).toHaveLength(0);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
test("alle Migrations applied aber Tabelle fehlt manuell → drift", async () => {
|
|
159
|
-
writeJournal([{ idx: 0, tag: "0000_init" }]);
|
|
160
|
-
writeSnapshotSimple(0, ["drift_test_users", "drift_test_orders"]);
|
|
161
|
-
await asRawClient(testDb.db).unsafe(`CREATE TABLE drift_test_users (id uuid PRIMARY KEY)`);
|
|
162
|
-
// drift_test_orders bewusst NICHT angelegt (simuliert manuellen DROP)
|
|
163
|
-
await ensureDrizzleMigrationsTable();
|
|
164
|
-
await insertAppliedMigration("hash-0000");
|
|
165
|
-
|
|
166
|
-
const report = await detectDrift(testDb.db, migrationsDir);
|
|
167
|
-
expect(report.ok).toBe(false);
|
|
168
|
-
expect(report.pendingMigrations).toHaveLength(0);
|
|
169
|
-
expect(report.missingTables).toEqual(["drift_test_orders"]);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
describe("Layer 3 — column-diff", () => {
|
|
173
|
-
test("snapshot column NOT NULL aber DB nullable → nullability-mismatch", async () => {
|
|
174
|
-
writeJournal([{ idx: 0, tag: "0000_init" }]);
|
|
175
|
-
writeSnapshot(0, [
|
|
176
|
-
{
|
|
177
|
-
name: "drift_test_users",
|
|
178
|
-
columns: {
|
|
179
|
-
id: { name: "id", type: "uuid", primaryKey: true, notNull: true },
|
|
180
|
-
email: { name: "email", type: "text", notNull: true },
|
|
181
|
-
},
|
|
182
|
-
},
|
|
183
|
-
]);
|
|
184
|
-
// DB hat email NULLABLE — drift.
|
|
185
|
-
await asRawClient(testDb.db).unsafe(
|
|
186
|
-
`CREATE TABLE drift_test_users (id uuid PRIMARY KEY, email text)`,
|
|
187
|
-
);
|
|
188
|
-
await ensureDrizzleMigrationsTable();
|
|
189
|
-
await insertAppliedMigration("hash-0000");
|
|
190
|
-
|
|
191
|
-
const report = await detectDrift(testDb.db, migrationsDir);
|
|
192
|
-
expect(report.ok).toBe(false);
|
|
193
|
-
expect(report.columnIssues).toHaveLength(1);
|
|
194
|
-
const issue = report.columnIssues[0];
|
|
195
|
-
expect(issue?.kind).toBe("nullability-mismatch");
|
|
196
|
-
expect(issue?.table).toBe("drift_test_users");
|
|
197
|
-
expect(issue?.column).toBe("email");
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
test("snapshot column im DB nicht da → missing-column", async () => {
|
|
201
|
-
writeJournal([{ idx: 0, tag: "0000_init" }]);
|
|
202
|
-
writeSnapshot(0, [
|
|
203
|
-
{
|
|
204
|
-
name: "drift_test_users",
|
|
205
|
-
columns: {
|
|
206
|
-
id: { name: "id", type: "uuid", primaryKey: true, notNull: true },
|
|
207
|
-
email: { name: "email", type: "text", notNull: true },
|
|
208
|
-
},
|
|
209
|
-
},
|
|
210
|
-
]);
|
|
211
|
-
// DB hat KEINE email-Spalte.
|
|
212
|
-
await asRawClient(testDb.db).unsafe(`CREATE TABLE drift_test_users (id uuid PRIMARY KEY)`);
|
|
213
|
-
await ensureDrizzleMigrationsTable();
|
|
214
|
-
await insertAppliedMigration("hash-0000");
|
|
215
|
-
|
|
216
|
-
const report = await detectDrift(testDb.db, migrationsDir);
|
|
217
|
-
expect(report.ok).toBe(false);
|
|
218
|
-
expect(report.columnIssues).toHaveLength(1);
|
|
219
|
-
expect(report.columnIssues[0]?.kind).toBe("missing-column");
|
|
220
|
-
expect(report.columnIssues[0]?.column).toBe("email");
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
test("DB hat extra Spalte die nicht im Snapshot ist → extra-column", async () => {
|
|
224
|
-
writeJournal([{ idx: 0, tag: "0000_init" }]);
|
|
225
|
-
writeSnapshot(0, [
|
|
226
|
-
{
|
|
227
|
-
name: "drift_test_users",
|
|
228
|
-
columns: {
|
|
229
|
-
id: { name: "id", type: "uuid", primaryKey: true, notNull: true },
|
|
230
|
-
},
|
|
231
|
-
},
|
|
232
|
-
]);
|
|
233
|
-
// DB hat zusätzliche Spalte (z.B. manueller ALTER TABLE in Prod).
|
|
234
|
-
await asRawClient(testDb.db).unsafe(
|
|
235
|
-
`CREATE TABLE drift_test_users (id uuid PRIMARY KEY, secret_legacy text)`,
|
|
236
|
-
);
|
|
237
|
-
await ensureDrizzleMigrationsTable();
|
|
238
|
-
await insertAppliedMigration("hash-0000");
|
|
239
|
-
|
|
240
|
-
const report = await detectDrift(testDb.db, migrationsDir);
|
|
241
|
-
expect(report.ok).toBe(false);
|
|
242
|
-
expect(report.columnIssues).toHaveLength(1);
|
|
243
|
-
expect(report.columnIssues[0]?.kind).toBe("extra-column");
|
|
244
|
-
expect(report.columnIssues[0]?.column).toBe("secret_legacy");
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
test("snapshot type vs db type mismatch → type-mismatch", async () => {
|
|
248
|
-
writeJournal([{ idx: 0, tag: "0000_init" }]);
|
|
249
|
-
writeSnapshot(0, [
|
|
250
|
-
{
|
|
251
|
-
name: "drift_test_users",
|
|
252
|
-
columns: {
|
|
253
|
-
id: { name: "id", type: "uuid", primaryKey: true, notNull: true },
|
|
254
|
-
age: { name: "age", type: "integer" },
|
|
255
|
-
},
|
|
256
|
-
},
|
|
257
|
-
]);
|
|
258
|
-
// DB hat age als TEXT statt INTEGER.
|
|
259
|
-
await asRawClient(testDb.db).unsafe(
|
|
260
|
-
`CREATE TABLE drift_test_users (id uuid PRIMARY KEY, age text)`,
|
|
261
|
-
);
|
|
262
|
-
await ensureDrizzleMigrationsTable();
|
|
263
|
-
await insertAppliedMigration("hash-0000");
|
|
264
|
-
|
|
265
|
-
const report = await detectDrift(testDb.db, migrationsDir);
|
|
266
|
-
expect(report.ok).toBe(false);
|
|
267
|
-
const typeIssue = report.columnIssues.find((i) => i.kind === "type-mismatch");
|
|
268
|
-
expect(typeIssue).toBeDefined();
|
|
269
|
-
if (typeIssue && typeIssue.kind === "type-mismatch") {
|
|
270
|
-
expect(typeIssue.column).toBe("age");
|
|
271
|
-
expect(typeIssue.expected).toBe("integer");
|
|
272
|
-
expect(typeIssue.actual).toBe("text");
|
|
273
|
-
}
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
test("clean state: alle Spalten matchen Snapshot → ok + columnIssues=[]", async () => {
|
|
277
|
-
writeJournal([{ idx: 0, tag: "0000_init" }]);
|
|
278
|
-
writeSnapshot(0, [
|
|
279
|
-
{
|
|
280
|
-
name: "drift_test_users",
|
|
281
|
-
columns: {
|
|
282
|
-
id: { name: "id", type: "uuid", primaryKey: true, notNull: true },
|
|
283
|
-
email: { name: "email", type: "text", notNull: true },
|
|
284
|
-
age: { name: "age", type: "integer" },
|
|
285
|
-
},
|
|
286
|
-
},
|
|
287
|
-
]);
|
|
288
|
-
await asRawClient(testDb.db).unsafe(`
|
|
289
|
-
CREATE TABLE drift_test_users (
|
|
290
|
-
id uuid PRIMARY KEY,
|
|
291
|
-
email text NOT NULL,
|
|
292
|
-
age integer
|
|
293
|
-
)
|
|
294
|
-
`);
|
|
295
|
-
await ensureDrizzleMigrationsTable();
|
|
296
|
-
await insertAppliedMigration("hash-0000");
|
|
297
|
-
|
|
298
|
-
const report = await detectDrift(testDb.db, migrationsDir);
|
|
299
|
-
expect(report.ok).toBe(true);
|
|
300
|
-
expect(report.columnIssues).toEqual([]);
|
|
301
|
-
});
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
test("public.__drizzle_migrations Fallback (Pre-0.20-Drizzle)", async () => {
|
|
305
|
-
writeJournal([{ idx: 0, tag: "0000_init" }]);
|
|
306
|
-
writeSnapshotSimple(0, ["drift_test_users"]);
|
|
307
|
-
await asRawClient(testDb.db).unsafe(`CREATE TABLE drift_test_users (id uuid PRIMARY KEY)`);
|
|
308
|
-
// Legacy: Tabelle in public-Schema statt drizzle-Schema
|
|
309
|
-
await dropDrizzleMigrationsTable();
|
|
310
|
-
await asRawClient(testDb.db).unsafe(`
|
|
311
|
-
CREATE TABLE public.__drizzle_migrations (
|
|
312
|
-
id serial PRIMARY KEY,
|
|
313
|
-
hash text NOT NULL,
|
|
314
|
-
created_at bigint
|
|
315
|
-
)
|
|
316
|
-
`);
|
|
317
|
-
try {
|
|
318
|
-
await asRawClient(testDb.db).unsafe(
|
|
319
|
-
`INSERT INTO public.__drizzle_migrations (hash, created_at) VALUES ('hash-0000', $1)`,
|
|
320
|
-
[Date.now()],
|
|
321
|
-
);
|
|
322
|
-
const report = await detectDrift(testDb.db, migrationsDir);
|
|
323
|
-
expect(report.ok).toBe(true);
|
|
324
|
-
} finally {
|
|
325
|
-
await asRawClient(testDb.db).unsafe(`DROP TABLE public.__drizzle_migrations`);
|
|
326
|
-
}
|
|
327
|
-
});
|
|
328
|
-
});
|
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
// Integration-Test für detectProjectionsToRebuild — die Brücke zwischen
|
|
2
|
-
// Snapshot-Diff (Welle 2) und ImplicitProjection (Sprint G). Beweist dass
|
|
3
|
-
// `kumiko migrate generate` das richtige Marker-File schreibt: ein
|
|
4
|
-
// Spalten-Add auf einer r.entity-Tabelle muss als
|
|
5
|
-
// `<feature>:projection:<entity>-entity` rebuild-Kandidat erkannt werden.
|
|
6
|
-
//
|
|
7
|
-
// Production-Behavior: ohne diese Brücke würden die Welle-2- und
|
|
8
|
-
// Sprint-G-Pieces nebeneinander leben aber sich nicht treffen — Marker
|
|
9
|
-
// wäre leer, kein Rebuild würde ausgelöst.
|
|
10
|
-
|
|
11
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
14
|
-
import { join } from "node:path";
|
|
15
|
-
import { createBooleanField, createEntity, createTextField, defineFeature } from "../../engine";
|
|
16
|
-
import { createRegistry } from "../../engine/registry";
|
|
17
|
-
import { detectProjectionsToRebuild } from "../projection-detection";
|
|
18
|
-
|
|
19
|
-
let migrationsDir: string;
|
|
20
|
-
|
|
21
|
-
beforeEach(() => {
|
|
22
|
-
migrationsDir = mkdtempSync(join(tmpdir(), "kumiko-detect-"));
|
|
23
|
-
mkdirSync(join(migrationsDir, "meta"), { recursive: true });
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
afterEach(() => {
|
|
27
|
-
rmSync(migrationsDir, { recursive: true, force: true });
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
function writeJournal(entries: { idx: number; tag: string }[]): void {
|
|
31
|
-
writeFileSync(
|
|
32
|
-
join(migrationsDir, "meta/_journal.json"),
|
|
33
|
-
JSON.stringify({
|
|
34
|
-
version: "7",
|
|
35
|
-
dialect: "postgresql",
|
|
36
|
-
entries: entries.map((e) => ({
|
|
37
|
-
idx: e.idx,
|
|
38
|
-
version: "7",
|
|
39
|
-
when: 1700000000000 + e.idx,
|
|
40
|
-
tag: e.tag,
|
|
41
|
-
breakpoints: true,
|
|
42
|
-
})),
|
|
43
|
-
}),
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function writeSnapshot(idx: number, tableName: string, columnNames: string[]): void {
|
|
48
|
-
const columns: Record<string, unknown> = {};
|
|
49
|
-
for (const name of columnNames) {
|
|
50
|
-
columns[name] = { name, type: "text" };
|
|
51
|
-
}
|
|
52
|
-
// Plus base-columns die jede Entity-Tabelle hat, damit wir nicht mit
|
|
53
|
-
// dem Test-Compare versehentlich kompletten neue Tabellen markieren.
|
|
54
|
-
columns["id"] = { name: "id", type: "uuid", primaryKey: true, notNull: true };
|
|
55
|
-
columns["tenant_id"] = { name: "tenant_id", type: "uuid", notNull: true };
|
|
56
|
-
columns["version"] = { name: "version", type: "integer", default: 1, notNull: true };
|
|
57
|
-
writeFileSync(
|
|
58
|
-
join(migrationsDir, "meta", `${String(idx).padStart(4, "0")}_snapshot.json`),
|
|
59
|
-
JSON.stringify({
|
|
60
|
-
tables: {
|
|
61
|
-
[`public.${tableName}`]: {
|
|
62
|
-
schema: "",
|
|
63
|
-
name: tableName,
|
|
64
|
-
columns,
|
|
65
|
-
},
|
|
66
|
-
},
|
|
67
|
-
}),
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const widgetEntity = createEntity({
|
|
72
|
-
table: "test_widgets",
|
|
73
|
-
fields: {
|
|
74
|
-
name: createTextField({ required: true }),
|
|
75
|
-
isEnabled: createBooleanField({ default: true }),
|
|
76
|
-
},
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
const widgetFeature = defineFeature("detecttest", (r) => {
|
|
80
|
-
r.entity("widget", widgetEntity);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
describe("detectProjectionsToRebuild", () => {
|
|
84
|
-
test("Spalten-Add auf r.entity-Tabelle → ImplicitProjection als Rebuild-Kandidat", () => {
|
|
85
|
-
// Initial-Migration: 2 Spalten
|
|
86
|
-
writeSnapshot(0, "test_widgets", ["name", "is_enabled"]);
|
|
87
|
-
// Folge-Migration: 3 Spalten (description dazu)
|
|
88
|
-
writeSnapshot(1, "test_widgets", ["name", "is_enabled", "description"]);
|
|
89
|
-
writeJournal([
|
|
90
|
-
{ idx: 0, tag: "0000_init" },
|
|
91
|
-
{ idx: 1, tag: "0001_add_description" },
|
|
92
|
-
]);
|
|
93
|
-
|
|
94
|
-
const registry = createRegistry([widgetFeature]);
|
|
95
|
-
const projections = detectProjectionsToRebuild(registry, migrationsDir);
|
|
96
|
-
|
|
97
|
-
expect(projections).toEqual(["detecttest:projection:widget-entity"]);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("identische Snapshots → keine Rebuild-Kandidaten", () => {
|
|
101
|
-
writeSnapshot(0, "test_widgets", ["name", "is_enabled"]);
|
|
102
|
-
writeSnapshot(1, "test_widgets", ["name", "is_enabled"]);
|
|
103
|
-
writeJournal([
|
|
104
|
-
{ idx: 0, tag: "0000_init" },
|
|
105
|
-
{ idx: 1, tag: "0001_no_op" },
|
|
106
|
-
]);
|
|
107
|
-
|
|
108
|
-
const registry = createRegistry([widgetFeature]);
|
|
109
|
-
expect(detectProjectionsToRebuild(registry, migrationsDir)).toEqual([]);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test("Initial-Migration (nur ein Snapshot) → leer (keine historischen Events)", () => {
|
|
113
|
-
writeSnapshot(0, "test_widgets", ["name"]);
|
|
114
|
-
writeJournal([{ idx: 0, tag: "0000_init" }]);
|
|
115
|
-
|
|
116
|
-
const registry = createRegistry([widgetFeature]);
|
|
117
|
-
expect(detectProjectionsToRebuild(registry, migrationsDir)).toEqual([]);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test("Spalten-Add auf einer Tabelle die KEINE Projection ist → leer", () => {
|
|
121
|
-
// Tabelle "unrelated" ist nicht als Projection registriert (kein
|
|
122
|
-
// r.entity, keine r.projection). Schema-Change soll keinen
|
|
123
|
-
// Rebuild-Marker erzeugen.
|
|
124
|
-
writeSnapshot(0, "unrelated", ["a"]);
|
|
125
|
-
writeSnapshot(1, "unrelated", ["a", "b"]);
|
|
126
|
-
writeJournal([
|
|
127
|
-
{ idx: 0, tag: "0000_init" },
|
|
128
|
-
{ idx: 1, tag: "0001_add_b" },
|
|
129
|
-
]);
|
|
130
|
-
|
|
131
|
-
const registry = createRegistry([widgetFeature]);
|
|
132
|
-
expect(detectProjectionsToRebuild(registry, migrationsDir)).toEqual([]);
|
|
133
|
-
});
|
|
134
|
-
});
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
// Unit-Tests für die Marker-File-IO. Production-Behavior:
|
|
2
|
-
// - Generate-Step schreibt Marker mit kanonischer Struktur
|
|
3
|
-
// - Apply-Step liest Marker zurück (oder null wenn keiner)
|
|
4
|
-
// - schemaVersion-Mismatch wirft (verhindert dass alte Markers gegen
|
|
5
|
-
// neue Lese-Logik fahren)
|
|
6
|
-
|
|
7
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { tmpdir } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import { readRebuildMarker, writeRebuildMarker } from "../rebuild-marker";
|
|
12
|
-
|
|
13
|
-
let tmpDir: string;
|
|
14
|
-
|
|
15
|
-
beforeEach(() => {
|
|
16
|
-
tmpDir = mkdtempSync(join(tmpdir(), "kumiko-marker-"));
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
afterEach(() => {
|
|
20
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
describe("writeRebuildMarker / readRebuildMarker round-trip", () => {
|
|
24
|
-
test("schreibt File mit kanonischer Struktur", () => {
|
|
25
|
-
writeRebuildMarker(tmpDir, "0042_brave_taskmaster", [
|
|
26
|
-
"publicstatus:projection:incident-entity",
|
|
27
|
-
"publicstatus:projection:component-entity",
|
|
28
|
-
]);
|
|
29
|
-
const raw = readFileSync(join(tmpDir, "0042_brave_taskmaster__rebuild.json"), "utf-8");
|
|
30
|
-
const parsed = JSON.parse(raw);
|
|
31
|
-
expect(parsed).toEqual({
|
|
32
|
-
schemaVersion: 1,
|
|
33
|
-
migrationTag: "0042_brave_taskmaster",
|
|
34
|
-
// sortiert — Reihenfolge der projections im Output ist deterministisch
|
|
35
|
-
projections: [
|
|
36
|
-
"publicstatus:projection:component-entity",
|
|
37
|
-
"publicstatus:projection:incident-entity",
|
|
38
|
-
],
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test("read returns parsed marker", () => {
|
|
43
|
-
writeRebuildMarker(tmpDir, "0001_foo", ["app:projection:bar-entity"]);
|
|
44
|
-
const marker = readRebuildMarker(tmpDir, "0001_foo");
|
|
45
|
-
expect(marker?.migrationTag).toBe("0001_foo");
|
|
46
|
-
expect(marker?.projections).toEqual(["app:projection:bar-entity"]);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("leere Projection-Liste schreibt KEIN File (Noise-Reduktion)", () => {
|
|
50
|
-
writeRebuildMarker(tmpDir, "0003_only_index", []);
|
|
51
|
-
expect(readRebuildMarker(tmpDir, "0003_only_index")).toBeNull();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("read returns null wenn File nicht existiert", () => {
|
|
55
|
-
expect(readRebuildMarker(tmpDir, "0099_never_written")).toBeNull();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test("schemaVersion-Mismatch wirft mit klarer Message", () => {
|
|
59
|
-
const path = join(tmpDir, "0042_future__rebuild.json");
|
|
60
|
-
writeFileSync(
|
|
61
|
-
path,
|
|
62
|
-
JSON.stringify({ schemaVersion: 999, migrationTag: "0042_future", projections: [] }),
|
|
63
|
-
);
|
|
64
|
-
expect(() => readRebuildMarker(tmpDir, "0042_future")).toThrow(/schemaVersion/);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test("korrupte JSON wirft (kein silent-null)", () => {
|
|
68
|
-
const path = join(tmpDir, "0050_corrupt__rebuild.json");
|
|
69
|
-
writeFileSync(path, "{ this is not json");
|
|
70
|
-
expect(() => readRebuildMarker(tmpDir, "0050_corrupt")).toThrow();
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("Idempotenz: zweiter write überschreibt", () => {
|
|
74
|
-
writeRebuildMarker(tmpDir, "0010_x", ["a:projection:one-entity"]);
|
|
75
|
-
writeRebuildMarker(tmpDir, "0010_x", ["a:projection:two-entity"]);
|
|
76
|
-
const marker = readRebuildMarker(tmpDir, "0010_x");
|
|
77
|
-
expect(marker?.projections).toEqual(["a:projection:two-entity"]);
|
|
78
|
-
});
|
|
79
|
-
});
|