@cosmicdrift/kumiko-bundled-features 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 +32 -0
- package/package.json +5 -5
- package/src/auth-email-password/i18n.ts +8 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +128 -1
- package/src/auth-email-password/web/login-screen.tsx +73 -8
- package/src/config/__tests__/cascade.integration.ts +419 -0
- package/src/config/__tests__/config.integration.ts +109 -2
- package/src/config/constants.ts +1 -0
- package/src/config/feature.ts +2 -0
- package/src/config/handlers/cascade.query.ts +70 -0
- package/src/config/handlers/values.query.ts +14 -4
- package/src/config/index.ts +17 -0
- package/src/config/resolver.ts +273 -1
- package/src/template-resolver/api.ts +21 -5
- package/src/template-resolver/handlers/list.query.ts +2 -0
- package/src/template-resolver/handlers/upsert-system.write.ts +7 -1
- package/src/template-resolver/handlers/upsert-tenant.write.ts +8 -1
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { seedConfigValues } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type {
|
|
3
|
+
ConfigCascade,
|
|
4
|
+
ConfigKeyDefinition,
|
|
5
|
+
ConfigKeyType,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import {
|
|
8
|
+
access,
|
|
9
|
+
createSystemConfig,
|
|
10
|
+
createSystemSeed,
|
|
11
|
+
createTenantConfig,
|
|
12
|
+
createTenantSeed,
|
|
13
|
+
createUserConfig,
|
|
14
|
+
defineFeature,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import {
|
|
17
|
+
createTestUser,
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
TestUsers,
|
|
21
|
+
unsafePushTables,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
24
|
+
import { ConfigHandlers, ConfigQueries } from "../constants";
|
|
25
|
+
import { createConfigAccessorFactory, createConfigFeature } from "../feature";
|
|
26
|
+
import { type ConfigResolver, createConfigResolver } from "../resolver";
|
|
27
|
+
import { configValueEntity, configValuesTable } from "../table";
|
|
28
|
+
|
|
29
|
+
let stack: TestStack;
|
|
30
|
+
let db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
|
|
31
|
+
let resolver: ConfigResolver;
|
|
32
|
+
|
|
33
|
+
const tenantAdmin = createTestUser({ id: 2 });
|
|
34
|
+
|
|
35
|
+
const cascadeFeature = defineFeature("cascade-test", (r) => {
|
|
36
|
+
r.requires("config");
|
|
37
|
+
|
|
38
|
+
r.config({
|
|
39
|
+
keys: {
|
|
40
|
+
tenantKey: createTenantConfig("text", {
|
|
41
|
+
default: "DEFAULT_TENANT",
|
|
42
|
+
read: access.all,
|
|
43
|
+
write: access.all,
|
|
44
|
+
}),
|
|
45
|
+
userKey: createUserConfig("text", {
|
|
46
|
+
default: "DEFAULT_USER",
|
|
47
|
+
read: access.all,
|
|
48
|
+
write: access.all,
|
|
49
|
+
}),
|
|
50
|
+
systemKey: createSystemConfig("text", {
|
|
51
|
+
default: "DEFAULT_SYSTEM",
|
|
52
|
+
read: access.systemAdmin,
|
|
53
|
+
write: access.systemAdmin,
|
|
54
|
+
}),
|
|
55
|
+
numberKey: createTenantConfig("number", {
|
|
56
|
+
default: 0,
|
|
57
|
+
read: access.all,
|
|
58
|
+
write: access.all,
|
|
59
|
+
}),
|
|
60
|
+
booleanKey: createTenantConfig("boolean", {
|
|
61
|
+
default: false,
|
|
62
|
+
read: access.all,
|
|
63
|
+
write: access.all,
|
|
64
|
+
}),
|
|
65
|
+
computedKey: createTenantConfig("number", {
|
|
66
|
+
// Plan-based stub: always returns 42 so the test can assert the
|
|
67
|
+
// `computed` level shows up between app-override and default.
|
|
68
|
+
computed: async () => 42,
|
|
69
|
+
read: access.all,
|
|
70
|
+
write: access.all,
|
|
71
|
+
}),
|
|
72
|
+
},
|
|
73
|
+
seeds: {
|
|
74
|
+
tenantKey: createTenantSeed({ value: "SEED_TENANT" }),
|
|
75
|
+
systemKey: createSystemSeed({ value: "SEED_SYSTEM" }),
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const configFeature = createConfigFeature();
|
|
81
|
+
|
|
82
|
+
const TENANT_KEY = "cascade-test:config:tenant-key";
|
|
83
|
+
const USER_KEY = "cascade-test:config:user-key";
|
|
84
|
+
const SYSTEM_KEY = "cascade-test:config:system-key";
|
|
85
|
+
const NUMBER_KEY = "cascade-test:config:number-key";
|
|
86
|
+
const BOOLEAN_KEY = "cascade-test:config:boolean-key";
|
|
87
|
+
const COMPUTED_KEY = "cascade-test:config:computed-key";
|
|
88
|
+
|
|
89
|
+
beforeAll(async () => {
|
|
90
|
+
resolver = createConfigResolver();
|
|
91
|
+
|
|
92
|
+
stack = await setupTestStack({
|
|
93
|
+
features: [configFeature, cascadeFeature],
|
|
94
|
+
extraContext: ({ registry }) => ({
|
|
95
|
+
configResolver: resolver,
|
|
96
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
db = stack.db;
|
|
100
|
+
|
|
101
|
+
// Materialise the config-values projection table
|
|
102
|
+
await unsafePushTables(db, { configValuesTable });
|
|
103
|
+
|
|
104
|
+
// Execute seeds defined in the cascade-test feature
|
|
105
|
+
const seedDefs = stack.registry
|
|
106
|
+
.getAllConfigSeeds()
|
|
107
|
+
.filter((s) => s.key.startsWith("cascade-test:"));
|
|
108
|
+
await seedConfigValues(seedDefs, configValuesTable, configValueEntity, stack.registry, db);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
afterAll(async () => {
|
|
112
|
+
await stack.cleanup();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("getCascade", () => {
|
|
116
|
+
test("tenant-scope key with system-row (from seed) + default", async () => {
|
|
117
|
+
const keyDef = stack.registry.getConfigKey(TENANT_KEY);
|
|
118
|
+
expect(keyDef).toBeDefined();
|
|
119
|
+
|
|
120
|
+
const cascade = await resolver.getCascade(
|
|
121
|
+
TENANT_KEY,
|
|
122
|
+
keyDef!,
|
|
123
|
+
tenantAdmin.tenantId,
|
|
124
|
+
tenantAdmin.id,
|
|
125
|
+
db,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(cascade.levels.length).toBeGreaterThanOrEqual(4);
|
|
129
|
+
// Seed creates a system-row (tenantId = SYSTEM_TENANT_ID)
|
|
130
|
+
const systemLevel = cascade.levels.find((l) => l.source === "system-row");
|
|
131
|
+
expect(systemLevel).toBeDefined();
|
|
132
|
+
expect(systemLevel?.hasValue).toBe(true);
|
|
133
|
+
expect(systemLevel?.value).toBe("SEED_TENANT");
|
|
134
|
+
|
|
135
|
+
// No tenant-row for this tenant
|
|
136
|
+
const tenantLevel = cascade.levels.find((l) => l.source === "tenant-row");
|
|
137
|
+
expect(tenantLevel).toBeDefined();
|
|
138
|
+
expect(tenantLevel?.hasValue).toBe(false);
|
|
139
|
+
|
|
140
|
+
const defaultLevel = cascade.levels.find((l) => l.source === "default");
|
|
141
|
+
expect(defaultLevel).toBeDefined();
|
|
142
|
+
|
|
143
|
+
const activeLevels = cascade.levels.filter((l) => l.isActive);
|
|
144
|
+
expect(activeLevels.length).toBe(1);
|
|
145
|
+
expect(activeLevels[0]?.source).toBe("system-row");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("tenant-scope key without tenant-row — default active", async () => {
|
|
149
|
+
const keyDef = stack.registry.getConfigKey(NUMBER_KEY);
|
|
150
|
+
expect(keyDef).toBeDefined();
|
|
151
|
+
|
|
152
|
+
const cascade = await resolver.getCascade(
|
|
153
|
+
NUMBER_KEY,
|
|
154
|
+
keyDef!,
|
|
155
|
+
tenantAdmin.tenantId,
|
|
156
|
+
tenantAdmin.id,
|
|
157
|
+
db,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
expect(cascade.levels.length).toBeGreaterThanOrEqual(4);
|
|
161
|
+
|
|
162
|
+
const tenantLevel = cascade.levels.find((l) => l.source === "tenant-row");
|
|
163
|
+
expect(tenantLevel).toBeDefined();
|
|
164
|
+
expect(tenantLevel?.hasValue).toBe(false);
|
|
165
|
+
|
|
166
|
+
const systemLevel = cascade.levels.find((l) => l.source === "system-row");
|
|
167
|
+
expect(systemLevel).toBeDefined();
|
|
168
|
+
expect(systemLevel?.hasValue).toBe(false);
|
|
169
|
+
|
|
170
|
+
const activeLevels = cascade.levels.filter((l) => l.isActive);
|
|
171
|
+
expect(activeLevels.length).toBe(1);
|
|
172
|
+
expect(activeLevels[0]?.source).toBe("default");
|
|
173
|
+
expect(activeLevels[0]?.value).toBe(0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("tenant-scope key with default only (no rows)", async () => {
|
|
177
|
+
const keyDef = stack.registry.getConfigKey(BOOLEAN_KEY);
|
|
178
|
+
expect(keyDef).toBeDefined();
|
|
179
|
+
|
|
180
|
+
const cascade = await resolver.getCascade(
|
|
181
|
+
BOOLEAN_KEY,
|
|
182
|
+
keyDef!,
|
|
183
|
+
tenantAdmin.tenantId,
|
|
184
|
+
tenantAdmin.id,
|
|
185
|
+
db,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const activeLevels = cascade.levels.filter((l) => l.isActive);
|
|
189
|
+
expect(activeLevels.length).toBe(1);
|
|
190
|
+
expect(activeLevels[0]?.source).toBe("default");
|
|
191
|
+
expect(activeLevels[0]?.value).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("user-scope key with user + tenant-row", async () => {
|
|
195
|
+
await stack.http.writeOk(
|
|
196
|
+
ConfigHandlers.set,
|
|
197
|
+
{ key: USER_KEY, value: "TENANT_VAL", scope: "tenant" },
|
|
198
|
+
tenantAdmin,
|
|
199
|
+
);
|
|
200
|
+
await stack.http.writeOk(
|
|
201
|
+
ConfigHandlers.set,
|
|
202
|
+
{ key: USER_KEY, value: "USER_VAL", scope: "user" },
|
|
203
|
+
tenantAdmin,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const keyDef = stack.registry.getConfigKey(USER_KEY);
|
|
207
|
+
expect(keyDef).toBeDefined();
|
|
208
|
+
|
|
209
|
+
const cascade = await resolver.getCascade(
|
|
210
|
+
USER_KEY,
|
|
211
|
+
keyDef!,
|
|
212
|
+
tenantAdmin.tenantId,
|
|
213
|
+
tenantAdmin.id,
|
|
214
|
+
db,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const userLevel = cascade.levels.find((l) => l.source === "user-row");
|
|
218
|
+
expect(userLevel).toBeDefined();
|
|
219
|
+
expect(userLevel?.value).toBe("USER_VAL");
|
|
220
|
+
expect(userLevel?.isActive).toBe(true);
|
|
221
|
+
|
|
222
|
+
const tenantLevel = cascade.levels.find((l) => l.source === "tenant-row");
|
|
223
|
+
expect(tenantLevel).toBeDefined();
|
|
224
|
+
expect(tenantLevel?.value).toBe("TENANT_VAL");
|
|
225
|
+
expect(tenantLevel?.isActive).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("system-scope key with system-row + default", async () => {
|
|
229
|
+
const keyDef = stack.registry.getConfigKey(SYSTEM_KEY);
|
|
230
|
+
expect(keyDef).toBeDefined();
|
|
231
|
+
|
|
232
|
+
const cascade = await resolver.getCascade(
|
|
233
|
+
SYSTEM_KEY,
|
|
234
|
+
keyDef!,
|
|
235
|
+
tenantAdmin.tenantId,
|
|
236
|
+
tenantAdmin.id,
|
|
237
|
+
db,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
expect(cascade.levels.length).toBeGreaterThanOrEqual(3);
|
|
241
|
+
const systemLevel = cascade.levels.find((l) => l.source === "system-row");
|
|
242
|
+
expect(systemLevel).toBeDefined();
|
|
243
|
+
expect(systemLevel?.hasValue).toBe(true);
|
|
244
|
+
expect(systemLevel?.value).toBe("SEED_SYSTEM");
|
|
245
|
+
expect(systemLevel?.isActive).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("getCascadeBatch", () => {
|
|
250
|
+
test("batch returns cascades for multiple keys", async () => {
|
|
251
|
+
const keys = [TENANT_KEY, NUMBER_KEY, BOOLEAN_KEY];
|
|
252
|
+
const keyDefs = new Map<string, ConfigKeyDefinition<ConfigKeyType>>();
|
|
253
|
+
for (const k of keys) {
|
|
254
|
+
const keyDef = stack.registry.getConfigKey(k);
|
|
255
|
+
if (keyDef) keyDefs.set(k, keyDef);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const cascades = await resolver.getCascadeBatch(
|
|
259
|
+
keys,
|
|
260
|
+
keyDefs,
|
|
261
|
+
tenantAdmin.tenantId,
|
|
262
|
+
tenantAdmin.id,
|
|
263
|
+
db,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
expect(cascades.size).toBe(3);
|
|
267
|
+
for (const [, cascade] of cascades) {
|
|
268
|
+
expect(cascade.levels.length).toBeGreaterThanOrEqual(4);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("empty keys returns empty map", async () => {
|
|
273
|
+
const cascades = await resolver.getCascadeBatch(
|
|
274
|
+
[],
|
|
275
|
+
new Map(),
|
|
276
|
+
tenantAdmin.tenantId,
|
|
277
|
+
tenantAdmin.id,
|
|
278
|
+
db,
|
|
279
|
+
);
|
|
280
|
+
expect(cascades.size).toBe(0);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("cascade levels — non-DB sources", () => {
|
|
285
|
+
test("computed key shows as active computed level when no row exists", async () => {
|
|
286
|
+
const keyDef = stack.registry.getConfigKey(COMPUTED_KEY);
|
|
287
|
+
expect(keyDef).toBeDefined();
|
|
288
|
+
|
|
289
|
+
const cascade = await resolver.getCascade(
|
|
290
|
+
COMPUTED_KEY,
|
|
291
|
+
keyDef!,
|
|
292
|
+
tenantAdmin.tenantId,
|
|
293
|
+
tenantAdmin.id,
|
|
294
|
+
db,
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const computedLevel = cascade.levels.find((l) => l.source === "computed");
|
|
298
|
+
expect(computedLevel).toBeDefined();
|
|
299
|
+
expect(computedLevel?.hasValue).toBe(true);
|
|
300
|
+
expect(computedLevel?.value).toBe(42);
|
|
301
|
+
expect(computedLevel?.isActive).toBe(true);
|
|
302
|
+
expect(cascade.value).toBe(42);
|
|
303
|
+
expect(cascade.source).toBe("computed");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("app-override appears above computed/default when set in resolver options", async () => {
|
|
307
|
+
// Build a one-off resolver with appOverrides to verify the cascade
|
|
308
|
+
// surfaces the override-level — main `resolver` is plain and would
|
|
309
|
+
// skip this path.
|
|
310
|
+
const overrideResolver = createConfigResolver({
|
|
311
|
+
appOverrides: new Map<string, string | number | boolean>([[BOOLEAN_KEY, true]]),
|
|
312
|
+
});
|
|
313
|
+
const keyDef = stack.registry.getConfigKey(BOOLEAN_KEY);
|
|
314
|
+
expect(keyDef).toBeDefined();
|
|
315
|
+
|
|
316
|
+
const cascade = await overrideResolver.getCascade(
|
|
317
|
+
BOOLEAN_KEY,
|
|
318
|
+
keyDef!,
|
|
319
|
+
tenantAdmin.tenantId,
|
|
320
|
+
tenantAdmin.id,
|
|
321
|
+
db,
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const overrideLevel = cascade.levels.find((l) => l.source === "app-override");
|
|
325
|
+
expect(overrideLevel).toBeDefined();
|
|
326
|
+
expect(overrideLevel?.hasValue).toBe(true);
|
|
327
|
+
expect(overrideLevel?.value).toBe(true);
|
|
328
|
+
expect(overrideLevel?.isActive).toBe(true);
|
|
329
|
+
// System-row stays empty; tenant-row also empty → override wins.
|
|
330
|
+
expect(cascade.value).toBe(true);
|
|
331
|
+
expect(cascade.source).toBe("app-override");
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe("reset cycle regression", () => {
|
|
336
|
+
// Pins the executor-create-with-fresh-id contract: hard-delete +
|
|
337
|
+
// re-set must hit a NEW aggregate stream, never version_conflict
|
|
338
|
+
// against the deleted one. If someone ever flips configValueEntity
|
|
339
|
+
// to deterministic IDs without adjusting set.write.ts, this test
|
|
340
|
+
// will catch the regression.
|
|
341
|
+
test("set → reset → set succeeds (no version_conflict)", async () => {
|
|
342
|
+
const RESET_KEY = NUMBER_KEY;
|
|
343
|
+
|
|
344
|
+
await stack.http.writeOk(
|
|
345
|
+
ConfigHandlers.set,
|
|
346
|
+
{ key: RESET_KEY, value: 11, scope: "tenant" },
|
|
347
|
+
tenantAdmin,
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
await stack.http.writeOk(
|
|
351
|
+
ConfigHandlers.reset,
|
|
352
|
+
{ key: RESET_KEY, scope: "tenant" },
|
|
353
|
+
tenantAdmin,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
// Second set after reset — would version_conflict if the executor
|
|
357
|
+
// re-used the deleted stream's aggregateId.
|
|
358
|
+
await stack.http.writeOk(
|
|
359
|
+
ConfigHandlers.set,
|
|
360
|
+
{ key: RESET_KEY, value: 22, scope: "tenant" },
|
|
361
|
+
tenantAdmin,
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const keyDef = stack.registry.getConfigKey(RESET_KEY);
|
|
365
|
+
expect(keyDef).toBeDefined();
|
|
366
|
+
const cascade = await resolver.getCascade(
|
|
367
|
+
RESET_KEY,
|
|
368
|
+
keyDef!,
|
|
369
|
+
tenantAdmin.tenantId,
|
|
370
|
+
tenantAdmin.id,
|
|
371
|
+
db,
|
|
372
|
+
);
|
|
373
|
+
expect(cascade.value).toBe(22);
|
|
374
|
+
expect(cascade.source).toBe("tenant-row");
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("config:query:cascade handler", () => {
|
|
379
|
+
test("systemAdmin sees all keys including SystemAdmin-restricted ones", async () => {
|
|
380
|
+
const data = await stack.http.queryOk<Record<string, ConfigCascade>>(
|
|
381
|
+
ConfigQueries.cascade,
|
|
382
|
+
{},
|
|
383
|
+
TestUsers.systemAdmin,
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
expect(data[TENANT_KEY]).toBeDefined();
|
|
387
|
+
expect(data[NUMBER_KEY]).toBeDefined();
|
|
388
|
+
expect(data[BOOLEAN_KEY]).toBeDefined();
|
|
389
|
+
expect(data[SYSTEM_KEY]).toBeDefined();
|
|
390
|
+
|
|
391
|
+
for (const cascade of Object.values(data)) {
|
|
392
|
+
expect(cascade.levels).toBeDefined();
|
|
393
|
+
expect(Array.isArray(cascade.levels)).toBe(true);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("filters to specific keys when keys param is set", async () => {
|
|
398
|
+
const data = await stack.http.queryOk<Record<string, ConfigCascade>>(
|
|
399
|
+
ConfigQueries.cascade,
|
|
400
|
+
{ keys: [TENANT_KEY] },
|
|
401
|
+
tenantAdmin,
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
expect(Object.keys(data).length).toBe(1);
|
|
405
|
+
expect(data[TENANT_KEY]).toBeDefined();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("user without read access does not see SystemAdmin-restricted key", async () => {
|
|
409
|
+
const data = await stack.http.queryOk<Record<string, ConfigCascade>>(
|
|
410
|
+
ConfigQueries.cascade,
|
|
411
|
+
{},
|
|
412
|
+
createTestUser({ id: 99, roles: ["User"] }),
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
expect(data[SYSTEM_KEY]).toBeUndefined();
|
|
416
|
+
// But keys with `read: access.all` stay visible to plain users.
|
|
417
|
+
expect(data[TENANT_KEY]).toBeDefined();
|
|
418
|
+
});
|
|
419
|
+
});
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createEncryptionProvider,
|
|
4
|
+
type DbConnection,
|
|
5
|
+
seedConfigValues,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
3
7
|
import {
|
|
4
8
|
access,
|
|
9
|
+
createSeed,
|
|
5
10
|
createSystemConfig,
|
|
11
|
+
createSystemSeed,
|
|
6
12
|
createTenantConfig,
|
|
7
13
|
createUserConfig,
|
|
8
14
|
defineFeature,
|
|
15
|
+
type TenantId,
|
|
9
16
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
17
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
11
18
|
import {
|
|
@@ -22,7 +29,7 @@ import { z } from "zod";
|
|
|
22
29
|
import { ConfigHandlers, ConfigQueries } from "../constants";
|
|
23
30
|
import { createConfigAccessor, createConfigAccessorFactory, createConfigFeature } from "../feature";
|
|
24
31
|
import { type ConfigResolver, createConfigResolver, validateAppOverrides } from "../resolver";
|
|
25
|
-
import { configValuesTable } from "../table";
|
|
32
|
+
import { configValueEntity, configValuesTable } from "../table";
|
|
26
33
|
|
|
27
34
|
// --- Setup ---
|
|
28
35
|
|
|
@@ -173,6 +180,22 @@ const integrationFeature = defineFeature("integration", (r) => {
|
|
|
173
180
|
});
|
|
174
181
|
|
|
175
182
|
const configFeature = createConfigFeature();
|
|
183
|
+
|
|
184
|
+
// Scenario 11: Config seeding — feature with deploy-time defaults
|
|
185
|
+
const seedFeature = defineFeature("seeddemo", (r) => {
|
|
186
|
+
r.requires("config");
|
|
187
|
+
return r.config({
|
|
188
|
+
keys: {
|
|
189
|
+
themeColor: createTenantConfig("text", { default: "blue" }),
|
|
190
|
+
maintenanceMode: createSystemConfig("boolean", { default: false }),
|
|
191
|
+
},
|
|
192
|
+
seeds: {
|
|
193
|
+
themeColor: createSeed({ value: "dark" }),
|
|
194
|
+
maintenanceMode: createSystemSeed({ value: true }),
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
176
199
|
const testEncryptionKey = randomBytes(32).toString("base64");
|
|
177
200
|
|
|
178
201
|
beforeAll(async () => {
|
|
@@ -188,6 +211,7 @@ beforeAll(async () => {
|
|
|
188
211
|
ordersFeature,
|
|
189
212
|
integrationFeature,
|
|
190
213
|
probeFeature,
|
|
214
|
+
seedFeature,
|
|
191
215
|
],
|
|
192
216
|
// Wire `ctx.config()` for real handlers: pass the resolver-bound factory
|
|
193
217
|
// so the dispatcher can mint a per-user accessor inside buildHandlerContext.
|
|
@@ -1244,3 +1268,86 @@ describe("scenario 10: getWithSource reports source-of-truth", () => {
|
|
|
1244
1268
|
expect(traced.value).toBe(flat);
|
|
1245
1269
|
});
|
|
1246
1270
|
});
|
|
1271
|
+
|
|
1272
|
+
// --- Scenario 11: Config Seeding ---
|
|
1273
|
+
//
|
|
1274
|
+
// Seeds are deploy-time defaults written as system-rows via the event-store
|
|
1275
|
+
// executor. They sit at cascade level 4 (system-row) — above app-override
|
|
1276
|
+
// and default but below any explicit user/tenant row.
|
|
1277
|
+
|
|
1278
|
+
describe("scenario 11: config seeding", () => {
|
|
1279
|
+
const SEED_THEME = "seeddemo:config:theme-color";
|
|
1280
|
+
const SEED_MAINT = "seeddemo:config:maintenance-mode";
|
|
1281
|
+
const T1 = "00000000-0000-4000-8000-0000000000aa" as TenantId;
|
|
1282
|
+
const T2 = "00000000-0000-4000-8000-0000000000bb" as TenantId;
|
|
1283
|
+
const T3 = "00000000-0000-4000-8000-0000000000cc" as TenantId;
|
|
1284
|
+
|
|
1285
|
+
beforeAll(async () => {
|
|
1286
|
+
const seedDefs = stack.registry
|
|
1287
|
+
.getAllConfigSeeds()
|
|
1288
|
+
.filter((s) => s.key.startsWith("seeddemo:"));
|
|
1289
|
+
await seedConfigValues(seedDefs, configValuesTable, configValueEntity, stack.registry, db);
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
test("returns seed value when no row exists", async () => {
|
|
1293
|
+
const configFn = createConfigAccessor(
|
|
1294
|
+
stack.registry,
|
|
1295
|
+
resolver,
|
|
1296
|
+
T1,
|
|
1297
|
+
"00000000-0000-4000-8000-0000000000aa",
|
|
1298
|
+
db,
|
|
1299
|
+
);
|
|
1300
|
+
expect(await configFn(SEED_THEME)).toBe("dark");
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
test("system-scope seed produces system-row in cascade", async () => {
|
|
1304
|
+
const configFn = createConfigAccessor(
|
|
1305
|
+
stack.registry,
|
|
1306
|
+
resolver,
|
|
1307
|
+
T1,
|
|
1308
|
+
"00000000-0000-4000-8000-0000000000aa",
|
|
1309
|
+
db,
|
|
1310
|
+
);
|
|
1311
|
+
expect(await configFn(SEED_MAINT)).toBe(true);
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
test("getWithSource reports source=system-row for seeded values", async () => {
|
|
1315
|
+
const keyDef = stack.registry.getConfigKey(SEED_THEME);
|
|
1316
|
+
if (!keyDef) throw new Error("key missing");
|
|
1317
|
+
const traced = await resolver.getWithSource(
|
|
1318
|
+
SEED_THEME,
|
|
1319
|
+
keyDef,
|
|
1320
|
+
T2,
|
|
1321
|
+
"00000000-0000-4000-8000-0000000000bb",
|
|
1322
|
+
db,
|
|
1323
|
+
);
|
|
1324
|
+
expect(traced.value).toBe("dark");
|
|
1325
|
+
expect(traced.source).toBe("system-row");
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
test("seed + app-override: seed wins (system-row > app-override)", async () => {
|
|
1329
|
+
const resolverWithOverride = createConfigResolver({
|
|
1330
|
+
appOverrides: validateAppOverrides(stack.registry, { [SEED_THEME]: "pink" }),
|
|
1331
|
+
});
|
|
1332
|
+
const configFn = createConfigAccessor(
|
|
1333
|
+
stack.registry,
|
|
1334
|
+
resolverWithOverride,
|
|
1335
|
+
T3,
|
|
1336
|
+
"00000000-0000-4000-8000-0000000000cc",
|
|
1337
|
+
db,
|
|
1338
|
+
);
|
|
1339
|
+
expect(await configFn(SEED_THEME)).toBe("dark");
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
test("admin override beats seed (tenant-row > system-row)", async () => {
|
|
1343
|
+
await stack.http.writeOk(ConfigHandlers.set, { key: SEED_THEME, value: "red" }, tenantAdmin);
|
|
1344
|
+
const configFn = createConfigAccessor(
|
|
1345
|
+
stack.registry,
|
|
1346
|
+
resolver,
|
|
1347
|
+
tenantAdmin.tenantId,
|
|
1348
|
+
tenantAdmin.id,
|
|
1349
|
+
db,
|
|
1350
|
+
);
|
|
1351
|
+
expect(await configFn(SEED_THEME)).toBe("red");
|
|
1352
|
+
});
|
|
1353
|
+
});
|
package/src/config/constants.ts
CHANGED
package/src/config/feature.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type TenantId,
|
|
13
13
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
14
14
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
15
|
+
import { cascadeQuery } from "./handlers/cascade.query";
|
|
15
16
|
import { resetWrite } from "./handlers/reset.write";
|
|
16
17
|
import { schemaQuery } from "./handlers/schema.query";
|
|
17
18
|
import { setWrite } from "./handlers/set.write";
|
|
@@ -38,6 +39,7 @@ export function createConfigFeature(): FeatureDefinition {
|
|
|
38
39
|
};
|
|
39
40
|
|
|
40
41
|
const queries = {
|
|
42
|
+
cascade: r.queryHandler(cascadeQuery),
|
|
41
43
|
values: r.queryHandler(valuesQuery),
|
|
42
44
|
schema: r.queryHandler(schemaQuery),
|
|
43
45
|
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ConfigCascade,
|
|
3
|
+
type ConfigCascadeLevel,
|
|
4
|
+
defineQueryHandler,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { requireConfigResolver } from "../feature";
|
|
8
|
+
import { hasConfigAccess } from "../write-helpers";
|
|
9
|
+
|
|
10
|
+
const MASKED = "••••••";
|
|
11
|
+
|
|
12
|
+
export const cascadeQuery = defineQueryHandler({
|
|
13
|
+
name: "cascade",
|
|
14
|
+
schema: z.object({
|
|
15
|
+
keys: z.array(z.string()).optional(),
|
|
16
|
+
}),
|
|
17
|
+
access: { openToAll: true },
|
|
18
|
+
handler: async (query, ctx) => {
|
|
19
|
+
const db = ctx.db;
|
|
20
|
+
const registry = ctx.registry;
|
|
21
|
+
const resolver = requireConfigResolver(ctx, "config:query:cascade");
|
|
22
|
+
|
|
23
|
+
const allKeys = registry.getAllConfigKeys();
|
|
24
|
+
const keys = query.payload.keys ?? Array.from(allKeys.keys());
|
|
25
|
+
|
|
26
|
+
const keyDefs = new Map<
|
|
27
|
+
string,
|
|
28
|
+
import("@cosmicdrift/kumiko-framework/engine").ConfigKeyDefinition
|
|
29
|
+
>();
|
|
30
|
+
const filteredKeys: string[] = [];
|
|
31
|
+
|
|
32
|
+
for (const key of keys) {
|
|
33
|
+
const keyDef = allKeys.get(key);
|
|
34
|
+
if (!keyDef) continue;
|
|
35
|
+
if (!hasConfigAccess(keyDef.access.read, query.user.roles)) continue;
|
|
36
|
+
keyDefs.set(key, keyDef);
|
|
37
|
+
filteredKeys.push(key);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cascades = await resolver.getCascadeBatch(
|
|
41
|
+
filteredKeys,
|
|
42
|
+
keyDefs,
|
|
43
|
+
query.user.tenantId,
|
|
44
|
+
query.user.id,
|
|
45
|
+
db,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const result: Record<string, ConfigCascade> = {};
|
|
49
|
+
for (const [key, cascade] of cascades) {
|
|
50
|
+
const keyDef = keyDefs.get(key);
|
|
51
|
+
if (!keyDef) continue;
|
|
52
|
+
|
|
53
|
+
if (keyDef.encrypted) {
|
|
54
|
+
const maskedLevels: ConfigCascadeLevel[] = cascade.levels.map((l) => ({
|
|
55
|
+
...l,
|
|
56
|
+
value: l.hasValue ? MASKED : l.value,
|
|
57
|
+
}));
|
|
58
|
+
result[key] = {
|
|
59
|
+
value: cascade.value !== undefined ? MASKED : cascade.value,
|
|
60
|
+
source: cascade.source,
|
|
61
|
+
levels: maskedLevels,
|
|
62
|
+
};
|
|
63
|
+
} else {
|
|
64
|
+
result[key] = cascade;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return result;
|
|
69
|
+
},
|
|
70
|
+
});
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type ConfigScope,
|
|
3
|
+
type ConfigValueSource,
|
|
4
|
+
defineQueryHandler,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
2
6
|
import { z } from "zod";
|
|
3
7
|
import { requireConfigResolver } from "../feature";
|
|
4
8
|
import { deserializeValue } from "../resolver";
|
|
@@ -15,11 +19,15 @@ export const valuesQuery = defineQueryHandler({
|
|
|
15
19
|
const resolver = requireConfigResolver(ctx, "config:query:values");
|
|
16
20
|
|
|
17
21
|
const allKeys = registry.getAllConfigKeys();
|
|
18
|
-
const storedValues = await resolver.
|
|
22
|
+
const storedValues = await resolver.getAllWithSource(query.user.tenantId, query.user.id, db);
|
|
19
23
|
|
|
20
24
|
const result: Record<
|
|
21
25
|
string,
|
|
22
|
-
{
|
|
26
|
+
{
|
|
27
|
+
value: string | number | boolean | undefined;
|
|
28
|
+
scope: ConfigScope;
|
|
29
|
+
source: ConfigValueSource;
|
|
30
|
+
}
|
|
23
31
|
> = {};
|
|
24
32
|
|
|
25
33
|
for (const [qualifiedKey, keyDef] of allKeys) {
|
|
@@ -27,6 +35,8 @@ export const valuesQuery = defineQueryHandler({
|
|
|
27
35
|
|
|
28
36
|
const stored = storedValues.get(qualifiedKey);
|
|
29
37
|
let value: string | number | boolean | undefined;
|
|
38
|
+
const source: ConfigValueSource = stored?.source ?? "default";
|
|
39
|
+
|
|
30
40
|
if (keyDef.encrypted) {
|
|
31
41
|
value = stored ? "••••••" : undefined;
|
|
32
42
|
} else if (stored?.value !== null && stored?.value !== undefined) {
|
|
@@ -35,7 +45,7 @@ export const valuesQuery = defineQueryHandler({
|
|
|
35
45
|
value = keyDef.default;
|
|
36
46
|
}
|
|
37
47
|
|
|
38
|
-
result[qualifiedKey] = { value, scope: keyDef.scope };
|
|
48
|
+
result[qualifiedKey] = { value, scope: keyDef.scope, source };
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
return result;
|