@mandujs/core 0.53.1 → 0.53.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.53.1",
3
+ "version": "0.53.2",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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.minify ?? process.env.NODE_ENV === "production") === false;
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.minify ?? process.env.NODE_ENV === "production",
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": JSON.stringify(process.env.NODE_ENV || "development"), ...options.define },
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.minify ?? process.env.NODE_ENV === "production",
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": JSON.stringify(process.env.NODE_ENV || "development"),
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.minify ?? process.env.NODE_ENV === "production",
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": JSON.stringify(process.env.NODE_ENV || "development"),
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.minify ?? process.env.NODE_ENV === "production",
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": JSON.stringify(process.env.NODE_ENV || "development"),
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.minify ?? process.env.NODE_ENV === "production",
1742
+ minify: shouldMinify(options),
1713
1743
  sourcemap: options.sourcemap ? "external" : "none",
1714
1744
  target: "browser",
1715
- splitting: options.splitting ?? (process.env.NODE_ENV === "production"),
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": JSON.stringify(process.env.NODE_ENV || "development"),
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 = (process.env.NODE_ENV === "production" ? "production" : "development") as
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 = options.outDir || path.join(rootDir, ".mandu/client");
1950
+ const outDir = resolveClientOutDir(rootDir, options.outDir);
1923
1951
  await fs.mkdir(outDir, { recursive: true });
1924
1952
 
1925
1953
  // Hydration 라우트가 없어도 빈 매니페스트를 저장해야 함
@@ -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
- minify: false,
415
- sourcemap: true,
416
- reactCompiler,
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
- minify: false,
1326
- sourcemap: true,
1327
- targetRouteIds: targetIds,
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
- minify: false,
1384
- sourcemap: true,
1385
- skipFrameworkBundles: true,
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
- minify: false,
1509
- sourcemap: true,
1510
- targetRouteIds: [routeId],
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
 
@@ -123,9 +123,14 @@ export interface IslandFileEntry {
123
123
  /**
124
124
  * 번들러 옵션
125
125
  */
126
- export interface BundlerOptions {
127
- /** 코드 압축 여부 (기본: production에서 true) */
128
- minify?: boolean;
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
  /** 파일 감시 모드 */
@@ -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
- * Structural check for "connection/pool closed" errors that Bun.SQL raises
258
- * after `.close()`. Bun surfaces these as `SQLiteError` / `PostgresError` /
259
- * `MySQLError` with a `code` ending in `CLOSED`.
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(): Promise<void> {
431
- if (closed) return; // idempotent
432
- closed = true;
433
- try {
434
- await bunSql.close();
435
- } catch (err) {
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
- function materialize(): Db {
475
- if (real) return real;
476
- real = _createDbWith(getBunSqlCtor(), config);
477
- return real;
478
- }
479
-
480
- const forward = async function forwardCall<T extends Row = Row>(
481
- strings: TemplateStringsArray,
482
- ...values: unknown[]
483
- ): Promise<T[]> {
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
- return await materialize().transaction(fn);
504
- };
505
- (forward as { close: Db["close"] }).close = async function close(): Promise<void> {
506
- // If no query ever ran, materialize() was never called — nothing to
507
- // close. Only materialize + close if a real handle exists.
508
- if (!real) return;
509
- await real.close();
510
- };
511
-
512
- return forward;
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
- const timeoutSec = MYSQL_LOCK_TIMEOUT_SECONDS;
137
- const rows = await db<{ acquired: number | bigint | null }>`
138
- SELECT GET_LOCK(${lockId}, ${timeoutSec}) AS acquired
139
- `;
140
- const first = rows[0];
141
- const value = first ? Number(first.acquired) : NaN;
142
- if (value !== 1) {
143
- throw new Error(
144
- `[@mandujs/core/db/migrations] GET_LOCK(${JSON.stringify(lockId)}, ${timeoutSec}) ` +
145
- `returned ${value === 0 ? "0 (timeout)" : "NULL (error)"}; ` +
146
- `another migration runner may be holding the lock.`,
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
  };