| author | |
| committer | |
| log | 75c6fcf3b31d17b367dd1177074446f029c21196 |
| tree | 81795abb5565ed1cff2848ccc9c592318c08a374 |
| parent | 44df465c5e6e99c1a98916fa6cbcbde01a666164 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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-astra32 files changed, 791 insertions(+), 29 deletions(-)
corpus/page-lifecycle/offline-creation/README.md created+25| ... | ... | @@ -0,0 +1,25 @@ |
| 1 | # Offline page creation | |
| 2 | ||
| 3 | Twelve independent replicas start from the same one-page section. Each queues | |
| 4 | a page titled `Same 🦋 é`, a body outline and a dependent body-text edit, then | |
| 5 | reopens its cache and synchronizes. All 36 receipts complete; page identities | |
| 6 | remain distinct and body text identifies each originating replica. | |
| 7 | ||
| 8 | `candidate/pages.one` is the resulting 13-page section. `cold` captures its fresh | |
| 9 | OneNote 2010 reopen. Native XML content/order and 216 explicit character-format | |
| 10 | comparisons pass. Active graphs compare exactly apart from the original seed | |
| 11 | page's previously absent section metadata copy, materialized natively with exact | |
| 12 | page metadata. The creation verifier checks that specific addition. | |
| 13 | `provenance.json` records the capture and removal of clone m6-cc15a5ee. | |
| 14 | ||
| 15 | Set `ONESTORE_OFFLINE_PAGE_OUTPUT` to an absolute, new output directory and run | |
| 16 | `cargo test -p onestore-offline --all-features --test sync | |
| 17 | page::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 | |
| 21 | retained native capture. | |
| 22 | ||
| 23 | This fixture is a serial reconciliation of twelve offline branches. The separate | |
| 24 | `offline_page` sanitizer target varies interleavings, interruption and reopen | |
| 25 | through 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 | 32 | `Operation::Text(TextEdit)`, `Operation::Insert(Insertion)`, |
| 33 | 33 | `Operation::Format(FormatEdit)`, `Operation::Split(SplitEdit)`, |
| 34 | 34 | `Operation::Join(JoinEdit)`, `Operation::Outline(OutlineEdit)` and |
| 35 | `Operation::Tree(TreeEdit)` through their | |
| 35 | `Operation::Tree(TreeEdit)` and `Operation::CreatePage(PageCreation)` through their | |
| 36 | 36 | `operation` field. Synchronization applies these in queue order, so an inserted |
| 37 | 37 | outline can precede its paragraphs and their later edits. Missing anchors or |
| 38 | 38 | existing insertion identities preserve a conflict and the complete local image. |
| 39 | 39 | |
| 40 | `create_page` accepts the core `PageCreation` intent and queues its page space | |
| 41 | and section entry as one publication. Subsequent outlines and title edits use | |
| 42 | the intent's stable identities immediately after local acknowledgement. | |
| 43 | Independent page additions rebase against the current section order; duplicate | |
| 44 | titles remain distinct. An unavailable or no-longer-leading insertion anchor | |
| 45 | produces `StructureChanged`. `rebase_page_creation_conflict(id, local, remote, | |
| 46 | before)` reviews a replacement anchor against both cache images while retaining | |
| 47 | the new page and dependent object identities. Existing page identities require | |
| 48 | reconciliation; a matching page alone does not establish a receipt. | |
| 49 | ||
| 40 | 50 | `split` and `join` accept the core `ParagraphSplit` and `ParagraphJoin` intents. |
| 41 | 51 | Splits retain allocated identities when the original UTF-16 boundary rebases; |
| 42 | 52 | 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. |
| 116 | 126 | Uncertain tree attempts require their original revision for confirmation, even |
| 117 | 127 | when an independent move or deletion has the same visible effect. |
| 118 | 128 | |
| 119 | Recognized earlier caches migrate transactionally to version nine. Version-eight | |
| 129 | Recognized earlier caches migrate transactionally to version ten. Publication | |
| 130 | attempts retain every changed space's revision; legacy attempts retain their | |
| 131 | single-space evidence. Earlier recovery archives remain readable without migration. | |
| 132 | Version-eight | |
| 120 | 133 | deletion observations retain their original identity-sensitive preconditions; |
| 121 | 134 | explicit conflict review upgrades them to the immutable-content comparison. |
| 122 | 135 | The migration retains images, local IDs, publication attempts, conflicts, |
| ... | ... | @@ -134,7 +147,8 @@ retrying. |
| 134 | 147 | `sync_once(&mut remote)` processes the oldest pending edit through a `Remote` |
| 135 | 148 | implementation, returning its ID and `EditStatus`. A durable `Published` receipt |
| 136 | 149 | survives reopening. Publication attempts are recorded before network I/O; a retained |
| 137 | attempted revision requires comparison, flushing and refreshed header version | |
| 150 | attempt requires every recorded revision to remain present, followed by comparison, | |
| 151 | flushing and refreshed header version | |
| 138 | 152 | metadata before acknowledgement. If a formatting attempt's revision is missing, |
| 139 | 153 | the complete requested effect can instead be confirmed on a uniquely aligned |
| 140 | 154 | range; 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 | 139 | *value as u64 - 6 |
| 140 | 140 | } |
| 141 | 141 | onestore_offline::Operation::Split(_) |
| 142 | | onestore_offline::Operation::CreatePage(_) | |
| 142 | 143 | | onestore_offline::Operation::Join(_) |
| 143 | 144 | | onestore_offline::Operation::Outline(_) |
| 144 | 145 | | onestore_offline::Operation::Tree(_) => { |
crates/onestore-offline/src/lib.rs+24-4| ... | ... | @@ -2,7 +2,7 @@ |
| 2 | 2 | #![doc = include_str!("../README.md")] |
| 3 | 3 | |
| 4 | 4 | use onestore::{ |
| 5 | ExGuid, Insertion, PreparedEdit, RevisionIndex, Store, | |
| 5 | ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store, | |
| 6 | 6 | document::{Document, Kind}, |
| 7 | 7 | }; |
| 8 | 8 | use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; |
| ... | ... | @@ -51,7 +51,7 @@ pub enum Error { |
| 51 | 51 | type Result<T> = std::result::Result<T, Error>; |
| 52 | 52 | |
| 53 | 53 | const APPLICATION_ID: u32 = 0x4f4e454f; |
| 54 | const SCHEMA_VERSION: u32 = 9; | |
| 54 | const SCHEMA_VERSION: u32 = 10; | |
| 55 | 55 | |
| 56 | 56 | /// Text and its observed precondition, retained across cache reopen and rebasing. |
| 57 | 57 | #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] |
| ... | ... | @@ -66,6 +66,7 @@ pub struct TextEdit { |
| 66 | 66 | #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] |
| 67 | 67 | #[serde(deny_unknown_fields)] |
| 68 | 68 | pub enum Operation { |
| 69 | CreatePage(PageCreation), | |
| 69 | 70 | Text(TextEdit), |
| 70 | 71 | Insert(Insertion), |
| 71 | 72 | Format(FormatEdit), |
| ... | ... | @@ -232,6 +233,17 @@ impl Replica { |
| 232 | 233 | self.record(source, space, Operation::Insert(insertion.clone()), &edit) |
| 233 | 234 | } |
| 234 | 235 | |
| 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 | 247 | fn record( |
| 236 | 248 | &self, |
| 237 | 249 | source: &[u8], |
| ... | ... | @@ -372,12 +384,20 @@ fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> { |
| 372 | 384 | let mut rows = query.query([])?; |
| 373 | 385 | let mut edits = Vec::new(); |
| 374 | 386 | while let Some(row) = rows.next()? { |
| 375 | edits.push(PendingEdit { | |
| 387 | let edit = PendingEdit { | |
| 376 | 388 | id: u64::try_from(row.get::<_, i64>(0)?).map_err(io::Error::other)?, |
| 377 | 389 | space: row.get::<_, String>(1)?.parse()?, |
| 378 | 390 | operation: serde_json::from_str(&row.get::<_, String>(2)?) |
| 379 | 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 | 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 | 22 | CREATE TABLE attempt ( |
| 23 | 23 | id INTEGER PRIMARY KEY CHECK(id=1), |
| 24 | 24 | edit_id INTEGER NOT NULL UNIQUE REFERENCES edits(id) ON DELETE CASCADE, |
| 25 | revision TEXT NOT NULL | |
| 25 | revisions TEXT NOT NULL | |
| 26 | 26 | ) STRICT; |
| 27 | 27 | CREATE TABLE receipts ( |
| 28 | 28 | edit_id INTEGER PRIMARY KEY CHECK(edit_id>0), |
| ... | ... | @@ -44,6 +44,8 @@ pub(crate) fn migrate(transaction: &Transaction<'_>, version: u32) -> Result<()> |
| 44 | 44 | if version < 5 { |
| 45 | 45 | transaction.execute_batch(ASSETS)?; |
| 46 | 46 | } |
| 47 | transaction.execute_batch("ALTER TABLE attempt RENAME COLUMN revision TO revisions;")?; | |
| 48 | migrate_attempts(transaction)?; | |
| 47 | 49 | return Ok(()); |
| 48 | 50 | } |
| 49 | 51 | |
| ... | ... | @@ -125,5 +127,14 @@ pub(crate) fn migrate(transaction: &Transaction<'_>, version: u32) -> Result<()> |
| 125 | 127 | for (id, revision) in receipts { |
| 126 | 128 | transaction.execute("INSERT INTO receipts VALUES (?1,?2)", params![id, revision])?; |
| 127 | 129 | } |
| 130 | migrate_attempts(transaction)?; | |
| 131 | Ok(()) | |
| 132 | } | |
| 133 | ||
| 134 | fn 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 | 139 | Ok(()) |
| 129 | 140 | } |
crates/onestore-offline/src/sync.rs+97-18| ... | ... | @@ -1,7 +1,10 @@ |
| 1 | 1 | use super::*; |
| 2 | 2 | use onestore::{CommitError, CommitState}; |
| 3 | 3 | use rusqlite::OptionalExtension; |
| 4 | use std::sync::{MutexGuard, TryLockError}; | |
| 4 | use std::{ | |
| 5 | collections::BTreeMap, | |
| 6 | sync::{MutexGuard, TryLockError}, | |
| 7 | }; | |
| 5 | 8 | |
| 6 | 9 | /// A single remote file with fresh reads and native-compatible guarded publication. |
| 7 | 10 | /// Errors retain publication state; confirmation compares, flushes, and notifies cached readers. |
| ... | ... | @@ -91,7 +94,7 @@ impl Replica { |
| 91 | 94 | }; |
| 92 | 95 | let attempted = transaction |
| 93 | 96 | .query_row( |
| 94 | "SELECT revision FROM attempt WHERE edit_id=?1", | |
| 97 | "SELECT revisions FROM attempt WHERE edit_id=?1", | |
| 95 | 98 | [i64::try_from(intent.id).map_err(io::Error::other)?], |
| 96 | 99 | |row| row.get::<_, String>(0), |
| 97 | 100 | ) |
| ... | ... | @@ -100,17 +103,19 @@ impl Replica { |
| 100 | 103 | transaction.commit()?; |
| 101 | 104 | (intent, attempted) |
| 102 | 105 | }; |
| 103 | if let Some(revision) = attempted { | |
| 104 | let mut revision = revision.parse::<ExGuid>()?; | |
| 106 | if let Some(encoded) = attempted { | |
| 107 | let revisions = attempted_revisions(&encoded, intent.space)?; | |
| 108 | let mut revision = revisions[&intent.space]; | |
| 105 | 109 | let store = Store::parse(&snapshot)?; |
| 106 | 110 | let index = RevisionIndex::parse(&store)?; |
| 107 | if !index | |
| 108 | .spaces | |
| 109 | .get(&intent.space) | |
| 110 | .is_some_and(|space| space.revisions.contains_key(&revision)) | |
| 111 | { | |
| 111 | if !revisions.iter().all(|(space, revision)| { | |
| 112 | index | |
| 113 | .spaces | |
| 114 | .get(space) | |
| 115 | .is_some_and(|space| space.revisions.contains_key(revision)) | |
| 116 | }) { | |
| 112 | 117 | let satisfied = match &intent.operation { |
| 113 | Operation::Format(edit) => edit | |
| 118 | Operation::Format(edit) if revisions.len() == 1 => edit | |
| 114 | 119 | .prepare(&snapshot, intent.space)? |
| 115 | 120 | .is_ok_and(|prepared| prepared.as_bytes() == snapshot), |
| 116 | 121 | _ => false, |
| ... | ... | @@ -133,6 +138,8 @@ impl Replica { |
| 133 | 138 | return Ok(Some((intent.id, EditStatus::Published { revision }))); |
| 134 | 139 | } |
| 135 | 140 | let candidate = match &intent.operation { |
| 141 | Operation::CreatePage(page) => PreparedEdit::create_page(&snapshot, page) | |
| 142 | .map_err(|_| ConflictKind::StructureChanged), | |
| 136 | 143 | Operation::Format(edit) => edit.prepare(&snapshot, intent.space)?, |
| 137 | 144 | Operation::Split(edit) => edit.prepare(&snapshot, intent.space)?, |
| 138 | 145 | Operation::Join(edit) => edit.prepare(&snapshot, intent.space)?, |
| ... | ... | @@ -186,6 +193,21 @@ impl Replica { |
| 186 | 193 | let store = Store::parse(prepared.as_bytes())?; |
| 187 | 194 | let index = RevisionIndex::parse(&store)?; |
| 188 | 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 | 212 | let mut connection = self |
| 191 | 213 | .connection |
| ... | ... | @@ -198,10 +220,10 @@ impl Replica { |
| 198 | 220 | [i64::try_from(intent.id).map_err(io::Error::other)?], |
| 199 | 221 | )?; |
| 200 | 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 | 224 | params![ |
| 203 | 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 | 229 | transaction.commit()?; |
| ... | ... | @@ -322,10 +344,39 @@ impl Replica { |
| 322 | 344 | ) |
| 323 | 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 | } |
| 328 | 357 | |
| 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 | 380 | /// Reviews a join against both current cache images, retaining its original text identities. |
| 330 | 381 | /// The surviving text identity must remain the same for dependent edits. |
| 331 | 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 | 580 | revision: revision.parse()?, |
| 530 | 581 | })); |
| 531 | 582 | } |
| 532 | let record: Option<(Option<String>, Option<i64>)> = connection.query_row( | |
| 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()?; | |
| 583 | let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; | |
| 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 | 591 | Ok(match record { |
| 535 | 592 | None => None, |
| 536 | Some((Some(revision), _)) => Some(EditStatus::AwaitingConfirmation { | |
| 537 | revision: revision.parse()?, | |
| 593 | Some((Some(revision), _, space)) => Some(EditStatus::AwaitingConfirmation { | |
| 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 | 602 | 0 => ConflictKind::TextChanged, |
| 541 | 603 | 1 => ConflictKind::TargetUnavailable, |
| 542 | 604 | 2 => ConflictKind::UnsupportedEdit, |
| ... | ... | @@ -552,6 +614,23 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat |
| 552 | 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 | ||
| 621 | fn 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 | 509 | ) |
| 510 | 510 | .unwrap(); |
| 511 | 511 | db.pragma_update(None, "user_version", 8).unwrap(); |
| 512 | db.execute_batch("ALTER TABLE attempt RENAME COLUMN revisions TO revision;") | |
| 513 | .unwrap(); | |
| 512 | 514 | drop(db); |
| 513 | 515 | let cache = Replica::open(&path).unwrap(); |
| 514 | 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 | 784 | db.execute("UPDATE edits SET operation=?1", [value.to_string()]) |
| 785 | 785 | .unwrap(); |
| 786 | 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 | 789 | drop(db); |
| 790 | 790 | let before = fs::read(&path).unwrap(); |
| ... | ... | @@ -797,7 +797,7 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { |
| 797 | 797 | |
| 798 | 798 | #[test] |
| 799 | 799 | fn 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 | 801 | use onestore_offline::{ConflictKind, EditStatus, Recovery}; |
| 802 | 802 | use sha2::{Digest, Sha256}; |
| 803 | 803 | let directory = tempfile::tempdir().unwrap(); |
| ... | ... | @@ -837,7 +837,8 @@ fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_content_confl |
| 837 | 837 | ) |
| 838 | 838 | .unwrap(); |
| 839 | 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 | 842 | edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, |
| 842 | 843 | kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND {ceiling})) STRICT; |
| 843 | 844 | PRAGMA user_version={version}; |
| ... | ... | @@ -860,6 +861,22 @@ fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_content_confl |
| 860 | 861 | ) |
| 861 | 862 | .unwrap(); |
| 862 | 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 | 880 | let cache = Replica::open(&path).unwrap(); |
| 864 | 881 | assert_eq!(cache.pending().unwrap(), queue); |
| 865 | 882 | assert_eq!( |
crates/onestore-offline/tests/support/page_schedule.rs created+156| ... | ... | @@ -0,0 +1,156 @@ |
| 1 | use crate::disk; | |
| 2 | use onestore::{ | |
| 3 | CommitError, ExGuid, Insertion, PreparedEdit, RevisionIndex, Store, | |
| 4 | document::{Document, Kind}, | |
| 5 | }; | |
| 6 | use onestore_offline::{EditStatus, Operation, Remote, Replica}; | |
| 7 | use std::{io, sync::LazyLock}; | |
| 8 | ||
| 9 | #[path = "../../../onestore/tests/support/current.rs"] | |
| 10 | mod current; | |
| 11 | ||
| 12 | static SOURCE: LazyLock<Vec<u8>> = | |
| 13 | LazyLock::new(|| onestore::create_section("page-schedule.one", "Original", "Author").unwrap()); | |
| 14 | ||
| 15 | fn 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 | ||
| 22 | struct Session<'a> { | |
| 23 | disk: &'a mut disk::Disk, | |
| 24 | operation: Option<&'a Operation>, | |
| 25 | } | |
| 26 | ||
| 27 | impl 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 | ||
| 72 | pub 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 | 9 | mod disk; |
| 10 | 10 | #[path = "sync/outline.rs"] |
| 11 | 11 | mod outline; |
| 12 | #[path = "sync/page.rs"] | |
| 13 | mod page; | |
| 14 | #[path = "support/page_schedule.rs"] | |
| 15 | mod page_schedule; | |
| 12 | 16 | #[path = "sync/tree.rs"] |
| 13 | 17 | mod tree; |
| 14 | 18 | #[path = "support/tree_schedule.rs"] |
crates/onestore-offline/tests/sync/page.rs created+350| ... | ... | @@ -0,0 +1,350 @@ |
| 1 | use super::*; | |
| 2 | use onestore::{Insertion, PageCreation}; | |
| 3 | use onestore_offline::{Operation, Recovery}; | |
| 4 | use std::collections::BTreeMap; | |
| 5 | ||
| 6 | #[test] | |
| 7 | fn 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] | |
| 27 | fn 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] | |
| 101 | fn 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] | |
| 173 | fn 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] | |
| 228 | fn 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] | |
| 289 | fn 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 | 62 | }) |
| 63 | 63 | } |
| 64 | 64 | |
| 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 | 73 | fn validate(&self) -> Result<(), Error> { |
| 66 | 74 | if self.guid == [0; 16] |
| 67 | 75 | || self.series_guid == [0; 16] |
fuzz/Cargo.toml+7| ... | ... | @@ -121,3 +121,10 @@ path = "fuzz_targets/page.rs" |
| 121 | 121 | test = false |
| 122 | 122 | doc = false |
| 123 | 123 | bench = false |
| 124 | ||
| 125 | [[bin]] | |
| 126 | name = "offline_page" | |
| 127 | path = "fuzz_targets/offline_page.rs" | |
| 128 | test = false | |
| 129 | doc = false | |
| 130 | bench = false |
fuzz/fuzz_targets/offline_page.rs created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | #![no_main] | |
| 2 | use libfuzzer_sys::fuzz_target; | |
| 3 | ||
| 4 | #[path = "../../crates/onestore/tests/support/disk.rs"] | |
| 5 | mod disk; | |
| 6 | #[path = "../../crates/onestore-offline/tests/support/page_schedule.rs"] | |
| 7 | mod page_schedule; | |
| 8 | ||
| 9 | fuzz_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 | 11 | |
| 12 | 12 | |
| 13 | 13 | class 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 | 18 | def test_created_pages_and_both_writers_survive_native_cold_reopens(self): |
| 15 | 19 | for source, capture in [('candidate', 'cold'), ('native/notebook', 'native/cold'), ('followup', 'followup/cold')]: |
| 16 | 20 | with self.subTest(source=source): |