max / audiofiles
4 files changed,
+194 insertions,
-14 deletions
| @@ -301,10 +301,12 @@ pub fn import(db: &Database, afcl: &Afcl, source: Option<&str>) -> Result<Import | |||
| 301 | 301 | .map_err(|e| CoreError::Serialization(e.to_string()))?; | |
| 302 | 302 | let tags = serde_json::to_string(&ex.tags) | |
| 303 | 303 | .map_err(|e| CoreError::Serialization(e.to_string()))?; | |
| 304 | + | // Globally-unique TEXT id (layer id is unique) so it survives cross-device sync. | |
| 305 | + | let ex_id = format!("{layer_id}#{exemplars}"); | |
| 304 | 306 | db.conn().execute( | |
| 305 | - | "INSERT INTO classifier_exemplars (layer_id, feat_version, vector, tags) | |
| 306 | - | VALUES (?1, ?2, ?3, ?4)", | |
| 307 | - | rusqlite::params![layer_id, FEATURE_VERSION, vector, tags], | |
| 307 | + | "INSERT INTO classifier_exemplars (id, layer_id, feat_version, vector, tags) | |
| 308 | + | VALUES (?1, ?2, ?3, ?4, ?5)", | |
| 309 | + | rusqlite::params![ex_id, layer_id, FEATURE_VERSION, vector, tags], | |
| 308 | 310 | )?; | |
| 309 | 311 | exemplars += 1; | |
| 310 | 312 | } | |
| @@ -389,11 +391,17 @@ pub fn remove_layer(db: &Database, layer_id: &str) -> Result<()> { | |||
| 389 | 391 | let rows = stmt.query_map([layer_id], |row| row.get::<_, String>(0))?; | |
| 390 | 392 | rows.collect::<std::result::Result<Vec<_>, _>>()? | |
| 391 | 393 | }; | |
| 392 | - | // delete_rule reconciles each rule's tags away (manual/local tags stay sticky). | |
| 394 | + | // Delete children explicitly (not via FK cascade): recursive_triggers is off, so cascade | |
| 395 | + | // deletes wouldn't fire the sync triggers and the removal wouldn't propagate to other | |
| 396 | + | // devices. Membership first, then the rules (delete_rule reconciles their tags away and | |
| 397 | + | // is sync-logged), then the exemplars, then the layer. | |
| 398 | + | db.conn() | |
| 399 | + | .execute("DELETE FROM classifier_layer_rules WHERE layer_id = ?1", [layer_id])?; | |
| 393 | 400 | for id in &rule_ids { | |
| 394 | 401 | rules::delete_rule(db, id)?; | |
| 395 | 402 | } | |
| 396 | - | // Cascades classifier_exemplars + classifier_layer_rules. | |
| 403 | + | db.conn() | |
| 404 | + | .execute("DELETE FROM classifier_exemplars WHERE layer_id = ?1", [layer_id])?; | |
| 397 | 405 | db.conn().execute("DELETE FROM classifier_layers WHERE id = ?1", [layer_id])?; | |
| 398 | 406 | Ok(()) | |
| 399 | 407 | } | |
| @@ -438,7 +446,7 @@ pub fn enabled_imported_exemplars(db: &Database) -> Result<Vec<ImportedExemplar> | |||
| 438 | 446 | )?; | |
| 439 | 447 | let rows = stmt.query_map([FEATURE_VERSION], |row| { | |
| 440 | 448 | Ok(( | |
| 441 | - | row.get::<_, i64>(0)?, | |
| 449 | + | row.get::<_, String>(0)?, | |
| 442 | 450 | row.get::<_, String>(1)?, | |
| 443 | 451 | row.get::<_, String>(2)?, | |
| 444 | 452 | row.get::<_, f64>(3)?, | |
| @@ -446,13 +454,13 @@ pub fn enabled_imported_exemplars(db: &Database) -> Result<Vec<ImportedExemplar> | |||
| 446 | 454 | })?; | |
| 447 | 455 | let mut out = Vec::new(); | |
| 448 | 456 | for r in rows { | |
| 449 | - | let (rowid, vec_json, tags_json, weight) = r?; | |
| 457 | + | let (id, vec_json, tags_json, weight) = r?; | |
| 450 | 458 | let vector: Vec<f64> = serde_json::from_str(&vec_json) | |
| 451 | 459 | .map_err(|e| CoreError::Serialization(e.to_string()))?; | |
| 452 | 460 | let tags: Vec<String> = serde_json::from_str(&tags_json) | |
| 453 | 461 | .map_err(|e| CoreError::Serialization(e.to_string()))?; | |
| 454 | 462 | if vector.len() == NUM_FEATURES { | |
| 455 | - | out.push(ImportedExemplar { id: format!("layer#{rowid}"), vector, tags, weight }); | |
| 463 | + | out.push(ImportedExemplar { id, vector, tags, weight }); | |
| 456 | 464 | } | |
| 457 | 465 | } | |
| 458 | 466 | Ok(out) |
| @@ -1345,6 +1345,78 @@ CREATE TABLE IF NOT EXISTS classifier_layer_rules ( | |||
| 1345 | 1345 | ); | |
| 1346 | 1346 | "#; | |
| 1347 | 1347 | ||
| 1348 | + | const MIGRATION_026: &str = r#" | |
| 1349 | + | -- Phase 7c: sync the classifier-layer tables across the user's own devices. | |
| 1350 | + | -- classifier_exemplars is recreated with a TEXT primary key: the M025 INTEGER autoincrement | |
| 1351 | + | -- id collides across devices (each device numbers from 1), which row-level sync can't | |
| 1352 | + | -- reconcile. Imported exemplars are re-importable, so dropping any M025 rows is acceptable. | |
| 1353 | + | DROP TABLE IF EXISTS classifier_exemplars; | |
| 1354 | + | CREATE TABLE classifier_exemplars ( | |
| 1355 | + | id TEXT PRIMARY KEY, -- globally unique ('<layer_id>#<n>') | |
| 1356 | + | layer_id TEXT NOT NULL REFERENCES classifier_layers(id) ON DELETE CASCADE, | |
| 1357 | + | feat_version INTEGER NOT NULL, | |
| 1358 | + | vector TEXT NOT NULL, | |
| 1359 | + | tags TEXT NOT NULL | |
| 1360 | + | ); | |
| 1361 | + | CREATE INDEX IF NOT EXISTS idx_classifier_exemplars_layer ON classifier_exemplars(layer_id); | |
| 1362 | + | ||
| 1363 | + | -- Sync triggers (opaque non-sensitive ids => cleartext row_id, like tag_rules/collections; | |
| 1364 | + | -- the JSON payload, which carries tag strings, is encrypted on the wire). recursive_triggers | |
| 1365 | + | -- is off, so FK cascades don't fire these — afcl::remove_layer deletes children explicitly. | |
| 1366 | + | CREATE TRIGGER IF NOT EXISTS sync_classifier_layers_insert AFTER INSERT ON classifier_layers | |
| 1367 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 1368 | + | BEGIN | |
| 1369 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 1370 | + | VALUES ('classifier_layers', 'INSERT', NEW.id, | |
| 1371 | + | json_object('id', NEW.id, 'name', NEW.name, 'kind', NEW.kind, 'weight', NEW.weight, | |
| 1372 | + | 'enabled', NEW.enabled, 'source', NEW.source, 'imported_at', NEW.imported_at)); | |
| 1373 | + | END; | |
| 1374 | + | CREATE TRIGGER IF NOT EXISTS sync_classifier_layers_update AFTER UPDATE ON classifier_layers | |
| 1375 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 1376 | + | BEGIN | |
| 1377 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 1378 | + | VALUES ('classifier_layers', 'UPDATE', NEW.id, | |
| 1379 | + | json_object('id', NEW.id, 'name', NEW.name, 'kind', NEW.kind, 'weight', NEW.weight, | |
| 1380 | + | 'enabled', NEW.enabled, 'source', NEW.source, 'imported_at', NEW.imported_at)); | |
| 1381 | + | END; | |
| 1382 | + | CREATE TRIGGER IF NOT EXISTS sync_classifier_layers_delete AFTER DELETE ON classifier_layers | |
| 1383 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 1384 | + | BEGIN | |
| 1385 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 1386 | + | VALUES ('classifier_layers', 'DELETE', OLD.id, json_object('id', OLD.id)); | |
| 1387 | + | END; | |
| 1388 | + | ||
| 1389 | + | CREATE TRIGGER IF NOT EXISTS sync_classifier_exemplars_insert AFTER INSERT ON classifier_exemplars | |
| 1390 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 1391 | + | BEGIN | |
| 1392 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 1393 | + | VALUES ('classifier_exemplars', 'INSERT', NEW.id, | |
| 1394 | + | json_object('id', NEW.id, 'layer_id', NEW.layer_id, 'feat_version', NEW.feat_version, | |
| 1395 | + | 'vector', NEW.vector, 'tags', NEW.tags)); | |
| 1396 | + | END; | |
| 1397 | + | CREATE TRIGGER IF NOT EXISTS sync_classifier_exemplars_delete AFTER DELETE ON classifier_exemplars | |
| 1398 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 1399 | + | BEGIN | |
| 1400 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 1401 | + | VALUES ('classifier_exemplars', 'DELETE', OLD.id, json_object('id', OLD.id)); | |
| 1402 | + | END; | |
| 1403 | + | ||
| 1404 | + | CREATE TRIGGER IF NOT EXISTS sync_classifier_layer_rules_insert AFTER INSERT ON classifier_layer_rules | |
| 1405 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 1406 | + | BEGIN | |
| 1407 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 1408 | + | VALUES ('classifier_layer_rules', 'INSERT', NEW.layer_id || ':' || NEW.rule_id, | |
| 1409 | + | json_object('layer_id', NEW.layer_id, 'rule_id', NEW.rule_id)); | |
| 1410 | + | END; | |
| 1411 | + | CREATE TRIGGER IF NOT EXISTS sync_classifier_layer_rules_delete AFTER DELETE ON classifier_layer_rules | |
| 1412 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 1413 | + | BEGIN | |
| 1414 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 1415 | + | VALUES ('classifier_layer_rules', 'DELETE', OLD.layer_id || ':' || OLD.rule_id, | |
| 1416 | + | json_object('layer_id', OLD.layer_id, 'rule_id', OLD.rule_id)); | |
| 1417 | + | END; | |
| 1418 | + | "#; | |
| 1419 | + | ||
| 1348 | 1420 | /// Register `hash_row_id(salt, key) -> TEXT` as a deterministic SQLite | |
| 1349 | 1421 | /// function on the given connection. Used by the M018 sync triggers so the | |
| 1350 | 1422 | /// `sync_changelog.row_id` field never carries cleartext content (tag strings, | |
| @@ -1450,6 +1522,7 @@ impl Database { | |||
| 1450 | 1522 | MIGRATION_023, | |
| 1451 | 1523 | MIGRATION_024, | |
| 1452 | 1524 | MIGRATION_025, | |
| 1525 | + | MIGRATION_026, | |
| 1453 | 1526 | ]; | |
| 1454 | 1527 | ||
| 1455 | 1528 | for (i, sql) in MIGRATIONS.iter().enumerate() { | |
| @@ -1634,7 +1707,7 @@ mod tests { | |||
| 1634 | 1707 | .conn() | |
| 1635 | 1708 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1636 | 1709 | .unwrap(); | |
| 1637 | - | assert_eq!(version, 25); | |
| 1710 | + | assert_eq!(version, 26); | |
| 1638 | 1711 | } | |
| 1639 | 1712 | ||
| 1640 | 1713 | #[test] | |
| @@ -1645,7 +1718,7 @@ mod tests { | |||
| 1645 | 1718 | .conn() | |
| 1646 | 1719 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1647 | 1720 | .unwrap(); | |
| 1648 | - | assert_eq!(version, 25); | |
| 1721 | + | assert_eq!(version, 26); | |
| 1649 | 1722 | } | |
| 1650 | 1723 | ||
| 1651 | 1724 | /// Open a fresh file-backed DB, close, reopen. The second open re-enters | |
| @@ -1664,7 +1737,7 @@ mod tests { | |||
| 1664 | 1737 | .conn() | |
| 1665 | 1738 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1666 | 1739 | .unwrap(); | |
| 1667 | - | assert_eq!(version, 25); | |
| 1740 | + | assert_eq!(version, 26); | |
| 1668 | 1741 | } | |
| 1669 | 1742 | ||
| 1670 | 1743 | /// Simulates the worst-case recovery path: a prior partial migration left | |
| @@ -1708,7 +1781,7 @@ mod tests { | |||
| 1708 | 1781 | .conn() | |
| 1709 | 1782 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1710 | 1783 | .unwrap(); | |
| 1711 | - | assert_eq!(version, 25); | |
| 1784 | + | assert_eq!(version, 26); | |
| 1712 | 1785 | } | |
| 1713 | 1786 | ||
| 1714 | 1787 | /// M018 contract: the `sync_changelog.row_id` for sensitive tables must | |
| @@ -1930,7 +2003,7 @@ mod tests { | |||
| 1930 | 2003 | let initial_version: i32 = conn | |
| 1931 | 2004 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1932 | 2005 | .unwrap(); | |
| 1933 | - | assert_eq!(initial_version, 25); | |
| 2006 | + | assert_eq!(initial_version, 26); | |
| 1934 | 2007 | ||
| 1935 | 2008 | let batch = format!( | |
| 1936 | 2009 | "BEGIN;\n{}\nPRAGMA user_version = 999;\nCOMMIT;", | |
| @@ -1993,7 +2066,7 @@ mod tests { | |||
| 1993 | 2066 | .conn() | |
| 1994 | 2067 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1995 | 2068 | .unwrap(); | |
| 1996 | - | assert_eq!(version, 25); | |
| 2069 | + | assert_eq!(version, 26); | |
| 1997 | 2070 | } | |
| 1998 | 2071 | ||
| 1999 | 2072 | #[test] |
| @@ -29,6 +29,9 @@ pub(crate) const UPSERT_ORDER: &[&str] = &[ | |||
| 29 | 29 | "tag_provenance", | |
| 30 | 30 | "tag_rules", | |
| 31 | 31 | "tag_policy", | |
| 32 | + | "classifier_layers", | |
| 33 | + | "classifier_exemplars", | |
| 34 | + | "classifier_layer_rules", | |
| 32 | 35 | "collection_members", | |
| 33 | 36 | "user_config", | |
| 34 | 37 | "edit_history", | |
| @@ -39,6 +42,9 @@ pub(crate) const DELETE_ORDER: &[&str] = &[ | |||
| 39 | 42 | "edit_history", | |
| 40 | 43 | "user_config", | |
| 41 | 44 | "collection_members", | |
| 45 | + | "classifier_layer_rules", | |
| 46 | + | "classifier_exemplars", | |
| 47 | + | "classifier_layers", | |
| 42 | 48 | "tag_policy", | |
| 43 | 49 | "tag_rules", | |
| 44 | 50 | "tag_provenance", | |
| @@ -85,6 +91,11 @@ pub(crate) fn table_columns(table: &str) -> Option<&'static [&'static str]> { | |||
| 85 | 91 | "actions", "created_at", | |
| 86 | 92 | ]), | |
| 87 | 93 | "tag_policy" => Some(&["tag", "review_threshold", "auto_threshold"]), | |
| 94 | + | "classifier_layers" => { | |
| 95 | + | Some(&["id", "name", "kind", "weight", "enabled", "source", "imported_at"]) | |
| 96 | + | } | |
| 97 | + | "classifier_exemplars" => Some(&["id", "layer_id", "feat_version", "vector", "tags"]), | |
| 98 | + | "classifier_layer_rules" => Some(&["layer_id", "rule_id"]), | |
| 88 | 99 | "collections" => Some(&["id", "name", "description", "created_at", "filter_json"]), | |
| 89 | 100 | "collection_members" => Some(&["collection_id", "sample_hash", "added_at"]), | |
| 90 | 101 | "user_config" => Some(&["key", "value"]), | |
| @@ -108,6 +119,9 @@ pub(crate) fn pk_columns(table: &str) -> &'static [&'static str] { | |||
| 108 | 119 | "tag_provenance" => &["sample_hash", "tag"], | |
| 109 | 120 | "tag_rules" => &["id"], | |
| 110 | 121 | "tag_policy" => &["tag"], | |
| 122 | + | "classifier_layers" => &["id"], | |
| 123 | + | "classifier_exemplars" => &["id"], | |
| 124 | + | "classifier_layer_rules" => &["layer_id", "rule_id"], | |
| 111 | 125 | "collections" => &["id"], | |
| 112 | 126 | "collection_members" => &["collection_id", "sample_hash"], | |
| 113 | 127 | "user_config" => &["key"], |
| @@ -32,6 +32,9 @@ pub fn create_initial_snapshot(conn: &Connection) -> Result<i64> { | |||
| 32 | 32 | ("tag_provenance", "SELECT sample_hash || ':' || tag, json_object('sample_hash', sample_hash, 'tag', tag, 'source', source, 'rule_id', rule_id) FROM tag_provenance"), | |
| 33 | 33 | ("tag_rules", "SELECT id, json_object('id', id, 'name', name, 'enabled', enabled, 'priority', priority, 'match_mode', match_mode, 'conditions', conditions, 'actions', actions, 'created_at', created_at) FROM tag_rules"), | |
| 34 | 34 | ("tag_policy", "SELECT tag, json_object('tag', tag, 'review_threshold', review_threshold, 'auto_threshold', auto_threshold) FROM tag_policy"), | |
| 35 | + | ("classifier_layers", "SELECT id, json_object('id', id, 'name', name, 'kind', kind, 'weight', weight, 'enabled', enabled, 'source', source, 'imported_at', imported_at) FROM classifier_layers"), | |
| 36 | + | ("classifier_exemplars", "SELECT id, json_object('id', id, 'layer_id', layer_id, 'feat_version', feat_version, 'vector', vector, 'tags', tags) FROM classifier_exemplars"), | |
| 37 | + | ("classifier_layer_rules", "SELECT layer_id || ':' || rule_id, json_object('layer_id', layer_id, 'rule_id', rule_id) FROM classifier_layer_rules"), | |
| 35 | 38 | ("collections", "SELECT CAST(id AS TEXT), json_object('id', id, 'name', name, 'description', description, 'created_at', created_at, 'filter_json', filter_json) FROM collections"), | |
| 36 | 39 | ("collection_members", "SELECT CAST(collection_id AS TEXT) || ':' || sample_hash, json_object('collection_id', collection_id, 'sample_hash', sample_hash, 'added_at', added_at) FROM collection_members"), | |
| 37 | 40 | ("user_config", "SELECT key, json_object('key', key, 'value', value) FROM user_config WHERE key NOT LIKE 'sync_%' AND key != 'loose_files'"), | |
| @@ -1540,4 +1543,86 @@ mod tests { | |||
| 1540 | 1543 | let changed = rules::apply_all_rules(&b).unwrap(); | |
| 1541 | 1544 | assert_eq!(changed, 0, "synced state already reconciled; re-apply is a no-op"); | |
| 1542 | 1545 | } | |
| 1546 | + | ||
| 1547 | + | /// Import an `.afcl` layer on device A, sync to fresh device B, and confirm the layer, | |
| 1548 | + | /// its exemplars (in the k-NN index), and its disabled rule all rebuild — then a layer | |
| 1549 | + | /// removal on A propagates the deletes to B. (Phase 7c.) | |
| 1550 | + | #[test] | |
| 1551 | + | fn imported_classifier_layer_syncs_to_fresh_device() { | |
| 1552 | + | use audiofiles_core::analysis::afcl::{self, Afcl, AfclExemplar, AfclManifest}; | |
| 1553 | + | use audiofiles_core::analysis::{classify::FEATURE_VERSION, exemplar}; | |
| 1554 | + | use audiofiles_core::rules::{MatchMode, Rule, RuleAction, RuleCondition, RuleField, RuleOp}; | |
| 1555 | + | ||
| 1556 | + | let make_afcl = || Afcl { | |
| 1557 | + | manifest: AfclManifest { | |
| 1558 | + | afcl_version: afcl::AFCL_VERSION, | |
| 1559 | + | feat_version: FEATURE_VERSION, | |
| 1560 | + | name: "shared kicks".into(), | |
| 1561 | + | description: String::new(), | |
| 1562 | + | kind: "imported".into(), | |
| 1563 | + | created_at: 0, | |
| 1564 | + | license_note: String::new(), | |
| 1565 | + | exemplar_count: 2, | |
| 1566 | + | rule_count: 1, | |
| 1567 | + | policy_count: 0, | |
| 1568 | + | }, | |
| 1569 | + | exemplars: vec![ | |
| 1570 | + | AfclExemplar { vector: vec![0.0; 35], tags: vec!["instrument.drum.kick".into()] }, | |
| 1571 | + | AfclExemplar { vector: vec![0.05; 35], tags: vec!["instrument.drum.kick".into()] }, | |
| 1572 | + | ], | |
| 1573 | + | rules: vec![Rule { | |
| 1574 | + | id: String::new(), | |
| 1575 | + | name: "shared kick rule".into(), | |
| 1576 | + | enabled: true, // import forces disabled | |
| 1577 | + | priority: 0, | |
| 1578 | + | match_mode: MatchMode::All, | |
| 1579 | + | conditions: vec![RuleCondition { | |
| 1580 | + | field: RuleField::Name, | |
| 1581 | + | op: RuleOp::Contains, | |
| 1582 | + | value: "kick".into(), | |
| 1583 | + | }], | |
| 1584 | + | actions: vec![RuleAction::AddTag("instrument.drum.kick".into())], | |
| 1585 | + | created_at: 0, | |
| 1586 | + | }], | |
| 1587 | + | policy: vec![], | |
| 1588 | + | }; | |
| 1589 | + | ||
| 1590 | + | // --- Device A: import the layer, then drain its changelog. --- | |
| 1591 | + | let a = setup_test_db(); | |
| 1592 | + | clear_changelog(a.conn()); | |
| 1593 | + | let summary = afcl::import(&a, &make_afcl(), Some("shared.afcl")).unwrap(); | |
| 1594 | + | let changes = drain_changelog(a.conn()); | |
| 1595 | + | ||
| 1596 | + | // --- Device B: fresh DB, apply the synced changes. --- | |
| 1597 | + | let b = setup_test_db(); | |
| 1598 | + | clear_changelog(b.conn()); | |
| 1599 | + | apply_remote_changes(b.conn(), &changes).unwrap(); | |
| 1600 | + | ||
| 1601 | + | // Layer, exemplars, and membership rebuilt. | |
| 1602 | + | let layers = afcl::list_layers(&b).unwrap(); | |
| 1603 | + | assert_eq!(layers.len(), 1); | |
| 1604 | + | assert_eq!(layers[0].id, summary.layer_id); | |
| 1605 | + | assert_eq!(layers[0].exemplar_count, 2); | |
| 1606 | + | assert_eq!(layers[0].rule_count, 1); | |
| 1607 | + | // The imported exemplars join B's k-NN index. | |
| 1608 | + | assert_eq!(afcl::enabled_imported_exemplars(&b).unwrap().len(), 2); | |
| 1609 | + | assert_eq!(exemplar::build_index(&b).unwrap().len(), 2); | |
| 1610 | + | // The imported rule synced and is disabled. | |
| 1611 | + | let synced = audiofiles_core::rules::list_rules(&b).unwrap(); | |
| 1612 | + | assert_eq!(synced.len(), 1); | |
| 1613 | + | assert!(!synced[0].enabled, "imported rule stays disabled across sync"); | |
| 1614 | + | ||
| 1615 | + | // --- Removal on A propagates to B (explicit child deletes are sync-logged). --- | |
| 1616 | + | clear_changelog(a.conn()); | |
| 1617 | + | afcl::remove_layer(&a, &summary.layer_id).unwrap(); | |
| 1618 | + | let del_changes = drain_changelog(a.conn()); | |
| 1619 | + | apply_remote_changes(b.conn(), &del_changes).unwrap(); | |
| 1620 | + | ||
| 1621 | + | assert!(afcl::list_layers(&b).unwrap().is_empty(), "layer removed on B"); | |
| 1622 | + | assert!(afcl::enabled_imported_exemplars(&b).unwrap().is_empty()); | |
| 1623 | + | assert!( | |
| 1624 | + | audiofiles_core::rules::list_rules(&b).unwrap().is_empty(), | |
| 1625 | + | "the layer's imported rule was removed on B too" | |
| 1626 | + | ); | |
| 1627 | + | } | |
| 1543 | 1628 | } |