| 193 |
193 |
|
|
| 194 |
194 |
|
Production uses a file-backed SQLite database. Tests use `:memory:`.
|
| 195 |
195 |
|
|
|
196 |
+ |
When adding a migration, make it **replay-safe**: every `CREATE TABLE / INDEX / TRIGGER` should be `IF NOT EXISTS` (or preceded by `DROP IF EXISTS` for triggers whose body changes), and any seed insert should be `INSERT OR IGNORE`. The `migration_replay_from_version_two_against_full_schema` test in `db.rs` rolls `user_version` back to 2 and re-runs every migration from M003 onward against a populated schema — non-idempotent CREATEs fail it. M001 (initial schema) and M002 (`DROP TABLE tags; ALTER tags_v2 RENAME TO tags`) are inherently one-shot and excluded from the replay test.
|
|
197 |
+ |
|
|
198 |
+ |
The connection registers a custom `hash_row_id(salt, key)` SQLite function on open (rusqlite `functions` feature). It's used by the M018 sync triggers; if you write a migration that creates new sync triggers, prefer it for any row_id that would otherwise leak user content.
|
|
199 |
+ |
|
| 196 |
200 |
|
### Sync Changelog Triggers
|
| 197 |
201 |
|
|
| 198 |
202 |
|
Every synced table has triggers that insert into `sync_changelog` on INSERT/UPDATE/DELETE. A `sync_state` row (`applying_remote = '1'`) suppresses triggers during pull operations to prevent recursion.
|
| 199 |
203 |
|
|
|
204 |
+ |
Per migration M018 (2026-06-02), `sync_changelog.row_id` is hashed via `hash_row_id(row_id_salt, canonical_key)` for sensitive tables (samples, audio_analysis, tags, collection_members) so the server never sees raw sample hashes or tag strings. The salt is generated per device, stored in `sync_state`, never synced. DELETE triggers also emit the canonical PK in the encrypted `data` field, which `resolve::apply_delete` reads to reconstruct WHERE clauses without parsing the (now-opaque) row_id. When adding a new synced table, follow the same pattern: wrap row_id in `hash_row_id(...)` if it carries user content, and emit the canonical PK into `data` for DELETE.
|
|
205 |
+ |
|
| 200 |
206 |
|
### rusqlite + async
|
| 201 |
207 |
|
|
| 202 |
208 |
|
`rusqlite::Connection` is `!Send`. In the sync crate (which uses tokio), all database operations go through `tokio::task::spawn_blocking`. In core (sync-only), no async runtime is needed.
|
| 205 |
211 |
|
|
| 206 |
212 |
|
Cloud sync is optional. The `SyncManager` coordinates push/pull:
|
| 207 |
213 |
|
|
| 208 |
|
- |
- **Tables synced** (in FK-safe order): `vfs`, `samples`, `collections`, `vfs_nodes`, `audio_analysis`, `tags`, `collection_members`, `smart_folders`
|
|
214 |
+ |
- **Tables synced** (in FK-safe order): `vfs`, `samples`, `collections`, `vfs_nodes`, `audio_analysis`, `tags`, `collection_members`, `user_config`, `edit_history` (smart_folders merged into `collections.filter_json` in M015 and the standalone table dropped)
|
| 209 |
215 |
|
- **Delete order** is reversed (children first)
|
| 210 |
216 |
|
- **Column whitelist:** `table_columns()` restricts which columns sync to prevent schema drift
|
| 211 |
217 |
|
- **Blob sync:** Sample files sync to cloud storage for VFS entries with `sync_files = true`
|