From 75c6fcf3b31d17b367dd1177074446f029c21196 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Tue, 8 Sep 2026 22:11:26 -0700 Subject: [PATCH] feat: persist offline page creation with complete publication proofs Queue page creation and dependent edits with stable identities. Retain every affected space revision before publication; uncertain page creation requires both page and section evidence. Preserve legacy cache attempts and read older recovery archives without migration. Reviewed anchor changes retain dependent identities. Validation: 105 offline and 151 Python tests; 24 deterministic twelve-replica schedules; 64 ASan schedules; native reopen of 36 reconciled intents across 13 pages; workspace Clippy and iOS compilation. Owned clone removed. Acceptance: evidence/m10/OFFLINE-PAGE-CREATION.md. Assisted-by: gpt-6-astra --- .../page-lifecycle/offline-creation/README.md | 25 ++ .../offline-creation/candidate/pages.one | Bin 0 -> 50384 bytes .../offline-creation/cold/notebook/pages.one | Bin 0 -> 51864 bytes .../offline-creation/cold/read/hierarchy.xml | 2 + .../offline-creation/cold/read/page-000.xml | 2 + .../offline-creation/cold/read/page-001.xml | 2 + .../offline-creation/cold/read/page-002.xml | 2 + .../offline-creation/cold/read/page-003.xml | 2 + .../offline-creation/cold/read/page-004.xml | 2 + .../offline-creation/cold/read/page-005.xml | 2 + .../offline-creation/cold/read/page-006.xml | 2 + .../offline-creation/cold/read/page-007.xml | 2 + .../offline-creation/cold/read/page-008.xml | 2 + .../offline-creation/cold/read/page-009.xml | 2 + .../offline-creation/cold/read/page-010.xml | 2 + .../offline-creation/cold/read/page-011.xml | 2 + .../offline-creation/cold/read/page-012.xml | 2 + .../offline-creation/provenance.json | 27 ++ crates/onestore-offline/README.md | 20 +- .../onestore-offline/examples/cache_probe.rs | 1 + crates/onestore-offline/src/lib.rs | 28 +- crates/onestore-offline/src/schema.rs | 13 +- crates/onestore-offline/src/sync.rs | 115 +++++- crates/onestore-offline/src/tree.rs | 2 + crates/onestore-offline/tests/cache.rs | 23 +- .../tests/support/page_schedule.rs | 156 ++++++++ crates/onestore-offline/tests/sync.rs | 4 + crates/onestore-offline/tests/sync/page.rs | 350 ++++++++++++++++++ crates/onestore/src/page.rs | 8 + fuzz/Cargo.toml | 7 + fuzz/fuzz_targets/offline_page.rs | 9 + tools/test_page_creation.py | 4 + 32 files changed, 791 insertions(+), 29 deletions(-) create mode 100644 corpus/page-lifecycle/offline-creation/README.md create mode 100644 corpus/page-lifecycle/offline-creation/candidate/pages.one create mode 100644 corpus/page-lifecycle/offline-creation/cold/notebook/pages.one create mode 100644 corpus/page-lifecycle/offline-creation/cold/read/hierarchy.xml create mode 100644 corpus/page-lifecycle/offline-creation/cold/read/page-000.xml create mode 100644 corpus/page-lifecycle/offline-creation/cold/read/page-001.xml create mode 100644 corpus/page-lifecycle/offline-creation/cold/read/page-002.xml create mode 100644 corpus/page-lifecycle/offline-creation/cold/read/page-003.xml create mode 100644 corpus/page-lifecycle/offline-creation/cold/read/page-004.xml create mode 100644 corpus/page-lifecycle/offline-creation/cold/read/page-005.xml create mode 100644 corpus/page-lifecycle/offline-creation/cold/read/page-006.xml create mode 100644 corpus/page-lifecycle/offline-creation/cold/read/page-007.xml create mode 100644 corpus/page-lifecycle/offline-creation/cold/read/page-008.xml create mode 100644 corpus/page-lifecycle/offline-creation/cold/read/page-009.xml create mode 100644 corpus/page-lifecycle/offline-creation/cold/read/page-010.xml create mode 100644 corpus/page-lifecycle/offline-creation/cold/read/page-011.xml create mode 100644 corpus/page-lifecycle/offline-creation/cold/read/page-012.xml create mode 100644 corpus/page-lifecycle/offline-creation/provenance.json create mode 100644 crates/onestore-offline/tests/support/page_schedule.rs create mode 100644 crates/onestore-offline/tests/sync/page.rs create mode 100644 fuzz/fuzz_targets/offline_page.rs 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 GIT binary patch literal 50384 zcmeHw2YeJo^#6t$q=jmb00#yMMGZX&=p`W?l2F7@lmjWD2oVKBQI8&w-kTVyp&FWm zCIL}`ibe!P1w8~c7Brw}#KP}=r(AaTc5{~i|G)lz44=u~&YOAj=CyfmcJH{{rT6$* z=QeiQzN2e$cEcqfKh?TrWbGAwV|JJSq|vE=;?F9&^4NtHJ~iiGk6NF7@BAxS+s`#3 zxVp;jH>u!rec2$cvR&8gy!~|k+fB2}^4mnZ1eN2TyU`_5w5RYU=ilsO&X4TFiEqFB zW!tR!5gYH>vv=tyj-HZ+ftH`9rY~C>wQla3F3VHLtk#aYdv})u|MxiHr6_ioSaBuL zPw^Yzr}$d@6n`IT`QeZ3`t>UBi(OOa^Q^^R{_(f{*|_Vz)(c)Vf){&nWl}Ar>dP&Z zs&D!!RRgFMb!OUS(E&ZtJs-i{le}bV;+%Lg?)%(;4-I_LJ`XAe) zPNN-tE-qiNeBjU}(R*5C*R8R0bYcLFS^vC}qLjcW@-bBJp;}rg#pqQ43=%*K0pNF< zD#d6u^Z~#G0kqORxaUnHivgHHs1^7)6uMVp_BWsOR<)xq%k@-3BbBO2id|1H5TMk~ zQtVwWKKRHe$1BlO>-~OT#}`)pBw@Tq{$JjnK2MpqOwg1~KqF2~Nm5ccU7RwB`?r$T*Xm=K2mW|)vvcQ094A6ER5FcV^!uQmgIeM`NfcbM2WpR z!M&6^q@NmY*h?|p{e|>b>f=q)OXxDR=jx>PAMH7s7B#8cduyHucqarD#VI3|!O9@2 z!M>nRz@k$D~ZYo!mB98 zGZ26Z@cGi!+ljBZpHi+e(O#8os2bIpRQ;$*{9fLC7)DdEKgMf(@QUR%9wXaQ>@}$Q zK2b|CyxBnTCD3Ge+wD&6Pj@$d2^`Cxt?bRMp+jfjt z5c}2lbuj;}*n3(jjvV~2O0~7x-ZKTzMu2Km1J(9!DNQX@;;Dk(vg(mZDj-y)oEn%Z zfbcPxx4asdCxBK0K!_D2@a^5?fFO4&7OIf07xW57_h4Z4GY`CXJ*DCwEm9jiR_g_H zP7s{~2@bJ#LTB*n^WS3L>~Vefm0mlJtXVxE_(eD;8^IvJeE(F? zdeIXF{>f;)KIEUg;R>nb@{6H=lK!1*Gubz-I6Mu z^(L;qA9EMQkN6WV%#^le{NspHT)V7Uj`%FJG|GCof`d06|c(KE*r78Lc4(LmO%dXPk#vETjdG^^ONW zqSSfyxrlWi^+{?xo;DQqXkc^tXSgX8@b#w(KlZ-*+2@hlcJF?=>DN7<1AaAM^^}8u z=(Bw2$73FKboHuyDK4S)H-n-j|Lo;~Sy60ML9@mMI|LBQ4j~}qDh_Odx%A0MPo+LU zPthB`YiP@mh!q2#j9P!A@|YUyn+2fVDBeQ5qQG}$3(2O_y^%JV`L3}UdCM_AKtxCx zG~S47X5$xRw5Ws_3)W7%XI&aj#X(itJJHDOrSNf)UOI)=D<&=;KggJ#~7RxzIogo`Ln3Nx9$#@9(^82AL3i?;1YjI5+CHs28*Vy5s zAb0Mg5WgA4MZ5i!6z4d6pb<>P?C@pqYdofsF;bHjiUo0!9B0lqQ0*Eox#tZHL$McE zdbUvfWc&n=8c$ptMAqJoxcWTtb^x_umHL>f9<{;N5PxwBq#YoVK!N@4OxU+)^65nD zq}ku4W}o@%UC?_g_L~WTP=W7-|I@@*=LFD30HhBZ;|{_isKir+scGV?6afq*0GvP* z86_bTBkO0yLVuw@n&9i409pxv={3QZFVuocJXQ451Yd~)7)StobOvAkJDT~8Sn_7+ zkyXpIpZ3R&KM{N_6P>Np5&Gf`zFv8JxNT&IelOqfy>x2dyQ|lt^EuHOQ$oRi>D1Z{h}&&~r9#z*J> z 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 literal 0 HcmV?d00001 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 GIT binary patch literal 51864 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 N2b?&dHbohC{|`cStdIZz literal 0 HcmV?d00001 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): -- 2.54.0