@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.
@@ -0,0 +1,34 @@
1
+ // es-ops — ES-Operations-Pattern für Kumiko-Apps.
2
+ //
3
+ // Phase 1: seed-migrations. Phase 2+ docken an dieselbe Infra an
4
+ // (kumiko_es_operations-Table mit operation_type-Discriminator).
5
+ //
6
+ // App-Author-API:
7
+ // - SeedMigration: default-export-Typ einer seed-file
8
+ // - runProdApp({ seedsDir }): Framework runs pending bei Boot
9
+ // - bunx kumiko ops seed:new|status|apply (CLI)
10
+ //
11
+ // Plan-Doc: kumiko-platform/docs/plans/features/es-ops.md
12
+
13
+ export {
14
+ type CreateSeedMigrationContextArgs,
15
+ createSeedMigrationContext,
16
+ } from "./context";
17
+ export {
18
+ createEsOperationsTable,
19
+ type EsOperationAppliedBy,
20
+ type EsOperationType,
21
+ esOperationsTable,
22
+ } from "./operations-schema";
23
+ export {
24
+ type RunPendingSeedMigrationsArgs,
25
+ type RunPendingSeedMigrationsResult,
26
+ runPendingSeedMigrations,
27
+ } from "./runner";
28
+ export type {
29
+ SeedMembershipRow,
30
+ SeedMigration,
31
+ SeedMigrationContext,
32
+ SeedTenantRow,
33
+ SeedUserRow,
34
+ } from "./types";
@@ -0,0 +1,57 @@
1
+ // Tracking-Table für ES-Operations (Phase 1: seed-migrations; Phase 2+:
2
+ // projection-rebuild, event-replay, stream-migration, ... — siehe
3
+ // kumiko-platform/docs/plans/features/es-ops.md).
4
+ //
5
+ // File-ID-Tracking analog drizzle-kit: filename ist die ID, applied-set
6
+ // liegt in dieser Tabelle. Pending = files-on-disk MINUS applied-set.
7
+ //
8
+ // operation_type-Discriminator lässt Phase 2+ dieselbe Tabelle nutzen,
9
+ // kein Schema-Sprawl. CLI-Status filtert per type:
10
+ // bunx kumiko ops seed:status → operation_type = "seed-migration"
11
+ // bunx kumiko ops projection:status → operation_type = "projection-rebuild"
12
+
13
+ import { sql } from "drizzle-orm";
14
+ import { type DbConnection, tableExists } from "../db";
15
+ import { index, integer, table as pgTable, text, timestamp } from "../db/dialect";
16
+ import { unsafePushTables } from "../stack";
17
+
18
+ export type EsOperationType = "seed-migration";
19
+ // Phase 2+ extensions — append here when implemented:
20
+ // | "projection-rebuild"
21
+ // | "event-replay"
22
+ // | "stream-migration"
23
+ // | "aggregate-rebuild"
24
+ // | "archived-stream-purge"
25
+
26
+ export type EsOperationAppliedBy = "boot" | "cli" | "ci-pipeline";
27
+
28
+ export const esOperationsTable = pgTable(
29
+ "kumiko_es_operations",
30
+ {
31
+ // Filename without extension serves as ID. Chronologically sortable
32
+ // (date-prefix convention), human-meaningful, no separate hash needed.
33
+ // Renaming a file = different ID = re-run; intentional (drizzle parity).
34
+ id: text("id").primaryKey(),
35
+ operationType: text("operation_type").notNull().$type<EsOperationType>(),
36
+ appliedAt: timestamp("applied_at", { withTimezone: true, mode: "string" })
37
+ .notNull()
38
+ .default(sql`now()`),
39
+ durationMs: integer("duration_ms").notNull(),
40
+ // Trace: came from boot-time auto-apply, explicit CLI, or CI step.
41
+ // Helps when forensics ask "wer hat das wann angestoßen".
42
+ appliedBy: text("applied_by").notNull().$type<EsOperationAppliedBy>(),
43
+ // Optional human-readable annotation — surfaced in `ops <op>:status`.
44
+ notes: text("notes"),
45
+ },
46
+ (t) => ({
47
+ typeIdx: index("kumiko_es_operations_type_idx").on(t.operationType),
48
+ }),
49
+ );
50
+
51
+ // Convenience for tests + boot-time setup (idempotent). Mirrors the
52
+ // createEventsTable pattern in event-store/events-schema.ts.
53
+ export async function createEsOperationsTable(db: DbConnection): Promise<void> {
54
+ if (!(await tableExists(db, "public.kumiko_es_operations"))) {
55
+ await unsafePushTables(db, { kumikoEsOperations: esOperationsTable });
56
+ }
57
+ }
@@ -0,0 +1,213 @@
1
+ // Runner für pending seed-migrations beim Boot.
2
+ //
3
+ // Flow:
4
+ // 1. List seeds/<*.ts> sorted ascending (filename = chronologische ID)
5
+ // 2. SELECT id FROM kumiko_es_operations WHERE operation_type='seed-migration'
6
+ // 3. pending = files \ applied
7
+ // 4. Für jeden pending in Order:
8
+ // a. dynamic import → default-export (SeedMigration)
9
+ // b. wenn migration.skippable && env[KUMIKO_SKIP_ES_OPS_<sanitized>]='1':
10
+ // → console.log + continue (kein Marker geschrieben)
11
+ // c. Tx start
12
+ // d. await migration.run(ctx)
13
+ // e. INSERT marker mit duration_ms + appliedBy
14
+ // f. Tx commit
15
+ // 5. On any failure: Tx rollback + console.error + throw
16
+ // → App-Boot bricht ab. Operator muss Failure fixen + retry.
17
+ //
18
+ // Skippable-Pattern: seed.skippable=true erlaubt im Notfall ein
19
+ // `KUMIKO_SKIP_ES_OPS_<id>=1` env-flag um eine kaputte Migration zu
20
+ // überspringen ohne ihr Code touchen zu müssen. NICHT als
21
+ // Standard-Workflow — wirklich Notfall.
22
+
23
+ import { readdir } from "node:fs/promises";
24
+ import path from "node:path";
25
+ import { eq, sql } from "drizzle-orm";
26
+ import type { DbConnection, DbRunner } from "../db";
27
+ import { esOperationsTable } from "./operations-schema";
28
+ import type { EsOperationAppliedBy, SeedMigration, SeedMigrationContext } from "./types";
29
+
30
+ // Stabiler 32-bit-Integer-Lock-Key für pg_advisory_xact_lock. Multi-Replica-
31
+ // Boots gegen den selben Stack greifen denselben Lock — sequentialisiert
32
+ // die Migration ohne dass jedes Pod alle pending Files parallel anwendet.
33
+ // Ohne Lock: Pod A + Pod B sehen beide dieselbe pending-Liste → beide
34
+ // laufen migration.run() → events DOUBLED, marker-unique-constraint
35
+ // catched zu spät (nur den Marker, nicht die schon-committed Events).
36
+ const ES_OPS_LOCK_KEY = 0x65_73_6f_70; // 'esop' als hex
37
+
38
+ export type RunPendingSeedMigrationsArgs = {
39
+ readonly db: DbConnection;
40
+ /** Absoluter Pfad zum seeds-Directory (typically <appRoot>/seeds). */
41
+ readonly seedsDir: string;
42
+ /** Factory die den Context für jede einzelne seed-Migration erzeugt.
43
+ * Caller bekommt einen DbRunner (Connection ODER aktive Tx) — Runner
44
+ * ruft das pro-migration im tx-Scope auf. */
45
+ readonly createContext: (dbRunner: DbRunner) => SeedMigrationContext;
46
+ /** Trace-marker: boot | cli | ci-pipeline. Landet in applied_by. */
47
+ readonly appliedBy: EsOperationAppliedBy;
48
+ /** Optional log-prefix override, default "[es-ops/seed-migration]". */
49
+ readonly logger?: (line: string) => void;
50
+ };
51
+
52
+ export type RunPendingSeedMigrationsResult = {
53
+ readonly appliedIds: readonly string[];
54
+ readonly skippedIds: readonly string[];
55
+ };
56
+
57
+ const DEFAULT_LOGGER = (line: string): void => {
58
+ // biome-ignore lint/suspicious/noConsole: boot-time-output, kein Logger-Inject hier
59
+ console.log(line);
60
+ };
61
+
62
+ const LOG_PREFIX = "[es-ops/seed-migration]";
63
+
64
+ export async function runPendingSeedMigrations(
65
+ args: RunPendingSeedMigrationsArgs,
66
+ ): Promise<RunPendingSeedMigrationsResult> {
67
+ const log = args.logger ?? DEFAULT_LOGGER;
68
+
69
+ const onDisk = await listSeedFiles(args.seedsDir);
70
+ if (onDisk.length === 0) {
71
+ log(`${LOG_PREFIX} no seed files in ${args.seedsDir} — skipping`);
72
+ return { appliedIds: [], skippedIds: [] };
73
+ }
74
+
75
+ const applied = await loadAppliedIds(args.db);
76
+ const pending = onDisk.filter((entry) => !applied.has(entry.id));
77
+ if (pending.length === 0) {
78
+ log(`${LOG_PREFIX} ${onDisk.length} on disk, all applied — nothing to do`);
79
+ return { appliedIds: [], skippedIds: [] };
80
+ }
81
+
82
+ log(`${LOG_PREFIX} ${pending.length}/${onDisk.length} pending`);
83
+
84
+ const appliedIds: string[] = [];
85
+ const skippedIds: string[] = [];
86
+
87
+ for (const entry of pending) {
88
+ const migration = await loadSeedModule(entry.filePath);
89
+
90
+ const envFlag = `KUMIKO_SKIP_ES_OPS_${sanitizeForEnv(entry.id)}`;
91
+ if (migration.skippable === true && process.env[envFlag] === "1") {
92
+ log(`${LOG_PREFIX} skip "${entry.id}" — ${envFlag}=1`);
93
+ skippedIds.push(entry.id);
94
+ continue;
95
+ }
96
+
97
+ const start = Date.now();
98
+ try {
99
+ await args.db.transaction(async (tx) => {
100
+ // Advisory-Lock: sequentialisiert Multi-Replica-Boots. Zweiter
101
+ // Pod blockt bis erster fertig ist, dann re-checked sein
102
+ // applied-set (außerhalb dieser Funktion in nächster Iteration)
103
+ // und findet den Marker → skip. Lock wird beim Tx-Commit
104
+ // automatisch released (xact-scope).
105
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(${ES_OPS_LOCK_KEY})`);
106
+
107
+ // Re-check applied-set INSIDE Tx + Lock — verhindert Race
108
+ // wo Pod-A schon committed hat während Pod-B vor dem Lock
109
+ // war. Sonst würde Pod-B die Migration nochmal ausführen.
110
+ const reCheck = (await tx.execute(
111
+ sql`SELECT 1 FROM kumiko_es_operations WHERE id = ${entry.id} LIMIT 1`,
112
+ )) as unknown as readonly unknown[];
113
+ if (reCheck.length > 0) {
114
+ log(`${LOG_PREFIX} race-skip "${entry.id}" — applied by parallel boot`);
115
+ // skip: race-detected — other replica committed marker between
116
+ // loadAppliedIds() and this tx; their run already covered the work.
117
+ return;
118
+ }
119
+
120
+ const ctx = args.createContext(tx);
121
+ await migration.run(ctx);
122
+ await tx.insert(esOperationsTable).values({
123
+ id: entry.id,
124
+ operationType: "seed-migration",
125
+ durationMs: Date.now() - start,
126
+ appliedBy: args.appliedBy,
127
+ notes: migration.description,
128
+ });
129
+ });
130
+ const elapsed = Date.now() - start;
131
+ log(`${LOG_PREFIX} ✓ ${entry.id} (${elapsed}ms) — ${migration.description}`);
132
+ appliedIds.push(entry.id);
133
+ } catch (err) {
134
+ const elapsed = Date.now() - start;
135
+ log(`${LOG_PREFIX} ✗ ${entry.id} (${elapsed}ms) — ${stringifyError(err)}`);
136
+ log(
137
+ `${LOG_PREFIX} ABORT — ${pending.length - appliedIds.length - skippedIds.length - 1} pending migrations were NOT attempted`,
138
+ );
139
+ throw err;
140
+ }
141
+ }
142
+
143
+ return { appliedIds, skippedIds };
144
+ }
145
+
146
+ type SeedFileEntry = { readonly id: string; readonly filePath: string };
147
+
148
+ async function listSeedFiles(seedsDir: string): Promise<readonly SeedFileEntry[]> {
149
+ let entries: string[];
150
+ try {
151
+ entries = await readdir(seedsDir);
152
+ } catch {
153
+ // Directory doesn't exist → treat as empty. App ohne seeds-Dir ist
154
+ // ein valider Zustand (no-op).
155
+ return [];
156
+ }
157
+ return entries
158
+ .filter((name) => name.endsWith(".ts") || name.endsWith(".mts") || name.endsWith(".js"))
159
+ .filter((name) => !name.startsWith("_") && !name.startsWith("."))
160
+ .sort() // filename = chronologische ID (date-prefix-convention)
161
+ .map((name) => ({
162
+ id: name.replace(/\.(ts|mts|js)$/, ""),
163
+ // resolve, nicht join: Bun's await import() braucht absolute Pfade.
164
+ // Wenn seedsDir relativ ist (z.B. "./seeds" aus runProdApp-Option),
165
+ // wäre der join-Pfad auch relativ → Bun's import-resolver such
166
+ // relativ zum runner.ts-Modul, nicht zu process.cwd() → fail mit
167
+ // "Cannot find module 'seeds/...' from '<runner-path>'".
168
+ filePath: path.resolve(seedsDir, name),
169
+ }));
170
+ }
171
+
172
+ async function loadAppliedIds(db: DbConnection): Promise<Set<string>> {
173
+ const rows = await db
174
+ .select({ id: esOperationsTable.id })
175
+ .from(esOperationsTable)
176
+ .where(eq(esOperationsTable.operationType, "seed-migration"));
177
+ return new Set(rows.map((r: { id: string }) => r.id));
178
+ }
179
+
180
+ async function loadSeedModule(filePath: string): Promise<SeedMigration> {
181
+ // Bun + Node both honor dynamic import on absolute paths. The seed-files
182
+ // must export their SeedMigration as default.
183
+ const mod = await import(filePath);
184
+ const migration: unknown = mod.default;
185
+ if (!isSeedMigration(migration)) {
186
+ throw new Error(
187
+ `[es-ops] seed file ${filePath} must export a SeedMigration as default ` +
188
+ `(object with { description: string, run: (ctx) => Promise<void> })`,
189
+ );
190
+ }
191
+ return migration;
192
+ }
193
+
194
+ function isSeedMigration(value: unknown): value is SeedMigration {
195
+ if (typeof value !== "object" || value === null) return false;
196
+ // @cast-boundary generic-record — narrowing unknown to property-bag for shape-check
197
+ const v = value as Partial<SeedMigration>;
198
+ return typeof v.description === "string" && typeof v.run === "function";
199
+ }
200
+
201
+ // "2026-05-20-fix-admin-roles" → "2026_05_20_FIX_ADMIN_ROLES"
202
+ function sanitizeForEnv(id: string): string {
203
+ return id.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
204
+ }
205
+
206
+ function stringifyError(err: unknown): string {
207
+ if (err instanceof Error) return `${err.name}: ${err.message}`;
208
+ try {
209
+ return JSON.stringify(err);
210
+ } catch {
211
+ return String(err);
212
+ }
213
+ }
@@ -0,0 +1,85 @@
1
+ // Public API für seed-migrations. Files in /seeds/<date>-<slug>.ts
2
+ // exportieren ein default-Object dieses Typs. Runner ruft `run(ctx)` in
3
+ // chronologischer Reihenfolge auf, einmal pro App-Boot (track in
4
+ // kumiko_es_operations).
5
+ //
6
+ // API-Stabilität: Phase 1 ist "minimal-viable". Helper am Context
7
+ // (findUserByEmail, etc.) wachsen on-demand — wenn eine real-Migration
8
+ // einen Lookup braucht den der Context nicht anbietet, fügen wir ihn
9
+ // dort hinzu. Das vermeidet "wir-bauen-alle-möglichen-Helper-spekulativ".
10
+ //
11
+ // ctx.db als Escape-Hatch ist erlaubt für READ-only lookups. WRITES
12
+ // IMMER via ctx.systemWriteAs damit Event-Store-Invariants bleiben
13
+ // (Source-of-Truth + Projection läuft automatisch).
14
+
15
+ import type { DbRunner } from "../db";
16
+ import type { WriteResult } from "../engine";
17
+
18
+ export type EsOperationAppliedBy = "boot" | "cli" | "ci-pipeline";
19
+
20
+ /** Default-Export einer seed-Migration-File. */
21
+ export type SeedMigration = {
22
+ /** Kurze Beschreibung was die Migration tut. Wird in kumiko_es_operations.notes
23
+ * und im `ops seed:status`-Output gezeigt. */
24
+ readonly description: string;
25
+
26
+ /** Optional: skippe diesen Seed wenn die env-var
27
+ * `KUMIKO_SKIP_ES_OPS_<sanitized-filename>=1` gesetzt ist (für Recovery /
28
+ * Debug-Boots). Default false = always-run-pending. */
29
+ readonly skippable?: boolean;
30
+
31
+ /** Hauptarbeit. ctx liefert systemWriteAs (Event-Store-konformer Pfad,
32
+ * bypassed Access-Checks via system-user) plus Read-Helpers.
33
+ *
34
+ * Throws → Marker NICHT geschrieben, App-Boot bricht ab, Retry bei
35
+ * nächstem Boot. Pro-Migration eigene Transaction; ein Failure stoppt
36
+ * alle nachfolgenden pending-Migrations (Order-Erhalt). */
37
+ readonly run: (ctx: SeedMigrationContext) => Promise<void>;
38
+ };
39
+
40
+ /** Read-shape eines User-Eintrags wie an Seed-Helpers exposed.
41
+ * Schmaler Subset von AuthUserRow — Seeds brauchen typischerweise nur
42
+ * diese Felder zur Identifikation. */
43
+ export type SeedUserRow = {
44
+ readonly id: string;
45
+ readonly email: string;
46
+ readonly tenantId: string;
47
+ };
48
+
49
+ /** Read-shape eines Membership-Eintrags wie an Seed-Helpers exposed. */
50
+ export type SeedMembershipRow = {
51
+ readonly userId: string;
52
+ readonly tenantId: string;
53
+ readonly roles: readonly string[];
54
+ };
55
+
56
+ /** Read-shape eines Tenant-Eintrags wie an Seed-Helpers exposed. */
57
+ export type SeedTenantRow = {
58
+ readonly id: string;
59
+ readonly name: string;
60
+ readonly tenantKey: string;
61
+ };
62
+
63
+ export type SeedMigrationContext = {
64
+ /** Event-Store-konformer Write via existing write-handler. System-User
65
+ * als Executor bypassed Access-Check (Standard-Seed-Pattern). Events
66
+ * haben inserted_by_id = SYSTEM_TENANT_ID-User → audit-fähig.
67
+ *
68
+ * Typ-Signatur folgt existing ctx.writeAs (payload als unknown) — Type-
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>;
72
+
73
+ // Read-helpers für die häufigsten Lookups. Wachsen on-demand —
74
+ // Phase 1 deckt den admin-roles-Driver-Use-Case ab; weitere Lookups
75
+ // kommen mit weiteren Seeds.
76
+ readonly findUserByEmail: (email: string) => Promise<SeedUserRow | null>;
77
+ readonly findMembershipsOfUser: (userId: string) => Promise<readonly SeedMembershipRow[]>;
78
+ readonly findTenants: () => Promise<readonly SeedTenantRow[]>;
79
+
80
+ /** Escape-Hatch — direkter DB-Zugang. Nur für READ-only Lookups die der
81
+ * Context nicht standard-mäßig anbietet. WRITES via systemWriteAs!
82
+ * Type ist DbRunner (Connection oder aktive Tx) weil der Runner den
83
+ * Context pro-Migration im Tx-Scope erzeugt. */
84
+ readonly db: DbRunner;
85
+ };