@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.
- package/package.json +1 -1
- package/src/agent/__tests__/context.test.ts +237 -237
- package/src/agent/types.ts +308 -308
- package/src/agent/verify.ts +406 -406
- 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 +71 -0
- package/src/router/client-entry.ts +286 -0
- package/src/router/fs-scanner.ts +21 -8
- package/src/runtime/server.ts +16 -8
- package/src/watcher/__tests__/watcher.test.ts +59 -0
- package/src/watcher/watcher.ts +61 -22
|
@@ -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", () => {
|