@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.
@@ -69,7 +69,7 @@ import type {
69
69
  PendingMigration,
70
70
  SqlProvider,
71
71
  } from "../../resource/ddl/types";
72
- import type { Db } from "../index";
72
+ import { withPinnedDbHandle, type Db } from "../index";
73
73
  import {
74
74
  DEFAULT_HISTORY_TABLE,
75
75
  SAFE_HISTORY_TABLE_RE,
@@ -128,6 +128,24 @@ export class MigrationTimeoutError extends Error {
128
128
  }
129
129
  }
130
130
 
131
+ function assertNoTamperedHistory(
132
+ history: HistoryRow[],
133
+ diskByVersion: Map<string, PendingMigration>,
134
+ ): void {
135
+ for (const row of history) {
136
+ if (row.success !== 1) continue;
137
+ const disk = diskByVersion.get(row.version);
138
+ if (!disk) continue; // orphan on the history side — surfaced via status(), not apply()
139
+ if (disk.checksum !== row.checksum) {
140
+ throw new MigrationTamperedError(
141
+ disk.filename,
142
+ row.checksum,
143
+ disk.checksum,
144
+ );
145
+ }
146
+ }
147
+ }
148
+
131
149
  // ─── Public API ─────────────────────────────────────────────────────────────
132
150
 
133
151
  /** Options for {@link createMigrationRunner}. */
@@ -246,34 +264,18 @@ export function createMigrationRunner(
246
264
  async function apply(
247
265
  opts: { dryRun?: boolean } = {},
248
266
  ): Promise<AppliedMigration[]> {
249
- await ensureReady();
250
-
251
- // Tamper check BEFORE acquiring the lock so the fast-fail path
252
- // doesn't hold the advisory lock longer than necessary.
253
- const history = await readAllHistory(db, historyTable);
254
267
  const diskFiles = await readMigrationsFromDisk(migrationsDir);
255
268
  const diskByVersion = new Map(diskFiles.map((f) => [f.version, f]));
256
- for (const row of history) {
257
- if (row.success !== 1) continue;
258
- const disk = diskByVersion.get(row.version);
259
- if (!disk) continue; // orphan on the history side — surfaced via status(), not apply()
260
- if (disk.checksum !== row.checksum) {
261
- throw new MigrationTamperedError(
262
- disk.filename,
263
- row.checksum,
264
- disk.checksum,
265
- );
266
- }
267
- }
268
269
 
269
- const appliedVersions = new Set(
270
- history.filter((h) => h.success === 1).map((h) => h.version),
271
- );
272
- const pending = diskFiles.filter((f) => !appliedVersions.has(f.version));
273
- if (pending.length === 0) return [];
274
-
275
- // Dry-run: report what we WOULD apply, no IO, no history.
276
270
  if (opts.dryRun === true) {
271
+ await ensureReady();
272
+ const history = await readAllHistory(db, historyTable);
273
+ assertNoTamperedHistory(history, diskByVersion);
274
+
275
+ const appliedVersions = new Set(
276
+ history.filter((h) => h.success === 1).map((h) => h.version),
277
+ );
278
+ const pending = diskFiles.filter((f) => !appliedVersions.has(f.version));
277
279
  return pending.map<AppliedMigration>((p) => ({
278
280
  version: p.version,
279
281
  filename: p.filename,
@@ -290,100 +292,115 @@ export function createMigrationRunner(
290
292
 
291
293
  const applied: AppliedMigration[] = [];
292
294
 
293
- heldLock = await acquireMigrationLock(db, lockStrategy);
294
- try {
295
- for (const migration of pending) {
296
- const start = Date.now();
297
-
298
- const statements = splitStatements(migration.sql);
299
- if (statements.length === 0) {
300
- // Empty migration still record a history row so we don't
301
- // re-run it. execution_ms = 0 reflects reality.
295
+ const runLockedApply = async (): Promise<AppliedMigration[]> => {
296
+ heldLock = await acquireMigrationLock(db, lockStrategy);
297
+ try {
298
+ await ensureReady();
299
+ const lockedHistory = await readAllHistory(db, historyTable);
300
+ assertNoTamperedHistory(lockedHistory, diskByVersion);
301
+ const lockedAppliedVersions = new Set(
302
+ lockedHistory.filter((h) => h.success === 1).map((h) => h.version),
303
+ );
304
+ const lockedPending = diskFiles.filter((f) => !lockedAppliedVersions.has(f.version));
305
+
306
+ for (const migration of lockedPending) {
307
+ const start = Date.now();
308
+
309
+ const statements = splitStatements(migration.sql);
310
+ if (statements.length === 0) {
311
+ // Empty migration — still record a history row so we don't
312
+ // re-run it. execution_ms = 0 reflects reality.
313
+ await insertHistory(db, historyTable, {
314
+ version: migration.version,
315
+ filename: migration.filename,
316
+ checksum: migration.checksum,
317
+ applied_at: new Date(),
318
+ execution_ms: 0,
319
+ success: 1,
320
+ installed_by: installedBy,
321
+ });
322
+ applied.push({
323
+ version: migration.version,
324
+ filename: migration.filename,
325
+ checksum: migration.checksum,
326
+ appliedAt: new Date(),
327
+ executionMs: 0,
328
+ success: true,
329
+ });
330
+ continue;
331
+ }
332
+
333
+ try {
334
+ await db.transaction(async (tx) => {
335
+ for (const stmt of statements) {
336
+ await execRaw(tx, stmt);
337
+ const elapsed = Date.now() - start;
338
+ if (elapsed > applyTimeoutMs) {
339
+ throw new MigrationTimeoutError(
340
+ migration.filename,
341
+ elapsed,
342
+ applyTimeoutMs,
343
+ );
344
+ }
345
+ }
346
+ });
347
+ } catch (err) {
348
+ if (err instanceof MigrationTimeoutError) throw err;
349
+ // Wrap with migration context so downstream callers know
350
+ // which file blew up. Preserve the original stack where
351
+ // possible via `cause`.
352
+ const msg = err instanceof Error ? err.message : String(err);
353
+ const wrapped = new Error(
354
+ `[@mandujs/core/db/migrations] Failed to apply ${migration.filename}: ${msg}`,
355
+ );
356
+ // Preserve the original as a `cause` chain for diagnostics.
357
+ (wrapped as { cause?: unknown }).cause = err;
358
+ throw wrapped;
359
+ }
360
+
361
+ const executionMs = Date.now() - start;
362
+ const appliedAt = new Date();
363
+
364
+ // History row is written AFTER the SQL transaction commits.
365
+ // If this INSERT itself fails, the migration has run but we
366
+ // have no record — the user will see it as pending again.
367
+ // Mitigation: the insert is a single tiny statement; in
368
+ // practice it either succeeds or the whole connection is
369
+ // dead (in which case subsequent apply() calls will also fail
370
+ // and the user will debug from the DB side).
302
371
  await insertHistory(db, historyTable, {
303
372
  version: migration.version,
304
373
  filename: migration.filename,
305
374
  checksum: migration.checksum,
306
- applied_at: new Date(),
307
- execution_ms: 0,
375
+ applied_at: appliedAt,
376
+ execution_ms: executionMs,
308
377
  success: 1,
309
378
  installed_by: installedBy,
310
379
  });
380
+
311
381
  applied.push({
312
382
  version: migration.version,
313
383
  filename: migration.filename,
314
384
  checksum: migration.checksum,
315
- appliedAt: new Date(),
316
- executionMs: 0,
385
+ appliedAt,
386
+ executionMs,
317
387
  success: true,
318
388
  });
319
- continue;
320
389
  }
321
-
322
- try {
323
- await db.transaction(async (tx) => {
324
- for (const stmt of statements) {
325
- await execRaw(tx, stmt);
326
- const elapsed = Date.now() - start;
327
- if (elapsed > applyTimeoutMs) {
328
- throw new MigrationTimeoutError(
329
- migration.filename,
330
- elapsed,
331
- applyTimeoutMs,
332
- );
333
- }
334
- }
335
- });
336
- } catch (err) {
337
- if (err instanceof MigrationTimeoutError) throw err;
338
- // Wrap with migration context so downstream callers know
339
- // which file blew up. Preserve the original stack where
340
- // possible via `cause`.
341
- const msg = err instanceof Error ? err.message : String(err);
342
- const wrapped = new Error(
343
- `[@mandujs/core/db/migrations] Failed to apply ${migration.filename}: ${msg}`,
344
- );
345
- // Preserve the original as a `cause` chain for diagnostics.
346
- (wrapped as { cause?: unknown }).cause = err;
347
- throw wrapped;
390
+ } finally {
391
+ if (heldLock) {
392
+ await heldLock.release();
393
+ heldLock = null;
348
394
  }
395
+ }
349
396
 
350
- const executionMs = Date.now() - start;
351
- const appliedAt = new Date();
352
-
353
- // History row is written AFTER the SQL transaction commits.
354
- // If this INSERT itself fails, the migration has run but we
355
- // have no record — the user will see it as pending again.
356
- // Mitigation: the insert is a single tiny statement; in
357
- // practice it either succeeds or the whole connection is
358
- // dead (in which case subsequent apply() calls will also fail
359
- // and the user will debug from the DB side).
360
- await insertHistory(db, historyTable, {
361
- version: migration.version,
362
- filename: migration.filename,
363
- checksum: migration.checksum,
364
- applied_at: appliedAt,
365
- execution_ms: executionMs,
366
- success: 1,
367
- installed_by: installedBy,
368
- });
397
+ return applied;
398
+ };
369
399
 
370
- applied.push({
371
- version: migration.version,
372
- filename: migration.filename,
373
- checksum: migration.checksum,
374
- appliedAt,
375
- executionMs,
376
- success: true,
377
- });
378
- }
379
- } finally {
380
- if (heldLock) {
381
- await heldLock.release();
382
- heldLock = null;
383
- }
400
+ if (lockStrategy === "mysql_get_lock") {
401
+ return await withPinnedDbHandle(db, runLockedApply);
384
402
  }
385
-
386
- return applied;
403
+ return await runLockedApply();
387
404
  }
388
405
 
389
406
  async function status(): Promise<MigrationStatus> {