@mandujs/core 0.54.5 → 0.54.7

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.
@@ -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", () => {