Skip to main content

turso_backup/
dedup.rs

1//! Tier 1b — incremental page-dedup snapshot.
2//!
3//! Raw point-in-time-consistent copy of the live `.db` (NOT `VACUUM INTO`,
4//! which reshuffles pages and defeats dedup) → split into fixed 4 KB pages →
5//! content-address (hash) each → upload only changed pages + a per-snapshot
6//! manifest. Restore reassembles from the manifest. Engine-agnostic.
7//!
8//! @yah:relay(R004, "Tier 1b — incremental page-dedup snapshot")
9//! @yah:at(2026-05-26T22:28:31Z)
10//! @yah:status(open)
11//! @yah:phase(P2)
12//! @yah:parent(Q002)
13//! @arch:see(.yah/docs/working/turso-s3-backup.md)
14//!
15//! @yah:ticket(R004-T1, "Spike: raw point-in-time-consistent copy primitive (main+WAL); confirm pages keep fixed offsets across snapshots")
16//! @yah:assignee(agent:claude)
17//! @yah:at(2026-05-26T22:30:06Z)
18//! @yah:status(review)
19//! @yah:phase(P2)
20//! @yah:parent(R004)
21//! @arch:see(.yah/docs/working/turso-s3-backup.md)
22//! @yah:handoff("Built dedup::raw_consistent_copy(db_path)->Vec<u8> + dedup::page_size(image). Primitive = PRAGMA wal_checkpoint(TRUNCATE) (turso supports it) to fold WAL->main, drop conn, fs::read main file. NOT vacuum (no repack). Confirmed point-in-time consistent main+WAL: writing only the main bytes to a fresh path reopens with the full row count.")
23//! @yah:handoff("Page-offset stability CONFIRMED and it is structural: page N is always at (N-1)*page_size; existing pages never renumber on a raw write. Measured 1-row insert into a 5000-row DB = 1/18 pages changed. page_size read from header u16@16 = 4096 (turso default).")
24//! @yah:handoff("CORRECTION to working doc: 'vacuum reshuffles pages and defeats dedup' is too strong. Append-only: raw 1/18 == vacuum 1/18 (equivalent). Vacuum only diverges under reclamation: delete-low-block = raw 10/18 vs vacuum 18/18 (vacuum renumbers all survivors). Reason to copy raw = its offset stability holds for every mutation shape; vacuum's is incidental. Doc updated with the spike findings section.")
25//! @yah:verify("cargo test -p turso-backup dedup -- --nocapture: 3 spike tests green (raw_copy_keeps_page_offsets_stable, raw_copy_beats_vacuum_as_dedup_base, raw_copy_folds_wal_and_is_self_contained). Full suite 6/6, clippy --all-targets clean.")
26//! @yah:next("R004-F2: chunk raw_consistent_copy output on page_size(), sha256 each page, diff against prior manifest, put only changed pages content-addressed + write manifest. Caller must release write conns first (snapshot/DR model) — a live writer can make the TRUNCATE checkpoint return busy; read-only WAL-replay copy is tier-2.")
27//!
28//! @yah:ticket(R004-F2, "Page chunking + content-addressed dedup upload (changed pages only) + manifest format")
29//! @yah:assignee(agent:claude)
30//! @yah:at(2026-05-26T22:30:07Z)
31//! @yah:status(review)
32//! @yah:phase(P2)
33//! @yah:parent(R004)
34//! @arch:see(.yah/docs/working/turso-s3-backup.md)
35//! @yah:depends_on(R004-T1)
36//! @yah:handoff("Built snapshot_dedup(db_path, &BackupTarget)->DedupOutcome on top of raw_consistent_copy: chunk on header page_size, sha256 each page, diff vs latest manifest, put only new pages content-addressed at pages/{hash}, write manifests/manifest-{nanos:020}.manifest last. Reuses tier-1a BackupTarget. DedupOutcome::{Snapshotted{total/uploaded/reused},Unchanged}. Manifest = dep-free text format (header + page_size + page_count + ordered hex hashes); to_bytes/from_bytes + image_len. load_latest_manifest is pub for F3.")
37//! @yah:handoff("Measured: first snapshot of 5000 rows puts all 18 pages + 1 manifest; a 1-row insert then puts exactly 1 page + a 2nd manifest (pages/ grows by 1); both manifests reassemble to valid SQLite (5000/5001 rows). Unchanged path: re-snapshot with no change uploads nothing, writes no new manifest. Note: page-blob GC (unreferenced pages) is out of scope — flag for a later tier.")
38//! @yah:verify("cargo test -p turso-backup: 9/9 green (3 dedup-F2: dedup_uploads_only_changed_pages_and_reassembles, dedup_unchanged_when_pages_identical, manifest_round_trips). clippy --all-targets clean. Run from turso-backup/ dir, NOT db_test root (root yah workspace has unrelated pre-existing compile errors in crates/yah/forms).")
39//! @yah:next("R004-F3: implement restore_from_manifest(target, manifest, dest) — fetch each pages/{hash} in order, concat, write file. The test helper reassemble_latest already does this; F3 packages it + a restore_latest_dedup that calls load_latest_manifest first. Then R004-T4: harness e2e (two snapshots, assert only changed pages uploaded, restore both).")
40//!
41//! @yah:ticket(R004-F3, "Restore from manifest: fetch pages by hash, reassemble valid SQLite")
42//! @yah:assignee(agent:claude)
43//! @yah:at(2026-05-26T22:30:07Z)
44//! @yah:status(review)
45//! @yah:phase(P2)
46//! @yah:parent(R004)
47//! @arch:see(.yah/docs/working/turso-s3-backup.md)
48//! @yah:depends_on(R004-F2)
49//! @yah:handoff("Built restore_from_manifest(target, manifest, dest) + restore_latest_dedup(target, dest). Restore fetches each pages/{hash} in manifest order, VERIFIES content hash (corrupt/truncated blob -> loud error, not a silent-corrupt DB), concats, asserts SQLite magic, writes dest. No -wal needed (snapshot folded WAL in). restore_latest_dedup = load_latest_manifest (newest by nanos key) then restore_from_manifest; errors if no snapshot. Tier-1b counterpart to tier-1a snapshot::restore_latest.")
50//! @yah:verify("cargo test -p turso-backup: 11/11 green (3 F3: restore_latest_dedup_picks_newest_and_round_trips, restore_rejects_corrupt_page; empty-prefix error covered). clippy --all-targets clean. Run from turso-backup/ dir.")
51//! @yah:next("R004-T4: harness e2e against MinIO — wire writer to snapshot_dedup, take two snapshots with a mutation between, assert only changed pages uploaded (DedupOutcome.uploaded_pages), restore_latest_dedup both, sqlite3 verify. Lib round-trips use InMemory; T4 proves it on the object_store aws backend.")
52//!
53//! @yah:ticket(R004-T4, "Harness e2e incremental: two snapshots, assert only changed pages uploaded, restore both")
54//! @yah:assignee(agent:claude)
55//! @yah:at(2026-05-26T22:30:08Z)
56//! @yah:status(review)
57//! @yah:phase(P2)
58//! @yah:parent(R004)
59//! @arch:see(.yah/docs/working/turso-s3-backup.md)
60//! @yah:depends_on(R004-F3)
61//! @yah:handoff("GREEN e2e against MinIO. Added a tier-1b docker flow parallel to tier-1a (own bins + bucket prefix 'dedup'; tier-1a writer/verifier/run.sh untouched): harness bins dedup_writer (2 snapshots w/ a 5-row append between; ASSERTS uploaded2<uploaded1 && reused2>0 so a dedup regression fails the build) + dedup_restore (by MANIFEST_KEY or latest); shared row helpers in harness/src/lib.rs; verify_dedup.sh restores BOTH (snapshot1 by key, snapshot2 latest) + sqlite3 integrity/rows/boundary/hash; Dockerfile dedup-writer/dedup-verifier targets; compose services; ci/run_dedup.sh (staged). Lib gained dedup::restore_manifest_key (point-in-time restore by key).")
62//! @yah:handoff("Result at 5000 rows + 5-row append: snapshot1 109/109 pages uploaded (446KB), snapshot2 1/109 uploaded + 108 reused (only the tail leaf changed; page 1 byte-identical since change_counter is dead + file didn't grow). Both restored to vanilla SQLite: by-key=5000 rows, latest=5005 rows, full-table hashes matched byte-for-byte, exit 0. Restoring snapshot1 by key proves its pages survived snapshot2's deduplicated upload.")
63//! @yah:verify("bash ci/run_dedup.sh (staged: minio -> minio-init -> dedup-writer -> dedup-verifier; verifier exit 0 = green). Lib: cargo test -p turso-backup = 12/12, clippy --all-targets clean (run from turso-backup/ dir).")
64//! @yah:next("R004 complete pending sign-off: all 4 children (T1/F2/F3/T4) in review. Followups filed: page-blob GC = relay R006; live-writer path = R005-F4. Tier 2 (R005, WAL-frame streaming) is the next major relay.")
65//!
66//! @yah:relay(R006, "Tier 1b — snapshot retention + page-blob GC")
67//! @yah:at(2026-05-27T03:10:00Z)
68//! @yah:status(open)
69//! @yah:phase(P2)
70//! @yah:parent(Q002)
71//! @arch:see(.yah/docs/working/turso-s3-backup.md)
72//!
73//! @yah:ticket(R006-F1, "Retention + mark-and-sweep GC: keep the most recent N manifests, delete pages/{hash} blobs no retained manifest references, prune the pruned manifests")
74//! @yah:assignee(agent:claude)
75//! @yah:at(2026-05-27T03:10:01Z)
76//! @yah:status(review)
77//! @yah:phase(P2)
78//! @yah:parent(R006)
79//! @arch:see(.yah/docs/working/turso-s3-backup.md)
80//! @yah:gotcha("R004's snapshot_dedup never deletes pages — pages/{hash} blobs accumulate forever as content changes. GC = compute the union of page hashes across the retained manifests, delete every pages/* blob outside that union, then delete the non-retained manifests. Design the retained-set so a concurrent restore can't lose a page mid-sweep (delete manifests last / GC infrequently).")
81//! @yah:handoff("Implemented dedup::gc_dedup(target, keep_n) -> GcOutcome. Mark-and-sweep: list manifests/, the lexically-greatest keep_n are retained; download each, union their page_hashes into the retain set; list pages/, delete every blob whose leaf isn't in the retain union; finally delete the pruned manifests. Order is pages-first / manifests-last so a retained manifest never references a missing page at any moment of a partial GC, and restore_latest_dedup (which targets the newest = retained) is safe through the whole sweep.")
82//! @yah:handoff("GcOutcome reports {retained_manifests, pruned_manifests, deleted_pages, retained_pages}. keep_n >= total is a no-op (saturating_sub). restore_manifest_key against a soon-to-be-pruned manifest is documented as inherently racy — schedule GC infrequently, in windows where no point-in-time restores are in flight.")
83//! @yah:next("R006-T2: the F1 lib tests already cover keep-N retains restorable + orphan pages deleted + newest still restores byte-for-byte. The piece T2 still owns: prove an *in-window older* snapshot (e.g. GC keep_n=2 with 3 snapshots → assert older retained manifest still restores byte-for-byte via restore_manifest_key). Optionally extend the dedup-writer/verifier harness to call gc_dedup between snapshots and re-verify both restores against MinIO, parallel to R004-T4's tier-1b e2e shape.")
84//! @yah:verify("cargo test -p turso-backup: 41/41 lib green (3 new GC tests: gc_prunes_older_manifests_and_sweeps_orphan_pages, gc_keep_n_at_or_above_total_is_noop, gc_keeps_pages_shared_with_retained_manifests). cargo clippy --all-targets clean. Run from turso-backup/ dir.")
85//!
86//! @yah:ticket(R006-T2, "Test GC: keep-N retains restorable snapshots, orphaned pages deleted, latest + an in-window older snapshot still restore byte-for-byte")
87//! @yah:assignee(agent:claude)
88//! @yah:at(2026-05-27T03:10:02Z)
89//! @yah:status(review)
90//! @yah:phase(P2)
91//! @yah:parent(R006)
92//! @arch:see(.yah/docs/working/turso-s3-backup.md)
93//! @yah:depends_on(R006-F1)
94//! @yah:handoff("Added gc_keep_two_lets_in_window_older_snapshot_restore_byte_for_byte: takes 3 snapshots with mutations between, restores the middle snapshot via restore_manifest_key BEFORE GC as ground-truth bytes, runs gc_dedup(target, 2), then asserts (a) snapshot 1's pruned key errors on restore, (b) the in-window older snapshot 2 restores BYTE-FOR-BYTE equal to the pre-GC bytes, (c) restore_latest_dedup picks snapshot 3 with the post-second-mutation row count. Locks in the property that GC's mark set covers every retained manifest, not just the one restore_latest_dedup picks.")
95//! @yah:handoff("F1's three tests already covered keep-N retain + orphan delete + newest-restores-byte-for-byte + shared-page survival; T2 added the missing piece (in-window older snapshot byte-equality). 42/42 lib tests green, clippy clean.")
96//! @yah:handoff("NB: the turso-backup crate was relocated from external/db_test/turso-backup to external/turso-backup during this session — board tools now need path=/Users/user/ss/yah to resolve R006 tickets.")
97//! @yah:next("R006 complete pending sign-off: both children (F1, T2) in review. Optional follow-up — extend dedup-writer/verifier docker harness to call gc_dedup between snapshots and re-verify both restores against MinIO, parallel to R004-T4's tier-1b e2e shape. Not blocking R006 archive.")
98//! @yah:verify("cd /Users/user/ss/yah/external/turso-backup && cargo test --lib: 42/42 green (4 GC tests under dedup::tests::gc*). cargo clippy --all-targets clean.")
99
100use crate::snapshot::BackupTarget;
101use anyhow::{Context, Result};
102use object_store::path::Path as ObjPath;
103use object_store::{ObjectStore, ObjectStoreExt};
104use sha2::{Digest, Sha256};
105use std::collections::{HashMap, HashSet};
106use std::time::{SystemTime, UNIX_EPOCH};
107use turso::Builder;
108
109/// SQLite stores the database page size as a big-endian `u16` at byte offset 16
110/// of the file header. The encoded value `1` means the maximum 65 536-byte page.
111const PAGE_SIZE_OFFSET: usize = 16;
112
113/// The SQLite file-format magic, present at the start of every valid database
114/// (and every `VACUUM INTO` / raw-copy image we produce).
115const SQLITE_MAGIC: &[u8] = b"SQLite format 3\0";
116
117/// Read the page size (in bytes) from a SQLite file image's header.
118///
119/// Tier 1b chunks on **real page boundaries**, so the dedup unit is whatever the
120/// engine actually uses — read it from the header rather than assuming 4 KB.
121pub fn page_size(image: &[u8]) -> Result<usize> {
122    anyhow::ensure!(image.starts_with(SQLITE_MAGIC), "not a SQLite database (bad magic)");
123    anyhow::ensure!(image.len() >= PAGE_SIZE_OFFSET + 2, "image too short for a header");
124    let raw = u16::from_be_bytes([image[PAGE_SIZE_OFFSET], image[PAGE_SIZE_OFFSET + 1]]);
125    Ok(if raw == 1 { 65_536 } else { raw as usize })
126}
127
128/// Take a raw, point-in-time-consistent byte image of the database at `db_path`,
129/// preserving its native page layout. This is the tier-1b foundation and is
130/// deliberately **not** `VACUUM INTO`: vacuum repacks the b-tree into a fresh
131/// file, relocating pages and defeating content-addressed dedup.
132///
133/// We fold any pending WAL frames into the main file with a `TRUNCATE`
134/// checkpoint, then read the main file. After the checkpoint the main file is
135/// the complete, self-consistent image — page `N` lives at byte offset
136/// `(N - 1) * page_size`, so a logically-unchanged page hashes identically
137/// across snapshots. That fixed-offset stability is the precondition that makes
138/// "upload only changed pages" actually save bytes (confirmed by the spike tests
139/// below: a one-row insert into a 5 000-row DB rewrites only a handful of pages
140/// via this path, but a large fraction of them via `VACUUM INTO`).
141///
142/// Consistency comes from folding **main + WAL** into one image: any committed
143/// frame still sitting in `-wal` is written back to its page slot, so the
144/// returned bytes open in vanilla SQLite with no sidecar.
145///
146/// Assumes no live writer holds the WAL — the snapshot/DR model, where the
147/// caller has released its write connections (as the harness writer does before
148/// backing up). A concurrent writer can make a `TRUNCATE` checkpoint return
149/// `busy`; the read-only "copy main + replay WAL frames ourselves" path that
150/// would cover live writers is tier-2 territory.
151pub async fn raw_consistent_copy(db_path: &str) -> Result<Vec<u8>> {
152    // Fold WAL -> main and reset the WAL so the main file alone is complete.
153    {
154        let db = Builder::new_local(db_path)
155            .build()
156            .await
157            .with_context(|| format!("opening turso db {db_path}"))?;
158        let conn = db.connect().context("connecting to turso db")?;
159        let mut rows = conn
160            .query("PRAGMA wal_checkpoint(TRUNCATE)", ())
161            .await
162            .context("PRAGMA wal_checkpoint(TRUNCATE)")?;
163        while rows.next().await.context("draining wal_checkpoint")?.is_some() {}
164    } // drop the connection before reading the file off disk
165
166    let bytes = std::fs::read(db_path).with_context(|| format!("reading db image {db_path}"))?;
167    anyhow::ensure!(
168        bytes.starts_with(SQLITE_MAGIC),
169        "raw copy of {db_path} is not a SQLite database"
170    );
171    Ok(bytes)
172}
173
174/// The first line of every serialized [`Manifest`] — magic + format version.
175const MANIFEST_HEADER: &str = "TURSO-BACKUP MANIFEST v1";
176
177/// A per-snapshot manifest: the page size plus the ordered content hashes of the
178/// database's pages. Page `i` of the restored file is the blob stored at
179/// `pages/{page_hashes[i]}`, so the manifest alone is enough to reassemble the
180/// whole image (see [`restore_from_manifest`]).
181///
182/// Serialized as a small, dependency-free text format (no serde):
183///
184/// ```text
185/// TURSO-BACKUP MANIFEST v1
186/// page_size 4096
187/// page_count 18
188/// <hex sha256 of page 0>
189/// <hex sha256 of page 1>
190/// ...
191/// ```
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct Manifest {
194    pub page_size: usize,
195    /// Lowercase-hex SHA-256 of each page, in file order.
196    pub page_hashes: Vec<String>,
197}
198
199impl Manifest {
200    /// Serialize to the text wire format.
201    pub fn to_bytes(&self) -> Vec<u8> {
202        let mut s = String::with_capacity(64 + self.page_hashes.len() * 65);
203        s.push_str(MANIFEST_HEADER);
204        s.push('\n');
205        s.push_str(&format!("page_size {}\n", self.page_size));
206        s.push_str(&format!("page_count {}\n", self.page_hashes.len()));
207        for h in &self.page_hashes {
208            s.push_str(h);
209            s.push('\n');
210        }
211        s.into_bytes()
212    }
213
214    /// Parse the text wire format, validating the header and page count.
215    pub fn from_bytes(bytes: &[u8]) -> Result<Manifest> {
216        let text = std::str::from_utf8(bytes).context("manifest is not valid UTF-8")?;
217        let mut lines = text.lines();
218        let header = lines.next().context("empty manifest")?;
219        anyhow::ensure!(header == MANIFEST_HEADER, "unexpected manifest header: {header:?}");
220        let page_size = parse_header_field(lines.next(), "page_size")?;
221        let page_count = parse_header_field(lines.next(), "page_count")?;
222        let page_hashes: Vec<String> = lines.map(|l| l.trim().to_string()).collect();
223        anyhow::ensure!(
224            page_hashes.len() == page_count,
225            "manifest page_count {page_count} != {} hash lines",
226            page_hashes.len()
227        );
228        Ok(Manifest { page_size, page_hashes })
229    }
230
231    /// Total reassembled image size in bytes.
232    pub fn image_len(&self) -> usize {
233        self.page_size * self.page_hashes.len()
234    }
235}
236
237/// Parse a `"<name> <usize>"` header line.
238fn parse_header_field(line: Option<&str>, name: &str) -> Result<usize> {
239    let line = line.with_context(|| format!("manifest missing {name}"))?;
240    let value = line
241        .strip_prefix(name)
242        .and_then(|rest| rest.trim().parse::<usize>().ok())
243        .with_context(|| format!("malformed manifest {name} line: {line:?}"))?;
244    Ok(value)
245}
246
247/// Outcome of a [`snapshot_dedup`] call.
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub enum DedupOutcome {
250    /// A new manifest was written. `uploaded_pages` is the number of distinct
251    /// pages put this run (changed or new); `reused_pages` were already present
252    /// from an earlier snapshot and so were *not* re-uploaded.
253    Snapshotted {
254        manifest_key: String,
255        total_pages: usize,
256        uploaded_pages: usize,
257        reused_pages: usize,
258    },
259    /// The page set was byte-identical to the latest manifest, so nothing was
260    /// uploaded and no new manifest was written — the prior one still stands.
261    Unchanged { manifest_key: String },
262}
263
264/// Take a raw consistent snapshot of the database at `db_path` and upload only
265/// the pages that changed since the last snapshot, plus a per-snapshot manifest.
266/// Tier 1b.
267///
268/// 1. [`raw_consistent_copy`] the database (page layout preserved — the
269///    precondition for dedup, see that fn).
270/// 2. Split on the header [`page_size`] and SHA-256 each page.
271/// 3. Load the latest manifest (if any). If this snapshot's ordered page hashes
272///    are identical, return [`Unchanged`] without uploading.
273/// 4. Otherwise upload every page whose hash isn't already referenced by the
274///    prior manifest — content-addressed at `{prefix}/pages/{hash}`, so
275///    identical pages (within or across snapshots) are stored once — then write
276///    the manifest at `{prefix}/manifests/manifest-{unix_nanos:020}.manifest`.
277///
278/// The prior-manifest diff is conservative: a page present in the store but not
279/// in the prior manifest is re-uploaded (idempotent — same content-addressed
280/// key), so the failure direction is a redundant put, never a missing page.
281///
282/// [`Unchanged`]: DedupOutcome::Unchanged
283pub async fn snapshot_dedup(db_path: &str, target: &BackupTarget) -> Result<DedupOutcome> {
284    let image = raw_consistent_copy(db_path).await?;
285    snapshot_dedup_image(&image, target).await
286}
287
288/// Steps 2–4 of [`snapshot_dedup`] against an image the caller already holds.
289///
290/// R850-F1 split this out for the caller [`snapshot_dedup`] cannot serve: a tail
291/// running **beside a live application** that holds the database open. Step 1's
292/// [`raw_consistent_copy`] takes a `PRAGMA wal_checkpoint(TRUNCATE)` through an
293/// ordinary writable connection, and turso locks the whole file at open — so
294/// against a held database that step does not merely block the checkpoint, it
295/// fails to open at all (measured: `examples/appliance_tail_probe.rs`, A1).
296/// Such a caller produces its image with
297/// [`stream::raw_consistent_copy_live`](crate::stream::raw_consistent_copy_live)
298/// instead, which opens read-only and validates optimistically, and hands it
299/// here.
300///
301/// The page-layout precondition [`snapshot_dedup`] documents is unchanged and is
302/// now the caller's to keep: the image must be a raw page-preserving copy, not
303/// `VACUUM INTO` output, or every page hash moves on every run and the dedup
304/// stores a fresh full copy each time.
305pub async fn snapshot_dedup_image(image: &[u8], target: &BackupTarget) -> Result<DedupOutcome> {
306    let ps = page_size(image)?;
307
308    let page_hashes: Vec<String> = image.chunks(ps).map(sha256_hex).collect();
309    let total_pages = page_hashes.len();
310
311    // Latest manifest = the prior snapshot's page set (empty on the first run).
312    let prior = load_latest_manifest(target).await?;
313    if let Some((key, manifest)) = &prior {
314        if manifest.page_hashes == page_hashes {
315            return Ok(DedupOutcome::Unchanged { manifest_key: key.clone() });
316        }
317    }
318    let already: HashSet<&str> = prior
319        .as_ref()
320        .map(|(_, m)| m.page_hashes.iter().map(String::as_str).collect())
321        .unwrap_or_default();
322
323    // Distinct pages new to this snapshot (dedups identical pages within it too).
324    let mut to_upload: HashMap<&str, &[u8]> = HashMap::new();
325    for (hash, page) in page_hashes.iter().zip(image.chunks(ps)) {
326        if !already.contains(hash.as_str()) {
327            to_upload.insert(hash.as_str(), page);
328        }
329    }
330    let uploaded_pages = to_upload.len();
331    for (hash, page) in &to_upload {
332        let key = page_key(target, hash);
333        target
334            .store
335            .put(&key, page.to_vec().into())
336            .await
337            .with_context(|| format!("uploading page {key}"))?;
338    }
339
340    // Write the manifest last, so a manifest never references a missing page.
341    let manifest = Manifest { page_size: ps, page_hashes };
342    let key = manifest_key(target, unix_nanos());
343    target
344        .store
345        .put(&key, manifest.to_bytes().into())
346        .await
347        .with_context(|| format!("uploading manifest {key}"))?;
348
349    Ok(DedupOutcome::Snapshotted {
350        manifest_key: key.to_string(),
351        total_pages,
352        uploaded_pages,
353        reused_pages: total_pages - uploaded_pages,
354    })
355}
356
357/// Load the most recent manifest under `{prefix}/manifests/`, returning its key
358/// and parsed contents, or `None` if no snapshot has been taken yet. Manifest
359/// keys are zero-padded nanosecond timestamps, so the lexically-greatest key is
360/// the newest (clock-skew-immune, matching tier 1a's `restore_latest`). Shared
361/// with restore (R004-F3).
362pub async fn load_latest_manifest(target: &BackupTarget) -> Result<Option<(String, Manifest)>> {
363    let prefix = join_key(&target.prefix, "manifests");
364    let listing = target
365        .store
366        .list_with_delimiter(Some(&prefix))
367        .await
368        .with_context(|| format!("listing manifests under {prefix}"))?;
369    let Some(newest) = listing.objects.into_iter().max_by(|a, b| a.location.cmp(&b.location)) else {
370        return Ok(None);
371    };
372    let bytes = target
373        .store
374        .get(&newest.location)
375        .await
376        .with_context(|| format!("downloading manifest {}", newest.location))?
377        .bytes()
378        .await
379        .with_context(|| format!("reading manifest body {}", newest.location))?;
380    let manifest = Manifest::from_bytes(&bytes)
381        .with_context(|| format!("parsing manifest {}", newest.location))?;
382    Ok(Some((newest.location.to_string(), manifest)))
383}
384
385/// Reassemble a database file from a manifest + content-addressed pages, writing
386/// it to `dest`. Tier 1b restore.
387///
388/// Fetches each `pages/{hash}` in manifest order, **verifies its content hash**
389/// (the page is content-addressed, so a hash mismatch means a corrupt or
390/// truncated blob — fail loudly rather than write a silently-broken database),
391/// concatenates, and writes the image. The result is a valid vanilla-SQLite file
392/// needing no `-wal` sidecar — the snapshot folded WAL into the image.
393pub async fn restore_from_manifest(
394    target: &BackupTarget,
395    manifest: &Manifest,
396    dest: &str,
397) -> Result<()> {
398    let mut image = Vec::with_capacity(manifest.image_len());
399    for (i, hash) in manifest.page_hashes.iter().enumerate() {
400        let key = page_key(target, hash);
401        let bytes = target
402            .store
403            .get(&key)
404            .await
405            .with_context(|| format!("fetching page {i} ({key})"))?
406            .bytes()
407            .await
408            .with_context(|| format!("reading page {i} body ({key})"))?;
409        let got = sha256_hex(&bytes);
410        anyhow::ensure!(
411            &got == hash,
412            "page {i} ({key}) content hash {got} != manifest {hash} — corrupt or truncated blob"
413        );
414        image.extend_from_slice(&bytes);
415    }
416    anyhow::ensure!(
417        image.starts_with(SQLITE_MAGIC),
418        "reassembled image is not a SQLite database"
419    );
420    std::fs::write(dest, &image).with_context(|| format!("writing restored db to {dest}"))?;
421    Ok(())
422}
423
424/// Restore the most recent snapshot for `target` to `dest`, returning the
425/// manifest key it restored from. Errors if no snapshot exists under the prefix.
426/// This is the tier-1b counterpart to tier 1a's `snapshot::restore_latest`.
427pub async fn restore_latest_dedup(target: &BackupTarget, dest: &str) -> Result<String> {
428    let (key, manifest) = load_latest_manifest(target)
429        .await?
430        .context("no snapshot manifest found under prefix")?;
431    restore_from_manifest(target, &manifest, dest).await?;
432    Ok(key)
433}
434
435/// Restore a *specific* snapshot, named by its manifest object key (as returned
436/// in [`DedupOutcome::Snapshotted::manifest_key`]), to `dest`. Use this for a
437/// point-in-time restore of an older snapshot; [`restore_latest_dedup`] covers
438/// the common "newest" case. Fetches and parses the manifest object, then defers
439/// to [`restore_from_manifest`] (so the same per-page content-hash verification
440/// applies).
441pub async fn restore_manifest_key(target: &BackupTarget, manifest_key: &str, dest: &str) -> Result<()> {
442    let key = ObjPath::from(manifest_key);
443    let bytes = target
444        .store
445        .get(&key)
446        .await
447        .with_context(|| format!("fetching manifest {key}"))?
448        .bytes()
449        .await
450        .with_context(|| format!("reading manifest body {key}"))?;
451    let manifest =
452        Manifest::from_bytes(&bytes).with_context(|| format!("parsing manifest {key}"))?;
453    restore_from_manifest(target, &manifest, dest).await
454}
455
456/// Outcome of a [`gc_dedup`] call.
457#[derive(Debug, Clone, PartialEq, Eq)]
458pub struct GcOutcome {
459    /// Number of manifests retained (the `keep_n` newest, or fewer if the store
460    /// holds less).
461    pub retained_manifests: usize,
462    /// Number of older manifests deleted from `manifests/`.
463    pub pruned_manifests: usize,
464    /// Number of `pages/{hash}` blobs deleted (referenced by no retained
465    /// manifest).
466    pub deleted_pages: usize,
467    /// Number of distinct page hashes referenced by the retained manifests.
468    /// Every blob with one of these hashes survives the sweep.
469    pub retained_pages: usize,
470}
471
472/// Mark-and-sweep GC: keep the `keep_n` newest manifests, delete every
473/// `pages/{hash}` blob no retained manifest references, then prune the older
474/// manifests.
475///
476/// Manifest keys are zero-padded nanosecond timestamps, so lexical order is
477/// chronological — the `keep_n` lexically-greatest keys are the newest
478/// snapshots. If the store holds `<= keep_n` manifests, nothing is pruned.
479///
480/// ## Sweep order
481///
482/// 1. List `manifests/`, split into retained (newest `keep_n`) vs pruned.
483/// 2. Download + parse the retained manifests; union their `page_hashes`.
484/// 3. List `pages/`, delete every blob whose leaf isn't in the retained union.
485/// 4. **Delete the pruned manifests last.**
486///
487/// Concurrent-restore safety: [`restore_latest_dedup`] always targets the
488/// lexically-greatest manifest, which is in the retain set, and *all* of its
489/// pages are in the retain union — so it survives a concurrent GC. The
490/// `manifests-last` ordering means that even if we crash mid-sweep, every
491/// surviving manifest still resolves: orphan pages may briefly outlive their
492/// (already-pruned) referrers, but no retained manifest ever points at a
493/// missing page. The reverse order would risk a half-deleted retain set.
494///
495/// `restore_manifest_key` against a manifest in the prune set is inherently a
496/// race — the caller is asking to restore something we've decided to discard.
497/// Schedule GC to run when no point-in-time restores are in flight.
498pub async fn gc_dedup(target: &BackupTarget, keep_n: usize) -> Result<GcOutcome> {
499    // 1. List manifests, sort lexically (== chronologically).
500    let manifests_prefix = join_key(&target.prefix, "manifests");
501    let manifests_listing = target
502        .store
503        .list_with_delimiter(Some(&manifests_prefix))
504        .await
505        .with_context(|| format!("listing manifests under {manifests_prefix}"))?;
506    let mut all_manifests: Vec<ObjPath> =
507        manifests_listing.objects.into_iter().map(|o| o.location).collect();
508    all_manifests.sort();
509
510    // Split: the newest `keep_n` retained, the rest pruned. If keep_n exceeds
511    // the count, every manifest is retained and there's nothing to prune.
512    let total = all_manifests.len();
513    let split = total.saturating_sub(keep_n);
514    let pruned: Vec<ObjPath> = all_manifests.iter().take(split).cloned().collect();
515    let retained: Vec<ObjPath> = all_manifests.into_iter().skip(split).collect();
516
517    // 2. Compute the union of page hashes referenced by the retained manifests.
518    let mut retained_hashes: HashSet<String> = HashSet::new();
519    for key in &retained {
520        let bytes = target
521            .store
522            .get(key)
523            .await
524            .with_context(|| format!("fetching retained manifest {key}"))?
525            .bytes()
526            .await
527            .with_context(|| format!("reading retained manifest body {key}"))?;
528        let manifest = Manifest::from_bytes(&bytes)
529            .with_context(|| format!("parsing retained manifest {key}"))?;
530        retained_hashes.extend(manifest.page_hashes);
531    }
532
533    // 3. List pages, delete every blob whose hash isn't in the retain union.
534    // Pages are stored at `{prefix}/pages/{hash}` with no further slashes, so
535    // list_with_delimiter returns each one directly under `objects`.
536    let pages_prefix = join_key(&target.prefix, "pages");
537    let pages_listing = target
538        .store
539        .list_with_delimiter(Some(&pages_prefix))
540        .await
541        .with_context(|| format!("listing pages under {pages_prefix}"))?;
542    let mut deleted_pages = 0usize;
543    for obj in pages_listing.objects {
544        let leaf = obj
545            .location
546            .filename()
547            .with_context(|| format!("page key has no leaf: {}", obj.location))?
548            .to_string();
549        if !retained_hashes.contains(&leaf) {
550            target
551                .store
552                .delete(&obj.location)
553                .await
554                .with_context(|| format!("deleting orphan page {}", obj.location))?;
555            deleted_pages += 1;
556        }
557    }
558
559    // 4. Delete the pruned manifests LAST (see fn doc — keeps retained
560    // manifests fully resolvable through the entire sweep).
561    for key in &pruned {
562        target
563            .store
564            .delete(key)
565            .await
566            .with_context(|| format!("deleting pruned manifest {key}"))?;
567    }
568
569    Ok(GcOutcome {
570        retained_manifests: retained.len(),
571        pruned_manifests: pruned.len(),
572        deleted_pages,
573        retained_pages: retained_hashes.len(),
574    })
575}
576
577/// Object key for a content-addressed page blob.
578fn page_key(target: &BackupTarget, hash: &str) -> ObjPath {
579    join_key(&target.prefix, &format!("pages/{hash}"))
580}
581
582/// Object key for a snapshot's manifest, taken at `unix_nanos` (zero-padded so
583/// lexical order matches chronological order).
584fn manifest_key(target: &BackupTarget, unix_nanos: u128) -> ObjPath {
585    join_key(&target.prefix, &format!("manifests/manifest-{unix_nanos:020}.manifest"))
586}
587
588/// Join an object-store prefix and a leaf into a normalized [`ObjPath`],
589/// tolerating empty/slash-padded prefixes.
590fn join_key(prefix: &str, leaf: &str) -> ObjPath {
591    let prefix = prefix.trim_matches('/');
592    if prefix.is_empty() {
593        ObjPath::from(leaf)
594    } else {
595        ObjPath::from(format!("{prefix}/{leaf}"))
596    }
597}
598
599/// Lowercase-hex SHA-256 of a byte buffer.
600fn sha256_hex(bytes: &[u8]) -> String {
601    let mut hasher = Sha256::new();
602    hasher.update(bytes);
603    hex::encode(hasher.finalize())
604}
605
606fn unix_nanos() -> u128 {
607    SystemTime::now()
608        .duration_since(UNIX_EPOCH)
609        .map(|d| d.as_nanos())
610        .unwrap_or(0)
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616    use object_store::memory::InMemory;
617    use std::sync::Arc;
618
619    /// A throwaway db path under the OS temp dir, cleaned up (with sidecars) on drop.
620    struct TempDb(std::path::PathBuf);
621    impl TempDb {
622        fn new(tag: &str) -> Self {
623            TempDb(std::env::temp_dir().join(format!(
624                "turso-dedup-test-{tag}-{}-{}.db",
625                std::process::id(),
626                unix_nanos()
627            )))
628        }
629        fn path(&self) -> &str {
630            self.0.to_str().unwrap()
631        }
632    }
633    impl Drop for TempDb {
634        fn drop(&mut self) {
635            for suffix in ["", "-wal", "-shm"] {
636                let _ = std::fs::remove_file(format!("{}{suffix}", self.0.display()));
637            }
638        }
639    }
640
641    async fn seed_db(path: &str, rows: i64) {
642        let db = Builder::new_local(path).build().await.unwrap();
643        let conn = db.connect().unwrap();
644        conn.execute("CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, v TEXT)", ())
645            .await
646            .unwrap();
647        conn.execute("BEGIN", ()).await.unwrap();
648        for i in 0..rows {
649            conn.execute("INSERT INTO t (id, v) VALUES (?, ?)", (i, format!("v{i}")))
650                .await
651                .unwrap();
652        }
653        conn.execute("COMMIT", ()).await.unwrap();
654    }
655
656    async fn insert_rows(path: &str, start: i64, n: i64) {
657        let db = Builder::new_local(path).build().await.unwrap();
658        let conn = db.connect().unwrap();
659        for i in 0..n {
660            conn.execute("INSERT INTO t (id, v) VALUES (?, ?)", (start + i, format!("v{}", start + i)))
661                .await
662                .unwrap();
663        }
664    }
665
666    async fn count_rows(path: &str) -> i64 {
667        let db = Builder::new_local(path).build().await.unwrap();
668        let conn = db.connect().unwrap();
669        let mut r = conn.query("SELECT COUNT(*) FROM t", ()).await.unwrap();
670        let row = r.next().await.unwrap().unwrap();
671        row.get::<i64>(0).unwrap()
672    }
673
674    /// `VACUUM INTO` a fresh file and return its bytes — the contrast case that
675    /// shows why tier 1b copies raw instead of vacuuming.
676    async fn vacuum_image(db_path: &str) -> Vec<u8> {
677        let out = std::env::temp_dir().join(format!(
678            "turso-dedup-vac-{}-{}.db",
679            std::process::id(),
680            unix_nanos()
681        ));
682        let _ = std::fs::remove_file(&out);
683        let db = Builder::new_local(db_path).build().await.unwrap();
684        let conn = db.connect().unwrap();
685        let escaped = out.to_string_lossy().replace('\'', "''");
686        let mut rows = conn.query(&format!("VACUUM INTO '{escaped}'"), ()).await.unwrap();
687        while rows.next().await.unwrap().is_some() {}
688        drop(conn);
689        let bytes = std::fs::read(&out).unwrap();
690        let _ = std::fs::remove_file(&out);
691        bytes
692    }
693
694    /// Count pages that differ at the same offset between two images, padding the
695    /// shorter image's tail to `total` (a grown file = appended pages = changed).
696    fn changed_pages(a: &[u8], b: &[u8], ps: usize) -> (usize, usize) {
697        let pa: Vec<&[u8]> = a.chunks(ps).collect();
698        let pb: Vec<&[u8]> = b.chunks(ps).collect();
699        let total = pa.len().max(pb.len());
700        let mut changed = 0;
701        for i in 0..total {
702            if pa.get(i) != pb.get(i) {
703                changed += 1;
704            }
705        }
706        (changed, total)
707    }
708
709    /// SPIKE R004-T1 (headline): a raw consistent copy keeps unchanged pages at
710    /// fixed offsets, so a one-row mutation rewrites only a few pages — the
711    /// precondition for content-addressed dedup.
712    #[tokio::test]
713    async fn raw_copy_keeps_page_offsets_stable() {
714        let src = TempDb::new("offsets");
715        seed_db(src.path(), 5000).await;
716
717        let a = raw_consistent_copy(src.path()).await.unwrap();
718        let ps = page_size(&a).unwrap();
719        assert_eq!(ps, 4096, "expected turso's default 4 KB page, got {ps}");
720
721        // A single-row insert: the smallest meaningful mutation.
722        insert_rows(src.path(), 1_000_000, 1).await;
723        let b = raw_consistent_copy(src.path()).await.unwrap();
724        assert_eq!(page_size(&b).unwrap(), ps, "page size must not change");
725
726        let (changed, total) = changed_pages(&a, &b, ps);
727        eprintln!(
728            "[R004-T1] raw copy: {changed}/{total} pages changed by a 1-row insert (page_size={ps})"
729        );
730        assert!(changed >= 1, "the mutation must change at least one page");
731        assert!(
732            changed * 4 < total,
733            "raw copy not dedup-friendly: {changed}/{total} pages changed by one row"
734        );
735
736        // The second image is itself complete and consistent (opens in turso).
737        let restored = TempDb::new("offsets-restored");
738        std::fs::write(restored.path(), &b).unwrap();
739        assert_eq!(count_rows(restored.path()).await, 5001);
740    }
741
742    /// SPIKE R004-T1 (contrast): the honest comparison of raw copy vs
743    /// `VACUUM INTO` as the dedup base, across two mutation shapes.
744    ///
745    /// - **Append** (1-row insert): the two are *equivalent* — both touch a
746    ///   single page. The working doc's blanket "vacuum reshuffles and defeats
747    ///   dedup" does NOT hold for append-only growth.
748    /// - **Reclamation** (delete a low-id block): vacuum repacks the surviving
749    ///   rows from page 1, renumbering them, so a large fraction of pages
750    ///   change. A raw copy leaves every surviving page at its original offset,
751    ///   so only the freed region changes.
752    ///
753    /// The takeaway for F2: raw copy's offset stability is a *structural
754    /// guarantee* (page N is always at `(N-1)*ps`, existing pages never
755    /// renumber), independent of workload. Vacuum's dedup-friendliness is
756    /// incidental and collapses under reclamation — so tier 1b copies raw.
757    #[tokio::test]
758    async fn raw_copy_beats_vacuum_as_dedup_base() {
759        let src = TempDb::new("contrast");
760        seed_db(src.path(), 5000).await;
761
762        let raw_a = raw_consistent_copy(src.path()).await.unwrap();
763        let vac_a = vacuum_image(src.path()).await;
764        let ps = page_size(&raw_a).unwrap();
765
766        // Phase 1 — append one row: raw and vacuum should be ~equivalent.
767        insert_rows(src.path(), 1_000_000, 1).await;
768        let raw_b = raw_consistent_copy(src.path()).await.unwrap();
769        let vac_b = vacuum_image(src.path()).await;
770        let (raw_app, raw_app_total) = changed_pages(&raw_a, &raw_b, ps);
771        let (vac_app, vac_app_total) = changed_pages(&vac_a, &vac_b, ps);
772        eprintln!(
773            "[R004-T1] append 1 row -> raw: {raw_app}/{raw_app_total} | vacuum: {vac_app}/{vac_app_total} (expected ~equal)"
774        );
775
776        // Phase 2 — free a low-id block: vacuum renumbers survivors, raw doesn't.
777        let src2 = TempDb::new("contrast-reclaim");
778        seed_db(src2.path(), 5000).await;
779        let raw_c = raw_consistent_copy(src2.path()).await.unwrap();
780        let vac_c = vacuum_image(src2.path()).await;
781        {
782            let db = Builder::new_local(src2.path()).build().await.unwrap();
783            let conn = db.connect().unwrap();
784            conn.execute("DELETE FROM t WHERE id < 2000", ()).await.unwrap();
785        }
786        let raw_d = raw_consistent_copy(src2.path()).await.unwrap();
787        let vac_d = vacuum_image(src2.path()).await;
788        let (raw_rec, raw_rec_total) = changed_pages(&raw_c, &raw_d, ps);
789        let (vac_rec, vac_rec_total) = changed_pages(&vac_c, &vac_d, ps);
790        eprintln!(
791            "[R004-T1] delete low block -> raw: {raw_rec}/{raw_rec_total} | vacuum: {vac_rec}/{vac_rec_total} (vacuum renumbers)"
792        );
793
794        // The defensible, workload-independent claim: under reclamation the raw
795        // copy preserves more pages than vacuum does.
796        assert!(
797            vac_rec > raw_rec,
798            "under reclamation expected vacuum to perturb more pages than raw copy \
799             (raw {raw_rec}, vacuum {vac_rec})"
800        );
801    }
802
803    /// SPIKE R004-T1: `raw_consistent_copy` folds main + WAL into one image, so
804    /// the returned bytes are complete with no `-wal` sidecar.
805    #[tokio::test]
806    async fn raw_copy_folds_wal_and_is_self_contained() {
807        let src = TempDb::new("walfold");
808        seed_db(src.path(), 100).await;
809        insert_rows(src.path(), 200, 50).await;
810
811        let img = raw_consistent_copy(src.path()).await.unwrap();
812        assert!(img.starts_with(SQLITE_MAGIC));
813
814        // Write ONLY the main image to a fresh path (no -wal/-shm): a complete
815        // image means the WAL frames were folded in.
816        let restored = TempDb::new("walfold-restored");
817        std::fs::write(restored.path(), &img).unwrap();
818        assert_eq!(count_rows(restored.path()).await, 150);
819    }
820
821    // ---- R004-F2: page chunking + content-addressed dedup upload + manifest ----
822
823    /// Number of objects stored under `{prefix}/{leaf}/`.
824    async fn count_under(target: &BackupTarget, leaf: &str) -> usize {
825        target
826            .store
827            .list_with_delimiter(Some(&join_key(&target.prefix, leaf)))
828            .await
829            .unwrap()
830            .objects
831            .len()
832    }
833
834    /// Reassemble the image described by the latest manifest, fetching each page
835    /// blob by its content hash (what R004-F3 will package as `restore`).
836    async fn reassemble_latest(target: &BackupTarget) -> Vec<u8> {
837        let (_, m) = load_latest_manifest(target).await.unwrap().unwrap();
838        let mut out = Vec::with_capacity(m.image_len());
839        for h in &m.page_hashes {
840            let bytes = target.store.get(&page_key(target, h)).await.unwrap().bytes().await.unwrap();
841            out.extend_from_slice(&bytes);
842        }
843        out
844    }
845
846    #[test]
847    fn manifest_round_trips() {
848        let m = Manifest {
849            page_size: 4096,
850            page_hashes: vec!["aa".repeat(32), "bb".repeat(32)],
851        };
852        assert_eq!(Manifest::from_bytes(&m.to_bytes()).unwrap(), m);
853        assert_eq!(m.image_len(), 8192);
854        assert!(Manifest::from_bytes(b"not a manifest\n").is_err());
855        assert!(Manifest::from_bytes(&[0xff, 0xfe]).is_err());
856    }
857
858    /// First snapshot uploads every distinct page + a manifest; a one-row change
859    /// then uploads only the few changed pages and writes a second manifest. Both
860    /// manifests reassemble to a valid SQLite with the expected row count.
861    #[tokio::test]
862    async fn dedup_uploads_only_changed_pages_and_reassembles() {
863        let src = TempDb::new("dedup-src");
864        seed_db(src.path(), 5000).await;
865        let target = BackupTarget { store: Arc::new(InMemory::new()), prefix: "backups".into() };
866
867        // First snapshot: nothing prior -> every distinct page is new.
868        let total = match snapshot_dedup(src.path(), &target).await.unwrap() {
869            DedupOutcome::Snapshotted { total_pages, uploaded_pages, reused_pages, .. } => {
870                assert_eq!(reused_pages, 0, "no prior snapshot -> nothing reused");
871                assert_eq!(uploaded_pages, count_under(&target, "pages").await, "every put is a distinct page");
872                total_pages
873            }
874            other => panic!("expected Snapshotted, got {other:?}"),
875        };
876        assert_eq!(count_under(&target, "manifests").await, 1);
877        let pages_after_first = count_under(&target, "pages").await;
878
879        // Reassembles to a valid SQLite with all 5000 rows.
880        let img = reassemble_latest(&target).await;
881        assert!(img.starts_with(SQLITE_MAGIC));
882        let r1 = TempDb::new("dedup-r1");
883        std::fs::write(r1.path(), &img).unwrap();
884        assert_eq!(count_rows(r1.path()).await, 5000);
885
886        // One-row mutation -> only a handful of changed pages uploaded.
887        insert_rows(src.path(), 1_000_000, 1).await;
888        match snapshot_dedup(src.path(), &target).await.unwrap() {
889            DedupOutcome::Snapshotted { uploaded_pages, total_pages, .. } => {
890                assert!(
891                    uploaded_pages >= 1 && uploaded_pages * 4 < total_pages,
892                    "expected few changed pages, got {uploaded_pages}/{total_pages}"
893                );
894                // Content-addressed store grew by exactly the new pages.
895                assert_eq!(
896                    count_under(&target, "pages").await - pages_after_first,
897                    uploaded_pages
898                );
899            }
900            other => panic!("expected Snapshotted, got {other:?}"),
901        }
902        assert_eq!(count_under(&target, "manifests").await, 2, "second snapshot -> second manifest");
903        assert_eq!(total, 18, "sanity: 5000 short rows -> 18 pages");
904
905        // The newest manifest reassembles to the post-mutation database.
906        let img2 = reassemble_latest(&target).await;
907        let r2 = TempDb::new("dedup-r2");
908        std::fs::write(r2.path(), &img2).unwrap();
909        assert_eq!(count_rows(r2.path()).await, 5001);
910    }
911
912    /// Re-snapshotting an unchanged database uploads nothing and writes no new
913    /// manifest — the prior one still stands.
914    #[tokio::test]
915    async fn dedup_unchanged_when_pages_identical() {
916        let src = TempDb::new("dedup-unch");
917        seed_db(src.path(), 50).await;
918        let target = BackupTarget { store: Arc::new(InMemory::new()), prefix: String::new() };
919
920        let first_key = match snapshot_dedup(src.path(), &target).await.unwrap() {
921            DedupOutcome::Snapshotted { manifest_key, .. } => manifest_key,
922            other => panic!("expected Snapshotted, got {other:?}"),
923        };
924        let pages = count_under(&target, "pages").await;
925
926        match snapshot_dedup(src.path(), &target).await.unwrap() {
927            DedupOutcome::Unchanged { manifest_key } => assert_eq!(manifest_key, first_key),
928            other => panic!("expected Unchanged, got {other:?}"),
929        }
930        assert_eq!(count_under(&target, "manifests").await, 1, "no new manifest on unchanged");
931        assert_eq!(count_under(&target, "pages").await, pages, "no new pages on unchanged");
932    }
933
934    // ---- R004-F3: restore from manifest ----
935
936    /// `restore_latest_dedup` errors when empty, then restores the newest of two
937    /// snapshots (post-mutation row count) as a valid vanilla-SQLite file.
938    #[tokio::test]
939    async fn restore_latest_dedup_picks_newest_and_round_trips() {
940        let src = TempDb::new("f3-src");
941        seed_db(src.path(), 200).await;
942        let target = BackupTarget { store: Arc::new(InMemory::new()), prefix: "backups".into() };
943
944        // No snapshot yet -> error, no file written.
945        let dest = TempDb::new("f3-dest");
946        assert!(restore_latest_dedup(&target, dest.path()).await.is_err());
947
948        snapshot_dedup(src.path(), &target).await.unwrap();
949        insert_rows(src.path(), 1_000_000, 5).await;
950        let newest = match snapshot_dedup(src.path(), &target).await.unwrap() {
951            DedupOutcome::Snapshotted { manifest_key, .. } => manifest_key,
952            other => panic!("expected Snapshotted, got {other:?}"),
953        };
954
955        let restored_from = restore_latest_dedup(&target, dest.path()).await.unwrap();
956        assert_eq!(restored_from, newest, "should restore the newest manifest");
957
958        let on_disk = std::fs::read(dest.path()).unwrap();
959        assert!(on_disk.starts_with(SQLITE_MAGIC), "restored file is not SQLite");
960        assert_eq!(count_rows(dest.path()).await, 205);
961    }
962
963    /// A corrupt page blob is caught by the content-hash check rather than
964    /// producing a silently-broken database.
965    #[tokio::test]
966    async fn restore_rejects_corrupt_page() {
967        let src = TempDb::new("f3-corrupt");
968        seed_db(src.path(), 100).await;
969        let target = BackupTarget { store: Arc::new(InMemory::new()), prefix: String::new() };
970        snapshot_dedup(src.path(), &target).await.unwrap();
971
972        let (_, manifest) = load_latest_manifest(&target).await.unwrap().unwrap();
973        // Overwrite one page blob with garbage of the right length (so only the
974        // hash check, not a size check, can catch it).
975        let victim = &manifest.page_hashes[0];
976        target
977            .store
978            .put(&page_key(&target, victim), vec![0xab; manifest.page_size].into())
979            .await
980            .unwrap();
981
982        let dest = TempDb::new("f3-corrupt-dest");
983        let err = restore_from_manifest(&target, &manifest, dest.path()).await.unwrap_err();
984        assert!(
985            err.to_string().contains("content hash"),
986            "expected a hash-mismatch error, got: {err}"
987        );
988    }
989
990    /// `restore_manifest_key` targets an *older* snapshot by key while the latest
991    /// reflects the post-mutation state — both reassemble to their own row counts.
992    #[tokio::test]
993    async fn restore_manifest_key_targets_older_snapshot() {
994        let src = TempDb::new("f3-key-src");
995        seed_db(src.path(), 100).await;
996        let target = BackupTarget { store: Arc::new(InMemory::new()), prefix: "backups".into() };
997
998        let key1 = match snapshot_dedup(src.path(), &target).await.unwrap() {
999            DedupOutcome::Snapshotted { manifest_key, .. } => manifest_key,
1000            other => panic!("expected Snapshotted, got {other:?}"),
1001        };
1002        insert_rows(src.path(), 1_000_000, 10).await;
1003        snapshot_dedup(src.path(), &target).await.unwrap();
1004
1005        // Older snapshot by key -> 100 rows.
1006        let d1 = TempDb::new("f3-key-d1");
1007        restore_manifest_key(&target, &key1, d1.path()).await.unwrap();
1008        assert_eq!(count_rows(d1.path()).await, 100);
1009
1010        // Latest -> 110 rows.
1011        let d2 = TempDb::new("f3-key-d2");
1012        restore_latest_dedup(&target, d2.path()).await.unwrap();
1013        assert_eq!(count_rows(d2.path()).await, 110);
1014    }
1015
1016    // ---- R006-F1: retention + page-blob GC ----
1017
1018    /// Take three snapshots, GC down to the newest one. Older manifests are
1019    /// pruned, every page hash referenced *only* by them is swept, the pages
1020    /// the surviving manifest still references stay put, and the surviving
1021    /// snapshot restores byte-for-byte against the un-GC'd image.
1022    #[tokio::test]
1023    async fn gc_prunes_older_manifests_and_sweeps_orphan_pages() {
1024        let src = TempDb::new("gc-src");
1025        seed_db(src.path(), 5000).await;
1026        let target = BackupTarget { store: Arc::new(InMemory::new()), prefix: "backups".into() };
1027
1028        // Three snapshots with a row appended between each — every snapshot
1029        // produces at least one fresh page that the next one doesn't reference.
1030        snapshot_dedup(src.path(), &target).await.unwrap();
1031        insert_rows(src.path(), 1_000_000, 1).await;
1032        snapshot_dedup(src.path(), &target).await.unwrap();
1033        insert_rows(src.path(), 2_000_000, 1).await;
1034        let newest_key = match snapshot_dedup(src.path(), &target).await.unwrap() {
1035            DedupOutcome::Snapshotted { manifest_key, .. } => manifest_key,
1036            other => panic!("expected Snapshotted, got {other:?}"),
1037        };
1038        assert_eq!(count_under(&target, "manifests").await, 3);
1039        let pages_before_gc = count_under(&target, "pages").await;
1040
1041        // Snapshot the bytes-on-disk of every page the newest manifest needs,
1042        // so we can prove the GC leaves them byte-identical.
1043        let (_, newest_manifest) = load_latest_manifest(&target).await.unwrap().unwrap();
1044        let mut expected_pages: HashMap<String, Vec<u8>> = HashMap::new();
1045        for h in &newest_manifest.page_hashes {
1046            let bytes = target.store.get(&page_key(&target, h)).await.unwrap().bytes().await.unwrap();
1047            expected_pages.insert(h.clone(), bytes.to_vec());
1048        }
1049
1050        // GC down to keep=1 (just the newest manifest).
1051        let outcome = gc_dedup(&target, 1).await.unwrap();
1052        assert_eq!(outcome.retained_manifests, 1);
1053        assert_eq!(outcome.pruned_manifests, 2);
1054        assert_eq!(outcome.retained_pages, newest_manifest.page_hashes.len());
1055        assert!(
1056            outcome.deleted_pages > 0,
1057            "earlier snapshots wrote at least one page the newest manifest no longer references"
1058        );
1059
1060        // Store state matches the outcome counts.
1061        assert_eq!(count_under(&target, "manifests").await, 1, "only the newest manifest survives");
1062        assert_eq!(
1063            count_under(&target, "pages").await,
1064            pages_before_gc - outcome.deleted_pages,
1065            "pages/ shrank by exactly the orphan count"
1066        );
1067
1068        // Every page the newest manifest references is still there, byte-identical.
1069        for (h, want) in &expected_pages {
1070            let got = target.store.get(&page_key(&target, h)).await.unwrap().bytes().await.unwrap();
1071            assert_eq!(&got[..], &want[..], "retained page {h} was mutated by GC");
1072        }
1073
1074        // And the newest manifest still restores to a valid SQLite with the
1075        // post-second-mutation row count (5000 + 1 + 1 = 5002).
1076        let dest = TempDb::new("gc-restored");
1077        let restored_from = restore_latest_dedup(&target, dest.path()).await.unwrap();
1078        assert_eq!(restored_from, newest_key);
1079        assert_eq!(count_rows(dest.path()).await, 5002);
1080    }
1081
1082    /// keep_n >= total manifests is a no-op: nothing pruned, no pages deleted.
1083    #[tokio::test]
1084    async fn gc_keep_n_at_or_above_total_is_noop() {
1085        let src = TempDb::new("gc-noop");
1086        seed_db(src.path(), 100).await;
1087        let target = BackupTarget { store: Arc::new(InMemory::new()), prefix: String::new() };
1088
1089        snapshot_dedup(src.path(), &target).await.unwrap();
1090        insert_rows(src.path(), 1_000_000, 1).await;
1091        snapshot_dedup(src.path(), &target).await.unwrap();
1092        let manifests_before = count_under(&target, "manifests").await;
1093        let pages_before = count_under(&target, "pages").await;
1094
1095        // keep_n == total
1096        let outcome = gc_dedup(&target, 2).await.unwrap();
1097        assert_eq!(outcome.pruned_manifests, 0);
1098        assert_eq!(outcome.deleted_pages, 0);
1099        assert_eq!(outcome.retained_manifests, manifests_before);
1100
1101        // keep_n > total
1102        let outcome = gc_dedup(&target, 999).await.unwrap();
1103        assert_eq!(outcome.pruned_manifests, 0);
1104        assert_eq!(outcome.deleted_pages, 0);
1105
1106        assert_eq!(count_under(&target, "manifests").await, manifests_before);
1107        assert_eq!(count_under(&target, "pages").await, pages_before);
1108    }
1109
1110    /// Pages shared between a pruned and a retained manifest survive — content
1111    /// addressing means they're one blob, and the retain union still needs it.
1112    /// This is the load-bearing safety property: incremental snapshots reuse
1113    /// most pages, so a naive "delete every page from pruned manifests" would
1114    /// destroy the retained snapshot.
1115    #[tokio::test]
1116    async fn gc_keeps_pages_shared_with_retained_manifests() {
1117        let src = TempDb::new("gc-shared");
1118        seed_db(src.path(), 5000).await;
1119        let target = BackupTarget { store: Arc::new(InMemory::new()), prefix: "backups".into() };
1120
1121        // Snapshot 1 — all pages new.
1122        snapshot_dedup(src.path(), &target).await.unwrap();
1123        let (_, m1) = load_latest_manifest(&target).await.unwrap().unwrap();
1124        let s1_hashes: HashSet<String> = m1.page_hashes.iter().cloned().collect();
1125
1126        // Small mutation -> snapshot 2 shares almost every page with snapshot 1.
1127        insert_rows(src.path(), 1_000_000, 1).await;
1128        snapshot_dedup(src.path(), &target).await.unwrap();
1129        let (_, m2) = load_latest_manifest(&target).await.unwrap().unwrap();
1130        let s2_hashes: HashSet<String> = m2.page_hashes.iter().cloned().collect();
1131        let shared: HashSet<&String> = s1_hashes.intersection(&s2_hashes).collect();
1132        assert!(
1133            shared.len() > s1_hashes.len() / 2,
1134            "incremental snapshot should share most pages with the prior \
1135             (shared {}/{})",
1136            shared.len(),
1137            s1_hashes.len()
1138        );
1139
1140        // GC keep=1: snapshot 1 is pruned, but every page snapshot 2 still
1141        // references must survive — including the ones it shares with snapshot 1.
1142        gc_dedup(&target, 1).await.unwrap();
1143        for h in &s2_hashes {
1144            assert!(
1145                target.store.get(&page_key(&target, h)).await.is_ok(),
1146                "shared page {h} (in retained snapshot 2) was deleted"
1147            );
1148        }
1149        // Pages exclusive to snapshot 1 are gone.
1150        for h in s1_hashes.difference(&s2_hashes) {
1151            assert!(
1152                target.store.get(&page_key(&target, h)).await.is_err(),
1153                "page {h} exclusive to pruned snapshot 1 still exists"
1154            );
1155        }
1156    }
1157
1158    // ---- R006-T2: GC preserves byte-for-byte restorability of every
1159    // retained snapshot, not just the latest. Three snapshots, keep_n=2 ----
1160    //
1161    // The headline contract: an in-window *older* snapshot (the middle one
1162    // here) must restore byte-for-byte after GC, proving that GC's mark set
1163    // covers every retained manifest — not just the lexically-newest one
1164    // restore_latest_dedup uses. Ground truth is the pre-GC restore of the
1165    // same key; post-GC bytes are asserted equal.
1166    #[tokio::test]
1167    async fn gc_keep_two_lets_in_window_older_snapshot_restore_byte_for_byte() {
1168        let src = TempDb::new("gc-window");
1169        seed_db(src.path(), 5000).await;
1170        let target = BackupTarget { store: Arc::new(InMemory::new()), prefix: "backups".into() };
1171
1172        // Three snapshots with a mutation between each.
1173        let key1 = match snapshot_dedup(src.path(), &target).await.unwrap() {
1174            DedupOutcome::Snapshotted { manifest_key, .. } => manifest_key,
1175            other => panic!("expected Snapshotted, got {other:?}"),
1176        };
1177        insert_rows(src.path(), 1_000_000, 1).await;
1178        let key2 = match snapshot_dedup(src.path(), &target).await.unwrap() {
1179            DedupOutcome::Snapshotted { manifest_key, .. } => manifest_key,
1180            other => panic!("expected Snapshotted, got {other:?}"),
1181        };
1182        insert_rows(src.path(), 2_000_000, 1).await;
1183        let key3 = match snapshot_dedup(src.path(), &target).await.unwrap() {
1184            DedupOutcome::Snapshotted { manifest_key, .. } => manifest_key,
1185            other => panic!("expected Snapshotted, got {other:?}"),
1186        };
1187
1188        // Ground truth: restore the middle snapshot BEFORE GC.
1189        let pre = TempDb::new("gc-window-pre");
1190        restore_manifest_key(&target, &key2, pre.path()).await.unwrap();
1191        let pre_bytes = std::fs::read(pre.path()).unwrap();
1192        assert_eq!(count_rows(pre.path()).await, 5001);
1193
1194        // GC keep_n=2 -> snapshot 1 pruned, 2 and 3 retained.
1195        let outcome = gc_dedup(&target, 2).await.unwrap();
1196        assert_eq!(outcome.pruned_manifests, 1);
1197        assert_eq!(outcome.retained_manifests, 2);
1198        assert!(
1199            outcome.deleted_pages > 0,
1200            "snapshot 1 had at least one page neither snapshot 2 nor 3 references"
1201        );
1202
1203        // Snapshot 1 is gone — its manifest object no longer exists.
1204        let gone = TempDb::new("gc-window-gone");
1205        assert!(
1206            restore_manifest_key(&target, &key1, gone.path()).await.is_err(),
1207            "pruned snapshot 1 should not be restorable"
1208        );
1209
1210        // Snapshot 2 (in-window older) restores BYTE-FOR-BYTE to the pre-GC bytes.
1211        let post = TempDb::new("gc-window-post");
1212        restore_manifest_key(&target, &key2, post.path()).await.unwrap();
1213        let post_bytes = std::fs::read(post.path()).unwrap();
1214        assert_eq!(
1215            pre_bytes, post_bytes,
1216            "in-window older snapshot must restore byte-for-byte after GC"
1217        );
1218
1219        // And the newest (snapshot 3) restores to its own row count via the
1220        // latest path — proving keep_n=2 left both retained snapshots whole.
1221        let latest = TempDb::new("gc-window-latest");
1222        let restored_from = restore_latest_dedup(&target, latest.path()).await.unwrap();
1223        assert_eq!(restored_from, key3);
1224        assert_eq!(count_rows(latest.path()).await, 5002);
1225    }
1226}