@cosmicdrift/kumiko-framework 0.4.1 → 0.5.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/CHANGELOG.md +54 -0
- package/package.json +6 -2
- package/src/es-ops/README.md +119 -0
- package/src/es-ops/__tests__/context.integration.ts +267 -0
- package/src/es-ops/__tests__/runner.integration.ts +363 -0
- package/src/es-ops/__tests__/runner.test.ts +192 -0
- package/src/es-ops/context.ts +113 -0
- package/src/es-ops/index.ts +34 -0
- package/src/es-ops/operations-schema.ts +57 -0
- package/src/es-ops/runner.ts +213 -0
- package/src/es-ops/types.ts +85 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// Integration-Test gegen echtes Postgres. Verifiziert:
|
|
2
|
+
// - Marker landet in kumiko_es_operations nach Erfolg
|
|
3
|
+
// - Idempotency: zweiter Boot skipped applied seeds
|
|
4
|
+
// - Tx-Rollback bei Failure (kein Marker geschrieben)
|
|
5
|
+
// - systemWriteAs leitet zum Dispatcher durch
|
|
6
|
+
// - End-to-End mit echten findUserByEmail / findMembershipsOfUser
|
|
7
|
+
//
|
|
8
|
+
// Heavy lifting (mock-dispatcher, in-memory-applied-set) liegt in
|
|
9
|
+
// runner.test.ts. Hier nur DB-Round-Trip-Wahrheit.
|
|
10
|
+
|
|
11
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { sql } from "drizzle-orm";
|
|
15
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
|
16
|
+
import { createTestDb, type TestDb } from "../../stack";
|
|
17
|
+
import { createSeedMigrationContext } from "../context";
|
|
18
|
+
import { createEsOperationsTable, esOperationsTable } from "../operations-schema";
|
|
19
|
+
import { runPendingSeedMigrations } from "../runner";
|
|
20
|
+
|
|
21
|
+
let testDb: TestDb;
|
|
22
|
+
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
testDb = await createTestDb();
|
|
25
|
+
await createEsOperationsTable(testDb.db);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterAll(async () => {
|
|
29
|
+
await testDb.cleanup();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
await testDb.db.execute(sql`TRUNCATE kumiko_es_operations RESTART IDENTITY`);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function makeTempSeedsDir(files: readonly { name: string; content: string }[]): string {
|
|
37
|
+
const dir = mkdtempSync(join(tmpdir(), "es-ops-integration-"));
|
|
38
|
+
for (const f of files) writeFileSync(join(dir, f.name), f.content);
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeMockDispatcher() {
|
|
43
|
+
const calls: Array<{ qn: string; payload: unknown }> = [];
|
|
44
|
+
return {
|
|
45
|
+
write: vi.fn(async (qn: string, payload: unknown) => {
|
|
46
|
+
calls.push({ qn, payload });
|
|
47
|
+
return { isSuccess: true as const, data: {} };
|
|
48
|
+
}),
|
|
49
|
+
query: vi.fn(),
|
|
50
|
+
command: vi.fn(),
|
|
51
|
+
batch: vi.fn(),
|
|
52
|
+
resolveAuthClaims: vi.fn(),
|
|
53
|
+
calls,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("runPendingSeedMigrations (integration)", () => {
|
|
58
|
+
test("first run: applies pending + writes marker, second run: skips applied", async () => {
|
|
59
|
+
const dir = makeTempSeedsDir([
|
|
60
|
+
{
|
|
61
|
+
name: "2026-05-20-noop.ts",
|
|
62
|
+
content: `
|
|
63
|
+
export default {
|
|
64
|
+
description: "no-op seed for integration test",
|
|
65
|
+
run: async () => {},
|
|
66
|
+
};
|
|
67
|
+
`,
|
|
68
|
+
},
|
|
69
|
+
]);
|
|
70
|
+
try {
|
|
71
|
+
const dispatcher = makeMockDispatcher();
|
|
72
|
+
|
|
73
|
+
// First run: pending → applied
|
|
74
|
+
const r1 = await runPendingSeedMigrations({
|
|
75
|
+
db: testDb.db,
|
|
76
|
+
seedsDir: dir,
|
|
77
|
+
appliedBy: "boot",
|
|
78
|
+
createContext: (dbRunner) =>
|
|
79
|
+
createSeedMigrationContext({ dispatcher: dispatcher as never, dbRunner }),
|
|
80
|
+
logger: () => {},
|
|
81
|
+
});
|
|
82
|
+
expect(r1.appliedIds).toEqual(["2026-05-20-noop"]);
|
|
83
|
+
|
|
84
|
+
// Marker landed
|
|
85
|
+
const markers1 = await testDb.db.select().from(esOperationsTable);
|
|
86
|
+
expect(markers1).toHaveLength(1);
|
|
87
|
+
expect(markers1[0]?.id).toBe("2026-05-20-noop");
|
|
88
|
+
expect(markers1[0]?.operationType).toBe("seed-migration");
|
|
89
|
+
expect(markers1[0]?.appliedBy).toBe("boot");
|
|
90
|
+
|
|
91
|
+
// Second run: already applied → skipped, no new markers
|
|
92
|
+
const r2 = await runPendingSeedMigrations({
|
|
93
|
+
db: testDb.db,
|
|
94
|
+
seedsDir: dir,
|
|
95
|
+
appliedBy: "boot",
|
|
96
|
+
createContext: (dbRunner) =>
|
|
97
|
+
createSeedMigrationContext({ dispatcher: dispatcher as never, dbRunner }),
|
|
98
|
+
logger: () => {},
|
|
99
|
+
});
|
|
100
|
+
expect(r2.appliedIds).toEqual([]);
|
|
101
|
+
const markers2 = await testDb.db.select().from(esOperationsTable);
|
|
102
|
+
expect(markers2).toHaveLength(1);
|
|
103
|
+
} finally {
|
|
104
|
+
rmSync(dir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("seed throws → Tx rollback, kein Marker geschrieben", async () => {
|
|
109
|
+
const dir = makeTempSeedsDir([
|
|
110
|
+
{
|
|
111
|
+
name: "2026-05-20-fails.ts",
|
|
112
|
+
content: `
|
|
113
|
+
export default {
|
|
114
|
+
description: "intentional fail",
|
|
115
|
+
run: async () => { throw new Error("boom"); },
|
|
116
|
+
};
|
|
117
|
+
`,
|
|
118
|
+
},
|
|
119
|
+
]);
|
|
120
|
+
try {
|
|
121
|
+
const dispatcher = makeMockDispatcher();
|
|
122
|
+
await expect(
|
|
123
|
+
runPendingSeedMigrations({
|
|
124
|
+
db: testDb.db,
|
|
125
|
+
seedsDir: dir,
|
|
126
|
+
appliedBy: "boot",
|
|
127
|
+
createContext: (dbRunner) =>
|
|
128
|
+
createSeedMigrationContext({ dispatcher: dispatcher as never, dbRunner }),
|
|
129
|
+
logger: () => {},
|
|
130
|
+
}),
|
|
131
|
+
).rejects.toThrow(/boom/);
|
|
132
|
+
|
|
133
|
+
const markers = await testDb.db.select().from(esOperationsTable);
|
|
134
|
+
expect(markers).toHaveLength(0);
|
|
135
|
+
} finally {
|
|
136
|
+
rmSync(dir, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("systemWriteAs leitet zum Dispatcher mit SYSTEM_TENANT-User durch", async () => {
|
|
141
|
+
const dir = makeTempSeedsDir([
|
|
142
|
+
{
|
|
143
|
+
name: "2026-05-20-uses-dispatcher.ts",
|
|
144
|
+
content: `
|
|
145
|
+
export default {
|
|
146
|
+
description: "calls a write-handler",
|
|
147
|
+
run: async (ctx) => {
|
|
148
|
+
await ctx.systemWriteAs("some:write:handler", { foo: "bar" });
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
`,
|
|
152
|
+
},
|
|
153
|
+
]);
|
|
154
|
+
try {
|
|
155
|
+
const dispatcher = makeMockDispatcher();
|
|
156
|
+
await runPendingSeedMigrations({
|
|
157
|
+
db: testDb.db,
|
|
158
|
+
seedsDir: dir,
|
|
159
|
+
appliedBy: "boot",
|
|
160
|
+
createContext: (dbRunner) =>
|
|
161
|
+
createSeedMigrationContext({ dispatcher: dispatcher as never, dbRunner }),
|
|
162
|
+
logger: () => {},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(dispatcher.write).toHaveBeenCalledTimes(1);
|
|
166
|
+
expect(dispatcher.write).toHaveBeenCalledWith(
|
|
167
|
+
"some:write:handler",
|
|
168
|
+
{ foo: "bar" },
|
|
169
|
+
expect.objectContaining({ tenantId: expect.any(String) }),
|
|
170
|
+
);
|
|
171
|
+
} finally {
|
|
172
|
+
rmSync(dir, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("WriteResult{isSuccess:false} → throw + Marker NICHT geschrieben", async () => {
|
|
177
|
+
// Critical: ohne diese Garantie würde ein silent-failed Write den Seed
|
|
178
|
+
// als "applied" markieren → beim nächsten Boot kein retry → DB-Drift
|
|
179
|
+
// bleibt unbemerkt.
|
|
180
|
+
const dir = makeTempSeedsDir([
|
|
181
|
+
{
|
|
182
|
+
name: "2026-05-20-write-fails.ts",
|
|
183
|
+
content: `
|
|
184
|
+
export default {
|
|
185
|
+
description: "tries a handler that returns isSuccess:false",
|
|
186
|
+
run: async (ctx) => {
|
|
187
|
+
await ctx.systemWriteAs("some:write:handler", { foo: "bar" });
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
`,
|
|
191
|
+
},
|
|
192
|
+
]);
|
|
193
|
+
try {
|
|
194
|
+
const dispatcher = {
|
|
195
|
+
write: vi.fn(async () => ({
|
|
196
|
+
isSuccess: false as const,
|
|
197
|
+
error: { code: "version_conflict", message: "stream changed" },
|
|
198
|
+
})),
|
|
199
|
+
query: vi.fn(),
|
|
200
|
+
command: vi.fn(),
|
|
201
|
+
batch: vi.fn(),
|
|
202
|
+
resolveAuthClaims: vi.fn(),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
await expect(
|
|
206
|
+
runPendingSeedMigrations({
|
|
207
|
+
db: testDb.db,
|
|
208
|
+
seedsDir: dir,
|
|
209
|
+
appliedBy: "boot",
|
|
210
|
+
createContext: (dbRunner) =>
|
|
211
|
+
createSeedMigrationContext({ dispatcher: dispatcher as never, dbRunner }),
|
|
212
|
+
logger: () => {},
|
|
213
|
+
}),
|
|
214
|
+
).rejects.toThrow(/version_conflict/);
|
|
215
|
+
|
|
216
|
+
// Kein Marker — bei nächstem Boot würde der Seed retried
|
|
217
|
+
const markers = await testDb.db.select().from(esOperationsTable);
|
|
218
|
+
expect(markers).toHaveLength(0);
|
|
219
|
+
} finally {
|
|
220
|
+
rmSync(dir, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("documented limitation: dispatcher-writes vor throw bleiben committed (idempotency-Pflicht für seeds)", async () => {
|
|
225
|
+
// Documents NICHT-Garantie aus dem README: systemWriteAs läuft durch
|
|
226
|
+
// den App-Dispatcher mit eigener tx-Verwaltung — die Runner-Tx
|
|
227
|
+
// schützt NUR den Marker-Insert + direct dbRunner-reads, NICHT die
|
|
228
|
+
// dispatcher-Writes. Daher müssen Seeds idempotent sein.
|
|
229
|
+
//
|
|
230
|
+
// Test: dispatcher.write wird 1× erfolgreich aufgerufen, dann throws.
|
|
231
|
+
// Expectation:
|
|
232
|
+
// - dispatcher.write was called 1x (confirms write went through)
|
|
233
|
+
// - kein Marker (run was rolled back)
|
|
234
|
+
// - bei "echtem" Setup wäre die Event-Row schon committed → retry
|
|
235
|
+
// müsste idempotent sein, sonst Duplikat.
|
|
236
|
+
const dir = makeTempSeedsDir([
|
|
237
|
+
{
|
|
238
|
+
name: "2026-05-20-write-then-throw.ts",
|
|
239
|
+
content: `
|
|
240
|
+
export default {
|
|
241
|
+
description: "writes successfully then throws (idempotency test)",
|
|
242
|
+
run: async (ctx) => {
|
|
243
|
+
await ctx.systemWriteAs("some:write:handler", { step: 1 });
|
|
244
|
+
throw new Error("post-write failure");
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
`,
|
|
248
|
+
},
|
|
249
|
+
]);
|
|
250
|
+
try {
|
|
251
|
+
const dispatcher = makeMockDispatcher();
|
|
252
|
+
await expect(
|
|
253
|
+
runPendingSeedMigrations({
|
|
254
|
+
db: testDb.db,
|
|
255
|
+
seedsDir: dir,
|
|
256
|
+
appliedBy: "boot",
|
|
257
|
+
createContext: (dbRunner) =>
|
|
258
|
+
createSeedMigrationContext({ dispatcher: dispatcher as never, dbRunner }),
|
|
259
|
+
logger: () => {},
|
|
260
|
+
}),
|
|
261
|
+
).rejects.toThrow(/post-write failure/);
|
|
262
|
+
|
|
263
|
+
// Write-handler WURDE aufgerufen — dispatcher-tx isoliert vom runner-tx
|
|
264
|
+
expect(dispatcher.write).toHaveBeenCalledTimes(1);
|
|
265
|
+
// Marker NICHT gesetzt — retry beim nächsten Boot wird die Migration
|
|
266
|
+
// nochmal ausführen. Wenn der Write nicht idempotent ist → Duplikat.
|
|
267
|
+
const markers = await testDb.db.select().from(esOperationsTable);
|
|
268
|
+
expect(markers).toHaveLength(0);
|
|
269
|
+
} finally {
|
|
270
|
+
rmSync(dir, { recursive: true, force: true });
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("applied-set filter: entries already in kumiko_es_operations werden geskipped", async () => {
|
|
275
|
+
// Test deckt den loadAppliedIds-Filter ab (pending = files \ applied).
|
|
276
|
+
// Der pg_advisory_xact_lock + inner-tx re-check ist eine zweite Defense-
|
|
277
|
+
// Linie für echte parallel-Pod-Races zwischen loadAppliedIds und der
|
|
278
|
+
// pro-Migration Tx — diese Race ist empirisch nicht reproduzierbar in
|
|
279
|
+
// einem Single-Process-Test ohne extra Lock-Coordination. Wir verifizieren
|
|
280
|
+
// hier nur die obere Filter-Schicht (häufigster Pfad).
|
|
281
|
+
const dir = makeTempSeedsDir([
|
|
282
|
+
{
|
|
283
|
+
name: "2026-05-20-race.ts",
|
|
284
|
+
content: `
|
|
285
|
+
export default {
|
|
286
|
+
description: "race-test",
|
|
287
|
+
run: async () => {
|
|
288
|
+
throw new Error("MUST NOT BE CALLED — re-check should skip");
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
`,
|
|
292
|
+
},
|
|
293
|
+
]);
|
|
294
|
+
try {
|
|
295
|
+
// Pre-seed marker als wäre ein parallel-Pod schon durch
|
|
296
|
+
await testDb.db.insert(esOperationsTable).values({
|
|
297
|
+
id: "2026-05-20-race",
|
|
298
|
+
operationType: "seed-migration",
|
|
299
|
+
durationMs: 42,
|
|
300
|
+
appliedBy: "boot",
|
|
301
|
+
notes: "applied by simulated parallel-pod",
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const dispatcher = makeMockDispatcher();
|
|
305
|
+
// Würde normalerweise als pending klassifiziert (loadAppliedIds liest
|
|
306
|
+
// BEFORE the tx) — der re-check inside tx muss das catchen.
|
|
307
|
+
// Achtung: das obere applied-set-load sieht den Marker auch schon —
|
|
308
|
+
// dieses Test ist daher eher eine Wahrscheinlichkeits-Aussage über
|
|
309
|
+
// den Race-Pfad, nicht ein deterministischer Race-Repro. Aber:
|
|
310
|
+
// wenn der re-check funktioniert, läuft `run()` nicht.
|
|
311
|
+
await runPendingSeedMigrations({
|
|
312
|
+
db: testDb.db,
|
|
313
|
+
seedsDir: dir,
|
|
314
|
+
appliedBy: "boot",
|
|
315
|
+
createContext: (dbRunner) =>
|
|
316
|
+
createSeedMigrationContext({ dispatcher: dispatcher as never, dbRunner }),
|
|
317
|
+
logger: () => {},
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
expect(dispatcher.write).not.toHaveBeenCalled();
|
|
321
|
+
const markers = await testDb.db.select().from(esOperationsTable);
|
|
322
|
+
expect(markers).toHaveLength(1); // nur der pre-seeded
|
|
323
|
+
} finally {
|
|
324
|
+
rmSync(dir, { recursive: true, force: true });
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("multiple seeds: apply in chronological order, halt on first failure", async () => {
|
|
329
|
+
const dir = makeTempSeedsDir([
|
|
330
|
+
{
|
|
331
|
+
name: "2026-05-19-first.ts",
|
|
332
|
+
content: `export default { description: "first", run: async () => {} };`,
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
name: "2026-05-20-fails.ts",
|
|
336
|
+
content: `export default { description: "fails", run: async () => { throw new Error("stop here"); } };`,
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
name: "2026-05-21-never.ts",
|
|
340
|
+
content: `export default { description: "never reached", run: async () => {} };`,
|
|
341
|
+
},
|
|
342
|
+
]);
|
|
343
|
+
try {
|
|
344
|
+
const dispatcher = makeMockDispatcher();
|
|
345
|
+
await expect(
|
|
346
|
+
runPendingSeedMigrations({
|
|
347
|
+
db: testDb.db,
|
|
348
|
+
seedsDir: dir,
|
|
349
|
+
appliedBy: "boot",
|
|
350
|
+
createContext: (dbRunner) =>
|
|
351
|
+
createSeedMigrationContext({ dispatcher: dispatcher as never, dbRunner }),
|
|
352
|
+
logger: () => {},
|
|
353
|
+
}),
|
|
354
|
+
).rejects.toThrow(/stop here/);
|
|
355
|
+
|
|
356
|
+
// Nur first hat marker — fails warf, never wurde nie attempted
|
|
357
|
+
const markers = await testDb.db.select().from(esOperationsTable);
|
|
358
|
+
expect(markers.map((m) => m.id)).toEqual(["2026-05-19-first"]);
|
|
359
|
+
} finally {
|
|
360
|
+
rmSync(dir, { recursive: true, force: true });
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Unit-Tests für den runner. Heavy lifting (Tx, applied-set-diff,
|
|
2
|
+
// dispatcher-call) testen wir gegen Postgres in der integration-test.
|
|
3
|
+
// Hier nur die pure-logic-Pfade die kein echtes DB brauchen.
|
|
4
|
+
|
|
5
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
import { runPendingSeedMigrations } from "../runner";
|
|
10
|
+
|
|
11
|
+
function makeTempSeedsDir(files: readonly { name: string; content: string }[]): string {
|
|
12
|
+
const dir = mkdtempSync(join(tmpdir(), "es-ops-test-"));
|
|
13
|
+
for (const f of files) writeFileSync(join(dir, f.name), f.content);
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Minimal DB-Stub — Runner ruft transaction() + select() + insert() +
|
|
18
|
+
// execute() auf. execute() liefert ein leeres array für den
|
|
19
|
+
// re-check-inside-lock (= "nicht applied, weiter mit Run").
|
|
20
|
+
function makeStubDb(initialApplied: readonly string[] = []) {
|
|
21
|
+
const inserts: Array<Record<string, unknown>> = [];
|
|
22
|
+
const applied = new Set(initialApplied);
|
|
23
|
+
const db = {
|
|
24
|
+
transaction: async (cb: (tx: unknown) => Promise<void>) => {
|
|
25
|
+
await cb(db);
|
|
26
|
+
},
|
|
27
|
+
select: () => ({
|
|
28
|
+
from: () => ({
|
|
29
|
+
where: async () => Array.from(applied).map((id) => ({ id })),
|
|
30
|
+
}),
|
|
31
|
+
}),
|
|
32
|
+
insert: () => ({
|
|
33
|
+
values: async (row: Record<string, unknown>) => {
|
|
34
|
+
inserts.push(row);
|
|
35
|
+
if (typeof row["id"] === "string") applied.add(row["id"]);
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
// execute: für pg_advisory_xact_lock + re-check. Leere Liste = "nicht
|
|
39
|
+
// applied im Inner-Lock-Scope, weiter mit Run". applied-set check via
|
|
40
|
+
// select() oben wird sowieso schon angewendet.
|
|
41
|
+
execute: async (_q: unknown) => [],
|
|
42
|
+
};
|
|
43
|
+
return { db, inserts, applied };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("runPendingSeedMigrations", () => {
|
|
47
|
+
test("no seeds dir → no-op", async () => {
|
|
48
|
+
const { db } = makeStubDb();
|
|
49
|
+
const result = await runPendingSeedMigrations({
|
|
50
|
+
db: db as never,
|
|
51
|
+
seedsDir: "/path/does/not/exist",
|
|
52
|
+
appliedBy: "boot",
|
|
53
|
+
createContext: () => ({}) as never,
|
|
54
|
+
logger: () => {},
|
|
55
|
+
});
|
|
56
|
+
expect(result.appliedIds).toEqual([]);
|
|
57
|
+
expect(result.skippedIds).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("listSeedFiles: filtert non-seed Files raus, sortiert chronologisch", async () => {
|
|
61
|
+
const dir = makeTempSeedsDir([
|
|
62
|
+
{ name: "2026-05-20-fix-roles.ts", content: makeSeedFile("fix-roles") },
|
|
63
|
+
{ name: "2026-05-19-init.ts", content: makeSeedFile("init") },
|
|
64
|
+
{ name: "_helper.ts", content: makeSeedFile("helper") }, // _-prefix → skip
|
|
65
|
+
{ name: "README.md", content: "" }, // .md → skip
|
|
66
|
+
{ name: ".hidden.ts", content: "" }, // dot-prefix → skip
|
|
67
|
+
]);
|
|
68
|
+
try {
|
|
69
|
+
const { db } = makeStubDb();
|
|
70
|
+
const result = await runPendingSeedMigrations({
|
|
71
|
+
db: db as never,
|
|
72
|
+
seedsDir: dir,
|
|
73
|
+
appliedBy: "boot",
|
|
74
|
+
createContext: () => ({}) as never,
|
|
75
|
+
logger: () => {},
|
|
76
|
+
});
|
|
77
|
+
// Beide gültige seeds laufen, in chronologischer Reihenfolge
|
|
78
|
+
expect(result.appliedIds).toEqual(["2026-05-19-init", "2026-05-20-fix-roles"]);
|
|
79
|
+
} finally {
|
|
80
|
+
rmSync(dir, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("skippable + env-flag → skip ohne run", async () => {
|
|
85
|
+
const dir = makeTempSeedsDir([
|
|
86
|
+
{ name: "2026-05-20-skip-me.ts", content: makeSeedFile("skip-me", { skippable: true }) },
|
|
87
|
+
]);
|
|
88
|
+
const envKey = "KUMIKO_SKIP_ES_OPS_2026_05_20_SKIP_ME";
|
|
89
|
+
process.env[envKey] = "1";
|
|
90
|
+
try {
|
|
91
|
+
const { db, inserts } = makeStubDb();
|
|
92
|
+
const result = await runPendingSeedMigrations({
|
|
93
|
+
db: db as never,
|
|
94
|
+
seedsDir: dir,
|
|
95
|
+
appliedBy: "boot",
|
|
96
|
+
createContext: () => ({}) as never,
|
|
97
|
+
logger: () => {},
|
|
98
|
+
});
|
|
99
|
+
expect(result.appliedIds).toEqual([]);
|
|
100
|
+
expect(result.skippedIds).toEqual(["2026-05-20-skip-me"]);
|
|
101
|
+
// Kein marker geschrieben — beim nächsten Boot ohne Flag würde es laufen
|
|
102
|
+
expect(inserts).toHaveLength(0);
|
|
103
|
+
} finally {
|
|
104
|
+
delete process.env[envKey];
|
|
105
|
+
rmSync(dir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("already-applied seed wird übersprungen", async () => {
|
|
110
|
+
const dir = makeTempSeedsDir([
|
|
111
|
+
{ name: "2026-05-19-init.ts", content: makeSeedFile("init") },
|
|
112
|
+
{ name: "2026-05-20-new.ts", content: makeSeedFile("new") },
|
|
113
|
+
]);
|
|
114
|
+
try {
|
|
115
|
+
const { db, inserts } = makeStubDb(["2026-05-19-init"]);
|
|
116
|
+
const result = await runPendingSeedMigrations({
|
|
117
|
+
db: db as never,
|
|
118
|
+
seedsDir: dir,
|
|
119
|
+
appliedBy: "boot",
|
|
120
|
+
createContext: () => ({}) as never,
|
|
121
|
+
logger: () => {},
|
|
122
|
+
});
|
|
123
|
+
// Nur new lief
|
|
124
|
+
expect(result.appliedIds).toEqual(["2026-05-20-new"]);
|
|
125
|
+
expect(inserts).toHaveLength(1);
|
|
126
|
+
expect(inserts[0]?.["id"]).toBe("2026-05-20-new");
|
|
127
|
+
} finally {
|
|
128
|
+
rmSync(dir, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("seed-file ohne default-export → klarer Error", async () => {
|
|
133
|
+
const dir = makeTempSeedsDir([
|
|
134
|
+
{ name: "2026-05-20-broken.ts", content: "export const notDefault = {};" },
|
|
135
|
+
]);
|
|
136
|
+
try {
|
|
137
|
+
const { db } = makeStubDb();
|
|
138
|
+
await expect(
|
|
139
|
+
runPendingSeedMigrations({
|
|
140
|
+
db: db as never,
|
|
141
|
+
seedsDir: dir,
|
|
142
|
+
appliedBy: "boot",
|
|
143
|
+
createContext: () => ({}) as never,
|
|
144
|
+
logger: () => {},
|
|
145
|
+
}),
|
|
146
|
+
).rejects.toThrow(/must export a SeedMigration as default/);
|
|
147
|
+
} finally {
|
|
148
|
+
rmSync(dir, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("seed.run throws → abort + no marker", async () => {
|
|
153
|
+
const dir = makeTempSeedsDir([
|
|
154
|
+
{ name: "2026-05-20-good.ts", content: makeSeedFile("good") },
|
|
155
|
+
{ name: "2026-05-21-fails.ts", content: makeSeedFile("fails", { fail: true }) },
|
|
156
|
+
{ name: "2026-05-22-never.ts", content: makeSeedFile("never") },
|
|
157
|
+
]);
|
|
158
|
+
try {
|
|
159
|
+
const { db, inserts } = makeStubDb();
|
|
160
|
+
await expect(
|
|
161
|
+
runPendingSeedMigrations({
|
|
162
|
+
db: db as never,
|
|
163
|
+
seedsDir: dir,
|
|
164
|
+
appliedBy: "boot",
|
|
165
|
+
createContext: () => ({}) as never,
|
|
166
|
+
logger: () => {},
|
|
167
|
+
}),
|
|
168
|
+
).rejects.toThrow();
|
|
169
|
+
// good lief, fails warf, never wurde NIE attempted
|
|
170
|
+
expect(inserts.map((r) => r["id"])).toEqual(["2026-05-20-good"]);
|
|
171
|
+
} finally {
|
|
172
|
+
rmSync(dir, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// --- Test-Helpers -----------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
function makeSeedFile(
|
|
180
|
+
description: string,
|
|
181
|
+
options: { skippable?: boolean; fail?: boolean } = {},
|
|
182
|
+
): string {
|
|
183
|
+
return `
|
|
184
|
+
export default {
|
|
185
|
+
description: ${JSON.stringify(description)},
|
|
186
|
+
${options.skippable ? "skippable: true," : ""}
|
|
187
|
+
run: async () => {
|
|
188
|
+
${options.fail ? "throw new Error('intentional fail');" : ""}
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
`;
|
|
192
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// SeedMigrationContext-Builder. Caller (runProdApp/CLI) übergibt einen
|
|
2
|
+
// schon-konfigurierten Dispatcher; der Builder produziert pro-Migration
|
|
3
|
+
// einen tx-scoped Context der via dispatcher.write den existierenden
|
|
4
|
+
// Handler-Pfad nutzt — gleiches Pattern wie ein User-UI-Click.
|
|
5
|
+
//
|
|
6
|
+
// SystemUser bypassed Access-Checks (Standard-Seed-Pattern, siehe
|
|
7
|
+
// config-seed.ts:40). Events haben createdBy = SYSTEM_TENANT_ID-User
|
|
8
|
+
// → audit-fähig.
|
|
9
|
+
|
|
10
|
+
import { sql } from "drizzle-orm";
|
|
11
|
+
import type { DbRunner } from "../db";
|
|
12
|
+
import { createSystemUser, SYSTEM_TENANT_ID } from "../engine";
|
|
13
|
+
import type { Dispatcher } from "../pipeline/dispatcher";
|
|
14
|
+
import type { SeedMembershipRow, SeedMigrationContext, SeedTenantRow } from "./types";
|
|
15
|
+
|
|
16
|
+
export type CreateSeedMigrationContextArgs = {
|
|
17
|
+
readonly dispatcher: Dispatcher;
|
|
18
|
+
readonly dbRunner: DbRunner;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Builder: gibt eine factory-function zurück die der Runner pro-Migration
|
|
22
|
+
* aufruft. Der dbRunner kann eine Top-Connection oder eine Tx sein —
|
|
23
|
+
* Read-Helpers nutzen ihn direkt, systemWriteAs delegiert an dispatcher.
|
|
24
|
+
*
|
|
25
|
+
* Hinweis: dispatcher.write hat eigene tx-Logik. Wenn der Runner um die
|
|
26
|
+
* Migration eine outer-tx legt, läuft dispatcher.write als nested via
|
|
27
|
+
* postgres-savepoint. Beim Failure rollt der outer-tx auch das
|
|
28
|
+
* dispatcher-write zurück → kein partial-Apply möglich. */
|
|
29
|
+
export function createSeedMigrationContext(
|
|
30
|
+
args: CreateSeedMigrationContextArgs,
|
|
31
|
+
): SeedMigrationContext {
|
|
32
|
+
const systemUser = createSystemUser(SYSTEM_TENANT_ID);
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
systemWriteAs: async (handlerQualifiedName, payload) => {
|
|
36
|
+
const result = await args.dispatcher.write(handlerQualifiedName, payload, systemUser);
|
|
37
|
+
// Critical: WriteResult{isSuccess: false} würde sonst silent durchlaufen
|
|
38
|
+
// → Marker landet trotz failed-Write → Migration falsch als "applied"
|
|
39
|
+
// markiert. Hier throw damit der Runner's outer-tx rollback macht und
|
|
40
|
+
// Marker NICHT geschrieben wird. Seed-Author kann via try/catch eigene
|
|
41
|
+
// Fehler-Behandlung machen wenn ein soft-failure erwartet ist.
|
|
42
|
+
if (!result.isSuccess) {
|
|
43
|
+
const code = result.error?.code ?? "unknown";
|
|
44
|
+
const message = result.error?.message ?? "(no message)";
|
|
45
|
+
throw new Error(
|
|
46
|
+
`[es-ops/seed-migration] systemWriteAs("${handlerQualifiedName}") failed: ${code} — ${message}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
findUserByEmail: async (email) => {
|
|
53
|
+
// Direct DB-Read via read_users-Projection (gleicher Pfad wie
|
|
54
|
+
// UserQueries.findForAuth aber ohne Dispatcher-Roundtrip; Seeds
|
|
55
|
+
// greifen oft 1-N Lookups → direkt schneller).
|
|
56
|
+
// @cast-boundary db-row — drizzle execute(sql) returns row-array
|
|
57
|
+
// direkt (kein { rows }-Wrapper); column-types vom SQL-Cast oben
|
|
58
|
+
const rows = (await args.dbRunner.execute(
|
|
59
|
+
sql`SELECT id::text AS id, email, tenant_id::text AS tenant_id
|
|
60
|
+
FROM read_users
|
|
61
|
+
WHERE email = ${email}
|
|
62
|
+
LIMIT 1`,
|
|
63
|
+
)) as unknown as readonly { id: string; email: string; tenant_id: string }[];
|
|
64
|
+
const row = rows[0];
|
|
65
|
+
if (!row) return null;
|
|
66
|
+
return { id: row.id, email: row.email, tenantId: row.tenant_id };
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
findMembershipsOfUser: async (userId) => {
|
|
70
|
+
// @cast-boundary db-row — roles ist JSON-string in der text-Spalte
|
|
71
|
+
// (Memory: tenant-membership.created payload "[\"User\"]"), wird unten geparst
|
|
72
|
+
const rows = (await args.dbRunner.execute(
|
|
73
|
+
sql`SELECT user_id::text AS user_id, tenant_id::text AS tenant_id, roles
|
|
74
|
+
FROM read_tenant_memberships
|
|
75
|
+
WHERE user_id = ${userId}`,
|
|
76
|
+
)) as unknown as readonly { user_id: string; tenant_id: string; roles: string }[];
|
|
77
|
+
return rows.map(
|
|
78
|
+
(r): SeedMembershipRow => ({
|
|
79
|
+
userId: r.user_id,
|
|
80
|
+
tenantId: r.tenant_id,
|
|
81
|
+
roles: safeParseRolesJson(r.roles),
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
findTenants: async () => {
|
|
87
|
+
// @cast-boundary db-row
|
|
88
|
+
const rows = (await args.dbRunner.execute(
|
|
89
|
+
sql`SELECT id::text AS id, name, tenant_key
|
|
90
|
+
FROM read_tenants
|
|
91
|
+
ORDER BY inserted_at`,
|
|
92
|
+
)) as unknown as readonly { id: string; name: string; tenant_key: string }[];
|
|
93
|
+
return rows.map((r): SeedTenantRow => ({ id: r.id, name: r.name, tenantKey: r.tenant_key }));
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
db: args.dbRunner,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function safeParseRolesJson(raw: string): readonly string[] {
|
|
101
|
+
try {
|
|
102
|
+
const parsed: unknown = JSON.parse(raw);
|
|
103
|
+
if (Array.isArray(parsed) && parsed.every((x) => typeof x === "string")) {
|
|
104
|
+
return parsed;
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// Fallthrough — return empty rather than throwing in a seed context.
|
|
108
|
+
}
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Re-export für Caller-Convenience.
|
|
113
|
+
export type { SeedMigrationContext } from "./types";
|