@cosmicdrift/kumiko-framework 0.5.2 → 0.6.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,66 @@
1
1
  # @cosmicdrift/kumiko-framework
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8489d18: feat(es-ops): Phase 1.5 — tenantIdOverride + dry-run-validator + E2E-Test + Doku
8
+
9
+ Phase 1.5 schließt die Lücken aus Phase 1 die den ersten Driver-Use-Case
10
+ (publicstatus admin-roles) blockten. Siehe Retro:
11
+ `kumiko-platform/docs/plans/features/es-ops-phase1-retro.md` (PR #9).
12
+
13
+ **A1 — tenantIdOverride:**
14
+ `SeedMigrationContext.systemWriteAs(qn, payload, tenantIdOverride?)`.
15
+ Default SYSTEM_TENANT_ID (unverändert für System-scope-Aggregates wie
16
+ config-values). Mit override: `createSystemUser(tenantIdOverride)` als
17
+ Executor, damit der Event-Store-Executor den Aggregate-Stream im
18
+ richtigen Tenant findet. Fix für die `version_conflict`-Klasse-Bug
19
+ (Memory `feedback_event_store_tenant_consistency.md`).
20
+
21
+ **A2 — dry-run-validator:**
22
+ Runner parsed seed-files vor `migration.run()` per regex
23
+ `systemWriteAs\(["']([^"']+)["']`, sammelt handler-QNs, validiert
24
+ gegen `registry.getWriteHandler(qn)`. Fail-fast mit klarer Message
25
+
26
+ - Datei + QN statt zur Runtime "handler not found". Catched camelCase-
27
+ typos (kebab-case-vs-camelCase Drift) + andere QN-Drift zur Boot-Zeit.
28
+ runProdApp reicht den richtigen Registry rein (`registry` neu in
29
+ RunPendingSeedMigrationsArgs).
30
+
31
+ **A3 — E2E-Test:**
32
+ `packages/bundled-features/src/__tests__/es-ops-e2e.integration.ts`
33
+ mit `setupTestStack`-Pattern: tenant+config Features echt geladen,
34
+ echtes Membership-Aggregate via TenantHandlers.addMember im Demo-Tenant,
35
+ seed-migration ruft update-member-roles mit tenantIdOverride → write
36
+ geht durch, Marker landed, Event in Store, Read-Model aktualisiert.
37
+ Plus typo-Test: seed mit camelCase fail-t Dry-Run mit
38
+ `/dry-run found.*unknown handler-QN/`. **TDD-First**: ohne A1+A2 wäre
39
+ der test rot.
40
+
41
+ **A4 — Doku:**
42
+ `framework/src/es-ops/README.md` erweitert um „Wann brauche ich
43
+ tenantIdOverride?" + „Deployment-Anforderungen" (Docker COPY, Idempotenz,
44
+ Multi-Replica) + „Lokaler Smoke vor Push". Recipe-README + seed-files
45
+ auf neue API aktualisiert.
46
+
47
+ **A5 — Smoke-Skript-Template:**
48
+ `samples/recipes/seed-migration/scripts/smoke.ts` als copy-paste-Template
49
+ für App-Authors: Bun-runnable, offline (read-only, kein DB-Write),
50
+ validiert Module-Load + QN-Resolution + System-User-Access. Recipe-
51
+ README dokumentiert Pflicht-Pattern.
52
+
53
+ **Bonus-Fix:**
54
+ `tenant:write:create`-access auf `["system", "SystemAdmin"]` erweitert
55
+ (symmetrisch zu update-member-roles). Aufgedeckt durch Recipe-Smoke +
56
+ initial-tenants-Seed. Pinning-Test in `tenant.integration.ts` updated.
57
+
58
+ **Test-State:** 45/45 grün (Pre-Push). Typecheck clean. Biome clean.
59
+ as-cast-Audit clean. Guard-silent-skip clean. Recipe-Smoke clean.
60
+
61
+ **Folge-Step (separater PR):** publicstatus driver-sample reaktivieren
62
+ mit lokalem Pre-Push-Smoke gegen publicstatus' echtes Feature-Set.
63
+
3
64
  ## 0.5.2
4
65
 
5
66
  ### 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.6.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.6.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,72 @@ 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.tenantId, // ← tenantIdOverride: Aggregate lebt im Tenant-Stream, NICHT SYSTEM
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 Tenant-Stream | ✅ `m.tenantId` |
51
+ | App-Entity (orders, tasks, …) | Tenant-Stream | ✅ Tenant-Id aus dem Lookup |
52
+
53
+ Ohne `tenantIdOverride` sucht der Executor den Stream gegen SYSTEM_TENANT → `version_conflict`. Memory: `feedback_event_store_tenant_consistency.md`.
54
+
55
+ ## Deployment-Anforderungen
56
+
57
+ Wichtig — wird gerne übersehen:
58
+
59
+ ### Docker / Bun-Bundle
60
+
61
+ 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:
62
+
63
+ ```dockerfile
64
+ # Nach dem dist-server/-COPY:
65
+ COPY --from=build --chown=app:app /app/seeds ./seeds
66
+ ```
67
+
68
+ 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).
69
+
70
+ ### Idempotenz-Pflicht
71
+
72
+ 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.**
73
+
74
+ Standard-Pattern:
75
+ ```ts
76
+ const memberships = await ctx.findMembershipsOfUser(adminId);
77
+ for (const m of memberships) {
78
+ if (m.roles.includes("TenantAdmin")) continue; // ← check-then-write
79
+ await ctx.systemWriteAs(...);
80
+ }
81
+ ```
82
+
83
+ Anti-Pattern (NICHT idempotent):
84
+ ```ts
85
+ for (let i = 0; i < 5; i++) {
86
+ await ctx.systemWriteAs("create-something", { ... }); // ← Re-Run produziert Duplikate
87
+ }
88
+ ```
89
+
90
+ ### Multi-Replica-Boot
91
+
92
+ `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.
93
+
94
+ ### Lokaler Smoke vor Push
95
+
96
+ 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.
97
+
40
98
  ## CLI
41
99
 
42
100
  ```bash
@@ -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
@@ -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
 
@@ -67,8 +67,27 @@ export type SeedMigrationContext = {
67
67
  *
68
68
  * Typ-Signatur folgt existing ctx.writeAs (payload als unknown) — Type-
69
69
  * 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>;
70
+ * was updateMemberRoles braucht"). Versucht NICHT Generic-Magic.
71
+ *
72
+ * **tenantIdOverride (Phase 1.5):** wenn das Ziel-Aggregate in einem
73
+ * spezifischen Tenant-Stream lebt (nicht SYSTEM_TENANT_ID, was Default
74
+ * ist), MUSS der Caller die Stream-tenantId mitgeben — sonst sucht der
75
+ * Event-Store-Executor den Aggregate-Stream gegen `SYSTEM_TENANT_ID`
76
+ * und liefert `version_conflict` (siehe Memory
77
+ * `feedback_event_store_tenant_consistency.md` + Driver-Use-Case
78
+ * publicstatus-admin-roles in `project_es_ops_phase1_retro.md`).
79
+ *
80
+ * Typische Pattern:
81
+ * - System-scope-Aggregate (config-values, system text-content) →
82
+ * tenantIdOverride weglassen (Default SYSTEM_TENANT_ID).
83
+ * - Tenant-scope-Aggregate (memberships, tenant-config, app-data) →
84
+ * `tenantIdOverride: m.tenantId` (oder den Stream-Tenant aus
85
+ * einem find*-Helper). */
86
+ readonly systemWriteAs: (
87
+ handlerQualifiedName: string,
88
+ payload: unknown,
89
+ tenantIdOverride?: TenantId,
90
+ ) => Promise<WriteResult>;
72
91
 
73
92
  // Read-helpers für die häufigsten Lookups. Wachsen on-demand —
74
93
  // Phase 1 deckt den admin-roles-Driver-Use-Case ab; weitere Lookups