@cosmicdrift/kumiko-framework 0.177.0 → 0.178.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.177.0",
3
+ "version": "0.178.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>",
@@ -182,7 +182,7 @@
182
182
  "./package.json": "./package.json"
183
183
  },
184
184
  "dependencies": {
185
- "@cosmicdrift/kumiko-types": "0.177.0",
185
+ "@cosmicdrift/kumiko-types": "0.178.0",
186
186
  "bullmq": "^5.76.7",
187
187
  "bun-types": "^1.3.13",
188
188
  "hono": "^4.12.27",
@@ -198,7 +198,7 @@
198
198
  "zod": "^4.4.3"
199
199
  },
200
200
  "devDependencies": {
201
- "@cosmicdrift/kumiko-dispatcher-live": "0.177.0",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.178.0",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -0,0 +1,212 @@
1
+ // r.contentCollection() — sugar over r.nav() that also records which
2
+ // template-resource kind the node lists, so the client can derive the tree
3
+ // provider instead of the app repeating navId + kind.
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
7
+ import { validateBoot as validateBootRaw } from "../boot-validator";
8
+ import { buildAppSchema } from "../build-app-schema";
9
+ import { defineFeature } from "../define-feature";
10
+ import { createRegistry } from "../registry";
11
+
12
+ function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
13
+ validateBootRaw(withBootValidatorFixture(features));
14
+ }
15
+
16
+ describe("r.contentCollection() — registration", () => {
17
+ test("registers a nav entry with provider:true and returns its qualified name", () => {
18
+ let qn = "";
19
+ const feature = defineFeature("mail", (r) => {
20
+ qn = r.contentCollection({
21
+ id: "templates",
22
+ kind: "mail-html",
23
+ nav: { label: "mail:nav.templates", icon: "file" },
24
+ });
25
+ });
26
+
27
+ expect(qn).toBe("mail:nav:templates");
28
+ // The children arrive from a runtime provider — without provider:true the
29
+ // node would render as an empty leaf.
30
+ expect(feature.navs["templates"]?.provider).toBe(true);
31
+ expect(feature.navs["templates"]?.label).toBe("mail:nav.templates");
32
+ expect(feature.navs["templates"]?.icon).toBe("file");
33
+ expect(feature.contentCollections?.["templates"]?.kind).toBe("mail-html");
34
+ });
35
+
36
+ test("passes nav placement through: parent, order, access, workspaces", () => {
37
+ const feature = defineFeature("mail", (r) => {
38
+ r.nav({ id: "root", label: "mail:nav.root" });
39
+ r.contentCollection({
40
+ id: "templates",
41
+ kind: "mail-html",
42
+ nav: {
43
+ label: "mail:nav.templates",
44
+ parent: "mail:nav:root",
45
+ order: 20,
46
+ access: { roles: ["TenantAdmin"] },
47
+ workspaces: ["mail:workspace:ops"],
48
+ },
49
+ });
50
+ });
51
+
52
+ const nav = feature.navs["templates"];
53
+ expect(nav?.parent).toBe("mail:nav:root");
54
+ expect(nav?.order).toBe(20);
55
+ expect(nav?.access).toEqual({ roles: ["TenantAdmin"] });
56
+ expect(nav?.workspaces).toEqual(["mail:workspace:ops"]);
57
+ });
58
+
59
+ test("passes createAction and hover actions through to the nav entry", () => {
60
+ const target = { featureId: "template-resolver", action: "create", args: { folder: "" } };
61
+ const feature = defineFeature("mail", (r) => {
62
+ r.contentCollection({
63
+ id: "templates",
64
+ kind: "mail-html",
65
+ nav: {
66
+ label: "mail:nav.templates",
67
+ createAction: { label: "mail:action.new", icon: "plus", target },
68
+ actions: [{ label: "mail:action.list", icon: "list", target }],
69
+ },
70
+ });
71
+ });
72
+
73
+ // Without these a collection can only list what already exists — the "+"
74
+ // affordance is the whole authoring entry point.
75
+ expect(feature.navs["templates"]?.createAction?.label).toBe("mail:action.new");
76
+ expect(feature.navs["templates"]?.actions).toHaveLength(1);
77
+ });
78
+
79
+ test("the nav node inherits the collection's access — no node the handler would refuse", () => {
80
+ const feature = defineFeature("mail", (r) => {
81
+ r.contentCollection({
82
+ id: "prompts",
83
+ kind: "ai-prompt",
84
+ access: { roles: ["PromptEngineer"] },
85
+ nav: { label: "mail:nav.prompts" },
86
+ });
87
+ });
88
+
89
+ expect(feature.navs["prompts"]?.access).toEqual({ roles: ["PromptEngineer"] });
90
+ });
91
+
92
+ test("an explicit nav.access still wins over the collection's", () => {
93
+ const feature = defineFeature("mail", (r) => {
94
+ r.contentCollection({
95
+ id: "prompts",
96
+ kind: "ai-prompt",
97
+ access: { roles: ["PromptEngineer"] },
98
+ nav: { label: "mail:nav.prompts", access: { roles: ["TenantAdmin"] } },
99
+ });
100
+ });
101
+
102
+ expect(feature.navs["prompts"]?.access).toEqual({ roles: ["TenantAdmin"] });
103
+ });
104
+
105
+ test("records ownership so the handlers can scope reads", () => {
106
+ const feature = defineFeature("mail", (r) => {
107
+ r.contentCollection({
108
+ id: "signatures",
109
+ kind: "mail-html",
110
+ ownership: "user",
111
+ nav: { label: "mail:nav.signatures" },
112
+ });
113
+ });
114
+
115
+ expect(feature.contentCollections?.["signatures"]?.ownership).toBe("user");
116
+ });
117
+
118
+ test("rejects a second collection with the same id", () => {
119
+ expect(() =>
120
+ defineFeature("mail", (r) => {
121
+ r.contentCollection({ id: "templates", kind: "mail-html", nav: { label: "a" } });
122
+ r.contentCollection({ id: "templates", kind: "ai-prompt", nav: { label: "b" } });
123
+ }),
124
+ ).toThrow(/already registered/);
125
+ });
126
+
127
+ test("rejects an id already taken by a plain r.nav()", () => {
128
+ expect(() =>
129
+ defineFeature("mail", (r) => {
130
+ r.nav({ id: "templates", label: "mail:nav.templates" });
131
+ r.contentCollection({ id: "templates", kind: "mail-html", nav: { label: "b" } });
132
+ }),
133
+ ).toThrow(/already registered/);
134
+ });
135
+
136
+ test("rejects a non-kebab id", () => {
137
+ expect(() =>
138
+ defineFeature("mail", (r) => {
139
+ r.contentCollection({ id: "MailTemplates", kind: "mail-html", nav: { label: "a" } });
140
+ }),
141
+ ).toThrow(/kebab-case/);
142
+ });
143
+ });
144
+
145
+ describe("r.contentCollection() — boot validation", () => {
146
+ test("a collection mounted under another feature's nav passes", () => {
147
+ const mail = defineFeature("mail", (r) => {
148
+ r.nav({ id: "root", label: "mail:nav.root" });
149
+ });
150
+ const templates = defineFeature("templates", (r) => {
151
+ r.contentCollection({
152
+ id: "mail-templates",
153
+ kind: "mail-html",
154
+ nav: { label: "templates:nav.mail", parent: "mail:nav:root" },
155
+ });
156
+ });
157
+
158
+ expect(() => validateBoot([mail, templates])).not.toThrow();
159
+ });
160
+
161
+ test("a dangling parent fails boot instead of silently vanishing from the sidebar", () => {
162
+ const templates = defineFeature("templates", (r) => {
163
+ r.contentCollection({
164
+ id: "mail-templates",
165
+ kind: "mail-html",
166
+ nav: { label: "templates:nav.mail", parent: "mail:nav:root" },
167
+ });
168
+ });
169
+
170
+ expect(() => validateBoot([templates])).toThrow(/mail:nav:root/);
171
+ });
172
+ });
173
+
174
+ describe("buildAppSchema — content collections", () => {
175
+ test("projects collections with the nav QN qualified", () => {
176
+ const registry = createRegistry([
177
+ defineFeature("mail", (r) => {
178
+ r.nav({ id: "root", label: "mail:nav.root" });
179
+ r.contentCollection({
180
+ id: "templates",
181
+ kind: "mail-html",
182
+ nav: { label: "mail:nav.templates", parent: "mail:nav:root" },
183
+ });
184
+ }),
185
+ ]);
186
+
187
+ const schema = buildAppSchema(registry);
188
+ const mail = schema.features.find((f) => f.featureName === "mail");
189
+ expect(mail?.contentCollections).toEqual([
190
+ {
191
+ id: "templates",
192
+ kind: "mail-html",
193
+ nav: { label: "mail:nav.templates", parent: "mail:nav:root" },
194
+ navQn: "mail:nav:templates",
195
+ },
196
+ ]);
197
+ // The nav entry itself still travels the normal route — the collection
198
+ // list only carries what a NavDefinition cannot express.
199
+ expect(mail?.navs?.map((n) => n.id)).toContain("templates");
200
+ });
201
+
202
+ test("omits the slot for features without collections", () => {
203
+ const registry = createRegistry([
204
+ defineFeature("shop", (r) => {
205
+ r.nav({ id: "catalog", label: "shop:nav.catalog" });
206
+ }),
207
+ ]);
208
+
209
+ const shop = buildAppSchema(registry).features.find((f) => f.featureName === "shop");
210
+ expect(shop?.contentCollections).toBeUndefined();
211
+ });
212
+ });
@@ -46,11 +46,21 @@ export function buildAppSchema(registry: Registry, options: BuildAppSchemaOption
46
46
  const features: FeatureSchema[] = [];
47
47
  for (const [featureName, feature] of registry.features) {
48
48
  const navs = Object.values(feature.navs);
49
+ // The nav entry alone doesn't say which kind a collection lists, so the
50
+ // client can't derive its tree provider from `navs` — project the
51
+ // collections separately, with the nav QN already qualified.
52
+ const contentCollections = Object.values(feature.contentCollections ?? {}).map(
53
+ (collection) => ({
54
+ ...collection,
55
+ navQn: `${featureName}:nav:${collection.id}`,
56
+ }),
57
+ );
49
58
  const featureSchema: FeatureSchema = {
50
59
  featureName,
51
60
  entities: projectEntities(feature.entities ?? {}),
52
61
  screens: Object.values(feature.screens),
53
62
  ...(navs.length > 0 && { navs }),
63
+ ...(contentCollections.length > 0 && { contentCollections }),
54
64
  // #1059: verbatim r.translations({keys}) — see FeatureSchema.translations
55
65
  // doc for why this must NOT go through registry.getAllTranslations()
56
66
  // (double-prefixes features that already qualify their own keys).
@@ -152,6 +152,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
152
152
  claimKeys: state.claimKeys,
153
153
  screens: state.screens,
154
154
  navs: state.navs,
155
+ contentCollections: state.contentCollections,
155
156
  workspaces: state.workspaces,
156
157
  httpRoutes: state.httpRoutes,
157
158
  storeTables: state.storeTables,
@@ -38,7 +38,7 @@ import type {
38
38
  WriteHandlerDef,
39
39
  } from "./types";
40
40
  import type { HttpRouteDefinition } from "./types/http-route";
41
- import type { NavDefinition } from "./types/nav";
41
+ import type { ContentCollectionDefinition, NavDefinition } from "./types/nav";
42
42
  import type { ScreenDefinition } from "./types/screen";
43
43
  import type { WorkspaceDefinition } from "./types/workspace";
44
44
 
@@ -95,6 +95,7 @@ export type FeatureBuilderState = {
95
95
  claimKeys: Record<string, ClaimKeyDefinition>;
96
96
  screens: Record<string, ScreenDefinition>;
97
97
  navs: Record<string, NavDefinition>;
98
+ contentCollections: Record<string, ContentCollectionDefinition>;
98
99
  workspaces: Record<string, WorkspaceDefinition>;
99
100
  httpRoutes: Record<string, HttpRouteDefinition>;
100
101
  translations: TranslationKeys;
@@ -155,6 +156,7 @@ export function createInitialFeatureBuilderState(): FeatureBuilderState {
155
156
  claimKeys: {},
156
157
  screens: {},
157
158
  navs: {},
159
+ contentCollections: {},
158
160
  workspaces: {},
159
161
  httpRoutes: {},
160
162
  translations: {},
@@ -25,7 +25,7 @@ import type {
25
25
  } from "./types";
26
26
  import { HookPhases } from "./types";
27
27
  import type { HttpRouteDefinition } from "./types/http-route";
28
- import type { NavDefinition } from "./types/nav";
28
+ import type { ContentCollectionDefinition, NavDefinition } from "./types/nav";
29
29
  import type { ScreenDefinition } from "./types/screen";
30
30
  import type { WorkspaceDefinition } from "./types/workspace";
31
31
 
@@ -373,6 +373,41 @@ export function buildUiExtensionsMethods<TName extends string>(
373
373
  nav(definition: NavDefinition): void {
374
374
  registerNav(definition);
375
375
  },
376
+ contentCollection(definition: ContentCollectionDefinition): string {
377
+ if (state.contentCollections[definition.id]) {
378
+ throw new Error(
379
+ `[Feature ${name}] Content collection "${definition.id}" already registered. ` +
380
+ `Collection ids must be unique per feature.`,
381
+ );
382
+ }
383
+ // registerNav owns the kebab + collision checks, including collisions
384
+ // with a plain r.nav() of the same id. Optional fields stay absent
385
+ // rather than explicitly undefined — buildAppSchema's JSON-safety check
386
+ // flags undefined values.
387
+ registerNav({
388
+ id: definition.id,
389
+ label: definition.nav.label,
390
+ ...(definition.nav.icon !== undefined && { icon: definition.nav.icon }),
391
+ ...(definition.nav.parent !== undefined && { parent: definition.nav.parent }),
392
+ ...(definition.nav.order !== undefined && { order: definition.nav.order }),
393
+ // Nav visibility follows the collection's access unless the caller
394
+ // overrode it — a node the handler would refuse has no business in
395
+ // the sidebar.
396
+ ...((definition.nav.access ?? definition.access) !== undefined && {
397
+ access: definition.nav.access ?? definition.access,
398
+ }),
399
+ ...(definition.nav.workspaces !== undefined && { workspaces: definition.nav.workspaces }),
400
+ ...(definition.nav.createAction !== undefined && {
401
+ createAction: definition.nav.createAction,
402
+ }),
403
+ ...(definition.nav.actions !== undefined && { actions: definition.nav.actions }),
404
+ // The tree children come from a runtime provider keyed on this QN —
405
+ // a collection without it would render as an empty leaf.
406
+ provider: true,
407
+ });
408
+ state.contentCollections[definition.id] = definition;
409
+ return `${name}:nav:${definition.id}`;
410
+ },
376
411
  workspace(definition: WorkspaceDefinition): void {
377
412
  // Same kebab guard as r.screen / r.nav so authoring-time mistakes
378
413
  // surface at the feature file, not deep in registry boot.
@@ -292,6 +292,7 @@ export type {
292
292
  ConfigValue,
293
293
  ConfigValueSource,
294
294
  ConfigValueWithSource,
295
+ ContentCollectionDefinition,
295
296
  CreateSeedOptions,
296
297
  CreateTenantSeedOptions,
297
298
  CreateUserSeedOptions,
@@ -206,7 +206,7 @@ export {
206
206
  parseTenantId,
207
207
  SYSTEM_TENANT_ID,
208
208
  } from "@cosmicdrift/kumiko-types/identifiers";
209
- export type { NavDefinition } from "@cosmicdrift/kumiko-types/nav";
209
+ export type { ContentCollectionDefinition, NavDefinition } from "@cosmicdrift/kumiko-types/nav";
210
210
  export type {
211
211
  FromRule,
212
212
  FromRuleKind,
@@ -9,7 +9,7 @@
9
9
 
10
10
  import type { TranslationKeys } from "../engine/types/config";
11
11
  import type { EntityDefinition } from "../engine/types/fields";
12
- import type { NavDefinition } from "../engine/types/nav";
12
+ import type { ContentCollectionDefinition, NavDefinition } from "../engine/types/nav";
13
13
  import type { ScreenDefinition } from "../engine/types/screen";
14
14
  import type { WorkspaceDefinition } from "../engine/types/workspace";
15
15
 
@@ -20,6 +20,12 @@ export type FeatureSchema = {
20
20
  // Flat list; resolveNavigation builds the tree at render-time from
21
21
  // the registry's indexes. Omitted when the app has no top-level nav.
22
22
  readonly navs?: readonly NavDefinition[];
23
+ // Content collections declared via r.contentCollection(), each with its nav
24
+ // QN already qualified. The matching nav entries are in `navs` like any
25
+ // other; this list only carries what a NavDefinition can't express — which
26
+ // template-resource `kind` the node lists — so the client can build one
27
+ // tree provider per collection. Omitted when a feature declares none.
28
+ readonly contentCollections?: readonly QualifiedContentCollection[];
23
29
  // Server-authored `r.translations({ keys })`, projected verbatim — byte-
24
30
  // identical keys, NOT re-prefixed with featureName (unlike the registry's
25
31
  // internal mergedTranslations, which double-prefixes features that
@@ -39,6 +45,12 @@ export type FeatureSchema = {
39
45
  readonly workspaces?: readonly WorkspaceSchema[];
40
46
  };
41
47
 
48
+ // A content collection as it reaches the client: the declaration plus the
49
+ // already-qualified nav QN, so consumers don't rebuild "<feature>:nav:<id>".
50
+ export type QualifiedContentCollection = ContentCollectionDefinition & {
51
+ readonly navQn: string;
52
+ };
53
+
42
54
  // Per-workspace projection of the engine's WorkspaceDefinition + the
43
55
  // pre-resolved member nav QNs. The shell renders the switcher from
44
56
  // `definition` and filters the nav tree using `navMembers`.
@@ -91,4 +91,9 @@ export type { TargetRef } from "../engine/types/target-ref";
91
91
  export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node";
92
92
  export type { WorkspaceDefinition } from "../engine/types/workspace";
93
93
  export { PROJECTION_DETAIL_ENTITY } from "../i18n/required-surface-keys";
94
- export type { AppSchema, FeatureSchema, WorkspaceSchema } from "./app-schema";
94
+ export type {
95
+ AppSchema,
96
+ FeatureSchema,
97
+ QualifiedContentCollection,
98
+ WorkspaceSchema,
99
+ } from "./app-schema";