Skip to main content

max / audiofiles

db: idempotent migrations + replay test Subagent-audited all 17 migrations for replay safety. Made M004, M005, M006, M007, M012 fully idempotent (CREATE TABLE/INDEX/TRIGGER IF NOT EXISTS; M007 seed INSERT OR IGNORE). M008-M011, M016, M017 were already replay-safe via DROP IF EXISTS + CREATE. M014 already idempotent. Added migration_replay_from_file_no_op (open/close/reopen cycle) and migration_replay_from_version_two_against_full_schema (rolls user_version=2 against a populated schema and re-runs M003 onward). Future non-idempotent CREATEs will fail this test loudly. M001 (initial schema) and M002 (DROP TABLE tags; ALTER tags_v2 RENAME TO tags) are inherently one-shot and not replay-safe — neither needs to be (both ship to fresh DBs exactly once). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-03 01:59 UTC
Commit: cf13b8e66178f115ea86b3cb9e4be726faffe883
Parent: c275392
1 file changed, +90 insertions, -38 deletions
@@ -134,7 +134,7 @@ ALTER TABLE audio_analysis ADD COLUMN classification TEXT;
134 134 "#;
135 135
136 136 const MIGRATION_004: &str = r#"
137 - CREATE TABLE waveform_data (
137 + CREATE TABLE IF NOT EXISTS waveform_data (
138 138 hash TEXT PRIMARY KEY REFERENCES samples(hash) ON DELETE CASCADE,
139 139 num_buckets INTEGER NOT NULL,
140 140 peak_data BLOB NOT NULL,
@@ -142,17 +142,17 @@ CREATE TABLE waveform_data (
142 142 duration REAL NOT NULL,
143 143 generated_at INTEGER NOT NULL
144 144 );
145 - CREATE INDEX idx_analysis_duration ON audio_analysis(duration);
146 - CREATE INDEX idx_analysis_classification ON audio_analysis(classification);
147 - CREATE INDEX idx_samples_name ON samples(original_name);
145 + CREATE INDEX IF NOT EXISTS idx_analysis_duration ON audio_analysis(duration);
146 + CREATE INDEX IF NOT EXISTS idx_analysis_classification ON audio_analysis(classification);
147 + CREATE INDEX IF NOT EXISTS idx_samples_name ON samples(original_name);
148 148 "#;
149 149
150 150 const MIGRATION_005: &str = r#"
151 - CREATE TABLE user_config (key TEXT PRIMARY KEY, value TEXT NOT NULL);
151 + CREATE TABLE IF NOT EXISTS user_config (key TEXT PRIMARY KEY, value TEXT NOT NULL);
152 152 "#;
153 153
154 154 const MIGRATION_006: &str = r#"
155 - CREATE TABLE fingerprints (
155 + CREATE TABLE IF NOT EXISTS fingerprints (
156 156 hash TEXT PRIMARY KEY REFERENCES samples(hash) ON DELETE CASCADE,
157 157 envelope BLOB NOT NULL,
158 158 sample_rate INTEGER NOT NULL,
@@ -165,11 +165,11 @@ const MIGRATION_007: &str = r#"
165 165 ALTER TABLE vfs ADD COLUMN sync_files INTEGER NOT NULL DEFAULT 0;
166 166
167 167 -- Sync metadata key-value store
168 - CREATE TABLE sync_state (
168 + CREATE TABLE IF NOT EXISTS sync_state (
169 169 key TEXT PRIMARY KEY,
170 170 value TEXT NOT NULL
171 171 );
172 - INSERT INTO sync_state (key, value) VALUES
172 + INSERT OR IGNORE INTO sync_state (key, value) VALUES
173 173 ('device_id', ''),
174 174 ('pull_cursor', ''),
175 175 ('auto_sync_enabled', '0'),
@@ -179,7 +179,7 @@ INSERT INTO sync_state (key, value) VALUES
179 179 ('initial_snapshot_done', '0');
180 180
181 181 -- Local change log for push/pull sync
182 - CREATE TABLE sync_changelog (
182 + CREATE TABLE IF NOT EXISTS sync_changelog (
183 183 id INTEGER PRIMARY KEY AUTOINCREMENT,
184 184 table_name TEXT NOT NULL,
185 185 op TEXT NOT NULL,
@@ -188,12 +188,12 @@ CREATE TABLE sync_changelog (
188 188 data TEXT,
189 189 pushed INTEGER NOT NULL DEFAULT 0
190 190 );
191 - CREATE INDEX idx_changelog_pushed ON sync_changelog(pushed);
191 + CREATE INDEX IF NOT EXISTS idx_changelog_pushed ON sync_changelog(pushed);
192 192
193 193 -- ── Triggers: record changes unless applying remote data ──
194 194
195 195 -- samples
196 - CREATE TRIGGER sync_samples_insert AFTER INSERT ON samples
196 + CREATE TRIGGER IF NOT EXISTS sync_samples_insert AFTER INSERT ON samples
197 197 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
198 198 BEGIN
199 199 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -203,7 +203,7 @@ BEGIN
203 203 'import_date', NEW.import_date, 'last_modified', NEW.last_modified));
204 204 END;
205 205
206 - CREATE TRIGGER sync_samples_update AFTER UPDATE ON samples
206 + CREATE TRIGGER IF NOT EXISTS sync_samples_update AFTER UPDATE ON samples
207 207 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
208 208 BEGIN
209 209 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -213,7 +213,7 @@ BEGIN
213 213 'import_date', NEW.import_date, 'last_modified', NEW.last_modified));
214 214 END;
215 215
216 - CREATE TRIGGER sync_samples_delete AFTER DELETE ON samples
216 + CREATE TRIGGER IF NOT EXISTS sync_samples_delete AFTER DELETE ON samples
217 217 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
218 218 BEGIN
219 219 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -221,7 +221,7 @@ BEGIN
221 221 END;
222 222
223 223 -- audio_analysis
224 - CREATE TRIGGER sync_audio_analysis_insert AFTER INSERT ON audio_analysis
224 + CREATE TRIGGER IF NOT EXISTS sync_audio_analysis_insert AFTER INSERT ON audio_analysis
225 225 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
226 226 BEGIN
227 227 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -235,7 +235,7 @@ BEGIN
235 235 'zero_crossing_rate', NEW.zero_crossing_rate, 'classification', NEW.classification));
236 236 END;
237 237
238 - CREATE TRIGGER sync_audio_analysis_update AFTER UPDATE ON audio_analysis
238 + CREATE TRIGGER IF NOT EXISTS sync_audio_analysis_update AFTER UPDATE ON audio_analysis
239 239 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
240 240 BEGIN
241 241 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -249,7 +249,7 @@ BEGIN
249 249 'zero_crossing_rate', NEW.zero_crossing_rate, 'classification', NEW.classification));
250 250 END;
251 251
252 - CREATE TRIGGER sync_audio_analysis_delete AFTER DELETE ON audio_analysis
252 + CREATE TRIGGER IF NOT EXISTS sync_audio_analysis_delete AFTER DELETE ON audio_analysis
253 253 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
254 254 BEGIN
255 255 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -257,7 +257,7 @@ BEGIN
257 257 END;
258 258
259 259 -- vfs
260 - CREATE TRIGGER sync_vfs_insert AFTER INSERT ON vfs
260 + CREATE TRIGGER IF NOT EXISTS sync_vfs_insert AFTER INSERT ON vfs
261 261 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
262 262 BEGIN
263 263 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -267,7 +267,7 @@ BEGIN
267 267 'sync_files', NEW.sync_files));
268 268 END;
269 269
270 - CREATE TRIGGER sync_vfs_update AFTER UPDATE ON vfs
270 + CREATE TRIGGER IF NOT EXISTS sync_vfs_update AFTER UPDATE ON vfs
271 271 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
272 272 BEGIN
273 273 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -277,7 +277,7 @@ BEGIN
277 277 'sync_files', NEW.sync_files));
278 278 END;
279 279
280 - CREATE TRIGGER sync_vfs_delete AFTER DELETE ON vfs
280 + CREATE TRIGGER IF NOT EXISTS sync_vfs_delete AFTER DELETE ON vfs
281 281 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
282 282 BEGIN
283 283 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -285,7 +285,7 @@ BEGIN
285 285 END;
286 286
287 287 -- vfs_nodes
288 - CREATE TRIGGER sync_vfs_nodes_insert AFTER INSERT ON vfs_nodes
288 + CREATE TRIGGER IF NOT EXISTS sync_vfs_nodes_insert AFTER INSERT ON vfs_nodes
289 289 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
290 290 BEGIN
291 291 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -295,7 +295,7 @@ BEGIN
295 295 'sample_hash', NEW.sample_hash, 'created_at', NEW.created_at));
296 296 END;
297 297
298 - CREATE TRIGGER sync_vfs_nodes_update AFTER UPDATE ON vfs_nodes
298 + CREATE TRIGGER IF NOT EXISTS sync_vfs_nodes_update AFTER UPDATE ON vfs_nodes
299 299 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
300 300 BEGIN
301 301 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -305,7 +305,7 @@ BEGIN
305 305 'sample_hash', NEW.sample_hash, 'created_at', NEW.created_at));
306 306 END;
307 307
308 - CREATE TRIGGER sync_vfs_nodes_delete AFTER DELETE ON vfs_nodes
308 + CREATE TRIGGER IF NOT EXISTS sync_vfs_nodes_delete AFTER DELETE ON vfs_nodes
309 309 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
310 310 BEGIN
311 311 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -313,7 +313,7 @@ BEGIN
313 313 END;
314 314
315 315 -- tags
316 - CREATE TRIGGER sync_tags_insert AFTER INSERT ON tags
316 + CREATE TRIGGER IF NOT EXISTS sync_tags_insert AFTER INSERT ON tags
317 317 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
318 318 BEGIN
319 319 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -321,7 +321,7 @@ BEGIN
321 321 json_object('sample_hash', NEW.sample_hash, 'tag', NEW.tag));
322 322 END;
323 323
324 - CREATE TRIGGER sync_tags_delete AFTER DELETE ON tags
324 + CREATE TRIGGER IF NOT EXISTS sync_tags_delete AFTER DELETE ON tags
325 325 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
326 326 BEGIN
327 327 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -329,7 +329,7 @@ BEGIN
329 329 END;
330 330
331 331 -- collections
332 - CREATE TRIGGER sync_collections_insert AFTER INSERT ON collections
332 + CREATE TRIGGER IF NOT EXISTS sync_collections_insert AFTER INSERT ON collections
333 333 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
334 334 BEGIN
335 335 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -338,7 +338,7 @@ BEGIN
338 338 'description', NEW.description, 'created_at', NEW.created_at));
339 339 END;
340 340
341 - CREATE TRIGGER sync_collections_update AFTER UPDATE ON collections
341 + CREATE TRIGGER IF NOT EXISTS sync_collections_update AFTER UPDATE ON collections
342 342 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
343 343 BEGIN
344 344 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -347,7 +347,7 @@ BEGIN
347 347 'description', NEW.description, 'created_at', NEW.created_at));
348 348 END;
349 349
350 - CREATE TRIGGER sync_collections_delete AFTER DELETE ON collections
350 + CREATE TRIGGER IF NOT EXISTS sync_collections_delete AFTER DELETE ON collections
351 351 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
352 352 BEGIN
353 353 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -355,7 +355,7 @@ BEGIN
355 355 END;
356 356
357 357 -- collection_members
358 - CREATE TRIGGER sync_collection_members_insert AFTER INSERT ON collection_members
358 + CREATE TRIGGER IF NOT EXISTS sync_collection_members_insert AFTER INSERT ON collection_members
359 359 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
360 360 BEGIN
361 361 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -365,7 +365,7 @@ BEGIN
365 365 'added_at', NEW.added_at));
366 366 END;
367 367
368 - CREATE TRIGGER sync_collection_members_delete AFTER DELETE ON collection_members
368 + CREATE TRIGGER IF NOT EXISTS sync_collection_members_delete AFTER DELETE ON collection_members
369 369 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
370 370 BEGIN
371 371 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -374,7 +374,7 @@ BEGIN
374 374 END;
375 375
376 376 -- smart_folders
377 - CREATE TRIGGER sync_smart_folders_insert AFTER INSERT ON smart_folders
377 + CREATE TRIGGER IF NOT EXISTS sync_smart_folders_insert AFTER INSERT ON smart_folders
378 378 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
379 379 BEGIN
380 380 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -383,7 +383,7 @@ BEGIN
383 383 'query_json', NEW.query_json, 'created_at', NEW.created_at));
384 384 END;
385 385
386 - CREATE TRIGGER sync_smart_folders_update AFTER UPDATE ON smart_folders
386 + CREATE TRIGGER IF NOT EXISTS sync_smart_folders_update AFTER UPDATE ON smart_folders
387 387 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
388 388 BEGIN
389 389 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -392,7 +392,7 @@ BEGIN
392 392 'query_json', NEW.query_json, 'created_at', NEW.created_at));
393 393 END;
394 394
395 - CREATE TRIGGER sync_smart_folders_delete AFTER DELETE ON smart_folders
395 + CREATE TRIGGER IF NOT EXISTS sync_smart_folders_delete AFTER DELETE ON smart_folders
396 396 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
397 397 BEGIN
398 398 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -400,7 +400,7 @@ BEGIN
400 400 END;
401 401
402 402 -- user_config (exclude sync-internal keys)
403 - CREATE TRIGGER sync_user_config_insert AFTER INSERT ON user_config
403 + CREATE TRIGGER IF NOT EXISTS sync_user_config_insert AFTER INSERT ON user_config
404 404 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
405 405 AND NEW.key NOT LIKE 'sync_%'
406 406 BEGIN
@@ -409,7 +409,7 @@ BEGIN
409 409 json_object('key', NEW.key, 'value', NEW.value));
410 410 END;
411 411
412 - CREATE TRIGGER sync_user_config_update AFTER UPDATE ON user_config
412 + CREATE TRIGGER IF NOT EXISTS sync_user_config_update AFTER UPDATE ON user_config
413 413 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
414 414 AND NEW.key NOT LIKE 'sync_%'
415 415 BEGIN
@@ -418,7 +418,7 @@ BEGIN
418 418 json_object('key', NEW.key, 'value', NEW.value));
419 419 END;
420 420
421 - CREATE TRIGGER sync_user_config_delete AFTER DELETE ON user_config
421 + CREATE TRIGGER IF NOT EXISTS sync_user_config_delete AFTER DELETE ON user_config
422 422 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
423 423 AND OLD.key NOT LIKE 'sync_%'
424 424 BEGIN
@@ -586,11 +586,11 @@ CREATE TABLE IF NOT EXISTS edit_history (
586 586 params_json TEXT,
587 587 created_at INTEGER NOT NULL DEFAULT (unixepoch())
588 588 );
589 - CREATE INDEX idx_edit_history_source ON edit_history(source_hash);
590 - CREATE INDEX idx_edit_history_result ON edit_history(result_hash);
589 + CREATE INDEX IF NOT EXISTS idx_edit_history_source ON edit_history(source_hash);
590 + CREATE INDEX IF NOT EXISTS idx_edit_history_result ON edit_history(result_hash);
591 591
592 592 -- Sync trigger for edit_history
593 - CREATE TRIGGER sync_edit_history_insert AFTER INSERT ON edit_history
593 + CREATE TRIGGER IF NOT EXISTS sync_edit_history_insert AFTER INSERT ON edit_history
594 594 WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
595 595 BEGIN
596 596 INSERT INTO sync_changelog (table_name, op, row_id, data)
@@ -952,6 +952,58 @@ mod tests {
952 952 assert_eq!(version, 17);
953 953 }
954 954
955 + /// Open a fresh file-backed DB, close, reopen. The second open re-enters
956 + /// `migrate()`; with `user_version=17` no migration body runs, but the
957 + /// shape verifies our open/close cycle is clean (no locks, no WAL leak).
958 + #[test]
959 + fn migration_replay_from_file_no_op() {
960 + let dir = tempfile::tempdir().unwrap();
961 + let path = dir.path().join("audiofiles.db");
962 +
963 + let db = Database::open(&path).unwrap();
964 + drop(db);
965 +
966 + let db = Database::open(&path).unwrap();
967 + let version: i32 = db
968 + .conn()
969 + .query_row("PRAGMA user_version", [], |row| row.get(0))
970 + .unwrap();
971 + assert_eq!(version, 17);
972 + }
973 +
974 + /// Simulates the worst-case recovery path: a prior partial migration left
975 + /// every object in place but `user_version` rolled back. Re-running
976 + /// `migrate()` against the pre-populated schema must succeed without
977 + /// duplicate-object errors. This catches the "silent failure → bump
978 + /// user_version" bug class for every NEW migration we add.
979 + ///
980 + /// We roll back to version 2 (not 0): M001 is the initial schema and
981 + /// M002 is a `DROP TABLE tags; ALTER tags_v2 RENAME TO tags` rebuild
982 + /// dance — neither is replay-safe by construction, and neither needs to
983 + /// be (both ship to fresh DBs exactly once). Every migration from M003
984 + /// onward MUST be replay-safe; if you add a new one that isn't, this
985 + /// test fails and you should add `IF NOT EXISTS` / `DROP IF EXISTS` /
986 + /// `INSERT OR IGNORE` accordingly.
987 + #[test]
988 + fn migration_replay_from_version_two_against_full_schema() {
989 + let dir = tempfile::tempdir().unwrap();
990 + let path = dir.path().join("audiofiles.db");
991 +
992 + Database::open(&path).unwrap();
993 +
994 + {
995 + let conn = Connection::open(&path).unwrap();
996 + conn.execute_batch("PRAGMA user_version = 2").unwrap();
997 + }
998 +
999 + let db = Database::open(&path).unwrap();
1000 + let version: i32 = db
1001 + .conn()
1002 + .query_row("PRAGMA user_version", [], |row| row.get(0))
1003 + .unwrap();
1004 + assert_eq!(version, 17);
1005 + }
1006 +
955 1007 #[test]
956 1008 fn foreign_keys_enforced() {
957 1009 let db = Database::open_in_memory().unwrap();