Skip to main content

blockworx_store/
migrate.rs

1//! `blockworx migrate`: a container written when revs were zstd, read by a
2//! build that writes them as gzip.
3//!
4//! One compressor everywhere is what lets a container move between a
5//! browser and a desktop untranslated, and zstd's C does not build for
6//! `wasm32-unknown-unknown` — so the format changed, and the containers
7//! already on the developer's disk are carried over rather than left
8//! behind. This module is the last thing in the tree that names zstd and
9//! goes with the release after the one that lands it.
10//!
11//! **The manifest is rewritten, because it has to be.** A row stamps the
12//! digest of the bytes the rev file holds, so re-compressing a rev changes
13//! what its row must say, and every row after it links to that row. Each
14//! row keeps every column it carried — the wall time, the author, the
15//! camera, the kind, the label — and gets a new `hash` and a new `parent`,
16//! which is exactly the chain the same rows would have had if this build
17//! had written them. The file goes down whole or not at all.
18
19use blockworx_doc::rev::Rev;
20
21use crate::container::MANIFEST;
22use crate::manifest::{self, End, Row};
23use crate::record::Digest;
24use crate::revs::{REVS, entry, pack};
25use crate::storage::{Entry, Storage, ready_now};
26
27/// What a rev file was called before the encoding changed.
28fn zstd_entry(at: Rev) -> Entry {
29    Entry::under(REVS, &format!("{:06}.json.zst", at.get()))
30}
31
32/// What the migration did.
33#[derive(Clone, Copy, PartialEq, Eq, Debug)]
34pub enum Migrated {
35    /// Every rev was already gzip and every row already stamped it, so
36    /// nothing was written. Running the migration twice is running it
37    /// once.
38    AlreadyDone,
39    Rewrote {
40        revs: usize,
41    },
42}
43
44impl std::fmt::Display for Migrated {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            Migrated::AlreadyDone => write!(f, "already gzip: nothing to do"),
48            Migrated::Rewrote { revs } => {
49                write!(
50                    f,
51                    "{revs} revs rewritten as gzip, and the manifest re-stamped"
52                )
53            }
54        }
55    }
56}
57
58#[derive(Debug, thiserror::Error)]
59pub enum MigrationFailure {
60    #[error("the manifest could not be read: {0}")]
61    Read(std::io::Error),
62    #[error("the manifest does not verify at {0} — migrating it would set the break in stone")]
63    Broken(manifest::BreakReport),
64    #[error("rev {} is neither gzip nor zstd: there is nothing under either name", .0.get())]
65    NoSuchRev(Rev),
66    #[error("rev {}'s bytes are not zstd: {why}", .at.get())]
67    NotZstd { at: Rev, why: std::io::Error },
68    #[error("rev {} could not be written: {why}", .at.get())]
69    NotWritten { at: Rev, why: std::io::Error },
70    #[error("the re-stamped manifest did not land: {0}")]
71    NotStamped(std::io::Error),
72    #[error("the migrated container did not reopen: {0}")]
73    Reopen(String),
74}
75
76/// Rewrite `storage`'s revs from zstd to gzip, re-stamp the rows that name
77/// them, and refresh the projection so its stamp names the head as it now
78/// stands.
79///
80/// Each rev lands under its new name before the old one is removed, and
81/// the manifest is rewritten only once every rev has: a run that stops
82/// halfway leaves a container whose revs are partly converted and whose
83/// rows still stamp the zstd bytes, which the next run finishes.
84///
85/// # Errors
86/// [`MigrationFailure`] — the manifest could not be read or does not
87/// verify, a rev is missing or is not zstd, or a write did not land.
88pub fn to_gzip<S: Storage>(storage: &S) -> Result<Migrated, MigrationFailure> {
89    let text = read_manifest(storage)?;
90    let scanned = manifest::scan(&text);
91    if let End::Broken(report) = scanned.end {
92        return Err(MigrationFailure::Broken(report));
93    }
94    let mut rows: Vec<Row> = scanned
95        .rows
96        .into_iter()
97        .map(|verified| verified.row)
98        .collect();
99    let mut rewritten = 0;
100    let mut stamped: Vec<(Rev, Digest)> = Vec::new();
101    for at in rows
102        .iter()
103        .filter(|row| row.kind.takes_a_rev())
104        .map(|row| row.rev)
105    {
106        let (digest, moved) = carry_over(storage, at)?;
107        rewritten += usize::from(moved);
108        stamped.push((at, digest));
109    }
110    restamp(&mut rows, &stamped);
111
112    let text_now = lines(&rows);
113    if rewritten == 0 && text_now == text {
114        return Ok(Migrated::AlreadyDone);
115    }
116    ready_now(storage.write(&MANIFEST, text_now.as_bytes()))
117        .map_err(MigrationFailure::NotStamped)?;
118    Ok(Migrated::Rewrote { revs: rewritten })
119}
120
121/// Read rev `at` as zstd and write it back as gzip, answering the digest
122/// its row must now stamp and whether anything moved.
123fn carry_over<S: Storage>(storage: &S, at: Rev) -> Result<(Digest, bool), MigrationFailure> {
124    let old = zstd_entry(at);
125    let new = entry(at);
126    let Some(bytes) = read(storage, &old, at)? else {
127        // Already carried over — by an earlier run, or by this build.
128        let held = read(storage, &new, at)?.ok_or(MigrationFailure::NoSuchRev(at))?;
129        return Ok((Digest::of(&held), false));
130    };
131    let json =
132        zstd::decode_all(bytes.as_slice()).map_err(|why| MigrationFailure::NotZstd { at, why })?;
133    let packed = pack(&json).map_err(|why| MigrationFailure::NotWritten { at, why })?;
134    let written =
135        ready_now(storage.write(&new, &packed)).and_then(|()| ready_now(storage.remove(&old)));
136    written.map_err(|why| MigrationFailure::NotWritten { at, why })?;
137    Ok((Digest::of(&packed), true))
138}
139
140fn read<S: Storage>(
141    storage: &S,
142    at: &Entry,
143    rev: Rev,
144) -> Result<Option<Vec<u8>>, MigrationFailure> {
145    ready_now(storage.read(at)).map_err(|why| MigrationFailure::NotWritten { at: rev, why })
146}
147
148/// Give every row the hash its rev now has, and chain the file again.
149///
150/// A row that spends a rev stamps that rev's own bytes; one that does not
151/// — a tag — stamps the head it was appended under, which is the last rev
152/// before it in the file. The same rule [`Store`](crate::handle::Store) writes
153/// rows under, read
154/// off a file instead of a session.
155fn restamp(rows: &mut [Row], stamped: &[(Rev, Digest)]) {
156    let mut head = Digest::of(&[]);
157    let mut parent = Digest::genesis();
158    for row in rows.iter_mut() {
159        if row.kind.takes_a_rev()
160            && let Some((_, digest)) = stamped.iter().find(|(rev, _)| *rev == row.rev)
161        {
162            head = *digest;
163        }
164        row.hash = head;
165        row.parent = parent;
166        parent = row.digest();
167    }
168}
169
170fn lines(rows: &[Row]) -> String {
171    let mut text = String::new();
172    for row in rows {
173        text.push_str(&String::from_utf8_lossy(&row.canonical_bytes()));
174        text.push('\n');
175    }
176    text
177}
178
179fn read_manifest<S: Storage>(storage: &S) -> Result<String, MigrationFailure> {
180    let bytes = ready_now(storage.read(&MANIFEST))
181        .map_err(MigrationFailure::Read)?
182        .ok_or_else(|| {
183            MigrationFailure::Read(std::io::Error::from(std::io::ErrorKind::NotFound))
184        })?;
185    String::from_utf8(bytes).map_err(|why| {
186        MigrationFailure::Read(std::io::Error::new(
187            std::io::ErrorKind::InvalidData,
188            why.to_string(),
189        ))
190    })
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::fixture;
197    use crate::handle::Store;
198    use crate::storage::Native;
199    use crate::tags::Tagging;
200
201    /// The whole claim in one test: every rev comes over, the manifest
202    /// still verifies against the bytes that are now there, and the
203    /// document reads back unchanged.
204    #[test]
205    fn a_zstd_container_comes_over_and_still_verifies() {
206        let dir = fixture::dir("migrate-gzip");
207        let root = dir.join("doc.bwx");
208        let document = {
209            let mut store =
210                Store::create(Native::at(&root), fixture::clock()).expect("the container");
211            for commit in fixture::edits(3) {
212                store
213                    .submit_edit(commit, &fixture::author())
214                    .expect("the edit lands");
215            }
216            store
217                .tag(
218                    blockworx_doc::fixtures::rev(2),
219                    "Initial Draft",
220                    Tagging::Added,
221                    &fixture::author(),
222                )
223                .expect("the tag lands");
224            store.document().clone()
225        };
226        roll_back_to_zstd(&root);
227
228        let migrated = to_gzip(&Native::at(&root)).expect("the container comes over");
229        assert_eq!(migrated, Migrated::Rewrote { revs: 3 });
230
231        for at in crate::revs::through(blockworx_doc::fixtures::rev(3)) {
232            assert!(
233                root.join(entry(at).as_str()).is_file(),
234                "rev {} was not written as gzip",
235                at.get(),
236            );
237            assert!(
238                !root.join(zstd_entry(at).as_str()).exists(),
239                "rev {}'s old file was left behind",
240                at.get(),
241            );
242        }
243
244        let reopened = Store::open(Native::at(&root), fixture::clock()).expect("it reopens");
245        assert!(
246            reopened.read_only_reason().is_none(),
247            "the migrated manifest does not verify: {:?}",
248            reopened.read_only_reason(),
249        );
250        assert_eq!(reopened.document().clone(), document);
251        assert_eq!(
252            reopened.tags().of(blockworx_doc::fixtures::rev(2)),
253            ["Initial Draft"],
254            "the tag row did not come over",
255        );
256        assert!(
257            crate::dump::verify(Native::at(&root))
258                .expect("the manifest reads")
259                .broken
260                .is_none(),
261            "the fsck does not accept the migrated container",
262        );
263    }
264
265    /// Running it twice is running it once.
266    #[test]
267    fn a_container_already_gzip_is_left_alone() {
268        let dir = fixture::dir("migrate-idempotent");
269        let root = dir.join("doc.bwx");
270        {
271            let mut store =
272                Store::create(Native::at(&root), fixture::clock()).expect("the container");
273            for commit in fixture::edits(2) {
274                store
275                    .submit_edit(commit, &fixture::author())
276                    .expect("the edit lands");
277            }
278        }
279        let before = std::fs::read(root.join(MANIFEST.as_str())).expect("the manifest");
280
281        assert_eq!(
282            to_gzip(&Native::at(&root)).expect("it answers"),
283            Migrated::AlreadyDone,
284        );
285        assert_eq!(
286            std::fs::read(root.join(MANIFEST.as_str())).expect("the manifest"),
287            before,
288            "a container with nothing to migrate had its manifest rewritten",
289        );
290    }
291
292    /// A run cut short leaves a half-converted container, and the next one
293    /// finishes it.
294    #[test]
295    fn a_half_converted_container_is_finished_by_the_next_run() {
296        let dir = fixture::dir("migrate-half");
297        let root = dir.join("doc.bwx");
298        {
299            let mut store =
300                Store::create(Native::at(&root), fixture::clock()).expect("the container");
301            for commit in fixture::edits(3) {
302                store
303                    .submit_edit(commit, &fixture::author())
304                    .expect("the edit lands");
305            }
306        }
307        roll_back_to_zstd(&root);
308        // One rev carried over by hand, as an interrupted run would leave
309        // it: the new file down, the old one gone, the rows untouched.
310        let at = blockworx_doc::fixtures::rev(1);
311        let json = zstd::decode_all(
312            std::fs::read(root.join(zstd_entry(at).as_str()))
313                .expect("the old rev")
314                .as_slice(),
315        )
316        .expect("it decodes");
317        std::fs::write(
318            root.join(entry(at).as_str()),
319            pack(&json).expect("it packs"),
320        )
321        .expect("the new rev");
322        std::fs::remove_file(root.join(zstd_entry(at).as_str())).expect("the old rev goes");
323
324        assert_eq!(
325            to_gzip(&Native::at(&root)).expect("the rest comes over"),
326            Migrated::Rewrote { revs: 2 },
327        );
328        let reopened = Store::open(Native::at(&root), fixture::clock()).expect("it reopens");
329        assert!(reopened.read_only_reason().is_none());
330    }
331
332    /// A manifest that does not verify is left where it is: migrating it
333    /// would set the break in stone under a fresh chain.
334    #[test]
335    fn a_broken_manifest_is_refused() {
336        let dir = fixture::dir("migrate-broken");
337        let root = dir.join("doc.bwx");
338        {
339            let mut store =
340                Store::create(Native::at(&root), fixture::clock()).expect("the container");
341            for commit in fixture::edits(3) {
342                store
343                    .submit_edit(commit, &fixture::author())
344                    .expect("the edit lands");
345            }
346        }
347        let mut lines = crate::tests::manifest_lines(&root);
348        lines[1] = lines[1].replace("Added a block", "Added a blork");
349        crate::tests::write_manifest(&root, &lines);
350
351        assert!(matches!(
352            to_gzip(&Native::at(&root)),
353            Err(MigrationFailure::Broken(_)),
354        ));
355    }
356
357    /// Rewrite a gzip container's revs as zstd and re-stamp its rows, so a
358    /// test can stand a container as the old build would have left it.
359    fn roll_back_to_zstd(root: &std::path::Path) {
360        let text = std::fs::read_to_string(root.join(MANIFEST.as_str())).expect("the manifest");
361        let mut rows: Vec<Row> = manifest::scan(&text)
362            .rows
363            .into_iter()
364            .map(|verified| verified.row)
365            .collect();
366        let mut stamped: Vec<(Rev, Digest)> = Vec::new();
367        for at in rows
368            .iter()
369            .filter(|row| row.kind.takes_a_rev())
370            .map(|row| row.rev)
371        {
372            let gz = root.join(entry(at).as_str());
373            let json = crate::revs::unpack(&std::fs::read(&gz).expect("the gzip rev"))
374                .expect("it decodes");
375            let packed = zstd::encode_all(json.as_slice(), 1).expect("it compresses");
376            std::fs::write(root.join(zstd_entry(at).as_str()), &packed).expect("the zstd rev");
377            std::fs::remove_file(&gz).expect("the gzip rev goes");
378            stamped.push((at, Digest::of(&packed)));
379        }
380        restamp(&mut rows, &stamped);
381        std::fs::write(root.join(MANIFEST.as_str()), lines(&rows)).expect("the manifest");
382    }
383}