release: WRNexusJS 0.8.0
This commit is contained in:
+104
-17
@@ -20,6 +20,18 @@ export interface Migration {
|
||||
}
|
||||
|
||||
const MIGRATIONS_TABLE = "_wire_migrations";
|
||||
const MIGRATION_LOCKS_TABLE = "_wire_migration_locks";
|
||||
|
||||
export interface MigrationRunOptions {
|
||||
/** Return pending migration names without executing their SQL. */
|
||||
dryRun?: boolean;
|
||||
/** Stop safely between migrations. Active database statements cannot be interrupted portably. */
|
||||
signal?: AbortSignal;
|
||||
/** Coordinate migration runners through the database. Default true. */
|
||||
lock?: boolean;
|
||||
/** Allow recovery of a lock left by a crashed process. Default 5 minutes. */
|
||||
lockTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Split a migration file into its `up` and `down` SQL sections. */
|
||||
export function parseMigration(name: string, content: string): Migration {
|
||||
@@ -50,7 +62,7 @@ export function loadMigrations(dir: string): Migration[] {
|
||||
|
||||
async function ensureTable(db: Db): Promise<void> {
|
||||
await db.exec(
|
||||
`CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
|
||||
`CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (name VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,38 +75,113 @@ export async function appliedMigrations(db: Db): Promise<string[]> {
|
||||
return rows.map((r) => r.name);
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw signal.reason ?? new DOMException("Migration aborted", "AbortError");
|
||||
}
|
||||
|
||||
async function acquireMigrationLock(db: Db, timeoutMs: number): Promise<() => Promise<void>> {
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new RangeError("migration lockTimeoutMs must be a positive number");
|
||||
}
|
||||
await db.exec(
|
||||
`CREATE TABLE IF NOT EXISTS ${MIGRATION_LOCKS_TABLE} (name VARCHAR(255) PRIMARY KEY, owner VARCHAR(255) NOT NULL, expires_at VARCHAR(40) NOT NULL)`,
|
||||
);
|
||||
const owner = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
await db.exec(`DELETE FROM ${MIGRATION_LOCKS_TABLE} WHERE name = ? AND expires_at <= ?`, [
|
||||
"global",
|
||||
now.toISOString(),
|
||||
]);
|
||||
try {
|
||||
await db.exec(
|
||||
`INSERT INTO ${MIGRATION_LOCKS_TABLE} (name, owner, expires_at) VALUES (?, ?, ?)`,
|
||||
["global", owner, new Date(now.getTime() + timeoutMs).toISOString()],
|
||||
);
|
||||
} catch (error) {
|
||||
const held = await db.all(`SELECT owner FROM ${MIGRATION_LOCKS_TABLE} WHERE name = ?`, [
|
||||
"global",
|
||||
]);
|
||||
if (held.length === 0) throw error;
|
||||
throw new Error("WRN-DB-MIGRATION-LOCKED: another process is running database migrations", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
return async () => {
|
||||
await db.exec(`DELETE FROM ${MIGRATION_LOCKS_TABLE} WHERE name = ? AND owner = ?`, [
|
||||
"global",
|
||||
owner,
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply an ordered migration list (each in a transaction). Returns applied names. */
|
||||
export async function applyMigrations(db: Db, migrations: readonly Migration[]): Promise<string[]> {
|
||||
export async function applyMigrations(
|
||||
db: Db,
|
||||
migrations: readonly Migration[],
|
||||
options: MigrationRunOptions = {},
|
||||
): Promise<string[]> {
|
||||
if (migrations.length === 0) return [];
|
||||
throwIfAborted(options.signal);
|
||||
const applied = new Set(await appliedMigrations(db));
|
||||
const pending = migrations.filter((migration) => !applied.has(migration.name));
|
||||
if (options.dryRun || pending.length === 0) return pending.map(({ name }) => name);
|
||||
const release =
|
||||
options.lock === false
|
||||
? undefined
|
||||
: await acquireMigrationLock(db, options.lockTimeoutMs ?? 300_000);
|
||||
const done: string[] = [];
|
||||
for (const migration of pending) {
|
||||
await db.tx(async (tx) => {
|
||||
if (migration.up) await tx.exec(migration.up);
|
||||
await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [migration.name]);
|
||||
});
|
||||
done.push(migration.name);
|
||||
try {
|
||||
// Re-read after locking because another runner may have completed while we waited.
|
||||
const current = new Set(await appliedMigrations(db));
|
||||
for (const migration of pending.filter(({ name }) => !current.has(name))) {
|
||||
throwIfAborted(options.signal);
|
||||
await db.tx(async (tx) => {
|
||||
if (migration.up) await tx.exec(migration.up);
|
||||
await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [migration.name]);
|
||||
});
|
||||
done.push(migration.name);
|
||||
}
|
||||
return done;
|
||||
} finally {
|
||||
await release?.();
|
||||
}
|
||||
return done;
|
||||
}
|
||||
|
||||
/** Apply all pending migrations from a directory. */
|
||||
export async function migrate(db: Db, dir: string): Promise<string[]> {
|
||||
return applyMigrations(db, loadMigrations(dir));
|
||||
export async function migrate(
|
||||
db: Db,
|
||||
dir: string,
|
||||
options: MigrationRunOptions = {},
|
||||
): Promise<string[]> {
|
||||
return applyMigrations(db, loadMigrations(dir), options);
|
||||
}
|
||||
|
||||
/** Roll back the most recently applied migration. Returns its name, or null. */
|
||||
export async function rollback(db: Db, dir: string): Promise<string | null> {
|
||||
export async function rollback(
|
||||
db: Db,
|
||||
dir: string,
|
||||
options: Omit<MigrationRunOptions, "dryRun"> & { dryRun?: boolean } = {},
|
||||
): Promise<string | null> {
|
||||
throwIfAborted(options.signal);
|
||||
const applied = await appliedMigrations(db);
|
||||
const last = applied[applied.length - 1];
|
||||
if (!last) return null;
|
||||
if (options.dryRun) return last;
|
||||
const release =
|
||||
options.lock === false
|
||||
? undefined
|
||||
: await acquireMigrationLock(db, options.lockTimeoutMs ?? 300_000);
|
||||
const migration = loadMigrations(dir).find((m) => m.name === last);
|
||||
await db.tx(async (tx) => {
|
||||
if (migration?.down) await tx.exec(migration.down);
|
||||
await tx.exec(`DELETE FROM ${MIGRATIONS_TABLE} WHERE name = ?`, [last]);
|
||||
});
|
||||
return last;
|
||||
try {
|
||||
throwIfAborted(options.signal);
|
||||
await db.tx(async (tx) => {
|
||||
if (migration?.down) await tx.exec(migration.down);
|
||||
await tx.exec(`DELETE FROM ${MIGRATIONS_TABLE} WHERE name = ?`, [last]);
|
||||
});
|
||||
return last;
|
||||
} finally {
|
||||
await release?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Full status: every migration file with whether it has been applied. */
|
||||
|
||||
Reference in New Issue
Block a user