@cosmicdrift/kumiko-framework 0.4.1 → 0.5.1

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,59 @@
1
1
  # @cosmicdrift/kumiko-framework
2
2
 
3
+ ## 0.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 0e00015: fix(es-ops): path.resolve statt path.join für seedsDir → seed-files
8
+
9
+ Bun's `await import()` braucht absolute Pfade. Wenn der App-Author
10
+ `runProdApp({ seedsDir: "./seeds" })` setzt (relativ), würde
11
+ `path.join("./seeds", "foo.ts")` einen relativen Pfad liefern → Bun's
12
+ Import-Resolver such relativ zum `runner.ts`-Modul (nicht zum
13
+ `process.cwd()`) → `Cannot find module 'seeds/...' from '<runner-path>'`.
14
+
15
+ `path.resolve` löst gegen `process.cwd()` auf → absolute Pfade →
16
+ Import funktioniert. Aufgedeckt beim ersten Live-Boot der publicstatus-
17
+ Driver-Migration (Pod CrashLoopBackOff).
18
+
19
+ ## 0.5.0
20
+
21
+ ### Minor Changes
22
+
23
+ - 7ff69ab: feat(es-ops): Phase 1 — file-based seed-migrations
24
+
25
+ Neues first-class Operations-Pattern fürs Framework. Liefert `seed-migrations`
26
+ als drizzle-migrate-equivalent für Event-Sourcing-Aggregate-Updates die
27
+ idempotent-Seeder nicht erfassen können (z.B. „Member hat schon eine
28
+ Rolle, aber jetzt soll noch eine dazukommen").
29
+
30
+ Public-API:
31
+
32
+ - `runProdApp({ seedsDir })` — Auto-apply pending Migrations beim Boot
33
+ - `SeedMigration`-Interface (default-Export einer `seeds/<id>.ts`-File)
34
+ - `SeedMigrationContext` mit `systemWriteAs` (ruft existing write-handler
35
+ als System-User) + Read-Helpers (`findUserByEmail`,
36
+ `findMembershipsOfUser`, `findTenants`)
37
+ - CLI: `bunx kumiko ops seed:new|status|apply`
38
+ - Tracking-Table `kumiko_es_operations` mit `operation_type`-Discriminator
39
+ (vorbereitet auf Phase 2+ Operations: projection-rebuild, event-replay,
40
+ stream-migration, ...)
41
+ - Env-Flags: `KUMIKO_SKIP_ES_OPS=1` (alle skippen für Recovery),
42
+ `KUMIKO_SKIP_ES_OPS_<ID>=1` (einzelne kaputte skippen)
43
+
44
+ Garantien: single-run via tracking, atomic via per-migration-Tx,
45
+ chronological order via filename-prefix, fail-stop bei Failure (kein
46
+ Partial-Apply), ES-konform via Handler-Dispatch.
47
+
48
+ Sub-path-Export: `@cosmicdrift/kumiko-framework/es-ops`
49
+
50
+ Plan-Doc: `kumiko-platform/docs/plans/features/es-ops.md`
51
+ Recipe: `samples/recipes/seed-migration/`
52
+ Driver-Use-Case: publicstatus admin-roles-drift (parallel-Branch
53
+ `feat/es-ops-driver-admin-roles`).
54
+
55
+ Phase 2+ skizziert + offen markiert — Implementation pro Use-Case.
56
+
3
57
  ## 0.4.1
4
58
 
5
59
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
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>",
@@ -32,6 +32,10 @@
32
32
  "types": "./src/compliance/index.ts",
33
33
  "default": "./src/compliance/index.ts"
34
34
  },
35
+ "./es-ops": {
36
+ "types": "./src/es-ops/index.ts",
37
+ "default": "./src/es-ops/index.ts"
38
+ },
35
39
  "./engine": {
36
40
  "types": "./src/engine/index.ts",
37
41
  "default": "./src/engine/index.ts"
@@ -159,7 +163,7 @@
159
163
  "zod": "^4.4.3"
160
164
  },
161
165
  "devDependencies": {
162
- "@cosmicdrift/kumiko-dispatcher-live": "0.4.1",
166
+ "@cosmicdrift/kumiko-dispatcher-live": "0.5.1",
163
167
  "@types/uuid": "^11.0.0",
164
168
  "bun-types": "^1.3.13",
165
169
  "drizzle-kit": "^0.31.10",
@@ -0,0 +1,119 @@
1
+ # es-ops
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.
4
+
5
+ ## Quick API
6
+
7
+ ```ts
8
+ import { runProdApp } from "@cosmicdrift/kumiko-dev-server";
9
+
10
+ await runProdApp({
11
+ features: [...],
12
+ seedsDir: "./seeds", // ← einzige Setup-Pflicht
13
+ // ...
14
+ });
15
+ ```
16
+
17
+ > **Phase 1 Scope:** `runProdApp`-only. `runDevApp`-Integration folgt in Phase 1.5 (braucht separaten Dispatcher-Bootstrap, der stack-typed ist). Für lokale Tests: laufe `bunx kumiko ops seed:status` gegen die Dev-DB um pending seeds zu sehen, dann `runProdApp` lokal mit DEV-Connection für Apply.
18
+
19
+ ```ts
20
+ // seeds/2026-05-20-fix-admin-roles.ts
21
+ import type { SeedMigration } from "@cosmicdrift/kumiko-framework/es-ops";
22
+
23
+ export default {
24
+ description: "ergänze TenantAdmin-Rolle für admin@example.com",
25
+ run: async (ctx) => {
26
+ const admin = await ctx.findUserByEmail("admin@example.com");
27
+ if (!admin) return;
28
+ for (const m of await ctx.findMembershipsOfUser(admin.id)) {
29
+ 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
+ });
35
+ }
36
+ },
37
+ } satisfies SeedMigration;
38
+ ```
39
+
40
+ ## CLI
41
+
42
+ ```bash
43
+ bunx kumiko ops seed:new <slug> # scaffold seeds/<date>-<slug>.ts
44
+ bunx kumiko ops seed:status # was applied, was pending
45
+ bunx kumiko ops seed:apply [--dry-run] # pending applien (CLI-Pfad in Phase 1.5)
46
+ ```
47
+
48
+ ## Garantien
49
+
50
+ | Garantie | Wie |
51
+ |---|---|
52
+ | **Single-Run** | Marker in `kumiko_es_operations` + `pg_advisory_xact_lock` sequentialisiert Multi-Replica-Boots |
53
+ | **Marker-Atomicity** | Runner-Tx + Re-Check inside lock → Marker reflektiert "Run wurde wirklich attempted" |
54
+ | **Order** | File-name = chronologische ID; Failure stoppt alle pending |
55
+ | **ES-konform** | `systemWriteAs` ruft existing Handler → Events landen im Store |
56
+ | **Recovery** | `skippable: true` + `KUMIKO_SKIP_ES_OPS_<ID>=1` env-flag für Notfall-Skip |
57
+ | **Boot-skip** | `KUMIKO_SKIP_ES_OPS=1` env-var skipped alle pending (Debug-Boots) |
58
+
59
+ ### Was NICHT garantiert ist
60
+
61
+ **Seed-Body ist NICHT atomic vs. den Marker.** `systemWriteAs` läuft durch den App-Dispatcher mit dessen eigener Tx-Verwaltung (separat von der Runner-Tx). Wenn ein Seed `systemWriteAs` 5× erfolgreich aufruft und dann throws, sind die 5 Events **committed**, der Marker aber **nicht** geschrieben. Beim nächsten Boot retried der Runner — Seeds müssen daher **idempotent** sein:
62
+
63
+ ```ts
64
+ // Gut: skip wenn schon korrigiert
65
+ for (const m of memberships) {
66
+ if (m.roles.includes("TenantAdmin")) continue;
67
+ await ctx.systemWriteAs(...);
68
+ }
69
+
70
+ // Schlecht: jeder Re-Run dupliziert
71
+ for (const m of memberships) {
72
+ await ctx.systemWriteAs("create-something-new", ...); // double on retry!
73
+ }
74
+ ```
75
+
76
+ Die meisten realen Seeds sind natürlich idempotent (existing-Lookup → conditional-write). Volle End-to-End-Atomicity (write + marker im gleichen Tx) ist als Phase 1.5 vorgesehen — braucht Refactor wie der Dispatcher die outer-Tx übernimmt.
77
+
78
+ ## Architektur
79
+
80
+ `packages/framework/src/es-ops/` enthält:
81
+
82
+ | File | Zweck |
83
+ |---|---|
84
+ | `operations-schema.ts` | `kumiko_es_operations` table-definition + `createEsOperationsTable` helper |
85
+ | `types.ts` | `SeedMigration` + `SeedMigrationContext` Public-API |
86
+ | `runner.ts` | `runPendingSeedMigrations` — Diff + Tx + Marker |
87
+ | `context.ts` | `createSeedMigrationContext` — Read-Helpers + `systemWriteAs` |
88
+ | `index.ts` | barrel-export |
89
+
90
+ Tabellen-Schema:
91
+
92
+ ```sql
93
+ CREATE TABLE kumiko_es_operations (
94
+ id TEXT PRIMARY KEY,
95
+ operation_type TEXT NOT NULL, -- "seed-migration" | (Phase 2+)
96
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
97
+ duration_ms INTEGER NOT NULL,
98
+ applied_by TEXT NOT NULL, -- "boot" | "cli" | "ci-pipeline"
99
+ notes TEXT
100
+ );
101
+ ```
102
+
103
+ ## Phase 2+
104
+
105
+ `operation_type`-Discriminator lässt zukünftige Operations dieselbe Tabelle + dasselbe CLI-Pattern nutzen:
106
+
107
+ - `projection-rebuild` — TRUNCATE read_* + Replay aus Events
108
+ - `event-replay` — Notification re-send ohne DB-Write
109
+ - `event-backfill` — Missing-Events für Pre-ES-Daten
110
+ - `stream-migration` — Aggregate-Stream-Tenant-Move (Sysadmin-Bug)
111
+ - `aggregate-rebuild` — Snapshot-Refresh
112
+
113
+ Implementation: **on demand** (siehe `kumiko-platform/docs/plans/features/es-ops.md`).
114
+
115
+ ## Driver-Use-Case
116
+
117
+ publicstatus' admin-Member hatte initial `roles: ["Admin"]`. Sprint Role-Naming-Drift ergänzte „TenantAdmin", aber der idempotent-Seeder skipped existing Memberships → DB-Drift. Phase 1 löst genau diese Klasse von Bugs.
118
+
119
+ Siehe Sample: `samples/recipes/seed-migration/`.
@@ -0,0 +1,267 @@
1
+ // Integration-Tests für SeedMigrationContext-Read-Helpers + skippable-
2
+ // integration. Verifizieren dass:
3
+ // - findUserByEmail liest read_users korrekt (typed result-cast)
4
+ // - findMembershipsOfUser parst JSON-encoded roles korrekt
5
+ // - findTenants returnt sorted-by-inserted_at
6
+ // - skippable + env-flag: kein marker geschrieben (gegen real-DB)
7
+ // - ctx.db ist DbRunner (Escape-Hatch für direct-reads)
8
+ //
9
+ // Schema-stubs sind raw CREATE TABLE, weil das vollständige user/tenant-
10
+ // Feature in den Tests zu schwer wäre — wir testen nur den Read-Helper-
11
+ // Layer, nicht die volle Event-Store-Pipeline.
12
+
13
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { sql } from "drizzle-orm";
17
+ import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
18
+ import { createTestDb, type TestDb } from "../../stack";
19
+ import { createSeedMigrationContext } from "../context";
20
+ import { createEsOperationsTable, esOperationsTable } from "../operations-schema";
21
+ import { runPendingSeedMigrations } from "../runner";
22
+
23
+ let testDb: TestDb;
24
+
25
+ beforeAll(async () => {
26
+ testDb = await createTestDb();
27
+ await createEsOperationsTable(testDb.db);
28
+
29
+ // Minimal-Schema-Stubs für die 3 Read-Tabellen die context.ts liest.
30
+ // Spalten matchen production (siehe Sysadmin-Stream-Tenant-Bug Memory).
31
+ await testDb.db.execute(sql`
32
+ CREATE TABLE IF NOT EXISTS read_users (
33
+ id uuid PRIMARY KEY,
34
+ email text NOT NULL,
35
+ tenant_id uuid NOT NULL
36
+ );
37
+ CREATE TABLE IF NOT EXISTS read_tenant_memberships (
38
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
39
+ user_id text NOT NULL,
40
+ tenant_id uuid NOT NULL,
41
+ roles text NOT NULL
42
+ );
43
+ CREATE TABLE IF NOT EXISTS read_tenants (
44
+ id uuid PRIMARY KEY,
45
+ name text NOT NULL,
46
+ tenant_key text NOT NULL,
47
+ inserted_at timestamptz NOT NULL DEFAULT now()
48
+ );
49
+ `);
50
+ });
51
+
52
+ afterAll(async () => {
53
+ await testDb.cleanup();
54
+ });
55
+
56
+ beforeEach(async () => {
57
+ await testDb.db.execute(sql`
58
+ TRUNCATE kumiko_es_operations, read_users, read_tenant_memberships, read_tenants
59
+ `);
60
+ });
61
+
62
+ function makeMockDispatcher() {
63
+ return {
64
+ write: vi.fn(async () => ({ isSuccess: true as const, data: {} })),
65
+ query: vi.fn(),
66
+ command: vi.fn(),
67
+ batch: vi.fn(),
68
+ resolveAuthClaims: vi.fn(),
69
+ };
70
+ }
71
+
72
+ function makeTempSeedsDir(files: readonly { name: string; content: string }[]): string {
73
+ const dir = mkdtempSync(join(tmpdir(), "es-ops-ctx-integ-"));
74
+ for (const f of files) writeFileSync(join(dir, f.name), f.content);
75
+ return dir;
76
+ }
77
+
78
+ // --- Read-Helpers --------------------------------------------------------
79
+
80
+ describe("SeedMigrationContext.findUserByEmail (integration)", () => {
81
+ test("liest existing user-row korrekt + maps tenant_id → tenantId", async () => {
82
+ const userId = "01900000-0000-7000-8000-000000000001";
83
+ const tenantId = "00000000-0000-4000-8000-000000000099";
84
+ await testDb.db.execute(sql`
85
+ INSERT INTO read_users (id, email, tenant_id)
86
+ VALUES (${userId}::uuid, 'admin@example.com', ${tenantId}::uuid)
87
+ `);
88
+
89
+ const ctx = createSeedMigrationContext({
90
+ dispatcher: makeMockDispatcher() as never,
91
+ dbRunner: testDb.db,
92
+ });
93
+ const found = await ctx.findUserByEmail("admin@example.com");
94
+ expect(found).toEqual({ id: userId, email: "admin@example.com", tenantId });
95
+ });
96
+
97
+ test("liefert null bei unknown email (kein throw)", async () => {
98
+ const ctx = createSeedMigrationContext({
99
+ dispatcher: makeMockDispatcher() as never,
100
+ dbRunner: testDb.db,
101
+ });
102
+ const found = await ctx.findUserByEmail("does-not-exist@example.com");
103
+ expect(found).toBeNull();
104
+ });
105
+ });
106
+
107
+ describe("SeedMigrationContext.findMembershipsOfUser (integration)", () => {
108
+ test("parst JSON-encoded roles-Spalte zu string[]", async () => {
109
+ const userId = "01900000-0000-7000-8000-000000000001";
110
+ const tenantId1 = "00000000-0000-4000-8000-000000000001";
111
+ 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
+ `);
117
+
118
+ const ctx = createSeedMigrationContext({
119
+ dispatcher: makeMockDispatcher() as never,
120
+ dbRunner: testDb.db,
121
+ });
122
+ const memberships = await ctx.findMembershipsOfUser(userId);
123
+ expect(memberships).toHaveLength(2);
124
+
125
+ const m1 = memberships.find((m) => m.tenantId === tenantId1);
126
+ expect(m1?.roles).toEqual(["Admin", "TenantAdmin"]);
127
+
128
+ const m2 = memberships.find((m) => m.tenantId === tenantId2);
129
+ expect(m2?.roles).toEqual(["User"]);
130
+ });
131
+
132
+ test("malformed roles-JSON → leeres Array (defensive, no throw)", async () => {
133
+ // Defensive: wenn ein corrupted row kommt, soll der Seed nicht
134
+ // explodieren — kann selbst entscheiden was zu tun ist.
135
+ 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
+ `);
140
+ const ctx = createSeedMigrationContext({
141
+ dispatcher: makeMockDispatcher() as never,
142
+ dbRunner: testDb.db,
143
+ });
144
+ const memberships = await ctx.findMembershipsOfUser(userId);
145
+ expect(memberships[0]?.roles).toEqual([]);
146
+ });
147
+
148
+ test("liefert leere Liste bei userId ohne memberships", async () => {
149
+ const ctx = createSeedMigrationContext({
150
+ dispatcher: makeMockDispatcher() as never,
151
+ dbRunner: testDb.db,
152
+ });
153
+ const memberships = await ctx.findMembershipsOfUser("01900000-0000-7000-8000-000000000099");
154
+ expect(memberships).toEqual([]);
155
+ });
156
+ });
157
+
158
+ describe("SeedMigrationContext.findTenants (integration)", () => {
159
+ test("returnt alle Tenants sortiert nach inserted_at", async () => {
160
+ await testDb.db.execute(sql`
161
+ INSERT INTO read_tenants (id, name, tenant_key, inserted_at) VALUES
162
+ ('00000000-0000-4000-8000-000000000002'::uuid, 'Beta', 'beta', '2026-01-02'),
163
+ ('00000000-0000-4000-8000-000000000001'::uuid, 'Alpha', 'alpha', '2026-01-01')
164
+ `);
165
+ const ctx = createSeedMigrationContext({
166
+ dispatcher: makeMockDispatcher() as never,
167
+ dbRunner: testDb.db,
168
+ });
169
+ const tenants = await ctx.findTenants();
170
+ expect(tenants.map((t) => t.tenantKey)).toEqual(["alpha", "beta"]); // ORDER BY inserted_at ASC
171
+ expect(tenants[0]).toMatchObject({ name: "Alpha", tenantKey: "alpha" });
172
+ });
173
+ });
174
+
175
+ // --- skippable + env-flag (Integration) ---------------------------------
176
+
177
+ describe("runPendingSeedMigrations: skippable + env-flag (integration)", () => {
178
+ test("skippable=true + env-flag='1' → kein Marker in DB", async () => {
179
+ const dir = makeTempSeedsDir([
180
+ {
181
+ name: "2026-05-20-skip-via-env.ts",
182
+ content: `
183
+ export default {
184
+ description: "skippable seed",
185
+ skippable: true,
186
+ run: async () => {
187
+ throw new Error("MUST NOT BE CALLED — env-flag should skip me");
188
+ },
189
+ };
190
+ `,
191
+ },
192
+ ]);
193
+ const envKey = "KUMIKO_SKIP_ES_OPS_2026_05_20_SKIP_VIA_ENV";
194
+ process.env[envKey] = "1";
195
+ try {
196
+ const r = await runPendingSeedMigrations({
197
+ db: testDb.db,
198
+ seedsDir: dir,
199
+ appliedBy: "boot",
200
+ createContext: (dbRunner) =>
201
+ createSeedMigrationContext({ dispatcher: makeMockDispatcher() as never, dbRunner }),
202
+ logger: () => {},
203
+ });
204
+ expect(r.appliedIds).toEqual([]);
205
+ expect(r.skippedIds).toEqual(["2026-05-20-skip-via-env"]);
206
+
207
+ // Kritisch: KEIN Marker — beim nächsten Boot ohne env-flag würde
208
+ // der Seed dann tatsächlich laufen.
209
+ const markers = await testDb.db.select().from(esOperationsTable);
210
+ expect(markers).toHaveLength(0);
211
+ } finally {
212
+ delete process.env[envKey];
213
+ rmSync(dir, { recursive: true, force: true });
214
+ }
215
+ });
216
+
217
+ test("skippable=true OHNE env-flag → läuft normal", async () => {
218
+ const dir = makeTempSeedsDir([
219
+ {
220
+ name: "2026-05-20-skippable-but-no-flag.ts",
221
+ content: `
222
+ export default {
223
+ description: "skippable seed, kein env-flag gesetzt",
224
+ skippable: true,
225
+ run: async () => {},
226
+ };
227
+ `,
228
+ },
229
+ ]);
230
+ try {
231
+ const r = await runPendingSeedMigrations({
232
+ db: testDb.db,
233
+ seedsDir: dir,
234
+ appliedBy: "boot",
235
+ createContext: (dbRunner) =>
236
+ createSeedMigrationContext({ dispatcher: makeMockDispatcher() as never, dbRunner }),
237
+ logger: () => {},
238
+ });
239
+ expect(r.appliedIds).toEqual(["2026-05-20-skippable-but-no-flag"]);
240
+ expect(r.skippedIds).toEqual([]);
241
+
242
+ const markers = await testDb.db.select().from(esOperationsTable);
243
+ expect(markers).toHaveLength(1);
244
+ } finally {
245
+ rmSync(dir, { recursive: true, force: true });
246
+ }
247
+ });
248
+ });
249
+
250
+ // --- ctx.db Escape-Hatch (Integration) -----------------------------------
251
+
252
+ describe("SeedMigrationContext.db (escape-hatch, integration)", () => {
253
+ test("ctx.db kann für eigene Lookups genutzt werden (read-only)", async () => {
254
+ await testDb.db.execute(sql`
255
+ INSERT INTO read_tenants (id, name, tenant_key) VALUES
256
+ ('00000000-0000-4000-8000-000000000007'::uuid, 'Lucky', 'lucky')
257
+ `);
258
+ const ctx = createSeedMigrationContext({
259
+ dispatcher: makeMockDispatcher() as never,
260
+ dbRunner: testDb.db,
261
+ });
262
+ const rows = (await ctx.db.execute(
263
+ sql`SELECT name FROM read_tenants WHERE tenant_key = 'lucky'`,
264
+ )) as unknown as readonly { name: string }[];
265
+ expect(rows[0]?.name).toBe("Lucky");
266
+ });
267
+ });