Personal outliner built with Rust.
4

Configure Feed

Select the types of activity you want to include in your feed.

implement add-format-versioning: meta.json format stamp checked before any load, refuse-newer untouched, sequential migration skeleton with contiguity gate and pre-migration snapshot backup, committed golden-graph compatibility test, Loro pin policy in README

graham.systems (Jul 15, 2026, 11:50 AM -0700) 96185de2 f27c769b

+457 -12
+1
Cargo.lock
··· 7491 7491 "loro", 7492 7492 "rand 0.10.2", 7493 7493 "serde", 7494 + "serde_json", 7494 7495 "steel-core", 7495 7496 "tantivy", 7496 7497 "trawler-core",
+27
README.md
··· 77 77 `TRAWLER_TEST_SEED=<seed>`. Deeper sweeps are `#[ignore]`d alongside the 78 78 other expensive tests. 79 79 80 + A third always-on gate protects format compatibility: the **golden graph** 81 + (`crates/trawler-core/tests/golden/graph`, exercised by 82 + `tests/golden_graph.rs`) is a small committed graph directory every test 83 + run must open and read identically. It is regenerated only via the 84 + `#[ignore]`d `regenerate_golden_graph` test, and only together with a 85 + deliberate format version bump. 86 + 87 + ### Loro upgrade policy 88 + 89 + The `loro` dependency stays pinned to an exact version. An upgrade PR must 90 + show the golden-graph test passing *unmodified*, plus a round-trip check 91 + (open the golden graph → export a snapshot with the new Loro → reopen → 92 + identical content). If a Loro upgrade cannot read existing snapshots, that 93 + is by definition a graph format change: bump `CURRENT_FORMAT_VERSION`, 94 + register the migration (e.g. re-export via the previously pinned version), 95 + and regenerate the golden graph in the same commit — the 96 + `migration_chain_is_contiguous` test enforces the pairing. 97 + 80 98 ### Where your data lives 81 99 82 100 By default the graph directory is `%APPDATA%\trawler\graph`. Override it with ··· 156 174 snapshot.loro # the last compacted full snapshot of the Loro doc 157 175 updates.log # length-prefixed incremental update blobs since the 158 176 # last snapshot — replayed on top of it when opening 177 + meta.json # semantic format version stamp (format-gating fields 178 + # only), checked before anything else is read 159 179 search-index/ # tantivy full-text index; entirely disposable, 160 180 # rebuilt automatically if missing 161 181 ``` ··· 163 183 - **`snapshot.loro` + `updates.log` are the only source of truth.** Every 164 184 other file (the search index) can be deleted safely; it's rebuilt 165 185 transparently on next open. 186 + - **The graph format is versioned** (`meta.json`, currently format 1). A 187 + graph stamped with a *newer* format than the running build is refused 188 + with a clear error, touching nothing — upgrade trawler to open it. Older 189 + formats migrate automatically through a sequential, test-gated migration 190 + chain (with the pre-migration snapshot kept as `snapshot.loro.v<N>.bak`); 191 + a graph from before versioning existed is treated as format 1 and 192 + stamped on next open. 166 193 - The Loro document holds one movable tree (container name `"outline"`). 167 194 Tree roots are pages; everything else is a block. A block's text lives in 168 195 its metadata map under the `content` key (a mergeable Loro text); typed
+1
crates/trawler-core/Cargo.toml
··· 20 20 loro = "1.13.6" 21 21 rand = { version = "0.10.2", optional = true } 22 22 serde = { version = "1.0.228", features = ["derive"] } 23 + serde_json = "1.0.150" 23 24 steel-core = "0.8.2" 24 25 tantivy = "0.26.1" 25 26
+271 -1
crates/trawler-core/src/storage.rs
··· 18 18 19 19 const SNAPSHOT_FILE: &str = "snapshot.loro"; 20 20 const UPDATES_FILE: &str = "updates.log"; 21 + const META_FILE: &str = "meta.json"; 22 + 23 + /// The semantic format version of the graph directory this build reads and 24 + /// writes (openspec change add-format-versioning). Distinct from Loro's own 25 + /// encoding version: this tracks trawler's *use* of the directory — 26 + /// container names, file layout, framing, metadata conventions. Bumping it 27 + /// requires registering a migration step in [`MIGRATIONS`] (a unit test 28 + /// enforces the chain stays contiguous) and regenerating the golden graph 29 + /// fixture in the same commit. 30 + pub const CURRENT_FORMAT_VERSION: u32 = 1; 31 + 32 + /// One migration step: entry `i` migrates a graph directory from format 33 + /// `i + 1` to `i + 2` by rewriting files in place. Steps run sequentially 34 + /// on open, re-stamping `meta.json` after each so an interrupted migration 35 + /// resumes at the failed step. 36 + pub type Migration = fn(&Path) -> io::Result<()>; 37 + 38 + /// The registered migration chain, `MIGRATIONS[i]`: format `i+1` → `i+2`. 39 + /// Empty while `CURRENT_FORMAT_VERSION` is 1. 40 + pub const MIGRATIONS: &[Migration] = &[]; 41 + 42 + /// Format-gating fields only — anything describing graph *content* belongs 43 + /// in the Loro document itself, not here. Kept as a JSON object (not a bare 44 + /// integer) so future gating fields can be added compatibly. 45 + #[derive(serde::Serialize, serde::Deserialize)] 46 + struct GraphMeta { 47 + format_version: u32, 48 + } 49 + 50 + /// The recorded format version, or `None` when `meta.json` is absent 51 + /// (every graph created before versioning existed — treated as format 1). 52 + /// Readable without decoding the Loro document by design: a future format 53 + /// change may alter how the document itself is stored. 54 + fn read_format_version(dir: &Path) -> io::Result<Option<u32>> { 55 + let path = dir.join(META_FILE); 56 + if !path.exists() { 57 + return Ok(None); 58 + } 59 + let bytes = fs::read(&path)?; 60 + let meta: GraphMeta = serde_json::from_slice(&bytes) 61 + .map_err(|e| io::Error::other(format!("unreadable {META_FILE}: {e}")))?; 62 + Ok(Some(meta.format_version)) 63 + } 64 + 65 + /// Atomically stamp the directory's format version (temp + fsync + rename, 66 + /// same pattern as the snapshot). 67 + fn write_format_version(dir: &Path, version: u32) -> io::Result<()> { 68 + let bytes = serde_json::to_vec_pretty(&GraphMeta { 69 + format_version: version, 70 + }) 71 + .map_err(io::Error::other)?; 72 + let tmp_path = dir.join(format!("{META_FILE}.tmp")); 73 + { 74 + let mut tmp = File::create(&tmp_path)?; 75 + tmp.write_all(&bytes)?; 76 + tmp.sync_all()?; 77 + } 78 + fs::rename(&tmp_path, dir.join(META_FILE))?; 79 + Ok(()) 80 + } 81 + 82 + /// Gate a graph directory on its format version before any load work: 83 + /// refuse newer formats touching nothing, migrate older formats through the 84 + /// chain (backing up the pre-migration snapshot first), stamp legacy 85 + /// directories. Parameterized over `current`/`migrations` so tests can 86 + /// exercise the machinery with synthetic chains; production passes 87 + /// [`CURRENT_FORMAT_VERSION`] and [`MIGRATIONS`]. 88 + fn check_and_migrate(dir: &Path, current: u32, migrations: &[Migration]) -> io::Result<()> { 89 + let recorded = read_format_version(dir)?; 90 + let effective = recorded.unwrap_or(1); 91 + 92 + if effective > current { 93 + return Err(io::Error::other(format!( 94 + "this graph was created by a newer trawler (format {effective}); \ 95 + this build reads up to format {current} — upgrade trawler to open it" 96 + ))); 97 + } 98 + 99 + if effective < current { 100 + // Keep the pre-migration snapshot recoverable no matter what the 101 + // migration steps do. 102 + let snapshot = dir.join(SNAPSHOT_FILE); 103 + if snapshot.exists() { 104 + fs::copy( 105 + &snapshot, 106 + dir.join(format!("{SNAPSHOT_FILE}.v{effective}.bak")), 107 + )?; 108 + } 109 + for step in (effective as usize - 1)..(current as usize - 1) { 110 + let migrate = migrations.get(step).ok_or_else(|| { 111 + io::Error::other(format!( 112 + "no migration registered for format {} -> {}", 113 + step + 1, 114 + step + 2 115 + )) 116 + })?; 117 + migrate(dir)?; 118 + write_format_version(dir, (step + 2) as u32)?; 119 + } 120 + } else if recorded.is_none() && dir.join(SNAPSHOT_FILE).exists() { 121 + // A legacy graph at the current version: acquire a stamp. Best 122 + // effort — a failure to stamp degrades to today's (unversioned) 123 + // behavior rather than blocking the open. 124 + if let Err(e) = write_format_version(dir, current) { 125 + eprintln!("trawler: could not stamp graph format version: {e}"); 126 + } 127 + } 128 + Ok(()) 129 + } 21 130 22 131 /// Opens or creates a graph directory backed by a single Loro document. 23 132 /// ··· 34 143 } 35 144 36 145 impl GraphStorage { 37 - /// Create a brand-new, empty graph directory. 146 + /// Create a brand-new, empty graph directory, stamped with the current 147 + /// format version. 38 148 pub fn create(dir: impl AsRef<Path>) -> io::Result<Self> { 39 149 let dir = dir.as_ref().to_path_buf(); 40 150 fs::create_dir_all(&dir)?; 151 + write_format_version(&dir, CURRENT_FORMAT_VERSION)?; 41 152 let doc = LoroDoc::new(); 42 153 // Touch the outline tree so it exists in the very first snapshot, 43 154 // and enable ordered (fractional-index) children — required by ··· 55 166 56 167 /// Open an existing graph directory, replaying the snapshot and then 57 168 /// any updates recorded after it. 169 + /// 170 + /// The format version gate runs first, before any other file is read: 171 + /// newer-format graphs are refused untouched, older ones are migrated, 172 + /// and unstamped legacy graphs acquire a stamp (spec: "Graph directory 173 + /// format is versioned"). 58 174 pub fn open(dir: impl AsRef<Path>) -> io::Result<Self> { 59 175 let dir = dir.as_ref().to_path_buf(); 176 + check_and_migrate(&dir, CURRENT_FORMAT_VERSION, MIGRATIONS)?; 60 177 let doc = LoroDoc::new(); 61 178 62 179 let snapshot_path = dir.join(SNAPSHOT_FILE); ··· 301 418 // we lose at most the last unacknowledged edit, never crash. 302 419 let reopened = GraphStorage::open(&dir).unwrap(); 303 420 assert_eq!(reopened.doc().get_tree(OUTLINE_TREE).roots().len(), 0); 421 + fs::remove_dir_all(&dir).ok(); 422 + } 423 + 424 + // -- Format versioning (openspec change add-format-versioning) -------- 425 + 426 + /// Every file in `dir` mapped to its bytes, for byte-identical 427 + /// comparisons around the newer-format refusal. 428 + fn dir_bytes(dir: &Path) -> std::collections::BTreeMap<String, Vec<u8>> { 429 + fs::read_dir(dir) 430 + .unwrap() 431 + .map(|e| { 432 + let e = e.unwrap(); 433 + ( 434 + e.file_name().to_string_lossy().into_owned(), 435 + fs::read(e.path()).unwrap(), 436 + ) 437 + }) 438 + .collect() 439 + } 440 + 441 + #[test] 442 + fn create_stamps_current_format_version() { 443 + let dir = temp_dir("format-create-stamp"); 444 + GraphStorage::create(&dir).unwrap(); 445 + assert_eq!( 446 + read_format_version(&dir).unwrap(), 447 + Some(CURRENT_FORMAT_VERSION) 448 + ); 449 + fs::remove_dir_all(&dir).ok(); 450 + } 451 + 452 + /// spec scenario: "Legacy graph acquires a stamp" 453 + #[test] 454 + fn legacy_graph_opens_and_gains_stamp() { 455 + let dir = temp_dir("format-legacy-stamp"); 456 + { 457 + let storage = GraphStorage::create(&dir).unwrap(); 458 + storage.doc().get_tree(OUTLINE_TREE).create(None).unwrap(); 459 + storage.persist_update().unwrap(); 460 + } 461 + // A graph from before versioning existed: no meta.json. 462 + fs::remove_file(dir.join(META_FILE)).unwrap(); 463 + 464 + let reopened = GraphStorage::open(&dir).unwrap(); 465 + assert_eq!(reopened.doc().get_tree(OUTLINE_TREE).roots().len(), 1); 466 + assert_eq!( 467 + read_format_version(&dir).unwrap(), 468 + Some(CURRENT_FORMAT_VERSION) 469 + ); 470 + fs::remove_dir_all(&dir).ok(); 471 + } 472 + 473 + /// spec scenario: "Newer format is refused untouched" 474 + #[test] 475 + fn newer_format_is_refused_with_directory_untouched() { 476 + let dir = temp_dir("format-refuse-newer"); 477 + { 478 + let storage = GraphStorage::create(&dir).unwrap(); 479 + storage.doc().get_tree(OUTLINE_TREE).create(None).unwrap(); 480 + storage.persist_update().unwrap(); 481 + } 482 + write_format_version(&dir, CURRENT_FORMAT_VERSION + 1).unwrap(); 483 + let before = dir_bytes(&dir); 484 + 485 + let err = GraphStorage::open(&dir) 486 + .err() 487 + .expect("newer format must refuse to open") 488 + .to_string(); 489 + assert!( 490 + err.contains(&format!("format {}", CURRENT_FORMAT_VERSION + 1)) 491 + && err.contains(&format!("format {CURRENT_FORMAT_VERSION}")), 492 + "refusal must name both versions, got: {err}" 493 + ); 494 + assert_eq!(dir_bytes(&dir), before, "refusal must touch nothing"); 495 + fs::remove_dir_all(&dir).ok(); 496 + } 497 + 498 + /// spec scenario: "Migration chain has no gaps" — a version bump 499 + /// without its migration step fails this test at build-gate time. 500 + #[test] 501 + fn migration_chain_is_contiguous() { 502 + assert_eq!( 503 + MIGRATIONS.len(), 504 + (CURRENT_FORMAT_VERSION - 1) as usize, 505 + "CURRENT_FORMAT_VERSION is {CURRENT_FORMAT_VERSION} but {} migration step(s) \ 506 + are registered — register the missing step(s) in MIGRATIONS", 507 + MIGRATIONS.len() 508 + ); 509 + } 510 + 511 + fn synth_step_one(dir: &Path) -> io::Result<()> { 512 + fs::write(dir.join("migrated-1"), b"1") 513 + } 514 + fn synth_step_two(dir: &Path) -> io::Result<()> { 515 + fs::write(dir.join("migrated-2"), b"2") 516 + } 517 + fn synth_step_fails(_: &Path) -> io::Result<()> { 518 + Err(io::Error::other("synthetic migration failure")) 519 + } 520 + 521 + /// Exercise the migration machinery with a synthetic chain (the real 522 + /// chain is empty at format 1): steps run in order from the recorded 523 + /// version, each re-stamps, and the pre-migration snapshot is backed up. 524 + #[test] 525 + fn synthetic_migration_chain_runs_and_stamps_each_step() { 526 + let dir = temp_dir("format-synth-migrate"); 527 + { 528 + let storage = GraphStorage::create(&dir).unwrap(); 529 + storage.doc().get_tree(OUTLINE_TREE).create(None).unwrap(); 530 + storage.persist_update().unwrap(); 531 + } 532 + 533 + check_and_migrate(&dir, 3, &[synth_step_one, synth_step_two]).unwrap(); 534 + 535 + assert_eq!(read_format_version(&dir).unwrap(), Some(3)); 536 + assert!(dir.join("migrated-1").exists()); 537 + assert!(dir.join("migrated-2").exists()); 538 + assert!( 539 + dir.join(format!("{SNAPSHOT_FILE}.v1.bak")).exists(), 540 + "pre-migration snapshot backup must exist" 541 + ); 542 + 543 + // The graph content survived the (no-op) steps: stamp back to the 544 + // real current version and open normally. 545 + write_format_version(&dir, CURRENT_FORMAT_VERSION).unwrap(); 546 + let reopened = GraphStorage::open(&dir).unwrap(); 547 + assert_eq!(reopened.doc().get_tree(OUTLINE_TREE).roots().len(), 1); 548 + fs::remove_dir_all(&dir).ok(); 549 + } 550 + 551 + /// spec scenario: "Interrupted migration resumes safely" — a failing 552 + /// step leaves the intermediate stamp, and a later attempt resumes from 553 + /// it rather than re-running completed steps. 554 + #[test] 555 + fn interrupted_migration_resumes_from_recorded_version() { 556 + let dir = temp_dir("format-migrate-resume"); 557 + { 558 + let storage = GraphStorage::create(&dir).unwrap(); 559 + storage.doc().get_tree(OUTLINE_TREE).create(None).unwrap(); 560 + storage.persist_update().unwrap(); 561 + } 562 + 563 + // First attempt: step 1 succeeds (stamps v2), step 2 dies. 564 + check_and_migrate(&dir, 3, &[synth_step_one, synth_step_fails]).unwrap_err(); 565 + assert_eq!(read_format_version(&dir).unwrap(), Some(2)); 566 + assert!(dir.join(format!("{SNAPSHOT_FILE}.v1.bak")).exists()); 567 + fs::remove_file(dir.join("migrated-1")).unwrap(); 568 + 569 + // Resume: only the remaining step runs (migrated-1 is not recreated). 570 + check_and_migrate(&dir, 3, &[synth_step_one, synth_step_two]).unwrap(); 571 + assert_eq!(read_format_version(&dir).unwrap(), Some(3)); 572 + assert!(!dir.join("migrated-1").exists(), "step 1 must not re-run"); 573 + assert!(dir.join("migrated-2").exists()); 304 574 fs::remove_dir_all(&dir).ok(); 305 575 } 306 576 }
+3
crates/trawler-core/tests/golden/graph/meta.json
··· 1 + { 2 + "format_version": 1 3 + }
crates/trawler-core/tests/golden/graph/snapshot.loro

This is a binary file and will not be displayed.

crates/trawler-core/tests/golden/graph/updates.log

This is a binary file and will not be displayed.

+143
crates/trawler-core/tests/golden_graph.rs
··· 1 + //! Golden-graph compatibility gate (openspec change add-format-versioning, 2 + //! spec "Cross-release open compatibility is continuously verified"): a 3 + //! small graph directory committed under `tests/golden/graph` that every 4 + //! test run must open and read identically. Any change that alters how 5 + //! existing graph files decode — a Loro version bump, a framing change, a 6 + //! container rename — fails here instead of surfacing as a user's 7 + //! unreadable vault. 8 + //! 9 + //! REGENERATION RULE: the committed directory is regenerated only via the 10 + //! `#[ignore]`d `regenerate_golden_graph` test, and only together with a 11 + //! deliberate format version bump (plus its migration) in the same commit. 12 + //! 13 + //! Layout on purpose: a *compacted* snapshot carrying the fixture content 14 + //! (exercises full snapshot decode) plus one post-compaction update blob 15 + //! (exercises update-log replay) plus `meta.json` (exercises the version 16 + //! gate). 17 + 18 + use std::fs; 19 + use std::path::{Path, PathBuf}; 20 + 21 + use trawler_core::index::GraphIndex; 22 + use trawler_core::outline::{Outline, Position}; 23 + use trawler_core::storage::GraphStorage; 24 + 25 + const GOLDEN_UPDATE_TEXT: &str = "golden update entry (replayed from updates.log)"; 26 + 27 + fn golden_dir() -> PathBuf { 28 + Path::new(env!("CARGO_MANIFEST_DIR")) 29 + .join("tests") 30 + .join("golden") 31 + .join("graph") 32 + } 33 + 34 + fn temp_copy_of_golden(name: &str) -> PathBuf { 35 + let dest = std::env::temp_dir().join(format!("trawler-golden-{name}-{}", std::process::id())); 36 + let _ = fs::remove_dir_all(&dest); 37 + fs::create_dir_all(&dest).unwrap(); 38 + for entry in fs::read_dir(golden_dir()).expect("committed golden graph directory") { 39 + let entry = entry.unwrap(); 40 + fs::copy(entry.path(), dest.join(entry.file_name())).unwrap(); 41 + } 42 + dest 43 + } 44 + 45 + /// The gate: opening the committed golden graph must keep answering 46 + /// exactly what it answered when it was generated. Runs against a temp 47 + /// copy so the committed directory is never modified (opening stamps/ 48 + /// migrates in place). 49 + #[test] 50 + fn golden_graph_opens_and_reads_identically() { 51 + let dir = temp_copy_of_golden("verify"); 52 + let storage = GraphStorage::open(&dir).expect("golden graph must open"); 53 + let outline = Outline::new(storage.doc()); 54 + 55 + // Page set and order (creation order: journal, design, reading). 56 + let roots = outline.children(None); 57 + let names: Vec<String> = roots.iter().map(|&r| outline.content(r).unwrap()).collect(); 58 + assert_eq!( 59 + names, 60 + vec![ 61 + "2026-07-10".to_string(), 62 + "trawler-design".to_string(), 63 + "reading-list".to_string(), 64 + ] 65 + ); 66 + 67 + // The journal page: 3 fixture blocks + the post-compaction golden 68 + // entry, proving the update blob replayed on top of the snapshot. 69 + let journal_children = outline.children(Some(roots[0])); 70 + assert_eq!(journal_children.len(), 4); 71 + assert_eq!( 72 + outline.content(journal_children[3]).unwrap(), 73 + GOLDEN_UPDATE_TEXT 74 + ); 75 + assert_eq!( 76 + outline.content(journal_children[0]).unwrap(), 77 + "Started the trawler dogfood log #trawler" 78 + ); 79 + 80 + // Derived answers over the whole graph. 81 + let index = GraphIndex::rebuild(storage.doc()); 82 + assert_eq!( 83 + index.tags.get("project").map(|s| s.len()), 84 + Some(6), 85 + "#project tag membership" 86 + ); 87 + assert_eq!( 88 + index.properties.get("due").map(|m| m.len()), 89 + Some(2), 90 + "two blocks carry a due property" 91 + ); 92 + let priorities: Vec<&str> = index 93 + .properties 94 + .get("priority") 95 + .unwrap() 96 + .values() 97 + .filter_map(|v| v.as_text()) 98 + .collect(); 99 + assert!(priorities.contains(&"high") && priorities.contains(&"medium")); 100 + let design_backlinks = index.backlinks_of(&trawler_core::graph::NodeId::tree(roots[1])); 101 + assert_eq!( 102 + design_backlinks.len(), 103 + 1, 104 + "one journal block references [[trawler-design]]" 105 + ); 106 + 107 + // The version gate saw a stamped, current-format graph. 108 + let meta = fs::read_to_string(dir.join("meta.json")).expect("golden meta.json"); 109 + assert!(meta.contains("\"format_version\": 1"), "meta was: {meta}"); 110 + 111 + fs::remove_dir_all(&dir).ok(); 112 + } 113 + 114 + /// Regenerate the committed golden directory. `#[ignore]`d on purpose — 115 + /// run only alongside a deliberate format version bump: 116 + /// `cargo test -p trawler-core --test golden_graph -- --ignored` 117 + #[test] 118 + #[ignore = "rewrites the committed golden graph — only run with a format version bump"] 119 + fn regenerate_golden_graph() { 120 + let dir = golden_dir(); 121 + let _ = fs::remove_dir_all(&dir); 122 + fs::create_dir_all(dir.parent().unwrap()).unwrap(); 123 + 124 + let fixture = trawler_core::fixtures::seed(&dir).expect("seed fixture graph"); 125 + // Fold the fixture content into the snapshot (full snapshot decode is 126 + // the main thing the gate protects)... 127 + fixture.storage.compact().expect("compact golden graph"); 128 + // ...then one more persisted edit so updates.log replay is covered too. 129 + let outline = Outline::new(fixture.storage.doc()); 130 + outline 131 + .create_block( 132 + Some(fixture.journal_page), 133 + Position::Index(3), 134 + GOLDEN_UPDATE_TEXT, 135 + ) 136 + .expect("golden update block"); 137 + fixture 138 + .storage 139 + .persist_update() 140 + .expect("persist golden update"); 141 + 142 + println!("regenerated golden graph at {}", dir.display()); 143 + }
+11 -11
openspec/changes/add-format-versioning/tasks.md
··· 1 1 ## 1. Version stamp (trawler-core) 2 2 3 - - [ ] 1.1 `CURRENT_FORMAT_VERSION: u32 = 1` and `meta.json` read/write (`{"format_version": N}`, temp-file + fsync + atomic rename), written by `GraphStorage::create` 4 - - [ ] 1.2 `open()` reads the stamp before any other load work: absent → treat as 1 and stamp (idempotent, non-fatal on failure); newer than current → refuse with an error naming both versions, touching nothing 5 - - [ ] 1.3 Unit tests: create stamps; legacy dir (no meta.json) opens and gains stamp; artificially newer stamp refused with directory byte-identical afterward 3 + - [x] 1.1 `CURRENT_FORMAT_VERSION: u32 = 1` and `meta.json` read/write (`{"format_version": N}`, temp-file + fsync + atomic rename), written by `GraphStorage::create` 4 + - [x] 1.2 `open()` reads the stamp before any other load work: absent → treat as 1 and stamp (idempotent, non-fatal on failure); newer than current → refuse with an error naming both versions, touching nothing 5 + - [x] 1.3 Unit tests: create stamps; legacy dir (no meta.json) opens and gains stamp; artificially newer stamp refused with directory byte-identical afterward 6 6 7 7 ## 2. Migration skeleton (trawler-core) 8 8 9 - - [ ] 2.1 `MIGRATIONS: &[fn(&Path) -> io::Result<()>]` registry (empty today); `open()` runs steps sequentially for older stamps, re-stamping after each; pre-migration `snapshot.loro` copied to `snapshot.loro.v{N}.bak` before the first step 10 - - [ ] 2.2 Contiguity test: `MIGRATIONS.len() == CURRENT_FORMAT_VERSION - 1` 11 - - [ ] 2.3 Test the machinery with a synthetic migration in test code only (register a step under `#[cfg(test)]`, stamp a dir older, open, assert step ran, version re-stamped, backup exists; simulate interruption between steps and assert resumption) 9 + - [x] 2.1 `MIGRATIONS: &[fn(&Path) -> io::Result<()>]` registry (empty today); `open()` runs steps sequentially for older stamps, re-stamping after each; pre-migration `snapshot.loro` copied to `snapshot.loro.v{N}.bak` before the first step 10 + - [x] 2.2 Contiguity test: `MIGRATIONS.len() == CURRENT_FORMAT_VERSION - 1` 11 + - [x] 2.3 Test the machinery with a synthetic migration in test code only (the runner is parameterized over `current`/`migrations` — cleaner than a `#[cfg(test)]` registry mutation; tests pass a synthetic chain, assert steps ran, versions re-stamped per step, backup exists; interruption simulated with a failing step, resumption asserted to skip completed steps) 12 12 13 13 ## 3. Golden-graph compatibility (trawler-core) 14 14 15 - - [ ] 3.1 Generate the golden fixture once from the deterministic fixture graph: small directory (snapshot.loro, a few update blobs, meta.json) committed under `tests/golden/` 16 - - [ ] 3.2 Compatibility test: open the golden dir, assert a recorded content snapshot (block texts, structure, references, tags); document the regenerate-only-with-format-bump rule in the test header 15 + - [x] 3.1 Generate the golden fixture once from the deterministic fixture graph: small directory (compacted snapshot.loro carrying the content, one post-compaction update blob, meta.json — 3.7KB total) committed under `tests/golden/graph`, regenerated only via the `#[ignore]`d `regenerate_golden_graph` test 16 + - [x] 3.2 Compatibility test: opens a temp *copy* (open stamps/migrates in place — the committed dir must never be modified by a test run), asserts recorded content (page set/order, journal children incl. the update-replayed block, tag membership, properties, backlinks, format stamp); regenerate-only-with-format-bump rule documented in the test header 17 17 18 18 ## 4. Policy and docs 19 19 20 - - [ ] 4.1 README development section: Loro upgrade policy paragraph (exact pin; upgrade PR must pass the golden test unmodified plus an open→export→reopen round-trip; inability to read old snapshots = format bump + migration + golden regeneration) 21 - - [ ] 4.2 README graph-directory-format section: document `meta.json` and newer-format refusal behavior 22 - - [ ] 4.3 `cargo clippy --workspace --all-targets -- -D warnings` and `cargo test --workspace` pass 20 + - [x] 4.1 README development section: Loro upgrade policy paragraph (exact pin; upgrade PR must pass the golden test unmodified plus an open→export→reopen round-trip; inability to read old snapshots = format bump + migration + golden regeneration) 21 + - [x] 4.2 README graph-directory-format section: document `meta.json` and newer-format refusal behavior 22 + - [x] 4.3 `cargo clippy --workspace --all-targets -- -D warnings` and `cargo test --workspace` pass (also verified: devtools feature build)