diff --git a/corpus/page-lifecycle/offline-creation/README.md b/corpus/page-lifecycle/offline-creation/README.md new file mode 100644 index 0000000000000000000000000000000000000000..b810c2a3bba92531b56898fc529a803196485726 --- /dev/null +++ b/corpus/page-lifecycle/offline-creation/README.md @@ -0,0 +1,25 @@ +# Offline page creation + +Twelve independent replicas start from the same one-page section. Each queues +a page titled `Same 🦋 é`, a body outline and a dependent body-text edit, then +reopens its cache and synchronizes. All 36 receipts complete; page identities +remain distinct and body text identifies each originating replica. + +`candidate/pages.one` is the resulting 13-page section. `cold` captures its fresh +OneNote 2010 reopen. Native XML content/order and 216 explicit character-format +comparisons pass. Active graphs compare exactly apart from the original seed +page's previously absent section metadata copy, materialized natively with exact +page metadata. The creation verifier checks that specific addition. +`provenance.json` records the capture and removal of clone m6-cc15a5ee. + +Set `ONESTORE_OFFLINE_PAGE_OUTPUT` to an absolute, new output directory and run +`cargo test -p onestore-offline --all-features --test sync +page::offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles +-- --exact`. Capture the result with `tools/native_runner.py INPUT OUTPUT +--expected-pages 13 --collect-notebook`. After building examples, +`python -m unittest discover -s tools -p test_page_creation.py -v` verifies the +retained native capture. + +This fixture is a serial reconciliation of twelve offline branches. The separate +`offline_page` sanitizer target varies interleavings, interruption and reopen +through the shared `tests/support/page_schedule.rs` model. diff --git a/corpus/page-lifecycle/offline-creation/candidate/pages.one b/corpus/page-lifecycle/offline-creation/candidate/pages.one new file mode 100644 index 0000000000000000000000000000000000000000..128dcb4b988fd6a590f0b2295ad69e66745727cd Binary files /dev/null and b/corpus/page-lifecycle/offline-creation/candidate/pages.one differ diff --git a/corpus/page-lifecycle/offline-creation/cold/notebook/pages.one b/corpus/page-lifecycle/offline-creation/cold/notebook/pages.one new file mode 100644 index 0000000000000000000000000000000000000000..d2b161ceb75989c1955371ce4b86ad8608fe150e Binary files /dev/null and b/corpus/page-lifecycle/offline-creation/cold/notebook/pages.one differ diff --git a/corpus/page-lifecycle/offline-creation/cold/read/hierarchy.xml b/corpus/page-lifecycle/offline-creation/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..5853659977987a61b6cbfa87659e645a49a4ebf6 --- /dev/null +++ b/corpus/page-lifecycle/offline-creation/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-000.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..4ab89e04f2fb00acbdd2a18accaccb210afb3fee --- /dev/null +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-001.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..6b2c687bb6cd5a4847c34c13ab97ccfa8b5942bd --- /dev/null +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-002.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..0cb80c3d598a97b3cf49f24517a50fe2003ca836 --- /dev/null +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-002.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-003.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..624ae72aef85046aae879bee4ba2366afe55af26 --- /dev/null +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-004.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..1866dec8c32dc4c15942d5dfdab07a6e741ab2e1 --- /dev/null +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-004.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-005.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..72e2c7cbe3e6e6756b6c083e2e3731efd353de3c --- /dev/null +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-005.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-006.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..77aec796dcc1d229296a99cae0a838ff0f2ede48 --- /dev/null +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-006.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-007.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..93079149c7b678adb6d125cbe07702ef714eb97b --- /dev/null +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-007.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-008.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..692f7fc332401b10fbfbfdc37a2a8218d7a7f2d0 --- /dev/null +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-008.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-009.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..3795d7efedb3e73366f3ccd9ee86a994658a5e12 --- /dev/null +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-009.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-010.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..befb1525c176e8b6807fbf0fc38d5af6c92deaaf --- /dev/null +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-010.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-011.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..84ecfebaf488eeb483fc2710b6b56c2d8d29f33a --- /dev/null +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-011.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-012.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-012.xml new file mode 100644 index 0000000000000000000000000000000000000000..84591ddf147a5a290d835bc4d34deb0a2a49ffe4 --- /dev/null +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-012.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/offline-creation/provenance.json b/corpus/page-lifecycle/offline-creation/provenance.json new file mode 100644 index 0000000000000000000000000000000000000000..09bde4165d3cb3ebc9fe348505bd3f09734a3993 --- /dev/null +++ b/corpus/page-lifecycle/offline-creation/provenance.json @@ -0,0 +1,27 @@ +{ + "run": { + "notebook": "/Users/clo/dev/one/evidence/m10/offline-page-candidate", + "expected_pages": 13, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } + }, + "machine": { + "name": "m6-cc15a5ee", + "hostname": "ONE-M6-CC15A5EE" + }, + "teardown": { + "absent": true + } +} diff --git a/crates/onestore-offline/README.md b/crates/onestore-offline/README.md index 3b7de1d28cae619876fbb2d18d24fcddcdcb599b..59cc0655dced263b3bffe84b9a01a6874feea914 100644 --- a/crates/onestore-offline/README.md +++ b/crates/onestore-offline/README.md @@ -32,11 +32,21 @@ the new text for subsequent offline edits. Pending entries expose `Operation::Text(TextEdit)`, `Operation::Insert(Insertion)`, `Operation::Format(FormatEdit)`, `Operation::Split(SplitEdit)`, `Operation::Join(JoinEdit)`, `Operation::Outline(OutlineEdit)` and -`Operation::Tree(TreeEdit)` through their +`Operation::Tree(TreeEdit)` and `Operation::CreatePage(PageCreation)` through their `operation` field. Synchronization applies these in queue order, so an inserted outline can precede its paragraphs and their later edits. Missing anchors or existing insertion identities preserve a conflict and the complete local image. +`create_page` accepts the core `PageCreation` intent and queues its page space +and section entry as one publication. Subsequent outlines and title edits use +the intent's stable identities immediately after local acknowledgement. +Independent page additions rebase against the current section order; duplicate +titles remain distinct. An unavailable or no-longer-leading insertion anchor +produces `StructureChanged`. `rebase_page_creation_conflict(id, local, remote, +before)` reviews a replacement anchor against both cache images while retaining +the new page and dependent object identities. Existing page identities require +reconciliation; a matching page alone does not establish a receipt. + `split` and `join` accept the core `ParagraphSplit` and `ParagraphJoin` intents. Splits retain allocated identities when the original UTF-16 boundary rebases; dependent edits can address `ParagraphSplit::text_object()` immediately after @@ -116,7 +126,10 @@ remain the same before replay or review; later queued edits keep their targets. Uncertain tree attempts require their original revision for confirmation, even when an independent move or deletion has the same visible effect. -Recognized earlier caches migrate transactionally to version nine. Version-eight +Recognized earlier caches migrate transactionally to version ten. Publication +attempts retain every changed space's revision; legacy attempts retain their +single-space evidence. Earlier recovery archives remain readable without migration. +Version-eight deletion observations retain their original identity-sensitive preconditions; explicit conflict review upgrades them to the immutable-content comparison. The migration retains images, local IDs, publication attempts, conflicts, @@ -134,7 +147,8 @@ retrying. `sync_once(&mut remote)` processes the oldest pending edit through a `Remote` implementation, returning its ID and `EditStatus`. A durable `Published` receipt survives reopening. Publication attempts are recorded before network I/O; a retained -attempted revision requires comparison, flushing and refreshed header version +attempt requires every recorded revision to remain present, followed by comparison, +flushing and refreshed header version metadata before acknowledgement. If a formatting attempt's revision is missing, the complete requested effect can instead be confirmed on a uniquely aligned range; its receipt identifies that confirmed current revision. Otherwise the diff --git a/crates/onestore-offline/examples/cache_probe.rs b/crates/onestore-offline/examples/cache_probe.rs index d7ab493d9e48d0f610994266a1a9cc32b6cb2f68..9a3f1e4b3d2c729926f79fe3c337c49193dcaa66 100644 --- a/crates/onestore-offline/examples/cache_probe.rs +++ b/crates/onestore-offline/examples/cache_probe.rs @@ -139,6 +139,7 @@ fn main() -> Result<(), Box> { *value as u64 - 6 } onestore_offline::Operation::Split(_) + | onestore_offline::Operation::CreatePage(_) | onestore_offline::Operation::Join(_) | onestore_offline::Operation::Outline(_) | onestore_offline::Operation::Tree(_) => { diff --git a/crates/onestore-offline/src/lib.rs b/crates/onestore-offline/src/lib.rs index e95c56c6e1c9c577930580b3fcc09c0715d1c6be..8bad5718bdd02719993f4aec63a5983935b04250 100644 --- a/crates/onestore-offline/src/lib.rs +++ b/crates/onestore-offline/src/lib.rs @@ -2,7 +2,7 @@ #![doc = include_str!("../README.md")] use onestore::{ - ExGuid, Insertion, PreparedEdit, RevisionIndex, Store, + ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store, document::{Document, Kind}, }; use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; @@ -51,7 +51,7 @@ pub enum Error { type Result = std::result::Result; const APPLICATION_ID: u32 = 0x4f4e454f; -const SCHEMA_VERSION: u32 = 9; +const SCHEMA_VERSION: u32 = 10; /// Text and its observed precondition, retained across cache reopen and rebasing. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -66,6 +66,7 @@ pub struct TextEdit { #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] pub enum Operation { + CreatePage(PageCreation), Text(TextEdit), Insert(Insertion), Format(FormatEdit), @@ -232,6 +233,17 @@ impl Replica { self.record(source, space, Operation::Insert(insertion.clone()), &edit) } + /// Queues a new page and its section entry with stable identities for dependent edits. + pub fn create_page(&self, source: &[u8], page: &PageCreation) -> Result> { + let edit = PreparedEdit::create_page(source, page)?; + self.record( + source, + page.space(), + Operation::CreatePage(page.clone()), + &edit, + ) + } + fn record( &self, source: &[u8], @@ -372,12 +384,20 @@ fn pending(connection: &Connection) -> Result> { let mut rows = query.query([])?; let mut edits = Vec::new(); while let Some(row) = rows.next()? { - edits.push(PendingEdit { + let edit = PendingEdit { id: u64::try_from(row.get::<_, i64>(0)?).map_err(io::Error::other)?, space: row.get::<_, String>(1)?.parse()?, operation: serde_json::from_str(&row.get::<_, String>(2)?) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?, - }); + }; + if matches!(&edit.operation, Operation::CreatePage(page) if page.space() != edit.space) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Cached page identity differs from its creation intent", + ) + .into()); + } + edits.push(edit); } Ok(edits) } diff --git a/crates/onestore-offline/src/schema.rs b/crates/onestore-offline/src/schema.rs index ecbbb5b526c81f4247c9e7cbb1942f49223d89bf..4e3af92b6e7452778c95ea8f32702cabc429d494 100644 --- a/crates/onestore-offline/src/schema.rs +++ b/crates/onestore-offline/src/schema.rs @@ -22,7 +22,7 @@ pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> { CREATE TABLE attempt ( id INTEGER PRIMARY KEY CHECK(id=1), edit_id INTEGER NOT NULL UNIQUE REFERENCES edits(id) ON DELETE CASCADE, - revision TEXT NOT NULL + revisions TEXT NOT NULL ) STRICT; CREATE TABLE receipts ( edit_id INTEGER PRIMARY KEY CHECK(edit_id>0), @@ -44,6 +44,8 @@ pub(crate) fn migrate(transaction: &Transaction<'_>, version: u32) -> Result<()> if version < 5 { transaction.execute_batch(ASSETS)?; } + transaction.execute_batch("ALTER TABLE attempt RENAME COLUMN revision TO revisions;")?; + migrate_attempts(transaction)?; return Ok(()); } @@ -125,5 +127,14 @@ pub(crate) fn migrate(transaction: &Transaction<'_>, version: u32) -> Result<()> for (id, revision) in receipts { transaction.execute("INSERT INTO receipts VALUES (?1,?2)", params![id, revision])?; } + migrate_attempts(transaction)?; + Ok(()) +} + +fn migrate_attempts(transaction: &Transaction<'_>) -> Result<()> { + transaction.execute_batch( + "UPDATE attempt SET revisions=json_object( + (SELECT space FROM edits WHERE edits.id=attempt.edit_id), revisions);", + )?; Ok(()) } diff --git a/crates/onestore-offline/src/sync.rs b/crates/onestore-offline/src/sync.rs index 1e550bf9bc31a836be899bb3ee07648c2eedf46c..098208ceed46534143953a19b415265b12707ff8 100644 --- a/crates/onestore-offline/src/sync.rs +++ b/crates/onestore-offline/src/sync.rs @@ -1,7 +1,10 @@ use super::*; use onestore::{CommitError, CommitState}; use rusqlite::OptionalExtension; -use std::sync::{MutexGuard, TryLockError}; +use std::{ + collections::BTreeMap, + sync::{MutexGuard, TryLockError}, +}; /// A single remote file with fresh reads and native-compatible guarded publication. /// Errors retain publication state; confirmation compares, flushes, and notifies cached readers. @@ -91,7 +94,7 @@ impl Replica { }; let attempted = transaction .query_row( - "SELECT revision FROM attempt WHERE edit_id=?1", + "SELECT revisions FROM attempt WHERE edit_id=?1", [i64::try_from(intent.id).map_err(io::Error::other)?], |row| row.get::<_, String>(0), ) @@ -100,17 +103,19 @@ impl Replica { transaction.commit()?; (intent, attempted) }; - if let Some(revision) = attempted { - let mut revision = revision.parse::()?; + if let Some(encoded) = attempted { + let revisions = attempted_revisions(&encoded, intent.space)?; + let mut revision = revisions[&intent.space]; let store = Store::parse(&snapshot)?; let index = RevisionIndex::parse(&store)?; - if !index - .spaces - .get(&intent.space) - .is_some_and(|space| space.revisions.contains_key(&revision)) - { + if !revisions.iter().all(|(space, revision)| { + index + .spaces + .get(space) + .is_some_and(|space| space.revisions.contains_key(revision)) + }) { let satisfied = match &intent.operation { - Operation::Format(edit) => edit + Operation::Format(edit) if revisions.len() == 1 => edit .prepare(&snapshot, intent.space)? .is_ok_and(|prepared| prepared.as_bytes() == snapshot), _ => false, @@ -133,6 +138,8 @@ impl Replica { return Ok(Some((intent.id, EditStatus::Published { revision }))); } let candidate = match &intent.operation { + Operation::CreatePage(page) => PreparedEdit::create_page(&snapshot, page) + .map_err(|_| ConflictKind::StructureChanged), Operation::Format(edit) => edit.prepare(&snapshot, intent.space)?, Operation::Split(edit) => edit.prepare(&snapshot, intent.space)?, Operation::Join(edit) => edit.prepare(&snapshot, intent.space)?, @@ -186,6 +193,21 @@ impl Replica { let store = Store::parse(prepared.as_bytes())?; let index = RevisionIndex::parse(&store)?; let revision = index.spaces[&intent.space].labels[&(ExGuid::default(), 1)]; + let before_store = Store::parse(&snapshot)?; + let before = RevisionIndex::parse(&before_store)?; + let revisions: BTreeMap<_, _> = index + .spaces + .iter() + .filter_map(|(sid, space)| { + let revision = *space.labels.get(&(ExGuid::default(), 1))?; + (before + .spaces + .get(sid) + .and_then(|s| s.labels.get(&(ExGuid::default(), 1))) + != Some(&revision)) + .then_some((*sid, revision)) + }) + .collect(); { let mut connection = self .connection @@ -198,10 +220,10 @@ impl Replica { [i64::try_from(intent.id).map_err(io::Error::other)?], )?; transaction.execute( - "INSERT INTO attempt(id, edit_id, revision) VALUES (1, ?1, ?2)", + "INSERT INTO attempt(id, edit_id, revisions) VALUES (1, ?1, ?2)", params![ i64::try_from(intent.id).map_err(io::Error::other)?, - revision.to_string() + serde_json::to_string(&revisions).map_err(io::Error::other)? ], )?; transaction.commit()?; @@ -322,10 +344,39 @@ impl Replica { ) .into()); } + Operation::CreatePage(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Review page placement using rebase_page_creation_conflict", + ) + .into()); + } }) }) } + /// Repositions an unattempted page-creation conflict, retaining dependent object identities. + pub fn rebase_page_creation_conflict( + &self, + id: u64, + local: &[u8], + remote: &[u8], + before: Option, + ) -> Result<()> { + self.resolve_conflict(id, local, remote, |intent| { + let Operation::CreatePage(page) = intent.operation else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Select a page-creation conflict", + ) + .into()); + }; + let page = page.reposition(before)?; + PreparedEdit::create_page(remote, &page)?; + Ok(Operation::CreatePage(page)) + }) + } + /// Reviews a join against both current cache images, retaining its original text identities. /// The surviving text identity must remain the same for dependent edits. pub fn rebase_join_conflict(&self, id: u64, local: &[u8], remote: &[u8]) -> Result<()> { @@ -529,14 +580,25 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result, Option)> = connection.query_row( - "SELECT attempt.revision, conflicts.kind FROM edits LEFT JOIN attempt ON attempt.edit_id=edits.id LEFT JOIN conflicts ON conflicts.edit_id=edits.id WHERE edits.id=?1", [id], |row| Ok((row.get(0)?,row.get(1)?))).optional()?; + let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; + let column = if version < 10 { + "revision" + } else { + "revisions" + }; + let record: Option<(Option, Option, String)> = connection.query_row( + &format!("SELECT attempt.{column}, conflicts.kind, edits.space FROM edits LEFT JOIN attempt ON attempt.edit_id=edits.id LEFT JOIN conflicts ON conflicts.edit_id=edits.id WHERE edits.id=?1"), [id], |row| Ok((row.get(0)?,row.get(1)?, row.get(2)?))).optional()?; Ok(match record { None => None, - Some((Some(revision), _)) => Some(EditStatus::AwaitingConfirmation { - revision: revision.parse()?, + Some((Some(revision), _, space)) => Some(EditStatus::AwaitingConfirmation { + revision: if version < 10 { + revision.parse()? + } else { + let space = space.parse()?; + attempted_revisions(&revision, space)?[&space] + }, }), - Some((None, Some(kind))) => Some(EditStatus::Conflict(match kind { + Some((None, Some(kind), _)) => Some(EditStatus::Conflict(match kind { 0 => ConflictKind::TextChanged, 1 => ConflictKind::TargetUnavailable, 2 => ConflictKind::UnsupportedEdit, @@ -552,6 +614,23 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result Some(EditStatus::Pending), + Some((None, None, _)) => Some(EditStatus::Pending), }) } + +fn attempted_revisions(encoded: &str, space: ExGuid) -> Result> { + let revisions: BTreeMap = serde_json::from_str(encoded) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if !revisions.contains_key(&space) + || revisions + .iter() + .any(|(sid, rid)| sid.guid == [0; 16] || rid.guid == [0; 16]) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Cached publication evidence is incomplete", + ) + .into()); + } + Ok(revisions) +} diff --git a/crates/onestore-offline/src/tree.rs b/crates/onestore-offline/src/tree.rs index ab8077974486d99c57f1da6f46ec1fc7557779a0..f696f9bdae9f023d64d2a534c9b0848f59bf5bdb 100644 --- a/crates/onestore-offline/src/tree.rs +++ b/crates/onestore-offline/src/tree.rs @@ -509,6 +509,8 @@ mod tests { ) .unwrap(); db.pragma_update(None, "user_version", 8).unwrap(); + db.execute_batch("ALTER TABLE attempt RENAME COLUMN revisions TO revision;") + .unwrap(); drop(db); let cache = Replica::open(&path).unwrap(); assert_eq!(cache.pending().unwrap(), queue); diff --git a/crates/onestore-offline/tests/cache.rs b/crates/onestore-offline/tests/cache.rs index dc9efa6f7116b46952eecb7cc41e4343110346f9..554e50de35068d76ea96c781fb4eb53785136459 100644 --- a/crates/onestore-offline/tests/cache.rs +++ b/crates/onestore-offline/tests/cache.rs @@ -784,7 +784,7 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { db.execute("UPDATE edits SET operation=?1", [value.to_string()]) .unwrap(); if matches!(operation, "Text" | "Insert") { - db.execute_batch("DROP TABLE assets; DROP TABLE conflicts; CREATE TABLE conflicts (edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 2)) STRICT; PRAGMA user_version=3;").unwrap(); + db.execute_batch("ALTER TABLE attempt RENAME COLUMN revisions TO revision; DROP TABLE assets; DROP TABLE conflicts; CREATE TABLE conflicts (edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 2)) STRICT; PRAGMA user_version=3;").unwrap(); } drop(db); let before = fs::read(&path).unwrap(); @@ -797,7 +797,7 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { #[test] fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_content_conflicts() { - for (version, ceiling) in [(5, 3), (6, 4), (7, 5), (8, 6)] { + for (version, ceiling) in [(5, 3), (6, 4), (7, 5), (8, 6), (9, 6)] { use onestore_offline::{ConflictKind, EditStatus, Recovery}; use sha2::{Digest, Sha256}; let directory = tempfile::tempdir().unwrap(); @@ -837,7 +837,8 @@ fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_content_confl ) .unwrap(); db.execute_batch(&format!( - "DROP TABLE conflicts; CREATE TABLE conflicts ( + "ALTER TABLE attempt RENAME COLUMN revisions TO revision; + DROP TABLE conflicts; CREATE TABLE conflicts ( edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND {ceiling})) STRICT; PRAGMA user_version={version}; @@ -860,6 +861,22 @@ fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_content_confl ) .unwrap(); drop(db); + let archive = directory.path().join("legacy-recovery.sqlite"); + std::fs::copy(&path, &archive).unwrap(); + let archive_db = rusqlite::Connection::open(&archive).unwrap(); + archive_db + .pragma_update(None, "application_id", 0x4f4e4552) + .unwrap(); + drop(archive_db); + let original_archive = std::fs::read(&archive).unwrap(); + let recovery = Recovery::open(&archive).unwrap(); + assert_eq!( + recovery.status(first).unwrap(), + Some(EditStatus::AwaitingConfirmation { revision }) + ); + assert_eq!(recovery.pending().unwrap(), queue); + drop(recovery); + assert!(std::fs::read(&archive).unwrap() == original_archive); let cache = Replica::open(&path).unwrap(); assert_eq!(cache.pending().unwrap(), queue); assert_eq!( diff --git a/crates/onestore-offline/tests/support/page_schedule.rs b/crates/onestore-offline/tests/support/page_schedule.rs new file mode 100644 index 0000000000000000000000000000000000000000..de836500ae5000559cea1e6210d43e28c3fb2012 --- /dev/null +++ b/crates/onestore-offline/tests/support/page_schedule.rs @@ -0,0 +1,156 @@ +use crate::disk; +use onestore::{ + CommitError, ExGuid, Insertion, PreparedEdit, RevisionIndex, Store, + document::{Document, Kind}, +}; +use onestore_offline::{EditStatus, Operation, Remote, Replica}; +use std::{io, sync::LazyLock}; + +#[path = "../../../onestore/tests/support/current.rs"] +mod current; + +static SOURCE: LazyLock> = + LazyLock::new(|| onestore::create_section("page-schedule.one", "Original", "Author").unwrap()); + +fn pages(source: &[u8]) -> Vec<(ExGuid, ExGuid)> { + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + index.validate_current().unwrap(); + Document::parse(&index).unwrap().pages().unwrap() +} + +struct Session<'a> { + disk: &'a mut disk::Disk, + operation: Option<&'a Operation>, +} + +impl Remote for Session<'_> { + fn read(&mut self) -> io::Result> { + Ok(self.disk.visible.clone()) + } + + fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + let mut expected = pages(&self.disk.visible); + match self.operation.unwrap() { + Operation::CreatePage(page) => expected.push((page.space(), page.object())), + Operation::Insert(insertion) => { + let store = Store::parse(edit.as_bytes()).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let matching: Vec<_> = document + .spaces + .values() + .filter_map(|space| { + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + view.nodes.get(&insertion.text_object()) + }) + .collect(); + assert_eq!(matching.len(), 1); + assert!( + matches!(&matching[0].kind, Kind::RichText { text, .. } if text == "Body 🦋 é") + ); + } + _ => panic!(), + } + assert_eq!(pages(edit.as_bytes()), expected); + let old = current::current(&self.disk.durable); + let new = current::current(edit.as_bytes()); + let result = edit.commit(self.disk); + let observed = current::current(&self.disk.durable); + assert!(observed == old || observed == new); + if result.is_ok() { + assert_eq!(observed, new); + } + result + } + + fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { + onestore::confirm_snapshot(self.disk, snapshot) + } +} + +pub fn run(input: &[u8]) { + let directory = tempfile::tempdir().unwrap(); + let mut replicas: [Option; 12] = std::array::from_fn(|_| None); + let mut owned: [Vec<(ExGuid, ExGuid)>; 12] = std::array::from_fn(|_| Vec::new()); + let mut disk = disk::Disk { + visible: SOURCE.clone(), + durable: SOURCE.clone(), + operation: 0, + fail_at: None, + write_limit: 4096, + random: 1, + }; + for step in input.chunks_exact(8).take(48) { + let actor = usize::from(step[0]) % replicas.len(); + let path = directory.path().join(format!("{actor}.sqlite")); + let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, &SOURCE).unwrap()); + let snapshot = cache.snapshot().unwrap(); + let pending = cache.pending().unwrap(); + match step[1] % 6 { + 0 | 1 => { + let title = (step[2] & 1 != 0).then_some("Same 🦋 é"); + let page = onestore::PageCreation::new(None, title, "Author").unwrap(); + cache.create_page(&snapshot, &page).unwrap().unwrap(); + owned[actor].push((page.space(), page.object())); + if step[1] % 6 == 1 { + let insertion = + Insertion::outline(page.object(), 36.0, 36.0, "Body 🦋 é", "Author") + .unwrap(); + cache + .insert(&cache.snapshot().unwrap(), page.space(), &insertion) + .unwrap() + .unwrap(); + } + } + 2 => { + let statuses: Vec<_> = pending + .iter() + .map(|edit| cache.status(edit.id).unwrap()) + .collect(); + replicas[actor] = None; + let cache = Replica::open(&path).unwrap(); + assert!(cache.snapshot().unwrap() == snapshot); + assert_eq!(cache.pending().unwrap(), pending); + assert_eq!( + pending + .iter() + .map(|edit| cache.status(edit.id).unwrap()) + .collect::>(), + statuses + ); + replicas[actor] = Some(cache); + } + 3 | 4 => { + disk.operation = 0; + disk.write_limit = if step[3] & 1 == 0 { 17 } else { 4096 }; + disk.fail_at = + (step[2] != 0).then_some(usize::from(u16::from_le_bytes([step[2], step[3]]))); + disk.random = u64::from(step[4]) + 1; + let prior = pending + .first() + .and_then(|edit| cache.status(edit.id).unwrap()); + let mut session = Session { + disk: &mut disk, + operation: pending.first().map(|edit| &edit.operation), + }; + let result = cache.sync_once(&mut session); + if matches!(prior, Some(EditStatus::AwaitingConfirmation { .. })) { + assert!( + result.is_err() + || !matches!(result.unwrap(), Some((_, EditStatus::Conflict(_)))) + ); + } + } + 5 => { + disk.visible.clone_from(&disk.durable); + disk.fail_at = None; + } + _ => unreachable!(), + } + let local = pages(&replicas[actor].as_ref().unwrap().snapshot().unwrap()); + for page in &owned[actor] { + assert!(local.contains(page)); + } + } +} diff --git a/crates/onestore-offline/tests/sync.rs b/crates/onestore-offline/tests/sync.rs index 97b0459683cbff4c92c13acd022e7a3d8cebaf8b..fac7429f7c0b8a8b3a3bd17e0cae43fa5b3dc8c1 100644 --- a/crates/onestore-offline/tests/sync.rs +++ b/crates/onestore-offline/tests/sync.rs @@ -9,6 +9,10 @@ use std::io; mod disk; #[path = "sync/outline.rs"] mod outline; +#[path = "sync/page.rs"] +mod page; +#[path = "support/page_schedule.rs"] +mod page_schedule; #[path = "sync/tree.rs"] mod tree; #[path = "support/tree_schedule.rs"] diff --git a/crates/onestore-offline/tests/sync/page.rs b/crates/onestore-offline/tests/sync/page.rs new file mode 100644 index 0000000000000000000000000000000000000000..711facd5b6e82585dbcad80b9ea9630dfeb42dc4 --- /dev/null +++ b/crates/onestore-offline/tests/sync/page.rs @@ -0,0 +1,350 @@ +use super::*; +use onestore::{Insertion, PageCreation}; +use onestore_offline::{Operation, Recovery}; +use std::collections::BTreeMap; + +#[test] +fn twelve_replica_page_schedules_retain_acknowledged_pages_through_interruptions() { + for seed in 0..24_u64 { + let mut random = seed + 1956; + let mut input = Vec::new(); + for step in 0..48 { + random ^= random << 13; + random ^= random >> 7; + random ^= random << 17; + let mut action = random.to_le_bytes(); + if step < 12 { + action[0] = step; + action[1] = 1; + } + input.extend_from_slice(&action); + } + page_schedule::run(&input); + } +} + +#[test] +fn version_two_uncertain_text_migrates_without_replay_or_lost_receipts() { + let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); + let (sid, oid, _) = text(&source); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("legacy.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + let id = cache + .edit_text(&source, sid, oid, 0..0, "Old ") + .unwrap() + .unwrap(); + let snapshot = cache.snapshot().unwrap(); + let pending = cache.pending().unwrap(); + let store = Store::parse(&snapshot).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let revision = index.spaces[&sid].labels[&(ExGuid::default(), 1)]; + drop(cache); + let db = rusqlite::Connection::open(&path).unwrap(); + db.execute_batch( + "DROP TABLE edits; DROP TABLE assets; + CREATE TABLE edits ( + id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), space TEXT NOT NULL, + object TEXT NOT NULL, before_text TEXT NOT NULL, + start INTEGER NOT NULL CHECK(start BETWEEN 0 AND 4294967295), + end INTEGER NOT NULL CHECK(end BETWEEN start AND 4294967295), replacement TEXT NOT NULL + ) STRICT; + ALTER TABLE attempt RENAME COLUMN revisions TO revision; + PRAGMA user_version=2;", + ) + .unwrap(); + db.execute( + "INSERT INTO edits VALUES (?1,?2,?3,'Original',0,0,'Old ')", + rusqlite::params![i64::try_from(id).unwrap(), sid.to_string(), oid.to_string()], + ) + .unwrap(); + db.execute( + "INSERT INTO attempt VALUES (1,?1,?2)", + rusqlite::params![i64::try_from(id).unwrap(), revision.to_string()], + ) + .unwrap(); + db.execute( + "INSERT INTO receipts VALUES (1000,?1)", + [revision.to_string()], + ) + .unwrap(); + db.execute("UPDATE sqlite_sequence SET seq=1000 WHERE name='edits'", []) + .unwrap(); + drop(db); + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.pending().unwrap(), pending); + assert_eq!( + cache.status(id).unwrap(), + Some(EditStatus::AwaitingConfirmation { revision }) + ); + assert_eq!( + cache.status(1000).unwrap(), + Some(EditStatus::Published { revision }) + ); + assert!(cache.snapshot().unwrap() == snapshot); + let mut server = Server::new(&snapshot); + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Published { revision })) + ); + assert_eq!(server.publications, 0); + assert_eq!(server.confirmations, 1); + assert_eq!( + cache + .edit_text(&cache.snapshot().unwrap(), sid, oid, 0..0, "Next ") + .unwrap(), + Some(1001) + ); +} + +#[test] +fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() { + let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); + let directory = tempfile::tempdir().unwrap(); + let mut server = Server::new(&source); + let mut expected = Vec::new(); + for actor in 0..12 { + let path = directory.path().join(format!("{actor}.sqlite")); + let cache = Replica::create(&path, &source).unwrap(); + let page = PageCreation::new(None, Some("Same 🦋 é"), "Offline author").unwrap(); + let id = cache.create_page(&source, &page).unwrap().unwrap(); + let insertion = Insertion::outline(page.object(), 36.0, 36.0, "Body", "Author").unwrap(); + cache + .insert(&cache.snapshot().unwrap(), page.space(), &insertion) + .unwrap() + .unwrap(); + cache + .edit_text( + &cache.snapshot().unwrap(), + page.space(), + insertion.text_object(), + 4..4, + &format!(" {actor}"), + ) + .unwrap() + .unwrap(); + let original = cache.pending().unwrap(); + assert!( + matches!(&original[0].operation, Operation::CreatePage(retained) if retained == &page) + ); + let local = cache.snapshot().unwrap(); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.pending().unwrap(), original); + assert_eq!(cache.snapshot().unwrap(), local); + for edit in original { + assert!(matches!(cache.sync_once(&mut server).unwrap(), + Some((published, EditStatus::Published { .. })) if published == edit.id)); + } + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) + )); + expected.push(( + page.space(), + page.object(), + insertion.text_object(), + format!("Body {actor}"), + )); + } + let store = Store::parse(&server.durable).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let pages = document.pages().unwrap(); + assert_eq!(pages.len(), 13); + for (actual, (sid, page, text, expected)) in pages[1..].iter().zip(&expected) { + assert_eq!(*actual, (*sid, *page)); + let space = &document.spaces[sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + assert!(matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text == expected)); + } + assert_eq!(server.publications, 36); + if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_OUTPUT") { + std::fs::create_dir(&output).unwrap(); + std::fs::write( + std::path::Path::new(&output).join("pages.one"), + &server.durable, + ) + .unwrap(); + } +} + +#[test] +fn uncertain_page_publication_retains_both_revisions_and_never_replays() { + for fault in [ + Fault::UnknownBefore, + Fault::UnknownAfter, + Fault::PanicBefore, + Fault::PanicAfter, + ] { + let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("pages.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + let page = PageCreation::new(None, Some("Created"), "Author").unwrap(); + let id = cache.create_page(&source, &page).unwrap().unwrap(); + let local = cache.snapshot().unwrap(); + let mut server = Server::new(&source); + server.fault = fault; + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + cache.sync_once(&mut server) + })); + let attempted = cache.status(id).unwrap().unwrap(); + assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. })); + let archive = directory.path().join("recovery.sqlite"); + cache.export_recovery(&archive).unwrap(); + assert_eq!( + Recovery::open(&archive).unwrap().status(id).unwrap(), + Some(attempted) + ); + drop(cache); + let db = rusqlite::Connection::open(&path).unwrap(); + let encoded: String = db + .query_row("SELECT revisions FROM attempt", [], |row| row.get(0)) + .unwrap(); + let revisions: BTreeMap = serde_json::from_str(&encoded).unwrap(); + assert_eq!(revisions.len(), 2); + assert!(revisions.contains_key(&page.space())); + drop(db); + let cache = Replica::open(&path).unwrap(); + let observed = server.visible.clone(); + let result = cache.sync_once(&mut server).unwrap().unwrap(); + let visible = matches!(fault, Fault::UnknownAfter | Fault::PanicAfter); + assert_eq!(server.publications, 1); + assert_eq!(server.confirmations, usize::from(visible)); + if visible { + assert!(matches!(result.1, EditStatus::Published { .. })); + assert!(cache.snapshot().unwrap() == observed); + assert!(observed[..212] == server.durable[..212]); + assert!(observed[252..] == server.durable[252..]); + } else { + assert_eq!(result, (id, attempted)); + assert_eq!(cache.snapshot().unwrap(), local); + } + } +} + +#[test] +fn surviving_page_revision_alone_does_not_confirm_section_publication() { + let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("pages.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + let page = PageCreation::new(None, None, "Author").unwrap(); + let id = cache.create_page(&source, &page).unwrap().unwrap(); + let mut server = Server::new(&source); + server.fault = Fault::UnknownAfter; + assert!(cache.sync_once(&mut server).is_err()); + let attempted = cache.status(id).unwrap().unwrap(); + drop(cache); + let db = rusqlite::Connection::open(&path).unwrap(); + let encoded: String = db + .query_row("SELECT revisions FROM attempt", [], |row| row.get(0)) + .unwrap(); + let revisions: BTreeMap = serde_json::from_str(&encoded).unwrap(); + drop(db); + let complete = server.visible.clone(); + let (§ion, &retired) = revisions + .iter() + .find(|(sid, _)| **sid != page.space()) + .unwrap(); + let store = Store::parse(&complete).unwrap(); + let mut positions = Vec::new(); + for list in store.lists.values() { + for node in &list.nodes { + if node.id == 0x1e + && node.payload[..16] == retired.guid + && node.payload[16..20] == retired.n.to_le_bytes() + { + positions.push(node.offset); + } + } + } + assert_eq!(positions.len(), 1); + // Replacing only the revision identity models maintenance retiring the section proof. + let at = positions[0] + 4; + server.visible[at] ^= 0x40; + let store = Store::parse(&server.visible).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + index.validate_current().unwrap(); + assert!(!index.spaces[§ion].revisions.contains_key(&retired)); + assert!( + index.spaces[&page.space()] + .revisions + .contains_key(&revisions[&page.space()]) + ); + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, attempted))); + assert_eq!(server.confirmations, 0); + assert_eq!(server.publications, 1); + server.visible = complete; + assert!(matches!( + cache.sync_once(&mut server).unwrap(), + Some((_, EditStatus::Published { .. })) + )); + assert_eq!(server.publications, 1); +} + +#[test] +fn changed_page_anchor_requires_review_without_regenerating_dependent_identities() { + let source = + include_bytes!("../../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one"); + let remote = + include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let pages = Document::parse(&index).unwrap().pages().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("pages.sqlite"); + let cache = Replica::create(&path, source).unwrap(); + let page = PageCreation::new(Some(pages[4].0), Some("Created"), "Author").unwrap(); + let id = cache.create_page(source, &page).unwrap().unwrap(); + let insertion = + Insertion::outline(page.object(), 36.0, 36.0, "Retained body", "Author").unwrap(); + cache + .insert(&cache.snapshot().unwrap(), page.space(), &insertion) + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + let pending = cache.pending().unwrap(); + let mut server = Server::new(remote); + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) + ); + assert!( + cache + .rebase_page_creation_conflict(id, source, remote, None) + .is_err() + ); + assert!( + cache + .rebase_page_creation_conflict(id, &local, source, None) + .is_err() + ); + cache + .rebase_page_creation_conflict(id, &local, remote, None) + .unwrap(); + assert_eq!(cache.snapshot().unwrap(), local); + assert_eq!(cache.pending().unwrap()[1], pending[1]); + let Operation::CreatePage(reviewed) = &cache.pending().unwrap()[0].operation else { + panic!() + }; + assert_eq!(*reviewed, page.reposition(None).unwrap()); + drop(cache); + let cache = Replica::open(&path).unwrap(); + for _ in 0..2 { + assert!(matches!( + cache.sync_once(&mut server).unwrap(), + Some((_, EditStatus::Published { .. })) + )); + } + assert_eq!(server.publications, 2); + let store = Store::parse(&server.durable).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let space = &document.spaces[&page.space()]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + assert!(matches!(&view.nodes[&insertion.text_object()].kind, + Kind::RichText { text, .. } if text == "Retained body")); +} diff --git a/crates/onestore/src/page.rs b/crates/onestore/src/page.rs index bf44af9cf36e99aa638570ab4770adf2108157c2..4365eb796248336dce961141b15f761828d252d5 100644 --- a/crates/onestore/src/page.rs +++ b/crates/onestore/src/page.rs @@ -62,6 +62,14 @@ impl PageCreation { }) } + /// Changes the insertion anchor while retaining all identities and creation metadata. + pub fn reposition(&self, before: Option) -> Result { + let mut page = self.clone(); + page.before = before; + page.validate()?; + Ok(page) + } + fn validate(&self) -> Result<(), Error> { if self.guid == [0; 16] || self.series_guid == [0; 16] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 13a8e908866d6a6d86414023b19f09314deaeb60..296bdf5e9816cb4c97fbb00ded598f66df6477d0 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -121,3 +121,10 @@ path = "fuzz_targets/page.rs" test = false doc = false bench = false + +[[bin]] +name = "offline_page" +path = "fuzz_targets/offline_page.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/offline_page.rs b/fuzz/fuzz_targets/offline_page.rs new file mode 100644 index 0000000000000000000000000000000000000000..103617b5c15807aef9f01363225b4384d4d366e8 --- /dev/null +++ b/fuzz/fuzz_targets/offline_page.rs @@ -0,0 +1,9 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; + +#[path = "../../crates/onestore/tests/support/disk.rs"] +mod disk; +#[path = "../../crates/onestore-offline/tests/support/page_schedule.rs"] +mod page_schedule; + +fuzz_target!(|input: &[u8]| page_schedule::run(input)); diff --git a/tools/test_page_creation.py b/tools/test_page_creation.py index 5a0c777299347384402c658db116e7fa874901d5..2ebdc63791729cfa1b434736467d9572d8880d4c 100644 --- a/tools/test_page_creation.py +++ b/tools/test_page_creation.py @@ -11,6 +11,10 @@ FIXTURE = Path(__file__).resolve().parent.parent / 'corpus/page-lifecycle/creati class PageCreationTest(unittest.TestCase): + def test_offline_created_pages_and_dependent_bodies_survive_native_reopen(self): + fixture = FIXTURE.parent / 'offline-creation' + compare(fixture / 'candidate', fixture / 'cold') + def test_created_pages_and_both_writers_survive_native_cold_reopens(self): for source, capture in [('candidate', 'cold'), ('native/notebook', 'native/cold'), ('followup', 'followup/cold')]: with self.subTest(source=source):