@cosmicdrift/kumiko-framework 0.4.0 → 0.5.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.
@@ -123,6 +123,12 @@ export type ConfigStoredRow = {
123
123
  readonly userId: string | null;
124
124
  };
125
125
 
126
+ // Extended row returned by ConfigResolver.getAllWithSource — includes the
127
+ // resolution source so the UI can display where each value came from.
128
+ export type ConfigStoredRowWithSource = ConfigStoredRow & {
129
+ readonly source: ConfigValueSource;
130
+ };
131
+
126
132
  // Which layer of the cascade actually produced a value. Emitted only by
127
133
  // `getWithSource` — regular `get` hides this to keep the hot-path simple.
128
134
  // Use-case: Ops-debugging ("warum ist mein Wert 50 und nicht 100?") without
@@ -141,6 +147,22 @@ export type ConfigValueWithSource = {
141
147
  readonly source: ConfigValueSource;
142
148
  };
143
149
 
150
+ /// Full cascade for a single config key — every level the resolver
151
+ /// walks through, with the winning level marked.
152
+ export type ConfigCascadeLevel = {
153
+ readonly label: string;
154
+ readonly value: string | number | boolean | undefined;
155
+ readonly source: ConfigValueSource;
156
+ readonly isActive: boolean;
157
+ readonly hasValue: boolean;
158
+ };
159
+
160
+ export type ConfigCascade = {
161
+ readonly value: string | number | boolean | undefined;
162
+ readonly source: ConfigValueSource;
163
+ readonly levels: readonly ConfigCascadeLevel[];
164
+ };
165
+
144
166
  // Minimal contract handlers (set/reset/values.query) call against the
145
167
  // resolver. Lives in the framework so SharedContextFields.configResolver
146
168
  // can drop the `unknown` cast — the concrete implementation in
@@ -176,6 +198,39 @@ export type ConfigResolver = {
176
198
  userId: string,
177
199
  db: DbConnection | TenantDb,
178
200
  ): Promise<ReadonlyMap<string, ConfigStoredRow>>;
201
+
202
+ // Like getAll() but also reports the resolution source for each key.
203
+ // Use when the caller needs to display the cascade origin (e.g. the
204
+ // values.query handler serves the UI's hierarchy badge). Hot-path
205
+ // callers should prefer getAll() for the narrower return type.
206
+ getAllWithSource(
207
+ tenantId: TenantId,
208
+ userId: string,
209
+ db: DbConnection | TenantDb,
210
+ ): Promise<ReadonlyMap<string, ConfigStoredRowWithSource>>;
211
+
212
+ // Returns ALL cascade levels for a single key — not just the winner.
213
+ // Each level shows its value (or undefined if not set) and whether it
214
+ // is the active/winning level. Levels are ordered by specificity
215
+ // descending (most specific first).
216
+ getCascade(
217
+ qualifiedKey: string,
218
+ keyDef: ConfigKeyDefinition,
219
+ tenantId: TenantId,
220
+ userId: string,
221
+ db: DbConnection | TenantDb,
222
+ ): Promise<ConfigCascade>;
223
+
224
+ // Batch variant: resolves cascades for N keys in one DB round-trip.
225
+ // keyDefs must contain definitions for every key in the keys array.
226
+ // Returns a map of qualifiedKey → ConfigCascade.
227
+ getCascadeBatch(
228
+ keys: readonly string[],
229
+ keyDefs: ReadonlyMap<string, ConfigKeyDefinition>,
230
+ tenantId: TenantId,
231
+ userId: string,
232
+ db: DbConnection | TenantDb,
233
+ ): Promise<ReadonlyMap<string, ConfigCascade>>;
179
234
  };
180
235
 
181
236
  // --- Process-Placement (runIn) ---
@@ -304,3 +359,44 @@ export type ReferenceDataDef = {
304
359
  readonly data: readonly Record<string, unknown>[];
305
360
  readonly upsertKey?: string | undefined;
306
361
  };
362
+
363
+ // --- Config Seeding ---
364
+
365
+ // A deploy-time default for a config key, written via the event-store
366
+ // executor at boot. Idempotent — if the stream already exists the executor
367
+ // returns version_conflict and seedConfigValues counts it as skipped.
368
+ // See config-seeding.md.
369
+ //
370
+ // `scope` is optional on the factory-output: createSeed leaves it unset
371
+ // (define-feature derives it from keyDef.scope). createSystemSeed /
372
+ // createTenantSeed / createUserSeed always set it explicitly.
373
+ //
374
+ // `tenantId` / `userId` semantics:
375
+ // - system scope: both stay undefined (row stored under SYSTEM_TENANT_ID).
376
+ // - tenant scope: tenantId optional (undefined → fallback row under
377
+ // SYSTEM_TENANT_ID, visible to all tenants via resolver cascade).
378
+ // - user scope: BOTH tenantId AND userId required, otherwise the resolver
379
+ // can never match the row (user-scope cascade looks up the user's actual
380
+ // tenantId, not SYSTEM_TENANT_ID).
381
+ export type ConfigSeedDef = {
382
+ readonly key: string; // fully-qualified config key name (set by define-feature)
383
+ readonly value: string | number | boolean;
384
+ readonly scope?: ConfigScope;
385
+ readonly tenantId?: string;
386
+ readonly userId?: string;
387
+ };
388
+
389
+ // Factory types for ergonomic seed creation in r.config({ seeds }).
390
+
391
+ export type CreateSeedOptions = {
392
+ readonly value: string | number | boolean;
393
+ };
394
+
395
+ export type CreateTenantSeedOptions = {
396
+ readonly tenantId?: string;
397
+ };
398
+
399
+ export type CreateUserSeedOptions = {
400
+ readonly tenantId: string;
401
+ readonly userId: string;
402
+ };
@@ -5,6 +5,7 @@ import type {
5
5
  ConfigKeyDefinition,
6
6
  ConfigKeyHandle,
7
7
  ConfigKeyType,
8
+ ConfigSeedDef,
8
9
  JobDefinition,
9
10
  JobHandlerFn,
10
11
  NotificationDataFn,
@@ -170,6 +171,7 @@ export type FeatureDefinition = {
170
171
  readonly hooks: HookMap;
171
172
  readonly entityHooks: EntityHookMap;
172
173
  readonly configKeys: Readonly<Record<string, ConfigKeyDefinition>>;
174
+ readonly configSeeds: readonly ConfigSeedDef[];
173
175
  readonly jobs: Readonly<Record<string, JobDefinition>>;
174
176
  readonly registrarExtensions: Readonly<Record<string, RegistrarExtensionDef>>;
175
177
  readonly extensionUsages: readonly RegistrarExtensionRegistration[];
@@ -356,8 +358,11 @@ export type FeatureRegistrar<TFeature extends string = string> = {
356
358
 
357
359
  // Returns a handle map keyed exactly like the input. Pass any handle to
358
360
  // `ctx.config(handle)` to get the value type narrowed by the key's `type`.
361
+ // Optional `seeds` declare boot-time system-rows that are written via the
362
+ // event-store executor — idempotent, skipped when the stream already exists.
359
363
  config<TKeys extends Readonly<Record<string, ConfigKeyDefinition<ConfigKeyType>>>>(definition: {
360
364
  readonly keys: TKeys;
365
+ readonly seeds?: Readonly<Record<string, ConfigSeedDef>>;
361
366
  }): { readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]> };
362
367
 
363
368
  job(name: string, options: Omit<JobDefinition, "name" | "handler">, handler: JobHandlerFn): void;
@@ -656,6 +661,7 @@ export type Registry = {
656
661
  getAllTranslations(): TranslationKeys;
657
662
  getConfigKey(qualifiedKey: string): ConfigKeyDefinition | undefined;
658
663
  getAllConfigKeys(): ReadonlyMap<string, ConfigKeyDefinition>;
664
+ getAllConfigSeeds(): readonly ConfigSeedDef[];
659
665
  // Feature-declared secrets, aggregated across all registered features.
660
666
  // Keyed by qualified name ("<feature>:<shortName>"). Used by the rotation
661
667
  // job (to iterate "known" secrets) and admin-UIs to list available keys.
@@ -13,6 +13,8 @@ export type {
13
13
  ConfigAccessor,
14
14
  ConfigAccessorFactory,
15
15
  ConfigBounds,
16
+ ConfigCascade,
17
+ ConfigCascadeLevel,
16
18
  ConfigComputedContext,
17
19
  ConfigComputedFn,
18
20
  ConfigDefinition,
@@ -21,10 +23,15 @@ export type {
21
23
  ConfigKeyHandle,
22
24
  ConfigKeyType,
23
25
  ConfigResolver,
26
+ ConfigSeedDef,
24
27
  ConfigStoredRow,
28
+ ConfigStoredRowWithSource,
25
29
  ConfigValue,
26
30
  ConfigValueSource,
27
31
  ConfigValueWithSource,
32
+ CreateSeedOptions,
33
+ CreateTenantSeedOptions,
34
+ CreateUserSeedOptions,
28
35
  JobDefinition,
29
36
  JobHandlerFn,
30
37
  JobRunIn,
@@ -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
+ });