@cosmicdrift/kumiko-framework 0.48.1 → 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.
- package/package.json +1 -1
- package/src/api/__tests__/origin-middleware.integration.test.ts +132 -0
- package/src/api/__tests__/origin-middleware.test.ts +191 -0
- package/src/api/anonymous-cookie.ts +1 -0
- package/src/api/api-constants.ts +9 -0
- package/src/api/auth-middleware.ts +2 -0
- package/src/api/auth-routes.ts +29 -4
- package/src/api/csrf-middleware.ts +1 -4
- package/src/api/origin-middleware.ts +98 -0
- package/src/api/server.ts +20 -0
- package/src/bun-db/__tests__/_helpers.ts +1 -0
- package/src/bun-db/query.ts +1 -0
- package/src/db/__tests__/migrate-generator.test.ts +11 -0
- package/src/db/collect-table-metas.ts +2 -1
- package/src/db/dialect.ts +3 -1
- package/src/db/migrate-generator.ts +6 -2
- package/src/db/queries/event-store.ts +1 -0
- package/src/engine/__tests__/boot-validator.test.ts +23 -0
- package/src/engine/__tests__/build-app-schema-settings-hub.test.ts +118 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +174 -0
- package/src/engine/__tests__/config-helpers.test.ts +57 -0
- package/src/engine/boot-validator/config-deps.ts +26 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/build-app-schema.ts +45 -0
- package/src/engine/build-config-feature-schema.ts +228 -0
- package/src/engine/config-helpers.ts +25 -0
- package/src/engine/define-handler.ts +1 -0
- package/src/engine/entity-handlers.ts +7 -1
- package/src/engine/feature-manifest.ts +1 -7
- package/src/engine/index.ts +8 -0
- package/src/engine/pipeline.ts +1 -0
- package/src/engine/qualified-name.ts +1 -0
- package/src/engine/state-machine.ts +1 -0
- package/src/engine/steps/compute.ts +1 -1
- package/src/engine/steps/return.ts +1 -0
- package/src/engine/types/config.ts +35 -0
- package/src/engine/types/index.ts +2 -0
- package/src/engine/types/screen.ts +13 -0
- package/src/env/index.ts +1 -0
- package/src/errors/write-error-info.ts +2 -0
- package/src/es-ops/__tests__/context.integration.test.ts +33 -24
- package/src/es-ops/__tests__/runner.integration.test.ts +130 -86
- package/src/es-ops/context.ts +6 -4
- package/src/event-store/event-store.ts +3 -0
- package/src/files/file-handle.ts +1 -1
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +62 -1
- package/src/migrations/index.ts +1 -0
- package/src/migrations/pending-rebuilds.ts +48 -9
- package/src/pipeline/dispatcher.ts +5 -5
- package/src/pipeline/lifecycle-pipeline.ts +4 -4
- package/src/stack/table-helpers.ts +1 -0
- package/src/time/tz-context.ts +3 -3
- package/src/utils/compare.ts +10 -0
- package/src/utils/ids.ts +1 -0
- 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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
372
|
+
dispatcher,
|
|
364
373
|
dbRunner: testDb.db,
|
|
365
374
|
});
|
|
366
375
|
const rows = (await asRawClient(ctx.db).unsafe(
|
|
@@ -1,30 +1,96 @@
|
|
|
1
|
-
//
|
|
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
|
|
6
|
-
// -
|
|
8
|
+
// - systemWriteAs leitet zum echten Handler im SYSTEM_TENANT-Stream durch
|
|
9
|
+
// - WriteResult{isSuccess:false} bricht den Run ab (kein Marker)
|
|
7
10
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
|
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("
|
|
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
|
-
|
|
163
|
-
|
|
217
|
+
registry,
|
|
218
|
+
createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
|
|
164
219
|
logger: () => {},
|
|
165
220
|
});
|
|
166
221
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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("
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
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
|
|
280
|
+
// Test: dispatcher.write committet 1× erfolgreich, dann throws der Seed.
|
|
233
281
|
// Expectation:
|
|
234
|
-
// -
|
|
235
|
-
// - kein Marker (run
|
|
236
|
-
// - bei
|
|
237
|
-
//
|
|
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("
|
|
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
|
-
|
|
260
|
-
|
|
306
|
+
registry,
|
|
307
|
+
createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
|
|
261
308
|
logger: () => {},
|
|
262
309
|
}),
|
|
263
310
|
).rejects.toThrow(/post-write failure/);
|
|
264
311
|
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
312
|
+
// Event blieb committed — der 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
|
-
//
|
|
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
|
-
|
|
318
|
-
|
|
362
|
+
registry,
|
|
363
|
+
createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
|
|
319
364
|
logger: () => {},
|
|
320
365
|
});
|
|
321
366
|
|
|
322
|
-
expect(
|
|
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
|
-
|
|
353
|
-
|
|
396
|
+
registry,
|
|
397
|
+
createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
|
|
354
398
|
logger: () => {},
|
|
355
399
|
}),
|
|
356
400
|
).rejects.toThrow(/stop here/);
|
package/src/es-ops/context.ts
CHANGED
|
@@ -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
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
}
|
package/src/files/file-handle.ts
CHANGED
|
@@ -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
|
|