Skip to main content

max / audiofiles

db: restore dropped audio_analysis sync-trigger columns (M032) MIGRATION_018 recreated the audio_analysis insert/update sync triggers to salt row_id but copied the pre-M011 column list, silently dropping spectral_bandwidth, centroid_variance, crest_factor, attack_time, and classification_confidence from sync_changelog. Edits to those columns stopped propagating across devices (the initial snapshot still carried them once). MIGRATION_032 recreates the triggers with the salted row_id and the full column set, plus a regression test asserting the live trigger bodies emit every later-added column. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-11 01:50 UTC
Commit: 1741cbb132cdd184215fe1f5eb5ec9d6352cd317
Parent: 7de44de
1 file changed, +83 insertions, -6 deletions
@@ -1507,6 +1507,53 @@ CREATE VIEW IF NOT EXISTS live_samples AS
1507 1507 SELECT * FROM samples WHERE deleted_at IS NULL;
1508 1508 "#;
1509 1509
1510 + const MIGRATION_032: &str = r#"
1511 + -- M018 recreated the audio_analysis sync triggers (to salt row_id) but copied
1512 + -- the pre-M011 column list, silently dropping spectral_bandwidth,
1513 + -- centroid_variance, crest_factor, attack_time, and classification_confidence
1514 + -- from the sync_changelog payload. The initial snapshot still carries them, but
1515 + -- ongoing edits to those five columns never propagate across devices. Recreate
1516 + -- the insert/update triggers with M018's salted row_id AND the full column set.
1517 + DROP TRIGGER IF EXISTS sync_audio_analysis_insert;
1518 + DROP TRIGGER IF EXISTS sync_audio_analysis_update;
1519 +
1520 + CREATE TRIGGER sync_audio_analysis_insert AFTER INSERT ON audio_analysis
1521 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
1522 + BEGIN
1523 + INSERT INTO sync_changelog (table_name, op, row_id, data)
1524 + VALUES ('audio_analysis', 'INSERT',
1525 + hash_row_id((SELECT value FROM sync_state WHERE key = 'row_id_salt'), NEW.hash),
1526 + json_object('hash', NEW.hash, 'bpm', NEW.bpm, 'musical_key', NEW.musical_key,
1527 + 'duration', NEW.duration, 'sample_rate', NEW.sample_rate, 'channels', NEW.channels,
1528 + 'peak_db', NEW.peak_db, 'rms_db', NEW.rms_db, 'is_loop', NEW.is_loop,
1529 + 'spectral_centroid', NEW.spectral_centroid, 'onset_strength', NEW.onset_strength,
1530 + 'analyzed_at', NEW.analyzed_at, 'lufs', NEW.lufs,
1531 + 'spectral_flatness', NEW.spectral_flatness, 'spectral_rolloff', NEW.spectral_rolloff,
1532 + 'zero_crossing_rate', NEW.zero_crossing_rate, 'classification', NEW.classification,
1533 + 'spectral_bandwidth', NEW.spectral_bandwidth, 'centroid_variance', NEW.centroid_variance,
1534 + 'crest_factor', NEW.crest_factor, 'attack_time', NEW.attack_time,
1535 + 'classification_confidence', NEW.classification_confidence));
1536 + END;
1537 +
1538 + CREATE TRIGGER sync_audio_analysis_update AFTER UPDATE ON audio_analysis
1539 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
1540 + BEGIN
1541 + INSERT INTO sync_changelog (table_name, op, row_id, data)
1542 + VALUES ('audio_analysis', 'UPDATE',
1543 + hash_row_id((SELECT value FROM sync_state WHERE key = 'row_id_salt'), NEW.hash),
1544 + json_object('hash', NEW.hash, 'bpm', NEW.bpm, 'musical_key', NEW.musical_key,
1545 + 'duration', NEW.duration, 'sample_rate', NEW.sample_rate, 'channels', NEW.channels,
1546 + 'peak_db', NEW.peak_db, 'rms_db', NEW.rms_db, 'is_loop', NEW.is_loop,
1547 + 'spectral_centroid', NEW.spectral_centroid, 'onset_strength', NEW.onset_strength,
1548 + 'analyzed_at', NEW.analyzed_at, 'lufs', NEW.lufs,
1549 + 'spectral_flatness', NEW.spectral_flatness, 'spectral_rolloff', NEW.spectral_rolloff,
1550 + 'zero_crossing_rate', NEW.zero_crossing_rate, 'classification', NEW.classification,
1551 + 'spectral_bandwidth', NEW.spectral_bandwidth, 'centroid_variance', NEW.centroid_variance,
1552 + 'crest_factor', NEW.crest_factor, 'attack_time', NEW.attack_time,
1553 + 'classification_confidence', NEW.classification_confidence));
1554 + END;
1555 + "#;
1556 +
1510 1557 /// Register `hash_row_id(salt, key) -> TEXT` as a deterministic SQLite
1511 1558 /// function on the given connection. Used by the M018 sync triggers so the
1512 1559 /// `sync_changelog.row_id` field never carries cleartext content (tag strings,
@@ -1643,6 +1690,7 @@ impl Database {
1643 1690 MIGRATION_029,
1644 1691 MIGRATION_030,
1645 1692 MIGRATION_031,
1693 + MIGRATION_032,
1646 1694 ];
1647 1695
1648 1696 for (i, sql) in MIGRATIONS.iter().enumerate() {
@@ -1886,7 +1934,7 @@ mod tests {
1886 1934 .conn()
1887 1935 .query_row("PRAGMA user_version", [], |row| row.get(0))
1888 1936 .unwrap();
1889 - assert_eq!(version, 31);
1937 + assert_eq!(version, 32);
1890 1938 }
1891 1939
1892 1940 #[test]
@@ -1897,7 +1945,36 @@ mod tests {
1897 1945 .conn()
1898 1946 .query_row("PRAGMA user_version", [], |row| row.get(0))
1899 1947 .unwrap();
1900 - assert_eq!(version, 31);
1948 + assert_eq!(version, 32);
1949 + }
1950 +
1951 + #[test]
1952 + fn audio_analysis_sync_triggers_carry_all_columns() {
1953 + // Regression guard for the M018 -> M032 fix: M018 recreated these
1954 + // triggers with the pre-M011 column list, so edits to the five columns
1955 + // below stopped propagating to sync_changelog (and thus across devices).
1956 + // Assert the live trigger bodies emit every later-added analysis column.
1957 + let db = Database::open_in_memory().unwrap();
1958 + let later_columns = [
1959 + "spectral_bandwidth",
1960 + "centroid_variance",
1961 + "crest_factor",
1962 + "attack_time",
1963 + "classification_confidence",
1964 + ];
1965 + for trigger in ["sync_audio_analysis_insert", "sync_audio_analysis_update"] {
1966 + let sql: String = db
1967 + .conn()
1968 + .query_row(
1969 + "SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?1",
1970 + [trigger],
1971 + |row| row.get(0),
1972 + )
1973 + .unwrap();
1974 + for col in later_columns {
1975 + assert!(sql.contains(col), "{trigger} is missing column {col}");
1976 + }
1977 + }
1901 1978 }
1902 1979
1903 1980 /// Open a fresh file-backed DB, close, reopen. The second open re-enters
@@ -1916,7 +1993,7 @@ mod tests {
1916 1993 .conn()
1917 1994 .query_row("PRAGMA user_version", [], |row| row.get(0))
1918 1995 .unwrap();
1919 - assert_eq!(version, 31);
1996 + assert_eq!(version, 32);
1920 1997 }
1921 1998
1922 1999 /// Simulates the worst-case recovery path: a prior partial migration left
@@ -1960,7 +2037,7 @@ mod tests {
1960 2037 .conn()
1961 2038 .query_row("PRAGMA user_version", [], |row| row.get(0))
1962 2039 .unwrap();
1963 - assert_eq!(version, 31);
2040 + assert_eq!(version, 32);
1964 2041 }
1965 2042
1966 2043 /// M018 contract: the `sync_changelog.row_id` for sensitive tables must
@@ -2182,7 +2259,7 @@ mod tests {
2182 2259 let initial_version: i32 = conn
2183 2260 .query_row("PRAGMA user_version", [], |row| row.get(0))
2184 2261 .unwrap();
2185 - assert_eq!(initial_version, 31);
2262 + assert_eq!(initial_version, 32);
2186 2263
2187 2264 let batch = format!(
2188 2265 "BEGIN;\n{}\nPRAGMA user_version = 999;\nCOMMIT;",
@@ -2245,7 +2322,7 @@ mod tests {
2245 2322 .conn()
2246 2323 .query_row("PRAGMA user_version", [], |row| row.get(0))
2247 2324 .unwrap();
2248 - assert_eq!(version, 31);
2325 + assert_eq!(version, 32);
2249 2326 }
2250 2327
2251 2328 #[test]