@cosmicdrift/kumiko-framework 0.4.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 +26 -0
- package/package.json +2 -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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
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
|
+
|
|
3
29
|
## 0.4.0
|
|
4
30
|
|
|
5
31
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.4.
|
|
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.4.
|
|
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,
|
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
|
},
|
|
@@ -123,6 +123,12 @@ export type ConfigStoredRow = {
|
|
|
123
123
|
readonly userId: string | null;
|
|
124
124
|
};
|
|
125
125
|
|
|
126
|
+
// Extended row returned by ConfigResolver.getAllWithSource — includes the
|
|
127
|
+
// resolution source so the UI can display where each value came from.
|
|
128
|
+
export type ConfigStoredRowWithSource = ConfigStoredRow & {
|
|
129
|
+
readonly source: ConfigValueSource;
|
|
130
|
+
};
|
|
131
|
+
|
|
126
132
|
// Which layer of the cascade actually produced a value. Emitted only by
|
|
127
133
|
// `getWithSource` — regular `get` hides this to keep the hot-path simple.
|
|
128
134
|
// Use-case: Ops-debugging ("warum ist mein Wert 50 und nicht 100?") without
|
|
@@ -141,6 +147,22 @@ export type ConfigValueWithSource = {
|
|
|
141
147
|
readonly source: ConfigValueSource;
|
|
142
148
|
};
|
|
143
149
|
|
|
150
|
+
/// Full cascade for a single config key — every level the resolver
|
|
151
|
+
/// walks through, with the winning level marked.
|
|
152
|
+
export type ConfigCascadeLevel = {
|
|
153
|
+
readonly label: string;
|
|
154
|
+
readonly value: string | number | boolean | undefined;
|
|
155
|
+
readonly source: ConfigValueSource;
|
|
156
|
+
readonly isActive: boolean;
|
|
157
|
+
readonly hasValue: boolean;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export type ConfigCascade = {
|
|
161
|
+
readonly value: string | number | boolean | undefined;
|
|
162
|
+
readonly source: ConfigValueSource;
|
|
163
|
+
readonly levels: readonly ConfigCascadeLevel[];
|
|
164
|
+
};
|
|
165
|
+
|
|
144
166
|
// Minimal contract handlers (set/reset/values.query) call against the
|
|
145
167
|
// resolver. Lives in the framework so SharedContextFields.configResolver
|
|
146
168
|
// can drop the `unknown` cast — the concrete implementation in
|
|
@@ -176,6 +198,39 @@ export type ConfigResolver = {
|
|
|
176
198
|
userId: string,
|
|
177
199
|
db: DbConnection | TenantDb,
|
|
178
200
|
): Promise<ReadonlyMap<string, ConfigStoredRow>>;
|
|
201
|
+
|
|
202
|
+
// Like getAll() but also reports the resolution source for each key.
|
|
203
|
+
// Use when the caller needs to display the cascade origin (e.g. the
|
|
204
|
+
// values.query handler serves the UI's hierarchy badge). Hot-path
|
|
205
|
+
// callers should prefer getAll() for the narrower return type.
|
|
206
|
+
getAllWithSource(
|
|
207
|
+
tenantId: TenantId,
|
|
208
|
+
userId: string,
|
|
209
|
+
db: DbConnection | TenantDb,
|
|
210
|
+
): Promise<ReadonlyMap<string, ConfigStoredRowWithSource>>;
|
|
211
|
+
|
|
212
|
+
// Returns ALL cascade levels for a single key — not just the winner.
|
|
213
|
+
// Each level shows its value (or undefined if not set) and whether it
|
|
214
|
+
// is the active/winning level. Levels are ordered by specificity
|
|
215
|
+
// descending (most specific first).
|
|
216
|
+
getCascade(
|
|
217
|
+
qualifiedKey: string,
|
|
218
|
+
keyDef: ConfigKeyDefinition,
|
|
219
|
+
tenantId: TenantId,
|
|
220
|
+
userId: string,
|
|
221
|
+
db: DbConnection | TenantDb,
|
|
222
|
+
): Promise<ConfigCascade>;
|
|
223
|
+
|
|
224
|
+
// Batch variant: resolves cascades for N keys in one DB round-trip.
|
|
225
|
+
// keyDefs must contain definitions for every key in the keys array.
|
|
226
|
+
// Returns a map of qualifiedKey → ConfigCascade.
|
|
227
|
+
getCascadeBatch(
|
|
228
|
+
keys: readonly string[],
|
|
229
|
+
keyDefs: ReadonlyMap<string, ConfigKeyDefinition>,
|
|
230
|
+
tenantId: TenantId,
|
|
231
|
+
userId: string,
|
|
232
|
+
db: DbConnection | TenantDb,
|
|
233
|
+
): Promise<ReadonlyMap<string, ConfigCascade>>;
|
|
179
234
|
};
|
|
180
235
|
|
|
181
236
|
// --- Process-Placement (runIn) ---
|
|
@@ -304,3 +359,44 @@ export type ReferenceDataDef = {
|
|
|
304
359
|
readonly data: readonly Record<string, unknown>[];
|
|
305
360
|
readonly upsertKey?: string | undefined;
|
|
306
361
|
};
|
|
362
|
+
|
|
363
|
+
// --- Config Seeding ---
|
|
364
|
+
|
|
365
|
+
// A deploy-time default for a config key, written via the event-store
|
|
366
|
+
// executor at boot. Idempotent — if the stream already exists the executor
|
|
367
|
+
// returns version_conflict and seedConfigValues counts it as skipped.
|
|
368
|
+
// See config-seeding.md.
|
|
369
|
+
//
|
|
370
|
+
// `scope` is optional on the factory-output: createSeed leaves it unset
|
|
371
|
+
// (define-feature derives it from keyDef.scope). createSystemSeed /
|
|
372
|
+
// createTenantSeed / createUserSeed always set it explicitly.
|
|
373
|
+
//
|
|
374
|
+
// `tenantId` / `userId` semantics:
|
|
375
|
+
// - system scope: both stay undefined (row stored under SYSTEM_TENANT_ID).
|
|
376
|
+
// - tenant scope: tenantId optional (undefined → fallback row under
|
|
377
|
+
// SYSTEM_TENANT_ID, visible to all tenants via resolver cascade).
|
|
378
|
+
// - user scope: BOTH tenantId AND userId required, otherwise the resolver
|
|
379
|
+
// can never match the row (user-scope cascade looks up the user's actual
|
|
380
|
+
// tenantId, not SYSTEM_TENANT_ID).
|
|
381
|
+
export type ConfigSeedDef = {
|
|
382
|
+
readonly key: string; // fully-qualified config key name (set by define-feature)
|
|
383
|
+
readonly value: string | number | boolean;
|
|
384
|
+
readonly scope?: ConfigScope;
|
|
385
|
+
readonly tenantId?: string;
|
|
386
|
+
readonly userId?: string;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// Factory types for ergonomic seed creation in r.config({ seeds }).
|
|
390
|
+
|
|
391
|
+
export type CreateSeedOptions = {
|
|
392
|
+
readonly value: string | number | boolean;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
export type CreateTenantSeedOptions = {
|
|
396
|
+
readonly tenantId?: string;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
export type CreateUserSeedOptions = {
|
|
400
|
+
readonly tenantId: string;
|
|
401
|
+
readonly userId: string;
|
|
402
|
+
};
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
ConfigKeyDefinition,
|
|
6
6
|
ConfigKeyHandle,
|
|
7
7
|
ConfigKeyType,
|
|
8
|
+
ConfigSeedDef,
|
|
8
9
|
JobDefinition,
|
|
9
10
|
JobHandlerFn,
|
|
10
11
|
NotificationDataFn,
|
|
@@ -170,6 +171,7 @@ export type FeatureDefinition = {
|
|
|
170
171
|
readonly hooks: HookMap;
|
|
171
172
|
readonly entityHooks: EntityHookMap;
|
|
172
173
|
readonly configKeys: Readonly<Record<string, ConfigKeyDefinition>>;
|
|
174
|
+
readonly configSeeds: readonly ConfigSeedDef[];
|
|
173
175
|
readonly jobs: Readonly<Record<string, JobDefinition>>;
|
|
174
176
|
readonly registrarExtensions: Readonly<Record<string, RegistrarExtensionDef>>;
|
|
175
177
|
readonly extensionUsages: readonly RegistrarExtensionRegistration[];
|
|
@@ -356,8 +358,11 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
356
358
|
|
|
357
359
|
// Returns a handle map keyed exactly like the input. Pass any handle to
|
|
358
360
|
// `ctx.config(handle)` to get the value type narrowed by the key's `type`.
|
|
361
|
+
// Optional `seeds` declare boot-time system-rows that are written via the
|
|
362
|
+
// event-store executor — idempotent, skipped when the stream already exists.
|
|
359
363
|
config<TKeys extends Readonly<Record<string, ConfigKeyDefinition<ConfigKeyType>>>>(definition: {
|
|
360
364
|
readonly keys: TKeys;
|
|
365
|
+
readonly seeds?: Readonly<Record<string, ConfigSeedDef>>;
|
|
361
366
|
}): { readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]> };
|
|
362
367
|
|
|
363
368
|
job(name: string, options: Omit<JobDefinition, "name" | "handler">, handler: JobHandlerFn): void;
|
|
@@ -656,6 +661,7 @@ export type Registry = {
|
|
|
656
661
|
getAllTranslations(): TranslationKeys;
|
|
657
662
|
getConfigKey(qualifiedKey: string): ConfigKeyDefinition | undefined;
|
|
658
663
|
getAllConfigKeys(): ReadonlyMap<string, ConfigKeyDefinition>;
|
|
664
|
+
getAllConfigSeeds(): readonly ConfigSeedDef[];
|
|
659
665
|
// Feature-declared secrets, aggregated across all registered features.
|
|
660
666
|
// Keyed by qualified name ("<feature>:<shortName>"). Used by the rotation
|
|
661
667
|
// job (to iterate "known" secrets) and admin-UIs to list available keys.
|
|
@@ -13,6 +13,8 @@ export type {
|
|
|
13
13
|
ConfigAccessor,
|
|
14
14
|
ConfigAccessorFactory,
|
|
15
15
|
ConfigBounds,
|
|
16
|
+
ConfigCascade,
|
|
17
|
+
ConfigCascadeLevel,
|
|
16
18
|
ConfigComputedContext,
|
|
17
19
|
ConfigComputedFn,
|
|
18
20
|
ConfigDefinition,
|
|
@@ -21,10 +23,15 @@ export type {
|
|
|
21
23
|
ConfigKeyHandle,
|
|
22
24
|
ConfigKeyType,
|
|
23
25
|
ConfigResolver,
|
|
26
|
+
ConfigSeedDef,
|
|
24
27
|
ConfigStoredRow,
|
|
28
|
+
ConfigStoredRowWithSource,
|
|
25
29
|
ConfigValue,
|
|
26
30
|
ConfigValueSource,
|
|
27
31
|
ConfigValueWithSource,
|
|
32
|
+
CreateSeedOptions,
|
|
33
|
+
CreateTenantSeedOptions,
|
|
34
|
+
CreateUserSeedOptions,
|
|
28
35
|
JobDefinition,
|
|
29
36
|
JobHandlerFn,
|
|
30
37
|
JobRunIn,
|