mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(server): run release migrations hidden by an unreleased identity record
Local connection-address builds created server_identity from a migration whose ledger row is dated after 0109 to 0111. Drizzle applies only migrations newer than the newest recorded one, so a database that ran one of those builds would skip the release migrations recorded before it, including 0111_add_koreader_session_tombstones, on its next upgrade. Drop that one record by its exact hash before migrating. 0112 creates the same table only when it is missing, so the installation UUID survives and the table is recorded under its released migration. A journal that owns the hash keeps the record, and builds without this step never touch it, so the server that ran the unreleased build still restarts normally until upgraded.
This commit is contained in:
@@ -6,7 +6,7 @@ import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
import { createPostgresClientConfig } from '../db/postgres-connection-config';
|
||||
import { reconcileMigrationLedgerTimestamps } from './migration-ledger-compatibility';
|
||||
import { dropUnreleasedMigrationRows, reconcileMigrationLedgerTimestamps } from './migration-ledger-compatibility';
|
||||
import { installPostgresExtensions } from './postgres-extensions';
|
||||
import { prepareLegacySeriesIndexColumns } from './series-index-migration-compatibility';
|
||||
|
||||
@@ -42,7 +42,9 @@ async function runMigrations() {
|
||||
try {
|
||||
await installPostgresExtensions(pool);
|
||||
const migrationsFolder = resolveMigrationsFolder();
|
||||
await reconcileMigrationLedgerTimestamps(pool, readMigrationFiles({ migrationsFolder }));
|
||||
const migrationFiles = readMigrationFiles({ migrationsFolder });
|
||||
await dropUnreleasedMigrationRows(pool, migrationFiles);
|
||||
await reconcileMigrationLedgerTimestamps(pool, migrationFiles);
|
||||
await prepareLegacySeriesIndexColumns(pool);
|
||||
|
||||
await migrate(drizzle(pool), { migrationsFolder });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { MigrationMeta } from 'drizzle-orm/migrator';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
import { reconcileMigrationLedgerTimestamps } from './migration-ledger-compatibility';
|
||||
import { dropUnreleasedMigrationRows, reconcileMigrationLedgerTimestamps } from './migration-ledger-compatibility';
|
||||
|
||||
const migrations = [
|
||||
{ hash: 'first', folderMillis: 100 },
|
||||
@@ -12,7 +12,7 @@ function createPool(tableName: string | null, ledgerRows: Array<{ id: number; ha
|
||||
const query = vi.fn((statement: string) => {
|
||||
if (statement.includes('to_regclass')) return Promise.resolve({ rows: [{ tableName }] });
|
||||
if (statement.includes('SELECT id, hash')) return Promise.resolve({ rows: ledgerRows });
|
||||
if (statement.startsWith('UPDATE')) return Promise.resolve({ rows: [], rowCount: 1 });
|
||||
if (statement.startsWith('UPDATE') || statement.startsWith('DELETE')) return Promise.resolve({ rows: [], rowCount: 1 });
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
const release = vi.fn();
|
||||
@@ -62,3 +62,45 @@ describe('reconcileMigrationLedgerTimestamps', () => {
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropUnreleasedMigrationRows', () => {
|
||||
const unreleasedIdentity = '1ec15e36d30f9bf4e79f92cb59eb461e5f76f53f43293d1b08a15253f22f35cf';
|
||||
|
||||
it('drops the unreleased identity record so release migrations recorded before it still run', async () => {
|
||||
const { pool, query, release } = createPool('drizzle.__drizzle_migrations');
|
||||
|
||||
await expect(dropUnreleasedMigrationRows(pool, migrations)).resolves.toEqual({ removedRows: 1 });
|
||||
expect(query).toHaveBeenCalledWith('DELETE FROM drizzle.__drizzle_migrations WHERE hash = ANY($1::text[])', [[unreleasedIdentity]]);
|
||||
expect(query).toHaveBeenLastCalledWith('COMMIT');
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does nothing before Drizzle has created its ledger table', async () => {
|
||||
const { pool, query } = createPool(null);
|
||||
|
||||
await expect(dropUnreleasedMigrationRows(pool, migrations)).resolves.toEqual({ removedRows: 0 });
|
||||
expect(query.mock.calls.some(([statement]) => statement.startsWith('DELETE'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a record the current journal owns', async () => {
|
||||
const { pool } = createPool('drizzle.__drizzle_migrations');
|
||||
|
||||
await expect(dropUnreleasedMigrationRows(pool, [...migrations, { hash: unreleasedIdentity }])).resolves.toEqual({ removedRows: 0 });
|
||||
expect(pool.connect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rolls back and releases the client when the drop fails', async () => {
|
||||
const query = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockRejectedValueOnce(new Error('ledger unavailable'))
|
||||
.mockResolvedValueOnce({ rows: [] });
|
||||
const release = vi.fn();
|
||||
const pool = { connect: vi.fn(() => Promise.resolve({ query, release })) } as unknown as Pick<Pool, 'connect'>;
|
||||
|
||||
await expect(dropUnreleasedMigrationRows(pool, migrations)).rejects.toThrow('ledger unavailable');
|
||||
expect(query).toHaveBeenLastCalledWith('ROLLBACK');
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,58 @@ type CompatibilityResult = {
|
||||
repairedRows: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ledger rows written by builds that never shipped. Drizzle applies only migrations newer than the
|
||||
* newest recorded one, so a row recorded after release migrations its build never had hides those
|
||||
* migrations for good. A hash belongs here only when a released migration recreates its effect
|
||||
* idempotently, which makes dropping the row safe.
|
||||
*/
|
||||
const UNRELEASED_MIGRATION_HASHES = [
|
||||
// `server_identity`, created by local connection-address builds before
|
||||
// 0112_add_server_connection_identity, which creates the same table if it is missing.
|
||||
'1ec15e36d30f9bf4e79f92cb59eb461e5f76f53f43293d1b08a15253f22f35cf',
|
||||
] as const;
|
||||
|
||||
export async function dropUnreleasedMigrationRows(
|
||||
pool: Pick<Pool, 'connect'>,
|
||||
migrations: readonly Pick<MigrationMeta, 'hash'>[],
|
||||
): Promise<{ removedRows: number }> {
|
||||
const known = new Set(migrations.map((migration) => migration.hash));
|
||||
const hashes = UNRELEASED_MIGRATION_HASHES.filter((hash) => !known.has(hash));
|
||||
if (hashes.length === 0) return { removedRows: 0 };
|
||||
|
||||
const client = await pool.connect();
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(`SELECT pg_advisory_xact_lock(hashtext('bookorbit'), hashtext('migration-ledger-compatibility'))`);
|
||||
const table = await client.query<{ tableName: string | null }>(`SELECT to_regclass('drizzle.__drizzle_migrations')::text AS "tableName"`);
|
||||
if (table.rows[0]?.tableName === null || table.rows.length === 0) {
|
||||
await client.query('COMMIT');
|
||||
return { removedRows: 0 };
|
||||
}
|
||||
const removed = await client.query(`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ANY($1::text[])`, [hashes]);
|
||||
await client.query('COMMIT');
|
||||
const removedRows = removed.rowCount ?? 0;
|
||||
if (removedRows > 0) {
|
||||
console.warn(
|
||||
`[db.migration-ledger-compatibility] [end] durationMs=${Date.now() - startedAt} removed=${removedRows} - unreleased migration records dropped`,
|
||||
);
|
||||
}
|
||||
return { removedRows };
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
const errorClass = error instanceof Error ? error.name : typeof error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(
|
||||
`[db.migration-ledger-compatibility] [fail] durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${sanitizeLogValue(message)}" - unreleased migration records could not be dropped`,
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
type LedgerRow = {
|
||||
id: number;
|
||||
hash: string;
|
||||
|
||||
Reference in New Issue
Block a user