@mandujs/core 0.53.0 → 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.
Files changed (112) hide show
  1. package/package.json +38 -2
  2. package/src/a11y/run-audit.ts +6 -6
  3. package/src/brain/adapters/oauth-flow.ts +1 -1
  4. package/src/brain/architecture/analyzer.ts +0 -2
  5. package/src/brain/credentials.ts +2 -2
  6. package/src/bundler/__tests__/build-runner.ts +13 -1
  7. package/src/bundler/__tests__/extended-watch.test.ts +3 -2
  8. package/src/bundler/__tests__/fast-refresh.test.ts +10 -12
  9. package/src/bundler/build.test.ts +30 -33
  10. package/src/bundler/build.ts +56 -27
  11. package/src/bundler/dev.ts +44 -39
  12. package/src/bundler/prerender.ts +63 -0
  13. package/src/bundler/safe-build.test.ts +2 -1
  14. package/src/bundler/types.ts +13 -6
  15. package/src/change/history.ts +1 -1
  16. package/src/change/snapshot.ts +18 -9
  17. package/src/client/Link.tsx +9 -9
  18. package/src/client/globals.ts +3 -2
  19. package/src/client/hooks.ts +0 -4
  20. package/src/client/hydrate.ts +1 -1
  21. package/src/client/router.ts +12 -12
  22. package/src/client/rpc.ts +1 -1
  23. package/src/client/use-fetch.ts +6 -6
  24. package/src/client/use-sse.ts +5 -5
  25. package/src/config/mcp-status.ts +0 -1
  26. package/src/config/validate.ts +4 -4
  27. package/src/config/watcher.ts +1 -1
  28. package/src/content/collection.ts +8 -12
  29. package/src/content/content-layer.ts +5 -5
  30. package/src/content/data-store.ts +2 -2
  31. package/src/content/loader-context.ts +0 -1
  32. package/src/content/loaders/file.ts +1 -1
  33. package/src/content/loaders/glob.ts +4 -4
  34. package/src/content/loaders/types.ts +1 -1
  35. package/src/content/meta-store.ts +1 -1
  36. package/src/content/types.ts +1 -1
  37. package/src/contract/define.ts +11 -12
  38. package/src/contract/index.ts +3 -3
  39. package/src/contract/protection.ts +2 -2
  40. package/src/contract/types.ts +1 -1
  41. package/src/contract/validator.ts +0 -1
  42. package/src/db/__tests__/db.test.ts +4 -7
  43. package/src/db/index.ts +138 -51
  44. package/src/db/migrations/index.ts +3 -0
  45. package/src/db/migrations/lock.ts +78 -23
  46. package/src/db/migrations/runner.ts +118 -101
  47. package/src/deploy/cache.ts +1 -0
  48. package/src/design/extract.ts +2 -1
  49. package/src/design/patch.ts +0 -1
  50. package/src/devtools/ai/mcp-connector.ts +24 -24
  51. package/src/devtools/client/components/kitchen-root.tsx +1 -1
  52. package/src/devtools/client/components/panel/errors-panel.tsx +1 -1
  53. package/src/devtools/client/state-manager.ts +1 -1
  54. package/src/devtools/init.ts +2 -3
  55. package/src/devtools/server/source-context.ts +1 -1
  56. package/src/devtools/worker/redaction-worker.ts +8 -4
  57. package/src/devtools/worker/worker-manager.ts +4 -3
  58. package/src/error/classifier.ts +1 -1
  59. package/src/error/domains.ts +1 -1
  60. package/src/filling/sse.ts +5 -5
  61. package/src/generator/generate.ts +2 -2
  62. package/src/guard/analyzer.ts +1 -1
  63. package/src/guard/ast-analyzer.ts +7 -7
  64. package/src/guard/auto-correct.ts +2 -2
  65. package/src/guard/config-guard.ts +0 -2
  66. package/src/guard/contract-guard.ts +1 -1
  67. package/src/guard/decision-memory.ts +2 -2
  68. package/src/guard/design-inline-class.ts +2 -1
  69. package/src/guard/graph.ts +1 -1
  70. package/src/guard/healing.ts +36 -41
  71. package/src/guard/negotiation.ts +3 -3
  72. package/src/guard/reporter.ts +1 -4
  73. package/src/guard/semantic-slots.ts +2 -2
  74. package/src/guard/suggestions.ts +2 -2
  75. package/src/guard/watcher.ts +5 -4
  76. package/src/index.ts +5 -5
  77. package/src/intent/index.ts +1 -1
  78. package/src/kitchen/api/agent-devtools-api.ts +544 -0
  79. package/src/kitchen/api/contract-api.ts +1 -1
  80. package/src/kitchen/api/file-api.ts +3 -3
  81. package/src/kitchen/kitchen-handler.ts +63 -38
  82. package/src/kitchen/kitchen-ui.ts +346 -62
  83. package/src/lockfile/validate.ts +0 -1
  84. package/src/openapi/generator.ts +2 -1
  85. package/src/openapi/openapi.test.ts +43 -14
  86. package/src/perf/user-marks.ts +1 -1
  87. package/src/plugins/registry.ts +1 -2
  88. package/src/plugins/types.ts +1 -1
  89. package/src/resource/__tests__/generator.test.ts +32 -15
  90. package/src/resource/ddl/__tests__/emit.test.ts +24 -0
  91. package/src/resource/ddl/diff.ts +0 -1
  92. package/src/resource/ddl/emit.ts +12 -1
  93. package/src/resource/generator-repo.ts +40 -20
  94. package/src/resource/generator.ts +1 -1
  95. package/src/resource/generators/contract.ts +1 -1
  96. package/src/resource/schema.ts +2 -1
  97. package/src/router/fs-routes.ts +6 -6
  98. package/src/router/fs-scanner.ts +1 -1
  99. package/src/runtime/env.ts +1 -1
  100. package/src/runtime/image-handler.ts +2 -1
  101. package/src/runtime/middleware.ts +2 -1
  102. package/src/runtime/server.ts +67 -60
  103. package/src/runtime/ssr.ts +3 -3
  104. package/src/runtime/streaming-ssr.ts +25 -27
  105. package/src/scheduler/validate.ts +1 -1
  106. package/src/seo/integration/ssr.ts +2 -3
  107. package/src/seo/render/opengraph.ts +1 -1
  108. package/src/seo/resolve/index.ts +0 -4
  109. package/src/slot/corrector.ts +1 -1
  110. package/src/slot/validator.ts +4 -4
  111. package/src/watcher/reporter.ts +1 -1
  112. package/src/watcher/watcher.ts +19 -19
@@ -12,9 +12,9 @@
12
12
 
13
13
  import type { Loader, GlobLoaderOptions, ParsedMarkdown } from "./types";
14
14
  import type { LoaderContext, DataEntry } from "../types";
15
- import { LoaderError, ParseError } from "../types";
15
+ import { ParseError } from "../types";
16
16
  import { generateFileDigest, combineDigests } from "../digest";
17
- import { inferParser, MARKDOWN_EXTENSIONS } from "./types";
17
+ import { inferParser } from "./types";
18
18
  import * as fs from "fs";
19
19
  import * as path from "path";
20
20
 
@@ -108,7 +108,7 @@ export function glob(options: GlobLoaderOptions): Loader {
108
108
  name: "glob",
109
109
 
110
110
  async load(context: LoaderContext): Promise<void> {
111
- const { store, config, logger, parseData, renderMarkdown, watcher } = context;
111
+ const { store, config, logger, parseData: _parseData, renderMarkdown: _renderMarkdown, watcher } = context;
112
112
 
113
113
  // 기본 디렉토리
114
114
  const baseDir = base
@@ -181,7 +181,7 @@ async function processFile(
181
181
  context: LoaderContext,
182
182
  filePath: string,
183
183
  id: string,
184
- baseDir: string
184
+ _baseDir: string
185
185
  ): Promise<void> {
186
186
  const { store, parseData, renderMarkdown, logger } = context;
187
187
 
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { ZodSchema } from "zod";
8
- import type { LoaderContext, DataEntry, RenderedContent } from "../types";
8
+ import type { LoaderContext, RenderedContent } from "../types";
9
9
 
10
10
  /**
11
11
  * 로더 인터페이스
@@ -102,7 +102,7 @@ export class ContentMetaStore {
102
102
  */
103
103
  private autoSave(): void {
104
104
  if (this.options.autoSave && this.options.filePath) {
105
- this.save();
105
+ void this.save();
106
106
  }
107
107
  }
108
108
 
@@ -110,7 +110,7 @@ export interface LoaderContext {
110
110
  /**
111
111
  * parseData 옵션
112
112
  */
113
- export interface ParseDataOptions<T> {
113
+ export interface ParseDataOptions<_T> {
114
114
  /** 엔트리 ID */
115
115
  id: string;
116
116
  /** 원본 데이터 */
@@ -27,16 +27,15 @@
27
27
  * ```
28
28
  */
29
29
 
30
- import { z, type ZodType, type ZodObject, type ZodRawShape } from 'zod';
31
- import {
32
- getZodTypeName,
33
- getZodInnerType,
34
- getZodArrayElementType,
35
- getZodObjectShape,
36
- getZodEnumValues,
37
- getZodChecks,
38
- isZodRequired,
39
- } from './zod-utils';
30
+ import type { ZodType } from 'zod';
31
+ import {
32
+ getZodTypeName,
33
+ getZodInnerType,
34
+ getZodArrayElementType,
35
+ getZodObjectShape,
36
+ getZodEnumValues,
37
+ isZodRequired,
38
+ } from './zod-utils';
40
39
 
41
40
  // ============================================================================
42
41
  // Types
@@ -167,7 +166,7 @@ export function generateApiHandler<T extends ContractDefinition>(
167
166
  throw new Error(`Endpoint "${String(endpointName)}" not found in contract`);
168
167
  }
169
168
 
170
- const { method, path, input, output, description } = endpoint;
169
+ const { method, path, input, description } = endpoint;
171
170
 
172
171
  return `
173
172
  import { Mandu } from '@mandujs/core';
@@ -345,7 +344,7 @@ export function generateOpenAPISpec<T extends ContractDefinition>(
345
344
  const paths: Record<string, Record<string, unknown>> = {};
346
345
 
347
346
  for (const [name, endpoint] of Object.entries(contract.__endpoints)) {
348
- const { method, path, input, output, description, tags, errors } = endpoint;
347
+ const { method, path, input, description, tags, errors } = endpoint;
349
348
 
350
349
  if (!paths[path]) {
351
350
  paths[path] = {};
@@ -37,10 +37,10 @@ export {
37
37
  type RpcWireError,
38
38
  } from "./rpc";
39
39
 
40
- import type { ContractDefinition, ContractInstance, ContractSchema } from "./schema";
41
- import type { ContractHandlers, RouteDefinition } from "./handler";
40
+ import type { ContractDefinition, ContractInstance } from "./schema";
41
+
42
42
  import { defineHandler, defineRoute } from "./handler";
43
- import { createClient, contractFetch, type ClientOptions } from "./client";
43
+ import { createClient, contractFetch } from "./client";
44
44
  import { createClientContract } from "./client-safe";
45
45
 
46
46
  /**
@@ -102,7 +102,7 @@ export function extractProtectedFields(
102
102
  }
103
103
  // 민감 필드도 보호 대상
104
104
  else if (isSensitiveField(value)) {
105
- const meta = getMetadata(value, SENSITIVE_FIELD) as SensitiveFieldMetadata | undefined;
105
+ const _meta = getMetadata(value, SENSITIVE_FIELD) as SensitiveFieldMetadata | undefined;
106
106
  fields.push({
107
107
  path: currentPath,
108
108
  reason: "Sensitive field - redacted in logs",
@@ -187,7 +187,7 @@ export function extractContractProtectedFields(
187
187
  /**
188
188
  * 객체에서 경로로 값 가져오기
189
189
  */
190
- function getValueByPath(obj: unknown, path: string): unknown {
190
+ function _getValueByPath(obj: unknown, path: string): unknown {
191
191
  const parts = path.split(".");
192
192
  let current: unknown = obj;
193
193
 
@@ -14,7 +14,7 @@ import type {
14
14
  /**
15
15
  * Extract inferred type from Zod schema
16
16
  */
17
- type InferZod<T> = T extends z.ZodTypeAny ? z.infer<T> : never;
17
+ type _InferZod<T> = T extends z.ZodTypeAny ? z.infer<T> : never;
18
18
 
19
19
  /**
20
20
  * Extract inferred type from response schema (supports ResponseSchemaWithExamples)
@@ -19,7 +19,6 @@ import type {
19
19
  ContractValidationError,
20
20
  ContractValidationIssue,
21
21
  MethodRequestSchema,
22
- ContractNormalizeMode,
23
22
  ResponseSchemaWithExamples,
24
23
  } from "./schema";
25
24
  import {
@@ -107,13 +107,10 @@ function createFakeCtor(): { Ctor: BunSqlCtor; state: FakeState } {
107
107
  return Object.assign(call, methods);
108
108
  }
109
109
 
110
- class FakeBunSql {
111
- constructor(config: Record<string, unknown>) {
112
- state.ctorCalls.push(config);
113
- // Return the callable instance — NOT a class instance. Returning a
114
- // non-this value from a constructor replaces the default return.
115
- return makeFakeSqlInstance(false) as FakeBunSql;
116
- }
110
+ function FakeBunSql(config: Record<string, unknown>) {
111
+ state.ctorCalls.push(config);
112
+ // Return the callable instance — NOT the default constructed object.
113
+ return makeFakeSqlInstance(false);
117
114
  }
118
115
 
119
116
  return {
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
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./history-table";
2
+ export * from "./lock";
3
+ export * from "./runner";
@@ -102,11 +102,11 @@ async function acquirePgAdvisoryLock(
102
102
  // hash-function reproduction.
103
103
  await db`SELECT pg_advisory_lock(hashtext(${lockId})::bigint)`;
104
104
 
105
- let released = false;
105
+ let _released = false;
106
106
  return {
107
107
  async release(): Promise<void> {
108
- if (released) return;
109
- released = true;
108
+ if (_released) return;
109
+ _released = true;
110
110
  try {
111
111
  await db`SELECT pg_advisory_unlock(hashtext(${lockId})::bigint)`;
112
112
  } catch (err) {
@@ -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,25 +134,30 @@ 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
- let released = false;
156
+ let _released = false;
151
157
  return {
152
158
  async release(): Promise<void> {
153
- if (released) return;
154
- released = true;
159
+ if (_released) return;
160
+ _released = true;
155
161
  try {
156
162
  await db`SELECT RELEASE_LOCK(${lockId})`;
157
163
  } catch (err) {
@@ -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
  };
@@ -229,11 +284,11 @@ async function acquireSqliteImmediate(
229
284
  // Wait for the previous holder to release.
230
285
  await previous;
231
286
 
232
- let released = false;
287
+ let _released = false;
233
288
  return {
234
289
  async release(): Promise<void> {
235
- if (released) return;
236
- released = true;
290
+ if (_released) return;
291
+ _released = true;
237
292
  release();
238
293
  // If no one queued behind us, drop the entry so the registry
239
294
  // doesn't grow unbounded across many apply() cycles.
@@ -245,10 +300,10 @@ async function acquireSqliteImmediate(
245
300
  }
246
301
 
247
302
  function makeNoopLock(): MigrationLock {
248
- let released = false;
303
+ let _released = false;
249
304
  return {
250
305
  async release(): Promise<void> {
251
- released = true;
306
+ _released = true;
252
307
  },
253
308
  };
254
309
  }