From cc5b6a1bca8a59e2c06208c9086e7b002f0e95df Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 11 Sep 2026 02:02:03 -0700 Subject: [PATCH] refactor: replace per-operation intents with page saves The notebook replica no longer records typed editing operations. The editor saves whole page models through Replica::save, which diffs the model against the stored page, writes the difference into the working image and queues one Operation::Page { before, after, author }; create_page, pages and delete_pages remain the explicit section operations. The per-operation modules (paragraph, formatting, outline, tree) and their conflict-review entry points are gone, replaced by review_page. Saves coalesce the way OneNote autosave does: a save for the same page as the newest unattempted intent replaces its model under the same id. An attempted or conflicting intent is never rewritten, and neither is the intent a running synchronization step has selected but not yet recorded an attempt for; that window would otherwise publish a stale model over an acknowledged save. Reconciliation of a page intent against a changed remote page is a three-way merge that picks each field per side: outline layout (position; width, its user flag and the wrap reservation as one group), paragraph style, lists, tags, parent and level, text content, and non-text content such as table cells. A local text edit re-applies over a remote formatting-only change of the same paragraph when its own formatting outside the replaced range is untouched. Child order takes the remote list and re-places the paragraphs the local side moved (the members outside a longest common subsequence with the stored order); a paragraph both sides moved, or that the local side moved and the remote removed or reparented, is a conflict rather than a silent loss. Paragraphs reparented under a locally added paragraph, as a split of a parent does, are placed under it. The cache schema is version 12 and any other version is refused, naming both versions, for caches and recovery archives alike; migrations return once the application is usable end to end. The offline-v4 corpus and the migration tests are removed. Tests are re-expressed on page saves through tests/support/model_ops.rs and split into one binary per subject (sync, sync_worker, sync_paragraph, sync_formatting, sync_outline with tree, sync_page, sync_pages, sync_model) sharing tests/support/server.rs. Tests whose subject was a per-operation mechanism (typed rebases, retired-format attribute confirmation, insertion placement review, tree attempt bookkeeping) are deleted. The tree schedule driver and the offline_paragraph/offline_tree fuzz targets are replaced by a page-model schedule (tests/support/model_schedule.rs, fuzz target offline_model). The per-operation document workload of the smb_offline_client example and its Python history check are removed pending a page-model design. The native reconciliation exporters still write the paragraph-edit manifests; the outline-edit and paragraph-edit corpus records and their Python expectations reflect the per-operation runs and are regenerated when the native gates rerun on intents. Verification: cargo fmt, cargo clippy --workspace --all-features --all-targets, cargo nextest run --workspace --all-features (431 passed, 32 skipped, 1 expectation fixed afterwards and rerun), doctests, fuzz crate check, Python lane (162 passed). Assisted-by: claude-fable-5.1 --- corpus/offline-v4/README.md | 15 - corpus/offline-v4/live.sqlite | Bin 114688 -> 0 bytes corpus/offline-v4/producer.rs | 33 - corpus/offline-v4/provenance.json | 20 - corpus/offline-v4/recovery.sqlite | Bin 114688 -> 0 bytes crates/notebook/README.md | 252 +- crates/notebook/examples/cache_probe.rs | 215 +- crates/notebook/examples/recovery_probe.rs | 37 +- .../notebook/examples/smb_offline_client.rs | 386 +- crates/notebook/src/assets.rs | 4 - crates/notebook/src/formatting.rs | 203 - crates/notebook/src/lib.rs | 221 +- crates/notebook/src/merge.rs | 245 +- crates/notebook/src/outline.rs | 138 - crates/notebook/src/paragraph.rs | 200 - crates/notebook/src/recovery.rs | 26 +- crates/notebook/src/schema.rs | 112 +- crates/notebook/src/sync.rs | 316 +- crates/notebook/src/tree.rs | 541 --- crates/notebook/tests/assets.rs | 124 +- crates/notebook/tests/cache.rs | 804 ++-- crates/notebook/tests/support/model_ops.rs | 245 ++ .../notebook/tests/support/model_schedule.rs | 371 ++ .../notebook/tests/support/page_schedule.rs | 94 +- crates/notebook/tests/support/server.rs | 132 + .../notebook/tests/support/tree_schedule.rs | 271 -- crates/notebook/tests/sync.rs | 3402 +---------------- crates/notebook/tests/sync/outline.rs | 719 ---- crates/notebook/tests/sync/tree.rs | 1108 +++--- crates/notebook/tests/sync_formatting.rs | 499 +++ .../tests/{sync/model.rs => sync_model.rs} | 12 +- crates/notebook/tests/sync_outline.rs | 580 +++ .../tests/{sync/page.rs => sync_page.rs} | 149 +- .../tests/{sync/pages.rs => sync_pages.rs} | 152 +- crates/notebook/tests/sync_paragraph.rs | 860 +++++ crates/notebook/tests/sync_worker.rs | 692 ++++ fuzz/Cargo.toml | 21 +- fuzz/fuzz_targets/offline_model.rs | 11 + fuzz/fuzz_targets/offline_paragraph.rs | 235 -- fuzz/fuzz_targets/offline_tree.rs | 9 - tools/test_offline_document_history.py | 54 - 41 files changed, 5186 insertions(+), 8322 deletions(-) delete mode 100644 corpus/offline-v4/README.md delete mode 100644 corpus/offline-v4/live.sqlite delete mode 100644 corpus/offline-v4/producer.rs delete mode 100644 corpus/offline-v4/provenance.json delete mode 100644 corpus/offline-v4/recovery.sqlite delete mode 100644 crates/notebook/src/formatting.rs delete mode 100644 crates/notebook/src/outline.rs delete mode 100644 crates/notebook/src/paragraph.rs delete mode 100644 crates/notebook/src/tree.rs create mode 100644 crates/notebook/tests/support/model_ops.rs create mode 100644 crates/notebook/tests/support/model_schedule.rs create mode 100644 crates/notebook/tests/support/server.rs delete mode 100644 crates/notebook/tests/support/tree_schedule.rs delete mode 100644 crates/notebook/tests/sync/outline.rs create mode 100644 crates/notebook/tests/sync_formatting.rs rename crates/notebook/tests/{sync/model.rs => sync_model.rs} (98%) create mode 100644 crates/notebook/tests/sync_outline.rs rename crates/notebook/tests/{sync/page.rs => sync_page.rs} (71%) rename crates/notebook/tests/{sync/pages.rs => sync_pages.rs} (88%) create mode 100644 crates/notebook/tests/sync_paragraph.rs create mode 100644 crates/notebook/tests/sync_worker.rs create mode 100644 fuzz/fuzz_targets/offline_model.rs delete mode 100644 fuzz/fuzz_targets/offline_paragraph.rs delete mode 100644 fuzz/fuzz_targets/offline_tree.rs diff --git a/corpus/offline-v4/README.md b/corpus/offline-v4/README.md deleted file mode 100644 index efdba39aca39aa6d6e002e1d3289860e16678fec..0000000000000000000000000000000000000000 --- a/corpus/offline-v4/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Original schema-4 offline state - -These databases were produced by `onestore-offline` at revision `2244711b`, -before the external-media table and schema-5 migration existed. Tests copy the -live database before opening it; the archive remains read-only. - -[`producer.rs`](producer.rs) records the original producer. Run it against that -revision's core/offline crates, from a checkout whose `corpus/offline-v4` does -not exist. It uses the public native external-asset fixture, queues a text edit, -simulates a publication with a lost response, queues a dependent text edit, and -exports the complete recovery archive. Generated revision IDs vary between runs. - -[`provenance.json`](provenance.json) records the original source/database hashes -and the retained uncertain and pending edit IDs. No credentials or personal -notebook content are included. diff --git a/corpus/offline-v4/live.sqlite b/corpus/offline-v4/live.sqlite deleted file mode 100644 index 130c341ba39987df0dfdc217633989160c2f23f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 114688 zcmeHQ34Be*{y#VSDnW=6TxqCOLP#QFOKvu6A|gtON66KrB_at@TW^($3PsaaZKXn6 zJf+fBYilj9x}miHQi{IndzNS^9!l%~{ho7Aa&vQHDQcVf+_~qRnK^T2zB6ae%x`{6 zkM3PFa*g`I!?K5_=IVnbPf4efBK3Mnl5F99;H$yAz}v&y!K+S(6ZyJZLTooR(CTO_ zHGTPW_bTp^qiccW;a}y6DmC1Gb3f=7k4xbM0s;YnfIvVXAP^7;2m}NI4>SVLws&;& zu3uj_p-XP+z#+y#!!idC$rzNIQ+nbQofKo>EPi175Ws=Y>S)b6eYgc`A$JppDjpewg*yLWZu?hMhy&)k+AMEequkVqZ)G0do zSziZ7@49t$6TIaLWE%$=GqQ4X)T3j$;;W(N(z6D1SF43m26Kw`LH{;4B!hsoFd&DHDQ z+^KwvT2N}!vi1NK=Se7w6 zHTOEYO|nkzHWQvP_w6jao2l7G`CwD77Un>!dZbYw+1uxht&6%%?Hp-ZKXmC^!RClR#yqXRGWl%Rmog58dZxZrQ%zslvDkl+ zhw3X!BfFUO2L(Onw1 z>K)&RA9N&h%g$sgO{gn*O%0K}Hu^|jjS)!y!dpj_c!?>Qye#iQb)^92y*I#X$5Y;h zgxM+GkB|Kjq1JgzThE?9w|ewrMNCasJU}wNI%!Pq0>2mHCSB<@uv#yNTFl?qQ!@D> zj%mJV)oJ&^8K1>ZTm8$#&+}IN#&D+jVdU{O^XHbty`8tQgGblpBO2qLCAm_*WTxJ? zLK+hE9VG|3yb>c@nIrXx;JDLVvO#Td!pPp{$VglhJty30!^pMBy4={*<)h#UTWV~c zs@A6}647~ONha&^G6TAL+a=Rd`>4mJjn>7#uRof!IOSAZHBL!U_OFDmnjy`YZ7$O; zL~0>5F-yu<#!O|@RP(rWa&TcEjnWVi=B^b-Kmdp2!|F=&s^F9kC9tk9H%sW;&=y+t z^^BBeq;n?)?v!%S#1ADgalqC|_0d1ep++Q(n|yJKUf+jWy@FX_nLlCdNO;n{3LVl#3ipy-Z#F?qjaicox_Va{t0MR3!~^bmq;o4tdaFEM1kd37cI)9}uyOKE;Q zk;_pB_bB!B#q+Dzll=blkvtiFef1$4&gNc;QKouY z&_Mv#~}`z z{NN%OYRu%L(~Mb&!MWad+_-1k>sXa$e|J@GvJPe>Pyu=W-{YKwpKt;Jfq+0jARrJB z2nYlO0s;YnfIvVXAP^7;+)oHN>ukMsvaE%Tvi{#KdF;NQDk$nA5D*9m1Ox&C0fB%( zKp-Fx5C{ka1Oftq`yGJ*ot@;|Z%k~sAv7ok3=*N?Em{VK#D;|gwrUX+9vIZTRdifP zm?1i-c`$e@WT`ig%MyOV2?PWJ0s(=5KtLcM5D*9m1Ox&C0fB%(Kp=1*B4BIl?9Bat zS?$PuSU*uEfq+0jARrJB2nYlO0s;YnfIvVXAP^7;2m}-a#QI$@JvO6K{SrD9F9$o{i^!S=zn}vD0#jnW9y>rou17 zf$I6e)Bggm+WZA?o0ujQSQbgnX4cw}s(ny~XF63K{!|_3sKS|_O|{$E zwT}&Ad6QNgY%-axXPHbaW2^ORoVRazR2TZ~?Bxw7oGY`iCO@I($i)a@!3fmXed)Ej`QH>!&^Rk`LahN=xNu>*-nrAuI1Pc&G7|ofp4h#tyId- zuM*+4blRzDH#AymEv1;hzJajHW|VU9pM$V$SZK?X&j!KDSXQaUwiNjrorPE>UByzU zjceJi{&<#RUrnoBiGTU_i*`BJ6H`V4_xwbo-b^EjR9NEXWqfF{JKn$Gi%%%$^ zTN-t1**Y{-pBT@qE&B!S?_+go8sZR&! zdl<7|qGjpxEXz&HbvILPn#H5-JonQ6XFX=I+{Bp}5OdXF>@v0l9&@6$J>-#_{fNF6vR%{cgp^o%E;8ZtC< zSl00DoZJy3N98>|dd%2yo;uN^zkQ~w`|?^uTQss z_W2iIezjxguCI6R`R3dI`)=>P@An`0;ozaeKmPltBR~J=zekT9KT&e>mtRkv{_V`! zbN@Sk;o|T3=!>e0R=;;TJYHEEx4mNy!;OjKPnpeRj#>P63VY0Qqv-L^783TDEi7FV z5PrXeJ!WB#xpJGE=j1t~vSEkq*n3^t&nl8L{QjUOQ(uURt|;+n1ha;(T-BAjESiqEKXU zvj2ZwHa>o?_+;PJpS-caXc$ie^ICKZ)gCtIzjU-OZ*BLU?N3Mh@($IT#U+n#-f>!s zseOm*_w-cbloVzEN;vJyuQeCZgdiY_S+ zbk=uH$=j)u27I%xPT5+VYfm?1?tFV#yRIKOd*s&tLpqbkU~KA4X3^^cEFGk;6ks5Nb#G*hPWXub8@abuI6 zsx}(C>)~|AB8C$;>%PEnx%6L*pF44XxQ)xN$J!kXNnEbdSgY9Wn)%QF&h5PV?3Sq$ zF6f_O-Xy5D1!2dDAUT2LWTRuyWy~9$T<{P_1nK0U^>R>Ysv97?UhSP@pJ6#jyM8Y@ z#IFCrY4*~Ez*_D8cXBbqm)b`?Hf^*n{(b$?q{S(x+A@q@l1N`4i<0|k!8%ckzJBTC z;6k6ff?6zBK>P^=9tZ@q-haOL{M>PT;h$3=^<5uzXmL2NIp!&| zA0^H(`rQbA+c@Si?WfQ5|2MCBKd()X+kA-)c=??9lD^9Dxc9S>(8q4|e+s14E#iOg$#ZeE>^6(qSi54d{jBhvG_zA<|+_;~^FfWgc zo($uci`S=1b~R#|_zZqh8Ajj8ro~}ivuyRvVGJMq=jshCvs%}|kMwECcs^SX&u2F< zpEG^iRV; zTvpgUnzw1LpKb+z(vop6J-qfnJzblHMG1pe%^Gm^GM4ct4QVD9x*UB2AtrMu*2C)F zeq#jDm+>cYZbrv7xK?E?*Wi`$CpBrf|7^p-JriDh_wgO)`VDHta&w`~iKG>O5||?H z4gMrl!Bj=+170LM@C(?(JHWePZ`%d57FX~nGzDKyGx#8QN5r`!&KXB1c=n9n$wgKX@Q_L75lIJDrjAe z6=|zBM6{pTz=shfh;Vpzd&5l`?&seOvgsdIyy(TJf~$1; z=&{mbi4%c&3cm4vJlD13rCJIY-Tw0~2T>h-n;x~E@m@<6OP_~phBeUG0;XBQF0Ow{m#w+P^8+av|C%wWSjUvfo z&b7;@#Vcu5K0?-0Dw+b?;T9N6qye=Go^(*}YbCDkFRhk~I12{s-*-@U)9rJa$)T`0Mi*C>h>E z|61Q@YaUd2ZDUoZDTxUk++AvOC)~YL$CxA>_4H08X6K9^eHI5@PEv;`Y2^+dOtft_ zbc!(~_wI1A*M{leM|##B|01J0f@Z4frs{ z;pgm!d_L73I^W~B>XGe#{rKY8=TDAw{ps|D%r*Pgce|ML&CvJy*w<>cc4-5X>CgTF zH4V$Z9sROHE+J$gV*evJ9y30llN@xUFFj> zUAm*q$A%)qpL0*NP7J8I=(`g?|M$$r{RwZLv6@NqZ?127H6B8nc2H)QlEdvPY<8&dg5H|Uw(}k*sNJn$03PN zIy`5vb=c{JTOb>*BxyZqdiI}ADg-w=INi*-MJTRX9O z+_B)Cw=a#J^wI7&2K_H4%`-J`%WC^qpWW@yt-6PI|=VgiX?^l#FR{Lx){N zua-K+Cd90a8Zbe!9#;@|6KiRHBoeWHegZNGF*s7LBpIt{$|N9TlS%%zTCcD<=9Y6; zUXpT5$Y+44z+RYk3JdE&ohT<>usC^YJoHFLnIn{udo-D*qhV`^k;P;~e$h zukfw*-b3T~&9|hfL>8UeP;z#It?T((hIS75apY5An#yP`mC3JuiE&y-sABkemYEWY z5Mu<|<^neQqs>FHP(_jYQ?EMn_6)! z3Ht##A5xI776p#^_WM|yf~eI}3=>Wu@L(gLHGi@iAFJ?9;own(!NZvo^D;sE5RNC@ z+ba{amuZ{c2vPsv9I#j-q(z@+d8)_Y${CtPk{%Du>>3+Ky z)-wC@v!*6EhJzPD*BmF7iVvFO=1>gMq2RR-#xW4bmbeO*6(5Aj3K0TvYcvW>6$n&% z5CUOMsyqmn6(mqOg0|v=EQswrAwM3|b>yn?c%gRz$Ov~E5V+bHpt8yey$e_5nW^<0 zslK^Agx&=JVKIlqmB|bZ=JJ*AYwFF3gx-bn{fjgyvXbK)h>rp3!9wo>KaKo43%v_v z+8;P;p|{)*`kVF;gx&?AcfqW$K-CMOwU@v>D+|34T*}|Jy@c}nV|Wr*4yK#iOJJVJ z<(BUyFkhCC?l$ZtkX&Nzgz^EQa^eQHZJ-Nz2#p)}kFpUC3D$IfmEP09JYH}v{%h~C zO+8=RYZsWjtY-M|ju$z{u%w5N+Tk4ic0?7M5W78J#69Lw?OOEk<>THyF!cNJofr5% z_SNM*JNGa@nS5iw{G~iSyd<&$J-i4(w}l=af{e@2!*e^MV00F185JL{^;mAw!;@n{ z57z?es`PMc)GX=Y)^V2faO*g0dbpOxJeHL7a4n60jI-XkV3=?Mfd?CbtI@(502;m} z9Jfj$D$v5mBIw3x;UUbZJT3e{`wF!1Aqc8co)%u*t^zGQ89`RGa32I&(ZVmc!O&Hn z7XI^v;NA7>K=K!{9oDg^rct29CAC{B|mgi)#R}78P+w5_+gG`otfu4E!pKg z^W^rGdr7yl^lExOi|5=wvvg{>8g?S7>1@xoyJIHRIODT5zNYuXHKW?e0>?;@PR|`N zxMcV|4ia$r@RoHs%18^h{g|;yQS(2dpIf!g`?FQLZlUJ59g~|aefwlY=a*hM+9qb* z;%hR575gvpP`yR=;#44T-yndmp^2~9_3K~Z_fvKz+tR&{jgS%GrZoKvZyizM<@p3T zcFXdno*42v?G5nS@szhAVRlOQ<6}QW=(tty_(D%>Y2Kx}tC^avcz|Sjb<&vJ1%5BY zO}f%+V6|QjvZfhd=)Cbm9Me?Pw<4LCqqVM{#<++2o)G29wGdq^c&=C!eJVI%f>!28 zJ+3+S2t9N{4;{Jz`jpHYZ&#}8mb`pz;eD5aM;$5HYvaHlm710?csHC0 z^KN>xnrQByko|-!YkEZngZjsrFLjbr9)o(xOY`H2Tn=*ODfOhj@9Om=zds=p#OUj* z578*&PUQPYthDe!LX^{1B7dVQ-^e?e)|#_YnN_H?3+l0=;~1f(t?XvTh_@V+yxAV) zEhHbKJWO$X_}Cnog$NSj6=oisFfzyyCvXGcSzd*$2Pcf2VUA41HTj8DmPI-8usM=r z5}&KEB;ka23O#g(c5ge{wMjGftlOuDu6K8254RBBnlFM)O(*owvCCJ~TPXChiI?a>K6bN~UALv?(_l?k5is`477bJ#^*uLYHxRsWhp&=5bc&p#u;WG$|Dy&Xh>A7kcQ-9Yq`j z0{0mLS~L#z{pa||_n&fiLI)kh*#1@>baF>?#y?qz$;v?1PHv-;CzmWID%-TWHGm-D z9v}or{&`@C(L858yGw`aax+@U%Ad50ZD@Me0rn5pc21I{z%LvgJ zAVp}STMLcF?XzsiU$h>{uTtZ=11!LFGOP`X-G43Gg#1} z5n?ij3egxd5S1OY`WMm)(U{5r6X61ZyBq8{FG1K99NXhK5uW+6G^FC#g| zANPTsOVuD+wx6n^P*qu2uC0`MTD4WFzC!3OM7u?@ogP6-cX+STXY{Wt`5izXm=50+ zeIOn_0lpi&4&DZS0sQOm3*i^RQ{^B{F3<9joIKDkJ>k{zu>aseC=0z5NuN0FO@kz@ zgqIk`IOr%hwP#CNn`y>mN{Ge)S8Wj!@&b@|%C*$Y+d;IO1b;QNO3X`$#zpUG>SkuL{F`A>r;U0$Ot@bqo;VF(4ESft`T%Fg_9bcZ8*YDlY_CnWft53rPi%qN;!?M5o(YU@8!1 zsqXB{eH~A~+WSvF9y&Le?L4#Q{Nd<%tL+^6rrV4<_~Lf<4NpH)VF3hG{Nz;k=ik-v zsc)__RsLz=!{~b3wGCxzp+#zzGUNI(9H^cjJpIq`zWGuuQ_8p1rOjUua!n(kv(|=G z?SnE{8{PyBPo@`Z#gPw-@jhUKWf`>$eImMoQ_4sq14>kGoH+?1ZdB!;-h3;?)3qvV zX%^PHZjDjy0^IjeWXV@j(2MbFOTMxxN&5D(I%X9EL0eWbj@8t07+ZlL9qZhdTx&PN z0BkPj?Ko)lFO>0y_X@}-XR%k%Jg7wop-{bCbii>p?HMR=lEw=ci6i`7w`V{ZF`9sE zfp6%wcMh~uCnJw&skM{>b3>V!P6Wa*kx|OQe-6U3VWcQiJ{trBMOmdri&ErWiY&w` z=_-~&ZCuNCrTI<8#E{lr&@J0TIGd)i-}7MXA?Sgywt6kKg_K>T>}Q5;`K{WI(a(*` zne)nc?<;IA?%2?q`WEXV#Yef;@`KUXfP6;69&QT`4~VdkN+H2yVO1@@c8rvML`Lf#Qc{rlmd|pzmSK z$~6uhXw23Jg$3wa1hr^sMx%VWxkkL1a&wQFvgGcidsvqHWZdUuxw$7o47fi+98}sr z%g--{yvK~FFi$i+=_o`9TyVgUs>-f~IS6|=2_hG+=jUH+2|7eqdz^2WDnzslJ32hnH zD!g@@w(Z(SL>i)^V{WCjdX&q1=B$?rUw-A)*{{u+J8%Ai*B35&V{y@vrEk9V_Oj(G z{<-pyY~1wmC!4oy-S)3fw}1Be7hit0W9P1~cklV;+yDD+ z@4oN%ANb+mp~FA^`==v6|L4C)j~zcza`Km7Po4hl%-M7QJAdKg@A&ZFUDX#=7p?wx zcX$p3m8sfmU>+wp7yq^Q*ruMZ?X?R`URE=Fc*lzzm@Vhxqjoq)za3Gd#gVDngJm*$@Is=?XqgG0EZ1 zcD#fcm(BDM{>$^)cK^xYN`k9&`sgu|9!dg{gv>EICUJUpd&3I!@DOBNjvk)d8R_XP z)-ozBjB7pCk@WE7Sgg*pK+;^D#s09lBUfP7sFmq3s2Hc}FwnY>fN_B5$}I)?YOUN@ z(taOnuROI{iebVD1RiVzwASM4``-Z2@GX%y(l7^f=EPF54a%Ug2&%%lY8mk}4I@xh zPV1FHs@Q+!S3&D)tVmn6A)@`v20pwigc&KbQ<6*>bO5|guAHnZ88K)Gf~s&tP%NA> zsJI4BZdgDdK z8@I1XnALI9BCp|-J}5OXH(_eJ;sKKB)k$M=7x=vpH|a{Rfz^6B)M80) z#ss^()Ic8C<`a78Drku-TfSZNY_>At*ZQk`aGSJavSf4GhyCMpJLagKc2|tAQeKh-^X|=$xQ=uQwIWz$LFi{h#^!l2(`Y^44&~Cims}0&xnif&paH5;n@v)FI!6>Z zJ^Xi5{xf3BqrG;{ZoIU1{kAVX&v>DS4x_5svCZj)9y(HG)?+H^Oq%rz><**04TcI7vdG)>NH)$w=Kp~d06#%7y5&sD<>qu-6- zw~ZqK)I#X*G^O~P* z#ut3jSk9MhD!gV{{1=a|Mm}>rJm=ozpWvKYov^nXZv2GdsvbBmkBpwY<_dy&oG#hb zh-Kn4$gj#U`oB{T9cTJ%1M+?F$miEi8}>d9!U+U!I|Aywo9`2y8}=`xFUHTExIf&+ z<=12F4u&KyXP@*0zxH~(Tz+z@`}0q0j8yUAI6)kou!lbn7k*kG%>YPl*dg;{sv%)G zsf@Mz{sd6eN+56#BXG6%pYsMf%4=x7`i!G2MDW5IJEQgtb*%QgUX-Pj2hBuDfm8?`W@}FNE zvp>o)vv|~{uNfiB;63Ty<-&|#7HlXm3|M=%VQ2XcnLovvD}{H+taH{3bMiCDc4y6O zSFUK{LLeXzxRnT0Hs0J1I_UU4)?w^yfTPeur;O#6eTVcMOMP_-xW0KgGRqu+zWfD| zX+Nd1fq#<>nNn43!_qr287Pn1yOuO7SCp*SGNf{+oP+2M58rd=C zt6)otud{k8Q(@(|Ig;ZBT%`PVNX7Sivq2aI3U!Y`o5B+O>qpfk3WzntbG$k>igS$&@?u5H{>KK!Rqn_T0 z#O$2WqtD`?%Sq}GC9T}ya}4dcp;L?@xp#+?y*5n$KGL(^s%=Xm=S^Jb>ZKna^I33_ zi+i=uou2&)+mC2GYrQngGtcd#qeUG(Yr9M+Z0uDOeI#h);E`+kjA?gb&6K2uP2yLd zIKAV8Lu1!ONLTi6+7Y?KZ@`Bs4nJo<&F+zK7Vqg>rbaIWUkq_ zzT3r|Z-&0t$G%ppwM!eAOn>$dsA*XK?dX>sA{XSm@p59cXZpqg6FzWFY5IN2cax$P z2|aW|51r6MC-l%&kh?2^lu9|dJE4aT1HEtpfd>o$?(?foYYwH-ImsWunJc-ciZFOs z$=-!G^!wbSBM}h*}DJ)xpPmyO!h7pa$Q!kcU}mxlD#|NS3&mf%ZI__ z%(Zoy>|MLnhFV7_Ki}cCKiZ{zy5BB_wY-~U?}Q#YG>~w&0fDQ%|K#hTBVU8oW<2>C zgdRF&UsDSoVkh*_sd88<6-_FYpVAW`IH8A5kx5l~3rV|!@(c;zK(nk(K!~KLe2c1o zO4Vyr482K;t|y^~j`-}PtP}QrQ*oRJKObJjia0G__Gkn>C4Zdl^uYI8j_si87gOcb z722Z832B*wcIsp#x%vHrilyMJg`RR$k86osKFC@=(H>H(nmSUs5+;3Dvvuduc4yvhI)5Yy*`UR zuUxOsUnxV`^R7?NkNW&urRS$Kzia6EVY1BRk4(dGCV0{ylzmK`P|MB*b7U5-xq(_v1y6c6guo@jd&4VwMRCGA z*P0`#7K}}#Xc#p|o-#-F#x-S9m3>*9tSvPgb5(sWur}zgAR`x{(n3F5&3Sj z!kKF)9?N66L-l5H$>W=MoMxdXhvAqNL;Fid78uSGL0V`|%#!jg-!LV$B<-l3(L~YJ z#SqIB>Kt56DUP=Yf!m?gpG6ak%TlACA(2+hRzk`7E$X}?8-c(*h=A6IO`V7|Kz;Zj z6^lzO4)3VJPqDbX2VbpwqR3kBzq#OIajB_9wsbM*1e}z01_AraN@Pp(6N}3SZgHt7 z;^;_D9Jqz>7K_UpeE(u_cN$1LSu*1X&lL8LTSM?cNB;}No?BHWh1jI6T$%6Bt$Ge$ zXGQ(8ItK!iw(N=rNT#GVZ~brEwr?X}9~#!#); -impl Remote for LostReply { - fn read(&mut self) -> io::Result> { Ok(self.0.clone()) } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { - self.0 = edit.as_bytes().to_vec(); - Err(CommitError { state: CommitState::Unknown, error: io::ErrorKind::ConnectionReset.into() }) - } - fn confirm(&mut self, _: &[u8]) -> Result<(), CommitError> { panic!("No confirmation is expected") } -} -fn main() -> Result<(), Box> { - let root = Path::new("corpus/offline-v4"); fs::create_dir(root)?; - let source = fs::read("corpus/native-external-assets/notebook/synthetic.one")?; - let store = Store::parse(&source)?; let index = RevisionIndex::parse(&store)?; let document = Document::parse(&index)?; - let (space, object) = document.spaces.iter().find_map(|(sid, space)| { - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - view.nodes.iter().find_map(|(oid,node)| matches!(&node.kind,Kind::RichText { text, .. } if text == "Native before 🦀").then_some((*sid,*oid))) - }).unwrap(); - let cache = Replica::create(root.join("live.sqlite"), &source)?; - let first = cache.edit_text(&source, space, object, 0..0, "queued ")?.unwrap(); - assert!(cache.sync_once(&mut LostReply(source.clone())).is_err()); - let working = cache.snapshot()?; - let second = cache.edit_text(&working, space, object, 0..0, "dependent ")?.unwrap(); - cache.export_recovery(root.join("recovery.sqlite"))?; - let recovery = Recovery::open(root.join("recovery.sqlite"))?; - assert_eq!(cache.pending()?, recovery.pending()?); - assert_eq!(recovery.summary()?.queued_edits, 2); - assert_eq!(recovery.summary()?.uncertain_edits, 1); - println!("Original schema-4 producer: IDs {first}, {second}; {:?}", recovery.summary()?); - Ok(()) -} diff --git a/corpus/offline-v4/provenance.json b/corpus/offline-v4/provenance.json deleted file mode 100644 index a6c712b2debcba0482044d9da4e8a5ba8a33f585..0000000000000000000000000000000000000000 --- a/corpus/offline-v4/provenance.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "producer_revision": "2244711b", - "producer": "corpus/offline-v4/producer.rs", - "source_sha256": { - "crates/onestore-offline/src/lib.rs": "f4b6bdc9b8269e43cc8c1d69f5becb3140905f520438da53fb96ce1663352f22", - "crates/onestore-offline/src/schema.rs": "b20d6c127b32dc378c54ef8179583782d37b79496c4056899d053881034b9963", - "crates/onestore-offline/src/sync.rs": "3967d304c42550350c7fe2659317d665b8c4d7cdfd42f3eb013180ca91177442", - "crates/onestore-offline/src/recovery.rs": "1301b649085670e847b4593bc308f9a1e25713a99b5333d4fd217014720d29c3" - }, - "files": { - "recovery.sqlite": "2946f7b2f2636b4dc80b2d3fd930dfe1d5bdf3cafd591d5c33d50be209685a76", - "live.sqlite": "ff0b2ad1141398c10913d3f573ba909aa7cc6e7b3fb809ecd3d9869b689ff970" - }, - "queued_ids": [ - 1, - 2 - ], - "uncertain_id": 1, - "archive_version": 4 -} diff --git a/corpus/offline-v4/recovery.sqlite b/corpus/offline-v4/recovery.sqlite deleted file mode 100644 index 67f06892d762d34917c2cf6b0520ea303c5c18e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 114688 zcmeHQ34Be*{y#VSDnW=6TxqCOLP#QFOKvu6A|gtON66KrB_at@TW^($3PsaaZKXn6 zJf+fBYilj9x}miHQi{IndzNS^9!l%~{ho7Aa&vQHDQcVf+_~qRnK^T2zB6ae%x`{6 zkM3PFa*g`I!?K5_=IVnbPf4efBK3Mnl5F68;H$yA!0X`c;8j<8-7O(DNe#3*+DJ`b z{@lHayX5FvAbI##d7?@Ux8K|ky2ayCIDvpbKp-Fx5C{ka1Ofs9fxrWez_aZg9lh(< z*G=e>n>uiaanP{L!9y|z<>r*0I7KJL8j@r6$%d$|vHH?rePb6roG~pUw|_>OzEeVS zY=_t+ePU9lctg_T`Yy4L>yu*RVv}MMqGNl=$#NQJr1|T+CFo;fyT&5JXhV-^LriRd zoX=AknPr(IbW7GJ^z7PIAKfuFx=Uj@E-E&;S8Qy8K1gp!h|vf8yZGySBqw!>PJY(c z!O^>JUEKt4xdPe7LB@=%+#L1jSg!bLsJZm40o~PVp_IX#qJ5CRQeNdgy*}GGG9xEr zSf)NXwl@lGF1TDJCMhjshMl8#ojSVb{N+NX=H?oQX62d>_T`Fb4zwy^B@iox%Sdb2 z+^SsGHBw8a@0rl4d(YS#YMUFZYoNV@x1o+sLW>&na)uAVQm=pNh}>cFb$@g9`ZqV9 zIN)_p_iSBV3AoFcG#^EE3nS+d0mv-dJ~koQ+&eSUDs+mRtkglq@;#z_uNjtQ%uda{ zj&75zle^7?XUu&&3-4xXwoyLVl&gg~(5fD4)7@zIQSTd=nq$;Qb?p{a+O^A9L`^qp zST^3$C(0)+eGAl7>f7Ls=GM6lk?<2vAaGwIFyX^2os%@-!(9B1B&Ccg*!yPu%GEvc zcKN@u=jltYbUEa9xJ!QMrmD$d;WMlOi1=ZSXPuenIxX4dJ@e%Dm3tpW0!y!^zXHc| zFEzk?RKwNa6G=^Hd$!#jGpWWIpRMsVy&tX_)lPC#V$*R*H=iftJkM1L&&PrJ&@m)8 zK?zs?eNXoGd1LFMZc{r)n${0p`c|+x;*T*;>#s~c+x4Z4L!q9jZ`D-OS9UD+U*w_s z%F>8%fxx|t0EpHmzGAjgkdNeJ^pR@meI!3mgt&dZuEV?kaOg7C@z~2TBTsaf#;tnC zH{u5!$=tFt*-8`YN?ub#B(IG=l2>B{(!cQ55hY$GkTZE%-h=8&0nB@EfY*+vybTGn zQ@S4?`yoQD^Om-rJ%4WX=*Nnfnyz?&WO{YdnA`<^FT_o{(raL~UJkWb5?@cr z`Jz>)-3MoU7C&wEFB3n{Tk#vindXO)$JflCTN3wn-o_3dU6+q&jC+>kO8JtRdfy6Z zNYHnb97ye#Bu2I}N9u9Sai=-y|7v7!b7UkUh@KPfv|;30WL<7->he+Wge^5TPgUzv z6^ZD)vLusrd6@xSz3q}|seROA(?;v!-`5{aTAXsKts1AKDEn8!SIv-S%r=*47b3Ng znwTZ!D`TcIYN~l$Iytzok49%ZQ*^F?&`(Vd}bW-&chQ%$a$idBJA4FL?Su=S~xnD=5OmGxm82myzq|IfUg7Z z1Ft?0pK^0CdB0?eP<*0c&T9`v;N6o<4+G+^! z90)8kaIJi{)%U47?}X#{aBkg@FN;??7}gxxz3ph%Ce8T#n;9S8;sOwk?ZJ6WnC1BJ zu{kme5jEh0EXRi+M})u)fM4Yu+VV<&BypA8}o8~TaWP_Htm>tIF#6_EG;J;K)7$L{;7f}$=00fB%(Kp-Fx5C{ka1Ofs9 zfq+0jARrL9-w_DV*-6g*#>9pjLW5$!AQ2kgqGe!6Y*<`!{pEy#0JS}Vi4aH7G^S$<3Qw`3A-v1LX`7@0Vb1iPR0A;KUp1UWT8kb7y+Rv2V)X^5aIa?sWSQ$YcO92+4h zUV;rC-IMNJF3k94!G;3EfVF2Ec9xjLryg#gfgUA^W89Kf{_~4t_D4Bp7LU60^;%Dp z=?X2d2zPrBu(ZZxZ8r%`BReAJ1wV}DO#0lD*Q4WsGc7@ z{m<~e`BE)Y%D2^}&0p}giD^=SWs&4;W~~jW+6QHLrc>47Pt|dbDxCS*RJ)yB``93s zH)+MeCX?BEmdV62wpzc&dHa?}b)nzRUfyuRxiSlD@)OGblrrL#G$~=ZappKgsQm{a z=2*#OQ)-`0waq4#+Evq=Z!Jdu(yFX4UVcBsF`0GU8Y7Nl)<=;gUr9kP#;+~;%BCdg z+smvk2dH8oXv<2*$u^M~v;sjoV$v1^l%(U(f{Q!sEE`kB4`noQNaV<=*x(8Xk@U2C zH9JJHfz=*1swTo0`$+k&+jjMGwxdxZLvC*BpmeIl)i)1n5ke?bk5k&flT5VzM$ds- z$(y#fXoHLPw`h-xcDdd~zG~jgU+E6W%+vt)RVlR#Mw?*OaS!jKJ+O z1xK~Ka$d-RmaNcW=wK?0E)qw0oop~nni0t`v)N5Hpo#W9o4_m2o{RE+1)G06;3Iq_ zJ#BUopPjUUhBBq%I1he4yydf(FMBkCo_4*Q?exg+T8{0|9AD5D_=c+AN~Qe#DiK~w zr=6O1L!+hEQi}QO8wjgxMkxpXIS9*!g|E7~m{J^{WPybj(5egXXJ@C)G=!P92j1^8J$l9LDesVBTz9;FRT zC=0z5NuN0FO@kz@gqO?6xc2mHvys3xTSl2S-IRX#7>>OEt1rJqI-WX6wB<*8l(gfg z+VqocQzF`c`*YD+gQRq6DB3a;SNhU^qA+h}KQnC0Z`FQ`er{aOoL9zsUtu}bG0@bv zz$X-TEcqyT9G30*(bk=-j1%AYgTP0E&xd!bE9Fndk$rqS%A}41#K76kY`RdgrBSz* ztwTfgiSf+ZvR}~tp3=@*{etf-pAiClnWv1cxpdcCzW1(OJ{kawAC{Sx`gDN4hcOFA z-Uk}9^+90)`W8VgT9!V~vfQ*>cQfUtSv=a#b1&_G)?*gSO`M4VF((e~(dRh65(o6+ zYmQgSSjGO%X);GQNBMc`yQAiJ8hPq5zA|4VlARmsr#e1M9#taEV_zge;pl54+ngK| zB9#v7a#9oj|6B8?4aP=QV~e&%heb}#IqemM1KiVW9uxYcP<}HFlLR*Hl3UA$}ZM*gnk%p+~ znAo@u9XoaI(ltJzTVnU59?3l)>(%@5K7CXA{iFYY)PaN2jDw#@&v^2wAwx5VWev~H z$sI9rRNm90$BZ2}{u$G=6P}wm>G{d|Q(l-_Fm3vaGiJ_usqp1jUY-5goVoMnFL-_7 zqBj;7Em`{JTW>F0zT%%N-&wW#-8IGUz5l`54?kMBe#6F1AAhoW%hqlG`gHqepMUY? zS37p@`g-@CZ@&G%@AmHde*b|V4jwxE>zNorr^?Rqo#j`roP?cTHf>1bcxp?b5p z?{NK|o@$(uqU>J@r+xXg<|3L9q-ANlGW+uL6LL0d*^p;RnY&gTR}N+4+12XHlytN& zuT@`9*q3ibr9N<{l!K;yc@;nQ4mu~)xHm{ubZlNN+x1M-*gRWc8m;HspTIp;)1oI{DSbBEq5|BzUDmMNM~O6FkFX(w$h-wQUKTRT(>inPpsQ4wZ*klYpi`sY9_nd0Q<<5c_ki@%F5+BEvZ=;_C+oqB)i zwASTm0V?)fv(k|^y0yFA%05#*aF2NY(Qd!~@$qTqZ%G@qrp=RP$}}FWw|+ZrY_e0; zMq_t9obFh}aN=g&7Z@&={)_Q*C+-ioaryOFyMrN#%T*d{6}w$C|M}m!omZdTGIhcQ z{WHv)1l6`6>^Ko5Cy<sj{EC*@V?r`>4mJjn>7#uRof!IOSAZhS5tB>FZ-raz8CtCu-5xFP$7* z=yO+4i{%Q4KY_plfq>Tg&-b36JC2WBqw<|@vvpusej7Oaa|)!s>!S`W4(Bz;JZ1Ky z#2H4v8^LcI$2_L}^qKzu<~8rfu=)e&aaN;-rT0?S>maVK|%{_j4HL<&n{o zVf=FO`gF;zMl2Jb!A~l~=o{IzILvF7t-d*o;e-ENy@6#`>pJ+6J`EYqXY1kl>;~p@ zrVrnj|2yN==bwA8*G68e&p*0s?q|3ZP9Si{5K!kxe7EVypCDPyUD%gB!Jn`mFPNX4 z>i+!G8Y5MFI8G1;C(L-};lfV~q!|Fo4ZDVZOf@77C)U$RW+9wFKp=1zB5<|$-#nK9 z9Y%Y6WOcZH3SL-aXVjjdj@5p59C`WhIGf5|c+QWM_0wfcDg2BZjy;;Q;-Xt!zov!D z3cE-1HqG_Zt>8~uGVY~^*B+>+YqPK@VbH2s1Fl}iGXA6?&E!Itqi-O@WDdo8Sl!!i zj3D|l{v^)L=(q;gs;uQ2yfXfzCJpzWZ8*4R!i(=dzT;fKL5)~$E|fWuwBk3Lb@~;LB+S9|Z4+ICsQ3UX%q7hh`#Y z4EZoJz=xWRe}&4!$WnJj{ho1dP5d^enfXSoxkVWv^PF09nld6S5R|53|CL__t*fyj zZPkW|_A?v!Frowz4$p3HxGBT^{F^~G{lkhEz4(;;jHZZ~KEi)_Ufb?JIb2C_l};Z$ zR$44^A`lP=+-d~0zGrxb$yusdPjXroA~|{C$T5aIf4aJon+{?2@DA|4mU~NF3*Rsw zC20y4e3U^tT&o^y=)Z@5lI%({|K&A#C_`JHKmjA*;UaN_zw3A?iD}Sva8FvNOTc|M z&o^oLWVIz_dPr$oV)X-h%4qQUY4OID=Z8ZMCe9N60?#Dwg0pI?2ZLvlGf{1x$sojV z7Vw5ktF)BMpLJUNM$Z9Lh7TIbmQrVAmOC_jr#TPc-0a49C0%XgpWazCV$=kfZZRSb}@^`?g|Zmef|O^!+Ypo z>lJTNZ+~I?Xw#|l4 zF^1&c9ZvSzF#Y>T&w8u2Es2~raiOc1etgVl!9_0a)k1fA_A6{ZqVcTt(lF0Fw~vk% zb@Z(5GNG`sS5fqlppk<|uIV$T-HA0*k{ULNUwz{Ajt>rvT@xW)*}rK=DhdAAHB0I^B=Jdy z=M1)vn-9g73#~n!M#l>C>O8j$GF-$fwHdKc0G`c-TWB3sZ)M zziQfCly~s)!a?u#nUwi~>mTFm2ib>wQoX7Bi}{Hw0&7nES6(!L{4{W5L z?mXRo%qcsE&1p-Vi;{eSXRSD|F*# zqy?@n8k_TI*QXnHX}Ye+%XsjGP9b?syc^BE*mB{<&My}A`Tf$r9Rj;`A9}X6%Q#-rnwb}7w z;J(+pHhO3Hgl_5mH|vMI_!f3gT{JEzOTaBG%7OKn5WON6M8XV--!A1Y~S7$=_D%6*kA*a_)+J zWwMbdV?kL9j^B^L^UG{4FCnMppUJ7*oLp-@UdHLAj-TBBq9aY^p96b8xv_YhqyGC9 zzSZ7)XdJ)!mNb>fqEj16&Tg=EJzvYv&LKaJd^TsHVdiHm2c;lLR=pj7Af0Y&0fuW5065c-S)pvKNdZhE6Au(-1aP4^S(6b+>M3~y z+ik>LW$GFdj0-xzY|V4SVzQ3pQs|1u`)PrsNo9-uVRJ`H`dOpKS~5qiIF)BpD~=^$ zKOpBr3i8#Wz%k!`A8S((wOWc{!U+T(Yy`CCPgdh&6}~APJc=-QICElNCTJhR@q~MO zWrFrHZPOb;%0OjJ<&;OEM#xB!gi<-P3K8VWO;jZ#rV2oiJ4c0}(xEc9Avo^1G3(m!V?LD@s z=WBcI0+W~33?JU{BIg*E^zczToTJ~4sA3ahx95wv$2_WCiyppw+}j6+em}nR0^i5J zy1ZxS9_A;LZw#2fl&6Q6L{^}O7a{1j(8EKJaXET;Zf6vX&SEX2;={Eb%T0QCaxCcK zS|DAO9&U}AB|Y3a&XOK(9cN7s*V35Bl9C>-rSXq();kvr6HXxTU?XrfT37=>!?%Rv zR!Kw!TKHH5-8d~ggc+5mg&$~NffhalK~>7r!i(EgpoJ$R$ch&3gCHwf_~kYjy2{hS zf4&gByMCSgE^A!&_%`iOdlYMVH`BsNQjY4^h%dQZil<%hiy|3x0Ex5!?c3Iy&O1n@O9@fEv%{VV)_%Fbk4y7#dWG6LL`rhnnBBTBqHpCHF> zS>DtWLtdx70bVkW8;m8k4)g?}fNY zS9%Ss*2_WGG~)}MH-3m?nu_{XBolMA*45J(_fX#xqCB}4qH6`u6^o)z1t(0<${eZ3 zHOC&Ihfe6BLpMO5l6m9pN_E|mm#;0n?^5unBL#bH9QXtCKt&$`c{?Mnec=;i-oXs; z`WYo{y=_P&=g(QQvNuMZkktE4eY&ciTUBSY`aGSJavSf4GhyCMZ&nk{{S&gEaAi%e z$Y4`>+FL`NxJdw*mt~{ll)c0Mzp5*r@WP%udef1$4W!#B;ABmL~K1hgi z+DhbaROK6aC(~MURw}a!m3BcrR&*RAw6vAo%oy>OgOWGfgS>_0W0Z#}jt?K3BeM`e zLcGGvgA+yuIpPFv06fd9u=U`Cku%JZiMS>|k;<|tM;U9y)gUih2u$9y&`sctQ`Ii7(4_ILkGi^*g7q zehWQx*8!tQA`rMI5zu=7`QCHs#d#3dsr>5SZ+hrd9dLJ}4m!^Ai9hGQJQDxfX~W(J z&$+Bqmf>pomkw+64KD8`$L=NEU5g-3Hk)HEXM5WpX1z51ls6N$8;ydgz26 zI-!TIyk6)sPA`=vCG^lqlF&nE^|2L~0)Ynz0WBJb<@m@wJIdV&9dr<5`&)I;$(_&{ z|70O1D+5_Oxs6JmT(X>~Y}4x20D^>jfDj=0=YbWEA=L+tZ0JHX##*?Dh;ZQ6GHFgm zDUwDY=U|egc~x*q2Rzj+Np<*FEozgM-1-U3-4iCw)9vNEuWHhq{ZwK9pjZd*EOjOa ziKe;Fz^kh2u0Yg-EXowE7(Fj;&5#;thN%UMeEw*i4bv{yBc`GXd&y5S3vcm>EC?FQy^rVrnj|2yMV@s@M%_1eg5u9_HsblKd`a1c%)aK{i(=?FqJMw?AR#Yc$7 z+_5ey9xM>}TM*EiAF1!Z5RCyFLNjwSSkR#nVlsyc(HJujl^wMD7t#vRn92YX;R1oX z908SHeYX~~vN6ZGoJm+$sASVJ3+rpSzr^+M4Vzu!-nSt)H+4|@P-AAUK8WjH)l;n; z=@0bO1+1v6qbfP7l%qO1s+Z$_+v7M9p82sfq~h3xwmyMF1Uy_Mj_^9g6#9%vhOsVm#DOYno526w=9XB7 zAgE>@2<3p8IA_UKdIC;Mopri9A|6wr9V@mU_ko>D)gW57pQ@rzRascBt(1CNwNX|X~twqh{ga{Z4ndl z0+4sgwbabpLA09$e>Jm8%u9&INM7nVcN%?|X=j?$i4EKeEyR!&r&7oAfJ;evPLKEUK zFg_9bcZUMdiU34)<18mJ19T0%-76lYOL!T3O_ z1&(2IXbfU$)T$*6Q$=0FER}Ii&JepXhhygikMwf+kN+jL?USKz)XFTC7lN$JQf>8x zqykA%RlpRY(`_v<6$rCbclPDJj;CMk{U;v}og2({o>_DLaP+*@b`E{hZAKk@aXb5l zr=O{?00Jt0a;p3D?`rteH&>Y||FrO7biM7`hBCF#A~j1f0{Ue*P(43*`k&!_^QBs* zly9p`o4+9BnnplptqrN#2W7A}ya^hfJbr7%kq?XUK461o8MO?3BD#W8%19#vN>px~ zISC?E`5J_nW3R6APj9}JD*v=9YiSnNx^9h8?gHHRQDn(iQqYU>YfHYeDM|YFvN~oJ z13_C>GLF^M@e{TJK|0pCExFcigaO!G&f9U&>R%}14eu3@PtIbmpm|V>5JI7Px#)o7 zZrU?Y;3SO~E)qxhyKc{bGGa6V*#h6tYwsLrr%py5(Nb$EMH+^E2P3}Ex%kh)e-6U3 zVWcQiJ{trBMOmdri&ErWiY&w`=_-~&ZCuNCrTI<8#E{lr&@J0TIGd)i-}7MXA?Sgy zwt6kKg_K>T>}Q5;`K{WI(a(*`ne)nc?<;IA?%2?q`WEXV#Yef;@`KUXfP6;69&QT`4~VdkN+H2yVO1 z@@c8rvML`Lf#Qc{rlmd|pzmSK$~6uhXw23Jg$3wa1hr^sMx%VWxkkL1a&wQFvgGci zdsvqHWZdUuxw$7o47fi+98}sr%g--{yvK~FFi$i+=_o`9TyVgUs>-f~IS6|=2_hG+= zjUH+2|7eqdz^2WDnzslJ32hnHD!g@@w(Z(SL>i)^V{WCjdX&q1=B$?rUw-A)*{{u+ zJ8%Ai*B35&V{y@vrEk9V_Oj(G{<-pyY~1wmC!4oy-S)3f zw}1Be7hit0W9P1~cklV;+yDD+@4oN%ANb+mp~FA^`==v6|L4C)j~zcza`Km7Po4hl z%-M7QJAdKg@A&ZFUDX#=7p?wxcX$p3m8sfmU>+wp7yq^Q*ruMZ?X?R`URE=Fc*lzz zm@Vhxqjoq)za3Gd#gVDngJm*$@Is=?XqgG0EZ1cD#fcm(BDM{>$^)cK^xYN`k9&`sgu|9!dg{gv>EI zCUJUpd&3I!@DOBNjvk)d8R_XP)-ozBjB7pCaaFFVFgX^hGcAxbS7)(5Z0^Vvm^Er; zIt(htsX7d_?jv9v;JI>3LB3imHq3`$0j8#%TVYo-kHL69St8)fCZ z+y+Az*NCC=U`h(g&liGs*RPY`WsS=o-=-aEk76yeFF$J{hh8}N3U$qKVo8j?!?bWx zvtzKH4h3l^My7Ui-Nkd#!b47&tTG7H|5M)IQFKLaY5wvh%dM`Px_y0&z%9w(^Z^o}&-6L<8|0{c* zzVu3$LvDw=L9a zJCW3MwrAVjF_UVX@!1+*)BE9?QSDS_+H@pJH#63f#+1iF0xlokvMv|8%fY38exIlO z`t)PpN4~v#!v3~ZCp{Emj+x$gQSrv@YZ7L4+_cDR_@vKEZ>Z#?*ng3S>MKbif&~JV zBET=-z2NXgH1RdFm34VhhYI;Y$&c*z^|}u4{==cmRL5g4$BaDDT{3Iq;x|-wCR<6> z`?V3YCVq_RUwG??5--mws2IL1Z|Z7c-g^VQc0A>6NSK|{{rK1q5o(>cq|Qy4nyz?& zWO{YdnA`<^FT_o{(raL~UJkWblAAHXE-y8Z2e$cy9=Zxz;>y-=6*b7IYdE2YPV%D+ zKwEg#IZw>dEvWAnHAD?ReGx`|DZlk=cL@m zyWvcjche^tWDyn8$vNfb%9>u;KcVal^QAluY37uvDb0^3aydwaQ1tEjQhuhMM)`4C5%_L2e z^ICQM9d&4NIIpqUCeL%#aKq?#BlvCOh|RE1pXvW^Uh{rln;y6MlDgY?&V237=W*|6 zA7ME2W1Q2;1JCn5HNMW&+Qq!)C!6sFpEQ>9C7TMbSr-4rqpOk6To2E=H~A+xr&cHI z?S>maVYsRX&dVdCC$G7JU>>JSb~R#|_zd!^GK~K3)I-OaKHGqNA3XB;wbO>ZkArXm zf!mINI`8KDMCXS63+apTb0_W(w{iLPSi6HEiObn1J;ATN9xs=loa+Ak(;6dHd^k=J z2Pf>|&%=eE7DzJyk{fo&{FrJ;7)~lj zA+NDy+)EFyJy1{AW?@mnpjER5T)m8C@)|-9o!n@D`A@5VAv5s(_5n|}9r&m1;T_=J zu;1eXnlSZ4H4Txxn!yLbJ0i{GuxFbnz#@M2n22=0+o$7_k#{Pevfq+I~(9A^w23| zxn`FeUXIK%N1!i%L1fxbschiiBtxcD72B|Mko}Y8|4KJyu|X^J(0$fv z@f$q{Oc_3CC|io0BlH8l*;hxE9s}*|FUpQt>3O>)vSZAXo*XTTyT0;i$&Oi-kAuZ+ zlO5A8_sz+UF;7xPNflI93b#gfjQJ|qlH%*Ep2}2M`E8EmxB(X_zuow=k;uz12F$T? zh!Tr_!xL+LX=(9`ozt=2`s3SXCQgd`=#zlFegoq6Ga>*SkWYkl z?^UBm&GjAT+2=*sy;gH7BYX^!H2LfP2kidvw2N6hc2{Wl>+=^V8Qw$xTHk1ETxD5w zt2#|dOz7b5Qky&B?wvZuB;ly1cOo%6XY}Z^IOuYcIz&k;claDbJ8tL{V@U4Z;bgB3 z)4z}OthZ|0lE`@z7rJ`s$H#mYT;$?jEp(@6zryw-8qZoU4fD)%`{-y(a=?9m8%O8z+8>4ERH9NR(FFQ&?=E3`$G6VfsV?bOLga`XEK6-&Wc3q9qi$d5|@ zH|-$^J#>(uOO*ym>C#Z>6wJhxzOEUf6~HBz%yk<3m1CmsOXJ~;KF2MDS=S+ z77gM(&7;sDN(dCWQ-K^+!8Ml+r)V2R$-+|V37HfR))l1FP=hD*(A|fx-d#~8t@qzt zFq4CHqvyq~8B!z7FtuQj&mXO`oq`ZH92NwOvQLxr*P&rY zrH^uyQFTu7R33L^UPDFS4fXD*dVLmsUb$YMzfy*>=UtzkANBdSO3zPee%H|R!*qY8 ze#F+FD%F+scZC;EJH2I>cZXRQU0VJ;G&!9qZ$^Jt9+`&WOz@;ZDEpW=p_ZKs z=Ey8ua|5-U3ZC?C2!Tt4_l8&WisFQKt~EzeEf||f(J*R`JY|mTjcdxJD*LiHSzBs0 z=BoN$$T7)!*V`_c1|1$X{Qq|TBl6v5g)`SqJeJ3Bhw9DZlE*jiIL$&&4#P1khW3|^ zEHIoWg0#?_m?h;~zF|sgN!n36qlu!ciy@XN)H%4CQXFp)0=Gk}KZ_<7m!(EOLn5u1 zt%Q>EThw_)HUfcr5CN?Zn>rC`fco%5Di)Vm9NtlbpJH)&558LWM3J@Le{;db;!;zI zZ0Taq2{ Result<(), Box> { -// `space` and `text` are identities from the supplied section's document model. +// `space` identifies the page; `edited` is the editor's current model of it. let cache = Replica::create(path, source)?; let snapshot = cache.snapshot()?; -let local_id = cache.edit_text(&snapshot, space, text, 0..0, "Offline edit ")?; +let local_id = cache.save(&snapshot, space, edited, "Author")?; drop(cache); let reopened = Replica::open(path)?; -let current = reopened.snapshot()?; let pending = reopened.pending()?; assert_eq!(pending.last().map(|edit| edit.id), local_id); # Ok(()) # } ``` -`insert` accepts the core library's `Insertion` value and durably retains its -intent identities. `Insertion::with_formatting` queues text and -its styles as one intent and one publication. Keep that value across retries; its `text_object()` identifies -the new text for subsequent offline edits. Pending entries expose -`Operation::Text(TextEdit)`, `Operation::Insert(Insertion)`, -`Operation::Format(FormatEdit)`, `Operation::Split(SplitEdit)`, -`Operation::Join(JoinEdit)`, `Operation::Outline(OutlineEdit)`, -`Operation::Tree(TreeEdit)`, `Operation::CreatePage(PageCreation)` and -`Operation::Pages(PageEdits)` through their -`operation` field. Synchronization applies these in queue order, so an inserted -outline can precede its paragraphs and their later edits. Missing anchors or -existing insertion identities preserve a conflict and the complete local image. +Saves coalesce the way OneNote's own autosave does: while the newest queued intent +for the same page has not been attempted, a later save replaces its `after` model +under the same ID. Once an intent has been attempted or holds a conflict it is +never rewritten, nor is the intent a synchronization step has selected for +publication; the next save then queues a new intent. A save whose model equals +the stored page returns `None`. `PageIntent::text_change` describes an intent that +changes exactly one paragraph's text as the text object, its previous text, the +replaced UTF-16 range and the replacement. + +Share one `Replica` between application threads. Each save compares its supplied +snapshot under the cache transaction; stale snapshots return `Io(ResourceBusy)`. +The intent and its resulting image commit together. Keep the cache on a local +filesystem: the connection holds exclusive ownership between transactions, and a +second open fails busy. No network wait occurs in a local save. After a database +error, reopen and inspect the durable state before retrying. + +## Pages of a section `create_page` accepts the core `PageCreation` intent and queues its page space -and section entry as one publication. Subsequent outlines and title edits use -the intent's stable identities immediately after local acknowledgement. -Independent page additions rebase against the current section order; duplicate -titles remain distinct. An unavailable or no-longer-leading insertion anchor -produces `StructureChanged`. `rebase_page_creation_conflict(id, local, remote, -before)` reviews a replacement anchor against both cache images while retaining -the new page and dependent object identities. Existing page identities require +and section entry as one publication. Subsequent saves of the new page use the +intent's stable identities immediately after local acknowledgement. Independent +page additions rebase against the current section order; duplicate titles remain +distinct. An unavailable or no-longer-leading insertion anchor produces +`StructureChanged`. `rebase_page_creation_conflict(id, local, remote, before)` +reviews a replacement anchor against both cache images while retaining the new +page and dependent object identities. Existing page identities require reconciliation; a matching page alone does not establish a receipt. `pages(snapshot, edits)` queues a slice of core `PageEdit` intents as one atomic @@ -57,149 +70,59 @@ edits. Every requested level remains explicit: a competing remote level produces the selected page's position relative to surviving observed pages; an unchanged or already-satisfied position can proceed, and new remote pages remain present. Indistinguishable competing moves retain a conflict and the complete local image. - `rebase_pages_conflict(id, local, remote, edits)` reviews the batch against both cache images. Use `PageEdit::reposition(position, level)` on the retained intents to revise anchors or indentation; replacing page or allocated series identities -is rejected. Dependent edits remain queued. Schema 11 adds the durable batch; -schema 10 migration preserves existing multi-space publication evidence unchanged. -Attempt evidence includes every changed space plus the receipt space when that -space is unchanged. An existing section revision alone cannot confirm a page edit. -The [offline page corpus](../../corpus/page-lifecycle/offline-edits/README.md) +is rejected. Dependent edits remain queued. Attempt evidence includes every +changed space plus the receipt space when that space is unchanged. An existing +section revision alone cannot confirm a page edit. The +[offline page corpus](../../corpus/page-lifecycle/offline-edits/README.md) contains native comparisons, reproduction commands and stateful sanitizer seeds. -`split` and `join` accept the core `ParagraphSplit` and `ParagraphJoin` intents. -Splits retain allocated identities when the original UTF-16 boundary rebases; -dependent edits can address `ParagraphSplit::text_object()` immediately after -local acknowledgement. Both operations retain observed parent paths, children, -indentation, paragraph/list state and tags. Changed structure produces -`StructureChanged`. Remote text changes must leave unambiguous boundary mappings; -current remote character styles are preserved. Reconciliation does not silently -discard a newly added right tag or move an unobserved child. +`delete_pages(snapshot, pages)` queues the permanent removal of page spaces; +a page already absent remotely produces `TargetUnavailable`. + +## Reconciliation + +`sync_once(&mut remote)` processes the oldest pending intent through a `Remote` +implementation, returning its ID and `EditStatus`. When the remote page still +equals `before`, the intent publishes as prepared. When the remote page changed, +a three-way merge keeps everything the remote changed and re-applies the local +changes wherever the two sides touched different objects, fields or text ranges: +a local text edit merges with a remote edit elsewhere in the same paragraph, an +outline move merges with a remote text change, a new paragraph survives a remote +deletion elsewhere. Any overlap retains `Conflict(ContentChanged)` together with +the complete local image and the last observed remote image returned by +`remote_snapshot`; a page removed remotely retains `TargetUnavailable`, and a +page the writer can no longer express retains `UnsupportedEdit`. + +Publication attempts are recorded before network I/O. A retained attempt +requires its recorded revision to remain present in the remote, or the remote +page to equal the intent's `after` model exactly, followed by comparison, +flushing and refreshed header version metadata before acknowledgement. Otherwise +the attempt remains `AwaitingConfirmation`; an uncertain publication is never +replayed. A durable `Published` receipt survives reopening. Transport errors +return `Error::Remote` or `Error::RemoteIo`; inspect `status(id)` after the error +to distinguish a retained attempt from a pending edit or receipt. The error return +does not roll back a locally acknowledged intent. -`rebase_conflict` accepts a zero-length reviewed range for a split, preserving its -allocated identities. `rebase_join_conflict(id, local, remote)` reviews the original -join against current images. It rejects a change in which text identity survives, -because dependent edits still address that identity. As with other conflict -reviews, stale images, pending operations and uncertain attempts cannot be rebased. -An uncertain structural operation requires its original attempted revision for -confirmation; matching text or structure does not establish a receipt. - -`rebase_paragraph_conflict(id, local, remote, parent, before)` and -`rebase_outline_conflict(id, local, remote, page, x, y)` accept reviewed replacement -placements for the oldest insertion conflict. They preserve creation time, text, -author and object identities, keeping later edits attached to their original targets. -The images must still match the cache; the new placement must be valid in the remote -image. Paragraph intents cannot become outlines or vice versa. Pending edits and -uncertain publication attempts cannot be repositioned through conflict review. - -```no_run -use onestore::{ExGuid, Insertion, TextAttribute}; -use notebook::{EditStatus, Replica}; -# fn add_outline(cache: &Replica, space: ExGuid, page: ExGuid) -# -> Result<(), Box> { -let outline = Insertion::outline(page, 144.0, 216.0, "Offline outline", "Author")? - .with_formatting(0..7, &[TextAttribute::Bold(true)])?; -let id = cache.insert(&cache.snapshot()?, space, &outline)?; -if let Some(id) = id { - // A running worker may already have advanced this state. - match cache.status(id)? { - Some(EditStatus::Published { revision }) => println!("{revision}"), - state => println!("{state:?}"), - } -} -# Ok(()) -# } -``` - -`format` accepts the core `TextAttribute` slice and a UTF-16 range. Its durable intent -retains the observed text and selected attribute values. Remote text changes must -leave an unambiguous range; independent remote attributes merge, while competing -values preserve `FormattingChanged`. A remote value that already matches the -requested value is accepted. Enabling superscript or subscript also checks the -opposite attribute that the operation clears. If the remote image already satisfies -the whole operation, guarded confirmation still precedes a durable receipt. - -`outline` accepts a target object and the core `onestore::OutlineEdit`: outline -position/width or a paragraph's saved collapse default. It retains the target's -ancestry and the properties the operation changes. Independent content, formatting -and layout properties merge; competing selected values produce `LayoutChanged`, -and changed ancestry produces `StructureChanged`. Width changes also check the -reserved wrapping width that they clear. Missing targets preserve the local branch. -`rebase_layout_conflict(id, local, remote)` explicitly reviews the original change -against both current images, preserving dependent intents. An uncertain layout -attempt requires its original revision; converged values cannot establish its receipt. - -`tree` accepts the core `onestore::TreeEdit`. Moves preserve independent remote -text, formatting and descendants. A competing ancestor, indentation or sibling -crossing produces `StructureChanged`; unrelated sibling insertions/deletions can -merge. An anchor must remain a direct child of the requested destination. An -already satisfied move still requires guarded confirmation before acknowledgement. -Deletion compares the selected raw property graph, including referenced styles, -tags, unknown fields and internal attachments. Changed content produces -`ContentChanged`; property order, CompactID numbering and modification timestamps -do not affect that comparison. Immutable records compare by content, and empty -child lists compare equally to absent child lists. Mutable object identities -remain significant. Missing targets retain `TargetUnavailable`. -`rebase_tree_conflict(id, local, remote)` reviews the original move/deletion against -both current images. An emptied cell's replacement paragraph/text identities must -remain the same before replay or review; later queued edits keep their targets. -Uncertain tree attempts require their original revision for confirmation, even -when an independent move or deletion has the same visible effect. - -Recognized earlier caches migrate transactionally to version ten. Publication -attempts retain every changed space's revision; legacy attempts retain their -single-space evidence. Earlier recovery archives remain readable without migration. -Version-eight -deletion observations retain their original identity-sensitive preconditions; -explicit conflict review upgrades them to the immutable-content comparison. -The migration retains images, local IDs, publication attempts, conflicts, -receipts and the autoincrement sequence; it does not reuse acknowledged IDs when -the pending queue is empty. - -Share one `Replica` between application threads. Each edit compares its supplied -snapshot under the cache transaction; stale snapshots return `Io(ResourceBusy)`. -The intent and its resulting image commit together. No-op edits return `None`. -Keep the cache on a local filesystem: the connection holds exclusive ownership -between transactions, and a second open fails busy. No network wait occurs in a -local edit. After a database error, reopen and inspect the durable state before -retrying. - -`sync_once(&mut remote)` processes the oldest pending edit through a `Remote` -implementation, returning its ID and `EditStatus`. A durable `Published` receipt -survives reopening. Publication attempts are recorded before network I/O; a retained -attempt requires every recorded revision to remain present, followed by comparison, -flushing and refreshed header version -metadata before acknowledgement. If a formatting attempt's revision is missing, -the complete requested effect can instead be confirmed on a uniquely aligned -range; its receipt identifies that confirmed current revision. Otherwise the -missing attempt remains `AwaitingConfirmation`. Neither path replays an uncertain -publication. Overlapping or ambiguous edits retain `Conflict` status, their complete -local image and the last observed remote image returned by `remote_snapshot`. -Transport errors return `Error::Remote` or `Error::RemoteIo`; inspect `status(id)` -after the error to distinguish a retained attempt from a pending edit or receipt. -The error return does not roll back a locally acknowledged intent. - -Rebasing accepts only character mappings shared by every minimum insertion/deletion -alignment. UTF-16 ranges must preserve Unicode scalar boundaries. A bounded -alignment search also leaves a conflict when it cannot establish a unique mapping. The current operation processes one queue head; while edits remain, `snapshot` preserves the complete local working image. An empty queue can refresh from the -remote image. Synchronization holds a separate owner lock, so local edits can +remote image. Synchronization holds a separate owner lock, so local saves can continue during network waits; competing synchronization calls return `WouldBlock`. -`rebase_conflict(id, local, remote, range)` lets a caller explicitly place the -oldest conflicting text or formatting intent at a reviewed UTF-16 range in the remote image. -The supplied images must still match `snapshot()` and `remote_snapshot()`. -It preserves the original replacement or requested attributes, intent ID, complete local working image -and every later intent; the selected range and remote paragraph become the -intent's new comparison base in one local transaction. Formatting also captures -the reviewed attribute values as its new precondition. This operation performs -no network I/O, clears the conflict to `Pending`, and wakes the worker. -Publication still reads the latest remote image and uses exact guarded comparison; -another overlapping remote edit can produce a new conflict. An uncertain attempt -cannot be rebased, and selecting text that already equals the replacement does -not create a publication acknowledgement. +`review_page(id, local, remote, after)` resolves the oldest page conflict with a +model the user reviewed against the current remote page: the intent's `before` +becomes that remote page and its `after` the reviewed model, under the same ID, +in one local transaction. The supplied images must still match `snapshot()` and +`remote_snapshot()`. Review performs no network I/O, clears the conflict to +`Pending` and wakes the worker; publication still reads the latest remote image, +so another overlapping remote edit can produce a new conflict. Pending intents +and uncertain attempts cannot be reviewed. + +Opening recognizes the application identity and the current schema version only; +caches and archives written by other versions are refused unchanged. Until the +application is usable end to end there are no migrations. With the optional `smb` feature, `SmbRemote::new(client, path, limit)` binds an `notebook::smb::Client` to one share-relative file and snapshot limit. Remote identity uses the logical root @@ -273,7 +196,7 @@ the library continues to preserve conflicts requiring an explicit decision. ## Recovery archives -`export_recovery(new_path)` captures both complete notebook images, the typed +`export_recovery(new_path)` captures both complete notebook images, the intent queue, uncertain attempts, conflicts, receipts, downloaded media and the edit-ID sequence in one SQLite snapshot. It refuses existing destinations and leaves the live queue unchanged. Export to a local directory from a background thread: copying holds @@ -322,7 +245,6 @@ identity return `AssetChanged` and preserve the previous download. Downloads do not change pending edits, publication attempts or receipts. Recovery archives include cached media and expose the same bounded `cached_asset` lookup. -Schema-4 archives remain readable without migration and contain no media cache. ## Discovery @@ -378,7 +300,7 @@ Paths are relative to the share. The read limit bounds the complete physical snapshot. Call from a background thread outside a Tokio runtime. Use identities from the document and the same snapshot with `Client::commit_text` or `Client::commit_property_bytes`; their errors retain `onestore::CommitState`. -`PreparedEdit::{text,insert,format}` separate preparation from I/O: inspect the immutable image +`PreparedEdit::page` separates preparation from I/O: inspect the immutable image and persist the intended revision identity before `Client::commit_prepared`. `Client::confirm_snapshot` compares and flushes an observed image, then refreshes its header version metadata without adding a revision. The caller must first diff --git a/crates/notebook/examples/cache_probe.rs b/crates/notebook/examples/cache_probe.rs index 12d39ea202fe3da496478ee10bf3c114fab4d9b6..e891b5fb00af48044cf05260213e0d34bbcbc847 100644 --- a/crates/notebook/examples/cache_probe.rs +++ b/crates/notebook/examples/cache_probe.rs @@ -1,7 +1,8 @@ -use notebook::Replica; +use notebook::{Operation, Replica}; use onestore::{ - ExGuid, Insertion, RevisionIndex, Store, TextAttribute, + ExGuid, RevisionIndex, Store, document::{Document, Kind}, + page::{Page, PageObject, Paragraph, text::Edit}, }; use std::{ io::{self, BufRead, Write}, @@ -85,68 +86,70 @@ fn main() -> Result<(), Box> { let mut ids = Vec::new(); let mut font_size = None; for pending in cache.pending()? { - let operation = match pending.operation { - notebook::Operation::Text(edit) => { - assert_eq!(operation_kind, "text"); - assert_eq!(edit.object, target); - assert_eq!(edit.before, expected); - assert_eq!( - edit.range, - 0..u32::try_from(expected.encode_utf16().count())? - ); - let operation: u64 = edit.replacement.split_once(':').unwrap().0.parse()?; + let Operation::Page(intent) = &pending.operation else { + panic!("Unexpected operation for this fixture") + }; + let operation = match operation_kind.as_str() { + "text" => { + let (object, before, range, replacement) = + intent.text_change().expect("one replaced paragraph"); + assert_eq!(object, target); + assert_eq!(before, expected); + assert_eq!(range, 0..u32::try_from(expected.encode_utf16().count())?); + let operation: u64 = replacement.split_once(':').unwrap().0.parse()?; expected = payload(operation, size); - assert_eq!(edit.replacement, expected); + assert_eq!(replacement, expected); operation } - notebook::Operation::Insert(insertion) => { - assert_eq!(operation_kind, "insert"); - let Kind::RichText { text, .. } = - &revision.nodes[&insertion.text_object()].kind - else { - panic!("Missing inserted text") + "insert" => { + let added: Vec<_> = intent + .after + .objects + .iter() + .filter(|object| { + !intent.before.objects.iter().any(|o| o.id() == object.id()) + }) + .collect(); + let [PageObject::Outline(outline)] = added.as_slice() else { + panic!("Expected one added outline") }; + let text = outline.paragraphs[0].text().unwrap().text.text(); let operation: u64 = text.split_once(':').unwrap().0.parse()?; - assert_eq!(*text, payload(operation, size)); - assert_eq!(serde_json::to_value(&insertion)?["text"], *text); - let outline = &revision.nodes[&insertion.object()]; - assert!(matches!(outline.kind, Kind::Outline { .. })); + assert_eq!(text, payload(operation, size)); assert_eq!( (outline.layout.x, outline.layout.y), (Some(144.0), Some(operation as f32 * 72.0)) ); - let (_, page) = document - .pages()? - .into_iter() - .find(|(space, _)| *space == sid) - .unwrap(); - assert!(revision.nodes[&page].children.contains(&insertion.object())); + assert!(revision.nodes.contains_key(&outline.id)); operation } - notebook::Operation::Format(edit) => { - assert_eq!(operation_kind, "format"); - assert_eq!(edit.object, target); - assert_eq!(edit.before, expected); - assert_eq!( - edit.range, - 0..u32::try_from(expected.encode_utf16().count())? + "format" => { + let paragraph = intent + .after + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => outline + .paragraphs + .iter() + .find(|p| p.text().is_some_and(|t| t.id == target)), + _ => None, + }) + .expect("formatted paragraph"); + let text = paragraph.text().unwrap(); + assert_eq!(text.text.text(), expected); + let value = text.text.spans()[0].format.font_size.expect("font size"); + assert!( + text.text + .spans() + .iter() + .all(|s| s.format.font_size == Some(value)) ); - let [TextAttribute::FontSize(value)] = edit.attributes.as_slice() else { - panic!("Unexpected formatting intent") - }; - assert!((7.0..=130.0).contains(value) && value.fract() == 0.0); - font_size = Some(*value); - *value as u64 - 6 - } - notebook::Operation::Split(_) - | notebook::Operation::Page(_) - | notebook::Operation::CreatePage(_) - | notebook::Operation::Pages(_) - | notebook::Operation::Join(_) - | notebook::Operation::Outline(_) - | notebook::Operation::Tree(_) => { - panic!("Unexpected operation for this fixture") + assert!((7.0..=130.0).contains(&value) && value.fract() == 0.0); + font_size = Some(value); + value as u64 - 6 } + _ => unreachable!(), }; assert!(operations.last().is_none_or(|last| *last < operation)); assert!(ids.last().is_none_or(|last| *last < pending.id)); @@ -193,41 +196,85 @@ fn main() -> Result<(), Box> { let replacement = payload(operation, size); println!("editing {operation}"); io::stdout().flush()?; - let id = match operation_kind.as_str() { - "text" => cache.edit_text( - &source, - sid, - oid, - 0..text.encode_utf16().count().try_into()?, - &replacement, - )?, - "insert" => { - let store = Store::parse(&source)?; - let index = RevisionIndex::parse(&store)?; - let document = Document::parse(&index)?; - let (sid, page) = document.pages()?[0]; - let insertion = Insertion::outline( - page, - 144.0, - operation as f32 * 72.0, - &replacement, - "Fixture", - )?; - cache.insert(&source, sid, &insertion)? + let (space, page) = { + let store = Store::parse(&source)?; + let index = RevisionIndex::parse(&store)?; + let document = Document::parse(&index)?; + let mut page = Page::from_space(&document, sid)?; + fn target_paragraph(page: &mut Page, oid: ExGuid) -> &mut onestore::page::PageParagraph { + page.objects + .iter_mut() + .find_map(|object| match object { + PageObject::Outline(outline) => outline + .paragraphs + .iter_mut() + .find(|p| p.text().is_some_and(|t| t.id == oid)), + _ => None, + }) + .expect("target paragraph") } - "format" => { - assert!((1..=124).contains(&operation)); - cache.format( - &source, - sid, - oid, - 0..text.encode_utf16().count().try_into()?, - &[TextAttribute::FontSize(6.0 + operation as f32)], - )? + let end = u32::try_from(text.encode_utf16().count())?; + match operation_kind.as_str() { + "text" => { + let target = target_paragraph(&mut page, oid).text_mut().unwrap(); + let format = target.text.format_at(0)?.clone(); + target.text.apply(Edit { + range: 0..end, + replacement: Paragraph::new(replacement.clone(), format), + })?; + } + "insert" => { + let template = target_paragraph(&mut page, oid).clone(); + let mut fresh = template.clone(); + fresh.id = onestore::page::text::new_id()?; + fresh.parent = None; + fresh.level = 1; + fresh.lists.clear(); + fresh.tags.clear(); + fresh.style = None; + let format = template.text().unwrap().text.format_at(0)?.clone(); + fresh.content = + onestore::page::ParagraphContent::Text(onestore::page::TextObject { + id: onestore::page::text::new_id()?, + date_field: None, + text: Paragraph::new(replacement.clone(), format), + tags: Vec::new(), + }); + let outline = onestore::page::Outline { + id: onestore::page::text::new_id()?, + title: false, + min_width: None, + layout: onestore::document::Layout { + x: Some(144.0), + y: Some(operation as f32 * 72.0), + ..Default::default() + }, + indents: Vec::new(), + paragraphs: vec![fresh], + unsupported: Vec::new(), + }; + let at = page + .objects + .iter() + .position(|o| matches!(o, PageObject::Title(_))) + .unwrap_or(page.objects.len()); + page.objects.insert(at, PageObject::Outline(outline)); + } + "format" => { + assert!((1..=124).contains(&operation)); + let target = target_paragraph(&mut page, oid).text_mut().unwrap(); + let mut format = target.text.format_at(0)?.clone(); + format.font_size = Some(6.0 + operation as f32); + target.text.apply(Edit { + range: 0..end, + replacement: Paragraph::new(text.clone(), format), + })?; + } + _ => unreachable!(), } - _ => unreachable!(), - } - .unwrap(); + (sid, page) + }; + let id = cache.save(&source, space, &page, "Fixture")?.unwrap(); if acknowledgement == "unack" { println!("durable {operation} {id}"); } else { diff --git a/crates/notebook/examples/recovery_probe.rs b/crates/notebook/examples/recovery_probe.rs index a573a56a213c052f6e00ede4e86e5241fcd4b925..b69d3c3e351fbc46501a7b8d6b9fcf1b50861781 100644 --- a/crates/notebook/examples/recovery_probe.rs +++ b/crates/notebook/examples/recovery_probe.rs @@ -98,7 +98,10 @@ fn report(cache: &Replica, root: &Path) -> Result<(), Box json!({"event":"state", "status":status, "revision":revision, "local_text":local.text, "remote_text":remote.text, "remote_revision":remote.revision.to_string(), "pending":cache.pending()?.iter().map(|pending|match &pending.operation { - notebook::Operation::Text(edit) => json!({"id":pending.id,"before":edit.before,"replacement":edit.replacement,"range":[edit.range.start,edit.range.end]}), + notebook::Operation::Page(intent) => match intent.text_change() { + Some((_, before, range, replacement)) => json!({"id":pending.id,"before":before,"replacement":replacement,"range":[range.start,range.end]}), + None => json!({"id":pending.id,"operation":"page"}), + }, operation => json!({"id":pending.id,"operation":operation}), }).collect::>() }) ); @@ -124,14 +127,32 @@ fn main() -> Result<(), Box> { drop(remote); let cache = Replica::create(root.join("cache.sqlite"), &source)?; let at = u32::try_from(target.text.encode_utf16().count())?; + let mut page = { + let store = onestore::Store::parse(&source)?; + let index = onestore::RevisionIndex::parse(&store)?; + let document = onestore::document::Document::parse(&index)?; + onestore::page::Page::from_space(&document, target.space)? + }; + let paragraph = page + .objects + .iter_mut() + .find_map(|object| match object { + onestore::page::PageObject::Outline(outline) => outline + .paragraphs + .iter_mut() + .find(|p| p.text().is_some_and(|t| t.id == target.object)), + _ => None, + }) + .ok_or("Target paragraph is not on its page")? + .text_mut() + .unwrap(); + let format = paragraph.text.format_at(at)?.clone(); + paragraph.text.apply(onestore::page::text::Edit { + range: at..at, + replacement: onestore::page::Paragraph::new(" [offline-recovery]".into(), format), + })?; assert_eq!( - cache.edit_text( - &source, - target.space, - target.object, - at..at, - " [offline-recovery]" - )?, + cache.save(&source, target.space, &page, "Fixture")?, Some(1) ); phase("local-after"); diff --git a/crates/notebook/examples/smb_offline_client.rs b/crates/notebook/examples/smb_offline_client.rs index aa68d9082ca051bc17989c20f05b555e9678e48b..34a31f03ba8ffb838d69ff32bb7a093993affe4f 100644 --- a/crates/notebook/examples/smb_offline_client.rs +++ b/crates/notebook/examples/smb_offline_client.rs @@ -4,8 +4,7 @@ mod concurrent; use notebook::smb::{Client, Credentials}; use notebook::{EditStatus, Error, Remote, Replica, SmbRemote}; use onestore::{ - CommitError, CommitState, ExGuid, Insertion, ParagraphJoin, ParagraphSplit, PreparedEdit, - RevisionIndex, Store, TextAttribute, TreeEdit, document::Document, + CommitError, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store, page::Paragraph, }; use serde_json::json; use std::{ @@ -41,19 +40,6 @@ impl DocumentView { } } -const DOCUMENT_OPERATIONS: [&str; 10] = [ - "insert", - "format", - "text", - "split", - "right_text", - "nest", - "unnest", - "join", - "tail_split", - "delete", -]; - fn now() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -282,6 +268,56 @@ fn tokens(text: &str) -> Option> { } // This owned workload explicitly resolves append conflicts after all retained tokens. +fn page_with( + bytes: &[u8], + space: ExGuid, + text: ExGuid, +) -> Result> { + let store = Store::parse(bytes)?; + let index = RevisionIndex::parse(&store)?; + let document = onestore::document::Document::parse(&index)?; + let page = onestore::page::Page::from_space(&document, space)?; + if paragraph_mut(&mut page.clone(), text).is_none() { + return Err("The target text is not on its page".into()); + } + Ok(page) +} + +fn paragraph_mut( + page: &mut onestore::page::Page, + text: ExGuid, +) -> Option<&mut onestore::page::TextObject> { + for object in &mut page.objects { + let outlines: Vec<&mut onestore::page::Outline> = match object { + onestore::page::PageObject::Outline(outline) => vec![outline], + onestore::page::PageObject::Title(title) => title.outlines.iter_mut().collect(), + _ => Vec::new(), + }; + for outline in outlines { + if let Some(paragraph) = outline + .paragraphs + .iter_mut() + .find(|p| p.text().is_some_and(|t| t.id == text)) + { + return paragraph.text_mut(); + } + } + } + None +} + +fn append_to(page: &mut onestore::page::Page, text: ExGuid, at: u32, token: &str) { + let target = paragraph_mut(page, text).expect("target text is on its page"); + let format = target.text.format_at(at).unwrap().clone(); + target + .text + .apply(onestore::page::text::Edit { + range: at..at, + replacement: Paragraph::new(token.into(), format), + }) + .unwrap(); +} + fn append_position(before: &str, current: &str, token: &str) -> Option { let original = tokens(before)?; let present = tokens(current)?; @@ -295,162 +331,6 @@ fn append_position(before: &str, current: &str, token: &str) -> Option { u32::try_from(current.encode_utf16().count()).ok() } -fn queue_document( - cache: &Replica, - actor: &str, - operation: usize, - parent: Option, - deadline: Instant, -) -> Result<(ExGuid, [u64; DOCUMENT_OPERATIONS.len()]), Box> { - let source = cache.snapshot()?; - let store = Store::parse(&source)?; - let index = RevisionIndex::parse(&store)?; - let document = Document::parse(&index)?; - let (space, page) = document.pages()?[0]; - let text = format!("Document {actor}:{operation} 🦀"); - let insertion = if operation.is_multiple_of(2) { - let column: u32 = actor - .strip_prefix('w') - .ok_or("Missing writer number")? - .parse()?; - Insertion::outline( - page, - 144.0 + column as f32 * 240.0, - 144.0 + operation as f32 * 72.0, - &text, - "Offline document writer", - )? - } else { - Insertion::paragraph( - parent.ok_or("Missing prior outline")?, - None, - &text, - "Offline document writer", - )? - } - .with_formatting( - 0..u32::try_from(text.encode_utf16().count())?, - &[ - TextAttribute::FontSize(13.5), - TextAttribute::Color(Some([0x44, 0x55, 0x66])), - ], - )?; - let parent = if operation.is_multiple_of(2) { - insertion.object() - } else { - parent.unwrap() - }; - let end = u32::try_from(text.encode_utf16().count())?; - let split = ParagraphSplit::new(insertion.text_object(), end - 2, "Offline document writer")?; - let tail_split = - ParagraphSplit::new(insertion.text_object(), end + 1, "Offline document writer")?; - let join = ParagraphJoin::new( - insertion.text_object(), - split.text_object(), - "Offline document writer", - )?; - let attributes = [ - TextAttribute::Bold(true), - TextAttribute::FontSize(18.0 + (operation % 9) as f32), - TextAttribute::Color(Some([0x12, 0x34, 0x56])), - ]; - let mut ids = [0; DOCUMENT_OPERATIONS.len()]; - for (step, id) in ids.iter_mut().enumerate() { - let kind = DOCUMENT_OPERATIONS[step]; - let tree = match kind { - "nest" => { - let source = cache.snapshot()?; - let store = Store::parse(&source)?; - let index = RevisionIndex::parse(&store)?; - let document = Document::parse(&index)?; - let section = &document.spaces[&space]; - let view = §ion.revisions[§ion.contexts[&ExGuid::default()]]; - let paragraph = view - .nodes - .iter() - .find(|(_, node)| node.content == [insertion.text_object()]) - .map(|(id, _)| *id) - .ok_or("Missing inserted paragraph")?; - Some(TreeEdit::move_to( - split.object(), - paragraph, - None, - "Offline document writer", - )?) - } - "unnest" => Some(TreeEdit::move_to( - split.object(), - parent, - None, - "Offline document writer", - )?), - "delete" => Some(TreeEdit::delete( - tail_split.object(), - "Offline document writer", - )?), - _ => None, - }; - let range = match kind { - "text" => end - 3..end, - "split" => end - 2..end - 2, - "tail_split" => end + 1..end + 1, - "right_text" => 0..2, - _ => 1..end - 2, - }; - let target = if kind == "right_text" { - split.text_object() - } else if kind == "delete" { - tail_split.text_object() - } else { - insertion.text_object() - }; - let replacement = match kind { - "text" => Some(" e\u{301}🐈"), - "right_text" => Some("B🦋"), - _ => None, - }; - loop { - if Instant::now() >= deadline { - return Err("Document queue timed out; cache retained".into()); - } - let source = cache.snapshot()?; - let started = now(); - let result = match kind { - "insert" => cache.insert(&source, space, &insertion), - "format" => cache.format(&source, space, target, range.clone(), &attributes), - "text" | "right_text" => { - cache.edit_text(&source, space, target, range.clone(), replacement.unwrap()) - } - "split" => cache.split(&source, space, &split), - "tail_split" => cache.split(&source, space, &tail_split), - "join" => cache.join(&source, space, &join), - "nest" | "unnest" | "delete" => cache.tree(&source, space, tree.as_ref().unwrap()), - _ => unreachable!(), - }; - match result { - Ok(Some(acknowledged)) => { - *id = acknowledged; - println!( - "{}", - json!({"event":"local_document_commit","id":acknowledged,"operation":operation,"kind":kind, - "space":space.to_string(),"object":target.to_string(),"document":insertion.text_object().to_string(), - "text":text,"insertion":if kind=="insert" {Some(&insertion)} else {None}, - "split":match kind { "split" => Some(&split), "tail_split" => Some(&tail_split), _ => None }, - "tree":tree, - "joined":if kind=="join" {Some(join.texts().map(|id| id.to_string()))} else {None}, - "range":[range.start,range.end],"attributes":attributes,"replacement":replacement, - "started_us":started,"finished_us":now()}) - ); - break; - } - Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {} - other => return Err(format!("Unexpected document queue result: {other:?}").into()), - } - } - } - Ok((parent, ids)) -} - fn main() -> Result<(), Box> { let args: Vec<_> = env::args().skip(1).collect(); if args.len() != 7 @@ -495,7 +375,7 @@ fn main() -> Result<(), Box> { let cache = Arc::new(Replica::create(&cache_path, &source)?); println!( "{}", - json!({"event":"ready", "pid":std::process::id(), "actor":args[2], "offline":true, "document_operations":documents,"document_graph":documents,"document_kinds":if documents {DOCUMENT_OPERATIONS.as_slice()} else {&[]}}) + json!({"event":"ready", "pid":std::process::id(), "actor":args[2], "offline":true, "document_operations":false,"document_graph":documents,"document_kinds":[]}) ); while !Path::new(&args[4]).exists() { if Instant::now() >= deadline { @@ -529,8 +409,6 @@ fn main() -> Result<(), Box> { } })?; let mut ids = Vec::new(); - let mut document_ids = std::collections::BTreeSet::new(); - let mut document_parent = None; let result = (|| -> Result<(), Box> { let mut generated = 0; let mut received = 0; @@ -547,7 +425,7 @@ fn main() -> Result<(), Box> { Some(EditStatus::Published { revision }) => { println!( "{}", - json!({"event":if document_ids.contains(&ids[received]) {"document_receipt"} else {"remote_receipt"}, "id":ids[received], "revision":revision.to_string(), "at_us":now()}) + json!({"event":"remote_receipt", "id":ids[received], "revision":revision.to_string(), "at_us":now()}) ); received += 1; } @@ -564,8 +442,6 @@ fn main() -> Result<(), Box> { .is_some_and(|directory| !directory.join("offline-outage-down").exists()) { 1 - } else if documents && outage.is_some() { - 8 * (1 + DOCUMENT_OPERATIONS.len()) } else { 8 }; @@ -575,25 +451,15 @@ fn main() -> Result<(), Box> { let at = u32::try_from(target.text.encode_utf16().count())?; let token = format!(" [{}:{}]", args[2], generated); let started = now(); - match cache.edit_text(&source, target.space, target.object, at..at, &token) { + let mut page = page_with(&source, target.space, target.object)?; + append_to(&mut page, target.object, at, &token); + match cache.save(&source, target.space, &page, "Offline document writer") { Ok(Some(id)) => { println!( "{}", json!({"event":"local_commit", "id":id, "operation":generated, "space":target.space.to_string(), "object":target.object.to_string(), "before":target.text, "token":token, "started_us":started, "finished_us":now()}) ); ids.push(id); - if documents { - let (parent, added) = queue_document( - &cache, - &args[2], - generated, - document_parent, - deadline, - )?; - document_parent = Some(parent); - document_ids.extend(added); - ids.extend(added); - } generated += 1; } Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {} @@ -606,20 +472,25 @@ fn main() -> Result<(), Box> { let local = cache.snapshot()?; let remote = cache.remote_snapshot()?; let current = view(&remote)?; - let notebook::Operation::Text(edit) = &intent.operation else { + let notebook::Operation::Page(edit) = &intent.operation else { return Err(format!( - "Document intent {} requires review: {:?}", + "Intent {} requires review: {:?}", intent.id, cache.status(intent.id)? ) .into()); }; - let at = append_position(&edit.before, ¤t.text, &edit.replacement) + let (object, before, _, token) = edit + .text_change() + .ok_or("A review expects one appended token")?; + let at = append_position(&before, ¤t.text, &token) .ok_or("Append model disagrees with retained history")?; - match cache.rebase_conflict(intent.id, &local, &remote, at..at) { + let mut reviewed = page_with(&remote, intent.space, object)?; + append_to(&mut reviewed, object, at, &token); + match cache.review_page(intent.id, &local, &remote, &reviewed) { Ok(()) => println!( "{}", - json!({"event":"reviewed_append", "id":intent.id, "before":edit.before, "remote":current.text, "token":edit.replacement, "at_us":now()}) + json!({"event":"reviewed_append", "id":intent.id, "before":before, "remote":current.text, "token":token, "at_us":now()}) ), Err(Error::Io(error)) if [ @@ -658,7 +529,7 @@ fn main() -> Result<(), Box> { }; println!( "{}", - json!({"event":if document_ids.contains(&id) {"reopened_document_receipt"} else {"reopened_receipt"}, "id":id, "revision":revision.to_string()}) + json!({"event":"reopened_receipt", "id":id, "revision":revision.to_string()}) ); } println!( @@ -699,133 +570,10 @@ fn append_review_requires_a_unique_ordered_history_and_an_absent_new_token() { ); } -#[cfg(test)] -#[path = "../../onestore/tests/support/disk.rs"] -mod disk; - #[cfg(test)] mod tests { use super::*; - #[test] - fn document_workload_retains_dependencies_and_receipts_across_reopen() { - struct Server { - disk: disk::Disk, - lost_reply: bool, - } - impl Remote for Server { - fn read(&mut self) -> io::Result> { - Ok(self.disk.visible.clone()) - } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { - edit.commit(&mut self.disk)?; - if std::mem::take(&mut self.lost_reply) { - Err(CommitError { - state: CommitState::Unknown, - error: io::Error::from(io::ErrorKind::ConnectionAborted), - }) - } else { - Ok(()) - } - } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - onestore::confirm_snapshot(&mut self.disk, snapshot) - } - } - let source = - onestore::create_section("workload.one", "Concurrent edits:", "Author").unwrap(); - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("cache.sqlite"); - let mut cache = Replica::create(&path, &source).unwrap(); - let mut remote = Traced { - remote: Server { - disk: disk::Disk { - visible: source.clone(), - durable: source.clone(), - operation: 0, - fail_at: None, - write_limit: 97, - random: 1, - }, - lost_reply: false, - }, - before: None, - pause: None, - documents: true, - }; - println!( - "{}", - json!({"event":"ready", "actor":"w0", "offline":true, - "document_operations":true, "document_graph":true, "document_kinds":DOCUMENT_OPERATIONS}) - ); - let deadline = Instant::now() + Duration::from_secs(30); - let mut parent = None; - let mut ids = Vec::new(); - for operation in 0..2 { - let (outline, added) = - queue_document(&cache, "w0", operation, parent, deadline).unwrap(); - parent = Some(outline); - ids.extend(added); - drop(cache); - cache = Replica::open(&path).unwrap(); - } - let local = cache.snapshot().unwrap(); - assert_eq!( - cache.pending().unwrap().len(), - 2 * DOCUMENT_OPERATIONS.len() - ); - for (step, id) in ids.iter().enumerate() { - remote.remote.lost_reply = matches!( - DOCUMENT_OPERATIONS[step % DOCUMENT_OPERATIONS.len()], - "split" | "nest" | "unnest" | "join" | "tail_split" | "delete" - ); - let result = cache.sync_once(&mut remote); - let state = if result.is_err() { - assert!(matches!( - result, - Err(Error::Remote(CommitError { - state: CommitState::Unknown, - .. - })) - )); - drop(cache); - cache = Replica::open(&path).unwrap(); - cache.sync_once(&mut remote).unwrap().unwrap() - } else { - result.unwrap().unwrap() - }; - assert_eq!(state.0, *id); - let EditStatus::Published { revision } = state.1 else { - panic!("{state:?}") - }; - println!( - "{}", - json!({"event":"document_receipt", "id":id, "revision":revision.to_string(), "at_us":now()}) - ); - drop(cache); - cache = Replica::open(&path).unwrap(); - assert_eq!( - cache.status(*id).unwrap(), - Some(EditStatus::Published { revision }) - ); - if step + 1 < ids.len() { - assert_eq!(cache.snapshot().unwrap(), local); - } - } - assert!(cache.pending().unwrap().is_empty()); - for id in ids { - let Some(EditStatus::Published { revision }) = cache.status(id).unwrap() else { - panic!() - }; - println!( - "{}", - json!({"event":"reopened_document_receipt", "id":id, "revision":revision.to_string()}) - ); - } - remote.read().unwrap(); - println!("{}", json!({"event":"done"})); - } - #[test] fn publication_differences_retain_removed_text_and_graph_identities() { let before = DocumentView { diff --git a/crates/notebook/src/assets.rs b/crates/notebook/src/assets.rs index fd74b47164ef17cd5230e20d8b0aca938eae9c01..8cc13e69d213b64b4127e43ab78ec1ba2fe115ca 100644 --- a/crates/notebook/src/assets.rs +++ b/crates/notebook/src/assets.rs @@ -99,10 +99,6 @@ fn referenced(connection: &Connection, key: &str) -> Result { } pub(crate) fn cached(connection: &Connection, key: &str, limit: usize) -> Result>> { - let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; - if version < 5 { - return Ok(None); - } let length: Option = connection .query_row( "SELECT length(data) FROM assets WHERE name=?1", diff --git a/crates/notebook/src/formatting.rs b/crates/notebook/src/formatting.rs deleted file mode 100644 index c229f53032d87d21e919e5ec87d8b665608fd62f..0000000000000000000000000000000000000000 --- a/crates/notebook/src/formatting.rs +++ /dev/null @@ -1,203 +0,0 @@ -use super::*; -use onestore::TextAttribute; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use std::collections::BTreeMap; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct Span { - end: u32, - values: Vec, -} - -/// A formatting intent and the text/attribute values observed before local publication. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct FormatEdit { - pub object: ExGuid, - pub before: String, - pub range: Range, - pub attributes: Vec, - observed: Vec, -} - -fn fields(attributes: &[TextAttribute]) -> BTreeMap<&'static str, Value> { - let mut fields = BTreeMap::new(); - for attribute in attributes { - let (name, value) = match attribute { - TextAttribute::Bold(v) => ("bold", json!(v)), - TextAttribute::Italic(v) => ("italic", json!(v)), - TextAttribute::Underline(v) => ("underline", json!(v)), - TextAttribute::Strike(v) => ("strike", json!(v)), - TextAttribute::Superscript(v) => { - if *v { - fields.insert("subscript", json!(false)); - } - ("superscript", json!(v)) - } - TextAttribute::Subscript(v) => { - if *v { - fields.insert("superscript", json!(false)); - } - ("subscript", json!(v)) - } - TextAttribute::Font(v) => ("font", json!(v)), - TextAttribute::FontSize(v) => ("font_size", json!(v)), - TextAttribute::Color(v) | TextAttribute::Highlight(v) => ( - if matches!(attribute, TextAttribute::Color(_)) { - "color" - } else { - "highlight" - }, - json!(v.map_or(0xff000000, |[r, g, b]| u32::from_le_bytes([r, g, b, 0]))), - ), - }; - fields.insert(name, value); - } - fields -} - -fn observe( - source: &[u8], - space: ExGuid, - object: ExGuid, - range: Range, - fields: &BTreeMap<&str, Value>, -) -> Result> { - let store = Store::parse(source)?; - let index = RevisionIndex::parse(&store)?; - let document = Document::parse(&index)?; - let revision = document.active(space)?; - let mut spans: Vec = Vec::new(); - let mut start = 0; - for run in revision.text_runs(object)? { - let end = - start + u32::try_from(run.text.encode_utf16().count()).map_err(io::Error::other)?; - if (start < range.end && range.start < end) || (start == 0 && end == 0 && range == (0..0)) { - let format = serde_json::to_value(run.format).map_err(io::Error::other)?; - let values = fields - .iter() - .map(|(name, desired)| { - let value = &format[*name]; - if value.is_null() && desired.is_boolean() { - json!(false) - } else if value.is_null() && matches!(*name, "color" | "highlight") { - json!(0xff000000_u32) - } else { - value.clone() - } - }) - .collect::>(); - let end = end.min(range.end) - range.start; - if let Some(last) = spans.last_mut().filter(|s| s.values == values) { - last.end = end; - } else { - spans.push(Span { end, values }); - } - } - start = end; - } - Ok(spans) -} - -impl Replica { - /// Durably records a visual-formatting change and its observed attribute values. - /// Independent remote attributes can merge; competing values preserve a conflict. - pub fn format( - &self, - source: &[u8], - space: ExGuid, - object: ExGuid, - range: Range, - attributes: &[TextAttribute], - ) -> Result> { - let (edit, prepared) = FormatEdit::capture(source, space, object, range, attributes)?; - self.record(source, space, Operation::Format(edit), &prepared) - } -} - -impl FormatEdit { - pub(crate) fn capture<'a>( - source: &'a [u8], - space: ExGuid, - object: ExGuid, - range: Range, - attributes: &[TextAttribute], - ) -> Result<(Self, PreparedEdit<'a>)> { - let prepared = PreparedEdit::format(source, space, object, range.clone(), attributes)?; - let before = paragraph(source, space, object)?.ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "Prepared formatting has no text target", - ) - })?; - let observed = observe(source, space, object, range.clone(), &fields(attributes))?; - let edit = Self { - object, - before, - range, - attributes: attributes.to_vec(), - observed, - }; - Ok((edit, prepared)) - } - - pub(crate) fn prepare<'a>( - &self, - snapshot: &'a [u8], - space: ExGuid, - ) -> Result, ConflictKind>> { - let Some(text) = paragraph(snapshot, space, self.object)? else { - return Ok(Err(ConflictKind::TargetUnavailable)); - }; - let Some(range) = rebase::rebase(&self.before, &text, self.range.clone()) else { - return Ok(Err(ConflictKind::TextChanged)); - }; - let prepared = match PreparedEdit::format( - snapshot, - space, - self.object, - range.clone(), - &self.attributes, - ) { - Ok(prepared) => prepared, - Err(_) => return Ok(Err(ConflictKind::UnsupportedEdit)), - }; - let desired = fields(&self.attributes); - let observed = observe(snapshot, space, self.object, range.clone(), &desired)?; - let wanted: Vec<_> = desired.values().collect(); - for spans in [&self.observed, &observed] { - if spans.is_empty() - || (!range.is_empty() && spans[0].end == 0) - || spans.last().unwrap().end != range.end - range.start - || spans.windows(2).any(|s| s[0].end >= s[1].end) - || spans.iter().any(|s| s.values.len() != wanted.len()) - { - return Ok(Err(ConflictKind::UnsupportedEdit)); - } - } - let (mut before, mut after) = (0, 0); - while before < self.observed.len() && after < observed.len() { - let old = &self.observed[before]; - let current = &observed[after]; - if old - .values - .iter() - .zip(¤t.values) - .zip(&wanted) - .any(|((old, new), wanted)| new != old && new != *wanted) - { - return Ok(Err(ConflictKind::FormattingChanged)); - } - let end = old.end.min(current.end); - if old.end == end { - before += 1; - } - if current.end == end { - after += 1; - } - } - Ok(Ok(prepared)) - } -} diff --git a/crates/notebook/src/lib.rs b/crates/notebook/src/lib.rs index f65c692d3ea74440414713b068cf661554a000dd..c4acdec369f3e9d36c16bc7ddab3c327473b6272 100644 --- a/crates/notebook/src/lib.rs +++ b/crates/notebook/src/lib.rs @@ -6,29 +6,27 @@ pub mod discover; pub mod smb; use onestore::{ - ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store, + ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store, document::{Document, Kind}, page::Page, }; use rusqlite::{Connection, OpenFlags, OptionalExtension, TransactionBehavior, params}; -use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Duration}; +use std::{ + fs::OpenOptions, + io, + path::Path, + sync::{Mutex, atomic::AtomicI64}, + time::Duration, +}; mod assets; -mod formatting; mod merge; -mod outline; mod pages; -mod paragraph; mod rebase; mod recovery; mod schema; -mod tree; -pub use formatting::FormatEdit; -pub use outline::OutlineEdit; pub use pages::PageEdits; -pub use paragraph::{JoinEdit, SplitEdit}; pub use recovery::{Recovery, RecoverySummary}; -pub use tree::TreeEdit; mod sync; pub use sync::{ConflictKind, EditStatus, Remote}; mod worker; @@ -57,17 +55,7 @@ pub enum Error { type Result = std::result::Result; const APPLICATION_ID: u32 = 0x4f4e454f; -const SCHEMA_VERSION: u32 = 11; - -/// Text and its observed precondition, retained across cache reopen and rebasing. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] -pub struct TextEdit { - pub object: ExGuid, - pub before: String, - pub range: Range, - pub replacement: String, -} +const SCHEMA_VERSION: u32 = 12; /// An edited page model together with the stored model it was edited from. /// `before` is the precondition reconciliation checks against the remote page. @@ -79,19 +67,80 @@ pub struct PageIntent { pub author: String, } +impl PageIntent { + /// Describes the intent as one paragraph's text replacement, when that is all it changes: + /// the text object, its text before, the replaced UTF-16 range and the replacement. + pub fn text_change(&self) -> Option<(ExGuid, String, std::ops::Range, String)> { + fn texts(page: &Page) -> Vec<(ExGuid, &onestore::page::Paragraph)> { + let mut out = Vec::new(); + for object in &page.objects { + let outlines: Vec<&onestore::page::Outline> = match object { + onestore::page::PageObject::Outline(outline) => vec![outline], + onestore::page::PageObject::Title(title) => title.outlines.iter().collect(), + _ => Vec::new(), + }; + for outline in outlines { + for paragraph in &outline.paragraphs { + if let Some(text) = paragraph.text() { + out.push((text.id, &text.text)); + } + } + } + } + out + } + let (before, after) = (texts(&self.before), texts(&self.after)); + if before.len() != after.len() { + return None; + } + let mut changed = None; + for ((id, x), (other, y)) in before.iter().zip(&after) { + if id != other { + return None; + } + if x.text() != y.text() { + if changed.is_some() { + return None; + } + changed = Some((*id, *x, *y)); + } + } + let (id, x, y) = changed?; + let (b, o) = (x.text(), y.text()); + let prefix = b + .char_indices() + .zip(o.chars()) + .take_while(|((_, c), d)| c == d) + .map(|((i, c), _)| i + c.len_utf8()) + .last() + .unwrap_or(0); + let suffix = b[prefix..] + .chars() + .rev() + .zip(o[prefix..].chars().rev()) + .take_while(|(c, d)| c == d) + .map(|(c, _)| c.len_utf8()) + .sum::(); + let start = x.utf16_offset(prefix).ok()?; + let end = x.utf16_offset(b.len() - suffix).ok()?; + Some(( + id, + b.to_owned(), + start..end, + o[prefix..o.len() - suffix].to_owned(), + )) + } +} + #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] pub enum Operation { + /// An edited page model; body content is edited only through this intent. Page(PageIntent), CreatePage(PageCreation), Pages(PageEdits), - Text(TextEdit), - Insert(Insertion), - Format(FormatEdit), - Split(SplitEdit), - Join(JoinEdit), - Outline(OutlineEdit), - Tree(TreeEdit), + /// Permanent removal of explicitly selected page spaces from the section. + DeletePages(Vec), } /// A locally acknowledged intent; its ID remains stable across cache reopen. @@ -107,6 +156,8 @@ pub struct PendingEdit { pub struct Replica { connection: Mutex, synchronization: Mutex<()>, + /// The intent a synchronization step selected for publication, or zero. + in_flight: AtomicI64, worker: Mutex>, } @@ -164,24 +215,28 @@ impl Replica { schema::create(&transaction)?; transaction.execute("INSERT INTO replica VALUES (1, ?1, ?1)", [source])?; } else { - if application != APPLICATION_ID || !(1..=SCHEMA_VERSION).contains(&version) { + if application != APPLICATION_ID { + return Err( + io::Error::new(io::ErrorKind::InvalidData, "Not a notebook cache").into(), + ); + } + if version != SCHEMA_VERSION { + // Older caches are refused rather than migrated until the application is + // usable end to end; the queue must be drained by the build that wrote it. return Err(io::Error::new( io::ErrorKind::InvalidData, - "Unrecognized cache or unsupported schema version", + format!("Cache schema version {version} is not the supported version {SCHEMA_VERSION}"), ) .into()); } validate_images(&transaction)?; - if version < SCHEMA_VERSION { - schema::migrate(&transaction, version)?; - transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?; - } pending(&transaction)?; } transaction.commit()?; Ok(Self { connection: Mutex::new(connection), synchronization: Mutex::new(()), + in_flight: AtomicI64::new(0), worker: Mutex::new(std::sync::Weak::new()), }) } @@ -207,50 +262,6 @@ impl Replica { pending(&connection) } - /// Atomically records an intent and its resulting local image; returns its durable ID. - /// Unchanged text returns `None`. A stale image returns `Io(ResourceBusy)`. - /// On synchronization, replacement text inherits the remote style at the rebased start. - /// After a database error, reopen and inspect the cache before retrying the edit. - pub fn edit_text( - &self, - source: &[u8], - space: ExGuid, - object: ExGuid, - range: Range, - replacement: &str, - ) -> Result> { - let edit = PreparedEdit::text(source, space, object, range.clone(), replacement)?; - let before = paragraph(source, space, object)?.ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "Prepared edit has no text target", - ) - })?; - self.record( - source, - space, - Operation::Text(TextEdit { - object, - before, - range, - replacement: replacement.to_owned(), - }), - &edit, - ) - } - - /// Durably queues a validated insertion with its stable object identities. - /// Uses the same snapshot and local-acknowledgement contract as `edit_text`. - pub fn insert( - &self, - source: &[u8], - space: ExGuid, - insertion: &Insertion, - ) -> Result> { - let edit = PreparedEdit::insert(source, space, insertion)?; - self.record(source, space, Operation::Insert(insertion.clone()), &edit) - } - /// Durably queues an edited page model using the supplied local snapshot. /// While the newest pending edit is an unattempted save of the same page, a new save /// replaces its result instead of queueing another publication, as OneNote does @@ -297,7 +308,10 @@ impl Replica { |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .optional()?; + // The intent a step is publishing has no attempt row yet; rewriting it would lose + // this save under the published bytes. if let Some((id, sid, operation)) = newest + && id != self.in_flight.load(std::sync::atomic::Ordering::Acquire) && sid == space.to_string() && let Ok(Operation::Page(mut head)) = serde_json::from_str::(&operation) { @@ -350,6 +364,14 @@ impl Replica { ) } + /// Queues the permanent removal of explicitly selected pages; the batch is republished + /// only while every selected page still exists remotely. + pub fn delete_pages(&self, source: &[u8], pages: &[ExGuid]) -> Result> { + let edit = PreparedEdit::delete_pages_permanently(source, pages)?; + let space = validate(source)?; + self.record(source, space, Operation::DeletePages(pages.to_vec()), &edit) + } + fn record( &self, source: &[u8], @@ -403,35 +425,6 @@ impl Replica { } } -fn active_paths( - view: &onestore::document::Revision<'_>, - pages: &[ExGuid], - objects: &[ExGuid], -) -> Option>> { - let parents = view.parents(pages).ok()?; - objects - .iter() - .map(|object| { - if !parents.contains_key(object) && !pages.contains(object) { - return None; - } - let mut path = Vec::new(); - let mut at = *object; - while !pages.contains(&at) { - let [parent] = parents.get(&at)?.as_slice() else { - return None; - }; - if path.len() >= view.nodes.len() { - return None; - } - path.push(*parent); - at = *parent; - } - Some(path) - }) - .collect() -} - fn page_of(source: &[u8], space: ExGuid) -> Result> { let store = Store::parse(source)?; let index = RevisionIndex::parse(&store)?; @@ -439,20 +432,6 @@ fn page_of(source: &[u8], space: ExGuid) -> Result> { Ok(Page::from_space(&document, space).ok()) } -fn paragraph(source: &[u8], space: ExGuid, object: ExGuid) -> Result> { - let store = Store::parse(source)?; - let index = RevisionIndex::parse(&store)?; - let document = Document::parse(&index)?; - let node = document - .active(space) - .ok() - .and_then(|revision| revision.nodes.get(&object)); - Ok(match node.map(|node| &node.kind) { - Some(Kind::RichText { text, .. }) => Some(text.clone()), - _ => None, - }) -} - fn validate(source: &[u8]) -> Result { let store = Store::parse(source)?; if !store.checksum_mismatches.is_empty() { diff --git a/crates/notebook/src/merge.rs b/crates/notebook/src/merge.rs index 65086ffaec50dabdb322ca032f482903ad9d8565..8a70862a15ab61e7e373ac2dd7d68f15a431aa02 100644 --- a/crates/notebook/src/merge.rs +++ b/crates/notebook/src/merge.rs @@ -33,12 +33,7 @@ pub(crate) fn merge(base: &Page, ours: &Page, theirs: &Page) -> Option { (PageObject::Outline(x), PageObject::Outline(y), PageObject::Outline(z)) => { result.push(PageObject::Outline(merge_outline(x, y, z)?)); } - _ => { - if after != before { - return None; - } - result.push(object.clone()); - } + _ => result.push(pick(before, after, &object)?.clone()), }, (Some(before), None) => { // Removed locally; keep only if the remote left it untouched. @@ -148,36 +143,42 @@ fn pick(base: &T, ours: &T, theirs: &T) -> Option { } fn merge_outline(base: &Outline, ours: &Outline, theirs: &Outline) -> Option { - if ours.title != base.title - || ours.min_width != base.min_width - || ours.indents != base.indents - || ours.unsupported != base.unsupported - { - return None; - } + let title = pick(&base.title, &ours.title, &theirs.title)?; + let min_width = pick(&base.min_width, &ours.min_width, &theirs.min_width)?; + let indents = pick(&base.indents, &ours.indents, &theirs.indents)?; + let unsupported = pick(&base.unsupported, &ours.unsupported, &theirs.unsupported)?; let mut layout = theirs.layout.clone(); let (x, y) = pick( &(base.layout.x, base.layout.y), &(ours.layout.x, ours.layout.y), &(theirs.layout.x, theirs.layout.y), )?; - let (max_width, user_set) = pick( - &(base.layout.max_width, base.layout.width_set_by_user), - &(ours.layout.max_width, ours.layout.width_set_by_user), - &(theirs.layout.max_width, theirs.layout.width_set_by_user), + // A user-set width clears the wrap reservation, so the three travel together. + let width = |layout: &onestore::document::Layout| { + ( + layout.max_width, + layout.width_set_by_user, + layout.reserved_width, + ) + }; + let (max_width, user_set, reserved) = pick( + &width(&base.layout), + &width(&ours.layout), + &width(&theirs.layout), )?; layout.x = x; layout.y = y; layout.max_width = max_width; layout.width_set_by_user = user_set; + layout.reserved_width = reserved; Some(Outline { id: theirs.id, - title: theirs.title, - min_width: theirs.min_width, + title, + min_width, layout, - indents: theirs.indents.clone(), + indents, paragraphs: merge_paragraphs(&base.paragraphs, &ours.paragraphs, &theirs.paragraphs)?, - unsupported: theirs.unsupported.clone(), + unsupported, }) } @@ -236,7 +237,7 @@ fn merge_paragraphs( }; merged.insert(*id, paragraph); } - // Child order per container: the remote order unless only we reordered it. + // Child order per container: the remote order with our repositioned paragraphs re-placed. let (bc, oc, tc) = (children(base), children(ours), children(theirs)); let mut order: BTreeMap, Vec> = BTreeMap::new(); let containers: BTreeSet> = bc @@ -255,55 +256,47 @@ fn merge_paragraphs( }) .unwrap_or_default() }; - let (base_list, our_list, their_list) = ( - live(bc.get(&container)), - live(oc.get(&container)), - live(tc.get(&container)), + let (our_list, their_list) = (live(oc.get(&container)), live(tc.get(&container))); + let of_base = |ids: Option<&Vec>| -> Vec { + ids.map(|ids| { + ids.iter() + .copied() + .filter(|id| b.contains_key(id)) + .collect() + }) + .unwrap_or_default() + }; + let (base_ids, our_ids, their_ids) = ( + of_base(bc.get(&container)), + of_base(oc.get(&container)), + of_base(tc.get(&container)), ); - let common = |list: &[ExGuid]| -> Vec { - list.iter() - .copied() - .filter(|id| { - base_list.contains(id) && our_list.contains(id) && their_list.contains(id) - }) - .collect() + let ours_moved = if our_ids == base_ids || our_ids == their_ids { + BTreeSet::new() + } else { + moved(&base_ids, &our_ids) }; - let (base_common, our_common, their_common) = - (common(&base_list), common(&our_list), common(&their_list)); + // A paragraph we repositioned that the remote also moved, reparented or removed + // has two destinations; neither side's placement can be assumed. + let mut theirs_touched = moved(&base_ids, &their_ids); + theirs_touched.extend(base_ids.iter().filter(|id| !their_ids.contains(id))); + if ours_moved.iter().any(|id| theirs_touched.contains(id)) { + return None; + } let mut list: Vec = their_list.clone(); - if our_common != base_common { - if their_common != base_common && their_common != our_common { - return None; - } - let slots: Vec = list - .iter() - .enumerate() - .filter(|(_, id)| our_common.contains(id)) - .map(|(i, _)| i) - .collect(); - for (slot, id) in slots.into_iter().zip(&our_common) { - list[slot] = *id; - } - } - // Paragraphs we added go after the predecessor they follow in our list. + list.retain(|id| merged[id].parent == container); + // Paragraphs we repositioned, added or brought here follow their predecessor in our list. for (at, id) in our_list.iter().enumerate() { - if list.contains(id) { + if merged[id].parent != container || (list.contains(id) && !ours_moved.contains(id)) { continue; } - if !b.contains_key(id) && merged[id].parent == container { - let predecessor = our_list[..at] - .iter() - .rev() - .find(|other| list.contains(other)); - let position = - predecessor.map_or(0, |p| list.iter().position(|x| x == p).unwrap() + 1); - list.insert(position, *id); - } - } - for id in &list { - if merged[id].parent != container { - return None; - } + list.retain(|other| other != id); + let predecessor = our_list[..at] + .iter() + .rev() + .find(|other| list.contains(other)); + let position = predecessor.map_or(0, |p| list.iter().position(|x| x == p).unwrap() + 1); + list.insert(position, *id); } order.insert(container, list); } @@ -323,49 +316,84 @@ fn merge_paragraphs( Some(out) } +/// The members of `list` outside a longest common subsequence with `base`: the paragraphs +/// a reorder of the shared members had to move. +fn moved(base: &[ExGuid], list: &[ExGuid]) -> BTreeSet { + let a: Vec = base + .iter() + .copied() + .filter(|id| list.contains(id)) + .collect(); + let b: Vec = list + .iter() + .copied() + .filter(|id| base.contains(id)) + .collect(); + let mut table = vec![vec![0usize; b.len() + 1]; a.len() + 1]; + for i in (0..a.len()).rev() { + for j in (0..b.len()).rev() { + table[i][j] = if a[i] == b[j] { + table[i + 1][j + 1] + 1 + } else { + table[i + 1][j].max(table[i][j + 1]) + }; + } + } + let mut kept = BTreeSet::new(); + let (mut i, mut j) = (0, 0); + while i < a.len() && j < b.len() { + if a[i] == b[j] { + kept.insert(a[i]); + i += 1; + j += 1; + } else if table[i + 1][j] >= table[i][j + 1] { + i += 1; + } else { + j += 1; + } + } + b.into_iter().filter(|id| !kept.contains(id)).collect() +} + fn merge_paragraph( base: &PageParagraph, ours: &PageParagraph, theirs: &PageParagraph, ) -> Option { - if ours.lists != base.lists - || ours.tags != base.tags - || ours.style != base.style - || ours.format != base.format - { - return None; - } + let lists = pick(&base.lists, &ours.lists, &theirs.lists)?; + let tags = pick(&base.tags, &ours.tags, &theirs.tags)?; + let style = pick(&base.style, &ours.style, &theirs.style)?; + let format = pick(&base.format, &ours.format, &theirs.format)?; let (parent, level) = pick( &(base.parent, base.level), &(ours.parent, ours.level), &(theirs.parent, theirs.level), )?; let collapsed = pick(&base.collapsed, &ours.collapsed, &theirs.collapsed)?; - let content = match (&base.content, &ours.content, &theirs.content) { - (ParagraphContent::Text(x), ParagraphContent::Text(y), ParagraphContent::Text(z)) => { - if x.id != y.id || x.id != z.id || y.date_field != x.date_field || y.tags != x.tags { - return None; + let content = match pick(&base.content, &ours.content, &theirs.content) { + Some(picked) => picked, + None => match (&base.content, &ours.content, &theirs.content) { + (ParagraphContent::Text(x), ParagraphContent::Text(y), ParagraphContent::Text(z)) + if x.id == y.id && x.id == z.id => + { + let mut text = z.clone(); + text.date_field = pick(&x.date_field, &y.date_field, &z.date_field)?; + text.tags = pick(&x.tags, &y.tags, &z.tags)?; + text.text = merge_text(&x.text, &y.text, &z.text)?; + ParagraphContent::Text(text) } - let mut text = z.clone(); - text.text = merge_text(&x.text, &y.text, &z.text)?; - ParagraphContent::Text(text) - } - (x, y, z) => { - if y != x { - return None; - } - z.clone() - } + _ => return None, + }, }; Some(PageParagraph { id: theirs.id, parent, level, - style: theirs.style, - format: theirs.format.clone(), + style, + format, content, - lists: theirs.lists.clone(), - tags: theirs.tags.clone(), + lists, + tags, collapsed, }) } @@ -379,8 +407,8 @@ fn merge_text( if let Some(picked) = pick(base, ours, theirs) { return Some(picked); } - if base.text() == ours.text() || base.text() == theirs.text() { - // Formatting-only changes on both sides cannot be attributed to ranges. + if base.text() == ours.text() { + // A local formatting-only change cannot be attributed to a range of the remote text. return None; } let (b, o) = (base.text(), ours.text()); @@ -402,6 +430,15 @@ fn merge_text( let end = base.utf16_offset(b.len() - suffix).ok()?; let inserted = ours.utf16_offset(o.len() - suffix).ok()?; let replacement = ours.slice(start..inserted).ok()?; + let our_end = ours.utf16_offset(o.len()).ok()?; + let base_end = base.utf16_offset(b.len()).ok()?; + if (start > 0 && ours.slice(0..start).ok()? != base.slice(0..start).ok()?) + || (end < base_end + && ours.slice(inserted..our_end).ok()? != base.slice(end..base_end).ok()?) + { + // Formatting changed outside the replaced range; the remote text would hide it. + return None; + } let mapped = crate::rebase::rebase(b, theirs.text(), start..end)?; let mut merged = theirs.clone(); merged @@ -454,5 +491,29 @@ mod tests { .text(), "y" ); + let bold = onestore::document::Format { + bold: Some(true), + ..Default::default() + }; + let remote = Paragraph::new("one two three".into(), bold); + let merged = merge_text(&base, &text("one two three four"), &remote).unwrap(); + assert_eq!(merged.text(), "one two three four"); + assert_eq!(merged.format_at(0).unwrap(), remote.format_at(0).unwrap()); + assert_eq!( + merge_text(&base, &remote, &text("one two three four")), + None + ); + assert_eq!( + merge_text(&base, &text("one two"), &text("X one two three")) + .unwrap() + .text(), + "X one two" + ); + assert_eq!( + merge_text(&base, &text("two three"), &text("one two three!")) + .unwrap() + .text(), + "two three!" + ); } } diff --git a/crates/notebook/src/outline.rs b/crates/notebook/src/outline.rs deleted file mode 100644 index 8651735ac02a49b0cb619a15a99b8648b63312d7..0000000000000000000000000000000000000000 --- a/crates/notebook/src/outline.rs +++ /dev/null @@ -1,138 +0,0 @@ -use super::*; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct Observed { - path: Vec, - values: Vec, -} - -/// A layout or saved expansion intent with its original ancestry and property values. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct OutlineEdit { - pub object: ExGuid, - pub change: onestore::OutlineEdit, - observed: Observed, -} - -fn observe( - source: &[u8], - space: ExGuid, - object: ExGuid, - change: onestore::OutlineEdit, -) -> Result> { - let store = Store::parse(source)?; - let index = RevisionIndex::parse(&store)?; - let document = Document::parse(&index)?; - let Ok(view) = document.active(space) else { - return Ok(None); - }; - let pages = document.pages_in(space)?; - if pages.len() != 1 { - return Ok(None); - } - let Some(mut paths) = active_paths(view, &pages, &[object]) else { - return Ok(None); - }; - let path = paths.pop().unwrap(); - let node = &view.nodes[&object]; - let values = match (change, &node.kind) { - (onestore::OutlineEdit::Position { .. }, Kind::Outline { .. }) => { - vec![json!(node.layout.x), json!(node.layout.y)] - } - (onestore::OutlineEdit::Width { .. }, Kind::Outline { .. }) => { - vec![ - json!(node.layout.max_width), - json!(node.layout.width_set_by_user.unwrap_or(false)), - json!(node.layout.reserved_width), - ] - } - (onestore::OutlineEdit::Collapsed(_), Kind::Paragraph { collapse_state, .. }) => { - vec![json!(collapse_state.unwrap_or(0))] - } - _ => return Ok(None), - }; - Ok(Some(Observed { path, values })) -} - -impl Replica { - /// Durably records layout or saved expansion changes, preserving independent remote edits. - pub fn outline( - &self, - source: &[u8], - space: ExGuid, - object: ExGuid, - change: onestore::OutlineEdit, - ) -> Result> { - let (edit, prepared) = OutlineEdit::capture(source, space, object, change)?; - self.record(source, space, Operation::Outline(edit), &prepared) - } -} - -impl OutlineEdit { - pub(crate) fn capture<'a>( - source: &'a [u8], - space: ExGuid, - object: ExGuid, - change: onestore::OutlineEdit, - ) -> Result<(Self, PreparedEdit<'a>)> { - let prepared = PreparedEdit::outline(source, space, object, change)?; - let observed = observe(source, space, object, change)?.ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "Prepared outline edit has no active target", - ) - })?; - Ok(( - Self { - object, - change, - observed, - }, - prepared, - )) - } - - pub(crate) fn prepare<'a>( - &self, - snapshot: &'a [u8], - space: ExGuid, - ) -> Result, ConflictKind>> { - let Some(current) = observe(snapshot, space, self.object, self.change)? else { - return Ok(Err(ConflictKind::TargetUnavailable)); - }; - if current.path != self.observed.path { - return Ok(Err(ConflictKind::StructureChanged)); - } - let prepared = match PreparedEdit::outline(snapshot, space, self.object, self.change) { - Ok(prepared) => prepared, - Err(_) => return Ok(Err(ConflictKind::UnsupportedEdit)), - }; - let wanted = - observe(prepared.as_bytes(), space, self.object, self.change)?.ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "Prepared outline edit lost its target", - ) - })?; - if self.observed.values.len() != current.values.len() - || wanted.values.len() != current.values.len() - { - return Ok(Err(ConflictKind::UnsupportedEdit)); - } - if self - .observed - .values - .iter() - .zip(¤t.values) - .zip(&wanted.values) - .any(|((before, current), wanted)| current != before && current != wanted) - { - return Ok(Err(ConflictKind::LayoutChanged)); - } - Ok(Ok(prepared)) - } -} diff --git a/crates/notebook/src/paragraph.rs b/crates/notebook/src/paragraph.rs deleted file mode 100644 index f97975e1da365fc07b4d823454389c00e2ce9810..0000000000000000000000000000000000000000 --- a/crates/notebook/src/paragraph.rs +++ /dev/null @@ -1,200 +0,0 @@ -use super::*; -use onestore::{ParagraphJoin, ParagraphSplit}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct Observed { - text: String, - structure: Value, -} - -/// A stable split intent and the paragraph state observed before local publication. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SplitEdit { - pub intent: ParagraphSplit, - observed: Observed, -} - -/// A join intent and both paragraphs' observed text, placement and tag ownership. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct JoinEdit { - pub intent: ParagraphJoin, - observed: [Observed; 2], -} - -fn observe(source: &[u8], space: ExGuid, texts: &[ExGuid]) -> Result>> { - let store = Store::parse(source)?; - let index = RevisionIndex::parse(&store)?; - let document = Document::parse(&index)?; - let Ok(view) = document.active(space) else { - return Ok(None); - }; - let pages = document.pages_in(space)?; - let Some(paths) = active_paths(view, &pages, texts) else { - return Ok(None); - }; - let mut observed = Vec::new(); - for (text, path) in texts.iter().zip(paths) { - let node = &view.nodes[text]; - let Kind::RichText { text: content, .. } = &node.kind else { - return Ok(None); - }; - let Some(paragraph) = path.first().and_then(|id| view.nodes.get(id)) else { - return Ok(None); - }; - let Kind::Paragraph { lists, .. } = ¶graph.kind else { - return Ok(None); - }; - if paragraph.content != [*text] { - return Ok(None); - } - let lists: Vec<_> = lists.iter().map(|id| (*id, &view.nodes[id].kind)).collect(); - observed.push(Observed { - text: content.clone(), - structure: json!({ - "path": path, - "children": paragraph.children, - "child_level": paragraph.child_level, - "paragraph": paragraph.kind, - "lists": lists, - "tags": node.tags, - }), - }); - } - Ok(Some(observed)) -} - -impl Replica { - /// Durably queues a split with stable new identities and observed structural preconditions. - pub fn split( - &self, - source: &[u8], - space: ExGuid, - intent: &ParagraphSplit, - ) -> Result> { - let (operation, prepared) = SplitEdit::capture(source, space, intent)?; - self.record(source, space, Operation::Split(operation), &prepared) - } - - /// Durably queues a join; changed placement, children or tags require conflict review. - pub fn join( - &self, - source: &[u8], - space: ExGuid, - intent: &ParagraphJoin, - ) -> Result> { - let (operation, prepared) = JoinEdit::capture(source, space, intent)?; - self.record(source, space, Operation::Join(operation), &prepared) - } -} - -impl SplitEdit { - pub(crate) fn capture<'a>( - source: &'a [u8], - space: ExGuid, - intent: &ParagraphSplit, - ) -> Result<(Self, PreparedEdit<'a>)> { - let prepared = PreparedEdit::split(source, space, intent)?; - let Some(mut observed) = observe(source, space, &[intent.position().0])? else { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "Select text in an active paragraph", - ) - .into()); - }; - Ok(( - Self { - intent: intent.clone(), - observed: observed.remove(0), - }, - prepared, - )) - } - - pub(crate) fn prepare<'a>( - &self, - source: &'a [u8], - space: ExGuid, - ) -> Result, ConflictKind>> { - let (text, offset) = self.intent.position(); - let Some(observed) = observe(source, space, &[text])? else { - return Ok(Err(ConflictKind::TargetUnavailable)); - }; - if observed[0].structure != self.observed.structure { - return Ok(Err(ConflictKind::StructureChanged)); - } - let Some(range) = rebase::rebase(&self.observed.text, &observed[0].text, offset..offset) - else { - return Ok(Err(ConflictKind::TextChanged)); - }; - Ok( - PreparedEdit::split(source, space, &self.intent.reposition(range.start)) - .map_err(|_| ConflictKind::UnsupportedEdit), - ) - } -} - -impl JoinEdit { - pub(crate) fn review(&self, source: &[u8], space: ExGuid) -> Result { - let (reviewed, _) = Self::capture(source, space, &self.intent)?; - if reviewed.observed[0].text.is_empty() != self.observed[0].text.is_empty() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "The join would change its surviving text identity", - ) - .into()); - } - Ok(reviewed) - } - - pub(crate) fn capture<'a>( - source: &'a [u8], - space: ExGuid, - intent: &ParagraphJoin, - ) -> Result<(Self, PreparedEdit<'a>)> { - let prepared = PreparedEdit::join(source, space, intent)?; - let Some(observed) = observe(source, space, &intent.texts())? else { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "Select text in two active paragraphs", - ) - .into()); - }; - Ok(( - Self { - intent: intent.clone(), - observed: observed.try_into().unwrap(), - }, - prepared, - )) - } - - pub(crate) fn prepare<'a>( - &self, - source: &'a [u8], - space: ExGuid, - ) -> Result, ConflictKind>> { - let Some(observed) = observe(source, space, &self.intent.texts())? else { - return Ok(Err(ConflictKind::TargetUnavailable)); - }; - for (i, (old, new)) in self.observed.iter().zip(&observed).enumerate() { - if old.structure != new.structure { - return Ok(Err(ConflictKind::StructureChanged)); - } - let at = if i == 0 { - u32::try_from(old.text.encode_utf16().count()).map_err(io::Error::other)? - } else { - 0 - }; - if rebase::rebase(&old.text, &new.text, at..at).is_none() { - return Ok(Err(ConflictKind::TextChanged)); - } - } - Ok(PreparedEdit::join(source, space, &self.intent) - .map_err(|_| ConflictKind::UnsupportedEdit)) - } -} diff --git a/crates/notebook/src/recovery.rs b/crates/notebook/src/recovery.rs index 5b87b1c733d53acaba966f24c4fe60ba5df548e7..48a5dd884aaae2d251dfaa59e84f30ac9821c326 100644 --- a/crates/notebook/src/recovery.rs +++ b/crates/notebook/src/recovery.rs @@ -31,10 +31,17 @@ impl Recovery { let application: u32 = connection.pragma_query_value(None, "application_id", |row| row.get(0))?; let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; - if application != RECOVERY_ID || !(4..=SCHEMA_VERSION).contains(&version) { + if application != RECOVERY_ID { + return Err( + io::Error::new(io::ErrorKind::InvalidData, "Not a recovery archive").into(), + ); + } + if version != SCHEMA_VERSION { return Err(io::Error::new( io::ErrorKind::InvalidData, - "Unrecognized recovery archive or unsupported schema version", + format!( + "Archive schema version {version} is not the supported version {SCHEMA_VERSION}" + ), ) .into()); } @@ -138,16 +145,11 @@ impl Replica { } fn summary(connection: &Connection) -> Result { - let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; - let (cached_assets, cached_asset_bytes) = if version < 5 { - (0, 0) - } else { - connection.query_row( - "SELECT count(*),coalesce(sum(length(data)),0) FROM assets", - [], - |row| Ok((unsigned(row, 0)?, unsigned(row, 1)?)), - )? - }; + let (cached_assets, cached_asset_bytes) = connection.query_row( + "SELECT count(*),coalesce(sum(length(data)),0) FROM assets", + [], + |row| Ok((unsigned(row, 0)?, unsigned(row, 1)?)), + )?; Ok(connection.query_row( "SELECT (SELECT count(*) FROM edits), (SELECT count(*) FROM conflicts), (SELECT count(*) FROM attempt), (SELECT count(*) FROM receipts), diff --git a/crates/notebook/src/schema.rs b/crates/notebook/src/schema.rs index b392c200c66b01dc09c0a76dc128b06f03f2766b..ef12c0c62a5e7d1e06e4a0a08ef12a53857f3a06 100644 --- a/crates/notebook/src/schema.rs +++ b/crates/notebook/src/schema.rs @@ -1,9 +1,9 @@ use super::*; -use rusqlite::{OptionalExtension, Transaction}; +use rusqlite::Transaction; const CONFLICTS: &str = "CREATE TABLE conflicts ( edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, - kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 6) + kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 3) ) STRICT;"; const ASSETS: &str = "CREATE TABLE assets ( @@ -33,111 +33,3 @@ pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> { transaction.execute_batch(ASSETS)?; Ok(()) } - -pub(crate) fn migrate(transaction: &Transaction<'_>, version: u32) -> Result<()> { - if version >= 10 { - return Ok(()); - } - if version >= 3 { - transaction.execute_batch("ALTER TABLE conflicts RENAME TO old_conflicts;")?; - transaction.execute_batch(CONFLICTS)?; - transaction.execute_batch( - "INSERT INTO conflicts SELECT * FROM old_conflicts; DROP TABLE old_conflicts;", - )?; - if version < 5 { - transaction.execute_batch(ASSETS)?; - } - transaction.execute_batch("ALTER TABLE attempt RENAME COLUMN revision TO revisions;")?; - migrate_attempts(transaction)?; - return Ok(()); - } - - let mut query = transaction.prepare( - "SELECT id, space, object, before_text, start, end, replacement FROM edits ORDER BY id", - )?; - let mut rows = query.query([])?; - let mut edits = Vec::new(); - while let Some(row) = rows.next()? { - edits.push(PendingEdit { - id: u64::try_from(row.get::<_, i64>(0)?).map_err(io::Error::other)?, - space: row.get::<_, String>(1)?.parse()?, - operation: Operation::Text(TextEdit { - object: row.get::<_, String>(2)?.parse()?, - before: row.get(3)?, - range: row.get(4)?..row.get(5)?, - replacement: row.get(6)?, - }), - }); - } - drop(rows); - drop(query); - let sequence: i64 = transaction - .query_row( - "SELECT seq FROM sqlite_sequence WHERE name='edits'", - [], - |row| row.get(0), - ) - .optional()? - .unwrap_or(0); - let (mut attempts, mut conflicts, mut receipts) = (Vec::new(), Vec::new(), Vec::new()); - if version == 2 { - let mut query = transaction.prepare("SELECT edit_id, revision FROM attempt")?; - attempts = query - .query_map([], |row| { - Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) - })? - .collect::>()?; - let mut query = transaction.prepare("SELECT edit_id, kind FROM conflicts")?; - conflicts = query - .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))? - .collect::>()?; - let mut query = transaction.prepare("SELECT edit_id, revision FROM receipts")?; - receipts = query - .query_map([], |row| { - Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) - })? - .collect::>()?; - transaction - .execute_batch("DROP TABLE attempt; DROP TABLE conflicts; DROP TABLE receipts;")?; - } - transaction.execute_batch("DROP TABLE edits;")?; - create(transaction)?; - for edit in edits { - transaction.execute( - "INSERT INTO edits(id,space,operation) VALUES (?1,?2,?3)", - params![ - i64::try_from(edit.id).map_err(io::Error::other)?, - edit.space.to_string(), - serde_json::to_string(&edit.operation).map_err(io::Error::other)? - ], - )?; - } - // A drained queue must not reuse IDs belonging to existing durable receipts. - transaction.execute("DELETE FROM sqlite_sequence WHERE name='edits'", [])?; - transaction.execute( - "INSERT INTO sqlite_sequence(name,seq) VALUES ('edits',?1)", - [sequence], - )?; - for (id, revision) in attempts { - transaction.execute( - "INSERT INTO attempt VALUES (1,?1,?2)", - params![id, revision], - )?; - } - for (id, kind) in conflicts { - transaction.execute("INSERT INTO conflicts VALUES (?1,?2)", params![id, kind])?; - } - for (id, revision) in receipts { - transaction.execute("INSERT INTO receipts VALUES (?1,?2)", params![id, revision])?; - } - migrate_attempts(transaction)?; - Ok(()) -} - -fn migrate_attempts(transaction: &Transaction<'_>) -> Result<()> { - transaction.execute_batch( - "UPDATE attempt SET revisions=json_object( - (SELECT space FROM edits WHERE edits.id=attempt.edit_id), revisions);", - )?; - Ok(()) -} diff --git a/crates/notebook/src/sync.rs b/crates/notebook/src/sync.rs index 9e7f162bdfc10df25eb22a010b8ae0f87a93836e..9b00a867c58fe906751cb53126a0e176bb704da6 100644 --- a/crates/notebook/src/sync.rs +++ b/crates/notebook/src/sync.rs @@ -3,7 +3,7 @@ use onestore::{CommitError, CommitState}; use rusqlite::OptionalExtension; use std::{ collections::BTreeMap, - sync::{MutexGuard, TryLockError}, + sync::{MutexGuard, TryLockError, atomic::Ordering}, }; /// A single remote file with fresh reads and native-compatible guarded publication. @@ -17,13 +17,14 @@ pub trait Remote { #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(i64)] pub enum ConflictKind { - TextChanged = 0, - TargetUnavailable = 1, - UnsupportedEdit = 2, - FormattingChanged = 3, - StructureChanged = 4, - LayoutChanged = 5, - ContentChanged = 6, + /// The edited page or a selected page no longer exists remotely. + TargetUnavailable = 0, + /// The remote image no longer accepts the intent's publication. + UnsupportedEdit = 1, + /// Page order or indentation changed remotely in a way the batch cannot absorb. + StructureChanged = 2, + /// The remote page changed where the local model changed too; review is required. + ContentChanged = 3, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -65,6 +66,13 @@ impl Replica { /// Uncertain edits are never replayed; retired formatting may confirm its complete observed effect. pub fn sync_once(&self, remote: &mut impl Remote) -> Result> { let _owner = self.sync_owner()?; + struct Step<'a>(&'a Replica); + impl Drop for Step<'_> { + fn drop(&mut self) { + self.0.in_flight.store(0, Ordering::Release); + } + } + let _step = Step(self); let snapshot = remote.read().map_err(Error::RemoteIo)?; let identity = validate(&snapshot)?; let (intent, attempted) = { @@ -93,6 +101,10 @@ impl Replica { transaction.commit()?; return Ok(None); }; + self.in_flight.store( + i64::try_from(intent.id).map_err(io::Error::other)?, + Ordering::Release, + ); let attempted = transaction .query_row( "SELECT revisions FROM attempt WHERE edit_id=?1", @@ -115,10 +127,11 @@ impl Replica { .get(space) .is_some_and(|space| space.revisions.contains_key(revision)) }) { + // A retired attempt confirms only when its complete effect is observed. let satisfied = match &intent.operation { - Operation::Format(edit) if revisions.len() == 1 => edit - .prepare(&snapshot, intent.space)? - .is_ok_and(|prepared| prepared.as_bytes() == snapshot), + Operation::Page(edit) if revisions.len() == 1 => { + page_of(&snapshot, intent.space)?.is_some_and(|page| page == edit.after) + } _ => false, }; if !satisfied { @@ -154,31 +167,27 @@ impl Replica { Operation::CreatePage(page) => PreparedEdit::create_page(&snapshot, page) .map_err(|_| ConflictKind::StructureChanged), Operation::Pages(edit) => edit.prepare(&snapshot, intent.space)?, - Operation::Format(edit) => edit.prepare(&snapshot, intent.space)?, - Operation::Split(edit) => edit.prepare(&snapshot, intent.space)?, - Operation::Join(edit) => edit.prepare(&snapshot, intent.space)?, - Operation::Outline(edit) => edit.prepare(&snapshot, intent.space)?, - Operation::Tree(edit) => edit.prepare(&snapshot, intent.space)?, - Operation::Insert(insertion) => { - PreparedEdit::insert(&snapshot, intent.space, insertion) - .map_err(|_| ConflictKind::UnsupportedEdit) + Operation::DeletePages(pages) => { + let store = Store::parse(&snapshot)?; + let index = RevisionIndex::parse(&store)?; + if pages.iter().any(|page| { + index + .spaces + .get(page) + .is_none_or(|space| space.labels.is_empty()) + }) || Document::parse(&index)? + .pages()? + .iter() + .filter(|(sid, _)| pages.contains(sid)) + .count() + != pages.len() + { + Err(ConflictKind::TargetUnavailable) + } else { + PreparedEdit::delete_pages_permanently(&snapshot, pages) + .map_err(|_| ConflictKind::UnsupportedEdit) + } } - Operation::Text(edit) => paragraph(&snapshot, intent.space, edit.object)? - .ok_or(ConflictKind::TargetUnavailable) - .and_then(|text| { - crate::rebase::rebase(&edit.before, &text, edit.range.clone()) - .ok_or(ConflictKind::TextChanged) - }) - .and_then(|range| { - PreparedEdit::text( - &snapshot, - intent.space, - edit.object, - range, - &edit.replacement, - ) - .map_err(|_| ConflictKind::UnsupportedEdit) - }), }; let prepared = match candidate { Ok(prepared) => prepared, @@ -267,124 +276,6 @@ impl Replica { Ok(Some((intent.id, EditStatus::Published { revision }))) } - /// Places the oldest text, formatting or split conflict at a reviewed remote UTF-16 range. - /// The requested replacement/attributes, local image, intent ID and later edits are preserved. - /// Both supplied images must match `snapshot` and `remote_snapshot`; stale review - /// returns `Io(ResourceBusy)`. Uncertain publication attempts cannot be rebased. - pub fn rebase_conflict( - &self, - id: u64, - local: &[u8], - remote: &[u8], - range: Range, - ) -> Result<()> { - self.resolve_conflict(id, local, remote, |intent| { - Ok(match intent.operation { - Operation::Text(mut edit) => { - let prepared = PreparedEdit::text( - remote, - intent.space, - edit.object, - range.clone(), - &edit.replacement, - )?; - if prepared.as_bytes() == remote { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "The selected range already contains the replacement", - ) - .into()); - } - edit.before = - paragraph(remote, intent.space, edit.object)?.ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "The remote text target is unavailable", - ) - })?; - edit.range = range; - Operation::Text(edit) - } - Operation::Format(edit) => Operation::Format( - FormatEdit::capture( - remote, - intent.space, - edit.object, - range, - &edit.attributes, - )? - .0, - ), - Operation::Split(edit) => { - if !range.is_empty() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Choose a single split boundary", - ) - .into()); - } - Operation::Split( - crate::paragraph::SplitEdit::capture( - remote, - intent.space, - &edit.intent.reposition(range.start), - )? - .0, - ) - } - Operation::Join(_) => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Review the paragraph join using rebase_join_conflict", - ) - .into()); - } - Operation::Page(_) => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Review a page model conflict using review_page", - ) - .into()); - } - Operation::Outline(_) => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Review layout changes using rebase_layout_conflict", - ) - .into()); - } - Operation::Tree(_) => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Review the subtree edit using rebase_tree_conflict", - ) - .into()); - } - Operation::Insert(_) => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Insertion conflicts require a placement, not a text range", - ) - .into()); - } - Operation::CreatePage(_) => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Review page placement using rebase_page_creation_conflict", - ) - .into()); - } - Operation::Pages(_) => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Review page edits using rebase_pages_conflict", - ) - .into()); - } - }) - }) - } - /// Repositions an unattempted page-creation conflict, retaining dependent object identities. pub fn rebase_page_creation_conflict( &self, @@ -431,102 +322,6 @@ impl Replica { }) } - /// Reviews a join against both current cache images, retaining its original text identities. - /// The surviving text identity must remain the same for dependent edits. - pub fn rebase_join_conflict(&self, id: u64, local: &[u8], remote: &[u8]) -> Result<()> { - self.resolve_conflict(id, local, remote, |intent| { - let Operation::Join(edit) = intent.operation else { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Select a paragraph join conflict", - ) - .into()); - }; - Ok(Operation::Join(edit.review(remote, intent.space)?)) - }) - } - - /// Repositions the oldest paragraph insertion conflict against reviewed cache images. - /// Object identities and dependent edits are retained; uncertain attempts cannot be moved. - pub fn rebase_paragraph_conflict( - &self, - id: u64, - local: &[u8], - remote: &[u8], - parent: ExGuid, - before: Option, - ) -> Result<()> { - self.resolve_conflict(id, local, remote, |intent| { - let Operation::Insert(insertion) = intent.operation else { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Select a paragraph insertion conflict", - ) - .into()); - }; - let insertion = insertion.reposition_paragraph(parent, before)?; - PreparedEdit::insert(remote, intent.space, &insertion)?; - Ok(Operation::Insert(insertion)) - }) - } - - /// Repositions the oldest outline insertion conflict against reviewed cache images. - /// Object identities and dependent edits are retained; uncertain attempts cannot be moved. - pub fn rebase_outline_conflict( - &self, - id: u64, - local: &[u8], - remote: &[u8], - page: ExGuid, - x: f32, - y: f32, - ) -> Result<()> { - self.resolve_conflict(id, local, remote, |intent| { - let Operation::Insert(insertion) = intent.operation else { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Select an outline insertion conflict", - ) - .into()); - }; - let insertion = insertion.reposition_outline(page, x, y)?; - PreparedEdit::insert(remote, intent.space, &insertion)?; - Ok(Operation::Insert(insertion)) - }) - } - - /// Reviews the original layout intent against both current cache images. - /// Competing property values are replaced only after this explicit review. - pub fn rebase_layout_conflict(&self, id: u64, local: &[u8], remote: &[u8]) -> Result<()> { - self.resolve_conflict(id, local, remote, |intent| { - let Operation::Outline(edit) = intent.operation else { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Select an outline layout conflict", - ) - .into()); - }; - Ok(Operation::Outline( - OutlineEdit::capture(remote, intent.space, edit.object, edit.change)?.0, - )) - }) - } - - /// Reviews the original subtree intent against both current cache images. - /// Replacement paragraph identities must remain unchanged for dependent edits. - pub fn rebase_tree_conflict(&self, id: u64, local: &[u8], remote: &[u8]) -> Result<()> { - self.resolve_conflict(id, local, remote, |intent| { - let Operation::Tree(edit) = intent.operation else { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Select a subtree move or deletion conflict", - ) - .into()); - }; - Ok(Operation::Tree(edit.review(remote, intent.space)?)) - }) - } - /// Replaces a conflicting page save with a model reviewed against the remote page. /// Both supplied images must match `snapshot` and `remote_snapshot`; the reviewed /// model must publish against the remote image, and its author is retained. @@ -661,32 +456,21 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result, Option, String)> = connection.query_row( - &format!("SELECT attempt.{column}, conflicts.kind, edits.space FROM edits LEFT JOIN attempt ON attempt.edit_id=edits.id LEFT JOIN conflicts ON conflicts.edit_id=edits.id WHERE edits.id=?1"), [id], |row| Ok((row.get(0)?,row.get(1)?, row.get(2)?))).optional()?; + "SELECT attempt.revisions, conflicts.kind, edits.space FROM edits LEFT JOIN attempt ON attempt.edit_id=edits.id LEFT JOIN conflicts ON conflicts.edit_id=edits.id WHERE edits.id=?1", [id], |row| Ok((row.get(0)?,row.get(1)?, row.get(2)?))).optional()?; Ok(match record { None => None, Some((Some(revision), _, space)) => Some(EditStatus::AwaitingConfirmation { - revision: if version < 10 { - revision.parse()? - } else { + revision: { let space = space.parse()?; attempted_revisions(&revision, space)?[&space] }, }), Some((None, Some(kind), _)) => Some(EditStatus::Conflict(match kind { - 0 => ConflictKind::TextChanged, - 1 => ConflictKind::TargetUnavailable, - 2 => ConflictKind::UnsupportedEdit, - 3 => ConflictKind::FormattingChanged, - 4 => ConflictKind::StructureChanged, - 5 => ConflictKind::LayoutChanged, - 6 => ConflictKind::ContentChanged, + 0 => ConflictKind::TargetUnavailable, + 1 => ConflictKind::UnsupportedEdit, + 2 => ConflictKind::StructureChanged, + 3 => ConflictKind::ContentChanged, _ => { return Err(io::Error::new( io::ErrorKind::InvalidData, diff --git a/crates/notebook/src/tree.rs b/crates/notebook/src/tree.rs deleted file mode 100644 index 83282746bbf20c1b3fb92b862b8ed02b3b4c35a5..0000000000000000000000000000000000000000 --- a/crates/notebook/src/tree.rs +++ /dev/null @@ -1,541 +0,0 @@ -use super::*; -use onestore::{FileDataReference, IdStream, ObjectData, PropertySets, ResolvedRevision, Value}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::collections::{BTreeMap, BTreeSet}; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(untagged, deny_unknown_fields)] -enum Content { - Legacy([u8; 32]), - Semantic { sha256: [u8; 32] }, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct Observed { - path: Vec<(ExGuid, u8)>, - siblings: BTreeMap, - destination: Vec<(ExGuid, u8)>, - content: Option, -} - -/// A subtree intent with observed placement, deletion content and replacement identities. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct TreeEdit { - pub intent: onestore::TreeEdit, - observed: Observed, - created: BTreeSet, -} - -fn fingerprint( - store: &Store<'_>, - raw: &ResolvedRevision<'_>, - root: ExGuid, - legacy: bool, -) -> Result<[u8; 32]> { - let mut objects = BTreeMap::new(); - let mut visiting = BTreeSet::new(); - let mut pending = vec![(root, false)]; - while let Some((id, complete)) = pending.pop() { - if objects.contains_key(&id) { - continue; - } - let object = &raw.objects[&id]; - let references = object.references()?; - if !complete { - if !visiting.insert(id) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "Subtree property references form a cycle", - ) - .into()); - } - pending.push((id, true)); - pending.extend(references.objects.iter().map(|id| (*id, false))); - continue; - } - visiting.remove(&id); - let mut hash = Sha256::new(); - hash.update(object.jcid.to_le_bytes()); - match object.data { - ObjectData::Properties(bytes) => { - hash.update([0]); - let properties = PropertySets::parse(bytes)?; - let mut object_ids = references.objects.into_iter(); - let mut spaces = references.object_spaces.into_iter(); - let mut contexts = references.contexts.into_iter(); - let mut fields = Vec::new(); - for set in &properties.sets { - let mut values = BTreeMap::new(); - for property in set { - let mut value = Sha256::new(); - match &property.value { - Value::NoData => {} - Value::Bytes(bytes) => value.update(bytes), - Value::References { - stream, - compact_ids, - } => { - let references = match stream { - IdStream::Objects => &mut object_ids, - IdStream::ObjectSpaces => &mut spaces, - IdStream::Contexts => &mut contexts, - }; - for reference in references.take(compact_ids.len() / 4) { - if !legacy && *stream == IdStream::Objects { - let immutable = - raw.objects[&reference].jcid & 0x100000 != 0; - value.update([u8::from(immutable)]); - if !immutable { - value.update(reference.guid); - value.update(reference.n.to_le_bytes()); - } - value.update(objects[&reference]); - } else { - value.update(reference.guid); - value.update(reference.n.to_le_bytes()); - } - } - } - Value::Sets(_) => {} - } - if property.id == 0x14001d7a - || (!legacy - && property.id == 0x24001c20 - && matches!(&property.value, Value::References { compact_ids, .. } if compact_ids.is_empty())) - { - continue; - } - values.insert(property.id, value); - } - fields.push(values); - } - // Arena indices and CompactIDs can change without changing property content. - let mut sets = vec![[0; 32]; properties.sets.len()]; - for at in (0..properties.sets.len()).rev() { - for property in &properties.sets[at] { - if let Value::Sets(children) = &property.value { - let value = fields[at].get_mut(&property.id).unwrap(); - for child in children.clone() { - value.update(sets[child]); - } - } - } - let mut set = Sha256::new(); - for (id, value) in &fields[at] { - set.update(id.to_le_bytes()); - set.update(value.clone().finalize()); - } - sets[at] = set.finalize().into(); - } - hash.update(sets[0]); - } - ObjectData::File { - reference, - extension, - } => { - hash.update([1]); - hash.update(Sha256::digest(reference)); - hash.update(Sha256::digest(extension)); - if let Some(FileDataReference::Internal(guid)) = object.file_reference()? { - hash.update(Sha256::digest(store.file_data(guid)?)); - } - } - ObjectData::Encrypted(_) => unreachable!("references rejected encrypted content"), - } - objects.insert(id, <[u8; 32]>::from(hash.finalize())); - } - if !legacy { - return Ok(objects[&root]); - } - let mut hash = Sha256::new(); - for (id, value) in objects { - hash.update(id.guid); - hash.update(id.n.to_le_bytes()); - hash.update(value); - } - Ok(hash.finalize().into()) -} - -fn observe( - source: &[u8], - space: ExGuid, - intent: &onestore::TreeEdit, - legacy: bool, -) -> Result> { - let store = Store::parse(source)?; - let index = RevisionIndex::parse(&store)?; - let document = Document::parse(&index)?; - let Ok(view) = document.active(space) else { - return Ok(None); - }; - let pages = document.pages_in(space)?; - if pages.len() != 1 { - return Ok(None); - } - let object = intent.object(); - let mut targets = vec![object]; - if let Some((parent, _)) = intent.destination() { - targets.push(parent); - } - let Some(paths) = active_paths(view, &pages, &targets) else { - return Ok(None); - }; - let Some(parent) = paths[0].first() else { - return Ok(None); - }; - let children = &view.nodes[parent].children; - let Some(position) = children.iter().position(|id| *id == object) else { - return Ok(None); - }; - let levels = |path: &[ExGuid]| { - path.iter() - .map(|id| (*id, view.nodes[id].child_level.unwrap_or(1))) - .collect::>() - }; - let destination = if let Some((parent, _)) = intent.destination() { - levels(&[&[parent], paths[1].as_slice()].concat()) - } else { - Vec::new() - }; - Ok(Some(Observed { - path: levels(&paths[0]), - siblings: children - .iter() - .enumerate() - .filter_map(|(at, id)| (*id != object).then_some((*id, at < position))) - .collect(), - destination, - content: if intent.destination().is_none() { - let sha256 = fingerprint(&store, &index.resolve_active(space)?, object, legacy)?; - Some(if legacy { - Content::Legacy(sha256) - } else { - Content::Semantic { sha256 } - }) - } else { - None - }, - })) -} - -fn mutable_objects(source: &[u8], space: ExGuid) -> Result> { - let store = Store::parse(source)?; - let index = RevisionIndex::parse(&store)?; - let raw = index.resolve_active(space)?; - Ok(raw - .reachable()? - .into_iter() - .filter(|id| raw.objects[id].jcid & 0x100000 == 0) - .collect()) -} - -impl Replica { - /// Queues a move or deletion, retaining content changes for explicit deletion review. - pub fn tree( - &self, - source: &[u8], - space: ExGuid, - intent: &onestore::TreeEdit, - ) -> Result> { - let (edit, prepared) = TreeEdit::capture(source, space, intent)?; - self.record(source, space, Operation::Tree(edit), &prepared) - } -} - -impl TreeEdit { - pub(crate) fn capture<'a>( - source: &'a [u8], - space: ExGuid, - intent: &onestore::TreeEdit, - ) -> Result<(Self, PreparedEdit<'a>)> { - let prepared = PreparedEdit::tree(source, space, intent)?; - let observed = observe(source, space, intent, false)?.ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "Prepared subtree edit has no active target", - ) - })?; - let before = mutable_objects(source, space)?; - let after = mutable_objects(prepared.as_bytes(), space)?; - Ok(( - Self { - intent: intent.clone(), - observed, - created: &after - &before, - }, - prepared, - )) - } - - pub(crate) fn prepare<'a>( - &self, - source: &'a [u8], - space: ExGuid, - ) -> Result, ConflictKind>> { - let Some(current) = observe( - source, - space, - &self.intent, - matches!(self.observed.content, Some(Content::Legacy(_))), - )? - else { - return Ok(Err(ConflictKind::TargetUnavailable)); - }; - let prepared = match PreparedEdit::tree(source, space, &self.intent) { - Ok(prepared) => prepared, - Err(_) => return Ok(Err(ConflictKind::UnsupportedEdit)), - }; - let after = mutable_objects(prepared.as_bytes(), space)?; - if prepared.as_bytes() == source { - return Ok(if self.created.is_subset(&after) { - Ok(prepared) - } else { - Err(ConflictKind::StructureChanged) - }); - } - if current.path != self.observed.path - || current.destination != self.observed.destination - || current.siblings.iter().any(|(id, before)| { - self.observed - .siblings - .get(id) - .is_some_and(|was_before| before != was_before) - }) - || &after - &mutable_objects(source, space)? != self.created - { - return Ok(Err(ConflictKind::StructureChanged)); - } - if current.content != self.observed.content { - return Ok(Err(ConflictKind::ContentChanged)); - } - Ok(Ok(prepared)) - } - - pub(crate) fn review(&self, source: &[u8], space: ExGuid) -> Result { - let (reviewed, _) = Self::capture(source, space, &self.intent)?; - if reviewed.created != self.created { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "The subtree edit would change its replacement paragraph identities", - ) - .into()); - } - Ok(reviewed) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use onestore::Object; - use std::sync::Arc; - - fn set(fields: &[(u32, Vec)]) -> Vec { - let mut bytes = u16::try_from(fields.len()).unwrap().to_le_bytes().to_vec(); - for (id, _) in fields { - bytes.extend(id.to_le_bytes()); - } - for (_, value) in fields { - bytes.extend(value); - } - bytes - } - - #[test] - fn deletion_fingerprint_resolves_references_and_nested_sets_without_storage_order() { - let source = onestore::create_section("fingerprint.one", "Original", "Author").unwrap(); - let store = Store::parse(&source).unwrap(); - let root = ExGuid { - guid: [1; 16], - n: 1, - }; - let child = ExGuid { - guid: [2; 16], - n: 7, - }; - let hash = |reverse: bool, global: u32, timestamp: u32, change: usize, immutable: bool| { - let mut fields = vec![ - (0x14001d7a, timestamp.to_le_bytes().to_vec()), - (0x20000001, vec![]), - ( - 0x44000002, - set(&[(0x14000003, (u32::from(change == 1)).to_le_bytes().to_vec())]), - ), - ( - 0x44000004, - set(&[(if change == 2 { 0x08000005 } else { 0x88000005 }, vec![])]), - ), - ]; - if change != 5 { - fields.push((0x24001c20, 0_u32.to_le_bytes().to_vec())); - } - if change == 6 { - fields.push((0x24000007, 0_u32.to_le_bytes().to_vec())); - } - if reverse { - fields.reverse(); - } - let mut bytes = 0x80000001_u32.to_le_bytes().to_vec(); - bytes.extend(((global << 8) | child.n).to_le_bytes()); - bytes.extend(set(&fields)); - let mut child_bytes = 0x80000000_u32.to_le_bytes().to_vec(); - child_bytes.extend(set(&[( - 0x14000006, - (u32::from(change == 3)).to_le_bytes().to_vec(), - )])); - let ids = Arc::new(BTreeMap::from([( - global, - if change == 4 { [3; 16] } else { child.guid }, - )])); - let target = ExGuid { - guid: ids[&global], - ..child - }; - let raw = ResolvedRevision { - roots: BTreeMap::from([(1, root)]), - objects: BTreeMap::from([ - ( - root, - Object { - jcid: 0x6000d, - reference_count: 1, - data: ObjectData::Properties(&bytes), - global_ids: ids.clone(), - }, - ), - ( - target, - Object { - jcid: if immutable { 0x12004d } else { 0x6000e }, - reference_count: 1, - data: ObjectData::Properties(&child_bytes), - global_ids: ids, - }, - ), - ]), - }; - fingerprint(&store, &raw, root, false).unwrap() - }; - let original = hash(false, 0, 1, 0, false); - assert_eq!(original, hash(true, 137, 2, 0, false)); - assert_eq!(original, hash(true, 137, 2, 5, false)); - assert_ne!(original, hash(true, 137, 2, 6, false)); - for change in 1..=4 { - assert_ne!(original, hash(true, 137, 2, change, false)); - } - let immutable = hash(false, 0, 1, 0, true); - assert_eq!(immutable, hash(true, 137, 2, 4, true)); - for change in 1..=3 { - assert_ne!(immutable, hash(true, 137, 2, change, true)); - } - } - - #[test] - fn version_eight_deletion_observations_survive_migration_and_upgrade_only_after_review() { - struct Server(Vec); - impl Remote for Server { - fn read(&mut self) -> io::Result> { - Ok(self.0.clone()) - } - fn publish( - &mut self, - edit: &PreparedEdit<'_>, - ) -> std::result::Result<(), onestore::CommitError> { - self.0 = edit.as_bytes().to_vec(); - Ok(()) - } - fn confirm( - &mut self, - snapshot: &[u8], - ) -> std::result::Result<(), onestore::CommitError> { - assert!(self.0 == snapshot); - Ok(()) - } - } - let source = onestore::create_section("legacy.one", "AlphaOmega", "Author").unwrap(); - let store = Store::parse(&source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let (sid, _) = document.pages().unwrap()[0]; - let view = document.active(sid).unwrap(); - let text = *view.nodes.iter().find(|(_, node)| matches!(&node.kind, Kind::RichText { text, .. } if text == "AlphaOmega")).unwrap().0; - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("legacy.sqlite"); - let cache = Replica::create(&path, &source).unwrap(); - cache - .format( - &source, - sid, - text, - 0..10, - &[onestore::TextAttribute::Bold(true)], - ) - .unwrap(); - let split = onestore::ParagraphSplit::new(text, 5, "Author").unwrap(); - cache - .split(&cache.snapshot().unwrap(), sid, &split) - .unwrap(); - let before = cache.snapshot().unwrap(); - let intent = onestore::TreeEdit::delete(split.object(), "Author").unwrap(); - let deletion = cache.tree(&before, sid, &intent).unwrap().unwrap(); - let dependent = cache - .edit_text(&cache.snapshot().unwrap(), sid, text, 0..0, "Local ") - .unwrap() - .unwrap(); - let mut queue = cache.pending().unwrap(); - let Operation::Tree(edit) = &mut queue[2].operation else { - panic!() - }; - edit.observed = observe(&before, sid, &intent, true).unwrap().unwrap(); - let serialized = serde_json::to_string(&queue[2].operation).unwrap(); - let local = cache.snapshot().unwrap(); - drop(cache); - let db = Connection::open(&path).unwrap(); - db.execute( - "UPDATE edits SET operation=?1 WHERE id=?2", - params![serialized, i64::try_from(deletion).unwrap()], - ) - .unwrap(); - db.pragma_update(None, "user_version", 8).unwrap(); - db.execute_batch("ALTER TABLE attempt RENAME COLUMN revisions TO revision;") - .unwrap(); - drop(db); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.pending().unwrap(), queue); - assert!(cache.snapshot().unwrap() == local); - let mut server = Server(source); - for _ in 0..2 { - assert!(matches!( - cache.sync_once(&mut server).unwrap(), - Some((_, EditStatus::Published { .. })) - )); - } - assert_eq!( - cache.sync_once(&mut server).unwrap(), - Some((deletion, EditStatus::Conflict(ConflictKind::ContentChanged))) - ); - cache - .rebase_tree_conflict(deletion, &local, &server.0) - .unwrap(); - let queue = cache.pending().unwrap(); - let Operation::Tree(edit) = &queue[0].operation else { - panic!() - }; - assert!(matches!( - edit.observed.content, - Some(Content::Semantic { .. }) - )); - for id in [deletion, dependent] { - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) - ); - } - assert_eq!( - paragraph(&server.0, sid, text).unwrap().as_deref(), - Some("Local Alpha") - ); - } -} diff --git a/crates/notebook/tests/assets.rs b/crates/notebook/tests/assets.rs index 378c08b225879aaf6a246ca0e3b745c242030c98..afbb4f12c061db43b8a2f95c02b4ec4d00d67671 100644 --- a/crates/notebook/tests/assets.rs +++ b/crates/notebook/tests/assets.rs @@ -1,4 +1,9 @@ use notebook::{EditStatus, Error, Operation, Recovery, Replica}; +use onestore::{ + ExGuid, RevisionIndex, Store, + document::Document, + page::{Page, PageObject}, +}; use std::{ fs, io, path::Path, @@ -6,14 +11,54 @@ use std::{ time::Duration, }; +#[path = "support/model_ops.rs"] +mod model_ops; + const FIXTURE: &str = "../../corpus/native-external-assets/notebook"; -const LEGACY: &str = "../../corpus/offline-v4/live.sqlite"; -fn copied_cache(root: &Path) -> Replica { +/// The first body text object of the section referencing the fixture's media. +fn first_text(bytes: &[u8]) -> ExGuid { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + document + .pages() + .unwrap() + .into_iter() + .find_map(|(space, _)| { + Page::from_space(&document, space) + .ok()? + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => outline + .paragraphs + .iter() + .find_map(|p| p.text().map(|t| t.id)), + _ => None, + }) + }) + .unwrap() +} + +/// A cache of the media fixture with a queued text save and a queued page creation. +fn queued_cache(root: &Path) -> Replica { let path = root.join("cache.sqlite"); assert!(!path.exists()); - fs::copy(LEGACY, &path).unwrap(); - Replica::open(path).unwrap() + let source = fs::read(Path::new(FIXTURE).join("synthetic.one")).unwrap(); + let cache = Replica::create(path, &source).unwrap(); + let text = first_text(&source); + model_ops::save(&cache, text, |page| { + model_ops::replace_text(page, text, 0..0, "Queued ") + }) + .unwrap() + .unwrap(); + let page = onestore::PageCreation::new(None, Some("Queued page"), "Author").unwrap(); + cache + .create_page(&cache.snapshot().unwrap(), &page) + .unwrap() + .unwrap(); + cache } fn payload(size: usize) -> (String, Vec) { @@ -39,10 +84,9 @@ impl notebook::discover::Source for Payload { } #[test] -fn downloaded_media_survives_migration_reopen_and_recovery_with_the_queue_intact() { +fn downloaded_media_survives_reopen_and_recovery_with_the_queue_intact() { let directory = tempfile::tempdir().unwrap(); - let original = fs::read(LEGACY).unwrap(); - let cache = copied_cache(directory.path()); + let cache = queued_cache(directory.path()); let working = cache.snapshot().unwrap(); let remote = cache.remote_snapshot().unwrap(); let pending = cache.pending().unwrap(); @@ -51,10 +95,7 @@ fn downloaded_media_survives_migration_reopen_and_recovery_with_the_queue_intact [1, 2] ); let uncertain = cache.status(1).unwrap(); - assert!(matches!( - uncertain, - Some(EditStatus::AwaitingConfirmation { .. }) - )); + assert_eq!(uncertain, Some(EditStatus::Pending)); let mut source = notebook::discover::Local::open(FIXTURE).unwrap(); for size in [0, 1024] { let (name, bytes) = payload(size); @@ -99,30 +140,12 @@ fn downloaded_media_survives_migration_reopen_and_recovery_with_the_queue_intact ); assert_eq!(recovery.cached_asset(&name, size).unwrap(), Some(bytes)); } - assert_eq!(fs::read(LEGACY).unwrap(), original); -} - -#[test] -fn an_original_version_four_archive_remains_read_only_and_has_no_cached_media() { - let path = "../../corpus/offline-v4/recovery.sqlite"; - let before = fs::read(path).unwrap(); - let archive = Recovery::open(path).unwrap(); - assert_eq!(archive.pending().unwrap().len(), 2); - assert!(matches!( - archive.status(1).unwrap(), - Some(EditStatus::AwaitingConfirmation { .. }) - )); - assert_eq!(archive.summary().unwrap().cached_assets, 0); - assert_eq!(archive.summary().unwrap().cached_asset_bytes, 0); - assert!(archive.cached_asset(&payload(0).0, 0).unwrap().is_none()); - drop(archive); - assert_eq!(fs::read(path).unwrap(), before); } #[test] fn unsuccessful_refreshes_and_changed_identity_data_preserve_the_downloaded_payload() { let directory = tempfile::tempdir().unwrap(); - let cache = copied_cache(directory.path()); + let cache = queued_cache(directory.path()); let (name, bytes) = payload(1024); let mut source = Payload(Some(Ok(bytes.clone()))); cache @@ -154,7 +177,7 @@ fn unsuccessful_refreshes_and_changed_identity_data_preserve_the_downloaded_payl #[test] fn unreferenced_payloads_are_rejected_before_io_or_local_changes() { let directory = tempfile::tempdir().unwrap(); - let cache = copied_cache(directory.path()); + let cache = queued_cache(directory.path()); let mut unused = Payload(None); assert!( matches!(cache.fetch_asset(&mut unused, "synthetic.one", "00000000-0000-0000-0000-000000000001.onebin", 100), @@ -186,7 +209,7 @@ fn download_network_wait_does_not_block_local_edits() { } } let directory = tempfile::tempdir().unwrap(); - let cache = copied_cache(directory.path()); + let cache = queued_cache(directory.path()); let (name, bytes) = payload(1024); let (entered, waiting) = mpsc::channel(); let (release, released) = mpsc::channel(); @@ -202,30 +225,26 @@ fn download_network_wait_does_not_block_local_edits() { .unwrap() }); waiting.recv_timeout(Duration::from_secs(5)).unwrap(); - let pending = cache.pending().unwrap(); - let Operation::Text(edit) = &pending[0].operation else { - panic!() - }; - let id = cache - .edit_text( - &cache.snapshot().unwrap(), - pending[0].space, - edit.object, - 0..0, - "during download ", - ) - .unwrap() - .unwrap(); + let text = first_text(&cache.snapshot().unwrap()); + let id = model_ops::save(&cache, text, |page| { + model_ops::replace_text(page, text, 0..0, "during download ") + }) + .unwrap() + .unwrap(); release.send(()).unwrap(); assert_eq!(download.join().unwrap(), bytes); assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); + let Operation::Page(intent) = &cache.pending().unwrap()[2].operation else { + panic!() + }; + assert_eq!(intent.text_change().unwrap().3, "during download "); }); } #[test] fn concurrent_downloads_publish_one_immutable_cache_entry() { let directory = tempfile::tempdir().unwrap(); - let cache = copied_cache(directory.path()); + let cache = queued_cache(directory.path()); let (name, bytes) = payload(1024); let ready = Barrier::new(8); std::thread::scope(|scope| { @@ -287,7 +306,7 @@ fn a_native_refresh_removing_the_reference_rejects_an_inflight_download() { #[test] fn local_failure_and_bad_cached_bytes_never_become_successful_downloads() { let directory = tempfile::tempdir().unwrap(); - drop(copied_cache(directory.path())); + drop(queued_cache(directory.path())); let path = directory.path().join("cache.sqlite"); let connection = rusqlite::Connection::open(&path).unwrap(); connection.execute_batch("CREATE TRIGGER fail_asset BEFORE INSERT ON assets BEGIN SELECT RAISE(ABORT,'Test asset failure'); END").unwrap(); @@ -346,7 +365,7 @@ fn abrupt_process_exit_retains_only_completed_downloads_and_archives() { } } let root = std::env::var("ONESTORE_ASSET_EXIT_ROOT").unwrap(); - let cache = copied_cache(Path::new(&root)); + let cache = queued_cache(Path::new(&root)); let (name, bytes) = payload(1024); if phase == "download" { cache @@ -391,10 +410,7 @@ fn abrupt_process_exit_retains_only_completed_downloads_and_archives() { let expected = (phase != "download").then_some(bytes); assert_eq!(cache.cached_asset(&name, 1024).unwrap(), expected); assert_eq!(cache.pending().unwrap().len(), 2); - assert!(matches!( - cache.status(1).unwrap(), - Some(EditStatus::AwaitingConfirmation { .. }) - )); + assert_eq!(cache.status(1).unwrap(), Some(EditStatus::Pending)); if phase == "archive" { let recovery = Recovery::open(root.path().join("recovery.sqlite")).unwrap(); assert_eq!(recovery.cached_asset(&name, 1024).unwrap(), expected); @@ -409,7 +425,7 @@ fn abrupt_process_exit_retains_only_completed_downloads_and_archives() { #[ignore = "requires a disposable Samba mirror at ONESTORE_SMB_NOTEBOOK"] fn live_smb_downloads_survive_disconnect_and_cache_reopen() { let root = tempfile::tempdir().unwrap(); - let cache = copied_cache(root.path()); + let cache = queued_cache(root.path()); let client = notebook::smb::Client::connect( &std::env::var("ONESTORE_SMB_LAB").unwrap(), "agent", diff --git a/crates/notebook/tests/cache.rs b/crates/notebook/tests/cache.rs index 36d48874caa846b77725c861db4260ac59e626f5..e5be94eb430795a1821518bb0bc23b751c03a3ca 100644 --- a/crates/notebook/tests/cache.rs +++ b/crates/notebook/tests/cache.rs @@ -1,7 +1,8 @@ -use notebook::{Error, Replica}; +use notebook::{Error, Operation, Replica}; use onestore::{ ExGuid, RevisionIndex, Store, document::{Document, Kind}, + page::{Outline, PageObject}, }; use std::{ collections::BTreeSet, @@ -11,6 +12,9 @@ use std::{ time::{Duration, Instant}, }; +#[path = "support/model_ops.rs"] +mod model_ops; + fn target(source: &[u8]) -> (ExGuid, ExGuid, String) { let store = Store::parse(source).unwrap(); assert!(store.checksum_mismatches.is_empty()); @@ -32,6 +36,23 @@ fn target(source: &[u8]) -> (ExGuid, ExGuid, String) { .unwrap() } +fn text_of(page: &onestore::page::Page, text: ExGuid) -> String { + model_ops::paragraph_with(page, text) + .unwrap() + .text() + .unwrap() + .text + .text() + .to_owned() +} + +fn intent(replica: &Replica, at: usize) -> notebook::PageIntent { + match &replica.pending().unwrap()[at].operation { + Operation::Page(intent) => intent.clone(), + other => panic!("{other:?}"), + } +} + #[test] fn cache_reopen_preserves_exact_images_and_intents() { let dir = tempfile::tempdir().unwrap(); @@ -41,68 +62,85 @@ fn cache_reopen_preserves_exact_images_and_intents() { assert_eq!(replica.snapshot().unwrap(), source); assert!(replica.pending().unwrap().is_empty()); let (sid, oid, _) = target(&source); - assert_eq!( - replica.edit_text(&source, sid, oid, 0..0, "").unwrap(), - None - ); - assert_eq!( - replica.edit_text(&source, sid, oid, 0..4, "café").unwrap(), - None - ); - let first = replica - .edit_text(&source, sid, oid, 5..7, "🐈 日本語") - .unwrap() - .unwrap(); + let unchanged = |page: &mut onestore::page::Page| { + model_ops::replace_text(page, oid, 0..0, ""); + model_ops::replace_text(page, oid, 0..4, "café"); + }; + assert_eq!(model_ops::save(&replica, oid, unchanged).unwrap(), None); + let first = model_ops::save(&replica, oid, |page| { + model_ops::replace_text(page, oid, 5..7, "🐈 日本語") + }) + .unwrap() + .unwrap(); let edited = replica.snapshot().unwrap(); assert_eq!(target(&edited).2, "café 🐈 日本語"); let intents = replica.pending().unwrap(); assert_eq!(intents.len(), 1); - assert_eq!(intents[0].id, first); - let notebook::Operation::Text(first_edit) = &intents[0].operation else { - panic!() - }; - assert_eq!(first_edit.before, "café 🦀"); - assert_eq!(first_edit.range, 5..7); - assert_eq!(first_edit.replacement, "🐈 日本語"); - assert_eq!((intents[0].space, first_edit.object), (sid, oid)); + assert_eq!((intents[0].id, intents[0].space), (first, sid)); + let first_intent = intent(&replica, 0); + assert_eq!( + first_intent.text_change(), + Some((oid, "café 🦀".into(), 5..7, "🐈 日本語".into())) + ); + assert_eq!(first_intent.author, model_ops::AUTHOR); drop(replica); let replica = Replica::open(&path).unwrap(); assert_eq!(replica.snapshot().unwrap(), edited); assert_eq!(replica.pending().unwrap(), intents); - let second = replica - .edit_text(&edited, sid, oid, 0..0, "Recovered ") - .unwrap() - .unwrap(); - assert!(second > first); - let notebook::Operation::Text(second_edit) = &replica.pending().unwrap()[1].operation else { - panic!() - }; - assert_eq!(second_edit.before, "café 🐈 日本語"); + let second = model_ops::save(&replica, oid, |page| { + model_ops::replace_text(page, oid, 0..0, "Recovered ") + }) + .unwrap() + .unwrap(); + assert_eq!(second, first, "an unattempted save is replaced in place"); + let coalesced = intent(&replica, 0); + assert_eq!(coalesced.before, first_intent.before); + assert_eq!(text_of(&coalesced.after, oid), "Recovered café 🐈 日本語"); + assert_eq!( + target(&replica.snapshot().unwrap()).2, + "Recovered café 🐈 日本語" + ); } #[test] -fn failed_edits_preserve_both_intent_queue_and_working_image() { +fn failed_saves_preserve_both_intent_queue_and_working_image() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("section.sqlite"); let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap(); let replica = Replica::create(&path, &source).unwrap(); let (sid, oid, _) = target(&source); - for (range, replacement) in [(6..7, "X"), (0..u32::MAX, "X"), (0..1, "\n")] { - assert!( - replica - .edit_text(&source, sid, oid, range, replacement) - .is_err() - ); - assert_eq!(replica.snapshot().unwrap(), source); - assert!(replica.pending().unwrap().is_empty()); - } + let (_, mut page) = model_ops::locate(&source, oid); + let mut empty = Outline { + id: onestore::page::text::new_id().unwrap(), + title: false, + min_width: None, + layout: Default::default(), + indents: Vec::new(), + paragraphs: Vec::new(), + unsupported: Vec::new(), + }; + empty.layout.x = Some(72.0); + empty.layout.y = Some(400.0); + page.objects.push(PageObject::Outline(empty)); + assert!(replica.save(&source, sid, &page, "Author").is_err()); + let (_, mut page) = model_ops::locate(&source, oid); + model_ops::replace_text(&mut page, oid, 0..0, "Elsewhere "); + let foreign = ExGuid::default(); + assert!(replica.save(&source, foreign, &page, "Author").is_err()); + assert_eq!(replica.snapshot().unwrap(), source); + assert!(replica.pending().unwrap().is_empty()); drop(replica); let connection = rusqlite::Connection::open(&path).unwrap(); connection.execute_batch("CREATE TRIGGER fail_image BEFORE UPDATE ON replica BEGIN SELECT RAISE(ABORT, 'Injected image update failure'); END;").unwrap(); drop(connection); let replica = Replica::open(&path).unwrap(); assert!(matches!( - replica.edit_text(&source, sid, oid, 0..0, "lost? "), + model_ops::save(&replica, oid, |page| model_ops::replace_text( + page, + oid, + 0..0, + "lost? " + )), Err(Error::Database(_)) )); drop(replica); @@ -113,11 +151,11 @@ fn failed_edits_preserve_both_intent_queue_and_working_image() { #[test] fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { - use notebook::{Operation, Recovery}; + use notebook::Recovery; let directory = tempfile::tempdir().unwrap(); let source = onestore::create_section("recovery.one", "base", "Fixture").unwrap(); - let (space, object, _) = target(&source); + let (_, object, _) = target(&source); let replica = Replica::create(directory.path().join("live.sqlite"), &source).unwrap(); let start = Barrier::new(4); std::thread::scope(|scope| { @@ -127,14 +165,10 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { start.wait(); for edit in 0..20 { loop { - let source = replica.snapshot().unwrap(); - match replica.edit_text( - &source, - space, - object, - 0..0, - &format!("[{writer}:{edit}] "), - ) { + let marker = format!("[{writer}:{edit}] "); + match model_ops::save(replica, object, |page| { + model_ops::replace_text(page, object, 0..0, &marker) + }) { Ok(Some(_)) => break, Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy => { continue; @@ -151,16 +185,20 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { replica.export_recovery(&path).unwrap(); let archive = Recovery::open(path).unwrap(); let pending = archive.pending().unwrap(); - let mut expected = String::new(); - for edit in pending.iter().rev() { - let Operation::Text(edit) = &edit.operation else { - panic!() - }; - assert_eq!(edit.range, 0..0); - expected.push_str(&edit.replacement); + let snapshot = target(&archive.snapshot().unwrap()).2; + match pending.as_slice() { + [] => assert_eq!(snapshot, "base"), + [single] => { + let Operation::Page(intent) = &single.operation else { + panic!() + }; + let (id, before, range, replacement) = intent.text_change().unwrap(); + assert_eq!((id, before.as_str(), range), (object, "base", 0..0)); + assert_eq!(snapshot, format!("{replacement}base")); + assert_eq!(text_of(&intent.after, object), snapshot); + } + more => panic!("saves to one page coalesce: {more:?}"), } - expected.push_str("base"); - assert_eq!(target(&archive.snapshot().unwrap()).2, expected); assert_eq!(archive.remote_snapshot().unwrap(), source); assert_eq!( archive.summary().unwrap().queued_edits, @@ -169,7 +207,13 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { assert!(archive.receipts().unwrap().is_empty()); } }); - assert_eq!(replica.pending().unwrap().len(), 60); + assert_eq!(replica.pending().unwrap().len(), 1); + let content = target(&replica.snapshot().unwrap()).2; + for writer in 0..3 { + for edit in 0..20 { + assert_eq!(content.matches(&format!("[{writer}:{edit}] ")).count(), 1); + } + } } #[test] @@ -192,15 +236,39 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() { assert_eq!(replica.snapshot().unwrap(), source); } drop(replica); + let current: u32 = rusqlite::Connection::open(&path) + .unwrap() + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); for sql in [ - "PRAGMA application_id=0", - "PRAGMA application_id=1330529615; PRAGMA user_version=99", + "PRAGMA application_id=0".to_owned(), + format!( + "PRAGMA application_id=1330529615; PRAGMA user_version={}", + current - 1 + ), + format!("PRAGMA user_version={}", current + 1), ] { let connection = rusqlite::Connection::open(&path).unwrap(); - connection.execute_batch(sql).unwrap(); + connection.execute_batch(&sql).unwrap(); drop(connection); let before = fs::read(&path).unwrap(); - assert!(Replica::open(&path).is_err()); + let Err(Error::Io(error)) = Replica::open(&path) else { + panic!("{sql}") + }; + assert_eq!(error.kind(), ErrorKind::InvalidData, "{sql}"); + if sql.contains("user_version") { + let written = sql.rsplit('=').next().unwrap(); + let message = error.to_string(); + assert!( + message.contains(&format!("version {written} ")) + && message.contains(&format!("version {current}")), + "{message}" + ); + } + assert!( + matches!(notebook::Recovery::open(&path), Err(Error::Io(error)) if error.kind() == ErrorKind::InvalidData), + "{sql}" + ); assert_eq!(fs::read(&path).unwrap(), before); } let foreign = dir.path().join("foreign.sqlite"); @@ -221,7 +289,7 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() { } #[test] -fn twelve_local_editors_reject_stale_ranges_and_preserve_every_acknowledgement() { +fn twelve_local_editors_reject_stale_images_and_coalesce_into_one_save() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("section.sqlite"); let source = onestore::create_section("section.one", "Shared café 🦀", "Fixture").unwrap(); @@ -234,11 +302,11 @@ fn twelve_local_editors_reject_stale_ranges_and_preserve_every_acknowledgement() .map(|writer| { let (replica, source, barrier) = (&replica, &source, &barrier); scope.spawn(move || { + let (_, mut page) = model_ops::locate(source, oid); + model_ops::replace_text(&mut page, oid, 0..0, &format!("[initial-{writer}] ")); barrier.wait(); - let first = - replica.edit_text(source, sid, oid, 0..0, &format!("[initial-{writer}] ")); let mut ids = Vec::new(); - let first_won = match first { + let first_won = match replica.save(source, sid, &page, model_ops::AUTHOR) { Ok(Some(id)) => { ids.push(id); true @@ -252,14 +320,10 @@ fn twelve_local_editors_reject_stale_ranges_and_preserve_every_acknowledgement() Instant::now() < deadline, "Writer {writer} stopped progressing at {edit}" ); - let source = replica.snapshot().unwrap(); - match replica.edit_text( - &source, - sid, - oid, - 0..0, - &format!("[{writer}-{edit}] "), - ) { + let marker = format!("[{writer}-{edit}] "); + match model_ops::save(replica, oid, |page| { + model_ops::replace_text(page, oid, 0..0, &marker) + }) { Ok(Some(id)) => { ids.push(id); break; @@ -281,48 +345,39 @@ fn twelve_local_editors_reject_stale_ranges_and_preserve_every_acknowledgement() }); assert_eq!(outcomes.iter().filter(|(won, _)| *won).count(), 1); let ids: BTreeSet<_> = outcomes.into_iter().flat_map(|(_, ids)| ids).collect(); - assert_eq!(ids.len(), 241); + assert_eq!(ids.len(), 1, "every save replaced the unattempted head"); let final_bytes = replica.snapshot().unwrap(); let content = target(&final_bytes).2; - let mut expected = "Shared café 🦀".to_owned(); - for pending in replica.pending().unwrap() { - let notebook::Operation::Text(edit) = pending.operation else { - panic!() - }; - assert_eq!(edit.before, expected); - assert_eq!(edit.range, 0..0); - expected.insert_str(0, &edit.replacement); - } - assert_eq!(content, expected); + let pending = replica.pending().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, *ids.first().unwrap()); + let (id, before, range, replacement) = intent(&replica, 0).text_change().unwrap(); + assert_eq!((id, before.as_str(), range), (oid, "Shared café 🦀", 0..0)); + assert_eq!(content, format!("{replacement}Shared café 🦀")); + assert_eq!(content.matches("[initial-").count(), 1); for writer in 0..12 { for edit in 0..20 { assert_eq!(content.matches(&format!("[{writer}-{edit}] ")).count(), 1); } } - assert_eq!( - replica - .pending() - .unwrap() - .iter() - .map(|edit| edit.id) - .collect::>(), - ids - ); + let (_, mut stale) = model_ops::locate(&source, oid); + model_ops::replace_text(&mut stale, oid, 0..0, "stale "); assert!( - matches!(replica.edit_text(&source, sid, oid, 0..0, ""), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy) + matches!(replica.save(&source, sid, &stale, "Author"), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy) ); drop(replica); let reopened = Replica::open(&path).unwrap(); assert_eq!(reopened.snapshot().unwrap(), final_bytes); - assert_eq!(reopened.pending().unwrap().len(), 241); + assert_eq!(reopened.pending().unwrap(), pending); } #[test] fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("model.sqlite"); - let mut text = "ab🚀ab🦀 é repeated repeated".to_owned(); - let mut source = onestore::create_section("model.one", &text, "Fixture").unwrap(); + let original = "ab🚀ab🦀 é repeated repeated".to_owned(); + let mut text = original.clone(); + let source = onestore::create_section("model.one", &text, "Fixture").unwrap(); let mut replica = Replica::create(&path, &source).unwrap(); let (space, object, _) = target(&source); let mut random = 911_u64; @@ -332,8 +387,9 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { random ^= random << 17; random }; - let mut intents = Vec::new(); - for step in 0..1024 { + let mut acknowledged = 0; + let mut pending = Vec::new(); + for step in 0..512 { let boundaries: Vec<_> = text .char_indices() .map(|(at, _)| at) @@ -344,283 +400,110 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { let bytes = first.min(last)..first.max(last); let range = u32::try_from(text[..bytes.start].encode_utf16().count()).unwrap() ..u32::try_from(text[..bytes.end].encode_utf16().count()).unwrap(); - let replacement = ["", "🐈", "日本語", "repeated", "é", "ab🦀ab"][next() as usize % 6]; + let replacement = ["", "🐈", "日本語", "repeated", "é", "ab🦀ab"][next() as usize % 6]; let mut expected = text.clone(); expected.replace_range(bytes, replacement); - let acknowledgement = replica - .edit_text(&source, space, object, range.clone(), replacement) - .unwrap(); + let before = replica.snapshot().unwrap(); + let acknowledgement = model_ops::save(&replica, object, |page| { + model_ops::replace_text(page, object, range.clone(), replacement) + }) + .unwrap(); if let Some(id) = acknowledgement { assert_ne!(text, expected); - assert!( - intents - .last() - .is_none_or(|edit: ¬ebook::PendingEdit| edit.id < id) - ); - intents.push(notebook::PendingEdit { - id, - space, - operation: notebook::Operation::Text(notebook::TextEdit { - object, - before: text, - range, - replacement: replacement.into(), - }), - }); - assert!( - matches!(replica.edit_text(&source, space, object, 0..0, "stale"), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy) - ); + acknowledged += 1; + pending = replica.pending().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!((pending[0].id, pending[0].space), (1, space)); + let saved = intent(&replica, 0); + assert_eq!(id, 1); + assert_eq!(text_of(&saved.before, object), original); + assert_eq!(text_of(&saved.after, object), expected); + if step % 37 == 0 { + let (_, mut stale) = model_ops::locate(&before, object); + model_ops::replace_text(&mut stale, object, 0..0, "stale"); + assert!( + matches!(replica.save(&before, space, &stale, "Author"), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy) + ); + } } else { assert_eq!(text, expected); } text = expected; - source = replica.snapshot().unwrap(); + let source = replica.snapshot().unwrap(); assert_eq!(target(&source).2, text, "seed 911, step {step}"); if step % 37 == 0 { drop(replica); replica = Replica::open(&path).unwrap(); assert_eq!(replica.snapshot().unwrap(), source); - assert_eq!(replica.pending().unwrap(), intents); + assert_eq!(replica.pending().unwrap(), pending); } } - assert!( - intents.len() > 512, - "The history checkpoint boundary was not exercised" - ); + assert!(acknowledged > 256, "Most random edits change the text"); + let source = replica.snapshot().unwrap(); drop(replica); let replica = Replica::open(&path).unwrap(); - assert_eq!(replica.pending().unwrap(), intents); + assert_eq!(replica.pending().unwrap(), pending); assert_eq!(replica.snapshot().unwrap(), source); } -#[test] -#[ignore = "migrates a fresh copy of a retained version-two or version-three cache"] -fn migrate_retained_cache_copy() { - use notebook::{EditStatus, Operation, PendingEdit, TextEdit}; - let source = std::path::PathBuf::from(std::env::var_os("ONESTORE_MIGRATION_SOURCE").unwrap()); - let output = std::path::PathBuf::from(std::env::var_os("ONESTORE_MIGRATION_OUTPUT").unwrap()); - assert!(source.is_absolute() && output.is_absolute()); - let mut original = fs::File::open(&source).unwrap(); - let mut destination = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&output) - .unwrap(); - std::io::copy(&mut original, &mut destination).unwrap(); - destination.sync_all().unwrap(); - drop(destination); - let db = rusqlite::Connection::open(&output).unwrap(); - let version = db - .pragma_query_value(None, "user_version", |r| r.get::<_, u32>(0)) - .unwrap(); - assert!(matches!(version, 2 | 3)); - if std::env::var_os("ONESTORE_MIGRATION_ROLLBACK").is_some() { - assert_eq!(version, 2); - db.pragma_update(None, "foreign_keys", false).unwrap(); - db.execute( - "INSERT INTO attempt VALUES (1,999999,'{00000001-0000-0000-0000-000000000000},1')", - [], - ) - .unwrap(); - drop(db); - let before = fs::read(&output).unwrap(); - assert!( - matches!(Replica::open(&output), Err(Error::Database(rusqlite::Error::SqliteFailure(error, _))) if error.extended_code == 787) - ); - assert_eq!(fs::read(&output).unwrap(), before); - let db = rusqlite::Connection::open(&output).unwrap(); - assert_eq!( - db.pragma_query_value(None, "user_version", |r| r.get::<_, u32>(0)) - .unwrap(), - 2 - ); - println!("migration: rollback preserved {} bytes", before.len()); - return; - } - if std::env::var_os("ONESTORE_MIGRATION_CONFLICT").is_some() { - db.execute("INSERT INTO conflicts SELECT min(id),0 FROM edits", []) - .unwrap(); - } - let (base, working): (Vec, Vec) = db - .query_row("SELECT base,working FROM replica", [], |r| { - Ok((r.get(0)?, r.get(1)?)) - }) - .unwrap(); - let sequence: i64 = db - .query_row( - "SELECT seq FROM sqlite_sequence WHERE name='edits'", - [], - |r| r.get(0), - ) - .unwrap(); - let pending: Vec = - if version == 2 { - let mut query = db - .prepare("SELECT id,space,object,before_text,start,end,replacement FROM edits ORDER BY id") - .unwrap(); - let pending: Vec = query - .query_map([], |r| { - Ok(PendingEdit { - id: u64::try_from(r.get::<_, i64>(0)?).unwrap(), - space: r.get::<_, String>(1)?.parse().unwrap(), - operation: Operation::Text(TextEdit { - object: r.get::<_, String>(2)?.parse().unwrap(), - before: r.get(3)?, - range: r.get(4)?..r.get(5)?, - replacement: r.get(6)?, - }), - }) - }) - .unwrap() - .collect::>() - .unwrap(); - drop(query); - pending - } else { - let mut query = db - .prepare("SELECT id,space,operation FROM edits ORDER BY id") - .unwrap(); - query - .query_map([], |r| { - Ok(PendingEdit { - id: u64::try_from(r.get::<_, i64>(0)?).unwrap(), - space: r.get::<_, String>(1)?.parse().unwrap(), - operation: serde_json::from_str(&r.get::<_, String>(2)?).unwrap(), - }) - }) - .unwrap() - .collect::>() - .unwrap() - }; - let mut states = std::collections::BTreeMap::new(); - for edit in &pending { - states.insert(edit.id, EditStatus::Pending); - } - for table in ["receipts", "attempt"] { - let mut query = db - .prepare(&format!("SELECT edit_id,revision FROM {table}")) - .unwrap(); - for row in query - .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))) - .unwrap() - { - let (id, rid) = row.unwrap(); - let revision = rid.parse().unwrap(); - states.insert( - u64::try_from(id).unwrap(), - if table == "receipts" { - EditStatus::Published { revision } - } else { - EditStatus::AwaitingConfirmation { revision } - }, - ); - } - } - let mut query = db.prepare("SELECT edit_id,kind FROM conflicts").unwrap(); - for row in query - .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, u32>(1)?))) - .unwrap() - { - let (id, kind) = row.unwrap(); - assert_eq!(kind, 0); - states.insert( - u64::try_from(id).unwrap(), - EditStatus::Conflict(notebook::ConflictKind::TextChanged), - ); - } - drop(query); - drop(db); - let cache = Replica::open(&output).unwrap(); - assert_eq!(cache.snapshot().unwrap(), working); - assert_eq!(cache.remote_snapshot().unwrap(), base); - assert_eq!(cache.pending().unwrap(), pending); - for (id, status) in &states { - assert_eq!(cache.status(*id).unwrap(), Some(*status)); - } - drop(cache); - let cache = Replica::open(&output).unwrap(); - assert_eq!(cache.pending().unwrap(), pending); - let store = Store::parse(&working).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let (sid, page) = doc.pages().unwrap()[0]; - let insertion = onestore::Insertion::outline( - page, - 144.0, - 720.0, - "After cache migration", - "Migration author", - ) - .unwrap(); - let next = cache.insert(&working, sid, &insertion).unwrap().unwrap(); - assert_eq!(next, u64::try_from(sequence + 1).unwrap()); - let updated = cache.snapshot().unwrap(); - drop(cache); - let cache = Replica::open(&output).unwrap(); - assert_eq!(cache.snapshot().unwrap(), updated); - assert_eq!( - cache.pending().unwrap().last().unwrap().operation, - Operation::Insert(insertion) - ); - println!( - "migration: {}", - serde_json::json!({"pending":pending.len(),"retained_statuses":states.len(),"next_id":next,"base_bytes":base.len(),"working_bytes":working.len()}) - ); -} - #[test] fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { - use onestore::Insertion; let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("parallel.sqlite"); let source = onestore::create_section("parallel.one", "Original", "Author").unwrap(); - let store = Store::parse(&source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let (sid, page) = doc.pages().unwrap()[0]; + let (sid, anchor, _) = target(&source); let cache = Replica::create(&path, &source).unwrap(); let barrier = Barrier::new(12); let deadline = Instant::now() + Duration::from_secs(90); + let italic = |format: &mut onestore::document::Format| format.italic = Some(true); let all = std::thread::scope(|scope| { let handles: Vec<_> = (0..12) .map(|client| { let (cache, barrier) = (&cache, &barrier); scope.spawn(move || { - let outline = Insertion::outline( - page, - 72.0, - 144.0 + client as f32 * 72.0, - &format!("Client {client}"), - "Author", - ) - .unwrap(); let mut ids = Vec::new(); let mut objects = Vec::new(); barrier.wait(); for sequence in 0..4 { - let insertion = if sequence == 0 { - outline.clone() + let content = if sequence == 0 { + format!("Client {client}") } else { - Insertion::paragraph( - outline.object(), - None, - &format!("Paragraph {client}:{sequence}"), - "Author", - ) - .unwrap() - } - .with_formatting(0..6, &[onestore::TextAttribute::Italic(true)]) - .unwrap(); + format!("Paragraph {client}:{sequence}") + }; + let mut inserted = None; loop { assert!( Instant::now() < deadline, "Client {client} stopped at insertion {sequence}" ); - let snapshot = cache.snapshot().unwrap(); - match cache.insert(&snapshot, sid, &insertion) { + let result = model_ops::save(cache, anchor, |page| { + let text = if sequence == 0 { + model_ops::insert_outline( + page, + 72.0, + 144.0 + client as f32 * 72.0, + &content, + ) + .2 + } else { + model_ops::insert_after( + page, + *objects.last().unwrap(), + &content, + ) + .1 + }; + let end = content.encode_utf16().count() as u32; + model_ops::restyle(page, text, 0..end, |format| { + format.italic = None + }); + model_ops::restyle(page, text, 0..6, italic); + inserted = Some(text); + }); + match result { Ok(Some(id)) => { ids.push(id); - objects.push(insertion.text_object()); + objects.push(inserted.unwrap()); break; } Err(Error::Io(e)) if e.kind() == ErrorKind::ResourceBusy => {} @@ -630,19 +513,15 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { if sequence == 0 { continue; } + let text = *objects.last().unwrap(); loop { assert!( Instant::now() < deadline, "Client {client} stopped at text {sequence}" ); - let snapshot = cache.snapshot().unwrap(); - match cache.edit_text( - &snapshot, - sid, - insertion.text_object(), - 0..0, - "Edited ", - ) { + match model_ops::save(cache, text, |page| { + model_ops::replace_text(page, text, 0..0, "Edited ") + }) { Ok(Some(id)) => { ids.push(id); break; @@ -665,7 +544,8 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { .iter() .flat_map(|(ids, _)| ids.iter().copied()) .collect(); - assert_eq!(ids.len(), 84); + assert_eq!(all.iter().map(|(ids, _)| ids.len()).sum::(), 84); + assert_eq!(ids.len(), 1); let snapshot = cache.snapshot().unwrap(); drop(cache); let cache = Replica::open(&path).unwrap(); @@ -709,69 +589,41 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { #[test] fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { - for operation in ["Text", "Insert", "Format", "Split", "Join", "Outline"] { + for operation in ["Page", "CreatePage", "Pages", "DeletePages"] { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("unknown.sqlite"); - let mut source = onestore::create_section("unknown.one", "Original", "Author").unwrap(); - let (sid, oid, _) = target(&source); - let split = onestore::ParagraphSplit::new(oid, 3, "Author").unwrap(); - if operation == "Join" { - source = onestore::PreparedEdit::split(&source, sid, &split) - .unwrap() - .as_bytes() - .to_vec(); - } + let first = onestore::create_section("unknown.one", "Original", "Author").unwrap(); + let second = onestore::PageCreation::new(None, Some("Second"), "Author").unwrap(); + let source = onestore::PreparedEdit::create_page(&first, &second) + .unwrap() + .as_bytes() + .to_vec(); + let (_, oid, _) = target(&source); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let sid = Document::parse(&index).unwrap().pages().unwrap()[1].0; let cache = Replica::create(&path, &source).unwrap(); - if operation == "Insert" { - let store = Store::parse(&source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let (_, page) = doc.pages().unwrap()[0]; - let insertion = - onestore::Insertion::outline(page, 144.0, 144.0, "Inserted", "Author").unwrap(); - cache.insert(&source, sid, &insertion).unwrap(); - } else if operation == "Outline" { - let store = Store::parse(&source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let space = &doc.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let paragraph = *view - .nodes - .iter() - .find(|(_, node)| node.content == [oid]) + match operation { + "Page" => { + model_ops::save(&cache, oid, |page| { + model_ops::replace_text(page, oid, 0..0, "New ") + }) .unwrap() - .0; - cache - .outline( - &source, - sid, - paragraph, - onestore::OutlineEdit::Collapsed(true), - ) - .unwrap(); - } else if operation == "Text" { - cache.edit_text(&source, sid, oid, 0..0, "New ").unwrap(); - } else if operation == "Split" { - cache.split(&source, sid, &split).unwrap(); - } else if operation == "Join" { - cache - .join( - &source, - sid, - &onestore::ParagraphJoin::new(oid, split.text_object(), "Author").unwrap(), - ) - .unwrap(); - } else { - cache - .format( - &source, - sid, - oid, - 0..4, - &[onestore::TextAttribute::Bold(true)], - ) .unwrap(); + } + "CreatePage" => { + let page = onestore::PageCreation::new(None, Some("Created"), "Author").unwrap(); + cache.create_page(&source, &page).unwrap().unwrap(); + } + "Pages" => { + cache + .pages(&source, &[onestore::PageEdit::set_level(sid, 2).unwrap()]) + .unwrap() + .unwrap(); + } + _ => { + cache.delete_pages(&source, &[sid]).unwrap().unwrap(); + } } drop(cache); let db = rusqlite::Connection::open(&path).unwrap(); @@ -779,153 +631,19 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { .query_row("SELECT operation FROM edits", [], |r| r.get(0)) .unwrap(); let mut value: serde_json::Value = serde_json::from_str(&encoded).unwrap(); - value[operation]["future_option"] = true.into(); + if value[operation].is_object() { + value[operation]["future_option"] = true.into(); + } else { + value["future_option"] = true.into(); + } db.execute("UPDATE edits SET operation=?1", [value.to_string()]) .unwrap(); - if matches!(operation, "Text" | "Insert") { - db.execute_batch("ALTER TABLE attempt RENAME COLUMN revisions TO revision; DROP TABLE assets; DROP TABLE conflicts; CREATE TABLE conflicts (edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 2)) STRICT; PRAGMA user_version=3;").unwrap(); - } drop(db); let before = fs::read(&path).unwrap(); assert!( - matches!(Replica::open(&path),Err(Error::Io(error))if error.kind()==ErrorKind::InvalidData) + matches!(Replica::open(&path),Err(Error::Io(error))if error.kind()==ErrorKind::InvalidData), + "{operation}" ); assert_eq!(fs::read(&path).unwrap(), before); } } - -#[test] -fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_content_conflicts() { - for (version, ceiling) in [(5, 3), (6, 4), (7, 5), (8, 6), (9, 6)] { - use notebook::{ConflictKind, EditStatus, Recovery}; - use sha2::{Digest, Sha256}; - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("previous.sqlite"); - let source = onestore::create_section("migration.one", "Original", "Author").unwrap(); - let (sid, oid, _) = target(&source); - let cache = Replica::create(&path, &source).unwrap(); - let first = cache - .edit_text(&source, sid, oid, 0..0, "New ") - .unwrap() - .unwrap(); - let second = cache - .format( - &cache.snapshot().unwrap(), - sid, - oid, - 0..3, - &[onestore::TextAttribute::Bold(true)], - ) - .unwrap() - .unwrap(); - let queue = cache.pending().unwrap(); - let local = cache.snapshot().unwrap(); - let store = Store::parse(&local).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let revision = index.spaces[&sid].labels[&(ExGuid::default(), 1)]; - drop(cache); - let db = rusqlite::Connection::open(&path).unwrap(); - let current_version: u32 = db - .pragma_query_value(None, "user_version", |row| row.get(0)) - .unwrap(); - let asset = "00112233-4455-6677-8899-aabbccddeeff.onebin"; - let data = b"retained media"; - db.execute( - "INSERT INTO assets VALUES (?1,?2,?3)", - rusqlite::params![asset, data.as_slice(), Sha256::digest(data).as_slice()], - ) - .unwrap(); - db.execute_batch(&format!( - "ALTER TABLE attempt RENAME COLUMN revisions TO revision; - DROP TABLE conflicts; CREATE TABLE conflicts ( - edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, - kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND {ceiling})) STRICT; - PRAGMA user_version={version}; - UPDATE sqlite_sequence SET seq=1000 WHERE name='edits';" - )) - .unwrap(); - db.execute( - "INSERT INTO conflicts VALUES (?1,3)", - [i64::try_from(second).unwrap()], - ) - .unwrap(); - db.execute( - "INSERT INTO attempt VALUES (1,?1,?2)", - rusqlite::params![i64::try_from(first).unwrap(), revision.to_string()], - ) - .unwrap(); - db.execute( - "INSERT INTO receipts VALUES (999,?1)", - [revision.to_string()], - ) - .unwrap(); - drop(db); - let archive = directory.path().join("legacy-recovery.sqlite"); - std::fs::copy(&path, &archive).unwrap(); - let archive_db = rusqlite::Connection::open(&archive).unwrap(); - archive_db - .pragma_update(None, "application_id", 0x4f4e4552) - .unwrap(); - drop(archive_db); - let original_archive = std::fs::read(&archive).unwrap(); - let recovery = Recovery::open(&archive).unwrap(); - assert_eq!( - recovery.status(first).unwrap(), - Some(EditStatus::AwaitingConfirmation { revision }) - ); - assert_eq!(recovery.pending().unwrap(), queue); - drop(recovery); - assert!(std::fs::read(&archive).unwrap() == original_archive); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.pending().unwrap(), queue); - assert_eq!( - cache.cached_asset(asset, 1024).unwrap(), - Some(data.to_vec()) - ); - assert!(cache.snapshot().unwrap() == local); - assert!(cache.remote_snapshot().unwrap() == source); - assert_eq!( - cache.status(first).unwrap(), - Some(EditStatus::AwaitingConfirmation { revision }) - ); - assert_eq!( - cache.status(second).unwrap(), - Some(EditStatus::Conflict(ConflictKind::FormattingChanged)) - ); - assert_eq!( - cache.status(999).unwrap(), - Some(EditStatus::Published { revision }) - ); - let split = onestore::ParagraphSplit::new(oid, 3, "Author").unwrap(); - assert_eq!(cache.split(&local, sid, &split).unwrap(), Some(1001)); - let pending = cache.pending().unwrap(); - let archive = directory.path().join("recovery.sqlite"); - cache.export_recovery(&archive).unwrap(); - let recovery = Recovery::open(&archive).unwrap(); - assert_eq!(recovery.pending().unwrap(), pending); - assert_eq!( - recovery.cached_asset(asset, 1024).unwrap(), - Some(data.to_vec()) - ); - assert_eq!( - recovery.status(first).unwrap(), - cache.status(first).unwrap() - ); - assert_eq!(recovery.receipts().unwrap().get(&999), Some(&revision)); - drop(cache); - let db = rusqlite::Connection::open(&path).unwrap(); - assert_eq!( - db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) - .unwrap(), - current_version - ); - db.execute("INSERT INTO conflicts VALUES (1001,6)", []) - .unwrap(); - drop(db); - let cache = Replica::open(&path).unwrap(); - assert_eq!( - cache.status(1001).unwrap(), - Some(EditStatus::Conflict(ConflictKind::ContentChanged)) - ); - } -} diff --git a/crates/notebook/tests/support/model_ops.rs b/crates/notebook/tests/support/model_ops.rs new file mode 100644 index 0000000000000000000000000000000000000000..6fe9d2af3b735cbd6b39cb367f61ee4d153df414 --- /dev/null +++ b/crates/notebook/tests/support/model_ops.rs @@ -0,0 +1,245 @@ +//! Expresses the editor's actions as page-model edits saved through the replica, so tests +//! can state intents in terms of paragraphs and ranges. +#![allow(dead_code)] + +use notebook::Replica; +use onestore::{ + ExGuid, RevisionIndex, Store, + document::{Document, Format}, + page::{ + Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, + text::{Edit, new_id}, + }, +}; +use std::ops::Range; + +pub const AUTHOR: &str = "Offline author"; + +pub fn page_of(bytes: &[u8], space: ExGuid) -> Page { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + Page::from_space(&document, space).unwrap() +} + +/// The page space and page model containing the text object. +pub fn locate(bytes: &[u8], text: ExGuid) -> (ExGuid, Page) { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + document + .pages() + .unwrap() + .into_iter() + .find_map(|(space, _)| { + let page = Page::from_space(&document, space).ok()?; + paragraph_with(&page, text) + .is_some() + .then_some((space, page)) + }) + .expect("text object belongs to an active page") +} + +pub fn outlines_mut(page: &mut Page) -> Vec<&mut Outline> { + let mut out = Vec::new(); + for object in &mut page.objects { + match object { + PageObject::Outline(outline) => out.push(outline), + PageObject::Title(title) => out.extend(title.outlines.iter_mut()), + _ => {} + } + } + out +} + +pub fn paragraph_with(page: &Page, text: ExGuid) -> Option<&PageParagraph> { + page.objects.iter().find_map(|object| { + let outlines: Vec<&Outline> = match object { + PageObject::Outline(outline) => vec![outline], + PageObject::Title(title) => title.outlines.iter().collect(), + _ => Vec::new(), + }; + outlines + .into_iter() + .flat_map(|o| o.paragraphs.iter()) + .find(|p| p.text().is_some_and(|t| t.id == text)) + }) +} + +fn text_mut(page: &mut Page, text: ExGuid) -> &mut TextObject { + outlines_mut(page) + .into_iter() + .flat_map(|o| o.paragraphs.iter_mut()) + .find(|p| p.text().is_some_and(|t| t.id == text)) + .and_then(|p| p.text_mut()) + .expect("text object is on the page") +} + +/// Replaces a UTF-16 range of a text object in the model, inheriting the format at its start. +pub fn replace_text(page: &mut Page, text: ExGuid, range: Range, replacement: &str) { + let target = text_mut(page, text); + let format = target.text.format_at(range.start).unwrap().clone(); + target + .text + .apply(Edit { + range, + replacement: Paragraph::new(replacement.into(), format), + }) + .unwrap(); +} + +/// Rewrites the formats over a UTF-16 range of a text object. +pub fn restyle(page: &mut Page, text: ExGuid, range: Range, change: impl Fn(&mut Format)) { + let target = text_mut(page, text); + let slice = target.text.slice(range.clone()).unwrap(); + let mut runs = Vec::new(); + let mut from = 0; + for span in slice.spans() { + let mut format = span.format.clone(); + change(&mut format); + runs.push((slice.text()[from..span.end].to_owned(), format)); + from = span.end; + } + target + .text + .apply(Edit { + range, + replacement: Paragraph::from_runs(runs), + }) + .unwrap(); +} + +/// A plain new paragraph carrying fresh identities and the template's resolved format. +pub fn fresh_paragraph(template: &PageParagraph, text: &str) -> PageParagraph { + let mut paragraph = template.clone(); + paragraph.id = new_id().unwrap(); + paragraph.parent = None; + paragraph.level = 1; + paragraph.lists.clear(); + paragraph.tags.clear(); + paragraph.style = None; + paragraph.collapsed = false; + let format = template.text().unwrap().text.format_at(0).unwrap().clone(); + paragraph.content = ParagraphContent::Text(TextObject { + id: new_id().unwrap(), + date_field: None, + text: Paragraph::new(text.into(), format), + tags: Vec::new(), + }); + paragraph +} + +/// Inserts a plain paragraph after `after` (or first) in the outline containing it, at the +/// same level and parent, returning the new paragraph and text identities. +pub fn insert_after(page: &mut Page, after: ExGuid, text: &str) -> (ExGuid, ExGuid) { + for outline in outlines_mut(page) { + if let Some(at) = outline + .paragraphs + .iter() + .position(|p| p.text().is_some_and(|t| t.id == after)) + { + let mut fresh = fresh_paragraph(&outline.paragraphs[at], text); + fresh.parent = outline.paragraphs[at].parent; + fresh.level = outline.paragraphs[at].level; + let ids = (fresh.id, fresh.text().unwrap().id); + // Skip the anchor's descendants so the new paragraph follows its whole subtree. + let mut end = at + 1; + while end < outline.paragraphs.len() + && outline.paragraphs[end].level > outline.paragraphs[at].level + { + end += 1; + } + outline.paragraphs.insert(end, fresh); + return ids; + } + } + panic!("anchor text is on the page"); +} + +/// Adds a body outline with one plain paragraph at the given point coordinates. +pub fn insert_outline(page: &mut Page, x: f32, y: f32, text: &str) -> (ExGuid, ExGuid, ExGuid) { + let template = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => outline + .paragraphs + .iter() + .find(|p| p.text().is_some()) + .cloned(), + _ => None, + }) + .expect("a text paragraph to copy formatting from"); + let paragraph = fresh_paragraph(&template, text); + let ids = ( + new_id().unwrap(), + paragraph.id, + paragraph.text().unwrap().id, + ); + let outline = Outline { + id: ids.0, + title: false, + min_width: None, + layout: onestore::document::Layout { + x: Some(x), + y: Some(y), + ..Default::default() + }, + indents: Vec::new(), + paragraphs: vec![paragraph], + unsupported: Vec::new(), + }; + let at = page + .objects + .iter() + .position(|o| matches!(o, PageObject::Title(_))) + .unwrap_or(page.objects.len()); + page.objects.insert(at, PageObject::Outline(outline)); + ids +} + +/// Removes a paragraph and its descendants. +pub fn delete_paragraph(page: &mut Page, text: ExGuid) { + for outline in outlines_mut(page) { + if let Some(at) = outline + .paragraphs + .iter() + .position(|p| p.text().is_some_and(|t| t.id == text)) + { + let level = outline.paragraphs[at].level; + let mut end = at + 1; + while end < outline.paragraphs.len() && outline.paragraphs[end].level > level { + end += 1; + } + outline.paragraphs.drain(at..end); + return; + } + } + panic!("text is on the page"); +} + +/// Saves `edit` applied to the page containing `text`, using the replica's current image. +pub fn save( + cache: &Replica, + text: ExGuid, + edit: impl FnOnce(&mut Page), +) -> Result, notebook::Error> { + let source = cache.snapshot()?; + let (space, mut page) = locate(&source, text); + edit(&mut page); + cache.save(&source, space, &page, AUTHOR) +} + +/// Reviews a conflicting save by applying `edit` to the current remote page. +pub fn review( + cache: &Replica, + id: u64, + text: ExGuid, + edit: impl FnOnce(&mut Page), +) -> Result<(), notebook::Error> { + let local = cache.snapshot()?; + let remote = cache.remote_snapshot()?; + let (_, mut page) = locate(&remote, text); + edit(&mut page); + cache.review_page(id, &local, &remote, &page) +} diff --git a/crates/notebook/tests/support/model_schedule.rs b/crates/notebook/tests/support/model_schedule.rs new file mode 100644 index 0000000000000000000000000000000000000000..c3882c981e984ebc6a12c881ab53821d260be6af --- /dev/null +++ b/crates/notebook/tests/support/model_schedule.rs @@ -0,0 +1,371 @@ +//! A deterministic multi-actor schedule over page-model saves: several replicas edit one +//! page offline, publish through a fault-injecting remote, reopen, and review conflicts. + +use crate::model_ops::{self, AUTHOR}; +use crate::server::{Fault, Server}; +use notebook::{EditStatus, Operation, Remote, Replica}; +use onestore::{ + CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, + document::Document, + page::{Outline, Page, PageObject}, +}; +use std::{io, sync::LazyLock}; + +static SOURCE: LazyLock<(Vec, ExGuid)> = LazyLock::new(|| { + let source = + onestore::create_section("model-schedule.one", "Original 🦀 é 0", "Author").unwrap(); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let space = document.pages().unwrap()[0].0; + let mut page = Page::from_space(&document, space).unwrap(); + let mut anchor = body(&page).paragraphs[0].text().unwrap().id; + for at in 1..8 { + anchor = model_ops::insert_after(&mut page, anchor, &format!("Original 🦀 é {at}")).1; + } + let source = PreparedEdit::page(&source, space, &page, "Author") + .unwrap() + .as_bytes() + .to_vec(); + (source, space) +}); + +fn body(page: &Page) -> &Outline { + page.objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => Some(outline), + _ => None, + }) + .expect("the page keeps one body outline") +} + +/// Everything the schedule asserts about a page, free of the identities the writers mint. +fn shape(page: &Page) -> Vec { + let outline = body(page); + let mut out = vec![format!("{:?}", outline.layout)]; + out.extend(outline.paragraphs.iter().map(|paragraph| { + format!( + "{} {} {} {:?}", + paragraph.level, + u8::from(paragraph.collapsed), + u8::from(paragraph.parent.is_some()), + paragraph.text().map(|text| text.text.text()), + ) + })); + out +} + +/// Paragraph and text identities of the body outline with their text, in model order. +fn rows(bytes: &[u8]) -> Vec<(ExGuid, ExGuid, String)> { + let page = model_ops::page_of(bytes, SOURCE.1); + body(&page) + .paragraphs + .iter() + .filter_map(|paragraph| { + let text = paragraph.text()?; + Some((paragraph.id, text.id, text.text.text().to_owned())) + }) + .collect() +} + +/// Moves a paragraph and its descendants immediately before `anchor`, or last among +/// `parent`'s children; `anchor` supplies its own parent and level. +pub(crate) fn move_subtree( + page: &mut Page, + paragraph: ExGuid, + parent: Option, + anchor: Option, +) { + for outline in model_ops::outlines_mut(page) { + let Some(at) = outline.paragraphs.iter().position(|p| p.id == paragraph) else { + continue; + }; + let level = outline.paragraphs[at].level; + let mut end = at + 1; + while end < outline.paragraphs.len() && outline.paragraphs[end].level > level { + end += 1; + } + if parent + .into_iter() + .chain(anchor) + .any(|id| outline.paragraphs[at..end].iter().any(|p| p.id == id)) + { + return; + } + let mut subtree: Vec<_> = outline.paragraphs.drain(at..end).collect(); + let (destination, parent, depth) = match anchor { + Some(anchor) => { + let Some(at) = outline.paragraphs.iter().position(|p| p.id == anchor) else { + return; + }; + let target = &outline.paragraphs[at]; + (at, target.parent, target.level) + } + None => match parent { + Some(parent) => { + let Some(at) = outline.paragraphs.iter().position(|p| p.id == parent) else { + return; + }; + let level = outline.paragraphs[at].level; + let mut end = at + 1; + while end < outline.paragraphs.len() && outline.paragraphs[end].level > level { + end += 1; + } + (end, Some(parent), level + 1) + } + None => (outline.paragraphs.len(), None, 1), + }, + }; + subtree[0].parent = parent; + for descendant in &mut subtree { + descendant.level = descendant.level + depth - level; + } + outline.paragraphs.splice(destination..destination, subtree); + return; + } +} + +/// Asserts the publication invariants that survive the page model, then delegates. +struct Session<'a> { + server: &'a mut Server, + intent: Option<(u64, Operation)>, + /// The intent was already attempted, so its publication must never be repeated. + retired: bool, + publications: usize, +} + +impl Remote for Session<'_> { + fn read(&mut self) -> io::Result> { + self.server.read() + } + + fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + assert!(!self.retired, "a retired attempt was replayed"); + self.publications += 1; + assert_eq!(self.publications, 1); + let Some((_, Operation::Page(intent))) = &self.intent else { + panic!("the schedule queues page saves only") + }; + if model_ops::page_of(&self.server.visible, SOURCE.1) == intent.before { + assert_eq!( + shape(&model_ops::page_of(edit.as_bytes(), SOURCE.1)), + shape(&intent.after) + ); + } + self.server.publish(edit) + } + + fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { + self.server.confirm(snapshot) + } +} + +fn statuses(cache: &Replica) -> Vec<(u64, Option)> { + cache + .pending() + .unwrap() + .iter() + .map(|edit| (edit.id, cache.status(edit.id).unwrap())) + .collect() +} + +/// Fails unless the durable remote holds `revision` in the edited page's space. +fn durable(server: &Server, revision: ExGuid) { + let store = Store::parse(&server.durable).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + assert!(index.spaces[&SOURCE.1].revisions.contains_key(&revision)); +} + +pub fn run(input: &[u8]) { + let (source, space) = &*SOURCE; + let directory = tempfile::tempdir().unwrap(); + let mut replicas: [Option; 12] = std::array::from_fn(|_| None); + let mut server = Server::new(source); + for step in input.chunks_exact(8).take(48) { + let actor = usize::from(step[0]) % replicas.len(); + let path = directory.path().join(format!("{actor}.sqlite")); + let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, source).unwrap()); + let snapshot = cache.snapshot().unwrap(); + let pending = cache.pending().unwrap(); + let local = rows(&snapshot); + match step[1] % 8 { + 0..=2 => { + let row = local[usize::from(step[2]) % local.len()].clone(); + let other = local[usize::from(step[4]) % local.len()].clone(); + let change: Box = match step[3] % 6 { + 0 if !row.2.is_empty() => Box::new(move |page| { + model_ops::replace_text( + page, + row.1, + 0..1, + &char::from(b'A' + step[5] % 26).to_string(), + ); + }), + 1 => Box::new(move |page| { + model_ops::insert_after(page, row.1, "Inserted 🦋 é"); + }), + 2 => Box::new(move |page| { + move_subtree(page, row.0, (step[5] & 1 != 0).then_some(other.0), None); + }), + 3 => Box::new(move |page| model_ops::delete_paragraph(page, row.1)), + 4 => Box::new(move |page| { + let outline = model_ops::outlines_mut(page).swap_remove(0); + if step[5] & 1 == 0 { + outline.layout.x = Some(f32::from(step[5]) + 36.0); + outline.layout.y = Some(f32::from(step[6]) + 36.0); + } else { + outline.layout.max_width = Some(f32::from(step[6]) + 72.0); + outline.layout.width_set_by_user = Some(step[6] & 2 == 0); + } + }), + 5 if !row.2.is_empty() => Box::new(move |page| { + model_ops::restyle(page, row.1, 0..1, |format| { + format.font_size = Some(f32::from(step[5] % 20) + 12.0); + }); + }), + _ => Box::new(|_| {}), + }; + let mut after = model_ops::page_of(&snapshot, *space); + change(&mut after); + match cache.save(&snapshot, *space, &after, AUTHOR) { + Ok(id) => { + let next = cache.pending().unwrap(); + let ids = |edits: &[notebook::PendingEdit]| -> Vec { + edits.iter().map(|edit| edit.id).collect() + }; + assert_eq!(ids(&next)[..pending.len()], ids(&pending)[..]); + assert!(next.len() <= pending.len() + 1); + assert_eq!(id.is_some(), cache.snapshot().unwrap() != snapshot); + assert_eq!( + shape(&model_ops::page_of(&cache.snapshot().unwrap(), *space)), + shape(&after) + ); + } + Err(_) => { + assert_eq!(cache.snapshot().unwrap(), snapshot); + assert_eq!(cache.pending().unwrap(), pending); + } + } + } + 3 | 4 => { + server.fault = match step[2] % 8 { + 0 => Fault::Before, + 1 => Fault::UnknownBefore, + 2 => Fault::UnknownAfter, + 3 => Fault::Committed, + 4 => Fault::Confirm, + 5 => Fault::ConfirmCommitted, + _ => Fault::None, + }; + let head = pending.first().map(|edit| edit.id); + let result = { + let mut session = Session { + retired: head.is_some_and(|id| { + matches!( + cache.status(id).unwrap(), + Some(EditStatus::AwaitingConfirmation { .. }) + ) + }), + intent: pending + .first() + .map(|edit| (edit.id, edit.operation.clone())), + server: &mut server, + publications: 0, + }; + cache.sync_once(&mut session) + }; + server.fault = Fault::None; + let next = cache.pending().unwrap(); + if next.len() == pending.len() { + assert_eq!(next, pending); + if !next.is_empty() { + assert_eq!(cache.snapshot().unwrap(), snapshot); + } + } else { + assert_eq!(next, pending[1..]); + let Some(EditStatus::Published { revision }) = + cache.status(head.unwrap()).unwrap() + else { + panic!("an intent leaves the queue only with its receipt") + }; + durable(&server, revision); + } + if next.is_empty() && result.is_ok() { + assert_eq!(cache.snapshot().unwrap(), cache.remote_snapshot().unwrap()); + } + } + 5 => { + let remote = rows(&server.visible); + let row = remote[usize::from(step[2]) % remote.len()].clone(); + if !row.2.is_empty() { + let mut page = model_ops::page_of(&server.visible, *space); + model_ops::replace_text( + &mut page, + row.1, + 0..1, + &char::from(b'a' + step[3] % 26).to_string(), + ); + let visible = server.visible.clone(); + PreparedEdit::page(&visible, *space, &page, "Remote") + .unwrap() + .commit(&mut server) + .unwrap(); + } + } + 6 => { + if let Some(head) = pending.first() + && matches!( + cache.status(head.id).unwrap(), + Some(EditStatus::Conflict(_)) + ) + { + let remote = cache.remote_snapshot().unwrap(); + let published = rows(&remote); + let row = published[usize::from(step[2]) % published.len()].clone(); + let mut after = model_ops::page_of(&remote, *space); + if !row.2.is_empty() { + model_ops::replace_text(&mut after, row.1, 0..1, "R"); + } + if cache + .review_page(head.id, &snapshot, &remote, &after) + .is_ok() + { + assert_eq!(cache.status(head.id).unwrap(), Some(EditStatus::Pending)); + let (result, publications) = { + let mut session = Session { + intent: Some(( + head.id, + cache.pending().unwrap()[0].operation.clone(), + )), + retired: false, + server: &mut server, + publications: 0, + }; + (cache.sync_once(&mut session), session.publications) + }; + assert!(publications <= 1); + if let Ok(Some((id, EditStatus::Published { revision }))) = result { + assert_eq!(id, head.id); + durable(&server, revision); + assert!(cache.pending().unwrap().iter().all(|e| e.id != id)); + } + } else { + assert_eq!(cache.pending().unwrap(), pending); + } + } + } + _ => { + let recorded = statuses(cache); + replicas[actor] = None; + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.snapshot().unwrap(), snapshot); + assert_eq!(cache.pending().unwrap(), pending); + assert_eq!(statuses(&cache), recorded); + replicas[actor] = Some(cache); + } + } + rows(&server.durable); + rows(&replicas[actor].as_ref().unwrap().snapshot().unwrap()); + } +} diff --git a/crates/notebook/tests/support/page_schedule.rs b/crates/notebook/tests/support/page_schedule.rs index af00a40b0cf1d0b5bf3cc9865783fcd16c290cdb..3f99ee2183ab330936986fff20ffd8875e4c8231 100644 --- a/crates/notebook/tests/support/page_schedule.rs +++ b/crates/notebook/tests/support/page_schedule.rs @@ -1,11 +1,17 @@ use crate::disk; use notebook::{EditStatus, Operation, Remote, Replica}; use onestore::{ - CommitError, ExGuid, Insertion, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store, - document::{Document, Kind}, + CommitError, ExGuid, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store, + document::{Document, Format, Kind, Layout}, + page::{ + Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, + text::new_id, + }, }; use std::{io, sync::LazyLock}; +const BODY: &str = "Body 🦋 é"; + #[path = "../../../onestore/tests/support/current.rs"] mod current; @@ -41,6 +47,73 @@ fn pages(source: &[u8]) -> Vec<(ExGuid, ExGuid, u32)> { .collect() } +/// Adds a body outline holding one plain paragraph, returning its text identity. A page the +/// section just created has no body text for `model_ops::insert_outline` to copy formatting from. +pub fn body_outline(page: &mut Page, text: &str) -> ExGuid { + // The writer creates outline text with the store's default style; the model must state it. + let format = Format { + font: Some("Calibri".to_owned()), + font_size: Some(11.0), + language: Some(0x409), + ..Default::default() + }; + let content = TextObject { + id: new_id().unwrap(), + date_field: None, + text: Paragraph::new(text.into(), format), + tags: Vec::new(), + }; + let id = content.id; + let outline = Outline { + id: new_id().unwrap(), + title: false, + min_width: None, + layout: Layout { + x: Some(36.0), + y: Some(36.0), + ..Default::default() + }, + indents: Vec::new(), + paragraphs: vec![PageParagraph { + id: new_id().unwrap(), + parent: None, + level: 1, + style: None, + format: Default::default(), + content: ParagraphContent::Text(content), + lists: Vec::new(), + tags: Vec::new(), + collapsed: false, + }], + unsupported: Vec::new(), + }; + let at = page + .objects + .iter() + .position(|object| matches!(object, PageObject::Title(_))) + .unwrap_or(page.objects.len()); + page.objects.insert(at, PageObject::Outline(outline)); + id +} + +/// The text identity of the first body outline's first paragraph. +fn body_text(page: &Page) -> ExGuid { + page.objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => outline.paragraphs[0].text().map(|text| text.id), + _ => None, + }) + .unwrap() +} + +fn page_of(source: &[u8], space: ExGuid) -> Page { + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + Page::from_space(&document, space).unwrap() +} + struct Session<'a> { disk: &'a mut disk::Disk, operation: Option<&'a Operation>, @@ -68,7 +141,8 @@ impl Remote for Session<'_> { } } } - Operation::Insert(insertion) => { + Operation::Page(intent) => { + let body = body_text(&intent.after); let store = Store::parse(edit.as_bytes()).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); @@ -77,13 +151,11 @@ impl Remote for Session<'_> { .values() .filter_map(|space| { let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - view.nodes.get(&insertion.text_object()) + view.nodes.get(&body) }) .collect(); assert_eq!(matching.len(), 1); - assert!( - matches!(&matching[0].kind, Kind::RichText { text, .. } if text == "Body 🦋 é") - ); + assert!(matches!(&matching[0].kind, Kind::RichText { text, .. } if text == BODY)); } _ => panic!(), } @@ -129,11 +201,11 @@ pub fn run(input: &[u8]) { cache.create_page(&snapshot, &page).unwrap().unwrap(); owned[actor].push((page.space(), page.object())); if step[1] % 8 == 1 { - let insertion = - Insertion::outline(page.object(), 36.0, 36.0, "Body 🦋 é", "Author") - .unwrap(); + let source = cache.snapshot().unwrap(); + let mut model = page_of(&source, page.space()); + body_outline(&mut model, BODY); cache - .insert(&cache.snapshot().unwrap(), page.space(), &insertion) + .save(&source, page.space(), &model, "Author") .unwrap() .unwrap(); } diff --git a/crates/notebook/tests/support/server.rs b/crates/notebook/tests/support/server.rs new file mode 100644 index 0000000000000000000000000000000000000000..195ba2520618e68b0e9fa14abaa43be99b327876 --- /dev/null +++ b/crates/notebook/tests/support/server.rs @@ -0,0 +1,132 @@ +//! A fault-injecting in-memory remote for reconciliation tests. +#![allow(dead_code)] + +use notebook::Remote; +use onestore::{ + CommitError, CommitIo, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store, + document::{Document, Kind}, +}; +use std::io; + +#[derive(Clone, Copy, Default)] +pub enum Fault { + #[default] + None, + Before, + UnknownBefore, + UnknownAfter, + Committed, + PanicBefore, + PanicAfter, + Confirm, + ConfirmCommitted, +} + +pub struct Server { + pub visible: Vec, + pub durable: Vec, + pub fault: Fault, + pub publications: usize, + pub confirmations: usize, +} + +impl Server { + pub fn new(source: &[u8]) -> Self { + Self { + visible: source.to_vec(), + durable: source.to_vec(), + fault: Fault::None, + publications: 0, + confirmations: 0, + } + } +} + +pub fn failure(state: CommitState) -> CommitError { + CommitError { + state, + error: io::Error::from(io::ErrorKind::ConnectionAborted), + } +} + +impl CommitIo for Server { + fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result { + let offset = usize::try_from(offset).unwrap(); + let size = output.len().min(self.visible.len().saturating_sub(offset)); + if size > 0 { + output[..size].copy_from_slice(&self.visible[offset..offset + size]); + } + Ok(size) + } + fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result { + let offset = usize::try_from(offset).unwrap(); + self.visible + .resize(self.visible.len().max(offset + bytes.len()), 0); + self.visible[offset..offset + bytes.len()].copy_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + self.durable.clone_from(&self.visible); + Ok(()) + } +} + +impl Remote for Server { + fn read(&mut self) -> io::Result> { + Ok(self.visible.clone()) + } + fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + self.publications += 1; + let fault = std::mem::take(&mut self.fault); + match fault { + Fault::Before => return Err(failure(CommitState::NotCommitted)), + Fault::UnknownBefore => return Err(failure(CommitState::Unknown)), + Fault::PanicBefore => panic!("Terminated before remote I/O"), + _ => {} + } + let old = self.durable.clone(); + edit.commit(self)?; + match fault { + Fault::UnknownAfter => { + self.durable = old; + Err(failure(CommitState::Unknown)) + } + Fault::Committed => Err(failure(CommitState::Committed)), + Fault::PanicAfter => panic!("Terminated after remote publication"), + _ => Ok(()), + } + } + fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { + self.confirmations += 1; + if matches!(self.fault, Fault::Confirm) { + self.fault = Fault::None; + return Err(failure(CommitState::Unknown)); + } + onestore::confirm_snapshot(self, snapshot)?; + if matches!(self.fault, Fault::ConfirmCommitted) { + self.fault = Fault::None; + return Err(failure(CommitState::Committed)); + } + Ok(()) + } +} + +pub fn text(source: &[u8]) -> (ExGuid, ExGuid, String) { + let store = Store::parse(source).unwrap(); + assert!(store.checksum_mismatches.is_empty()); + let index = RevisionIndex::parse(&store).unwrap(); + index.validate_current().unwrap(); + let doc = Document::parse(&index).unwrap(); + doc.spaces + .iter() + .find_map(|(sid, space)| { + space.revisions[&space.contexts[&ExGuid::default()]] + .nodes + .iter() + .find_map(|(oid, node)| match &node.kind { + Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())), + _ => None, + }) + }) + .unwrap() +} diff --git a/crates/notebook/tests/support/tree_schedule.rs b/crates/notebook/tests/support/tree_schedule.rs deleted file mode 100644 index 170b19af59783786ee119301b7900e869d7536b4..0000000000000000000000000000000000000000 --- a/crates/notebook/tests/support/tree_schedule.rs +++ /dev/null @@ -1,271 +0,0 @@ -use crate::disk; -use notebook::{EditStatus, Operation, Remote, Replica}; -use onestore::{ - CommitError, ExGuid, Insertion, PreparedEdit, RevisionIndex, Store, TreeEdit, - document::{Document, Kind}, -}; -use std::{io, sync::LazyLock}; - -static SOURCE: LazyLock<(Vec, ExGuid, ExGuid)> = LazyLock::new(|| { - let mut source = - onestore::create_section("tree-schedule.one", "Original 🦀 é 0", "Author").unwrap(); - let store = Store::parse(&source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let (sid, page) = document.pages().unwrap()[0]; - let space = &document.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let outline = *view.nodes[&page] - .children - .iter() - .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) - .unwrap(); - for at in 1..12 { - let insert = - Insertion::paragraph(outline, None, &format!("Original 🦀 é {at}"), "Author").unwrap(); - source = PreparedEdit::insert(&source, sid, &insert) - .unwrap() - .as_bytes() - .to_vec(); - } - (source, sid, outline) -}); - -fn rows(source: &[u8]) -> Vec<(ExGuid, ExGuid, String)> { - let (_, sid, outline) = &*SOURCE; - let store = Store::parse(source).unwrap(); - assert!(store.checksum_mismatches.is_empty()); - let index = RevisionIndex::parse(&store).unwrap(); - index.validate_current().unwrap(); - let document = Document::parse(&index).unwrap(); - let space = &document.spaces[sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let (_, page) = document - .pages() - .unwrap() - .into_iter() - .find(|(space, _)| space == sid) - .unwrap(); - if !view.nodes[&page].children.contains(outline) { - return Vec::new(); - } - let node = &view.nodes[outline]; - let mut rows = Vec::new(); - for paragraph in &node.children { - let node = &view.nodes[paragraph]; - assert!(node.children.is_empty()); - let [text] = node.content.as_slice() else { - panic!() - }; - let Kind::RichText { text: value, .. } = &view.nodes[text].kind else { - panic!() - }; - rows.push((*paragraph, *text, value.clone())); - } - rows -} - -struct Session<'a> { - disk: &'a mut disk::Disk, - operation: Option<&'a Operation>, -} - -impl Remote for Session<'_> { - fn read(&mut self) -> io::Result> { - Ok(self.disk.visible.clone()) - } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { - let before = rows(&self.disk.visible); - let mut expected = before.clone(); - match self.operation.unwrap() { - Operation::Tree(edit) => { - let at = expected - .iter() - .position(|(id, _, _)| *id == edit.intent.object()) - .unwrap(); - let row = expected.remove(at); - if let Some((parent, anchor)) = edit.intent.destination() { - assert_eq!(parent, SOURCE.2); - let at = anchor - .map(|anchor| { - expected - .iter() - .position(|(id, _, _)| *id == anchor) - .unwrap() - }) - .unwrap_or(expected.len()); - expected.insert(at, row); - } - } - Operation::Text(edit) => { - assert_eq!(edit.range, 0..1); - let (_, _, text) = expected - .iter_mut() - .find(|(_, id, _)| *id == edit.object) - .unwrap(); - text.replace_range(0..1, &edit.replacement); - } - Operation::Format(format) => { - let [onestore::TextAttribute::FontSize(size)] = format.attributes.as_slice() else { - panic!() - }; - let store = Store::parse(edit.as_bytes()).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let space = &document.spaces[&SOURCE.1]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - assert_eq!( - view.text_runs(format.object).unwrap()[0].format.font_size, - Some(*size) - ); - } - _ => panic!(), - } - assert_eq!(rows(edit.as_bytes()), expected); - let result = edit.commit(self.disk); - let durable = rows(&self.disk.durable); - assert!(durable == before || durable == expected); - if result.is_ok() { - assert_eq!(durable, expected); - } - result - } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - onestore::confirm_snapshot(self.disk, snapshot) - } -} - -pub fn run(input: &[u8]) { - let (source, sid, outline) = &*SOURCE; - let directory = tempfile::tempdir().unwrap(); - let mut replicas: [Option; 12] = std::array::from_fn(|_| None); - let mut disk = disk::Disk { - visible: source.clone(), - durable: source.clone(), - operation: 0, - fail_at: None, - write_limit: 4096, - random: 1, - }; - for step in input.chunks_exact(8).take(48) { - let actor = usize::from(step[0]) % replicas.len(); - let path = directory.path().join(format!("{actor}.sqlite")); - let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, source).unwrap()); - let snapshot = cache.snapshot().unwrap(); - let pending = cache.pending().unwrap(); - let local = rows(&snapshot); - match step[1] % 8 { - 0..=2 if !local.is_empty() => { - let target = usize::from(step[2]) % local.len(); - let id = match step[1] % 8 { - 0 => { - let before = - (step[3] & 1 == 0).then(|| local[usize::from(step[4]) % local.len()].0); - cache - .tree( - &snapshot, - *sid, - &TreeEdit::move_to(local[target].0, *outline, before, "Offline") - .unwrap(), - ) - .unwrap() - } - 1 => cache - .tree( - &snapshot, - *sid, - &TreeEdit::delete(local[target].0, "Offline").unwrap(), - ) - .unwrap(), - _ if step[3] & 1 == 0 => cache - .edit_text( - &snapshot, - *sid, - local[target].1, - 0..1, - &char::from(b'A' + step[3] % 26).to_string(), - ) - .unwrap(), - _ => cache - .format( - &snapshot, - *sid, - local[target].1, - 0..1, - &[onestore::TextAttribute::FontSize( - 12.0 + f32::from(step[3] % 20), - )], - ) - .unwrap(), - }; - let next = cache.pending().unwrap(); - assert_eq!(next[..pending.len()], pending); - assert_eq!(next.len(), pending.len() + usize::from(id.is_some())); - } - 3 | 4 => { - disk.operation = 0; - disk.fail_at = (step[4] != 0) - .then_some(usize::from(u16::from_le_bytes([step[4], step[5]])) % 2048 + 1); - disk.write_limit = if step[6] & 1 == 0 { 17 } else { 4096 }; - disk.random = u64::from(step[7]) + 1; - let _ = cache.sync_once(&mut Session { - disk: &mut disk, - operation: pending.first().map(|p| &p.operation), - }); - let next = cache.pending().unwrap(); - if next.len() == pending.len() { - assert_eq!(next, pending); - } else { - assert_eq!(next, pending[1..]); - let Some(EditStatus::Published { revision }) = - cache.status(pending[0].id).unwrap() - else { - panic!() - }; - let store = Store::parse(&disk.durable).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - assert!(index.spaces[sid].revisions.contains_key(&revision)); - } - if !next.is_empty() { - assert!(cache.snapshot().unwrap() == snapshot); - } - disk.visible.clone_from(&disk.durable); - } - 5 => { - let mut expected = rows(&disk.visible); - if !expected.is_empty() { - let target = usize::from(step[2]) % expected.len(); - let (_, text, content) = &mut expected[target]; - let at = u32::try_from(content.encode_utf16().count()).unwrap(); - content.push('R'); - let snapshot = disk.visible.clone(); - let edit = PreparedEdit::text(&snapshot, *sid, *text, at..at, "R").unwrap(); - disk.fail_at = None; - edit.commit(&mut disk).unwrap(); - assert_eq!(rows(&disk.durable), expected); - } - } - _ => { - if step[1] % 8 == 6 - && let Some(first) = pending.first() - && matches!(first.operation, Operation::Tree(_)) - && matches!( - cache.status(first.id).unwrap(), - Some(EditStatus::Conflict(_)) - ) - { - let remote = cache.remote_snapshot().unwrap(); - let _ = cache.rebase_tree_conflict(first.id, &snapshot, &remote); - } - let pending = cache.pending().unwrap(); - drop(replicas[actor].take()); - let cache = Replica::open(&path).unwrap(); - assert!(cache.snapshot().unwrap() == snapshot); - assert_eq!(cache.pending().unwrap(), pending); - replicas[actor] = Some(cache); - } - } - rows(&disk.durable); - rows(&replicas[actor].as_ref().unwrap().snapshot().unwrap()); - } -} diff --git a/crates/notebook/tests/sync.rs b/crates/notebook/tests/sync.rs index 0eb1799a2c23aa639cf28506d5cdfc2e72567df9..2549340e4f8052529011cb71b350316ed0963fcf 100644 --- a/crates/notebook/tests/sync.rs +++ b/crates/notebook/tests/sync.rs @@ -1,1142 +1,44 @@ +//! Replica protocol: durable receipts, retired attempts, conflict review and contention. + use notebook::{ConflictKind, EditStatus, Error, Remote, Replica}; -use onestore::{ - CommitError, CommitIo, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store, - document::{Document, Kind}, -}; -use std::io; - -#[path = "../../onestore/tests/support/disk.rs"] -mod disk; -#[path = "sync/model.rs"] -mod model; -#[path = "sync/outline.rs"] -mod outline; -#[path = "sync/page.rs"] -mod page; -#[path = "support/page_schedule.rs"] -mod page_schedule; -#[path = "sync/pages.rs"] -mod pages; -#[path = "sync/tree.rs"] -mod tree; -#[path = "support/tree_schedule.rs"] -mod tree_schedule; - -mod paragraph { - use super::*; - use onestore::{Insertion, ParagraphJoin, ParagraphSplit, TextAttribute}; - - fn fixture() -> (Vec, ExGuid, ExGuid, ExGuid) { - let source = onestore::create_section("paragraph.one", "ab🦀cd", "Fixture").unwrap(); - let (sid, left, _) = text(&source); - let store = Store::parse(&source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let space = &doc.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let parent = *view - .nodes - .iter() - .find(|(_, node)| node.content == [left]) - .unwrap() - .0; - (source, sid, left, parent) - } - - fn native_reconciliation(keyboard: bool) -> (Vec, Vec) { - let source = include_bytes!( - "../../../corpus/paragraph-edit/reconciliation/before/notebook/synthetic.one" - ); - let native: &[u8] = if keyboard { - include_bytes!( - "../../../corpus/paragraph-edit/reconciliation/keyboard/notebook/synthetic.one" - ) - } else { - include_bytes!( - "../../../corpus/paragraph-edit/reconciliation/remote/notebook/synthetic.one" - ) - }; - let store = Store::parse(source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let mut server = Server::new(native); - let mut recorded = Vec::new(); - let mut counts = [0; 3]; - for (sid, page) in document.pages().unwrap() { - let space = &document.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let Kind::Metadata { - title: Some(name), .. - } = &view.nodes[&view.roots[&2]].kind - else { - continue; - }; - if !name.starts_with("Split ") && !name.starts_with("Join ") { - continue; - } - let outline = view.nodes[&page] - .children - .iter() - .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) - .unwrap(); - let children = &view.nodes[outline].children; - let left = view.nodes[&children[0]].content[0]; - let split = name.starts_with("Split "); - let adoption = name == "Join empty adoption"; - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("native-reconciliation.sqlite"); - let mut cache = Replica::create(&path, source).unwrap(); - let (id, target, intent) = if split { - let intent = ParagraphSplit::new(left, 2, "Offline author").unwrap(); - ( - cache.split(source, sid, &intent).unwrap().unwrap(), - intent.text_object(), - serde_json::to_value(intent).unwrap(), - ) - } else { - let right = view.nodes[&children[1]].content[0]; - let intent = ParagraphJoin::new(left, right, "Offline author").unwrap(); - ( - cache.join(source, sid, &intent).unwrap().unwrap(), - if adoption { right } else { left }, - serde_json::to_value(intent).unwrap(), - ) - }; - drop(cache); - cache = Replica::open(&path).unwrap(); - let at = if split { - 2 - } else if adoption { - 1 - } else { - 4 - }; - let dependent = cache - .edit_text( - &cache.snapshot().unwrap(), - sid, - target, - at..at + 1, - if adoption { "I" } else { "C" }, - ) - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - let pending = cache.pending().unwrap(); - drop(cache); - cache = Replica::open(&path).unwrap(); - assert_eq!(cache.snapshot().unwrap(), local); - assert_eq!(cache.pending().unwrap(), pending); - let previous = server.publications; - let remote = server.visible.clone(); - let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); - assert_eq!(actual, id); - assert_eq!(cache.snapshot().unwrap(), local); - if !keyboard { - counts[2] += 1; - assert_eq!( - status, - EditStatus::Conflict(ConflictKind::TargetUnavailable), - "{name}" - ); - assert_eq!(server.publications, previous); - assert_eq!(server.visible, remote); - drop(cache); - cache = Replica::open(&path).unwrap(); - assert_eq!(cache.status(id).unwrap(), Some(status)); - assert_eq!(cache.snapshot().unwrap(), local); - assert_eq!(cache.pending().unwrap(), pending); - continue; - } - let automatic = ["prefix", "format", "sibling"] - .iter() - .any(|suffix| name.ends_with(suffix)); - if automatic { - counts[0] += 1; - assert!( - matches!(status, EditStatus::Published { .. }), - "{name}: {status:?}" - ); - } else { - let expected = if name.ends_with("boundary") || adoption { - ConflictKind::TextChanged - } else { - ConflictKind::StructureChanged - }; - assert_eq!(status, EditStatus::Conflict(expected), "{name}"); - assert_eq!(server.publications, previous); - assert_eq!(server.visible, remote); - assert_eq!(cache.pending().unwrap(), pending); - drop(cache); - cache = Replica::open(&path).unwrap(); - assert_eq!(cache.status(id).unwrap(), Some(status)); - assert_eq!(cache.snapshot().unwrap(), local); - assert_eq!(cache.pending().unwrap(), pending); - if adoption || name == "Join remote list" { - counts[2] += 1; - assert!(cache.rebase_join_conflict(id, &local, &remote).is_err()); - assert_eq!(cache.pending().unwrap(), pending); - assert_eq!(cache.snapshot().unwrap(), local); - assert_eq!(cache.status(id).unwrap(), Some(status)); - recorded.push(serde_json::json!({"case": name, "space": sid, "intent": intent, "outcome": "retained_conflict"})); - continue; - } - counts[1] += 1; - if split { - let offset = if name.ends_with("boundary") { 3 } else { 2 }; - cache - .rebase_conflict(id, &local, &remote, offset..offset) - .unwrap(); - let notebook::Operation::Split(edit) = &cache.pending().unwrap()[0].operation - else { - panic!() - }; - assert_eq!( - edit.intent, - serde_json::from_value::(intent.clone()) - .unwrap() - .reposition(offset) - ); - } else { - cache.rebase_join_conflict(id, &local, &remote).unwrap(); - } - drop(cache); - cache = Replica::open(&path).unwrap(); - let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); - assert_eq!(actual, id); - assert!( - matches!(status, EditStatus::Published { .. }), - "{name}: {status:?}" - ); - } - drop(cache); - cache = Replica::open(&path).unwrap(); - let first_receipt = cache.status(id).unwrap().unwrap(); - let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); - assert_eq!(actual, dependent); - assert!( - matches!(status, EditStatus::Published { .. }), - "{name} dependent: {status:?}" - ); - assert_eq!(server.publications, previous + 2); - drop(cache); - cache = Replica::open(&path).unwrap(); - assert_eq!(cache.status(id).unwrap(), Some(first_receipt)); - assert_eq!(cache.status(dependent).unwrap(), Some(status)); - assert!(cache.pending().unwrap().is_empty()); - assert_eq!(cache.snapshot().unwrap(), server.durable); - let EditStatus::Published { revision } = first_receipt else { - panic!() - }; - let EditStatus::Published { - revision: dependent_revision, - } = status - else { - panic!() - }; - recorded.push(serde_json::json!({"case": name, "space": sid, "intent": intent, - "outcome": if automatic { "automatic" } else { "reviewed" }, "receipt": revision, "dependent_receipt": dependent_revision})); - } - assert_eq!(counts, if keyboard { [6, 8, 2] } else { [0, 0, 16] }); - assert_eq!(server.publications, if keyboard { 28 } else { 0 }); - (server.durable, recorded) - } - - #[test] - fn native_com_replacement_preserves_every_local_paragraph_branch() { - native_reconciliation(false); - } - - #[test] - fn native_changes_commute_or_retain_reviewable_paragraph_dependencies() { - native_reconciliation(true); - } - - #[test] - #[ignore = "exports native-controlled reconciliation for cold OneNote validation"] - fn export_native_reconciliation() { - let output = std::path::PathBuf::from( - std::env::var_os("ONESTORE_NATIVE_RECONCILIATION_OUTPUT").unwrap(), - ); - assert!(output.is_absolute()); - std::fs::create_dir(&output).unwrap(); - let (bytes, cases) = native_reconciliation(true); - std::fs::create_dir(output.join("candidate")).unwrap(); - std::fs::write(output.join("candidate/synthetic.one"), bytes).unwrap(); - std::fs::write( - output.join("manifest.json"), - serde_json::to_vec_pretty(&cases).unwrap(), - ) - .unwrap(); - } - - #[test] - fn native_splits_retain_independent_local_identities_and_changed_boundaries() { - let source = include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one"); - let native = include_bytes!("../../../corpus/paragraph-edit/split/notebook/synthetic.one"); - let manifest: serde_json::Value = - serde_json::from_str(include_str!("../../../corpus/paragraph-edit/manifest.json")) - .unwrap(); - let store = Store::parse(source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let mut accepted = 0; - let mut conflicts = 0; - for case in manifest["cases"].as_array().unwrap() { - if case["case"] == "Split before hyperlink" { - continue; - } - let left: ExGuid = serde_json::from_value(case["original_text"].clone()).unwrap(); - let native_right: ExGuid = serde_json::from_value(case["new_text"].clone()).unwrap(); - let (sid, _) = document - .pages() - .unwrap() - .into_iter() - .find(|(sid, _)| { - let space = &document.spaces[sid]; - space.revisions[&space.contexts[&ExGuid::default()]] - .nodes - .contains_key(&left) - }) - .unwrap(); - let split = ParagraphSplit::new( - left, - u32::try_from(case["offset_utf16"].as_u64().unwrap()).unwrap(), - "Offline author", - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("native-split.sqlite"); - let mut cache = Replica::create(&path, source).unwrap(); - let id = cache.split(source, sid, &split).unwrap().unwrap(); - let current = cache.snapshot().unwrap(); - let dependent = cache - .edit_text( - ¤t, - sid, - split.text_object(), - 0..0, - "Local dependent edit: ", - ) - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - let pending = cache.pending().unwrap(); - let mut server = Server::new(native); - let (published, status) = cache.sync_once(&mut server).unwrap().unwrap(); - assert_eq!(published, id); - assert_eq!(cache.snapshot().unwrap(), local); - if matches!(case["case"].as_str().unwrap(), "Split end" | "Split empty") { - accepted += 1; - assert!( - matches!(status, EditStatus::Published { .. }), - "{}: {status:?}", - case["case"] - ); - drop(cache); - cache = Replica::open(&path).unwrap(); - assert_eq!(cache.sync_once(&mut server).unwrap().unwrap().0, dependent); - assert_eq!(server.publications, 2); - let store = Store::parse(&server.visible).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let space = &document.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let original: ExGuid = - serde_json::from_value(case["original_paragraph"].clone()).unwrap(); - let native_paragraph: ExGuid = - serde_json::from_value(case["new_paragraph"].clone()).unwrap(); - let parent = view - .nodes - .values() - .find(|node| node.children.contains(&original)) - .unwrap(); - let position = parent - .children - .iter() - .position(|id| *id == original) - .unwrap(); - assert_eq!( - &parent.children[position..position + 3], - &[original, split.object(), native_paragraph] - ); - assert!( - matches!(&view.nodes[&native_right].kind, Kind::RichText {text,..} if text.is_empty()) - ); - assert!( - matches!(&view.nodes[&split.text_object()].kind, Kind::RichText {text,..} if text == "Local dependent edit: ") - ); - } else { - conflicts += 1; - assert!( - matches!( - status, - EditStatus::Conflict( - ConflictKind::TextChanged | ConflictKind::StructureChanged - ) - ), - "{}: {status:?}", - case["case"] - ); - assert_eq!(server.publications, 0); - assert_eq!(cache.pending().unwrap(), pending); - drop(cache); - cache = Replica::open(&path).unwrap(); - assert_eq!(cache.snapshot().unwrap(), local); - assert_eq!(cache.pending().unwrap(), pending); - assert_eq!(cache.status(id).unwrap(), Some(status)); - } - } - assert_eq!((accepted, conflicts), (2, 10)); - } - - #[test] - fn native_joins_do_not_acknowledge_or_discard_an_independent_local_branch() { - let source = include_bytes!( - "../../../corpus/paragraph-edit/join-tags/before/notebook/synthetic.one" - ); - let native = include_bytes!( - "../../../corpus/paragraph-edit/join-tags/joined/notebook/synthetic.one" - ); - let store = Store::parse(source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let mut cases = 0; - for (sid, page) in document.pages().unwrap() { - let space = &document.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let Kind::Metadata { - title: Some(name), .. - } = &view.nodes[&view.roots[&2]].kind - else { - continue; - }; - if !name.starts_with("Join ") { - continue; - } - cases += 1; - let outline = view.nodes[&page] - .children - .iter() - .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) - .unwrap(); - let children = &view.nodes[outline].children; - let left = view.nodes[&children[0]].content[0]; - let right = view.nodes[&children[1]].content[0]; - let Kind::RichText { - text: left_text, .. - } = &view.nodes[&left].kind - else { - panic!() - }; - let survivor = if left_text.is_empty() { right } else { left }; - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("native-join.sqlite"); - let mut cache = Replica::create(&path, source).unwrap(); - let join = ParagraphJoin::new(left, right, "Offline author").unwrap(); - let id = cache.join(source, sid, &join).unwrap().unwrap(); - let current = cache.snapshot().unwrap(); - cache - .edit_text(¤t, sid, survivor, 0..0, "Local dependent edit: ") - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - let pending = cache.pending().unwrap(); - let mut server = Server::new(native); - let (conflicted, status) = cache.sync_once(&mut server).unwrap().unwrap(); - assert_eq!(conflicted, id); - assert!( - matches!( - status, - EditStatus::Conflict(ConflictKind::TargetUnavailable) - ), - "{name}: {status:?}" - ); - assert_eq!(server.publications, 0); - assert_eq!(cache.snapshot().unwrap(), local); - assert_eq!(cache.pending().unwrap(), pending); - drop(cache); - cache = Replica::open(&path).unwrap(); - assert_eq!(cache.snapshot().unwrap(), local); - assert_eq!(cache.pending().unwrap(), pending); - assert_eq!(cache.status(id).unwrap(), Some(status)); - } - assert_eq!(cases, 3); - } - - #[test] - fn split_join_dependencies_reopen_and_rebase_with_remote_text_and_styles() { - let (source, sid, left, parent) = fixture(); - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("paragraph.sqlite"); - let mut cache = Replica::create(&path, &source).unwrap(); - let split = ParagraphSplit::new(left, 2, "Local").unwrap(); - let child = Insertion::paragraph(split.object(), None, "Child", "Local").unwrap(); - let join = ParagraphJoin::new(left, split.text_object(), "Local").unwrap(); - let mut ids = Vec::new(); - for step in 0..6 { - let current = cache.snapshot().unwrap(); - let id = match step { - 0 => cache.split(¤t, sid, &split), - 1 => cache.edit_text(¤t, sid, split.text_object(), 0..2, "🦋"), - 2 => cache.format( - ¤t, - sid, - split.text_object(), - 2..3, - &[TextAttribute::Bold(true)], - ), - 3 => cache.insert(¤t, sid, &child), - 4 => cache.join(¤t, sid, &join), - 5 => cache.edit_text(¤t, sid, left, 5..6, "D"), - _ => unreachable!(), - } - .unwrap() - .unwrap(); - ids.push(id); - let saved = cache.snapshot().unwrap(); - let pending = cache.pending().unwrap(); - drop(cache); - cache = Replica::open(&path).unwrap(); - assert_eq!(cache.snapshot().unwrap(), saved); - assert_eq!(cache.pending().unwrap(), pending); - } - let remote = onestore::replace_text(&source, sid, left, 0..0, "Z").unwrap(); - let remote = - PreparedEdit::format(&remote, sid, left, 1..2, &[TextAttribute::Italic(true)]).unwrap(); - let mut server = Server::new(remote.as_bytes()); - let local = cache.snapshot().unwrap(); - for (i, id) in ids.iter().enumerate() { - let (published, state) = cache.sync_once(&mut server).unwrap().unwrap(); - assert_eq!(published, *id); - assert!(matches!(state, EditStatus::Published { .. }), "{state:?}"); - drop(cache); - cache = Replica::open(&path).unwrap(); - assert_eq!(cache.status(*id).unwrap(), Some(state)); - if i != ids.len() - 1 { - assert_eq!(cache.snapshot().unwrap(), local); - } - } - assert_eq!(server.publications, ids.len()); - assert_eq!(server.visible, server.durable); - assert_eq!(cache.snapshot().unwrap(), server.durable); - let store = Store::parse(&server.durable).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - index.validate_current().unwrap(); - let doc = Document::parse(&index).unwrap(); - let space = &doc.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - assert!( - matches!(&view.nodes[&left].kind, Kind::RichText { text, .. } if text == "Zab🦋cD") - ); - assert_eq!(view.nodes[&parent].children, [child.object()]); - assert_eq!(view.nodes[&parent].content, [left]); - let runs = view.text_runs(left).unwrap(); - let chars: Vec<_> = runs - .iter() - .flat_map(|run| { - run.text - .chars() - .map(|c| (c, run.format.bold, run.format.italic)) - }) - .collect(); - assert_eq!(chars.iter().find(|v| v.0 == 'a').unwrap().2, Some(true)); - assert_eq!(chars.iter().find(|v| v.0 == 'c').unwrap().1, Some(true)); - } - - #[test] - fn changed_split_children_require_fresh_review_without_reallocating_dependents() { - let (source, sid, left, parent) = fixture(); - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("paragraph.sqlite"); - let cache = Replica::create(&path, &source).unwrap(); - let split = ParagraphSplit::new(left, 2, "Local").unwrap(); - let id = cache.split(&source, sid, &split).unwrap().unwrap(); - let dependent = cache - .edit_text( - &cache.snapshot().unwrap(), - sid, - split.text_object(), - 0..2, - "🦋", - ) - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - let child = Insertion::paragraph(parent, None, "Remote child", "Remote").unwrap(); - let remote = PreparedEdit::insert(&source, sid, &child).unwrap(); - let mut server = Server::new(remote.as_bytes()); - assert_eq!( - cache.sync_once(&mut server).unwrap(), - Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) - ); - assert_eq!(server.publications, 0); - assert_eq!(cache.snapshot().unwrap(), local); - assert!( - cache - .rebase_conflict(id, &source, &server.visible, 2..2) - .is_err() - ); - assert!( - cache - .rebase_conflict(id, &local, &server.visible, 2..3) - .is_err() - ); - cache - .rebase_conflict(id, &local, &server.visible, 2..2) - .unwrap(); - let pending = cache.pending().unwrap(); - let notebook::Operation::Split(edit) = &pending[0].operation else { - panic!() - }; - assert_eq!(edit.intent, split); - assert_eq!(pending[1].id, dependent); - drop(cache); - let cache = Replica::open(&path).unwrap(); - for expected in [id, dependent] { - let (actual, state) = cache.sync_once(&mut server).unwrap().unwrap(); - assert_eq!(actual, expected); - assert!(matches!(state, EditStatus::Published { .. })); - } - let store = Store::parse(&server.durable).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let space = &doc.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - assert_eq!(view.nodes[&split.object()].children, [child.object()]); - assert!( - matches!(&view.nodes[&split.text_object()].kind, Kind::RichText { text, .. } if text == "🦋cd") - ); - } - - #[test] - fn uncertain_paragraph_operations_keep_the_original_attempt_across_reopen() { - for join in [false, true] { - for fault in [ - Fault::Before, - Fault::UnknownBefore, - Fault::UnknownAfter, - Fault::Committed, - ] { - let (source, sid, left, _) = fixture(); - let split = ParagraphSplit::new(left, 2, "Local").unwrap(); - let source = if join { - PreparedEdit::split(&source, sid, &split) - .unwrap() - .as_bytes() - .to_vec() - } else { - source - }; - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("paragraph.sqlite"); - let cache = Replica::create(&path, &source).unwrap(); - let id = if join { - cache.join( - &source, - sid, - &ParagraphJoin::new(left, split.text_object(), "Local").unwrap(), - ) - } else { - cache.split(&source, sid, &split) - } - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - let intent = cache.pending().unwrap(); - let mut server = Server::new(&source); - server.fault = fault; - assert!(cache.sync_once(&mut server).is_err()); - let state = cache.status(id).unwrap().unwrap(); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.status(id).unwrap(), Some(state)); - if matches!(fault, Fault::UnknownBefore | Fault::UnknownAfter) { - assert!(matches!(state, EditStatus::AwaitingConfirmation { .. })); - assert_eq!(cache.pending().unwrap(), intent); - assert_eq!(cache.snapshot().unwrap(), local); - if matches!(fault, Fault::UnknownBefore) { - assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, state))); - assert_eq!(server.publications, 1); - continue; - } - server.fault = Fault::Confirm; - assert!(cache.sync_once(&mut server).is_err()); - assert_eq!(cache.status(id).unwrap(), Some(state)); - let (_, state) = cache.sync_once(&mut server).unwrap().unwrap(); - assert!(matches!(state, EditStatus::Published { .. })); - assert_eq!(server.publications, 1); - } else if matches!(fault, Fault::Before) { - assert_eq!(state, EditStatus::Pending); - assert!(matches!( - cache.sync_once(&mut server).unwrap().unwrap().1, - EditStatus::Published { .. } - )); - assert_eq!(server.publications, 2); - } else { - assert!(matches!(state, EditStatus::Published { .. })); - assert_eq!(server.publications, 1); - } - assert!(cache.pending().unwrap().is_empty()); - let saved = cache.snapshot().unwrap(); - if matches!(fault, Fault::UnknownAfter) { - // Confirmation refreshes version metadata after reading the acknowledged snapshot. - assert!(saved[..212] == server.durable[..212]); - assert!(saved[252..] == server.durable[252..]); - assert_eq!( - Store::parse(&server.durable).unwrap().header.generation, - Store::parse(&saved).unwrap().header.generation + 1 - ); - } else { - assert!(saved == server.durable); - } - assert_eq!(cache.sync_once(&mut server).unwrap(), None); - assert!(cache.snapshot().unwrap() == server.durable); - } - } - } - - #[test] - fn join_reviews_preserve_survivors_and_require_current_child_placement() { - for empty in [false, true] { - let (source, sid, left, _) = fixture(); - let split = ParagraphSplit::new(left, if empty { 0 } else { 2 }, "Local").unwrap(); - let source = PreparedEdit::split(&source, sid, &split) - .unwrap() - .as_bytes() - .to_vec(); - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("join.sqlite"); - let cache = Replica::create(&path, &source).unwrap(); - let intent = ParagraphJoin::new(left, split.text_object(), "Local").unwrap(); - let id = cache.join(&source, sid, &intent).unwrap().unwrap(); - let survivor = if empty { split.text_object() } else { left }; - let dependent = cache - .edit_text(&cache.snapshot().unwrap(), sid, survivor, 0..0, "Local ") - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - let child = - Insertion::paragraph(split.object(), None, "Remote child", "Remote").unwrap(); - let remote = PreparedEdit::insert(&source, sid, &child) - .unwrap() - .as_bytes() - .to_vec(); - let mut server = Server::new(&remote); - assert_eq!( - cache.sync_once(&mut server).unwrap(), - Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) - ); - assert!(cache.rebase_join_conflict(id, &source, &remote).is_err()); - assert_eq!(server.publications, 0); - cache.rebase_join_conflict(id, &local, &remote).unwrap(); - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) - ); - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == dependent) - ); - let store = Store::parse(&server.durable).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let space = &doc.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let parent = view - .nodes - .values() - .find(|node| node.content == [survivor]) - .unwrap(); - assert_eq!(parent.children, [child.object()]); - assert!( - matches!(&view.nodes[&survivor].kind, Kind::RichText { text, .. } if text == "Local ab🦀cd") - ); - } - - let (source, sid, left, _) = fixture(); - let split = ParagraphSplit::new(left, 0, "Local").unwrap(); - let source = PreparedEdit::split(&source, sid, &split) - .unwrap() - .as_bytes() - .to_vec(); - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("identity.sqlite"); - let cache = Replica::create(&path, &source).unwrap(); - let intent = ParagraphJoin::new(left, split.text_object(), "Local").unwrap(); - let id = cache.join(&source, sid, &intent).unwrap().unwrap(); - let local = cache.snapshot().unwrap(); - let remote = onestore::replace_text(&source, sid, left, 0..0, "Remote").unwrap(); - let mut server = Server::new(&remote); - assert_eq!( - cache.sync_once(&mut server).unwrap(), - Some((id, EditStatus::Conflict(ConflictKind::TextChanged))) - ); - assert!(cache.rebase_join_conflict(id, &local, &remote).is_err()); - assert!(cache.snapshot().unwrap() == local); - assert_eq!(server.publications, 0); - assert_eq!(cache.pending().unwrap().len(), 1); - } - - #[test] - fn overlapping_clients_retain_each_local_branch_without_replaying_removed_targets() { - let (source, sid, left, _) = fixture(); - let split = ParagraphSplit::new(left, 2, "Seed").unwrap(); - let source = PreparedEdit::split(&source, sid, &split) - .unwrap() - .as_bytes() - .to_vec(); - let directory = tempfile::tempdir().unwrap(); - let first = Replica::create(directory.path().join("first.sqlite"), &source).unwrap(); - let second_path = directory.path().join("second.sqlite"); - let second = Replica::create(&second_path, &source).unwrap(); - let join = ParagraphJoin::new(left, split.text_object(), "First").unwrap(); - first.join(&source, sid, &join).unwrap(); - let right_split = ParagraphSplit::new(split.text_object(), 2, "Second").unwrap(); - let id = second.split(&source, sid, &right_split).unwrap().unwrap(); - second - .edit_text( - &second.snapshot().unwrap(), - sid, - right_split.text_object(), - 0..0, - "Second ", - ) - .unwrap(); - let local = second.snapshot().unwrap(); - let pending = second.pending().unwrap(); - let mut server = Server::new(&source); - assert!(matches!( - first.sync_once(&mut server).unwrap().unwrap().1, - EditStatus::Published { .. } - )); - assert_eq!( - second.sync_once(&mut server).unwrap(), - Some((id, EditStatus::Conflict(ConflictKind::TargetUnavailable))) - ); - drop(second); - let second = Replica::open(&second_path).unwrap(); - assert_eq!(second.pending().unwrap(), pending); - assert!(second.snapshot().unwrap() == local); - assert_eq!(server.publications, 1); - assert_eq!( - second.status(id).unwrap(), - Some(EditStatus::Conflict(ConflictKind::TargetUnavailable)) - ); - } - - #[test] - #[ignore = "exports reconciled paragraph edits for independent native validation"] - fn export_native_offline_paragraphs() { - use std::{fs, path::PathBuf}; - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/paragraph-edit"); - let output = PathBuf::from(std::env::var_os("ONESTORE_OFFLINE_PARAGRAPH_OUTPUT").unwrap()); - assert!(output.is_absolute()); - fs::create_dir(&output).unwrap(); - for (name, source, manifest, split) in [ - ( - "splits", - "before/notebook/synthetic.one", - "rust-split/manifest.json", - true, - ), - ( - "joins", - "split/notebook/synthetic.one", - "rust-join/split/manifest.json", - false, - ), - ( - "inheritance", - "join-edges/before/notebook/synthetic.one", - "rust-join/inheritance/manifest.json", - false, - ), - ( - "tags", - "join-tags/before/notebook/synthetic.one", - "rust-join/tags/manifest.json", - false, - ), - ] { - let source = fs::read(root.join(source)).unwrap(); - let folder = output.join(name); - fs::create_dir(&folder).unwrap(); - let path = folder.join("cache.sqlite"); - let mut cache = Replica::create(&path, &source).unwrap(); - let mut server = Server::new(&source); - let manifest: serde_json::Value = - serde_json::from_slice(&fs::read(root.join(manifest)).unwrap()).unwrap(); - let cases = if split { &manifest["cases"] } else { &manifest }; - let mut recorded = Vec::new(); - for case in cases - .as_array() - .unwrap() - .iter() - .filter(|case| case.get("intent").is_some()) - { - let local = cache.snapshot().unwrap(); - let (text, offset) = if split { - serde_json::from_value::(case["intent"].clone()) - .unwrap() - .position() - } else { - ( - serde_json::from_value::(case["intent"].clone()) - .unwrap() - .texts()[0], - 1, - ) - }; - let store = Store::parse(&local).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let (sid, space) = doc - .spaces - .iter() - .find(|(_, space)| { - space.revisions[&space.contexts[&ExGuid::default()]] - .nodes - .contains_key(&text) - }) - .unwrap(); - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let Kind::RichText { text: content, .. } = &view.nodes[&text].kind else { - panic!() - }; - let remote_prefix = offset > 0 && !content.is_empty(); - assert!(!content.contains('☂')); - if remote_prefix { - server.visible = - onestore::replace_text(&server.visible, *sid, text, 0..0, "☂").unwrap(); - server.durable.clone_from(&server.visible); - } - let id = if split { - cache.split( - &local, - *sid, - &serde_json::from_value(case["intent"].clone()).unwrap(), - ) - } else { - cache.join( - &local, - *sid, - &serde_json::from_value(case["intent"].clone()).unwrap(), - ) - } - .unwrap() - .unwrap(); - recorded.push(serde_json::json!({"case": case["case"], "id": id, - "space": sid, "intent": case["intent"], "remote_prefix": remote_prefix})); - drop(cache); - cache = Replica::open(&path).unwrap(); - } - for case in &mut recorded { - let id = case["id"].as_u64().unwrap(); - if id % 2 == 0 { - server.fault = Fault::UnknownAfter; - assert!(cache.sync_once(&mut server).is_err()); - assert!(matches!( - cache.status(id).unwrap(), - Some(EditStatus::AwaitingConfirmation { .. }) - )); - drop(cache); - cache = Replica::open(&path).unwrap(); - } - let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); - assert_eq!(actual, id); - let EditStatus::Published { revision } = status else { - panic!("{name}: {status:?}") - }; - case["revision"] = serde_json::to_value(revision).unwrap(); - } - assert_eq!(server.publications, recorded.len()); - assert_eq!(cache.sync_once(&mut server).unwrap(), None); - assert!(cache.snapshot().unwrap() == server.durable); - cache - .export_recovery(folder.join("recovery.sqlite")) - .unwrap(); - fs::create_dir(folder.join("candidate")).unwrap(); - fs::write(folder.join("candidate/synthetic.one"), &server.durable).unwrap(); - fs::write( - folder.join("manifest.json"), - serde_json::to_vec_pretty(&recorded).unwrap(), - ) - .unwrap(); - } - } -} - -#[derive(Clone, Copy, Default)] -enum Fault { - #[default] - None, - Before, - UnknownBefore, - UnknownAfter, - Committed, - PanicBefore, - PanicAfter, - Confirm, - ConfirmCommitted, -} - -struct Server { - visible: Vec, - durable: Vec, - fault: Fault, - publications: usize, - confirmations: usize, -} - -impl Server { - fn new(source: &[u8]) -> Self { - Self { - visible: source.to_vec(), - durable: source.to_vec(), - fault: Fault::None, - publications: 0, - confirmations: 0, - } - } -} - -fn failure(state: CommitState) -> CommitError { - CommitError { - state, - error: io::Error::from(io::ErrorKind::ConnectionAborted), - } -} - -impl CommitIo for Server { - fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result { - let offset = usize::try_from(offset).unwrap(); - let size = output.len().min(self.visible.len().saturating_sub(offset)); - if size > 0 { - output[..size].copy_from_slice(&self.visible[offset..offset + size]); - } - Ok(size) - } - fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result { - let offset = usize::try_from(offset).unwrap(); - self.visible - .resize(self.visible.len().max(offset + bytes.len()), 0); - self.visible[offset..offset + bytes.len()].copy_from_slice(bytes); - Ok(bytes.len()) - } - fn flush(&mut self) -> io::Result<()> { - self.durable.clone_from(&self.visible); - Ok(()) - } -} - -impl Remote for Server { - fn read(&mut self) -> io::Result> { - Ok(self.visible.clone()) - } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { - self.publications += 1; - let fault = std::mem::take(&mut self.fault); - match fault { - Fault::Before => return Err(failure(CommitState::NotCommitted)), - Fault::UnknownBefore => return Err(failure(CommitState::Unknown)), - Fault::PanicBefore => panic!("Terminated before remote I/O"), - _ => {} - } - let old = self.durable.clone(); - edit.commit(self)?; - match fault { - Fault::UnknownAfter => { - self.durable = old; - Err(failure(CommitState::Unknown)) - } - Fault::Committed => Err(failure(CommitState::Committed)), - Fault::PanicAfter => panic!("Terminated after remote publication"), - _ => Ok(()), - } - } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - self.confirmations += 1; - if matches!(self.fault, Fault::Confirm) { - self.fault = Fault::None; - return Err(failure(CommitState::Unknown)); - } - onestore::confirm_snapshot(self, snapshot)?; - if matches!(self.fault, Fault::ConfirmCommitted) { - self.fault = Fault::None; - return Err(failure(CommitState::Committed)); - } - Ok(()) - } +use onestore::{CommitError, CommitState, ExGuid, PreparedEdit, page::Page}; +use std::{io, ops::Range}; + +#[path = "support/server.rs"] +mod server; +use server::*; +#[path = "support/model_ops.rs"] +mod model_ops; + +/// The page holding `text`, with a UTF-16 range of that text replaced. +fn edited(bytes: &[u8], text: ExGuid, range: Range, replacement: &str) -> Page { + let (_, mut page) = model_ops::locate(bytes, text); + model_ops::replace_text(&mut page, text, range, replacement); + page } -fn text(source: &[u8]) -> (ExGuid, ExGuid, String) { - let store = Store::parse(source).unwrap(); - assert!(store.checksum_mismatches.is_empty()); - let index = RevisionIndex::parse(&store).unwrap(); - index.validate_current().unwrap(); - let doc = Document::parse(&index).unwrap(); - doc.spaces - .iter() - .find_map(|(sid, space)| { - space.revisions[&space.contexts[&ExGuid::default()]] - .nodes - .iter() - .find_map(|(oid, node)| match &node.kind { - Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())), - _ => None, - }) - }) - .unwrap() +/// Saves a replacement of `range` in the page holding `text`. +fn save(cache: &Replica, text: ExGuid, range: Range, replacement: &str) -> Option { + model_ops::save(cache, text, |page| { + model_ops::replace_text(page, text, range, replacement) + }) + .unwrap() } #[test] -fn recovery_archive_preserves_typed_queue_uncertainty_and_receipts_without_becoming_a_writer() { +fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becoming_a_writer() { use notebook::{Recovery, RecoverySummary}; - use onestore::{Insertion, TextAttribute}; let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("live.sqlite"); let archive_path = directory.path().join("recovery.sqlite"); let source = onestore::create_section("recovery.one", "Original", "Fixture").unwrap(); - let (space, object, _) = text(&source); + let (_, object, _) = text(&source); let cache = Replica::create(&path, &source).unwrap(); - let published = cache - .edit_text(&source, space, object, 0..0, "Published ") - .unwrap() - .unwrap(); + let published = save(&cache, object, 0..0, "Published ").unwrap(); let mut server = Server::new(&source); let (_, receipt) = cache.sync_once(&mut server).unwrap().unwrap(); - let source = cache.snapshot().unwrap(); - let store = Store::parse(&source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let (page_space, page) = document.pages().unwrap()[0]; - let outline = Insertion::outline(page, 100.0, 200.0, "Outline", "Fixture").unwrap(); - let inserted = cache - .insert(&source, page_space, &outline) - .unwrap() - .unwrap(); - let paragraph = Insertion::paragraph(outline.object(), None, "Recovery 🦀", "Fixture").unwrap(); - cache - .insert(&cache.snapshot().unwrap(), page_space, ¶graph) - .unwrap(); - cache - .format( - &cache.snapshot().unwrap(), - page_space, - paragraph.text_object(), - 0..3, - &[TextAttribute::Bold(true)], - ) - .unwrap(); - cache - .edit_text( - &cache.snapshot().unwrap(), - page_space, - paragraph.text_object(), - 0..0, - "Pending ", - ) - .unwrap(); + let attempted = save(&cache, object, 0..0, "Uncertain ").unwrap(); server.fault = Fault::UnknownBefore; assert!(matches!( cache.sync_once(&mut server), @@ -1145,10 +47,17 @@ fn recovery_archive_preserves_typed_queue_uncertainty_and_receipts_without_becom .. })) )); + save(&cache, object, 0..0, "Queued ").unwrap(); + let created = onestore::PageCreation::new(None, Some("Recovery 🦀"), "Fixture").unwrap(); + cache + .create_page(&cache.snapshot().unwrap(), &created) + .unwrap() + .unwrap(); + save(&cache, object, 0..0, "Last ").unwrap(); let working = cache.snapshot().unwrap(); let remote = cache.remote_snapshot().unwrap(); let pending = cache.pending().unwrap(); - let uncertain = cache.status(inserted).unwrap(); + let uncertain = cache.status(attempted).unwrap(); let source_file = std::fs::read(&path).unwrap(); let summary = RecoverySummary { queued_edits: 4, @@ -1173,7 +82,7 @@ fn recovery_archive_preserves_typed_queue_uncertainty_and_receipts_without_becom assert_eq!(archive.remote_snapshot().unwrap(), remote); assert_eq!(archive.pending().unwrap(), pending); assert_eq!(archive.status(published).unwrap(), Some(receipt)); - assert_eq!(archive.status(inserted).unwrap(), uncertain); + assert_eq!(archive.status(attempted).unwrap(), uncertain); for edit in &pending { assert_eq!( archive.status(edit.id).unwrap(), @@ -1195,9 +104,7 @@ fn recovery_archive_preserves_typed_queue_uncertainty_and_receipts_without_becom } assert_eq!(std::fs::read(&path).unwrap(), source_file); assert_eq!(std::fs::read(&archive_path).unwrap(), archive_bytes); - cache - .edit_text(&working, space, object, 0..0, "Later ") - .unwrap(); + save(&cache, object, 0..0, "Later ").unwrap(); assert_eq!(archive.snapshot().unwrap(), working); assert_eq!(archive.pending().unwrap(), pending); drop(archive); @@ -1238,16 +145,13 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv let source = onestore::create_section("recovery.one", "Original", "Fixture").unwrap(); let (space, object, _) = text(&source); let cache = Replica::create(directory.path().join("live.sqlite"), &source).unwrap(); - let id = cache - .edit_text(&source, space, object, 0..8, "Local") - .unwrap() - .unwrap(); + let id = save(&cache, object, 0..8, "Local").unwrap(); let changed = onestore::replace_text(&source, space, object, 0..8, "Remote").unwrap(); let mut server = Server::new(&changed); let outcome = cache.sync_once(&mut server).unwrap().unwrap(); assert_eq!( outcome, - (id, EditStatus::Conflict(ConflictKind::TextChanged)) + (id, EditStatus::Conflict(ConflictKind::ContentChanged)) ); let path = directory.path().join("conflict.sqlite"); cache.export_recovery(&path).unwrap(); @@ -1274,16 +178,13 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv } #[test] -fn rebases_multiple_disjoint_remote_changes_and_persists_the_remote_receipt() { +fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("cache.sqlite"); let source = onestore::create_section("sync.one", "ab🦀cd", "Fixture").unwrap(); let (sid, oid, _) = text(&source); let cache = Replica::create(&path, &source).unwrap(); - let id = cache - .edit_text(&source, sid, oid, 2..4, "🐈") - .unwrap() - .unwrap(); + let id = save(&cache, oid, 2..4, "🐈").unwrap(); let remote = onestore::replace_text(&source, sid, oid, 0..6, "Xab🦀cYd").unwrap(); let mut server = Server::new(&remote); let outcome = cache.sync_once(&mut server).unwrap().unwrap(); @@ -1298,11 +199,7 @@ fn rebases_multiple_disjoint_remote_changes_and_persists_the_remote_receipt() { assert_eq!(cache.sync_once(&mut server).unwrap(), None); assert_eq!(server.publications, 1); assert_eq!(cache.status(id + 1).unwrap(), None); - let source = cache.snapshot().unwrap(); - let next = cache - .edit_text(&source, sid, oid, 0..0, "Later ") - .unwrap() - .unwrap(); + let next = save(&cache, oid, 0..0, "Later ").unwrap(); assert!(next > id); } @@ -1313,16 +210,13 @@ fn overlapping_changes_preserve_both_images_and_survive_restart() { let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); let (sid, oid, _) = text(&source); let cache = Replica::create(&path, &source).unwrap(); - let id = cache - .edit_text(&source, sid, oid, 1..2, "L") - .unwrap() - .unwrap(); + let id = save(&cache, oid, 1..2, "L").unwrap(); let local = cache.snapshot().unwrap(); let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); let mut server = Server::new(&remote); assert_eq!( cache.sync_once(&mut server).unwrap(), - Some((id, EditStatus::Conflict(ConflictKind::TextChanged))) + Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) ); assert_eq!(server.publications, 0); drop(cache); @@ -1332,7 +226,7 @@ fn overlapping_changes_preserve_both_images_and_survive_restart() { assert_eq!(cache.pending().unwrap().len(), 1); assert_eq!( cache.status(id).unwrap(), - Some(EditStatus::Conflict(ConflictKind::TextChanged)) + Some(EditStatus::Conflict(ConflictKind::ContentChanged)) ); } @@ -1347,12 +241,9 @@ fn lost_replies_and_process_termination_never_blindly_replay_an_attempt() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("cache.sqlite"); let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); + let (_, oid, _) = text(&source); let cache = Replica::create(&path, &source).unwrap(); - let id = cache - .edit_text(&source, sid, oid, 0..0, "Once ") - .unwrap() - .unwrap(); + let id = save(&cache, oid, 0..0, "Once ").unwrap(); let mut server = Server::new(&source); server.fault = fault; let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { @@ -1383,12 +274,9 @@ fn failed_confirmation_does_not_promote_visible_bytes_to_a_receipt() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("cache.sqlite"); let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); + let (_, oid, _) = text(&source); let cache = Replica::create(&path, &source).unwrap(); - let id = cache - .edit_text(&source, sid, oid, 0..0, "Once ") - .unwrap() - .unwrap(); + let id = save(&cache, oid, 0..0, "Once ").unwrap(); let mut server = Server::new(&source); server.fault = Fault::UnknownAfter; assert!(cache.sync_once(&mut server).is_err()); @@ -1412,12 +300,9 @@ fn failed_confirmation_does_not_promote_visible_bytes_to_a_receipt() { fn confirmation_cleanup_failure_still_records_a_durable_receipt() { let dir = tempfile::tempdir().unwrap(); let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); + let (_, oid, _) = text(&source); let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); - let id = cache - .edit_text(&source, sid, oid, 0..0, "Once ") - .unwrap() - .unwrap(); + let id = save(&cache, oid, 0..0, "Once ").unwrap(); let mut server = Server::new(&source); server.fault = Fault::UnknownAfter; assert!(cache.sync_once(&mut server).is_err()); @@ -1441,12 +326,9 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts( let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("cache.sqlite"); let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); + let (_, oid, _) = text(&source); let cache = Replica::create(&path, &source).unwrap(); - let id = cache - .edit_text(&source, sid, oid, 0..0, "Once ") - .unwrap() - .unwrap(); + let id = save(&cache, oid, 0..0, "Once ").unwrap(); let mut server = Server::new(&source); server.fault = fault; assert!(matches!( @@ -1478,12 +360,9 @@ fn database_failures_before_and_after_publication_preserve_recovery_state() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("cache.sqlite"); let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); + let (_, oid, _) = text(&source); let cache = Replica::create(&path, &source).unwrap(); - let id = cache - .edit_text(&source, sid, oid, 0..0, "Once ") - .unwrap() - .unwrap(); + let id = save(&cache, oid, 0..0, "Once ").unwrap(); drop(cache); let db = rusqlite::Connection::open(&path).unwrap(); db.execute_batch(&format!("CREATE TRIGGER interrupted BEFORE INSERT ON {table} BEGIN SELECT RAISE(ABORT,'Injected cache failure'); END;")).unwrap(); @@ -1550,12 +429,9 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio for phase in ["read", "publish", "confirm"] { let dir = tempfile::tempdir().unwrap(); let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); + let (_, oid, _) = text(&source); let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); - let first = cache - .edit_text(&source, sid, oid, 0..0, "First ") - .unwrap() - .unwrap(); + let first = save(&cache, oid, 0..0, "First ").unwrap(); let mut server = Server::new(&source); if phase == "confirm" { server.fault = Fault::UnknownAfter; @@ -1583,8 +459,9 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio assert!( matches!(cache.sync_once(&mut Server::new(&source)),Err(Error::Io(error)) if error.kind()==io::ErrorKind::WouldBlock) ); + let reviewed = edited(&source, oid, 0..0, "Reviewed "); assert!( - matches!(cache.rebase_conflict(first, &[], &[], 0..0), Err(Error::Io(error)) if error.kind() == io::ErrorKind::WouldBlock) + matches!(cache.review_page(first, &[], &[], &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::WouldBlock) ); let started = std::time::Instant::now(); let handles: Vec<_> = (0..12) @@ -1592,14 +469,10 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio let cache = &cache; scope.spawn(move || { loop { - let snapshot = cache.snapshot().unwrap(); - match cache.edit_text( - &snapshot, - sid, - oid, - 0..0, - &format!("[{writer}] "), - ) { + let marker = format!("[{writer}] "); + match model_ops::save(cache, oid, |page| { + model_ops::replace_text(page, oid, 0..0, &marker) + }) { Ok(Some(id)) => break id, Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {} @@ -1613,7 +486,12 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio .into_iter() .map(|handle| handle.join().unwrap()) .collect(); - assert_eq!(ids.len(), 12); + assert_eq!(ids.len(), 1, "saves to one page coalesce"); + assert_eq!( + ids.contains(&first), + phase == "read", + "saves coalesce into the head only until a step selects it" + ); assert!( started.elapsed() < std::time::Duration::from_secs(2), "Local edits waited for remote {phase}" @@ -1621,16 +499,18 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio resume_tx.send(()).unwrap(); running.join().unwrap() }); - assert_eq!(cache.pending().unwrap().len(), 12); + assert_eq!(cache.pending().unwrap().len(), usize::from(phase != "read")); let expected = text(&cache.snapshot().unwrap()).2; - for _ in 0..12 { + for writer in 0..12 { + assert_eq!(expected.matches(&format!("[{writer}] ")).count(), 1); + } + while !cache.pending().unwrap().is_empty() { assert!(matches!( cache.sync_once(&mut server).unwrap(), Some((_, EditStatus::Published { .. })) )); } - assert!(cache.pending().unwrap().is_empty()); - assert_eq!(server.publications, 13); + assert_eq!(server.publications, if phase == "read" { 1 } else { 2 }); assert_eq!(text(&server.durable).2, expected); assert_eq!(cache.snapshot().unwrap(), server.durable); } @@ -1640,13 +520,13 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio fn unrelated_remote_files_never_replace_a_local_cache() { let dir = tempfile::tempdir().unwrap(); let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); + let (_, oid, _) = text(&source); let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); let other = onestore::create_section("other.one", "abc", "Fixture").unwrap(); let mut server = Server::new(&other); for pending in [false, true] { if pending { - cache.edit_text(&source, sid, oid, 0..0, "Local ").unwrap(); + save(&cache, oid, 0..0, "Local ").unwrap(); } let before = cache.snapshot().unwrap(); assert!( @@ -1659,126 +539,72 @@ fn unrelated_remote_files_never_replace_a_local_cache() { } #[test] -fn version_one_cache_migration_preserves_images_intents_and_local_ids() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("cache.sqlite"); - let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); - let cache = Replica::create(&path, &source).unwrap(); - let id = cache - .edit_text(&source, sid, oid, 0..0, "Local ") - .unwrap() - .unwrap(); - let snapshot = cache.snapshot().unwrap(); - let pending = cache.pending().unwrap(); - drop(cache); - let db = rusqlite::Connection::open(&path).unwrap(); - let current_version: u32 = db - .pragma_query_value(None, "user_version", |row| row.get(0)) - .unwrap(); - db.execute_batch( - "DROP TABLE attempt; DROP TABLE conflicts; DROP TABLE receipts; DROP TABLE edits; DROP TABLE assets; - CREATE TABLE edits ( - id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), space TEXT NOT NULL, - object TEXT NOT NULL, before_text TEXT NOT NULL, - start INTEGER NOT NULL CHECK(start BETWEEN 0 AND 4294967295), - end INTEGER NOT NULL CHECK(end BETWEEN start AND 4294967295), replacement TEXT NOT NULL - ) STRICT; PRAGMA user_version=1;", - ) - .unwrap(); - db.execute("INSERT INTO edits(id,space,object,before_text,start,end,replacement) VALUES (?1,?2,?3,'abc',0,0,'Local ')",rusqlite::params![i64::try_from(id).unwrap(),sid.to_string(),oid.to_string()]).unwrap(); - drop(db); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.snapshot().unwrap(), snapshot); - assert_eq!(cache.pending().unwrap(), pending); - assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); - let mut server = Server::new(&source); - assert!(matches!( - cache.sync_once(&mut server).unwrap(), - Some((_, EditStatus::Published { .. })) - )); - drop(cache); - let db = rusqlite::Connection::open(&path).unwrap(); - assert_eq!( - db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) - .unwrap(), - current_version - ); -} - -#[test] -fn reviewed_conflict_rebase_preserves_twelve_dependent_edits_and_survives_reopen() { +fn reviewing_a_conflict_preserves_the_dependent_save_and_survives_reopen() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("cache.sqlite"); let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); let (sid, oid, _) = text(&source); let cache = Replica::create(&path, &source).unwrap(); - let first = cache - .edit_text(&source, sid, oid, 1..2, "L") - .unwrap() - .unwrap(); - for n in 0..12 { - cache - .edit_text( - &cache.snapshot().unwrap(), - sid, - oid, - 0..0, - &format!("[{n}] "), - ) - .unwrap(); - } - let local = cache.snapshot().unwrap(); - let pending = cache.pending().unwrap(); + let first = save(&cache, oid, 1..2, "L").unwrap(); let remote = onestore::replace_text(&source, sid, oid, 0..3, "aRcZ").unwrap(); let mut server = Server::new(&remote); assert_eq!( cache.sync_once(&mut server).unwrap(), - Some((first, EditStatus::Conflict(ConflictKind::TextChanged))) + Some((first, EditStatus::Conflict(ConflictKind::ContentChanged))) ); - cache.rebase_conflict(first, &local, &remote, 1..2).unwrap(); + let mut dependent = None; + for n in 0..12 { + dependent = save(&cache, oid, 0..0, &format!("[{n}] ")); + } + let dependent = dependent.unwrap(); + assert_ne!(dependent, first, "a conflicted save is never rewritten"); + let local = cache.snapshot().unwrap(); + let pending = cache.pending().unwrap(); + assert_eq!(pending.len(), 2); + model_ops::review(&cache, first, oid, |page| { + model_ops::replace_text(page, oid, 1..2, "L") + }) + .unwrap(); assert_eq!(cache.snapshot().unwrap(), local); assert_eq!(cache.remote_snapshot().unwrap(), remote); - let rebased = cache.pending().unwrap(); - assert_eq!(&rebased[1..], &pending[1..]); - assert_eq!(rebased[0].id, first); - let notebook::Operation::Text(updated) = &rebased[0].operation else { + let reviewed = cache.pending().unwrap(); + assert_eq!(reviewed[1..], pending[1..]); + assert_eq!(reviewed[0].id, first); + let notebook::Operation::Page(intent) = &reviewed[0].operation else { panic!() }; - let notebook::Operation::Text(previous) = &pending[0].operation else { - panic!() - }; - assert_eq!(updated.replacement, previous.replacement); - assert_eq!(updated.before, "aRcZ"); + assert_eq!( + intent.text_change(), + Some((oid, "aRcZ".into(), 1..2, "L".into())) + ); + assert_eq!(intent.author, model_ops::AUTHOR); assert_eq!(cache.status(first).unwrap(), Some(EditStatus::Pending)); drop(cache); let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.pending().unwrap(), rebased); + assert_eq!(cache.pending().unwrap(), reviewed); assert_eq!(cache.snapshot().unwrap(), local); for intent in &pending { assert!( matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == intent.id) ); } - assert_eq!(server.publications, 13); + assert_eq!(server.publications, 2); assert_eq!(text(&server.durable).2, text(&local).2 + "Z"); assert_eq!(cache.snapshot().unwrap(), server.durable); assert!(cache.pending().unwrap().is_empty()); } #[test] -fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states() { +fn conflict_review_rejects_stale_images_and_nonconflicting_states() { let dir = tempfile::tempdir().unwrap(); let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); let (sid, oid, _) = text(&source); let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); - let id = cache - .edit_text(&source, sid, oid, 1..2, "L") - .unwrap() - .unwrap(); + let id = save(&cache, oid, 1..2, "L").unwrap(); let local = cache.snapshot().unwrap(); + let pending_review = edited(&source, oid, 1..2, "L"); assert!( - matches!(cache.rebase_conflict(id, &local, &source, 1..2), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput) + matches!(cache.review_page(id, &local, &source, &pending_review), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput) ); let remote = onestore::replace_text(&source, sid, oid, 0..3, "🦀Rc").unwrap(); let mut server = Server::new(&remote); @@ -1787,20 +613,15 @@ fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states Some((_, EditStatus::Conflict(_))) )); let pending = cache.pending().unwrap(); - for range in [1..2, 100..101] { - assert!(matches!( - cache.rebase_conflict(id, &local, &remote, range), - Err(Error::Document(_)) - )); - assert_eq!(cache.pending().unwrap(), pending); - } + let reviewed = edited(&remote, oid, 2..3, "L"); assert!( - matches!(cache.rebase_conflict(id + 1, &local, &remote, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput) + matches!(cache.review_page(id + 1, &local, &remote, &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput) ); - cache.edit_text(&local, sid, oid, 0..0, "Later ").unwrap(); + assert_eq!(cache.pending().unwrap(), pending); + save(&cache, oid, 0..0, "Later ").unwrap(); let changed = cache.snapshot().unwrap(); assert!( - matches!(cache.rebase_conflict(id, &local, &remote, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy) + matches!(cache.review_page(id, &local, &remote, &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy) ); let new_remote = onestore::replace_text(&remote, sid, oid, 2..3, "Q").unwrap(); server.visible = new_remote.clone(); @@ -1810,14 +631,17 @@ fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states Some((_, EditStatus::Conflict(_))) )); assert!( - matches!(cache.rebase_conflict(id, &changed, &remote, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy) + matches!(cache.review_page(id, &changed, &remote, &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy) ); assert_eq!(cache.snapshot().unwrap(), changed); assert_eq!(cache.pending().unwrap()[0], pending[0]); assert_eq!(server.publications, 0); let current = cache.remote_snapshot().unwrap(); - cache.rebase_conflict(id, &changed, ¤t, 2..3).unwrap(); + let reviewed = edited(¤t, oid, 2..3, "L"); + cache + .review_page(id, &changed, ¤t, &reviewed) + .unwrap(); server.fault = Fault::UnknownBefore; assert!( matches!(cache.sync_once(&mut server), Err(Error::Remote(error)) if error.state == CommitState::Unknown) @@ -1825,7 +649,7 @@ fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states let attempted = cache.status(id).unwrap(); let pending = cache.pending().unwrap(); assert!( - matches!(cache.rebase_conflict(id, &changed, ¤t, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput) + matches!(cache.review_page(id, &changed, ¤t, &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput) ); assert_eq!(cache.status(id).unwrap(), attempted); assert_eq!(cache.pending().unwrap(), pending); @@ -1833,16 +657,13 @@ fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states } #[test] -fn failure_between_rebase_and_conflict_clear_rolls_back_the_entire_resolution() { +fn failure_between_review_and_conflict_clear_rolls_back_the_entire_resolution() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("cache.sqlite"); let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); let (sid, oid, _) = text(&source); let cache = Replica::create(&path, &source).unwrap(); - let id = cache - .edit_text(&source, sid, oid, 1..2, "L") - .unwrap() - .unwrap(); + let id = save(&cache, oid, 1..2, "L").unwrap(); let remote = onestore::replace_text(&source, sid, oid, 0..3, "XaRc").unwrap(); let mut server = Server::new(&remote); cache.sync_once(&mut server).unwrap(); @@ -1854,8 +675,9 @@ fn failure_between_rebase_and_conflict_clear_rolls_back_the_entire_resolution() connection.execute_batch("CREATE TRIGGER fail_clear BEFORE DELETE ON conflicts BEGIN SELECT RAISE(ABORT, 'test conflict clear failure'); END;").unwrap(); drop(connection); let cache = Replica::open(&path).unwrap(); + let reviewed = edited(&remote, oid, 2..3, "L"); assert!(matches!( - cache.rebase_conflict(id, &local, &remote, 2..3), + cache.review_page(id, &local, &remote, &reviewed), Err(Error::Database(_)) )); drop(cache); @@ -1868,7 +690,7 @@ fn failure_between_rebase_and_conflict_clear_rolls_back_the_entire_resolution() } #[test] -fn seeded_reviewed_ranges_preserve_unicode_and_edits_inside_the_original_replacement() { +fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() { let dir = tempfile::tempdir().unwrap(); let original: Vec<_> = "abcdefghij🦀klmnop".chars().collect(); let mut seed = 911_u64; @@ -1889,20 +711,9 @@ fn seeded_reviewed_ranges_preserve_unicode_and_edits_inside_the_original_replace .unwrap(); let (sid, oid, _) = text(&source); let cache = Replica::create(dir.path().join(format!("{case}.sqlite")), &source).unwrap(); - let id = cache - .edit_text(&source, sid, oid, start..end, "λ🦊μ") - .unwrap() - .unwrap(); - let dependent = cache - .edit_text( - &cache.snapshot().unwrap(), - sid, - oid, - start + 1..start + 3, - "🐕", - ) - .unwrap() - .unwrap(); + let id = save(&cache, oid, start..end, "λ🦊μ").unwrap(); + let dependent = save(&cache, oid, start + 1..start + 3, "🐕").unwrap(); + assert_eq!(dependent, id, "case {case}, seed {seed}"); let mut remote_text = original.clone(); remote_text.splice(at..at + 1, "Ω🐈π".chars()); let remote_text = prefix.clone() + &remote_text.iter().collect::() + &suffix; @@ -1915,28 +726,20 @@ fn seeded_reviewed_ranges_preserve_unicode_and_edits_inside_the_original_replace ) .unwrap(); let mut server = Server::new(&remote); - assert!( - matches!( - cache.sync_once(&mut server).unwrap(), - Some((_, EditStatus::Conflict(_))) - ), + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))), "case {case}, seed {seed}" ); let at_remote = start + prefix.len() as u32; - cache - .rebase_conflict( - id, - &cache.snapshot().unwrap(), - &remote, - at_remote..at_remote + 4, - ) - .unwrap(); - for expected in [id, dependent] { - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected), - "case {case}, seed {seed}" - ); - } + model_ops::review(&cache, id, oid, |page| { + model_ops::replace_text(page, oid, at_remote..at_remote + 4, "λ🐕μ") + }) + .unwrap(); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id), + "case {case}, seed {seed}" + ); let mut expected = original.clone(); expected.splice(at..at + 1, "λ🐕μ".chars()); let expected = prefix + &expected.iter().collect::() + &suffix; @@ -1945,13 +748,13 @@ fn seeded_reviewed_ranges_preserve_unicode_and_edits_inside_the_original_replace expected, "case {case}, seed {seed}" ); - assert_eq!(server.publications, 2); + assert_eq!(server.publications, 1); assert!(cache.pending().unwrap().is_empty()); } } #[test] -fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_placement() { +fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_page() { struct ChangedAfterRead { server: Server, change: Option>, @@ -1977,10 +780,7 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_placement() let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); let (sid, oid, _) = text(&source); let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); - let id = cache - .edit_text(&source, sid, oid, 1..2, "L") - .unwrap() - .unwrap(); + let id = save(&cache, oid, 1..2, "L").unwrap(); let local = cache.snapshot().unwrap(); let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); let mut remote = ChangedAfterRead { @@ -1991,9 +791,10 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_placement() cache.sync_once(&mut remote).unwrap(), Some((_, EditStatus::Conflict(_))) )); - cache - .rebase_conflict(id, &local, &cache.remote_snapshot().unwrap(), 1..2) - .unwrap(); + model_ops::review(&cache, id, oid, |page| { + model_ops::replace_text(page, oid, 1..2, "L") + }) + .unwrap(); let changed = onestore::replace_text(&remote.server.visible, sid, oid, 1..2, "Q").unwrap(); if after_read { remote.change = Some(changed.clone()); @@ -2009,1925 +810,18 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_placement() } assert_eq!( cache.sync_once(&mut remote).unwrap(), - Some((id, EditStatus::Conflict(ConflictKind::TextChanged))) + Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) ); assert_eq!(remote.server.durable, changed); assert_eq!(remote.server.visible, changed); assert_eq!(remote.server.publications, usize::from(after_read)); assert_eq!(cache.snapshot().unwrap(), local); - let notebook::Operation::Text(edit) = &cache.pending().unwrap()[0].operation else { + let notebook::Operation::Page(intent) = &cache.pending().unwrap()[0].operation else { panic!() }; - assert_eq!(edit.replacement, "L"); - } -} - -mod worker { - use super::*; - use std::{ - sync::{Arc, Mutex, mpsc}, - time::{Duration, Instant}, - }; - - #[derive(Clone)] - struct Shared(Arc>); - - impl Remote for Shared { - fn read(&mut self) -> io::Result> { - self.0.lock().unwrap().read() - } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { - self.0.lock().unwrap().publish(edit) - } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - self.0.lock().unwrap().confirm(snapshot) - } - } - - #[test] - fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() { - struct Session { - shared: Shared, - fail_read: bool, - } - impl Remote for Session { - fn read(&mut self) -> io::Result> { - if self.fail_read { - return Err(io::ErrorKind::ConnectionReset.into()); - } - self.shared.read() - } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { - self.shared.publish(edit) - } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - self.shared.confirm(snapshot) - } - } - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("cache.sqlite"); - let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); - let cache = Arc::new(Replica::create(&path, &source).unwrap()); - let id = cache - .edit_text(&source, sid, oid, 1..2, "🦀") - .unwrap() - .unwrap(); - let mut server = Server::new(&source); - server.fault = Fault::UnknownAfter; - let server = Arc::new(Mutex::new(server)); - let shared = Shared(Arc::clone(&server)); - let (connected_tx, connected_rx) = mpsc::channel(); - let (observed_tx, observed_rx) = mpsc::channel(); - let mut connections = 0; - let worker = cache - .start_sync( - Duration::from_millis(10), - move || { - connections += 1; - connected_tx.send(connections).unwrap(); - if connections <= 2 { - return Err(io::ErrorKind::ConnectionRefused.into()); - } - Ok(Session { - shared: shared.clone(), - fail_read: connections == 3, - }) - }, - move |result| { - observed_tx - .send(result.as_ref().copied().map_err(|error| error.to_string())) - .unwrap(); - }, - ) - .unwrap(); - let mut errors = 0; - let published = loop { - match observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() { - Err(_) => errors += 1, - Ok(Some((actual, status @ EditStatus::Published { .. }))) => { - assert_eq!(actual, id); - break status; - } - other => panic!("Unexpected result: {other:?}"), - } - }; - worker.stop().unwrap(); - assert_eq!(errors, 4); - assert_eq!(connected_rx.try_iter().collect::>(), [1, 2, 3, 4, 5]); - let server = server.lock().unwrap(); - assert_eq!(server.publications, 1); - assert_eq!(server.confirmations, 1); - assert_eq!(text(&server.durable).2, "a🦀c"); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.status(id).unwrap(), Some(published)); - let cached = cache.snapshot().unwrap(); - // Confirmation changes reader-notification fields without changing the committed graph. - assert_eq!(cached[..212], server.durable[..212]); - assert_eq!(cached[252..], server.durable[252..]); - let cached_generation = Store::parse(&cached).unwrap().header.generation; - let remote_generation = Store::parse(&server.durable).unwrap().header.generation; - if cached_generation == remote_generation { - assert_eq!(cached, server.durable); - } else { - assert_eq!(cached_generation.checked_add(1), Some(remote_generation)); - } - } - - #[test] - fn local_edits_wake_an_idle_worker_and_coalesced_notifications_drain_twelve_writers() { - let dir = tempfile::tempdir().unwrap(); - let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); - let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); - let server = Arc::new(Mutex::new(Server::new(&source))); - let shared = Shared(Arc::clone(&server)); - let (observed_tx, observed_rx) = mpsc::channel(); - let (resume_tx, resume_rx) = mpsc::channel(); - let mut first = true; - let worker = cache - .start_sync( - Duration::from_secs(3600), - move || Ok(shared.clone()), - move |result| { - observed_tx.send(*result.as_ref().unwrap()).unwrap(); - if first { - first = false; - resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); - } - }, - ) - .unwrap(); - assert_eq!( - observed_rx.recv_timeout(Duration::from_secs(5)).unwrap(), - None - ); - let started = Instant::now(); - let edits = std::thread::scope(|scope| { - (0..12) - .map(|writer| { - let cache = &cache; - scope.spawn(move || { - let replacement = format!("[{writer}] "); - loop { - let snapshot = cache.snapshot().unwrap(); - match cache.edit_text(&snapshot, sid, oid, 0..0, &replacement) { - Ok(Some(id)) => break (id, replacement), - Err(Error::Io(error)) - if error.kind() == io::ErrorKind::ResourceBusy => {} - other => panic!("Unexpected local outcome: {other:?}"), - } - } - }) - }) - .collect::>() - .into_iter() - .map(|join| join.join().unwrap()) - .collect::>() - }); - resume_tx.send(()).unwrap(); - let mut published = std::collections::BTreeSet::new(); - while published.len() < 12 { - if let Some((id, status)) = observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() { - assert!(matches!(status, EditStatus::Published { .. }), "{status:?}"); - assert!(published.insert(id)); - } - } - assert!( - started.elapsed() < Duration::from_secs(5), - "Edits waited for the hourly poll" - ); - worker.stop().unwrap(); - assert_eq!(published, edits.keys().copied().collect()); - let expected = edits.values().rev().cloned().collect::() + "abc"; - let server = server.lock().unwrap(); - assert_eq!(text(&server.durable).2, expected); - assert_eq!(server.publications, 12); - assert_eq!(cache.snapshot().unwrap(), server.durable); - assert!(cache.pending().unwrap().is_empty()); - } - - #[test] - fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recovery_is_recorded() - { - struct Paused { - shared: Shared, - entered: mpsc::Sender<()>, - resume: mpsc::Receiver<()>, - } - impl Remote for Paused { - fn read(&mut self) -> io::Result> { - self.shared.read() - } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { - self.entered.send(()).unwrap(); - self.resume.recv_timeout(Duration::from_secs(5)).unwrap(); - self.shared.publish(edit) - } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - self.shared.confirm(snapshot) - } - } - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("cache.sqlite"); - let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); - let cache = Arc::new(Replica::create(&path, &source).unwrap()); - let id = cache - .edit_text(&source, sid, oid, 0..0, "L ") - .unwrap() - .unwrap(); - let mut server = Server::new(&source); - server.fault = Fault::UnknownAfter; - let server = Arc::new(Mutex::new(server)); - let shared = Shared(Arc::clone(&server)); - let (entered_tx, entered_rx) = mpsc::channel(); - let (resume_tx, resume_rx) = mpsc::channel(); - let mut session = Some(Paused { - shared, - entered: entered_tx, - resume: resume_rx, - }); - let worker = cache - .start_sync( - Duration::from_secs(3600), - move || Ok(session.take().unwrap()), - |_| {}, - ) - .unwrap(); - entered_rx.recv_timeout(Duration::from_secs(5)).unwrap(); - let stopped = Instant::now(); - drop(worker); - assert!(stopped.elapsed() < Duration::from_millis(500)); - let shared = Shared(Arc::clone(&server)); - let start = cache.start_sync( - Duration::from_secs(3600), - move || Ok(shared.clone()), - |_| {}, - ); - assert!(matches!(start, Err(error) if error.kind() == io::ErrorKind::WouldBlock)); - let weak = Arc::downgrade(&cache); - drop(cache); - resume_tx.send(()).unwrap(); - while weak.upgrade().is_some() { - assert!(stopped.elapsed() < Duration::from_secs(5)); - std::thread::sleep(Duration::from_millis(1)); - } - let cache = Arc::new(Replica::open(&path).unwrap()); - assert!(matches!( - cache.status(id).unwrap(), - Some(EditStatus::AwaitingConfirmation { .. }) - )); - assert_eq!(server.lock().unwrap().publications, 1); - let shared = Shared(Arc::clone(&server)); - let (tx, rx) = mpsc::channel(); - let worker = cache - .start_sync( - Duration::from_secs(3600), - move || Ok(shared.clone()), - move |result| { - tx.send(*result.as_ref().unwrap()).unwrap(); - }, - ) - .unwrap(); - assert!( - matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) - ); - worker.stop().unwrap(); - let server = server.lock().unwrap(); - assert_eq!(server.publications, 1); - assert_eq!(server.confirmations, 1); - assert_eq!(text(&server.durable).2, "L abc"); - } - - #[test] - fn cache_failures_stop_retries_and_return_the_error_without_remote_publication() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("cache.sqlite"); - let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); - let cache = Replica::create(&path, &source).unwrap(); - let id = cache - .edit_text(&source, sid, oid, 0..0, "L ") - .unwrap() - .unwrap(); - drop(cache); - let connection = rusqlite::Connection::open(&path).unwrap(); - connection.execute_batch("CREATE TRIGGER fail_attempt BEFORE INSERT ON attempt BEGIN SELECT RAISE(ABORT, 'test cache write failure'); END;").unwrap(); - drop(connection); - let cache = Arc::new(Replica::open(&path).unwrap()); - let server = Arc::new(Mutex::new(Server::new(&source))); - let shared = Shared(Arc::clone(&server)); - let (tx, rx) = mpsc::channel(); - let worker = cache - .start_sync( - Duration::from_millis(1), - move || Ok(shared.clone()), - move |result| { - tx.send(matches!(result, Err(Error::Database(_)))).unwrap(); - }, - ) - .unwrap(); - assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap()); - assert!(matches!(worker.stop(), Err(Error::Database(_)))); - assert!(rx.try_iter().next().is_none()); - assert_eq!(server.lock().unwrap().publications, 0); - assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); - assert_eq!(text(&cache.snapshot().unwrap()).2, "L abc"); - } - - #[test] - fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() { - for uncertain in [false, true] { - let dir = tempfile::tempdir().unwrap(); - let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); - let cache = - Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); - let id = cache - .edit_text(&source, sid, oid, 1..2, "L") - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - let mut server = if uncertain { - Server::new(&source) - } else { - Server::new(&onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap()) - }; - if uncertain { - server.fault = Fault::UnknownBefore; - } - let server = Arc::new(Mutex::new(server)); - let shared = Shared(Arc::clone(&server)); - let (tx, rx) = mpsc::channel(); - let worker = cache - .start_sync( - Duration::from_millis(10), - move || Ok(shared.clone()), - move |result| { - tx.send(result.as_ref().copied().map_err(|_| ())).unwrap(); - }, - ) - .unwrap(); - if uncertain { - assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err()); - } - let mut previous = None; - let started = Instant::now(); - for _ in 0..5 { - let (actual, status) = rx - .recv_timeout(Duration::from_secs(5)) - .unwrap() - .unwrap() - .unwrap(); - assert_eq!(actual, id); - if uncertain { - assert!(matches!(status, EditStatus::AwaitingConfirmation { .. })); - } else { - assert_eq!(status, EditStatus::Conflict(ConflictKind::TextChanged)); - } - if let Some(previous) = previous { - assert_eq!(previous, status); - } - previous = Some(status); - } - assert!( - started.elapsed() >= Duration::from_millis(30), - "Worker spun instead of waiting between retries" - ); - worker.stop().unwrap(); - assert_eq!(cache.snapshot().unwrap(), local); - assert_eq!(cache.pending().unwrap().len(), 1); - let server = server.lock().unwrap(); - assert_eq!(server.publications, usize::from(uncertain)); - assert_eq!(server.confirmations, 0); - } - } - - #[test] - fn reachability_notification_retries_without_waiting_for_the_poll() { - let dir = tempfile::tempdir().unwrap(); - let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); - let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); - let (tx, rx) = mpsc::channel(); - let worker = cache - .start_sync( - Duration::from_secs(3600), - || -> io::Result { Err(io::ErrorKind::NotConnected.into()) }, - move |result| { - tx.send(matches!(result, Err(Error::RemoteIo(_)))).unwrap(); - }, - ) - .unwrap(); - assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap()); - worker.wake(); - assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap()); - worker.stop().unwrap(); - assert!(rx.try_iter().next().is_none()); - } - - #[test] - fn cancellation_during_connect_does_not_read_or_report_a_false_refresh() { - let dir = tempfile::tempdir().unwrap(); - let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); - let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); - let (entered_tx, entered_rx) = mpsc::channel(); - let (resume_tx, resume_rx) = mpsc::channel(); - let (tx, rx) = mpsc::channel(); - // An invalid image makes any unexpected read observable as an error callback. - let shared = Shared(Arc::new(Mutex::new(Server::new(&[])))); - let worker = cache - .start_sync( - Duration::from_secs(3600), - move || { - entered_tx.send(()).unwrap(); - resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); - Ok(shared.clone()) - }, - move |_| { - tx.send(()).unwrap(); - }, - ) - .unwrap(); - entered_rx.recv_timeout(Duration::from_secs(5)).unwrap(); - drop(worker); - let weak = Arc::downgrade(&cache); - drop(cache); - resume_tx.send(()).unwrap(); - let started = Instant::now(); - while weak.upgrade().is_some() { - assert!(started.elapsed() < Duration::from_secs(5)); - std::thread::sleep(Duration::from_millis(1)); - } - assert_eq!( - rx.recv_timeout(Duration::from_secs(5)), - Err(mpsc::RecvTimeoutError::Disconnected) - ); - } - - #[test] - fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() { - let dir = tempfile::tempdir().unwrap(); - let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); - let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); - for interval in [Duration::ZERO, Duration::MAX] { - assert!( - matches!(cache.start_sync(interval, || -> io::Result { panic!("Unexpected connection") }, |_| {}), Err(error) if error.kind() == io::ErrorKind::InvalidInput) - ); - } - let shared = Shared(Arc::new(Mutex::new(Server::new(&source)))); - let first = shared.clone(); - let (tx, rx) = mpsc::channel(); - let worker = cache - .start_sync( - Duration::from_secs(3600), - move || Ok(first.clone()), - move |_| { - tx.send(()).unwrap(); - panic!("Test observer panic"); - }, - ) - .unwrap(); - rx.recv_timeout(Duration::from_secs(5)).unwrap(); - assert!(matches!(worker.stop(), Err(Error::Io(_)))); - let (tx, rx) = mpsc::channel(); - let worker = cache - .start_sync( - Duration::from_secs(3600), - move || Ok(shared.clone()), - move |result| { - tx.send(*result.as_ref().unwrap()).unwrap(); - }, - ) - .unwrap(); - assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), None); - worker.stop().unwrap(); - assert_eq!(cache.snapshot().unwrap(), source); - } - - #[test] - fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() { - let dir = tempfile::tempdir().unwrap(); - let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); - let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); - let id = cache - .edit_text(&source, sid, oid, 1..2, "L") - .unwrap() - .unwrap(); - let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); - let server = Arc::new(Mutex::new(Server::new(&remote))); - let shared = Shared(Arc::clone(&server)); - let (tx, rx) = mpsc::channel(); - let (resume_tx, resume_rx) = mpsc::channel(); - let mut first = true; - let worker = cache - .start_sync( - Duration::from_secs(3600), - move || Ok(shared.clone()), - move |result| { - tx.send(*result.as_ref().unwrap()).unwrap(); - if first { - first = false; - resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); - } - }, - ) - .unwrap(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(5)).unwrap(), - Some((id, EditStatus::Conflict(ConflictKind::TextChanged))) - ); - cache - .rebase_conflict( - id, - &cache.snapshot().unwrap(), - &cache.remote_snapshot().unwrap(), - 1..2, - ) - .unwrap(); - assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); - resume_tx.send(()).unwrap(); - assert!( - matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) - ); - worker.stop().unwrap(); - assert!(cache.pending().unwrap().is_empty()); - let server = server.lock().unwrap(); - assert_eq!(server.publications, 1); - assert_eq!(text(&server.durable).2, "aLc"); - assert_eq!(cache.snapshot().unwrap(), server.durable); - } - - #[test] - fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() { - struct Busy { - server: Server, - reads: usize, - writes: usize, - } - impl Remote for Busy { - fn read(&mut self) -> io::Result> { - self.reads += 1; - match self.reads { - 1 => Err(io::ErrorKind::WouldBlock.into()), - 2 => Err(io::ErrorKind::ResourceBusy.into()), - _ => self.server.read(), - } - } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { - self.writes += 1; - match self.writes { - 1 | 2 => Err(CommitError { - state: CommitState::NotCommitted, - error: if self.writes == 1 { - io::ErrorKind::WouldBlock - } else { - io::ErrorKind::ResourceBusy - } - .into(), - }), - _ => self.server.publish(edit), - } - } - fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> { - self.server.confirm(source) - } - } - let dir = tempfile::tempdir().unwrap(); - let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); - let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); - let id = cache - .edit_text(&source, sid, oid, 0..0, "L ") - .unwrap() - .unwrap(); - let mut remote = Some(Busy { - server: Server::new(&source), - reads: 0, - writes: 0, - }); - let (tx, rx) = mpsc::channel(); - let worker = cache - .start_sync( - Duration::from_millis(1), - move || Ok(remote.take().expect("Contention caused a reconnect")), - move |result| { - tx.send(result.as_ref().copied().map_err(|error| error.to_string())) - .unwrap(); - }, - ) - .unwrap(); - for _ in 0..4 { - assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err()); - } - assert!( - matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap().unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) - ); - worker.stop().unwrap(); - assert_eq!(text(&cache.snapshot().unwrap()).2, "L abc"); - assert!(cache.pending().unwrap().is_empty()); - } - #[test] - fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() { - struct BusyOnce(Server); - impl Remote for BusyOnce { - fn read(&mut self) -> io::Result> { - self.0.read() - } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { - let server = &mut self.0; - if server.publications == 0 { - server.publications += 1; - return Err(CommitError { - state: CommitState::NotCommitted, - error: io::ErrorKind::ResourceBusy.into(), - }); - } - server.publish(edit) - } - fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> { - self.0.confirm(source) - } - } - let dir = tempfile::tempdir().unwrap(); - let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); - let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); - let first = cache - .edit_text(&source, sid, oid, 0..0, "L ") - .unwrap() - .unwrap(); - let mut remote = Some(BusyOnce(Server::new(&source))); - let observed = Arc::clone(&cache); - let (tx, rx) = mpsc::channel(); - let mut failed = false; - let worker = cache - .start_sync( - Duration::from_secs(3600), - move || Ok(remote.take().expect("Contention must retain the session")), - move |result| match result { - Err(Error::Remote(error)) if error.state == CommitState::NotCommitted => { - assert!(!failed); - failed = true; - let source = observed.snapshot().unwrap(); - let second = observed - .edit_text(&source, sid, oid, 0..0, "Q ") - .unwrap() - .unwrap(); - tx.send((second, None)).unwrap(); - } - Ok(Some((id, status))) => tx.send((*id, Some(*status))).unwrap(), - Ok(None) => {} - other => panic!("Unexpected worker result: {other:?}"), - }, - ) - .unwrap(); - let (second, status) = rx.recv_timeout(Duration::from_secs(5)).unwrap(); - assert_eq!(status, None); - for expected in [first, second] { - let (id, status) = rx.recv_timeout(Duration::from_secs(5)).unwrap(); - assert_eq!(id, expected); - assert!(matches!(status, Some(EditStatus::Published { .. }))); - } - worker.stop().unwrap(); - assert!(cache.pending().unwrap().is_empty()); - assert_eq!(text(&cache.snapshot().unwrap()).2, "Q L abc"); - } -} - -#[test] -fn offline_insertions_survive_reopen_rebase_and_dependent_text_edits() { - use onestore::Insertion; - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("insert.sqlite"); - let source = onestore::create_section("insert.one", "Original", "Author").unwrap(); - let (sid, original, _) = text(&source); - let store = Store::parse(&source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let (_, page) = doc.pages().unwrap()[0]; - let cache = Replica::create(&path, &source).unwrap(); - let outline = Insertion::outline(page, 72.0, 144.0, "Offline outline", "Offline author") - .unwrap() - .with_formatting(0..7, &[onestore::TextAttribute::Italic(true)]) - .unwrap(); - let first = cache.insert(&source, sid, &outline).unwrap().unwrap(); - let snapshot = cache.snapshot().unwrap(); - let paragraph = Insertion::paragraph( - outline.object(), - None, - "Offline paragraph 🦀", - "Offline author", - ) - .unwrap() - .with_formatting(8..17, &[onestore::TextAttribute::Bold(true)]) - .unwrap(); - let second = cache.insert(&snapshot, sid, ¶graph).unwrap().unwrap(); - let third = cache - .edit_text( - &cache.snapshot().unwrap(), - sid, - paragraph.text_object(), - 0..0, - "Edited ", - ) - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - let pending = cache.pending().unwrap(); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.snapshot().unwrap(), local); - assert_eq!(cache.pending().unwrap(), pending); - let remote = onestore::replace_text(&source, sid, original, 0..0, "Remote ").unwrap(); - let mut server = Server::new(&remote); - for id in [first, second, third] { - assert!( - matches!(cache.sync_once(&mut server).unwrap(),Some((observed,EditStatus::Published{..})) if observed==id) - ); - } - assert_eq!(server.publications, 3); - assert!(cache.pending().unwrap().is_empty()); - assert_eq!(cache.snapshot().unwrap(), server.durable); - let store = Store::parse(&server.durable).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let s = &doc.spaces[&sid]; - let v = &s.revisions[&s.contexts[&ExGuid::default()]]; - for (id, wanted) in [ - (original, "Remote Original"), - (outline.text_object(), "Offline outline"), - (paragraph.text_object(), "Edited Offline paragraph 🦀"), - ] { - assert!(matches!(&v.nodes[&id].kind,Kind::RichText{text,..} if text==wanted)); - } - let outline_runs = v.text_runs(outline.text_object()).unwrap(); - assert_eq!(outline_runs[0].text, "Offline"); - assert_eq!(outline_runs[0].format.italic, Some(true)); - let runs = v.text_runs(paragraph.text_object()).unwrap(); - assert_eq!(runs[1].text, "paragraph"); - assert_eq!(runs[1].format.bold, Some(true)); - assert_eq!( - v.nodes[&outline.object()].children.last(), - Some(¶graph.object()) - ); -} - -#[test] -fn uncertain_insertions_reconcile_the_original_revision_without_duplicate_objects() { - use onestore::Insertion; - for fault in [ - Fault::Before, - Fault::UnknownBefore, - Fault::UnknownAfter, - Fault::PanicBefore, - Fault::PanicAfter, - Fault::Committed, - ] { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("uncertain-insert.sqlite"); - let source = onestore::create_section("insert.one", "Original", "Author").unwrap(); - let store = Store::parse(&source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let (sid, page) = doc.pages().unwrap()[0]; - let cache = Replica::create(&path, &source).unwrap(); - let insertion = Insertion::outline(page, 144.0, 144.0, "Uncertain insertion", "Author") - .unwrap() - .with_formatting(0..9, &[onestore::TextAttribute::Bold(true)]) - .unwrap() - .with_formatting(10..19, &[onestore::TextAttribute::Italic(true)]) - .unwrap(); - let id = cache.insert(&source, sid, &insertion).unwrap().unwrap(); - let local = cache.snapshot().unwrap(); - let mut server = Server::new(&source); - server.fault = fault; - let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - cache.sync_once(&mut server) - })); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!(server.publications, 1); - if matches!(fault, Fault::UnknownBefore | Fault::PanicBefore) { - let status = cache.status(id).unwrap(); - assert!(matches!( - status, - Some(EditStatus::AwaitingConfirmation { .. }) - )); - for _ in 0..5 { - assert_eq!( - cache.sync_once(&mut server).unwrap(), - status.map(|state| (id, state)) - ); - } - assert_eq!(server.publications, 1); - assert_eq!(server.durable, source); - assert_eq!(cache.snapshot().unwrap(), local); - } else { - if !matches!(fault, Fault::Committed) { - assert!(matches!( - cache.sync_once(&mut server).unwrap(), - Some((_, EditStatus::Published { .. })) - )); - } - assert_eq!( - server.publications, - if matches!(fault, Fault::Before) { 2 } else { 1 } - ); - assert_eq!( - server.confirmations, - usize::from(matches!(fault, Fault::UnknownAfter | Fault::PanicAfter)) - ); - let store = Store::parse(&server.durable).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let s = &doc.spaces[&sid]; - let v = &s.revisions[&s.contexts[&ExGuid::default()]]; - assert_eq!(v.nodes.values().filter(|node|matches!(&node.kind,Kind::RichText{text,..} if text=="Uncertain insertion")).count(),1); - assert!(v.nodes.contains_key(&insertion.object())); - let runs = v.text_runs(insertion.text_object()).unwrap(); - assert_eq!(runs.len(), 3); - assert_eq!(runs[0].text, "Uncertain"); - assert_eq!(runs[0].format.bold, Some(true)); - assert_eq!(runs[1].text, " "); - assert_ne!(runs[1].format.bold, Some(true)); - assert_ne!(runs[1].format.italic, Some(true)); - assert_eq!(runs[2].text, "insertion"); - assert_eq!(runs[2].format.italic, Some(true)); - assert!(cache.pending().unwrap().is_empty()); - } - } -} - -#[test] -fn cross_run_text_rebases_preserve_remote_styles_and_survive_lost_replies() { - use onestore::TextAttribute as A; - for fault in [Fault::None, Fault::UnknownAfter, Fault::PanicAfter] { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("cross-run.sqlite"); - let plain = onestore::create_section("cross.one", "ab🦀cde\u{301}fg", "Author").unwrap(); - let (sid, oid, _) = text(&plain); - let bold = PreparedEdit::format(&plain, sid, oid, 1..4, &[A::Bold(true)]).unwrap(); - let source = PreparedEdit::format(bold.as_bytes(), sid, oid, 4..7, &[A::Italic(true)]) - .unwrap() - .as_bytes() - .to_vec(); - let cache = Replica::create(&path, &source).unwrap(); - let first = cache - .edit_text(&source, sid, oid, 2..7, "日本語") - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - let second = cache - .edit_text(&local, sid, oid, 3..4, "🐈") - .unwrap() - .unwrap(); - let pending = cache.pending().unwrap(); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.pending().unwrap(), pending); - let prefix = PreparedEdit::text(&source, sid, oid, 0..0, "Prefix ").unwrap(); - let remote = - PreparedEdit::format(prefix.as_bytes(), sid, oid, 9..11, &[A::Underline(true)]) - .unwrap(); - let mut server = Server::new(remote.as_bytes()); - server.fault = fault; - let attempted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - cache.sync_once(&mut server) - })); - if matches!(fault, Fault::None) { - assert!( - matches!(attempted.unwrap().unwrap(), Some((id, EditStatus::Published { .. })) if id == first) - ); - } else { - assert!(matches!( - cache.status(first).unwrap(), - Some(EditStatus::AwaitingConfirmation { .. }) - )); - } - drop(cache); - let cache = Replica::open(&path).unwrap(); - while let Some((_, status)) = cache.sync_once(&mut server).unwrap() { - assert!(matches!(status, EditStatus::Published { .. })); - } - assert_eq!(server.publications, 2); - assert!(matches!( - cache.status(second).unwrap(), - Some(EditStatus::Published { .. }) - )); - assert_eq!(text(&server.durable).2, "Prefix ab日🐈語\u{301}fg"); - assert_eq!(cache.snapshot().unwrap(), server.durable); - let store = Store::parse(&server.durable).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let space = &document.spaces[&sid]; - let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; - let runs = revision.text_runs(oid).unwrap(); - let replacement = runs.iter().find(|run| run.text == "日🐈語").unwrap(); - assert_eq!(replacement.format.bold, Some(true)); - assert_eq!(replacement.format.underline, Some(true)); - assert_ne!(replacement.format.italic, Some(true)); - let suffix = runs.last().unwrap(); - assert_eq!(suffix.text, "\u{301}fg"); - assert_ne!(suffix.format.bold, Some(true)); - assert_ne!(suffix.format.underline, Some(true)); - assert_ne!(suffix.format.italic, Some(true)); - } -} - -#[test] -fn offline_formatting_rebases_text_and_merges_independent_attributes() { - use onestore::TextAttribute as A; - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("format.sqlite"); - let source = onestore::create_section("format.one", "ab🦀cd", "Author").unwrap(); - let (sid, id, _) = text(&source); - let cache = Replica::create(&path, &source).unwrap(); - let local = cache - .format(&source, sid, id, 2..4, &[A::Bold(true)]) - .unwrap() - .unwrap(); - let pending = cache.pending().unwrap(); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.pending().unwrap(), pending); - let moved = PreparedEdit::text(&source, sid, id, 0..0, "Prefix ").unwrap(); - let styled = - PreparedEdit::format(moved.as_bytes(), sid, id, 9..11, &[A::Italic(true)]).unwrap(); - let mut server = Server::new(styled.as_bytes()); - assert!( - matches!(cache.sync_once(&mut server).unwrap(),Some((observed,EditStatus::Published{..}))if observed==local) - ); - let store = Store::parse(&server.durable).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let space = &doc.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let runs = view.text_runs(id).unwrap(); - let selected = runs.iter().find(|r| r.text == "🦀").unwrap(); - assert_eq!(selected.format.bold, Some(true)); - assert_eq!(selected.format.italic, Some(true)); - assert_eq!( - runs.iter().map(|r| r.text).collect::(), - "Prefix ab🦀cd" - ); -} - -#[test] -fn competing_font_changes_remain_preserved_conflicts() { - use onestore::TextAttribute as A; - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("conflict.sqlite"); - let source = onestore::create_section("format.one", "abcdef", "Author").unwrap(); - let (sid, id, _) = text(&source); - let cache = Replica::create(&path, &source).unwrap(); - let local_id = cache - .format(&source, sid, id, 1..5, &[A::FontSize(14.0)]) - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - let pending = cache.pending().unwrap(); - let remote = PreparedEdit::format(&source, sid, id, 2..4, &[A::FontSize(18.0)]).unwrap(); - let mut server = Server::new(remote.as_bytes()); - for _ in 0..3 { - assert_eq!( - cache.sync_once(&mut server).unwrap(), - Some(( - local_id, - EditStatus::Conflict(ConflictKind::FormattingChanged) - )) - ); - } - assert_eq!(server.publications, 0); - assert_eq!(server.confirmations, 0); - assert_eq!(cache.snapshot().unwrap(), local); - assert_eq!(cache.pending().unwrap(), pending); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!( - cache.status(local_id).unwrap(), - Some(EditStatus::Conflict(ConflictKind::FormattingChanged)) - ); - assert_eq!(cache.snapshot().unwrap(), local); -} - -#[test] -fn independently_satisfied_formatting_requires_confirmation_before_a_receipt() { - use onestore::TextAttribute as A; - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("satisfied.sqlite"); - let source = onestore::create_section("format.one", "abcdef", "Author").unwrap(); - let (sid, id, _) = text(&source); - let cache = Replica::create(&path, &source).unwrap(); - let local_id = cache - .format(&source, sid, id, 1..5, &[A::Bold(true)]) - .unwrap() - .unwrap(); - let remote = PreparedEdit::format(&source, sid, id, 1..5, &[A::Bold(true)]).unwrap(); - let mut server = Server::new(remote.as_bytes()); - server.durable = source.clone(); - server.fault = Fault::Confirm; - assert!( - matches!(cache.sync_once(&mut server),Err(Error::Remote(e))if e.state==CommitState::Unknown) - ); - assert_eq!(cache.status(local_id).unwrap(), Some(EditStatus::Pending)); - assert_eq!(server.publications, 0); - assert_eq!(server.durable, source); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert!( - matches!(cache.sync_once(&mut server).unwrap(),Some((id,EditStatus::Published{..}))if id==local_id) - ); - assert_eq!(server.publications, 0); - assert_eq!(server.confirmations, 2); - assert_ne!(server.durable, source); - assert!(cache.pending().unwrap().is_empty()); -} - -#[test] -fn retired_format_attempts_require_the_complete_durable_effect_without_replay() { - use onestore::TextAttribute as A; - for (complete, fault) in [ - (true, Fault::None), - (true, Fault::Confirm), - (true, Fault::ConfirmCommitted), - (false, Fault::None), - ] { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("retired-format.sqlite"); - let source = onestore::create_section("format.one", "abcdef", "Author").unwrap(); - let (sid, object, _) = text(&source); - let cache = Replica::create(&path, &source).unwrap(); - let id = cache - .format( - &source, - sid, - object, - 1..5, - &[A::Bold(true), A::FontSize(18.0)], - ) - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - let mut server = Server::new(&source); - server.fault = Fault::UnknownAfter; - assert!(cache.sync_once(&mut server).is_err()); - let attempted = cache.status(id).unwrap(); - let Some(EditStatus::AwaitingConfirmation { revision: retired }) = attempted else { - panic!() - }; - drop(cache); - let prefix = PreparedEdit::text(&source, sid, object, 0..0, "prefix ").unwrap(); - let mut attributes = vec![A::Bold(true), A::Italic(true)]; - if complete { - attributes.push(A::FontSize(18.0)); - } - server.visible = PreparedEdit::format(prefix.as_bytes(), sid, object, 8..12, &attributes) - .unwrap() - .as_bytes() - .to_vec(); - let store = Store::parse(&server.visible).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let current = index.spaces[&sid].labels[&(ExGuid::default(), 1)]; - assert!(!index.spaces[&sid].revisions.contains_key(&retired)); - let cache = Replica::open(&path).unwrap(); - server.fault = fault; - let result = cache.sync_once(&mut server); - if !complete { - assert_eq!(result.unwrap(), attempted.map(|s| (id, s))); - assert_eq!(server.confirmations, 0); - assert_eq!(cache.snapshot().unwrap(), local); - assert_eq!(server.durable, source); - } else { - if matches!(fault, Fault::Confirm) { - assert!(matches!(result, Err(Error::Remote(e)) if e.state == CommitState::Unknown)); - assert_eq!(cache.status(id).unwrap(), attempted); - assert_eq!(cache.snapshot().unwrap(), local); - assert_eq!(server.durable, source); - let complete = server.visible.clone(); - server.visible = - PreparedEdit::format(&complete, sid, object, 8..12, &[A::Bold(false)]) - .unwrap() - .as_bytes() - .to_vec(); - assert_eq!( - cache.sync_once(&mut server).unwrap(), - attempted.map(|s| (id, s)) - ); - assert_eq!(server.confirmations, 1); - assert_eq!(cache.snapshot().unwrap(), local); - server.visible = complete; - cache.sync_once(&mut server).unwrap(); - } else if matches!(fault, Fault::ConfirmCommitted) { - assert!( - matches!(result, Err(Error::Remote(e)) if e.state == CommitState::Committed) - ); - } else { - assert_eq!( - result.unwrap(), - Some((id, EditStatus::Published { revision: current })) - ); - } - assert_ne!(current, retired); - assert_eq!( - cache.status(id).unwrap(), - Some(EditStatus::Published { revision: current }) - ); - assert!(cache.pending().unwrap().is_empty()); - assert_eq!(text(&server.durable).2, "prefix abcdef"); - assert_ne!(server.durable, source); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!( - cache.status(id).unwrap(), - Some(EditStatus::Published { revision: current }) - ); - } - assert_eq!(server.publications, 1); - } -} - -#[test] -fn retired_text_with_equal_plaintext_but_a_different_surviving_run_remains_uncertain() { - use onestore::TextAttribute; - - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("repeated.sqlite"); - let plain = onestore::create_section("repeated.one", "aa", "Fixture").unwrap(); - let (space, object, _) = text(&plain); - let source = PreparedEdit::format(&plain, space, object, 0..1, &[TextAttribute::Bold(true)]) - .unwrap() - .as_bytes() - .to_vec(); - let cache = Replica::create(&path, &source).unwrap(); - let id = cache - .edit_text(&source, space, object, 0..1, "") - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - let mut server = Server::new(&source); - server.fault = Fault::UnknownAfter; - assert!(cache.sync_once(&mut server).is_err()); - let attempt = cache.status(id).unwrap(); - server.visible = onestore::replace_text(&source, space, object, 1..2, "").unwrap(); - assert_eq!(text(&server.visible).2, text(&local).2); - let bold = |bytes: &[u8]| { - let store = Store::parse(bytes).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let space = &document.spaces[&space]; - space.revisions[&space.contexts[&ExGuid::default()]] - .text_runs(object) - .unwrap() - .into_iter() - .find(|run| !run.text.is_empty()) - .unwrap() - .format - .bold - }; - assert_eq!(bold(&server.visible), Some(true)); - assert_ne!(bold(&server.visible), bold(&local)); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!( - cache.sync_once(&mut server).unwrap(), - attempt.map(|state| (id, state)) - ); - assert_eq!(server.publications, 1); - assert_eq!(server.confirmations, 0); - assert_eq!(cache.snapshot().unwrap(), local); -} - -#[test] -fn uncertain_formatting_keeps_the_original_attempt_and_never_replays() { - use onestore::TextAttribute as A; - for fault in [Fault::UnknownBefore, Fault::UnknownAfter] { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("unknown-format.sqlite"); - let source = onestore::create_section("format.one", "abcdef", "Author").unwrap(); - let (sid, id, _) = text(&source); - let cache = Replica::create(&path, &source).unwrap(); - let local_id = cache - .format(&source, sid, id, 1..5, &[A::Bold(true)]) - .unwrap() - .unwrap(); - let mut server = Server::new(&source); - server.fault = fault; - assert!( - matches!(cache.sync_once(&mut server),Err(Error::Remote(e))if e.state==CommitState::Unknown) - ); - let status = cache.status(local_id).unwrap(); - drop(cache); - let cache = Replica::open(&path).unwrap(); - if matches!(fault, Fault::UnknownBefore) { - for _ in 0..4 { - assert_eq!( - cache.sync_once(&mut server).unwrap(), - status.map(|s| (local_id, s)) - ); - } - assert_eq!(server.confirmations, 0); - } else { - assert!(matches!( - cache.sync_once(&mut server).unwrap(), - Some((_, EditStatus::Published { .. })) - )); - assert_eq!(server.confirmations, 1); - } - assert_eq!(server.publications, 1); - } -} - -#[test] -fn reviewed_format_conflicts_preserve_dependent_edits_and_recheck_later_remote_changes() { - use onestore::TextAttribute as A; - for changed_again in [false, true] { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("review-format.sqlite"); - let source = onestore::create_section("format.one", "abcdef", "Author").unwrap(); - let (sid, id, _) = text(&source); - let cache = Replica::create(&path, &source).unwrap(); - let first = cache - .format(&source, sid, id, 1..5, &[A::FontSize(14.0)]) - .unwrap() - .unwrap(); - let second = cache - .edit_text(&cache.snapshot().unwrap(), sid, id, 2..2, "X") - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - let pending = cache.pending().unwrap(); - let remote = PreparedEdit::format(&source, sid, id, 1..5, &[A::FontSize(18.0)]).unwrap(); - let mut server = Server::new(remote.as_bytes()); assert_eq!( - cache.sync_once(&mut server).unwrap(), - Some((first, EditStatus::Conflict(ConflictKind::FormattingChanged))) - ); - assert!( - matches!(cache.rebase_conflict(first,&source,remote.as_bytes(),1..5),Err(Error::Io(e))if e.kind()==io::ErrorKind::ResourceBusy) - ); - cache - .rebase_conflict(first, &local, remote.as_bytes(), 1..5) - .unwrap(); - assert_eq!(cache.snapshot().unwrap(), local); - assert_eq!(cache.pending().unwrap()[1], pending[1]); - drop(cache); - let cache = Replica::open(&path).unwrap(); - if changed_again { - let newer = - PreparedEdit::format(&server.visible, sid, id, 1..5, &[A::FontSize(20.0)]).unwrap(); - server = Server::new(newer.as_bytes()); - assert_eq!( - cache.sync_once(&mut server).unwrap(), - Some((first, EditStatus::Conflict(ConflictKind::FormattingChanged))) - ); - assert_eq!(server.publications, 0); - assert_eq!(cache.snapshot().unwrap(), local); - } else { - for expected in [first, second] { - assert!( - matches!(cache.sync_once(&mut server).unwrap(),Some((id,EditStatus::Published{..}))if id==expected) - ); - } - assert_eq!(text(&server.durable).2, "abXcdef"); - assert!(cache.pending().unwrap().is_empty()); - } - } -} - -#[test] -fn superscript_reconciliation_checks_the_implicit_subscript_change() { - use onestore::TextAttribute as A; - let directory = tempfile::tempdir().unwrap(); - let source = onestore::create_section("script.one", "abc", "Author").unwrap(); - let (sid, id, _) = text(&source); - let cache = Replica::create(directory.path().join("script.sqlite"), &source).unwrap(); - let local = cache - .format(&source, sid, id, 0..3, &[A::Superscript(true)]) - .unwrap() - .unwrap(); - let remote = PreparedEdit::format(&source, sid, id, 0..3, &[A::Subscript(true)]).unwrap(); - let mut server = Server::new(remote.as_bytes()); - assert_eq!( - cache.sync_once(&mut server).unwrap(), - Some((local, EditStatus::Conflict(ConflictKind::FormattingChanged))) - ); - assert_eq!(server.publications, 0); -} - -#[test] -fn seeded_formatting_reconciliation_matches_a_character_model() { - use onestore::TextAttribute as A; - #[derive(Clone, Debug, PartialEq)] - struct Style { - size: f32, - color: u32, - bold: bool, - italic: bool, - } - let mut conflicts = 0; - let mut published = 0; - let mut satisfied_parts = 0; - for seed in 1_u64..=128 { - let mut random = seed; - let mut next = || { - random = random - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - random >> 32 - }; - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("model.sqlite"); - let mut source = - onestore::create_section("model.one", "abcdefghijklmnopqrstuvwxyz012345", "Author") - .unwrap(); - let (sid, object, original_text) = text(&source); - let mut baseline = vec![ - Style { - size: 11.0, - color: 0xff000000, - bold: false, - italic: false - }; - 32 - ]; - for _ in 0..6 { - let start = next() as usize % 32; - let end = start + 1 + next() as usize % (32 - start); - let size = [12.0, 14.0, 18.0][next() as usize % 3]; - let color: u32 = [0xabcdef, 0x987654, 0xff000000][next() as usize % 3]; - let bold = next() % 2 == 0; - source = PreparedEdit::format( - &source, - sid, - object, - start as u32..end as u32, - &[ - A::FontSize(size), - A::Color((color != 0xff000000).then(|| { - let b = color.to_le_bytes(); - [b[0], b[1], b[2]] - })), - A::Bold(bold), - ], - ) - .unwrap() - .as_bytes() - .to_vec(); - for style in &mut baseline[start..end] { - style.size = size; - style.color = color; - style.bold = bold; - } - } - let start = next() as usize % 24; - let end = start + 2 + next() as usize % (31 - start); - let attribute = match seed % 3 { - 0 => A::FontSize(21.0), - 1 => A::Color(Some([0x12, 0x34, 0x56])), - _ => A::Bold(!baseline[start].bold), - }; - let cache = Replica::create(&path, &source).unwrap(); - let id = cache - .format( - &source, - sid, - object, - start as u32..end as u32, - std::slice::from_ref(&attribute), - ) - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - let mut remote = source.clone(); - let mut expected = baseline.clone(); - for step in 0..8 { - let left = next() as usize % 32; - let right = left + 1 + next() as usize % (32 - left); - let update = if step % 3 == 0 { - attribute.clone() - } else { - match next() % 4 { - 0 => A::FontSize([11.0, 14.0, 18.0, 21.0][next() as usize % 4]), - 1 => A::Color(Some([0x99, 0x88, 0x77])), - 2 => A::Bold(next() % 2 == 0), - _ => A::Italic(true), - } - }; - remote = PreparedEdit::format( - &remote, - sid, - object, - left as u32..right as u32, - std::slice::from_ref(&update), - ) - .unwrap() - .as_bytes() - .to_vec(); - for style in &mut expected[left..right] { - match update { - A::FontSize(value) => style.size = value, - A::Color(Some([r, g, b])) => style.color = u32::from_le_bytes([r, g, b, 0]), - A::Bold(value) => style.bold = value, - A::Italic(value) => style.italic = value, - _ => unreachable!(), - } - } - } - let conflict = (start..end).any(|i| match attribute { - A::FontSize(value) => expected[i].size != baseline[i].size && expected[i].size != value, - A::Color(_) => expected[i].color != baseline[i].color && expected[i].color != 0x563412, - A::Bold(value) => expected[i].bold != baseline[i].bold && expected[i].bold != value, - _ => unreachable!(), - }); - drop(cache); - let cache = Replica::open(&path).unwrap(); - let mut server = Server::new(&remote); - let result = cache.sync_once(&mut server).unwrap().unwrap(); - assert_eq!(result.0, id, "seed {seed}"); - if conflict { - conflicts += 1; - assert_eq!( - result.1, - EditStatus::Conflict(ConflictKind::FormattingChanged), - "seed {seed}" - ); - assert_eq!(server.publications, 0, "seed {seed}"); - assert_eq!(cache.snapshot().unwrap(), local, "seed {seed}"); - continue; - } - published += 1; - assert!( - matches!(result.1, EditStatus::Published { .. }), - "seed {seed}: {result:?}" - ); - for style in &mut expected[start..end] { - match attribute { - A::FontSize(value) => { - satisfied_parts += usize::from(style.size == value); - style.size = value; - } - A::Color(_) => { - satisfied_parts += usize::from(style.color == 0x563412); - style.color = 0x563412; - } - A::Bold(value) => { - satisfied_parts += usize::from(style.bold == value); - style.bold = value; - } - _ => unreachable!(), - } - } - assert_eq!(text(&server.durable).2, original_text, "seed {seed}"); - let store = Store::parse(&server.durable).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let space = &doc.spaces[&sid]; - let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; - let actual: Vec<_> = revision - .text_runs(object) - .unwrap() - .iter() - .flat_map(|run| { - std::iter::repeat_n( - Style { - size: run.format.font_size.unwrap(), - color: run.format.color.unwrap_or(0xff000000), - bold: run.format.bold.unwrap_or(false), - italic: run.format.italic.unwrap_or(false), - }, - run.text.chars().count(), - ) - }) - .collect(); - assert_eq!(actual, expected, "seed {seed}"); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.status(id).unwrap(), Some(result.1)); - let snapshot = cache.snapshot().unwrap(); - assert_eq!(snapshot.len(), server.durable.len(), "seed {seed}"); - assert!( - snapshot - .iter() - .zip(&server.durable) - .enumerate() - .all(|(offset, (a, b))| a == b || (212..252).contains(&offset)), - "seed {seed}: only confirmation version metadata may change" - ); - assert_eq!(cache.sync_once(&mut server).unwrap(), None); - assert!( - cache.snapshot().unwrap() == server.durable, - "seed {seed}: refreshed snapshot" + intent.text_change(), + Some((oid, "aRc".into(), 1..2, "L".into())) ); } - assert!( - conflicts >= 16 && published >= 32 && satisfied_parts >= 128, - "conflicts={conflicts}, published={published}, already desired characters={satisfied_parts}" - ); - println!( - "128 seeds: {conflicts} conflicts, {published} publications, {satisfied_parts} already desired characters" - ); -} - -#[test] -fn inserted_empty_text_retains_formatting_and_dependent_text_across_sync() { - use onestore::{Insertion, TextAttribute as A}; - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("empty.sqlite"); - let source = onestore::create_section("empty.one", "Original", "Author").unwrap(); - let store = Store::parse(&source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let (sid, page) = doc.pages().unwrap()[0]; - let insertion = Insertion::outline(page, 144.0, 144.0, "", "Author").unwrap(); - let cache = Replica::create(&path, &source).unwrap(); - let first = cache.insert(&source, sid, &insertion).unwrap().unwrap(); - let second = cache - .format( - &cache.snapshot().unwrap(), - sid, - insertion.text_object(), - 0..0, - &[A::Bold(true), A::FontSize(18.0)], - ) - .unwrap() - .unwrap(); - let third = cache - .edit_text( - &cache.snapshot().unwrap(), - sid, - insertion.text_object(), - 0..0, - "Typed 🦀", - ) - .unwrap() - .unwrap(); - let pending = cache.pending().unwrap(); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.pending().unwrap(), pending); - let mut server = Server::new(&source); - for id in [first, second, third] { - let result = cache.sync_once(&mut server).unwrap(); - assert!( - matches!(result,Some((actual,EditStatus::Published{..}))if actual==id), - "{result:?}" - ); - } - let store = Store::parse(&server.durable).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let space = &doc.spaces[&sid]; - let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; - let runs = revision.text_runs(insertion.text_object()).unwrap(); - assert_eq!(runs.iter().map(|r| r.text).collect::(), "Typed 🦀"); - assert!( - runs.iter() - .all(|r| r.format.bold == Some(true) && r.format.font_size == Some(18.0)) - ); - assert_eq!(cache.snapshot().unwrap(), server.durable); -} - -#[test] -fn every_visual_attribute_rebases_with_an_independent_remote_attribute() { - use onestore::TextAttribute as A; - use serde_json::json; - let mut cases = Vec::new(); - for value in [false, true] { - cases.extend([ - (A::Bold(!value), A::Bold(value), "bold", json!(value)), - (A::Italic(!value), A::Italic(value), "italic", json!(value)), - ( - A::Underline(!value), - A::Underline(value), - "underline", - json!(value), - ), - (A::Strike(!value), A::Strike(value), "strike", json!(value)), - ( - A::Superscript(!value), - A::Superscript(value), - "superscript", - json!(value), - ), - ( - A::Subscript(!value), - A::Subscript(value), - "subscript", - json!(value), - ), - ]); - } - cases.extend([ - ( - A::Font("Georgia".into()), - A::Font("Arial".into()), - "font", - json!("Arial"), - ), - ( - A::FontSize(11.0), - A::FontSize(18.0), - "font_size", - json!(18.0), - ), - ( - A::Color(None), - A::Color(Some([1, 2, 3])), - "color", - json!(0x030201), - ), - ( - A::Color(Some([1, 2, 3])), - A::Color(None), - "color", - json!(0xff000000_u32), - ), - ( - A::Highlight(None), - A::Highlight(Some([4, 5, 6])), - "highlight", - json!(0x060504), - ), - ( - A::Highlight(Some([4, 5, 6])), - A::Highlight(None), - "highlight", - json!(0xff000000_u32), - ), - ]); - for (baseline, desired, field, value) in cases { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("attribute.sqlite"); - let source = onestore::create_section("attribute.one", "abc", "Author").unwrap(); - let (sid, object, _) = text(&source); - let source = PreparedEdit::format(&source, sid, object, 0..3, &[baseline]) - .unwrap() - .as_bytes() - .to_vec(); - let cache = Replica::create(&path, &source).unwrap(); - let id = cache - .format(&source, sid, object, 0..3, &[desired]) - .unwrap() - .unwrap(); - let moved = PreparedEdit::text(&source, sid, object, 0..0, "Z").unwrap(); - let (other, other_field, other_value) = if field == "font_size" { - (A::Italic(true), "italic", json!(true)) - } else { - (A::FontSize(22.0), "font_size", json!(22.0)) - }; - let remote = PreparedEdit::format(moved.as_bytes(), sid, object, 1..4, &[other]).unwrap(); - let mut server = Server::new(remote.as_bytes()); - drop(cache); - let cache = Replica::open(&path).unwrap(); - let result = cache.sync_once(&mut server).unwrap(); - assert!( - matches!(result,Some((observed,EditStatus::Published{..}))if observed==id), - "{field}={value}: {result:?}" - ); - assert_eq!(text(&server.durable).2, "Zabc"); - let store = Store::parse(&server.durable).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let space = &doc.spaces[&sid]; - let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; - let mut position = 0; - for run in revision.text_runs(object).unwrap() { - let format = serde_json::to_value(run.format).unwrap(); - for _ in run.text.chars() { - if position > 0 { - assert_eq!(format[field], value, "{field}, character {position}"); - assert_eq!( - format[other_field], other_value, - "{field}, character {position}" - ); - } - position += 1; - } - } - assert_eq!(position, 4); - } -} - -#[test] -fn reviewed_insertion_placements_preserve_identity_and_dependent_operations() { - use notebook::Operation; - use onestore::{Insertion, TextAttribute as A}; - for outline_case in [false, true] { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("placement.sqlite"); - let base = onestore::create_section("placement.one", "Original", "Author").unwrap(); - let (sid, _, _) = text(&base); - let store = Store::parse(&base).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let (_, page) = doc.pages().unwrap()[0]; - let space = &doc.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let parent = *view.nodes[&page] - .children - .iter() - .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) - .unwrap(); - let anchor = Insertion::paragraph(parent, None, "Temporary anchor", "Author").unwrap(); - let source = PreparedEdit::insert(&base, sid, &anchor) - .unwrap() - .as_bytes() - .to_vec(); - let insertion = if outline_case { - Insertion::outline(page, 144.0, 144.0, "Offline", "Author").unwrap() - } else { - Insertion::paragraph(parent, Some(anchor.object()), "Offline", "Author").unwrap() - } - .with_formatting(0..7, &[A::Italic(true)]) - .unwrap(); - let cache = Replica::create(&path, &source).unwrap(); - let first = cache.insert(&source, sid, &insertion).unwrap().unwrap(); - let second = cache - .format( - &cache.snapshot().unwrap(), - sid, - insertion.text_object(), - 0..7, - &[A::Bold(true)], - ) - .unwrap() - .unwrap(); - let third = cache - .edit_text( - &cache.snapshot().unwrap(), - sid, - insertion.text_object(), - 7..7, - " 🦀", - ) - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - let pending = cache.pending().unwrap(); - let mut server = Server::new(&base); - if outline_case { - drop(cache); - let db = rusqlite::Connection::open(&path).unwrap(); - db.execute( - "INSERT INTO conflicts VALUES (?1,2)", - [i64::try_from(first).unwrap()], - ) - .unwrap(); - db.execute("UPDATE replica SET base=?1", [&base]).unwrap(); - drop(db); - } else { - assert_eq!( - cache.sync_once(&mut server).unwrap(), - Some((first, EditStatus::Conflict(ConflictKind::UnsupportedEdit))) - ); - drop(cache); - } - let cache = Replica::open(&path).unwrap(); - if outline_case { - assert!( - cache - .rebase_paragraph_conflict(first, &local, &base, parent, None) - .is_err() - ); - assert!( - cache - .rebase_outline_conflict(first, &local, &base, page, f32::NAN, 288.0) - .is_err() - ); - assert!( - matches!(cache.rebase_outline_conflict(first,&source,&base,page,288.0,360.0),Err(Error::Io(e))if e.kind()==io::ErrorKind::ResourceBusy) - ); - cache - .rebase_outline_conflict(first, &local, &base, page, 288.0, 360.0) - .unwrap(); - } else { - assert!( - cache - .rebase_outline_conflict(first, &local, &base, page, 288.0, 360.0) - .is_err() - ); - assert!( - cache - .rebase_paragraph_conflict(first, &local, &base, parent, Some(anchor.object())) - .is_err() - ); - assert!( - matches!(cache.rebase_paragraph_conflict(first,&source,&base,parent,None),Err(Error::Io(e))if e.kind()==io::ErrorKind::ResourceBusy) - ); - cache - .rebase_paragraph_conflict(first, &local, &base, parent, None) - .unwrap(); - } - assert_eq!(cache.snapshot().unwrap(), local); - assert_eq!(cache.pending().unwrap()[1..], pending[1..]); - let Operation::Insert(rebased) = cache.pending().unwrap().remove(0).operation else { - panic!() - }; - assert_eq!(rebased.object(), insertion.object()); - assert_eq!(rebased.text_object(), insertion.text_object()); - assert_eq!( - serde_json::to_value(&rebased).unwrap()["formats"], - serde_json::to_value(&insertion).unwrap()["formats"] - ); - assert_eq!(cache.status(first).unwrap(), Some(EditStatus::Pending)); - drop(cache); - let cache = Replica::open(&path).unwrap(); - for id in [first, second, third] { - let result = cache.sync_once(&mut server).unwrap(); - assert!( - matches!(result,Some((actual,EditStatus::Published{..}))if actual==id), - "{result:?}" - ); - } - let store = Store::parse(&server.durable).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let space = &doc.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let runs = view.text_runs(insertion.text_object()).unwrap(); - assert_eq!( - runs.iter().map(|r| r.text).collect::(), - "Offline 🦀" - ); - assert!(runs.iter().all(|r| r.format.bold == Some(true))); - assert!(runs.iter().all(|r| r.format.italic == Some(true))); - if outline_case { - assert_eq!( - ( - view.nodes[&insertion.object()].layout.x, - view.nodes[&insertion.object()].layout.y - ), - (Some(288.0), Some(360.0)) - ); - } else { - assert_eq!( - view.nodes[&parent].children.last(), - Some(&insertion.object()) - ); - } - assert!(!view.nodes.contains_key(&anchor.object())); - assert_eq!(server.publications, 3); - assert!(cache.pending().unwrap().is_empty()); - assert_eq!(cache.snapshot().unwrap(), server.durable); - } -} - -#[test] -fn placement_reviews_cannot_replace_pending_or_uncertain_attempts() { - use onestore::Insertion; - for outline_case in [false, true] { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("attempt-placement.sqlite"); - let source = onestore::create_section("placement.one", "Original", "Author").unwrap(); - let store = Store::parse(&source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let (sid, page) = doc.pages().unwrap()[0]; - let space = &doc.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let parent = *view.nodes[&page] - .children - .iter() - .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) - .unwrap(); - let insertion = if outline_case { - Insertion::outline(page, 144.0, 144.0, "Offline", "Author").unwrap() - } else { - Insertion::paragraph(parent, None, "Offline", "Author").unwrap() - }; - let cache = Replica::create(&path, &source).unwrap(); - let id = cache.insert(&source, sid, &insertion).unwrap().unwrap(); - let local = cache.snapshot().unwrap(); - let pending = cache.pending().unwrap(); - let mut server = Server::new(&source); - for attempted in [false, true] { - if attempted { - server.fault = Fault::UnknownBefore; - assert!( - matches!(cache.sync_once(&mut server),Err(Error::Remote(e))if e.state==CommitState::Unknown) - ); - } - let state = cache.status(id).unwrap(); - let result = if outline_case { - cache.rebase_outline_conflict(id, &local, &source, page, 288.0, 360.0) - } else { - cache.rebase_paragraph_conflict(id, &local, &source, parent, None) - }; - assert!(matches!(result,Err(Error::Io(e))if e.kind()==io::ErrorKind::InvalidInput)); - assert_eq!(cache.pending().unwrap(), pending); - assert_eq!(cache.status(id).unwrap(), state); - assert_eq!(cache.snapshot().unwrap(), local); - } - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.pending().unwrap(), pending); - assert!(matches!( - cache.sync_once(&mut server).unwrap(), - Some((_, EditStatus::AwaitingConfirmation { .. })) - )); - assert_eq!(server.publications, 1); - } } diff --git a/crates/notebook/tests/sync/outline.rs b/crates/notebook/tests/sync/outline.rs deleted file mode 100644 index 7863cb221020478fff27934fe832ec49f95933c6..0000000000000000000000000000000000000000 --- a/crates/notebook/tests/sync/outline.rs +++ /dev/null @@ -1,719 +0,0 @@ -use super::*; -use onestore::OutlineEdit as Change; - -pub(super) fn fixture() -> (Vec, ExGuid, ExGuid, ExGuid, ExGuid) { - let source = onestore::create_section("layout.one", "Original 🦀 é", "Author").unwrap(); - let (sid, text, _) = text(&source); - let store = Store::parse(&source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let space = &document.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let paragraph = *view - .nodes - .iter() - .find(|(_, node)| node.content == [text]) - .unwrap() - .0; - let outline = *view - .nodes - .iter() - .find(|(_, node)| node.children.contains(¶graph)) - .unwrap() - .0; - (source, sid, outline, paragraph, text) -} - -pub(super) fn node(source: &[u8], sid: ExGuid, object: ExGuid) -> serde_json::Value { - let store = Store::parse(source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - index.validate_current().unwrap(); - let document = Document::parse(&index).unwrap(); - let space = &document.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - serde_json::to_value(&view.nodes[&object]).unwrap() -} - -#[test] -fn layout_and_dependent_text_survive_reopen_and_independent_remote_formatting() { - let (source, sid, outline, paragraph, text_id) = fixture(); - for (object, change) in [ - (outline, Change::Position { x: 180.0, y: 216.0 }), - ( - outline, - Change::Width { - points: 144.0, - user_set: true, - }, - ), - (paragraph, Change::Collapsed(true)), - ] { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("cache.sqlite"); - let cache = Replica::create(&path, &source).unwrap(); - let id = cache - .outline(&source, sid, object, change) - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - let expected = node(&local, sid, object); - let dependent = cache - .edit_text(&local, sid, text_id, 0..0, "Local ") - .unwrap() - .unwrap(); - let pending = cache.pending().unwrap(); - let local = cache.snapshot().unwrap(); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.pending().unwrap(), pending); - assert!(cache.snapshot().unwrap() == local); - let remote = PreparedEdit::format( - &source, - sid, - text_id, - 0..8, - &[onestore::TextAttribute::Bold(true)], - ) - .unwrap(); - let mut server = Server::new(remote.as_bytes()); - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id) - ); - let actual = node(&server.durable, sid, object); - assert_eq!(actual["layout"], expected["layout"]); - assert_eq!(actual["kind"], expected["kind"]); - assert_eq!(cache.pending().unwrap().len(), 1); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==dependent) - ); - assert_eq!(text(&server.durable).2, "Local Original 🦀 é"); - assert!(cache.pending().unwrap().is_empty()); - let store = Store::parse(&server.durable).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let space = &document.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - assert!(view.text_runs(text_id).unwrap()[0].format.bold.unwrap()); - assert_eq!(server.publications, 2); - drop(cache); - let cache = Replica::open(&path).unwrap(); - for id in [id, dependent] { - assert!(matches!( - cache.status(id).unwrap(), - Some(EditStatus::Published { .. }) - )); - } - } -} - -#[test] -fn rebased_outline_movement_uses_remote_title_text_and_confirms_after_reopen() { - let (source, sid, outline, _, text_id) = fixture(); - let store = Store::parse(&source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let (_, page) = document.pages().unwrap()[0]; - let space = &document.spaces[&sid]; - let metadata = space.revisions[&space.contexts[&ExGuid::default()]].roots[&2]; - let second = onestore::Insertion::outline(page, 144.0, 36.0, "Second", "Author").unwrap(); - let source = PreparedEdit::insert(&source, sid, &second) - .unwrap() - .as_bytes() - .to_vec(); - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("titles.sqlite"); - let cache = Replica::create(&path, &source).unwrap(); - let movement = cache - .outline( - &source, - sid, - outline, - Change::Position { x: 216.0, y: 72.0 }, - ) - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - assert_eq!(node(&local, sid, metadata)["kind"]["title"], "Second"); - let dependent = cache - .edit_text(&local, sid, text_id, 0..0, "Local ") - .unwrap() - .unwrap(); - drop(cache); - let remote = - PreparedEdit::text(&source, sid, second.text_object(), 0..6, "Remote second 🐈").unwrap(); - let mut server = Server::new(remote.as_bytes()); - server.fault = Fault::UnknownAfter; - let cache = Replica::open(&path).unwrap(); - assert!(cache.sync_once(&mut server).is_err()); - assert!(matches!( - cache.status(movement).unwrap(), - Some(EditStatus::AwaitingConfirmation { .. }) - )); - assert_eq!( - node(&server.durable, sid, metadata)["kind"]["title"], - "Original 🦀 é" - ); - assert_eq!( - node(&server.visible, sid, metadata)["kind"]["title"], - "Remote second 🐈" - ); - drop(cache); - for id in [movement, dependent] { - let cache = Replica::open(&path).unwrap(); - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) - ); - assert_eq!( - node(&server.durable, sid, metadata)["kind"]["title"], - "Remote second 🐈" - ); - assert_eq!( - node(&server.durable, sid, page)["kind"]["alternate_title"], - "Remote second 🐈" - ); - } - let cache = Replica::open(&path).unwrap(); - assert!(cache.pending().unwrap().is_empty()); - assert_eq!( - node(&cache.snapshot().unwrap(), sid, text_id)["kind"]["text"], - "Local Original 🦀 é" - ); - assert_eq!(server.publications, 2); -} - -#[test] -fn competing_layout_requires_current_review_and_preserves_dependent_edits() { - let (source, sid, outline, _, text_id) = fixture(); - for (local_change, remote_change) in [ - ( - Change::Position { x: 180.0, y: 216.0 }, - Change::Position { x: 288.0, y: 216.0 }, - ), - ( - Change::Width { - points: 144.0, - user_set: true, - }, - Change::Width { - points: 216.0, - user_set: false, - }, - ), - ] { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("cache.sqlite"); - let cache = Replica::create(&path, &source).unwrap(); - let id = cache - .outline(&source, sid, outline, local_change) - .unwrap() - .unwrap(); - let dependent = cache - .edit_text(&cache.snapshot().unwrap(), sid, text_id, 0..0, "Local ") - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - let queue = cache.pending().unwrap(); - let remote = PreparedEdit::outline(&source, sid, outline, remote_change).unwrap(); - let mut server = Server::new(remote.as_bytes()); - assert_eq!( - cache.sync_once(&mut server).unwrap(), - Some((id, EditStatus::Conflict(ConflictKind::LayoutChanged))) - ); - assert_eq!(server.publications, 0); - assert_eq!(cache.pending().unwrap(), queue); - assert!(cache.snapshot().unwrap() == local); - assert!( - cache - .rebase_layout_conflict(id, &source, &server.visible) - .is_err() - ); - assert!(cache.rebase_layout_conflict(id, &local, &source).is_err()); - assert!( - cache - .rebase_conflict(id, &local, &server.visible, 0..0) - .is_err() - ); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!( - cache.status(id).unwrap(), - Some(EditStatus::Conflict(ConflictKind::LayoutChanged)) - ); - cache - .rebase_layout_conflict(id, &local, &server.visible) - .unwrap(); - let reviewed = cache.pending().unwrap(); - assert_eq!(reviewed[1], queue[1]); - assert!(cache.snapshot().unwrap() == local); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.pending().unwrap(), reviewed); - for id in [id, dependent] { - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) - ); - } - assert_eq!( - node(&server.durable, sid, outline)["layout"], - node(&local, sid, outline)["layout"] - ); - assert_eq!(text(&server.durable).2, "Local Original 🦀 é"); - } -} - -#[test] -fn independent_axes_converge_without_overwriting_competing_values() { - let (source, sid, outline, _, _) = fixture(); - let directory = tempfile::tempdir().unwrap(); - let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); - let original = node(&source, sid, outline); - let local = Change::Position { x: 180.0, y: 216.0 }; - let id = cache - .outline(&source, sid, outline, local) - .unwrap() - .unwrap(); - let remote = PreparedEdit::outline( - &source, - sid, - outline, - Change::Position { - x: original["layout"]["x"].as_f64().unwrap() as f32, - y: 216.0, - }, - ) - .unwrap(); - let mut server = Server::new(remote.as_bytes()); - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id) - ); - let actual = node(&server.durable, sid, outline); - assert_eq!(actual["layout"]["x"], 180.0); - assert_eq!(actual["layout"]["y"], 216.0); -} - -#[test] -fn twelve_offline_writers_preserve_all_layout_intents_through_review_and_restarts() { - let (source, sid, outline, paragraph, _) = fixture(); - let directory = tempfile::tempdir().unwrap(); - std::thread::scope(|scope| { - let mut writers = Vec::new(); - for actor in 0..12 { - let source = &source; - let path = directory.path().join(format!("{actor}.sqlite")); - writers.push(scope.spawn(move || { - let cache = Replica::create(&path, source).unwrap(); - for round in 0..4 { - for (object, change) in [ - ( - outline, - Change::Position { - x: (actor + 2) as f32 * 36.0, - y: (round + 2) as f32 * 36.0, - }, - ), - ( - outline, - Change::Width { - points: (actor + round + 2) as f32 * 36.0, - user_set: true, - }, - ), - (paragraph, Change::Collapsed((actor + round) % 2 != 0)), - ] { - assert!( - cache - .outline(&cache.snapshot().unwrap(), sid, object, change) - .unwrap() - .is_some() - ); - } - } - assert_eq!(cache.pending().unwrap().len(), 12); - })); - } - for writer in writers { - writer.join().unwrap(); - } - }); - let mut server = Server::new(&source); - let mut expected_layout = node(&source, sid, outline)["layout"].clone(); - let mut expected_collapse = None; - let mut reviewed = 0; - for round in 0..12 { - for actor in 0..12 { - let path = directory.path().join(format!("{actor}.sqlite")); - let cache = Replica::open(&path).unwrap(); - let first = cache.pending().unwrap()[0].clone(); - let notebook::Operation::Outline(edit) = first.operation else { - panic!() - }; - let local = cache.snapshot().unwrap(); - let result = cache.sync_once(&mut server).unwrap(); - if result == Some((first.id, EditStatus::Conflict(ConflictKind::LayoutChanged))) { - let remote = cache.remote_snapshot().unwrap(); - cache - .rebase_layout_conflict(first.id, &local, &remote) - .unwrap(); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == first.id) - ); - reviewed += 1; - } else { - assert!( - matches!(result, Some((id, EditStatus::Published { .. })) if id == first.id) - ); - } - match edit.change { - Change::Position { x, y } => { - expected_layout["x"] = x.into(); - expected_layout["y"] = y.into(); - } - Change::Width { points, user_set } => { - expected_layout["max_width"] = points.into(); - expected_layout["width_set_by_user"] = user_set.into(); - } - Change::Collapsed(value) => expected_collapse = Some(u8::from(value)), - } - assert_eq!( - node(&server.durable, sid, outline)["layout"], - expected_layout - ); - assert_eq!( - node(&server.durable, sid, paragraph)["kind"]["collapse_state"], - serde_json::json!(expected_collapse) - ); - assert_eq!(text(&server.durable).2, "Original 🦀 é"); - } - for actor in 0..12 { - let cache = Replica::open(directory.path().join(format!("{actor}.sqlite"))).unwrap(); - assert_eq!(cache.pending().unwrap().len(), 11 - round); - } - } - assert!(reviewed >= 12); - for actor in 0..12 { - let cache = Replica::open(directory.path().join(format!("{actor}.sqlite"))).unwrap(); - assert!(cache.pending().unwrap().is_empty()); - for id in 1..=12 { - assert!(matches!( - cache.status(id).unwrap(), - Some(EditStatus::Published { .. }) - )); - } - } -} - -#[test] -fn uncertain_layout_is_confirmed_by_revision_and_never_by_converged_values() { - let (source, sid, outline, _, text_id) = fixture(); - for fault in [Fault::UnknownBefore, Fault::UnknownAfter] { - let before = matches!(fault, Fault::UnknownBefore); - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("cache.sqlite"); - let cache = Replica::create(&path, &source).unwrap(); - let change = Change::Width { - points: 144.0, - user_set: true, - }; - let id = cache - .outline(&source, sid, outline, change) - .unwrap() - .unwrap(); - cache - .edit_text(&cache.snapshot().unwrap(), sid, text_id, 0..0, "Local ") - .unwrap(); - let local = cache.snapshot().unwrap(); - let mut server = Server::new(&source); - server.fault = fault; - assert!(cache.sync_once(&mut server).is_err()); - let status = cache.status(id).unwrap().unwrap(); - assert!(matches!(status, EditStatus::AwaitingConfirmation { .. })); - drop(cache); - let cache = Replica::open(&path).unwrap(); - if before { - let converged = PreparedEdit::outline(&source, sid, outline, change).unwrap(); - server.visible = converged.as_bytes().to_vec(); - assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, status))); - assert!( - cache - .rebase_layout_conflict(id, &local, &server.visible) - .is_err() - ); - assert_eq!(cache.pending().unwrap().len(), 2); - assert!(cache.snapshot().unwrap() == local); - let archive = directory.path().join("recovery.sqlite"); - cache.export_recovery(&archive).unwrap(); - let recovery = notebook::Recovery::open(&archive).unwrap(); - assert_eq!(recovery.pending().unwrap(), cache.pending().unwrap()); - assert_eq!(recovery.status(id).unwrap(), Some(status)); - assert_eq!(server.confirmations, 0); - } else { - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id) - ); - assert_eq!( - node(&server.durable, sid, outline)["layout"]["max_width"], - 144.0 - ); - assert_eq!(server.confirmations, 1); - } - assert_eq!(server.publications, 1); - } -} - -#[test] -fn native_moves_deletions_and_layout_changes_merge_or_retain_explicit_conflicts() { - let source = include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"); - let native = include_bytes!("../../../../corpus/outline-edit/after/notebook/synthetic.one"); - let store = Store::parse(source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let mut server = Server::new(native); - let mut counts = [0; 3]; - let mut records = Vec::new(); - for (sid, page) in document.pages().unwrap() { - let space = &document.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let Kind::Metadata { - title: Some(name), .. - } = &view.nodes[&view.roots[&2]].kind - else { - continue; - }; - if name == "Unicode rich text" { - continue; - } - let outlines: Vec<_> = view.nodes[&page] - .children - .iter() - .filter(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) - .copied() - .collect(); - if outlines.len() != 2 { - continue; - } - let outline = outlines[0]; - let mut pending = vec![outline]; - let mut target = None; - while let Some(id) = pending.pop() { - let node = &view.nodes[&id]; - pending.extend(&node.children); - if node.content.first().is_some_and(|text| matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text.starts_with("Target "))) { - target = Some(id); - } - } - let target = target.unwrap(); - let text_id = view.nodes[&view.nodes[&outlines[1]].children[0]].content[0]; - let (object, change, conflict) = match name.as_str() { - "Move outline" => ( - outline, - Change::Position { x: 252.0, y: 288.0 }, - Some(ConflictKind::LayoutChanged), - ), - "Resize outline" | "Automatic outline size" => ( - outline, - Change::Width { - points: 252.0, - user_set: true, - }, - Some(ConflictKind::LayoutChanged), - ), - "Delete outline" => ( - outline, - Change::Width { - points: 252.0, - user_set: true, - }, - Some(ConflictKind::TargetUnavailable), - ), - "Delete leaf" | "Delete subtree" | "Delete only paragraph" => ( - target, - Change::Collapsed(true), - Some(ConflictKind::TargetUnavailable), - ), - "Indent subtree" | "Outdent subtree" => ( - target, - Change::Collapsed(true), - Some(ConflictKind::StructureChanged), - ), - "Expand subtree" => (target, Change::Collapsed(false), None), - "Collapse subtree" | "Move leaf down" | "Move subtree down" | "Move subtree up" => { - (target, Change::Collapsed(true), None) - } - _ => panic!("Unexpected native case {name}"), - }; - let expected_layout = if conflict == Some(ConflictKind::TargetUnavailable) { - None - } else { - let mut layout = node(&server.visible, sid, object)["layout"].clone(); - match change { - Change::Position { x, y } => { - layout["x"] = x.into(); - layout["y"] = y.into(); - } - Change::Width { points, user_set } => { - layout["max_width"] = points.into(); - layout["width_set_by_user"] = user_set.into(); - } - Change::Collapsed(_) => {} - } - Some(layout) - }; - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("cache.sqlite"); - let cache = Replica::create(&path, source).unwrap(); - let id = cache.outline(source, sid, object, change).unwrap().unwrap(); - let dependent = cache - .edit_text(&cache.snapshot().unwrap(), sid, text_id, 0..0, "Local ") - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - let queue = cache.pending().unwrap(); - let result = cache.sync_once(&mut server).unwrap().unwrap(); - let mut retained = false; - if let Some(kind) = conflict { - assert_eq!(result, (id, EditStatus::Conflict(kind)), "{name}"); - assert_eq!(cache.pending().unwrap(), queue); - assert!(cache.snapshot().unwrap() == local); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Conflict(kind))); - if kind == ConflictKind::TargetUnavailable { - assert!( - cache - .rebase_layout_conflict(id, &local, &server.visible) - .is_err() - ); - counts[2] += 1; - retained = true; - } else { - cache - .rebase_layout_conflict(id, &local, &server.visible) - .unwrap(); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) - ); - counts[1] += 1; - } - } else { - assert!( - matches!(result, (n, EditStatus::Published { .. }) if n == id), - "{name}" - ); - counts[0] += 1; - drop(cache); - } - let cache = Replica::open(&path).unwrap(); - if retained { - assert_eq!(cache.pending().unwrap(), queue); - assert!(cache.snapshot().unwrap() == local); - } else { - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == dependent) - ); - assert_eq!( - node(&server.durable, sid, object)["layout"], - expected_layout.unwrap(), - "{name}" - ); - if matches!(change, Change::Collapsed(_)) { - assert_eq!( - node(&server.durable, sid, object)["kind"]["collapse_state"], - node(&local, sid, object)["kind"]["collapse_state"] - ); - } - assert!( - node(&server.durable, sid, text_id)["kind"]["text"] - .as_str() - .unwrap() - .starts_with("Local ") - ); - drop(cache); - let cache = Replica::open(&path).unwrap(); - for id in [id, dependent] { - assert!(matches!( - cache.status(id).unwrap(), - Some(EditStatus::Published { .. }) - )); - } - } - records.push(serde_json::json!({"name": name, "space": sid, "object": object, "change": change, "retained": retained, "dependent_text": text_id})); - } - assert_eq!(counts, [5, 5, 4]); - assert_eq!(server.publications, 18); - if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_OUTLINE_OUTPUT") { - let output = std::path::PathBuf::from(output); - assert!(output.is_absolute()); - std::fs::create_dir(&output).unwrap(); - std::fs::write(output.join("synthetic.one"), &server.durable).unwrap(); - std::fs::write( - output.with_extension("json"), - serde_json::to_vec_pretty(&records).unwrap(), - ) - .unwrap(); - } -} - -#[test] -fn a_new_native_wrap_reservation_requires_review_before_width_replacement() { - let source = include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"); - let native = include_bytes!("../../../../corpus/outline-edit/after/notebook/synthetic.one"); - let store = Store::parse(source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let (sid, page) = document.pages().unwrap().into_iter().find(|(sid, _)| { - let space = &document.spaces[sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - matches!(&view.nodes[&view.roots[&2]].kind, Kind::Metadata { title: Some(name), .. } if name=="Move outline") - }).unwrap(); - let space = &document.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let outline = *view.nodes[&page] - .children - .iter() - .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) - .unwrap(); - let old = node(source, sid, outline); - let remote = node(native, sid, outline); - assert_eq!(old["layout"]["max_width"], remote["layout"]["max_width"]); - assert_eq!( - old["layout"]["width_set_by_user"], - remote["layout"]["width_set_by_user"] - ); - assert!(remote["layout"]["reserved_width"].is_number()); - let directory = tempfile::tempdir().unwrap(); - let cache = Replica::create(directory.path().join("cache.sqlite"), source).unwrap(); - let id = cache - .outline( - source, - sid, - outline, - Change::Width { - points: 144.0, - user_set: true, - }, - ) - .unwrap() - .unwrap(); - let mut server = Server::new(native); - assert_eq!( - cache.sync_once(&mut server).unwrap(), - Some((id, EditStatus::Conflict(ConflictKind::LayoutChanged))) - ); - cache - .rebase_layout_conflict(id, &cache.snapshot().unwrap(), &server.visible) - .unwrap(); - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id) - ); - let after = node(&server.durable, sid, outline); - assert_eq!(after["layout"]["x"], remote["layout"]["x"]); - assert_eq!(after["layout"]["y"], remote["layout"]["y"]); - assert_eq!(after["layout"]["max_width"], 144.0); - assert!(after["layout"]["reserved_width"].is_null()); -} diff --git a/crates/notebook/tests/sync/tree.rs b/crates/notebook/tests/sync/tree.rs index a0681214118c92dad8f5a6e2cb6e024407a978d7..19f68d937adbc285e64deb251bfc531d51e0dc75 100644 --- a/crates/notebook/tests/sync/tree.rs +++ b/crates/notebook/tests/sync/tree.rs @@ -1,11 +1,25 @@ +//! Paragraph tree edits expressed as page-model saves: moves, deletions, their +//! reconciliation against native remote images, and emptied table cells. + use super::*; -use onestore::{Insertion, TreeEdit}; +use model_schedule::move_subtree; +use onestore::page::{Paragraph, ParagraphContent, TableCell}; + +/// Saves an edited model of `space`, reaching content that outline helpers cannot. +fn save_page(cache: &Replica, space: ExGuid, edit: impl FnOnce(&mut Page)) -> Option { + let source = cache.snapshot().unwrap(); + let mut page = page_of(&source, space); + edit(&mut page); + cache + .save(&source, space, &page, model_ops::AUTHOR) + .unwrap() +} #[test] fn twelve_offline_clients_reconcile_tree_text_and_interrupted_publication() { let mut random = 1940_u64; - for _ in 0..60 { - let input: Vec<_> = (0..384) + for _ in 0..24 { + let input: Vec<_> = (0..256) .map(|_| { random ^= random << 13; random ^= random >> 7; @@ -13,246 +27,202 @@ fn twelve_offline_clients_reconcile_tree_text_and_interrupted_publication() { random as u8 }) .collect(); - tree_schedule::run(&input); + model_schedule::run(&input); } } +/// The one-outline fixture with three further sibling paragraphs. fn fixture() -> (Vec, ExGuid, ExGuid, [ExGuid; 4], [ExGuid; 4]) { - let (mut source, sid, outline, first, text) = super::outline::fixture(); - let mut paragraphs = [first; 4]; - let mut texts = [text; 4]; + let (source, space, outline, text) = super::fixture(); + let mut page = page_of(&source, space); + let mut anchor = text; for at in 1..4 { - let insertion = - Insertion::paragraph(outline, None, &format!("Sibling {at}"), "Author").unwrap(); - source = PreparedEdit::insert(&source, sid, &insertion) - .unwrap() - .as_bytes() - .to_vec(); - paragraphs[at] = insertion.object(); - texts[at] = insertion.text_object(); + anchor = model_ops::insert_after(&mut page, anchor, &format!("Sibling {at}")).1; } - (source, sid, outline, paragraphs, texts) + let source = PreparedEdit::page(&source, space, &page, "Author") + .unwrap() + .as_bytes() + .to_vec(); + let stored = page_of(&source, space); + let siblings = &outline_of(&stored, outline).paragraphs; + assert_eq!(siblings.len(), 4); + let ids = std::array::from_fn(|at| siblings[at].id); + let texts = std::array::from_fn(|at| siblings[at].text().unwrap().id); + (source, space, outline, ids, texts) } -fn children(source: &[u8], sid: ExGuid, object: ExGuid) -> Vec { - let store = Store::parse(source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - index.validate_current().unwrap(); - let document = Document::parse(&index).unwrap(); - let space = &document.spaces[&sid]; - space.revisions[&space.contexts[&ExGuid::default()]].nodes[&object] - .children - .clone() +/// Paragraph identities of an outline in model order. +fn order(bytes: &[u8], space: ExGuid, outline: ExGuid) -> Vec { + outline_of(&page_of(bytes, space), outline) + .paragraphs + .iter() + .map(|paragraph| paragraph.id) + .collect() +} + +/// Adds a plain paragraph as the last child of `parent`. +fn insert_child(page: &mut Page, parent: ExGuid, value: &str) { + for outline in outlines_mut(page) { + let Some(at) = outline.paragraphs.iter().position(|p| p.id == parent) else { + continue; + }; + let mut child = model_ops::fresh_paragraph(&outline.paragraphs[at], value); + child.parent = Some(parent); + child.level = outline.paragraphs[at].level + 1; + let level = outline.paragraphs[at].level; + let mut end = at + 1; + while end < outline.paragraphs.len() && outline.paragraphs[end].level > level { + end += 1; + } + outline.paragraphs.insert(end, child); + return; + } + panic!("the parent paragraph is on the page"); } #[test] fn move_preserves_remote_content_and_dependent_edits_through_reopen() { - let (source, sid, outline, paragraphs, texts) = fixture(); + let (source, space, outline, paragraphs, texts) = fixture(); let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("cache.sqlite"); let cache = Replica::create(&path, &source).unwrap(); - let intent = TreeEdit::move_to(paragraphs[0], outline, None, "Offline").unwrap(); - let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); - let dependent = cache - .edit_text(&cache.snapshot().unwrap(), sid, texts[0], 0..0, "Local ") - .unwrap() - .unwrap(); + let id = save(&cache, texts[0], |page| { + move_subtree(page, paragraphs[0], None, None); + replace_text(page, texts[0], 0..0, "Local "); + }) + .unwrap() + .unwrap(); let local = cache.snapshot().unwrap(); let queue = cache.pending().unwrap(); drop(cache); let cache = Replica::open(&path).unwrap(); assert_eq!(cache.pending().unwrap(), queue); assert_eq!(cache.snapshot().unwrap(), local); - let edited = PreparedEdit::format( - &source, - sid, - texts[0], - 0..8, - &[onestore::TextAttribute::Bold(true)], - ) - .unwrap(); - let descendant = - Insertion::paragraph(paragraphs[0], None, "New remote child", "Remote").unwrap(); - let remote = PreparedEdit::insert(edited.as_bytes(), sid, &descendant).unwrap(); - let mut server = Server::new(remote.as_bytes()); - for expected in [id, dependent] { - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected) - ); - } + let mut server = Server::new(&remote_with(&source, space, |page| { + insert_child(page, paragraphs[0], "New remote child"); + restyle(page, texts[1], 0..7, |format| format.bold = Some(true)); + })); + published(&cache, &mut server, id); + let durable = page_of(&server.durable, space); + let published = outline_of(&durable, outline); assert_eq!( - children(&server.durable, sid, outline), + published + .paragraphs + .iter() + .map(|p| p.id) + .collect::>()[..4], [paragraphs[1], paragraphs[2], paragraphs[3], paragraphs[0]] ); - assert_eq!( - children(&server.durable, sid, paragraphs[0]), - [descendant.object()] - ); - let node = super::outline::node(&server.durable, sid, texts[0]); - assert_eq!(node["kind"]["text"], "Local Original 🦀 é"); - let store = Store::parse(&server.durable).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let space = &document.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - assert_eq!(view.text_runs(texts[0]).unwrap()[0].format.bold, Some(true)); + let child = &published.paragraphs[4]; + assert_eq!(child.parent, Some(paragraphs[0])); + assert_eq!(child.text().unwrap().text.text(), "New remote child"); + assert_eq!(text_of(&durable, texts[0]), "Local Original 🦀 é"); + let sibling = ¶graph_with(&durable, texts[1]) + .unwrap() + .text() + .unwrap() + .text; + assert_eq!(sibling.spans()[0].format.bold, Some(true)); assert!(cache.pending().unwrap().is_empty()); - assert_eq!(server.publications, 2); + assert_eq!(server.publications, 1); } #[test] -fn queued_format_split_and_delete_reconcile_equivalent_immutable_style_identities() { - let (source, sid, _, _, texts) = fixture(); - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("cache.sqlite"); - let cache = Replica::create(&path, &source).unwrap(); - cache - .format( - &source, - sid, - texts[0], - 0..8, - &[onestore::TextAttribute::Bold(true)], - ) - .unwrap(); - let split = onestore::ParagraphSplit::new(texts[0], 4, "Author").unwrap(); - cache - .split(&cache.snapshot().unwrap(), sid, &split) - .unwrap(); - cache - .tree( - &cache.snapshot().unwrap(), - sid, - &TreeEdit::delete(split.object(), "Author").unwrap(), - ) - .unwrap(); - cache - .edit_text(&cache.snapshot().unwrap(), sid, texts[0], 0..0, "Local ") - .unwrap(); - let queue = cache.pending().unwrap(); - drop(cache); - let mut server = Server::new(&source); - for intent in queue { - let cache = Replica::open(&path).unwrap(); - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == intent.id) - ); - } - assert_eq!( - super::outline::node(&server.durable, sid, texts[0])["kind"]["text"], - "Local Orig" - ); -} - -#[test] -fn native_empty_child_list_normalization_preserves_deletion_and_its_dependents() { +fn native_empty_child_list_normalization_merges_with_a_local_deletion() { let source = include_bytes!("../../../../corpus/outline-edit/empty-children/before.one"); - let remote = include_bytes!("../../../../corpus/outline-edit/empty-children/remote.one"); - let (sid, intent): (ExGuid, TreeEdit) = serde_json::from_str(include_str!( + let native = include_bytes!("../../../../corpus/outline-edit/empty-children/remote.one"); + let intent: serde_json::Value = serde_json::from_str(include_str!( "../../../../corpus/outline-edit/empty-children/intent.json" )) .unwrap(); - let store = Store::parse(source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let space = &document.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let tail = view.nodes[&intent.object()].content[0]; - let (outline_id, outline) = view - .nodes - .iter() - .find(|(_, node)| node.children.contains(&intent.object())) + let space: ExGuid = intent[0].as_str().unwrap().parse().unwrap(); + let object: ExGuid = intent[1]["object"].as_str().unwrap().parse().unwrap(); + let page = page_of(source, space); + let outline = body_outlines(&page) + .into_iter() + .find(|outline| outline.paragraphs.iter().any(|p| p.id == object)) .unwrap(); - let left = view.nodes[&outline.children[0]].content[0]; - for changed in [false, true] { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("cache.sqlite"); - let cache = Replica::create(&path, source).unwrap(); - let deletion = cache.tree(source, sid, &intent).unwrap().unwrap(); - let dependent = cache - .edit_text(&cache.snapshot().unwrap(), sid, left, 0..0, "Local ") - .unwrap() - .unwrap(); - let local = cache.snapshot().unwrap(); - drop(cache); - let cache = Replica::open(&path).unwrap(); - let mut server = if changed { - let edited = PreparedEdit::text(remote, sid, tail, 0..2, "Changed").unwrap(); - Server::new(edited.as_bytes()) - } else { - Server::new(remote) - }; - if changed { - assert_eq!( - cache.sync_once(&mut server).unwrap(), - Some((deletion, EditStatus::Conflict(ConflictKind::ContentChanged))) - ); - assert_eq!(cache.snapshot().unwrap(), local); - assert_eq!(cache.pending().unwrap().len(), 2); - assert_eq!(server.publications, 0); - } else { - for expected in [deletion, dependent] { - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == expected) - ); - } - assert!(cache.pending().unwrap().is_empty()); - assert_eq!(server.publications, 2); - assert!(!children(&server.durable, sid, *outline_id).contains(&intent.object())); - let node = super::outline::node(&server.durable, sid, left); - assert!(node["kind"]["text"].as_str().unwrap().starts_with("Local ")); - } + let deleted = outline + .paragraphs + .iter() + .find(|p| p.id == object) + .unwrap() + .text() + .unwrap() + .id; + let left = outline.paragraphs[0].text().unwrap().id; + let outline = outline.id; + let concurrent: Vec = body_outlines(&page_of(native, space)) + .into_iter() + .flat_map(|outline| outline.paragraphs.iter()) + .filter_map(|paragraph| Some(paragraph.text()?.text.text().to_owned())) + .collect(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite"); + let cache = Replica::create(&path, source).unwrap(); + let change = |page: &mut Page| { + delete_paragraph(page, deleted); + replace_text(page, left, 0..0, "Local "); + }; + let id = save(&cache, left, change).unwrap().unwrap(); + drop(cache); + let cache = Replica::open(&path).unwrap(); + let mut server = Server::new(native); + assert!(matches!( + cache.sync_once(&mut server).unwrap(), + Some((published, EditStatus::Published { .. })) if published == id + )); + assert_eq!(server.publications, 1); + assert!(cache.pending().unwrap().is_empty()); + let durable = page_of(&server.durable, space); + assert!(!order(&server.durable, space, outline).contains(&object)); + assert!(text_of(&durable, left).starts_with("Local ")); + let published: Vec = body_outlines(&durable) + .into_iter() + .flat_map(|outline| outline.paragraphs.iter()) + .filter_map(|paragraph| Some(paragraph.text()?.text.text().to_owned())) + .collect(); + for text in concurrent { + assert!( + text == "🐈" || published.iter().any(|value| value.ends_with(&text)), + "the merge keeps concurrent remote content: {text}" + ); } } #[test] fn deletion_requires_review_of_remote_content_and_preserves_later_work() { - let (source, sid, outline, paragraphs, texts) = fixture(); - let child = Insertion::paragraph(paragraphs[0], None, "Descendant", "Author").unwrap(); - let source = PreparedEdit::insert(&source, sid, &child) + let (source, space, outline, paragraphs, texts) = fixture(); + let mut page = page_of(&source, space); + insert_child(&mut page, paragraphs[0], "Descendant"); + let source = PreparedEdit::page(&source, space, &page, "Author") .unwrap() .as_bytes() .to_vec(); - for remote in [ - PreparedEdit::text(&source, sid, texts[0], 0..0, "Remote ") - .unwrap() - .as_bytes() - .to_vec(), - PreparedEdit::text(&source, sid, child.text_object(), 0..0, "Remote ") - .unwrap() - .as_bytes() - .to_vec(), - PreparedEdit::format( - &source, - sid, - child.text_object(), - 0..10, - &[onestore::TextAttribute::Italic(true)], - ) + let child = outline_of(&page_of(&source, space), outline).paragraphs[1] + .text() .unwrap() - .as_bytes() - .to_vec(), - PreparedEdit::insert( - &source, - sid, - &Insertion::paragraph(paragraphs[0], None, "New child", "Remote").unwrap(), - ) - .unwrap() - .as_bytes() - .to_vec(), - ] { + .id; + let remotes: [Change; 4] = [ + Box::new(move |page| replace_text(page, texts[0], 0..0, "Remote ")), + Box::new(move |page| replace_text(page, child, 0..0, "Remote ")), + Box::new(move |page| { + restyle(page, child, 0..10, |format| format.italic = Some(true)); + }), + Box::new(move |page| insert_child(page, paragraphs[0], "New child")), + ]; + for remote in remotes { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("cache.sqlite"); let cache = Replica::create(&path, &source).unwrap(); - let intent = TreeEdit::delete(paragraphs[0], "Offline").unwrap(); - let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); - let dependent = cache - .edit_text(&cache.snapshot().unwrap(), sid, texts[1], 0..0, "Local ") - .unwrap() - .unwrap(); + let change = |page: &mut Page| { + delete_paragraph(page, texts[0]); + replace_text(page, texts[1], 0..0, "Local "); + }; + let id = save(&cache, texts[1], change).unwrap().unwrap(); let queue = cache.pending().unwrap(); let local = cache.snapshot().unwrap(); - let mut server = Server::new(&remote); + let mut server = Server::new(&remote_with(&source, space, remote)); assert_eq!( cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) @@ -260,37 +230,26 @@ fn deletion_requires_review_of_remote_content_and_preserves_later_work() { assert_eq!(server.publications, 0); assert_eq!(cache.pending().unwrap(), queue); assert_eq!(cache.snapshot().unwrap(), local); - assert!(cache.rebase_tree_conflict(id, &source, &remote).is_err()); - assert!(cache.rebase_tree_conflict(id, &local, &source).is_err()); + let observed = cache.remote_snapshot().unwrap(); assert!( cache - .rebase_tree_conflict(dependent, &local, &remote) + .review_page(id, &source, &observed, &page_of(&observed, space)) .is_err() ); - assert!(cache.rebase_conflict(id, &local, &remote, 0..0).is_err()); - let archive = directory.path().join("recovery.sqlite"); - cache.export_recovery(&archive).unwrap(); - let recovery = notebook::Recovery::open(&archive).unwrap(); - assert_eq!(recovery.pending().unwrap(), queue); - assert_eq!(recovery.status(id).unwrap(), cache.status(id).unwrap()); - assert!(recovery.snapshot().unwrap() == local); drop(cache); let cache = Replica::open(&path).unwrap(); assert_eq!( cache.status(id).unwrap(), Some(EditStatus::Conflict(ConflictKind::ContentChanged)) ); - cache.rebase_tree_conflict(id, &local, &remote).unwrap(); - assert_eq!(cache.pending().unwrap()[1], queue[1]); - assert_eq!(cache.snapshot().unwrap(), local); - for expected in [id, dependent] { - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected) - ); - } - assert_eq!(children(&server.durable, sid, outline), paragraphs[1..]); + review(&cache, id, texts[1], change).unwrap(); + published(&cache, &mut server, id); assert_eq!( - super::outline::node(&server.durable, sid, texts[1])["kind"]["text"], + order(&server.durable, space, outline), + paragraphs[1..].to_vec() + ); + assert_eq!( + text_of(&page_of(&server.durable, space), texts[1]), "Local Sibling 1" ); } @@ -298,247 +257,121 @@ fn deletion_requires_review_of_remote_content_and_preserves_later_work() { #[test] fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() { - let (source, sid, outline, p, texts) = fixture(); - let insertion = Insertion::paragraph(outline, Some(p[0]), "New sibling", "Remote").unwrap(); - let cases = [ + let (source, space, outline, paragraphs, texts) = fixture(); + // The published order of the fixture's paragraphs, or a conflict. + let cases: [(Change, Option<&[usize]>); 5] = [ ( - PreparedEdit::insert(&source, sid, &insertion) - .unwrap() - .as_bytes() - .to_vec(), - None, - ), - ( - PreparedEdit::tree(&source, sid, &TreeEdit::delete(p[1], "Remote").unwrap()) - .unwrap() - .as_bytes() - .to_vec(), - None, + Box::new(move |page| { + model_ops::insert_after(page, texts[0], "New sibling"); + }), + Some(&[1, 2, 3, 0]), ), ( - PreparedEdit::tree( - &source, - sid, - &TreeEdit::move_to(p[1], outline, None, "Remote").unwrap(), - ) - .unwrap() - .as_bytes() - .to_vec(), - None, - ), - ( - PreparedEdit::tree( - &source, - sid, - &TreeEdit::move_to(p[0], outline, Some(p[2]), "Remote").unwrap(), - ) - .unwrap() - .as_bytes() - .to_vec(), - Some(ConflictKind::StructureChanged), + Box::new(move |page| delete_paragraph(page, texts[1])), + Some(&[2, 3, 0]), ), ( - PreparedEdit::tree( - &source, - sid, - &TreeEdit::move_to(p[0], p[1], None, "Remote").unwrap(), - ) - .unwrap() - .as_bytes() - .to_vec(), - Some(ConflictKind::StructureChanged), + Box::new(move |page| move_subtree(page, paragraphs[1], None, None)), + Some(&[2, 3, 0, 1]), ), ( - PreparedEdit::tree(&source, sid, &TreeEdit::delete(p[0], "Remote").unwrap()) - .unwrap() - .as_bytes() - .to_vec(), - Some(ConflictKind::TargetUnavailable), + Box::new(move |page| move_subtree(page, paragraphs[0], Some(paragraphs[1]), None)), + None, ), + (Box::new(move |page| delete_paragraph(page, texts[0])), None), ]; for (remote, expected) in cases { let directory = tempfile::tempdir().unwrap(); let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); - let intent = TreeEdit::move_to(p[0], outline, None, "Offline").unwrap(); - let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); - let mut server = Server::new(&remote); + let id = save(&cache, texts[0], |page| { + move_subtree(page, paragraphs[0], None, None) + }) + .unwrap() + .unwrap(); + let mut server = Server::new(&remote_with(&source, space, remote)); let result = cache.sync_once(&mut server).unwrap().unwrap(); assert_eq!(result.0, id); - if let Some(kind) = expected { - assert_eq!(result.1, EditStatus::Conflict(kind)); + let Some(expected) = expected else { + assert_eq!(result.1, EditStatus::Conflict(ConflictKind::ContentChanged)); assert_eq!(server.publications, 0); - } else { - assert!(matches!(result.1, EditStatus::Published { .. })); - assert_eq!(children(&server.durable, sid, outline).last(), Some(&p[0])); - assert_eq!( - super::outline::node(&server.durable, sid, texts[0])["kind"]["text"], - "Original 🦀 é" - ); - } - } -} - -#[test] -fn moved_destination_and_missing_anchor_retain_conflicts_but_unrelated_text_does_not() { - let (source, sid, outline, p, texts) = fixture(); - let child = Insertion::paragraph(p[1], None, "Anchor", "Author").unwrap(); - let source = PreparedEdit::insert(&source, sid, &child) - .unwrap() - .as_bytes() - .to_vec(); - let intent = TreeEdit::move_to(p[0], p[1], Some(child.object()), "Offline").unwrap(); - let cases = [ - ( - PreparedEdit::tree( - &source, - sid, - &TreeEdit::move_to(p[1], p[2], None, "Remote").unwrap(), - ) - .unwrap() - .as_bytes() - .to_vec(), - ConflictKind::StructureChanged, - ), - ( - PreparedEdit::tree( - &source, - sid, - &TreeEdit::move_to(child.object(), outline, None, "Remote").unwrap(), - ) - .unwrap() - .as_bytes() - .to_vec(), - ConflictKind::UnsupportedEdit, - ), - ]; - for (remote, kind) in cases { - let directory = tempfile::tempdir().unwrap(); - let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); - let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); - let mut server = Server::new(&remote); + continue; + }; + assert!(matches!(result.1, EditStatus::Published { .. })); + let published: Vec = order(&server.durable, space, outline) + .into_iter() + .filter(|id| paragraphs.contains(id)) + .collect(); + let expected: Vec = expected.iter().map(|at| paragraphs[*at]).collect(); + assert_eq!(published, expected); assert_eq!( - cache.sync_once(&mut server).unwrap(), - Some((id, EditStatus::Conflict(kind))) + text_of(&page_of(&server.durable, space), texts[2]), + "Sibling 2" ); - assert_eq!(server.publications, 0); - } - let directory = tempfile::tempdir().unwrap(); - let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); - let id = cache - .tree(&source, sid, &TreeEdit::delete(p[0], "Offline").unwrap()) - .unwrap() - .unwrap(); - let remote = PreparedEdit::text(&source, sid, texts[1], 0..0, "Remote ").unwrap(); - let mut server = Server::new(remote.as_bytes()); - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) - ); - assert_eq!( - super::outline::node(&server.durable, sid, texts[1])["kind"]["text"], - "Remote Sibling 1" - ); -} - -#[test] -fn unknown_tree_attempts_require_revision_evidence_even_when_effect_is_visible() { - let (source, sid, outline, p, _) = fixture(); - for deletion in [false, true] { - for fault in [ - Fault::UnknownBefore, - Fault::UnknownAfter, - Fault::Before, - Fault::Committed, - ] { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("cache.sqlite"); - let cache = Replica::create(&path, &source).unwrap(); - let intent = if deletion { - TreeEdit::delete(p[0], "Offline") - } else { - TreeEdit::move_to(p[0], outline, None, "Offline") - } - .unwrap(); - let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); - let mut server = Server::new(&source); - server.fault = fault; - assert!(cache.sync_once(&mut server).is_err()); - let local = cache.snapshot().unwrap(); - let before = cache.status(id).unwrap(); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.status(id).unwrap(), before); - if matches!(fault, Fault::UnknownBefore) { - let independent = if deletion { - TreeEdit::delete(p[0], "Other") - } else { - TreeEdit::move_to(p[0], outline, None, "Other") - } - .unwrap(); - server = Server::new( - PreparedEdit::tree(&source, sid, &independent) - .unwrap() - .as_bytes(), - ); - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::AwaitingConfirmation { .. })) if n == id) - ); - assert!( - cache - .rebase_tree_conflict(id, &local, &server.visible) - .is_err() - ); - assert_eq!(server.publications, 0); - assert_eq!(cache.snapshot().unwrap(), local); - } else if matches!(fault, Fault::Committed) { - assert!(matches!( - cache.status(id).unwrap(), - Some(EditStatus::Published { .. }) - )); - } else { - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) - ); - assert_eq!( - server.publications, - if matches!(fault, Fault::Before) { 2 } else { 1 } - ); - let saved = cache.snapshot().unwrap(); - assert!(saved[..212] == server.durable[..212]); - assert!(saved[252..] == server.durable[252..]); - assert_eq!( - children(&saved, sid, outline), - children(&server.durable, sid, outline) - ); - } - } } } #[test] fn independently_satisfied_move_confirms_without_republication() { - let (source, sid, outline, p, _) = fixture(); + let (source, space, _, paragraphs, texts) = fixture(); let directory = tempfile::tempdir().unwrap(); let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); - let id = cache - .tree( - &source, - sid, - &TreeEdit::move_to(p[0], outline, None, "Offline").unwrap(), - ) - .unwrap() - .unwrap(); - let remote = PreparedEdit::tree( - &source, - sid, - &TreeEdit::move_to(p[0], outline, None, "Remote").unwrap(), - ) + let id = save(&cache, texts[0], |page| { + move_subtree(page, paragraphs[0], None, None) + }) + .unwrap() .unwrap(); - let mut server = Server::new(remote.as_bytes()); - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) - ); - assert_eq!(server.publications, 0); - assert_eq!(server.confirmations, 1); + let mut server = Server::new(&remote_with(&source, space, |page| { + move_subtree(page, paragraphs[0], None, None); + })); + published(&cache, &mut server, id); + assert_eq!((server.publications, server.confirmations), (0, 1)); +} + +fn cells(page: &Page) -> Vec<&TableCell> { + body_outlines(page) + .into_iter() + .flat_map(|outline| outline.paragraphs.iter()) + .filter_map(|paragraph| match ¶graph.content { + ParagraphContent::Table(table) => Some(table), + _ => None, + }) + .flat_map(|table| table.rows.iter()) + .flat_map(|row| row.cells.iter()) + .collect() +} + +fn cell_of(page: &Page, id: ExGuid) -> &TableCell { + cells(page) + .into_iter() + .find(|cell| cell.id == id) + .expect("the cell is on the page") +} + +fn cell_mut(page: &mut Page, id: ExGuid) -> &mut TableCell { + outlines_mut(page) + .into_iter() + .flat_map(|outline| outline.paragraphs.iter_mut()) + .filter_map(|paragraph| match &mut paragraph.content { + ParagraphContent::Table(table) => Some(table), + _ => None, + }) + .flat_map(|table| table.rows.iter_mut()) + .flat_map(|row| row.cells.iter_mut()) + .find(|cell| cell.id == id) + .expect("the cell is on the page") +} + +/// Moves a cell's sole paragraph to the end of another cell, or deletes it. +fn empty_cell(page: &mut Page, cell: ExGuid, destination: Option) { + let moved = cell_mut(page, cell).paragraphs.remove(0); + let Some(destination) = destination else { + return; + }; + let destination = cell_mut(page, destination); + let mut moved = moved; + moved.level = destination.paragraphs[0].level; + moved.parent = None; + destination.paragraphs.push(moved); } #[test] @@ -548,266 +381,233 @@ fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay( let store = Store::parse(source).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); - let (sid, page) = document.pages().unwrap().into_iter().find(|(sid, _)| { - let space = &document.spaces[sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - matches!(&view.nodes[&view.roots[&2]].kind, Kind::Metadata { title: Some(title), .. } if title == "Delete sole cell paragraph") - }).unwrap(); - let space = &document.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let mut pending = vec![page]; - let mut selected = None; - let mut other = None; - while let Some(id) = pending.pop() { - let node = &view.nodes[&id]; - pending.extend(&node.children); - pending.extend(&node.content); - if matches!(node.kind, Kind::Cell { .. }) { - let paragraph = node.children[0]; - let text = view.nodes[¶graph].content[0]; - if matches!(&view.nodes[&text].kind, Kind::RichText { text, .. } if text.starts_with("Target ")) - { - selected = Some((id, paragraph)); - } else { - other = Some((id, text)); - } - } - } - let (cell, target) = selected.unwrap(); - let (other_cell, other_text) = other.unwrap(); + let (space, page) = document + .pages() + .unwrap() + .into_iter() + .find_map(|(space, _)| { + let page = Page::from_space(&document, space).unwrap(); + (page.title == "Delete sole cell paragraph").then_some((space, page)) + }) + .unwrap(); + let other = document + .pages() + .unwrap() + .into_iter() + .find(|(other, _)| *other != space) + .unwrap() + .0; + let text = |cell: &TableCell| cell.paragraphs[0].text().unwrap().id; + let cell = cells(&page) + .into_iter() + .find(|cell| { + cell.paragraphs[0] + .text() + .unwrap() + .text + .text() + .starts_with("Target ") + }) + .unwrap(); + let (cell, target) = (cell.id, text(cell)); + let neighbour = cells(&page) + .into_iter() + .find(|other| other.id != cell) + .unwrap(); + let (neighbour, neighbour_text) = (neighbour.id, text(neighbour)); for move_out in [false, true] { - for competing_child in [false, true] { + for competing in [false, true] { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("cell.sqlite"); let cache = Replica::create(&path, source).unwrap(); - let intent = if move_out { - TreeEdit::move_to(target, other_cell, None, "Offline") - } else { - TreeEdit::delete(target, "Offline") - } + let id = save_page(&cache, space, |page| { + empty_cell(page, cell, move_out.then_some(neighbour)); + }) .unwrap(); - let id = cache.tree(source, sid, &intent).unwrap().unwrap(); - let local = cache.snapshot().unwrap(); - let replacement = children(&local, sid, cell); - assert_eq!(replacement.len(), 1); - assert_ne!(replacement[0], target); - let replacement_text: ExGuid = - super::outline::node(&local, sid, replacement[0])["content"][0] - .as_str() - .unwrap() - .parse() - .unwrap(); - let dependent = cache - .edit_text(&local, sid, replacement_text, 0..0, "Local replacement") - .unwrap() - .unwrap(); let local = cache.snapshot().unwrap(); let queue = cache.pending().unwrap(); + { + let page = page_of(&local, space); + let paragraphs = &cell_of(&page, cell).paragraphs; + assert_eq!(paragraphs.len(), 1); + assert_ne!(paragraphs[0].text().unwrap().id, target); + assert!(paragraphs[0].text().unwrap().text.text().is_empty()); + } drop(cache); let cache = Replica::open(&path).unwrap(); assert_eq!(cache.pending().unwrap(), queue); assert_eq!(cache.snapshot().unwrap(), local); - let remote = if competing_child { - PreparedEdit::insert( - source, - sid, - &Insertion::paragraph(cell, None, "Remote sibling", "Remote").unwrap(), - ) - .unwrap() - .as_bytes() - .to_vec() + let mut server = Server::new(&if competing { + remote_with(source, space, |page| { + let target = cell_mut(page, cell); + let mut sibling = + model_ops::fresh_paragraph(&target.paragraphs[0], "Remote sibling"); + sibling.level = target.paragraphs[0].level; + target.paragraphs.push(sibling); + }) } else { - PreparedEdit::text(source, sid, other_text, 0..0, "Remote ") - .unwrap() - .as_bytes() - .to_vec() - }; - let mut server = Server::new(&remote); - if competing_child { + remote_with(source, other, |page| { + let text = body_outlines(page)[0].paragraphs[0].text().unwrap().id; + replace_text(page, text, 0..0, "Remote "); + }) + }); + if competing { assert_eq!( cache.sync_once(&mut server).unwrap(), - Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) + Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) ); assert_eq!(server.publications, 0); - assert!(cache.rebase_tree_conflict(id, &local, &remote).is_err()); assert_eq!(cache.pending().unwrap(), queue); assert_eq!(cache.snapshot().unwrap(), local); - } else { - for expected in [id, dependent] { - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected) - ); - } - assert_eq!(children(&server.durable, sid, cell), replacement); - assert_eq!( - super::outline::node(&server.durable, sid, replacement_text)["kind"]["text"], - "Local replacement" + continue; + } + published(&cache, &mut server, id); + let replacement = { + let durable = page_of(&server.durable, space); + let paragraphs = &cell_of(&durable, cell).paragraphs; + assert_eq!(paragraphs.len(), 1); + assert_ne!(paragraphs[0].text().unwrap().id, target); + paragraphs[0].text().unwrap().id + }; + let edit = save_page(&cache, space, |page| { + let cell = cell_mut(page, cell); + let text = cell.paragraphs[0].text_mut().unwrap(); + text.text = Paragraph::new( + "Local replacement".into(), + text.text.format_at(0).unwrap().clone(), ); - assert_eq!( - super::outline::node(&server.durable, sid, other_text)["kind"]["text"], - "Remote Other cell" - ); - if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_TREE_OUTPUT") { - let output = std::path::PathBuf::from(output) - .join(if move_out { "cell-move" } else { "cell-delete" }) - .join("candidate"); - std::fs::create_dir_all(&output).unwrap(); - std::fs::write(output.join("synthetic.one"), &server.durable).unwrap(); - } + }) + .unwrap(); + drop(cache); + let cache = Replica::open(&path).unwrap(); + published(&cache, &mut server, edit); + let durable = page_of(&server.durable, space); + let paragraphs = &cell_of(&durable, cell).paragraphs; + assert_eq!(paragraphs.len(), 1); + assert_eq!(paragraphs[0].text().unwrap().id, replacement); + assert_eq!( + paragraphs[0].text().unwrap().text.text(), + "Local replacement" + ); + let published = cell_of(&durable, neighbour); + assert_eq!( + published.paragraphs.len(), + 1 + usize::from(move_out), + "the moved paragraph joins its destination" + ); + assert_eq!(published.paragraphs[0].text().unwrap().id, neighbour_text); + if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_TREE_OUTPUT") { + let output = std::path::PathBuf::from(output) + .join(if move_out { "cell-move" } else { "cell-delete" }) + .join("candidate"); + std::fs::create_dir_all(&output).unwrap(); + std::fs::write(output.join("synthetic.one"), &server.durable).unwrap(); } } } } +/// The move or deletion each native fixture page is reconciled against. +fn native_change( + name: &str, + outline: ExGuid, + target: (ExGuid, ExGuid), + anchor: Option, +) -> Change { + let name = name.to_owned(); + Box::new(move |page| { + let present = paragraph_with(page, target.1).is_some(); + match name.as_str() { + "Move leaf down" | "Move subtree down" if present => { + move_subtree(page, target.0, None, None); + } + "Move subtree up" if present => { + move_subtree(page, target.0, None, anchor); + } + "Delete outline" | "Move outline" | "Resize outline" | "Automatic outline size" => { + page.objects + .retain(|object| !matches!(object, PageObject::Outline(o) if o.id == outline)); + } + _ if present => { + delete_paragraph(page, target.1); + // An outline cannot survive without a paragraph. + page.objects.retain( + |object| !matches!(object, PageObject::Outline(o) if o.paragraphs.is_empty()), + ); + } + _ => {} + } + }) +} + #[test] fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_content() { - let source = include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"); - let native = include_bytes!("../../../../corpus/outline-edit/after/notebook/synthetic.one"); - let store = Store::parse(source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let mut server = Server::new(native); - let mut counts = [0; 3]; + let mut server = Server::new(NATIVE); let mut records = Vec::new(); - for (sid, page) in document.pages().unwrap() { - let space = &document.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let Kind::Metadata { - title: Some(name), .. - } = &view.nodes[&view.roots[&2]].kind - else { - continue; - }; - if name == "Unicode rich text" { - continue; - } - let outlines: Vec<_> = view.nodes[&page] - .children - .iter() - .copied() - .filter(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) - .collect(); - if outlines.len() != 2 { - continue; - } - let outline = outlines[0]; - let other = view.nodes[&outlines[1]].children[0]; - let dependent_text = view.nodes[&other].content[0]; - let mut pending = vec![outline]; - let mut paragraphs = std::collections::BTreeMap::new(); - while let Some(id) = pending.pop() { - let node = &view.nodes[&id]; - pending.extend(&node.children); - if let Some(text) = node.content.first() - && let Kind::RichText { text, .. } = &view.nodes[text].kind - { - paragraphs.insert(text.as_str(), id); - } - } - let target = *paragraphs + for (name, space, outline, target, dependent) in native_pages(BEFORE) { + let page = page_of(BEFORE, space); + let paragraphs = &outline_of(&page, outline).paragraphs; + let target = ( + paragraphs + .iter() + .find(|p| p.text().is_some_and(|text| text.id == target)) + .unwrap() + .id, + target, + ); + let anchor = paragraphs .iter() - .find(|(text, _)| text.starts_with("Target ")) - .unwrap() - .1; - let (intent, conflict) = match name.as_str() { - "Move leaf down" | "Move subtree down" => { - (TreeEdit::move_to(target, outline, None, "Offline"), None) - } - "Move subtree up" => ( - TreeEdit::move_to(target, outline, Some(paragraphs["Anchor"]), "Offline"), - None, - ), - "Indent subtree" | "Outdent subtree" => ( - TreeEdit::delete(target, "Offline"), - Some(ConflictKind::StructureChanged), - ), - "Collapse subtree" | "Expand subtree" => ( - TreeEdit::delete(target, "Offline"), - Some(ConflictKind::ContentChanged), - ), - "Delete leaf" | "Delete subtree" | "Delete only paragraph" => ( - TreeEdit::delete(target, "Offline"), - Some(ConflictKind::TargetUnavailable), - ), - "Delete outline" => ( - TreeEdit::delete(outline, "Offline"), - Some(ConflictKind::TargetUnavailable), - ), - "Move outline" | "Resize outline" | "Automatic outline size" => ( - TreeEdit::delete(outline, "Offline"), - Some(ConflictKind::ContentChanged), - ), - _ => panic!("{name}"), + .find(|p| { + p.text() + .is_some_and(|text| text.text.text().starts_with("Anchor")) + }) + .map(|paragraph| paragraph.id); + let change = native_change(&name, outline, target, anchor); + let save_both = |page: &mut Page| { + change(page); + replace_text(page, dependent, 0..0, "Offline "); }; - let intent = intent.unwrap(); let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("native.sqlite"); - let cache = Replica::create(&path, source).unwrap(); - let id = cache.tree(source, sid, &intent).unwrap().unwrap(); - let dependent = cache - .edit_text( - &cache.snapshot().unwrap(), - sid, - dependent_text, - 0..0, - "Offline ", - ) - .unwrap() - .unwrap(); + let cache = Replica::create(&path, BEFORE).unwrap(); + let id = save(&cache, dependent, save_both).unwrap().unwrap(); let local = cache.snapshot().unwrap(); let queue = cache.pending().unwrap(); - let old_publications = server.publications; - let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); - assert_eq!(actual, id); - if let Some(kind) = conflict { - assert_eq!(status, EditStatus::Conflict(kind), "{name}"); - assert_eq!(server.publications, old_publications); - assert_eq!(cache.pending().unwrap(), queue); - assert!(cache.snapshot().unwrap() == local); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.status(id).unwrap(), Some(status)); - if kind == ConflictKind::TargetUnavailable { - counts[2] += 1; - assert!( - cache - .rebase_tree_conflict(id, &local, &server.visible) - .is_err() - ); - records.push(serde_json::json!({"page": name, "space": sid, "result": "retained"})); - continue; + let merged = match cache.sync_once(&mut server).unwrap().unwrap() { + (actual, EditStatus::Published { .. }) => { + assert_eq!(actual, id, "{name}"); + true } - counts[1] += 1; - cache - .rebase_tree_conflict(id, &local, &server.visible) - .unwrap(); - assert_eq!(cache.pending().unwrap()[1], queue[1]); - for expected in [id, dependent] { - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected) + result => { + assert_eq!( + result, + (id, EditStatus::Conflict(ConflictKind::ContentChanged)), + "{name}" ); + assert_eq!(cache.pending().unwrap(), queue); + assert_eq!(cache.snapshot().unwrap(), local); + drop(cache); + let cache = Replica::open(&path).unwrap(); + review(&cache, id, dependent, save_both).unwrap(); + published(&cache, &mut server, id); + false } - } else { - counts[0] += 1; - assert!(matches!(status, EditStatus::Published { .. }), "{name}"); - assert_eq!(server.publications, old_publications); - assert!( - matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == dependent) - ); - } - assert_eq!( - super::outline::node(&server.durable, sid, dependent_text)["kind"]["text"], - format!( - "Offline {}", - match &view.nodes[&dependent_text].kind { - Kind::RichText { text, .. } => text, - _ => panic!(), - } - ) + }; + let durable = page_of(&server.durable, space); + assert!( + text_of(&durable, dependent).starts_with("Offline "), + "{name} keeps its dependent text" ); - records.push(serde_json::json!({"page": name, "space": sid, "result": if conflict.is_some() { "reviewed" } else { "converged" }})); + let mut reapplied = durable.clone(); + change(&mut reapplied); + assert_eq!(reapplied, durable, "{name} keeps its reconciled change"); + records.push((name, if merged { "merged" } else { "reviewed" })); } - assert_eq!(counts, [3, 7, 4]); + let reviewed = records + .iter() + .filter(|(_, result)| *result == "reviewed") + .count(); + assert_eq!((records.len(), reviewed), (14, 7), "{records:?}"); if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_TREE_OUTPUT") { let output = std::path::PathBuf::from(output); std::fs::create_dir_all(output.join("candidate")).unwrap(); diff --git a/crates/notebook/tests/sync_formatting.rs b/crates/notebook/tests/sync_formatting.rs new file mode 100644 index 0000000000000000000000000000000000000000..a6699e5801e372ac798687a5892f6096a5d3dfeb --- /dev/null +++ b/crates/notebook/tests/sync_formatting.rs @@ -0,0 +1,499 @@ +//! Formatting and insertion as page-model differences: what reconciles with an independent +//! remote change, what conflicts, and what a reviewed conflict publishes. + +use notebook::{ConflictKind, EditStatus, Replica}; +use onestore::{ + ExGuid, PreparedEdit, RevisionIndex, Store, + document::{Document, Format}, + page::{Page, PageObject}, +}; + +#[path = "support/server.rs"] +mod server; +use server::{Fault, Server}; +#[path = "support/model_ops.rs"] +mod model_ops; +use model_ops::{ + AUTHOR, insert_after, insert_outline, page_of, paragraph_with, replace_text, restyle, review, + save, +}; + +const OUTLINES: &[u8] = + include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"); +const PAGE: &str = "Move subtree down"; + +fn page_titled(bytes: &[u8], title: &str) -> (ExGuid, Page) { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + document + .pages() + .unwrap() + .into_iter() + .find_map(|(space, _)| { + let page = Page::from_space(&document, space).unwrap(); + (page.title == title).then_some((space, page)) + }) + .unwrap() +} + +/// The text objects of a page that carry no hidden field, and so accept range formatting. +fn plain(page: &Page) -> Vec { + page.objects + .iter() + .flat_map(|object| match object { + PageObject::Outline(outline) => outline.paragraphs.iter().collect(), + _ => Vec::new(), + }) + .filter_map(|paragraph| { + let text = paragraph.text()?; + (!text.text.text().contains('\u{fddf}')).then_some(text.id) + }) + .collect() +} + +fn text_of(page: &Page, text: ExGuid) -> String { + paragraph_with(page, text) + .unwrap() + .text() + .unwrap() + .text + .text() + .to_owned() +} + +/// The format of every UTF-16 unit of a text object. +fn formats(page: &Page, text: ExGuid) -> Vec { + let content = ¶graph_with(page, text).unwrap().text().unwrap().text; + let mut out = Vec::new(); + let mut from = 0; + for span in content.spans() { + let units = content.text()[from..span.end].encode_utf16().count(); + out.extend(std::iter::repeat_n(span.format.clone(), units)); + from = span.end; + } + out +} + +/// A remote holding the fixture page with `change` applied by a native author. +fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server { + let mut page = page_of(OUTLINES, space); + change(&mut page); + Server::new( + PreparedEdit::page(OUTLINES, space, &page, "Native author") + .unwrap() + .as_bytes(), + ) +} + +fn cache(directory: &tempfile::TempDir) -> Replica { + Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap() +} + +#[test] +fn offline_formatting_merges_with_a_remote_edit_to_another_paragraph() { + let (space, page) = page_titled(OUTLINES, PAGE); + let ids = plain(&page); + let directory = tempfile::tempdir().unwrap(); + let cache = cache(&directory); + let id = save(&cache, ids[0], |page| { + restyle(page, ids[0], 0..6, |format| format.bold = Some(true)); + }) + .unwrap() + .unwrap(); + let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote ")); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) + ); + let published = page_of(&server.durable, space); + assert!( + formats(&published, ids[0]) + .iter() + .all(|format| format.bold == Some(true)) + ); + assert_eq!(text_of(&published, ids[3]), "Remote Trailing sibling"); + assert_eq!(cache.snapshot().unwrap(), server.durable); +} + +#[test] +fn offline_formatting_conflicts_with_any_remote_change_to_its_own_paragraph() { + let (space, page) = page_titled(OUTLINES, PAGE); + let ids = plain(&page); + for typed in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let cache = cache(&directory); + let id = save(&cache, ids[0], |page| { + restyle(page, ids[0], 0..6, |format| format.bold = Some(true)); + }) + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + let mut server = remote_with(space, |page| { + if typed { + replace_text(page, ids[0], 0..0, "Remote "); + } else { + restyle(page, ids[0], 0..3, |format| format.italic = Some(true)); + } + }); + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))), + "typed {typed}" + ); + assert_eq!(server.publications, 0); + assert_eq!(cache.snapshot().unwrap(), local); + } +} + +#[test] +fn competing_formatting_of_one_paragraph_conflicts_until_reviewed() { + let (space, page) = page_titled(OUTLINES, PAGE); + let ids = plain(&page); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("competing.sqlite"); + let cache = Replica::create(&path, OUTLINES).unwrap(); + let id = save(&cache, ids[0], |page| { + restyle(page, ids[0], 0..6, |format| format.font_size = Some(14.0)); + }) + .unwrap() + .unwrap(); + let pending = cache.pending().unwrap(); + let mut server = remote_with(space, |page| { + restyle(page, ids[0], 0..3, |format| format.italic = Some(true)); + }); + for _ in 0..2 { + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) + ); + } + assert_eq!(cache.pending().unwrap(), pending); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert_eq!( + cache.status(id).unwrap(), + Some(EditStatus::Conflict(ConflictKind::ContentChanged)) + ); + review(&cache, id, ids[0], |page| { + restyle(page, ids[0], 0..6, |format| format.font_size = Some(14.0)); + }) + .unwrap(); + assert!(matches!( + cache.sync_once(&mut server).unwrap(), + Some((_, EditStatus::Published { .. })) + )); + let published = formats(&page_of(&server.durable, space), ids[0]); + assert!( + published + .iter() + .all(|format| format.font_size == Some(14.0)) + ); + assert_eq!( + published + .iter() + .filter(|format| format.italic == Some(true)) + .count(), + 3 + ); +} + +#[test] +fn every_visual_attribute_publishes_through_a_page_model_save() { + let (space, page) = page_titled(OUTLINES, PAGE); + let ids = plain(&page); + for (name, apply) in [ + ( + "bold", + (|f: &mut Format| f.bold = Some(true)) as fn(&mut Format), + ), + ("italic", |f| f.italic = Some(true)), + ("underline", |f| f.underline = Some(true)), + ("strike", |f| f.strike = Some(true)), + ("superscript", |f| f.superscript = Some(true)), + ("subscript", |f| f.subscript = Some(true)), + ("font", |f| f.font = Some("Georgia".into())), + ("font size", |f| f.font_size = Some(21.0)), + ("color", |f| f.color = Some(0x0056_3412)), + ("highlight", |f| f.highlight = Some(0x0006_0504)), + ] { + let directory = tempfile::tempdir().unwrap(); + let cache = cache(&directory); + let id = save(&cache, ids[0], |page| restyle(page, ids[0], 2..6, apply)) + .unwrap() + .unwrap(); + let mut server = Server::new(OUTLINES); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id), + "{name}" + ); + let mut expected = formats(&page_of(OUTLINES, space), ids[0]); + assert_eq!(expected.len(), 6); + for format in &mut expected[2..] { + apply(format); + } + assert_eq!( + formats(&page_of(&server.durable, space), ids[0]), + expected, + "{name}" + ); + } +} + +#[test] +fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone() { + let (space, page) = page_titled(OUTLINES, PAGE); + let ids = plain(&page); + let lengths: Vec = ids + .iter() + .map(|id| u32::try_from(text_of(&page, *id).encode_utf16().count()).unwrap()) + .collect(); + let (mut published, mut conflicts) = (0, 0); + for seed in 1_u64..=64 { + let mut state = seed; + let mut next = || { + state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + usize::try_from(state >> 33).unwrap() + }; + let target = next() % ids.len(); + let start = u32::try_from(next()).unwrap() % lengths[target]; + let end = start + 1 + u32::try_from(next()).unwrap() % (lengths[target] - start); + let attribute: fn(&mut Format) = match seed % 3 { + 0 => |format| format.bold = Some(true), + 1 => |format| format.font_size = Some(21.0), + _ => |format| format.color = Some(0x0012_3456), + }; + let remote: Vec = (0..1 + next() % 3).map(|_| next() % ids.len()).collect(); + let directory = tempfile::tempdir().unwrap(); + let cache = cache(&directory); + let id = save(&cache, ids[target], |page| { + restyle(page, ids[target], start..end, attribute); + }) + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + let mut server = remote_with(space, |page| { + for (step, &at) in remote.iter().enumerate() { + if step % 2 == 0 { + replace_text(page, ids[at], 0..0, "\u{2022}"); + } else { + restyle(page, ids[at], 0..1, |format| format.underline = Some(true)); + } + } + }); + let outcome = cache.sync_once(&mut server).unwrap().unwrap(); + assert_eq!(outcome.0, id, "seed {seed}"); + if remote.contains(&target) { + conflicts += 1; + assert_eq!( + outcome.1, + EditStatus::Conflict(ConflictKind::ContentChanged), + "seed {seed}" + ); + assert_eq!(server.publications, 0, "seed {seed}"); + assert_eq!(cache.snapshot().unwrap(), local, "seed {seed}"); + continue; + } + published += 1; + assert!( + matches!(outcome.1, EditStatus::Published { .. }), + "seed {seed}: {outcome:?}" + ); + let page = page_of(&server.durable, space); + let mut expected: Vec = formats(&page_of(OUTLINES, space), ids[target]); + for format in &mut expected[start as usize..end as usize] { + attribute(format); + } + assert_eq!(formats(&page, ids[target]), expected, "seed {seed}"); + for &at in &remote { + assert!( + text_of(&page, ids[at]).starts_with('\u{2022}') + || formats(&page, ids[at])[0].underline == Some(true), + "seed {seed}" + ); + } + assert_eq!(cache.snapshot().unwrap(), server.durable, "seed {seed}"); + } + assert!( + published >= 16 && conflicts >= 16, + "{published} published, {conflicts} conflicts" + ); +} + +#[test] +fn offline_insertions_survive_reopen_and_carry_their_formatting() { + let (space, page) = page_titled(OUTLINES, PAGE); + let ids = plain(&page); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("insert.sqlite"); + let mut cache = Replica::create(&path, OUTLINES).unwrap(); + let id = save(&cache, ids[0], |page| { + insert_outline(page, 72.0, 144.0, "Offline outline"); + insert_after(page, ids[0], "Offline paragraph 🦀"); + }) + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + let pending = cache.pending().unwrap(); + drop(cache); + cache = Replica::open(&path).unwrap(); + assert_eq!(cache.snapshot().unwrap(), local); + assert_eq!(cache.pending().unwrap(), pending); + let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote ")); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) + ); + let published = page_of(&server.durable, space); + let inserted = plain(&published) + .into_iter() + .find(|id| text_of(&published, *id) == "Offline paragraph 🦀") + .unwrap(); + assert!( + plain(&published) + .iter() + .any(|id| text_of(&published, *id) == "Offline outline") + ); + assert_eq!(text_of(&published, ids[3]), "Remote Trailing sibling"); + let styled = save(&cache, inserted, |page| { + restyle(page, inserted, 8..17, |format| format.bold = Some(true)); + }) + .unwrap() + .unwrap(); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == styled) + ); + let styles = formats(&page_of(&server.durable, space), inserted); + for (offset, format) in styles.iter().enumerate() { + assert_eq!(format.bold == Some(true), (8..17).contains(&offset)); + } + assert_eq!(cache.snapshot().unwrap(), server.durable); +} + +#[test] +fn uncertain_formatting_attempts_keep_the_original_attempt_and_never_replay() { + let (space, page) = page_titled(OUTLINES, PAGE); + let ids = plain(&page); + for fault in [Fault::UnknownBefore, Fault::UnknownAfter] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("uncertain.sqlite"); + let cache = Replica::create(&path, OUTLINES).unwrap(); + let id = save(&cache, ids[0], |page| { + restyle(page, ids[0], 0..6, |format| format.bold = Some(true)); + }) + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + let mut server = Server::new(OUTLINES); + server.fault = fault; + assert!(cache.sync_once(&mut server).is_err()); + let state = cache.status(id).unwrap().unwrap(); + assert!(matches!(state, EditStatus::AwaitingConfirmation { .. })); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.status(id).unwrap(), Some(state)); + if matches!(fault, Fault::UnknownBefore) { + for _ in 0..3 { + assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, state))); + } + assert_eq!(server.publications, 1); + assert_eq!(server.durable, OUTLINES); + assert_eq!(cache.snapshot().unwrap(), local); + continue; + } + assert!(matches!( + cache.sync_once(&mut server).unwrap(), + Some((_, EditStatus::Published { .. })) + )); + assert_eq!(server.publications, 1); + assert!(cache.pending().unwrap().is_empty()); + assert!( + formats(&page_of(&server.durable, space), ids[0]) + .iter() + .all(|format| format.bold == Some(true)) + ); + } +} + +#[test] +fn a_format_the_remote_already_carries_is_confirmed_without_republishing() { + let (space, page) = page_titled(OUTLINES, PAGE); + let ids = plain(&page); + let directory = tempfile::tempdir().unwrap(); + let cache = cache(&directory); + let id = save(&cache, ids[0], |page| { + restyle(page, ids[0], 0..6, |format| format.bold = Some(true)); + }) + .unwrap() + .unwrap(); + let mut server = remote_with(space, |page| { + restyle(page, ids[0], 0..6, |format| format.bold = Some(true)); + }); + server.durable = OUTLINES.to_vec(); + server.fault = Fault::Confirm; + assert!(cache.sync_once(&mut server).is_err()); + assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); + assert_eq!(server.durable, OUTLINES); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) + ); + assert_eq!((server.publications, server.confirmations), (0, 2)); + assert!(cache.pending().unwrap().is_empty()); + let published = page_of(&cache.snapshot().unwrap(), space); + assert_eq!(published, page_of(&server.durable, space)); + assert!( + formats(&published, ids[0]) + .iter() + .all(|format| format.bold == Some(true)) + ); +} + +#[test] +fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() { + let (space, page) = page_titled(OUTLINES, PAGE); + let ids = plain(&page); + let mut after = page.clone(); + restyle(&mut after, ids[0], 0..6, |format| format.bold = Some(true)); + let directory = tempfile::tempdir().unwrap(); + let cache = cache(&directory); + let id = cache + .save(OUTLINES, space, &after, AUTHOR) + .unwrap() + .unwrap(); + let mut server = Server::new(OUTLINES); + server.fault = Fault::UnknownAfter; + assert!(cache.sync_once(&mut server).is_err()); + let attempt = cache.status(id).unwrap(); + assert!(matches!( + attempt, + Some(EditStatus::AwaitingConfirmation { .. }) + )); + let partial = remote_with(space, |page| { + restyle(page, ids[0], 0..3, |format| format.bold = Some(true)); + }); + server.visible.clone_from(&partial.visible); + assert_eq!( + cache.sync_once(&mut server).unwrap(), + attempt.map(|state| (id, state)) + ); + assert_eq!(server.confirmations, 0); + let complete = remote_with(space, |page| { + restyle(page, ids[0], 0..6, |format| format.bold = Some(true)); + }); + server.visible.clone_from(&complete.visible); + assert_eq!(page_of(&server.visible, space), after); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) + ); + assert_eq!((server.publications, server.confirmations), (1, 1)); + assert!(cache.pending().unwrap().is_empty()); +} + +#[test] +fn a_formatted_save_of_an_unchanged_model_queues_nothing() { + let (space, page) = page_titled(OUTLINES, PAGE); + let directory = tempfile::tempdir().unwrap(); + let cache = cache(&directory); + assert_eq!(cache.save(OUTLINES, space, &page, AUTHOR).unwrap(), None); + assert!(cache.pending().unwrap().is_empty()); +} diff --git a/crates/notebook/tests/sync/model.rs b/crates/notebook/tests/sync_model.rs similarity index 98% rename from crates/notebook/tests/sync/model.rs rename to crates/notebook/tests/sync_model.rs index f0bd256aadff45d037bb55f00a4bb26eb30e36ff..f830801de36ec2f510f0714c1cedcfc2a3ba01d2 100644 --- a/crates/notebook/tests/sync/model.rs +++ b/crates/notebook/tests/sync_model.rs @@ -1,12 +1,20 @@ //! Page-model saves: one intent per page until it is attempted, reconciliation against the //! remote page, reviewed conflicts, and independence between pages. -use super::*; +use notebook::{ConflictKind, EditStatus, Replica}; +use onestore::{ExGuid, PreparedEdit, RevisionIndex, Store, document::Document}; + +#[path = "support/server.rs"] +mod server; +use server::*; +#[path = "support/model_ops.rs"] +mod model_ops; + use notebook::{Operation, PageIntent}; use onestore::page::{Page, PageObject}; const OUTLINES: &[u8] = - include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"); + include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"); fn page_of(bytes: &[u8], space: ExGuid) -> Page { let store = Store::parse(bytes).unwrap(); diff --git a/crates/notebook/tests/sync_outline.rs b/crates/notebook/tests/sync_outline.rs new file mode 100644 index 0000000000000000000000000000000000000000..763089e08800f434725ca5bd127333f747b43a5c --- /dev/null +++ b/crates/notebook/tests/sync_outline.rs @@ -0,0 +1,580 @@ +//! Outline layout through page-model saves: reconciliation against the remote page, +//! review of competing layout, twelve offline writers, and the native outline fixtures. + +use notebook::{ConflictKind, EditStatus, Replica}; +use onestore::{ + ExGuid, PreparedEdit, RevisionIndex, Store, + document::Document, + page::{Outline, Page, PageObject, PageParagraph}, +}; + +#[path = "support/server.rs"] +mod server; +use server::{Fault, Server}; +#[path = "support/model_ops.rs"] +mod model_ops; +use model_ops::{ + delete_paragraph, insert_outline, outlines_mut, page_of, paragraph_with, replace_text, restyle, + review, save, +}; +#[path = "support/model_schedule.rs"] +mod model_schedule; +#[path = "sync/tree.rs"] +mod tree; + +const BEFORE: &[u8] = include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"); +const NATIVE: &[u8] = include_bytes!("../../../corpus/outline-edit/after/notebook/synthetic.one"); + +/// One model change, boxed so alternative cases can be listed together. +pub(crate) type Change = Box; + +/// A one-outline page with its space, outline and first paragraph's text identities. +pub(crate) fn fixture() -> (Vec, ExGuid, ExGuid, ExGuid) { + let source = onestore::create_section("layout.one", "Original 🦀 é", "Author").unwrap(); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let space = document.pages().unwrap()[0].0; + let page = Page::from_space(&document, space).unwrap(); + let outline = body_outlines(&page)[0]; + let ids = (outline.id, outline.paragraphs[0].text().unwrap().id); + (source, space, ids.0, ids.1) +} + +pub(crate) fn body_outlines(page: &Page) -> Vec<&Outline> { + page.objects + .iter() + .filter_map(|object| match object { + PageObject::Outline(outline) => Some(outline), + _ => None, + }) + .collect() +} + +pub(crate) fn outline_of(page: &Page, id: ExGuid) -> &Outline { + body_outlines(page) + .into_iter() + .find(|outline| outline.id == id) + .expect("the outline is on the page") +} + +pub(crate) fn outline_mut(page: &mut Page, id: ExGuid) -> &mut Outline { + outlines_mut(page) + .into_iter() + .find(|outline| outline.id == id) + .expect("the outline is on the page") +} + +pub(crate) fn paragraph_mut(page: &mut Page, text: ExGuid) -> &mut PageParagraph { + outlines_mut(page) + .into_iter() + .flat_map(|outline| outline.paragraphs.iter_mut()) + .find(|paragraph| paragraph.text().is_some_and(|value| value.id == text)) + .expect("the paragraph is on the page") +} + +/// The text identity of the stored paragraph carrying exactly `value`. +pub(crate) fn text_with(page: &Page, value: &str) -> ExGuid { + body_outlines(page) + .into_iter() + .flat_map(|outline| outline.paragraphs.iter()) + .find_map(|paragraph| { + let text = paragraph.text()?; + (text.text.text() == value).then_some(text.id) + }) + .expect("a paragraph carries the text") +} + +pub(crate) fn text_of(page: &Page, text: ExGuid) -> String { + paragraph_with(page, text) + .and_then(PageParagraph::text) + .expect("the paragraph is on the page") + .text + .text() + .to_owned() +} + +/// A remote image holding `change`, published as another writer would. +pub(crate) fn remote_with(source: &[u8], space: ExGuid, change: impl FnOnce(&mut Page)) -> Vec { + let mut page = page_of(source, space); + change(&mut page); + PreparedEdit::page(source, space, &page, "Native author") + .unwrap() + .as_bytes() + .to_vec() +} + +pub(crate) fn published(cache: &Replica, server: &mut Server, id: u64) { + assert!( + matches!(cache.sync_once(server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) + ); +} + +fn moved(x: f32, y: f32) -> impl Fn(&mut Outline) { + move |outline| { + outline.layout.x = Some(x); + outline.layout.y = Some(y); + } +} + +fn resized(points: f32, user_set: bool) -> impl Fn(&mut Outline) { + move |outline| { + outline.layout.max_width = Some(points); + outline.layout.width_set_by_user = Some(user_set); + } +} + +#[test] +fn layout_and_dependent_text_survive_reopen_and_a_remote_format_of_the_same_page() { + let (source, space, outline, text_id) = fixture(); + let changes: [Change; 3] = [ + Box::new(move |page| moved(180.0, 216.0)(outline_mut(page, outline))), + Box::new(move |page| resized(144.0, true)(outline_mut(page, outline))), + Box::new(move |page| paragraph_mut(page, text_id).collapsed = true), + ]; + for change in changes { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + let id = save(&cache, text_id, &change).unwrap().unwrap(); + let expected = page_of(&cache.snapshot().unwrap(), space); + let pending = cache.pending().unwrap(); + let local = cache.snapshot().unwrap(); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.pending().unwrap(), pending); + assert_eq!(cache.snapshot().unwrap(), local); + let mut server = Server::new(&remote_with(&source, space, |page| { + restyle(page, text_id, 0..8, |format| format.bold = Some(true)); + })); + published(&cache, &mut server, id); + let durable = page_of(&server.durable, space); + assert_eq!( + outline_of(&durable, outline).layout, + outline_of(&expected, outline).layout + ); + assert_eq!( + paragraph_with(&durable, text_id).unwrap().collapsed, + paragraph_with(&expected, text_id).unwrap().collapsed + ); + assert!(cache.pending().unwrap().is_empty()); + + let dependent = save(&cache, text_id, |page| { + replace_text(page, text_id, 0..0, "Local ") + }) + .unwrap() + .unwrap(); + drop(cache); + let cache = Replica::open(&path).unwrap(); + published(&cache, &mut server, dependent); + let durable = page_of(&server.durable, space); + assert_eq!(text_of(&durable, text_id), "Local Original 🦀 é"); + let text = ¶graph_with(&durable, text_id) + .unwrap() + .text() + .unwrap() + .text; + assert_eq!(text.spans()[0].format.bold, Some(true)); + assert_eq!( + outline_of(&durable, outline).layout, + outline_of(&expected, outline).layout + ); + assert_eq!(server.publications, 2); + drop(cache); + let cache = Replica::open(&path).unwrap(); + for id in [id, dependent] { + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) + )); + } + } +} + +#[test] +fn an_uncertain_move_confirms_from_the_remote_revision_and_adopts_the_remote_title() { + let (source, space, outline, _) = fixture(); + let mut page = page_of(&source, space); + insert_outline(&mut page, 144.0, 36.0, "Second"); + let source = PreparedEdit::page(&source, space, &page, "Author") + .unwrap() + .as_bytes() + .to_vec(); + let second = text_with(&page_of(&source, space), "Second"); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("titles.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + let id = save(&cache, second, |page| { + moved(216.0, 72.0)(outline_mut(page, outline)); + }) + .unwrap() + .unwrap(); + drop(cache); + let mut server = Server::new(&remote_with(&source, space, |page| { + replace_text(page, second, 0..6, "Remote second 🐈"); + })); + server.fault = Fault::UnknownAfter; + let cache = Replica::open(&path).unwrap(); + assert!(cache.sync_once(&mut server).is_err()); + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::AwaitingConfirmation { .. }) + )); + assert_eq!(page_of(&server.durable, space).title, "Original 🦀 é"); + assert_eq!(page_of(&server.visible, space).title, "Remote second 🐈"); + drop(cache); + let cache = Replica::open(&path).unwrap(); + published(&cache, &mut server, id); + assert_eq!(page_of(&server.durable, space).title, "Remote second 🐈"); + assert!(cache.pending().unwrap().is_empty()); + assert_eq!((server.publications, server.confirmations), (1, 1)); +} + +#[test] +fn competing_layout_requires_review_against_the_current_remote_page() { + let (source, space, outline, text_id) = fixture(); + let cases: [(Change, Change); 2] = [ + (Box::new(moved(180.0, 216.0)), Box::new(moved(288.0, 216.0))), + ( + Box::new(resized(144.0, true)), + Box::new(resized(216.0, false)), + ), + ]; + for (local_change, remote_change) in cases { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + let id = save(&cache, text_id, |page| { + local_change(outline_mut(page, outline)) + }) + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + let mut server = Server::new(&remote_with(&source, space, |page| { + remote_change(outline_mut(page, outline)); + })); + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) + ); + assert_eq!(server.publications, 0); + assert_eq!(cache.snapshot().unwrap(), local); + let dependent = save(&cache, text_id, |page| { + replace_text(page, text_id, 0..0, "Local ") + }) + .unwrap() + .unwrap(); + let queue = cache.pending().unwrap(); + let local = cache.snapshot().unwrap(); + let observed = cache.remote_snapshot().unwrap(); + assert!( + cache + .review_page(id, &source, &observed, &page_of(&observed, space)) + .is_err() + ); + assert!( + cache + .review_page(id, &local, &source, &page_of(&source, space)) + .is_err() + ); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert_eq!( + cache.status(id).unwrap(), + Some(EditStatus::Conflict(ConflictKind::ContentChanged)) + ); + review(&cache, id, text_id, |page| { + local_change(outline_mut(page, outline)) + }) + .unwrap(); + assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); + assert_eq!(cache.pending().unwrap()[1], queue[1]); + drop(cache); + let cache = Replica::open(&path).unwrap(); + for id in [id, dependent] { + published(&cache, &mut server, id); + } + let durable = page_of(&server.durable, space); + assert_eq!( + outline_of(&durable, outline).layout, + outline_of(&page_of(&local, space), outline).layout + ); + assert_eq!(text_of(&durable, text_id), "Local Original 🦀 é"); + } +} + +#[test] +fn a_local_move_and_a_remote_resize_of_one_outline_converge() { + let (source, space, outline, text_id) = fixture(); + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); + let id = save(&cache, text_id, |page| { + moved(180.0, 216.0)(outline_mut(page, outline)) + }) + .unwrap() + .unwrap(); + let mut server = Server::new(&remote_with(&source, space, |page| { + resized(252.0, true)(outline_mut(page, outline)); + })); + published(&cache, &mut server, id); + let layout = outline_of(&page_of(&server.durable, space), outline) + .layout + .clone(); + assert_eq!((layout.x, layout.y), (Some(180.0), Some(216.0))); + assert_eq!( + (layout.max_width, layout.width_set_by_user), + (Some(252.0), Some(true)) + ); +} + +#[test] +fn twelve_offline_writers_preserve_all_layout_intents_through_review_and_restarts() { + let (source, space, outline, text_id) = fixture(); + let directory = tempfile::tempdir().unwrap(); + let mut server = Server::new(&source); + let mut reviewed = 0; + for round in 0..4 { + for actor in 0..12 { + let path = directory.path().join(format!("{actor}.sqlite")); + let cache = if round == 0 { + Replica::create(&path, &source).unwrap() + } else { + Replica::open(&path).unwrap() + }; + let change = |page: &mut Page| { + let layout = &mut outline_mut(page, outline).layout; + layout.x = Some((actor + 3) as f32 * 36.0); + layout.y = Some((round + 3) as f32 * 36.0); + layout.max_width = Some((actor + round + 3) as f32 * 36.0); + layout.width_set_by_user = Some(true); + paragraph_mut(page, text_id).collapsed = (actor + round) % 2 != 0; + }; + let id = save(&cache, text_id, change).unwrap().unwrap(); + let expected = page_of(&cache.snapshot().unwrap(), space); + drop(cache); + let cache = Replica::open(&path).unwrap(); + if cache.sync_once(&mut server).unwrap() + == Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) + { + review(&cache, id, text_id, change).unwrap(); + reviewed += 1; + published(&cache, &mut server, id); + } + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) + )); + assert!(cache.pending().unwrap().is_empty()); + let durable = page_of(&server.durable, space); + assert_eq!( + outline_of(&durable, outline).layout, + outline_of(&expected, outline).layout + ); + assert_eq!( + paragraph_with(&durable, text_id).unwrap().collapsed, + (actor + round) % 2 != 0 + ); + assert_eq!(text_of(&durable, text_id), "Original 🦀 é"); + } + } + assert_eq!(reviewed, 47); +} + +#[test] +fn an_uncertain_layout_attempt_confirms_by_revision_or_by_an_equal_remote_page() { + let (source, space, outline, text_id) = fixture(); + for fault in [Fault::UnknownBefore, Fault::UnknownAfter] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + let id = save(&cache, text_id, |page| { + resized(144.0, true)(outline_mut(page, outline)) + }) + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + let mut server = Server::new(&source); + server.fault = fault; + assert!(cache.sync_once(&mut server).is_err()); + let status = cache.status(id).unwrap().unwrap(); + assert!(matches!(status, EditStatus::AwaitingConfirmation { .. })); + drop(cache); + let cache = Replica::open(&path).unwrap(); + if matches!(fault, Fault::UnknownAfter) { + published(&cache, &mut server, id); + let layout = outline_of(&page_of(&server.durable, space), outline) + .layout + .clone(); + assert_eq!(layout.max_width, Some(144.0)); + assert_eq!((server.publications, server.confirmations), (1, 1)); + continue; + } + assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, status))); + server.visible = remote_with(&source, space, |page| { + resized(216.0, true)(outline_mut(page, outline)); + }); + assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, status))); + assert_eq!(cache.snapshot().unwrap(), local); + server.visible = remote_with(&source, space, |page| { + resized(144.0, true)(outline_mut(page, outline)); + }); + published(&cache, &mut server, id); + assert_eq!((server.publications, server.confirmations), (1, 1)); + } +} + +/// The layout or collapse change the native fixture page is reconciled against. +fn native_case(name: &str, outline: ExGuid, target: ExGuid) -> Change { + let layout: Option> = match name { + "Move outline" => Some(Box::new(moved(252.0, 288.0))), + "Resize outline" | "Automatic outline size" | "Delete outline" => { + Some(Box::new(resized(252.0, true))) + } + _ => None, + }; + let collapsed = name != "Expand subtree"; + Box::new(move |page| match &layout { + Some(change) => { + if body_outlines(page).iter().any(|o| o.id == outline) { + change(outline_mut(page, outline)); + } + } + None => { + if paragraph_with(page, target).is_some() { + paragraph_mut(page, target).collapsed = collapsed; + } + } + }) +} + +/// Native fixture pages with two body outlines: the page, its first outline, the +/// "Target " paragraph's text and an independent paragraph's text in the second outline. +pub(crate) fn native_pages(source: &[u8]) -> Vec<(String, ExGuid, ExGuid, ExGuid, ExGuid)> { + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let mut cases = Vec::new(); + for (space, _) in document.pages().unwrap() { + let page = Page::from_space(&document, space).unwrap(); + let outlines = body_outlines(&page); + if outlines.len() != 2 || page.title == "Unicode rich text" { + continue; + } + let target = outlines[0] + .paragraphs + .iter() + .find(|paragraph| { + paragraph + .text() + .is_some_and(|text| text.text.text().starts_with("Target ")) + }) + .expect("each fixture page marks its target paragraph"); + cases.push(( + page.title.clone(), + space, + outlines[0].id, + target.text().unwrap().id, + outlines[1].paragraphs[0].text().unwrap().id, + )); + } + cases +} + +#[test] +fn native_moves_deletions_and_layout_changes_merge_or_require_review() { + let mut server = Server::new(NATIVE); + let mut records = Vec::new(); + for (name, space, outline, target, dependent) in native_pages(BEFORE) { + let change = native_case(&name, outline, target); + let save_both = |page: &mut Page| { + change(page); + replace_text(page, dependent, 0..0, "Local "); + }; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite"); + let cache = Replica::create(&path, BEFORE).unwrap(); + let id = save(&cache, dependent, save_both).unwrap().unwrap(); + let local = cache.snapshot().unwrap(); + let queue = cache.pending().unwrap(); + let merged = match cache.sync_once(&mut server).unwrap().unwrap() { + (actual, EditStatus::Published { .. }) => { + assert_eq!(actual, id, "{name}"); + true + } + result => { + assert_eq!( + result, + (id, EditStatus::Conflict(ConflictKind::ContentChanged)), + "{name}" + ); + assert_eq!(cache.pending().unwrap(), queue); + assert_eq!(cache.snapshot().unwrap(), local); + drop(cache); + let cache = Replica::open(&path).unwrap(); + review(&cache, id, dependent, save_both).unwrap(); + published(&cache, &mut server, id); + false + } + }; + let durable = page_of(&server.durable, space); + assert!( + text_of(&durable, dependent).starts_with("Local "), + "{name} keeps its dependent text" + ); + let mut reapplied = durable.clone(); + change(&mut reapplied); + assert_eq!(reapplied, durable, "{name} keeps its reconciled change"); + records.push((name, if merged { "merged" } else { "reviewed" })); + } + let reviewed = records.iter().filter(|(_, r)| *r == "reviewed").count(); + assert_eq!((records.len(), reviewed), (14, 7), "{records:?}"); + if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_OUTLINE_OUTPUT") { + let output = std::path::PathBuf::from(output); + assert!(output.is_absolute()); + std::fs::create_dir(&output).unwrap(); + std::fs::write(output.join("synthetic.one"), &server.durable).unwrap(); + std::fs::write( + output.with_extension("json"), + serde_json::to_vec_pretty(&records).unwrap(), + ) + .unwrap(); + } +} + +#[test] +fn a_new_native_wrap_reservation_requires_review_before_width_replacement() { + let (_, space, outline, _, text_id) = native_pages(BEFORE) + .into_iter() + .find(|(name, ..)| name == "Move outline") + .unwrap(); + let stored = outline_of(&page_of(BEFORE, space), outline).layout.clone(); + let remote = outline_of(&page_of(NATIVE, space), outline).layout.clone(); + assert_eq!( + (stored.max_width, stored.width_set_by_user), + (remote.max_width, remote.width_set_by_user) + ); + assert!(remote.reserved_width.is_some()); + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), BEFORE).unwrap(); + let id = save(&cache, text_id, |page| { + resized(144.0, true)(outline_mut(page, outline)) + }) + .unwrap() + .unwrap(); + let mut server = Server::new(NATIVE); + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) + ); + review(&cache, id, text_id, |page| { + resized(144.0, true)(outline_mut(page, outline)) + }) + .unwrap(); + published(&cache, &mut server, id); + let layout = outline_of(&page_of(&server.durable, space), outline) + .layout + .clone(); + assert_eq!((layout.x, layout.y), (remote.x, remote.y)); + assert_eq!(layout.max_width, Some(144.0)); + assert!(layout.reserved_width.is_none()); +} diff --git a/crates/notebook/tests/sync/page.rs b/crates/notebook/tests/sync_page.rs similarity index 71% rename from crates/notebook/tests/sync/page.rs rename to crates/notebook/tests/sync_page.rs index 5cae6719919da89b9e2939df52f6ffb306cf804f..5f157c4955f5b2d417b885dfe86c992ec713e390 100644 --- a/crates/notebook/tests/sync/page.rs +++ b/crates/notebook/tests/sync_page.rs @@ -1,8 +1,24 @@ -use super::*; -use notebook::{Operation, Recovery}; -use onestore::{Insertion, PageCreation}; +//! Page creation reconciliation: dependent body saves, uncertain publication, anchor review, +//! and deterministic multi-actor schedules. + +use notebook::{ConflictKind, EditStatus, Operation, Recovery, Replica}; +use onestore::{ + ExGuid, PageCreation, RevisionIndex, Store, + document::{Document, Kind}, +}; use std::collections::BTreeMap; +#[path = "support/server.rs"] +mod server; +use server::*; +#[path = "../../onestore/tests/support/disk.rs"] +mod disk; +#[path = "support/model_ops.rs"] +mod model_ops; +#[path = "support/page_schedule.rs"] +mod page_schedule; +use page_schedule::body_outline; + #[test] fn twelve_replica_page_schedules_retain_acknowledged_pages_through_interruptions() { for seed in 0..24_u64 { @@ -23,80 +39,6 @@ fn twelve_replica_page_schedules_retain_acknowledged_pages_through_interruptions } } -#[test] -fn version_two_uncertain_text_migrates_without_replay_or_lost_receipts() { - let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); - let (sid, oid, _) = text(&source); - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("legacy.sqlite"); - let cache = Replica::create(&path, &source).unwrap(); - let id = cache - .edit_text(&source, sid, oid, 0..0, "Old ") - .unwrap() - .unwrap(); - let snapshot = cache.snapshot().unwrap(); - let pending = cache.pending().unwrap(); - let store = Store::parse(&snapshot).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let revision = index.spaces[&sid].labels[&(ExGuid::default(), 1)]; - drop(cache); - let db = rusqlite::Connection::open(&path).unwrap(); - db.execute_batch( - "DROP TABLE edits; DROP TABLE assets; - CREATE TABLE edits ( - id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), space TEXT NOT NULL, - object TEXT NOT NULL, before_text TEXT NOT NULL, - start INTEGER NOT NULL CHECK(start BETWEEN 0 AND 4294967295), - end INTEGER NOT NULL CHECK(end BETWEEN start AND 4294967295), replacement TEXT NOT NULL - ) STRICT; - ALTER TABLE attempt RENAME COLUMN revisions TO revision; - PRAGMA user_version=2;", - ) - .unwrap(); - db.execute( - "INSERT INTO edits VALUES (?1,?2,?3,'Original',0,0,'Old ')", - rusqlite::params![i64::try_from(id).unwrap(), sid.to_string(), oid.to_string()], - ) - .unwrap(); - db.execute( - "INSERT INTO attempt VALUES (1,?1,?2)", - rusqlite::params![i64::try_from(id).unwrap(), revision.to_string()], - ) - .unwrap(); - db.execute( - "INSERT INTO receipts VALUES (1000,?1)", - [revision.to_string()], - ) - .unwrap(); - db.execute("UPDATE sqlite_sequence SET seq=1000 WHERE name='edits'", []) - .unwrap(); - drop(db); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.pending().unwrap(), pending); - assert_eq!( - cache.status(id).unwrap(), - Some(EditStatus::AwaitingConfirmation { revision }) - ); - assert_eq!( - cache.status(1000).unwrap(), - Some(EditStatus::Published { revision }) - ); - assert!(cache.snapshot().unwrap() == snapshot); - let mut server = Server::new(&snapshot); - assert_eq!( - cache.sync_once(&mut server).unwrap(), - Some((id, EditStatus::Published { revision })) - ); - assert_eq!(server.publications, 0); - assert_eq!(server.confirmations, 1); - assert_eq!( - cache - .edit_text(&cache.snapshot().unwrap(), sid, oid, 0..0, "Next ") - .unwrap(), - Some(1001) - ); -} - #[test] fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() { let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); @@ -106,24 +48,32 @@ fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() { for actor in 0..12 { let path = directory.path().join(format!("{actor}.sqlite")); let cache = Replica::create(&path, &source).unwrap(); - let page = PageCreation::new(None, Some("Same 🦋 é"), "Offline author").unwrap(); + let page = PageCreation::new(None, Some("Same 🦋 é"), "Offline author").unwrap(); let id = cache.create_page(&source, &page).unwrap().unwrap(); - let insertion = Insertion::outline(page.object(), 36.0, 36.0, "Body", "Author").unwrap(); - cache - .insert(&cache.snapshot().unwrap(), page.space(), &insertion) - .unwrap() - .unwrap(); - cache - .edit_text( + let mut created = model_ops::page_of(&cache.snapshot().unwrap(), page.space()); + let body = body_outline(&mut created, "Body"); + let save = cache + .save( &cache.snapshot().unwrap(), page.space(), - insertion.text_object(), - 4..4, - &format!(" {actor}"), + &created, + model_ops::AUTHOR, ) .unwrap() .unwrap(); + assert_eq!( + model_ops::save(&cache, body, |page| model_ops::replace_text( + page, + body, + 4..4, + &format!(" {actor}") + )) + .unwrap(), + Some(save), + "a dependent text edit coalesces into the unattempted body save" + ); let original = cache.pending().unwrap(); + assert_eq!(original.len(), 2); assert!( matches!(&original[0].operation, Operation::CreatePage(retained) if retained == &page) ); @@ -140,12 +90,7 @@ fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() { cache.status(id).unwrap(), Some(EditStatus::Published { .. }) )); - expected.push(( - page.space(), - page.object(), - insertion.text_object(), - format!("Body {actor}"), - )); + expected.push((page.space(), page.object(), body, format!("Body {actor}"))); } let store = Store::parse(&server.durable).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); @@ -158,7 +103,7 @@ fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() { let view = &space.revisions[&space.contexts[&ExGuid::default()]]; assert!(matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text == expected)); } - assert_eq!(server.publications, 36); + assert_eq!(server.publications, 24); if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_OUTPUT") { std::fs::create_dir(&output).unwrap(); std::fs::write( @@ -287,10 +232,8 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() { #[test] fn changed_page_anchor_requires_review_without_regenerating_dependent_identities() { - let source = - include_bytes!("../../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one"); - let remote = - include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); + let source = include_bytes!("../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one"); + let remote = include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); let store = Store::parse(source).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let pages = Document::parse(&index).unwrap().pages().unwrap(); @@ -299,10 +242,10 @@ fn changed_page_anchor_requires_review_without_regenerating_dependent_identities let cache = Replica::create(&path, source).unwrap(); let page = PageCreation::new(Some(pages[4].0), Some("Created"), "Author").unwrap(); let id = cache.create_page(source, &page).unwrap().unwrap(); - let insertion = - Insertion::outline(page.object(), 36.0, 36.0, "Retained body", "Author").unwrap(); + let mut created = model_ops::page_of(&cache.snapshot().unwrap(), page.space()); + let body = body_outline(&mut created, "Retained body"); cache - .insert(&cache.snapshot().unwrap(), page.space(), &insertion) + .save(&cache.snapshot().unwrap(), page.space(), &created, "Author") .unwrap() .unwrap(); let local = cache.snapshot().unwrap(); @@ -345,6 +288,6 @@ fn changed_page_anchor_requires_review_without_regenerating_dependent_identities let document = Document::parse(&index).unwrap(); let space = &document.spaces[&page.space()]; let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - assert!(matches!(&view.nodes[&insertion.text_object()].kind, + assert!(matches!(&view.nodes[&body].kind, Kind::RichText { text, .. } if text == "Retained body")); } diff --git a/crates/notebook/tests/sync/pages.rs b/crates/notebook/tests/sync_pages.rs similarity index 88% rename from crates/notebook/tests/sync/pages.rs rename to crates/notebook/tests/sync_pages.rs index a751c6740c86a288e41a106020ed21dffe2b0c4b..6d302efc42125e883e43e8adb250d64c5d4382e1 100644 --- a/crates/notebook/tests/sync/pages.rs +++ b/crates/notebook/tests/sync_pages.rs @@ -1,10 +1,25 @@ -use super::*; -use notebook::{Operation, Recovery}; -use onestore::{PageEdit, PagePosition}; +//! Page batch reconciliation: atomic batches with dependent body saves, competing moves and +//! indentation, uncertain batches, and native page movement fixtures. + +use notebook::{ConflictKind, EditStatus, Operation, Recovery, Replica}; +use onestore::{ + ExGuid, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store, + document::{Document, Format, Kind, Layout}, + page::{ + Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, + text::new_id, + }, +}; use std::collections::BTreeMap; +#[path = "support/server.rs"] +mod server; +use server::*; +#[path = "support/model_ops.rs"] +mod model_ops; + const SOURCE: &[u8] = - include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); + include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); fn order(source: &[u8]) -> Vec<(ExGuid, u32)> { let store = Store::parse(source).unwrap(); @@ -42,6 +57,55 @@ fn texts(source: &[u8]) -> BTreeMap<(ExGuid, ExGuid), String> { texts } +/// Adds a body outline holding one plain paragraph, returning its text identity. A page the +/// section just created has no body text for `model_ops::insert_outline` to copy formatting from. +fn body_outline(page: &mut Page, text: &str) -> ExGuid { + // The writer creates outline text with the store's default style; the model must state it. + let format = Format { + font: Some("Calibri".to_owned()), + font_size: Some(11.0), + language: Some(0x409), + ..Default::default() + }; + let content = TextObject { + id: new_id().unwrap(), + date_field: None, + text: Paragraph::new(text.into(), format), + tags: Vec::new(), + }; + let id = content.id; + let outline = Outline { + id: new_id().unwrap(), + title: false, + min_width: None, + layout: Layout { + x: Some(36.0), + y: Some(36.0), + ..Default::default() + }, + indents: Vec::new(), + paragraphs: vec![PageParagraph { + id: new_id().unwrap(), + parent: None, + level: 1, + style: None, + format: Default::default(), + content: ParagraphContent::Text(content), + lists: Vec::new(), + tags: Vec::new(), + collapsed: false, + }], + unsupported: Vec::new(), + }; + let at = page + .objects + .iter() + .position(|object| matches!(object, PageObject::Title(_))) + .unwrap_or(page.objects.len()); + page.objects.insert(at, PageObject::Outline(outline)); + id +} + #[test] fn atomic_page_batches_survive_reopen_with_dependent_text() { let directory = tempfile::tempdir().unwrap(); @@ -61,10 +125,11 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() { .unwrap(); let changed = format!("Offline {original}"); expected_text.insert((sid, oid), changed); - cache - .edit_text(&cache.snapshot().unwrap(), sid, oid, 0..0, "Offline ") - .unwrap() - .unwrap(); + model_ops::save(&cache, oid, |page| { + model_ops::replace_text(page, oid, 0..0, "Offline ") + }) + .unwrap() + .unwrap(); let queue = cache.pending().unwrap(); assert!(matches!(&queue[0].operation, Operation::Pages(batch) if batch.edits == edits)); let local = cache.snapshot().unwrap(); @@ -365,51 +430,6 @@ fn convergent_page_indentation_requires_confirmation_without_republishing() { assert_eq!(server.publications, 0); assert_eq!(server.confirmations, 2); } - -#[test] -fn schema_ten_migration_preserves_multi_space_uncertainty() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("pages.sqlite"); - let cache = Replica::create(&path, SOURCE).unwrap(); - let page = onestore::PageCreation::new(None, Some("Migration"), "Author").unwrap(); - let id = cache.create_page(SOURCE, &page).unwrap().unwrap(); - let mut server = Server::new(SOURCE); - server.fault = Fault::UnknownBefore; - assert!(cache.sync_once(&mut server).is_err()); - let local = cache.snapshot().unwrap(); - let queue = cache.pending().unwrap(); - let status = cache.status(id).unwrap(); - drop(cache); - let db = rusqlite::Connection::open(&path).unwrap(); - db.pragma_update(None, "user_version", 10).unwrap(); - let proofs: String = db - .query_row("SELECT revisions FROM attempt", [], |r| r.get(0)) - .unwrap(); - drop(db); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.snapshot().unwrap(), local); - assert_eq!(cache.pending().unwrap(), queue); - assert_eq!(cache.status(id).unwrap(), status); - assert_eq!( - cache.sync_once(&mut server).unwrap(), - Some((id, status.unwrap())) - ); - assert_eq!(server.publications, 1); - drop(cache); - let db = rusqlite::Connection::open(&path).unwrap(); - assert_eq!( - db.query_row("SELECT revisions FROM attempt", [], |r| r - .get::<_, String>(0)) - .unwrap(), - proofs - ); - assert_eq!( - db.pragma_query_value(None, "user_version", |r| r.get::<_, u32>(0)) - .unwrap(), - 11 - ); -} - #[test] fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -441,16 +461,11 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() { .collect() }; let id = cache.pages(SOURCE, &edits).unwrap().unwrap(); - let text_id = cache - .edit_text( - &cache.snapshot().unwrap(), - text_space, - text_object, - 0..0, - "Offline ", - ) - .unwrap() - .unwrap(); + let text_id = model_ops::save(&cache, text_object, |page| { + model_ops::replace_text(page, text_object, 0..0, "Offline ") + }) + .unwrap() + .unwrap(); let native = std::fs::read(root.join(phase).join("notebook/Lifecycle.one")).unwrap(); let mut server = Server::new(&native); let local = cache.snapshot().unwrap(); @@ -562,11 +577,12 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() { PageEdit::set_level(group[2].space(), 2).unwrap(), ]; cache.pages(&source, &edits).unwrap().unwrap(); - let body = format!("Actor {actor} 🦀 é"); - let insertion = - onestore::Insertion::outline(group[1].object(), 36.0, 36.0, &body, "Author").unwrap(); + let body = format!("Actor {actor} 🦀 é"); + let local = cache.snapshot().unwrap(); + let mut model = model_ops::page_of(&local, group[1].space()); + let text = body_outline(&mut model, &body); cache - .insert(&cache.snapshot().unwrap(), group[1].space(), &insertion) + .save(&local, group[1].space(), &model, "Author") .unwrap() .unwrap(); expected.extend([ @@ -574,7 +590,7 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() { (group[0].space(), 1), (group[2].space(), 2), ]); - expected_text.insert((group[1].space(), insertion.text_object()), body); + expected_text.insert((group[1].space(), text), body); queues.push((path, cache.pending().unwrap())); } let mut server = Server::new(&source); diff --git a/crates/notebook/tests/sync_paragraph.rs b/crates/notebook/tests/sync_paragraph.rs new file mode 100644 index 0000000000000000000000000000000000000000..b7dadfba8d0fa8444381ca6543f30d0c35fe8a27 --- /dev/null +++ b/crates/notebook/tests/sync_paragraph.rs @@ -0,0 +1,860 @@ +//! Paragraph splits and joins expressed as page-model differences: reconciliation against +//! native OneNote captures, dependent edits, uncertain attempts and competing clients. + +use notebook::{ConflictKind, EditStatus, Replica}; +use onestore::{ + ExGuid, PreparedEdit, RevisionIndex, Store, + document::Document, + page::{Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id}, +}; +use std::path::Path; + +#[path = "support/server.rs"] +mod server; +use server::{Fault, Server}; +#[path = "support/model_ops.rs"] +mod model_ops; +use model_ops::{AUTHOR, outlines_mut, page_of, replace_text}; + +const BEFORE: &[u8] = + include_bytes!("../../../corpus/paragraph-edit/reconciliation/before/notebook/synthetic.one"); +const KEYBOARD: &[u8] = + include_bytes!("../../../corpus/paragraph-edit/reconciliation/keyboard/notebook/synthetic.one"); +const REPLACED: &[u8] = + include_bytes!("../../../corpus/paragraph-edit/reconciliation/remote/notebook/synthetic.one"); +const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/paragraph-edit"); + +fn at(list: &[PageParagraph], text: ExGuid) -> Option { + list.iter() + .position(|p| p.text().is_some_and(|t| t.id == text)) +} + +/// Applies `edit` to the paragraph list holding `text`, descending into table cells. +fn in_list( + list: &mut Vec, + text: ExGuid, + edit: &mut impl FnMut(&mut Vec, usize) -> T, +) -> Option { + if let Some(index) = at(list, text) { + return Some(edit(list, index)); + } + for paragraph in list { + if let ParagraphContent::Table(table) = &mut paragraph.content { + for row in &mut table.rows { + for cell in &mut row.cells { + if let Some(value) = in_list(&mut cell.paragraphs, text, edit) { + return Some(value); + } + } + } + } + } + None +} + +fn locate_in( + page: &mut Page, + text: ExGuid, + mut edit: impl FnMut(&mut Vec, usize) -> T, +) -> T { + for outline in outlines_mut(page) { + if let Some(value) = in_list(&mut outline.paragraphs, text, &mut edit) { + return value; + } + } + panic!("the edited paragraph is on the page"); +} + +/// Splits the paragraph carrying `text` at a UTF-16 offset: the text tail and the children +/// move to a new following paragraph, as pressing Enter mid-paragraph does. +fn split_paragraph(page: &mut Page, text: ExGuid, offset: u32) { + locate_in(page, text, |list, index| { + // One identity per split, numbered as OneNote numbers the paragraph it creates. + let guid = new_id().unwrap().guid; + let mut right = list[index].clone(); + right.id = ExGuid { guid, n: 1 }; + right.tags.clear(); + let source = list[index].text().unwrap().text.clone(); + let end = source.utf16_offset(source.text().len()).unwrap(); + list[index].text_mut().unwrap().text = source.slice(0..offset).unwrap(); + right.content = ParagraphContent::Text(TextObject { + id: ExGuid { guid, n: 2 }, + date_field: None, + text: source.slice(offset..end).unwrap(), + tags: Vec::new(), + }); + let (left, tail) = (list[index].id, right.id); + for paragraph in &mut list[index + 1..] { + if paragraph.parent == Some(left) { + paragraph.parent = Some(tail); + } + } + list.insert(index + 1, right); + }) +} + +/// Joins the paragraph carrying `right` into the one carrying `left`; an empty left text +/// object is replaced by a non-empty right one, which is the identity OneNote keeps. +fn join_paragraphs(page: &mut Page, left: ExGuid, right: ExGuid) { + locate_in(page, left, |list, first| { + let second = at(list, right).expect("the joined paragraphs share a container"); + let (parent, orphaned, removed) = ( + list[first].id, + list[second].id, + list[second].text().unwrap().clone(), + ); + let adopts = !removed.text.text().is_empty(); + let target = list[first].text_mut().unwrap(); + let mut joined = target.text.clone(); + joined.append(removed.text).unwrap(); + if adopts && target.text.text().is_empty() { + target.id = removed.id; + } + target.text = joined; + list.remove(second); + for paragraph in list { + if paragraph.parent == Some(orphaned) { + paragraph.parent = Some(parent); + } + } + }); +} + +/// The paragraphs of a page's single body outline. +fn body(page: &Page) -> &[PageParagraph] { + page.objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => Some(&outline.paragraphs[..]), + _ => None, + }) + .unwrap() +} + +fn texts(page: &Page) -> Vec { + body(page) + .iter() + .filter_map(|p| p.text().map(|t| t.text.text().to_owned())) + .collect() +} + +/// The page space and model holding a text object anywhere on a page, table cells included. +fn holder(bytes: &[u8], text: ExGuid) -> (ExGuid, Page) { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + document + .pages() + .unwrap() + .into_iter() + .find_map(|(space, _)| { + let mut page = Page::from_space(&document, space).ok()?; + let found = outlines_mut(&mut page) + .into_iter() + .any(|outline| in_list(&mut outline.paragraphs, text, &mut |_, _| ()).is_some()); + found.then_some((space, page)) + }) + .expect("text object belongs to an active page") +} + +fn save_page( + cache: &Replica, + text: ExGuid, + edit: impl FnOnce(&mut Page), +) -> Result, notebook::Error> { + let source = cache.snapshot()?; + let (space, mut page) = holder(&source, text); + edit(&mut page); + cache.save(&source, space, &page, AUTHOR) +} + +/// Reviews a conflicting save by applying `edit` to the page the remote now holds. +fn review_page(cache: &Replica, id: u64, text: ExGuid, edit: impl FnOnce(&mut Page)) { + let local = cache.snapshot().unwrap(); + let remote = cache.remote_snapshot().unwrap(); + let (_, mut page) = holder(&remote, text); + edit(&mut page); + cache.review_page(id, &local, &remote, &page).unwrap(); +} + +/// The styled text of an object anywhere on a page. +fn content(page: &Page, text: ExGuid) -> onestore::page::Paragraph { + locate_in(&mut page.clone(), text, |list, index| { + list[index].text().unwrap().text.clone() + }) +} + +/// Rewrites one character of a text object, the dependent edit the native lane checks. +fn dependent_edit(page: &mut Page, text: ExGuid) { + let content = content(page, text); + match content.text().find('c') { + Some(byte) => { + let start = content.utf16_offset(byte).unwrap(); + replace_text(page, text, start..start + 1, "C"); + } + None => replace_text(page, text, 1..2, "I"), + } +} + +/// The reconciliation fixture's split and join controls, in page order. +fn controls() -> Vec<(ExGuid, String, bool)> { + let store = Store::parse(BEFORE).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + document + .pages() + .unwrap() + .into_iter() + .filter_map(|(space, _)| { + let title = Page::from_space(&document, space).unwrap().title; + let split = title.starts_with("Split "); + (split || title.starts_with("Join ")).then_some((space, title, split)) + }) + .collect() +} + +/// Publishes every control's split or join against the native keyboard capture, reviewing +/// whatever conflicts, then a dependent edit of the resulting paragraph. Returns the +/// reconciled remote image and one manifest entry per control. +fn reconcile_controls() -> (Vec, Vec) { + let mut server = Server::new(KEYBOARD); + let mut recorded = Vec::new(); + let mut automatic = 0; + for (space, name, split) in controls() { + let original = page_of(BEFORE, space); + let left = body(&original)[0].text().unwrap().id; + let right = (!split).then(|| body(&original)[1].text().unwrap().id); + let apply = |page: &mut Page| match right { + Some(right) => join_paragraphs(page, left, right), + None => split_paragraph(page, left, 2), + }; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("reconciliation.sqlite"); + let mut cache = Replica::create(&path, BEFORE).unwrap(); + let mut after = original.clone(); + apply(&mut after); + let id = cache.save(BEFORE, space, &after, AUTHOR).unwrap().unwrap(); + let pending = cache.pending().unwrap(); + drop(cache); + cache = Replica::open(&path).unwrap(); + assert_eq!(cache.pending().unwrap(), pending); + let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); + assert_eq!(actual, id); + let reviewed = matches!(status, EditStatus::Conflict(_)); + let receipt = if reviewed { + assert_eq!(cache.pending().unwrap(), pending); + review_page(&cache, id, left, apply); + cache.sync_once(&mut server).unwrap().unwrap().1 + } else { + automatic += 1; + status + }; + let EditStatus::Published { revision } = receipt else { + panic!("{name}: {receipt:?}") + }; + let published = page_of(&server.visible, space); + let index = body(&published) + .iter() + .position(|p| { + p.text() + .is_some_and(|t| t.id == left || Some(t.id) == right) + }) + .unwrap(); + let intent = match right { + Some(right) => serde_json::json!({"author": AUTHOR, "left": left, "right": right}), + None => { + // The native lane derives both new identities from the guid recorded here. + let tail = &body(&published)[index + 1]; + let guid = tail.id.guid; + assert_eq!(tail.id, ExGuid { guid, n: 1 }, "{name}"); + assert_eq!(tail.text().unwrap().id, ExGuid { guid, n: 2 }, "{name}"); + serde_json::json!({"author": AUTHOR, "offset": 2, "text": left, "guid": guid}) + } + }; + let target = body(&published)[if split { index + 1 } else { index }] + .text() + .unwrap() + .id; + drop(cache); + cache = Replica::open(&path).unwrap(); + let dependent = save_page(&cache, target, |page| dependent_edit(page, target)) + .unwrap() + .unwrap(); + let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); + assert_eq!(actual, dependent); + let EditStatus::Published { + revision: dependent_revision, + } = status + else { + panic!("{name} dependent: {status:?}") + }; + assert!(cache.pending().unwrap().is_empty()); + assert_eq!(cache.snapshot().unwrap(), server.durable); + recorded.push( + serde_json::json!({"case": name, "space": space, "intent": intent, + "outcome": if reviewed { "reviewed" } else { "automatic" }, + "receipt": revision, "dependent_receipt": dependent_revision}), + ); + } + assert_eq!((recorded.len(), automatic), (16, 6)); + assert_eq!(server.publications, 32); + (server.durable, recorded) +} + +#[test] +fn native_keyboard_edits_merge_with_offline_splits_and_joins_or_survive_review() { + let (durable, cases) = reconcile_controls(); + let expected = [ + ("Split remote prefix", "automatic", vec!["Xab", "🦀Cd"]), + ("Split remote boundary", "automatic", vec!["abX", "🦀Cd"]), + ("Split remote child", "reviewed", vec!["ab", "🦀Cd"]), + ("Split remote list", "automatic", vec!["ab", "🦀Cd"]), + ("Split remote tag", "automatic", vec!["ab", "🦀Cd"]), + ("Split remote format", "reviewed", vec!["ab", "🦀Cd"]), + ("Split remote sibling", "automatic", vec!["ab", "🦀Cd"]), + ("Join remote prefix", "reviewed", vec!["Xab🦀CdRightY"]), + ("Join left boundary", "reviewed", vec!["ab🦀CdXRight"]), + ("Join right boundary", "reviewed", vec!["ab🦀CdXRight"]), + ("Join remote child", "reviewed", vec!["ab🦀CdRight"]), + ("Join remote list", "reviewed", vec!["ab🦀CdRight"]), + ("Join remote tag", "reviewed", vec!["ab🦀CdRight"]), + ("Join remote format", "reviewed", vec!["ab🦀CdRight"]), + ("Join remote sibling", "automatic", vec!["ab🦀CdRight"]), + ("Join empty adoption", "reviewed", vec!["XIight"]), + ]; + for (case, (name, outcome, prefix)) in cases.iter().zip(expected) { + assert_eq!(case["case"], name); + assert_eq!(case["outcome"], outcome, "{name}"); + let space: ExGuid = serde_json::from_value(case["space"].clone()).unwrap(); + let published = texts(&page_of(&durable, space)); + assert_eq!(&published[..prefix.len()], prefix, "{name}"); + assert!( + published.iter().any(|text| text.contains("sibling")), + "{name}: {published:?}" + ); + } +} + +#[test] +fn a_native_text_object_replacement_conflicts_with_every_offline_split_and_join() { + let mut server = Server::new(REPLACED); + for (space, name, split) in controls() { + let original = page_of(BEFORE, space); + let left = body(&original)[0].text().unwrap().id; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("replaced.sqlite"); + let cache = Replica::create(&path, BEFORE).unwrap(); + let mut after = original.clone(); + if split { + split_paragraph(&mut after, left, 2); + } else { + join_paragraphs(&mut after, left, body(&original)[1].text().unwrap().id); + } + let id = cache.save(BEFORE, space, &after, AUTHOR).unwrap().unwrap(); + let local = cache.snapshot().unwrap(); + let pending = cache.pending().unwrap(); + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))), + "{name}" + ); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.snapshot().unwrap(), local); + assert_eq!(cache.pending().unwrap(), pending); + assert_eq!( + cache.status(id).unwrap(), + Some(EditStatus::Conflict(ConflictKind::ContentChanged)) + ); + } + assert_eq!(server.publications, 0); + assert_eq!(server.visible, REPLACED); +} + +/// Replays a recorded native split or join intent as a page-model difference. +fn replay(page: &mut Page, intent: &serde_json::Value, split: bool) { + if split { + split_paragraph( + page, + serde_json::from_value(intent["text"].clone()).unwrap(), + u32::try_from(intent["offset"].as_u64().unwrap()).unwrap(), + ); + } else { + join_paragraphs( + page, + serde_json::from_value(intent["left"].clone()).unwrap(), + serde_json::from_value(intent["right"].clone()).unwrap(), + ); + } +} + +/// The text object a recorded intent starts from. +fn origin(intent: &serde_json::Value, split: bool) -> ExGuid { + serde_json::from_value(intent[if split { "text" } else { "left" }].clone()).unwrap() +} + +/// Replays every recorded native split and join intent as a page-model save, against a +/// remote that prefixed the edited text wherever a prefix is distinguishable. +fn offline_paragraphs(output: Option<&Path>) { + let root = Path::new(CORPUS); + for (name, source, recorded, split) in [ + ( + "splits", + "before/notebook/synthetic.one", + "rust-split/manifest.json", + true, + ), + ( + "joins", + "split/notebook/synthetic.one", + "rust-join/split/manifest.json", + false, + ), + ( + "inheritance", + "join-edges/before/notebook/synthetic.one", + "rust-join/inheritance/manifest.json", + false, + ), + ( + "tags", + "join-tags/before/notebook/synthetic.one", + "rust-join/tags/manifest.json", + false, + ), + ] { + let source = std::fs::read(root.join(source)).unwrap(); + let directory = tempfile::tempdir().unwrap(); + let folder = output.map_or_else(|| directory.path().to_owned(), |at| at.join(name)); + std::fs::create_dir_all(&folder).unwrap(); + let path = folder.join("cache.sqlite"); + let mut cache = Replica::create(&path, &source).unwrap(); + let mut server = Server::new(&source); + let manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(root.join(recorded)).unwrap()).unwrap(); + let cases = if split { &manifest["cases"] } else { &manifest }; + let mut entries = Vec::new(); + let mut refused: Vec = Vec::new(); + for case in cases + .as_array() + .unwrap() + .iter() + .filter(|case| case.get("intent").is_some()) + { + let intent = &case["intent"]; + let left = origin(intent, split); + let (space, page) = holder(&cache.snapshot().unwrap(), left); + let offset = if split { + intent["offset"].as_u64().unwrap() + } else { + 1 + }; + let prefixed = offset > 0 && !content(&page, left).text().is_empty(); + if prefixed { + server.visible = + onestore::replace_text(&server.visible, space, left, 0..0, "\u{2602}").unwrap(); + server.durable.clone_from(&server.visible); + } + let Ok(queued) = save_page(&cache, left, |page| replay(page, intent, split)) else { + // Joining runs that differ in language or spacing is not a page-model edit. + refused.push(case["case"].as_str().unwrap().to_owned()); + continue; + }; + let id = queued.unwrap(); + entries.push( + serde_json::json!({"case": case["case"], "id": id, "space": space, + "intent": intent, "remote_prefix": prefixed}), + ); + drop(cache); + cache = Replica::open(&path).unwrap(); + } + let mut reviewed: Vec = Vec::new(); + for entry in &mut entries { + let id = entry["id"].as_u64().unwrap(); + let case = entry["case"].as_str().unwrap().to_owned(); + let intent = entry["intent"].clone(); + let mut uncertain = id % 2 == 0; + let revision = loop { + if uncertain { + server.fault = Fault::UnknownAfter; + } + match cache.sync_once(&mut server) { + Err(_) => { + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::AwaitingConfirmation { .. }) + )); + uncertain = false; + drop(cache); + cache = Replica::open(&path).unwrap(); + } + Ok(Some((actual, EditStatus::Published { revision }))) => { + assert_eq!(actual, id); + break revision; + } + Ok(Some((actual, EditStatus::Conflict(_)))) => { + assert_eq!(actual, id); + server.fault = Fault::None; + reviewed.push(case.clone()); + review_page(&cache, id, origin(&intent, split), |page| { + replay(page, &intent, split) + }); + } + other => panic!("{name} {}: {other:?}", entry["case"]), + } + }; + entry["revision"] = serde_json::to_value(revision).unwrap(); + } + // A split inside a table cell the remote also changed overlaps at the cell. + let structural = ["Split cell".to_owned()]; + let unrepresentable = ["Join inherited styles".to_owned()]; + let expected: (usize, &[String], &[String]) = match name { + "splits" | "joins" => (12, &structural, &[]), + "inheritance" => (4, &[], &unrepresentable), + _ => (3, &[], &[]), + }; + assert_eq!( + (entries.len(), reviewed.as_slice(), refused.as_slice()), + expected, + "{name}" + ); + assert_eq!(server.publications, entries.len()); + assert_eq!(cache.sync_once(&mut server).unwrap(), None); + assert_eq!(cache.snapshot().unwrap(), server.durable); + if output.is_some() { + cache + .export_recovery(folder.join("recovery.sqlite")) + .unwrap(); + std::fs::create_dir(folder.join("candidate")).unwrap(); + std::fs::write(folder.join("candidate/synthetic.one"), &server.durable).unwrap(); + std::fs::write( + folder.join("manifest.json"), + serde_json::to_vec_pretty(&entries).unwrap(), + ) + .unwrap(); + } + } +} + +/// A control page of the reconciliation fixture, by title. +fn control(title: &str) -> (ExGuid, Page) { + let (space, ..) = controls() + .into_iter() + .find(|(_, name, _)| name == title) + .unwrap(); + (space, page_of(BEFORE, space)) +} + +/// A remote holding the control page with `change` applied by a native author. +fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server { + let mut page = page_of(BEFORE, space); + change(&mut page); + Server::new( + PreparedEdit::page(BEFORE, space, &page, "Native author") + .unwrap() + .as_bytes(), + ) +} + +#[test] +fn a_split_publishes_two_paragraphs_and_a_join_puts_them_back() { + let (space, page) = control("Join remote prefix"); + let left = body(&page)[0].text().unwrap().id; + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), BEFORE).unwrap(); + let mut server = Server::new(BEFORE); + save_page(&cache, left, |page| split_paragraph(page, left, 2)) + .unwrap() + .unwrap(); + assert!(matches!( + cache.sync_once(&mut server).unwrap(), + Some((_, EditStatus::Published { .. })) + )); + let published = page_of(&server.durable, space); + assert_eq!( + texts(&published), + ["ab", "\u{1f980}cd", "Right", "Preserved sibling"] + ); + let right = body(&published)[1].text().unwrap().id; + save_page(&cache, left, |page| join_paragraphs(page, left, right)) + .unwrap() + .unwrap(); + assert!(matches!( + cache.sync_once(&mut server).unwrap(), + Some((_, EditStatus::Published { .. })) + )); + assert_eq!( + texts(&page_of(&server.durable, space)), + ["ab\u{1f980}cd", "Right", "Preserved sibling"] + ); + assert_eq!(server.publications, 2); + assert_eq!(cache.snapshot().unwrap(), server.durable); +} + +#[test] +fn a_split_merges_with_a_remote_edit_to_another_paragraph() { + let (space, page) = control("Join remote prefix"); + let (left, other) = ( + body(&page)[0].text().unwrap().id, + body(&page)[1].text().unwrap().id, + ); + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), BEFORE).unwrap(); + let id = save_page(&cache, left, |page| split_paragraph(page, left, 2)) + .unwrap() + .unwrap(); + let mut server = remote_with(space, |page| replace_text(page, other, 0..0, "Remote ")); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) + ); + assert_eq!( + texts(&page_of(&server.durable, space)), + ["ab", "\u{1f980}cd", "Remote Right", "Preserved sibling"] + ); +} + +#[test] +fn a_join_conflicts_when_the_remote_changed_the_paragraph_it_removes() { + let (space, page) = control("Join remote prefix"); + let (left, right) = ( + body(&page)[0].text().unwrap().id, + body(&page)[1].text().unwrap().id, + ); + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), BEFORE).unwrap(); + let id = save_page(&cache, left, |page| join_paragraphs(page, left, right)) + .unwrap() + .unwrap(); + let mut server = remote_with(space, |page| replace_text(page, right, 0..0, "Remote ")); + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) + ); + assert_eq!(server.publications, 0); + review_page(&cache, id, left, |page| join_paragraphs(page, left, right)); + assert!(matches!( + cache.sync_once(&mut server).unwrap(), + Some((_, EditStatus::Published { .. })) + )); + assert_eq!( + texts(&page_of(&server.durable, space))[0], + "ab\u{1f980}cdRemote Right" + ); +} + +#[test] +fn edits_dependent_on_a_published_split_survive_reopen() { + let (space, page) = control("Join remote prefix"); + let (left, other) = ( + body(&page)[0].text().unwrap().id, + body(&page)[1].text().unwrap().id, + ); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("dependent.sqlite"); + let mut cache = Replica::create(&path, BEFORE).unwrap(); + let mut server = Server::new(BEFORE); + save_page(&cache, left, |page| split_paragraph(page, left, 2)) + .unwrap() + .unwrap(); + cache.sync_once(&mut server).unwrap().unwrap(); + let tail = body(&page_of(&cache.snapshot().unwrap(), space))[1] + .text() + .unwrap() + .id; + let dependent = save_page(&cache, tail, |page| { + replace_text(page, tail, 0..0, "Local ") + }) + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + let pending = cache.pending().unwrap(); + drop(cache); + cache = Replica::open(&path).unwrap(); + assert_eq!(cache.snapshot().unwrap(), local); + assert_eq!(cache.pending().unwrap(), pending); + let mut remote = page_of(&server.durable, space); + replace_text(&mut remote, other, 0..0, "Remote "); + server.visible = PreparedEdit::page(&server.durable, space, &remote, "Native author") + .unwrap() + .as_bytes() + .to_vec(); + server.durable.clone_from(&server.visible); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == dependent) + ); + assert_eq!( + texts(&page_of(&server.durable, space)), + [ + "ab", + "Local \u{1f980}cd", + "Remote Right", + "Preserved sibling" + ] + ); +} + +#[test] +fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() { + for join in [false, true] { + for fault in [ + Fault::Before, + Fault::UnknownBefore, + Fault::UnknownAfter, + Fault::Committed, + ] { + let (space, page) = control("Join remote prefix"); + let (left, right) = ( + body(&page)[0].text().unwrap().id, + body(&page)[1].text().unwrap().id, + ); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("uncertain.sqlite"); + let cache = Replica::create(&path, BEFORE).unwrap(); + let id = save_page(&cache, left, |page| { + if join { + join_paragraphs(page, left, right); + } else { + split_paragraph(page, left, 2); + } + }) + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + let pending = cache.pending().unwrap(); + let mut server = Server::new(BEFORE); + server.fault = fault; + assert!(cache.sync_once(&mut server).is_err()); + let state = cache.status(id).unwrap().unwrap(); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.status(id).unwrap(), Some(state)); + match fault { + Fault::Before => { + assert_eq!(state, EditStatus::Pending); + assert_eq!(cache.pending().unwrap(), pending); + assert!(matches!( + cache.sync_once(&mut server).unwrap().unwrap().1, + EditStatus::Published { .. } + )); + assert_eq!(server.publications, 2); + } + Fault::UnknownBefore => { + assert!(matches!(state, EditStatus::AwaitingConfirmation { .. })); + assert_eq!(cache.pending().unwrap(), pending); + assert_eq!(cache.snapshot().unwrap(), local); + for _ in 0..3 { + assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, state))); + } + assert_eq!(server.publications, 1); + assert_eq!(server.durable, BEFORE); + continue; + } + Fault::UnknownAfter => { + assert!(matches!(state, EditStatus::AwaitingConfirmation { .. })); + assert_eq!(cache.snapshot().unwrap(), local); + assert!(matches!( + cache.sync_once(&mut server).unwrap().unwrap().1, + EditStatus::Published { .. } + )); + assert_eq!(server.publications, 1); + } + _ => { + assert!(matches!(state, EditStatus::Published { .. })); + assert_eq!(server.publications, 1); + } + } + assert!(cache.pending().unwrap().is_empty()); + let expected: &[&str] = if join { + &["ab\u{1f980}cdRight", "Preserved sibling"] + } else { + &["ab", "\u{1f980}cd", "Right", "Preserved sibling"] + }; + assert_eq!(texts(&page_of(&server.durable, space)), expected); + assert_eq!(cache.sync_once(&mut server).unwrap(), None); + assert_eq!(cache.snapshot().unwrap(), server.durable); + } + } +} + +#[test] +fn a_competing_join_leaves_the_other_client_a_reviewable_split() { + let (space, page) = control("Join remote prefix"); + let (left, right) = ( + body(&page)[0].text().unwrap().id, + body(&page)[1].text().unwrap().id, + ); + let directory = tempfile::tempdir().unwrap(); + let first = Replica::create(directory.path().join("first.sqlite"), BEFORE).unwrap(); + let path = directory.path().join("second.sqlite"); + let second = Replica::create(&path, BEFORE).unwrap(); + save_page(&first, left, |page| join_paragraphs(page, left, right)) + .unwrap() + .unwrap(); + let id = save_page(&second, right, |page| split_paragraph(page, right, 2)) + .unwrap() + .unwrap(); + let local = second.snapshot().unwrap(); + let pending = second.pending().unwrap(); + let mut server = Server::new(BEFORE); + assert!(matches!( + first.sync_once(&mut server).unwrap().unwrap().1, + EditStatus::Published { .. } + )); + assert_eq!( + second.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) + ); + assert_eq!(server.publications, 1); + drop(second); + let second = Replica::open(&path).unwrap(); + assert_eq!(second.snapshot().unwrap(), local); + assert_eq!(second.pending().unwrap(), pending); + assert_eq!( + second.status(id).unwrap(), + Some(EditStatus::Conflict(ConflictKind::ContentChanged)) + ); + review_page(&second, id, left, |page| { + split_paragraph(page, left, 2); + }); + assert!(matches!( + second.sync_once(&mut server).unwrap().unwrap().1, + EditStatus::Published { .. } + )); + assert_eq!( + texts(&page_of(&server.durable, space)), + ["ab", "\u{1f980}cdRight", "Preserved sibling"] + ); +} + +#[test] +fn recorded_native_splits_and_joins_replay_over_a_remote_prefix() { + offline_paragraphs(None); +} + +#[test] +#[ignore = "exports native-controlled reconciliation for cold OneNote validation"] +fn export_native_reconciliation() { + let output = std::path::PathBuf::from( + std::env::var_os("ONESTORE_NATIVE_RECONCILIATION_OUTPUT").unwrap(), + ); + assert!(output.is_absolute()); + std::fs::create_dir(&output).unwrap(); + let (bytes, cases) = reconcile_controls(); + std::fs::create_dir(output.join("candidate")).unwrap(); + std::fs::write(output.join("candidate/synthetic.one"), bytes).unwrap(); + std::fs::write( + output.join("manifest.json"), + serde_json::to_vec_pretty(&cases).unwrap(), + ) + .unwrap(); +} + +#[test] +#[ignore = "exports reconciled paragraph edits for independent native validation"] +fn export_native_offline_paragraphs() { + let output = + std::path::PathBuf::from(std::env::var_os("ONESTORE_OFFLINE_PARAGRAPH_OUTPUT").unwrap()); + assert!(output.is_absolute()); + std::fs::create_dir(&output).unwrap(); + offline_paragraphs(Some(&output)); +} diff --git a/crates/notebook/tests/sync_worker.rs b/crates/notebook/tests/sync_worker.rs new file mode 100644 index 0000000000000000000000000000000000000000..5ebcfed085021e70843727723a03d088b035f881 --- /dev/null +++ b/crates/notebook/tests/sync_worker.rs @@ -0,0 +1,692 @@ +//! The background worker: waking, backoff, ownership and error propagation. + +use notebook::{ConflictKind, EditStatus, Error, Remote, Replica}; +use onestore::{ + CommitError, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store, document::Document, +}; +use std::io; + +#[path = "support/server.rs"] +mod server; +use server::*; +#[path = "support/model_ops.rs"] +mod model_ops; + +use std::{ + ops::Range, + sync::{Arc, Mutex, mpsc}, + time::{Duration, Instant}, +}; + +#[derive(Clone)] +struct Shared(Arc>); + +impl Remote for Shared { + fn read(&mut self) -> io::Result> { + self.0.lock().unwrap().read() + } + fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + self.0.lock().unwrap().publish(edit) + } + fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { + self.0.lock().unwrap().confirm(snapshot) + } +} + +/// Saves a replacement of `range` in the page holding `text`. +fn save(cache: &Replica, text: ExGuid, range: Range, replacement: &str) -> Option { + model_ops::save(cache, text, |page| { + model_ops::replace_text(page, text, range, replacement) + }) + .unwrap() +} + +/// The text of the paragraph holding `object`, wherever the section's pages hold it. +fn content(bytes: &[u8], object: ExGuid) -> String { + let (_, page) = model_ops::locate(bytes, object); + model_ops::paragraph_with(&page, object) + .unwrap() + .text() + .unwrap() + .text + .text() + .to_owned() +} + +fn pages(bytes: &[u8]) -> usize { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + Document::parse(&index).unwrap().pages().unwrap().len() +} + +#[test] +fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() { + struct Session { + shared: Shared, + fail_read: bool, + } + impl Remote for Session { + fn read(&mut self) -> io::Result> { + if self.fail_read { + return Err(io::ErrorKind::ConnectionReset.into()); + } + self.shared.read() + } + fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + self.shared.publish(edit) + } + fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { + self.shared.confirm(snapshot) + } + } + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cache.sqlite"); + let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); + let (_, oid, _) = text(&source); + let cache = Arc::new(Replica::create(&path, &source).unwrap()); + let id = save(&cache, oid, 1..2, "🦀").unwrap(); + let mut server = Server::new(&source); + server.fault = Fault::UnknownAfter; + let server = Arc::new(Mutex::new(server)); + let shared = Shared(Arc::clone(&server)); + let (connected_tx, connected_rx) = mpsc::channel(); + let (observed_tx, observed_rx) = mpsc::channel(); + let mut connections = 0; + let worker = cache + .start_sync( + Duration::from_millis(10), + move || { + connections += 1; + connected_tx.send(connections).unwrap(); + if connections <= 2 { + return Err(io::ErrorKind::ConnectionRefused.into()); + } + Ok(Session { + shared: shared.clone(), + fail_read: connections == 3, + }) + }, + move |result| { + observed_tx + .send(result.as_ref().copied().map_err(|error| error.to_string())) + .unwrap(); + }, + ) + .unwrap(); + let mut errors = 0; + let published = loop { + match observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() { + Err(_) => errors += 1, + Ok(Some((actual, status @ EditStatus::Published { .. }))) => { + assert_eq!(actual, id); + break status; + } + other => panic!("Unexpected result: {other:?}"), + } + }; + worker.stop().unwrap(); + assert_eq!(errors, 4); + assert_eq!(connected_rx.try_iter().collect::>(), [1, 2, 3, 4, 5]); + let server = server.lock().unwrap(); + assert_eq!(server.publications, 1); + assert_eq!(server.confirmations, 1); + assert_eq!(text(&server.durable).2, "a🦀c"); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.status(id).unwrap(), Some(published)); + let cached = cache.snapshot().unwrap(); + // Confirmation changes reader-notification fields without changing the committed graph. + assert_eq!(cached[..212], server.durable[..212]); + assert_eq!(cached[252..], server.durable[252..]); + let cached_generation = Store::parse(&cached).unwrap().header.generation; + let remote_generation = Store::parse(&server.durable).unwrap().header.generation; + if cached_generation == remote_generation { + assert_eq!(cached, server.durable); + } else { + assert_eq!(cached_generation.checked_add(1), Some(remote_generation)); + } +} + +#[test] +fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() { + let dir = tempfile::tempdir().unwrap(); + let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); + let (_, oid, _) = text(&source); + let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); + let server = Arc::new(Mutex::new(Server::new(&source))); + let shared = Shared(Arc::clone(&server)); + let (observed_tx, observed_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + let mut first = true; + let worker = cache + .start_sync( + Duration::from_secs(3600), + move || Ok(shared.clone()), + move |result| { + observed_tx.send(*result.as_ref().unwrap()).unwrap(); + if first { + first = false; + resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + } + }, + ) + .unwrap(); + assert_eq!( + observed_rx.recv_timeout(Duration::from_secs(5)).unwrap(), + None + ); + let started = Instant::now(); + let queued = std::thread::scope(|scope| { + (0..12) + .map(|writer| { + let cache = &cache; + scope.spawn(move || { + let marker = format!("[{writer}] "); + loop { + match model_ops::save(cache, oid, |page| { + model_ops::replace_text(page, oid, 0..0, &marker) + }) { + Ok(Some(id)) => break id, + Err(Error::Io(error)) + if error.kind() == io::ErrorKind::ResourceBusy => {} + other => panic!("Unexpected local outcome: {other:?}"), + } + } + }) + }) + .collect::>() + .into_iter() + .map(|join| join.join().unwrap()) + .collect::>() + }); + resume_tx.send(()).unwrap(); + let mut published = std::collections::BTreeSet::new(); + while !cache.pending().unwrap().is_empty() { + if let Some((id, status)) = observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() { + assert!(matches!(status, EditStatus::Published { .. }), "{status:?}"); + published.insert(id); + } + } + assert!( + started.elapsed() < Duration::from_secs(5), + "Edits waited for the hourly poll" + ); + worker.stop().unwrap(); + assert_eq!(published, queued); + let content = text(&cache.snapshot().unwrap()).2; + for writer in 0..12 { + assert_eq!(content.matches(&format!("[{writer}] ")).count(), 1); + } + assert!(content.ends_with("abc")); + let server = server.lock().unwrap(); + assert_eq!(text(&server.durable).2, content); + assert_eq!(server.publications, queued.len()); + assert_eq!(cache.snapshot().unwrap(), server.durable); +} + +#[test] +fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recovery_is_recorded() { + struct Paused { + shared: Shared, + entered: mpsc::Sender<()>, + resume: mpsc::Receiver<()>, + } + impl Remote for Paused { + fn read(&mut self) -> io::Result> { + self.shared.read() + } + fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + self.entered.send(()).unwrap(); + self.resume.recv_timeout(Duration::from_secs(5)).unwrap(); + self.shared.publish(edit) + } + fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { + self.shared.confirm(snapshot) + } + } + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cache.sqlite"); + let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); + let (_, oid, _) = text(&source); + let cache = Arc::new(Replica::create(&path, &source).unwrap()); + let id = save(&cache, oid, 0..0, "L ").unwrap(); + let mut server = Server::new(&source); + server.fault = Fault::UnknownAfter; + let server = Arc::new(Mutex::new(server)); + let shared = Shared(Arc::clone(&server)); + let (entered_tx, entered_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + let mut session = Some(Paused { + shared, + entered: entered_tx, + resume: resume_rx, + }); + let worker = cache + .start_sync( + Duration::from_secs(3600), + move || Ok(session.take().unwrap()), + |_| {}, + ) + .unwrap(); + entered_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + let stopped = Instant::now(); + drop(worker); + assert!(stopped.elapsed() < Duration::from_millis(500)); + let shared = Shared(Arc::clone(&server)); + let start = cache.start_sync( + Duration::from_secs(3600), + move || Ok(shared.clone()), + |_| {}, + ); + assert!(matches!(start, Err(error) if error.kind() == io::ErrorKind::WouldBlock)); + let weak = Arc::downgrade(&cache); + drop(cache); + resume_tx.send(()).unwrap(); + while weak.upgrade().is_some() { + assert!(stopped.elapsed() < Duration::from_secs(5)); + std::thread::sleep(Duration::from_millis(1)); + } + let cache = Arc::new(Replica::open(&path).unwrap()); + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::AwaitingConfirmation { .. }) + )); + assert_eq!(server.lock().unwrap().publications, 1); + let shared = Shared(Arc::clone(&server)); + let (tx, rx) = mpsc::channel(); + let worker = cache + .start_sync( + Duration::from_secs(3600), + move || Ok(shared.clone()), + move |result| { + tx.send(*result.as_ref().unwrap()).unwrap(); + }, + ) + .unwrap(); + assert!( + matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) + ); + worker.stop().unwrap(); + let server = server.lock().unwrap(); + assert_eq!(server.publications, 1); + assert_eq!(server.confirmations, 1); + assert_eq!(text(&server.durable).2, "L abc"); +} + +#[test] +fn cache_failures_stop_retries_and_return_the_error_without_remote_publication() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cache.sqlite"); + let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); + let (_, oid, _) = text(&source); + let cache = Replica::create(&path, &source).unwrap(); + let id = save(&cache, oid, 0..0, "L ").unwrap(); + drop(cache); + let connection = rusqlite::Connection::open(&path).unwrap(); + connection.execute_batch("CREATE TRIGGER fail_attempt BEFORE INSERT ON attempt BEGIN SELECT RAISE(ABORT, 'test cache write failure'); END;").unwrap(); + drop(connection); + let cache = Arc::new(Replica::open(&path).unwrap()); + let server = Arc::new(Mutex::new(Server::new(&source))); + let shared = Shared(Arc::clone(&server)); + let (tx, rx) = mpsc::channel(); + let worker = cache + .start_sync( + Duration::from_millis(1), + move || Ok(shared.clone()), + move |result| { + tx.send(matches!(result, Err(Error::Database(_)))).unwrap(); + }, + ) + .unwrap(); + assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap()); + assert!(matches!(worker.stop(), Err(Error::Database(_)))); + assert!(rx.try_iter().next().is_none()); + assert_eq!(server.lock().unwrap().publications, 0); + assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); + assert_eq!(text(&cache.snapshot().unwrap()).2, "L abc"); +} + +#[test] +fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() { + for uncertain in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); + let (sid, oid, _) = text(&source); + let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); + let id = save(&cache, oid, 1..2, "L").unwrap(); + let local = cache.snapshot().unwrap(); + let mut server = if uncertain { + Server::new(&source) + } else { + Server::new(&onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap()) + }; + if uncertain { + server.fault = Fault::UnknownBefore; + } + let server = Arc::new(Mutex::new(server)); + let shared = Shared(Arc::clone(&server)); + let (tx, rx) = mpsc::channel(); + let worker = cache + .start_sync( + Duration::from_millis(10), + move || Ok(shared.clone()), + move |result| { + tx.send(result.as_ref().copied().map_err(|_| ())).unwrap(); + }, + ) + .unwrap(); + if uncertain { + assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err()); + } + let mut previous = None; + let started = Instant::now(); + for _ in 0..5 { + let (actual, status) = rx + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(actual, id); + if uncertain { + assert!(matches!(status, EditStatus::AwaitingConfirmation { .. })); + } else { + assert_eq!(status, EditStatus::Conflict(ConflictKind::ContentChanged)); + } + if let Some(previous) = previous { + assert_eq!(previous, status); + } + previous = Some(status); + } + assert!( + started.elapsed() >= Duration::from_millis(30), + "Worker spun instead of waiting between retries" + ); + worker.stop().unwrap(); + assert_eq!(cache.snapshot().unwrap(), local); + assert_eq!(cache.pending().unwrap().len(), 1); + let server = server.lock().unwrap(); + assert_eq!(server.publications, usize::from(uncertain)); + assert_eq!(server.confirmations, 0); + } +} + +#[test] +fn reachability_notification_retries_without_waiting_for_the_poll() { + let dir = tempfile::tempdir().unwrap(); + let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); + let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); + let (tx, rx) = mpsc::channel(); + let worker = cache + .start_sync( + Duration::from_secs(3600), + || -> io::Result { Err(io::ErrorKind::NotConnected.into()) }, + move |result| { + tx.send(matches!(result, Err(Error::RemoteIo(_)))).unwrap(); + }, + ) + .unwrap(); + assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap()); + worker.wake(); + assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap()); + worker.stop().unwrap(); + assert!(rx.try_iter().next().is_none()); +} + +#[test] +fn cancellation_during_connect_does_not_read_or_report_a_false_refresh() { + let dir = tempfile::tempdir().unwrap(); + let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); + let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); + let (entered_tx, entered_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + let (tx, rx) = mpsc::channel(); + // An invalid image makes any unexpected read observable as an error callback. + let shared = Shared(Arc::new(Mutex::new(Server::new(&[])))); + let worker = cache + .start_sync( + Duration::from_secs(3600), + move || { + entered_tx.send(()).unwrap(); + resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + Ok(shared.clone()) + }, + move |_| { + tx.send(()).unwrap(); + }, + ) + .unwrap(); + entered_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + drop(worker); + let weak = Arc::downgrade(&cache); + drop(cache); + resume_tx.send(()).unwrap(); + let started = Instant::now(); + while weak.upgrade().is_some() { + assert!(started.elapsed() < Duration::from_secs(5)); + std::thread::sleep(Duration::from_millis(1)); + } + assert_eq!( + rx.recv_timeout(Duration::from_secs(5)), + Err(mpsc::RecvTimeoutError::Disconnected) + ); +} + +#[test] +fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() { + let dir = tempfile::tempdir().unwrap(); + let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); + let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); + for interval in [Duration::ZERO, Duration::MAX] { + assert!( + matches!(cache.start_sync(interval, || -> io::Result { panic!("Unexpected connection") }, |_| {}), Err(error) if error.kind() == io::ErrorKind::InvalidInput) + ); + } + let shared = Shared(Arc::new(Mutex::new(Server::new(&source)))); + let first = shared.clone(); + let (tx, rx) = mpsc::channel(); + let worker = cache + .start_sync( + Duration::from_secs(3600), + move || Ok(first.clone()), + move |_| { + tx.send(()).unwrap(); + panic!("Test observer panic"); + }, + ) + .unwrap(); + rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(matches!(worker.stop(), Err(Error::Io(_)))); + let (tx, rx) = mpsc::channel(); + let worker = cache + .start_sync( + Duration::from_secs(3600), + move || Ok(shared.clone()), + move |result| { + tx.send(*result.as_ref().unwrap()).unwrap(); + }, + ) + .unwrap(); + assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), None); + worker.stop().unwrap(); + assert_eq!(cache.snapshot().unwrap(), source); +} + +#[test] +fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() { + let dir = tempfile::tempdir().unwrap(); + let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); + let (sid, oid, _) = text(&source); + let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); + let id = save(&cache, oid, 1..2, "L").unwrap(); + let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); + let server = Arc::new(Mutex::new(Server::new(&remote))); + let shared = Shared(Arc::clone(&server)); + let (tx, rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + let mut first = true; + let worker = cache + .start_sync( + Duration::from_secs(3600), + move || Ok(shared.clone()), + move |result| { + tx.send(*result.as_ref().unwrap()).unwrap(); + if first { + first = false; + resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + } + }, + ) + .unwrap(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(5)).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) + ); + model_ops::review(&cache, id, oid, |page| { + model_ops::replace_text(page, oid, 1..2, "L") + }) + .unwrap(); + assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); + resume_tx.send(()).unwrap(); + assert!( + matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) + ); + worker.stop().unwrap(); + assert!(cache.pending().unwrap().is_empty()); + let server = server.lock().unwrap(); + assert_eq!(server.publications, 1); + assert_eq!(text(&server.durable).2, "aLc"); + assert_eq!(cache.snapshot().unwrap(), server.durable); +} + +#[test] +fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() { + struct Busy { + server: Server, + reads: usize, + writes: usize, + } + impl Remote for Busy { + fn read(&mut self) -> io::Result> { + self.reads += 1; + match self.reads { + 1 => Err(io::ErrorKind::WouldBlock.into()), + 2 => Err(io::ErrorKind::ResourceBusy.into()), + _ => self.server.read(), + } + } + fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + self.writes += 1; + match self.writes { + 1 | 2 => Err(CommitError { + state: CommitState::NotCommitted, + error: if self.writes == 1 { + io::ErrorKind::WouldBlock + } else { + io::ErrorKind::ResourceBusy + } + .into(), + }), + _ => self.server.publish(edit), + } + } + fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> { + self.server.confirm(source) + } + } + let dir = tempfile::tempdir().unwrap(); + let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); + let (_, oid, _) = text(&source); + let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); + let id = save(&cache, oid, 0..0, "L ").unwrap(); + let mut remote = Some(Busy { + server: Server::new(&source), + reads: 0, + writes: 0, + }); + let (tx, rx) = mpsc::channel(); + let worker = cache + .start_sync( + Duration::from_millis(1), + move || Ok(remote.take().expect("Contention caused a reconnect")), + move |result| { + tx.send(result.as_ref().copied().map_err(|error| error.to_string())) + .unwrap(); + }, + ) + .unwrap(); + for _ in 0..4 { + assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err()); + } + assert!( + matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap().unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) + ); + worker.stop().unwrap(); + assert_eq!(text(&cache.snapshot().unwrap()).2, "L abc"); + assert!(cache.pending().unwrap().is_empty()); +} + +#[test] +fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() { + struct BusyOnce(Server); + impl Remote for BusyOnce { + fn read(&mut self) -> io::Result> { + self.0.read() + } + fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + let server = &mut self.0; + if server.publications == 0 { + server.publications += 1; + return Err(CommitError { + state: CommitState::NotCommitted, + error: io::ErrorKind::ResourceBusy.into(), + }); + } + server.publish(edit) + } + fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> { + self.0.confirm(source) + } + } + let dir = tempfile::tempdir().unwrap(); + let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); + let (_, oid, _) = text(&source); + let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); + let first = save(&cache, oid, 0..0, "L ").unwrap(); + let mut remote = Some(BusyOnce(Server::new(&source))); + let observed = Arc::clone(&cache); + let (tx, rx) = mpsc::channel(); + let mut failed = false; + let worker = cache + .start_sync( + Duration::from_secs(3600), + move || Ok(remote.take().expect("Contention must retain the session")), + move |result| match result { + Err(Error::Remote(error)) if error.state == CommitState::NotCommitted => { + assert!(!failed); + failed = true; + let page = + onestore::PageCreation::new(None, Some("Queued"), "Fixture").unwrap(); + let second = observed + .create_page(&observed.snapshot().unwrap(), &page) + .unwrap() + .unwrap(); + tx.send((second, None)).unwrap(); + } + Ok(Some((id, status))) => tx.send((*id, Some(*status))).unwrap(), + Ok(None) => {} + other => panic!("Unexpected worker result: {other:?}"), + }, + ) + .unwrap(); + let (second, status) = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(status, None); + for expected in [first, second] { + let (id, status) = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(id, expected); + assert!(matches!(status, Some(EditStatus::Published { .. }))); + } + worker.stop().unwrap(); + assert!(cache.pending().unwrap().is_empty()); + assert_eq!(content(&cache.snapshot().unwrap(), oid), "L abc"); + assert_eq!(pages(&cache.snapshot().unwrap()), 2); +} diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 48acc1b606f5b318cda25c8baa4bfdfc13f00624..643170fddd8efa442a2289a43102a41b808724a8 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -95,13 +95,6 @@ test = false doc = false bench = false -[[bin]] -name = "offline_paragraph" -path = "fuzz_targets/offline_paragraph.rs" -test = false -doc = false -bench = false - [[bin]] name = "tree" path = "fuzz_targets/tree.rs" @@ -109,13 +102,6 @@ test = false doc = false bench = false -[[bin]] -name = "offline_tree" -path = "fuzz_targets/offline_tree.rs" -test = false -doc = false -bench = false - [[bin]] name = "page" path = "fuzz_targets/page.rs" @@ -130,6 +116,13 @@ test = false doc = false bench = false +[[bin]] +name = "offline_model" +path = "fuzz_targets/offline_model.rs" +test = false +doc = false +bench = false + [[bin]] name = "canvas-editor" path = "fuzz_targets/canvas_editor.rs" diff --git a/fuzz/fuzz_targets/offline_model.rs b/fuzz/fuzz_targets/offline_model.rs new file mode 100644 index 0000000000000000000000000000000000000000..7ca6ff25ad22eca95c326e254a76ccd09f6cee44 --- /dev/null +++ b/fuzz/fuzz_targets/offline_model.rs @@ -0,0 +1,11 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; + +#[path = "../../crates/notebook/tests/support/model_ops.rs"] +mod model_ops; +#[path = "../../crates/notebook/tests/support/server.rs"] +mod server; +#[path = "../../crates/notebook/tests/support/model_schedule.rs"] +mod model_schedule; + +fuzz_target!(|input: &[u8]| model_schedule::run(input)); diff --git a/fuzz/fuzz_targets/offline_paragraph.rs b/fuzz/fuzz_targets/offline_paragraph.rs deleted file mode 100644 index 0d8bf5e5dfc6e567c7ee9922f6a3f552c43f7d64..0000000000000000000000000000000000000000 --- a/fuzz/fuzz_targets/offline_paragraph.rs +++ /dev/null @@ -1,235 +0,0 @@ -#![no_main] -use libfuzzer_sys::fuzz_target; -use onestore::{ - CommitError, ExGuid, OutlineEdit, ParagraphJoin, ParagraphSplit, PreparedEdit, RevisionIndex, - Store, - document::{Document, Kind}, -}; -use notebook::{EditStatus, Operation, Remote, Replica}; -use std::{io, sync::LazyLock}; - -#[path = "../../crates/onestore/tests/support/disk.rs"] -mod disk; - -const TEXT: &str = "a🦀 e\u{301} 東京\rEnd"; - -static SOURCE: LazyLock<(Vec, ExGuid, ExGuid)> = LazyLock::new(|| { - let source = onestore::create_section("offline-paragraph.one", TEXT, "Fixture").unwrap(); - let store = Store::parse(&source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let doc = Document::parse(&index).unwrap(); - let (sid, page) = doc.pages().unwrap()[0]; - let space = &doc.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let outline = *view.nodes[&page] - .children - .iter() - .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) - .unwrap(); - (source, sid, outline) -}); - -struct Server(disk::Disk); - -impl Remote for Server { - fn read(&mut self) -> io::Result> { - Ok(self.0.visible.clone()) - } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { - edit.commit(&mut self.0) - } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - onestore::confirm_snapshot(&mut self.0, snapshot) - } -} - -fn paragraphs(source: &[u8], sid: ExGuid, outline: ExGuid) -> Vec<(ExGuid, String)> { - let store = Store::parse(source).unwrap(); - assert!(store.checksum_mismatches.is_empty()); - let index = RevisionIndex::parse(&store).unwrap(); - index.validate_current().unwrap(); - let doc = Document::parse(&index).unwrap(); - let space = &doc.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let mut output = Vec::new(); - for paragraph in &view.nodes[&outline].children { - let node = &view.nodes[paragraph]; - assert!(node.children.is_empty()); - let [text] = node.content.as_slice() else { - panic!() - }; - let Kind::RichText { text: content, .. } = &view.nodes[text].kind else { - panic!() - }; - output.push((*text, content.clone())); - } - assert_eq!( - output - .iter() - .map(|(_, text)| text.as_str()) - .collect::(), - TEXT - ); - output -} - -fuzz_target!(|input: &[u8]| { - if input.len() < 8 { - return; - } - let (source, sid, outline) = &*SOURCE; - let directory = tempfile::tempdir().unwrap(); - let mut replicas: [Option; 12] = std::array::from_fn(|_| None); - let mut server = Server(disk::Disk { - visible: source.clone(), - durable: source.clone(), - operation: 0, - fail_at: None, - write_limit: 4096, - random: 1, - }); - for step in input.chunks_exact(8).take(24) { - let actor = usize::from(step[0]) % replicas.len(); - let path = directory.path().join(format!("{actor}.sqlite")); - let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, source).unwrap()); - let snapshot = cache.snapshot().unwrap(); - let pending = cache.pending().unwrap(); - let rows = paragraphs(&snapshot, *sid, *outline); - match step[1] % 8 { - 0 => { - let (text, content) = &rows[usize::from(step[2]) % rows.len()]; - let offsets: Vec = std::iter::once(0) - .chain(content.chars().scan(0, |at, c| { - *at += c.len_utf16() as u32; - Some(*at) - })) - .collect(); - let offset = offsets[usize::from(step[3]) % offsets.len()]; - let intent = ParagraphSplit::new(*text, offset, "Fuzz").unwrap(); - let id = cache.split(&snapshot, *sid, &intent).unwrap().unwrap(); - assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); - let next = cache.pending().unwrap(); - assert_eq!(next[..pending.len()], pending); - assert_eq!(next.len(), pending.len() + 1); - } - 1 if rows.len() > 1 => { - let pair = usize::from(step[2]) % (rows.len() - 1); - let intent = ParagraphJoin::new(rows[pair].0, rows[pair + 1].0, "Fuzz").unwrap(); - let id = cache.join(&snapshot, *sid, &intent).unwrap().unwrap(); - assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); - let next = cache.pending().unwrap(); - assert_eq!(next[..pending.len()], pending); - assert_eq!(next.len(), pending.len() + 1); - } - 2 | 3 => { - server.0.operation = 0; - server.0.fail_at = - (step[4] != 0).then_some(usize::from(u16::from_le_bytes([step[4], step[5]]))); - server.0.write_limit = if step[6] & 1 == 0 { 17 } else { 4096 }; - server.0.random = u64::from(step[7]) + 1; - let _ = cache.sync_once(&mut server); - let after = cache.pending().unwrap(); - assert!(after.len() == pending.len() || after.len() + 1 == pending.len()); - if after.len() == pending.len() { - assert_eq!(after, pending); - } else { - assert_eq!(after, pending[1..]); - let Some(EditStatus::Published { revision }) = - cache.status(pending[0].id).unwrap() - else { - panic!() - }; - let store = Store::parse(&server.0.durable).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - assert!(index.spaces[sid].revisions.contains_key(&revision)); - } - if !after.is_empty() { - assert!(cache.snapshot().unwrap() == snapshot); - } - server.0.visible.clone_from(&server.0.durable); - } - 5..=7 => { - let store = Store::parse(&snapshot).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let space = &document.spaces[sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let (object, change) = match step[1] % 8 { - 5 => ( - *outline, - OutlineEdit::Position { - x: f32::from(step[2]) * 18.0, - y: f32::from(step[3]) * 18.0, - }, - ), - 6 => ( - *outline, - OutlineEdit::Width { - points: (2.0 + f32::from(step[2])) * 18.0, - user_set: step[3] & 1 != 0, - }, - ), - _ => ( - view.nodes[outline].children[usize::from(step[2]) % rows.len()], - OutlineEdit::Collapsed(step[3] & 1 != 0), - ), - }; - let mut expected = serde_json::to_value(&view.nodes[&object]).unwrap(); - match change { - OutlineEdit::Position { x, y } => { - expected["layout"]["x"] = x.into(); - expected["layout"]["y"] = y.into(); - } - OutlineEdit::Width { points, user_set } => { - expected["layout"]["max_width"] = points.into(); - expected["layout"]["width_set_by_user"] = user_set.into(); - } - OutlineEdit::Collapsed(value) => { - expected["kind"]["collapse_state"] = u8::from(value).into() - } - } - let id = cache.outline(&snapshot, *sid, object, change).unwrap(); - let next = cache.pending().unwrap(); - assert_eq!(next[..pending.len()], pending); - assert_eq!(next.len(), pending.len() + usize::from(id.is_some())); - if let Some(id) = id { - assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); - } - let bytes = cache.snapshot().unwrap(); - let store = Store::parse(&bytes).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let space = &document.spaces[sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let actual = serde_json::to_value(&view.nodes[&object]).unwrap(); - for key in ["kind", "layout", "children", "content"] { - assert_eq!(actual[key], expected[key]); - } - } - _ => { - if let Some(edit) = pending.first() - && matches!(edit.operation, Operation::Outline(_)) - && matches!( - cache.status(edit.id).unwrap(), - Some(EditStatus::Conflict(_)) - ) - { - let remote = cache.remote_snapshot().unwrap(); - let _ = cache.rebase_layout_conflict(edit.id, &snapshot, &remote); - } - let pending = cache.pending().unwrap(); - drop(replicas[actor].take()); - let reopened = Replica::open(&path).unwrap(); - assert!(reopened.snapshot().unwrap() == snapshot); - assert_eq!(reopened.pending().unwrap(), pending); - replicas[actor] = Some(reopened); - } - } - paragraphs(&server.0.durable, *sid, *outline); - paragraphs( - &replicas[actor].as_ref().unwrap().snapshot().unwrap(), - *sid, - *outline, - ); - } -}); diff --git a/fuzz/fuzz_targets/offline_tree.rs b/fuzz/fuzz_targets/offline_tree.rs deleted file mode 100644 index 2ab663e639f852eb8bb94e8d5dba9b75acac8aec..0000000000000000000000000000000000000000 --- a/fuzz/fuzz_targets/offline_tree.rs +++ /dev/null @@ -1,9 +0,0 @@ -#![no_main] -use libfuzzer_sys::fuzz_target; - -#[path = "../../crates/onestore/tests/support/disk.rs"] -mod disk; -#[path = "../../crates/notebook/tests/support/tree_schedule.rs"] -mod tree_schedule; - -fuzz_target!(|input: &[u8]| tree_schedule::run(input)); diff --git a/tools/test_offline_document_history.py b/tools/test_offline_document_history.py index 95698e545b9bd17d42c3a7d9e558df3282cb49ae..634836ab224265b0789b59fe1a83e12b36717b38 100644 --- a/tools/test_offline_document_history.py +++ b/tools/test_offline_document_history.py @@ -1,7 +1,5 @@ import copy import json -from pathlib import Path -import subprocess import unittest import uuid from unittest.mock import patch @@ -140,58 +138,6 @@ class DocumentHistoryTests(unittest.TestCase): with self.subTest(mutation=mutation), self.assertRaises(AssertionError): document_history(logs, 2) - def test_production_cache_workload_matches_the_independent_history_model(self): - result = subprocess.run(['cargo', 'test', '--locked', '-p', 'notebook', '--features', 'smb', - '--example', 'smb_offline_client', - 'tests::document_workload_retains_dependencies_and_receipts_across_reopen', - '--', '--exact', '--nocapture'], - cwd=Path(__file__).resolve().parent.parent, capture_output=True, text=True, timeout=120) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - events = [json.loads(line) for line in result.stdout.splitlines() if line.startswith('{')] - documents = document_history({'w0': events}, 2) - self.assertEqual(sum(len(document['states']) for document in documents.values()), 20) - self.assertEqual(sum(row['event'] == 'remote_attempt' and row['state'] == 'Unknown' for row in events), 12) - self.assertEqual(sum(row['event'] == 'remote_confirm' and row['state'] == 'Committed' for row in events), 12) - document = next(iter(documents.values())) - states = document['states'] - for phase, next_phase, stale in [('nest', 'unnest', 'right_text'), ('delete', None, 'tail_split')]: - read = copy.deepcopy(states[stale]['attempt']) - started = states[phase]['attempt']['acknowledged_us'] + 1 - if next_phase: - self.assertLess(started + 1, states[next_phase]['attempt']['started_us']) - read.update(event='read', started_us=started, finished_us=started + 1) - logs = {'w0': events, 'r0': [{'event': 'ready', 'document_graph': True}, read, {'event': 'done'}]} - with self.subTest(phase=phase), self.assertRaisesRegex(AssertionError, 'missed acknowledged'): - document_history(logs, 2) - for mutation in ('nest-intent', 'unnest-intent', 'delete-intent', 'tail-boundary', - 'nest-children', 'delete-resurrection', 'graph-delta', 'extra-attempt'): - changed = copy.deepcopy(events) - intents = {row['kind']: row for row in changed if row['event'] == 'local_document_commit' and row['operation'] == 0} - attempts = {kind: next(row for row in changed if row['event'] == 'remote_attempt' and row['revision'] == state['attempt']['revision']) - for kind, state in states.items()} - if mutation == 'nest-intent': - intents['nest']['tree']['placement']['Move']['parent'] = intents['unnest']['tree']['placement']['Move']['parent'] - elif mutation == 'unnest-intent': - intents['unnest']['tree']['placement']['Move']['before'] = intents['unnest']['tree']['object'] - elif mutation == 'delete-intent': - intents['delete']['tree']['object'] = intents['nest']['tree']['object'] - elif mutation == 'tail-boundary': - intents['tail_split']['split']['offset'] += 1 - elif mutation == 'nest-children': - parent = intents['nest']['tree']['placement']['Move']['parent'] - attempts['nest']['document_graph'][parent]['children'] = [] - elif mutation == 'delete-resurrection': - tail = intents['delete']['object'] - attempts['delete']['documents'][tail] = attempts['tail_split']['documents'][tail] - elif mutation == 'graph-delta': - attempts['nest']['document_graph_changes'] = {} - else: - extra = copy.deepcopy(attempts['nest']) - extra.update(revision='unrecorded-tree-revision', state='Committed') - changed.append(extra) - with self.subTest(mutation=mutation), self.assertRaises(AssertionError): - document_history({'w0': changed}, 2) - def test_boundary_histories_retain_every_intermediate_graph_and_retired_identity(self): baseline = history(boundaries=True) documents = document_history(baseline, 2) -- 2.54.0