@mandujs/core 0.54.5 → 0.54.6

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": "@mandujs/core",
3
- "version": "0.54.5",
3
+ "version": "0.54.6",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -80,24 +80,42 @@ const manifest: RoutesManifest = mode === "server-page-client-module"
80
80
  },
81
81
  ],
82
82
  }
83
- : {
84
- version: 1,
85
- routes: [
86
- {
87
- id: "demo",
88
- kind: "page",
89
- pattern: "/",
90
- module: "app/page.tsx",
91
- componentModule: "app/page.tsx",
92
- clientModule: "app/demo.client.tsx",
93
- hydration: {
94
- strategy: "island",
95
- priority: "visible",
96
- preload: false,
97
- },
98
- },
99
- ],
100
- };
83
+ : mode === "hydration-no-client-module"
84
+ ? {
85
+ version: 1,
86
+ routes: [
87
+ {
88
+ id: "login",
89
+ kind: "page",
90
+ pattern: "/login",
91
+ module: "app/login/page.tsx",
92
+ componentModule: "app/login/page.tsx",
93
+ hydration: {
94
+ strategy: "full",
95
+ priority: "immediate",
96
+ preload: false,
97
+ },
98
+ },
99
+ ],
100
+ }
101
+ : {
102
+ version: 1,
103
+ routes: [
104
+ {
105
+ id: "demo",
106
+ kind: "page",
107
+ pattern: "/",
108
+ module: "app/page.tsx",
109
+ componentModule: "app/page.tsx",
110
+ clientModule: "app/demo.client.tsx",
111
+ hydration: {
112
+ strategy: "island",
113
+ priority: "visible",
114
+ preload: false,
115
+ },
116
+ },
117
+ ],
118
+ };
101
119
 
102
120
  try {
103
121
  const result = await buildClientBundles(manifest, rootDir, {
@@ -28,11 +28,12 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
28
28
  import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
29
29
  import { tmpdir } from "os";
30
30
  import path from "path";
31
- import type { RouteSpec } from "../../spec/schema";
31
+ import type { RouteSpec, RoutesManifest } from "../../spec/schema";
32
32
  import {
33
33
  _testOnly_scanIslandFiles,
34
34
  _testOnly_scanPartialFiles,
35
35
  _testOnly_getHydratedRoutes,
36
+ _testOnly_getHydrationRoutesMissingClientModule,
36
37
  } from "../build";
37
38
  import { HMR_PERF } from "../../perf/hmr-markers";
38
39
  import {
@@ -504,6 +505,29 @@ describe("Phase 7.1 R1 Agent C — per-island scan skips non-hydrated routes", (
504
505
  });
505
506
  });
506
507
 
508
+ describe("hydration route validation", () => {
509
+ it("finds pages that request hydration without a client module", () => {
510
+ const manifest = {
511
+ version: 1,
512
+ routes: [
513
+ {
514
+ id: "login",
515
+ pattern: "/login",
516
+ kind: "page",
517
+ module: "app/login/page.tsx",
518
+ componentModule: "app/login/page.tsx",
519
+ hydration: { strategy: "full", priority: "immediate", preload: false },
520
+ },
521
+ pureSsrRoute("about", "/about", "app/about"),
522
+ pageRoute("dashboard", "/dashboard", "app/dashboard"),
523
+ ],
524
+ } as RoutesManifest;
525
+
526
+ const missing = _testOnly_getHydrationRoutesMissingClientModule(manifest);
527
+ expect(missing.map((route) => route.id)).toEqual(["login"]);
528
+ });
529
+ });
530
+
507
531
  describe("partial bundle scan", () => {
508
532
  let project: ReturnType<typeof createProject>;
509
533
 
@@ -218,4 +218,39 @@ describe("buildClientBundles vendor shims", () => {
218
218
  await rm(staleRoot, { recursive: true, force: true });
219
219
  }
220
220
  });
221
+
222
+ test("fails when hydration is enabled but no clientModule can be resolved", async () => {
223
+ const missingRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-hydration-no-client-"));
224
+ try {
225
+ await mkdir(path.join(missingRoot, "app", "login"), { recursive: true });
226
+ await mkdir(path.join(missingRoot, "src", "client", "widgets", "login-form"), { recursive: true });
227
+ await writeFile(
228
+ path.join(missingRoot, "package.json"),
229
+ JSON.stringify({ name: "mandu-hydration-no-client-test", type: "module" }, null, 2),
230
+ "utf-8",
231
+ );
232
+ await writeFile(
233
+ path.join(missingRoot, "app", "login", "page.tsx"),
234
+ 'import { LoginForm } from "../../src/client/widgets/login-form/LoginForm.client";\n' +
235
+ "export default function LoginPage() {\n" +
236
+ " return <main><LoginForm /></main>;\n" +
237
+ "}\n",
238
+ "utf-8",
239
+ );
240
+ await writeFile(
241
+ path.join(missingRoot, "src", "client", "widgets", "login-form", "LoginForm.client.tsx"),
242
+ "export function LoginForm() { return <form />; }\n",
243
+ "utf-8",
244
+ );
245
+
246
+ const noClientResult = await runBuildInSubprocess(missingRoot, "hydration-no-client-module");
247
+ const errors = noClientResult.errors.join("\n");
248
+ expect(noClientResult.success).toBe(false);
249
+ expect(errors).toContain("no clientModule could be resolved");
250
+ expect(errors).toContain("LoginForm.client");
251
+ expect(errors).toContain("partial({ component }).Render");
252
+ } finally {
253
+ await rm(missingRoot, { recursive: true, force: true });
254
+ }
255
+ });
221
256
  });
@@ -23,7 +23,10 @@ import type { BunPlugin } from "bun";
23
23
  import { mark, measure } from "../perf";
24
24
  import { HMR_PERF } from "../perf/hmr-markers";
25
25
  import { runOnBundleComplete } from "../plugins/runner";
26
- import { validateClientModuleForBrowserBundle } from "../router/client-entry";
26
+ import {
27
+ describeMissingHydrationClientModule,
28
+ validateClientModuleForBrowserBundle,
29
+ } from "../router/client-entry";
27
30
  import {
28
31
  readVendorCache,
29
32
  writeVendorCache,
@@ -236,7 +239,9 @@ export const _testOnly_scanPartialFiles = scanPartialFiles;
236
239
  *
237
240
  * @internal
238
241
  */
239
- export const _testOnly_getHydratedRoutes = getHydratedRoutes;
242
+ export const _testOnly_getHydratedRoutes = getHydratedRoutes;
243
+ export const _testOnly_getHydrationRoutesMissingClientModule =
244
+ getHydrationRoutesMissingClientModule;
240
245
 
241
246
  /**
242
247
  * Issue #240 Phase 2 — collect every file the React Compiler plugin
@@ -402,14 +407,23 @@ function createEmptyManifest(env: "development" | "production"): BundleManifest
402
407
  /**
403
408
  * Hydration이 필요한 라우트 필터링
404
409
  */
405
- function getHydratedRoutes(manifest: RoutesManifest): RouteSpec[] {
406
- return manifest.routes.filter(
407
- (route) =>
408
- route.kind === "page" &&
409
- route.clientModule &&
410
- needsHydration(route)
411
- );
412
- }
410
+ function getHydratedRoutes(manifest: RoutesManifest): RouteSpec[] {
411
+ return manifest.routes.filter(
412
+ (route) =>
413
+ route.kind === "page" &&
414
+ route.clientModule &&
415
+ needsHydration(route)
416
+ );
417
+ }
418
+
419
+ function getHydrationRoutesMissingClientModule(manifest: RoutesManifest): RouteSpec[] {
420
+ return manifest.routes.filter(
421
+ (route) =>
422
+ route.kind === "page" &&
423
+ !route.clientModule &&
424
+ needsHydration(route)
425
+ );
426
+ }
413
427
 
414
428
  const REACT_SHIM_EXPORTS = [
415
429
  "Activity",
@@ -2120,10 +2134,24 @@ export async function buildClientBundles(
2120
2134
  errors.push(`onBundleComplete[${e.source}]: ${e.error.message}`);
2121
2135
  }
2122
2136
  };
2123
- const env = resolveBundlerMode(options);
2124
-
2137
+ const env = resolveBundlerMode(options);
2138
+
2125
2139
  // 1. Hydration이 필요한 라우트 필터링
2126
2140
  const invalidClientRouteIds = new Set<string>();
2141
+ const runtimeRoutes = manifest.routes.filter((route) => route.kind === "page" && needsHydration(route));
2142
+ const partialFiles = runtimeRoutes.length > 0 ? await scanPartialFiles(rootDir) : [];
2143
+ const routesMissingClientModule = getHydrationRoutesMissingClientModule(manifest);
2144
+ if (routesMissingClientModule.length > 0) {
2145
+ const missingClientErrors = await Promise.all(
2146
+ routesMissingClientModule.map((route) =>
2147
+ describeMissingHydrationClientModule(route, rootDir, {
2148
+ allowPartialOnly: partialFiles.length > 0,
2149
+ })
2150
+ )
2151
+ );
2152
+ errors.push(...missingClientErrors.filter((error): error is string => error !== null));
2153
+ }
2154
+
2127
2155
  let hydratedRoutes = getHydratedRoutes(manifest);
2128
2156
  if (hydratedRoutes.length > 0) {
2129
2157
  const validRoutes: RouteSpec[] = [];
@@ -2138,10 +2166,7 @@ export async function buildClientBundles(
2138
2166
  }
2139
2167
  hydratedRoutes = validRoutes;
2140
2168
  }
2141
- const runtimeRoutes = manifest.routes.filter((route) => route.kind === "page" && needsHydration(route));
2142
- const partialFiles = runtimeRoutes.length > 0 ? await scanPartialFiles(rootDir) : [];
2143
-
2144
- // 2. 출력 디렉토리 생성 (항상 필요 - 매니페스트 저장용)
2169
+ // 2. 출력 디렉토리 생성 (항상 필요 - 매니페스트 저장용)
2145
2170
  const outDir = resolveClientOutDir(rootDir, options.outDir);
2146
2171
  await fs.mkdir(outDir, { recursive: true });
2147
2172
 
@@ -99,17 +99,25 @@ export interface GeneratedMap {
99
99
  frameworkPaths: string[];
100
100
  }
101
101
 
102
- async function ensureDir(dirPath: string): Promise<void> {
103
- try {
104
- await fs.mkdir(dirPath, { recursive: true });
105
- } catch {
106
- // ignore if exists
107
- }
108
- }
109
-
110
- async function getExistingFiles(dir: string): Promise<string[]> {
111
- try {
112
- const files = await fs.readdir(dir);
102
+ async function ensureDir(dirPath: string): Promise<void> {
103
+ try {
104
+ await fs.mkdir(dirPath, { recursive: true });
105
+ } catch {
106
+ // ignore if exists
107
+ }
108
+ }
109
+
110
+ function touchGenerateStamp(rootDir: string): void {
111
+ const stampDir = path.join(rootDir, ".mandu");
112
+ if (!fsSync.existsSync(stampDir)) {
113
+ fsSync.mkdirSync(stampDir, { recursive: true });
114
+ }
115
+ fsSync.writeFileSync(path.join(stampDir, "generate.stamp"), Date.now().toString());
116
+ }
117
+
118
+ async function getExistingFiles(dir: string): Promise<string[]> {
119
+ try {
120
+ const files = await fs.readdir(dir);
113
121
  return files.filter((f) => f.endsWith(".route.ts") || f.endsWith(".route.tsx"));
114
122
  } catch {
115
123
  return [];
@@ -138,9 +146,10 @@ export async function generateRoutes(
138
146
  warnings: [],
139
147
  };
140
148
 
141
- // Suppress watcher during generation to avoid false positives
142
- const watcher = getWatcher();
143
- watcher?.suppress();
149
+ // Suppress watcher during generation to avoid false positives
150
+ const watcher = getWatcher();
151
+ watcher?.suppress();
152
+ touchGenerateStamp(rootDir);
144
153
 
145
154
  const generatedPaths = resolveGeneratedPaths(rootDir);
146
155
  const serverRoutesDir = generatedPaths.serverRoutesDir;
@@ -354,14 +363,10 @@ export async function generateRoutes(
354
363
  const mapPath = path.join(mapDir, "generated.map.json");
355
364
  await Bun.write(mapPath, JSON.stringify(generatedMap, null, 2));
356
365
 
357
- // Resume watcher after generation
358
- watcher?.resume();
359
- // Cross-process timestamp: watcher skips warnings if generate finished recently
360
- const stampDir = path.join(rootDir, ".mandu");
361
- if (!fsSync.existsSync(stampDir)) {
362
- fsSync.mkdirSync(stampDir, { recursive: true });
363
- }
364
- fsSync.writeFileSync(path.join(stampDir, "generate.stamp"), Date.now().toString());
365
-
366
- return result;
367
- }
366
+ // Cross-process timestamp: watcher skips warnings if generate finished recently
367
+ touchGenerateStamp(rootDir);
368
+ // Resume watcher after the stamp is visible.
369
+ watcher?.resume();
370
+
371
+ return result;
372
+ }
@@ -671,10 +671,12 @@ describe("generateSchemaArtifacts — diff + migration orchestration", () => {
671
671
  const result = await computeSchemaGeneration(resources, rootDir);
672
672
  expect(result.changes.length).toBe(2);
673
673
  expect(result.changes.every((c) => c.kind === "create-table")).toBe(true);
674
- expect(result.migrationFilename).not.toBeNull();
675
- expect(result.migrationSql).toContain("CREATE TABLE");
676
- expect(result.desiredSchema).toContain("CREATE TABLE");
677
- expect(Object.keys(result.desiredSchemaByTable).sort()).toEqual(["products", "users"]);
674
+ expect(result.migrationFilename).not.toBeNull();
675
+ expect(result.migrationSql).toContain("CREATE TABLE");
676
+ expect(result.migrationSql).not.toMatch(/\bBEGIN\b/i);
677
+ expect(result.migrationSql).not.toMatch(/\bCOMMIT\b/i);
678
+ expect(result.desiredSchema).toContain("CREATE TABLE");
679
+ expect(Object.keys(result.desiredSchemaByTable).sort()).toEqual(["products", "users"]);
678
680
  } finally {
679
681
  await fs.rm(rootDir, { recursive: true, force: true });
680
682
  }
@@ -415,13 +415,112 @@ describe("emitChange — add-column", () => {
415
415
  expect(emitChange(change, "mysql"))
416
416
  .toBe("ALTER TABLE `users` ADD COLUMN `age` DOUBLE;");
417
417
  });
418
- test("sqlite", () => {
419
- expect(emitChange(change, "sqlite"))
420
- .toBe('ALTER TABLE "users" ADD COLUMN "age" REAL;');
421
- });
422
-
423
- test("indexed:true field emits ADD COLUMN plus auto CREATE INDEX", () => {
424
- const indexed: Change = {
418
+ test("sqlite", () => {
419
+ expect(emitChange(change, "sqlite"))
420
+ .toBe('ALTER TABLE "users" ADD COLUMN "age" REAL;');
421
+ });
422
+
423
+ test("sqlite refuses required add-column without a non-NULL constant default (#294)", () => {
424
+ const required: Change = {
425
+ kind: "add-column",
426
+ resourceName: "votes",
427
+ field: field({ name: "created_at", type: "date" }),
428
+ };
429
+
430
+ expect(() => emitChange(required, "sqlite")).toThrow(
431
+ /SQLite cannot add required column "created_at"/,
432
+ );
433
+ });
434
+
435
+ test("sqlite allows required add-column when a scalar literal default is present", () => {
436
+ const requiredWithDefault: Change = {
437
+ kind: "add-column",
438
+ resourceName: "votes",
439
+ field: field({
440
+ name: "created_at",
441
+ type: "date",
442
+ default: { kind: "literal", value: "1970-01-01T00:00:00.000Z" },
443
+ }),
444
+ };
445
+
446
+ expect(emitChange(requiredWithDefault, "sqlite")).toBe(
447
+ 'ALTER TABLE "votes" ADD COLUMN "created_at" TEXT NOT NULL DEFAULT \'1970-01-01T00:00:00.000Z\';',
448
+ );
449
+ });
450
+
451
+ test("sqlite refuses default: now on add-column because SQLite requires a constant default", () => {
452
+ const nowDefault: Change = {
453
+ kind: "add-column",
454
+ resourceName: "votes",
455
+ field: field({
456
+ name: "created_at",
457
+ type: "date",
458
+ nullable: true,
459
+ default: { kind: "now" },
460
+ }),
461
+ };
462
+
463
+ expect(() => emitChange(nowDefault, "sqlite")).toThrow(
464
+ /CURRENT_TIMESTAMP, which SQLite rejects/,
465
+ );
466
+ });
467
+
468
+ test("sqlite refuses UNIQUE add-column because SQLite cannot add the constraint in place", () => {
469
+ const unique: Change = {
470
+ kind: "add-column",
471
+ resourceName: "votes",
472
+ field: field({ name: "slug", type: "string", nullable: true, unique: true }),
473
+ };
474
+
475
+ expect(() => emitChange(unique, "sqlite")).toThrow(/cannot add UNIQUE column/);
476
+ });
477
+
478
+ test("sqlite refuses PRIMARY KEY add-column because SQLite cannot add the constraint in place", () => {
479
+ const primary: Change = {
480
+ kind: "add-column",
481
+ resourceName: "votes",
482
+ field: field({ name: "other_id", type: "uuid", primary: true }),
483
+ };
484
+
485
+ expect(() => emitChange(primary, "sqlite")).toThrow(/cannot add PRIMARY KEY column/);
486
+ });
487
+
488
+ test("sqlite refuses raw function defaults on add-column", () => {
489
+ const rawFunction: Change = {
490
+ kind: "add-column",
491
+ resourceName: "votes",
492
+ field: field({
493
+ name: "created_at",
494
+ type: "date",
495
+ nullable: true,
496
+ default: { kind: "sql", expr: "datetime('now')" },
497
+ }),
498
+ };
499
+
500
+ expect(() => emitChange(rawFunction, "sqlite")).toThrow(
501
+ /non-constant function defaults/,
502
+ );
503
+ });
504
+
505
+ test("sqlite refuses raw expression defaults on add-column", () => {
506
+ const rawExpression: Change = {
507
+ kind: "add-column",
508
+ resourceName: "votes",
509
+ field: field({
510
+ name: "score",
511
+ type: "number",
512
+ nullable: true,
513
+ default: { kind: "sql", expr: "(1+2)" },
514
+ }),
515
+ };
516
+
517
+ expect(() => emitChange(rawExpression, "sqlite")).toThrow(
518
+ /only accepts literal constant defaults/,
519
+ );
520
+ });
521
+
522
+ test("indexed:true field emits ADD COLUMN plus auto CREATE INDEX", () => {
523
+ const indexed: Change = {
425
524
  kind: "add-column",
426
525
  resourceName: "users",
427
526
  field: field({ name: "role", type: "string", indexed: true }),
@@ -477,22 +576,24 @@ describe("emitChange — alter-column-type stub", () => {
477
576
  stub: true,
478
577
  };
479
578
 
480
- test("emits comment block with required TODO text", () => {
481
- for (const p of PROVIDERS) {
482
- const sql = emitChange(change, p);
483
- expect(sql).toContain("-- TODO: Mandu does not auto-generate ALTER COLUMN TYPE in v1.");
484
- expect(sql).toContain("mandu db apply");
485
- expect(sql).toContain("Column type change detected: users.age");
486
- expect(sql).toContain("from: number");
487
- expect(sql).toContain("to: string");
488
- }
489
- });
490
-
491
- test("includes SELECT 1 no-op statement", () => {
492
- const sql = emitChange(change, "postgres");
493
- expect(sql).toContain("SELECT 1;");
494
- });
495
- });
579
+ test("emits comment block with required TODO text", () => {
580
+ for (const p of PROVIDERS) {
581
+ const sql = emitChange(change, p);
582
+ expect(sql).toContain("-- TODO: Mandu does not auto-generate ALTER COLUMN TYPE in v1.");
583
+ expect(sql).toContain("mandu db apply");
584
+ expect(sql).toContain("Column type change detected: users.age");
585
+ expect(sql).toContain("from: number");
586
+ expect(sql).toContain("to: string");
587
+ expect(sql).toContain("mandu_manual_migration_required");
588
+ }
589
+ });
590
+
591
+ test("includes a deliberate failing manual-migration sentinel", () => {
592
+ const sql = emitChange(change, "postgres");
593
+ expect(sql).toContain("SELECT mandu_manual_migration_required");
594
+ expect(sql).not.toContain("SELECT 1;");
595
+ });
596
+ });
496
597
 
497
598
  describe("emitChange — alter-column-nullable", () => {
498
599
  test("postgres SET NOT NULL / DROP NOT NULL", () => {
@@ -510,24 +611,24 @@ describe("emitChange — alter-column-nullable", () => {
510
611
  ).toBe('ALTER TABLE "users" ALTER COLUMN "email" DROP NOT NULL;');
511
612
  });
512
613
 
513
- test("sqlite emits a stub (cannot toggle NOT NULL in place)", () => {
514
- const sql = emitChange(
515
- { kind: "alter-column-nullable", resourceName: "users", fieldName: "email", nullable: true },
516
- "sqlite",
517
- );
518
- expect(sql).toContain("TODO: SQLite cannot toggle NOT NULL in place");
519
- expect(sql).toContain("SELECT 1;");
520
- });
521
-
522
- test("mysql emits a stub (MODIFY COLUMN needs full type)", () => {
523
- const sql = emitChange(
524
- { kind: "alter-column-nullable", resourceName: "users", fieldName: "email", nullable: false },
525
- "mysql",
526
- );
527
- expect(sql).toContain("MySQL MODIFY COLUMN requires");
528
- expect(sql).toContain("SELECT 1;");
529
- });
530
- });
614
+ test("sqlite emits a stub (cannot toggle NOT NULL in place)", () => {
615
+ const sql = emitChange(
616
+ { kind: "alter-column-nullable", resourceName: "users", fieldName: "email", nullable: true },
617
+ "sqlite",
618
+ );
619
+ expect(sql).toContain("TODO: SQLite cannot toggle NOT NULL in place");
620
+ expect(sql).toContain("mandu_manual_migration_required");
621
+ });
622
+
623
+ test("mysql emits a stub (MODIFY COLUMN needs full type)", () => {
624
+ const sql = emitChange(
625
+ { kind: "alter-column-nullable", resourceName: "users", fieldName: "email", nullable: false },
626
+ "mysql",
627
+ );
628
+ expect(sql).toContain("MySQL MODIFY COLUMN requires");
629
+ expect(sql).toContain("mandu_manual_migration_required");
630
+ });
631
+ });
531
632
 
532
633
  describe("emitChange — alter-column-default", () => {
533
634
  test("postgres SET DEFAULT", () => {
@@ -551,20 +652,35 @@ describe("emitChange — alter-column-default", () => {
551
652
  expect(sql).toBe('ALTER TABLE "users" ALTER COLUMN "status" DROP DEFAULT;');
552
653
  });
553
654
 
554
- test("mysql supports SET/DROP DEFAULT directly", () => {
555
- expect(
556
- emitChange(
557
- {
655
+ test("mysql supports SET/DROP DEFAULT directly", () => {
656
+ expect(
657
+ emitChange(
658
+ {
558
659
  kind: "alter-column-default",
559
660
  resourceName: "u",
560
661
  fieldName: "f",
561
662
  default: { kind: "literal", value: 1 },
562
663
  },
563
664
  "mysql",
564
- ),
565
- ).toBe("ALTER TABLE `u` ALTER COLUMN `f` SET DEFAULT 1;");
566
- });
567
- });
665
+ ),
666
+ ).toBe("ALTER TABLE `u` ALTER COLUMN `f` SET DEFAULT 1;");
667
+ });
668
+
669
+ test("sqlite emits a failing manual stub for default changes", () => {
670
+ const sql = emitChange(
671
+ {
672
+ kind: "alter-column-default",
673
+ resourceName: "users",
674
+ fieldName: "status",
675
+ default: { kind: "literal", value: "active" },
676
+ },
677
+ "sqlite",
678
+ );
679
+ expect(sql).toContain("TODO: SQLite cannot ALTER DEFAULT in place");
680
+ expect(sql).toContain("mandu_manual_migration_required");
681
+ expect(sql).not.toContain("SELECT 1;");
682
+ });
683
+ });
568
684
 
569
685
  describe("emitChange — indexes", () => {
570
686
  test("add-index single column, non-unique", () => {
@@ -15,10 +15,11 @@
15
15
  * 4. Value literals in `DEFAULT` clauses flow through `resolveDefault`
16
16
  * which handles quote escaping. `kind: "sql"` is the explicit
17
17
  * escape hatch — caller's responsibility.
18
- * 5. Unsupported changes (v1 scope — `alter-column-type`) emit a
19
- * `-- TODO:` comment block + a no-op `SELECT 1;` so the generated
20
- * migration still parses and Agent C's runner can record an
21
- * "applied but manual" row.
18
+ * 5. Unsupported changes (v1 scope — `alter-column-type`, and some
19
+ * dialect-specific ALTER gaps) emit a `-- TODO:` comment block plus
20
+ * a deliberate failing sentinel statement. The generated migration
21
+ * is reviewable/editable, but `mandu db apply` will not record it
22
+ * until the operator replaces the TODO with real SQL.
22
23
  *
23
24
  * Determinism:
24
25
  * - `emitCreateTable` emits columns in `DdlFieldDef` array order — the
@@ -338,15 +339,16 @@ export function emitChange(change: Change, provider: SqlProvider): string {
338
339
  // Change emitters (internal — called only via emitChange dispatch).
339
340
  // =====================================================================
340
341
 
341
- function emitAddColumn(
342
- resourceName: string,
343
- field: DdlFieldDef,
344
- provider: SqlProvider,
345
- ): string {
346
- const table = quoteIdent(resourceName, provider);
347
- const columnDef = emitColumnDef(field, provider);
348
- const addColumn = `ALTER TABLE ${table} ADD COLUMN ${columnDef};`;
349
- if (!field.indexed || field.unique || field.primary) return addColumn;
342
+ function emitAddColumn(
343
+ resourceName: string,
344
+ field: DdlFieldDef,
345
+ provider: SqlProvider,
346
+ ): string {
347
+ validateAddColumn(resourceName, field, provider);
348
+ const table = quoteIdent(resourceName, provider);
349
+ const columnDef = emitColumnDef(field, provider);
350
+ const addColumn = `ALTER TABLE ${table} ADD COLUMN ${columnDef};`;
351
+ if (!field.indexed || field.unique || field.primary) return addColumn;
350
352
  return [
351
353
  addColumn,
352
354
  emitCreateIndex(
@@ -356,8 +358,85 @@ function emitAddColumn(
356
358
  false,
357
359
  provider,
358
360
  ),
359
- ].join("\n");
360
- }
361
+ ].join("\n");
362
+ }
363
+
364
+ function validateAddColumn(
365
+ resourceName: string,
366
+ field: DdlFieldDef,
367
+ provider: SqlProvider,
368
+ ): void {
369
+ if (provider !== "sqlite") return;
370
+
371
+ if (field.primary) {
372
+ throw new Error(
373
+ `SQLite cannot add PRIMARY KEY column "${field.name}" to existing table "${resourceName}" via ALTER TABLE. ` +
374
+ `Create a manual table-rebuild migration instead.`,
375
+ );
376
+ }
377
+
378
+ if (field.unique) {
379
+ throw new Error(
380
+ `SQLite cannot add UNIQUE column "${field.name}" to existing table "${resourceName}" via ALTER TABLE. ` +
381
+ `Add a nullable/non-unique column first, backfill it, then create a unique index manually.`,
382
+ );
383
+ }
384
+
385
+ const defaultIssue = sqliteAddColumnDefaultIssue(field);
386
+ if (defaultIssue) {
387
+ throw new Error(
388
+ `SQLite cannot add column "${field.name}" to existing table "${resourceName}" with this DEFAULT: ` +
389
+ `${defaultIssue} Use a scalar literal default or write a manual backfill migration.`,
390
+ );
391
+ }
392
+
393
+ if (field.nullable) return;
394
+ if (field.default !== undefined && !isSqliteNullDefault(field.default)) return;
395
+
396
+ throw new Error(
397
+ `SQLite cannot add required column "${field.name}" to existing table "${resourceName}" without a non-NULL constant DEFAULT. ` +
398
+ `Add a scalar default, set required:false, or write a manual backfill migration.`,
399
+ );
400
+ }
401
+
402
+ function sqliteAddColumnDefaultIssue(field: DdlFieldDef): string | null {
403
+ const def = field.default;
404
+ if (!def) return null;
405
+ if (def.kind === "now") {
406
+ return `default "now" maps to CURRENT_TIMESTAMP, which SQLite rejects in ADD COLUMN.`;
407
+ }
408
+ if (def.kind !== "sql") return null;
409
+
410
+ const expr = def.expr.trim();
411
+ if (/^CURRENT_(?:TIME|DATE|TIMESTAMP)\b/i.test(expr)) {
412
+ return `SQLite rejects CURRENT_TIME/CURRENT_DATE/CURRENT_TIMESTAMP in ADD COLUMN.`;
413
+ }
414
+ if (/\b[A-Za-z_][A-Za-z0-9_]*\s*\(/.test(expr)) {
415
+ return `SQLite rejects non-constant function defaults in ADD COLUMN.`;
416
+ }
417
+ if (!isSqliteAddColumnConstantSqlDefault(expr)) {
418
+ return `SQLite ADD COLUMN only accepts literal constant defaults; raw SQL expression ${JSON.stringify(expr)} is not safe to emit automatically.`;
419
+ }
420
+ return null;
421
+ }
422
+
423
+ function isSqliteNullDefault(def: NonNullable<DdlFieldDef["default"]>): boolean {
424
+ return def.kind === "null" || (def.kind === "sql" && /^NULL$/i.test(def.expr.trim()));
425
+ }
426
+
427
+ function isSqliteAddColumnConstantSqlDefault(expr: string): boolean {
428
+ let value = expr.trim();
429
+ while (value.startsWith("(") && value.endsWith(")")) {
430
+ value = value.slice(1, -1).trim();
431
+ }
432
+ return (
433
+ /^NULL$/i.test(value) ||
434
+ /^(?:TRUE|FALSE)$/i.test(value) ||
435
+ /^[+-]?(?:\d+|\d+\.\d+|\.\d+)(?:e[+-]?\d+)?$/i.test(value) ||
436
+ /^'(?:''|[^'])*'$/.test(value) ||
437
+ /^X'(?:[0-9a-f]{2})*'$/i.test(value)
438
+ );
439
+ }
361
440
 
362
441
  /**
363
442
  * DROP COLUMN.
@@ -383,8 +462,8 @@ function emitDropColumn(
383
462
  *
384
463
  * Output: a multi-line SQL comment block naming the resource + field +
385
464
  * fromType → toType, followed by the literal TODO message and a no-op
386
- * `SELECT 1;` so the migration runner (Agent C) parses and advances past
387
- * this statement.
465
+ * a deliberate failing sentinel so the migration runner refuses to mark
466
+ * this TODO as applied until the operator edits the migration manually.
388
467
  */
389
468
  function emitAlterColumnTypeStub(
390
469
  resourceName: string,
@@ -398,11 +477,13 @@ function emitAlterColumnTypeStub(
398
477
  `-- from: ${fromType}`,
399
478
  `-- to: ${toType}`,
400
479
  `-- TODO: Mandu does not auto-generate ALTER COLUMN TYPE in v1.`,
401
- `-- Please write the migration manually, then re-run \`mandu db apply\`.`,
402
- `-- ================================================================`,
403
- `SELECT 1;`,
404
- ].join("\n");
405
- }
480
+ `-- Please write the migration manually, then re-run \`mandu db apply\`.`,
481
+ `-- ================================================================`,
482
+ manualMigrationRequiredStatement(
483
+ `Mandu cannot auto-generate ALTER COLUMN TYPE for ${resourceName}.${fieldName}`,
484
+ ),
485
+ ].join("\n");
486
+ }
406
487
 
407
488
  function emitAlterColumnNullable(
408
489
  resourceName: string,
@@ -419,30 +500,34 @@ function emitAlterColumnNullable(
419
500
  : `ALTER TABLE ${table} ALTER COLUMN ${col} SET NOT NULL;`;
420
501
  }
421
502
  if (provider === "sqlite") {
422
- // SQLite cannot toggle NOT NULL on an existing column without a full
423
- // table recreate. Emit a stub so the user handles it manually.
424
- return [
425
- `-- ================================================================`,
426
- `-- Nullability change: ${resourceName}.${fieldName} → ${nullable ? "NULL" : "NOT NULL"}`,
427
- `-- TODO: SQLite cannot toggle NOT NULL in place. Recreate the table`,
428
- `-- manually (CREATE new, INSERT SELECT, DROP old, RENAME) and re-run.`,
429
- `-- ================================================================`,
430
- `SELECT 1;`,
431
- ].join("\n");
432
- }
503
+ // SQLite cannot toggle NOT NULL on an existing column without a full
504
+ // table recreate. Emit a failing stub so the user handles it manually.
505
+ return [
506
+ `-- ================================================================`,
507
+ `-- Nullability change: ${resourceName}.${fieldName} → ${nullable ? "NULL" : "NOT NULL"}`,
508
+ `-- TODO: SQLite cannot toggle NOT NULL in place. Recreate the table`,
509
+ `-- manually (CREATE new, INSERT SELECT, DROP old, RENAME) and re-run.`,
510
+ `-- ================================================================`,
511
+ manualMigrationRequiredStatement(
512
+ `SQLite nullability change requires manual table rebuild for ${resourceName}.${fieldName}`,
513
+ ),
514
+ ].join("\n");
515
+ }
433
516
  // MySQL requires the full column spec; we cannot reconstruct it here.
434
517
  // Emit a stub — Agent B's diff emits `alter-column-nullable` only when
435
518
  // everything else matches, so this is narrow-scope.
436
519
  return [
437
520
  `-- ================================================================`,
438
521
  `-- Nullability change: ${resourceName}.${fieldName} → ${nullable ? "NULL" : "NOT NULL"}`,
439
- `-- TODO: MySQL MODIFY COLUMN requires the full column type; Mandu v1`,
440
- `-- cannot emit this automatically. Please edit this migration to use`,
441
- `-- \`ALTER TABLE ${resourceName} MODIFY COLUMN ${fieldName} <TYPE> ${nullable ? "NULL" : "NOT NULL"}\``,
442
- `-- ================================================================`,
443
- `SELECT 1;`,
444
- ].join("\n");
445
- }
522
+ `-- TODO: MySQL MODIFY COLUMN requires the full column type; Mandu v1`,
523
+ `-- cannot emit this automatically. Please edit this migration to use`,
524
+ `-- \`ALTER TABLE ${resourceName} MODIFY COLUMN ${fieldName} <TYPE> ${nullable ? "NULL" : "NOT NULL"}\``,
525
+ `-- ================================================================`,
526
+ manualMigrationRequiredStatement(
527
+ `MySQL nullability change requires manual MODIFY COLUMN for ${resourceName}.${fieldName}`,
528
+ ),
529
+ ].join("\n");
530
+ }
446
531
 
447
532
  function emitAlterColumnDefault(
448
533
  resourceName: string,
@@ -458,15 +543,17 @@ function emitAlterColumnDefault(
458
543
  : `ALTER TABLE ${table} ALTER COLUMN ${col} DROP DEFAULT;`;
459
544
  }
460
545
  if (provider === "sqlite") {
461
- // Same constraint as nullability — SQLite needs a table recreate.
462
- return [
463
- `-- ================================================================`,
464
- `-- Default change: ${resourceName}.${fieldName}`,
465
- `-- TODO: SQLite cannot ALTER DEFAULT in place. Recreate the table.`,
466
- `-- ================================================================`,
467
- `SELECT 1;`,
468
- ].join("\n");
469
- }
546
+ // Same constraint as nullability — SQLite needs a table recreate.
547
+ return [
548
+ `-- ================================================================`,
549
+ `-- Default change: ${resourceName}.${fieldName}`,
550
+ `-- TODO: SQLite cannot ALTER DEFAULT in place. Recreate the table.`,
551
+ `-- ================================================================`,
552
+ manualMigrationRequiredStatement(
553
+ `SQLite default change requires manual table rebuild for ${resourceName}.${fieldName}`,
554
+ ),
555
+ ].join("\n");
556
+ }
470
557
  // MySQL — ALTER COLUMN ... SET DEFAULT / DROP DEFAULT is actually supported.
471
558
  return def
472
559
  ? `ALTER TABLE ${table} ALTER COLUMN ${col} SET DEFAULT ${resolveDefault(def, provider)};`
@@ -510,7 +597,7 @@ function emitRenameTable(
510
597
  return `ALTER TABLE ${from} RENAME TO ${to};`;
511
598
  }
512
599
 
513
- function emitRenameColumn(
600
+ function emitRenameColumn(
514
601
  resourceName: string,
515
602
  oldName: string,
516
603
  newName: string,
@@ -522,7 +609,15 @@ function emitRenameColumn(
522
609
  // All three dialects use `ALTER TABLE ... RENAME COLUMN ... TO ...` in
523
610
  // their modern versions (PG >=9.2, MySQL >=8.0.3, SQLite >=3.25).
524
611
  return `ALTER TABLE ${table} RENAME COLUMN ${from} TO ${to};`;
525
- }
612
+ }
613
+
614
+ function manualMigrationRequiredStatement(message: string): string {
615
+ return `SELECT mandu_manual_migration_required(${sqlStringLiteral(message)});`;
616
+ }
617
+
618
+ function sqlStringLiteral(value: string): string {
619
+ return `'${value.replace(/'/g, "''")}'`;
620
+ }
526
621
 
527
622
  // =====================================================================
528
623
  // Validation helpers.
@@ -363,21 +363,17 @@ ${result.migrationSql}`;
363
363
  // ============================================
364
364
 
365
365
  /**
366
- * Wrap the sequence of `Change` → SQL emission with a transaction
367
- * header/footer. SQLite uses `BEGIN` / `COMMIT` (plain) because its
368
- * migration runner invokes each file via `db.transaction()` anyway
369
- * but the explicit BEGIN/COMMIT is harmless inside an already-open tx
370
- * and makes the file runnable standalone via `sqlite3 foo.db < file.sql`.
371
- */
372
- function composeMigrationSql(changes: readonly Change[], provider: SqlProvider): string {
373
- const body = emitChanges(changes, provider);
374
- if (body.length === 0) return "";
375
- return `BEGIN;
376
-
377
- ${body}
378
-
379
- COMMIT;`;
380
- }
366
+ * Compose the sequence of `Change` → SQL emission.
367
+ *
368
+ * Do not wrap with `BEGIN` / `COMMIT`: the migration runner already applies
369
+ * each file inside a transaction, and SQLite rejects nested BEGIN with
370
+ * "cannot start a transaction within a transaction".
371
+ */
372
+ function composeMigrationSql(changes: readonly Change[], provider: SqlProvider): string {
373
+ const body = emitChanges(changes, provider);
374
+ if (body.length === 0) return "";
375
+ return body;
376
+ }
381
377
 
382
378
  // ============================================
383
379
  // Internals — filesystem I/O
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { findClientComponentImports } from "./client-entry";
3
+
4
+ describe("findClientComponentImports", () => {
5
+ it("detects named .client imports for diagnostics", () => {
6
+ const imports = findClientComponentImports(`
7
+ import { LoginForm, SubmitButton as Button } from "@/client/widgets/login-form/LoginForm.client";
8
+ import Header from "./Header.client.tsx";
9
+ `);
10
+
11
+ expect(imports).toEqual([
12
+ {
13
+ module: "@/client/widgets/login-form/LoginForm.client",
14
+ kind: "named",
15
+ names: ["LoginForm", "SubmitButton"],
16
+ },
17
+ {
18
+ module: "./Header.client.tsx",
19
+ kind: "default",
20
+ names: ["Header"],
21
+ },
22
+ ]);
23
+ });
24
+ });
@@ -2,6 +2,12 @@ import { readFile } from "fs/promises";
2
2
  import path from "path";
3
3
  import type { RouteSpec } from "../spec/schema";
4
4
 
5
+ export interface ClientComponentImport {
6
+ module: string;
7
+ kind: "default" | "named" | "namespace" | "side-effect" | "mixed";
8
+ names: string[];
9
+ }
10
+
5
11
  export function normalizeRouteModulePath(value: string | undefined): string {
6
12
  return (value ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
7
13
  }
@@ -32,6 +38,62 @@ async function readRouteModule(rootDir: string, modulePath: string): Promise<str
32
38
  }
33
39
  }
34
40
 
41
+ export function findClientComponentImports(source: string): ClientComponentImport[] {
42
+ const imports: ClientComponentImport[] = [];
43
+ const importFromPattern = /import\s+([\s\S]*?)\s+from\s+["']([^"']*\.client(?:\.[tj]sx?)?)["']/g;
44
+ const sideEffectPattern = /import\s+["']([^"']*\.client(?:\.[tj]sx?)?)["']/g;
45
+
46
+ for (const match of source.matchAll(importFromPattern)) {
47
+ const clause = (match[1] ?? "").trim();
48
+ const module = match[2] ?? "";
49
+ const names: string[] = [];
50
+ let hasDefault = false;
51
+ let hasNamed = false;
52
+ let hasNamespace = false;
53
+
54
+ const namedMatch = clause.match(/\{([^}]*)\}/);
55
+ if (namedMatch) {
56
+ hasNamed = true;
57
+ for (const rawName of namedMatch[1].split(",")) {
58
+ const name = rawName.trim();
59
+ if (!name) continue;
60
+ names.push(name.split(/\s+as\s+/i)[0].trim());
61
+ }
62
+ }
63
+
64
+ if (/\*\s+as\s+/.test(clause)) {
65
+ hasNamespace = true;
66
+ const namespaceMatch = clause.match(/\*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)/);
67
+ if (namespaceMatch?.[1]) names.push(namespaceMatch[1]);
68
+ }
69
+
70
+ const beforeNamed = clause.split("{")[0]?.replace(/,\s*$/, "").trim() ?? "";
71
+ if (beforeNamed && !beforeNamed.startsWith("*")) {
72
+ hasDefault = true;
73
+ names.push(beforeNamed.split(",")[0].trim());
74
+ }
75
+
76
+ const kind =
77
+ (hasDefault && (hasNamed || hasNamespace))
78
+ ? "mixed"
79
+ : hasNamed
80
+ ? "named"
81
+ : hasNamespace
82
+ ? "namespace"
83
+ : "default";
84
+
85
+ imports.push({ module, kind, names });
86
+ }
87
+
88
+ for (const match of source.matchAll(sideEffectPattern)) {
89
+ const module = match[1] ?? "";
90
+ if (imports.some((entry) => entry.module === module)) continue;
91
+ imports.push({ module, kind: "side-effect", names: [] });
92
+ }
93
+
94
+ return imports;
95
+ }
96
+
35
97
  export async function shouldPreserveExistingClientModule(
36
98
  route: RouteSpec,
37
99
  clientModule: string,
@@ -69,3 +131,48 @@ export async function validateClientModuleForBrowserBundle(
69
131
 
70
132
  return null;
71
133
  }
134
+
135
+ export async function describeMissingHydrationClientModule(
136
+ route: RouteSpec,
137
+ rootDir: string,
138
+ options: { allowPartialOnly?: boolean } = {},
139
+ ): Promise<string | null> {
140
+ const hydration = route.hydration?.strategy ?? "island";
141
+ const base =
142
+ `[${route.id}] Route has hydration strategy "${hydration}" but no clientModule could be resolved. ` +
143
+ `Mandu cannot emit a working data-mandu-src for this route.`;
144
+
145
+ const componentModule = route.kind === "page" ? route.componentModule : undefined;
146
+ if (!componentModule) {
147
+ return options.allowPartialOnly && hydration === "island" ? null : base;
148
+ }
149
+
150
+ const source = await readRouteModule(rootDir, componentModule);
151
+ if (source === null) {
152
+ return options.allowPartialOnly && hydration === "island" ? null : base;
153
+ }
154
+
155
+ const clientImports = findClientComponentImports(source);
156
+ if (clientImports.length === 0) {
157
+ if (options.allowPartialOnly && hydration === "island") return null;
158
+ return (
159
+ `${base}\n` +
160
+ ` Fix: add a route-level client module (for example app/*.island.tsx or spec/slots/${route.id}.client.tsx) ` +
161
+ `or set hydration.strategy to "none".`
162
+ );
163
+ }
164
+
165
+ const importList = clientImports
166
+ .map((entry) => {
167
+ const suffix = entry.names.length > 0 ? ` (${entry.kind}: ${entry.names.join(", ")})` : ` (${entry.kind})`;
168
+ return ` - ${entry.module}${suffix}`;
169
+ })
170
+ .join("\n");
171
+
172
+ return (
173
+ `${base}\n` +
174
+ ` The page imports client-looking modules, but inline .client.tsx imports are not route bundles:\n` +
175
+ `${importList}\n` +
176
+ ` Fix: use partial({ component }).Render for embedded client regions, or expose a route-level client module.`
177
+ );
178
+ }
@@ -2232,18 +2232,26 @@ async function renderPageSSR(
2232
2232
 
2233
2233
  // Island 래핑: 레이아웃 적용 전에 페이지 콘텐츠만 island div로 감쌈
2234
2234
  // 이렇게 하면 레이아웃은 island 바깥에 위치하여 하이드레이션 시 레이아웃이 유지됨
2235
- const needsIslandWrap = !!(
2235
+ const needsIslandHydration = !!(
2236
2236
  route.hydration &&
2237
2237
  route.hydration.strategy !== "none" &&
2238
2238
  settings.bundleManifest
2239
2239
  );
2240
-
2241
- if (needsIslandWrap) {
2242
- const bundle = settings.bundleManifest?.bundles[route.id];
2243
- const bundleSrc = bundle?.js ? `${bundle.js}?t=${Date.now()}` : "";
2244
- const priority = route.hydration!.priority || "visible";
2245
- app = React.createElement("div", {
2246
- "data-mandu-island": route.id,
2240
+ const routeBundle = settings.bundleManifest?.bundles[route.id];
2241
+ const bundleSrc = routeBundle?.js ? `${routeBundle.js}?t=${Date.now()}` : "";
2242
+ const needsIslandWrap = needsIslandHydration && bundleSrc.length > 0;
2243
+
2244
+ if (needsIslandHydration && !needsIslandWrap && settings.isDev) {
2245
+ console.warn(
2246
+ `[Mandu] Hydration requested for route "${route.id}" but no client bundle was found. ` +
2247
+ `Run mandu build/generate and ensure the route has a clientModule.`,
2248
+ );
2249
+ }
2250
+
2251
+ if (needsIslandWrap) {
2252
+ const priority = route.hydration!.priority || "visible";
2253
+ app = React.createElement("div", {
2254
+ "data-mandu-island": route.id,
2247
2255
  "data-mandu-src": bundleSrc,
2248
2256
  "data-mandu-priority": priority,
2249
2257
  style: { display: "contents" },
@@ -0,0 +1,59 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { hasRecentGenerateStamp } from "../watcher";
6
+
7
+ const tmpRoots: string[] = [];
8
+
9
+ function makeRoot(): string {
10
+ const root = mkdtempSync(join(tmpdir(), "mandu-watch-"));
11
+ tmpRoots.push(root);
12
+ return root;
13
+ }
14
+
15
+ afterEach(() => {
16
+ for (const root of tmpRoots.splice(0)) {
17
+ rmSync(root, { recursive: true, force: true });
18
+ }
19
+ });
20
+
21
+ test("hasRecentGenerateStamp suppresses generated events using the root stamp", () => {
22
+ const root = makeRoot();
23
+ const now = 1_700_000_000_000;
24
+ mkdirSync(join(root, ".mandu"), { recursive: true });
25
+ writeFileSync(join(root, ".mandu", "generate.stamp"), String(now - 500));
26
+
27
+ const generatedFile = join(
28
+ root,
29
+ ".mandu",
30
+ "generated",
31
+ "server",
32
+ "repos",
33
+ "notification.repo.ts",
34
+ );
35
+
36
+ expect(hasRecentGenerateStamp(root, generatedFile, now)).toBe(true);
37
+ });
38
+
39
+ test("hasRecentGenerateStamp does not suppress stale generated events", () => {
40
+ const root = makeRoot();
41
+ const now = 1_700_000_000_000;
42
+ mkdirSync(join(root, ".mandu"), { recursive: true });
43
+ writeFileSync(join(root, ".mandu", "generate.stamp"), String(now - 30_000));
44
+
45
+ const generatedFile = join(root, ".mandu", "generated", "server", "old.route.ts");
46
+
47
+ expect(hasRecentGenerateStamp(root, generatedFile, now)).toBe(false);
48
+ });
49
+
50
+ test("hasRecentGenerateStamp does not suppress source files after generation", () => {
51
+ const root = makeRoot();
52
+ const now = 1_700_000_000_000;
53
+ mkdirSync(join(root, ".mandu"), { recursive: true });
54
+ writeFileSync(join(root, ".mandu", "generate.stamp"), String(now - 500));
55
+
56
+ const sourceFile = join(root, "app", "page.tsx");
57
+
58
+ expect(hasRecentGenerateStamp(root, sourceFile, now)).toBe(false);
59
+ });
@@ -61,13 +61,63 @@ const DEFAULT_CONFIG: Partial<WatcherConfig> = {
61
61
  * These cause EISDIR/ENOENT errors when file watchers try to access them.
62
62
  * See: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
63
63
  */
64
- const WINDOWS_RESERVED_NAMES = new Set([
65
- "CON", "PRN", "AUX", "NUL",
66
- "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
67
- "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
68
- ]);
69
-
70
- export class FileWatcher {
64
+ const WINDOWS_RESERVED_NAMES = new Set([
65
+ "CON", "PRN", "AUX", "NUL",
66
+ "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
67
+ "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
68
+ ]);
69
+
70
+ const GENERATE_STAMP_SUPPRESS_MS = 10_000;
71
+
72
+ function readGenerateStamp(stampFile: string): number | null {
73
+ try {
74
+ const stamp = Number.parseInt(fs.readFileSync(stampFile, "utf-8"), 10);
75
+ return Number.isFinite(stamp) ? stamp : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ function isManduGeneratedPath(rootDir: string, filePath: string): boolean {
82
+ const relativePath = path.relative(rootDir, filePath).replace(/\\/g, "/");
83
+ return relativePath === ".mandu/generated" || relativePath.startsWith(".mandu/generated/");
84
+ }
85
+
86
+ export function hasRecentGenerateStamp(
87
+ rootDir: string,
88
+ filePath: string,
89
+ now: number = Date.now()
90
+ ): boolean {
91
+ if (!isManduGeneratedPath(rootDir, filePath)) {
92
+ return false;
93
+ }
94
+
95
+ const candidates = new Set<string>([
96
+ path.join(rootDir, ".mandu", "generate.stamp"),
97
+ ]);
98
+ const resolvedRoot = path.resolve(rootDir);
99
+ let stampDir = path.dirname(path.resolve(filePath));
100
+
101
+ while (stampDir !== path.dirname(stampDir)) {
102
+ candidates.add(path.join(stampDir, ".mandu", "generate.stamp"));
103
+ if (stampDir === resolvedRoot) break;
104
+ stampDir = path.dirname(stampDir);
105
+ }
106
+
107
+ for (const stampFile of candidates) {
108
+ const stamp = readGenerateStamp(stampFile);
109
+ if (stamp === null) continue;
110
+
111
+ const ageMs = now - stamp;
112
+ if (ageMs >= 0 && ageMs < GENERATE_STAMP_SUPPRESS_MS) {
113
+ return true;
114
+ }
115
+ }
116
+
117
+ return false;
118
+ }
119
+
120
+ export class FileWatcher {
71
121
  private config: WatcherConfig;
72
122
  private chokidarWatcher: FSWatcher | null = null;
73
123
  private handlers: Set<WatchEventHandler> = new Set();
@@ -311,21 +361,10 @@ export class FileWatcher {
311
361
 
312
362
  const { rootDir } = this.config;
313
363
 
314
- // Cross-process: skip if generate finished within last 2 seconds
315
- // Walk up from the changed file to find nearest .mandu/generate.stamp
316
- let stampDir = path.dirname(filePath);
317
- while (stampDir !== path.dirname(stampDir)) {
318
- const stampFile = path.join(stampDir, ".mandu", "generate.stamp");
319
- try {
320
- const stamp = parseInt(fs.readFileSync(stampFile, "utf-8"), 10);
321
- if (Date.now() - stamp < 2000) return;
322
- break;
323
- } catch {}
324
- stampDir = path.dirname(stampDir);
325
- }
326
-
327
-
328
- // Validate file against rules
364
+ // Cross-process: skip generated-file churn from a recent mandu generate.
365
+ if (hasRecentGenerateStamp(rootDir, filePath)) return;
366
+
367
+ // Validate file against rules
329
368
  try {
330
369
  const warnings = await validateFile(filePath, event, rootDir);
331
370