@cosmicdrift/kumiko-framework 0.22.0 → 0.24.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__/schema-cli-status.integration.test.ts +95 -0
- package/src/__tests__/schema-cli.integration.test.ts +233 -0
- package/src/db/__tests__/rebuild-marker.test.ts +49 -1
- package/src/db/__tests__/sql-inventory.test.ts +1 -1
- package/src/db/rebuild-marker.ts +11 -5
- package/src/db/schema-inspection.ts +14 -1
- package/src/files/__tests__/storage-tracking.integration.test.ts +53 -1
- package/src/files/file-ref-entity.ts +8 -5
- package/src/files/file-routes.ts +8 -1
- package/src/files/storage-tracking.ts +25 -8
- package/src/migrations/__tests__/kumiko-drift.integration.test.ts +33 -0
- package/src/migrations/__tests__/kumiko-drift.report.test.ts +92 -0
- package/src/migrations/kumiko-drift.ts +27 -1
- package/src/schema-cli.ts +14 -12
- package/src/seeding/__tests__/entity-seed.test.ts +14 -3
- package/src/seeding/entity-seed.ts +12 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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>",
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Integration-Test für `runSchemaCli` `status`-Subcommand.
|
|
2
|
+
// Regression für review #155 finding 4: vorher schluckte `try/catch` jeden
|
|
3
|
+
// Fehler (Connection-, Permission-, Query-) und reportete alles als "pending".
|
|
4
|
+
// Jetzt: tracking-table fehlt → 0 applied (kein Fehler), echte Fehler werden
|
|
5
|
+
// propagiert. Konsistent zum detectKumikoDrift-Pattern.
|
|
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 { runSchemaCli, type SchemaCliOut } from "../schema-cli";
|
|
14
|
+
import { ensureTemporalPolyfill } from "../time/polyfill";
|
|
15
|
+
|
|
16
|
+
let testDb: BunTestDb;
|
|
17
|
+
let appDir: string;
|
|
18
|
+
let testUrl: string;
|
|
19
|
+
let prevDbUrl: string | undefined;
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
await ensureTemporalPolyfill();
|
|
23
|
+
testDb = await createTestDb();
|
|
24
|
+
const baseUrl = process.env["TEST_DATABASE_URL"];
|
|
25
|
+
if (!baseUrl) throw new Error("TEST_DATABASE_URL not set — required for this test file");
|
|
26
|
+
testUrl = baseUrl.replace(/\/[^/]+$/, `/${testDb.dbName}`);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterAll(async () => {
|
|
30
|
+
await testDb.cleanup();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
// runSchemaCli resolves appCwd/kumiko/migrations — writeMigration() legt das
|
|
35
|
+
// subdir bei Bedarf an. Tests, die den "Dir fehlt"-Pfad prüfen, rufen
|
|
36
|
+
// writeMigration nicht auf.
|
|
37
|
+
appDir = mkdtempSync(join(tmpdir(), "kumiko-cli-"));
|
|
38
|
+
prevDbUrl = process.env["DATABASE_URL"];
|
|
39
|
+
await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "_kumiko_migrations"`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
rmSync(appDir, { recursive: true, force: true });
|
|
44
|
+
if (prevDbUrl === undefined) delete process.env["DATABASE_URL"];
|
|
45
|
+
else process.env["DATABASE_URL"] = prevDbUrl;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function writeMigration(file: string, sql: string): void {
|
|
49
|
+
const migDir = join(appDir, "kumiko", "migrations");
|
|
50
|
+
mkdirSync(migDir, { recursive: true });
|
|
51
|
+
writeFileSync(join(migDir, file), sql);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function captureOut(): { out: SchemaCliOut; lines: string[] } {
|
|
55
|
+
const lines: string[] = [];
|
|
56
|
+
return {
|
|
57
|
+
out: { log: (l: string) => lines.push(l), err: (l: string) => lines.push(`ERR ${l}`) },
|
|
58
|
+
lines,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("runSchemaCli status", () => {
|
|
63
|
+
test("fresh DB without _kumiko_migrations → 0 applied (no throw)", async () => {
|
|
64
|
+
process.env["DATABASE_URL"] = testUrl;
|
|
65
|
+
writeMigration("0001_init.sql", `SELECT 1;`);
|
|
66
|
+
const { out, lines } = captureOut();
|
|
67
|
+
const code = await runSchemaCli(["status"], appDir, out);
|
|
68
|
+
expect(code).toBe(0);
|
|
69
|
+
const joined = lines.join("\n");
|
|
70
|
+
expect(joined).toContain("0 applied");
|
|
71
|
+
expect(joined).toContain("1 pending");
|
|
72
|
+
// Kein ERR-Prefix → kein silent-swallow eines echten Fehlers.
|
|
73
|
+
expect(joined).not.toContain("ERR ");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("missing kumiko/migrations dir → no-op exit 0", async () => {
|
|
77
|
+
// Bestätigt symmetrisch: kein Verzeichnis = nichts zu reporten, kein Crash.
|
|
78
|
+
process.env["DATABASE_URL"] = testUrl;
|
|
79
|
+
const { out, lines } = captureOut();
|
|
80
|
+
const code = await runSchemaCli(["status"], appDir, out);
|
|
81
|
+
expect(code).toBe(0);
|
|
82
|
+
expect(lines.join("\n")).toContain("Kein kumiko/migrations/");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("unreachable DB → propagates connection error (no silent '0 applied')", async () => {
|
|
86
|
+
// Der gefixte Pfad MUSS connection-failures propagieren — vor dem Fix hat
|
|
87
|
+
// `try { applied = new Set(...) } catch { applied = new Set() }` das zu
|
|
88
|
+
// "0 applied" verfälscht und den Operator in die Irre geführt.
|
|
89
|
+
// Port 1 wird auf POSIX-Systemen für nichts gebunden → connect refused.
|
|
90
|
+
process.env["DATABASE_URL"] = "postgresql://nobody:nope@127.0.0.1:1/__kumiko_unreachable__";
|
|
91
|
+
writeMigration("0001_init.sql", `SELECT 1;`);
|
|
92
|
+
const { out } = captureOut();
|
|
93
|
+
await expect(runSchemaCli(["status"], appDir, out)).rejects.toBeDefined();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { type BunTestDb, createTestDb } from "../bun-db/__tests__/bun-test-db";
|
|
6
|
+
import { runSchemaCli, type SchemaCliOut } from "../schema-cli";
|
|
7
|
+
import { ensureTemporalPolyfill } from "../time/polyfill";
|
|
8
|
+
|
|
9
|
+
function captureOut(): { out: SchemaCliOut; log: string[]; err: string[] } {
|
|
10
|
+
const log: string[] = [];
|
|
11
|
+
const err: string[] = [];
|
|
12
|
+
return { out: { log: (l) => log.push(l), err: (l) => err.push(l) }, log, err };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function freshAppCwd(): string {
|
|
16
|
+
const dir = join(
|
|
17
|
+
tmpdir(),
|
|
18
|
+
`kumiko-schema-cli-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
19
|
+
);
|
|
20
|
+
mkdirSync(dir, { recursive: true });
|
|
21
|
+
mkdirSync(join(dir, "kumiko"), { recursive: true });
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeSchemaFile(appCwd: string, tableName: string, extraField?: string): void {
|
|
26
|
+
const extra = extraField ? `, { name: "${extraField}", pgType: "text", notNull: false }` : "";
|
|
27
|
+
const content = `export const ENTITY_METAS = [
|
|
28
|
+
{
|
|
29
|
+
tableName: "${tableName}",
|
|
30
|
+
source: "unmanaged",
|
|
31
|
+
indexes: [],
|
|
32
|
+
columns: [
|
|
33
|
+
{ name: "id", pgType: "uuid", notNull: true, primaryKey: true, defaultSql: "gen_random_uuid()" }${extra}
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
`;
|
|
38
|
+
writeFileSync(join(appCwd, "kumiko/schema.ts"), content);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("runSchemaCli — no-DB paths", () => {
|
|
42
|
+
let appCwd: string;
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
appCwd = freshAppCwd();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("default subcommand prints usage and exits 0", async () => {
|
|
48
|
+
const cap = captureOut();
|
|
49
|
+
const code = await runSchemaCli([], appCwd, cap.out);
|
|
50
|
+
expect(code).toBe(0);
|
|
51
|
+
expect(cap.log.join("\n")).toContain("Subcommands:");
|
|
52
|
+
expect(cap.err).toHaveLength(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("unknown subcommand falls through to usage", async () => {
|
|
56
|
+
const cap = captureOut();
|
|
57
|
+
const code = await runSchemaCli(["lolwut"], appCwd, cap.out);
|
|
58
|
+
expect(code).toBe(0);
|
|
59
|
+
expect(cap.log.join("\n")).toContain("Subcommands:");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("generate without name exits 1 with neutral usage wording", async () => {
|
|
63
|
+
const cap = captureOut();
|
|
64
|
+
const code = await runSchemaCli(["generate"], appCwd, cap.out);
|
|
65
|
+
expect(code).toBe(1);
|
|
66
|
+
expect(cap.err.join("\n")).toContain("Usage: schema generate <name>");
|
|
67
|
+
expect(cap.err.join("\n")).not.toContain("kumiko-schema");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("generate with missing schema.ts exits 1", async () => {
|
|
71
|
+
const cap = captureOut();
|
|
72
|
+
const code = await runSchemaCli(["generate", "init"], appCwd, cap.out);
|
|
73
|
+
expect(code).toBe(1);
|
|
74
|
+
expect(cap.err.join("\n")).toContain("kumiko/schema.ts");
|
|
75
|
+
expect(cap.err.join("\n")).toContain("fehlt");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("generate writes 0001_<name>.sql + .snapshot.json and exits 0", async () => {
|
|
79
|
+
writeSchemaFile(appCwd, "tbl_a");
|
|
80
|
+
const cap = captureOut();
|
|
81
|
+
const code = await runSchemaCli(["generate", "init"], appCwd, cap.out);
|
|
82
|
+
expect(code).toBe(0);
|
|
83
|
+
|
|
84
|
+
const migrationsDir = join(appCwd, "kumiko/migrations");
|
|
85
|
+
expect(existsSync(migrationsDir)).toBe(true);
|
|
86
|
+
const files = readdirSync(migrationsDir);
|
|
87
|
+
expect(files).toContain("0001_init.sql");
|
|
88
|
+
expect(files).toContain(".snapshot.json");
|
|
89
|
+
|
|
90
|
+
const sql = readFileSync(join(migrationsDir, "0001_init.sql"), "utf8");
|
|
91
|
+
expect(sql).toContain('CREATE TABLE IF NOT EXISTS "tbl_a"');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("generate second time without schema changes prints skip + exits 0", async () => {
|
|
95
|
+
writeSchemaFile(appCwd, "tbl_a");
|
|
96
|
+
const first = captureOut();
|
|
97
|
+
await runSchemaCli(["generate", "init"], appCwd, first.out);
|
|
98
|
+
|
|
99
|
+
const cap = captureOut();
|
|
100
|
+
const code = await runSchemaCli(["generate", "noop"], appCwd, cap.out);
|
|
101
|
+
expect(code).toBe(0);
|
|
102
|
+
expect(cap.log.join("\n")).toContain("No schema changes detected");
|
|
103
|
+
const files = readdirSync(join(appCwd, "kumiko/migrations"));
|
|
104
|
+
expect(files).not.toContain("0002_noop.sql");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("apply without DATABASE_URL exits 1", async () => {
|
|
108
|
+
const dbUrl = process.env["DATABASE_URL"];
|
|
109
|
+
delete process.env["DATABASE_URL"];
|
|
110
|
+
try {
|
|
111
|
+
const cap = captureOut();
|
|
112
|
+
const code = await runSchemaCli(["apply"], appCwd, cap.out);
|
|
113
|
+
expect(code).toBe(1);
|
|
114
|
+
expect(cap.err.join("\n")).toContain("DATABASE_URL not set");
|
|
115
|
+
} finally {
|
|
116
|
+
if (dbUrl !== undefined) process.env["DATABASE_URL"] = dbUrl;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("status without DATABASE_URL exits 1", async () => {
|
|
121
|
+
const dbUrl = process.env["DATABASE_URL"];
|
|
122
|
+
delete process.env["DATABASE_URL"];
|
|
123
|
+
try {
|
|
124
|
+
const cap = captureOut();
|
|
125
|
+
const code = await runSchemaCli(["status"], appCwd, cap.out);
|
|
126
|
+
expect(code).toBe(1);
|
|
127
|
+
expect(cap.err.join("\n")).toContain("DATABASE_URL not set");
|
|
128
|
+
} finally {
|
|
129
|
+
if (dbUrl !== undefined) process.env["DATABASE_URL"] = dbUrl;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("status without kumiko/migrations exits 0 (legacy drizzle path)", async () => {
|
|
134
|
+
const prevDbUrl = process.env["DATABASE_URL"];
|
|
135
|
+
process.env["DATABASE_URL"] = "postgresql://placeholder:placeholder@localhost:1/placeholder";
|
|
136
|
+
try {
|
|
137
|
+
const cap = captureOut();
|
|
138
|
+
const code = await runSchemaCli(["status"], appCwd, cap.out);
|
|
139
|
+
expect(code).toBe(0);
|
|
140
|
+
expect(cap.log.join("\n")).toContain("alten drizzle-Pfad");
|
|
141
|
+
} finally {
|
|
142
|
+
if (prevDbUrl !== undefined) process.env["DATABASE_URL"] = prevDbUrl;
|
|
143
|
+
else delete process.env["DATABASE_URL"];
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("runSchemaCli — DB-backed paths", () => {
|
|
149
|
+
let testDb: BunTestDb;
|
|
150
|
+
let dbUrl: string;
|
|
151
|
+
let prevDbUrl: string | undefined;
|
|
152
|
+
|
|
153
|
+
beforeAll(async () => {
|
|
154
|
+
await ensureTemporalPolyfill();
|
|
155
|
+
testDb = await createTestDb();
|
|
156
|
+
const baseUrl =
|
|
157
|
+
process.env["TEST_DATABASE_URL"] ??
|
|
158
|
+
process.env["DATABASE_URL"] ??
|
|
159
|
+
"postgresql://kumiko:kumiko@localhost:15432/kumiko_test";
|
|
160
|
+
dbUrl = baseUrl.replace(/\/[^/]+$/, `/${testDb.dbName}`);
|
|
161
|
+
prevDbUrl = process.env["DATABASE_URL"];
|
|
162
|
+
process.env["DATABASE_URL"] = dbUrl;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
afterAll(async () => {
|
|
166
|
+
if (prevDbUrl !== undefined) process.env["DATABASE_URL"] = prevDbUrl;
|
|
167
|
+
else delete process.env["DATABASE_URL"];
|
|
168
|
+
await testDb?.cleanup();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("apply runs pending migrations, status reports 0 pending → exit 0", async () => {
|
|
172
|
+
const appCwd = freshAppCwd();
|
|
173
|
+
writeSchemaFile(appCwd, "tbl_apply_ok");
|
|
174
|
+
await runSchemaCli(["generate", "apply_ok"], appCwd, captureOut().out);
|
|
175
|
+
|
|
176
|
+
const applyCap = captureOut();
|
|
177
|
+
const applyCode = await runSchemaCli(["apply"], appCwd, applyCap.out);
|
|
178
|
+
expect(applyCode).toBe(0);
|
|
179
|
+
expect(applyCap.log.join("\n")).toContain("Applied 1");
|
|
180
|
+
|
|
181
|
+
const statusCap = captureOut();
|
|
182
|
+
const statusCode = await runSchemaCli(["status"], appCwd, statusCap.out);
|
|
183
|
+
expect(statusCode).toBe(0);
|
|
184
|
+
expect(statusCap.log.join("\n")).toContain("0 pending");
|
|
185
|
+
|
|
186
|
+
rmSync(appCwd, { recursive: true, force: true });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("status with pending migrations exits 1 (regression-pin: CI-gating signal)", async () => {
|
|
190
|
+
const appCwd = freshAppCwd();
|
|
191
|
+
writeSchemaFile(appCwd, "tbl_pending");
|
|
192
|
+
await runSchemaCli(["generate", "pending_test"], appCwd, captureOut().out);
|
|
193
|
+
|
|
194
|
+
const statusCap = captureOut();
|
|
195
|
+
const statusCode = await runSchemaCli(["status"], appCwd, statusCap.out);
|
|
196
|
+
expect(statusCode).toBe(1);
|
|
197
|
+
expect(statusCap.log.join("\n")).toContain("1 pending");
|
|
198
|
+
|
|
199
|
+
rmSync(appCwd, { recursive: true, force: true });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("apply on already-applied migrations is idempotent + exits 0", async () => {
|
|
203
|
+
const appCwd = freshAppCwd();
|
|
204
|
+
writeSchemaFile(appCwd, "tbl_idem");
|
|
205
|
+
await runSchemaCli(["generate", "idem_test"], appCwd, captureOut().out);
|
|
206
|
+
await runSchemaCli(["apply"], appCwd, captureOut().out);
|
|
207
|
+
|
|
208
|
+
const cap = captureOut();
|
|
209
|
+
const code = await runSchemaCli(["apply"], appCwd, cap.out);
|
|
210
|
+
expect(code).toBe(0);
|
|
211
|
+
expect(cap.log.join("\n")).toContain("already applied");
|
|
212
|
+
|
|
213
|
+
rmSync(appCwd, { recursive: true, force: true });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("baseline marks migrations applied without running SQL", async () => {
|
|
217
|
+
const appCwd = freshAppCwd();
|
|
218
|
+
writeSchemaFile(appCwd, "tbl_baseline");
|
|
219
|
+
await runSchemaCli(["generate", "baseline_test"], appCwd, captureOut().out);
|
|
220
|
+
|
|
221
|
+
const cap = captureOut();
|
|
222
|
+
const code = await runSchemaCli(["baseline"], appCwd, cap.out);
|
|
223
|
+
expect(code).toBe(0);
|
|
224
|
+
expect(cap.log.join("\n")).toContain("Marked 1 migration(s) as applied");
|
|
225
|
+
|
|
226
|
+
const statusCap = captureOut();
|
|
227
|
+
const statusCode = await runSchemaCli(["status"], appCwd, statusCap.out);
|
|
228
|
+
expect(statusCode).toBe(0);
|
|
229
|
+
expect(statusCap.log.join("\n")).toContain("0 pending");
|
|
230
|
+
|
|
231
|
+
rmSync(appCwd, { recursive: true, force: true });
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -9,11 +9,12 @@ import { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker } from "..
|
|
|
9
9
|
function meta(
|
|
10
10
|
tableName: string,
|
|
11
11
|
extraColumn?: EntityTableMeta["columns"][number],
|
|
12
|
+
indexes: EntityTableMeta["indexes"] = [],
|
|
12
13
|
): EntityTableMeta {
|
|
13
14
|
return {
|
|
14
15
|
tableName,
|
|
15
16
|
source: "unmanaged",
|
|
16
|
-
indexes
|
|
17
|
+
indexes,
|
|
17
18
|
columns: [
|
|
18
19
|
{ name: "id", pgType: "uuid", notNull: true, primaryKey: true },
|
|
19
20
|
...(extraColumn ? [extraColumn] : []),
|
|
@@ -40,6 +41,30 @@ describe("rebuildTablesFromDiff", () => {
|
|
|
40
41
|
const snap = snapshotFromMetas([meta("read_a")]);
|
|
41
42
|
expect(rebuildTablesFromDiff(diffSnapshots(snap, snap))).toEqual([]);
|
|
42
43
|
});
|
|
44
|
+
|
|
45
|
+
test("index-only change → no rebuild (ALTER bringt Tabelle alleine in Soll)", () => {
|
|
46
|
+
const prev = snapshotFromMetas([meta("read_a")]);
|
|
47
|
+
const next = snapshotFromMetas([
|
|
48
|
+
meta("read_a", undefined, [{ name: "read_a_id_idx", columns: ["id"] }]),
|
|
49
|
+
]);
|
|
50
|
+
expect(rebuildTablesFromDiff(diffSnapshots(prev, next))).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("dropped-column-only change → no rebuild", () => {
|
|
54
|
+
const prev = snapshotFromMetas([
|
|
55
|
+
meta("read_a", { name: "old", pgType: "text", notNull: false }),
|
|
56
|
+
]);
|
|
57
|
+
const next = snapshotFromMetas([meta("read_a")]);
|
|
58
|
+
expect(rebuildTablesFromDiff(diffSnapshots(prev, next))).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("new-column change → rebuild (Backfill aus Events nötig)", () => {
|
|
62
|
+
const prev = snapshotFromMetas([meta("read_a")]);
|
|
63
|
+
const next = snapshotFromMetas([
|
|
64
|
+
meta("read_a", { name: "title", pgType: "text", notNull: false }),
|
|
65
|
+
]);
|
|
66
|
+
expect(rebuildTablesFromDiff(diffSnapshots(prev, next))).toEqual(["read_a"]);
|
|
67
|
+
});
|
|
43
68
|
});
|
|
44
69
|
|
|
45
70
|
describe("write/read marker", () => {
|
|
@@ -83,4 +108,27 @@ describe("write/read marker", () => {
|
|
|
83
108
|
rmSync(dir, { recursive: true, force: true });
|
|
84
109
|
}
|
|
85
110
|
});
|
|
111
|
+
|
|
112
|
+
test("version-mismatch marker → [] (graceful degradation, blockt v2 nicht als v1)", () => {
|
|
113
|
+
const dir = tmpDir();
|
|
114
|
+
try {
|
|
115
|
+
writeFileSync(
|
|
116
|
+
join(dir, "0005_future.rebuild.json"),
|
|
117
|
+
JSON.stringify({ version: 2, tables: ["read_x"] }),
|
|
118
|
+
);
|
|
119
|
+
expect(readRebuildMarker(dir, "0005_future")).toEqual([]);
|
|
120
|
+
} finally {
|
|
121
|
+
rmSync(dir, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("missing version field → []", () => {
|
|
126
|
+
const dir = tmpDir();
|
|
127
|
+
try {
|
|
128
|
+
writeFileSync(join(dir, "0006_noversion.rebuild.json"), JSON.stringify({ tables: ["x"] }));
|
|
129
|
+
expect(readRebuildMarker(dir, "0006_noversion")).toEqual([]);
|
|
130
|
+
} finally {
|
|
131
|
+
rmSync(dir, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
86
134
|
});
|
|
@@ -30,7 +30,7 @@ describe("sql-inventory", () => {
|
|
|
30
30
|
true,
|
|
31
31
|
);
|
|
32
32
|
expect(isRawSqlAllowed("/repo/bin/commands/schema.ts")).toBe(true);
|
|
33
|
-
expect(isRawSqlAllowed("/repo/scripts/codemod-
|
|
33
|
+
expect(isRawSqlAllowed("/repo/scripts/codemod-bun-db-swap.ts")).toBe(true);
|
|
34
34
|
expect(
|
|
35
35
|
isRawSqlAllowed("/repo/packages/bundled-features/src/sessions/handlers/cleanup.job.ts"),
|
|
36
36
|
).toBe(false);
|
package/src/db/rebuild-marker.ts
CHANGED
|
@@ -33,12 +33,16 @@ function markerPathFor(migrationsDir: string, migrationId: string): string {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
// Tabellen die nach dieser Migration einen Projection-Rebuild brauchen können:
|
|
36
|
-
// neu angelegte +
|
|
37
|
-
//
|
|
38
|
-
//
|
|
36
|
+
// neu angelegte + Tabellen mit neuer Spalte. Index-/Nullability-/Default-/
|
|
37
|
+
// Drop-only-Änderungen brauchen KEINEN Rebuild — das generierte ALTER-SQL
|
|
38
|
+
// bringt die Tabelle alleine in den Soll-Zustand. Über-Rebuild ist safe-but-
|
|
39
|
+
// wasteful (Tabellen-Lock + Full-Replay auf grossen Streams), deshalb hier
|
|
40
|
+
// konservativ einschränken. Sortiert + dedupliziert für stabilen PR-Diff.
|
|
39
41
|
export function rebuildTablesFromDiff(diff: SchemaDiff): readonly string[] {
|
|
40
42
|
const names = new Set<string>();
|
|
41
|
-
for (const t of diff.changedTables)
|
|
43
|
+
for (const t of diff.changedTables) {
|
|
44
|
+
if (t.newColumns.length > 0) names.add(t.tableName);
|
|
45
|
+
}
|
|
42
46
|
for (const t of diff.newTables) names.add(t.tableName);
|
|
43
47
|
return [...names].sort();
|
|
44
48
|
}
|
|
@@ -68,7 +72,9 @@ export function readRebuildMarker(migrationsDir: string, migrationId: string): r
|
|
|
68
72
|
} catch {
|
|
69
73
|
return [];
|
|
70
74
|
}
|
|
71
|
-
if (typeof parsed !== "object" || parsed === null
|
|
75
|
+
if (typeof parsed !== "object" || parsed === null) return [];
|
|
76
|
+
if (!("version" in parsed) || parsed.version !== MARKER_VERSION) return [];
|
|
77
|
+
if (!("tables" in parsed)) return [];
|
|
72
78
|
const { tables } = parsed;
|
|
73
79
|
if (!Array.isArray(tables)) return [];
|
|
74
80
|
return tables.filter((t): t is string => typeof t === "string");
|
|
@@ -29,10 +29,23 @@ export async function tableExists(
|
|
|
29
29
|
unsafe?: (s: string, p?: readonly unknown[]) => Promise<readonly { exists: boolean }[]>;
|
|
30
30
|
};
|
|
31
31
|
const client = dbAny.$client ?? dbAny.session?.client ?? dbAny;
|
|
32
|
+
// quote_ident-Round-trip auf SQL-Seite: ohne Quotes folded postgres
|
|
33
|
+
// unquoted identifier case-insensitiv (myWidget → mywidget), während die
|
|
34
|
+
// generierte DDL den Namen via quoteIdent("myWidget") → "myWidget" case-
|
|
35
|
+
// preserved schreibt. quote_ident sorgt für identische Quotierung beidseits.
|
|
36
|
+
// Schema-qualifizierte Namen (`public.events`) werden per Split einzeln quotet.
|
|
37
|
+
const dotIdx = fullyQualifiedName.indexOf(".");
|
|
38
|
+
const [sql, params] =
|
|
39
|
+
dotIdx >= 0
|
|
40
|
+
? [
|
|
41
|
+
`SELECT to_regclass(quote_ident($1) || '.' || quote_ident($2)) IS NOT NULL AS exists`,
|
|
42
|
+
[fullyQualifiedName.slice(0, dotIdx), fullyQualifiedName.slice(dotIdx + 1)],
|
|
43
|
+
]
|
|
44
|
+
: [`SELECT to_regclass(quote_ident($1)) IS NOT NULL AS exists`, [fullyQualifiedName]];
|
|
32
45
|
const rows = await (
|
|
33
46
|
client as {
|
|
34
47
|
unsafe: (s: string, p?: readonly unknown[]) => Promise<readonly { exists: boolean }[]>;
|
|
35
48
|
}
|
|
36
|
-
).unsafe(
|
|
49
|
+
).unsafe(sql, params);
|
|
37
50
|
return rows[0]?.exists ?? false;
|
|
38
51
|
}
|
|
@@ -9,13 +9,17 @@
|
|
|
9
9
|
// Drizzle's mode:"number", so arithmetic in assertions Just Works).
|
|
10
10
|
|
|
11
11
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
12
13
|
import { asRawClient, selectMany } from "../../db/query";
|
|
14
|
+
import { createTenantDb } from "../../db/tenant-db";
|
|
13
15
|
import type { SessionUser } from "../../engine";
|
|
14
16
|
import { createTestUser, setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
15
17
|
import { buildMultipartBody, patchFileInstanceofForBunTest } from "../../testing";
|
|
16
18
|
import {
|
|
17
19
|
createFilesFeature,
|
|
18
20
|
createInMemoryFileProvider,
|
|
21
|
+
fileRefEntity,
|
|
22
|
+
fileRefsTable,
|
|
19
23
|
filesStorageTrackingFeature,
|
|
20
24
|
type InMemoryFileProvider,
|
|
21
25
|
tenantStorageUsageTable,
|
|
@@ -63,7 +67,7 @@ beforeEach(async () => {
|
|
|
63
67
|
await stack.eventDispatcher?.ensureRegistered();
|
|
64
68
|
});
|
|
65
69
|
|
|
66
|
-
async function upload(user: SessionUser, name: string, content: Uint8Array): Promise<
|
|
70
|
+
async function upload(user: SessionUser, name: string, content: Uint8Array): Promise<string> {
|
|
67
71
|
const token = await stack.jwt.sign(user);
|
|
68
72
|
const formData = new FormData();
|
|
69
73
|
formData.append("file", new File([Buffer.from(content)], name, { type: "image/png" }));
|
|
@@ -74,6 +78,28 @@ async function upload(user: SessionUser, name: string, content: Uint8Array): Pro
|
|
|
74
78
|
body: multipartBody,
|
|
75
79
|
});
|
|
76
80
|
expect(res.status).toBe(201);
|
|
81
|
+
const body = (await res.json()) as { id: string };
|
|
82
|
+
return body.id;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function deleteFile(user: SessionUser, id: string): Promise<void> {
|
|
86
|
+
const token = await stack.jwt.sign(user);
|
|
87
|
+
const res = await stack.app.request(`/api/files/${id}`, {
|
|
88
|
+
method: "DELETE",
|
|
89
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
90
|
+
});
|
|
91
|
+
expect(res.status).toBe(200);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function restoreFile(user: SessionUser, id: string): Promise<void> {
|
|
95
|
+
// No HTTP route for restore — drive the entity executor directly, which is
|
|
96
|
+
// the same path file-routes uses for create/delete. Emits fileRef.restored
|
|
97
|
+
// with { previous } that the MSP re-increments on.
|
|
98
|
+
const executor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
|
|
99
|
+
entityName: "fileRef",
|
|
100
|
+
});
|
|
101
|
+
const result = await executor.restore({ id }, user, createTenantDb(stack.db, user.tenantId));
|
|
102
|
+
if (!result.isSuccess) throw new Error(`restore failed: ${JSON.stringify(result)}`);
|
|
77
103
|
}
|
|
78
104
|
|
|
79
105
|
async function usageFor(tenantId: string): Promise<{ totalBytes: number; fileCount: number }> {
|
|
@@ -121,6 +147,32 @@ describe("tenant-storage-usage MSP", () => {
|
|
|
121
147
|
expect(otherUsage).toEqual({ totalBytes: LARGE.length, fileCount: 1 });
|
|
122
148
|
});
|
|
123
149
|
|
|
150
|
+
test("delete decrements totalBytes and fileCount by the deleted file's size", async () => {
|
|
151
|
+
const idSmall = await upload(admin, "a.png", SMALL);
|
|
152
|
+
await upload(admin, "b.png", LARGE);
|
|
153
|
+
await stack.eventDispatcher?.runOnce();
|
|
154
|
+
|
|
155
|
+
await deleteFile(admin, idSmall);
|
|
156
|
+
await stack.eventDispatcher?.runOnce();
|
|
157
|
+
|
|
158
|
+
const usage = await usageFor(admin.tenantId);
|
|
159
|
+
expect(usage).toEqual({ totalBytes: LARGE.length, fileCount: 1 });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("restore re-increments after delete — round-trip leaves usage unchanged", async () => {
|
|
163
|
+
const id = await upload(admin, "a.png", SMALL);
|
|
164
|
+
await stack.eventDispatcher?.runOnce();
|
|
165
|
+
expect(await usageFor(admin.tenantId)).toEqual({ totalBytes: SMALL.length, fileCount: 1 });
|
|
166
|
+
|
|
167
|
+
await deleteFile(admin, id);
|
|
168
|
+
await stack.eventDispatcher?.runOnce();
|
|
169
|
+
expect(await usageFor(admin.tenantId)).toEqual({ totalBytes: 0, fileCount: 0 });
|
|
170
|
+
|
|
171
|
+
await restoreFile(admin, id);
|
|
172
|
+
await stack.eventDispatcher?.runOnce();
|
|
173
|
+
expect(await usageFor(admin.tenantId)).toEqual({ totalBytes: SMALL.length, fileCount: 1 });
|
|
174
|
+
});
|
|
175
|
+
|
|
124
176
|
test("lastUpdatedAt is set and advances on subsequent uploads", async () => {
|
|
125
177
|
await upload(admin, "a.png", SMALL);
|
|
126
178
|
await stack.eventDispatcher?.runOnce();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createEntity, createNumberField, createTextField
|
|
1
|
+
import { createEntity, createNumberField, createTextField } from "../engine";
|
|
2
2
|
|
|
3
3
|
// fileRef — das File-Metadata-Entity. Ganz normales ES-Entity: Upload/Delete
|
|
4
4
|
// laufen über den Standard-Executor (file-routes.ts), die Tabelle `file_refs`
|
|
@@ -8,11 +8,16 @@ import { createEntity, createNumberField, createTextField, createTimestampField
|
|
|
8
8
|
// Ein Delete markiert `isDeleted=true` (wiederherstellbar, kein "sofort weg");
|
|
9
9
|
// echtes Erasure (Art. 17) läuft über den Forget-Hook + Retention-Cleanup.
|
|
10
10
|
//
|
|
11
|
+
// `insertedAt`/`insertedById` sind framework-managed base columns (siehe
|
|
12
|
+
// buildBaseColumns in table-builder.ts) und dürfen NICHT als Entity-Fields
|
|
13
|
+
// dupliziert werden — fieldColumns gewinnen beim Merge, und die Field-Variante
|
|
14
|
+
// ohne `.default(now()).notNull()` macht inserted_at still nullable.
|
|
15
|
+
//
|
|
11
16
|
// PII-Annotations:
|
|
12
17
|
// - fileName → pii: true (Originalname enthält oft Personen-Bezug:
|
|
13
18
|
// "Marc-Lebenslauf.pdf", "Krankheitsattest-Mai.pdf"). Andere Felder
|
|
14
|
-
// (storageKey, mimeType, size, entityType, entityId, fieldName
|
|
15
|
-
//
|
|
19
|
+
// (storageKey, mimeType, size, entityType, entityId, fieldName) treffen
|
|
20
|
+
// die PII-Heuristik nicht.
|
|
16
21
|
export const fileRefEntity = createEntity({
|
|
17
22
|
table: "file_refs",
|
|
18
23
|
softDelete: true,
|
|
@@ -24,7 +29,5 @@ export const fileRefEntity = createEntity({
|
|
|
24
29
|
entityType: createTextField(),
|
|
25
30
|
entityId: createTextField(),
|
|
26
31
|
fieldName: createTextField(),
|
|
27
|
-
insertedAt: createTimestampField({ sortable: true, filterable: true }),
|
|
28
|
-
insertedById: createTextField(),
|
|
29
32
|
},
|
|
30
33
|
});
|
package/src/files/file-routes.ts
CHANGED
|
@@ -236,7 +236,14 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
236
236
|
// entity, not a files-specific path.
|
|
237
237
|
const result = await executor.delete({ id }, user, createTenantDb(db, user.tenantId));
|
|
238
238
|
if (!result.isSuccess) {
|
|
239
|
-
|
|
239
|
+
// NotFound (race between guard and executor) reuses the 404-masking
|
|
240
|
+
// pattern from the access-deny path above. Everything else (version
|
|
241
|
+
// conflict 409, ownership 403, validation 422, internal 500) gets the
|
|
242
|
+
// real status off the error so callers can distinguish recoverable
|
|
243
|
+
// from terminal — pauschales 404 hier hat genau das verschleiert.
|
|
244
|
+
const status = result.error.httpStatus as 400 | 403 | 404 | 409 | 422 | 500;
|
|
245
|
+
const code = status === 404 ? "not_found" : result.error.code;
|
|
246
|
+
return c.json({ error: code }, status);
|
|
240
247
|
}
|
|
241
248
|
return c.json({ ok: true });
|
|
242
249
|
});
|
|
@@ -17,15 +17,24 @@ import { defineFeature } from "../engine";
|
|
|
17
17
|
|
|
18
18
|
// fileRef is a standard ES entity, so usage tracking subscribes to its
|
|
19
19
|
// auto-verb events. `fileRef.created` payload carries the entity fields
|
|
20
|
-
// (incl. size); `fileRef.deleted`
|
|
21
|
-
// row), so the byte count to
|
|
20
|
+
// (incl. size); `fileRef.deleted` / `fileRef.restored` carry `{ previous }`
|
|
21
|
+
// (the pre-event row), so the byte count to apply lives at previous.size.
|
|
22
22
|
const FILE_REF_CREATED = entityEventName("fileRef", "created");
|
|
23
23
|
const FILE_REF_DELETED = entityEventName("fileRef", "deleted");
|
|
24
|
+
const FILE_REF_RESTORED = entityEventName("fileRef", "restored");
|
|
24
25
|
|
|
25
26
|
function readNumber(value: unknown): number {
|
|
26
27
|
return typeof value === "number" ? value : 0;
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
function sizeFromPrevious(payload: Record<string, unknown>): number {
|
|
31
|
+
const previous = payload["previous"];
|
|
32
|
+
if (previous && typeof previous === "object" && !Array.isArray(previous)) {
|
|
33
|
+
return readNumber((previous as Record<string, unknown>)["size"]); // @cast-boundary engine-payload
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
// bigint in `mode: "number"` returns a JS number (safe up to 2^53 ≈ 9e15
|
|
30
39
|
// bytes ≈ 8 petabytes per tenant — large enough for any practical storage
|
|
31
40
|
// quota). Default "bigint" mode would hand back a bigint value, which
|
|
@@ -59,12 +68,7 @@ export const filesStorageTrackingFeature = defineFeature("files-storage-tracking
|
|
|
59
68
|
);
|
|
60
69
|
},
|
|
61
70
|
[FILE_REF_DELETED]: async (event, tx) => {
|
|
62
|
-
|
|
63
|
-
const previous = event.payload["previous"];
|
|
64
|
-
const size =
|
|
65
|
-
previous && typeof previous === "object" && !Array.isArray(previous)
|
|
66
|
-
? readNumber((previous as Record<string, unknown>)["size"]) // @cast-boundary engine-payload
|
|
67
|
-
: 0;
|
|
71
|
+
const size = sizeFromPrevious(event.payload);
|
|
68
72
|
// Decrement on delete. INSERT values are 0/0 so a delete that somehow
|
|
69
73
|
// precedes any upload can't create negative usage; the on-conflict
|
|
70
74
|
// path applies the real negative delta. Async (dispatcher) —
|
|
@@ -77,6 +81,19 @@ export const filesStorageTrackingFeature = defineFeature("files-storage-tracking
|
|
|
77
81
|
{ set: { lastUpdatedAt: sql`now()` } },
|
|
78
82
|
);
|
|
79
83
|
},
|
|
84
|
+
[FILE_REF_RESTORED]: async (event, tx) => {
|
|
85
|
+
// Restore re-increments by the soft-deleted row's size — symmetric
|
|
86
|
+
// to delete. Without this handler totalBytes/fileCount drift low
|
|
87
|
+
// after every delete→restore round-trip.
|
|
88
|
+
const size = sizeFromPrevious(event.payload);
|
|
89
|
+
await incrementCounter(
|
|
90
|
+
tx,
|
|
91
|
+
tenantStorageUsageTable,
|
|
92
|
+
{ tenantId: event.tenantId, totalBytes: size, fileCount: 1 },
|
|
93
|
+
{ totalBytes: size, fileCount: 1 },
|
|
94
|
+
{ set: { lastUpdatedAt: sql`now()` } },
|
|
95
|
+
);
|
|
96
|
+
},
|
|
80
97
|
},
|
|
81
98
|
});
|
|
82
99
|
});
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
runMigrationsFromDir,
|
|
17
17
|
} from "../../db/migrate-runner";
|
|
18
18
|
import { asRawClient } from "../../db/query";
|
|
19
|
+
import { tableExists } from "../../db/schema-inspection";
|
|
19
20
|
import { createEntity, createTextField } from "../../engine";
|
|
20
21
|
import { ensureTemporalPolyfill } from "../../time/polyfill";
|
|
21
22
|
import { assertKumikoSchemaCurrent, detectKumikoDrift, SchemaDriftError } from "../kumiko-drift";
|
|
@@ -38,6 +39,7 @@ beforeEach(async () => {
|
|
|
38
39
|
await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "_kumiko_migrations"`);
|
|
39
40
|
await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "kdrift_widget"`);
|
|
40
41
|
await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "kdrift_gen"`);
|
|
42
|
+
await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "kdriftMixed"`);
|
|
41
43
|
});
|
|
42
44
|
|
|
43
45
|
afterEach(() => {
|
|
@@ -101,6 +103,37 @@ describe("kumiko-drift boot-gate", () => {
|
|
|
101
103
|
expect(report.missingTables).toEqual(["kdrift_widget"]);
|
|
102
104
|
});
|
|
103
105
|
|
|
106
|
+
test("missing migrations dir → ok (no migrations to validate)", async () => {
|
|
107
|
+
// Regression für review #155 finding 1: existing-App-Upgrade ohne
|
|
108
|
+
// ./kumiko/migrations darf nicht roh ENOENT werfen (würde sonst plain
|
|
109
|
+
// Error statt SchemaDriftError → kein Remediation-Hint im Boot-Log).
|
|
110
|
+
rmSync(dir, { recursive: true, force: true });
|
|
111
|
+
const report = await detectKumikoDrift(testDb.db, dir);
|
|
112
|
+
expect(report.ok).toBe(true);
|
|
113
|
+
expect(report.pending).toEqual([]);
|
|
114
|
+
await expect(assertKumikoSchemaCurrent(testDb.db, dir)).resolves.toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("mixed-case snapshot tableName resolves via quote_ident round-trip", async () => {
|
|
118
|
+
// Regression für review #155 finding 3: postgres folded unquoted
|
|
119
|
+
// identifier case-insensitiv (myWidget → mywidget) in to_regclass, während
|
|
120
|
+
// die DDL via quoteIdent("kdriftMixed") → "kdriftMixed" case-preserved
|
|
121
|
+
// schreibt. tableExists muss quote_ident-Round-trip machen, sonst False-
|
|
122
|
+
// positive-Drift bei jeder mixed-case Entity-Tabelle.
|
|
123
|
+
await asRawClient(testDb.db).unsafe(`CREATE TABLE "kdriftMixed" ("id" text PRIMARY KEY)`);
|
|
124
|
+
expect(await tableExists(testDb.db, "kdriftMixed")).toBe(true);
|
|
125
|
+
// Lowercase-Variante DARF nicht matchen — Round-trip preserved case.
|
|
126
|
+
expect(await tableExists(testDb.db, "kdriftmixed")).toBe(false);
|
|
127
|
+
|
|
128
|
+
writeMigration("0001_init.sql", `CREATE TABLE "kdriftMixed" ("id" text PRIMARY KEY);`);
|
|
129
|
+
writeSnapshot(["kdriftMixed"]);
|
|
130
|
+
await asRawClient(testDb.db).unsafe(`DROP TABLE "kdriftMixed"`);
|
|
131
|
+
await runMigrationsFromDir(testDb.db, dir);
|
|
132
|
+
const report = await detectKumikoDrift(testDb.db, dir);
|
|
133
|
+
expect(report.missingTables).toEqual([]);
|
|
134
|
+
expect(report.ok).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
104
137
|
test("baseline marks migrations applied without running SQL", async () => {
|
|
105
138
|
// Tabelle existiert schon (wie eine adoptierte Prod-DB), Migration NICHT applyen.
|
|
106
139
|
await asRawClient(testDb.db).unsafe(`CREATE TABLE "kdrift_widget" ("id" text PRIMARY KEY)`);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Unit-Tests für formatKumikoDriftReport — per-cause Remediation.
|
|
2
|
+
// Regression für review #155 finding 2: vor dem Fix hat der Report immer den
|
|
3
|
+
// "kumiko schema apply"-Hint gezeigt, auch wenn das Problem ein checksum-
|
|
4
|
+
// mismatch war (den apply NICHT löst). Operator folgte der Anweisung und
|
|
5
|
+
// landete in einer Sackgasse.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { formatKumikoDriftReport, type KumikoDriftReport } from "../kumiko-drift";
|
|
9
|
+
|
|
10
|
+
const empty: KumikoDriftReport = {
|
|
11
|
+
ok: true,
|
|
12
|
+
pending: [],
|
|
13
|
+
checksumMismatches: [],
|
|
14
|
+
missingTables: [],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("formatKumikoDriftReport", () => {
|
|
18
|
+
test("ok report → short success line", () => {
|
|
19
|
+
expect(formatKumikoDriftReport(empty)).toBe("Schema is current.");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("pending only → recommends 'schema apply'", () => {
|
|
23
|
+
const report: KumikoDriftReport = {
|
|
24
|
+
...empty,
|
|
25
|
+
ok: false,
|
|
26
|
+
pending: ["0001_init", "0002_add_locale"],
|
|
27
|
+
};
|
|
28
|
+
const out = formatKumikoDriftReport(report);
|
|
29
|
+
expect(out).toContain("2 unapplied migration(s)");
|
|
30
|
+
expect(out).toContain("0001_init");
|
|
31
|
+
expect(out).toContain("Run 'kumiko schema apply'");
|
|
32
|
+
expect(out).not.toContain("checksum");
|
|
33
|
+
expect(out).not.toContain("dropped after apply");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("checksum mismatch only → suggests revert/hand-correct, NOT 'schema apply'", () => {
|
|
37
|
+
const report: KumikoDriftReport = {
|
|
38
|
+
...empty,
|
|
39
|
+
ok: false,
|
|
40
|
+
checksumMismatches: [
|
|
41
|
+
{ id: "0001_init", expected: "abcdef0123456789", actual: "fedcba9876543210" },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
const out = formatKumikoDriftReport(report);
|
|
45
|
+
expect(out).toContain("1 edited-after-apply");
|
|
46
|
+
expect(out).toContain("Revert the edited migration");
|
|
47
|
+
expect(out).toContain("cannot resolve a");
|
|
48
|
+
expect(out).toContain("checksum mismatch");
|
|
49
|
+
expect(out).not.toContain("Run 'kumiko schema apply'");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("missing tables without pending → suggests backup/regen, NOT 'schema apply'", () => {
|
|
53
|
+
const report: KumikoDriftReport = {
|
|
54
|
+
...empty,
|
|
55
|
+
ok: false,
|
|
56
|
+
missingTables: ["widget"],
|
|
57
|
+
};
|
|
58
|
+
const out = formatKumikoDriftReport(report);
|
|
59
|
+
expect(out).toContain("1 missing table(s)");
|
|
60
|
+
expect(out).toContain("dropped after apply");
|
|
61
|
+
expect(out).toContain("Restore from backup");
|
|
62
|
+
expect(out).not.toContain("Run 'kumiko schema apply'");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("missing tables WITH pending → only the 'schema apply' hint (pending covers it)", () => {
|
|
66
|
+
// Wenn pending da ist, applied das die noch fehlenden Tabellen → kein
|
|
67
|
+
// Backup-Restore nötig. Verhindert verwirrenden Doppel-Hint.
|
|
68
|
+
const report: KumikoDriftReport = {
|
|
69
|
+
...empty,
|
|
70
|
+
ok: false,
|
|
71
|
+
pending: ["0002_add_widget"],
|
|
72
|
+
missingTables: ["widget"],
|
|
73
|
+
};
|
|
74
|
+
const out = formatKumikoDriftReport(report);
|
|
75
|
+
expect(out).toContain("Run 'kumiko schema apply'");
|
|
76
|
+
expect(out).not.toContain("Restore from backup");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("pending + mismatch combined → both remediation lines", () => {
|
|
80
|
+
const report: KumikoDriftReport = {
|
|
81
|
+
...empty,
|
|
82
|
+
ok: false,
|
|
83
|
+
pending: ["0002_add_locale"],
|
|
84
|
+
checksumMismatches: [
|
|
85
|
+
{ id: "0001_init", expected: "abcdef0123456789", actual: "fedcba9876543210" },
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
const out = formatKumikoDriftReport(report);
|
|
89
|
+
expect(out).toContain("Run 'kumiko schema apply'");
|
|
90
|
+
expect(out).toContain("Revert the edited migration");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// ALTERs / stale defs) is a documented follow-up; see
|
|
15
15
|
// docs/plans/migration-system-consolidation.md.
|
|
16
16
|
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
17
18
|
import { join } from "node:path";
|
|
18
19
|
import type { DbConnection } from "../db/connection";
|
|
19
20
|
import { loadSnapshotJson } from "../db/migrate-generator";
|
|
@@ -48,6 +49,15 @@ export async function detectKumikoDrift(
|
|
|
48
49
|
db: DbConnection,
|
|
49
50
|
migrationsDir: string,
|
|
50
51
|
): Promise<KumikoDriftReport> {
|
|
52
|
+
// App auf neuer Framework-Version, hat aber `./kumiko/migrations` noch nicht
|
|
53
|
+
// generiert (z.B. erstes Upgrade, custom Schema-Setup ohne kumiko schema
|
|
54
|
+
// generate) → keine local migrations → keine drift. Konsistent zum
|
|
55
|
+
// status-Subcommand, der ebenfalls existsSync-guarded ist. Ohne diesen Guard
|
|
56
|
+
// würde loadMigrationsFromDir → readdirSync synchron ENOENT werfen
|
|
57
|
+
// (plain Error, kein SchemaDriftError) und der Boot crasht roh.
|
|
58
|
+
if (!existsSync(migrationsDir)) {
|
|
59
|
+
return { ok: true, pending: [], checksumMismatches: [], missingTables: [] };
|
|
60
|
+
}
|
|
51
61
|
const local = loadMigrationsFromDir(migrationsDir);
|
|
52
62
|
// Frische DB ohne je gelaufenes `kumiko schema apply` → tracking-table fehlt.
|
|
53
63
|
// Das ist kein Fehler, sondern "nichts applied" → alle local sind pending.
|
|
@@ -106,8 +116,24 @@ export function formatKumikoDriftReport(report: KumikoDriftReport): string {
|
|
|
106
116
|
lines.push(` ${report.missingTables.length} missing table(s):`);
|
|
107
117
|
for (const t of report.missingTables) lines.push(` - ${t}`);
|
|
108
118
|
}
|
|
119
|
+
// Per-Cause Remediation — `kumiko schema apply` löst NUR pending. Checksum-
|
|
120
|
+
// mismatch ist eine Sackgasse für apply (MigrationChecksumMismatchError) und
|
|
121
|
+
// baseline (ON CONFLICT DO NOTHING → landet in alreadyTracked). Missing
|
|
122
|
+
// tables ohne pending = manuell gelöschte Tabelle nach apply → ebenfalls
|
|
123
|
+
// nicht durch apply heilbar.
|
|
109
124
|
lines.push("");
|
|
110
|
-
|
|
125
|
+
if (report.pending.length > 0) {
|
|
126
|
+
lines.push("Run 'kumiko schema apply' to apply the pending migration(s).");
|
|
127
|
+
}
|
|
128
|
+
if (report.checksumMismatches.length > 0) {
|
|
129
|
+
lines.push("Revert the edited migration file(s) to their applied content, or hand-correct");
|
|
130
|
+
lines.push("the checksum in _kumiko_migrations — 'kumiko schema apply' cannot resolve a");
|
|
131
|
+
lines.push("checksum mismatch.");
|
|
132
|
+
}
|
|
133
|
+
if (report.missingTables.length > 0 && report.pending.length === 0) {
|
|
134
|
+
lines.push("Missing table(s) without pending migration(s) — table was dropped after apply.");
|
|
135
|
+
lines.push("Restore from backup, or generate a new migration that re-creates the table.");
|
|
136
|
+
}
|
|
111
137
|
return lines.join("\n");
|
|
112
138
|
}
|
|
113
139
|
|
package/src/schema-cli.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
rebuildTablesFromDiff,
|
|
21
21
|
type renderTablesDdl,
|
|
22
22
|
runMigrationsFromDir,
|
|
23
|
+
tableExists,
|
|
23
24
|
writeRebuildMarker,
|
|
24
25
|
writeSnapshotJson,
|
|
25
26
|
} from "./db";
|
|
@@ -75,7 +76,7 @@ export async function runSchemaCli(
|
|
|
75
76
|
case "generate": {
|
|
76
77
|
const name = argv[1];
|
|
77
78
|
if (!name) {
|
|
78
|
-
out.err(" Usage:
|
|
79
|
+
out.err(" Usage: schema generate <name>");
|
|
79
80
|
return 1;
|
|
80
81
|
}
|
|
81
82
|
if (!existsSync(schemaFile)) {
|
|
@@ -124,7 +125,7 @@ export async function runSchemaCli(
|
|
|
124
125
|
);
|
|
125
126
|
}
|
|
126
127
|
out.log("");
|
|
127
|
-
out.log(" Review + ggf. hand-edit + git add + commit. Apply via:
|
|
128
|
+
out.log(" Review + ggf. hand-edit + git add + commit. Apply via: schema apply");
|
|
128
129
|
out.log("");
|
|
129
130
|
return 0;
|
|
130
131
|
}
|
|
@@ -136,7 +137,7 @@ export async function runSchemaCli(
|
|
|
136
137
|
return 1;
|
|
137
138
|
}
|
|
138
139
|
if (!existsSync(migrationsDir)) {
|
|
139
|
-
out.err(` ${migrationsDir} fehlt — erst
|
|
140
|
+
out.err(` ${migrationsDir} fehlt — erst schema generate <name>.`);
|
|
140
141
|
return 1;
|
|
141
142
|
}
|
|
142
143
|
const { db, close } = createDbConnection(dbUrl);
|
|
@@ -172,7 +173,7 @@ export async function runSchemaCli(
|
|
|
172
173
|
return 1;
|
|
173
174
|
}
|
|
174
175
|
if (!existsSync(migrationsDir)) {
|
|
175
|
-
out.err(` ${migrationsDir} fehlt — erst
|
|
176
|
+
out.err(` ${migrationsDir} fehlt — erst schema generate <name>.`);
|
|
176
177
|
return 1;
|
|
177
178
|
}
|
|
178
179
|
const { db, close } = createDbConnection(dbUrl);
|
|
@@ -207,13 +208,14 @@ export async function runSchemaCli(
|
|
|
207
208
|
const local = loadMigrationsFromDir(migrationsDir);
|
|
208
209
|
const { db, close } = createDbConnection(dbUrl);
|
|
209
210
|
try {
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
211
|
+
// Frische DB ohne je gelaufenes `kumiko schema apply` → tracking-table
|
|
212
|
+
// fehlt = "nichts applied". Connection-/Permission-Fehler dagegen
|
|
213
|
+
// sollen NICHT geschluckt werden (False-pending verschleiert das Problem) —
|
|
214
|
+
// tableExists prüft gezielt nur Existenz, alles andere propagiert.
|
|
215
|
+
const trackingExists = await tableExists(db, "_kumiko_migrations");
|
|
216
|
+
const applied = trackingExists
|
|
217
|
+
? new Set((await fetchAppliedMigrations(db)).map((a) => a.id))
|
|
218
|
+
: new Set<string>();
|
|
217
219
|
out.log("");
|
|
218
220
|
out.log(` ${local.length} migrations in ${migrationsDir}:`);
|
|
219
221
|
for (const m of local) out.log(` ${applied.has(m.id) ? "✓" : " "} ${m.id}`);
|
|
@@ -221,7 +223,7 @@ export async function runSchemaCli(
|
|
|
221
223
|
out.log("");
|
|
222
224
|
out.log(` ${applied.size} applied, ${pending} pending.`);
|
|
223
225
|
out.log("");
|
|
224
|
-
return 0;
|
|
226
|
+
return pending === 0 ? 0 : 1;
|
|
225
227
|
} finally {
|
|
226
228
|
await close();
|
|
227
229
|
}
|
|
@@ -6,7 +6,7 @@ describe("runEventStoreSeed", () => {
|
|
|
6
6
|
let updateCalls = 0;
|
|
7
7
|
let createCalls = 0;
|
|
8
8
|
|
|
9
|
-
const result = await runEventStoreSeed({
|
|
9
|
+
const result = await runEventStoreSeed<string>({
|
|
10
10
|
existing: { id: "agg-1", version: 3 },
|
|
11
11
|
create: async () => {
|
|
12
12
|
createCalls++;
|
|
@@ -26,7 +26,7 @@ describe("runEventStoreSeed", () => {
|
|
|
26
26
|
test('ifExists="update" calls update when row exists', async () => {
|
|
27
27
|
let updateCalls = 0;
|
|
28
28
|
|
|
29
|
-
const result = await runEventStoreSeed({
|
|
29
|
+
const result = await runEventStoreSeed<string>({
|
|
30
30
|
existing: { id: "agg-2", version: 1 },
|
|
31
31
|
ifExists: "update",
|
|
32
32
|
create: async () => ({ id: "new" }),
|
|
@@ -44,7 +44,7 @@ describe("runEventStoreSeed", () => {
|
|
|
44
44
|
test("missing row calls create", async () => {
|
|
45
45
|
let createCalls = 0;
|
|
46
46
|
|
|
47
|
-
const result = await runEventStoreSeed({
|
|
47
|
+
const result = await runEventStoreSeed<string>({
|
|
48
48
|
existing: null,
|
|
49
49
|
create: async () => {
|
|
50
50
|
createCalls++;
|
|
@@ -56,4 +56,15 @@ describe("runEventStoreSeed", () => {
|
|
|
56
56
|
expect(result.id).toBe("created");
|
|
57
57
|
expect(createCalls).toBe(1);
|
|
58
58
|
});
|
|
59
|
+
|
|
60
|
+
test("supports numeric id type via explicit generic", async () => {
|
|
61
|
+
const result = await runEventStoreSeed<number>({
|
|
62
|
+
existing: { id: 42, version: 1 },
|
|
63
|
+
ifExists: "update",
|
|
64
|
+
create: async () => ({ id: 99 }),
|
|
65
|
+
update: async (row) => ({ id: row.id }),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(result.id).toBe(42);
|
|
69
|
+
});
|
|
59
70
|
});
|
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
import { DEFAULT_SEED_IF_EXISTS, type SeedIfExists } from "./types";
|
|
2
2
|
|
|
3
|
-
export type EventStoreSeedExisting = {
|
|
4
|
-
readonly id:
|
|
3
|
+
export type EventStoreSeedExisting<TId extends string | number = string> = {
|
|
4
|
+
readonly id: TId;
|
|
5
5
|
readonly version: number;
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
-
export type RunEventStoreSeedOptions<
|
|
8
|
+
export type RunEventStoreSeedOptions<
|
|
9
|
+
TId extends string | number = string,
|
|
10
|
+
TExisting extends EventStoreSeedExisting<TId> = EventStoreSeedExisting<TId>,
|
|
11
|
+
> = {
|
|
9
12
|
readonly existing: TExisting | null | undefined;
|
|
10
13
|
readonly ifExists?: SeedIfExists;
|
|
11
|
-
readonly create: () => Promise<{ id:
|
|
12
|
-
readonly update: (existing: TExisting) => Promise<{ id:
|
|
14
|
+
readonly create: () => Promise<{ id: TId }>;
|
|
15
|
+
readonly update: (existing: TExisting) => Promise<{ id: TId }>;
|
|
13
16
|
};
|
|
14
17
|
|
|
15
18
|
/** Shared create-or-skip/update path for event-store boot-seed helpers. */
|
|
16
|
-
export async function runEventStoreSeed<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
export async function runEventStoreSeed<
|
|
20
|
+
TId extends string | number = string,
|
|
21
|
+
TExisting extends EventStoreSeedExisting<TId> = EventStoreSeedExisting<TId>,
|
|
22
|
+
>(opts: RunEventStoreSeedOptions<TId, TExisting>): Promise<{ id: TId }> {
|
|
19
23
|
const ifExists = opts.ifExists ?? DEFAULT_SEED_IF_EXISTS;
|
|
20
24
|
if (opts.existing != null) {
|
|
21
25
|
if (ifExists === "skip") {
|