@cosmicdrift/kumiko-framework 0.21.1 → 0.23.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/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/engine/boot-validator/screens-nav.ts +12 -1
- package/src/engine/index.ts +3 -1
- package/src/engine/types/index.ts +3 -1
- package/src/engine/types/screen.ts +20 -1
- package/src/files/__tests__/file-field-pipeline.integration.test.ts +2 -1
- package/src/files/__tests__/files.integration.test.ts +10 -9
- package/src/files/__tests__/storage-tracking.integration.test.ts +2 -1
- package/src/files/feature.ts +21 -0
- package/src/files/file-ref-entity.ts +30 -0
- package/src/files/file-ref-table.ts +12 -21
- package/src/files/file-routes.ts +47 -81
- package/src/files/index.ts +3 -7
- package/src/files/storage-tracking.ts +36 -6
- 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/src/testing/e2e-generator.ts +4 -0
- package/src/ui-types/index.ts +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.1",
|
|
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
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { qualifyEntityName } from "../qualified-name";
|
|
2
2
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
3
3
|
import type { FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
|
|
4
|
-
import { normalizeEditField, normalizeListColumn } from "../types/screen";
|
|
4
|
+
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
|
|
5
5
|
|
|
6
6
|
// --- Screen validation ---
|
|
7
7
|
//
|
|
@@ -62,6 +62,7 @@ export function validateScreens(
|
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
for (const section of screen.layout.sections) {
|
|
65
|
+
if (isExtensionEditSection(section)) continue;
|
|
65
66
|
if (section.fields.length === 0) {
|
|
66
67
|
throw new Error(
|
|
67
68
|
`[Feature ${feature.name}] Screen "${screenId}" (configEdit) has a section "${section.title}" ` +
|
|
@@ -160,6 +161,7 @@ export function validateScreens(
|
|
|
160
161
|
);
|
|
161
162
|
}
|
|
162
163
|
for (const section of screen.layout.sections) {
|
|
164
|
+
if (isExtensionEditSection(section)) continue;
|
|
163
165
|
if (section.fields.length === 0) {
|
|
164
166
|
throw new Error(
|
|
165
167
|
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) has a section "${section.title}" ` +
|
|
@@ -341,6 +343,15 @@ export function validateScreens(
|
|
|
341
343
|
);
|
|
342
344
|
}
|
|
343
345
|
for (const section of screen.layout.sections) {
|
|
346
|
+
if (isExtensionEditSection(section)) {
|
|
347
|
+
if (section.component.react === undefined && section.component.native === undefined) {
|
|
348
|
+
throw new Error(
|
|
349
|
+
`[Feature ${feature.name}] Screen "${screenId}" (entityEdit) extension section ` +
|
|
350
|
+
`"${section.title}" has no component — declare a react/native component marker.`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
344
355
|
if (section.fields.length === 0) {
|
|
345
356
|
throw new Error(
|
|
346
357
|
`[Feature ${feature.name}] Screen "${screenId}" (entityEdit) has a section "${section.title}" ` +
|
package/src/engine/index.ts
CHANGED
|
@@ -226,7 +226,9 @@ export type {
|
|
|
226
226
|
CustomScreenRoute,
|
|
227
227
|
DateFieldDef,
|
|
228
228
|
DeleteContext,
|
|
229
|
+
EditExtensionSection,
|
|
229
230
|
EditFieldSpec,
|
|
231
|
+
EditFieldsSection,
|
|
230
232
|
EditLayout,
|
|
231
233
|
EditSectionSpec,
|
|
232
234
|
EntityDefinition,
|
|
@@ -325,7 +327,7 @@ export type {
|
|
|
325
327
|
export { DEFAULT_CURRENCIES, HookPhases } from "./types";
|
|
326
328
|
export { resolveName, withResponseData } from "./types/handlers";
|
|
327
329
|
export { isSystemTenant, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers";
|
|
328
|
-
export { normalizeEditField, normalizeListColumn } from "./types/screen";
|
|
330
|
+
export { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "./types/screen";
|
|
329
331
|
export type {
|
|
330
332
|
PipelineBuildCtx,
|
|
331
333
|
PipelineCtx,
|
|
@@ -204,7 +204,9 @@ export type {
|
|
|
204
204
|
ConfigEditScreenDefinition,
|
|
205
205
|
CustomScreenDefinition,
|
|
206
206
|
CustomScreenRoute,
|
|
207
|
+
EditExtensionSection,
|
|
207
208
|
EditFieldSpec,
|
|
209
|
+
EditFieldsSection,
|
|
208
210
|
EditLayout,
|
|
209
211
|
EditSectionSpec,
|
|
210
212
|
EntityEditScreenDefinition,
|
|
@@ -222,7 +224,7 @@ export type {
|
|
|
222
224
|
ScreenSlots,
|
|
223
225
|
ToolbarAction,
|
|
224
226
|
} from "./screen";
|
|
225
|
-
export { normalizeEditField, normalizeListColumn } from "./screen";
|
|
227
|
+
export { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "./screen";
|
|
226
228
|
export type { TargetRef } from "./target-ref";
|
|
227
229
|
export type {
|
|
228
230
|
Subscribe,
|
|
@@ -280,12 +280,31 @@ export type EditFieldSpec =
|
|
|
280
280
|
readonly renderer?: FieldRenderer;
|
|
281
281
|
};
|
|
282
282
|
|
|
283
|
-
|
|
283
|
+
// A section is either a normal field-grid (default — `kind` omitted keeps
|
|
284
|
+
// every existing screen-def working) or an extension slot that mounts a
|
|
285
|
+
// feature-provided component. The extension component is resolved client-
|
|
286
|
+
// side by name (same `__component` marker as custom screens / column
|
|
287
|
+
// renderers) and receives the host entity name + id, so a bundled feature
|
|
288
|
+
// (e.g. custom-fields) can load and persist its own data inside the form.
|
|
289
|
+
export type EditSectionSpec = EditFieldsSection | EditExtensionSection;
|
|
290
|
+
|
|
291
|
+
export type EditFieldsSection = {
|
|
292
|
+
readonly kind?: "fields";
|
|
284
293
|
readonly title: string;
|
|
285
294
|
readonly columns?: number;
|
|
286
295
|
readonly fields: readonly EditFieldSpec[];
|
|
287
296
|
};
|
|
288
297
|
|
|
298
|
+
export type EditExtensionSection = {
|
|
299
|
+
readonly kind: "extension";
|
|
300
|
+
readonly title: string;
|
|
301
|
+
readonly component: PlatformComponent;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
export function isExtensionEditSection(section: EditSectionSpec): section is EditExtensionSection {
|
|
305
|
+
return section.kind === "extension";
|
|
306
|
+
}
|
|
307
|
+
|
|
289
308
|
export type EditLayout = {
|
|
290
309
|
readonly sections: readonly EditSectionSpec[];
|
|
291
310
|
};
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
unsafeCreateEntityTable,
|
|
38
38
|
} from "../../stack";
|
|
39
39
|
import { buildMultipartBody, patchFileInstanceofForBunTest } from "../../testing";
|
|
40
|
+
import { createFilesFeature } from "../feature";
|
|
40
41
|
import { createLocalProvider } from "../local-provider";
|
|
41
42
|
|
|
42
43
|
// Covers ALL four file-field variants: singular (file/image) stores a UUID in
|
|
@@ -73,7 +74,7 @@ beforeAll(async () => {
|
|
|
73
74
|
patchFileInstanceofForBunTest();
|
|
74
75
|
storagePath = await mkdtemp(join(tmpdir(), "kumiko-file-field-pipeline-"));
|
|
75
76
|
stack = await setupTestStack({
|
|
76
|
-
features: [documentFeature],
|
|
77
|
+
features: [createFilesFeature(), documentFeature],
|
|
77
78
|
files: { storageProvider: createLocalProvider(storagePath) },
|
|
78
79
|
});
|
|
79
80
|
await unsafeCreateEntityTable(stack.db, documentEntity);
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
patchFileInstanceofForBunTest,
|
|
29
29
|
} from "../../testing";
|
|
30
30
|
import { fileRefsTable } from "../file-ref-table";
|
|
31
|
-
import {
|
|
31
|
+
import type { FileRoutesOptions } from "../file-routes";
|
|
32
32
|
import { createInMemoryFileProvider } from "../in-memory-provider";
|
|
33
33
|
import { createLocalProvider } from "../local-provider";
|
|
34
34
|
import type { FileStorageProvider } from "../types";
|
|
@@ -248,18 +248,17 @@ describe("file upload flow via API", () => {
|
|
|
248
248
|
expect(downloaded[0]).toBe(0x89); // PNG magic byte
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
-
test("upload
|
|
252
|
-
//
|
|
253
|
-
//
|
|
251
|
+
test("upload emits fileRef.created to the entity stream", async () => {
|
|
252
|
+
// fileRef is a standard ES entity — the upload goes through the entity
|
|
253
|
+
// executor, so the stream carries exactly one `fileRef.created` at v1.
|
|
254
254
|
const events = await loadAggregate(testDb.db, uploadedFileId, adminUser.tenantId);
|
|
255
255
|
|
|
256
256
|
expect(events).toHaveLength(1);
|
|
257
257
|
const event = events[0];
|
|
258
|
-
expect(event?.type).toBe(
|
|
258
|
+
expect(event?.type).toBe("fileRef.created");
|
|
259
259
|
expect(event?.version).toBe(1);
|
|
260
260
|
|
|
261
|
-
type
|
|
262
|
-
fileRefId: string;
|
|
261
|
+
type CreatedPayload = {
|
|
263
262
|
fileName: string;
|
|
264
263
|
mimeType: string;
|
|
265
264
|
size: number;
|
|
@@ -269,8 +268,10 @@ describe("file upload flow via API", () => {
|
|
|
269
268
|
data?: unknown;
|
|
270
269
|
binary?: unknown;
|
|
271
270
|
};
|
|
272
|
-
|
|
273
|
-
|
|
271
|
+
// The entity executor strips `id` from the event payload (it's the
|
|
272
|
+
// aggregateId / stream id, which we loaded by above) and threads
|
|
273
|
+
// insertedById through metadata — neither appears in payload.
|
|
274
|
+
const payload = event!.payload as CreatedPayload;
|
|
274
275
|
expect(payload.fileName).toBe("logo.png");
|
|
275
276
|
expect(payload.mimeType).toBe("image/png");
|
|
276
277
|
expect(payload.size).toBe(testPngContent.length);
|