@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 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.4.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.4.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,
@@ -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 { access, createSystemConfig, createTenantConfig, createUserConfig } from "./config-helpers";
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,
@@ -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
  },