@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.
- package/CHANGELOG.md +64 -0
- package/package.json +6 -2
- package/src/db/__tests__/config-seed.integration.ts +279 -0
- package/src/db/config-seed.ts +104 -0
- package/src/db/index.ts +1 -0
- package/src/engine/config-helpers.ts +53 -0
- package/src/engine/define-feature.ts +20 -0
- package/src/engine/index.ts +17 -1
- package/src/engine/registry.ts +7 -0
- package/src/engine/types/config.ts +96 -0
- package/src/engine/types/feature.ts +6 -0
- package/src/engine/types/index.ts +7 -0
- 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 +208 -0
- package/src/es-ops/types.ts +85 -0
|
@@ -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
|
+
});
|