@cosmicdrift/kumiko-framework 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,72 @@
1
1
  # @cosmicdrift/kumiko-framework
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 010b410: feat(auth-email-password): "Bestätigungs-Mail erneut senden" im LoginScreen
8
+
9
+ LoginScreen bietet bei reason=email_not_verified jetzt einen Resend-Link
10
+ im Fehler-Banner — der existierende `requestEmailVerification`-Endpoint
11
+ wird direkt aufgerufen, der Banner wechselt nach Erfolg zum Info-Variant
12
+ ("Wir haben dir eine neue Bestätigungs-Mail geschickt.").
13
+
14
+ UX-Details:
15
+
16
+ - Bei 429 → inline-Hint "Bitte warte kurz und versuche es erneut."
17
+ - Bei Netzwerk/sonstigen Fehlern → inline-Hint "Konnte nicht senden."
18
+ - Anti-Typo-Gate: ändert der User die Email-Eingabe nach dem Login-Fail,
19
+ verschwindet der Resend-Link — sonst würde Resend silent-success an die
20
+ geänderte (potentiell typoed) Adresse gehen ohne User-Feedback.
21
+ - Andere Failure-Codes (invalid_credentials etc.) zeigen weiterhin keinen
22
+ Resend-Link.
23
+
24
+ i18n: 4 neue Keys (DE+EN) im `auth.login.resend*`-Namespace, additive.
25
+ Apps die ihre Translations override-en müssen nichts ändern.
26
+
27
+ Additive UI-Feature — keine API-Breaks, keine Schema-Migration.
28
+
29
+ ## 0.4.0
30
+
31
+ ### Minor Changes
32
+
33
+ - 825e7d2: Visual-Tree V.1.4 → V.1.6 — Feature-complete Editor + Folder-Hierarchy + Roving-tabindex.
34
+
35
+ **V.1.4** — explicit `folder?: string` Schema-Field auf text-block-entity. Slug bleibt
36
+ kebab-only validiert, Folder explizit gesetzt. Tree gruppiert via `groupBlocksByFolder`
37
+ (ersetzt `groupBlocksBySlugPrefix`). `Subscribe<T>` Signature um optional `emitError`
38
+ erweitert für explicit async-error-Pfade. ProviderBranch zeigt Error-Banner mit
39
+ Retry-Button. Drift-Test pinnt seedTextBlock-vs-set.write Slug-Validation.
40
+
41
+ **V.1.4b** — URL-State-Routing für Editor-Target via `nav.searchParams`. F5 + Back-Button
42
+ stellen den Editor-State wieder her. Format: `?t=text-content:edit&a_slug=...&a_lang=...`.
43
+ Plus `useDispatchTarget` hook ersetzt globalen `dispatchTarget` als empfohlenen Production-
44
+ Pfad (legacy bleibt für Test-Hooks).
45
+
46
+ **V.1.5** — Arrow-Key-Navigation (`<aside role="tree">`, ARIA-tree-Pattern) + SSE-driven
47
+ Tree-Refresh. `ClientFeatureDefinition.treeEntities?: string[]` listet Entity-Namen pro
48
+ Provider; live-events triggern provider-re-mount → Stale-Tree-state="stub"→"filled"
49
+ flippt nach save automatisch.
50
+
51
+ **V.1.5c+d** — Active-Node-Highlight (explicit blue + 2px border-l + scrollIntoView),
52
+ VS-Code-Polish (compact spacing, focus-visible, folder-icon-color text-amber, indent-
53
+ guides per ancestor-depth), Folder-Wrapper für legal-pages ("📁 Legal" + slug-first
54
+ Verschachtelung) und text-content ("📁 Content").
55
+
56
+ **V.1.6** — Multi-level Folder-Splitting (`folder="page/marketing"` → nested folders,
57
+ walk-or-create-pattern, folder/leaf-collision-tolerant). Roving-tabindex (nur focused-
58
+ treeitem hat tabIndex=0, Tab cyclt aus dem Tree raus).
59
+
60
+ 35/35 kumiko check PASS, 13/13 group-blocks + 22/22 text-content integration tests grün.
61
+ Browser + Keyboard lokal validated.
62
+
63
+ **Breaking**: `TreeContext` Type entfernt (V.1.2 SR2-Rip — war nie genutzt). Provider sind
64
+ session-bound: `TreeChildrenSubscribe = () => Subscribe<T>` statt `(ctx) => Subscribe<T>`.
65
+
66
+ **V.1.7-Followups**: useEffect-deps in VisualTree-focus-init (Performance), Cancellation-
67
+ Token in TreeProvider's fetch (emit-after-unmount-warning), inline-rename, drag-drop,
68
+ file-icons per slug-extension, parent-jump bei ArrowLeft auf collapsed-item.
69
+
3
70
  ## 0.3.0
4
71
 
5
72
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -159,7 +159,7 @@
159
159
  "zod": "^4.4.3"
160
160
  },
161
161
  "devDependencies": {
162
- "@cosmicdrift/kumiko-dispatcher-live": "0.3.0",
162
+ "@cosmicdrift/kumiko-dispatcher-live": "0.4.1",
163
163
  "@types/uuid": "^11.0.0",
164
164
  "bun-types": "^1.3.13",
165
165
  "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,
@@ -293,7 +309,6 @@ export type {
293
309
  TreeActionDef,
294
310
  TreeActionsHandle,
295
311
  TreeChildrenSubscribe,
296
- TreeContext,
297
312
  TreeNode,
298
313
  TreeNodeState,
299
314
  UnsafeAppendEventFn,
@@ -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
  },
@@ -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;
@@ -573,9 +578,10 @@ export type FeatureRegistrar<TFeature extends string = string> = {
573
578
  // that emits the top-level Tree-Knoten when the Visual-Workspace
574
579
  // (navigation: "tree") mounts. At-most-one call per feature.
575
580
  //
576
- // Provider receives a TreeContext (Phase-0-stub, opaque) and an
577
- // emit-function; returns an unsubscribe-function. Initial-emit synchron
578
- // oder async, weitere Emits beliebig oft (e.g. on entity-update SSE).
581
+ // Provider returns a Subscribe-Function (emit-fn → unsubscribe-fn).
582
+ // Initial-emit synchron oder async, weitere Emits beliebig oft (e.g.
583
+ // on entity-update SSE). Provider sind session-bound; tenantId fließt
584
+ // über die Backend-Session bei fetch/dispatch, nicht über ein ctx-Arg.
579
585
  //
580
586
  // A feature without r.tree() is invisible in `navigation: "tree"`-
581
587
  // workspaces — that's the Zero-Whitelist-Filter from visual-tree.md A2:
@@ -655,6 +661,7 @@ export type Registry = {
655
661
  getAllTranslations(): TranslationKeys;
656
662
  getConfigKey(qualifiedKey: string): ConfigKeyDefinition | undefined;
657
663
  getAllConfigKeys(): ReadonlyMap<string, ConfigKeyDefinition>;
664
+ getAllConfigSeeds(): readonly ConfigSeedDef[];
658
665
  // Feature-declared secrets, aggregated across all registered features.
659
666
  // Keyed by qualified name ("<feature>:<shortName>"). Used by the rotation
660
667
  // job (to iterate "known" secrets) and admin-UIs to list available keys.
@@ -1,4 +1,3 @@
1
- // @runtime client
2
1
  // Domain-identifier type aliases. Used everywhere a tenantId/userId/aggregateId
3
2
  // travels through the framework. One declaration per concept so future
4
3
  // representation changes (branded types, UUID validation, opaque wrappers)
@@ -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,
@@ -218,7 +225,6 @@ export type {
218
225
  TreeActionDef,
219
226
  TreeActionsHandle,
220
227
  TreeChildrenSubscribe,
221
- TreeContext,
222
228
  TreeNode,
223
229
  TreeNodeState,
224
230
  } from "./tree-node";
@@ -23,7 +23,6 @@
23
23
  //
24
24
  // Siehe docs/plans/architecture/visual-tree.md A4.
25
25
 
26
- import type { TenantId } from "./identifiers";
27
26
  import type { TargetRef } from "./target-ref";
28
27
 
29
28
  export type TreeNodeState = "filled" | "stub" | "empty" | "loading" | "error";
@@ -75,25 +74,28 @@ export type TreeNode = {
75
74
  // Subscribe<T> — Provider implementiert: emit(initial); ...emit(updated);
76
75
  // und gibt unsubscribe-Function zurück. Caller (Tree-Component) ruft
77
76
  // unsubscribe auf wenn Knoten unmounted/eingeklappt wird.
78
- export type Subscribe<T> = (emit: (value: T) => void) => () => void;
77
+ //
78
+ // **V.1.4 emitError**: optional callback für async-error-Pfade (fetch-
79
+ // fail, SSE-disconnect). Provider die explizit Errors signalisieren
80
+ // wollen rufen `emitError(e)` statt empty-emit; VisualTree zeigt
81
+ // Error-Banner mit Retry-Button. Sync-Throws im Provider-Body werden
82
+ // vom useEffect-try/catch abgefangen — emitError ist nur für async.
83
+ export type Subscribe<T> = (
84
+ emit: (value: T) => void,
85
+ emitError?: (error: Error) => void,
86
+ ) => () => void;
79
87
 
80
88
  // TreeChildrenSubscribe — Lazy-Variante für dynamic Children. Wird
81
- // erst aufgerufen wenn der Knoten im UI ausgeklappt wird. ctx ist
82
- // für Phase 0 ein opaque empty Type; V.1.1 erweitert ihn um Query-/
83
- // Subscribe-Helpers (entity-list, slug-list etc.).
84
- export type TreeChildrenSubscribe = (ctx: TreeContext) => Subscribe<readonly TreeNode[]>;
85
-
86
- // TreeContext Provider erhält context-Objekt mit den minimal-nötigen
87
- // React-Tree-State-Bridges. V.1.1 startet mit `tenantId` only (Provider
88
- // braucht tenant-awareness sonst stale-tenant-Bug bei Tenant-Switch);
89
- // `query` und `subscribe` werden additiv ergänzt wenn ein konkreter
90
- // Konsument sie braucht (V.1.2: text-content slug-list-query, später
91
- // SSE-driven re-emit). Memory `[Keine Optionen ohne Bedarf]` — surface
92
- // wächst mit Bedarfen, nicht mit Spekulation. Siehe visual-tree.md
93
- // V.1.1-Decision D1.
94
- export type TreeContext = Readonly<{
95
- readonly tenantId: TenantId;
96
- }>;
89
+ // erst aufgerufen wenn der Knoten im UI ausgeklappt wird. Kein ctx-
90
+ // Argument: Provider sind session-bound; Backend liest tenantId aus
91
+ // session bei jedem fetch/dispatch. V.1.1 hatte ein ctx mit tenantId,
92
+ // das aber im Browser nie echten Tenant trug (war auf SYSTEM_TENANT_ID
93
+ // gepinnt) und vom einzigen V.1.2-Consumer (text-content) ignoriert
94
+ // wurde. SR2-Rip 2026-05-18: Dead-API entfernt; wenn später ein
95
+ // Provider tenant-aware-rendern muss (z.B. cross-tenant-Dashboards
96
+ // für SystemAdmin), wird ctx mit echtem Tenant-Source aus dem Auth-
97
+ // Layer re-introduziert. YAGNI bis dahin.
98
+ export type TreeChildrenSubscribe = () => Subscribe<readonly TreeNode[]>;
97
99
 
98
100
  // TreeActionDef — Schema-Eintrag pro Action in der treeActions-Map
99
101
  // eines Features. Phase 0: Args sind ein optionales Type-Sample