@cosmicdrift/kumiko-dev-server 0.55.1 → 0.56.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.55.1",
3
+ "version": "0.56.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>",
@@ -46,8 +46,8 @@
46
46
  "kumiko-schema-check": "./bin/kumiko-schema-check.ts"
47
47
  },
48
48
  "dependencies": {
49
- "@cosmicdrift/kumiko-bundled-features": "0.50.0",
50
- "@cosmicdrift/kumiko-framework": "0.50.0",
49
+ "@cosmicdrift/kumiko-bundled-features": "0.55.1",
50
+ "@cosmicdrift/kumiko-framework": "0.55.1",
51
51
  "ts-morph": "^28.0.0"
52
52
  },
53
53
  "publishConfig": {
@@ -2,7 +2,12 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { renderDefineFile, renderInlineSchemasFile, renderTypesAugmentation } from "../render";
5
+ import {
6
+ renderDefineFile,
7
+ renderInlineSchemasFile,
8
+ renderTypesAugmentation,
9
+ renderWriteHandlerTypes,
10
+ } from "../render";
6
11
  import type { ScannedEvent } from "../scan-events";
7
12
 
8
13
  describe("renderTypesAugmentation", () => {
@@ -69,4 +74,19 @@ describe("renderDefineFile", () => {
69
74
  expect(out).toContain("defineWriteHandler");
70
75
  expect(out).toContain("types.generated.d.ts");
71
76
  });
77
+
78
+ test("emits createTypedDispatcher when handler QNs provided", () => {
79
+ const out = renderDefineFile(["tenant:write:create", "tenant:write:update"]);
80
+ expect(out).toContain("export type TypedDispatcher");
81
+ expect(out).toContain("export function createTypedDispatcher");
82
+ expect(out).toContain('export type { WriteHandlerQn } from "./types.generated"');
83
+ });
84
+ });
85
+
86
+ describe("renderWriteHandlerTypes", () => {
87
+ test("emits union type lines", () => {
88
+ const out = renderWriteHandlerTypes(["tenant:write:create"]);
89
+ expect(out).toContain("export type WriteHandlerQn =");
90
+ expect(out).toContain('| "tenant:write:create"');
91
+ });
72
92
  });
@@ -287,6 +287,37 @@ export default defineFeature("dup", (r) => {
287
287
  expect(w).toBeDefined();
288
288
  });
289
289
 
290
+ test("emits WriteHandlerQn union and TypedDispatcher when handlerQns passed", () => {
291
+ const appRoot = makeAppDir();
292
+ write(
293
+ appRoot,
294
+ "src/feature/events.ts",
295
+ `import { z } from "zod";\nexport const sSchema = z.object({ id: z.string() });\n`,
296
+ );
297
+ write(
298
+ appRoot,
299
+ "src/feature/feature.ts",
300
+ `import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
301
+ import { sSchema } from "./events";
302
+ export default defineFeature("typed", (r) => {
303
+ r.defineEvent("only", sSchema);
304
+ });
305
+ `,
306
+ );
307
+
308
+ const result = runCodegen({
309
+ appRoot,
310
+ handlerQns: ["typed:write:create"],
311
+ });
312
+ expect(result.skipped).toBe(false);
313
+
314
+ const types = readFileSync(join(appRoot, ".kumiko", "types.generated.d.ts"), "utf-8");
315
+ expect(types).toContain('| "typed:write:create"');
316
+
317
+ const define = readFileSync(join(appRoot, ".kumiko", "define.ts"), "utf-8");
318
+ expect(define).toContain("createTypedDispatcher");
319
+ });
320
+
290
321
  test("idempotent — second run does not re-write files", () => {
291
322
  const appRoot = makeAppDir();
292
323
  write(
@@ -2,6 +2,7 @@ export {
2
2
  renderDefineFile,
3
3
  renderInlineSchemasFile,
4
4
  renderTypesAugmentation,
5
+ renderWriteHandlerTypes,
5
6
  } from "./render";
6
7
  export { type CodegenOptions, type CodegenResult, runCodegen } from "./run-codegen";
7
8
  export {
@@ -169,7 +169,35 @@ export function renderInlineSchemasFile(
169
169
  * the import back to "@cosmicdrift/kumiko-framework/engine" and you're back on the
170
170
  * loose default. Migration is reversible, no behavioural surface change.
171
171
  */
172
- export function renderDefineFile(): string {
172
+ export function renderDefineFile(handlerQns: readonly string[] = []): string {
173
+ const typedDispatcherBlock =
174
+ handlerQns.length > 0
175
+ ? [
176
+ "",
177
+ `import type { Dispatcher, WriteOpts, WriteResult } from "@cosmicdrift/kumiko-headless";`,
178
+ "",
179
+ `export type { WriteHandlerQn } from "./types.generated";`,
180
+ "",
181
+ `/** Dispatcher with write() narrowed to registered handler QNs. */`,
182
+ `export type TypedDispatcher = Omit<Dispatcher, "write"> & {`,
183
+ ` write<TData = unknown>(`,
184
+ ` type: WriteHandlerQn,`,
185
+ ` payload: unknown,`,
186
+ ` opts?: WriteOpts,`,
187
+ ` ): Promise<WriteResult<TData>>;`,
188
+ `};`,
189
+ "",
190
+ `/** Wrap any Dispatcher so write() only accepts registered QNs at compile time. */`,
191
+ `export function createTypedDispatcher(dispatcher: Dispatcher): TypedDispatcher {`,
192
+ ` return {`,
193
+ ` ...dispatcher,`,
194
+ ` write: (type, payload, opts) => dispatcher.write(type, payload, opts),`,
195
+ ` };`,
196
+ `}`,
197
+ "",
198
+ ].join("\n")
199
+ : "";
200
+
173
201
  const body = [
174
202
  HEADER,
175
203
  "",
@@ -228,8 +256,26 @@ export function renderDefineFile(): string {
228
256
  `): QueryHandlerDefinition<TName, TSchema, TResult, KumikoEventTypeMap> {`,
229
257
  ` return fwDefineQueryHandler<TName, TSchema, TResult, KumikoEventTypeMap>(def);`,
230
258
  `}`,
231
- "",
259
+ typedDispatcherBlock,
232
260
  ].join("\n");
233
261
 
234
262
  return body;
235
263
  }
264
+
265
+ /**
266
+ * Render `WriteHandlerQn` type lines — exports a union of all registered
267
+ * write handler QNs so custom-screen code can type-check dispatcher.write
268
+ * calls at compile time:
269
+ *
270
+ * ```ts
271
+ * import type { WriteHandlerQn } from "@app/define";
272
+ * dispatcher.write<WriteHandlerQn>("tenant:write:create", payload); // ✓
273
+ * dispatcher.write<WriteHandlerQn>("tenant:write:creat", payload); // ✗ TS error
274
+ * ```
275
+ */
276
+ export function renderWriteHandlerTypes(handlerQns: readonly string[]): string {
277
+ if (handlerQns.length === 0) return "";
278
+
279
+ const lines = handlerQns.map((qn) => ` | "${qn}"`);
280
+ return ["", `export type WriteHandlerQn =`, ...lines, ";", ""].join("\n");
281
+ }
@@ -16,12 +16,22 @@
16
16
 
17
17
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
- import { renderDefineFile, renderInlineSchemasFile, renderTypesAugmentation } from "./render";
19
+ import {
20
+ renderDefineFile,
21
+ renderInlineSchemasFile,
22
+ renderTypesAugmentation,
23
+ renderWriteHandlerTypes,
24
+ } from "./render";
20
25
  import { type ScanWarning, scanEvents } from "./scan-events";
21
26
 
22
27
  export type CodegenOptions = {
23
28
  /** App-Root — `<appRoot>/.kumiko/` ist der Output-Ordner. */
24
29
  readonly appRoot: string;
30
+ /** Optional: alle registrierten Write-Handler-QNs. Wenn nicht gesetzt,
31
+ * versucht der Codegen `feature-manifest.json` im appRoot zu lesen.
32
+ * Die Dev-Server-Integration übergibt die QNs direkt aus der gebooteten
33
+ * Registry — CLI ruft sie aus dem Manifest. */
34
+ readonly handlerQns?: readonly string[];
25
35
  };
26
36
 
27
37
  export type CodegenResult = {
@@ -67,7 +77,8 @@ export function runCodegen(opts: CodegenOptions): CodegenResult {
67
77
  mkdirSync(outputDir, { recursive: true });
68
78
 
69
79
  const typesContent = renderTypesAugmentation(scan.events, outputDir);
70
- const defineContent = renderDefineFile();
80
+ const handlerQns = opts.handlerQns ?? readHandlerQnsFromManifest(opts.appRoot);
81
+ const defineContent = renderDefineFile(handlerQns);
71
82
  const schemasContent = renderInlineSchemasFile(scan.events, opts.appRoot);
72
83
  // package.json — turns `.kumiko/` into a real installable package
73
84
  // named `@app/define`. Apps that declare
@@ -77,7 +88,13 @@ export function runCodegen(opts: CodegenOptions): CodegenResult {
77
88
  // is required — yarn classic v1 ignored deps in versionless workspaces.
78
89
  const packageJsonContent = renderKumikoPackageJson();
79
90
 
80
- const didWriteTypes = writeIfChanged(typesPath, typesContent);
91
+ // WriteHandlerQn-Union an den types-Content anhängen (optional). Der
92
+ // Dev-Server übergibt handlerQns aus der lebenden Registry (genauer);
93
+ // der CLI-Fallback liest das feature-manifest.json falls vorhanden.
94
+ const finalTypesContent =
95
+ handlerQns.length > 0 ? `${typesContent}${renderWriteHandlerTypes(handlerQns)}` : typesContent;
96
+
97
+ const didWriteTypes = writeIfChanged(typesPath, finalTypesContent);
81
98
  const didWriteDefine = writeIfChanged(definePath, defineContent);
82
99
  writeIfChanged(packageJsonPath, packageJsonContent);
83
100
  const didWriteSchemas =
@@ -155,3 +172,27 @@ function removeIfExists(path: string): boolean {
155
172
  rmSync(path, { force: true });
156
173
  return true;
157
174
  }
175
+
176
+ /**
177
+ * Fallback: liest `feature-manifest.json` aus dem appRoot und extrahiert
178
+ * alle Write-Handler-QNs. Fehlende/stale Manifeste sind kein Fehler —
179
+ * dann wird schlicht keine `WriteHandlerQn`-Union generiert (CLI ohne
180
+ * Manifest oder CI-Setup ohne Manifest-Generator).
181
+ */
182
+ function readHandlerQnsFromManifest(appRoot: string): readonly string[] {
183
+ try {
184
+ const manifestPath = join(appRoot, "feature-manifest.json");
185
+ const raw = readFileSync(manifestPath, "utf-8");
186
+ const manifest = JSON.parse(raw) as {
187
+ readonly features: ReadonlyArray<{ readonly writeHandlers?: readonly string[] }>;
188
+ };
189
+ const qns: string[] = [];
190
+ for (const f of manifest.features) {
191
+ if (f.writeHandlers) qns.push(...f.writeHandlers);
192
+ }
193
+ qns.sort();
194
+ return qns;
195
+ } catch {
196
+ return [];
197
+ }
198
+ }
@@ -21,6 +21,12 @@ import { runCodegen } from "./run-codegen";
21
21
  export type WatchOptions = {
22
22
  /** App-Wurzel — gleiche Bedeutung wie für `runCodegen`. */
23
23
  readonly appRoot: string;
24
+ /** Optional: alle registrierten Write-Handler-QNs. Durchgereicht an
25
+ * `runCodegen` für die Generierung der `WriteHandlerQn`-Union.
26
+ * Der Dev-Server extrahiert sie beim Boot aus den Features und
27
+ * reicht sie hier rein — der Watcher reicht sie bei jedem Re-Run
28
+ * an `runCodegen` weiter, damit der generated-Type nicht verschwindet. */
29
+ readonly handlerQns?: readonly string[];
24
30
  /** Wartet diese Millisekunden zusätzliche Events ab, bevor codegen
25
31
  * einmal fährt. 50ms catched typische save-bursts (Editor-Saves
26
32
  * feuern oft 2-3 Events: temp-file → rename → cleanup), bleibt aber
@@ -58,7 +64,10 @@ export function watchAndRegenerate(opts: WatchOptions): WatchHandle {
58
64
 
59
65
  const fire = () => {
60
66
  try {
61
- const result = runCodegen({ appRoot: opts.appRoot });
67
+ const result = runCodegen({
68
+ appRoot: opts.appRoot,
69
+ ...(opts.handlerQns !== undefined && { handlerQns: opts.handlerQns }),
70
+ });
62
71
  if (opts.onResult) {
63
72
  opts.onResult(result);
64
73
  } else {
@@ -48,11 +48,11 @@ const hasBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
48
48
  // and only resolve the type when Bun is actually around.
49
49
  type BunServer = typeof Bun extends undefined ? unknown : ReturnType<typeof Bun.serve>;
50
50
 
51
- // biome-ignore lint/suspicious/noConsole: dev-server status logging
52
51
  // @wrapper-known semantic-alias
52
+ // biome-ignore lint/suspicious/noConsole: dev-server status logging
53
53
  const logInfo = (msg: string): void => console.log(msg);
54
- // biome-ignore lint/suspicious/noConsole: dev-server error logging
55
54
  // @wrapper-known semantic-alias
55
+ // biome-ignore lint/suspicious/noConsole: dev-server error logging
56
56
  const logError = (...args: unknown[]): void => console.error(...args);
57
57
 
58
58
  /** Multi-Entry-Mode für Apps die mehrere getrennte Bundles ausliefern
@@ -29,6 +29,7 @@ import {
29
29
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
30
30
  import type { SessionMetadata } from "@cosmicdrift/kumiko-framework/api";
31
31
  import {
32
+ collectWriteHandlerQns,
32
33
  createRegistry,
33
34
  type EffectiveFeaturesResolver,
34
35
  type FeatureDefinition,
@@ -37,6 +38,7 @@ import {
37
38
  type SessionUser,
38
39
  type TenantId,
39
40
  type TierResolverPlugin,
41
+ validateAppCustomScreenWriteQns,
40
42
  validateBoot,
41
43
  } from "@cosmicdrift/kumiko-framework/engine";
42
44
  import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
@@ -191,6 +193,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
191
193
  // CrashLoopBackOff sterben ließ (#359). Wirft synchron, bevor ein
192
194
  // Socket oder Watcher (codegen-Write) aufgeht.
193
195
  validateBoot(features);
196
+ validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
194
197
 
195
198
  // Codegen + File-Watcher — schreibt `<appRoot>/.kumiko/types.generated.d.ts`
196
199
  // + `define.ts` aus den r.defineEvent-Aufrufen der App, einmal beim
@@ -202,7 +205,11 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
202
205
  // Watcher läuft solange der Dev-Server lebt; close() bei Shutdown
203
206
  // wird über das createKumikoServer-Handle implizit erledigt (Bun's
204
207
  // process-exit räumt fs.watch-handles auf).
205
- watchAndRegenerate({ appRoot: process.cwd() });
208
+ // Sammelt alle Write-Handler-QNs für den Codegen — damit
209
+ // `types.generated.d.ts` eine `WriteHandlerQn`-Union exportieren
210
+ // kann, die dispatcher.write-Aufrufe client-seitig typisiert.
211
+ const handlerQns = collectWriteHandlerQns(features);
212
+ watchAndRegenerate({ appRoot: process.cwd(), handlerQns: [...handlerQns] });
206
213
 
207
214
  // Sprint-8a Tier-Composition auto-wire: scan features for a
208
215
  // tenantTierResolver-extension. If found AND user didn't supply own
@@ -47,12 +47,14 @@ import { createSseBroker, type SseBroker } from "@cosmicdrift/kumiko-framework/a
47
47
  import { createDbConnection, type DbRunner } from "@cosmicdrift/kumiko-framework/db";
48
48
  import {
49
49
  buildAppSchema,
50
+ collectWriteHandlerQns,
50
51
  createRegistry,
51
52
  type EffectiveFeaturesResolver,
52
53
  type FeatureDefinition,
53
54
  findTierResolverUsage,
54
55
  type TenantId,
55
56
  type TierResolverPlugin,
57
+ validateAppCustomScreenWriteQns,
56
58
  validateBoot,
57
59
  } from "@cosmicdrift/kumiko-framework/engine";
58
60
  import {
@@ -592,6 +594,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
592
594
  });
593
595
 
594
596
  validateBoot(features);
597
+ validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
595
598
  const registry = createRegistry(features);
596
599
 
597
600
  // C1 boot-mode exit: validators ran + registry built; no DB/Redis client