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:
neon
2026-10-13 19:43:15 -06:00
parent fbaf0b63df
commit c6b9eb59f6
3 changed files with 100 additions and 4 deletions
+4 -2
View File
@@ -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;