@cosmicdrift/kumiko-framework 0.5.2 → 0.7.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/CHANGELOG.md +67 -0
- package/package.json +2 -2
- package/src/es-ops/README.md +66 -6
- package/src/es-ops/__tests__/context.integration.ts +100 -10
- package/src/es-ops/context.ts +33 -7
- package/src/es-ops/runner.ts +55 -1
- package/src/es-ops/types.ts +36 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# @cosmicdrift/kumiko-framework
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- bcf43b6: es-ops: `SeedMembershipRow` exposes `streamTenantId` (stream-tenant aus `kumiko_events.v1`) neben dem payload-`tenantId`. Seed-Authors müssen den `kumiko_events`-JOIN nicht mehr selbst bauen — `m.streamTenantId` ist der korrekte Wert für `systemWriteAs`'s `tenantIdOverride` wenn das Aggregate von einem fremden Executor angelegt wurde (typisches `seedTenantMembership(by=systemAdmin)`-Pattern).
|
|
8
|
+
|
|
9
|
+
## 0.6.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 8489d18: feat(es-ops): Phase 1.5 — tenantIdOverride + dry-run-validator + E2E-Test + Doku
|
|
14
|
+
|
|
15
|
+
Phase 1.5 schließt die Lücken aus Phase 1 die den ersten Driver-Use-Case
|
|
16
|
+
(publicstatus admin-roles) blockten. Siehe Retro:
|
|
17
|
+
`kumiko-platform/docs/plans/features/es-ops-phase1-retro.md` (PR #9).
|
|
18
|
+
|
|
19
|
+
**A1 — tenantIdOverride:**
|
|
20
|
+
`SeedMigrationContext.systemWriteAs(qn, payload, tenantIdOverride?)`.
|
|
21
|
+
Default SYSTEM_TENANT_ID (unverändert für System-scope-Aggregates wie
|
|
22
|
+
config-values). Mit override: `createSystemUser(tenantIdOverride)` als
|
|
23
|
+
Executor, damit der Event-Store-Executor den Aggregate-Stream im
|
|
24
|
+
richtigen Tenant findet. Fix für die `version_conflict`-Klasse-Bug
|
|
25
|
+
(Memory `feedback_event_store_tenant_consistency.md`).
|
|
26
|
+
|
|
27
|
+
**A2 — dry-run-validator:**
|
|
28
|
+
Runner parsed seed-files vor `migration.run()` per regex
|
|
29
|
+
`systemWriteAs\(["']([^"']+)["']`, sammelt handler-QNs, validiert
|
|
30
|
+
gegen `registry.getWriteHandler(qn)`. Fail-fast mit klarer Message
|
|
31
|
+
|
|
32
|
+
- Datei + QN statt zur Runtime "handler not found". Catched camelCase-
|
|
33
|
+
typos (kebab-case-vs-camelCase Drift) + andere QN-Drift zur Boot-Zeit.
|
|
34
|
+
runProdApp reicht den richtigen Registry rein (`registry` neu in
|
|
35
|
+
RunPendingSeedMigrationsArgs).
|
|
36
|
+
|
|
37
|
+
**A3 — E2E-Test:**
|
|
38
|
+
`packages/bundled-features/src/__tests__/es-ops-e2e.integration.ts`
|
|
39
|
+
mit `setupTestStack`-Pattern: tenant+config Features echt geladen,
|
|
40
|
+
echtes Membership-Aggregate via TenantHandlers.addMember im Demo-Tenant,
|
|
41
|
+
seed-migration ruft update-member-roles mit tenantIdOverride → write
|
|
42
|
+
geht durch, Marker landed, Event in Store, Read-Model aktualisiert.
|
|
43
|
+
Plus typo-Test: seed mit camelCase fail-t Dry-Run mit
|
|
44
|
+
`/dry-run found.*unknown handler-QN/`. **TDD-First**: ohne A1+A2 wäre
|
|
45
|
+
der test rot.
|
|
46
|
+
|
|
47
|
+
**A4 — Doku:**
|
|
48
|
+
`framework/src/es-ops/README.md` erweitert um „Wann brauche ich
|
|
49
|
+
tenantIdOverride?" + „Deployment-Anforderungen" (Docker COPY, Idempotenz,
|
|
50
|
+
Multi-Replica) + „Lokaler Smoke vor Push". Recipe-README + seed-files
|
|
51
|
+
auf neue API aktualisiert.
|
|
52
|
+
|
|
53
|
+
**A5 — Smoke-Skript-Template:**
|
|
54
|
+
`samples/recipes/seed-migration/scripts/smoke.ts` als copy-paste-Template
|
|
55
|
+
für App-Authors: Bun-runnable, offline (read-only, kein DB-Write),
|
|
56
|
+
validiert Module-Load + QN-Resolution + System-User-Access. Recipe-
|
|
57
|
+
README dokumentiert Pflicht-Pattern.
|
|
58
|
+
|
|
59
|
+
**Bonus-Fix:**
|
|
60
|
+
`tenant:write:create`-access auf `["system", "SystemAdmin"]` erweitert
|
|
61
|
+
(symmetrisch zu update-member-roles). Aufgedeckt durch Recipe-Smoke +
|
|
62
|
+
initial-tenants-Seed. Pinning-Test in `tenant.integration.ts` updated.
|
|
63
|
+
|
|
64
|
+
**Test-State:** 45/45 grün (Pre-Push). Typecheck clean. Biome clean.
|
|
65
|
+
as-cast-Audit clean. Guard-silent-skip clean. Recipe-Smoke clean.
|
|
66
|
+
|
|
67
|
+
**Folge-Step (separater PR):** publicstatus driver-sample reaktivieren
|
|
68
|
+
mit lokalem Pre-Push-Smoke gegen publicstatus' echtes Feature-Set.
|
|
69
|
+
|
|
3
70
|
## 0.5.2
|
|
4
71
|
|
|
5
72
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
"zod": "^4.4.3"
|
|
164
164
|
},
|
|
165
165
|
"devDependencies": {
|
|
166
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
166
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.7.0",
|
|
167
167
|
"@types/uuid": "^11.0.0",
|
|
168
168
|
"bun-types": "^1.3.13",
|
|
169
169
|
"drizzle-kit": "^0.31.10",
|
package/src/es-ops/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# es-ops
|
|
2
2
|
|
|
3
|
-
ES-Operations für Kumiko-Apps. Phase 1 liefert `seed-migrations` als file-basiertes Diff-and-Apply für Aggregate-State-Updates, die idempotent-Seeder nicht erfassen können.
|
|
3
|
+
ES-Operations für Kumiko-Apps. Phase 1+1.5 liefert `seed-migrations` als file-basiertes Diff-and-Apply für Aggregate-State-Updates, die idempotent-Seeder nicht erfassen können.
|
|
4
|
+
|
|
5
|
+
> **Phase 1 vs 1.5:** Phase 1 hatte den Foundation-Code, Phase 1.5 hat den ersten realen Driver-Use-Case durch (publicstatus admin-roles) und brachte: `tenantIdOverride` für Tenant-scope-Aggregates, Dry-Run-Validator für Handler-QNs, Deploy-Doku, lokales Smoke-Pattern. Pflicht-Lesen: [Retro](../../../../kumiko-platform/docs/plans/features/es-ops-phase1-retro.md).
|
|
4
6
|
|
|
5
7
|
## Quick API
|
|
6
8
|
|
|
@@ -27,16 +29,74 @@ export default {
|
|
|
27
29
|
if (!admin) return;
|
|
28
30
|
for (const m of await ctx.findMembershipsOfUser(admin.id)) {
|
|
29
31
|
if (m.roles.includes("TenantAdmin")) continue;
|
|
30
|
-
await ctx.systemWriteAs(
|
|
31
|
-
|
|
32
|
-
tenantId: m.tenantId,
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
await ctx.systemWriteAs(
|
|
33
|
+
"tenant:write:update-member-roles",
|
|
34
|
+
{ userId: admin.id, tenantId: m.tenantId, roles: [...m.roles, "TenantAdmin"] },
|
|
35
|
+
m.streamTenantId, // ← tenantIdOverride aus dem JOIN auf kumiko_events.v1
|
|
36
|
+
);
|
|
35
37
|
}
|
|
36
38
|
},
|
|
37
39
|
} satisfies SeedMigration;
|
|
38
40
|
```
|
|
39
41
|
|
|
42
|
+
### Wann brauche ich `tenantIdOverride`?
|
|
43
|
+
|
|
44
|
+
Faustregel: **wenn das Ziel-Aggregate via Tenant-User erstellt wurde, brauchst Du den Override.**
|
|
45
|
+
|
|
46
|
+
| Aggregate-Typ | Stream-Tenant | `tenantIdOverride` |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| config-values (system-scope) | SYSTEM_TENANT | weglassen |
|
|
49
|
+
| system text-content | SYSTEM_TENANT | weglassen |
|
|
50
|
+
| tenant-membership | jeweiliger Stream-Tenant aus events.v1 | ✅ `m.streamTenantId` (NICHT `m.tenantId` — die beiden können divergieren!) |
|
|
51
|
+
| App-Entity (orders, tasks, …) | Tenant-Stream | ✅ Tenant-Id aus dem Lookup |
|
|
52
|
+
|
|
53
|
+
**Warum nicht `m.tenantId`?** read_tenant_memberships.tenant_id ist der payload-tenant (logisches Mitgliedschafts-Ziel), kumiko_events.tenant_id der v1-Row ist der stream-tenant (wo das Aggregate physisch lebt). seedTenantMembership mit `by=systemAdmin` lässt die zwei auseinanderlaufen — der Helper `findMembershipsOfUser` liefert beide getrennt, damit Seeds den richtigen wählen können.
|
|
54
|
+
|
|
55
|
+
Ohne `tenantIdOverride` sucht der Executor den Stream gegen SYSTEM_TENANT → `version_conflict`. Memory: `feedback_event_store_tenant_consistency.md`.
|
|
56
|
+
|
|
57
|
+
## Deployment-Anforderungen
|
|
58
|
+
|
|
59
|
+
Wichtig — wird gerne übersehen:
|
|
60
|
+
|
|
61
|
+
### Docker / Bun-Bundle
|
|
62
|
+
|
|
63
|
+
Seeds werden zur Runtime via `await import(absolutePath)` geladen. Bun's Bundler strippt dynamic-import-Targets → seeds/-Tree muss **als raw-TS-Tree** ins Image kopiert werden:
|
|
64
|
+
|
|
65
|
+
```dockerfile
|
|
66
|
+
# Nach dem dist-server/-COPY:
|
|
67
|
+
COPY --from=build --chown=app:app /app/seeds ./seeds
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Plus: in der `bun build` Stage NICHT mit `--minify` durch die seed-Files laufen (sie sind keine Eingabe — der Bundler bundlet `bin/main.ts`, nicht das seeds-Verzeichnis).
|
|
71
|
+
|
|
72
|
+
### Idempotenz-Pflicht
|
|
73
|
+
|
|
74
|
+
Seed-Body läuft **NICHT** atomic mit dem Marker (siehe „Was NICHT garantiert ist" unten). Wenn ein Seed mid-way thrown wirft, sind die schon committed Events drin, der Marker aber nicht → Retry beim nächsten Boot. **Seeds müssen idempotent sein.**
|
|
75
|
+
|
|
76
|
+
Standard-Pattern:
|
|
77
|
+
```ts
|
|
78
|
+
const memberships = await ctx.findMembershipsOfUser(adminId);
|
|
79
|
+
for (const m of memberships) {
|
|
80
|
+
if (m.roles.includes("TenantAdmin")) continue; // ← check-then-write
|
|
81
|
+
await ctx.systemWriteAs(...);
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Anti-Pattern (NICHT idempotent):
|
|
86
|
+
```ts
|
|
87
|
+
for (let i = 0; i < 5; i++) {
|
|
88
|
+
await ctx.systemWriteAs("create-something", { ... }); // ← Re-Run produziert Duplikate
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Multi-Replica-Boot
|
|
93
|
+
|
|
94
|
+
`pg_advisory_xact_lock` sequentialisiert parallele Pod-Boots. Lock-Key ist global (`0x65736f70` / „esop"), nicht migration-spezifisch → bei N pending Migrationen läuft N-mal sequentiell, nicht parallel. Für die typische seed-Migration-Workload ist das schnell genug; bei sehr langen Migrationen (>30s) auf einem Multi-Replica-Stack: erst manuell als CLI-Step laufen lassen (`bunx kumiko ops seed:apply`), dann Pod-Rollout.
|
|
95
|
+
|
|
96
|
+
### Lokaler Smoke vor Push
|
|
97
|
+
|
|
98
|
+
Pflicht-Pattern: bevor Du seeds in main pushst, einmal lokal gegen Dev-DB den Boot-Loop laufen lassen. Siehe `samples/recipes/seed-migration/scripts/smoke.ts` als copy-paste-Template.
|
|
99
|
+
|
|
40
100
|
## CLI
|
|
41
101
|
|
|
42
102
|
```bash
|
|
@@ -55,10 +55,34 @@ afterAll(async () => {
|
|
|
55
55
|
|
|
56
56
|
beforeEach(async () => {
|
|
57
57
|
await testDb.db.execute(sql`
|
|
58
|
-
TRUNCATE kumiko_es_operations, read_users, read_tenant_memberships, read_tenants
|
|
58
|
+
TRUNCATE kumiko_es_operations, kumiko_events, read_users, read_tenant_memberships, read_tenants
|
|
59
|
+
RESTART IDENTITY CASCADE
|
|
59
60
|
`);
|
|
60
61
|
});
|
|
61
62
|
|
|
63
|
+
// Helper: simulate `seedTenantMembership` writing both the read-row and
|
|
64
|
+
// its v1-event with a custom stream-tenant. Tests use this to construct
|
|
65
|
+
// the stream-vs-payload-tenant scenarios that drive the JOIN-helper.
|
|
66
|
+
async function insertMembershipWithEvent(args: {
|
|
67
|
+
readonly id: string;
|
|
68
|
+
readonly userId: string;
|
|
69
|
+
readonly payloadTenantId: string;
|
|
70
|
+
readonly streamTenantId: string;
|
|
71
|
+
readonly roles: string;
|
|
72
|
+
}): Promise<void> {
|
|
73
|
+
await testDb.db.execute(sql`
|
|
74
|
+
INSERT INTO read_tenant_memberships (id, user_id, tenant_id, roles)
|
|
75
|
+
VALUES (${args.id}::uuid, ${args.userId}, ${args.payloadTenantId}::uuid, ${args.roles})
|
|
76
|
+
`);
|
|
77
|
+
await testDb.db.execute(sql`
|
|
78
|
+
INSERT INTO kumiko_events
|
|
79
|
+
(aggregate_id, aggregate_type, tenant_id, version, type, payload, metadata, created_by)
|
|
80
|
+
VALUES
|
|
81
|
+
(${args.id}::uuid, 'tenant-membership', ${args.streamTenantId}::uuid, 1,
|
|
82
|
+
'tenant-membership.created', '{}'::jsonb, '{"userId":"system"}'::jsonb, 'system')
|
|
83
|
+
`);
|
|
84
|
+
}
|
|
85
|
+
|
|
62
86
|
function makeMockDispatcher() {
|
|
63
87
|
return {
|
|
64
88
|
write: vi.fn(async () => ({ isSuccess: true as const, data: {} })),
|
|
@@ -107,13 +131,24 @@ describe("SeedMigrationContext.findUserByEmail (integration)", () => {
|
|
|
107
131
|
describe("SeedMigrationContext.findMembershipsOfUser (integration)", () => {
|
|
108
132
|
test("parst JSON-encoded roles-Spalte zu string[]", async () => {
|
|
109
133
|
const userId = "01900000-0000-7000-8000-000000000001";
|
|
134
|
+
const aggId1 = "00000000-0000-4000-8000-0000000000a1";
|
|
135
|
+
const aggId2 = "00000000-0000-4000-8000-0000000000a2";
|
|
110
136
|
const tenantId1 = "00000000-0000-4000-8000-000000000001";
|
|
111
137
|
const tenantId2 = "00000000-0000-4000-8000-000000000002";
|
|
112
|
-
await
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
138
|
+
await insertMembershipWithEvent({
|
|
139
|
+
id: aggId1,
|
|
140
|
+
userId,
|
|
141
|
+
payloadTenantId: tenantId1,
|
|
142
|
+
streamTenantId: tenantId1,
|
|
143
|
+
roles: '["Admin", "TenantAdmin"]',
|
|
144
|
+
});
|
|
145
|
+
await insertMembershipWithEvent({
|
|
146
|
+
id: aggId2,
|
|
147
|
+
userId,
|
|
148
|
+
payloadTenantId: tenantId2,
|
|
149
|
+
streamTenantId: tenantId2,
|
|
150
|
+
roles: '["User"]',
|
|
151
|
+
});
|
|
117
152
|
|
|
118
153
|
const ctx = createSeedMigrationContext({
|
|
119
154
|
dispatcher: makeMockDispatcher() as never,
|
|
@@ -129,14 +164,49 @@ describe("SeedMigrationContext.findMembershipsOfUser (integration)", () => {
|
|
|
129
164
|
expect(m2?.roles).toEqual(["User"]);
|
|
130
165
|
});
|
|
131
166
|
|
|
167
|
+
test("stream-tenant != payload-tenant wird korrekt ausgewiesen (Driver-Bug)", async () => {
|
|
168
|
+
// Reproduziert den publicstatus-Driver-Fall: seedTenantMembership
|
|
169
|
+
// wurde mit by=systemAdmin aufgerufen → executor.tenantId=
|
|
170
|
+
// SYSTEM_TENANT_ID landet als events.tenant_id, während payload.
|
|
171
|
+
// tenantId der target-Tenant ist. Die beiden divergieren.
|
|
172
|
+
const userId = "01900000-0000-7000-8000-000000000001";
|
|
173
|
+
const aggId = "00000000-0000-4000-8000-0000000000b1";
|
|
174
|
+
const payloadTenant = "00000000-0000-4000-8000-000000000042";
|
|
175
|
+
const streamTenant = "00000000-0000-4000-8000-000000000001"; // SYSTEM_TENANT-Stil
|
|
176
|
+
await insertMembershipWithEvent({
|
|
177
|
+
id: aggId,
|
|
178
|
+
userId,
|
|
179
|
+
payloadTenantId: payloadTenant,
|
|
180
|
+
streamTenantId: streamTenant,
|
|
181
|
+
roles: '["Admin"]',
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const ctx = createSeedMigrationContext({
|
|
185
|
+
dispatcher: makeMockDispatcher() as never,
|
|
186
|
+
dbRunner: testDb.db,
|
|
187
|
+
});
|
|
188
|
+
const [m] = await ctx.findMembershipsOfUser(userId);
|
|
189
|
+
expect(m).toEqual({
|
|
190
|
+
userId,
|
|
191
|
+
tenantId: payloadTenant,
|
|
192
|
+
streamTenantId: streamTenant,
|
|
193
|
+
roles: ["Admin"],
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
132
197
|
test("malformed roles-JSON → leeres Array (defensive, no throw)", async () => {
|
|
133
198
|
// Defensive: wenn ein corrupted row kommt, soll der Seed nicht
|
|
134
199
|
// explodieren — kann selbst entscheiden was zu tun ist.
|
|
135
200
|
const userId = "01900000-0000-7000-8000-000000000002";
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
201
|
+
const aggId = "00000000-0000-4000-8000-0000000000c1";
|
|
202
|
+
const tenantId = "00000000-0000-4000-8000-000000000003";
|
|
203
|
+
await insertMembershipWithEvent({
|
|
204
|
+
id: aggId,
|
|
205
|
+
userId,
|
|
206
|
+
payloadTenantId: tenantId,
|
|
207
|
+
streamTenantId: tenantId,
|
|
208
|
+
roles: "not-json",
|
|
209
|
+
});
|
|
140
210
|
const ctx = createSeedMigrationContext({
|
|
141
211
|
dispatcher: makeMockDispatcher() as never,
|
|
142
212
|
dbRunner: testDb.db,
|
|
@@ -153,6 +223,26 @@ describe("SeedMigrationContext.findMembershipsOfUser (integration)", () => {
|
|
|
153
223
|
const memberships = await ctx.findMembershipsOfUser("01900000-0000-7000-8000-000000000099");
|
|
154
224
|
expect(memberships).toEqual([]);
|
|
155
225
|
});
|
|
226
|
+
|
|
227
|
+
test("membership ohne v1-Event wird vom INNER JOIN ausgefiltert (Drift-Detection)", async () => {
|
|
228
|
+
// Schutz vor Data-Drift: read-row ohne event-row ist kein legitimer
|
|
229
|
+
// Zustand für ein ES-Aggregate. Statt einer Half-Row zurückzugeben
|
|
230
|
+
// verschwindet die Row aus dem Result — Seed-Author sieht "0 memberships"
|
|
231
|
+
// statt einer mit fehlendem stream-tenant zu arbeiten und schwer
|
|
232
|
+
// diagnostizierbare version_conflict-Errors zu produzieren.
|
|
233
|
+
const userId = "01900000-0000-7000-8000-000000000003";
|
|
234
|
+
await testDb.db.execute(sql`
|
|
235
|
+
INSERT INTO read_tenant_memberships (id, user_id, tenant_id, roles) VALUES
|
|
236
|
+
('00000000-0000-4000-8000-0000000000d1'::uuid, ${userId},
|
|
237
|
+
'00000000-0000-4000-8000-000000000005'::uuid, '["Admin"]')
|
|
238
|
+
`);
|
|
239
|
+
const ctx = createSeedMigrationContext({
|
|
240
|
+
dispatcher: makeMockDispatcher() as never,
|
|
241
|
+
dbRunner: testDb.db,
|
|
242
|
+
});
|
|
243
|
+
const memberships = await ctx.findMembershipsOfUser(userId);
|
|
244
|
+
expect(memberships).toEqual([]);
|
|
245
|
+
});
|
|
156
246
|
});
|
|
157
247
|
|
|
158
248
|
describe("SeedMigrationContext.findTenants (integration)", () => {
|
package/src/es-ops/context.ts
CHANGED
|
@@ -29,11 +29,20 @@ export type CreateSeedMigrationContextArgs = {
|
|
|
29
29
|
export function createSeedMigrationContext(
|
|
30
30
|
args: CreateSeedMigrationContextArgs,
|
|
31
31
|
): SeedMigrationContext {
|
|
32
|
-
|
|
32
|
+
// Default-Executor für System-scope-Aggregates (config-values, system
|
|
33
|
+
// text-content, etc.). Bei Tenant-scope-Aggregates muss der Caller
|
|
34
|
+
// explizit `tenantIdOverride` übergeben — siehe types.ts Doku.
|
|
35
|
+
const defaultSystemUser = createSystemUser(SYSTEM_TENANT_ID);
|
|
33
36
|
|
|
34
37
|
return {
|
|
35
|
-
systemWriteAs: async (handlerQualifiedName, payload) => {
|
|
36
|
-
|
|
38
|
+
systemWriteAs: async (handlerQualifiedName, payload, tenantIdOverride) => {
|
|
39
|
+
// tenantIdOverride: baut einen System-User mit der Stream-tenantId
|
|
40
|
+
// damit der Event-Store-Executor das Aggregate im richtigen Stream
|
|
41
|
+
// findet. Verhindert die version_conflict-Falle (siehe Memory
|
|
42
|
+
// feedback_event_store_tenant_consistency.md).
|
|
43
|
+
const executor =
|
|
44
|
+
tenantIdOverride !== undefined ? createSystemUser(tenantIdOverride) : defaultSystemUser;
|
|
45
|
+
const result = await args.dispatcher.write(handlerQualifiedName, payload, executor);
|
|
37
46
|
// Critical: WriteResult{isSuccess: false} würde sonst silent durchlaufen
|
|
38
47
|
// → Marker landet trotz failed-Write → Migration falsch als "applied"
|
|
39
48
|
// markiert. Hier throw damit der Runner's outer-tx rollback macht und
|
|
@@ -67,17 +76,34 @@ export function createSeedMigrationContext(
|
|
|
67
76
|
},
|
|
68
77
|
|
|
69
78
|
findMembershipsOfUser: async (userId) => {
|
|
79
|
+
// INNER JOIN auf kumiko_events um den stream-tenant (events.tenant_id
|
|
80
|
+
// der v1-Row) neben dem payload-tenant (memberships.tenant_id) zu
|
|
81
|
+
// liefern. Die beiden divergieren wenn das Aggregate von einem
|
|
82
|
+
// Executor mit fremder tenantId angelegt wurde (seedTenantMembership
|
|
83
|
+
// by=systemAdmin) — typischer publicstatus-Driver-Use-Case.
|
|
84
|
+
// INNER (nicht LEFT): kein v1-Event bei vorhandener Read-Row wäre
|
|
85
|
+
// Data-Drift, kein legitimer Zustand für Seed-Migrations.
|
|
70
86
|
// @cast-boundary db-row — roles ist JSON-string in der text-Spalte
|
|
71
87
|
// (Memory: tenant-membership.created payload "[\"User\"]"), wird unten geparst
|
|
72
88
|
const rows = (await args.dbRunner.execute(
|
|
73
|
-
sql`SELECT user_id::text AS user_id,
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
89
|
+
sql`SELECT m.user_id::text AS user_id,
|
|
90
|
+
m.tenant_id::text AS tenant_id,
|
|
91
|
+
e.tenant_id::text AS stream_tenant_id,
|
|
92
|
+
m.roles
|
|
93
|
+
FROM read_tenant_memberships m
|
|
94
|
+
JOIN kumiko_events e ON e.aggregate_id = m.id AND e.version = 1
|
|
95
|
+
WHERE m.user_id = ${userId}`,
|
|
96
|
+
)) as unknown as readonly {
|
|
97
|
+
user_id: string;
|
|
98
|
+
tenant_id: string;
|
|
99
|
+
stream_tenant_id: string;
|
|
100
|
+
roles: string;
|
|
101
|
+
}[];
|
|
77
102
|
return rows.map(
|
|
78
103
|
(r): SeedMembershipRow => ({
|
|
79
104
|
userId: r.user_id,
|
|
80
105
|
tenantId: r.tenant_id,
|
|
106
|
+
streamTenantId: r.stream_tenant_id,
|
|
81
107
|
roles: safeParseRolesJson(r.roles),
|
|
82
108
|
}),
|
|
83
109
|
);
|
package/src/es-ops/runner.ts
CHANGED
|
@@ -20,10 +20,11 @@
|
|
|
20
20
|
// überspringen ohne ihr Code touchen zu müssen. NICHT als
|
|
21
21
|
// Standard-Workflow — wirklich Notfall.
|
|
22
22
|
|
|
23
|
-
import { readdir } from "node:fs/promises";
|
|
23
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
import { eq, sql } from "drizzle-orm";
|
|
26
26
|
import type { DbConnection, DbRunner } from "../db";
|
|
27
|
+
import type { Registry } from "../engine";
|
|
27
28
|
import { esOperationsTable } from "./operations-schema";
|
|
28
29
|
import type { EsOperationAppliedBy, SeedMigration, SeedMigrationContext } from "./types";
|
|
29
30
|
|
|
@@ -45,6 +46,14 @@ export type RunPendingSeedMigrationsArgs = {
|
|
|
45
46
|
readonly createContext: (dbRunner: DbRunner) => SeedMigrationContext;
|
|
46
47
|
/** Trace-marker: boot | cli | ci-pipeline. Landet in applied_by. */
|
|
47
48
|
readonly appliedBy: EsOperationAppliedBy;
|
|
49
|
+
/** Optional registry für Dry-Run-Validation: parsed jeden seed-file und
|
|
50
|
+
* checkt dass alle referenzierten handler-QNs in der Registry existieren
|
|
51
|
+
* BEVOR die Migration läuft. Catched camelCase-typos + andere QN-Drift
|
|
52
|
+
* zur Boot-Zeit statt mitten im write-cycle (Phase 1.5 / A2).
|
|
53
|
+
*
|
|
54
|
+
* Wenn weggelassen → kein Dry-Run (backward-compat für tests die ohne
|
|
55
|
+
* Registry arbeiten). runProdApp reicht den richtigen Registry rein. */
|
|
56
|
+
readonly registry?: Registry;
|
|
48
57
|
/** Optional log-prefix override, default "[es-ops/seed-migration]". */
|
|
49
58
|
readonly logger?: (line: string) => void;
|
|
50
59
|
};
|
|
@@ -84,6 +93,31 @@ export async function runPendingSeedMigrations(
|
|
|
84
93
|
const appliedIds: string[] = [];
|
|
85
94
|
const skippedIds: string[] = [];
|
|
86
95
|
|
|
96
|
+
// Dry-Run-Pass (Phase 1.5 / A2): vor JEDER migration alle handler-QNs aus
|
|
97
|
+
// den seed-files parsen + gegen registry checken. Fail-fast vor erstem
|
|
98
|
+
// write — gibt klare error-message mit Datei + qn statt zur runtime
|
|
99
|
+
// "handler not found" mitten im migration-flow.
|
|
100
|
+
if (args.registry !== undefined) {
|
|
101
|
+
const unknownQns: Array<{ id: string; qn: string }> = [];
|
|
102
|
+
for (const entry of pending) {
|
|
103
|
+
const source = await readFile(entry.filePath, "utf-8");
|
|
104
|
+
for (const qn of extractWriteHandlerQns(source)) {
|
|
105
|
+
if (!args.registry.getWriteHandler(qn)) {
|
|
106
|
+
unknownQns.push({ id: entry.id, qn });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (unknownQns.length > 0) {
|
|
111
|
+
const lines = unknownQns.map((u) => ` - ${u.id}: "${u.qn}" not registered`);
|
|
112
|
+
throw new Error(
|
|
113
|
+
`[es-ops/seed-migration] dry-run found ${unknownQns.length} unknown handler-QN(s):\n${lines.join(
|
|
114
|
+
"\n",
|
|
115
|
+
)}\n Check spelling against your TenantHandlers/AuthHandlers constants (kebab-case after the colon).`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
log(`${LOG_PREFIX} dry-run ok — all referenced handler-QNs registered`);
|
|
119
|
+
}
|
|
120
|
+
|
|
87
121
|
for (const entry of pending) {
|
|
88
122
|
const migration = await loadSeedModule(entry.filePath);
|
|
89
123
|
|
|
@@ -203,6 +237,26 @@ function sanitizeForEnv(id: string): string {
|
|
|
203
237
|
return id.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
|
|
204
238
|
}
|
|
205
239
|
|
|
240
|
+
// Parse seed-file source + extract handler-QNs aus `systemWriteAs(...)`-
|
|
241
|
+
// Calls. Reine regex (kein AST) — fängt die häufigen Inline-String-Cases:
|
|
242
|
+
// ctx.systemWriteAs("foo:write:bar", payload)
|
|
243
|
+
// systemWriteAs("foo:write:bar", ...) (destructured)
|
|
244
|
+
//
|
|
245
|
+
// Edge-Cases die NICHT geguckt werden:
|
|
246
|
+
// - QN aus Variable: `const qn = "..."; ctx.systemWriteAs(qn, ...)`
|
|
247
|
+
// - String-Concat / Template-Literals mit dynamic vars
|
|
248
|
+
// Diese Pattern sind selten in real seed-migrations + bleibt als known-
|
|
249
|
+
// limitation dokumentiert. Wer dynamic-QN braucht, weiß was er tut.
|
|
250
|
+
function extractWriteHandlerQns(source: string): readonly string[] {
|
|
251
|
+
const pattern = /systemWriteAs\s*\(\s*["']([^"']+)["']/g;
|
|
252
|
+
const out = new Set<string>();
|
|
253
|
+
for (const match of source.matchAll(pattern)) {
|
|
254
|
+
const qn = match[1];
|
|
255
|
+
if (qn) out.add(qn);
|
|
256
|
+
}
|
|
257
|
+
return [...out];
|
|
258
|
+
}
|
|
259
|
+
|
|
206
260
|
function stringifyError(err: unknown): string {
|
|
207
261
|
if (err instanceof Error) return `${err.name}: ${err.message}`;
|
|
208
262
|
try {
|
package/src/es-ops/types.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// (Source-of-Truth + Projection läuft automatisch).
|
|
14
14
|
|
|
15
15
|
import type { DbRunner } from "../db";
|
|
16
|
-
import type { WriteResult } from "../engine";
|
|
16
|
+
import type { TenantId, WriteResult } from "../engine";
|
|
17
17
|
|
|
18
18
|
export type EsOperationAppliedBy = "boot" | "cli" | "ci-pipeline";
|
|
19
19
|
|
|
@@ -46,10 +46,23 @@ export type SeedUserRow = {
|
|
|
46
46
|
readonly tenantId: string;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
/** Read-shape eines Membership-Eintrags wie an Seed-Helpers exposed.
|
|
49
|
+
/** Read-shape eines Membership-Eintrags wie an Seed-Helpers exposed.
|
|
50
|
+
* Unterscheidet zwei tenantIds: die "logische" aus dem Read-Projektion
|
|
51
|
+
* (`tenantId`) und die "physische" aus dem Aggregate-Stream
|
|
52
|
+
* (`streamTenantId`). Die beiden weichen voneinander ab wenn das
|
|
53
|
+
* Aggregate von einem Executor mit anderer tenantId angelegt wurde
|
|
54
|
+
* (z.B. seedTenantMembership-by=systemAdmin) — typischer
|
|
55
|
+
* publicstatus-Driver-Use-Case. */
|
|
50
56
|
export type SeedMembershipRow = {
|
|
51
57
|
readonly userId: string;
|
|
58
|
+
/** Payload-tenant aus `read_tenant_memberships.tenant_id`. Geht ins
|
|
59
|
+
* write-payload als `tenantId`. */
|
|
52
60
|
readonly tenantId: string;
|
|
61
|
+
/** Stream-tenant aus `kumiko_events.tenant_id` der v1-Row. MUSS als
|
|
62
|
+
* `tenantIdOverride` an `systemWriteAs` durchgereicht werden, sonst
|
|
63
|
+
* sucht der Event-Store-Executor den Stream im falschen Tenant und
|
|
64
|
+
* liefert `version_conflict`. */
|
|
65
|
+
readonly streamTenantId: string;
|
|
53
66
|
readonly roles: readonly string[];
|
|
54
67
|
};
|
|
55
68
|
|
|
@@ -67,8 +80,27 @@ export type SeedMigrationContext = {
|
|
|
67
80
|
*
|
|
68
81
|
* Typ-Signatur folgt existing ctx.writeAs (payload als unknown) — Type-
|
|
69
82
|
* Safety kommt über handler-spezifische Wrapper im Aufrufer ("ich weiß
|
|
70
|
-
* was updateMemberRoles braucht"). Versucht NICHT Generic-Magic.
|
|
71
|
-
|
|
83
|
+
* was updateMemberRoles braucht"). Versucht NICHT Generic-Magic.
|
|
84
|
+
*
|
|
85
|
+
* **tenantIdOverride (Phase 1.5):** wenn das Ziel-Aggregate in einem
|
|
86
|
+
* spezifischen Tenant-Stream lebt (nicht SYSTEM_TENANT_ID, was Default
|
|
87
|
+
* ist), MUSS der Caller die Stream-tenantId mitgeben — sonst sucht der
|
|
88
|
+
* Event-Store-Executor den Aggregate-Stream gegen `SYSTEM_TENANT_ID`
|
|
89
|
+
* und liefert `version_conflict` (siehe Memory
|
|
90
|
+
* `feedback_event_store_tenant_consistency.md` + Driver-Use-Case
|
|
91
|
+
* publicstatus-admin-roles in `project_es_ops_phase1_retro.md`).
|
|
92
|
+
*
|
|
93
|
+
* Typische Pattern:
|
|
94
|
+
* - System-scope-Aggregate (config-values, system text-content) →
|
|
95
|
+
* tenantIdOverride weglassen (Default SYSTEM_TENANT_ID).
|
|
96
|
+
* - Tenant-scope-Aggregate (memberships, tenant-config, app-data) →
|
|
97
|
+
* `tenantIdOverride: m.tenantId` (oder den Stream-Tenant aus
|
|
98
|
+
* einem find*-Helper). */
|
|
99
|
+
readonly systemWriteAs: (
|
|
100
|
+
handlerQualifiedName: string,
|
|
101
|
+
payload: unknown,
|
|
102
|
+
tenantIdOverride?: TenantId,
|
|
103
|
+
) => Promise<WriteResult>;
|
|
72
104
|
|
|
73
105
|
// Read-helpers für die häufigsten Lookups. Wachsen on-demand —
|
|
74
106
|
// Phase 1 deckt den admin-roles-Driver-Use-Case ab; weitere Lookups
|