@cosmicdrift/kumiko-bundled-features 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
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
+ });
@@ -9,6 +9,7 @@ export const ConfigHandlers = {
9
9
 
10
10
  // Qualified query handler names (QN format: scope:type:name)
11
11
  export const ConfigQueries = {
12
+ cascade: "config:query:cascade",
12
13
  values: "config:query:values",
13
14
  schema: "config:query:schema",
14
15
  } as const;
@@ -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 { type ConfigScope, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
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.getAll(query.user.tenantId, query.user.id, db);
22
+ const storedValues = await resolver.getAllWithSource(query.user.tenantId, query.user.id, db);
19
23
 
20
24
  const result: Record<
21
25
  string,
22
- { value: string | number | boolean | undefined; scope: ConfigScope }
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;