@cosmicdrift/kumiko-framework 0.150.0 → 0.151.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.150.0",
3
+ "version": "0.151.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>",
@@ -193,7 +193,7 @@
193
193
  "zod": "^4.4.3"
194
194
  },
195
195
  "devDependencies": {
196
- "@cosmicdrift/kumiko-dispatcher-live": "0.150.0",
196
+ "@cosmicdrift/kumiko-dispatcher-live": "0.151.1",
197
197
  "bun-types": "^1.3.13",
198
198
  "pino-pretty": "^13.1.3"
199
199
  },
@@ -23,6 +23,7 @@ export {
23
23
  incrementCounter,
24
24
  insertMany,
25
25
  insertOne,
26
+ runInSavepoint,
26
27
  selectMany,
27
28
  transaction,
28
29
  type UpsertOnConflictOptions,
package/src/db/index.ts CHANGED
@@ -107,6 +107,7 @@ export {
107
107
  fetchOne,
108
108
  insertMany,
109
109
  insertOne,
110
+ runInSavepoint,
110
111
  selectMany,
111
112
  transaction,
112
113
  updateMany,
@@ -17,6 +17,7 @@ export {
17
17
  fetchOne,
18
18
  insertMany,
19
19
  insertOne,
20
+ runInSavepoint,
20
21
  selectMany,
21
22
  transaction,
22
23
  updateMany,
@@ -351,3 +351,88 @@ describe("buildConfigFeatureSchema — access + workspace", () => {
351
351
  expect([...roles].sort()).toEqual(["Admin", "SystemAdmin", "TenantAdmin"]);
352
352
  });
353
353
  });
354
+
355
+ describe("buildConfigFeatureSchema — cross-feature group", () => {
356
+ test("two features sharing a group bucket under one screen/nav per scope, not two", () => {
357
+ const timeTracking = defineFeature("time-tracking", (r) => {
358
+ r.config({
359
+ keys: {
360
+ enabled: createTenantConfig("boolean", {
361
+ group: "bmc-settings",
362
+ mask: { title: "bmc.time-tracking-enabled" },
363
+ }),
364
+ },
365
+ });
366
+ });
367
+ const autoProtocol = defineFeature("auto-protocol", (r) => {
368
+ r.config({
369
+ keys: {
370
+ enabled: createTenantConfig("boolean", {
371
+ group: "bmc-settings",
372
+ mask: { title: "bmc.auto-protocol-enabled" },
373
+ }),
374
+ },
375
+ });
376
+ });
377
+
378
+ const out = buildConfigFeatureSchema(createRegistry([timeTracking, autoProtocol]));
379
+ expect(out.screens.filter((s) => s.id === "bmc-settings-tenant")).toHaveLength(1);
380
+ expect(out.navs.filter((n) => n.id === "bmc-settings-tenant")).toHaveLength(1);
381
+
382
+ const screen = out.screens.find((s) => s.id === "bmc-settings-tenant");
383
+ if (screen?.type !== "configEdit") throw new Error("expected configEdit screen");
384
+ // Neither feature owns the group name, so field ids are prefixed by the
385
+ // owning feature to stay unique — the whole point of the collision guard.
386
+ expect(screen.configKeys).toEqual({
387
+ "time-tracking-enabled": "time-tracking:config:enabled",
388
+ "auto-protocol-enabled": "auto-protocol:config:enabled",
389
+ });
390
+ });
391
+
392
+ test("same group + colliding owner-prefixed field id throws instead of silently overwriting", () => {
393
+ // "a-b" owner + shortKey "x" and "a" owner + shortKey "b-x" both produce
394
+ // the prefixed field id "a-b-x" — the exact clobbering hazard the
395
+ // owner-prefix scheme must catch, not just avoid in the common case.
396
+ const ab = defineFeature("a-b", (r) => {
397
+ r.config({
398
+ keys: { x: createTenantConfig("boolean", { group: "shared", mask: { title: "ab.x" } }) },
399
+ });
400
+ });
401
+ const a = defineFeature("a", (r) => {
402
+ r.config({
403
+ keys: {
404
+ bX: createTenantConfig("boolean", { group: "shared", mask: { title: "a.b-x" } }),
405
+ },
406
+ });
407
+ });
408
+ expect(() => buildConfigFeatureSchema(createRegistry([ab, a]))).toThrow(
409
+ /both resolve to field id "a-b-x"/,
410
+ );
411
+ });
412
+
413
+ test("group unset → behavior unchanged (buckets under the owning feature, plain shortKey)", () => {
414
+ const feature = defineFeature("ungrouped", (r) => {
415
+ r.config({
416
+ keys: { flag: createTenantConfig("boolean", { mask: { title: "u.flag" } }) },
417
+ });
418
+ });
419
+ const out = buildConfigFeatureSchema(createRegistry([feature]));
420
+ const screen = out.screens.find((s) => s.id === "ungrouped-tenant");
421
+ if (screen?.type !== "configEdit") throw new Error("expected configEdit screen");
422
+ expect(screen.configKeys).toEqual({ flag: "ungrouped:config:flag" });
423
+ });
424
+
425
+ test("invalid (non-kebab) group value throws at schema-build time", () => {
426
+ const feature = defineFeature("badgroup", (r) => {
427
+ r.config({
428
+ keys: {
429
+ flag: createTenantConfig("boolean", {
430
+ group: "Not Kebab!",
431
+ mask: { title: "bg.flag" },
432
+ }),
433
+ },
434
+ });
435
+ });
436
+ expect(() => buildConfigFeatureSchema(createRegistry([feature]))).toThrow(/kebab-case/);
437
+ });
438
+ });
@@ -257,6 +257,22 @@ describe("config helpers — allowPerRequest opt-in", () => {
257
257
  });
258
258
  });
259
259
 
260
+ describe("config helpers — group (Settings-Hub namespace override)", () => {
261
+ test("group is carried when set", () => {
262
+ const key = createTenantConfig("boolean", { group: "tenant-settings" });
263
+ expect(key.group).toBe("tenant-settings");
264
+ });
265
+
266
+ test("no group → field absent (defaults to the owning feature)", () => {
267
+ expect(createTenantConfig("boolean").group).toBeUndefined();
268
+ });
269
+
270
+ test("group is available on every scope factory", () => {
271
+ expect(createSystemConfig("text", { group: "shared" }).group).toBe("shared");
272
+ expect(createUserConfig("text", { group: "shared" }).group).toBe("shared");
273
+ });
274
+ });
275
+
260
276
  describe("config helpers — computed (plan-based / derived values)", () => {
261
277
  test("computed function attaches to the definition and returns the typed value", async () => {
262
278
  const key = createTenantConfig("number", {
@@ -1208,6 +1208,47 @@ describe("r.config()", () => {
1208
1208
  });
1209
1209
  });
1210
1210
 
1211
+ // --- r.configKey() ---
1212
+
1213
+ describe("r.configKey()", () => {
1214
+ test("returns a bare handle with the same qualified name r.config({keys}) would produce", () => {
1215
+ let viaConfigKey!: { readonly name: string; readonly type: "boolean" };
1216
+ let viaConfig!: { readonly defaultVat: { readonly name: string; readonly type: "number" } };
1217
+ defineFeature("invoicing", (r) => {
1218
+ viaConfigKey = r.configKey("enabled", createTenantConfig("boolean", { default: false }));
1219
+ });
1220
+ defineFeature("invoicing2", (r) => {
1221
+ viaConfig = r.config({ keys: { defaultVat: createTenantConfig("number", { default: 19 }) } });
1222
+ });
1223
+
1224
+ expect(viaConfigKey.name).toBe("invoicing:config:enabled");
1225
+ expect(viaConfigKey.type).toBe("boolean");
1226
+ // Structural check: configKey()'s handle is exactly what config({keys:{one}})
1227
+ // would have produced for that single key — same shape, same qualification scheme.
1228
+ expect(viaConfig.defaultVat.name).toBe("invoicing2:config:default-vat");
1229
+ });
1230
+
1231
+ test("registers the key on the feature exactly like r.config would", () => {
1232
+ const feature = defineFeature("shop", (r) => {
1233
+ r.configKey("maxItems", createTenantConfig("number", { default: 10 }));
1234
+ });
1235
+ expect(feature.configKeys["maxItems"]).toBeDefined();
1236
+ expect(feature.configKeys["maxItems"]?.type).toBe("number");
1237
+ expect(feature.configKeys["maxItems"]?.scope).toBe("tenant");
1238
+
1239
+ const registry = createRegistry([feature]);
1240
+ expect(registry.getConfigKey("shop:config:max-items")).toBeDefined();
1241
+ });
1242
+
1243
+ test("camelCase feature + key are kebab-cased in the handle name", () => {
1244
+ let handle!: { readonly name: string };
1245
+ defineFeature("billingCore", (r) => {
1246
+ handle = r.configKey("monthlyTotalCents", createSystemConfig("number", { default: 0 }));
1247
+ });
1248
+ expect(handle.name).toBe("billing-core:config:monthly-total-cents");
1249
+ });
1250
+ });
1251
+
1211
1252
  // --- Relations ---
1212
1253
 
1213
1254
  describe("r.relation()", () => {
@@ -23,6 +23,7 @@ import {
23
23
  createSelectField,
24
24
  createTextField,
25
25
  } from "./factories";
26
+ import { isKebabSegment } from "./qualified-name";
26
27
  import type { ConfigKeyDefinition } from "./types/config";
27
28
  import type { Registry } from "./types/feature";
28
29
  import type { FieldDefinition } from "./types/fields";
@@ -90,7 +91,14 @@ const MACHINE_WRITE_ROLE = "system" as const;
90
91
 
91
92
  type MaskedKey = {
92
93
  readonly qn: string;
94
+ // Effective group this key is bucketed under for the Settings-Hub —
95
+ // `def.group` if set, else `ownerFeature`. This is what screen/nav
96
+ // generation groups by.
93
97
  readonly feature: string;
98
+ // The feature that actually declared this key (always derived from `qn`).
99
+ // Needed to disambiguate field ids when `feature !== ownerFeature` (a
100
+ // cross-feature `group`) and for collision error messages.
101
+ readonly ownerFeature: string;
94
102
  readonly shortKey: string;
95
103
  readonly def: ConfigKeyDefinition;
96
104
  };
@@ -197,15 +205,30 @@ function buildScreen(
197
205
  const configKeys: Record<string, string> = {};
198
206
  const fields: Record<string, FieldDefinition> = {};
199
207
  const fieldLabels: Record<string, string> = {};
208
+ // Field id collapses to the plain shortKey when the key stays in its own
209
+ // feature's group (100% of today's apps — byte-identical output). Only a
210
+ // cross-feature `group` (feature !== ownerFeature) needs the owner prefix,
211
+ // to keep two features sharing a group from clobbering the same shortKey.
212
+ const fieldId = (k: MaskedKey): string =>
213
+ k.feature === k.ownerFeature ? k.shortKey : `${k.ownerFeature}-${k.shortKey}`;
214
+ const seenFieldIds = new Map<string, string>();
200
215
  for (const k of keys) {
201
- configKeys[k.shortKey] = k.qn;
202
- fields[k.shortKey] = deriveField(k.def);
216
+ const id = fieldId(k);
217
+ const prevQn = seenFieldIds.get(id);
218
+ if (prevQn !== undefined) {
219
+ throw new Error(
220
+ `[Settings-Hub] group "${feature}" scope "${scope}": config keys "${prevQn}" and "${k.qn}" both resolve to field id "${id}" — rename one key or its group.`,
221
+ );
222
+ }
223
+ seenFieldIds.set(id, k.qn);
224
+ configKeys[id] = k.qn;
225
+ fields[id] = deriveField(k.def);
203
226
  // mask is the visibility gate, so collectMaskedKeys guarantees it here.
204
- if (k.def.mask) fieldLabels[k.shortKey] = k.def.mask.title;
227
+ if (k.def.mask) fieldLabels[id] = k.def.mask.title;
205
228
  }
206
229
  const section: EditFieldsSection = {
207
230
  title: `${feature}.settings`,
208
- fields: keys.map((k) => k.shortKey),
231
+ fields: keys.map(fieldId),
209
232
  };
210
233
  return {
211
234
  id: shortId,
@@ -242,9 +265,16 @@ function collectMaskedKeys(registry: Registry): MaskedKey[] {
242
265
  if (def.mask === undefined || def.computed !== undefined) continue;
243
266
  const sep = qn.indexOf(":config:");
244
267
  if (sep === -1) continue;
268
+ const ownerFeature = qn.slice(0, sep);
269
+ if (def.group !== undefined && !isKebabSegment(def.group)) {
270
+ throw new Error(
271
+ `[Feature ${ownerFeature}] config key "${qn.slice(sep + ":config:".length)}" has invalid group "${def.group}" — must be kebab-case.`,
272
+ );
273
+ }
245
274
  out.push({
246
275
  qn,
247
- feature: qn.slice(0, sep),
276
+ feature: def.group ?? ownerFeature,
277
+ ownerFeature,
248
278
  shortKey: qn.slice(sep + ":config:".length),
249
279
  def,
250
280
  });
@@ -95,6 +95,7 @@ type ConfigKeyOptions<T extends ConfigKeyType> = {
95
95
  inheritedToTenant?: boolean;
96
96
  backing?: ConfigBacking;
97
97
  mask?: ConfigMask;
98
+ group?: string;
98
99
  };
99
100
 
100
101
  // --- Scope Defaults ---
@@ -132,6 +133,7 @@ function createConfigKey<T extends ConfigKeyType>(
132
133
  ...(opts.inheritedToTenant === false ? { inheritedToTenant: false } : {}),
133
134
  ...(opts.backing === "secrets" ? { backing: "secrets" } : {}),
134
135
  ...(opts.mask ? { mask: opts.mask } : {}),
136
+ ...(opts.group ? { group: opts.group } : {}),
135
137
  };
136
138
  }
137
139
 
@@ -32,41 +32,57 @@ export function buildConfigEventsJobsMethods<TName extends string>(
32
32
  state: FeatureBuilderState,
33
33
  name: TName,
34
34
  ) {
35
- return {
36
- config<TKeys extends Readonly<Record<string, ConfigKeyDefinition<ConfigKeyType>>>>(definition: {
37
- readonly keys: TKeys;
38
- readonly seeds?: Readonly<Record<string, ConfigSeedDef>>;
39
- }): { readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]> } {
40
- // Qualify eagerly (same as defineEvent) so the handle name matches what
41
- // the registry stores — lazy qualification would break compile-time
42
- // autocomplete and hand-built test registries.
43
- const handles: Record<string, ConfigKeyHandle<ConfigKeyType>> = {};
44
- for (const [key, keyDef] of Object.entries(definition.keys)) {
45
- state.configKeys[key] = keyDef;
46
- handles[key] = {
47
- name: qn(toKebab(name), "config", toKebab(key)),
48
- type: keyDef.type,
49
- };
50
- }
51
- // Parse seeds: resolve qualified key names and validate scope
52
- if (definition.seeds) {
53
- for (const [shortKey, seedDef] of Object.entries(definition.seeds)) {
54
- const keyDef = definition.keys[shortKey];
55
- if (!keyDef) continue; // skip boot-validator reports unknown keys
56
- const qualifiedKey = qn(toKebab(name), "config", toKebab(shortKey));
57
- const scope = seedDef.scope ?? keyDef.scope;
58
- state.configSeeds.push({
59
- key: qualifiedKey,
60
- value: seedDef.value,
61
- scope,
62
- tenantId: seedDef.tenantId,
63
- userId: seedDef.userId,
64
- });
65
- }
35
+ // Hoisted out of the returned object literal (not just a method) so
36
+ // `configKey()` below can call it directly — object-literal methods
37
+ // aren't in scope for their siblings without a `this`-bind.
38
+ function config<
39
+ TKeys extends Readonly<Record<string, ConfigKeyDefinition<ConfigKeyType>>>,
40
+ >(definition: {
41
+ readonly keys: TKeys;
42
+ readonly seeds?: Readonly<Record<string, ConfigSeedDef>>;
43
+ }): { readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]> } {
44
+ // Qualify eagerly (same as defineEvent) so the handle name matches what
45
+ // the registry stores — lazy qualification would break compile-time
46
+ // autocomplete and hand-built test registries.
47
+ const handles: Record<string, ConfigKeyHandle<ConfigKeyType>> = {};
48
+ for (const [key, keyDef] of Object.entries(definition.keys)) {
49
+ state.configKeys[key] = keyDef;
50
+ handles[key] = {
51
+ name: qn(toKebab(name), "config", toKebab(key)),
52
+ type: keyDef.type,
53
+ };
54
+ }
55
+ // Parse seeds: resolve qualified key names and validate scope
56
+ if (definition.seeds) {
57
+ for (const [shortKey, seedDef] of Object.entries(definition.seeds)) {
58
+ const keyDef = definition.keys[shortKey];
59
+ if (!keyDef) continue; // skip — boot-validator reports unknown keys
60
+ const qualifiedKey = qn(toKebab(name), "config", toKebab(shortKey));
61
+ const scope = seedDef.scope ?? keyDef.scope;
62
+ state.configSeeds.push({
63
+ key: qualifiedKey,
64
+ value: seedDef.value,
65
+ scope,
66
+ tenantId: seedDef.tenantId,
67
+ userId: seedDef.userId,
68
+ });
66
69
  }
67
- return handles as {
68
- readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]>;
69
- }; // @cast-boundary engine-bridge Mapped-Type-Inference at config()-callsite
70
+ }
71
+ return handles as {
72
+ readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]>;
73
+ }; // @cast-boundary engine-bridge — Mapped-Type-Inference at config()-callsite
74
+ }
75
+
76
+ return {
77
+ config,
78
+ // Shorthand for a single key — same handle shape `r.config({keys:{name:def}})`
79
+ // would produce for that key, just without the wrapping record. No seeds
80
+ // param: callers needing seeds use `r.config` directly.
81
+ configKey<T extends ConfigKeyType>(
82
+ keyName: string,
83
+ def: ConfigKeyDefinition<T>,
84
+ ): ConfigKeyHandle<T> {
85
+ return config({ keys: { [keyName]: def } })[keyName] as ConfigKeyHandle<T>; // @cast-boundary engine-bridge — mapped-type narrows to the single key
70
86
  },
71
87
  job(
72
88
  jobName: string,
@@ -136,6 +136,14 @@ export type ConfigKeyDefinition<T extends ConfigKeyType = ConfigKeyType> = {
136
136
  // manuelles r.screen/r.nav). Fehlt `mask`, gilt der Key als internes
137
137
  // Plumbing (ENV-provisioniert/computed) und erscheint NICHT im Hub.
138
138
  readonly mask?: ConfigMask;
139
+ // Überschreibt, unter welchem Settings-Hub-Namespace/Screen dieser Key
140
+ // gruppiert wird (Default: das deklarierende Feature). Erlaubt einem
141
+ // Feature, seine Keys unter einem fremden oder geteilten Namespace zu
142
+ // bündeln (z.B. viele flache Migrations-Flags unter "tenant-settings"),
143
+ // ohne dass das Ziel-Feature sie kennt. Rührt NICHT an qualifiziertem
144
+ // Namen, Storage, Seeds oder App-Overrides — nur reine UI-Gruppierung.
145
+ // Muss kebab-case sein (Boot-Validierung).
146
+ readonly group?: string;
139
147
  };
140
148
 
141
149
  // Label-Träger für den Settings-Hub. `title` ist ein i18n-Key (kein Literal —
@@ -508,6 +508,14 @@ export type FeatureRegistrar<TFeature extends string = string> = {
508
508
  readonly seeds?: Readonly<Record<string, ConfigSeedDef>>;
509
509
  }): { readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]> };
510
510
 
511
+ // Shorthand for a single key — same handle shape `config({keys:{name:def}})`
512
+ // would produce for that key, no wrapping record. No seeds param: callers
513
+ // needing seeds use `config` directly.
514
+ configKey<T extends ConfigKeyType>(
515
+ keyName: string,
516
+ def: ConfigKeyDefinition<T>,
517
+ ): ConfigKeyHandle<T>;
518
+
511
519
  job(name: string, options: Omit<JobDefinition, "name" | "handler">, handler: JobHandlerFn): void;
512
520
 
513
521
  notification(