@mandujs/core 0.53.1 → 0.53.3
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/build.ts +54 -26
- package/src/bundler/dev.ts +21 -17
- package/src/bundler/types.ts +8 -3
- package/src/config/validate.ts +3 -3
- package/src/db/index.ts +138 -51
- package/src/db/migrations/lock.ts +67 -12
- package/src/db/migrations/runner.ts +118 -101
- package/src/diagnose/__tests__/checks.test.ts +74 -3
- package/src/diagnose/checks.ts +112 -0
- package/src/diagnose/index.ts +1 -0
- package/src/diagnose/run.ts +2 -0
- package/src/kitchen/api/agent-devtools-api.ts +544 -0
- package/src/kitchen/kitchen-handler.ts +33 -16
- package/src/kitchen/kitchen-ui.ts +346 -62
- package/src/resource/__tests__/generator.test.ts +32 -15
- package/src/resource/ddl/__tests__/emit.test.ts +24 -0
- package/src/resource/ddl/emit.ts +12 -1
- package/src/resource/generator-repo.ts +40 -20
- package/src/runtime/ssr.ts +24 -31
- package/src/runtime/streaming-ssr.ts +32 -37
package/package.json
CHANGED
package/src/bundler/build.ts
CHANGED
|
@@ -65,9 +65,9 @@ function manduDefaultPlugins(options: BundlerOptions): BunPlugin[] {
|
|
|
65
65
|
* this; SSR builds use `manduDefaultPlugins()` directly because React
|
|
66
66
|
* Compiler offers zero benefit for one-shot HTML rendering.
|
|
67
67
|
*/
|
|
68
|
-
function manduClientPlugins(options: BundlerOptions): BunPlugin[] {
|
|
69
|
-
const base = manduDefaultPlugins(options);
|
|
70
|
-
if (options.reactCompiler?.enabled !== true) return base;
|
|
68
|
+
function manduClientPlugins(options: BundlerOptions): BunPlugin[] {
|
|
69
|
+
const base = manduDefaultPlugins(options);
|
|
70
|
+
if (options.reactCompiler?.enabled !== true) return base;
|
|
71
71
|
// Lazy import to avoid pulling the react-compiler module into every
|
|
72
72
|
// build graph — SSR / non-client paths never touch this branch.
|
|
73
73
|
const { reactCompiler } = require("./plugins/react-compiler") as typeof __ManduPluginsReactCompilerTypes0;
|
|
@@ -75,9 +75,41 @@ function manduClientPlugins(options: BundlerOptions): BunPlugin[] {
|
|
|
75
75
|
...base,
|
|
76
76
|
reactCompiler({
|
|
77
77
|
reactCompilerConfig: options.reactCompiler.compilerConfig,
|
|
78
|
-
}),
|
|
79
|
-
];
|
|
80
|
-
}
|
|
78
|
+
}),
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveBundlerMode(options: BundlerOptions): "development" | "production" {
|
|
83
|
+
return options.mode ?? (process.env.NODE_ENV === "production" ? "production" : "development");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isDevelopmentBuild(options: BundlerOptions): boolean {
|
|
87
|
+
return resolveBundlerMode(options) === "development";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function shouldMinify(options: BundlerOptions): boolean {
|
|
91
|
+
return options.minify ?? (resolveBundlerMode(options) === "production");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function shouldSplitChunks(options: BundlerOptions): boolean {
|
|
95
|
+
return options.splitting ?? (resolveBundlerMode(options) === "production");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function nodeEnvDefine(options: BundlerOptions): string {
|
|
99
|
+
return JSON.stringify(resolveBundlerMode(options));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function resolveClientOutDir(rootDir: string, outDir?: string): string {
|
|
103
|
+
const defaultOutDir = path.join(rootDir, ".mandu/client");
|
|
104
|
+
if (!outDir) return defaultOutDir;
|
|
105
|
+
|
|
106
|
+
const resolvedOutDir = path.isAbsolute(outDir) ? outDir : path.resolve(rootDir, outDir);
|
|
107
|
+
if (path.normalize(resolvedOutDir) === path.normalize(path.join(rootDir, ".mandu"))) {
|
|
108
|
+
return defaultOutDir;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return resolvedOutDir;
|
|
112
|
+
}
|
|
81
113
|
|
|
82
114
|
/**
|
|
83
115
|
* Scan for *.island.tsx / *.island.ts files across hydrated route directories.
|
|
@@ -230,20 +262,20 @@ async function buildPerIslandBundle(
|
|
|
230
262
|
// Phase 7.1 B-1/B-4: wire Bun's native React Fast Refresh transform +
|
|
231
263
|
// Mandu's boundary injection plugin — but only in dev. Production
|
|
232
264
|
// bundles stay clean of `$RefreshReg$` / `$RefreshSig$` stubs.
|
|
233
|
-
const isDev = (options
|
|
265
|
+
const isDev = isDevelopmentBuild(options);
|
|
234
266
|
try {
|
|
235
267
|
await Bun.write(entryPath, generateIslandEntry(entry.name, entry.filePath));
|
|
236
268
|
const result = await safeBuild({
|
|
237
269
|
entrypoints: [entryPath],
|
|
238
270
|
outdir: outDir,
|
|
239
271
|
naming: outputName,
|
|
240
|
-
minify: options
|
|
272
|
+
minify: shouldMinify(options),
|
|
241
273
|
sourcemap: options.sourcemap ? "external" : "none",
|
|
242
274
|
target: "browser",
|
|
243
275
|
...(isDev ? { reactFastRefresh: true } : {}),
|
|
244
276
|
plugins: [...manduClientPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
|
|
245
277
|
external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
|
|
246
|
-
define: { "process.env.NODE_ENV":
|
|
278
|
+
define: { "process.env.NODE_ENV": nodeEnvDefine(options), ...options.define },
|
|
247
279
|
});
|
|
248
280
|
await fs.unlink(entryPath).catch(() => {});
|
|
249
281
|
if (!result.success) {
|
|
@@ -1235,12 +1267,12 @@ async function buildRouterRuntime(
|
|
|
1235
1267
|
entrypoints: [routerPath],
|
|
1236
1268
|
outdir: outDir,
|
|
1237
1269
|
naming: outputName,
|
|
1238
|
-
minify: options
|
|
1270
|
+
minify: shouldMinify(options),
|
|
1239
1271
|
sourcemap: options.sourcemap ? "external" : "none",
|
|
1240
1272
|
target: "browser",
|
|
1241
1273
|
plugins: manduDefaultPlugins(options),
|
|
1242
1274
|
define: {
|
|
1243
|
-
"process.env.NODE_ENV":
|
|
1275
|
+
"process.env.NODE_ENV": nodeEnvDefine(options),
|
|
1244
1276
|
...options.define,
|
|
1245
1277
|
},
|
|
1246
1278
|
});
|
|
@@ -1311,13 +1343,13 @@ async function buildRuntime(
|
|
|
1311
1343
|
entrypoints: [runtimePath],
|
|
1312
1344
|
outdir: outDir,
|
|
1313
1345
|
naming: outputName,
|
|
1314
|
-
minify: options
|
|
1346
|
+
minify: shouldMinify(options),
|
|
1315
1347
|
sourcemap: options.sourcemap ? "external" : "none",
|
|
1316
1348
|
target: "browser",
|
|
1317
1349
|
external: ["react", "react-dom", "react-dom/client"],
|
|
1318
1350
|
plugins: manduDefaultPlugins(options),
|
|
1319
1351
|
define: {
|
|
1320
|
-
"process.env.NODE_ENV":
|
|
1352
|
+
"process.env.NODE_ENV": nodeEnvDefine(options),
|
|
1321
1353
|
...options.define,
|
|
1322
1354
|
},
|
|
1323
1355
|
});
|
|
@@ -1472,8 +1504,7 @@ async function buildVendorShims(
|
|
|
1472
1504
|
// Phase 7.1 B-2: dev-only Fast Refresh shims. In production we skip
|
|
1473
1505
|
// them entirely so `react-refresh/runtime` is never bundled and the
|
|
1474
1506
|
// attack surface / bundle size regressions stay zero for deploys.
|
|
1475
|
-
const isDev =
|
|
1476
|
-
(options.minify ?? process.env.NODE_ENV === "production") === false;
|
|
1507
|
+
const isDev = isDevelopmentBuild(options);
|
|
1477
1508
|
|
|
1478
1509
|
const shims: Array<{ name: string; source: string; key: VendorShimKey; cacheId: string }> = [
|
|
1479
1510
|
{ name: "_react", source: generateReactShimSource(), key: "react", cacheId: "react" },
|
|
@@ -1601,13 +1632,13 @@ async function buildVendorShims(
|
|
|
1601
1632
|
entrypoints: [srcPath],
|
|
1602
1633
|
outdir: outDir,
|
|
1603
1634
|
naming: outputName,
|
|
1604
|
-
minify: options
|
|
1635
|
+
minify: shouldMinify(options),
|
|
1605
1636
|
sourcemap: options.sourcemap ? "external" : "none",
|
|
1606
1637
|
target: "browser",
|
|
1607
1638
|
external: shimExternal,
|
|
1608
1639
|
plugins: manduDefaultPlugins(options),
|
|
1609
1640
|
define: {
|
|
1610
|
-
"process.env.NODE_ENV":
|
|
1641
|
+
"process.env.NODE_ENV": nodeEnvDefine(options),
|
|
1611
1642
|
...options.define,
|
|
1612
1643
|
},
|
|
1613
1644
|
});
|
|
@@ -1697,8 +1728,7 @@ async function buildIsland(
|
|
|
1697
1728
|
|
|
1698
1729
|
// Phase 7.1 B-1/B-4: wire native Fast Refresh transform + Mandu's
|
|
1699
1730
|
// boundary injection plugin. Dev-only; prod bundles remain clean.
|
|
1700
|
-
const isDev =
|
|
1701
|
-
(options.minify ?? process.env.NODE_ENV === "production") === false;
|
|
1731
|
+
const isDev = isDevelopmentBuild(options);
|
|
1702
1732
|
try {
|
|
1703
1733
|
// 엔트리 래퍼 생성
|
|
1704
1734
|
await Bun.write(entryPath, generateIslandEntry(route.id, clientModulePath));
|
|
@@ -1709,15 +1739,15 @@ async function buildIsland(
|
|
|
1709
1739
|
entrypoints: [entryPath],
|
|
1710
1740
|
outdir: outDir,
|
|
1711
1741
|
naming: options.splitting ? "[name]-[hash].js" : outputName,
|
|
1712
|
-
minify: options
|
|
1742
|
+
minify: shouldMinify(options),
|
|
1713
1743
|
sourcemap: options.sourcemap ? "external" : "none",
|
|
1714
1744
|
target: "browser",
|
|
1715
|
-
splitting: options
|
|
1745
|
+
splitting: shouldSplitChunks(options),
|
|
1716
1746
|
...(isDev ? { reactFastRefresh: true } : {}),
|
|
1717
1747
|
plugins: [...manduClientPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
|
|
1718
1748
|
external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
|
|
1719
1749
|
define: {
|
|
1720
|
-
"process.env.NODE_ENV":
|
|
1750
|
+
"process.env.NODE_ENV": nodeEnvDefine(options),
|
|
1721
1751
|
...options.define,
|
|
1722
1752
|
},
|
|
1723
1753
|
});
|
|
@@ -1911,15 +1941,13 @@ export async function buildClientBundles(
|
|
|
1911
1941
|
errors.push(`onBundleComplete[${e.source}]: ${e.error.message}`);
|
|
1912
1942
|
}
|
|
1913
1943
|
};
|
|
1914
|
-
const env = (
|
|
1915
|
-
| "development"
|
|
1916
|
-
| "production";
|
|
1944
|
+
const env = resolveBundlerMode(options);
|
|
1917
1945
|
|
|
1918
1946
|
// 1. Hydration이 필요한 라우트 필터링
|
|
1919
1947
|
const hydratedRoutes = getHydratedRoutes(manifest);
|
|
1920
1948
|
|
|
1921
1949
|
// 2. 출력 디렉토리 생성 (항상 필요 - 매니페스트 저장용)
|
|
1922
|
-
const outDir =
|
|
1950
|
+
const outDir = resolveClientOutDir(rootDir, options.outDir);
|
|
1923
1951
|
await fs.mkdir(outDir, { recursive: true });
|
|
1924
1952
|
|
|
1925
1953
|
// Hydration 라우트가 없어도 빈 매니페스트를 저장해야 함
|
package/src/bundler/dev.ts
CHANGED
|
@@ -409,11 +409,12 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
409
409
|
} = options;
|
|
410
410
|
|
|
411
411
|
// 초기 빌드
|
|
412
|
-
console.log("🔨 Initial client bundle build...");
|
|
413
|
-
const initialBuild = await buildClientBundles(manifest, rootDir, {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
412
|
+
console.log("🔨 Initial client bundle build...");
|
|
413
|
+
const initialBuild = await buildClientBundles(manifest, rootDir, {
|
|
414
|
+
mode: "development",
|
|
415
|
+
minify: false,
|
|
416
|
+
sourcemap: true,
|
|
417
|
+
reactCompiler,
|
|
417
418
|
});
|
|
418
419
|
|
|
419
420
|
if (initialBuild.success) {
|
|
@@ -1321,10 +1322,11 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
1321
1322
|
const targetIds = Array.from(new Set(islandRoots.map((r) => r.routeId)));
|
|
1322
1323
|
const startTime = performance.now();
|
|
1323
1324
|
try {
|
|
1324
|
-
const result = await buildClientBundles(manifest, rootDir, {
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1325
|
+
const result = await buildClientBundles(manifest, rootDir, {
|
|
1326
|
+
mode: "development",
|
|
1327
|
+
minify: false,
|
|
1328
|
+
sourcemap: true,
|
|
1329
|
+
targetRouteIds: targetIds,
|
|
1328
1330
|
reactCompiler,
|
|
1329
1331
|
});
|
|
1330
1332
|
const buildTime = performance.now() - startTime;
|
|
@@ -1379,10 +1381,11 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
1379
1381
|
|
|
1380
1382
|
try {
|
|
1381
1383
|
// #185: framework 번들 (runtime/router/vendor/devtools) 스킵 — 사용자 코드 변경 시 불필요
|
|
1382
|
-
const result = await buildClientBundles(manifest, rootDir, {
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1384
|
+
const result = await buildClientBundles(manifest, rootDir, {
|
|
1385
|
+
mode: "development",
|
|
1386
|
+
minify: false,
|
|
1387
|
+
sourcemap: true,
|
|
1388
|
+
skipFrameworkBundles: true,
|
|
1386
1389
|
reactCompiler,
|
|
1387
1390
|
});
|
|
1388
1391
|
|
|
@@ -1504,10 +1507,11 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
1504
1507
|
|
|
1505
1508
|
try {
|
|
1506
1509
|
// 단일 island만 재빌드 (Runtime/Router/Vendor 스킵, #122)
|
|
1507
|
-
const result = await buildClientBundles(manifest, rootDir, {
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1510
|
+
const result = await buildClientBundles(manifest, rootDir, {
|
|
1511
|
+
mode: "development",
|
|
1512
|
+
minify: false,
|
|
1513
|
+
sourcemap: true,
|
|
1514
|
+
targetRouteIds: [routeId],
|
|
1511
1515
|
reactCompiler,
|
|
1512
1516
|
});
|
|
1513
1517
|
|
package/src/bundler/types.ts
CHANGED
|
@@ -123,9 +123,14 @@ export interface IslandFileEntry {
|
|
|
123
123
|
/**
|
|
124
124
|
* 번들러 옵션
|
|
125
125
|
*/
|
|
126
|
-
export interface BundlerOptions {
|
|
127
|
-
/**
|
|
128
|
-
|
|
126
|
+
export interface BundlerOptions {
|
|
127
|
+
/**
|
|
128
|
+
* Build mode. This controls development-only transforms such as React
|
|
129
|
+
* Fast Refresh independently from minification.
|
|
130
|
+
*/
|
|
131
|
+
mode?: "development" | "production";
|
|
132
|
+
/** 코드 압축 여부 (기본: production에서 true) */
|
|
133
|
+
minify?: boolean;
|
|
129
134
|
/** 소스맵 생성 여부 */
|
|
130
135
|
sourcemap?: boolean;
|
|
131
136
|
/** 파일 감시 모드 */
|
package/src/config/validate.ts
CHANGED
|
@@ -237,9 +237,9 @@ const BuildBudgetConfigSchema = z
|
|
|
237
237
|
/**
|
|
238
238
|
* Build 설정 스키마 (strict)
|
|
239
239
|
*/
|
|
240
|
-
const BuildConfigSchema = z
|
|
241
|
-
.object({
|
|
242
|
-
outDir: z.string().default(".mandu"),
|
|
240
|
+
const BuildConfigSchema = z
|
|
241
|
+
.object({
|
|
242
|
+
outDir: z.string().default(".mandu/client"),
|
|
243
243
|
minify: z.boolean().default(true),
|
|
244
244
|
sourcemap: z.boolean().default(false),
|
|
245
245
|
splitting: z.boolean().default(false),
|
package/src/db/index.ts
CHANGED
|
@@ -115,7 +115,7 @@ export interface DbConfig {
|
|
|
115
115
|
* returns the full result array. The attached methods support one-shot
|
|
116
116
|
* reads, transactions, and shutdown.
|
|
117
117
|
*/
|
|
118
|
-
export interface Db {
|
|
118
|
+
export interface Db {
|
|
119
119
|
/**
|
|
120
120
|
* Tagged-template query. Values are bound as parameters, never
|
|
121
121
|
* interpolated as SQL text.
|
|
@@ -158,8 +158,17 @@ export interface Db {
|
|
|
158
158
|
* Closes the connection pool. Subsequent queries reject with a clear
|
|
159
159
|
* "pool closed" error. Calling `close()` twice is a no-op (idempotent).
|
|
160
160
|
*/
|
|
161
|
-
close(): Promise<void>;
|
|
162
|
-
}
|
|
161
|
+
close(options?: DbCloseOptions): Promise<void>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Options forwarded to Bun.SQL pool shutdown. */
|
|
165
|
+
export interface DbCloseOptions {
|
|
166
|
+
/**
|
|
167
|
+
* Maximum seconds to wait for in-flight queries before closing the pool.
|
|
168
|
+
* `0` asks Bun.SQL to close immediately.
|
|
169
|
+
*/
|
|
170
|
+
timeout?: number;
|
|
171
|
+
}
|
|
163
172
|
|
|
164
173
|
// ─── Bun runtime surface (structural; no `any`) ─────────────────────────────
|
|
165
174
|
|
|
@@ -181,14 +190,14 @@ interface BunSqlOptions {
|
|
|
181
190
|
* A Bun.SQL instance — itself a callable tagged-template function with
|
|
182
191
|
* methods attached. We only model the subset we actually use.
|
|
183
192
|
*/
|
|
184
|
-
interface BunSqlInstance {
|
|
185
|
-
<T = unknown>(strings: TemplateStringsArray, ...values: unknown[]): Promise<
|
|
186
|
-
T[] & { count?: number; command?: string }
|
|
187
|
-
>;
|
|
188
|
-
begin<R>(fn: (tx: BunSqlInstance) => Promise<R>): Promise<R>;
|
|
189
|
-
close(): Promise<void>;
|
|
190
|
-
readonly options?: BunSqlOptions;
|
|
191
|
-
}
|
|
193
|
+
interface BunSqlInstance {
|
|
194
|
+
<T = unknown>(strings: TemplateStringsArray, ...values: unknown[]): Promise<
|
|
195
|
+
T[] & { count?: number; command?: string }
|
|
196
|
+
>;
|
|
197
|
+
begin<R>(fn: (tx: BunSqlInstance) => Promise<R>): Promise<R>;
|
|
198
|
+
close(options?: DbCloseOptions): Promise<void>;
|
|
199
|
+
readonly options?: BunSqlOptions;
|
|
200
|
+
}
|
|
192
201
|
|
|
193
202
|
/**
|
|
194
203
|
* Constructor surface — `Bun.SQL` is a class; we only need the `new`
|
|
@@ -250,13 +259,35 @@ function getBunSqlCtor(): BunSqlCtor {
|
|
|
250
259
|
|
|
251
260
|
// ─── Error helpers ──────────────────────────────────────────────────────────
|
|
252
261
|
|
|
253
|
-
const POOL_CLOSED_MESSAGE =
|
|
254
|
-
"[@mandujs/core/db] pool closed — query issued after Db.close().";
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
262
|
+
const POOL_CLOSED_MESSAGE =
|
|
263
|
+
"[@mandujs/core/db] pool closed — query issued after Db.close().";
|
|
264
|
+
|
|
265
|
+
const PIN_DB_HANDLE = Symbol.for("@mandujs/core/db/pin-handle");
|
|
266
|
+
|
|
267
|
+
interface DbHandlePin {
|
|
268
|
+
[PIN_DB_HANDLE]?: <R>(fn: () => Promise<R>) => Promise<R>;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Internal helper for code paths that must keep a MySQL session alive across
|
|
273
|
+
* transaction boundaries, such as named advisory locks. Public Db callers do
|
|
274
|
+
* not need this; normal MySQL transactions recycle the handle afterwards.
|
|
275
|
+
*
|
|
276
|
+
* @internal
|
|
277
|
+
*/
|
|
278
|
+
export async function withPinnedDbHandle<R>(
|
|
279
|
+
db: Db,
|
|
280
|
+
fn: () => Promise<R>,
|
|
281
|
+
): Promise<R> {
|
|
282
|
+
const pin = (db as DbHandlePin)[PIN_DB_HANDLE];
|
|
283
|
+
if (!pin) return await fn();
|
|
284
|
+
return await pin(fn);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Structural check for "connection/pool closed" errors that Bun.SQL raises
|
|
289
|
+
* after `.close()`. Bun surfaces these as `SQLiteError` / `PostgresError` /
|
|
290
|
+
* `MySQLError` with a `code` ending in `CLOSED`.
|
|
260
291
|
*/
|
|
261
292
|
function isPoolClosedError(err: unknown): boolean {
|
|
262
293
|
if (!err || typeof err !== "object") return false;
|
|
@@ -427,12 +458,14 @@ function buildDbHandle(bunSql: BunSqlInstance, provider: SqlProvider): Db {
|
|
|
427
458
|
});
|
|
428
459
|
};
|
|
429
460
|
|
|
430
|
-
(db as { close: Db["close"] }).close = async function close(
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
461
|
+
(db as { close: Db["close"] }).close = async function close(
|
|
462
|
+
options?: DbCloseOptions,
|
|
463
|
+
): Promise<void> {
|
|
464
|
+
if (closed) return; // idempotent
|
|
465
|
+
closed = true;
|
|
466
|
+
try {
|
|
467
|
+
await bunSql.close(options);
|
|
468
|
+
} catch (err) {
|
|
436
469
|
// Bun.SQL can throw if already-closed under the hood (mostly from a
|
|
437
470
|
// racing concurrent close). We already flipped our flag so subsequent
|
|
438
471
|
// queries reject cleanly — swallow this one.
|
|
@@ -451,7 +484,7 @@ function buildDbHandle(bunSql: BunSqlInstance, provider: SqlProvider): Db {
|
|
|
451
484
|
*
|
|
452
485
|
* @throws {TypeError} when `config.url` is missing or empty.
|
|
453
486
|
*/
|
|
454
|
-
export function createDb(config: DbConfig): Db {
|
|
487
|
+
export function createDb(config: DbConfig): Db {
|
|
455
488
|
// Up-front config validation. We check `url` here (not only in
|
|
456
489
|
// `_createDbWith`) so the error fires at construction time — matches
|
|
457
490
|
// the TypeError contract the public API documents.
|
|
@@ -469,18 +502,43 @@ export function createDb(config: DbConfig): Db {
|
|
|
469
502
|
// fires on the first real call, with a version-specific message.
|
|
470
503
|
//
|
|
471
504
|
// We still need to *return* a Db now, so call through a forwarding
|
|
472
|
-
// function that probes on demand.
|
|
473
|
-
let real: Db | null = null;
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
505
|
+
// function that probes on demand.
|
|
506
|
+
let real: Db | null = null;
|
|
507
|
+
let closed = false;
|
|
508
|
+
let pinDepth = 0;
|
|
509
|
+
let pendingMysqlRecycle = false;
|
|
510
|
+
function materialize(): Db {
|
|
511
|
+
if (closed) {
|
|
512
|
+
throw new Error(POOL_CLOSED_MESSAGE);
|
|
513
|
+
}
|
|
514
|
+
if (real) return real;
|
|
515
|
+
real = _createDbWith(getBunSqlCtor(), config);
|
|
516
|
+
return real;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function flushPendingMysqlRecycle(): Promise<void> {
|
|
520
|
+
if (provider !== "mysql" || pinDepth > 0 || !pendingMysqlRecycle) return;
|
|
521
|
+
pendingMysqlRecycle = false;
|
|
522
|
+
if (closed || !real) return;
|
|
523
|
+
const db = real;
|
|
524
|
+
real = null;
|
|
525
|
+
await db.close({ timeout: 0 });
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function recycleMysqlHandle(db: Db): Promise<void> {
|
|
529
|
+
if (provider !== "mysql" || real !== db || closed) return;
|
|
530
|
+
if (pinDepth > 0) {
|
|
531
|
+
pendingMysqlRecycle = true;
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
real = null;
|
|
535
|
+
await db.close({ timeout: 0 });
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const forward = async function forwardCall<T extends Row = Row>(
|
|
539
|
+
strings: TemplateStringsArray,
|
|
540
|
+
...values: unknown[]
|
|
541
|
+
): Promise<T[]> {
|
|
484
542
|
return await materialize()<T>(strings, ...values);
|
|
485
543
|
} as unknown as Db;
|
|
486
544
|
|
|
@@ -497,17 +555,46 @@ export function createDb(config: DbConfig): Db {
|
|
|
497
555
|
...values: unknown[]
|
|
498
556
|
): Promise<T | null> {
|
|
499
557
|
return await materialize().one<T>(strings, ...values);
|
|
500
|
-
};
|
|
501
|
-
(forward as { transaction: Db["transaction"] }).transaction =
|
|
502
|
-
async function transaction<R>(fn: (tx: Db) => Promise<R>): Promise<R> {
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
}
|
|
558
|
+
};
|
|
559
|
+
(forward as { transaction: Db["transaction"] }).transaction =
|
|
560
|
+
async function transaction<R>(fn: (tx: Db) => Promise<R>): Promise<R> {
|
|
561
|
+
const hadMaterializedHandle = real !== null;
|
|
562
|
+
let db = materialize();
|
|
563
|
+
if (provider === "mysql" && hadMaterializedHandle) {
|
|
564
|
+
await recycleMysqlHandle(db);
|
|
565
|
+
db = materialize();
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
return await db.transaction(fn);
|
|
569
|
+
} finally {
|
|
570
|
+
await recycleMysqlHandle(db);
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
(forward as { close: Db["close"] }).close = async function close(
|
|
574
|
+
options?: DbCloseOptions,
|
|
575
|
+
): Promise<void> {
|
|
576
|
+
if (closed) return;
|
|
577
|
+
closed = true;
|
|
578
|
+
// If no query ever ran, materialize() was never called — nothing to close.
|
|
579
|
+
if (!real) return;
|
|
580
|
+
const db = real;
|
|
581
|
+
real = null;
|
|
582
|
+
await db.close(options);
|
|
583
|
+
};
|
|
584
|
+
Object.defineProperty(forward, PIN_DB_HANDLE, {
|
|
585
|
+
value: async function withPinnedHandle<R>(fn: () => Promise<R>): Promise<R> {
|
|
586
|
+
pinDepth += 1;
|
|
587
|
+
try {
|
|
588
|
+
return await fn();
|
|
589
|
+
} finally {
|
|
590
|
+
pinDepth -= 1;
|
|
591
|
+
await flushPendingMysqlRecycle();
|
|
592
|
+
}
|
|
593
|
+
},
|
|
594
|
+
enumerable: false,
|
|
595
|
+
writable: false,
|
|
596
|
+
configurable: false,
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
return forward;
|
|
600
|
+
}
|
|
@@ -126,6 +126,7 @@ async function acquireMysqlLock(
|
|
|
126
126
|
db: Db,
|
|
127
127
|
lockId: string,
|
|
128
128
|
): Promise<MigrationLock> {
|
|
129
|
+
const processLock = await acquireMysqlProcessMutex(lockId);
|
|
129
130
|
// `GET_LOCK` returns:
|
|
130
131
|
// 1 — lock granted
|
|
131
132
|
// 0 — timeout (still blocked after the timeout)
|
|
@@ -133,18 +134,23 @@ async function acquireMysqlLock(
|
|
|
133
134
|
//
|
|
134
135
|
// Bun.SQL returns the single row as `[{ acquired: 1 }]`; we destructure
|
|
135
136
|
// defensively to handle any driver-side aliasing.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
`
|
|
146
|
-
|
|
147
|
-
|
|
137
|
+
try {
|
|
138
|
+
const timeoutSec = MYSQL_LOCK_TIMEOUT_SECONDS;
|
|
139
|
+
const rows = await db<{ acquired: number | bigint | null }>`
|
|
140
|
+
SELECT GET_LOCK(${lockId}, ${timeoutSec}) AS acquired
|
|
141
|
+
`;
|
|
142
|
+
const first = rows[0];
|
|
143
|
+
const value = first ? Number(first.acquired) : NaN;
|
|
144
|
+
if (value !== 1) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`[@mandujs/core/db/migrations] GET_LOCK(${JSON.stringify(lockId)}, ${timeoutSec}) ` +
|
|
147
|
+
`returned ${value === 0 ? "0 (timeout)" : "NULL (error)"}; ` +
|
|
148
|
+
`another migration runner may be holding the lock.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
await processLock.release();
|
|
153
|
+
throw err;
|
|
148
154
|
}
|
|
149
155
|
|
|
150
156
|
let _released = false;
|
|
@@ -159,6 +165,55 @@ async function acquireMysqlLock(
|
|
|
159
165
|
console.warn(
|
|
160
166
|
`[@mandujs/core/db/migrations] RELEASE_LOCK failed: ${msg}`,
|
|
161
167
|
);
|
|
168
|
+
} finally {
|
|
169
|
+
await processLock.release();
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Same-process MySQL runners are serialised before hitting GET_LOCK.
|
|
177
|
+
* Cross-process safety still comes from MySQL's named lock; this mutex only
|
|
178
|
+
* avoids concurrent GET_LOCK calls from separate Bun.SQL pools in one runtime.
|
|
179
|
+
*/
|
|
180
|
+
const MYSQL_LOCK_REGISTRY_SYMBOL = Symbol.for(
|
|
181
|
+
"@mandujs/core/db/migrations/mysql-locks",
|
|
182
|
+
);
|
|
183
|
+
interface MysqlLockRegistry {
|
|
184
|
+
chains: Map<string, Promise<void>>;
|
|
185
|
+
}
|
|
186
|
+
function getMysqlLockRegistry(): MysqlLockRegistry {
|
|
187
|
+
const g = globalThis as unknown as Record<symbol, unknown>;
|
|
188
|
+
let reg = g[MYSQL_LOCK_REGISTRY_SYMBOL] as MysqlLockRegistry | undefined;
|
|
189
|
+
if (!reg) {
|
|
190
|
+
reg = { chains: new Map() };
|
|
191
|
+
g[MYSQL_LOCK_REGISTRY_SYMBOL] = reg;
|
|
192
|
+
}
|
|
193
|
+
return reg;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function acquireMysqlProcessMutex(lockId: string): Promise<MigrationLock> {
|
|
197
|
+
const registry = getMysqlLockRegistry();
|
|
198
|
+
const previous = registry.chains.get(lockId) ?? Promise.resolve();
|
|
199
|
+
|
|
200
|
+
let release!: () => void;
|
|
201
|
+
const nextPromise = new Promise<void>((resolve) => {
|
|
202
|
+
release = resolve;
|
|
203
|
+
});
|
|
204
|
+
const tail = previous.then(() => nextPromise);
|
|
205
|
+
registry.chains.set(lockId, tail);
|
|
206
|
+
|
|
207
|
+
await previous;
|
|
208
|
+
|
|
209
|
+
let _released = false;
|
|
210
|
+
return {
|
|
211
|
+
async release(): Promise<void> {
|
|
212
|
+
if (_released) return;
|
|
213
|
+
_released = true;
|
|
214
|
+
release();
|
|
215
|
+
if (registry.chains.get(lockId) === tail) {
|
|
216
|
+
registry.chains.delete(lockId);
|
|
162
217
|
}
|
|
163
218
|
},
|
|
164
219
|
};
|