@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 +1 -1
- package/src/bundler/__tests__/build-runner.ts +36 -18
- package/src/bundler/__tests__/cold-start.test.ts +25 -1
- package/src/bundler/build.test.ts +35 -0
- package/src/bundler/build.ts +41 -16
- package/src/generator/generate.ts +30 -25
- package/src/resource/__tests__/generator.test.ts +6 -4
- package/src/resource/ddl/__tests__/emit.test.ts +165 -49
- package/src/resource/ddl/emit.ts +146 -51
- package/src/resource/generator-schema.ts +11 -15
- package/src/router/client-entry.test.ts +24 -0
- package/src/router/client-entry.ts +107 -0
- package/src/runtime/server.ts +16 -8
- package/src/watcher/__tests__/watcher.test.ts +59 -0
- package/src/watcher/watcher.ts +61 -22
package/package.json
CHANGED
|
@@ -80,24 +80,42 @@ const manifest: RoutesManifest = mode === "server-page-client-module"
|
|
|
80
80
|
},
|
|
81
81
|
],
|
|
82
82
|
}
|
|
83
|
-
:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
});
|
package/src/bundler/build.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
//
|
|
358
|
-
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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.
|
|
677
|
-
expect(
|
|
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("
|
|
424
|
-
const
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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("
|
|
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("
|
|
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", () => {
|
package/src/resource/ddl/emit.ts
CHANGED
|
@@ -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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
347
|
-
const
|
|
348
|
-
const
|
|
349
|
-
|
|
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
|
-
*
|
|
387
|
-
* this
|
|
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
|
-
|
|
404
|
-
|
|
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
|
-
|
|
431
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*/
|
|
372
|
-
function composeMigrationSql(changes: readonly Change[], provider: SqlProvider): string {
|
|
373
|
-
const body = emitChanges(changes, provider);
|
|
374
|
-
if (body.length === 0) return "";
|
|
375
|
-
return
|
|
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
|
+
}
|
package/src/runtime/server.ts
CHANGED
|
@@ -2232,18 +2232,26 @@ async function renderPageSSR(
|
|
|
2232
2232
|
|
|
2233
2233
|
// Island 래핑: 레이아웃 적용 전에 페이지 콘텐츠만 island div로 감쌈
|
|
2234
2234
|
// 이렇게 하면 레이아웃은 island 바깥에 위치하여 하이드레이션 시 레이아웃이 유지됨
|
|
2235
|
-
const
|
|
2235
|
+
const needsIslandHydration = !!(
|
|
2236
2236
|
route.hydration &&
|
|
2237
2237
|
route.hydration.strategy !== "none" &&
|
|
2238
2238
|
settings.bundleManifest
|
|
2239
2239
|
);
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
"
|
|
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
|
+
});
|
package/src/watcher/watcher.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|