@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 +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/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
|
@@ -69,7 +69,7 @@ import type {
|
|
|
69
69
|
PendingMigration,
|
|
70
70
|
SqlProvider,
|
|
71
71
|
} from "../../resource/ddl/types";
|
|
72
|
-
import type
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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:
|
|
307
|
-
execution_ms:
|
|
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
|
|
316
|
-
executionMs
|
|
385
|
+
appliedAt,
|
|
386
|
+
executionMs,
|
|
317
387
|
success: true,
|
|
318
388
|
});
|
|
319
|
-
continue;
|
|
320
389
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
await
|
|
324
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
371
|
-
|
|
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> {
|