@cosmicdrift/kumiko-framework 0.4.1 → 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 +38 -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 +208 -0
- package/src/es-ops/types.ts +85 -0
|
@@ -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,208 @@
|
|
|
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
|
+
filePath: path.join(seedsDir, name),
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function loadAppliedIds(db: DbConnection): Promise<Set<string>> {
|
|
168
|
+
const rows = await db
|
|
169
|
+
.select({ id: esOperationsTable.id })
|
|
170
|
+
.from(esOperationsTable)
|
|
171
|
+
.where(eq(esOperationsTable.operationType, "seed-migration"));
|
|
172
|
+
return new Set(rows.map((r: { id: string }) => r.id));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function loadSeedModule(filePath: string): Promise<SeedMigration> {
|
|
176
|
+
// Bun + Node both honor dynamic import on absolute paths. The seed-files
|
|
177
|
+
// must export their SeedMigration as default.
|
|
178
|
+
const mod = await import(filePath);
|
|
179
|
+
const migration: unknown = mod.default;
|
|
180
|
+
if (!isSeedMigration(migration)) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`[es-ops] seed file ${filePath} must export a SeedMigration as default ` +
|
|
183
|
+
`(object with { description: string, run: (ctx) => Promise<void> })`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return migration;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isSeedMigration(value: unknown): value is SeedMigration {
|
|
190
|
+
if (typeof value !== "object" || value === null) return false;
|
|
191
|
+
// @cast-boundary generic-record — narrowing unknown to property-bag for shape-check
|
|
192
|
+
const v = value as Partial<SeedMigration>;
|
|
193
|
+
return typeof v.description === "string" && typeof v.run === "function";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// "2026-05-20-fix-admin-roles" → "2026_05_20_FIX_ADMIN_ROLES"
|
|
197
|
+
function sanitizeForEnv(id: string): string {
|
|
198
|
+
return id.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function stringifyError(err: unknown): string {
|
|
202
|
+
if (err instanceof Error) return `${err.name}: ${err.message}`;
|
|
203
|
+
try {
|
|
204
|
+
return JSON.stringify(err);
|
|
205
|
+
} catch {
|
|
206
|
+
return String(err);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -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
|
+
};
|