authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-08 22:11:26-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-08 22:11:26-07:00
log75c6fcf3b31d17b367dd1177074446f029c21196
tree81795abb5565ed1cff2848ccc9c592318c08a374
parent44df465c5e6e99c1a98916fa6cbcbde01a666164
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

32 files changed, 791 insertions(+), 29 deletions(-)

corpus/page-lifecycle/offline-creation/README.md created+25
...@@ -0,0 +1,25 @@
1# Offline page creation
2
3Twelve independent replicas start from the same one-page section. Each queues
4a page titled `Same 🦋 é`, a body outline and a dependent body-text edit, then
5reopens its cache and synchronizes. All 36 receipts complete; page identities
6remain distinct and body text identifies each originating replica.
7
8`candidate/pages.one` is the resulting 13-page section. `cold` captures its fresh
9OneNote 2010 reopen. Native XML content/order and 216 explicit character-format
10comparisons pass. Active graphs compare exactly apart from the original seed
11page's previously absent section metadata copy, materialized natively with exact
12page metadata. The creation verifier checks that specific addition.
13`provenance.json` records the capture and removal of clone m6-cc15a5ee.
14
15Set `ONESTORE_OFFLINE_PAGE_OUTPUT` to an absolute, new output directory and run
16`cargo test -p onestore-offline --all-features --test sync
17page::offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles
18-- --exact`. Capture the result with `tools/native_runner.py INPUT OUTPUT
19--expected-pages 13 --collect-notebook`. After building examples,
20`python -m unittest discover -s tools -p test_page_creation.py -v` verifies the
21retained native capture.
22
23This fixture is a serial reconciliation of twelve offline branches. The separate
24`offline_page` sanitizer target varies interleavings, interruption and reopen
25through the shared `tests/support/page_schedule.rs` model.
corpus/page-lifecycle/offline-creation/candidate/pages.one created
Binary files /dev/null and b/corpus/page-lifecycle/offline-creation/candidate/pages.one differ
corpus/page-lifecycle/offline-creation/cold/notebook/pages.one created
Binary files /dev/null and b/corpus/page-lifecycle/offline-creation/cold/notebook/pages.one differ
corpus/page-lifecycle/offline-creation/cold/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{F830A298-FD46-4884-B4C0-6F0BF45C3B90}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-09T05:08:15.000Z" color="#B49EDE"><one:Section name="pages" ID="{9ADFA36E-F0EB-051E-115A-102FF772CA03}{1}{B0}" path="C:\one-tests\runs\capture\notebook\pages.one" lastModifiedTime="2026-09-09T05:08:15.000Z" color="#8AA8E4"><one:Page ID="{1F0CC9C0-C49B-0C91-08C6-AA89176EF5BF}{1}{B0}" name="Original" dateTime="2026-09-09T05:06:23.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1"/><one:Page ID="{D3E5FB89-79C3-05BA-1805-E25CE31ED175}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:24.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1"/><one:Page ID="{AC84259B-D9B6-0A59-2748-93C35602B0BD}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:24.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1"/><one:Page ID="{CC3DEC87-5707-084C-0528-649AB24AD8B7}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:24.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1"/><one:Page ID="{7CA0430A-8905-04E4-0455-CFE53ECEE2AA}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:25.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1"/><one:Page ID="{557FC918-74CB-09B0-19E7-3D1809D43E13}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:25.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1"/><one:Page ID="{54F2D7D9-CAFE-0C6B-232B-2AE669F85BEA}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:25.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1"/><one:Page ID="{9EE67EFB-DA69-05C0-334D-18E6DCBBE33B}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:25.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1"/><one:Page ID="{52FFB786-C5E9-07F5-1BE1-C5095C3F3F9B}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:26.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1"/><one:Page ID="{1606DF11-F2FE-0BF3-2AD6-DF46E0A08ECA}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:26.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1"/><one:Page ID="{6CB51A17-61D4-0DBF-2C43-4CB4C9D2B6A1}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:26.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1"/><one:Page ID="{87E24DEE-963B-09DA-1E68-D198DC52EDA5}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:26.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1"/><one:Page ID="{AC1E7B39-43AC-0370-2960-486A10D01E43}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:27.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/page-lifecycle/offline-creation/cold/read/page-000.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{1606DF11-F2FE-0BF3-2AD6-DF46E0A08ECA}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:26.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Offline author" lastModifiedBy="Offline author" creationTime="2026-09-09T05:06:26.000Z" lastModifiedTime="2026-09-09T05:06:26.000Z" objectID="{1606DF11-F2FE-0BF3-2AD6-DF46E0A08ECA}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Same &#129419; é]]></one:T></one:OE></one:Title><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-09T05:06:26.000Z" objectID="{4DC55091-E701-07D1-1F57-520E4B9C14AD}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="97.35448455810547" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-09T05:06:26.000Z" lastModifiedTime="2026-09-09T05:06:26.000Z" objectID="{4DC55091-E701-07D1-1F57-520E4B9C14AD}{3}{B0}" alignment="left"><one:T><![CDATA[Body 8]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-lifecycle/offline-creation/cold/read/page-001.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{1F0CC9C0-C49B-0C91-08C6-AA89176EF5BF}{1}{B0}" name="Original" dateTime="2026-09-09T05:06:23.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-09T05:06:23.000Z" objectID="{9ADFA36E-F0EB-051E-115A-102FF772CA03}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="101.66748046875" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-09T05:06:23.000Z" lastModifiedTime="2026-09-09T05:06:23.000Z" objectID="{9ADFA36E-F0EB-051E-115A-102FF772CA03}{24}{B0}" alignment="left"><one:T><![CDATA[Original]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-lifecycle/offline-creation/cold/read/page-002.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{52FFB786-C5E9-07F5-1BE1-C5095C3F3F9B}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:26.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Offline author" lastModifiedBy="Offline author" creationTime="2026-09-09T05:06:26.000Z" lastModifiedTime="2026-09-09T05:06:26.000Z" objectID="{52FFB786-C5E9-07F5-1BE1-C5095C3F3F9B}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Same &#129419; é]]></one:T></one:OE></one:Title><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-09T05:06:26.000Z" objectID="{6FF29CE3-FA4E-08F5-0E60-DF83A72CF205}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="97.35448455810547" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-09T05:06:26.000Z" lastModifiedTime="2026-09-09T05:06:26.000Z" objectID="{6FF29CE3-FA4E-08F5-0E60-DF83A72CF205}{3}{B0}" alignment="left"><one:T><![CDATA[Body 7]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-lifecycle/offline-creation/cold/read/page-003.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{54F2D7D9-CAFE-0C6B-232B-2AE669F85BEA}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:25.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Offline author" lastModifiedBy="Offline author" creationTime="2026-09-09T05:06:25.000Z" lastModifiedTime="2026-09-09T05:06:25.000Z" objectID="{54F2D7D9-CAFE-0C6B-232B-2AE669F85BEA}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Same &#129419; é]]></one:T></one:OE></one:Title><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-09T05:06:25.000Z" objectID="{90835CA0-7D2C-0889-17BA-5D11B3E7B486}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="97.35448455810547" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-09T05:06:25.000Z" lastModifiedTime="2026-09-09T05:06:25.000Z" objectID="{90835CA0-7D2C-0889-17BA-5D11B3E7B486}{3}{B0}" alignment="left"><one:T><![CDATA[Body 5]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-lifecycle/offline-creation/cold/read/page-004.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{557FC918-74CB-09B0-19E7-3D1809D43E13}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:25.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Offline author" lastModifiedBy="Offline author" creationTime="2026-09-09T05:06:25.000Z" lastModifiedTime="2026-09-09T05:06:25.000Z" objectID="{557FC918-74CB-09B0-19E7-3D1809D43E13}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Same &#129419; é]]></one:T></one:OE></one:Title><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-09T05:06:25.000Z" objectID="{51D93968-7A84-0AD0-3C31-985726CCBCC2}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="97.35448455810547" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-09T05:06:25.000Z" lastModifiedTime="2026-09-09T05:06:25.000Z" objectID="{51D93968-7A84-0AD0-3C31-985726CCBCC2}{3}{B0}" alignment="left"><one:T><![CDATA[Body 4]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-lifecycle/offline-creation/cold/read/page-005.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{6CB51A17-61D4-0DBF-2C43-4CB4C9D2B6A1}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:26.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Offline author" lastModifiedBy="Offline author" creationTime="2026-09-09T05:06:26.000Z" lastModifiedTime="2026-09-09T05:06:26.000Z" objectID="{6CB51A17-61D4-0DBF-2C43-4CB4C9D2B6A1}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Same &#129419; é]]></one:T></one:OE></one:Title><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-09T05:06:26.000Z" objectID="{7725B82C-645F-0B2B-37FE-E1431BEF768F}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="97.35448455810547" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-09T05:06:26.000Z" lastModifiedTime="2026-09-09T05:06:26.000Z" objectID="{7725B82C-645F-0B2B-37FE-E1431BEF768F}{3}{B0}" alignment="left"><one:T><![CDATA[Body 9]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-lifecycle/offline-creation/cold/read/page-006.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{7CA0430A-8905-04E4-0455-CFE53ECEE2AA}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:25.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Offline author" lastModifiedBy="Offline author" creationTime="2026-09-09T05:06:25.000Z" lastModifiedTime="2026-09-09T05:06:25.000Z" objectID="{7CA0430A-8905-04E4-0455-CFE53ECEE2AA}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Same &#129419; é]]></one:T></one:OE></one:Title><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-09T05:06:25.000Z" objectID="{A1B77445-30F3-071B-2616-CBF8748C32AB}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="97.35448455810547" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-09T05:06:25.000Z" lastModifiedTime="2026-09-09T05:06:25.000Z" objectID="{A1B77445-30F3-071B-2616-CBF8748C32AB}{3}{B0}" alignment="left"><one:T><![CDATA[Body 3]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-lifecycle/offline-creation/cold/read/page-007.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{87E24DEE-963B-09DA-1E68-D198DC52EDA5}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:26.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Offline author" lastModifiedBy="Offline author" creationTime="2026-09-09T05:06:26.000Z" lastModifiedTime="2026-09-09T05:06:26.000Z" objectID="{87E24DEE-963B-09DA-1E68-D198DC52EDA5}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Same &#129419; é]]></one:T></one:OE></one:Title><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-09T05:06:27.000Z" objectID="{C3B0AAD1-B11A-0DCA-287E-D00A5BDC7028}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="102.9296875" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-09T05:06:26.000Z" lastModifiedTime="2026-09-09T05:06:27.000Z" objectID="{C3B0AAD1-B11A-0DCA-287E-D00A5BDC7028}{3}{B0}" alignment="left"><one:T><![CDATA[Body 10]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-lifecycle/offline-creation/cold/read/page-008.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{9EE67EFB-DA69-05C0-334D-18E6DCBBE33B}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:25.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Offline author" lastModifiedBy="Offline author" creationTime="2026-09-09T05:06:25.000Z" lastModifiedTime="2026-09-09T05:06:25.000Z" objectID="{9EE67EFB-DA69-05C0-334D-18E6DCBBE33B}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Same &#129419; é]]></one:T></one:OE></one:Title><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-09T05:06:26.000Z" objectID="{DF4AA824-AF22-0491-1F7A-0EEF3AF04369}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="97.35448455810547" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-09T05:06:25.000Z" lastModifiedTime="2026-09-09T05:06:26.000Z" objectID="{DF4AA824-AF22-0491-1F7A-0EEF3AF04369}{3}{B0}" alignment="left"><one:T><![CDATA[Body 6]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-lifecycle/offline-creation/cold/read/page-009.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{AC1E7B39-43AC-0370-2960-486A10D01E43}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:27.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Offline author" lastModifiedBy="Offline author" creationTime="2026-09-09T05:06:27.000Z" lastModifiedTime="2026-09-09T05:06:27.000Z" objectID="{AC1E7B39-43AC-0370-2960-486A10D01E43}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Same &#129419; é]]></one:T></one:OE></one:Title><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-09T05:06:27.000Z" objectID="{423E0C00-FA84-099F-113B-397CAEE28758}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="102.9296875" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-09T05:06:27.000Z" lastModifiedTime="2026-09-09T05:06:27.000Z" objectID="{423E0C00-FA84-099F-113B-397CAEE28758}{3}{B0}" alignment="left"><one:T><![CDATA[Body 11]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-lifecycle/offline-creation/cold/read/page-010.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{AC84259B-D9B6-0A59-2748-93C35602B0BD}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:24.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Offline author" lastModifiedBy="Offline author" creationTime="2026-09-09T05:06:24.000Z" lastModifiedTime="2026-09-09T05:06:24.000Z" objectID="{AC84259B-D9B6-0A59-2748-93C35602B0BD}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Same &#129419; é]]></one:T></one:OE></one:Title><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-09T05:06:24.000Z" objectID="{2A37A85A-3C9C-0CBE-2D9A-253D334E6301}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="97.35448455810547" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-09T05:06:24.000Z" lastModifiedTime="2026-09-09T05:06:24.000Z" objectID="{2A37A85A-3C9C-0CBE-2D9A-253D334E6301}{3}{B0}" alignment="left"><one:T><![CDATA[Body 1]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-lifecycle/offline-creation/cold/read/page-011.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{CC3DEC87-5707-084C-0528-649AB24AD8B7}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:24.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Offline author" lastModifiedBy="Offline author" creationTime="2026-09-09T05:06:24.000Z" lastModifiedTime="2026-09-09T05:06:24.000Z" objectID="{CC3DEC87-5707-084C-0528-649AB24AD8B7}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Same &#129419; é]]></one:T></one:OE></one:Title><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-09T05:06:25.000Z" objectID="{DD7A7A6D-CF78-0C0A-3512-66C69436AC88}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="97.35448455810547" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-09T05:06:24.000Z" lastModifiedTime="2026-09-09T05:06:25.000Z" objectID="{DD7A7A6D-CF78-0C0A-3512-66C69436AC88}{3}{B0}" alignment="left"><one:T><![CDATA[Body 2]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-lifecycle/offline-creation/cold/read/page-012.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{D3E5FB89-79C3-05BA-1805-E25CE31ED175}{1}{B0}" name="Same 🦋 é" dateTime="2026-09-09T05:06:24.000Z" lastModifiedTime="2026-09-09T05:08:15.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Offline author" lastModifiedBy="Offline author" creationTime="2026-09-09T05:06:24.000Z" lastModifiedTime="2026-09-09T05:06:24.000Z" objectID="{D3E5FB89-79C3-05BA-1805-E25CE31ED175}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Same &#129419; é]]></one:T></one:OE></one:Title><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-09T05:06:24.000Z" objectID="{72A18D56-4556-035E-3AD5-CF9B88EA399E}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="97.35448455810547" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-09T05:06:24.000Z" lastModifiedTime="2026-09-09T05:06:24.000Z" objectID="{72A18D56-4556-035E-3AD5-CF9B88EA399E}{3}{B0}" alignment="left"><one:T><![CDATA[Body 0]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-lifecycle/offline-creation/provenance.json created+27
...@@ -0,0 +1,27 @@
1{
2 "run": {
3 "notebook": "/Users/clo/dev/one/evidence/m10/offline-page-candidate",
4 "expected_pages": 13,
5 "author": null,
6 "author_timeout_seconds": 600,
7 "inspect": false,
8 "collect_notebook": true,
9 "base": {
10 "file": "win7-office-base.qcow2",
11 "format": "qcow2",
12 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
13 "virtual_size": 68719476736
14 },
15 "scripts": {
16 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
17 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
18 }
19 },
20 "machine": {
21 "name": "m6-cc15a5ee",
22 "hostname": "ONE-M6-CC15A5EE"
23 },
24 "teardown": {
25 "absent": true
26 }
27}
crates/onestore-offline/README.md+17-3
...@@ -32,11 +32,21 @@ the new text for subsequent offline edits. Pending entries expose...@@ -32,11 +32,21 @@ the new text for subsequent offline edits. Pending entries expose
32`Operation::Text(TextEdit)`, `Operation::Insert(Insertion)`,32`Operation::Text(TextEdit)`, `Operation::Insert(Insertion)`,
33`Operation::Format(FormatEdit)`, `Operation::Split(SplitEdit)`,33`Operation::Format(FormatEdit)`, `Operation::Split(SplitEdit)`,
34`Operation::Join(JoinEdit)`, `Operation::Outline(OutlineEdit)` and34`Operation::Join(JoinEdit)`, `Operation::Outline(OutlineEdit)` and
35`Operation::Tree(TreeEdit)` through their35`Operation::Tree(TreeEdit)` and `Operation::CreatePage(PageCreation)` through their
36`operation` field. Synchronization applies these in queue order, so an inserted36`operation` field. Synchronization applies these in queue order, so an inserted
37outline can precede its paragraphs and their later edits. Missing anchors or37outline can precede its paragraphs and their later edits. Missing anchors or
38existing insertion identities preserve a conflict and the complete local image.38existing insertion identities preserve a conflict and the complete local image.
3939
40`create_page` accepts the core `PageCreation` intent and queues its page space
41and section entry as one publication. Subsequent outlines and title edits use
42the intent's stable identities immediately after local acknowledgement.
43Independent page additions rebase against the current section order; duplicate
44titles remain distinct. An unavailable or no-longer-leading insertion anchor
45produces `StructureChanged`. `rebase_page_creation_conflict(id, local, remote,
46before)` reviews a replacement anchor against both cache images while retaining
47the new page and dependent object identities. Existing page identities require
48reconciliation; a matching page alone does not establish a receipt.
49
40`split` and `join` accept the core `ParagraphSplit` and `ParagraphJoin` intents.50`split` and `join` accept the core `ParagraphSplit` and `ParagraphJoin` intents.
41Splits retain allocated identities when the original UTF-16 boundary rebases;51Splits retain allocated identities when the original UTF-16 boundary rebases;
42dependent edits can address `ParagraphSplit::text_object()` immediately after52dependent 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....@@ -116,7 +126,10 @@ remain the same before replay or review; later queued edits keep their targets.
116Uncertain tree attempts require their original revision for confirmation, even126Uncertain tree attempts require their original revision for confirmation, even
117when an independent move or deletion has the same visible effect.127when an independent move or deletion has the same visible effect.
118128
119Recognized earlier caches migrate transactionally to version nine. Version-eight129Recognized earlier caches migrate transactionally to version ten. Publication
130attempts retain every changed space's revision; legacy attempts retain their
131single-space evidence. Earlier recovery archives remain readable without migration.
132Version-eight
120deletion observations retain their original identity-sensitive preconditions;133deletion observations retain their original identity-sensitive preconditions;
121explicit conflict review upgrades them to the immutable-content comparison.134explicit conflict review upgrades them to the immutable-content comparison.
122The migration retains images, local IDs, publication attempts, conflicts,135The migration retains images, local IDs, publication attempts, conflicts,
...@@ -134,7 +147,8 @@ retrying....@@ -134,7 +147,8 @@ retrying.
134`sync_once(&mut remote)` processes the oldest pending edit through a `Remote`147`sync_once(&mut remote)` processes the oldest pending edit through a `Remote`
135implementation, returning its ID and `EditStatus`. A durable `Published` receipt148implementation, returning its ID and `EditStatus`. A durable `Published` receipt
136survives reopening. Publication attempts are recorded before network I/O; a retained149survives reopening. Publication attempts are recorded before network I/O; a retained
137attempted revision requires comparison, flushing and refreshed header version150attempt requires every recorded revision to remain present, followed by comparison,
151flushing and refreshed header version
138metadata before acknowledgement. If a formatting attempt's revision is missing,152metadata before acknowledgement. If a formatting attempt's revision is missing,
139the complete requested effect can instead be confirmed on a uniquely aligned153the complete requested effect can instead be confirmed on a uniquely aligned
140range; its receipt identifies that confirmed current revision. Otherwise the154range; its receipt identifies that confirmed current revision. Otherwise the
crates/onestore-offline/examples/cache_probe.rs+1
...@@ -139,6 +139,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -139,6 +139,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
139 *value as u64 - 6139 *value as u64 - 6
140 }140 }
141 onestore_offline::Operation::Split(_)141 onestore_offline::Operation::Split(_)
142 | onestore_offline::Operation::CreatePage(_)
142 | onestore_offline::Operation::Join(_)143 | onestore_offline::Operation::Join(_)
143 | onestore_offline::Operation::Outline(_)144 | onestore_offline::Operation::Outline(_)
144 | onestore_offline::Operation::Tree(_) => {145 | onestore_offline::Operation::Tree(_) => {
crates/onestore-offline/src/lib.rs+24-4
...@@ -2,7 +2,7 @@...@@ -2,7 +2,7 @@
2#![doc = include_str!("../README.md")]2#![doc = include_str!("../README.md")]
33
4use onestore::{4use onestore::{
5 ExGuid, Insertion, PreparedEdit, RevisionIndex, Store,5 ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store,
6 document::{Document, Kind},6 document::{Document, Kind},
7};7};
8use rusqlite::{Connection, OpenFlags, TransactionBehavior, params};8use rusqlite::{Connection, OpenFlags, TransactionBehavior, params};
...@@ -51,7 +51,7 @@ pub enum Error {...@@ -51,7 +51,7 @@ pub enum Error {
51type Result<T> = std::result::Result<T, Error>;51type Result<T> = std::result::Result<T, Error>;
5252
53const APPLICATION_ID: u32 = 0x4f4e454f;53const APPLICATION_ID: u32 = 0x4f4e454f;
54const SCHEMA_VERSION: u32 = 9;54const SCHEMA_VERSION: u32 = 10;
5555
56/// Text and its observed precondition, retained across cache reopen and rebasing.56/// Text and its observed precondition, retained across cache reopen and rebasing.
57#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]57#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
...@@ -66,6 +66,7 @@ pub struct TextEdit {...@@ -66,6 +66,7 @@ pub struct TextEdit {
66#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]66#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
67#[serde(deny_unknown_fields)]67#[serde(deny_unknown_fields)]
68pub enum Operation {68pub enum Operation {
69 CreatePage(PageCreation),
69 Text(TextEdit),70 Text(TextEdit),
70 Insert(Insertion),71 Insert(Insertion),
71 Format(FormatEdit),72 Format(FormatEdit),
...@@ -232,6 +233,17 @@ impl Replica {...@@ -232,6 +233,17 @@ impl Replica {
232 self.record(source, space, Operation::Insert(insertion.clone()), &edit)233 self.record(source, space, Operation::Insert(insertion.clone()), &edit)
233 }234 }
234235
236 /// Queues a new page and its section entry with stable identities for dependent edits.
237 pub fn create_page(&self, source: &[u8], page: &PageCreation) -> Result<Option<u64>> {
238 let edit = PreparedEdit::create_page(source, page)?;
239 self.record(
240 source,
241 page.space(),
242 Operation::CreatePage(page.clone()),
243 &edit,
244 )
245 }
246
235 fn record(247 fn record(
236 &self,248 &self,
237 source: &[u8],249 source: &[u8],
...@@ -372,12 +384,20 @@ fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> {...@@ -372,12 +384,20 @@ fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> {
372 let mut rows = query.query([])?;384 let mut rows = query.query([])?;
373 let mut edits = Vec::new();385 let mut edits = Vec::new();
374 while let Some(row) = rows.next()? {386 while let Some(row) = rows.next()? {
375 edits.push(PendingEdit {387 let edit = PendingEdit {
376 id: u64::try_from(row.get::<_, i64>(0)?).map_err(io::Error::other)?,388 id: u64::try_from(row.get::<_, i64>(0)?).map_err(io::Error::other)?,
377 space: row.get::<_, String>(1)?.parse()?,389 space: row.get::<_, String>(1)?.parse()?,
378 operation: serde_json::from_str(&row.get::<_, String>(2)?)390 operation: serde_json::from_str(&row.get::<_, String>(2)?)
379 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,391 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,
380 });392 };
393 if matches!(&edit.operation, Operation::CreatePage(page) if page.space() != edit.space) {
394 return Err(io::Error::new(
395 io::ErrorKind::InvalidData,
396 "Cached page identity differs from its creation intent",
397 )
398 .into());
399 }
400 edits.push(edit);
381 }401 }
382 Ok(edits)402 Ok(edits)
383}403}
crates/onestore-offline/src/schema.rs+12-1
...@@ -22,7 +22,7 @@ pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> {...@@ -22,7 +22,7 @@ pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> {
22 CREATE TABLE attempt (22 CREATE TABLE attempt (
23 id INTEGER PRIMARY KEY CHECK(id=1),23 id INTEGER PRIMARY KEY CHECK(id=1),
24 edit_id INTEGER NOT NULL UNIQUE REFERENCES edits(id) ON DELETE CASCADE,24 edit_id INTEGER NOT NULL UNIQUE REFERENCES edits(id) ON DELETE CASCADE,
25 revision TEXT NOT NULL25 revisions TEXT NOT NULL
26 ) STRICT;26 ) STRICT;
27 CREATE TABLE receipts (27 CREATE TABLE receipts (
28 edit_id INTEGER PRIMARY KEY CHECK(edit_id>0),28 edit_id INTEGER PRIMARY KEY CHECK(edit_id>0),
...@@ -44,6 +44,8 @@ pub(crate) fn migrate(transaction: &Transaction<'_>, version: u32) -> Result<()>...@@ -44,6 +44,8 @@ pub(crate) fn migrate(transaction: &Transaction<'_>, version: u32) -> Result<()>
44 if version < 5 {44 if version < 5 {
45 transaction.execute_batch(ASSETS)?;45 transaction.execute_batch(ASSETS)?;
46 }46 }
47 transaction.execute_batch("ALTER TABLE attempt RENAME COLUMN revision TO revisions;")?;
48 migrate_attempts(transaction)?;
47 return Ok(());49 return Ok(());
48 }50 }
4951
...@@ -125,5 +127,14 @@ pub(crate) fn migrate(transaction: &Transaction<'_>, version: u32) -> Result<()>...@@ -125,5 +127,14 @@ pub(crate) fn migrate(transaction: &Transaction<'_>, version: u32) -> Result<()>
125 for (id, revision) in receipts {127 for (id, revision) in receipts {
126 transaction.execute("INSERT INTO receipts VALUES (?1,?2)", params![id, revision])?;128 transaction.execute("INSERT INTO receipts VALUES (?1,?2)", params![id, revision])?;
127 }129 }
130 migrate_attempts(transaction)?;
131 Ok(())
132}
133
134fn migrate_attempts(transaction: &Transaction<'_>) -> Result<()> {
135 transaction.execute_batch(
136 "UPDATE attempt SET revisions=json_object(
137 (SELECT space FROM edits WHERE edits.id=attempt.edit_id), revisions);",
138 )?;
128 Ok(())139 Ok(())
129}140}
crates/onestore-offline/src/sync.rs+97-18
...@@ -1,7 +1,10 @@...@@ -1,7 +1,10 @@
1use super::*;1use super::*;
2use onestore::{CommitError, CommitState};2use onestore::{CommitError, CommitState};
3use rusqlite::OptionalExtension;3use rusqlite::OptionalExtension;
4use std::sync::{MutexGuard, TryLockError};4use std::{
5 collections::BTreeMap,
6 sync::{MutexGuard, TryLockError},
7};
58
6/// A single remote file with fresh reads and native-compatible guarded publication.9/// A single remote file with fresh reads and native-compatible guarded publication.
7/// Errors retain publication state; confirmation compares, flushes, and notifies cached readers.10/// Errors retain publication state; confirmation compares, flushes, and notifies cached readers.
...@@ -91,7 +94,7 @@ impl Replica {...@@ -91,7 +94,7 @@ impl Replica {
91 };94 };
92 let attempted = transaction95 let attempted = transaction
93 .query_row(96 .query_row(
94 "SELECT revision FROM attempt WHERE edit_id=?1",97 "SELECT revisions FROM attempt WHERE edit_id=?1",
95 [i64::try_from(intent.id).map_err(io::Error::other)?],98 [i64::try_from(intent.id).map_err(io::Error::other)?],
96 |row| row.get::<_, String>(0),99 |row| row.get::<_, String>(0),
97 )100 )
...@@ -100,17 +103,19 @@ impl Replica {...@@ -100,17 +103,19 @@ impl Replica {
100 transaction.commit()?;103 transaction.commit()?;
101 (intent, attempted)104 (intent, attempted)
102 };105 };
103 if let Some(revision) = attempted {106 if let Some(encoded) = attempted {
104 let mut revision = revision.parse::<ExGuid>()?;107 let revisions = attempted_revisions(&encoded, intent.space)?;
108 let mut revision = revisions[&intent.space];
105 let store = Store::parse(&snapshot)?;109 let store = Store::parse(&snapshot)?;
106 let index = RevisionIndex::parse(&store)?;110 let index = RevisionIndex::parse(&store)?;
107 if !index111 if !revisions.iter().all(|(space, revision)| {
108 .spaces112 index
109 .get(&intent.space)113 .spaces
110 .is_some_and(|space| space.revisions.contains_key(&revision))114 .get(space)
111 {115 .is_some_and(|space| space.revisions.contains_key(revision))
116 }) {
112 let satisfied = match &intent.operation {117 let satisfied = match &intent.operation {
113 Operation::Format(edit) => edit118 Operation::Format(edit) if revisions.len() == 1 => edit
114 .prepare(&snapshot, intent.space)?119 .prepare(&snapshot, intent.space)?
115 .is_ok_and(|prepared| prepared.as_bytes() == snapshot),120 .is_ok_and(|prepared| prepared.as_bytes() == snapshot),
116 _ => false,121 _ => false,
...@@ -133,6 +138,8 @@ impl Replica {...@@ -133,6 +138,8 @@ impl Replica {
133 return Ok(Some((intent.id, EditStatus::Published { revision })));138 return Ok(Some((intent.id, EditStatus::Published { revision })));
134 }139 }
135 let candidate = match &intent.operation {140 let candidate = match &intent.operation {
141 Operation::CreatePage(page) => PreparedEdit::create_page(&snapshot, page)
142 .map_err(|_| ConflictKind::StructureChanged),
136 Operation::Format(edit) => edit.prepare(&snapshot, intent.space)?,143 Operation::Format(edit) => edit.prepare(&snapshot, intent.space)?,
137 Operation::Split(edit) => edit.prepare(&snapshot, intent.space)?,144 Operation::Split(edit) => edit.prepare(&snapshot, intent.space)?,
138 Operation::Join(edit) => edit.prepare(&snapshot, intent.space)?,145 Operation::Join(edit) => edit.prepare(&snapshot, intent.space)?,
...@@ -186,6 +193,21 @@ impl Replica {...@@ -186,6 +193,21 @@ impl Replica {
186 let store = Store::parse(prepared.as_bytes())?;193 let store = Store::parse(prepared.as_bytes())?;
187 let index = RevisionIndex::parse(&store)?;194 let index = RevisionIndex::parse(&store)?;
188 let revision = index.spaces[&intent.space].labels[&(ExGuid::default(), 1)];195 let revision = index.spaces[&intent.space].labels[&(ExGuid::default(), 1)];
196 let before_store = Store::parse(&snapshot)?;
197 let before = RevisionIndex::parse(&before_store)?;
198 let revisions: BTreeMap<_, _> = index
199 .spaces
200 .iter()
201 .filter_map(|(sid, space)| {
202 let revision = *space.labels.get(&(ExGuid::default(), 1))?;
203 (before
204 .spaces
205 .get(sid)
206 .and_then(|s| s.labels.get(&(ExGuid::default(), 1)))
207 != Some(&revision))
208 .then_some((*sid, revision))
209 })
210 .collect();
189 {211 {
190 let mut connection = self212 let mut connection = self
191 .connection213 .connection
...@@ -198,10 +220,10 @@ impl Replica {...@@ -198,10 +220,10 @@ impl Replica {
198 [i64::try_from(intent.id).map_err(io::Error::other)?],220 [i64::try_from(intent.id).map_err(io::Error::other)?],
199 )?;221 )?;
200 transaction.execute(222 transaction.execute(
201 "INSERT INTO attempt(id, edit_id, revision) VALUES (1, ?1, ?2)",223 "INSERT INTO attempt(id, edit_id, revisions) VALUES (1, ?1, ?2)",
202 params![224 params![
203 i64::try_from(intent.id).map_err(io::Error::other)?,225 i64::try_from(intent.id).map_err(io::Error::other)?,
204 revision.to_string()226 serde_json::to_string(&revisions).map_err(io::Error::other)?
205 ],227 ],
206 )?;228 )?;
207 transaction.commit()?;229 transaction.commit()?;
...@@ -322,10 +344,39 @@ impl Replica {...@@ -322,10 +344,39 @@ impl Replica {
322 )344 )
323 .into());345 .into());
324 }346 }
347 Operation::CreatePage(_) => {
348 return Err(io::Error::new(
349 io::ErrorKind::InvalidInput,
350 "Review page placement using rebase_page_creation_conflict",
351 )
352 .into());
353 }
325 })354 })
326 })355 })
327 }356 }
328357
358 /// Repositions an unattempted page-creation conflict, retaining dependent object identities.
359 pub fn rebase_page_creation_conflict(
360 &self,
361 id: u64,
362 local: &[u8],
363 remote: &[u8],
364 before: Option<ExGuid>,
365 ) -> Result<()> {
366 self.resolve_conflict(id, local, remote, |intent| {
367 let Operation::CreatePage(page) = intent.operation else {
368 return Err(io::Error::new(
369 io::ErrorKind::InvalidInput,
370 "Select a page-creation conflict",
371 )
372 .into());
373 };
374 let page = page.reposition(before)?;
375 PreparedEdit::create_page(remote, &page)?;
376 Ok(Operation::CreatePage(page))
377 })
378 }
379
329 /// Reviews a join against both current cache images, retaining its original text identities.380 /// Reviews a join against both current cache images, retaining its original text identities.
330 /// The surviving text identity must remain the same for dependent edits.381 /// The surviving text identity must remain the same for dependent edits.
331 pub fn rebase_join_conflict(&self, id: u64, local: &[u8], remote: &[u8]) -> Result<()> {382 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<EditStat...@@ -529,14 +580,25 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat
529 revision: revision.parse()?,580 revision: revision.parse()?,
530 }));581 }));
531 }582 }
532 let record: Option<(Option<String>, Option<i64>)> = connection.query_row(583 let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?;
533 "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()?;584 let column = if version < 10 {
585 "revision"
586 } else {
587 "revisions"
588 };
589 let record: Option<(Option<String>, Option<i64>, String)> = connection.query_row(
590 &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()?;
534 Ok(match record {591 Ok(match record {
535 None => None,592 None => None,
536 Some((Some(revision), _)) => Some(EditStatus::AwaitingConfirmation {593 Some((Some(revision), _, space)) => Some(EditStatus::AwaitingConfirmation {
537 revision: revision.parse()?,594 revision: if version < 10 {
595 revision.parse()?
596 } else {
597 let space = space.parse()?;
598 attempted_revisions(&revision, space)?[&space]
599 },
538 }),600 }),
539 Some((None, Some(kind))) => Some(EditStatus::Conflict(match kind {601 Some((None, Some(kind), _)) => Some(EditStatus::Conflict(match kind {
540 0 => ConflictKind::TextChanged,602 0 => ConflictKind::TextChanged,
541 1 => ConflictKind::TargetUnavailable,603 1 => ConflictKind::TargetUnavailable,
542 2 => ConflictKind::UnsupportedEdit,604 2 => ConflictKind::UnsupportedEdit,
...@@ -552,6 +614,23 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat...@@ -552,6 +614,23 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat
552 .into());614 .into());
553 }615 }
554 })),616 })),
555 Some((None, None)) => Some(EditStatus::Pending),617 Some((None, None, _)) => Some(EditStatus::Pending),
556 })618 })
557}619}
620
621fn attempted_revisions(encoded: &str, space: ExGuid) -> Result<BTreeMap<ExGuid, ExGuid>> {
622 let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(encoded)
623 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
624 if !revisions.contains_key(&space)
625 || revisions
626 .iter()
627 .any(|(sid, rid)| sid.guid == [0; 16] || rid.guid == [0; 16])
628 {
629 return Err(io::Error::new(
630 io::ErrorKind::InvalidData,
631 "Cached publication evidence is incomplete",
632 )
633 .into());
634 }
635 Ok(revisions)
636}
crates/onestore-offline/src/tree.rs+2
...@@ -509,6 +509,8 @@ mod tests {...@@ -509,6 +509,8 @@ mod tests {
509 )509 )
510 .unwrap();510 .unwrap();
511 db.pragma_update(None, "user_version", 8).unwrap();511 db.pragma_update(None, "user_version", 8).unwrap();
512 db.execute_batch("ALTER TABLE attempt RENAME COLUMN revisions TO revision;")
513 .unwrap();
512 drop(db);514 drop(db);
513 let cache = Replica::open(&path).unwrap();515 let cache = Replica::open(&path).unwrap();
514 assert_eq!(cache.pending().unwrap(), queue);516 assert_eq!(cache.pending().unwrap(), queue);
crates/onestore-offline/tests/cache.rs+20-3
...@@ -784,7 +784,7 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() {...@@ -784,7 +784,7 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() {
784 db.execute("UPDATE edits SET operation=?1", [value.to_string()])784 db.execute("UPDATE edits SET operation=?1", [value.to_string()])
785 .unwrap();785 .unwrap();
786 if matches!(operation, "Text" | "Insert") {786 if matches!(operation, "Text" | "Insert") {
787 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();787 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();
788 }788 }
789 drop(db);789 drop(db);
790 let before = fs::read(&path).unwrap();790 let before = fs::read(&path).unwrap();
...@@ -797,7 +797,7 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() {...@@ -797,7 +797,7 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() {
797797
798#[test]798#[test]
799fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_content_conflicts() {799fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_content_conflicts() {
800 for (version, ceiling) in [(5, 3), (6, 4), (7, 5), (8, 6)] {800 for (version, ceiling) in [(5, 3), (6, 4), (7, 5), (8, 6), (9, 6)] {
801 use onestore_offline::{ConflictKind, EditStatus, Recovery};801 use onestore_offline::{ConflictKind, EditStatus, Recovery};
802 use sha2::{Digest, Sha256};802 use sha2::{Digest, Sha256};
803 let directory = tempfile::tempdir().unwrap();803 let directory = tempfile::tempdir().unwrap();
...@@ -837,7 +837,8 @@ fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_content_confl...@@ -837,7 +837,8 @@ fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_content_confl
837 )837 )
838 .unwrap();838 .unwrap();
839 db.execute_batch(&format!(839 db.execute_batch(&format!(
840 "DROP TABLE conflicts; CREATE TABLE conflicts (840 "ALTER TABLE attempt RENAME COLUMN revisions TO revision;
841 DROP TABLE conflicts; CREATE TABLE conflicts (
841 edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE,842 edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE,
842 kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND {ceiling})) STRICT;843 kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND {ceiling})) STRICT;
843 PRAGMA user_version={version};844 PRAGMA user_version={version};
...@@ -860,6 +861,22 @@ fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_content_confl...@@ -860,6 +861,22 @@ fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_content_confl
860 )861 )
861 .unwrap();862 .unwrap();
862 drop(db);863 drop(db);
864 let archive = directory.path().join("legacy-recovery.sqlite");
865 std::fs::copy(&path, &archive).unwrap();
866 let archive_db = rusqlite::Connection::open(&archive).unwrap();
867 archive_db
868 .pragma_update(None, "application_id", 0x4f4e4552)
869 .unwrap();
870 drop(archive_db);
871 let original_archive = std::fs::read(&archive).unwrap();
872 let recovery = Recovery::open(&archive).unwrap();
873 assert_eq!(
874 recovery.status(first).unwrap(),
875 Some(EditStatus::AwaitingConfirmation { revision })
876 );
877 assert_eq!(recovery.pending().unwrap(), queue);
878 drop(recovery);
879 assert!(std::fs::read(&archive).unwrap() == original_archive);
863 let cache = Replica::open(&path).unwrap();880 let cache = Replica::open(&path).unwrap();
864 assert_eq!(cache.pending().unwrap(), queue);881 assert_eq!(cache.pending().unwrap(), queue);
865 assert_eq!(882 assert_eq!(
crates/onestore-offline/tests/support/page_schedule.rs created+156
...@@ -0,0 +1,156 @@
1use crate::disk;
2use onestore::{
3 CommitError, ExGuid, Insertion, PreparedEdit, RevisionIndex, Store,
4 document::{Document, Kind},
5};
6use onestore_offline::{EditStatus, Operation, Remote, Replica};
7use std::{io, sync::LazyLock};
8
9#[path = "../../../onestore/tests/support/current.rs"]
10mod current;
11
12static SOURCE: LazyLock<Vec<u8>> =
13 LazyLock::new(|| onestore::create_section("page-schedule.one", "Original", "Author").unwrap());
14
15fn pages(source: &[u8]) -> Vec<(ExGuid, ExGuid)> {
16 let store = Store::parse(source).unwrap();
17 let index = RevisionIndex::parse(&store).unwrap();
18 index.validate_current().unwrap();
19 Document::parse(&index).unwrap().pages().unwrap()
20}
21
22struct Session<'a> {
23 disk: &'a mut disk::Disk,
24 operation: Option<&'a Operation>,
25}
26
27impl Remote for Session<'_> {
28 fn read(&mut self) -> io::Result<Vec<u8>> {
29 Ok(self.disk.visible.clone())
30 }
31
32 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
33 let mut expected = pages(&self.disk.visible);
34 match self.operation.unwrap() {
35 Operation::CreatePage(page) => expected.push((page.space(), page.object())),
36 Operation::Insert(insertion) => {
37 let store = Store::parse(edit.as_bytes()).unwrap();
38 let index = RevisionIndex::parse(&store).unwrap();
39 let document = Document::parse(&index).unwrap();
40 let matching: Vec<_> = document
41 .spaces
42 .values()
43 .filter_map(|space| {
44 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
45 view.nodes.get(&insertion.text_object())
46 })
47 .collect();
48 assert_eq!(matching.len(), 1);
49 assert!(
50 matches!(&matching[0].kind, Kind::RichText { text, .. } if text == "Body 🦋 é")
51 );
52 }
53 _ => panic!(),
54 }
55 assert_eq!(pages(edit.as_bytes()), expected);
56 let old = current::current(&self.disk.durable);
57 let new = current::current(edit.as_bytes());
58 let result = edit.commit(self.disk);
59 let observed = current::current(&self.disk.durable);
60 assert!(observed == old || observed == new);
61 if result.is_ok() {
62 assert_eq!(observed, new);
63 }
64 result
65 }
66
67 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
68 onestore::confirm_snapshot(self.disk, snapshot)
69 }
70}
71
72pub fn run(input: &[u8]) {
73 let directory = tempfile::tempdir().unwrap();
74 let mut replicas: [Option<Replica>; 12] = std::array::from_fn(|_| None);
75 let mut owned: [Vec<(ExGuid, ExGuid)>; 12] = std::array::from_fn(|_| Vec::new());
76 let mut disk = disk::Disk {
77 visible: SOURCE.clone(),
78 durable: SOURCE.clone(),
79 operation: 0,
80 fail_at: None,
81 write_limit: 4096,
82 random: 1,
83 };
84 for step in input.chunks_exact(8).take(48) {
85 let actor = usize::from(step[0]) % replicas.len();
86 let path = directory.path().join(format!("{actor}.sqlite"));
87 let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, &SOURCE).unwrap());
88 let snapshot = cache.snapshot().unwrap();
89 let pending = cache.pending().unwrap();
90 match step[1] % 6 {
91 0 | 1 => {
92 let title = (step[2] & 1 != 0).then_some("Same 🦋 é");
93 let page = onestore::PageCreation::new(None, title, "Author").unwrap();
94 cache.create_page(&snapshot, &page).unwrap().unwrap();
95 owned[actor].push((page.space(), page.object()));
96 if step[1] % 6 == 1 {
97 let insertion =
98 Insertion::outline(page.object(), 36.0, 36.0, "Body 🦋 é", "Author")
99 .unwrap();
100 cache
101 .insert(&cache.snapshot().unwrap(), page.space(), &insertion)
102 .unwrap()
103 .unwrap();
104 }
105 }
106 2 => {
107 let statuses: Vec<_> = pending
108 .iter()
109 .map(|edit| cache.status(edit.id).unwrap())
110 .collect();
111 replicas[actor] = None;
112 let cache = Replica::open(&path).unwrap();
113 assert!(cache.snapshot().unwrap() == snapshot);
114 assert_eq!(cache.pending().unwrap(), pending);
115 assert_eq!(
116 pending
117 .iter()
118 .map(|edit| cache.status(edit.id).unwrap())
119 .collect::<Vec<_>>(),
120 statuses
121 );
122 replicas[actor] = Some(cache);
123 }
124 3 | 4 => {
125 disk.operation = 0;
126 disk.write_limit = if step[3] & 1 == 0 { 17 } else { 4096 };
127 disk.fail_at =
128 (step[2] != 0).then_some(usize::from(u16::from_le_bytes([step[2], step[3]])));
129 disk.random = u64::from(step[4]) + 1;
130 let prior = pending
131 .first()
132 .and_then(|edit| cache.status(edit.id).unwrap());
133 let mut session = Session {
134 disk: &mut disk,
135 operation: pending.first().map(|edit| &edit.operation),
136 };
137 let result = cache.sync_once(&mut session);
138 if matches!(prior, Some(EditStatus::AwaitingConfirmation { .. })) {
139 assert!(
140 result.is_err()
141 || !matches!(result.unwrap(), Some((_, EditStatus::Conflict(_))))
142 );
143 }
144 }
145 5 => {
146 disk.visible.clone_from(&disk.durable);
147 disk.fail_at = None;
148 }
149 _ => unreachable!(),
150 }
151 let local = pages(&replicas[actor].as_ref().unwrap().snapshot().unwrap());
152 for page in &owned[actor] {
153 assert!(local.contains(page));
154 }
155 }
156}
crates/onestore-offline/tests/sync.rs+4
...@@ -9,6 +9,10 @@ use std::io;...@@ -9,6 +9,10 @@ use std::io;
9mod disk;9mod disk;
10#[path = "sync/outline.rs"]10#[path = "sync/outline.rs"]
11mod outline;11mod outline;
12#[path = "sync/page.rs"]
13mod page;
14#[path = "support/page_schedule.rs"]
15mod page_schedule;
12#[path = "sync/tree.rs"]16#[path = "sync/tree.rs"]
13mod tree;17mod tree;
14#[path = "support/tree_schedule.rs"]18#[path = "support/tree_schedule.rs"]
crates/onestore-offline/tests/sync/page.rs created+350
...@@ -0,0 +1,350 @@
1use super::*;
2use onestore::{Insertion, PageCreation};
3use onestore_offline::{Operation, Recovery};
4use std::collections::BTreeMap;
5
6#[test]
7fn twelve_replica_page_schedules_retain_acknowledged_pages_through_interruptions() {
8 for seed in 0..24_u64 {
9 let mut random = seed + 1956;
10 let mut input = Vec::new();
11 for step in 0..48 {
12 random ^= random << 13;
13 random ^= random >> 7;
14 random ^= random << 17;
15 let mut action = random.to_le_bytes();
16 if step < 12 {
17 action[0] = step;
18 action[1] = 1;
19 }
20 input.extend_from_slice(&action);
21 }
22 page_schedule::run(&input);
23 }
24}
25
26#[test]
27fn version_two_uncertain_text_migrates_without_replay_or_lost_receipts() {
28 let source = onestore::create_section("pages.one", "Original", "Author").unwrap();
29 let (sid, oid, _) = text(&source);
30 let directory = tempfile::tempdir().unwrap();
31 let path = directory.path().join("legacy.sqlite");
32 let cache = Replica::create(&path, &source).unwrap();
33 let id = cache
34 .edit_text(&source, sid, oid, 0..0, "Old ")
35 .unwrap()
36 .unwrap();
37 let snapshot = cache.snapshot().unwrap();
38 let pending = cache.pending().unwrap();
39 let store = Store::parse(&snapshot).unwrap();
40 let index = RevisionIndex::parse(&store).unwrap();
41 let revision = index.spaces[&sid].labels[&(ExGuid::default(), 1)];
42 drop(cache);
43 let db = rusqlite::Connection::open(&path).unwrap();
44 db.execute_batch(
45 "DROP TABLE edits; DROP TABLE assets;
46 CREATE TABLE edits (
47 id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), space TEXT NOT NULL,
48 object TEXT NOT NULL, before_text TEXT NOT NULL,
49 start INTEGER NOT NULL CHECK(start BETWEEN 0 AND 4294967295),
50 end INTEGER NOT NULL CHECK(end BETWEEN start AND 4294967295), replacement TEXT NOT NULL
51 ) STRICT;
52 ALTER TABLE attempt RENAME COLUMN revisions TO revision;
53 PRAGMA user_version=2;",
54 )
55 .unwrap();
56 db.execute(
57 "INSERT INTO edits VALUES (?1,?2,?3,'Original',0,0,'Old ')",
58 rusqlite::params![i64::try_from(id).unwrap(), sid.to_string(), oid.to_string()],
59 )
60 .unwrap();
61 db.execute(
62 "INSERT INTO attempt VALUES (1,?1,?2)",
63 rusqlite::params![i64::try_from(id).unwrap(), revision.to_string()],
64 )
65 .unwrap();
66 db.execute(
67 "INSERT INTO receipts VALUES (1000,?1)",
68 [revision.to_string()],
69 )
70 .unwrap();
71 db.execute("UPDATE sqlite_sequence SET seq=1000 WHERE name='edits'", [])
72 .unwrap();
73 drop(db);
74 let cache = Replica::open(&path).unwrap();
75 assert_eq!(cache.pending().unwrap(), pending);
76 assert_eq!(
77 cache.status(id).unwrap(),
78 Some(EditStatus::AwaitingConfirmation { revision })
79 );
80 assert_eq!(
81 cache.status(1000).unwrap(),
82 Some(EditStatus::Published { revision })
83 );
84 assert!(cache.snapshot().unwrap() == snapshot);
85 let mut server = Server::new(&snapshot);
86 assert_eq!(
87 cache.sync_once(&mut server).unwrap(),
88 Some((id, EditStatus::Published { revision }))
89 );
90 assert_eq!(server.publications, 0);
91 assert_eq!(server.confirmations, 1);
92 assert_eq!(
93 cache
94 .edit_text(&cache.snapshot().unwrap(), sid, oid, 0..0, "Next ")
95 .unwrap(),
96 Some(1001)
97 );
98}
99
100#[test]
101fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() {
102 let source = onestore::create_section("pages.one", "Original", "Author").unwrap();
103 let directory = tempfile::tempdir().unwrap();
104 let mut server = Server::new(&source);
105 let mut expected = Vec::new();
106 for actor in 0..12 {
107 let path = directory.path().join(format!("{actor}.sqlite"));
108 let cache = Replica::create(&path, &source).unwrap();
109 let page = PageCreation::new(None, Some("Same 🦋 é"), "Offline author").unwrap();
110 let id = cache.create_page(&source, &page).unwrap().unwrap();
111 let insertion = Insertion::outline(page.object(), 36.0, 36.0, "Body", "Author").unwrap();
112 cache
113 .insert(&cache.snapshot().unwrap(), page.space(), &insertion)
114 .unwrap()
115 .unwrap();
116 cache
117 .edit_text(
118 &cache.snapshot().unwrap(),
119 page.space(),
120 insertion.text_object(),
121 4..4,
122 &format!(" {actor}"),
123 )
124 .unwrap()
125 .unwrap();
126 let original = cache.pending().unwrap();
127 assert!(
128 matches!(&original[0].operation, Operation::CreatePage(retained) if retained == &page)
129 );
130 let local = cache.snapshot().unwrap();
131 drop(cache);
132 let cache = Replica::open(&path).unwrap();
133 assert_eq!(cache.pending().unwrap(), original);
134 assert_eq!(cache.snapshot().unwrap(), local);
135 for edit in original {
136 assert!(matches!(cache.sync_once(&mut server).unwrap(),
137 Some((published, EditStatus::Published { .. })) if published == edit.id));
138 }
139 assert!(matches!(
140 cache.status(id).unwrap(),
141 Some(EditStatus::Published { .. })
142 ));
143 expected.push((
144 page.space(),
145 page.object(),
146 insertion.text_object(),
147 format!("Body {actor}"),
148 ));
149 }
150 let store = Store::parse(&server.durable).unwrap();
151 let index = RevisionIndex::parse(&store).unwrap();
152 let document = Document::parse(&index).unwrap();
153 let pages = document.pages().unwrap();
154 assert_eq!(pages.len(), 13);
155 for (actual, (sid, page, text, expected)) in pages[1..].iter().zip(&expected) {
156 assert_eq!(*actual, (*sid, *page));
157 let space = &document.spaces[sid];
158 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
159 assert!(matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text == expected));
160 }
161 assert_eq!(server.publications, 36);
162 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_OUTPUT") {
163 std::fs::create_dir(&output).unwrap();
164 std::fs::write(
165 std::path::Path::new(&output).join("pages.one"),
166 &server.durable,
167 )
168 .unwrap();
169 }
170}
171
172#[test]
173fn uncertain_page_publication_retains_both_revisions_and_never_replays() {
174 for fault in [
175 Fault::UnknownBefore,
176 Fault::UnknownAfter,
177 Fault::PanicBefore,
178 Fault::PanicAfter,
179 ] {
180 let source = onestore::create_section("pages.one", "Original", "Author").unwrap();
181 let directory = tempfile::tempdir().unwrap();
182 let path = directory.path().join("pages.sqlite");
183 let cache = Replica::create(&path, &source).unwrap();
184 let page = PageCreation::new(None, Some("Created"), "Author").unwrap();
185 let id = cache.create_page(&source, &page).unwrap().unwrap();
186 let local = cache.snapshot().unwrap();
187 let mut server = Server::new(&source);
188 server.fault = fault;
189 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
190 cache.sync_once(&mut server)
191 }));
192 let attempted = cache.status(id).unwrap().unwrap();
193 assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. }));
194 let archive = directory.path().join("recovery.sqlite");
195 cache.export_recovery(&archive).unwrap();
196 assert_eq!(
197 Recovery::open(&archive).unwrap().status(id).unwrap(),
198 Some(attempted)
199 );
200 drop(cache);
201 let db = rusqlite::Connection::open(&path).unwrap();
202 let encoded: String = db
203 .query_row("SELECT revisions FROM attempt", [], |row| row.get(0))
204 .unwrap();
205 let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();
206 assert_eq!(revisions.len(), 2);
207 assert!(revisions.contains_key(&page.space()));
208 drop(db);
209 let cache = Replica::open(&path).unwrap();
210 let observed = server.visible.clone();
211 let result = cache.sync_once(&mut server).unwrap().unwrap();
212 let visible = matches!(fault, Fault::UnknownAfter | Fault::PanicAfter);
213 assert_eq!(server.publications, 1);
214 assert_eq!(server.confirmations, usize::from(visible));
215 if visible {
216 assert!(matches!(result.1, EditStatus::Published { .. }));
217 assert!(cache.snapshot().unwrap() == observed);
218 assert!(observed[..212] == server.durable[..212]);
219 assert!(observed[252..] == server.durable[252..]);
220 } else {
221 assert_eq!(result, (id, attempted));
222 assert_eq!(cache.snapshot().unwrap(), local);
223 }
224 }
225}
226
227#[test]
228fn surviving_page_revision_alone_does_not_confirm_section_publication() {
229 let source = onestore::create_section("pages.one", "Original", "Author").unwrap();
230 let directory = tempfile::tempdir().unwrap();
231 let path = directory.path().join("pages.sqlite");
232 let cache = Replica::create(&path, &source).unwrap();
233 let page = PageCreation::new(None, None, "Author").unwrap();
234 let id = cache.create_page(&source, &page).unwrap().unwrap();
235 let mut server = Server::new(&source);
236 server.fault = Fault::UnknownAfter;
237 assert!(cache.sync_once(&mut server).is_err());
238 let attempted = cache.status(id).unwrap().unwrap();
239 drop(cache);
240 let db = rusqlite::Connection::open(&path).unwrap();
241 let encoded: String = db
242 .query_row("SELECT revisions FROM attempt", [], |row| row.get(0))
243 .unwrap();
244 let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();
245 drop(db);
246 let complete = server.visible.clone();
247 let (&section, &retired) = revisions
248 .iter()
249 .find(|(sid, _)| **sid != page.space())
250 .unwrap();
251 let store = Store::parse(&complete).unwrap();
252 let mut positions = Vec::new();
253 for list in store.lists.values() {
254 for node in &list.nodes {
255 if node.id == 0x1e
256 && node.payload[..16] == retired.guid
257 && node.payload[16..20] == retired.n.to_le_bytes()
258 {
259 positions.push(node.offset);
260 }
261 }
262 }
263 assert_eq!(positions.len(), 1);
264 // Replacing only the revision identity models maintenance retiring the section proof.
265 let at = positions[0] + 4;
266 server.visible[at] ^= 0x40;
267 let store = Store::parse(&server.visible).unwrap();
268 let index = RevisionIndex::parse(&store).unwrap();
269 index.validate_current().unwrap();
270 assert!(!index.spaces[&section].revisions.contains_key(&retired));
271 assert!(
272 index.spaces[&page.space()]
273 .revisions
274 .contains_key(&revisions[&page.space()])
275 );
276 let cache = Replica::open(&path).unwrap();
277 assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, attempted)));
278 assert_eq!(server.confirmations, 0);
279 assert_eq!(server.publications, 1);
280 server.visible = complete;
281 assert!(matches!(
282 cache.sync_once(&mut server).unwrap(),
283 Some((_, EditStatus::Published { .. }))
284 ));
285 assert_eq!(server.publications, 1);
286}
287
288#[test]
289fn changed_page_anchor_requires_review_without_regenerating_dependent_identities() {
290 let source =
291 include_bytes!("../../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one");
292 let remote =
293 include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
294 let store = Store::parse(source).unwrap();
295 let index = RevisionIndex::parse(&store).unwrap();
296 let pages = Document::parse(&index).unwrap().pages().unwrap();
297 let directory = tempfile::tempdir().unwrap();
298 let path = directory.path().join("pages.sqlite");
299 let cache = Replica::create(&path, source).unwrap();
300 let page = PageCreation::new(Some(pages[4].0), Some("Created"), "Author").unwrap();
301 let id = cache.create_page(source, &page).unwrap().unwrap();
302 let insertion =
303 Insertion::outline(page.object(), 36.0, 36.0, "Retained body", "Author").unwrap();
304 cache
305 .insert(&cache.snapshot().unwrap(), page.space(), &insertion)
306 .unwrap()
307 .unwrap();
308 let local = cache.snapshot().unwrap();
309 let pending = cache.pending().unwrap();
310 let mut server = Server::new(remote);
311 assert_eq!(
312 cache.sync_once(&mut server).unwrap(),
313 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
314 );
315 assert!(
316 cache
317 .rebase_page_creation_conflict(id, source, remote, None)
318 .is_err()
319 );
320 assert!(
321 cache
322 .rebase_page_creation_conflict(id, &local, source, None)
323 .is_err()
324 );
325 cache
326 .rebase_page_creation_conflict(id, &local, remote, None)
327 .unwrap();
328 assert_eq!(cache.snapshot().unwrap(), local);
329 assert_eq!(cache.pending().unwrap()[1], pending[1]);
330 let Operation::CreatePage(reviewed) = &cache.pending().unwrap()[0].operation else {
331 panic!()
332 };
333 assert_eq!(*reviewed, page.reposition(None).unwrap());
334 drop(cache);
335 let cache = Replica::open(&path).unwrap();
336 for _ in 0..2 {
337 assert!(matches!(
338 cache.sync_once(&mut server).unwrap(),
339 Some((_, EditStatus::Published { .. }))
340 ));
341 }
342 assert_eq!(server.publications, 2);
343 let store = Store::parse(&server.durable).unwrap();
344 let index = RevisionIndex::parse(&store).unwrap();
345 let document = Document::parse(&index).unwrap();
346 let space = &document.spaces[&page.space()];
347 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
348 assert!(matches!(&view.nodes[&insertion.text_object()].kind,
349 Kind::RichText { text, .. } if text == "Retained body"));
350}
crates/onestore/src/page.rs+8
...@@ -62,6 +62,14 @@ impl PageCreation {...@@ -62,6 +62,14 @@ impl PageCreation {
62 })62 })
63 }63 }
6464
65 /// Changes the insertion anchor while retaining all identities and creation metadata.
66 pub fn reposition(&self, before: Option<ExGuid>) -> Result<Self, Error> {
67 let mut page = self.clone();
68 page.before = before;
69 page.validate()?;
70 Ok(page)
71 }
72
65 fn validate(&self) -> Result<(), Error> {73 fn validate(&self) -> Result<(), Error> {
66 if self.guid == [0; 16]74 if self.guid == [0; 16]
67 || self.series_guid == [0; 16]75 || self.series_guid == [0; 16]
fuzz/Cargo.toml+7
...@@ -121,3 +121,10 @@ path = "fuzz_targets/page.rs"...@@ -121,3 +121,10 @@ path = "fuzz_targets/page.rs"
121test = false121test = false
122doc = false122doc = false
123bench = false123bench = false
124
125[[bin]]
126name = "offline_page"
127path = "fuzz_targets/offline_page.rs"
128test = false
129doc = false
130bench = false
fuzz/fuzz_targets/offline_page.rs created+9
...@@ -0,0 +1,9 @@
1#![no_main]
2use libfuzzer_sys::fuzz_target;
3
4#[path = "../../crates/onestore/tests/support/disk.rs"]
5mod disk;
6#[path = "../../crates/onestore-offline/tests/support/page_schedule.rs"]
7mod page_schedule;
8
9fuzz_target!(|input: &[u8]| page_schedule::run(input));
tools/test_page_creation.py+4
...@@ -11,6 +11,10 @@ FIXTURE = Path(__file__).resolve().parent.parent / 'corpus/page-lifecycle/creati...@@ -11,6 +11,10 @@ FIXTURE = Path(__file__).resolve().parent.parent / 'corpus/page-lifecycle/creati
1111
1212
13class PageCreationTest(unittest.TestCase):13class PageCreationTest(unittest.TestCase):
14 def test_offline_created_pages_and_dependent_bodies_survive_native_reopen(self):
15 fixture = FIXTURE.parent / 'offline-creation'
16 compare(fixture / 'candidate', fixture / 'cold')
17
14 def test_created_pages_and_both_writers_survive_native_cold_reopens(self):18 def test_created_pages_and_both_writers_survive_native_cold_reopens(self):
15 for source, capture in [('candidate', 'cold'), ('native/notebook', 'native/cold'), ('followup', 'followup/cold')]:19 for source, capture in [('candidate', 'cold'), ('native/notebook', 'native/cold'), ('followup', 'followup/cold')]:
16 with self.subTest(source=source):20 with self.subTest(source=source):