diff --git a/desktop/src-tauri/src/archive/retention.rs b/desktop/src-tauri/src/archive/retention.rs index 618bfbbb6..ef809468c 100644 --- a/desktop/src-tauri/src/archive/retention.rs +++ b/desktop/src-tauri/src/archive/retention.rs @@ -139,8 +139,9 @@ fn table_shape_matches( /// Whether the scope-age index exists on `archived_event_scopes` with exactly /// the expected key columns in order. False when the index is absent, sits on -/// the wrong table, or indexes the wrong columns — all of which M4 repairs by -/// dropping and recreating it (an index carries no data, so a rebuild is safe). +/// the wrong table, indexes the wrong columns, or is partial (carries a `WHERE` +/// predicate) — all of which M4 repairs by dropping and recreating it (an index +/// carries no data, so a rebuild is safe). pub(super) fn scope_age_index_is_correct(conn: &Connection) -> Result { let table: Option = conn .query_row( @@ -154,6 +155,27 @@ pub(super) fn scope_age_index_is_correct(conn: &Connection) -> Result(0), + ) + .optional() + .map_err(|e| format!("shape check: scope-age index partiality: {e}"))? + .map(|p| p != 0) + .unwrap_or(true); + if is_partial { + return Ok(false); + } + let mut stmt = conn .prepare(&format!( "SELECT name FROM pragma_index_info('{SCOPE_AGE_INDEX_NAME}') ORDER BY seqno" diff --git a/desktop/src-tauri/src/archive/retention_tests.rs b/desktop/src-tauri/src/archive/retention_tests.rs index 723ee9ae2..6ad2c167e 100644 --- a/desktop/src-tauri/src/archive/retention_tests.rs +++ b/desktop/src-tauri/src/archive/retention_tests.rs @@ -466,6 +466,50 @@ fn test_m4_wrong_index_order_dropped_and_rebuilt() { ); } +#[test] +fn test_m4_partial_age_index_dropped_and_rebuilt_non_partial() { + let db = NamedTempFile::new().unwrap(); + build_pre_m4_db(db.path()); + // Precreate the scope-age index with the expected NAME, right table, and + // EXACT key order — but a `WHERE` predicate making it PARTIAL. It holds the + // right columns, so `pragma_index_info` (key columns only) cannot tell it + // apart from the required index; only the partiality probe catches it. A + // partial index cannot serve the unrestricted prune-age range scan, so M4 + // must treat it like any other wrong shape and rebuild it non-partial. + { + let conn = Connection::open(db.path()).unwrap(); + conn.execute_batch(&format!( + "CREATE INDEX {SCOPE_AGE_INDEX_NAME} + ON archived_event_scopes + (identity_pubkey, relay_url, scope_type, scope_value, archived_at, id) + WHERE archived_at > 1000;" + )) + .unwrap(); + // The validator must reject the partial index BEFORE M4 runs — this is + // the exact check that fails without the partiality probe. + assert!( + !scope_age_index_is_correct(&conn).unwrap(), + "a partial index with correct columns must not be certified" + ); + } + // M4 rebuilds the partial index into a non-partial one, then the marker lands. + let conn = fresh(&db); + assert_eq!(m4_marker_count(&conn), 1, "M4 completes after the rebuild"); + assert!( + scope_age_index_is_correct(&conn).unwrap(), + "the index was rebuilt non-partial with the correct key order" + ); + // Prove the rebuilt index carries no `WHERE` predicate. + let partial: i64 = conn + .query_row( + "SELECT partial FROM pragma_index_list('archived_event_scopes') WHERE name = ?1", + params![SCOPE_AGE_INDEX_NAME], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(partial, 0, "the rebuilt index must not be partial"); +} + // ── Concurrency ─────────────────────────────────────────────────────────────── #[test]