@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,69 @@
|
|
|
1
1
|
# @cosmicdrift/kumiko-framework
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7ff69ab: feat(es-ops): Phase 1 — file-based seed-migrations
|
|
8
|
+
|
|
9
|
+
Neues first-class Operations-Pattern fürs Framework. Liefert `seed-migrations`
|
|
10
|
+
als drizzle-migrate-equivalent für Event-Sourcing-Aggregate-Updates die
|
|
11
|
+
idempotent-Seeder nicht erfassen können (z.B. „Member hat schon eine
|
|
12
|
+
Rolle, aber jetzt soll noch eine dazukommen").
|
|
13
|
+
|
|
14
|
+
Public-API:
|
|
15
|
+
|
|
16
|
+
- `runProdApp({ seedsDir })` — Auto-apply pending Migrations beim Boot
|
|
17
|
+
- `SeedMigration`-Interface (default-Export einer `seeds/<id>.ts`-File)
|
|
18
|
+
- `SeedMigrationContext` mit `systemWriteAs` (ruft existing write-handler
|
|
19
|
+
als System-User) + Read-Helpers (`findUserByEmail`,
|
|
20
|
+
`findMembershipsOfUser`, `findTenants`)
|
|
21
|
+
- CLI: `bunx kumiko ops seed:new|status|apply`
|
|
22
|
+
- Tracking-Table `kumiko_es_operations` mit `operation_type`-Discriminator
|
|
23
|
+
(vorbereitet auf Phase 2+ Operations: projection-rebuild, event-replay,
|
|
24
|
+
stream-migration, ...)
|
|
25
|
+
- Env-Flags: `KUMIKO_SKIP_ES_OPS=1` (alle skippen für Recovery),
|
|
26
|
+
`KUMIKO_SKIP_ES_OPS_<ID>=1` (einzelne kaputte skippen)
|
|
27
|
+
|
|
28
|
+
Garantien: single-run via tracking, atomic via per-migration-Tx,
|
|
29
|
+
chronological order via filename-prefix, fail-stop bei Failure (kein
|
|
30
|
+
Partial-Apply), ES-konform via Handler-Dispatch.
|
|
31
|
+
|
|
32
|
+
Sub-path-Export: `@cosmicdrift/kumiko-framework/es-ops`
|
|
33
|
+
|
|
34
|
+
Plan-Doc: `kumiko-platform/docs/plans/features/es-ops.md`
|
|
35
|
+
Recipe: `samples/recipes/seed-migration/`
|
|
36
|
+
Driver-Use-Case: publicstatus admin-roles-drift (parallel-Branch
|
|
37
|
+
`feat/es-ops-driver-admin-roles`).
|
|
38
|
+
|
|
39
|
+
Phase 2+ skizziert + offen markiert — Implementation pro Use-Case.
|
|
40
|
+
|
|
41
|
+
## 0.4.1
|
|
42
|
+
|
|
43
|
+
### Patch Changes
|
|
44
|
+
|
|
45
|
+
- 010b410: feat(auth-email-password): "Bestätigungs-Mail erneut senden" im LoginScreen
|
|
46
|
+
|
|
47
|
+
LoginScreen bietet bei reason=email_not_verified jetzt einen Resend-Link
|
|
48
|
+
im Fehler-Banner — der existierende `requestEmailVerification`-Endpoint
|
|
49
|
+
wird direkt aufgerufen, der Banner wechselt nach Erfolg zum Info-Variant
|
|
50
|
+
("Wir haben dir eine neue Bestätigungs-Mail geschickt.").
|
|
51
|
+
|
|
52
|
+
UX-Details:
|
|
53
|
+
|
|
54
|
+
- Bei 429 → inline-Hint "Bitte warte kurz und versuche es erneut."
|
|
55
|
+
- Bei Netzwerk/sonstigen Fehlern → inline-Hint "Konnte nicht senden."
|
|
56
|
+
- Anti-Typo-Gate: ändert der User die Email-Eingabe nach dem Login-Fail,
|
|
57
|
+
verschwindet der Resend-Link — sonst würde Resend silent-success an die
|
|
58
|
+
geänderte (potentiell typoed) Adresse gehen ohne User-Feedback.
|
|
59
|
+
- Andere Failure-Codes (invalid_credentials etc.) zeigen weiterhin keinen
|
|
60
|
+
Resend-Link.
|
|
61
|
+
|
|
62
|
+
i18n: 4 neue Keys (DE+EN) im `auth.login.resend*`-Namespace, additive.
|
|
63
|
+
Apps die ihre Translations override-en müssen nichts ändern.
|
|
64
|
+
|
|
65
|
+
Additive UI-Feature — keine API-Breaks, keine Schema-Migration.
|
|
66
|
+
|
|
3
67
|
## 0.4.0
|
|
4
68
|
|
|
5
69
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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.0",
|
|
163
167
|
"@types/uuid": "^11.0.0",
|
|
164
168
|
"bun-types": "^1.3.13",
|
|
165
169
|
"drizzle-kit": "^0.31.10",
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
createEntity,
|
|
5
|
+
createSystemConfig,
|
|
6
|
+
createTenantConfig,
|
|
7
|
+
createTextField,
|
|
8
|
+
createUserConfig,
|
|
9
|
+
SYSTEM_TENANT_ID,
|
|
10
|
+
} from "../../engine";
|
|
11
|
+
import type { ConfigSeedDef, Registry } from "../../engine/types";
|
|
12
|
+
import { createTestDb, type TestDb, unsafeCreateEntityTable } from "../../stack";
|
|
13
|
+
import { seedConfigValues } from "../config-seed";
|
|
14
|
+
import { createEncryptionProvider } from "../encryption";
|
|
15
|
+
import { buildDrizzleTable } from "../table-builder";
|
|
16
|
+
|
|
17
|
+
// --- Test Entity ---
|
|
18
|
+
// Mirrors the config-value entity from bundled-features with a unique
|
|
19
|
+
// table name so it never collides with the real config table.
|
|
20
|
+
const configEntity = createEntity({
|
|
21
|
+
table: "read_cfg_seed_test",
|
|
22
|
+
fields: {
|
|
23
|
+
key: createTextField({ required: true }),
|
|
24
|
+
value: createTextField({}),
|
|
25
|
+
userId: createTextField({}),
|
|
26
|
+
},
|
|
27
|
+
indexes: [
|
|
28
|
+
{
|
|
29
|
+
unique: true,
|
|
30
|
+
columns: ["key", "tenantId", "userId"],
|
|
31
|
+
name: "cfg_seed_test_unique",
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
});
|
|
35
|
+
const configTable = buildDrizzleTable("cfgSeedTest", configEntity);
|
|
36
|
+
|
|
37
|
+
// --- Registry Stub ---
|
|
38
|
+
const KEY_DEFS = {
|
|
39
|
+
"test:config:service-url": createSystemConfig("text", {
|
|
40
|
+
default: "https://default.example.com",
|
|
41
|
+
}),
|
|
42
|
+
"test:config:max-upload": createTenantConfig("number", { default: 10 }),
|
|
43
|
+
"test:config:stripe-key": createTenantConfig("text", { encrypted: true }),
|
|
44
|
+
"test:config:theme": createUserConfig("text", { default: "blue" }),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const mockRegistry = {
|
|
48
|
+
getConfigKey: (key: string) => KEY_DEFS[key as keyof typeof KEY_DEFS] ?? undefined,
|
|
49
|
+
} as unknown as Registry;
|
|
50
|
+
|
|
51
|
+
// --- Helpers ---
|
|
52
|
+
const encryption = createEncryptionProvider(
|
|
53
|
+
Buffer.from("0123456789abcdef0123456789abcdef").toString("base64"),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
let testDb: TestDb;
|
|
57
|
+
|
|
58
|
+
async function countRows(): Promise<number> {
|
|
59
|
+
const [r] = await testDb.db.execute<{ count: number }>(
|
|
60
|
+
sql`SELECT COUNT(*)::int AS count FROM read_cfg_seed_test`,
|
|
61
|
+
);
|
|
62
|
+
return r?.count ?? 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function countEvents(): Promise<number> {
|
|
66
|
+
const [r] = await testDb.db.execute<{ count: number }>(
|
|
67
|
+
sql`SELECT COUNT(*)::int AS count FROM kumiko_events`,
|
|
68
|
+
);
|
|
69
|
+
return r?.count ?? 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- Setup ---
|
|
73
|
+
beforeAll(async () => {
|
|
74
|
+
testDb = await createTestDb();
|
|
75
|
+
await unsafeCreateEntityTable(testDb.db, configEntity, "cfgSeedTest");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterAll(async () => {
|
|
79
|
+
await testDb.cleanup();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
beforeEach(async () => {
|
|
83
|
+
await testDb.db.execute(sql`TRUNCATE kumiko_events, read_cfg_seed_test RESTART IDENTITY CASCADE`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// --- Tests ---
|
|
87
|
+
|
|
88
|
+
describe("seedConfigValues", () => {
|
|
89
|
+
test("inserts initial seeds — creates rows + events", async () => {
|
|
90
|
+
const TENANT_A = "22222222-2222-4222-8222-222222222222";
|
|
91
|
+
const seeds: ConfigSeedDef[] = [
|
|
92
|
+
{ key: "test:config:service-url", value: "https://prod.example.com", scope: "system" },
|
|
93
|
+
{ key: "test:config:max-upload", value: 50, scope: "tenant" },
|
|
94
|
+
{
|
|
95
|
+
key: "test:config:theme",
|
|
96
|
+
value: "dark",
|
|
97
|
+
scope: "user",
|
|
98
|
+
tenantId: TENANT_A,
|
|
99
|
+
userId: "u-123",
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const result = await seedConfigValues(
|
|
104
|
+
seeds,
|
|
105
|
+
configTable,
|
|
106
|
+
configEntity,
|
|
107
|
+
mockRegistry,
|
|
108
|
+
testDb.db,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(result.created).toBe(3);
|
|
112
|
+
expect(result.skipped).toBe(0);
|
|
113
|
+
expect(await countRows()).toBe(3);
|
|
114
|
+
expect(await countEvents()).toBe(3);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("idempotent re-run — all skipped", async () => {
|
|
118
|
+
const seeds: ConfigSeedDef[] = [
|
|
119
|
+
{ key: "test:config:service-url", value: "https://prod.example.com", scope: "system" },
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const first = await seedConfigValues(seeds, configTable, configEntity, mockRegistry, testDb.db);
|
|
123
|
+
expect(first).toEqual({ created: 1, skipped: 0 });
|
|
124
|
+
|
|
125
|
+
const second = await seedConfigValues(
|
|
126
|
+
seeds,
|
|
127
|
+
configTable,
|
|
128
|
+
configEntity,
|
|
129
|
+
mockRegistry,
|
|
130
|
+
testDb.db,
|
|
131
|
+
);
|
|
132
|
+
expect(second).toEqual({ created: 0, skipped: 1 });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("insert-only — value change ignored on re-boot", async () => {
|
|
136
|
+
const seeds: ConfigSeedDef[] = [{ key: "test:config:max-upload", value: 10, scope: "tenant" }];
|
|
137
|
+
|
|
138
|
+
await seedConfigValues(seeds, configTable, configEntity, mockRegistry, testDb.db);
|
|
139
|
+
|
|
140
|
+
const seedsChanged: ConfigSeedDef[] = [
|
|
141
|
+
{ key: "test:config:max-upload", value: 999, scope: "tenant" },
|
|
142
|
+
];
|
|
143
|
+
const result = await seedConfigValues(
|
|
144
|
+
seedsChanged,
|
|
145
|
+
configTable,
|
|
146
|
+
configEntity,
|
|
147
|
+
mockRegistry,
|
|
148
|
+
testDb.db,
|
|
149
|
+
);
|
|
150
|
+
expect(result).toEqual({ created: 0, skipped: 1 });
|
|
151
|
+
|
|
152
|
+
// Old value persists
|
|
153
|
+
const [row] = await testDb.db.execute<{ value: string }>(
|
|
154
|
+
sql`SELECT value FROM read_cfg_seed_test WHERE key = 'test:config:max-upload' LIMIT 1`,
|
|
155
|
+
);
|
|
156
|
+
expect(row).toBeDefined();
|
|
157
|
+
expect(JSON.parse(row!.value)).toBe(10);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("scope mapping — system/tenant under SYSTEM_TENANT_ID, user under real tenantId", async () => {
|
|
161
|
+
const TENANT_A = "11111111-1111-4111-8111-111111111111";
|
|
162
|
+
|
|
163
|
+
const seeds: ConfigSeedDef[] = [
|
|
164
|
+
{ key: "test:config:service-url", value: "x", scope: "system" },
|
|
165
|
+
{ key: "test:config:max-upload", value: 20, scope: "tenant" },
|
|
166
|
+
{ key: "test:config:theme", value: "dark", scope: "user", tenantId: TENANT_A, userId: "u-1" },
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
await seedConfigValues(seeds, configTable, configEntity, mockRegistry, testDb.db);
|
|
170
|
+
|
|
171
|
+
const rows = await testDb.db.execute<{
|
|
172
|
+
key: string;
|
|
173
|
+
tenantId: string;
|
|
174
|
+
userId: string | null;
|
|
175
|
+
}>(
|
|
176
|
+
sql`SELECT key, tenant_id AS "tenantId", user_id AS "userId" FROM read_cfg_seed_test ORDER BY key`,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const sys = rows.find((r) => r.key === "test:config:service-url");
|
|
180
|
+
const tnt = rows.find((r) => r.key === "test:config:max-upload");
|
|
181
|
+
const usr = rows.find((r) => r.key === "test:config:theme");
|
|
182
|
+
|
|
183
|
+
expect(sys!.tenantId).toBe(SYSTEM_TENANT_ID);
|
|
184
|
+
expect(sys!.userId).toBeNull();
|
|
185
|
+
|
|
186
|
+
expect(tnt!.tenantId).toBe(SYSTEM_TENANT_ID);
|
|
187
|
+
expect(tnt!.userId).toBeNull();
|
|
188
|
+
|
|
189
|
+
// user-scope seed must live under the user's actual tenantId so the
|
|
190
|
+
// resolver cascade can match it — never under SYSTEM_TENANT_ID.
|
|
191
|
+
expect(usr!.tenantId).toBe(TENANT_A);
|
|
192
|
+
expect(usr!.userId).toBe("u-1");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("user-scope seed without tenantId throws (would be unreachable)", async () => {
|
|
196
|
+
const seeds: ConfigSeedDef[] = [
|
|
197
|
+
{ key: "test:config:theme", value: "dark", scope: "user", userId: "u-1" },
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
await expect(
|
|
201
|
+
seedConfigValues(seeds, configTable, configEntity, mockRegistry, testDb.db),
|
|
202
|
+
).rejects.toThrow(/user-scope seed.*requires both tenantId and userId/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("encrypted seed without EncryptionProvider throws — fail loud at boot", async () => {
|
|
206
|
+
const seeds: ConfigSeedDef[] = [
|
|
207
|
+
{ key: "test:config:stripe-key", value: "sk_live_xxx", scope: "tenant" },
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
await expect(
|
|
211
|
+
seedConfigValues(seeds, configTable, configEntity, mockRegistry, testDb.db),
|
|
212
|
+
).rejects.toThrow(/encrypted but no EncryptionProvider/);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("encrypted seed with provider stores ciphertext, not plaintext", async () => {
|
|
216
|
+
const seeds: ConfigSeedDef[] = [
|
|
217
|
+
{ key: "test:config:stripe-key", value: "sk_live_secret_token", scope: "tenant" },
|
|
218
|
+
];
|
|
219
|
+
|
|
220
|
+
const result = await seedConfigValues(
|
|
221
|
+
seeds,
|
|
222
|
+
configTable,
|
|
223
|
+
configEntity,
|
|
224
|
+
mockRegistry,
|
|
225
|
+
testDb.db,
|
|
226
|
+
encryption,
|
|
227
|
+
);
|
|
228
|
+
expect(result).toEqual({ created: 1, skipped: 0 });
|
|
229
|
+
|
|
230
|
+
const [row] = await testDb.db.execute<{ value: string }>(
|
|
231
|
+
sql`SELECT value FROM read_cfg_seed_test WHERE key = 'test:config:stripe-key' LIMIT 1`,
|
|
232
|
+
);
|
|
233
|
+
expect(row).toBeDefined();
|
|
234
|
+
// value column holds ciphertext, never the plain token. The
|
|
235
|
+
// resolver later runs `decrypt → JSON.parse` to get the primitive
|
|
236
|
+
// back; we replay the same round-trip here.
|
|
237
|
+
expect(row!.value).not.toContain("sk_live_secret_token");
|
|
238
|
+
expect(JSON.parse(encryption.decrypt(row!.value))).toBe("sk_live_secret_token");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("race-safe parallel boot — two concurrent calls result in 1 created + 1 skipped", async () => {
|
|
242
|
+
const seeds: ConfigSeedDef[] = [
|
|
243
|
+
{ key: "test:config:service-url", value: "race-target", scope: "system" },
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
const [a, b] = await Promise.all([
|
|
247
|
+
seedConfigValues(seeds, configTable, configEntity, mockRegistry, testDb.db),
|
|
248
|
+
seedConfigValues(seeds, configTable, configEntity, mockRegistry, testDb.db),
|
|
249
|
+
]);
|
|
250
|
+
|
|
251
|
+
const totalCreated = (a.created ?? 0) + (b.created ?? 0);
|
|
252
|
+
const totalSkipped = (a.skipped ?? 0) + (b.skipped ?? 0);
|
|
253
|
+
expect(totalCreated).toBe(1);
|
|
254
|
+
expect(totalSkipped).toBe(1);
|
|
255
|
+
expect(await countRows()).toBe(1);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("unknown key — skipped gracefully", async () => {
|
|
259
|
+
const seeds: ConfigSeedDef[] = [
|
|
260
|
+
{ key: "test:config:nonexistent", value: "nope", scope: "tenant" },
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
const result = await seedConfigValues(
|
|
264
|
+
seeds,
|
|
265
|
+
configTable,
|
|
266
|
+
configEntity,
|
|
267
|
+
mockRegistry,
|
|
268
|
+
testDb.db,
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
expect(result).toEqual({ created: 0, skipped: 1 });
|
|
272
|
+
expect(await countRows()).toBe(0);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("empty seeds returns 0/0", async () => {
|
|
276
|
+
const result = await seedConfigValues([], configTable, configEntity, mockRegistry, testDb.db);
|
|
277
|
+
expect(result).toEqual({ created: 0, skipped: 0 });
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { v5 as uuidv5 } from "uuid";
|
|
2
|
+
import type { EntityDefinition } from "../engine";
|
|
3
|
+
import { createSystemUser, SYSTEM_TENANT_ID } from "../engine";
|
|
4
|
+
import type { ConfigSeedDef, Registry } from "../engine/types";
|
|
5
|
+
import type { DbConnection } from "./connection";
|
|
6
|
+
import type { EncryptionProvider } from "./encryption";
|
|
7
|
+
import { createEventStoreExecutor } from "./event-store-executor";
|
|
8
|
+
import type { DrizzleTable } from "./table-builder";
|
|
9
|
+
import { createTenantDb } from "./tenant-db";
|
|
10
|
+
|
|
11
|
+
// Namespace UUID for deterministic seed aggregate IDs. Same namespace +
|
|
12
|
+
// (key, tenantId, userId) triple always produces the same UUIDv5, which
|
|
13
|
+
// makes executor.create(…, expectedVersion: 0) hit version_conflict on
|
|
14
|
+
// re-boot — no new stream created, idempotent without DB-level checks.
|
|
15
|
+
const CONFIG_SEED_NS = "6f1e9d8c-2a5b-4c7d-9e3f-1a2b3c4d5e6f";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Seed config values at boot time via the event-store executor.
|
|
19
|
+
*
|
|
20
|
+
* For each ConfigSeedDef: calls executor.create(payload, SYSTEM_USER, db).
|
|
21
|
+
* When the aggregate stream already exists (e.g. re-boot, admin-override),
|
|
22
|
+
* the executor returns a WriteFailure(version_conflict) which we skip.
|
|
23
|
+
*
|
|
24
|
+
* Idempotent, race-safe via DB-level unique constraints, and visible to
|
|
25
|
+
* multi-stream-projection subscribers as normal configValue.created events.
|
|
26
|
+
*/
|
|
27
|
+
export async function seedConfigValues(
|
|
28
|
+
seeds: readonly ConfigSeedDef[],
|
|
29
|
+
table: DrizzleTable,
|
|
30
|
+
entity: EntityDefinition,
|
|
31
|
+
registry: Registry,
|
|
32
|
+
db: DbConnection,
|
|
33
|
+
encryption?: EncryptionProvider,
|
|
34
|
+
): Promise<{ created: number; skipped: number }> {
|
|
35
|
+
let created = 0;
|
|
36
|
+
let skipped = 0;
|
|
37
|
+
|
|
38
|
+
if (seeds.length === 0) return { created, skipped };
|
|
39
|
+
|
|
40
|
+
const systemUser = createSystemUser(SYSTEM_TENANT_ID);
|
|
41
|
+
const executor = createEventStoreExecutor(table, entity, { entityName: "config-value" });
|
|
42
|
+
const tdb = createTenantDb(db, SYSTEM_TENANT_ID, "system");
|
|
43
|
+
|
|
44
|
+
for (const seed of seeds) {
|
|
45
|
+
const keyDef = registry.getConfigKey(seed.key);
|
|
46
|
+
if (!keyDef) {
|
|
47
|
+
skipped++;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Encrypted keys without an encryption provider would silently write
|
|
52
|
+
// plaintext to a column the resolver later tries to decrypt — fail
|
|
53
|
+
// loud at boot, not on first read in prod.
|
|
54
|
+
if (keyDef.encrypted && !encryption) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`seedConfigValues: key "${seed.key}" is encrypted but no EncryptionProvider was supplied.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const scope = seed.scope ?? keyDef.scope;
|
|
61
|
+
|
|
62
|
+
// User-scope seeds need a concrete tenantId because the resolver
|
|
63
|
+
// user-cascade matches the user's actual tenantId, not the SYSTEM
|
|
64
|
+
// sentinel — a SYSTEM-rooted user row would be unreachable.
|
|
65
|
+
if (scope === "user" && (!seed.tenantId || !seed.userId)) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`seedConfigValues: user-scope seed "${seed.key}" requires both tenantId and userId — use createUserSeed({value}, {tenantId, userId}).`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const tenantId = seed.tenantId ?? SYSTEM_TENANT_ID;
|
|
72
|
+
const userId = scope === "user" ? (seed.userId ?? null) : null;
|
|
73
|
+
|
|
74
|
+
// Deterministic aggregate id (key+tenant+user triple) so re-boot hits
|
|
75
|
+
// the existing stream → version_conflict → counted as skipped.
|
|
76
|
+
const idSource = `${seed.key}:${tenantId}:${userId ?? ""}`;
|
|
77
|
+
const aggregateId = uuidv5(idSource, CONFIG_SEED_NS);
|
|
78
|
+
|
|
79
|
+
let value = JSON.stringify(seed.value);
|
|
80
|
+
if (keyDef.encrypted && encryption) {
|
|
81
|
+
value = encryption.encrypt(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const payload: Record<string, unknown> = {
|
|
85
|
+
id: aggregateId,
|
|
86
|
+
key: seed.key,
|
|
87
|
+
value,
|
|
88
|
+
tenantId,
|
|
89
|
+
userId,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const result = await executor.create(payload, systemUser, tdb);
|
|
93
|
+
|
|
94
|
+
if (result.isSuccess) {
|
|
95
|
+
created++;
|
|
96
|
+
} else {
|
|
97
|
+
// version_conflict (stream exists) and unique_violation (projection
|
|
98
|
+
// race) both mean "already seeded" — count as skipped, not error.
|
|
99
|
+
skipped++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { created, skipped };
|
|
104
|
+
}
|
package/src/db/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { assertExistsIn } from "./assert-exists-in";
|
|
2
2
|
export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
|
|
3
|
+
export { seedConfigValues } from "./config-seed";
|
|
3
4
|
export type { DbConnection, DbConnectionOptions, DbRow, DbRunner, DbTx } from "./connection";
|
|
4
5
|
export { createDbConnection, dbConnectionOptionsFromEnv } from "./connection";
|
|
5
6
|
export type { CursorQueryOptions, CursorResult } from "./cursor";
|
|
@@ -4,7 +4,11 @@ import type {
|
|
|
4
4
|
ConfigComputedFn,
|
|
5
5
|
ConfigKeyDefinition,
|
|
6
6
|
ConfigKeyType,
|
|
7
|
+
ConfigSeedDef,
|
|
7
8
|
ConfigValue,
|
|
9
|
+
CreateSeedOptions,
|
|
10
|
+
CreateTenantSeedOptions,
|
|
11
|
+
CreateUserSeedOptions,
|
|
8
12
|
} from "./types";
|
|
9
13
|
|
|
10
14
|
// --- Access Presets ---
|
|
@@ -103,3 +107,52 @@ export function createUserConfig<T extends ConfigKeyType>(
|
|
|
103
107
|
): ConfigKeyDefinition<T> {
|
|
104
108
|
return createConfigKey("user", type, opts);
|
|
105
109
|
}
|
|
110
|
+
|
|
111
|
+
// --- Seed Factories ---
|
|
112
|
+
//
|
|
113
|
+
// `key` is set to "" here — define-feature.ts fills in the qualified name
|
|
114
|
+
// from the seeds-record-key during r.config() processing.
|
|
115
|
+
|
|
116
|
+
// Scope-agnostic seed. The scope is derived from the matching keyDef in
|
|
117
|
+
// define-feature.ts (via `seed.scope ?? keyDef.scope`). NOT usable for
|
|
118
|
+
// user-scope keys — those need an explicit tenantId+userId, use
|
|
119
|
+
// `createUserSeed` instead.
|
|
120
|
+
export function createSeed(opts: CreateSeedOptions): ConfigSeedDef {
|
|
121
|
+
return { value: opts.value, key: "" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// System-scope seed. Always writes under SYSTEM_TENANT_ID.
|
|
125
|
+
export function createSystemSeed(opts: CreateSeedOptions): ConfigSeedDef {
|
|
126
|
+
return { value: opts.value, scope: "system", key: "" };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Tenant-scope seed. `tenantId` omitted → fallback row under
|
|
130
|
+
// SYSTEM_TENANT_ID (visible to all tenants via the resolver cascade).
|
|
131
|
+
// Explicit `tenantId` → seed targets that one tenant only.
|
|
132
|
+
export function createTenantSeed(
|
|
133
|
+
opts: CreateSeedOptions,
|
|
134
|
+
options?: CreateTenantSeedOptions,
|
|
135
|
+
): ConfigSeedDef {
|
|
136
|
+
return {
|
|
137
|
+
value: opts.value,
|
|
138
|
+
scope: "tenant",
|
|
139
|
+
key: "",
|
|
140
|
+
tenantId: options?.tenantId,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// User-scope seed. Both tenantId AND userId are required — the resolver
|
|
145
|
+
// matches against the user's actual tenantId, so a seed under
|
|
146
|
+
// SYSTEM_TENANT_ID would never resolve.
|
|
147
|
+
export function createUserSeed(
|
|
148
|
+
opts: CreateSeedOptions,
|
|
149
|
+
options: CreateUserSeedOptions,
|
|
150
|
+
): ConfigSeedDef {
|
|
151
|
+
return {
|
|
152
|
+
value: opts.value,
|
|
153
|
+
scope: "user",
|
|
154
|
+
key: "",
|
|
155
|
+
tenantId: options.tenantId,
|
|
156
|
+
userId: options.userId,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
ConfigKeyDefinition,
|
|
14
14
|
ConfigKeyHandle,
|
|
15
15
|
ConfigKeyType,
|
|
16
|
+
ConfigSeedDef,
|
|
16
17
|
EntityDefinition,
|
|
17
18
|
EntityRef,
|
|
18
19
|
EventDef,
|
|
@@ -112,6 +113,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
112
113
|
Record<string, PhasedHook<LifecycleHookFn>[]>
|
|
113
114
|
> = { postSave: {}, preDelete: {}, postDelete: {} };
|
|
114
115
|
const configKeys: Record<string, ConfigKeyDefinition> = {};
|
|
116
|
+
const configSeeds: ConfigSeedDef[] = [];
|
|
115
117
|
const jobs: Record<string, JobDefinition> = {};
|
|
116
118
|
const events: Record<string, { name: string; schema: ZodType; version: number }> = {};
|
|
117
119
|
const eventMigrations: Record<string, EventMigrationDef[]> = {};
|
|
@@ -358,6 +360,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
358
360
|
|
|
359
361
|
config<TKeys extends Readonly<Record<string, ConfigKeyDefinition<ConfigKeyType>>>>(definition: {
|
|
360
362
|
readonly keys: TKeys;
|
|
363
|
+
readonly seeds?: Readonly<Record<string, ConfigSeedDef>>;
|
|
361
364
|
}): { readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]> } {
|
|
362
365
|
// Qualify eagerly (same as defineEvent) so the handle name matches what
|
|
363
366
|
// the registry stores — lazy qualification would break compile-time
|
|
@@ -370,6 +373,22 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
370
373
|
type: keyDef.type,
|
|
371
374
|
};
|
|
372
375
|
}
|
|
376
|
+
// Parse seeds: resolve qualified key names and validate scope
|
|
377
|
+
if (definition.seeds) {
|
|
378
|
+
for (const [shortKey, seedDef] of Object.entries(definition.seeds)) {
|
|
379
|
+
const keyDef = definition.keys[shortKey];
|
|
380
|
+
if (!keyDef) continue; // skip — boot-validator reports unknown keys
|
|
381
|
+
const qualifiedKey = qn(toKebab(name), "config", toKebab(shortKey));
|
|
382
|
+
const scope = seedDef.scope ?? keyDef.scope;
|
|
383
|
+
configSeeds.push({
|
|
384
|
+
key: qualifiedKey,
|
|
385
|
+
value: seedDef.value,
|
|
386
|
+
scope,
|
|
387
|
+
tenantId: seedDef.tenantId,
|
|
388
|
+
userId: seedDef.userId,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
373
392
|
return handles as {
|
|
374
393
|
readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]>;
|
|
375
394
|
}; // @cast-boundary engine-bridge — Mapped-Type-Inference at config()-callsite
|
|
@@ -830,6 +849,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
830
849
|
postDelete: entityPostDelete,
|
|
831
850
|
},
|
|
832
851
|
configKeys,
|
|
852
|
+
configSeeds,
|
|
833
853
|
jobs,
|
|
834
854
|
notifications,
|
|
835
855
|
registrarExtensions,
|
package/src/engine/index.ts
CHANGED
|
@@ -4,7 +4,16 @@ export { hasAccess } from "./access";
|
|
|
4
4
|
export { validateBoot } from "./boot-validator";
|
|
5
5
|
export { buildAppSchema } from "./build-app-schema";
|
|
6
6
|
export { buildTarget } from "./build-target";
|
|
7
|
-
export {
|
|
7
|
+
export {
|
|
8
|
+
access,
|
|
9
|
+
createSeed,
|
|
10
|
+
createSystemConfig,
|
|
11
|
+
createSystemSeed,
|
|
12
|
+
createTenantConfig,
|
|
13
|
+
createTenantSeed,
|
|
14
|
+
createUserConfig,
|
|
15
|
+
createUserSeed,
|
|
16
|
+
} from "./config-helpers";
|
|
8
17
|
export type { SystemHookName } from "./constants";
|
|
9
18
|
export {
|
|
10
19
|
ConcurrencyModes,
|
|
@@ -193,6 +202,8 @@ export type {
|
|
|
193
202
|
ConcurrencyMode,
|
|
194
203
|
ConfigAccessor,
|
|
195
204
|
ConfigAccessorFactory,
|
|
205
|
+
ConfigCascade,
|
|
206
|
+
ConfigCascadeLevel,
|
|
196
207
|
ConfigDefinition,
|
|
197
208
|
ConfigEditScreenDefinition,
|
|
198
209
|
ConfigKeyAccess,
|
|
@@ -201,10 +212,15 @@ export type {
|
|
|
201
212
|
ConfigKeyType,
|
|
202
213
|
ConfigResolver,
|
|
203
214
|
ConfigScope,
|
|
215
|
+
ConfigSeedDef,
|
|
204
216
|
ConfigStoredRow,
|
|
217
|
+
ConfigStoredRowWithSource,
|
|
205
218
|
ConfigValue,
|
|
206
219
|
ConfigValueSource,
|
|
207
220
|
ConfigValueWithSource,
|
|
221
|
+
CreateSeedOptions,
|
|
222
|
+
CreateTenantSeedOptions,
|
|
223
|
+
CreateUserSeedOptions,
|
|
208
224
|
CustomScreenDefinition,
|
|
209
225
|
CustomScreenRoute,
|
|
210
226
|
DateFieldDef,
|
package/src/engine/registry.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
AuthClaimsHookDef,
|
|
7
7
|
ClaimKeyDefinition,
|
|
8
8
|
ConfigKeyDefinition,
|
|
9
|
+
ConfigSeedDef,
|
|
9
10
|
EntityDefinition,
|
|
10
11
|
EntityRelations,
|
|
11
12
|
EventDef,
|
|
@@ -136,6 +137,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
136
137
|
const extensionMap = new Map<string, RegistrarExtensionDef>();
|
|
137
138
|
const extensionUsages: RegistrarExtensionRegistration[] = [];
|
|
138
139
|
const allReferenceData: ReferenceDataDef[] = [];
|
|
140
|
+
const allConfigSeeds: ConfigSeedDef[] = [];
|
|
139
141
|
const mergedTranslations: Record<string, Record<string, string>> = {};
|
|
140
142
|
// Metric registry — keyed by fully qualified name (kumiko_<feature>_<short>).
|
|
141
143
|
// Boot-time validation rejects bad names; dashboards then safely rely on shape.
|
|
@@ -425,6 +427,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
425
427
|
}
|
|
426
428
|
extensionUsages.push(...feature.extensionUsages);
|
|
427
429
|
allReferenceData.push(...feature.referenceData);
|
|
430
|
+
allConfigSeeds.push(...feature.configSeeds);
|
|
428
431
|
|
|
429
432
|
// Metrics: validate + qualify per feature. Collisions across features are
|
|
430
433
|
// rejected here — two features can't both register "created_total" under
|
|
@@ -1288,6 +1291,10 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1288
1291
|
return allReferenceData;
|
|
1289
1292
|
},
|
|
1290
1293
|
|
|
1294
|
+
getAllConfigSeeds(): readonly ConfigSeedDef[] {
|
|
1295
|
+
return allConfigSeeds;
|
|
1296
|
+
},
|
|
1297
|
+
|
|
1291
1298
|
getProjectionsForSource(entityName: string): readonly ProjectionDefinition[] {
|
|
1292
1299
|
return projectionsBySource.get(entityName) ?? [];
|
|
1293
1300
|
},
|