@@ -23,9 +23,11 @@ be added (subissue `1525-08`).
2323### Starting point
2424
2525By the time this subissue is implemented, subissue ` 1525-05 ` will have delivered async SQLite
26- and MySQL drivers backed by ` sqlx ` . Each driver has an ` ensure_schema() ` latch that calls
27- ` create_database_tables() ` lazily. That method currently issues raw ` sqlx::query() ` DDL. This
28- subissue replaces that raw DDL path with ` sqlx::migrate!() ` .
26+ and MySQL drivers backed by ` sqlx ` . ` SchemaMigrator::create_database_tables() ` is invoked
27+ once from ` databases::setup::initialize_database() ` after the driver is built; subissue
28+ ` 1525-05 ` explicitly chose ** not** to use a per-method lazy ` ensure_schema() ` latch. The
29+ current ` create_database_tables() ` issues raw ` sqlx::query() ` DDL. This subissue replaces
30+ that raw DDL path with ` sqlx::migrate!() ` .
2931
3032There are already 3 migration files under ` packages/tracker-core/migrations/ ` (both ` sqlite/ `
3133and ` mysql/ ` subdirectories) that capture the schema history:
@@ -44,8 +46,10 @@ automatically. This subissue is the first time they are wired into the applicati
4446The current ` create_database_tables() ` method issues ` CREATE TABLE IF NOT EXISTS ` for all four
4547tables (` whitelist ` , ` torrents ` , ` torrent_aggregate_metrics ` , ` keys ` ) using hardcoded DDL that
4648already reflects the final schema state (nullable ` valid_until ` , all four tables present). The
47- current ` drop_database_tables() ` drops ` whitelist ` , ` torrents ` , and ` keys ` but ** not**
48- ` torrent_aggregate_metrics ` , which leaks across test drop/create cycles.
49+ current ` drop_database_tables() ` already drops all four tables (` whitelist ` , ` torrents ` ,
50+ ` keys ` , ** and** ` torrent_aggregate_metrics ` ) — there is no pre-existing omission. What is
51+ missing is ` _sqlx_migrations ` , which does not exist today and will be introduced by this
52+ subissue. All current drops use bare ` DROP TABLE ` (no ` IF EXISTS ` ).
4953
5054This gives two distinct behaviors today:
5155
@@ -154,9 +158,125 @@ This logic lives in a helper function called before `MIGRATOR.run(&pool)` inside
154158
155159After this subissue, ` SchemaMigrator::create_database_tables() ` calls the legacy-bootstrap
156160helper and then ` MIGRATOR.run(&pool) ` instead of issuing raw DDL. ` drop_database_tables() `
157- (used only in tests) must also drop the ` _sqlx_migrations ` and ` torrent_aggregate_metrics `
158- tables (fixing the pre-existing omission) so that the drop/create cycle used in the test suite
159- works correctly.
161+ (used in tests and in the ` axum-rest-tracker-api-server ` ` force_database_error ` helper) must
162+ also drop ` _sqlx_migrations ` (newly introduced by this subissue) and switch every drop to
163+ ` DROP TABLE IF EXISTS ` so the drop/create cycle used by ` databases::driver::tests::run_tests `
164+ (create → drop → create) leaves a clean slate that ` MIGRATOR.run() ` can re-bootstrap as a
165+ fresh database.
166+
167+ ## Findings from current-code analysis (2026-04-30)
168+
169+ Review of ` develop ` (post-` 1525-05 ` ) before starting implementation. These items refine or
170+ correct statements elsewhere in this spec; tasks below should be read with these in mind.
171+
172+ ### F1. No ` ensure_schema() ` latch exists — and none is planned
173+
174+ Subissue ` 1525-05 ` explicitly decided not to introduce a per-method lazy schema latch (see
175+ ` docs/issues/1717-1525-05-migrate-sqlite-and-mysql-to-sqlx.md ` : _ "Do ** not** use per-method
176+ lazy schema checks (` ensure_schema() ` )"_ ). ` create_database_tables() ` is called exactly once
177+ from ` databases::setup::initialize_database() ` . Any references to an ` ensure_schema() ` latch
178+ in earlier drafts of this spec are obsolete. Replace mentions of "the ` ensure_schema() ` latch
179+ remains in place" with "` create_database_tables() ` continues to be invoked once from
180+ ` initialize_database() ` ".
181+
182+ ### F2. ` drop_database_tables() ` already drops ` torrent_aggregate_metrics `
183+
184+ Both the SQLite and MySQL drivers in current code already drop all four tables. The spec's
185+ claim that this is a "pre-existing omission" is incorrect. The only ** new** drop required by
186+ this subissue is ` _sqlx_migrations ` . Acceptance criteria below are reworded accordingly. The
187+ ` DROP TABLE IF EXISTS ` switch (covering all five drops) remains a real change — current code
188+ uses bare ` DROP TABLE ` .
189+
190+ ### F3. Error construction follows a tuple-` From ` pattern, not a constructor
191+
192+ All existing ` sqlx ` -error sites use ` .map_err(|e| (e, DRIVER))? ` and rely on
193+ ` impl From<(SqlxError, Driver)> for Error ` . The proposed `Error::migration_error(driver,
194+ source)` constructor breaks that convention. Preferred shape:
195+
196+ - Add a new ` Error::MigrationError { source, driver } ` variant.
197+ - Add ` impl From<(sqlx::migrate::MigrateError, Driver)> for Error ` .
198+ - Call sites then write ` .map_err(|e| (e, DRIVER))? ` , identical to every other driver call.
199+
200+ Update Task 2 and the bootstrap helper code in Task 3 to use this shape. The acceptance
201+ criterion "` Error::migration_error() ` wraps ` MigrateError ` " should be reworded as "a new
202+ ` Error::MigrationError ` variant + ` From<(MigrateError, Driver)> ` impl wraps ` MigrateError ` ".
203+
204+ ### F4. ` sqlx ` 's ` migrate ` feature is already enabled transitively; only ` macros ` is missing
205+
206+ ` cargo tree ` confirms ` sqlx-core ` is built with the ` migrate ` feature already (so the
207+ ` sqlx::migrate::Migrator ` and ` MigrateError ` types are reachable today). The required
208+ addition in ` packages/tracker-core/Cargo.toml ` is the ** ` macros ` ** feature on ` sqlx ` , which
209+ gates the compile-time ` sqlx::migrate!() ` macro. No other feature additions are needed.
210+
211+ ### F5. SQLite migration 1 contains an invalid ` # ` comment
212+
213+ ` packages/tracker-core/migrations/sqlite/20240730183000_torrust_tracker_create_all_tables.sql `
214+ contains a Bash-style comment line (` # todo: rename to torrent_metrics ` ). SQLite's lexer does not
215+ accept ` # ` as a comment introducer (only ` -- ` and ` /* … */ ` ); only MySQL does. When
216+ ` MIGRATOR.run() ` executes this file against SQLite, the statement parser is expected to
217+ fail with a syntax error. ** Action in Task 1** : replace ` # ` with ` -- ` in the SQLite file
218+ (and in the MySQL file as well, for consistency, since ` -- ` is portable). Verify by running
219+ the SQLite driver tests after the change.
220+
221+ ### F6. MySQL migration 1 still uses ` INT(10) ` display-width syntax
222+
223+ MySQL 8.0 deprecated integer display-width attributes. ` INT(10) ` still parses but emits a
224+ warning and is dropped from ` SHOW CREATE TABLE ` output, which can cause schema-comparison
225+ noise. Not blocking for this subissue; flag as an optional cleanup or defer to subissue
226+ ` 1525-07 ` (Rust ↔ SQL type alignment) where integer widths are revisited.
227+
228+ ### F7. ` keys.key ` width is ` VARCHAR(32) ` , matches ` AUTH_KEY_LENGTH `
229+
230+ Verified: ` AUTH_KEY_LENGTH = 32 ` in ` packages/tracker-core/src/authentication/key/mod.rs ` .
231+ MySQL migration 1 uses ` VARCHAR(32) ` , so the migration file matches the ` format! ` -built DDL
232+ in the current driver. No discrepancy. Once migrations own the schema, the ` format! ` /
233+ ` AUTH_KEY_LENGTH ` coupling in ` mysql/schema_migrator.rs ` disappears (the column width is
234+ frozen in the migration file).
235+
236+ ### F8. Other consumers of ` drop_database_tables() ` outside the test harness
237+
238+ ` packages/axum-rest-tracker-api-server/tests/server/mod.rs::force_database_error ` calls
239+ ` drop_database_tables() ` to provoke query failures. After this subissue it will additionally
240+ drop ` _sqlx_migrations ` . Behaviour is unchanged for the test (subsequent queries still
241+ fail), but worth a sentence in the PR description.
242+
243+ ### F9. ` bootstrap_legacy_schema() ` precondition queries — concrete forms
244+
245+ The spec describes the checks abstractly. Concrete queries to use:
246+
247+ - ** ` _sqlx_migrations ` exists**
248+ - SQLite: ` SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations' `
249+ - MySQL: `SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND
250+ table_name = '_ sqlx_migrations'`
251+ - ** Legacy sentinel (` whitelist ` exists)** — same shape as above with ` name='whitelist' ` .
252+ - ** Migration 2 applied (` keys.valid_until ` is nullable)**
253+ - SQLite: ` PRAGMA table_info(keys) ` → row where ` name='valid_until' ` has ` notnull = 0 ` .
254+ - MySQL: `SELECT is_nullable FROM information_schema.columns WHERE table_schema =
255+ DATABASE() AND table_name = 'keys' AND column_name = 'valid_until'` → ` 'YES'`.
256+ - ** Migration 3 applied (` torrent_aggregate_metrics ` exists)** — sentinel-table check, same
257+ shape as the first two.
258+
259+ Important ordering: check ` _sqlx_migrations ` existence with a raw query ** before** calling
260+ ` MIGRATOR.ensure_migrations_table(pool) ` , because the latter creates the table if absent and
261+ would defeat the detection.
262+
263+ ### F10. ` apply_fake ` SQL — confirm column types and key types in sqlx 0.8
264+
265+ ` Migration::version ` is ` i64 ` , ` Migration::description ` is ` Cow<'static, str> ` , and
266+ ` Migration::checksum ` is ` Cow<'static, [u8]> ` . Binding ` &[u8] ` for the checksum column works
267+ in both backends. The ` _sqlx_migrations ` schema has columns
268+ `(version BIGINT PK, description TEXT, installed_on TIMESTAMP, success BOOL, checksum BLOB,
269+ execution_time BIGINT)` — verify this once during implementation by inspecting the table sqlx
270+ creates against a fresh DB; if column types differ across backends, adjust the INSERT bind
271+ types accordingly.
272+
273+ ### F11. ` database_setup ` test cycle is the natural drop/create test
274+
275+ ` packages/tracker-core/src/databases/driver/mod.rs::database_setup ` already does
276+ ` create → drop → create ` . After this subissue, the second ` create ` runs ` MIGRATOR.run() ` on
277+ a database where everything (including ` _sqlx_migrations ` ) was just dropped. No additional
278+ test is needed for the drop/create cycle scenario beyond verifying that this existing test
279+ still passes.
160280
161281## Tasks
162282
@@ -167,7 +287,15 @@ their SQL content is correct and consistent with the current schema produced by
167287DDL in ` 1525-05 ` . Do not change existing file timestamps or names. Fix content only if a
168288discrepancy is found.
169289
170- ** Outcome** : all three migration files are verified correct; nothing else changes yet.
290+ Known issue to fix as part of this task (see finding F5): the SQLite (and MySQL) migration
291+ ` 20240730183000_torrust_tracker_create_all_tables.sql ` contains a Bash-style line
292+ (` # todo: rename to ...torrent_metrics ` ). SQLite does not accept ` # ` line comments — replace ` # ` with ` -- ` in
293+ both backend files. This is the only content change expected; verify by running
294+ ` cargo test -p bittorrent-tracker-core run_sqlite_driver_tests ` after Task 3 wires the
295+ migrator in.
296+
297+ ** Outcome** : all three migration files compile under ` sqlx::migrate!() ` for both backends;
298+ the ` # ` -comment incompatibility is fixed.
171299
172300### Task 2 — Enable ` sqlx ` ` macros ` feature and add ` MIGRATOR ` statics
173301
@@ -190,7 +318,9 @@ static MIGRATOR: Migrator = sqlx::migrate!("migrations/sqlite");
190318static MIGRATOR : Migrator = sqlx :: migrate! (" migrations/mysql" );
191319```
192320
193- Add ` Error::migration_error() ` to ` databases/error.rs ` to wrap ` sqlx::migrate::MigrateError ` .
321+ Add a new ` Error::MigrationError { source, driver } ` variant to ` databases/error.rs ` and an
322+ ` impl From<(sqlx::migrate::MigrateError, Driver)> for Error ` so the new code can keep the
323+ established ` .map_err(|e| (e, DRIVER))? ` call pattern (see finding F3).
194324
195325** Outcome** : project compiles with migration statics defined but not yet called.
196326
@@ -225,14 +355,17 @@ async fn bootstrap_legacy_schema(pool: &Pool) -> Result<(), Error> {
225355 let migration_2_applied : bool = /* check keys.valid_until is nullable */ ;
226356 let migration_3_applied : bool = /* check torrent_aggregate_metrics table exists */ ;
227357 if ! migration_2_applied || ! migration_3_applied {
228- return Err ( Error :: migration_error (
229- DRIVER ,
230- std :: io :: Error :: new (
231- std :: io :: ErrorKind :: InvalidData ,
358+ // Build a `MigrateError` directly so the conversion goes through the
359+ // standard `From<(MigrateError, Driver)> for Error` impl introduced in Task 2.
360+ return Err ( (
361+ sqlx :: migrate :: MigrateError :: Source (
232362 " Legacy database is not fully migrated. Apply all three manual migrations \
233- listed in packages/tracker-core/migrations/README.md before upgrading to v4." ,
363+ listed in packages/tracker-core/migrations/README.md before upgrading to v4."
364+ . into (),
234365 ),
235- ));
366+ DRIVER ,
367+ )
368+ . into ());
236369 }
237370
238371 // PRECONDITION: all three manual migrations have been verified as applied:
@@ -244,7 +377,7 @@ async fn bootstrap_legacy_schema(pool: &Pool) -> Result<(), Error> {
244377 MIGRATOR
245378 . ensure_migrations_table (pool )
246379 . await
247- . map_err (| e | Error :: migration_error ( DRIVER , e ))? ;
380+ . map_err (| e | ( e , DRIVER ))? ;
248381 for migration in MIGRATOR . iter () {
249382 if migration . version <= 20_250_527_093_000 {
250383 // sqlx 0.8 does not expose a public `apply_fake()` API on `Migrator`.
@@ -265,7 +398,7 @@ async fn bootstrap_legacy_schema(pool: &Pool) -> Result<(), Error> {
265398 . bind (migration . checksum. as_ref ())
266399 . execute (pool )
267400 . await
268- . map_err (| e | Error :: migration_error ( DRIVER , e ))? ;
401+ . map_err (| e | ( e , DRIVER ))? ;
269402 }
270403 }
271404 Ok (())
@@ -277,7 +410,7 @@ async fn bootstrap_legacy_schema(pool: &Pool) -> Result<(), Error> {
277410``` rust
278411async fn create_database_tables (& self ) -> Result <(), Error > {
279412 bootstrap_legacy_schema (& self . pool). await ? ;
280- MIGRATOR . run (& self . pool). await . map_err (| e | Error :: migration_error ( DRIVER , e ))? ;
413+ MIGRATOR . run (& self . pool). await . map_err (| e | ( e , DRIVER ))? ;
281414 Ok (())
282415}
283416```
@@ -332,8 +465,8 @@ Update `packages/tracker-core/migrations/README.md` to replace the stale content
332465 migration file causes a checksum-mismatch error on the next startup for any database that has
333466 already applied that migration.
334467
335- The ` ensure_schema ()` latch remains in place — it now guards the
336- ` bootstrap_legacy_schema ()` + ` MIGRATOR.run ()` sequence .
468+ ` create_database_tables ()` continues to be invoked once from
469+ ` databases::setup::initialize_database ()` (no ` ensure_schema ()` latch — see finding F1) .
337470
338471** Outcome** : ` cargo test --workspace --all-targets ` passes. Schema is owned by migration files.
339472The README accurately reflects the new automatic migration behavior.
@@ -383,12 +516,14 @@ modules.
383516 confirmed correct and match the final schema produced by the hardcoded DDL in ` 1525-05 ` .
384517- [ ] ` sqlx::migrate!() ` (` macros ` feature) is used in both drivers; no raw DDL remains in
385518 ` create_database_tables() ` .
386- - [ ] ` drop_database_tables() ` drops ` _sqlx_migrations ` ** and ** ` torrent_aggregate_metrics `
387- (fixing the pre-existing omission) so the test cycle works. All five drops use
388- ` DROP TABLE IF EXISTS ` .
519+ - [ ] ` drop_database_tables() ` adds a drop for ` _sqlx_migrations ` (the only newly required
520+ drop — ` torrent_aggregate_metrics ` is already dropped today; see finding F2) and every
521+ drop is converted to ` DROP TABLE IF EXISTS ` .
389522- [ ] ` bootstrap_legacy_schema() ` verifies that migrations 2 and 3 were applied before
390523 fake-applying, and returns a descriptive error if the precondition is not met.
391- - [ ] ` Error::migration_error() ` wraps ` sqlx::migrate::MigrateError ` .
524+ - [ ] A new ` Error::MigrationError ` variant plus `impl From<(sqlx::migrate::MigrateError,
525+ Driver)> for Error` wrap ` MigrateError` , matching the existing tuple- ` From` pattern
526+ used by every other ` sqlx ` error site (see finding F3).
392527- [ ] ` packages/tracker-core/migrations/README.md ` is updated to document automatic migration
393528 behavior and the v4 upgrade requirement.
394529- [ ] Guidance for ` 1525-08 ` : PostgreSQL migration files start from migration 1 following the
0 commit comments