@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.
@@ -1,3 +1,8 @@
1
+ import type { DbConnection, EncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
2
+ import { seedConfigValues } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { configValueEntity, configValuesTable } from "./table";
5
+
1
6
  export {
2
7
  CONFIG_FEATURE,
3
8
  ConfigErrors,
@@ -13,3 +18,15 @@ export {
13
18
  export type { AppConfigOverrides, ConfigResolver } from "./resolver";
14
19
  export { createConfigResolver, validateAppOverrides } from "./resolver";
15
20
  export { configValuesTable } from "./table";
21
+
22
+ // Boot helper for runDevApp / runProdApp: pulls every ConfigSeedDef from
23
+ // the registry and writes the matching system/tenant/user rows via the
24
+ // event-store executor. Idempotent across boots — see config-seeding.md.
25
+ export function seedAllConfigValues(
26
+ registry: Registry,
27
+ db: DbConnection,
28
+ encryption?: EncryptionProvider,
29
+ ): Promise<{ created: number; skipped: number }> {
30
+ const seeds = registry.getAllConfigSeeds();
31
+ return seedConfigValues(seeds, configValuesTable, configValueEntity, registry, db, encryption);
32
+ }
@@ -5,14 +5,17 @@ import {
5
5
  type TenantDb,
6
6
  } from "@cosmicdrift/kumiko-framework/db";
7
7
  import type {
8
+ ConfigCascade,
9
+ ConfigCascadeLevel,
8
10
  ConfigKeyDefinition,
9
11
  ConfigResolver,
12
+ ConfigStoredRowWithSource,
10
13
  ConfigValueSource,
11
14
  ConfigValueWithSource,
12
15
  } from "@cosmicdrift/kumiko-framework/engine";
13
16
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
14
17
  import { assertUnreachable, parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
15
- import { and, eq, isNull, or } from "drizzle-orm";
18
+ import { and, eq, inArray, isNull, or } from "drizzle-orm";
16
19
  import { configValuesTable } from "./table";
17
20
 
18
21
  type ConfigRow = {
@@ -66,6 +69,148 @@ export type ConfigResolverOptions = {
66
69
  appOverrides?: AppConfigOverrides;
67
70
  };
68
71
 
72
+ // Shared cascade-builder. Single-key path passes a `findRow`-bound row
73
+ // fetcher (one SQL per lookup); batch path passes a closure over
74
+ // pre-loaded rows. The builder itself is unaware of which.
75
+ async function buildCascade(
76
+ qualifiedKey: string,
77
+ keyDef: ConfigKeyDefinition,
78
+ tenantId: string,
79
+ userId: string,
80
+ db: DbConnection | TenantDb,
81
+ fetchRow: (
82
+ tenantId: string,
83
+ userId: string | null,
84
+ ) => Promise<ConfigRow | null> | ConfigRow | null,
85
+ appOverrides: AppConfigOverrides | undefined,
86
+ encryption: EncryptionProvider | undefined,
87
+ ): Promise<ConfigCascade> {
88
+ type Lookup = {
89
+ tenantId: string;
90
+ userId: string | null;
91
+ source: ConfigValueSource;
92
+ label: string;
93
+ };
94
+ const lookups: Lookup[] = [];
95
+
96
+ switch (keyDef.scope) {
97
+ case "user":
98
+ lookups.push({ tenantId, userId, source: "user-row", label: "User" });
99
+ lookups.push({ tenantId, userId: null, source: "tenant-row", label: "Tenant" });
100
+ break;
101
+ case "tenant":
102
+ lookups.push({ tenantId, userId: null, source: "tenant-row", label: "Tenant" });
103
+ lookups.push({
104
+ tenantId: SYSTEM_TENANT_ID,
105
+ userId: null,
106
+ source: "system-row",
107
+ label: "System",
108
+ });
109
+ break;
110
+ case "system":
111
+ lookups.push({
112
+ tenantId: SYSTEM_TENANT_ID,
113
+ userId: null,
114
+ source: "system-row",
115
+ label: "System",
116
+ });
117
+ break;
118
+ default:
119
+ assertUnreachable(keyDef.scope, "config scope");
120
+ }
121
+
122
+ const levels: ConfigCascadeLevel[] = [];
123
+ let activeIndex = -1;
124
+
125
+ for (const lookup of lookups) {
126
+ const row = await fetchRow(lookup.tenantId, lookup.userId);
127
+ if (row?.value !== null && row?.value !== undefined) {
128
+ let raw = row.value;
129
+ if (keyDef.encrypted && encryption) {
130
+ raw = encryption.decrypt(raw);
131
+ }
132
+ if (activeIndex === -1) activeIndex = levels.length;
133
+ levels.push({
134
+ label: lookup.label,
135
+ value: deserializeValue(raw, keyDef.type),
136
+ source: lookup.source,
137
+ isActive: false,
138
+ hasValue: true,
139
+ });
140
+ } else {
141
+ levels.push({
142
+ label: lookup.label,
143
+ value: undefined,
144
+ source: lookup.source,
145
+ isActive: false,
146
+ hasValue: false,
147
+ });
148
+ }
149
+ }
150
+
151
+ const overrideValue = appOverrides?.get(qualifiedKey);
152
+ const hasOverride = overrideValue !== undefined;
153
+ if (activeIndex === -1 && hasOverride) activeIndex = levels.length;
154
+ levels.push({
155
+ label: "App-Override",
156
+ value: overrideValue,
157
+ source: "app-override",
158
+ isActive: false,
159
+ hasValue: hasOverride,
160
+ });
161
+
162
+ if (keyDef.computed) {
163
+ const value = await keyDef.computed({ tenantId, userId, db });
164
+ if (activeIndex === -1) activeIndex = levels.length;
165
+ levels.push({
166
+ label: "Computed",
167
+ value,
168
+ source: "computed",
169
+ isActive: false,
170
+ hasValue: true,
171
+ });
172
+ } else {
173
+ levels.push({
174
+ label: "Computed",
175
+ value: undefined,
176
+ source: "computed",
177
+ isActive: false,
178
+ hasValue: false,
179
+ });
180
+ }
181
+
182
+ if (keyDef.default !== undefined) {
183
+ if (activeIndex === -1) activeIndex = levels.length;
184
+ levels.push({
185
+ label: "Default",
186
+ value: keyDef.default,
187
+ source: "default",
188
+ isActive: false,
189
+ hasValue: true,
190
+ });
191
+ } else {
192
+ if (activeIndex === -1) activeIndex = levels.length;
193
+ levels.push({
194
+ label: "Default",
195
+ value: undefined,
196
+ source: "missing",
197
+ isActive: false,
198
+ hasValue: false,
199
+ });
200
+ }
201
+
202
+ const active = activeIndex >= 0 ? levels[activeIndex] : undefined;
203
+ if (active !== undefined) {
204
+ levels[activeIndex] = { ...active, isActive: true };
205
+ }
206
+
207
+ return {
208
+ value: active?.value,
209
+ source: active?.source ?? "missing",
210
+ levels,
211
+ };
212
+ }
213
+
69
214
  export function createConfigResolver(options: ConfigResolverOptions = {}): ConfigResolver {
70
215
  const { encryption, appOverrides } = options;
71
216
  async function findRow(
@@ -193,6 +338,133 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
193
338
 
194
339
  return result;
195
340
  },
341
+
342
+ async getAllWithSource(tenantId, userId, db) {
343
+ // Load ALL potentially relevant rows (user + tenant + system)
344
+ const rows = await db
345
+ .select()
346
+ .from(configValuesTable)
347
+ .where(
348
+ or(
349
+ and(eq(configValuesTable.tenantId, SYSTEM_TENANT_ID), isNull(configValuesTable.userId)),
350
+ and(eq(configValuesTable.tenantId, tenantId), isNull(configValuesTable.userId)),
351
+ and(eq(configValuesTable.tenantId, tenantId), eq(configValuesTable.userId, userId)),
352
+ ),
353
+ );
354
+
355
+ const result = new Map<string, ConfigStoredRowWithSource>();
356
+
357
+ // Group rows by key so we can determine the winner and its source
358
+ const groups = new Map<string, ConfigRow[]>();
359
+ for (const row of rows) {
360
+ const r = row as ConfigRow; // @cast-boundary db-row
361
+ const g = groups.get(r.key) ?? [];
362
+ g.push(r);
363
+ groups.set(r.key, g);
364
+ }
365
+
366
+ for (const [key, keyRows] of groups) {
367
+ const specificityOf = (candidate: ConfigRow) =>
368
+ (candidate.userId !== null ? 2 : 0) + (candidate.tenantId !== SYSTEM_TENANT_ID ? 1 : 0);
369
+
370
+ const first = keyRows[0];
371
+ if (!first) continue;
372
+ let winner: ConfigRow = first;
373
+ for (const r of keyRows) {
374
+ if (specificityOf(r) > specificityOf(winner)) {
375
+ winner = r;
376
+ }
377
+ }
378
+
379
+ let source: ConfigValueSource;
380
+ if (winner.userId !== null) {
381
+ source = "user-row";
382
+ } else if (winner.tenantId !== SYSTEM_TENANT_ID) {
383
+ source = "tenant-row";
384
+ } else {
385
+ source = "system-row";
386
+ }
387
+
388
+ result.set(key, { ...winner, source });
389
+ }
390
+
391
+ return result;
392
+ },
393
+
394
+ async getCascade(qualifiedKey, keyDef, tenantId, userId, db): Promise<ConfigCascade> {
395
+ // Single-key path uses findRow per cascade step. The batch path
396
+ // bulk-loads all rows up-front; both build identical levels arrays.
397
+ return buildCascade(
398
+ qualifiedKey,
399
+ keyDef,
400
+ tenantId,
401
+ userId,
402
+ db,
403
+ (tid, uid) => findRow(qualifiedKey, tid, uid, db),
404
+ appOverrides,
405
+ encryption,
406
+ );
407
+ },
408
+
409
+ async getCascadeBatch(
410
+ keys,
411
+ keyDefs,
412
+ tenantId,
413
+ userId,
414
+ db,
415
+ ): Promise<ReadonlyMap<string, ConfigCascade>> {
416
+ if (keys.length === 0) return new Map();
417
+
418
+ // One SQL query for all keys + every scope (user-row,
419
+ // tenant-row, system-row). The cascade-builder then matches
420
+ // per-key from this preloaded set instead of querying again.
421
+ const rows = await db
422
+ .select()
423
+ .from(configValuesTable)
424
+ .where(
425
+ and(
426
+ inArray(configValuesTable.key, [...keys]),
427
+ or(
428
+ and(
429
+ eq(configValuesTable.tenantId, SYSTEM_TENANT_ID),
430
+ isNull(configValuesTable.userId),
431
+ ),
432
+ and(eq(configValuesTable.tenantId, tenantId), isNull(configValuesTable.userId)),
433
+ and(eq(configValuesTable.tenantId, tenantId), eq(configValuesTable.userId, userId)),
434
+ ),
435
+ ),
436
+ );
437
+
438
+ const grouped = new Map<string, ConfigRow[]>();
439
+ for (const row of rows) {
440
+ const r = row as ConfigRow; // @cast-boundary db-row
441
+ const g = grouped.get(r.key) ?? [];
442
+ g.push(r);
443
+ grouped.set(r.key, g);
444
+ }
445
+
446
+ const result = new Map<string, ConfigCascade>();
447
+ for (const key of keys) {
448
+ const keyDef = keyDefs.get(key);
449
+ if (!keyDef) continue;
450
+
451
+ const keyRows = grouped.get(key) ?? [];
452
+ const cascade = await buildCascade(
453
+ key,
454
+ keyDef,
455
+ tenantId,
456
+ userId,
457
+ db,
458
+ (tid, uid) =>
459
+ keyRows.find((r) => r.tenantId === tid && (r.userId ?? null) === uid) ?? null,
460
+ appOverrides,
461
+ encryption,
462
+ );
463
+ result.set(key, cascade);
464
+ }
465
+
466
+ return result;
467
+ },
196
468
  };
197
469
  }
198
470
 
@@ -129,20 +129,34 @@ async function fetchTemplate(
129
129
  }
130
130
 
131
131
  function toPublic(row: TemplateResourceRow): TemplateResource {
132
+ // @cast-boundary db-row — Drizzle-Schema typisiert kind/contentFormat/
133
+ // scope/status als generic text. CHECK-Constraints in der DB schränken
134
+ // sie auf die Union-Types ein; Cast assertet das Schema-Wissen.
135
+ // linkedResources ist ein text-column mit JSON-payload (string→string map).
136
+ const kind = row.kind as RenderKind;
137
+ // @cast-boundary db-row — siehe kind.
138
+ const contentFormat = row.contentFormat as ContentFormat;
139
+ // @cast-boundary db-row — siehe kind.
140
+ const scope = row.scope as "system" | "tenant";
141
+ // @cast-boundary db-row — siehe kind.
142
+ const status = row.status as "draft" | "active" | "archived";
143
+ // @cast-boundary db-row — parseJson returnt Record<string, unknown>;
144
+ // linkedResources-Spalte enthält per Schema {key: signedUrl}-Map.
145
+ const linkedResources = parseJson(row.linkedResources) as Record<string, string>;
132
146
  return {
133
147
  id: String(row.id),
134
148
  version: row.version,
135
149
  tenantId: row.tenantId,
136
150
  slug: row.slug,
137
- kind: row.kind as RenderKind,
151
+ kind,
138
152
  locale: row.locale,
139
153
  content: row.content ?? "",
140
- contentFormat: row.contentFormat as ContentFormat,
154
+ contentFormat,
141
155
  variableSchema: parseJson(row.variableSchema),
142
- linkedResources: parseJson(row.linkedResources) as Record<string, string>,
143
- scope: row.scope as "system" | "tenant",
156
+ linkedResources,
157
+ scope,
144
158
  parentTemplateId: row.parentTemplateId,
145
- status: row.status as "draft" | "active" | "archived",
159
+ status,
146
160
  updatedAt: row.updatedAt,
147
161
  };
148
162
  }
@@ -151,6 +165,8 @@ function parseJson(raw: string | null): Record<string, unknown> {
151
165
  if (!raw) return {};
152
166
  try {
153
167
  const parsed = JSON.parse(raw);
168
+ // @cast-boundary engine-payload — JSON.parse returnt unknown, typeof-Guard
169
+ // grenzt auf object ein; Record<string, unknown> ist der minimale common-shape.
154
170
  return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
155
171
  } catch {
156
172
  return {};
@@ -48,6 +48,8 @@ export const listQuery = defineQueryHandler({
48
48
 
49
49
  const whereExpr = conditions.length > 0 ? and(...conditions) : undefined;
50
50
 
51
+ // @cast-boundary db-row — db.select returnt unknown[]; Row-Shape ist
52
+ // durch templateResourcesTable + buildBaseColumns garantiert.
51
53
  const rows = (await ctx.db
52
54
  .select()
53
55
  .from(templateResourcesTable)
@@ -19,6 +19,8 @@ export const upsertSystemWrite = defineWriteHandler({
19
19
  access: { roles: ["SystemAdmin"] },
20
20
  handler: async (event, ctx) => {
21
21
  const db = ctx.db;
22
+ // @cast-boundary engine-payload — SYSTEM_TENANT_ID ist UUID-Literal,
23
+ // assert auf TenantId-Branded-Type (parseTenantId-Equivalent).
22
24
  const tenantId = SYSTEM_TENANT_ID as TenantId;
23
25
  // executor-user muss SYSTEM_TENANT als tenantId haben, sonst sucht
24
26
  // event-store stream unter user.tenantId statt SYSTEM_TENANT → conflict.
@@ -63,10 +65,14 @@ export const upsertSystemWrite = defineWriteHandler({
63
65
 
64
66
  const result = await executor.create({ ...fields, tenantId }, executorUser, db);
65
67
  if (!result.isSuccess) return result;
68
+ // @cast-boundary db-row — executor.create returnt Record-row aus
69
+ // INSERT RETURNING; shape { id } ist garantiert weil PK in der
70
+ // Returning-Klausel ist.
71
+ const createdRow = result.data as { id: string | number };
66
72
  return {
67
73
  isSuccess: true as const,
68
74
  data: {
69
- id: String((result.data as { id: string | number }).id),
75
+ id: String(createdRow.id),
70
76
  slug: event.payload.slug,
71
77
  isNew: true,
72
78
  },
@@ -46,6 +46,9 @@ export const upsertTenantWrite = defineWriteHandler({
46
46
  }),
47
47
  );
48
48
  }
49
+ // @cast-boundary engine-payload — override aus Zod-parsed string,
50
+ // event.user.tenantId schon TenantId-branded; union als TenantId casten
51
+ // ist legit (override ist UUID-Format-validiert in schema).
49
52
  const tenantId = (override ?? event.user.tenantId) as TenantId;
50
53
  const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
51
54
 
@@ -86,10 +89,14 @@ export const upsertTenantWrite = defineWriteHandler({
86
89
 
87
90
  const result = await executor.create({ ...fields, tenantId }, executorUser, db);
88
91
  if (!result.isSuccess) return result;
92
+ // @cast-boundary db-row — executor.create returnt Record-row aus
93
+ // INSERT RETURNING; shape { id } ist garantiert weil PK in der
94
+ // Returning-Klausel ist.
95
+ const createdRow = result.data as { id: string | number };
89
96
  return {
90
97
  isSuccess: true as const,
91
98
  data: {
92
- id: String((result.data as { id: string | number }).id),
99
+ id: String(createdRow.id),
93
100
  slug: event.payload.slug,
94
101
  isNew: true,
95
102
  },