fix(db): keep migration checksums stable
Issue #155 removed the sqlx 0.8 rebuild workarounds by editing migrations 0007, 0014 and 0021 in place. Editing an applied migration changes its checksum, and `migrate()` refuses to run when one no longer matches what `_sqlx_migrations` recorded, so the daemon exited on startup against any database that had already applied them — production included. The sqlx 0.9 bump is the fix and survives: a new migration can carry `-- no-transaction` so `PRAGMA foreign_keys = OFF` holds and a table rebuild stops cascade-deleting its children. Only the retroactive cleanup of migrations that already ran is reverted, along with #153's `CHECK (title <> '')`, which rode on the 0021 edit and needs a migration of its own rather than a rewrite of history. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
-- no-transaction
|
||||
-- #73. movies.state used missing|grabbed|imported, predating arr-core's
|
||||
-- canonical MediaState vocabulary (missing|downloading|available) that
|
||||
-- series/episodes (0005) already follow.
|
||||
--
|
||||
-- SQLite can't ALTER a CHECK constraint, so this rebuilds the table. It runs
|
||||
-- outside a transaction (#155) so `PRAGMA foreign_keys = OFF` takes effect
|
||||
-- and dropping the old `movies` does not cascade into `movie_releases`.
|
||||
-- SQLite can't ALTER a CHECK constraint, so this rebuilds the table -- which
|
||||
-- means dropping the old copy. `movie_releases` references it with
|
||||
-- ON DELETE CASCADE, so the drop would take its rows down too; stash them
|
||||
-- first and restore them once the new `movies` exists with the same ids.
|
||||
|
||||
PRAGMA foreign_keys = OFF;
|
||||
CREATE TABLE movie_releases_backup AS SELECT * FROM movie_releases;
|
||||
|
||||
CREATE TABLE movies_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -68,4 +68,6 @@ BEGIN
|
||||
SELECT RAISE(ABORT, 'movies require a movie root');
|
||||
END;
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
INSERT INTO movie_releases SELECT * FROM movie_releases_backup;
|
||||
|
||||
DROP TABLE movie_releases_backup;
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
-- no-transaction
|
||||
-- #108: a vanished torrent must not re-grab. Overrides #86, which reopened
|
||||
-- the gap (state = 'missing', wanted untouched) so the next tick re-grabbed
|
||||
-- the same release. Adds 'parked' so the daemon can clear `wanted` and mark
|
||||
-- the title honestly instead — distinct from 'missing' (an open gap) and
|
||||
-- 'available' (satisfied). SQLite cannot alter a CHECK, so both tables are
|
||||
-- rebuilt (see 0007).
|
||||
--
|
||||
-- #155: runs outside a transaction so `PRAGMA foreign_keys = OFF` takes
|
||||
-- effect and dropping the old tables does not cascade into their children.
|
||||
|
||||
PRAGMA foreign_keys = OFF;
|
||||
CREATE TABLE movie_releases_backup AS SELECT * FROM movie_releases;
|
||||
|
||||
CREATE TABLE movies_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -67,6 +63,11 @@ BEGIN
|
||||
SELECT RAISE(ABORT, 'movies require a movie root');
|
||||
END;
|
||||
|
||||
INSERT INTO movie_releases SELECT * FROM movie_releases_backup;
|
||||
DROP TABLE movie_releases_backup;
|
||||
|
||||
CREATE TABLE episode_releases_backup AS SELECT * FROM episode_releases;
|
||||
|
||||
CREATE TABLE episodes_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
season_id INTEGER NOT NULL REFERENCES seasons (id) ON DELETE CASCADE,
|
||||
@@ -101,4 +102,5 @@ CREATE INDEX episodes_pending_search
|
||||
|
||||
CREATE INDEX episodes_state ON episodes (state);
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
INSERT INTO episode_releases SELECT * FROM episode_releases_backup;
|
||||
DROP TABLE episode_releases_backup;
|
||||
|
||||
@@ -1,51 +1,11 @@
|
||||
-- no-transaction
|
||||
-- #153. `episodes.title` accepted the empty string, which TMDB sends for an
|
||||
-- unaired episode it has not named yet. Empty titles leaked into §9.2's
|
||||
-- search haystack, §7.4 filenames and the compat shim as if they were real
|
||||
-- text. Rows already carrying `''` take the same "TBA" placeholder the TMDB
|
||||
-- boundary now substitutes — #121's guarded update replaces it once TMDB
|
||||
-- fills the title in — and the rebuilt table enforces non-empty with a
|
||||
-- CHECK. The rebuild runs outside a transaction (#155) so
|
||||
-- `PRAGMA foreign_keys = OFF` takes effect and dropping the old `episodes`
|
||||
-- does not cascade into `episode_releases`.
|
||||
|
||||
-- fills the title in.
|
||||
--
|
||||
-- A hard CHECK (title <> '') would need a table rebuild with foreign_keys
|
||||
-- off, which sqlx 0.8's migrator cannot run (it always wraps a migration in
|
||||
-- a transaction, where that pragma is a no-op); enforcement lives in code.
|
||||
UPDATE episodes SET title = 'TBA', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE title = '';
|
||||
|
||||
PRAGMA foreign_keys = OFF;
|
||||
|
||||
CREATE TABLE episodes_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
season_id INTEGER NOT NULL REFERENCES seasons (id) ON DELETE CASCADE,
|
||||
number INTEGER NOT NULL CHECK (number >= 0),
|
||||
title TEXT NOT NULL CHECK (title <> ''),
|
||||
air_date TEXT,
|
||||
wanted INTEGER NOT NULL DEFAULT 0 CHECK (wanted IN (0, 1)),
|
||||
state TEXT NOT NULL DEFAULT 'missing'
|
||||
CHECK (state IN ('missing', 'downloading', 'available', 'parked')),
|
||||
search_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_searched_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
vanished INTEGER NOT NULL DEFAULT 0 CHECK (vanished IN (0, 1)),
|
||||
UNIQUE (season_id, number)
|
||||
) STRICT;
|
||||
|
||||
INSERT INTO episodes_new (
|
||||
id, season_id, number, title, air_date, wanted, state, search_attempts,
|
||||
last_searched_at, created_at, updated_at, vanished
|
||||
)
|
||||
SELECT
|
||||
id, season_id, number, title, air_date, wanted, state, search_attempts,
|
||||
last_searched_at, created_at, updated_at, vanished
|
||||
FROM episodes;
|
||||
|
||||
DROP TABLE episodes;
|
||||
ALTER TABLE episodes_new RENAME TO episodes;
|
||||
|
||||
CREATE INDEX episodes_pending_search
|
||||
ON episodes (last_searched_at)
|
||||
WHERE wanted = 1 AND state = 'missing';
|
||||
|
||||
CREATE INDEX episodes_state ON episodes (state);
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
@@ -419,55 +419,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #153: an empty episode title takes the TBA placeholder during 0021's
|
||||
/// backfill, and the rebuilt table's CHECK rejects new empty titles.
|
||||
#[tokio::test]
|
||||
async fn empty_episode_titles_are_backfilled_and_then_rejected() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let db = Db::connect(dir.path().join("arr.db"))
|
||||
.await
|
||||
.expect("connect");
|
||||
|
||||
MIGRATOR
|
||||
.run_to(20, db.pool())
|
||||
.await
|
||||
.expect("migrations before the #153 backfill");
|
||||
|
||||
let series_id = sqlx::query(
|
||||
"INSERT INTO series (tmdb_id, title, root_id)
|
||||
SELECT 82728, 'Bluey', id FROM roots WHERE kind = 'tv' LIMIT 1",
|
||||
)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("series")
|
||||
.last_insert_rowid();
|
||||
let season_id = sqlx::query("INSERT INTO seasons (series_id, number) VALUES (?, 1)")
|
||||
.bind(series_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("season")
|
||||
.last_insert_rowid();
|
||||
sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 1, '')")
|
||||
.bind(season_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("empty title, valid before 0021");
|
||||
|
||||
db.migrate().await.expect("remaining migrations");
|
||||
|
||||
let title: String = sqlx::query_scalar("SELECT title FROM episodes")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("episode row survives the rebuild");
|
||||
assert_eq!(title, "TBA");
|
||||
|
||||
sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 2, '')")
|
||||
.bind(season_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect_err("the rebuilt column rejects the empty string");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seeds_two_tv_roots_with_distinct_policies() {
|
||||
let (_dir, db) = fresh().await;
|
||||
|
||||
Reference in New Issue
Block a user