release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-02 23:18:51 +05:30
parent 87507edf59
commit 586a6db8ff
625 changed files with 243608 additions and 11210 deletions
+104 -17
View File
@@ -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. */