@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 +54 -0
- package/package.json +6 -2
- package/src/es-ops/README.md +119 -0
- package/src/es-ops/__tests__/context.integration.ts +267 -0
- package/src/es-ops/__tests__/runner.integration.ts +363 -0
- package/src/es-ops/__tests__/runner.test.ts +192 -0
- package/src/es-ops/context.ts +113 -0
- package/src/es-ops/index.ts +34 -0
- package/src/es-ops/operations-schema.ts +57 -0
- package/src/es-ops/runner.ts +213 -0
- package/src/es-ops/types.ts +85 -0
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.
|
|
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.
|
|
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
|
+
});
|