fix(series): repair legacy series index upgrades

Normalize legacy float values before migration 0076 so 2.7 databases can upgrade without constraint failures or precision loss. Serialize the compatibility conversion across concurrent startup processes.

Fixes #1189
This commit is contained in:
neon
2026-08-29 00:23:39 +00:00
parent f8ce9f58f1
commit 93c8136899
7 changed files with 331 additions and 19 deletions
+18
View File
@@ -304,6 +304,24 @@ export const E2E_SUITES = Object.freeze({
...SHARED_DB_AND_HELPER_PATHS,
],
},
"series-index-migration": {
id: "series-index-migration",
name: "Series Index Migration",
timeout: 60,
lane: "full",
description: "Legacy series index upgrade compatibility suite",
vitestTarget: "test/series-index-migration.e2e-spec.ts",
junitOutput: `${TEST_RESULTS_DIR}/series-index-migration-e2e-junit.xml`,
prepareDedicatedDatabase: true,
useDedicatedDatabase: true,
changedPaths: [
"server/src/db/migrations/**",
"server/src/scripts/migrate.ts",
"server/src/scripts/series-index-migration-compatibility.ts",
"server/test/series-index-migration.e2e-spec.ts",
...SHARED_DB_AND_HELPER_PATHS,
],
},
"reading-attempts": {
id: "reading-attempts",
name: "Reading Attempts",
+1 -1
View File
@@ -28,7 +28,7 @@
"start:debug": "nest start --debug --watch --config nest-cli.dev.json",
"start:prod": "node dist/main",
"db:generate": "drizzle-kit generate --name",
"db:migrate": "tsx --env-file-if-exists=.env src/scripts/install-postgres-extensions.ts && drizzle-kit migrate",
"db:migrate": "tsx --env-file-if-exists=.env src/scripts/migrate.ts",
"e2e:db:migrate": "DATABASE_URL=postgres://bookorbit:bookorbit@localhost:5432/bookorbit_e2e pnpm db:migrate",
"db:migrate:runtime": "node dist/scripts/migrate.js",
"e2e:db:prepare": "tsx src/scripts/db-prepare-e2e.ts",
@@ -1,18 +0,0 @@
import { Pool } from 'pg';
import { createPostgresClientConfig } from '../db/postgres-connection-config';
import { installPostgresExtensions } from './postgres-extensions';
async function run(): Promise<void> {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error('DATABASE_URL is required');
const pool = new Pool(createPostgresClientConfig(connectionString));
try {
await installPostgresExtensions(pool);
} finally {
await pool.end();
}
}
void run();
+2
View File
@@ -6,6 +6,7 @@ import { Pool } from 'pg';
import { createPostgresClientConfig } from '../db/postgres-connection-config';
import { installPostgresExtensions } from './postgres-extensions';
import { prepareLegacySeriesIndexColumns } from './series-index-migration-compatibility';
function resolveMigrationsFolder(): string {
const candidates = [
@@ -38,6 +39,7 @@ async function runMigrations() {
try {
await installPostgresExtensions(pool);
await prepareLegacySeriesIndexColumns(pool);
const migrationsFolder = resolveMigrationsFolder();
await migrate(drizzle(pool), { migrationsFolder });
@@ -0,0 +1,76 @@
import type { Pool } from 'pg';
import { describe, expect, it, vi } from 'vitest';
import { prepareLegacySeriesIndexColumns } from './series-index-migration-compatibility';
function createPool(rows: Array<{ tableName: string }>, counts: Record<string, string> = {}) {
const query = vi.fn((statement: string) => {
if (statement.includes('information_schema.columns')) return Promise.resolve({ rows });
const table = Object.keys(counts).find((name) => statement.includes(`FROM public.${name}`));
if (table) return Promise.resolve({ rows: [{ count: counts[table] }] });
return Promise.resolve({ rows: [] });
});
const release = vi.fn();
const pool = { connect: vi.fn(() => Promise.resolve({ query, release })) } as unknown as Pick<Pool, 'connect'>;
return { pool, query, release };
}
describe('prepareLegacySeriesIndexColumns', () => {
it('does nothing when the legacy real columns are absent', async () => {
const { pool, query, release } = createPool([]);
await expect(prepareLegacySeriesIndexColumns(pool)).resolves.toEqual({ convertedColumns: 0, clearedValues: 0 });
expect(query).toHaveBeenCalledTimes(4);
expect(query).toHaveBeenNthCalledWith(1, 'BEGIN');
expect(query).toHaveBeenNthCalledWith(2, `SELECT pg_advisory_xact_lock(hashtext('bookorbit'), hashtext('series-index-migration-compatibility'))`);
expect(query).toHaveBeenLastCalledWith('COMMIT');
expect(release).toHaveBeenCalledOnce();
});
it('normalizes both legacy columns before migration 0076 runs', async () => {
const { pool, query } = createPool([{ tableName: 'book_metadata' }, { tableName: 'book_series_memberships' }], {
book_metadata: '2',
book_series_memberships: '1',
});
await expect(prepareLegacySeriesIndexColumns(pool)).resolves.toEqual({ convertedColumns: 2, clearedValues: 3 });
const statements = query.mock.calls.map(([statement]) => statement);
const counts = statements.filter((statement) => statement.includes('SELECT count(*)::text AS count'));
const alters = statements.filter((statement) => statement.includes('ALTER TABLE'));
expect(counts).toHaveLength(2);
for (const statement of counts) {
expect(statement).toContain('length(trim_scale(series_index::text::numeric)::text) > 20');
}
expect(alters).toHaveLength(2);
expect(alters).toEqual(
expect.arrayContaining([
expect.stringContaining('ALTER TABLE public.book_metadata'),
expect.stringContaining('ALTER TABLE public.book_series_memberships'),
]),
);
for (const statement of alters) {
expect(statement).toContain('trim_scale(series_index::text::numeric)::text');
expect(statement).toContain("series_index::text IN ('NaN', 'Infinity', '-Infinity')");
expect(statement).toContain('WHEN series_index < 0 THEN NULL');
expect(statement).toContain('WHEN length(trim_scale(series_index::text::numeric)::text) > 20 THEN NULL');
}
});
it('rolls back and releases the client when conversion fails', async () => {
const query = vi
.fn()
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [{ tableName: 'book_metadata' }] })
.mockResolvedValueOnce({ rows: [{ count: '0' }] })
.mockRejectedValueOnce(new Error('conversion failed'))
.mockResolvedValueOnce({ rows: [] });
const release = vi.fn();
const pool = { connect: vi.fn(() => Promise.resolve({ query, release })) } as unknown as Pick<Pool, 'connect'>;
await expect(prepareLegacySeriesIndexColumns(pool)).rejects.toThrow('conversion failed');
expect(query).toHaveBeenLastCalledWith('ROLLBACK');
expect(release).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,86 @@
import type { Pool } from 'pg';
import { sanitizeLogValue } from '../common/utils/log-sanitize.utils';
const LEGACY_SERIES_INDEX_TABLES = ['book_series_memberships', 'book_metadata'] as const;
type LegacySeriesIndexTable = (typeof LEGACY_SERIES_INDEX_TABLES)[number];
type CompatibilityResult = {
convertedColumns: number;
clearedValues: number;
};
function isLegacySeriesIndexTable(value: string): value is LegacySeriesIndexTable {
return LEGACY_SERIES_INDEX_TABLES.includes(value as LegacySeriesIndexTable);
}
export async function prepareLegacySeriesIndexColumns(pool: Pick<Pool, 'connect'>): Promise<CompatibilityResult> {
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('series-index-migration-compatibility'))`);
const columns = await client.query<{ tableName: string }>(`
SELECT table_name AS "tableName"
FROM information_schema.columns
WHERE table_schema = 'public'
AND column_name = 'series_index'
AND data_type = 'real'
AND table_name IN ('book_series_memberships', 'book_metadata')
ORDER BY table_name
`);
const tables = columns.rows.map((row) => row.tableName).filter(isLegacySeriesIndexTable);
if (tables.length === 0) {
await client.query('COMMIT');
return { convertedColumns: 0, clearedValues: 0 };
}
console.log(`[db.series-index-compatibility] [start] columns=${tables.length} - legacy series index conversion started`);
let clearedValues = 0;
for (const table of tables) {
const countResult = await client.query<{ count: string }>(`
SELECT count(*)::text AS count
FROM public.${table}
WHERE series_index IS NOT NULL
AND (
series_index::text IN ('NaN', 'Infinity', '-Infinity')
OR series_index < 0
OR length(trim_scale(series_index::text::numeric)::text) > 20
)
`);
clearedValues += Number.parseInt(countResult.rows[0]?.count ?? '0', 10);
await client.query(`
ALTER TABLE public.${table}
ALTER COLUMN series_index TYPE varchar(20)
USING CASE
WHEN series_index IS NULL THEN NULL
WHEN series_index::text IN ('NaN', 'Infinity', '-Infinity') THEN NULL
WHEN series_index < 0 THEN NULL
WHEN length(trim_scale(series_index::text::numeric)::text) > 20 THEN NULL
ELSE trim_scale(series_index::text::numeric)::text
END
`);
}
await client.query('COMMIT');
const endMessage = `[db.series-index-compatibility] [end] durationMs=${Date.now() - startedAt} columns=${tables.length} cleared=${clearedValues} - legacy series index conversion completed`;
if (clearedValues > 0) console.warn(endMessage);
else console.log(endMessage);
return { convertedColumns: tables.length, clearedValues };
} 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.series-index-compatibility] [fail] durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${sanitizeLogValue(message)}" - legacy series index conversion failed`,
);
throw error;
} finally {
client.release();
}
}
@@ -0,0 +1,148 @@
import { randomUUID } from 'crypto';
import { readFile, readdir } from 'fs/promises';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { Client, Pool } from 'pg';
import { prepareLegacySeriesIndexColumns } from '../src/scripts/series-index-migration-compatibility';
const MIGRATIONS_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), '../src/db/migrations');
function escapeIdentifier(identifier: string): string {
return `"${identifier.replace(/"/g, '""')}"`;
}
async function applyMigrations(client: Client, filenames: string[]): Promise<void> {
for (const filename of filenames) {
await client.query(await readFile(join(MIGRATIONS_DIRECTORY, filename), 'utf8'));
}
}
describe('Series index migration compatibility (e2e)', { timeout: 60_000 }, () => {
it('repairs legacy values before applying the 2.8 series index migration', async () => {
const baseUrl = new URL(process.env.DATABASE_URL!);
const databaseName = `series_index_${randomUUID().replace(/-/g, '').slice(0, 12)}_e2e`;
const targetUrl = new URL(baseUrl);
targetUrl.pathname = `/${databaseName}`;
const adminUrl = new URL(baseUrl);
adminUrl.pathname = '/postgres';
const admin = new Client({ connectionString: adminUrl.toString() });
let target: Client | undefined;
let pool: Pool | undefined;
await admin.connect();
try {
await admin.query(`CREATE DATABASE ${escapeIdentifier(databaseName)}`);
target = new Client({ connectionString: targetUrl.toString() });
await target.connect();
await target.query(`
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
CREATE EXTENSION IF NOT EXISTS "unaccent";
CREATE EXTENSION IF NOT EXISTS "vector";
CREATE OR REPLACE FUNCTION public.bookorbit_unaccent(value text)
RETURNS text
LANGUAGE sql
IMMUTABLE
PARALLEL SAFE
STRICT
AS $function$ SELECT public.unaccent('public.unaccent', value) $function$;
`);
const migrationFiles = (await readdir(MIGRATIONS_DIRECTORY)).filter((filename) => filename.endsWith('.sql')).sort();
await applyMigrations(
target,
migrationFiles.filter((filename) => filename < '0076_add_series_index_labels.sql'),
);
await target.query(`
WITH new_library AS (
INSERT INTO libraries (name) VALUES ('Series Index Migration') RETURNING id
), new_folder AS (
INSERT INTO library_folders (library_id, path)
SELECT id, '/series-index-migration' FROM new_library
RETURNING id, library_id
), new_book AS (
INSERT INTO books (library_id, library_folder_id, folder_path)
SELECT library_id, id, '/series-index-migration/book' FROM new_folder
RETURNING id
), new_series AS (
INSERT INTO book_series (name, normalized_name)
VALUES ('Legacy Series', 'legacy series')
RETURNING id
), new_membership AS (
INSERT INTO book_series_memberships (book_id, series_id, series_index)
SELECT new_book.id, new_series.id, -1::real
FROM new_book, new_series
)
INSERT INTO book_metadata (book_id, series_index)
SELECT id, 1e-5::real FROM new_book;
`);
await target.query(`
WITH samples(folder_path, series_index) AS (
VALUES
('/series-index-migration/precision-1234567', 123456.7::real),
('/series-index-migration/precision-16777215', 16777215::real),
('/series-index-migration/precision-10485765', 1048576.5::real)
), folder AS (
SELECT id, library_id
FROM library_folders
WHERE path = '/series-index-migration'
), new_books AS (
INSERT INTO books (library_id, library_folder_id, folder_path)
SELECT folder.library_id, folder.id, samples.folder_path
FROM folder, samples
RETURNING id, folder_path
)
INSERT INTO book_metadata (book_id, series_index)
SELECT new_books.id, samples.series_index
FROM new_books
JOIN samples USING (folder_path);
`);
const seriesIndexMigration = await readFile(join(MIGRATIONS_DIRECTORY, '0076_add_series_index_labels.sql'), 'utf8');
await target.query('BEGIN');
await expect(target.query(seriesIndexMigration)).rejects.toThrow('book_series_memberships_series_index_format_chk');
await target.query('ROLLBACK');
pool = new Pool({ connectionString: targetUrl.toString() });
const compatibilityResults = await Promise.all([prepareLegacySeriesIndexColumns(pool), prepareLegacySeriesIndexColumns(pool)]);
expect(compatibilityResults).toEqual(
expect.arrayContaining([
{ convertedColumns: 2, clearedValues: 1 },
{ convertedColumns: 0, clearedValues: 0 },
]),
);
await target.query(seriesIndexMigration);
const values = await target.query<{ source: string; seriesIndex: string | null }>(`
SELECT source, "seriesIndex"
FROM (
SELECT 0 AS sort_order, 'membership' AS source, series_index AS "seriesIndex" FROM book_series_memberships
UNION ALL
SELECT 1, books.folder_path, book_metadata.series_index
FROM book_metadata
INNER JOIN books ON books.id = book_metadata.book_id
) AS migrated_values
ORDER BY sort_order, source
`);
expect(values.rows).toEqual([
{ source: 'membership', seriesIndex: null },
{ source: '/series-index-migration/book', seriesIndex: '0.00001' },
{ source: '/series-index-migration/precision-10485765', seriesIndex: '1048576.5' },
{ source: '/series-index-migration/precision-1234567', seriesIndex: '123456.7' },
{ source: '/series-index-migration/precision-16777215', seriesIndex: '16777215' },
]);
await applyMigrations(
target,
migrationFiles.filter((filename) => filename > '0076_add_series_index_labels.sql'),
);
} finally {
await pool?.end();
await target?.end();
await admin.query(`DROP DATABASE IF EXISTS ${escapeIdentifier(databaseName)} WITH (FORCE)`);
await admin.end();
}
});
});