@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.
@@ -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";