@cosmicdrift/kumiko-framework 0.48.0 → 0.50.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.
Files changed (56) hide show
  1. package/package.json +1 -1
  2. package/src/api/__tests__/origin-middleware.integration.test.ts +132 -0
  3. package/src/api/__tests__/origin-middleware.test.ts +191 -0
  4. package/src/api/anonymous-cookie.ts +1 -0
  5. package/src/api/api-constants.ts +9 -0
  6. package/src/api/auth-middleware.ts +2 -0
  7. package/src/api/auth-routes.ts +29 -4
  8. package/src/api/csrf-middleware.ts +1 -4
  9. package/src/api/origin-middleware.ts +98 -0
  10. package/src/api/server.ts +20 -0
  11. package/src/bun-db/__tests__/_helpers.ts +1 -0
  12. package/src/bun-db/query.ts +1 -0
  13. package/src/db/__tests__/migrate-generator.test.ts +11 -0
  14. package/src/db/collect-table-metas.ts +2 -1
  15. package/src/db/dialect.ts +3 -1
  16. package/src/db/migrate-generator.ts +6 -2
  17. package/src/db/queries/event-store.ts +1 -0
  18. package/src/engine/__tests__/boot-validator.test.ts +23 -0
  19. package/src/engine/__tests__/build-app-schema-settings-hub.test.ts +118 -0
  20. package/src/engine/__tests__/build-config-feature-schema.test.ts +174 -0
  21. package/src/engine/__tests__/config-helpers.test.ts +57 -0
  22. package/src/engine/__tests__/feature-manifest.test.ts +45 -0
  23. package/src/engine/boot-validator/config-deps.ts +26 -0
  24. package/src/engine/boot-validator/index.ts +2 -0
  25. package/src/engine/build-app-schema.ts +45 -0
  26. package/src/engine/build-config-feature-schema.ts +228 -0
  27. package/src/engine/config-helpers.ts +25 -0
  28. package/src/engine/define-handler.ts +1 -0
  29. package/src/engine/entity-handlers.ts +7 -1
  30. package/src/engine/feature-manifest.ts +4 -3
  31. package/src/engine/index.ts +8 -0
  32. package/src/engine/pipeline.ts +1 -0
  33. package/src/engine/qualified-name.ts +1 -0
  34. package/src/engine/state-machine.ts +1 -0
  35. package/src/engine/steps/compute.ts +1 -1
  36. package/src/engine/steps/return.ts +1 -0
  37. package/src/engine/types/config.ts +35 -0
  38. package/src/engine/types/index.ts +2 -0
  39. package/src/engine/types/screen.ts +13 -0
  40. package/src/env/index.ts +1 -0
  41. package/src/errors/write-error-info.ts +2 -0
  42. package/src/es-ops/__tests__/context.integration.test.ts +33 -24
  43. package/src/es-ops/__tests__/runner.integration.test.ts +130 -86
  44. package/src/es-ops/context.ts +6 -4
  45. package/src/event-store/event-store.ts +3 -0
  46. package/src/files/file-handle.ts +1 -1
  47. package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +62 -1
  48. package/src/migrations/index.ts +1 -0
  49. package/src/migrations/pending-rebuilds.ts +48 -9
  50. package/src/pipeline/dispatcher.ts +5 -5
  51. package/src/pipeline/lifecycle-pipeline.ts +4 -4
  52. package/src/stack/table-helpers.ts +1 -0
  53. package/src/time/tz-context.ts +3 -3
  54. package/src/utils/compare.ts +10 -0
  55. package/src/utils/ids.ts +1 -0
  56. package/src/utils/index.ts +1 -0
@@ -1,3 +1,7 @@
1
+ // @no-server-stack: testet die SeedMigrationContext-Read-Helper (nur ctx.db);
2
+ // der feature-lose Dispatcher wird nur zum Bauen des Context gebraucht, kein
3
+ // HTTP-Pfad.
4
+ //
1
5
  // Integration-Tests für SeedMigrationContext-Read-Helpers + skippable-
2
6
  // integration. Verifizieren dass:
3
7
  // - findUserByEmail liest read_users korrekt (typed result-cast)
@@ -10,24 +14,41 @@
10
14
  // Feature in den Tests zu schwer wäre — wir testen nur den Read-Helper-
11
15
  // Layer, nicht die volle Event-Store-Pipeline.
12
16
 
13
- import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
17
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
14
18
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
15
19
  import { tmpdir } from "node:os";
16
20
  import { join } from "node:path";
17
21
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
18
22
  import { asRawClient, selectMany } from "../../db/query";
23
+ import { createRegistry } from "../../engine";
24
+ import { createEventsTable } from "../../event-store";
25
+ import { createDispatcher, type Dispatcher } from "../../pipeline";
19
26
  import { ensureTemporalPolyfill } from "../../time/polyfill";
20
27
  import { createSeedMigrationContext } from "../context";
21
28
  import { createEsOperationsTable, esOperationsTable } from "../operations-schema";
22
29
  import { runPendingSeedMigrations } from "../runner";
23
30
 
24
31
  let testDb: BunTestDb;
32
+ // Real (feature-less) dispatcher — these tests exercise the read-helper layer
33
+ // (findUserByEmail/…) which only touches ctx.db; the context builder still
34
+ // requires a dispatcher, so a real one with no handlers is wired here instead
35
+ // of a fabricated stub. systemWriteAs is never called in this suite.
36
+ let dispatcher: Dispatcher;
25
37
 
26
38
  beforeAll(async () => {
27
39
  await ensureTemporalPolyfill();
28
40
  testDb = await createTestDb();
41
+ await createEventsTable(testDb.db);
29
42
  await createEsOperationsTable(testDb.db);
30
43
 
44
+ const registry = createRegistry([]);
45
+ dispatcher = createDispatcher(registry, {
46
+ db: testDb.db,
47
+ redis: undefined as never,
48
+ entityCache: undefined as never,
49
+ registry,
50
+ });
51
+
31
52
  // Minimal-Schema-Stubs für die 3 Read-Tabellen die context.ts liest.
32
53
  // Spalten matchen production (siehe Sysadmin-Stream-Tenant-Bug Memory).
33
54
  await asRawClient(testDb.db).unsafe(`
@@ -91,16 +112,6 @@ async function insertMembershipWithEvent(args: {
91
112
  );
92
113
  }
93
114
 
94
- function makeMockDispatcher() {
95
- return {
96
- write: mock(async () => ({ isSuccess: true as const, data: {} })),
97
- query: mock(),
98
- command: mock(),
99
- batch: mock(),
100
- resolveAuthClaims: mock(),
101
- };
102
- }
103
-
104
115
  function makeTempSeedsDir(files: readonly { name: string; content: string }[]): string {
105
116
  const dir = mkdtempSync(join(tmpdir(), "es-ops-ctx-integ-"));
106
117
  for (const f of files) writeFileSync(join(dir, f.name), f.content);
@@ -122,7 +133,7 @@ describe("SeedMigrationContext.findUserByEmail (integration)", () => {
122
133
  );
123
134
 
124
135
  const ctx = createSeedMigrationContext({
125
- dispatcher: makeMockDispatcher() as never,
136
+ dispatcher,
126
137
  dbRunner: testDb.db,
127
138
  });
128
139
  const found = await ctx.findUserByEmail("admin@example.com");
@@ -131,7 +142,7 @@ describe("SeedMigrationContext.findUserByEmail (integration)", () => {
131
142
 
132
143
  test("liefert null bei unknown email (kein throw)", async () => {
133
144
  const ctx = createSeedMigrationContext({
134
- dispatcher: makeMockDispatcher() as never,
145
+ dispatcher,
135
146
  dbRunner: testDb.db,
136
147
  });
137
148
  const found = await ctx.findUserByEmail("does-not-exist@example.com");
@@ -162,7 +173,7 @@ describe("SeedMigrationContext.findMembershipsOfUser (integration)", () => {
162
173
  });
163
174
 
164
175
  const ctx = createSeedMigrationContext({
165
- dispatcher: makeMockDispatcher() as never,
176
+ dispatcher,
166
177
  dbRunner: testDb.db,
167
178
  });
168
179
  const memberships = await ctx.findMembershipsOfUser(userId);
@@ -193,7 +204,7 @@ describe("SeedMigrationContext.findMembershipsOfUser (integration)", () => {
193
204
  });
194
205
 
195
206
  const ctx = createSeedMigrationContext({
196
- dispatcher: makeMockDispatcher() as never,
207
+ dispatcher,
197
208
  dbRunner: testDb.db,
198
209
  });
199
210
  const [m] = await ctx.findMembershipsOfUser(userId);
@@ -219,7 +230,7 @@ describe("SeedMigrationContext.findMembershipsOfUser (integration)", () => {
219
230
  roles: "not-json",
220
231
  });
221
232
  const ctx = createSeedMigrationContext({
222
- dispatcher: makeMockDispatcher() as never,
233
+ dispatcher,
223
234
  dbRunner: testDb.db,
224
235
  });
225
236
  const memberships = await ctx.findMembershipsOfUser(userId);
@@ -228,7 +239,7 @@ describe("SeedMigrationContext.findMembershipsOfUser (integration)", () => {
228
239
 
229
240
  test("liefert leere Liste bei userId ohne memberships", async () => {
230
241
  const ctx = createSeedMigrationContext({
231
- dispatcher: makeMockDispatcher() as never,
242
+ dispatcher,
232
243
  dbRunner: testDb.db,
233
244
  });
234
245
  const memberships = await ctx.findMembershipsOfUser("01900000-0000-7000-8000-000000000099");
@@ -251,7 +262,7 @@ describe("SeedMigrationContext.findMembershipsOfUser (integration)", () => {
251
262
  [userId],
252
263
  );
253
264
  const ctx = createSeedMigrationContext({
254
- dispatcher: makeMockDispatcher() as never,
265
+ dispatcher,
255
266
  dbRunner: testDb.db,
256
267
  });
257
268
  const memberships = await ctx.findMembershipsOfUser(userId);
@@ -267,7 +278,7 @@ describe("SeedMigrationContext.findTenants (integration)", () => {
267
278
  ('00000000-0000-4000-8000-000000000001'::uuid, 'Alpha', 'alpha', '2026-01-01')
268
279
  `);
269
280
  const ctx = createSeedMigrationContext({
270
- dispatcher: makeMockDispatcher() as never,
281
+ dispatcher,
271
282
  dbRunner: testDb.db,
272
283
  });
273
284
  const tenants = await ctx.findTenants();
@@ -301,8 +312,7 @@ describe("runPendingSeedMigrations: skippable + env-flag (integration)", () => {
301
312
  db: testDb.db,
302
313
  seedsDir: dir,
303
314
  appliedBy: "boot",
304
- createContext: (dbRunner) =>
305
- createSeedMigrationContext({ dispatcher: makeMockDispatcher() as never, dbRunner }),
315
+ createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
306
316
  logger: () => {},
307
317
  });
308
318
  expect(r.appliedIds).toEqual([]);
@@ -336,8 +346,7 @@ describe("runPendingSeedMigrations: skippable + env-flag (integration)", () => {
336
346
  db: testDb.db,
337
347
  seedsDir: dir,
338
348
  appliedBy: "boot",
339
- createContext: (dbRunner) =>
340
- createSeedMigrationContext({ dispatcher: makeMockDispatcher() as never, dbRunner }),
349
+ createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
341
350
  logger: () => {},
342
351
  });
343
352
  expect(r.appliedIds).toEqual(["2026-05-20-skippable-but-no-flag"]);
@@ -360,7 +369,7 @@ describe("SeedMigrationContext.db (escape-hatch, integration)", () => {
360
369
  ('00000000-0000-4000-8000-000000000007'::uuid, 'Lucky', 'lucky')
361
370
  `);
362
371
  const ctx = createSeedMigrationContext({
363
- dispatcher: makeMockDispatcher() as never,
372
+ dispatcher,
364
373
  dbRunner: testDb.db,
365
374
  });
366
375
  const rows = (await asRawClient(ctx.db).unsafe(
@@ -1,30 +1,96 @@
1
- // Integration-Test gegen echtes Postgres. Verifiziert:
1
+ // @no-server-stack: seed-runner ist boot-time-Code — dispatcher.write läuft
2
+ // direkt vor entrypoint.start() (run-prod-app-Pattern), kein HTTP-route nötig.
3
+ //
4
+ // Integration-Test gegen echtes Postgres + echten Dispatcher. Verifiziert:
2
5
  // - Marker landet in kumiko_es_operations nach Erfolg
3
6
  // - Idempotency: zweiter Boot skipped applied seeds
4
7
  // - Tx-Rollback bei Failure (kein Marker geschrieben)
5
- // - systemWriteAs leitet zum Dispatcher durch
6
- // - End-to-End mit echten findUserByEmail / findMembershipsOfUser
8
+ // - systemWriteAs leitet zum echten Handler im SYSTEM_TENANT-Stream durch
9
+ // - WriteResult{isSuccess:false} bricht den Run ab (kein Marker)
7
10
  //
8
- // Heavy lifting (mock-dispatcher, in-memory-applied-set) liegt in
9
- // runner.test.ts. Hier nur DB-Round-Trip-Wahrheit.
11
+ // createDispatcher statt setupTestStack/HTTP: der seed-runner ist boot-time-
12
+ // Code, das dispatcher.write direkt vor entrypoint.start() ruft (siehe
13
+ // run-prod-app.ts) — kein HTTP-route. Ein echtes esopstest-Feature liefert die
14
+ // Handler, die die Seeds adressieren; nichts wird gemockt.
10
15
 
11
- import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
16
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
12
17
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
13
18
  import { tmpdir } from "node:os";
14
19
  import { join } from "node:path";
15
- import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
20
+ import { z } from "zod";
21
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
16
22
  import { asRawClient, insertOne, selectMany } from "../../db/query";
23
+ import { buildEntityTable } from "../../db/table-builder";
24
+ import {
25
+ createEntity,
26
+ createRegistry,
27
+ createTextField,
28
+ defineFeature,
29
+ SYSTEM_TENANT_ID,
30
+ } from "../../engine";
31
+ import { VersionConflictError } from "../../errors";
32
+ import { createEventsTable } from "../../event-store";
33
+ import { createDispatcher, type Dispatcher } from "../../pipeline";
34
+ import { createTestDb, type TestDb, unsafeCreateEntityTable } from "../../stack";
17
35
  import { ensureTemporalPolyfill } from "../../time/polyfill";
18
36
  import { createSeedMigrationContext } from "../context";
19
37
  import { createEsOperationsTable, esOperationsTable } from "../operations-schema";
20
38
  import { runPendingSeedMigrations } from "../runner";
21
39
 
22
- let testDb: BunTestDb;
40
+ // Minimal real feature whose handlers the seeds target. probe:create emits a
41
+ // real event through the real dispatcher; probe:fail returns a failed
42
+ // WriteResult (via a thrown VersionConflictError) so the isSuccess:false path
43
+ // is exercised against the production write-pipeline, not a stubbed return.
44
+ const probeEntity = createEntity({
45
+ table: "read_esops_probes",
46
+ fields: { label: createTextField({ required: true }) },
47
+ });
48
+ const probeTable = buildEntityTable("esops-probe", probeEntity);
49
+ const probeExecutor = createEventStoreExecutor(probeTable, probeEntity, {
50
+ entityName: "esops-probe",
51
+ });
52
+
53
+ const seedTestFeature = defineFeature("esopstest", (r) => {
54
+ r.entity("esops-probe", probeEntity);
55
+ // openToAll: reachable by the seed's system user (roles ["system"]) —
56
+ // hasAccess has no system-bypass, so a role-gated handler would be denied.
57
+ r.writeHandler(
58
+ "probe:create",
59
+ z.object({ label: z.string().min(1) }),
60
+ async (event, ctx) => probeExecutor.create(event.payload, event.user, ctx.db),
61
+ { access: { openToAll: true } },
62
+ );
63
+ r.writeHandler(
64
+ "probe:fail",
65
+ z.object({ label: z.string() }),
66
+ async () => {
67
+ throw new VersionConflictError({
68
+ entityId: "esops-probe",
69
+ expectedVersion: 1,
70
+ currentVersion: 2,
71
+ });
72
+ },
73
+ { access: { openToAll: true } },
74
+ );
75
+ });
76
+
77
+ let testDb: TestDb;
78
+ let dispatcher: Dispatcher;
79
+ let registry: ReturnType<typeof createRegistry>;
23
80
 
24
81
  beforeAll(async () => {
25
82
  await ensureTemporalPolyfill();
26
83
  testDb = await createTestDb();
84
+ await createEventsTable(testDb.db);
27
85
  await createEsOperationsTable(testDb.db);
86
+ await unsafeCreateEntityTable(testDb.db, probeEntity, "esops-probe");
87
+ registry = createRegistry([seedTestFeature]);
88
+ dispatcher = createDispatcher(registry, {
89
+ db: testDb.db,
90
+ redis: undefined as never,
91
+ entityCache: undefined as never,
92
+ registry,
93
+ });
28
94
  });
29
95
 
30
96
  afterAll(async () => {
@@ -32,7 +98,9 @@ afterAll(async () => {
32
98
  });
33
99
 
34
100
  beforeEach(async () => {
35
- await asRawClient(testDb.db).unsafe(`TRUNCATE kumiko_es_operations RESTART IDENTITY`);
101
+ await asRawClient(testDb.db).unsafe(
102
+ `TRUNCATE kumiko_es_operations, kumiko_events, read_esops_probes RESTART IDENTITY`,
103
+ );
36
104
  });
37
105
 
38
106
  function makeTempSeedsDir(files: readonly { name: string; content: string }[]): string {
@@ -41,19 +109,10 @@ function makeTempSeedsDir(files: readonly { name: string; content: string }[]):
41
109
  return dir;
42
110
  }
43
111
 
44
- function makeMockDispatcher() {
45
- const calls: Array<{ qn: string; payload: unknown }> = [];
46
- return {
47
- write: mock(async (qn: string, payload: unknown) => {
48
- calls.push({ qn, payload });
49
- return { isSuccess: true as const, data: {} };
50
- }),
51
- query: mock(),
52
- command: mock(),
53
- batch: mock(),
54
- resolveAuthClaims: mock(),
55
- calls,
56
- };
112
+ async function selectEvents(): Promise<readonly { type: string; tenant_id: string }[]> {
113
+ return (await asRawClient(testDb.db).unsafe(
114
+ `SELECT type, tenant_id::text AS tenant_id FROM kumiko_events ORDER BY id`,
115
+ )) as unknown as readonly { type: string; tenant_id: string }[];
57
116
  }
58
117
 
59
118
  describe("runPendingSeedMigrations (integration)", () => {
@@ -70,15 +129,13 @@ describe("runPendingSeedMigrations (integration)", () => {
70
129
  },
71
130
  ]);
72
131
  try {
73
- const dispatcher = makeMockDispatcher();
74
-
75
132
  // First run: pending → applied
76
133
  const r1 = await runPendingSeedMigrations({
77
134
  db: testDb.db,
78
135
  seedsDir: dir,
79
136
  appliedBy: "boot",
80
- createContext: (dbRunner) =>
81
- createSeedMigrationContext({ dispatcher: dispatcher as never, dbRunner }),
137
+ registry,
138
+ createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
82
139
  logger: () => {},
83
140
  });
84
141
  expect(r1.appliedIds).toEqual(["2026-05-20-noop"]);
@@ -95,8 +152,8 @@ describe("runPendingSeedMigrations (integration)", () => {
95
152
  db: testDb.db,
96
153
  seedsDir: dir,
97
154
  appliedBy: "boot",
98
- createContext: (dbRunner) =>
99
- createSeedMigrationContext({ dispatcher: dispatcher as never, dbRunner }),
155
+ registry,
156
+ createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
100
157
  logger: () => {},
101
158
  });
102
159
  expect(r2.appliedIds).toEqual([]);
@@ -120,14 +177,13 @@ describe("runPendingSeedMigrations (integration)", () => {
120
177
  },
121
178
  ]);
122
179
  try {
123
- const dispatcher = makeMockDispatcher();
124
180
  await expect(
125
181
  runPendingSeedMigrations({
126
182
  db: testDb.db,
127
183
  seedsDir: dir,
128
184
  appliedBy: "boot",
129
- createContext: (dbRunner) =>
130
- createSeedMigrationContext({ dispatcher: dispatcher as never, dbRunner }),
185
+ registry,
186
+ createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
131
187
  logger: () => {},
132
188
  }),
133
189
  ).rejects.toThrow(/boom/);
@@ -139,7 +195,7 @@ describe("runPendingSeedMigrations (integration)", () => {
139
195
  }
140
196
  });
141
197
 
142
- test("systemWriteAs leitet zum Dispatcher mit SYSTEM_TENANT-User durch", async () => {
198
+ test("systemWriteAs leitet zum echten Handler im SYSTEM_TENANT-Stream durch", async () => {
143
199
  const dir = makeTempSeedsDir([
144
200
  {
145
201
  name: "2026-05-20-uses-dispatcher.ts",
@@ -147,29 +203,29 @@ describe("runPendingSeedMigrations (integration)", () => {
147
203
  export default {
148
204
  description: "calls a write-handler",
149
205
  run: async (ctx) => {
150
- await ctx.systemWriteAs("some:write:handler", { foo: "bar" });
206
+ await ctx.systemWriteAs("esopstest:write:probe:create", { label: "bar" });
151
207
  },
152
208
  };
153
209
  `,
154
210
  },
155
211
  ]);
156
212
  try {
157
- const dispatcher = makeMockDispatcher();
158
213
  await runPendingSeedMigrations({
159
214
  db: testDb.db,
160
215
  seedsDir: dir,
161
216
  appliedBy: "boot",
162
- createContext: (dbRunner) =>
163
- createSeedMigrationContext({ dispatcher: dispatcher as never, dbRunner }),
217
+ registry,
218
+ createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
164
219
  logger: () => {},
165
220
  });
166
221
 
167
- expect(dispatcher.write).toHaveBeenCalledTimes(1);
168
- expect(dispatcher.write).toHaveBeenCalledWith(
169
- "some:write:handler",
170
- { foo: "bar" },
171
- expect.objectContaining({ tenantId: expect.any(String) }),
172
- );
222
+ // Real event landed in the SYSTEM_TENANT stream → proves the write
223
+ // routed through the dispatcher AND ran as the system user (no
224
+ // tenantIdOverride → createSystemUser(SYSTEM_TENANT_ID)).
225
+ const events = await selectEvents();
226
+ expect(events).toHaveLength(1);
227
+ expect(events[0]?.type).toBe("esops-probe.created");
228
+ expect(events[0]?.tenant_id).toBe(SYSTEM_TENANT_ID);
173
229
  } finally {
174
230
  rmSync(dir, { recursive: true, force: true });
175
231
  }
@@ -186,55 +242,47 @@ describe("runPendingSeedMigrations (integration)", () => {
186
242
  export default {
187
243
  description: "tries a handler that returns isSuccess:false",
188
244
  run: async (ctx) => {
189
- await ctx.systemWriteAs("some:write:handler", { foo: "bar" });
245
+ await ctx.systemWriteAs("esopstest:write:probe:fail", { label: "bar" });
190
246
  },
191
247
  };
192
248
  `,
193
249
  },
194
250
  ]);
195
251
  try {
196
- const dispatcher = {
197
- write: mock(async () => ({
198
- isSuccess: false as const,
199
- error: { code: "version_conflict", message: "stream changed" },
200
- })),
201
- query: mock(),
202
- command: mock(),
203
- batch: mock(),
204
- resolveAuthClaims: mock(),
205
- };
206
-
207
252
  await expect(
208
253
  runPendingSeedMigrations({
209
254
  db: testDb.db,
210
255
  seedsDir: dir,
211
256
  appliedBy: "boot",
212
- createContext: (dbRunner) =>
213
- createSeedMigrationContext({ dispatcher: dispatcher as never, dbRunner }),
257
+ registry,
258
+ createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
214
259
  logger: () => {},
215
260
  }),
216
261
  ).rejects.toThrow(/version_conflict/);
217
262
 
218
- // Kein Marker — bei nächstem Boot würde der Seed retried
263
+ // Kein Marker — bei nächstem Boot würde der Seed retried. Und der
264
+ // fehlgeschlagene Write hat kein Event hinterlassen.
219
265
  const markers = await selectMany(testDb.db, esOperationsTable);
220
266
  expect(markers).toHaveLength(0);
267
+ expect(await selectEvents()).toHaveLength(0);
221
268
  } finally {
222
269
  rmSync(dir, { recursive: true, force: true });
223
270
  }
224
271
  });
225
272
 
226
273
  test("documented limitation: dispatcher-writes vor throw bleiben committed (idempotency-Pflicht für seeds)", async () => {
227
- // Documents NICHT-Garantie aus dem README: systemWriteAs läuft durch
228
- // den App-Dispatcher mit eigener tx-Verwaltung die Runner-Tx
229
- // schützt NUR den Marker-Insert + direct dbRunner-reads, NICHT die
230
- // dispatcher-Writes. Daher müssen Seeds idempotent sein.
274
+ // Documents NICHT-Garantie aus dem README: systemWriteAs läuft durch den
275
+ // App-Dispatcher mit eigener tx-Verwaltung (runBatch öffnet eine eigene
276
+ // Transaktion auf context.db, der Runner-outer-tx wird NICHT durchgereicht)
277
+ // — die Runner-Tx schützt NUR den Marker-Insert + direct dbRunner-reads,
278
+ // NICHT die dispatcher-Writes. Daher müssen Seeds idempotent sein.
231
279
  //
232
- // Test: dispatcher.write wird 1× erfolgreich aufgerufen, dann throws.
280
+ // Test: dispatcher.write committet 1× erfolgreich, dann throws der Seed.
233
281
  // Expectation:
234
- // - dispatcher.write was called 1x (confirms write went through)
235
- // - kein Marker (run was rolled back)
236
- // - bei "echtem" Setup wäre die Event-Row schon committed retry
237
- // müsste idempotent sein, sonst Duplikat.
282
+ // - das Event ist committed (überlebt den Runner-Rollback)
283
+ // - kein Marker (run wurde zurückgerollt)
284
+ // - bei retry beim nächsten Boot muss der Write idempotent sein, sonst
285
+ // Duplikat genau die dokumentierte Pflicht.
238
286
  const dir = makeTempSeedsDir([
239
287
  {
240
288
  name: "2026-05-20-write-then-throw.ts",
@@ -242,7 +290,7 @@ describe("runPendingSeedMigrations (integration)", () => {
242
290
  export default {
243
291
  description: "writes successfully then throws (idempotency test)",
244
292
  run: async (ctx) => {
245
- await ctx.systemWriteAs("some:write:handler", { step: 1 });
293
+ await ctx.systemWriteAs("esopstest:write:probe:create", { label: "step1" });
246
294
  throw new Error("post-write failure");
247
295
  },
248
296
  };
@@ -250,22 +298,23 @@ describe("runPendingSeedMigrations (integration)", () => {
250
298
  },
251
299
  ]);
252
300
  try {
253
- const dispatcher = makeMockDispatcher();
254
301
  await expect(
255
302
  runPendingSeedMigrations({
256
303
  db: testDb.db,
257
304
  seedsDir: dir,
258
305
  appliedBy: "boot",
259
- createContext: (dbRunner) =>
260
- createSeedMigrationContext({ dispatcher: dispatcher as never, dbRunner }),
306
+ registry,
307
+ createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
261
308
  logger: () => {},
262
309
  }),
263
310
  ).rejects.toThrow(/post-write failure/);
264
311
 
265
- // Write-handler WURDE aufgerufen — dispatcher-tx isoliert vom runner-tx
266
- expect(dispatcher.write).toHaveBeenCalledTimes(1);
267
- // Marker NICHT gesetzt — retry beim nächsten Boot wird die Migration
268
- // nochmal ausführen. Wenn der Write nicht idempotent ist → Duplikat.
312
+ // Event blieb committedder dispatcher-tx ist vom runner-tx isoliert.
313
+ const events = await selectEvents();
314
+ expect(events).toHaveLength(1);
315
+ expect(events[0]?.type).toBe("esops-probe.created");
316
+ // Marker NICHT gesetzt — retry beim nächsten Boot führt die Migration
317
+ // nochmal aus. Wenn der Write nicht idempotent ist → Duplikat.
269
318
  const markers = await selectMany(testDb.db, esOperationsTable);
270
319
  expect(markers).toHaveLength(0);
271
320
  } finally {
@@ -303,23 +352,19 @@ describe("runPendingSeedMigrations (integration)", () => {
303
352
  notes: "applied by simulated parallel-pod",
304
353
  });
305
354
 
306
- const dispatcher = makeMockDispatcher();
307
355
  // Würde normalerweise als pending klassifiziert (loadAppliedIds liest
308
- // BEFORE the tx) — der re-check inside tx muss das catchen.
309
- // Achtung: das obere applied-set-load sieht den Marker auch schon —
310
- // dieses Test ist daher eher eine Wahrscheinlichkeits-Aussage über
311
- // den Race-Pfad, nicht ein deterministischer Race-Repro. Aber:
312
- // wenn der re-check funktioniert, läuft `run()` nicht.
356
+ // BEFORE the tx) — der re-check inside tx muss das catchen. Wenn der
357
+ // re-check funktioniert, läuft `run()` nicht (kein Event, kein Throw).
313
358
  await runPendingSeedMigrations({
314
359
  db: testDb.db,
315
360
  seedsDir: dir,
316
361
  appliedBy: "boot",
317
- createContext: (dbRunner) =>
318
- createSeedMigrationContext({ dispatcher: dispatcher as never, dbRunner }),
362
+ registry,
363
+ createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
319
364
  logger: () => {},
320
365
  });
321
366
 
322
- expect(dispatcher.write).not.toHaveBeenCalled();
367
+ expect(await selectEvents()).toHaveLength(0);
323
368
  const markers = await selectMany(testDb.db, esOperationsTable);
324
369
  expect(markers).toHaveLength(1); // nur der pre-seeded
325
370
  } finally {
@@ -343,14 +388,13 @@ describe("runPendingSeedMigrations (integration)", () => {
343
388
  },
344
389
  ]);
345
390
  try {
346
- const dispatcher = makeMockDispatcher();
347
391
  await expect(
348
392
  runPendingSeedMigrations({
349
393
  db: testDb.db,
350
394
  seedsDir: dir,
351
395
  appliedBy: "boot",
352
- createContext: (dbRunner) =>
353
- createSeedMigrationContext({ dispatcher: dispatcher as never, dbRunner }),
396
+ registry,
397
+ createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
354
398
  logger: () => {},
355
399
  }),
356
400
  ).rejects.toThrow(/stop here/);
@@ -27,10 +27,12 @@ export type CreateSeedMigrationContextArgs = {
27
27
  * aufruft. Der dbRunner kann eine Top-Connection oder eine Tx sein —
28
28
  * Read-Helpers nutzen ihn direkt, systemWriteAs delegiert an dispatcher.
29
29
  *
30
- * Hinweis: dispatcher.write hat eigene tx-Logik. Wenn der Runner um die
31
- * Migration eine outer-tx legt, läuft dispatcher.write als nested via
32
- * postgres-savepoint. Beim Failure rollt der outer-tx auch das
33
- * dispatcher-write zurück kein partial-Apply möglich. */
30
+ * Hinweis: dispatcher.write bekommt den Runner-tx NICHT durchgereicht
31
+ * runBatch öffnet eine eigene Transaktion auf context.db (eigene Pool-
32
+ * Connection). Die Writes committen daher unabhängig: ein Runner-Failure
33
+ * rollt NUR den Marker-Insert + dbRunner-reads zurück, die dispatcher-Writes
34
+ * bleiben committed. Seeds MÜSSEN deshalb idempotent sein (verifiziert in
35
+ * runner.integration.test.ts „dispatcher-writes vor throw bleiben committed"). */
34
36
  export function createSeedMigrationContext(
35
37
  args: CreateSeedMigrationContextArgs,
36
38
  ): SeedMigrationContext {
@@ -267,6 +267,7 @@ export async function loadAggregateAsOf(
267
267
  // the next CRUD write to fail with version_conflict. Indexed lookup on the
268
268
  // existing (aggregate_id, version) unique index. Returns 0 for empty/unknown
269
269
  // streams (matches create()'s expectedVersion=0 convention).
270
+ // @wrapper-known semantic-alias
270
271
  export async function getStreamVersion(
271
272
  db: DbRunner,
272
273
  aggregateId: string,
@@ -276,6 +277,7 @@ export async function getStreamVersion(
276
277
  }
277
278
 
278
279
  /** MAX(version) for one aggregate — no tenant filter. Used by seed idempotency. */
280
+ // @wrapper-known semantic-alias
279
281
  export async function getAggregateStreamMaxVersion(
280
282
  db: DbRunner,
281
283
  aggregateId: string,
@@ -301,6 +303,7 @@ export async function getAggregateStreamTenant(
301
303
  // projection/consumer lag math: lag = HWM - cursor. Single-row aggregate over
302
304
  // the bigserial PK index — sub-millisecond cost. Returns 0n on an empty log
303
305
  // (boot, fresh tenant, post-archive).
306
+ // @wrapper-known semantic-alias
304
307
  export async function getEventsHighWaterMark(db: DbRunner): Promise<bigint> {
305
308
  return selectEventsHighWaterMark(db);
306
309
  }
@@ -44,7 +44,7 @@ export function createFileHandle(key: string, provider: FileStorageProvider): Fi
44
44
 
45
45
  export function createFileContext(provider: FileStorageProvider): FileContext {
46
46
  return {
47
- ref: (key) => createFileHandle(key, provider),
47
+ ref: (key) => createFileHandle(key, provider), // @wrapper-known semantic-alias
48
48
  };
49
49
  }
50
50