@cosmicdrift/kumiko-framework 0.71.0 → 0.72.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.71.0",
3
+ "version": "0.72.0",
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>",
@@ -181,7 +181,7 @@
181
181
  "zod": "^4.4.3"
182
182
  },
183
183
  "devDependencies": {
184
- "@cosmicdrift/kumiko-dispatcher-live": "0.71.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.72.0",
185
185
  "bun-types": "^1.3.13",
186
186
  "pino-pretty": "^13.1.3"
187
187
  },
@@ -241,6 +241,39 @@ describe("defineFeature", () => {
241
241
 
242
242
  expect(rolesOf(feature.writeHandlers["user:invite"]?.access)).toEqual(["Admin", "SystemAdmin"]);
243
243
  });
244
+
245
+ test("r.uiHints() flows into the definition", () => {
246
+ const feature = defineFeature("test", (r) => {
247
+ r.uiHints({
248
+ displayLabel: "Test Feature",
249
+ category: "demo",
250
+ recommended: true,
251
+ configurableOptions: [
252
+ { key: "fancy", label: "Fancy mode", type: "boolean", default: false },
253
+ ],
254
+ });
255
+ });
256
+ expect(feature.uiHints?.displayLabel).toBe("Test Feature");
257
+ expect(feature.uiHints?.category).toBe("demo");
258
+ expect(feature.uiHints?.recommended).toBe(true);
259
+ expect(feature.uiHints?.configurableOptions).toEqual([
260
+ { key: "fancy", label: "Fancy mode", type: "boolean", default: false },
261
+ ]);
262
+ });
263
+
264
+ test("uiHints is absent when r.uiHints() is not called", () => {
265
+ const feature = defineFeature("test", () => {});
266
+ expect("uiHints" in feature).toBe(false);
267
+ });
268
+
269
+ test("r.uiHints() throws when called twice", () => {
270
+ expect(() =>
271
+ defineFeature("test", (r) => {
272
+ r.uiHints({ displayLabel: "A" });
273
+ r.uiHints({ displayLabel: "B" });
274
+ }),
275
+ ).toThrow(/r\.uiHints\(\) called twice/);
276
+ });
244
277
  });
245
278
 
246
279
  // --- Field Factories ---
@@ -84,4 +84,24 @@ describe("buildManifestFromRegistry — deterministic codepoint sort (#330)", ()
84
84
  expect(manifest.tier).toBeUndefined();
85
85
  expect(manifest.features[0]?.tier).toBeUndefined();
86
86
  });
87
+
88
+ test("uiHints flow through to the manifest when set", () => {
89
+ const registry = createRegistry([
90
+ defineFeature("with-hints", (r) => {
91
+ r.uiHints({ displayLabel: "With Hints", category: "demo", recommended: true });
92
+ }),
93
+ defineFeature("no-hints", () => {}),
94
+ ]);
95
+
96
+ const manifest = buildManifestFromRegistry(registry, { source: "test" });
97
+ const withHints = manifest.features.find((f) => f.name === "with-hints");
98
+ const noHints = manifest.features.find((f) => f.name === "no-hints");
99
+
100
+ expect(withHints?.uiHints).toEqual({
101
+ displayLabel: "With Hints",
102
+ category: "demo",
103
+ recommended: true,
104
+ });
105
+ expect(noHints && "uiHints" in noHints).toBe(false);
106
+ });
87
107
  });
@@ -63,6 +63,7 @@ import type {
63
63
  TreeActionDef,
64
64
  TreeActionsHandle,
65
65
  TreeChildrenSubscribe,
66
+ UiHints,
66
67
  UnmanagedTableEntry,
67
68
  ValidationHookFn,
68
69
  WriteHandlerDef,
@@ -157,6 +158,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
157
158
  let isSystemScoped = false;
158
159
  let toggleableDefault: boolean | undefined;
159
160
  let description: string | undefined;
161
+ let uiHints: UiHints | undefined;
160
162
  // Visual-Tree-Slots — at-most-one per feature, only-once-guard im
161
163
  // registrar (siehe r.treeActions / r.tree). Undefined wenn das Feature
162
164
  // keinen Visual-Tree-Beitrag liefert (Zero-Whitelist-Filter).
@@ -240,6 +242,13 @@ export function defineFeature<const TName extends string, TExports = undefined>(
240
242
  toggleableDefault = options.default;
241
243
  },
242
244
 
245
+ uiHints(hints: UiHints): void {
246
+ if (uiHints !== undefined) {
247
+ throw new Error(`[Feature ${name}] r.uiHints() called twice — UI hints are declared once`);
248
+ }
249
+ uiHints = hints;
250
+ },
251
+
243
252
  entity(
244
253
  entityName: string,
245
254
  definition: EntityDefinition,
@@ -944,6 +953,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
944
953
  requiredProjections,
945
954
  requiredSteps,
946
955
  ...(toggleableDefault !== undefined && { toggleableDefault }),
956
+ ...(uiHints !== undefined && { uiHints }),
947
957
  entities,
948
958
  entityTables,
949
959
  relations,
@@ -5,6 +5,7 @@ export {
5
5
  extractRequires,
6
6
  extractSystemScope,
7
7
  extractToggleable,
8
+ extractUiHints,
8
9
  } from "./round1";
9
10
  export {
10
11
  extractEntity,
@@ -6,6 +6,7 @@ import type {
6
6
  RequiresPattern,
7
7
  SystemScopePattern,
8
8
  ToggleablePattern,
9
+ UiHintsPattern,
9
10
  } from "../patterns";
10
11
  import { sourceLocationFromNode } from "../source-location";
11
12
  import {
@@ -84,6 +85,16 @@ export function extractSystemScope(
84
85
  });
85
86
  }
86
87
 
88
+ export function extractUiHints(
89
+ call: CallExpression,
90
+ sourceFile: SourceFile,
91
+ ): ExtractOutput<UiHintsPattern> {
92
+ return ok({
93
+ kind: "uiHints",
94
+ source: sourceLocationFromNode(call, sourceFile),
95
+ });
96
+ }
97
+
87
98
  export function extractDescribe(
88
99
  call: CallExpression,
89
100
  sourceFile: SourceFile,
@@ -58,6 +58,7 @@ import {
58
58
  extractTranslations,
59
59
  extractTree,
60
60
  extractTreeActions,
61
+ extractUiHints,
61
62
  extractUnmanagedTable,
62
63
  extractUseExtension,
63
64
  extractUsesApi,
@@ -301,6 +302,8 @@ function dispatchExtractor(
301
302
  return extractToggleable(call, sourceFile);
302
303
  case "describe":
303
304
  return extractDescribe(call, sourceFile);
305
+ case "uiHints":
306
+ return extractUiHints(call, sourceFile);
304
307
  // Round 2 — object-literal-based static patterns
305
308
  case "entity":
306
309
  return extractEntity(call, sourceFile);
@@ -80,6 +80,7 @@ export type PatternId =
80
80
  | { readonly kind: "systemScope" }
81
81
  | { readonly kind: "toggleable" }
82
82
  | { readonly kind: "describe" }
83
+ | { readonly kind: "uiHints" }
83
84
  | { readonly kind: "config" }
84
85
  | { readonly kind: "translations" }
85
86
  | { readonly kind: "authClaims" }
@@ -272,6 +273,7 @@ export const SINGLETON_KINDS: ReadonlySet<PatternId["kind"]> = new Set([
272
273
  "systemScope",
273
274
  "toggleable",
274
275
  "describe",
276
+ "uiHints",
275
277
  "config",
276
278
  "translations",
277
279
  "authClaims",
@@ -329,6 +331,7 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
329
331
  case "systemScope":
330
332
  case "toggleable":
331
333
  case "describe":
334
+ case "uiHints":
332
335
  case "config":
333
336
  case "translations":
334
337
  case "authClaims":
@@ -203,6 +203,16 @@ export type DescribePattern = {
203
203
  readonly text: string;
204
204
  };
205
205
 
206
+ // `r.uiHints({ displayLabel, category, recommended, configurableOptions })` —
207
+ // picker/scaffolder UI metadata that flows into feature-manifest.json under
208
+ // `feature.uiHints`. Tracked as opaque here (no structured re-parse): the
209
+ // picker reads the manifest, not the AST. The pattern exists so the
210
+ // UnknownPattern dispatcher doesn't trip on a new r.* call.
211
+ export type UiHintsPattern = {
212
+ readonly kind: "uiHints";
213
+ readonly source: SourceLocation;
214
+ };
215
+
206
216
  // `r.metric(shortName, options)` — declares a metric under its short name
207
217
  // (without the `kumiko_<feature>_` prefix; the framework qualifies it at
208
218
  // boot and validates snake_case + type suffix). Runtime usage:
@@ -590,6 +600,7 @@ export type FeaturePattern =
590
600
  | SystemScopePattern
591
601
  | ToggleablePattern
592
602
  | DescribePattern
603
+ | UiHintsPattern
593
604
  | MetricPattern
594
605
  | SecretPattern
595
606
  | ClaimKeyPattern
@@ -675,6 +686,7 @@ export function getEditability(pattern: FeaturePattern): Editability {
675
686
  case "extendsRegistrar":
676
687
  case "tree":
677
688
  case "envSchema":
689
+ case "uiHints":
678
690
  case "unknown":
679
691
  return "opaque";
680
692
  default: {
@@ -50,6 +50,7 @@ import type {
50
50
  TranslationsPattern,
51
51
  TreeActionsPattern,
52
52
  TreePattern,
53
+ UiHintsPattern,
53
54
  UnknownPattern,
54
55
  UseExtensionPattern,
55
56
  UsesApiPattern,
@@ -80,6 +81,8 @@ export function renderPattern(pattern: FeaturePattern): string {
80
81
  return renderToggleable(pattern);
81
82
  case "describe":
82
83
  return renderDescribe(pattern);
84
+ case "uiHints":
85
+ return renderUiHints(pattern);
83
86
  case "entity":
84
87
  return renderEntity(pattern);
85
88
  case "relation":
@@ -517,6 +520,10 @@ function renderExposesApi(p: ExposesApiPattern): string {
517
520
  return `r.exposesApi(${JSON.stringify(p.apiName)});`;
518
521
  }
519
522
 
523
+ function renderUiHints(p: UiHintsPattern): string {
524
+ return p.source.raw;
525
+ }
526
+
520
527
  function renderUnknown(p: UnknownPattern): string {
521
528
  // Round-trip preservation only: emit the raw call text from the
522
529
  // SourceLocation so the rendered file stays semantically identical
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { compareByCodepoint } from "../utils";
10
10
  import { qualifyEntityName } from "./qualified-name";
11
- import type { Registry } from "./types/feature";
11
+ import type { Registry, UiHints } from "./types/feature";
12
12
 
13
13
  export type ManifestConfigKey = {
14
14
  readonly key: string;
@@ -57,6 +57,10 @@ export type ManifestFeature = {
57
57
  * Von `collectWriteHandlerQns` abgeleitet — dient als Source-of-Truth
58
58
  * für den Client-seitigen Typcheck von `dispatcher.write`-Calls. */
59
59
  readonly writeHandlers: readonly string[];
60
+ /** Picker/scaffolder UI metadata declared via r.uiHints(). Optional —
61
+ * absent for features that haven't been annotated yet (the picker falls
62
+ * back to feature.name + feature.description). */
63
+ readonly uiHints?: UiHints;
60
64
  /** Optionaler Herkunfts-Tag (z.B. "enterprise") — gesetzt via Options. */
61
65
  readonly tier?: string;
62
66
  };
@@ -148,6 +152,7 @@ export function buildManifestFromRegistry(
148
152
  configKeys,
149
153
  secrets,
150
154
  writeHandlers: writeHandlerQns,
155
+ ...(feature.uiHints !== undefined && { uiHints: feature.uiHints }),
151
156
  ...(options.tier !== undefined && { tier: options.tier }),
152
157
  });
153
158
  }
@@ -31,6 +31,7 @@ const ALL_KINDS: FeaturePatternKind[] = [
31
31
  "systemScope",
32
32
  "toggleable",
33
33
  "describe",
34
+ "uiHints",
34
35
  "entity",
35
36
  "relation",
36
37
  "nav",
@@ -200,6 +201,8 @@ function makePlaceholderPattern(kind: FeaturePatternKind): FeaturePattern {
200
201
  return { kind, source: PLACEHOLDER_LOC, default: false };
201
202
  case "describe":
202
203
  return { kind, source: PLACEHOLDER_LOC, text: "x" };
204
+ case "uiHints":
205
+ return { kind, source: PLACEHOLDER_LOC };
203
206
  case "entity":
204
207
  return { kind, source: PLACEHOLDER_LOC, entityName: "x", definition: { fields: {} } };
205
208
  case "relation":
@@ -194,6 +194,16 @@ const describeSchema: PatternFormSchema = {
194
194
  ],
195
195
  };
196
196
 
197
+ const uiHintsSchema: PatternFormSchema = {
198
+ kind: "uiHints",
199
+ label: { en: "UI hints", de: "UI-Hinweise" },
200
+ summary: { en: "Picker/scaffolder metadata. Opaque to the Designer; rendered as raw TS source." },
201
+ category: "meta",
202
+ editability: "opaque",
203
+ singleton: true,
204
+ fields: [],
205
+ };
206
+
197
207
  const entitySchema: PatternFormSchema = {
198
208
  kind: "entity",
199
209
  label: { en: "Entity", de: "Entität" },
@@ -1161,6 +1171,7 @@ export const PATTERN_LIBRARY: Readonly<Record<FeaturePatternKind, PatternFormSch
1161
1171
  systemScope: systemScopeSchema,
1162
1172
  toggleable: toggleableSchema,
1163
1173
  describe: describeSchema,
1174
+ uiHints: uiHintsSchema,
1164
1175
  entity: entitySchema,
1165
1176
  relation: relationSchema,
1166
1177
  nav: navSchema,
@@ -171,6 +171,51 @@ export type UnmanagedTableDef = UnmanagedTableEntry & {
171
171
  readonly featureName: string;
172
172
  };
173
173
 
174
+ // --- UI-Hints (manifest-only, picker/scaffolder metadata) ---
175
+
176
+ // Optional, declarative UI metadata declared via `r.uiHints({...})`. Surfaces
177
+ // in feature-manifest.json under `feature.uiHints`. Consumers (the picker in
178
+ // `create-kumiko-app`, the docs feature-reference) treat absent hints as
179
+ // "no special treatment" — bare feature.name + feature.description still work.
180
+ //
181
+ // Keep this list lean. Anything that already has a home on the feature
182
+ // (configKeys.scope/default/encrypted, secretKeys, requires, etc.) lives there.
183
+ // Only add fields here that are genuinely UI-only.
184
+ export type UiHintOption =
185
+ | {
186
+ readonly key: string;
187
+ readonly label: string;
188
+ readonly type: "boolean";
189
+ readonly default: boolean;
190
+ }
191
+ | {
192
+ readonly key: string;
193
+ readonly label: string;
194
+ readonly type: "select";
195
+ readonly options: readonly string[];
196
+ readonly default: string;
197
+ }
198
+ | {
199
+ readonly key: string;
200
+ readonly label: string;
201
+ readonly type: "text";
202
+ readonly default?: string;
203
+ };
204
+
205
+ export type UiHints = {
206
+ // Picker-facing label ("Auth · Email + Password" instead of the bare
207
+ // feature-name "auth-email-password").
208
+ readonly displayLabel?: string;
209
+ // Grouping for the picker. Free-form string; the picker sorts/groups by it.
210
+ readonly category?: string;
211
+ // Pre-checked in the picker when the user runs `bun create kumiko-app`.
212
+ readonly recommended?: boolean;
213
+ // Sub-options the picker asks about per-feature (e.g. "Password-Reset-Flow
214
+ // on/off"). The scaffolder maps each key to a generator decision; the
215
+ // framework doesn't act on them at runtime.
216
+ readonly configurableOptions?: readonly UiHintOption[];
217
+ };
218
+
174
219
  // --- Feature Definition (output of defineFeature) ---
175
220
 
176
221
  export type FeatureDefinition = {
@@ -197,6 +242,9 @@ export type FeatureDefinition = {
197
242
  // means the feature is always-on (e.g. auth, tenant, user — core infra
198
243
  // that would brick the system if switchable).
199
244
  readonly toggleableDefault?: boolean;
245
+ // Declarative UI metadata for picker/scaffolder tooling. Set via r.uiHints().
246
+ // Pure manifest-side info — the framework runtime doesn't read it.
247
+ readonly uiHints?: UiHints;
200
248
  // entities/hooks/entityHooks are optional: defineFeature always
201
249
  // materializes them, but hand-built definitions at system boundaries
202
250
  // (test fixtures, partial boots — see registry.test.ts "slot robustness")
@@ -365,6 +413,8 @@ export type FeatureRegistrar<TFeature extends string = string> = {
365
413
  // feature; calling on an always-on feature (e.g. auth/tenant/user) is a
366
414
  // bug — and one nothing catches at boot, so don't.
367
415
  toggleable(options: { default: boolean }): void;
416
+ // Picker/scaffolder metadata — see UiHints. At most once per feature.
417
+ uiHints(hints: UiHints): void;
368
418
 
369
419
  entity(
370
420
  name: string,
@@ -74,6 +74,8 @@ export type {
74
74
  SecretKeyDefinition,
75
75
  SecretKeyHandle,
76
76
  SecretOptions,
77
+ UiHintOption,
78
+ UiHints,
77
79
  UnmanagedTableDef,
78
80
  UnmanagedTableEntry,
79
81
  } from "./feature";
@@ -200,3 +200,28 @@ describe("tenant-storage-usage MSP", () => {
200
200
  ).toBeGreaterThanOrEqual(0);
201
201
  });
202
202
  });
203
+
204
+ // The MSP tests above prove upload *accounting*; nothing asserts the file
205
+ // actually comes back. This pins the full HTTP read path (GET /files/:id →
206
+ // provider readStream → response body) — the user-facing roundtrip.
207
+ describe("file download roundtrip (GET /files/:id)", () => {
208
+ async function download(user: SessionUser, id: string): Promise<Response> {
209
+ const token = await stack.jwt.sign(user);
210
+ return stack.app.request(`/api/files/${id}`, {
211
+ headers: { Authorization: `Bearer ${token}` },
212
+ });
213
+ }
214
+
215
+ test("returns the exact bytes that were uploaded", async () => {
216
+ const id = await upload(admin, "roundtrip.png", LARGE);
217
+ const res = await download(admin, id);
218
+ expect(res.status).toBe(200);
219
+ expect(new Uint8Array(await res.arrayBuffer())).toEqual(LARGE);
220
+ });
221
+
222
+ test("a deleted file is no longer downloadable", async () => {
223
+ const id = await upload(admin, "gone.png", SMALL);
224
+ await deleteFile(admin, id);
225
+ expect((await download(admin, id)).status).toBe(404);
226
+ });
227
+ });
@@ -19,6 +19,11 @@ export function createFilesFeature(): FeatureDefinition {
19
19
  r.describe(
20
20
  "Exposes the `fileRef` entity and `createFilesFeature` from the framework core so that uploaded files \u2014 tracked in the `file_refs` table by `createFileRoutes` \u2014 participate in cross-feature hooks: `user-data-rights-defaults` automatically includes file blobs in GDPR exports and forget flows, and future tenant-lifecycle cleanup will delete all refs on tenant destroy. This feature does not add upload or download routes; those remain in the server bootstrap via the `options.files` parameter.",
21
21
  );
22
+ r.uiHints({
23
+ displayLabel: "Files \u00b7 Metadata",
24
+ category: "storage",
25
+ recommended: false,
26
+ });
22
27
  r.entity("fileRef", fileRefEntity);
23
28
  });
24
29
  }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { generateId } from "../ids";
3
+
4
+ // generateId is the row/stream/correlation ID source. The callers rely on
5
+ // it being a UUIDv7 (time-sortable → dense B-Tree indexes), not a v4 — a
6
+ // swap to v4 would silently kill the lexicographic-time ordering the event
7
+ // store and time-range queries depend on. These tests pin that contract.
8
+ const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
9
+
10
+ describe("generateId", () => {
11
+ test("returns a well-formed lowercase UUID", () => {
12
+ expect(generateId()).toMatch(UUID_RX);
13
+ });
14
+
15
+ test("is UUID version 7 (time-sortable, not v4)", () => {
16
+ // Version nibble lives in the 13th hex digit (index 14 incl. dashes).
17
+ expect(generateId()[14]).toBe("7");
18
+ });
19
+
20
+ test("is collision-free across a batch", () => {
21
+ const ids = Array.from({ length: 10_000 }, generateId);
22
+ expect(new Set(ids).size).toBe(ids.length);
23
+ });
24
+ });