@cosmicdrift/kumiko-dev-server 0.130.2 → 0.131.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-dev-server",
3
- "version": "0.130.2",
3
+ "version": "0.131.0",
4
4
  "description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -58,8 +58,8 @@
58
58
  "kumiko-schema-check": "./bin/kumiko-schema-check.ts"
59
59
  },
60
60
  "dependencies": {
61
- "@cosmicdrift/kumiko-bundled-features": "0.130.2",
62
- "@cosmicdrift/kumiko-framework": "0.130.2",
61
+ "@cosmicdrift/kumiko-bundled-features": "0.131.0",
62
+ "@cosmicdrift/kumiko-framework": "0.131.0",
63
63
  "ts-morph": "^28.0.0"
64
64
  },
65
65
  "publishConfig": {
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
4
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
+ import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
7
8
  import { scaffoldApp } from "../scaffold-app";
8
9
  import { scaffoldAppFeature } from "../scaffold-app-feature";
9
10
 
@@ -19,23 +20,60 @@ describe("scaffoldAppFeature", () => {
19
20
  rmSync(tmp, { recursive: true, force: true });
20
21
  });
21
22
 
22
- test("scaffolds src/features/<name>/feature.ts + index.ts", () => {
23
+ test("scaffolds die volle App-Feature-Konvention", () => {
23
24
  const result = scaffoldAppFeature({ name: "product-catalog", appRoot });
24
25
  expect(result.featureName).toBe("product-catalog");
25
26
  expect(result.files).toEqual([
26
27
  "src/features/product-catalog/feature.ts",
27
28
  "src/features/product-catalog/index.ts",
29
+ "src/features/product-catalog/constants.ts",
30
+ "src/features/product-catalog/i18n.ts",
31
+ "src/features/product-catalog/schema/product-catalog-item.ts",
32
+ "src/features/product-catalog/schema/index.ts",
33
+ "src/features/product-catalog/web/index.ts",
34
+ "src/features/product-catalog/__tests__/feature.boot.test.ts",
28
35
  ]);
29
- expect(existsSync(join(appRoot, "src/features/product-catalog/feature.ts"))).toBe(true);
30
- expect(existsSync(join(appRoot, "src/features/product-catalog/index.ts"))).toBe(true);
36
+ for (const file of result.files) {
37
+ expect(existsSync(join(appRoot, file))).toBe(true);
38
+ }
31
39
  });
32
40
 
33
- test("feature.ts uses kebab-name + camelCase variable", () => {
41
+ test("feature.ts registriert nur Entity aus schema/, Keys aus i18n.ts", () => {
34
42
  scaffoldAppFeature({ name: "product-catalog", appRoot });
35
43
  const feature = readFileSync(join(appRoot, "src/features/product-catalog/feature.ts"), "utf-8");
36
- expect(feature).toContain(`defineFeature("product-catalog"`);
44
+ expect(feature).toContain("defineFeature(PRODUCT_CATALOG_FEATURE");
37
45
  expect(feature).toContain("export const productCatalogFeature");
38
- expect(feature).toContain('r.entity("product-catalog-item"');
46
+ expect(feature).toContain('r.entity("product-catalog-item", productCatalogItemEntity)');
47
+ expect(feature).toContain('type: "entityList"');
48
+ expect(feature).toContain("r.translations({ keys: productCatalogTranslationKeys })");
49
+ // Konvention: Entity-Felder leben in schema/, nicht inline in feature.ts.
50
+ expect(feature).not.toContain("fields:");
51
+ });
52
+
53
+ test("Starter erfüllt Boot-Constraints: sortable-Feld, defaultSort, i18n-Pflichtkeys", () => {
54
+ scaffoldAppFeature({ name: "product-catalog", appRoot });
55
+ const entity = readFileSync(
56
+ join(appRoot, "src/features/product-catalog/schema/product-catalog-item.ts"),
57
+ "utf-8",
58
+ );
59
+ expect(entity).toContain("sortable: true");
60
+ const feature = readFileSync(join(appRoot, "src/features/product-catalog/feature.ts"), "utf-8");
61
+ expect(feature).toContain('defaultSort: { field: "title", dir: "asc" }');
62
+ const i18n = readFileSync(join(appRoot, "src/features/product-catalog/i18n.ts"), "utf-8");
63
+ expect(i18n).toContain('"screen:items.title"');
64
+ expect(i18n).toContain('"product-catalog:entity:product-catalog-item:field:title"');
65
+ });
66
+
67
+ test("web/index.ts ist Client-Stub, __tests__ enthält validateBoot-Test", () => {
68
+ scaffoldAppFeature({ name: "product-catalog", appRoot });
69
+ const web = readFileSync(join(appRoot, "src/features/product-catalog/web/index.ts"), "utf-8");
70
+ expect(web).toContain("@runtime client");
71
+ expect(web).toContain("export const productCatalogClient: ClientFeatureDefinition");
72
+ const bootTest = readFileSync(
73
+ join(appRoot, "src/features/product-catalog/__tests__/feature.boot.test.ts"),
74
+ "utf-8",
75
+ );
76
+ expect(bootTest).toContain("validateBoot([productCatalogFeature])");
39
77
  });
40
78
 
41
79
  test("auto-mounts in src/run-config.ts (import + APP_FEATURES entry)", () => {
@@ -119,6 +157,21 @@ describe("scaffoldAppFeature", () => {
119
157
  expect(healed).toMatch(/\]\s*as const;/);
120
158
  });
121
159
 
160
+ test("gescaffoldetes Feature bootet wirklich (validateBoot über dynamic import)", async () => {
161
+ // Ins Repo scaffolden (nicht os.tmpdir): nur hier löst der dynamic
162
+ // import "@cosmicdrift/kumiko-framework/engine" über node_modules auf.
163
+ const repoTmp = mkdtempSync(join(import.meta.dir, ".tmp-scaffold-"));
164
+ try {
165
+ scaffoldAppFeature({ name: "boot-probe", appRoot: repoTmp });
166
+ const mod = (await import(join(repoTmp, "src/features/boot-probe/feature.ts"))) as {
167
+ readonly bootProbeFeature: Parameters<typeof validateBoot>[0][number];
168
+ };
169
+ expect(() => validateBoot([mod.bootProbeFeature])).not.toThrow();
170
+ } finally {
171
+ rmSync(repoTmp, { recursive: true, force: true });
172
+ }
173
+ });
174
+
122
175
  test("rejects non-kebab-case", () => {
123
176
  expect(() => scaffoldAppFeature({ name: "ProductCatalog", appRoot })).toThrow(/kebab-case/);
124
177
  expect(() => scaffoldAppFeature({ name: "product_catalog", appRoot })).toThrow(/kebab-case/);
@@ -47,14 +47,27 @@ export function scaffoldAppFeature(options: ScaffoldAppFeatureOptions): Scaffold
47
47
  }
48
48
  mkdirSync(featureDir, { recursive: true });
49
49
 
50
- const files: string[] = [];
51
- const featureFile = join(featureDir, "feature.ts");
52
- writeFileSync(featureFile, renderFeature(options.name));
53
- files.push(`src/features/${options.name}/feature.ts`);
50
+ // Volle App-Feature-Konvention (Referenz: bundled-features/tenant):
51
+ // feature.ts = nur Registrierung, schema/ für Entities, i18n.ts für
52
+ // Server-Keys, web/ für die Client-Seite, __tests__/ mit Boot-Test.
53
+ // handlers/ + lib/ entstehen beim ersten echten Handler/Logik-Modul.
54
+ mkdirSync(join(featureDir, "schema"), { recursive: true });
55
+ mkdirSync(join(featureDir, "web"), { recursive: true });
56
+ mkdirSync(join(featureDir, "__tests__"), { recursive: true });
54
57
 
55
- const indexFile = join(featureDir, "index.ts");
56
- writeFileSync(indexFile, renderIndex(options.name));
57
- files.push(`src/features/${options.name}/index.ts`);
58
+ const files: string[] = [];
59
+ const write = (relative: string, content: string): void => {
60
+ writeFileSync(join(featureDir, relative), content);
61
+ files.push(`src/features/${options.name}/${relative}`);
62
+ };
63
+ write("feature.ts", renderFeature(options.name));
64
+ write("index.ts", renderIndex(options.name));
65
+ write("constants.ts", renderConstants(options.name));
66
+ write("i18n.ts", renderI18n(options.name));
67
+ write(join("schema", `${options.name}-item.ts`), renderEntity(options.name));
68
+ write(join("schema", "index.ts"), renderSchemaIndex(options.name));
69
+ write(join("web", "index.ts"), renderWebIndex(options.name));
70
+ write(join("__tests__", "feature.boot.test.ts"), renderBootTest(options.name));
58
71
 
59
72
  const runConfigPath = join(appRoot, "src", "run-config.ts");
60
73
  let autoMounted = false;
@@ -83,18 +96,38 @@ function renderFeature(name: string): string {
83
96
  const camel = kebabToCamel(name);
84
97
  return `// ${name} feature — scaffolded by \`kumiko add feature\`. Edit freely.
85
98
  //
86
- // Doc-Pointer: https://docs.kumiko.rocks/en/patterns/ for the \`r.*\` API
87
- // (r.entity, r.writeHandler, r.queryHandler, hooks, …).
99
+ // Konvention (Referenz: bundled-features, z.B. tenant/):
100
+ // feature.ts — NUR Registrierung (r.entity / r.screen / r.queryHandler / …)
101
+ // schema/ — Entity-Definitionen
102
+ // handlers/ — eine Datei pro Handler (defineQueryHandler / defineWriteHandler)
103
+ // lib/ — reine Domain-Logik
104
+ // web/ — Client-Seite (index.ts, ein Screen pro Datei)
105
+ // i18n.ts — Server-Translation-Keys
106
+ //
107
+ // Doc-Pointer: https://docs.kumiko.rocks/en/patterns/ for the \`r.*\` API.
88
108
 
89
109
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
110
+ import { ${constName(name)} } from "./constants";
111
+ import { ${camel}TranslationKeys } from "./i18n";
112
+ import { ${camel}ItemEntity } from "./schema";
113
+
114
+ export const ${camel}Feature = defineFeature(${constName(name)}, (r) => {
115
+ r.entity("${name}-item", ${camel}ItemEntity);
90
116
 
91
- export const ${camel}Feature = defineFeature("${name}", (r) => {
92
- // Starter: declare an entity. Drop and replace with your domain.
93
- r.entity("${name}-item", {
94
- fields: {
95
- title: { type: "text", required: true },
96
- },
117
+ // Deklarativer List-Screen CRUD-Query kommt aus der Entity.
118
+ // Für query-getriebene Tabellen: type "projectionList"; für
119
+ // Kennzahlen-Grids: type "dashboard".
120
+ r.screen({
121
+ id: "items",
122
+ type: "entityList",
123
+ entity: "${name}-item",
124
+ columns: ["title"],
125
+ defaultSort: { field: "title", dir: "asc" },
97
126
  });
127
+
128
+ r.translations({ keys: ${camel}TranslationKeys });
129
+
130
+ r.nav({ id: "items", label: "screen:items.title", screen: "${name}:screen:items" });
98
131
  });
99
132
  `;
100
133
  }
@@ -104,10 +137,79 @@ function renderIndex(name: string): string {
104
137
  return `export { ${camel}Feature } from "./feature";\n`;
105
138
  }
106
139
 
140
+ function renderConstants(name: string): string {
141
+ return `export const ${constName(name)} = "${name}";\n`;
142
+ }
143
+
144
+ function renderI18n(name: string): string {
145
+ const camel = kebabToCamel(name);
146
+ return `// Server-seitige Translation-Keys (r.translations). Screen-Titel und
147
+ // Field-Labels folgen den Framework-Konventionen — der Boot-Validator
148
+ // prüft die Abdeckung.
149
+
150
+ export const ${camel}TranslationKeys = {
151
+ "screen:items.title": { de: "Einträge", en: "Items" },
152
+ "${name}:entity:${name}-item:field:title": { de: "Titel", en: "Title" },
153
+ };
154
+ `;
155
+ }
156
+
157
+ function renderEntity(name: string): string {
158
+ const camel = kebabToCamel(name);
159
+ return `// Entity-Definition — Felder, Annotationen, Transitions.
160
+ // Starter: eine Text-Spalte. Ersetze sie durch deine Domain.
161
+
162
+ export const ${camel}ItemEntity = {
163
+ fields: {
164
+ title: { type: "text", required: true, sortable: true },
165
+ },
166
+ };
167
+ `;
168
+ }
169
+
170
+ function renderSchemaIndex(name: string): string {
171
+ const camel = kebabToCamel(name);
172
+ return `export { ${camel}ItemEntity } from "./${name}-item";\n`;
173
+ }
174
+
175
+ function renderWebIndex(name: string): string {
176
+ const camel = kebabToCamel(name);
177
+ return `// @runtime client
178
+ // Client-Seite des Features: translations, components (custom-Screens),
179
+ // columnRenderers. In src/app/client.tsx bei
180
+ // createKumikoApp({ clientFeatures: [...] }) einhängen — das passiert
181
+ // NICHT automatisch. Screens: eine Datei pro Screen in diesem Ordner.
182
+
183
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
184
+
185
+ export const ${camel}Client: ClientFeatureDefinition = {
186
+ name: "${name}",
187
+ };
188
+ `;
189
+ }
190
+
191
+ function renderBootTest(name: string): string {
192
+ const camel = kebabToCamel(name);
193
+ return `import { describe, expect, test } from "bun:test";
194
+ import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
195
+ import { ${camel}Feature } from "../feature";
196
+
197
+ describe("${name} feature", () => {
198
+ test("boots: Entity, Screens und i18n-Coverage validieren", () => {
199
+ expect(() => validateBoot([${camel}Feature])).not.toThrow();
200
+ });
201
+ });
202
+ `;
203
+ }
204
+
107
205
  function kebabToCamel(name: string): string {
108
206
  return name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
109
207
  }
110
208
 
209
+ function constName(name: string): string {
210
+ return `${name.replace(/-/g, "_").toUpperCase()}_FEATURE`;
211
+ }
212
+
111
213
  // ts-morph: open run-config, prepend import, append APP_FEATURES entry.
112
214
  // Returns true on success, throws on shape-mismatch — the caller rolls back
113
215
  // the scaffolded feature dir and re-throws so a re-run isn't blocked.