@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.
|
|
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.
|
|
62
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
36
|
+
for (const file of result.files) {
|
|
37
|
+
expect(existsSync(join(appRoot, file))).toBe(true);
|
|
38
|
+
}
|
|
31
39
|
});
|
|
32
40
|
|
|
33
|
-
test("feature.ts
|
|
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(
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
//
|
|
87
|
-
// (r.entity
|
|
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
|
-
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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.
|