@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 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.5.2",
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.5.2",
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",
@@ -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("tenant:write:updateMemberRoles", {
31
- userId: admin.id,
32
- tenantId: m.tenantId,
33
- roles: [...m.roles, "TenantAdmin"],
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 testDb.db.execute(sql`
113
- INSERT INTO read_tenant_memberships (user_id, tenant_id, roles) VALUES
114
- (${userId}, ${tenantId1}::uuid, '["Admin", "TenantAdmin"]'),
115
- (${userId}, ${tenantId2}::uuid, '["User"]')
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
- await testDb.db.execute(sql`
137
- INSERT INTO read_tenant_memberships (user_id, tenant_id, roles) VALUES
138
- (${userId}, '00000000-0000-4000-8000-000000000003'::uuid, 'not-json')
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)", () => {
@@ -29,11 +29,20 @@ export type CreateSeedMigrationContextArgs = {
29
29
  export function createSeedMigrationContext(
30
30
  args: CreateSeedMigrationContextArgs,
31
31
  ): SeedMigrationContext {
32
- const systemUser = createSystemUser(SYSTEM_TENANT_ID);
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
- const result = await args.dispatcher.write(handlerQualifiedName, payload, systemUser);
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, 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 }[];
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
  );
@@ -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 {
@@ -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
- readonly systemWriteAs: (handlerQualifiedName: string, payload: unknown) => Promise<WriteResult>;
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