@cosmicdrift/kumiko-dev-server 0.1.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.
Files changed (44) hide show
  1. package/bin/kumiko-build.ts +85 -0
  2. package/bin/kumiko-dev.ts +90 -0
  3. package/package.json +45 -0
  4. package/src/__tests__/build-prod-bundle.integration.ts +265 -0
  5. package/src/__tests__/build-prod-bundle.test.ts +262 -0
  6. package/src/__tests__/cache-headers.test.ts +70 -0
  7. package/src/__tests__/classify-change.test.ts +87 -0
  8. package/src/__tests__/compose-features-wiring.integration.ts +352 -0
  9. package/src/__tests__/compose-features.test.ts +81 -0
  10. package/src/__tests__/crash-tracker.test.ts +89 -0
  11. package/src/__tests__/create-kumiko-server.integration.ts +286 -0
  12. package/src/__tests__/few-shot-corpus.test.ts +311 -0
  13. package/src/__tests__/inject-schema.test.ts +62 -0
  14. package/src/__tests__/resolve-stylesheet.test.ts +90 -0
  15. package/src/__tests__/resolve-tailwind-cli.test.ts +49 -0
  16. package/src/__tests__/run-prod-app-spec.test.ts +57 -0
  17. package/src/__tests__/run-prod-app.integration.ts +535 -0
  18. package/src/__tests__/scaffold-feature.test.ts +143 -0
  19. package/src/__tests__/try-hono-first.test.ts +63 -0
  20. package/src/build-prod-bundle.ts +587 -0
  21. package/src/build-server-bundle.ts +308 -0
  22. package/src/build.ts +28 -0
  23. package/src/codegen/__tests__/run-codegen.test.ts +494 -0
  24. package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +467 -0
  25. package/src/codegen/__tests__/watch.test.ts +186 -0
  26. package/src/codegen/index.ts +17 -0
  27. package/src/codegen/render.ts +225 -0
  28. package/src/codegen/run-codegen.ts +157 -0
  29. package/src/codegen/scan-events.ts +574 -0
  30. package/src/codegen/watch.ts +127 -0
  31. package/src/compose-features.ts +128 -0
  32. package/src/crash-tracker.ts +56 -0
  33. package/src/create-kumiko-server.ts +1010 -0
  34. package/src/drizzle-config.ts +44 -0
  35. package/src/drizzle-tables-auth-mode.ts +32 -0
  36. package/src/drizzle-tables-minimal.ts +22 -0
  37. package/src/few-shot-corpus.ts +369 -0
  38. package/src/index.ts +57 -0
  39. package/src/inject-schema.ts +24 -0
  40. package/src/resolve-tailwind-cli.ts +28 -0
  41. package/src/run-dev-app.ts +290 -0
  42. package/src/run-prod-app.ts +892 -0
  43. package/src/scaffold-feature.ts +226 -0
  44. package/src/try-hono-first.ts +46 -0
@@ -0,0 +1,535 @@
1
+ // runProdApp Integration: bootet die komplette Production-Chain mit
2
+ // echtem Postgres + Redis. Beweist:
3
+ // - Migration ist idempotent (2× boot mit gleicher DB → kein Crash)
4
+ // - Seeds laufen einmal, beim 2. Boot no-op (idempotent-by-design)
5
+ // - HTTP-Server antwortet auf /api/health
6
+ // - SIGTERM-handler räumt sauber auf
7
+ //
8
+ // NICHT getestet: Bun.serve über echte TCP-Verbindung — wir treiben
9
+ // fetch direkt. Bun.serve-Wiring ist in Production-Coolify selbst
10
+ // getestet wenn der Container hochfährt.
11
+
12
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import { createDbConnection } from "@cosmicdrift/kumiko-framework/db";
16
+ import {
17
+ createBooleanField,
18
+ createEntity,
19
+ createTextField,
20
+ defineFeature,
21
+ } from "@cosmicdrift/kumiko-framework/engine";
22
+ import {
23
+ createArchivedStreamsTable,
24
+ createEventsTable,
25
+ } from "@cosmicdrift/kumiko-framework/event-store";
26
+ import {
27
+ createEventConsumerStateTable,
28
+ createProjectionStateTable,
29
+ } from "@cosmicdrift/kumiko-framework/pipeline";
30
+ import { ensureEntityTable } from "@cosmicdrift/kumiko-framework/stack";
31
+ import { sql } from "drizzle-orm";
32
+ import postgres from "postgres";
33
+ import { afterEach, beforeAll, describe, expect, test } from "vitest";
34
+ import { z } from "zod";
35
+ import { type ProdAppHandle, runProdApp } from "../run-prod-app";
36
+
37
+ // tmp-Verzeichnisse pro Test, in afterEach geräumt. Tests die staticDir
38
+ // brauchen registrieren ihren Pfad hier.
39
+ const tempDirs: string[] = [];
40
+
41
+ async function createTempStaticDir(files: Record<string, string>): Promise<string> {
42
+ const dir = await mkdtemp(join(tmpdir(), "kumiko-prod-static-"));
43
+ tempDirs.push(dir);
44
+ for (const [name, content] of Object.entries(files)) {
45
+ const fullPath = join(dir, name);
46
+ await mkdir(dirname(fullPath), { recursive: true });
47
+ await writeFile(fullPath, content);
48
+ }
49
+ return dir;
50
+ }
51
+
52
+ const widgetEntity = createEntity({
53
+ fields: {
54
+ name: createTextField({ required: true }),
55
+ active: createBooleanField({ default: true }),
56
+ },
57
+ table: "prod_widgets",
58
+ });
59
+
60
+ const widgetFeature = defineFeature("prod-probe", (r) => {
61
+ r.entity("widget", widgetEntity);
62
+ // Anonymous query — covers the "anonymousAccess flows from runProdApp
63
+ // through createApiEntrypoint to the auth-middleware" wiring that
64
+ // earlier silently dropped the option in the entrypoint layer.
65
+ r.queryHandler({
66
+ name: "ping",
67
+ schema: z.object({}),
68
+ access: { roles: ["anonymous"] },
69
+ handler: async () => ({ pong: true }),
70
+ });
71
+ });
72
+
73
+ const TENANT_ID = "00000000-0000-4000-8000-000000000001";
74
+
75
+ // Per-suite DB so reboots can be tested without conflicting with other
76
+ // test suites. Created in beforeAll, dropped at module end via the admin
77
+ // connection.
78
+ const TEST_DB = `kumiko_runprod_${Date.now().toString(36)}`;
79
+ const ADMIN_URL = process.env["TEST_DATABASE_URL"] ?? "";
80
+
81
+ let prodAppHandles: ProdAppHandle[] = [];
82
+
83
+ beforeAll(async () => {
84
+ if (!ADMIN_URL) throw new Error("TEST_DATABASE_URL must be set");
85
+ const adminClient = postgres(ADMIN_URL.replace(/\/[^/]+$/, "/postgres"));
86
+ try {
87
+ await adminClient.unsafe(`CREATE DATABASE "${TEST_DB}"`);
88
+ } finally {
89
+ await adminClient.end();
90
+ }
91
+ });
92
+
93
+ afterEach(async () => {
94
+ for (const handle of prodAppHandles) {
95
+ await handle.stop();
96
+ }
97
+ prodAppHandles = [];
98
+ for (const dir of tempDirs) {
99
+ await rm(dir, { recursive: true, force: true });
100
+ }
101
+ tempDirs.length = 0;
102
+ });
103
+
104
+ // Production-Apps booten gegen eine VORHER migrierte DB (CI-Step
105
+ // `kumiko migrate apply`). In diesem Test gibt's keine drizzle-Migration-
106
+ // Files, also imitieren wir den Migration-Step direkt: Framework-Infra-
107
+ // Tables + die widget-Entity-Tabelle anlegen, dann runProdApp mit
108
+ // `migrations: false` (= kein Schema-Drift-Gate) starten. So bleibt der
109
+ // Test fokussiert auf Boot-Wiring (Entrypoint, Hono-Routes, Seeds), ohne
110
+ // den Migrationspfad zu duplizieren.
111
+ async function migrateTestDb(): Promise<void> {
112
+ const url = ADMIN_URL.replace(/\/[^/]+$/, `/${TEST_DB}`);
113
+ const { db, close } = createDbConnection(url);
114
+ try {
115
+ await createEventsTable(db);
116
+ await createArchivedStreamsTable(db);
117
+ await createProjectionStateTable(db);
118
+ await createEventConsumerStateTable(db);
119
+ await ensureEntityTable(db, widgetEntity, "widget");
120
+ } finally {
121
+ await close();
122
+ }
123
+ }
124
+
125
+ let testDbMigrated = false;
126
+
127
+ async function boot(
128
+ seedFn?: (deps: { db: import("@cosmicdrift/kumiko-framework/db").DbConnection }) => Promise<void>,
129
+ extra?: Partial<Parameters<typeof runProdApp>[0]>,
130
+ ): Promise<ProdAppHandle> {
131
+ // Override env per boot to point at the suite's DB.
132
+ const originalDbUrl = process.env["DATABASE_URL"];
133
+ process.env["DATABASE_URL"] = ADMIN_URL.replace(/\/[^/]+$/, `/${TEST_DB}`);
134
+ process.env["REDIS_URL"] = process.env["REDIS_URL"] ?? "redis://localhost:16379";
135
+ process.env["JWT_SECRET"] = "test-runprod-secret-32-chars-min!!";
136
+ process.env["PORT"] = "0"; // Bun.serve picks an ephemeral port
137
+
138
+ if (!testDbMigrated) {
139
+ await migrateTestDb();
140
+ testDbMigrated = true;
141
+ }
142
+
143
+ try {
144
+ const handle = await runProdApp({
145
+ features: [widgetFeature],
146
+ autoListen: false,
147
+ migrations: false,
148
+ ...(seedFn && { seeds: [seedFn] }),
149
+ ...(extra ?? {}),
150
+ });
151
+ prodAppHandles.push(handle);
152
+ return handle;
153
+ } finally {
154
+ if (originalDbUrl !== undefined) process.env["DATABASE_URL"] = originalDbUrl;
155
+ else delete process.env["DATABASE_URL"];
156
+ }
157
+ }
158
+
159
+ describe("runProdApp", () => {
160
+ test("first boot creates entity tables, /api/health responds", async () => {
161
+ const handle = await boot();
162
+
163
+ const res = await handle.entrypoint.app.fetch(new Request("http://test/health"));
164
+ expect(res.status).toBe(200);
165
+ });
166
+
167
+ test("second boot against the same DB is idempotent — no crash, no duplicate tables", async () => {
168
+ await boot();
169
+ // First boot left tables in place. Restart on the same DB —
170
+ // ensureEntityTable should be a no-op for the existing rows.
171
+ const second = await boot();
172
+
173
+ const res = await second.entrypoint.app.fetch(new Request("http://test/health"));
174
+ expect(res.status).toBe(200);
175
+ });
176
+
177
+ test("extraRoutes-callback mounts custom HTTP-routes on the Hono-app", async () => {
178
+ // Beweist dass die runProdApp.extraRoutes-Option den Hono-app
179
+ // bekommt und Routes daran VOR dem static-fallback greifen — das
180
+ // ist das Fundament für /feed.xml, /sitemap.xml, /og-image etc.
181
+ let extraInvoked = false;
182
+ const handle = await boot(undefined, {
183
+ extraRoutes: (app, deps) => {
184
+ extraInvoked = true;
185
+ // deps.db + deps.redis sind die runProdApp-Connections — die
186
+ // Route kann gegen die Domain queryen, hier reicht ein simple
187
+ // Echo zum Beweis dass wir ans App-Object kommen.
188
+ app.get("/feed.xml", (c) => {
189
+ const dbAvailable = deps.db !== undefined;
190
+ return c.body(`<?xml version="1.0"?><probe ok="${dbAvailable}" />`, 200, {
191
+ "content-type": "application/rss+xml",
192
+ });
193
+ });
194
+ },
195
+ });
196
+
197
+ expect(extraInvoked).toBe(true);
198
+
199
+ // handle.fetch durchläuft den static-fallback wrapper — dort liegt
200
+ // die "Hono-First, dann Disk"-Logik. entrypoint.app.fetch würde den
201
+ // wrapper umgehen und damit die regression nicht erkennen.
202
+ const res = await handle.fetch(new Request("http://test/feed.xml"));
203
+ expect(res.status).toBe(200);
204
+ expect(res.headers.get("content-type")).toBe("application/rss+xml");
205
+ const body = await res.text();
206
+ expect(body).toContain('<probe ok="true" />');
207
+ });
208
+
209
+ test("static-fallback: extraRoute beats Disk-File at colliding path (Hono-First)", async () => {
210
+ // Regression-Test für den static-fallback-Bug von Phase 2 Step 1:
211
+ // wenn ein extraRoute (z.B. /feed.xml) UND eine gleichnamige Disk-
212
+ // Datei in staticDir existieren, gewinnt der extraRoute. Sonst
213
+ // schluckt der SPA-Fallback unbekannte Pfade als index.html und
214
+ // der App-Author wundert sich warum sein /feed.xml nichts macht.
215
+ const tmpStaticDir = await createTempStaticDir({
216
+ "feed.xml": "<this-is-the-disk-version />",
217
+ "index.html": "<html>SPA shell</html>",
218
+ });
219
+
220
+ const handle = await boot(undefined, {
221
+ staticDir: tmpStaticDir,
222
+ extraRoutes: (app) => {
223
+ app.get("/feed.xml", (c) =>
224
+ c.body("<this-is-the-hono-version />", 200, {
225
+ "content-type": "application/rss+xml",
226
+ }),
227
+ );
228
+ },
229
+ });
230
+
231
+ const res = await handle.fetch(new Request("http://test/feed.xml"));
232
+ expect(res.status).toBe(200);
233
+ expect(await res.text()).toContain("<this-is-the-hono-version />");
234
+ });
235
+
236
+ test("static-fallback: Disk-File served when no extraRoute matches", async () => {
237
+ // Komplement-Test: ohne kollidierenden extraRoute liefert der
238
+ // static-fallback die Disk-Datei. Beweist dass der Hono-First-Pfad
239
+ // nicht versehentlich Static-Files schluckt.
240
+ const tmpStaticDir = await createTempStaticDir({
241
+ "robots.txt": "User-agent: *\nAllow: /",
242
+ "index.html": "<html>SPA shell</html>",
243
+ });
244
+
245
+ const handle = await boot(undefined, { staticDir: tmpStaticDir });
246
+
247
+ const res = await handle.fetch(new Request("http://test/robots.txt"));
248
+ expect(res.status).toBe(200);
249
+ expect(await res.text()).toContain("User-agent: *");
250
+ });
251
+
252
+ test("static-fallback: unknown path → SPA-fallback to index.html", async () => {
253
+ // Path ohne extraRoute, ohne Disk-File, mit existierendem
254
+ // index.html → liefert die SPA-Shell. Standard-SPA-Routing-Pattern,
255
+ // aber wir wollen sicher sein dass der Hono-First-Refactor das
256
+ // nicht gebrochen hat.
257
+ const tmpStaticDir = await createTempStaticDir({
258
+ "index.html": "<html>SPA shell</html>",
259
+ });
260
+
261
+ const handle = await boot(undefined, { staticDir: tmpStaticDir });
262
+
263
+ const res = await handle.fetch(new Request("http://test/some/spa/route"));
264
+ expect(res.status).toBe(200);
265
+ expect(await res.text()).toContain("SPA shell");
266
+ });
267
+
268
+ test("hostDispatch: per-host html-Datei + Schema-Gating", async () => {
269
+ // Multi-App-Deployment: zwei HTML-Dateien für unterschiedliche
270
+ // Hosts. Schema wird NUR für admin-Host injected — Public-Host
271
+ // bekommt das pure HTML ohne __KUMIKO_SCHEMA__ Tag (Sicherheit).
272
+ const tmpStaticDir = await createTempStaticDir({
273
+ "index.html": "<html><body>PUBLIC</body><script src=/client.js></script></html>",
274
+ "admin.html": "<html><body>ADMIN</body><script src=/client.js></script></html>",
275
+ });
276
+
277
+ const handle = await boot(undefined, {
278
+ staticDir: tmpStaticDir,
279
+ hostDispatch: ({ host }) => {
280
+ if (host.startsWith("admin.")) {
281
+ return { kind: "html", file: "admin.html", injectSchema: true };
282
+ }
283
+ return { kind: "html", file: "index.html", injectSchema: false };
284
+ },
285
+ });
286
+
287
+ // Public host: index.html, KEIN schema-Tag.
288
+ const pubRes = await handle.fetch(
289
+ new Request("http://demo.example.test/", { headers: { host: "demo.example.test" } }),
290
+ );
291
+ expect(pubRes.status).toBe(200);
292
+ const pubBody = await pubRes.text();
293
+ expect(pubBody).toContain("PUBLIC");
294
+ expect(pubBody).not.toContain("__KUMIKO_SCHEMA__");
295
+
296
+ // Admin host: admin.html MIT schema-Tag.
297
+ const adminRes = await handle.fetch(
298
+ new Request("http://admin.example.test/", { headers: { host: "admin.example.test" } }),
299
+ );
300
+ expect(adminRes.status).toBe(200);
301
+ const adminBody = await adminRes.text();
302
+ expect(adminBody).toContain("ADMIN");
303
+ expect(adminBody).toContain("__KUMIKO_SCHEMA__");
304
+ });
305
+
306
+ test("hostDispatch: redirect-Modus", async () => {
307
+ const tmpStaticDir = await createTempStaticDir({
308
+ "index.html": "<html>fallback</html>",
309
+ });
310
+ const handle = await boot(undefined, {
311
+ staticDir: tmpStaticDir,
312
+ hostDispatch: ({ host }) =>
313
+ host === "apex.example.test"
314
+ ? { kind: "redirect", to: "https://target.example", status: 302 }
315
+ : { kind: "html", file: "index.html", injectSchema: false },
316
+ });
317
+
318
+ const res = await handle.fetch(
319
+ new Request("http://apex.example.test/", { headers: { host: "apex.example.test" } }),
320
+ );
321
+ expect(res.status).toBe(302);
322
+ expect(res.headers.get("Location")).toBe("https://target.example");
323
+ });
324
+
325
+ test("hostDispatch: 404-Modus für unbekannte Hosts", async () => {
326
+ const tmpStaticDir = await createTempStaticDir({
327
+ "index.html": "<html>fallback</html>",
328
+ });
329
+ const handle = await boot(undefined, {
330
+ staticDir: tmpStaticDir,
331
+ hostDispatch: ({ host }) =>
332
+ host === "known.example.test"
333
+ ? { kind: "html", file: "index.html", injectSchema: false }
334
+ : { kind: "not-found" },
335
+ });
336
+
337
+ const res = await handle.fetch(
338
+ new Request("http://unknown.example.test/", { headers: { host: "unknown.example.test" } }),
339
+ );
340
+ expect(res.status).toBe(404);
341
+ });
342
+
343
+ test("hostDispatch: CSP-Header-Passthrough pro Host", async () => {
344
+ const tmpStaticDir = await createTempStaticDir({
345
+ "index.html": "<html>x</html>",
346
+ });
347
+ const csp = "default-src 'self'; script-src 'self'";
348
+ const handle = await boot(undefined, {
349
+ staticDir: tmpStaticDir,
350
+ hostDispatch: () => ({ kind: "html", file: "index.html", injectSchema: false, csp }),
351
+ });
352
+
353
+ const res = await handle.fetch(new Request("http://x.example.test/"));
354
+ expect(res.status).toBe(200);
355
+ expect(res.headers.get("content-security-policy")).toBe(csp);
356
+ });
357
+
358
+ test("hostDispatch: assets bleiben host-unabhängig erreichbar", async () => {
359
+ // /assets/* darf NICHT durch hostDispatch laufen — Bundles werden
360
+ // vom client per absoluter URL nachgeladen, host-Sniffing wäre falsch.
361
+ const tmpStaticDir = await createTempStaticDir({
362
+ "index.html": "<html>x</html>",
363
+ "assets/app-abc.js": "console.log('app');",
364
+ });
365
+ const handle = await boot(undefined, {
366
+ staticDir: tmpStaticDir,
367
+ hostDispatch: () => ({ kind: "not-found" }),
368
+ });
369
+
370
+ const res = await handle.fetch(new Request("http://x.example.test/assets/app-abc.js"));
371
+ expect(res.status).toBe(200);
372
+ expect(await res.text()).toContain("console.log('app')");
373
+ });
374
+
375
+ test("anonymousAccess flows from runProdApp through entrypoint into the auth-middleware", async () => {
376
+ // Regression for the silent-drop bug: ApiEntrypointOptions had no
377
+ // anonymousAccess field, so runProdApp's option went into createApi
378
+ // Entrypoint's spread, vanished, and the auth-middleware never saw
379
+ // it → 401 missing_token even on `roles: ["anonymous"]` handlers.
380
+ const handle = await boot(undefined, {
381
+ anonymousAccess: { defaultTenantId: TENANT_ID },
382
+ });
383
+
384
+ const res = await handle.entrypoint.app.fetch(
385
+ new Request("http://test/api/query", {
386
+ method: "POST",
387
+ headers: { "content-type": "application/json" },
388
+ body: JSON.stringify({
389
+ type: "prod-probe:query:ping",
390
+ payload: {},
391
+ }),
392
+ }),
393
+ );
394
+ expect(res.status).toBe(200);
395
+ const body = (await res.json()) as { data?: { pong?: boolean } };
396
+ expect(body.data?.pong).toBe(true);
397
+ });
398
+
399
+ test("anonymousAccess as factory: receives {db, redis, registry}, resolver closures over db", async () => {
400
+ // Use case: tenantResolver looks up subdomain → tenantId in the DB
401
+ // at request time. The factory is called once at boot with db
402
+ // wired, the resolver inside captures it.
403
+ const seenDeps: { db: boolean; redis: boolean; registry: boolean } = {
404
+ db: false,
405
+ redis: false,
406
+ registry: false,
407
+ };
408
+
409
+ const handle = await boot(undefined, {
410
+ anonymousAccess: ({ db, redis, registry }) => {
411
+ seenDeps.db = db !== undefined;
412
+ seenDeps.redis = redis !== undefined;
413
+ seenDeps.registry = registry !== undefined;
414
+ return { defaultTenantId: TENANT_ID };
415
+ },
416
+ });
417
+
418
+ expect(seenDeps).toEqual({ db: true, redis: true, registry: true });
419
+
420
+ const res = await handle.entrypoint.app.fetch(
421
+ new Request("http://test/api/query", {
422
+ method: "POST",
423
+ headers: { "content-type": "application/json" },
424
+ body: JSON.stringify({ type: "prod-probe:query:ping", payload: {} }),
425
+ }),
426
+ );
427
+ expect(res.status).toBe(200);
428
+ });
429
+
430
+ test("extraContext as factory: factory called with {db, redis, registry} at boot", async () => {
431
+ // Factory-form for extraContext closes over db like anonymousAccess.
432
+ // In auth-mode the framework auto-sets configResolver; Factory-Result
433
+ // wird drauf gemerged. Wichtig: Factory wird genau einmal aufgerufen
434
+ // beim Boot, NACHDEM db/redis/registry konstruiert sind.
435
+ let invocations = 0;
436
+ let factoryDeps: { db: boolean; redis: boolean; registry: boolean } | null = null;
437
+
438
+ const handle = await boot(undefined, {
439
+ extraContext: ({ db, redis, registry }) => {
440
+ invocations++;
441
+ factoryDeps = {
442
+ db: db !== undefined,
443
+ redis: redis !== undefined,
444
+ registry: registry !== undefined,
445
+ };
446
+ return { _appCustomKey: "from-factory" };
447
+ },
448
+ });
449
+
450
+ expect(invocations).toBe(1);
451
+ expect(factoryDeps).toEqual({ db: true, redis: true, registry: true });
452
+ // Smoke: handle is functional (boot completed without error).
453
+ expect(handle.entrypoint).toBeDefined();
454
+ });
455
+
456
+ test("seed runs once on first boot, but the seed's own idempotence prevents duplication on reboot", async () => {
457
+ let seedInvocations = 0;
458
+ let inserted = false;
459
+
460
+ const seed = async ({
461
+ db,
462
+ }: {
463
+ db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
464
+ }) => {
465
+ seedInvocations++;
466
+ // Seed-side idempotence: check before inserting. runProdApp doesn't
467
+ // gate seeds — the seed itself is responsible.
468
+ const existing = await db.execute(sql`SELECT 1 FROM prod_widgets LIMIT 1`);
469
+ if (existing.length > 0) return;
470
+ await db.execute(sql`INSERT INTO prod_widgets (id, tenant_id, name) VALUES
471
+ (gen_random_uuid(), '00000000-0000-4000-8000-000000000001', 'seeded')`);
472
+ inserted = true;
473
+ };
474
+
475
+ await boot(seed);
476
+ expect(seedInvocations).toBe(1);
477
+ expect(inserted).toBe(true);
478
+
479
+ await boot(seed);
480
+ // Seed function was called both times (runProdApp doesn't track),
481
+ // but the seed's own check kept it from inserting again.
482
+ expect(seedInvocations).toBe(2);
483
+
484
+ // Probe DB — exactly one row.
485
+ const second = prodAppHandles[1];
486
+ if (!second) throw new Error("expected second handle");
487
+ // Use the entrypoint's DB context to query (clean shutdown handles
488
+ // the connection lifecycle).
489
+ const ctx = second.entrypoint as unknown as { app: { fetch: typeof fetch } };
490
+ const res = await ctx.app.fetch(new Request("http://test/health"));
491
+ expect(res.status).toBe(200);
492
+ });
493
+
494
+ test("Hard Boot-Gate: pending Migration im Journal → SchemaDriftError, kein Boot", async () => {
495
+ // Schreibt ein synthetisches Migration-Dir mit einer Migration die
496
+ // nie applied wurde. runProdApp soll mit SchemaDriftError abbrechen
497
+ // bevor irgendetwas anderes initialisiert wird.
498
+ const { mkdir } = await import("node:fs/promises");
499
+ const driftDir = await mkdtemp(join(tmpdir(), "kumiko-drift-boot-"));
500
+ tempDirs.push(driftDir);
501
+ await mkdir(join(driftDir, "meta"), { recursive: true });
502
+ await writeFile(
503
+ join(driftDir, "meta", "_journal.json"),
504
+ JSON.stringify({
505
+ version: "7",
506
+ dialect: "postgresql",
507
+ entries: [
508
+ {
509
+ idx: 0,
510
+ version: "7",
511
+ when: 1700000000000,
512
+ tag: "0000_pending_migration",
513
+ breakpoints: true,
514
+ },
515
+ ],
516
+ }),
517
+ );
518
+ await writeFile(
519
+ join(driftDir, "meta", "0000_snapshot.json"),
520
+ JSON.stringify({
521
+ tables: {
522
+ "public.never_created_table": {
523
+ schema: "",
524
+ name: "never_created_table",
525
+ columns: { id: { name: "id", type: "uuid", primaryKey: true, notNull: true } },
526
+ },
527
+ },
528
+ }),
529
+ );
530
+
531
+ await expect(boot(undefined, { migrations: { dir: driftDir } })).rejects.toThrow(
532
+ /Schema drift detected/,
533
+ );
534
+ });
535
+ });
@@ -0,0 +1,143 @@
1
+ // scaffoldFeature tests — verify the CLI's `create` subcommand produces
2
+ // a valid, parsable feature workspace. Strategy: scaffold into an
3
+ // in-tmpdir destination, then read the output back and assert:
4
+ // 1. package.json shape (workspace name, framework dep)
5
+ // 2. feature.ts is parsable by the canonical-form parser without
6
+ // ParseErrors and contains the Schema-Version-Header
7
+ // 3. featureName extracted by the parser matches what was scaffolded
8
+ // 4. Validation: bad names fail loudly, existing destination refuses
9
+ // to overwrite
10
+
11
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { parseSourceFile, VERSION_HEADER } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { Project } from "ts-morph";
16
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
17
+ import { scaffoldFeature } from "../scaffold-feature";
18
+
19
+ let workdir: string;
20
+
21
+ beforeEach(() => {
22
+ workdir = mkdtempSync(join(tmpdir(), "kumiko-scaffold-"));
23
+ });
24
+
25
+ afterEach(() => {
26
+ rmSync(workdir, { recursive: true, force: true });
27
+ });
28
+
29
+ describe("scaffoldFeature — output shape", () => {
30
+ test("creates package.json + tsconfig.json + src/feature.ts at the resolved destination", () => {
31
+ const result = scaffoldFeature({
32
+ name: "todoList",
33
+ destination: join(workdir, "todoList"),
34
+ });
35
+ expect(existsSync(result.packageJsonFile)).toBe(true);
36
+ expect(existsSync(result.tsconfigFile)).toBe(true);
37
+ expect(existsSync(result.featureFile)).toBe(true);
38
+ expect(result.featureName).toBe("todoList");
39
+ expect(result.packageName).toBe("@cosmicdrift/kumiko-sample-todo-list");
40
+ });
41
+
42
+ test("tsconfig.json is strict + bundler-resolution + no-emit", () => {
43
+ const result = scaffoldFeature({
44
+ name: "todoList",
45
+ destination: join(workdir, "todoList"),
46
+ });
47
+ const tsconfig = JSON.parse(readFileSync(result.tsconfigFile, "utf8"));
48
+ expect(tsconfig.compilerOptions.strict).toBe(true);
49
+ expect(tsconfig.compilerOptions.noUncheckedIndexedAccess).toBe(true);
50
+ expect(tsconfig.compilerOptions.moduleResolution).toBe("bundler");
51
+ expect(tsconfig.compilerOptions.noEmit).toBe(true);
52
+ expect(tsconfig.include).toEqual(["src/**/*"]);
53
+ });
54
+
55
+ test("package.json has workspace name + framework dep", () => {
56
+ const result = scaffoldFeature({
57
+ name: "todoList",
58
+ destination: join(workdir, "todoList"),
59
+ });
60
+ const pkg = JSON.parse(readFileSync(result.packageJsonFile, "utf8"));
61
+ expect(pkg.name).toBe("@cosmicdrift/kumiko-sample-todo-list");
62
+ expect(pkg.private).toBe(true);
63
+ expect(pkg.dependencies["@cosmicdrift/kumiko-framework"]).toBe("workspace:*");
64
+ });
65
+
66
+ test("feature.ts starts with the schema-version header", () => {
67
+ const result = scaffoldFeature({
68
+ name: "todoList",
69
+ destination: join(workdir, "todoList"),
70
+ });
71
+ const source = readFileSync(result.featureFile, "utf8");
72
+ expect(source.startsWith(VERSION_HEADER)).toBe(true);
73
+ });
74
+
75
+ test("scaffolded feature.ts parses cleanly with no errors", () => {
76
+ const result = scaffoldFeature({
77
+ name: "todoList",
78
+ destination: join(workdir, "todoList"),
79
+ });
80
+ const source = readFileSync(result.featureFile, "utf8");
81
+ const project = new Project({
82
+ skipAddingFilesFromTsConfig: true,
83
+ skipFileDependencyResolution: true,
84
+ useInMemoryFileSystem: true,
85
+ });
86
+ const sf = project.createSourceFile("scaffolded.ts", source);
87
+ const parsed = parseSourceFile(sf);
88
+ expect(parsed.errors).toEqual([]);
89
+ expect(parsed.featureName).toBe("todoList");
90
+ expect(parsed.patterns.length).toBeGreaterThan(0);
91
+ // Starter pattern is an entity, so the user has something to extend.
92
+ expect(parsed.patterns[0]?.kind).toBe("entity");
93
+ });
94
+ });
95
+
96
+ describe("scaffoldFeature — name validation", () => {
97
+ test("rejects empty name", () => {
98
+ expect(() => scaffoldFeature({ name: "", destination: join(workdir, "x") })).toThrow(
99
+ /feature name is required/,
100
+ );
101
+ });
102
+
103
+ test("rejects PascalCase / dashes / numbers-first", () => {
104
+ expect(() => scaffoldFeature({ name: "TodoList", destination: join(workdir, "a") })).toThrow(
105
+ /not a valid feature name/,
106
+ );
107
+ expect(() => scaffoldFeature({ name: "todo-list", destination: join(workdir, "b") })).toThrow(
108
+ /not a valid feature name/,
109
+ );
110
+ expect(() => scaffoldFeature({ name: "1todo", destination: join(workdir, "c") })).toThrow(
111
+ /not a valid feature name/,
112
+ );
113
+ });
114
+
115
+ test("rejects reserved words", () => {
116
+ expect(() => scaffoldFeature({ name: "delete", destination: join(workdir, "d") })).toThrow(
117
+ /reserved word/,
118
+ );
119
+ });
120
+ });
121
+
122
+ describe("scaffoldFeature — destination handling", () => {
123
+ test("default destination falls under repoRoot/samples/recipes/<kebab>", () => {
124
+ const result = scaffoldFeature({ name: "todoList", repoRoot: workdir });
125
+ expect(result.destination).toBe(join(workdir, "samples", "recipes", "todo-list"));
126
+ });
127
+
128
+ test("refuses to overwrite an existing destination", () => {
129
+ const dest = join(workdir, "todoList");
130
+ scaffoldFeature({ name: "todoList", destination: dest });
131
+ expect(() => scaffoldFeature({ name: "todoList", destination: dest })).toThrow(
132
+ /already exists/,
133
+ );
134
+ });
135
+
136
+ test("camelCase name → kebab-case directory + package suffix", () => {
137
+ const result = scaffoldFeature({
138
+ name: "userProfileCustomization",
139
+ destination: join(workdir, "userProfileCustomization"),
140
+ });
141
+ expect(result.packageName).toBe("@cosmicdrift/kumiko-sample-user-profile-customization");
142
+ });
143
+ });