From bd75120fd0bc96c21765e63397bc3f53aef56740 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 4 Oct 2026 20:27:34 +1100 Subject: [PATCH 1/5] connect: vendor the mdbase-next SDK (@mdbase-dev/sdk c573c96) The SDK is not published yet. The packed build is vendored and referenced with a file: spec until a published version replaces it. --- packages/connect/package.json | 1 + .../connect/vendor/mdbase-dev-sdk-c573c96.tgz | Bin 0 -> 112626 bytes pnpm-lock.yaml | 34 ++++++++++++++++++ 3 files changed, 35 insertions(+) create mode 100644 packages/connect/vendor/mdbase-dev-sdk-c573c96.tgz diff --git a/packages/connect/package.json b/packages/connect/package.json index f8f79e4..9eb810e 100644 --- a/packages/connect/package.json +++ b/packages/connect/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@mdbase-dev/connect": "0.1.0-beta.125", + "@mdbase-dev/sdk": "file:vendor/mdbase-dev-sdk-c573c96.tgz", "@mdbase-reader/core": "workspace:*", "@mdbase-reader/migration": "workspace:*", "yaml": "^2.9.0" diff --git a/packages/connect/vendor/mdbase-dev-sdk-c573c96.tgz b/packages/connect/vendor/mdbase-dev-sdk-c573c96.tgz new file mode 100644 index 0000000000000000000000000000000000000000..9221cb941c276d832ea9671ab94debe205524e99 GIT binary patch literal 112626 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
    {@DZO@;KnB-GI zv71CmjDsJF6M`H%1XTw=cx-V5re~LbKyP8(n|It?%V452OGCJN>v|)%@}BG$=8RoV~}=EFM`x=dj)LD}znyg2hr1UZCHw>RP2)uoLtwR7L*#U*^u z8r-U7a>VK(bzO!K4Ytpp!1ZwscQS~6EJ4?q*J3XO{Vd>y2^A2h5W%8yb#p5RYlM)4 zBr6tGHyg=`k3ddGNKuX7^x*O|V)!Lg&Dg?5s14YFwv}7R3u~(D>8{-{U#7U)Yb*PfofT8il zlW!p^HcZy|ZbwimLW+u1+VCM_46GLH{=tf`*<6&@noZn=T@dA& zPOkt1kkrD={s!Xl0Up@p!79m|YoGD}sv!3-l-wbC*SA4jY#Oy0UKTEZz6aBWR^^*T zlEz?)854jpY`g&c5g}v+kb~D=&>ks0KE?4hl0m!UU7NWMIfl(lRch#4YZvRxa{|vZ zDqBO%lrzi(l9OV2?w+wDuuzR^cdmbooQp2dcm%sWn)w(4CXv*8PPcQvM*28!)%86iO4kK6l*$7Wj2mpHvpm5@=N(9uB!X7K#LpZHYCr2Wf&Yow)^=S$J*j~;L9 zQhoSgy(ZJws@7Gj=jIh+sQJ@{0h!$LGhDmifK?_9_b@8=k2?uJR9nO~cWe34^5T0n0oPQa-6!m_A!yO^m&8Z$aCwhBN zed8t3_Gp{d+dZ&N+VC4X)YfxNRVu$?zKCY1EP0EfwqnBPD$1H{Tsn)6=B@PbveRaB z14ph-`m1{Acc;!-VyaA$nbH*+*={_`gLH?UtxKEG@pf<{)+2U9cLMdtfaMb@$|YoR zSndda1Vmy7zN1C2(e_P!c*vfVdBNx`11z%DESG``3$i$gv)@&<3LLcGsHI+Eg*_gR zSe74vMgWxyCR91g7io<71W6=sk}roXN}$BF>iBdJ@2!^xtFJ$}>|Uy9QNGV&A=xj= zHn%dWEJ6b)y0&GzHZ@uBAtqtg;Endj%M*Tb2O>glnKdvW~~dFY+@ z7abf-s=A7BN*ZEALYv&UA@v-=4Y$>vH$O${=3H^AYLYgnmY#240WtRqhJS(|*byGZ1E}*u(h)L*BTfy*krIa9wR`*k3&_}y&Jw}brE|T_;uXkk( z6jH0O^orTP&Pu!%kf)dhc%!Hn(9g!)I-QA<(6tD@@p@&na`bfO zGHTWO>kgr+M^F8B*#TC&p_#~YQx>h9YJ_(_Bui75>h_J1=~Gh$`rc|7#vY=OwPs-Z zSi_o(TBeoIu2NhNmn?-oMZ8tZd4ENGr**BZeBXGA$SR-r8ZmAaTXc|za;g=@xW14^ z(2b5V)U_{b>zEn;*C4Y7u^(O$$~J$RE)=&NYwDi2gcewZwEu}FOpJv^J!^fJ0DI<_ z{n}DY0k3;oigl7GCpN3e{PYW5+C9-&G>6Xebam(z&BTtpa@cJX8f>!EEw$;09f5MW z6*#0Gx7u0}z{tan`W@bEd&C@fI7F;=*22>v+eQyf-c~1&vw3l+BWr>iAm)<6)_A-o zL6`xtp}l^q(iJ_QwOkO*$Zm3nne7t;<_idK2ZGJI7#rhAf}yhdV{k=Y#!1FY!vw36 zH)EwlXr!u#J9)!Z>{zU%O*y}6K?>`hs+&bJ(x)C7)8bp`D>H9eW%{*i}go;4`g z%C_tz1OG~_YghJykHfy2UOplVGihjVHr9;9VP(yWHalzHMeXtwPW}kxak>v%>q_!-ZSDeUsBBdUEfT_3lv6(iW1?tl)1*+G$EsdDU@C`4DGGB6VZSYpt3E4|bqZMOC1)bA`7lj5o%OwIcul_-nQ;DN~ z$S!6O?8+lB-*Y(zBb!WaS)5!auJgIN;izHmC3CA!u5`^Fzu;{F?tdJ2{zr&7*k7vf z5V?~98WvKN+Wko9-mIt0=4cnmGd9VqnJzY@5z%;-V9LN68mQ%BxE^0dI6;$z`vgm$*0GBi_NP1{Le#@q zNTU9FmcFy`>S5^CuoZM{U*B5ZWDZp|Ml7K3w~n9MI%H4Zg;kl%124JT)ptL_ zfeiMoyrc}mSatV7>mbG>6<5`4JhIk#m82;v^F^h@#n2>yHN2?CEm-Cj*L8i^F=b?i z7_!%tCXvn&%Fx`?wFcpwSA|0CvjADvGiV#k+PRrqnU6uYK)*cpSE!g>VMz)YD2_SF z)LbPC@(iC1C^Rpj(YXQ>43sL4kFFbxsD-_8puF*SYD8|Z}2@E0<`cQmX` z-;=$kgCTD2paRZQ;unXgDuquM8F55kHC;7KE4QoF?i6}C{2xr_i!u@~cu^$T^$)MV zJEKh3;ZNwn{mH-Hzk)|M{8}8wiL4HhafCm~kKD&_I6Qgqfcc&ehxd<;AAZ3;x&QEF zcyx4fGJL>3fgcY}*eAnZ0|5&Ni27tW{0NgA4u`D%@4v}ELzcpgsdJ93H@#0O3;MJRU<}Qh40QtS{pn3m zg09Y3V3rs#iAwcYL=;D3wqNox;aNrV64_#|=Sgl8@BV;M@)tOAlz}(OlUV_%BYE#K z#saSM+0S_aAUJZFv77yN+*hOE6RPoq)qZ(Omv0()K8N(K@qok&CNda&jH!Y!|B^=G zvoaA`oT6Yimmr$dk_TNmQoa_Y!qOOI$g$Z?#Irn0BA&7|Ux_k;BYw6nW+J1CxD^P& zQVn-J<8o$wK9)0n{P1M-)yV@k6SrTF^%syJLSU{hOggKq)>VEZGCg^)L0{w47IT7D z%|uc%neiKOg(Cq|yR26XZ^jWX%e88$77v@@u?VUF#LVFxk|o$X_AJeLb@G5L^nVn$ zpj7yyxMdr1UF(I1K*`}xvUjRafGCAKoU!Ok+*auJPk+%-P$?%#MG5o=YDa(sq5Ro; zz~(|q4u@i+n95w@gIi%kdZW5ROCdzb=1_N@F9j>gJYGZs>OD2y^9qnBK@9q!1mn-% zRqM@&efCZbWHWY^eifz$SvyY~$kz1g_lWW{)-#aQYmV9a2!#cCIc>Cp=uL-uSIm~+ zp8h>}s^CylaMp#&ocWafkYv@DkIIs-+uu#rm5^N@Dhgp#u`t2_` zyy@!a1uxoP%z0t7SI#++DgnQMQhUADN8jV~FpXS!rxrP2&z~rnQ*UR=!a`}S zY3>;oLGQt{9^D^A7L8c2bb_%_by6?K1)mDNB+EX8oiEu}uJa=u94=N>SC~o=%xep0 zcCba4*5*{pvsAlkpnS(*l#yqYUdjTGFCQ_1vpxH|<+rK2j83;eo+Q^uhc$@oeWu?d z7vhOn=UGfksS1qCMNv@ET~M_2EG`8QH8Iza)PHNu|KQH%6ba|TOE!Q1Uqm?QnQ<_e zV-JaVDCCzA$XN_u4ZCQ-!M{Sk5BnA}D_NB;HsRS$bgHp0DP1+m6TQ7~N#Ln; zH)s0GkAy_;Kz3qF52+HjJMcBh^VG!`JoDgd`FjJ`f8r^&5|zk#MfHoyruhh#HEc-g4V{8Zee!CH1q2G0)dL66C! zx?;XuBFFyO7?T7|1pDk=wjunFy|sZfu^R;6js9lbU5n5)N{x3KT?^}W4AHMK?Wi^F z4M-|B*&dx>zL|RMk4|9hM<>8D?3VYB?UwhCq2>K!Xh}_e_~H4JtJhE289V&T+u_0Q z`N8z^-Itq#`uBr9ejgogK08d>^a80*t@4lk0pK5uZ)aMsBB ztkKoe=&pnnD=;u1I6OGK92l*u9KDdp8RuRbm;i zFZ-K==E0W(c64k#IXWJ&qZ9kl$$%XXtw+bh0lRGqvM(qBvZ#`?h1SustRuM+wt|HC3wTdR*eXHn9=>D;_ ziZJjo&(`A+I-pP{fRe9JdDb2m&(O}Mhr~0;-E1GCR_75@eLP}j<_B^PP*Hoq82^j7 zc>LtacYO%cL)6tO8=kFKWaV@RL?9eY;lr)c9NM(Wfn)h$VweFTYD0+3GrdvU8{Yt) z3QW;4G}$IS{F)sdf4OrLsA;lZCX}B9Ma3@Q_Ck~b??j}EH2d-1#riD? z>T5Q<{bFkW%RuIc*jMaec>Cqh{;@H1b8ND^%ZXxrXe6t2r-78?8kbya~7&`izaz zo}QW2^J6JnqI^C{GRT`QTg(|fZh$ilKr<~Z4=_TV(*BL#e)bMI82r7t9H0Jjkr|69 zW_k8>UQ}xgv)vJAx=ma}S6V_mY^RA}0>Jh6vX@QyX*7980TY~OZC2kd* zNx$e9oLi`gel^rYE!4p2=0nX!pVN<6%qtFtkP%5zun8bMCI-83a-Aj9B;qQT*QG%C z6f+i8V(zEBccfnUnwb>1`K1)t2vXV;u{*0Xu#a10AImoC=NPkfGw7frW03P-em?^i zw3UoO36MY69BLKiep8(I@LF6g}^z;in1uxIi@RDkolMm2A1c`hEKhGIQs#`)l zz(oD*`{^rCJ(87yE5Q}Nt7TYwYiDS#Qkfx5)1*hSeF`X9Naf3d20cG6vc>%6RN(@f zHPsJ>HV;U$Slk-KNDD_nJ$zNJKU4uq16XytCQ_v7Ade~5Vf7t>c5THfg&C=V^e$d*+*L1U6|6= zx@{+}a(Z+8<=f#U`|6D8zu5shf@fWK5klPfvv+vbk5p7db2e}pi7XylcY=SMB%pEw zurpJ`+Bz%6dH{y|VnC)7mv5h zJ!~cG$x*Mn8uE6ntO`8Q{w->0K6?klZZRigepQmW33yp&CFQ^wtUdm`sE(+rge`jC z; z*#V~%o24ZP@Wj7^K?v9YeGubZkdp=ES4JSA;n7)p)NS>rYgv3?oC=xRiP9F+C+j!- zx_yA^m)|B?+!)9^KzZ zyJuDWdiPBWdfk5Tc3-9JnGFDz7t=)4u)(frgMCbb-eyS_AWfu9Bxt4L*mPf} zek^%`(S4BkK?hvtnW|gN0!;jU%jZQZuoNjAPUwOSdXF3vXkgN{zV}WA?)4&8)55(% z*}}kX>S}$6n^;@~zrbAyAE0K(+ErB^P!pxUG4F`z#Bjh%aJbh_lsu&gc*j~Tid5*K zg0KGvsHN~b($GX&Lqihq@@;b2rI`A4)asT$3JtU?b?Vl=zbfl%vt5X!KUt$EsFCO{QD7)0#+{ESV5PNf*M#_V_< z0m87O`>1jE@^^FLsVSWw?jqkBEl5x__nO{OAE-TjRfm z4^NJd-T1HLqv7#y@n65jKg;3c>Fd`oUtc|b`Q+(E?e^is{zD)_a@%AAaN1CQ4NsH0 z`jcuD&8wdmdBv~9Z6rjDFQ=stSF+%d9z&Jq zS1BY4_2S137Q^f*_((O#7gdzc#Z?`FRscFc#lK@qz0p$z5jsr~LY8NuULs2UNhwn* z9#vP$_rB-#I7=iRbC7uePb%m5S7jfHGOuztb;B8#FIU;C5=w_vYl`JH$cdCKeJzl@ zQK@+_#GET{Tue~_%`lzf*cs;7Xh0$BP?iPmxR90{aN?C?bT7~H(l?LxG|$al@D`qd zDWHWk^Aev?L0pO~tAmS~exggrB$X8DG=vN!l1)mUQ96+^^f6`u7M;0UM+K&9s2a*; zo{jrxR)kyxDgq9Z5{TRCl#Nw@*BCSafS?%a-M}J9>jF=)ZANikZ){ssR~*x5(|^G@ zPL;=2#gkOlLPWWK1VP^~)RRQYg;2jCL7I3Tw?pKLGM}W4jlT(8Q!hd_tA0oLnK6%^ zRYvR)k%#tB0?Z#hq>LSURVIsTbdgihc8^{?KUKk&Nw!qkPjTv@o6riigeJl0(Q3jm z|7H|Lf_tv#Orxg!{lL}Dy<}(#wapi>UfU9J>ynldlM374`C+Pc-w9N=YoJ;b(;jL= zL3<3?XlxGKr9!tCqRO4{RkH@NNzE=yMcq4r>UIrOYhP^C;qZ!H23^GwfhtA~DGq8n z9oa-x2cN3v>aSM5aDxE0%%Et9#Q4GkY7g=#4B9Zi@eXW1swiBNXnpMgzMB||h7yb~ zJfQZVfx;jF^P84iYhmZBsEcgT*^=fFo(Vx!@=Veh$CGqXVh|*q`8pjl-cs2-k(jbd z$PR6Uk>QCbs)<@ArGY+wl!q!F%X3Ilr3ksUF(dS<*=&Ub}9%83aK+c{2>G8 zbqJvwLLS0P$^uch$M_H~@xLn9xfSOzKv(GjhL%%)HfUYvX){wyD9x1#-QqO^lM5Rk z)(pY?lFF7Et;mw+v;rhQo-MMQ0sf?NB`{!IVpYP{T}j}Sal#+NEn$EemVPMGoX1of z*LxXf$@-Yih(nkW>nR}suYjS7yPir?r2CU9EDSAcVGH9{=%&@4u~k|I+Q;2u)3)U`V6fH4Hh^j>u$%B|Bl@)CeQ#B{9~2neO@~OkyXp zOI5ku*du%%f5-r*`-}+2pl*?SRWd@2KwY<76*A()a-KgqC53aH+>CtudVOm~^FURx zv?UmFDcNGMt^-Q+=a!tf+*%K5QEC6>rHyg)9N65Uq`i$-9t1n8Fu*bbHqFZltVFT7 z46@uegNiI8Na2fylE46V+64>2e6xU@b1h(iqNXo*fb4+&=(nUg;~UB>jHAM^PGd+R zZ@Lcp_R2vn5GamVMth+FWV82}7YqlO&UfV8c*n(G@>gL78Kb5JneC$@(UgS8BYj$* zs4b#WCt>@=3f*_;@sEPvKy^a(3OF+ylxJ*64+JmMb%+0jQoqn>+9|s!iPf1k+Gg}& zo?QqDcF+J~P<28_7#oBjo3~l@%uP?Ao5!Fnw8EC%=&`1v4K{}y@TLz3q~m@Cl@xpW zw7Jq)?G0E^ccb53{*tETk=s=) z(>rfqVpn(=eaM6(3~N_%Lg!3X$-GKmT(zJEbB@$s9bR5;ZvH0gYnW%)YY=%ptz-Ep z<+>v6ue*J{4iDL5I+3A{ym=sZ9LH~rOr2EHo5LMdR$bt~WG|9$z$6fXia8ZAcxxF# zffhMVvGN22*Wx|1n(>UJgy7=oZrt{81xL|h>E!(3aL1IJ9oR9ic`0RP6XIuYyFLf zb6ZSXADM36*_WAM1!yP{S33DDrnR5uX)MY*?;YG?Uh!)YY%kTI24|F2L6A@xz>`os z52+VV4)Hw1ThX~02HD(`v@5r#v`ijGi&Ew-nD^lo+7G)b&$KRTt(5n9*h+d2H3oMU zpn8fX1QdP^+_C`bQUUsnguO%(cQGKijcRl=!Ra~0JWfyjWt?$wE%K=svb__bn(x5( zG@w3l+UjSk42{`5524=+;7jlcVjU1wKlOg?R#{$yhVw2?Aw(+j__N_l8d7^(yHX9J zg@%8|j4#_U-^wQ^nC`3}WYYV!AaW;a2Lf-dlHFzXYE8jEwxpiUc}=PWbv=W;*K#o@ zb2}8AVsV)ln8Os3uqI@YLzYKcBRaRKtOclsPyjINCFlb_<VcBBcS}uTtVvYgVX$@I zvT9;&MqK3INtFQwBKhY@Mb(Zmk3f?ER!^VnR-w})B*q}6SV+0!gI3W^SqU&ZwX`GF zh6hfNPp5eIz#m`XEa>;WGX!Gqh?y>wsV;iPbyj0(IvubVyqbmc1dgM>(ld6Cp!HiL zRrok_a)Bgh?K}tkcM-+I zjB|nZ%}YoWujxTi0H&SK_qbpMqVJdQUIQ~z3F>VYLvC9d^rIazUOkyc~vkcmlSCiX8CziG*XJ6B?tgs25d zEt#}AOmdBMxm%j?ZAMgwC?*mrHDJ(#H&- zDONxML&FGB`@Ix#tGQAYpDAv}Z-XPn*&>mo$lBPUnoNj|K`b%Z74X+M&jd2JrJzgJ z-PR|L2h3Egbl;#^O_S>xq~Q<+ooq;elZ9NPe>*5Q7uf_H^WXqksQNeViIHRqPBZgL|o z<*_|9WLpZY>5@pY*4MNQBC4{5-qu8wVZ&`P&A^By12r9}ej$_VHs-7n7_GNROZU#0 zdAV;h36r&-hrv56-sTLbm~lzYkwE;wzy=6?)Zh{RB&N}aD2EC*HWIKaV!MB9T0^a$ ztCzw;jYT+3&_hnmwEjY|$D+TjU}K$~L4Sp#`udDLXycQprY?;I84>ko-pW>Xdl1My z&D~0<1_L8`tzltvUkxa0Ek3ALHnF&YSH0nu=%i!IDR$}=9-L9&Qmw*Tyml7Nd$8G4 z+qQ}us}#Dg())JUTk4XvYNJ1zfMOrAoZ!0Hb)6tIikC4J$0e@UT%_ScI$eTB(A?`D zho9~s1OwzA1;O-9=7JQLbou(7ByKQZ1;h@;SgR-#b+*S)>sNVMCQH2jU@j-Z0jZcY zU{pq_i&;}tPvdDR%4C|TvU+IAJwRmy$k5PS-)CX}Fd&tk;6aAap9M*;_)X`QTU!qN+@N@}^%*N9AOVOU1Ilk$H=V=RYES z3W;uAbRL#aSgJety}&!k(SW^GO)=O{|32CU+ABz}v<(l&vjw zSx3d;lk9q=g)=Dep>|b?vX!}Kl)b_W9_SPg)W+!NYoZ0y?pC;p%F1%@-WEt5*Njeb zLCyxDXPR1&P?}?|HqAiMx4~YQfd1b4f7(~%LEk+*bM6NR?*rRlI^Y3>=Ce}87Q;m! zOPp-m3fe-8Lud>>8tztukMWH<*6Po?iQSxQSJpsJ2Q9-vdn!<@N!b!XeQ4m~XcsOF zW*GpOuDh%vbhWM7xv|072nV1n@ZF<~MVc&8-{TE2wLYlIG!)s-3juj#$qcr|TvhL} zduNW(tos|Ue63fp5v}QoDO&5!lsMfIYFolrYv1wchAttp5h--fmjkqUL}5OiLJd#X z3WyTMcB$GEV+-Zjx1hVGIBl%jkEpHHLa`&*($c?;H(NHC9*s1%cOQf!r5^V5pCZML_wbXjjzid&wH9H-v z3X3joVjyB?pH3-oa()lG(o)v!`cqwgs#C^!Z#o3`19H8mJUi-C`<+y~3UZ*PsU-$i zITVU5{3pA)YtM~aA@*@aFg-hWzn$5M#7)~stHS%ZgflqMHh6L__1G5QXpx>8aCNC* zfpV?@H_I2vH>V8aA4_H8g|swdTht%BMo9hIe+RG*A=~+o@-SfWn;x7GG;h@9EbX3&L$VkG{ zL+fU5;2$Gs2s$+`n>)S)T8>*@Jyu6Ksf|CbYD4fCk6ViJu`hE=bV$zofce+hFzD}T zNPfpisYDquH}!3V`a^Goz@zAakJ`L$QVM=^>h;D%_-J-=peef+zHgoLEj%~WnPe08p4}cloxACbX9AO25ga`R}vy+OSN)9Q`NX5Z&j{f+x>B{RkLy} zsu#91eXzHo-toe3m+)@6>Dv{L&2h-~IF@@2M0y9}Jkhy?cP;3Pd?}P_=@crUg0c6t zm`d=$Lxa0S0|eH@HY!>Yv!CZn0i+E%ak`j&PSKE*-iO>E;j`WV#c1#vr+hji<+D!E znt{N@viS>yF2beD!``kgy)I93F_|Y-@YXb_;zj_+m14k}rz6YgiaR7PE^RAjm&Wl4 ziXXLu#E+DT=vf`lfg2=RlqCf$W33+W1)wl$DPom)nZy|POeNW7`d&z;k2IQV-~Wxb zXrSi|CN-by>`%t(5Kc?DMk<^A{$}odjNE&!=dPux9lYP6lNQ!q@x}7#7`lIxY&BN= zAEmSYb>shr$H!kBdE)TH#}VH7y|__^TYu-0qAuas?5{;8jyD_G6-W6--s6MtFu>JfW z-ao$Yp8xmnKlm;F|JOSI*&9JYL~wN&Q9bGd;s#BuR3Z2cmcc_^CfwC2S`agUMR{t5 zT)$&4@axy=<7S@|)C28OX|o0?%BA=oxR~*UW}M|xl)rI?fAs$M9<%>w`TvXK6Lrh+F3lxNbN_}Tn z9&DwljRmBl#+I03?LhU`YjoUT>V{CpH20}Vo#=DZnFox~=+FZxoc)GkPEV25Pz=#l}Ws4z?H&$9kbkFiW&Fi(A)`(60M#@gq>JGg@w;vG^irRMCU*bO#Aha9TmhR@7m24D|!psMD|z?QicR5d#4m1Tk2C2~OdnBn-4 z1NL$vlQ`j7ze9v>DfNPQ^1aH9tTgqog?pgE%uAtP;WMYGYCK_Q>^jXSJbilvnQuls{{CB_7*C~0zvf`Ni#n8Irdm|R2=Wp~nGNDOMg zwLYN?ugwQ2fWRw!p>6CIW-6#br)Y1Ec~GaB7YVq&WcEZXm}xp%7Zxib_X93RCf=uj zaf-L01n&*bsN`Nh%)JQp2u~k?*$=?*cK0a1OL;tL-(5C$41v$jixj`8ZpjxO7$f9y zqO@94@g|o!HEc=exn*o{S##}7}ss|OnU1Ul?*EtB~jWQ(clEGQ@`k6|q1 z0mHKyEE90TWlF2EkeF6R#bAx=H5*e=HzlO|uM(!Gg2SS`mQz&;EcYZom&%fgc@EU? z^K%yX)izcj~q+wrTF?y3uJ}SL~(+-eS`R zH@5M;o(F+JsI+nG$J;T#Q5dZix*b<4iZf$U|3L!D^s#=hX#Jh)Ri zq_jGIy^y8Z5+1B3xOXMWl>T4d$NA+DfWw?*jXLp81ZO^_pbUu4h1$VbP0u{>Qp(e zzBHl@WW_Na8#MB#+7VlgPWn8sDW|CF3rZnUj!_s3<@ZbCCNBXD9NA|Q_xq51sS;)I zO`fNMXMNZG8^82BTAOg%uK(qYnnqCP0_vaYIZ$$YP%={h5xU;Vj2Cj2*FL|fC!5#) zy>njPTmZzNmE5*UP;Of-tNF_0sPKT?%Ybw>;gxV8(9Sv0@v~`hj<#ckO7bWRr={b)D4? ze5=x>l=es`8>+wF9p9M5u(a3Sn4dP<)g^cP0%@8u7FMwY%``rR{wSYO+}~^c+O!zW zAguY{lNx{9S?{@PHg0jb`ovq*e>e$s)%mx;AI`h_2yL_<`Yubuq(K@Bp^easG_B8e zh-(Ph^Cyz6AQBPkDq>P=sI?()5=fYL%0OYwbj5R2{3j%Zo1m#81|Csxzk>4%N&p1a zOhDeTG-VUWp;?w9s+z==c*Fr0>4z=J92LQy++*|}zqFZtizRqgv`#f%6Cmv8&p&6M zLWlK!ZY^zquOUBG688t#I}xP4`&&b3=(jzN9bH;|IIFMu6SOgki23dUO2TjM0$%^m zmYd!`PX0d{KK$aOCI3G>`r_m_`Ttk6|Bfw>&9UPJ3S~0lgGAS8tz+3Czdd?}O4lzG zU~1P;7NA#&R4yO`b+M>x2!Y-2SzcnSi82m?u@ZlxZ8H}098CsNo0p$gU{?a$ZFSem z`8$FP{6z}>`%qj-l#o}vWN9)L(K>=yy5|)XqndN5Cag02-o=Cm6I2AJYbF!mzaw~l z0BK&KgdWMsY?3BfoUX~_I?3ZiRU%U2cnp1s7|H{m(&yt9n7hy#DsXwS=V%(}--m+a3uhcYYqLpghqpG>+mTIKZiMHCY zDHyt6c|@&zf@4U*yW=SUYS#X2YE~LHffACFluz<#0kNn-X^$#RGR(yJHVnfuoR)c3 z&3RRc@{;b~R7VNL>G5apZZ_bR_@|4P--lF3Dw(bWg;WSc-i&Qs7(&+tE6&;Y;NalD zvd`Yp*3wr;R_4=Ft%G8@OoaFI(GiD=3Kb@&icvCY8)sLNJnkYN~x#W$wt)6Cp z*fQutxHOdXZDFMER4&4D0xzeSD%?gFNi^_W)k4CbkWdG0>o2E4FYNW%J$BR&tNgos zCCbNKTGp|*y#mt*^?oYUe+2&bI+?oN*A$^fw-;uEj4j3 znS&D5>|pPF^iHv`XvRw*a)j|%XxRc@@Qi3N#ar6?;bJ_g$g2XqcN-?^<`_n6wU||?Xq6KRSe6H26?CIR#j4K z`*bS(rt~=IzuGm|Fl$WfG3CgVJ~to|FWPU^k@_Ey<;%D>sK>9n1^_xb*}jHI^mkBt zNl0(2luP0 zO8BP1zi|oG3!&bdPg=?GH=3dke&Ih9^f2dhnWwTzx@rU(0Qry2&|K|9KMvVb9?jSo zd|Sp;=pC@411+^;v<4OIG)LR<1tb9X2Cek^wfUBOcenY&yp2>iF-0*!9s(^+1f6BK zV8pP1oj%wj1zekX~zgU9cff8%ZQ zQxKtP@6S}0$uUeHopj|{t7Y_^!&C{ywvKbt zMFf`1yjDQsL$AxYrP9C+OtBta3OLMi_#%nQyyEgkIrykhJLT21H55e2#UrK(+#c^{ zA8niBJXt1Dp8~%;eX2pV0xZzslBSzIgI5S(z^2<^)R6>@y$8xcboKoqbKP{fmee_q z1Xg3xX>|jc%oiy-NON$Uz7d(KOhLWLrUXut@Ce5IxGn`|+q~R-e;^z*n7*{tOHtU4 z!D)tjp@yc?G+iSelSr_MfGg!9D+P~c$rH3uIVjG+Zmm&Tj9lPDaxLh@iN9FWWMQQ`< z3I;$R$F2=mpW;Rf1o_fWi>a|_zN)fpWs#aRjq{eJ^gSHifD5;JiJSfMXG3o}?nCGEbmRpFteKDqo~=!wqzhoceNrN&u}h=;=cm z1DCKR&@@4!XLQ}Q>(DfX3iJY@JdFlz-I22wKP{oSZV48}O5nEZsY&(rsP^i}4dVWqvhdOav(6(2t zPw#9M8GU}6_L!+=jrM#~f{>MmuU>T*x~IrNfjGgjshT*5ld!-)TrN|DOS)srQ08+H zK&nF!2g5o42^{^;&e&1EBk0>Ri`Ld^&8jVD>shwLI%`_j+*ieyTq$2juMzQcFI9U^*b$5jz^Nd6L1ep_}7c*yzcJ=xy}_9&>+R4% zj0Bq8r?Z9-My_IL&_;vPiBc(Ow;~kR9pO~)YEc4t21TgGIss&T z>e&<@^u6o%f$WMFYNXi^c{1X6D!RzcxT>bNOQQsqOGxOy&LRydT)gPn=LQ2fZJQ~W(}DuhKz4A2R|+h(HryUNc(}Z|d%gt=sf6 z?fV(9r;7ODp~?i{EviU$&j)yQgZQ5{sI1#Qnrh57nZjp1=PQQ@NpsNxMPiPUE2C|q zaTJm!Ed)1lw=j8q^VVq!T}M_@g4W8H0R%GdEAT+m-Qv!UYeRcQZKi8ve2o){)_f!j zB|r_BN5tq}kga?}^{L99bRFIuiCh{f$SEk=miy0{2&?>+k}lfs4auV&MFEL={(tt~ zZM|({OBbEjd5SWZR{|ywMM|>lNm+I)K6KRa>9d@q=T_7rNEAgxB!CHkIz-X>_SJrX zeX(Dm-=iO8J<0yY7*&Nj0iSX_Cf4!KV$^O@a|FxBwC+3vlNPa~7I!20g4^9wKz-fr_BPFU+ZOu5~pI zIMOILcmQ2-yQ_j%(yTh2LQYK~>6OdLOsI`~Dhho=f&D1Jl#dfnrh`U3(z?2%LV;6j z7Vb5aM~1ej+oq3lS$d;s&r>Ltk*XM%t zSNqD$?|c&r`@H>~e2%^kmLcC$4WCZoM7!X2J?e#Kvoj~up@|EfCctwD@o9s0I9q4@VHJH|gCWtOYv(vaIT zgWEF zuGnLCE#D3r4SVN3SaB#fVM8`)i*u3q7_?%K*;uGksR{A;N&Og}YT0o-iI!lkbXE_@ zLOw1Z&F50npq~bAK2XYz{bsos^CA%ZWwI>Ftmq)E^qp@ocTmlYD3y?#u9z60D7?SV zAY=FxE)MJ|^iDXbVFw^)*?LrG)W#Z`9Qb7+Bw;(jj*1??s@P*}K%6{ZekuBO&+69= zRunRt0L2iX262z2aoxIC8vU{$uL`-lF2Ia}956SVEFJ;O`SB zbE_}f4D#oY2BvxLE_<++WHqLY$+}vq>kUF&j%i+-E1!rO8Y3K6tlSx|S{5xyfxRtM z5>0j~g(tj_$QQ836shkuPXMhYCg)TWfOic13-3t_k-W4^w~jUD9^-^t#sL0ss@kT_ z@p}wg<_XicJthhxMFug~rq2p=n-;`BBp!sG$yc?Yhf{YYCMr~s$}%|?gk}xvl2R*s z>pujke@(kf^nPCqqh*b8Idpa98cV3?yk!|nbWdyu-iyUJg&*TL--Jy*M&NwjKn?zH z#KI5nNDltWz+-x)mlz7iL&uMz$vF!^j6`*beUd%C-0<1rz+EVsE3 zPQ<6RE?zu1wq9(o!HF8i5E2Q_dW+~P!106*4{BM{OW9qGh1|$yB^O0CC&DP=IzI6* zx%IdXNSAE~HkQzOqA74iuC7U_%4w$Jyz4*Eh1W_8W%?alOg*6584Z+iuQ675J>{VE zp6GPj&vNMB+B_bou1(ay9OnLU?%ip!lK4MiUeGZ{cpB{+o<^2;RYCq+ z`1WJFDQ)bhU=6gIuu2d<(C6bT(f}!GSU>D7)z%tz$+gCO+cv&Ak1l?P;k7}H{2t?} zNk{ole7-TM1a$%g;V!bTHO`Ptx}Q2$++^rE)H22?_%sxX?>9@GGSEy}LdYjc*+INq zPl=cmnQ%|EhR%8v#aXMu>0OVfu~3a~f^trFa0v0Q^!M%Ns6qQ;D|mwjrR1RLd%W3B z>s6VG^MDHW|=IlCJ?|0uI?^woK3U$ROWc z)2OpF>kQelZK>)J=xgN{$3%_LvAX((NUmjiew6&mhiuT_+F@VgKVd_d(1a)G{R3MG zAdXo8V9@0x{b0 zn{T{2Gm3;%V%`n*Efg{D;2_AN$vL_Tdan>|0>vVymVq+-2y8a}dbb@AbrLr)rsF}_ zeilu;KAyJDwumO@#F*N|7rau|(}>|K%~S&1rL3B6!e!ONxn6k*_RMcfqqme+Wcy!oE(1Gcv~`Svin;-CJSi z&{IRm2Xd#Q03c*sn!X_e-X(dK>QKm#OAb|Y()O+1^Aw=suy40E!Peu#Zo6S+@R`>^ z!`=}s*T!~#mf9^7&mXUA=Cs*0Q+LO-ZcA;>n9>20K3UKGa{6)Zmv{ub4j9utWB7`d zuX5`5$N!My<<#SNS)#-&k6)ilew>_fFjaXGhQx=q#SGzth6+W98t!^sFsB~RODPh8 z1Lh*Cc#%Z&PyzY=j} z*mT!{*NF&T2T*pLs&LWV%pgnQbB=ZeoE4jbM42*iH)4zNNOUAPSpczbyah;6Tq#dgSe@6##498$6UNJcVRTx7KO->@armb(k-|!`gA!Zk`8?lgXEML!< z8dQB@!QcjD@v1unJ}@T4LX%O#7_4A$Fm{Jptd^}Mvm&;wR&0oai6S6!xbo0~?%Co- zXe$SN4;BNFSjlxvfuh?Iuup=Nrw_j1d$V366OEer*J^N9=_Gc_`_Vz#06v-mFpQR6 zkS+2KtS!+VpTM&sTjua--izk*?DAbyR7nJ0sd+EUg-=nI*5yGyWk_?bcX{w;;F)K{^66-{R)k=CL|{|E zUmIO|p*^5ex1Y9xw_mt)iEbEp*Q{g25VmAp*N;0pa;$LTus~z{+hH9y4%daFo|y(m zj@yPsTAPuYGwj{fxAys%Eh`(ch+s^!yp$P<=h-BhWAx&l_)KDgBUlzB6_6}5C@aVS zKqh1j&`j~nEH5=~=|;RM)~iqc<|g|QC>uxuk)5g;W+;Gsa6=Z=XB2=$R@ow%^xWWy zGw#V(WcccVXS3kC0eztI20RrtC{LfE7c|HpjGg6}qab(UaAXWYmV49jXxdW(SjdiX}YFX(rQFfY71@UxNdwa$6bUnR+_sCw zeXdVo0j&*_`(^)5JAKW-b-NkphH0=6ZC17SIOMAlm(N zajQo~Ax=CpwKU{Txa!im*YG-l0R8sXP*y8M3a+Hh-PlVYQBmA!1GKFv!4v+T%L-HL zE@xX?n(sw{zGO1|5__&klr^tVx1~po6PwLrE6(K}^Z@14wx(Lzy1m0XiGu(R=oflo zgy#7H{?7LAno@%xKDsN`O!VYiXo3zT^%IUGOv=zY#VfSQD2Hg;Wcb}_-3kJQ#K|Q= zZ$f6w-CZ_FjiDgKqcuJIRhPtfduz&=#UCsZNf?&TCKP3PFXfkQ^yv<|!4ZAMkX9Y* zaW!)3j&*rIo(!Iag<5zqV#Mz|vPBvZm zZ@LYv7s1&wh=E%??HvzJNCxj>`jCop>#W}J2_gujocswoX-?MLc|IxCFRAALaGag7R| zMvH&6HM)};5HCQWy~@atkPzmiP>p`Ff)8u{-86hs3!r#im9@DP{1LkvJtq+;zMW{M zuD8fa@|k`*_1La|jtz!9E(pAGDxED{=zD|X_iCkfl{J=8@cjOu%=R8w?jE1IKx^^d z|Efzw-4W`awjO=#g5GgP<+eYKlM=FFdz{EyYHf5Q|E#X$!epdUWj6W#+FBO8q2Sqk40f$_7_usAxw~rlVHi}jodRxMM8XvvlpbM4YX{+M(b^|D zfjo3SSJyqC6)oJuxA2%kOBzQV*C=^nNWGHoa3W_z<}5e;e^E*W)PTZBOYe9>IOg5h zfYdh=Ihl@yagF%*+i&ci4kHd=q*~q`?8h1jXsm8)xCep9U%>Z?8!}R76QHzLjVaJn zrXkG~AF^0S$T23Dk_owxbhR^<>kL!Ql4)fp(7LB=?Q2AUP`1lD*^}R8ZOw!`Ex6=K zTsEZRS)b+jI8D)>Glz%oak&fQvO%g$O! zB&gEDLGU*`riz~5XUfj=S7ZNoowe7U?qE9VI=kIw$2N?Nk{C*%1488Q3ncWAy)j&; zv^ITqzk<8&yXt#E3|bW{eSqua3rB9Q4#Cba+3|#9sohgA0}&yD0;=SUQt)MkkB5{ip@XnP5{p@6xBo?KRVG_{+*&HzA0YNe0rE-(2j~qI1Y_BjIm`#ty)-Vq0 zXa=extTPg|;a6I?dNk6RHp$*F?MC?N6~t+k20%(oS%RR2?eMfsFpGG?HOTEIa9@)m z$XsHHd>FRl=ndKNWOm{}5l?0k!>_9l_OOR;kf?iAxpk1GWma}~)ZhlG4+!NxF_A#f zgecivM*@_f$p);da*65npi;sltuqE3pPwKhoKtcaiAddbj&vY9g|v@{bzz;=g@C`5 z9_QmIJXQl?Q}&N4XDqc{fC z1sqFA4X%%DYg4IBGB)$Z#?;J7@}SAPCuapI5~tF2!4-~N{u&tqcIV_* z+9Qu}2{-Oo1cItM1bB@tZQjG$%&V|PFV9rh@h2X(0d+;KxOiyY&v4Q-wDWsn7W7A= zyZR%&)%K0uNRBO}ScmWWDJ)K=)6U00B*T#P5u06K_9O}6zvgG=yx1Ssv?Km9KQ?F3 z{eY~tSEVp0qD?qMbHx#9CyZ6QB=Ko-2wk#(6b(H5xdiKNn zSNkFpU_na4-Y>|i{$l)>N0amDjBmzCS#73S%zM9-fBDS+_51zZogL2K^oO zm+c3;{lQ>&7yj-K1`qJxzx)CJEdiJ9Fa7>!7-YZSXX@X7kpDKn{`w2{H5>gC>6rm%^{QF?5x7F>j zH(AU{*bZJmBFQ34*&<5PkUe{KScdHNvv-(Po)>^E%FC+k_P$`)uMPGpNtaguiTAJ1 z|M~R&4{tskzJ77~{P6uIb$C+}cbQGjNA$@en!G(4veEzAe3gugsJPyQaFt|2YO$kb z4tuBN`=63D&MwO#8~yxq@8>+p`Oji@I9CS802Oxuj!PK$f=*6=QKzs>VnX$`RhTjpU zDir5woM%Z|F|azbNtAwtRt#R0SsGQUR?*cj;2M-H1oy);C`zjmCSY1VQ$KI1V;vyl z;k4}R)!)ovB;anxQ@hw^P61*@d6B6ST1a{IO#CvwOnIe$G0OmuqX=0(kE&@_EX>lG zQ9T^^u=?rtmRSt*18D`})pz_#73ytH##zz(#i$K}Za-w`NvRh^Kozs3W?#}ua`;_Z zMTth=71=yH1M9LXjbI13nNpxi>oa^N<_iGC399Bnrqn*8Uw0rx=b-U6XWSc zkZS3)>x_7Lkxh6BnwyJbC?V7!GLssJLKiih`h}T6;`R%{0YB)ZpYvh^x41*~u+b(( zRPl0?N{?88cK@FBh!I_s$26%RdT|I#fLTk5ECqvL z$)fpXbX~IOtl)f)C1ttf?0@VIx_9TA4x{{^uiid8cy;jZ-SdMF2me#B%b#(mBiyn= zU{T8{ETGS^_n>;?&yym^Fx*}2nn1Z?s2DgyK|qFB+&PX>+i)-j(&^|O?0D2#_xvuw z9%Jk>jV_{O9*yT9s2LT*s(}@@*RL)M|XJ37LG)$^*l>+5 zFVjv-&u?|w13vW==Dsv#9nh>|nsSXY9bW(S*C^wB{k0HqH0h>?GM~1nUzMz*KJ5S> zsD;N-$RaFS=FtQ+yw^gmMk1L+DWlk3s1u7Z5DChR#k@!^z%U05BYq9Cclf0UiCt#P zd5jLs)r>D#xx~=yYQ~cSL^!BaiI24~1|lJ<@qj)f><{j(Y8F*2DM1K}Qn;}uX;fU- z2O@^c`Z+qz;~^yGPO57}kt@Z*HEvJ5IhPc|PutNoZ=@hV?K4?dod)se&CRorb$}b9IU3#oD zxSG!FzTVaXq*{m|2U*mVBbSJMB_+u-UM-XOX-_KR$n4ci%A@PWIGZPvS4n!V8y+P0 zzJHUW-)F?i>%}}t&qs9yOW|c&Cn&@?^PYOHpE`Dha2Jc&_?ks&0BS&$zgEq7fviKT z9J;rFYxb9{pY^x4NUhj!uJE!*Dv)5HPQ<8z`x;hVj*JN=hZb!f3qDUH|l&nL?darv5Xv7FAC;UFtD4YV6@gKBC5b-}y5-2q#2c4KEW z3TppWrsf!W6Ts?+Vo{&!nwH+%)A!HczIpXG_M7!sb`8Kq3-jFYAsiuUVGlGFBo4fI z_4e(%j(0X8Jw`I;7N^ygLR()bXta$uUoy(e9JJ;NK|`Nn)(+QS4$lIl{qD*R0*i@Q z!OxcSsK8w`vZ`~oKr`vlGTbopBpNcZvZLWRgo9)#4wjI?p@6^ebh!0gd>xwg(yavy zgrRn7l7J?0Iy%d0`p8^sB)qvk&FZtsJOjKH%eAqr*e^}tbI{*k5KU_loyz2Q&Y|ip zF))23+~XoYE25aAM1r`mXdO$QW9Zu4U7b#xAb$UxuX>l7_P632s%&130Ci@nWqVC;t zJWnRy^XsA1H0pf;6O(oX(8Z0tSF7Np;-!u*h7>gLhUIozx^2G50_CgmsOEn%Osz|q=>JwH0VGtIw1?lU<>}Z@Z@PPu+Vwq-gr61 z^eQnzkiHcN-t_Ag>DQ$(PSuXxf$CN5_ymMVyC^DIp(uCCDt`vCT%|GXz>1G>>I1rO zl*w6I;{ajKT-oFtEx%H`jkUW*?DPQgXaEA1$En9$0b2_I zZ3kxD6oUQFDcnuz*r5TyWY)QkW{t+-J_QT#iwmAsuadIjDK7%b@X-lnAAv_^>BN_a zNUXwy2eMVQXx4y4V`T1aZ_gPoPI_QyyaRXB7{m1U(V~^l)r1iG}g$4I|y}+mvT>qjH z9N2u~JLP-7_2BXpyeh5_K(2mW4l%u~S?1QaOtHV3Pzz>cmBJVDm0Um3#pSrMhMPqQBV_0;+dpMtKfj#iMo!?4+CxJ#67 zKCjS0aFpbe;r*LT!MSzB6#_0aUudz5l{itdp88S0ucG1%Gs@y!#kU1b^f=X;rT_I( zwwQYOO#J!uDHF+LJ+~q1AYKH#)#cLowQpI&f_e!;n)lF{Y;mchqt@>%<)LlaoR3(+ zYwNVWlT&=eG_DHzoMhJ7s^=<#Jo!#BRpZ;Hep#Rqggi0*)ECp9ZGLBSR;Mb%EJ^THGzT-80EjBtC0MVNPecZ8MxZxQMrRC@ zhErrR;dx~_6U7_^UC7jg0n(G`_pG{pT9WLOM3lh4$t4KsnyD^<2^Hr z(v;6zzB0XKG-n~OOumC3-f!W5UN4`Q@S*frd#djk2c*Nn18YY^8cVGdi^_49qTs7vTC%5dBAYY zUcnsgc2mSTpew4hC}Oh9BxXZBmy+(?KV6oX=1FhMpCMN`v=e^>C660CIteNm7DwzvIYrPBK(lP!v38CF#@z zp=|{cUy&vTLS^YqLnU{l3W;)#mJKN}i#?8+ik&riIE7WtuA5@d>iYG@KdSeiq1$4X z@6c<}T_k9w+|-BSkh(ce^F#NG+m<_&A!q+*+@(zUzfrdMJ>x$f4hFXT|6phE;7|Gg z50w88EG?{P=#$sA!#5OE~#Kf$d*A1goiXSSarU!Es-*W zxb_~^gdPu7)R;7~m}_beL=Bf{KbrNDBqJ6ybKxvMISvkCxk8R+t3#LaL@9 zjoY#Utx{Ik2X~>0EwZv=yAQ$#+YgvbiVZy!b5`Qzgd8-scGwK=A6x&n-B;CwC>W!! zD@``Eh(5+?> zk3Qv|m6Tc@fY2Yc(0J`SOp_{!F! zkSck%FaHO2i9J170ppzI>f`lm)MKwzToB%bivmPNBFUtZm{QiRNlNfnhiaEOz_Wv= zXbwAoZf*1IY{Y^{-JuuJHKJ3nA~~B?qH;sO+NA}r9k%#YB{~M7j-t}fWxce`lAmO3 z{2Ug>iE&2^7N6O0XVyN4w7s>hT+A(nmC`%EX>#GWuugP&hJS9WC@u`b^|u97i1h)= z!LGzCUKY}EP!XBWMywm z43O+XnI3vMjwp}CbQ;oJSyR<*MlZ9xZ-Tq5zb^~DqIMS^7438xK`=LHUfYYuzzOp@Z~jjkzq&LI@15Sn|GR}{Z5c?Evykg*Ac z=4d{L$l<7DAY;aCL|q=)iTrp}%z@3yh9O4*AlR=$DZ=l1)2w(AO=f`_sflc<&IU2g zkD#Uos&A!u54Gc()sABxXTQm;J`Wuu4T*@L$|&oLp1R6_X-A5DEk~p4v*P$P7@i|p zPy1PO6H>a@YCu~yG}o6LM{2u!j~@6PL4>YOm8P;i&Eg+;?euNQe>EF~x?(isD68!f zqTy!Oj&*!ynq5Ie_4>nbt@NQXRJImcn#uq5WtYA) zdwBcNDTLb;_$uv+e(eUY)D{Vc`>Qar6W2HECP`{@lO1R#Xk<=cu7yZM0E-~3h^rj) z4uiP^lp8E!6?*!JaES)@p7n#2YKRUxS|Kn=c_CtWk~mq{k;YaBCHb0D`Iy^qyTe+u zEd1TbC+;WUOu@3y{g$WzzB!v`<7oZ?fQOU@sXnOh{ZzIF?OEbTnrUh%g*#3-(hLKk zFXd}1Xb)KDPbGG9?~7Z~mtZ_hq}2#?dJSSgZETieNX`p3rodG3$a_So<|U(F7IOdq zq`<=cdd@yw6#)X`W!WYnBC4HeTa3I(EOhs+xH{Xr5)ys0VJn4Aq7_9HWvl?fYiSRg@5P!I0-;E0?9P z^4a?+bT1)+7nQy-+0$hy_$R34*1MXutZN|~t^$4xy}Fwe$j*Li5qbh>n9_sfWE~|)NMSg zRkNOG6?+4#bbwmTy`=mRurqy#wd=I8Rz!^1jm=HW-&osFK(P$0D31zdy2*>I$|l)- z$da=ZvK=%|J8JlAf^go~m(b=#;h3BiO9~Ep(G#W!fC7cs0Ut07(3_7td;+=Y>d>NC zp%9fkTyDWC6P$e1U75+EWwq7Ku_#(Jsc4kKxrLc3g@F$I@-WV{%2*j)W1J#j^j1YS zR}e@5N3q9BPPcc{O&d)FY-gvxGh}HdyqB3s-awEPd68T|@N@0*+=CAWLkg*s{zNf6 z-TLd00Voq`(0FVaAhac?^%je5fyBT&2POvl==0idSuv^}f@eC~;0Wkgci~1eB@CIw zsZ%O>DxP)soSqEU>WSlIq zasPzvv%0@e*dw>hz$o)2j>##785I^G+p-!Fx`-fKR<2IDSx_uj>7iw$DV$D|*)l!% znlZ=jgp8L{Ih1c0e(BE67)LW+PMv4?LCES#x|WPCY$)|PIs=G@U@Y6*z#>of*_K}o zHzx^ge4I_EC+zWK_NdE#WB3yiub#L^sa5p`+hUKQ8kguid9!?S%2*^Pr6#weJp(8S zs*3(>R6S_JOp?N1`hWeA_`k@-PY=I8-Fom~@YmC4v*_6@dY8?wPmjJk*m|%# zf+SW_5A#ll?F_x9FSFu2R2fj&_723@e$5i885gk}LbDg5cxQT>l`9;~wiO=m;rBx! zP0GrH+Mi1L`y8}8@6HA=t=cBH=YoDjtWhwDxRR;wUoTw(Cci6YDz{9Hm z@hxT}35_5Se8@sY5-?Pm&!ZF}T2jsm$acu8BFWiVf}xkoY8lNfvz;8`Xf&ea&0n*} z8w~r`>!G;3@r1!$1=Lj`D@#!V9zm!B7avPcGJ)G0oka;6P0)fi$?{p2UL(pfyP%+D zaP)!So73c~S{A(Av^};avuHAlw)%NCzaDJ&A1J4%e@el}eRGoJ035YR7L_wzZo(_8bUBN*9_%)hhnG~|aJhQ-{_Tgi&)&W|eRJ^o z1?aL?(SK)8C|aL?_v*zPw$HY=&Z$f-$Y;yShbTmHE}5Usd+`h! zTnh-P!FKXRtM!E*fDl=KqM-J~L|~wZ-&V{g=g((C`n`3Q;tcc}QN_RK*Y6^L2Qv|0 z{U2A#tS+7@0RB^^D71bW7l45P_4xUrTUKX-B{fnE= z)oQI%$kItv1-%}C6jjEpF0JKiEqPH68UQL=166@MnoG~6FIHTTQq4~+Th)e1g?J;# zA(&2!VsINbC<1BvRxpBJQ29HbZ~V%O3|{5SsvIKx<>dw?xPX&h7O06$K#mS}LPmc) z@`L*7^ry{bG?{ouiix10Z0$oZnXupX*|@f6;kPCX;<{Ue1!^eeECTZ)5kl9pWQjSd zK~QHwc{k=z6WtLuBx#pwahb)<@3Y<22xjN;bSs#ghb%c?n47%f3-EJ-BRHT#H)OJ) zLA(RI6nq(Kq}CZ*K)VAxqhlCr?;EMB|31$fJ^6(6W zMTD6&1O)7$_EIz~&U1~Y>b*Q?Zcb_J4!k(m>3$ZKc>ZVwWsYMie{Ww8=%tUq%?Q`M z@w-$nE*&nxFGUGxPS!PQ&*vgme`F#AQcF$#sXnQ|<_fKzrX4rS7Uf7XgnsJ9d8oD& z^{NXA#JbAYv>vuiA9@}_6Ox1`Px?*5 zqC)AzXb#|Z^{VCdzNSrkbiG*csz@gKzMY8OC&e1+GN-ti9&s?@Hi+eKh%}};V%@*rv^yHIQ zjnGNg{4FKMJeP?U4IyojTme7R4(WQDj^R{32VFRw=As_`q&BOcoZC-*%Vmlqi{$FN zsGJ3$+IHBlHac;D)8mj~@vet!9;7kW=VhEVTN2$<3`f@6$arKwC*8I9kL-q@1xYcx z?uXo-vLD`UYIej|!rohW-sbbfm*M^Rd=RqpEzkNomL#`@V`o(d-Oyd?t?r4N#6#1% zkv^@fpkfZccMo&TJ64P#%PZj|RZ0RgnMYtKW<>AB6_>EE_c7f_QFUD20ku1n2oJ$; zUA3$*Dj>PG?k+320|)}B8mSUW33ZfCC}!B_)V*|&Ro{s zkZ?7E`yJdXo%yTj0ASJfeDKY>-bhhF?N9BGP zLfyQv5^fiu+lsA8E>u=4+9chQQcMw=_ByOXAWiSn(@(|C|NqHewj>MMrPNVRH4+iwt(SRz9`RD zQa{k>=OKP;P9Fvt_z0!65@Q*mb>tgpj0XbG`wo(R=Ih!$!7jvghA}Pk$0C z7EUf7#gZ-JYUIzN6Xlv5HEfu>?#Fhf{8S#_+7)aaXSm?@Qy6klHXW(3NjmPqU~zCr zhb+5$+b-ZV$?YaY+L{tO_6~DY$Pw0mm%oP~e+LQU^U35*l0?fF<);eKA0r?rSIiD;*r(9KJgJ;fKTL9z)7* z*MPgR@Tuy!+RGByr2`!D0&RoyeF{Xj@7Ci zcj|gNp*AZgp;ktORExxAW<#RjiOn{~y250zy9Q$z-M>k1VTPrSWUxz9?5574nfmfe z8hKMoe)gl5+HS@&-ii&Sld(QQV%9-ElVobGK)ZwHKLk2@ga4<%vii&K@Bca2dbsnz z@&DW%Z2jr~`3K{F*Q$?_M~fbN0m!u4Q#2PRX$|O&-k%WjehXrByv*nPs)k%bygk9S zF+9Em#!6xJF&i!7@dn_%Z4O54BAPFGyAvou3@Sg+4R)Arh|42+gbFu}Vwob^8Xj?s zST**ZawyKPg?HM3B9J!ND~WbjLyixLTL?V3a2?1%;3qsL)WC><2C>z#LPxI=0&e9X ze4reJ$07QR_bXmN{zMZ$kzc_y11Rp8Pa;52hq#M6)i0h(h!b5E(FLEwx~UAtwR9F} z-nAbK(6`Xx`ChMv=L6aGweX81gjn#3gg_e%v#HVEy*a>ZH^P_i$#ZkCi{N;OmIwbR zq2UOmtI`VJ!3A2mo;~)G!)jI(bx{J;JX9&K#4^L0plgqg;yqRbS8JuD*xY2#u(ldv zFM{sPlaf|?lI01H3GW6X+|5=nAv8$l8D8i?OdRHTW%sAeY`k0y|!Ebtl&bD&HF!*}s>N^ijkuT=;A!-=(xFXIY}(uN1x1-{JZ3xV#iwXNF^0r z9_)0b_Ye4c37?+6C*~Atc~X=4orAJQw6n4)yMHt1>8&6Pel_7dF4_GXRkZ^$;hg{x zPqWT6(2eap$%7p?h>O-0i&W7$Zo#fU!KCJ1SHHnYOkIO$tGA@bW6}(PBFin0Vv9JW z%~=F)3exn)LV*z-J+l^&HXGubLN+m?ox1)ww22px?YwKcYASclb2p-C-v+ox<0d*j zu3oWKGmfj&IO8>Cr_S9TC?Iw3wpRV3n5{omw70R(QYS(U_D0{o4z-7XWKq6Nit$)r z&8k<(Lj=lmggvG&ybuxcJ6N4`Z!Oe2GfX>$L1O)TjqI?TB~u6As&3jKfm?M;x9^v9 zx&_D^bZ2^eV4CVIwsQw|qgS>L-)Y&wz;^WN#1{s0g?%J>YV9Z44}BlXJtxl79j_}d z`Z~bx$bt}^u9h2w{3qm^MpRJ^ENU_{U2F@I7-%YVAtG3T5xTia;<$ zy#<;Gkv<@Bk#KOaL=)aH-BSD95i;t&&D@=nUKAQ~ zs)A#&kVcE*b7GBpc9pfG>W&zJBT!16lb4x9r9tJ##sX8kF>!xKuK-GV0QP>VuR(oz z>5l?}U!L%?#kqq133M0?T!A$~FlZ)*bZD;2ZAl3G9a%1&fE-UGDNYw=8823?AI|z7 zeBIbzafK>mZl0$-G4Q>{v{7qu`qoMEv3u;h_>v9Puf4r!oO>~c zc7FF}m_|d?g@b5uZo;vTjb~9$3{J$djpgsijCYE_+S6!*5Gwo?CJ00SpBKF3>EyEk zKdhDi^Wn}zNB_UI)BjWd|A+ji^th0;8WfW7@3SoC6D_j|h%=LBy{}f%n~0^#egp-- zR*V$>RxwF^?;yEIUDixM`A!rPj#(6l1DfJNgP!=^`xi$q-aLD8di3Go11OAl`hOL{ z^amoP&!Eqic^tt_6?CE{!0w~sUEJ&TAXu)5RG~>0&3QSY@HNrW;hR$ksDJWUvWjK5SnWH>e5v5tJ_c-+K;YlIS1HG2niWWj30q&Ti0a z626a?TeyyN43SZ^}SIGM!VlM_Q$Gz2d(zdolOB!S*6Duc4yE(dDG zqc}E}3yxaYbM?G+LKfs}C+({*KKc&Eyq&q{+M(aBQg@aC$Q{raVdHhd_I`Zgmt>}C zaDfM7`LU@233IaP>^9aw5D#^WaAvqCBvdW3QK`G)Vt6N}-_G$s)T})}F+;#1YCCXR z$E(t0#-p5!X=4DK*<%`X4ddxFD<;A(g4;I{~a0|8n&9O|QgjOftQu=0X;n zo`;OUHG}Fphda{+rCc2+XGw|~S1)>1Chk7~Z*^f@u%etjWyif<@8SgBb-Rw$hP16u zJbb(BjcjbbqUPo?fv9vl?EB=~PKXB1DoW<%P}`IpoQ+QCY5U})Ys$phf)H;6Hq%TS z_5KhCp;$mWQLTGvr9+=gqb7g@?)l@Qeh?{ z5kh?mAX@&l<8|yX_B%0J+1rb*<8u519;7siJ#1a%mt{B^UJY)Cd&VuyG^CIW=gS zxBxnut3H1;1^)8Xx4K4h7`gs_RuOaX8gMIjx4kttIDmFhMIKxyjdN7*@}KbJMVMze zr3p)wD(JEI!j&QdJl0w`l!_p=z11tVNK?aH2=<1OD`TV~MmRGdqz>4TY@gjLxdjve zPr4ofS5~Ss;ecAjSQ8Mu4QFCrp_DaEt+mCb?X%1#S%=Ogdv;#{eq7*{%-f}NO=S55 zxm6&jw^1R3{qIsLM z=A+(r7q0y^wB{s3Us8et`|j{vZxIi{c?Equ(701U?pg2#$w7CH!3x=8!PA(>%IWii zR@VlG+Vg6b#pF&~+$3E)V{z?lhzW}Y`EO|Bh7sXKiroKjz@fzXB+CY$EQ_SN zW&ndqUWO&hE>RpzK$*0N0OGT>d@RRVHU|&O<>KuWq*hSYgsdv7g1D?-`-Xu(v=STV zNg9t>kZ1E`a@||R9{tzz7e5|8dvW^x;hX26k~;3hygaY697NVdHcrS$5#{-*aGOP& z#XNz3D1|iq1qs`-OZZDpe&0jNDDYa{fCPt_ouTV@FPk&H#n0go&_qYwndIsn=U zI^y|;is2w71RUEP^$bW$Xcs`+(1|7pZ)LsN3Ry=iVLhAjq+GIOIWS>cWIx zn|;V^yqHsa(gso2rM5vsZbDGym^u(i#09_)7fK0#Q+ z5oq$F(!#6U{I{Ruo88;{n?1PyQh}#zbpNJ|N)g4`57~D2mg$eSySF1YWMxz~n~2nT z|KCKK`SZWe6#s*95Pt~%dw+Z2iT`=Hz4NF1_Xo;t##d0|lLejR!`TvpE_oW~S&~*L z29SE2V)ROpN2r$Oh9cXJ7WXOIFInKG8!Ed@gl$^m0LPmp#%lnO2u6)UTquIxS7jYi zit-^%VU5PxW8Y<$bhN5R{(n(=Ew3&)5e72#TC%gIcNSaSJ66tx6d zI0-*w-=d$d9x1f0$+*F0Y{tTB(y$Dz)C-%rh(jmwqy4#-IztH62hxuzE0An1`Iul# zU_COFJ1txi-yx2R=E;dHeADZISKos1@mmX{QZ^0yJpMyat<dL`4#%Rmm0{3|;wI5yk#o)g?~JT(T0}-}N8Z_6_4niot zqreQ0AD*zEH5aJUk7~c;m-Fb%jN!uPGKFlZ8y7t|*_cBa2jjT*iGZql%v!V{U;^yx zlc0J2Q#`{y3#KS62rq2hp++!^9w<;}!V|?YS!^6|#~dsYykqAGtxAd9fW0FS4J5=t zDEtNm(!RQa)P`Y#p(Du?1fF10UjTB!NzAnFMjYBadntyiF(Op|-k>IT2;E}{b z$jU(Wh9T?V_Z`{JP-kCQ1wA+2hGbrJ9=GAcQwb$1t1zMYRW<`r5? z?v`1K5ftBWJqzyN)Wgv3AGcjQT*fyR3@9|o)txmNtYt1HsN!ipd-6!hv$W)%3&*qv zYcAo>s2D<^O?wGF?ynX9p|P@#9DkISmuTps-G{UvrF5ypE!XjONF01k%_5J{nyx=E zD>Jq{9jZ7nQ@DgJ=Lm0v88q%fNMcrz5qP8XsE^K}CN@r+@t=4ec^+%cNM7e;=OA{| zl%AtzwKh8fof0J#HxwKc4gzZw!a79hwcn#SmP(3h2lRA>Bzh zAbB9fd@^E}GoE6m#8g8SB_9>yz%_#xK&v;mcbr-=w0A|e0Ckec#coaPioEO{lW01A zBnt&KFVn?iW_W+tp4}g#@d-#mpd=*TA|{q9lOdQTNiZE3r!a}7ixf#cp1dBg^F#-N zKxk+}k*2;;vm^4`tMELpqcs`ssmBu4rj%solIBLKMsjrtBmsJ_PZ@jMcqKDvfm@f0cGlu490IMaUecagWDXV|r z8FYb8;tkc`dY?9(NM_SGv(D+G%_#97XF8kPzV&J5pqZhRe?Mg^MUT1XwWncG*|2fT z)i(j+2a-`IaYOqCg944?1%GkD(@L`Xpo5I26UzO*x}&$AkWSeh@S_C#Jxz{{b7L^e zIBxF<+&uv?Yls1y&VKXL=sNpeZgjc5ovM9dCVsj!H@q!?`=ZUwgGPzSEs>_q2~0DF z2QDIbV7j{z1a6Gh_r4W@LGaZI$6Ni1jLx_4O?NH#TI17FeEJa%wuZRk^B2{{lj1@h zNu_aHA)Em;v4QD;qT~YJJL|F06y2nwJfB~Wx@wXLvO$R^Qy0$2(h%Svwc?ZC<&ZQQ~uP-w+BKJD>H4WS0@rNIfKc`cqQPOoGPt2#vsKtlmmff6p<<-o9r~ z&AKj#Oi)appVtz+p3SW!$jFp!OP8nU09nh_)K$r6+t3@RhXT8fg4G01wmyE>c`BK%5Tois^k?PB9Jh4paY(?5@3NRToxJP89e zUY-$3G^nl+8_!TSHrBKzCdW#(S`+TZ3Sso42~0{jL#Kv13Z;a@5jSVZrag5|iD0QU zsM`&Yq$*vZ%A=W;1a6<1XX?zfbgH!ZtQ4F@HnwCB2MG+y0PIG4Xx~Uk0CC1kBoZ77 zQ#T2}@q=E1?W0#^?})nuYo~|wZV{Rz~ z))-<10gD7(3o}5S{WYkSb1B6L%fR1QeX@DfpjKPcG;o7?O}(knl!nxKqpaOgDgP8$ zEPH-0o7!XJBmHa){|^Pr@|Qo*|D(UN{m}9M=i&vURjk9l>;!A_g! z8`C1;X*|D%JZ%D@UwWq!fCN4SZ&NSyb)r zgsjTG<5#->i3>OnE_5%8Y+<>aT47Q!sApMv!HbGg17qT03D7#iu8)Q+8v|Q_aZj=Z zr!tEu$2Q0 zpvT^UQ$oZ>ucKU8ILhS&eTMN%6hhBA&qdsrYSVQUk+1<5x_bygaV*oAPZ3Axx8DRh z2i`~=Rlo8V#e+ejd7$}|dfaiox!BVh)9$rm?b^yCoNq5F3D)<*^?8G!imjKMbGngtnJbEi}P;`f0 zPj9_FeT!?*<7rhSyfkJE`f!CSr51bLVbw#r^$^v|1@!8|ian}{ZLdxfc|coggGhy4 zB>WP39T7(e^|&XM6cSTO0K6<{C8M(lW|5F)pvFVkN{|e33e=?bW4c_7c~S1MadIZN zn|etIHD!-|&&3`UNk`m$InCzt?6QQNEBah4=e*p5R85pv1GgpglUQ5p_In3|_iaBE z6My@SS@1Yn1`7L*`-X<+c|k*K-Gg2T9xpALgt-3xC1I-Js~V=;`4D-y0Pa$ zfx*$b+p<-(O`RQ!p`Le75MG5A>dM0TmguENIiIk>*1<+uUC%k=e_uv(2;>E~@ykpl zr9pxi0#S(!r$nJ~LZIV562VVjKz(HZAdNbuM7UsMch(Aht|QM1f!1)m&dLeyk(5Ey z6FYKDe{_M=#6#+by~u*BA`{uECeU#^y?vXU9i~+fb+MTEHEtw@s0)KKpN+d7_liiu z9aE>pk1xMuQIA6`XAoS4EI}=dKKWN68y_bpT_?R*WFu4Ab88(o_zeL}+`}HgpI#Hb z=Fg{Hg#h#ut`E_P^J*+!jZd7zhpW*My~`nT7}Rn91ZU$M`fd%H3Jk1)n?0mWFd|WH z_;|*zo{Wf#6ZiS5WIud(x$%fF{6P~w&SGG+EY2p&1t>)devv4rJU1AIUP1dhBU)DnY7gh#widT8`uX4Y42k3B+_eg0a@!iqcpJk9FdSx>F5FM9${f zIGTUJyBs8X%s?OY9;^D1uAF0&%Z#B>P(=ak(d&{@G|1ma{O|NYou}CB4~Ne$9p1W1Lj%hcvlji)21e2%8cTPJskTV`1Y21kA(dCHO2?3IajpS=PRU zgj8tQl32`T6^X->$NuvUHzf>?BrX;K2f**vqtH%N&hwm@-XU1Fa*9IMVFWkQ3pV;GSuA zsf&t0LkVSNO4wtzyT_7mzNrTe8=vj7Hz-*I!J=eOp2(UWv9DS1fNij(+m+KP2^`0b z1M0KiSfJ+h$rG3YF(I<(!xOP=I1N~jeD#QGd4#p#Pk6sQHFt+`hr_L7q#!RB;L-yI z56s5QcnrW*%OVBC+Xz%iBaz7q+;T=CT(o{j6BIGaNi>fNk*=x~5~278jh`c6W2aDM zIbzE+sk*g2Jk7AJk}hZ%8VM#46b<~l1H^zA@JRqogy>VQANTN-WJ7piGk7x*1{@M} z7Q&qtKXp)|wf9(KofRai6QbuaUROFuC* z_b;bT3-C;9VFMfZ@=Hd~$IkQUSSveV8&FU-K6c;KeJPhy*%vu^(A!XNyIf8|)FJ!> zd^t8=b`c^i=ybX#&A8c03HE)XvS1F2$3wJ6$)UWpa9+>y}~&V@Wq(N7_bwS445S<5HbFLJm(uR zWI#%VhjBq~M4KCzFwTOXC1q7yL$y+|DI&=>%5#2+I)W~nCl?&`fg`b3%FS$CCUFv_ z8)BM9J@&y4yq6M~^l%m_Ma!&?Ek_Ulz!s>d2|MyuR8Kig$+P5h5pJL@r z@=(_%l?XdabsUABlO6M?s9d}kiCXEXyxLP?TAlzZ7gkgcg}QRAW>;)q)>wN(9cm=1 zJdJ6^zA8ncvY=iq(sL^k1Ahd0-FL!|H0qZrhD--0kr%}VMx|qYSxp~l>C>Ncap!Sv zis^;p(+e|xnr4@$;D1bB2pSf!TXYWDZck%G4-UYAzWVhYK#oM)pa#|SQBPz)vMy3I10q$M*=&;&?&c=r>Bh4I0e^w%ga7uozx{3F_3Mr2&wDMM zk*N5GX*KV`d;oANVKfCDtP^3Nfvoe%PM4pKIX&= z8gJ8ax6vONn;v-Dc70Ep5Y~+`=nh7|W+`7$@2!V+V?K?R^Xl|hT(SU%IEnHGf;F%7 z-r@VmqbuFI;C# zU~uPEQR_yi5GXjdw^C>FDUY*RR4AbEKO5N@%QsG%HT1sIoHzez!wkvoA?~@hugO&} z=ClT$jpWb5o21h$@Y`0!X|7T>!}N+~it3a;bfeAkD1n*w1{nzh4gxI*lm*ZkU3h~S z6(ECNd`SNok&Vs}%|@ef@>*1co`ii%I1&Qwk0k!fQUj}0iSA>No{rvcb)5pm1?bA@ z^>!D{TJS+PbTOulz57 z7Ygt#^f^N_hmlF8>-SJ=plzIeJ`u)E5o(H!lW9VTNlh4zVfD#pa_OJ#1lE~9Hh2Jpoc06a-*Seh7v4J=q58(C=qqQ z3ONx5nG+05aY_`ZYu~?`k}1~|f_~mw#^RQ&#Vr{rTe4Eh@R=c#NhlkWJGP~DTn&q+ z`Jy(qp z-!-4Eo`7XMY1@xABrap`YpttiA{B7AwK>g@lP#;`{z+?Ta73MPi~vA$dk7OH7<5`gvRp0;jR_uRmZA3h# z__8UgTumIgy*HP3C%ri*xM%8O4w_v6NF=zUz(7HIYU~&&GNdg>`Lw6Dzt433yn>cJb$SlRiZ8&JQFBu#Z@ajQ zlpzBnB!1z|HB4u{Hj5zM3%2fTV2WP^2IMyV8HyCEBIZ-pX1jnGg+Sj6in4;fDY zwXDut0}&nIeN%-9zaB#DL=G_KTClsF-KJXRkl<&DUoBGegyiX(^`E~zo?39 z`D0peep=?yM13dVJM9*;Wd+Xj@^PA_>TfulqDggHF7q5QR=zlCNUNeH{81@^LNyWg z1eM^!3XawFSMm`oPvqo`wQ-JEE% zlZwd1UvAl^4sU54M#PEM&VrkE!(?sYWFb~V%WOF$prNH|y9S?bsJYj+j)ZrXL@0iT znE4@nvB;}y1^-0Yc!Q3<$B4S#5N+xEXiL5Pu|7YneKzG$wJho#0Pnk={RYD8ugFbT z?SS`hU%jAoY+!Tb1v0)ksWSX4yG(h}aX4>=fNqNGW~kJ|n(XRPRdtw7Gi`41*#=l^ z%$ddKPCBi6;-WknC-bCA%<-=^^GM6oPx!|-6Nhh}9X@~Y=0lyK&N#a?=xKT`aQ#lF zS#f&K)!~QnUc}|;K{Nq9@@c^@vh(`uzb~^YI^|ar&g1$iiaMqqK zl5zp=4eDi<#5_KQi()CCo%8F{B!v-SfVVo#0g*8|OHaW?=(NhRxqNX(hpxTGNjgbl zj%reM(1}Ops)^KWk{DzmnRU;c)vlSp><*{eSpmv;<5uWe$!R|uNCf3V`1}=g&a^vA~r_N;n zRY0o0B43akl})GkqMn6EaO{9H!F*0-o{VSFuf~rq_@u1tt&EHFO5|f*&v&`k@ zK!fy`Ar2XKSP#1{@yN5j+{Ov01!NzvokJs^G~+!KYRZLWo`bE`rk;uUUpvi}wSEgrOo7!N%KL`qU)xi#(gm zJmi~4MRjCU=JXdLt=<;j&Jit!?teiC9&TjC2DGRSJaJ?4MzV%7kEVD#vEGVVu^a#H z?W^af?_V4oiTn)eqGH^dBi2}ISD(!Aa6`%FdjGrL&vil&-&%w~!lIS8Q?*cc!n-%Kd2BVXM&)@_ z%{*Jr-)5Z?G@*py7Kx2TCQU^~9{#u>CRUfx_aEXRlx-omM9yaSr(Q`J~!iQN_y1b<=SaaPSbenKd-`BEhJvWP*xsR=@L!p`CtvLXZ`BO-kVp1)higJFDrPU&;Dip}EpE?=y=@d?v3oc)J$QT04 z{1@_F6qG1x)?ebGxM}&1nMc}EYb2r{+Hn${rQm-*X}qkvdX;oCU&h=NIN^yo!}{1s zFX-G9#Lzu&x6Gk?N^Cp5Hw!u4{`-;_*N()2PrVWc&6t{&n!LtAnE>p#e!2q}}X9%Qyk$MCT%jIsTEy zNW+*X#W`{pJWir#7)(?jxA<@aLyS71*_bo)LWF#c4vNO{36JZ`B@sQXG2$Y*0)15S zmkFOc>E_|Z#uVOovdsqiL)eez}y{yqS7h%LtFe1X@;wmdHr-PL?R zxQ&o!6o?ivoG0KcC2<*G;8#W|pC%La7>=Xw`kBc#dbrf9lu=IP`XtF)LoR@MZtA7_fm5BlvzThK--u|zNcfu zCYphFzM@#l0Fpvlrc0cgYn9(!YkD7wQi#bQJ1rkHcKng}>RY<3l3CkZ|6jF|;H2H& zn?b+60aH)pVbR!?0j}Hhy?IkvpG3Ab5~=CzqlrXxzUF%^gp%UbY-Q1%PaEKlK0t9h z|D@rq>`ARi`}kfzFpPw0wD^=>gMUPe{j$m$Uc$^1+j!5Xr72w-`|=Y8x@%fP(d+6I z#pJf(x+kSEVB^Ge#=F)cHud+RufOZi)ru~EQl}nioq}_J^#rba9(w1Gw75bC`;ptV zJ_6f#Xw!iIY7h96clNqn-u~Dw5BheW=`8zixvK|;d?s#5r*Tp)lCo?c^I};=u35%% zc@UQ@tAN#;!IrS^okB(>!2Se5k|tJ@iwyz#<+vcwW3LZFmMdu@%h&ci zkYhiL{YLh!u%6A=bmi24dUP_4ZU(gg22iK(qM}OB0P9h>JurG!dbBYEQ*&8dzh9>Q zx*i(05Al~(2Ba%6-D`Rj__;AfEyb~BUe(mqCtcso)hdQoGdvP2ngZNk|X;$Y>M4K*2Ajn z@2K_WuLh=ifbL)3ns8Q{E7}4dM=0a{7^kxN<85_*hdb}G*`BB;v^IKG*yQu zGP^;wVJ|vdhhys%Qu%+O zP0&wC!6iyG1QAq#gfgf{>L`_!)j+9EP34kQZ%BDvGd_b%(>V#5L?qCK49A>-!lY=z zBP3`NuW|l)WC_ToYZud{hp&*Rglb;!f=p#mbU;Z%RkLmz~f{^ zmg+Og45m8}>>vfiNd&NAO8IU(nd$V49ju9W5mmfMqIvSGB-%wLc0R8rGeOsa#7~zw zx?I;(yxZ=^@vy~>!gqO+OW0r(B8_;pTWfI>=LX~Usi6-_F*O0)|A8`22?G0XS z(A0RZa8(hdKU}+ET!l%cHypTiIRxVI}#i-5%*8pXUnR<9a-P# zE<)pq5ls9kFW6HyVM8{K;(%)DcG;~Mqe6y;x7D>2to$-z1rZ*XMF#m~!5Y=oQnS>& z4d=*aZa6o#Gg!f+g~xS-z_9;w=0uSM;Dxtr><{k`-WhOe(ip zSxHjw`n9}j8`gvt+#@pPGm`)0X(WGJi_kTlxKbZ8T;=FrpKMWO8nmr9tsei{`B?jA zMDsa%h@_`6Uqq?x^q(hbTC=D|YqvPIZNs@o6l^n$o52$t;c8Xj1V=`t?^Hrx=wQUH z-sfn7sRNp|g;Ab2OZ8HoxX|VF9kKV0I8U3oaSqNw>4ZbRcO&M59{U{XeCH_Vg%Sba zhc{vX)2JUn7ImGe!NU#At-h8Mg{HID=ZE|3zH8}g#jz#t3Zi0v=ihSy31RlW;1oF9 zy7S>k*7iPj)U1X|Fc;CS&5(nRB}q|THy8kr-6MpcIcowMtvOmx4A>GZ33*uXVrN(ybhDyL#%j-m=TEdT_>>y<=c$2gLj7^I1&-M zvwCl!Q{#Z&{c!Z%>9e=bUpzZfDq(ciJS8j3POMF$uY-8A5@qBz92)zS-l6FUrGa5BEzm1@o}9`KB+4N;CT3Dz?XhP<1(ZU(H5)IJIYO(KoYHToy0s)XOnYn=hrg&hIr-M@&Yi{2&^#UbAN5ILSt;)b2=5QGAEW? zu!H9kx8jHJ?utfnT?HZ^z)nsue)URZh&Jz}cek27!~^|XK8xm4|0($BPByc@l36H$ zy&50rCa5{T;B$(iBX4QIA<#hxteX6WU#SyOntF`4nnd7dR&{HC$AN?xyp0Apq4 zaqe(y<1~?u0m`G-hjl~1E*`8K3;zY9*giJ|Oe_9Ne|vXl+sXgCwY&W%{>vYl|2K-6 zSboTZFwf3NgGY|sgF$zQkyyx!^9T!NsrH3T94y$l;PNGd#7)usyku3@>9H5lWX4Fh zB_KS72r)vV!Xzq+YaI&rtCGzj?j!% z3U^ols_iNPVV+E}r$Rb_oW25nR3<8+I??|ey3a5bpE|$9cbH(UZigimonjb`3!h=i z4>F>FyK*w&l=owr73U@ROGAn<6b-T^MKF~1h6PjI+UtmJ=(DU=D0#Y#2gHEB))4T7FF+5h|h`F~|2 zGk%3Xk4ygdP$Y3h?Zyc-AynQ~hB>md>>^5AlXHGvF~c*k&#Ow)GaF7&td9 z3rGR_nB~2a|6R)fD8DbY??ZNs#nd#N$eeo`v#2s;?F>_-tGrDVH2^tqATb_z1_Yz* zG94jaVkzqjp>I)o?N5lS&WV>ks4(AHC0J}CE8jD-#BXc;phT(9^4 zv-hS=aU{#O=>B|vMH`>av1SX3v61Y(cOx>hifT!%u10F9v~Alngb;(61PBn<&VRp_ zk&)@)9?K)607-rNs6Wu9svKhU@L~D#NB;rm>;LOYY5!+U4gRlx|L4CSeKwQB4_TKG}?)?pS`+xjv{-=M<|Mc$P%{5)6!k=F5UidIK?C}5Z=ncD}?N||;*0~@5 z<3H6BbfkxUb#}s5|MUNUba-)k@=f{w7MB*+S7QFZ)xi(_?_2y=FUvUxM}Pd&-2ZL} z#Q*tz_iEwdePgS3X^fA5np=Fd_-L{D_U!!NsSssW0}=s;gA~ z^m40KIv%@PYR-FdP;+FQo?RXsAJj>|J{o;7-+EH&N@^Xk8#b)of`$ zF`t6df@)9Cqo$R0fsC4s>+w%>&&MyD?_8kevGXv0K5Afnb2ZYXa#_w7bB`M)c=x@p zx-$3JZ{N8`-N&EWeC@}zisH;C7kPU0{?R2NV&@v=Cc5bGVlKqB%{^MEwOj`e)SvG! z$$JmKIDX$rZ8|4jR{!4j4tGuW=>O#(?*qY)+W<~|X?@ARn;|{fJ2=@5hSWD??*ILN zmKNu_XEaMGxXd|{>gz5qc7FQj*-w`jF08Zn$o=5tRvW#kyW+G}ba{Q@PyhVq$!T?b z&@qVAgH>OAbx?n*olRY6sCM{2Zd&=1Z`4Bl<)h=KvtrHsGX2 zf-LMDoYi)WYr~@zzl(#PKi7pzv|nonMEL9fYE-#@{P^$h#sBLKt%p`RlIQ>O+Ujal z|8;43@dy9!w>kgmd|#e#V1|$9T7!GudwTykJ-wWJJlFmEV(#(3>cXIP4On;W<;C2; z=bai+|Mc(qx#LEey_kDEXN)mV!v98Qd-TMZYIFXV%9vlOC&m;T)%Lqxw@(-*{Ox&zP9v zS@X!TF=O|0cZ<95%yu73*MRz#U$bs0Ofh_7%uYFfTpDv+*1b4s9@5<9K5ZGZRX26i z{Bd_0^Ut1@VH z*Sq=3m@0UJ`_6rGKllMC4hwj*`UHPpwQigHT*KVY?n3uZ_JYeozmSH>-QxbZ@uN4p zPj2$WcdRuF6)oYaUBYe|jZN`qFz@dESM|)&c+iuYvE{3z`^5x9;XY03wL$YF9!TrY zKcG4nUvAZF{L;!LB3_Sv?OyO?S`S6L#a6==L+wNj6WyDbV#5Y&0NqUc#=TTr(mv+Q zzd($YI^+|#1W`NDcQoW;fr3YO&%Z5TSGw=&YxeQBQ|CFF-QtEOy5!^H=J~C9GjHsN zoUxz{5HHhZ8?=LjQPBwP;?VXqMD#3JqT+?Buu!IX5Iu&k9(r@#jVlX>vp##8SB;E0 zwXS(#o85C=uj8e8?i)bM++FCVSL_=SM}EQA(<>GTH5>_y=6&wtcDEnWZ>F8j-h5Q% zES>2+mX0_TrcM~l{wxv@6H~0;rSq{2aZ;q$yB9mNkCKz6 zd~yB{8m0Ee--3h8{SiWdz2(NrL9F$UCwsO%K4D)-715sEy-Db-gzNSR8oiiejp8TDDL9Y=k`I}V4p zN?1SC3pVb3NAK7a=Y=#F+;G!Manm<;QkyMx_r!}rEUfy&rSm8|kQFO5vI9={Z>4!KZqi@7b9qSpDN>p1c&Bnd|aZ=cL$nf)%@7 z#15wutuW@8-hWaBBA6x}$Ok!2;H=?YyDYNTSZvA;ycbIhs(2}>t8A;RhgKF}#LXhh zSOL@;uObQwsvNbKR=<#ZOGx~Vo%mH31A8hhhAltRNcRdGrtB;dt$T$rd!jvOL%Fla zWtI|W`$hH$$1~41sL(P(hgRueu8tqE)RJ+~NVuk8jRU0>;j47h;P}W*;aqSzY1Z zg_Z(6P`KhI-Rtjg{5GSOJH|3*O1nsClSsIcbNbHhB+-xL3Ysc97*sWE;xuaY{GoBL zZw@9>T~_ouK5}J`sdHeBIS4I!x(G#s)FarKVtE)y#!sa&pIn5xwH(|?u@RouEj*0e z7swiK;v{LE7p@zzJj7!Stu5DTHMBZ<^<->@XMG{lqV}~mZJpLpoBGN+*lj-4qv>wk z?CzdMAwe}9Ct{kyze#&je8-QEO2(P|0n|#JbO;sv>Q9s0V4!%6KVWjt{956cJ*eL> z)O8%T-QMY)RNR}i^cHM(C(}(pD_srzqGqYVb1t>YenZpG?wc?$Zr5u@QH!{;& zLie;dV=dGdzU%2)I>yAGb<^j0uP=Hp-{5x8jDvIgKV@*7+@Eo9mj6P7qsGQWlkYS* zE~Gf);OvL0=+}09x+p`5M&7mM8EiIpX}Oqu)AVF7L#KtbB~c_1iPDT?L8x1eP@Ub` zAk>11TeM#aW4Fx;<4Zb z-rSh9P)dH*y$>9M^|DHYJ!AjYrPVE@`eDD{VG-5$C00?;`cvzC=nA!_ZE+KN2={2?1O$eU=mA9yvhFzTL_?gH?#%NfJ+-0bw{AaXgYQ>eNH z>gEhe5oD{!>)p2+xz+?dD1>soA)*hhW4ODZ^^)Ih7=^G|wmDlt9mmsV@uq#~GyOo# zySAoS%{&B8Ex~0^EqCwC^!omt@7lFd-oIU{?s;lC>M8w6n;Rya0%465*6!yMyd2td z_!k@KndLWHhV7r=@{@#0c5Rb7jC*f=&9$5-A7#mbBJ(EEn4DX5EKWn^LO%yllZB(P zh6P%$DkDR`?nYqgyQTU}Vr%M)KH!V^8TNk0i{#q7&Na5KbB!$(DaW66+h=TD3u7yd zD_pTjU?G2O>(xMx-gGbrIoMUZs~-BJG88OA3p#a!FDEdKj)dFdw#<5pFzV_%&zcdz zL&pc2$tjoGB}_WP8J-S}Id!>JEuKL*yr0&Nc8{Y_J>E(@W;LOEr{ITY5z@}}Q~(ol z{jckhJ!gWzXjRVtTp07S`*xuS#(;^#W(=+hZZ&P{-6vlq-7)Us_+ze_PHGKE2$pdN z+7r>+waS<^=j~sw=9emCmRw@sauu|lt;fX{OqDu}K2fmhs+b90)XauBLdpJ3p91mZ zFZ4}U19NjR8XI(3q|%I0-V%$>%!du_MhBV^aySPhv+9Yb5Cs;d(3UV5rcwBWX?>I)(k>u3VS52ZlR?s z%#m6|4y6qwkXVhwt^RfpE?qWi+ueg(1S6Vg(Rzme!WmqWu50r62r{-!cEcT?cs#0L|5D4uD+~=#b?4 z6`x4&q5LPx|1cEQaX%Aqg7UxVle@VS@h&Gk42P0w6u;$?6yCkiB&3Y;_R90{k>cGQ zqOHDgHPoRAU^(reY3`OCV|Kb**2BKih4hK~PuE3{sg8!h&+XXm1ihkP#bbI0^lH@J zQax*1Q@xx2XzNUhnxx>!wwRWY`$yc#OWQtT6h6X#8og`VS^e3zqL;GUiL=Lc$egG4-LP4|%CSj(%X9JBGQr)kE$E)vq6;b=xi1 zuIXb(?y!t=9(oV}BpWJVX`{GYEN5@SzBQJuPt|4n`IXEyMsi6Fnu-ygDda11xrEbO zJejA=i~_-g0CZri8X<&+Q>@sDfaKGhyFU&jGv)6@t6zQowu=R-&v$x(`s-GhiqHg~ zapJ!J)FChY@#U>;F{{oW`UY_m$ybZX^hcri)@|?lk*lSMvfcfe&9_ZVvy$2@2+r5T z?8HVfZ2vTK`>XEjU9!e3IqyRoAnG)nCu6hor$Yp{e?AN)idJChn>ecU9eEJj_t-()i&TWZ$Nce zaLF+>h4XzofRsnkdwuqWd-lW(SKZ6p!2H%N$Q_Tmj|taaGzUR^fPQj>WM??|h)z%e z-%;~S0Tds~W4ey0zfvh^8$-nOmVKm-iF1aIBlTzlB8NYr4gPwaxcF0l0y}FrEXbN0 zH+k3U6WFR9z_bo{sDkJKr+{jo&~i%rA)nB>2HlYt-p8i2ZwS=$SkB5~Wz3@6b%TnZ z_f8k=h+1Yl@5Q21z-k{JUN7*ml1AyJl}dK!)6Pdg@8(CF({oF>OBut$iG@lAE#CnA zC^{idJvq#3THJ)x9rRe;-R~A*A&6p{fR@OIb!QzXv9oe{#%MyNpD5rg)6$W`&JLYh ztLc4$$?|!~M)X$CuSY^H;3X3GA;5>wfVOy&cip;Y!N!f++&%rkMuR61o?WJ!)gG7@ zX*#E;u0iiat6K|tVmdc*GLaU@^t^%955g|t*UH0 zCN1xvc=u#%_KRV+ubR014JoQSoW^j2ei6!HP0Hk)tH8NZk`$#}%5Coqa6aq!rX`yA z^LFNe=G*4h#t+yIPi!H0fp=>V>UEOMN_oW>8y=%V|8Fna4XG>?{r|v*!$_Sywp@-~ z(i7H%WAe7_^w(=KGq;8GjJCi0-pS_|!oWCiZlS}W0zxuqhdj=%1>Hr&Ubq4I0Nv^9vDbQ-lO8K=nk?7ABLZ z9{yhg3$F9iRcFKbgP}18&S`fz>~XD#k!D)Jd66BFVVs%Ek$*}xq(}shJ1en0EuOqE zV}D#w-YLrES0BP19LJ@zFiI)&dOa`8eUTP=Ov~a}Xm7 z^%IrSDsp*%9I+r$%Sk%Q&;?c+v*@(4LFty6PL_ZwNC@ISnMo&t?X-1&u`VNoHWS@0 z)_P~T4FxF}YK8ZgQ};gAW`c8xombF}`@AJ?Z*3!?X^8a;xz|t!1#i{V#h%gNr2}!! zRSJorsn1OwwP0j%auBwW&ayf=VMUO{9Sw7i@-9o8(ZpgG9LQ+jhi5k1H;52f$X|TY za>BdV1Mm!Gd^4>#0uwhB7xkiSAe%l&GB)O7*xEnU%}}l%H5&l=k%V7Uy(G=&#rIC# zjH@crm_?3Bavul7jiM!}Nh)+fgEPptp%NQ8UPh>VaC`%u>t+yW{Licu3Qc zh8GKjlWNLP1U4y4u(m&SA;Xl?Shi^wjRS~x;D5NR)c~%h&u){Mf zSc$$z>w++MfywMpVN))Tky90vfhmbws`(lD!c$K;D5>@?8@Y3T&Z zu)|J9GYEH{3fPzd610lBF<6i(t?>yhNK6xtUcCT77UrN~>!OG>B_xx8CJ`K8Qx=BV zp>x_s5)-lBs=?zaMQx_>uK&HWP+kW4oNE4}R;frUEpcA(#wV!rf*TJgrxC@;fgs$l zEp*F?vTFXIa$d*!Ux$_6AF=+_{vhV4LQet`P;rzKgevJc%8Vi>Pp`i6&)NE+@jWa@ zTnnAx<*RxVP95^butj}iv5$;ehA^q1p&NSPHLl%RPD&jUBEzvV2^`ogzqD3wp& zP5`L-8oBB%qdMvCdDuX2Mz~b(ss|Gw-hh_DX+Zm-o}Arr7xfE41JuW;;ijjt+i!Ik z(|`a8c2F8~P&!#z-eCo!VPPxyDqg{9zj2bIz9ET9bf;y}eRGGta5y9A%Vl0)($&1a zOV@buM9{TCs%uCI7RqvO*AC0C)U})y_^~qPW96B*140$a%O3Pzq=%BO&@XU_qaih& z^a7l~5Ha^ROv$8YfK&sZxt`eUktBzQFuK@q9u+$O9Y5r+k_j4X)|fTyH41sq`TiIM z{TIYa!o7}|`7(tJ;XtaMu~WH_k;{^Mk@O%;g90U8n%zg(lid*BCG_;LR!;zcTIh2^ z#td4^7Is$zD5qVaDao)Bu$&`=3(ZLETfMP5YLi`fVs&R+hM}#OL{uHR(=HdFfq>?M zcPO&zcY+Et@;0LSJFR=x{FBlAT4~H$8C8xGeNm{`2YtPw#|VQAS0e@~+}mNIxXjde zAyW4}XFE#RSHsfWK_At55|qun{6JXWD}4(?1Dr=ebM{1rmP00W;v7O{9?~K4*@NSV zQNamt?B(Vf|Dn|>_!>*AbziqjYEKYgA$*2dG~#rrpSxc4y*F|Ad@hXnEW5iu#O(ul zH;rvV83{QwyR@?#ZP*Rkf_5mKZwnjPye!(lsc; z=_7SEKtQO(O{%fV@&Ymhq-W*4-xyz`F+R0ap8)R^FgSj?GK6zPxP4j~bJ}=#&Z-_i zc*Y;MJ=1kcBF@D)GL_Jx)T$4vt4Sa$(BXlo{gbU;9`*n`1k6PNcAWG9JCtG@uY72e zP1k4354rAxFLxjvnHnZNuG|l#%niZdI^RMUO6bNk5Z^-;Q5_MbU^ z$h{0dCUCOK)xQLjO?4XQk5M#OS{k^&L9 zOu^2qGzYXSUg|=GKBQTLx2yS|tua5_ki~7*^uHR$Q|Dc@Xg8Xet**{DGI`Q2CsA9M ztbtcyUDc;Lz+~dUGHE5fyU_Nq>@yw&#=EylV6{VmAIWn>q`mYbUxO z&RmLFrs-3pC7>x#bJu5!ToY7G#RSq&rZ9obx5nmqJIdyxgaExrS$ZoHLDV!=T{p}U z8V7~3#;!1ycj+{YptPVmxLb>sRH<@vXGr)^bHPAqJkFR3S2noW(wNiI_n7I6umM9zbba3HA}GMTb-_De{o znI7x?ii>~{WtO&j1l=31WK zZ3$9(LV5|JyrKZB@<29J`O1@=oFtH{y?T7{n$O&_caU!`{@l ztj-X7Q*|^7x1^zlX&2ZG3fozQ0!Pg`lGg0wXdF|a?)n11!dfUt7Fq~V7K15zhv>P=%kqdAZJrWbv%tE+B zR^TEgB2?yn0Ye0N-TNM(sMc0*$sw1alVjPq(FtXr zrByT`6O+ms1(u8X&qHHA58cul%BdAuVF-GUJUT=(E4zv2Ev|ELK7#6D#HSHdP?q!{ zXiX zgih{Vd&SEhog9}M2kJPArdAv@ei4dnnhb-T($V7wC+%(6qNx9jzYDl)}+IXcmE#tQ1J$7z(Tw-y36;F$7;JPa{QU;(+UY(gbfkB z`j%eNlTFA_+zrEO!mXPT`}q1DxC-66Oy}DcGrFFb(Vfs4`QooJH496*K zp~lO|4h-T|`nfAWQ-{sjMXkGwwkb!=LcpQe&J$PgLg>;0D<@SMB1dBK;xfDj(W;`8 zPpjpaQCx@j@ik0FR_M|P(8EBzldSgCW55^_W_NNFnS2bfH376)7<(4Qji$f=izz2I zB2|*wPw|}5U~j%#ApE2kSu3|NiQ7l@1Skn63CobvDX{B z^Bd%>*NXih!uz`c)`CYl29g@@&)6LO2vb2KTR|Whb0j?HNd#B;*=VNKf)iS016rPB zsJsnhIHT`&$SKN#z_S174-#fbN7aT9xepBkU95=H)l$w3{gYre!fz%tRqh-9 z`VS;08+stgauj?Lo|*~gFZxOic5|dYki-n2J$CA0Wb|ja$pi|8lN~WoS9>tO@NDYQ z8a%4>_X|-d+h2iWNKCwg9dpf6$6R8U=zy4Q-;bi$#fh z$|tKzHn&Jd2!|(^1{t(SRg6uLTa3mO*e>*Z@Kk)n6DtL(`X~-sjkpmWENVnXb7u5h z$9dI~iElSUU6Y}wB9_&RZi)r+9lLoLEjl6%7ua*rO2#1?M=AuWkSXIgA(RUs2ze-b z*~%>_lux0B(Y9C1cpxrw6}vCcfm6bBlX3}}sls%PUrJ+sDMie36N-E$6xzVnHdjKx zp(GsSVaNcD2&#>Xp5>{1I6H=Lt*Lq6YKzEuVv+ z3OKOL8TcDK?~y2v@}kI`YLL{ZnaG}+63_o6B-beA)V5c@2w`^0vXLSDeh;RJqGzJ7Ut`*JVeYQ zT|`*W86rEU^Fzp_@kYr@=qz7KW4?%R(DF!1rnDt1wgsvnBwX2u;h1wB3?E8my|)R? zQMAy6<}3BGx|i=pF;dTif?ROb^A#?Ks&x*J!?n)b4TI5NXUO-4#_YKv-|vRJ__hAj z6=SZN{njv?eXKf8)3dvbKFaY!f?e##jBrLw)$msuXP@mj7GWEz%6V@xH5!CeC8EnR z6T0OeIRS}ybmka(U@dccr7t z!74KifN>E>%+nwdOBId>)vbcoL^3+=xwGyNthq;EwhqT^Sfw6Wog_6h<`*XoH-WfSv_i-E|d!EA0a+MK?#QVb?(7l;0u?d z{7lrMRDR}V0Rx!SLbs`XMs$~?66o`2X@#2oESai_g6!g50A?7mA~Vm*mglxYP+s6; z^H=BQodS-H{tVRHQmdzm0)|z0SO(?MMjj)*T;`>5*9&*2cS)haUT(TXlNEJ ztttI}t3~wpNb7=%p*ZxQTGcildsak+RX5q50Ua0Sd84<@t`+p74ul2Z0sctThr9$G zk<#~29M~e>E4)^vig!vgnDFQRcM6<(K7AztAGWa*@eBDPzUF!UbVm`tUg@QU zH0HvtKZj+|AC?XC(MF~FNERtFem!5t1Hz(y8NVLN_~XKu;|7Zwx*eZt$cLLg#VwJW5^O&XuWr5;bGz|%-8wE zw*TgK$jzn(Ok%Q#s*}8qpq?lSjp{u#>Ev$A(3-)%@S3vl+RCgv38hdgdXatIsVA&d zceb(@C)N+Ll(wSFl0i|r5tb__R#S#qLeE|HP`fui0j_#%U4YKE<`F=Q3mYyX$4r}xA-!_ZBR8z@;Z zIq=sawicnuo36ye+<*P!oBemTz4LK+TviawzjzP(?1pmODjujKmB9zTMWSGi_7ive=IIOKqnU$ z7k}#h|KH@l|1)>Bdwy|ndNTKTd4BH0!O`yA-9LAW&6R9*(c~;X)apwqDm8Y^#m9*OKS9-hd3i~PtbEU!@4IijkWMbHoKGIo-D38DBP=9yx>)Y*lFjEQHY5W4&+L77M1%| zH8WUq+LRv?AOwu5_J{Kql`$7pea&ShLzeA2{OAs`Ly2^1tTGil)>_Cfp?fpUQQGLi?{8p=2~M#0e?#+kqM z-Cc)(eC0q0ebpgo(MK27`}x1w5D4`~>HTG*_o(<^$IP4Py;CO{M-*lMPdx=|OCJ8L zBQ^iEr)N{%Gm*EB(mKQ}1Q2DkHECk#yBNT7HUfkACUEpUNGHN(3d=b?GgD4g`&EYE zveJj(X|Nnx?2OaFlyfr7$3anmPM<68`Go4WkjXAhN#E{*qj2@I9N?``-A;Nl0`)#E z{VDnaT;EX1hA&owK!#$9H2kh-OpDm|Hj4=Js_@IPpxjYShh(A`ycn^Uqhyh9b}@NB zjRVWmV0zoxxFqBUS1nKydRQnT<7ULorDn|(Y^n5a0Q zg|<&t6Z?eHHQ1iGvH5$#J(dcuskjP5j$ij!Us%;R&&uC!bu?2__wG;?$#HyvRQ60& z=MERQZ9Eyal>+LSsWgS`lMP)i-Km@a+Pe?|U|j~D8gv$!~0C#uC2d9J87 zx4KJ71(EgUoO)R4KDPAJd<|UowB@00WAc0nD}5^7`b81o<|o}&qc(lEuBaG~?rwJL zr1-qcEJW2z~z1uFjXb>|_=GbD1ZSX>zv8JxP7nHblpb4bk8 zESc^Q@fp_^nwv9-1yT2yy zfeM`ZRyuuh2%OLs(u!XdcQuI8e)D@+MOWqDaC}v3%U3%wMA*+FwatQe3%rmET-2?M z7x-my*9DHir0s2=Y{;@cOLBRXB_)PWEsUvDsNX2QqJ>W**|U~aCug&kQ$tC3;)T0Q zcSnnm5bviMTO*89P}$t!!%fvq$nrnCPr{Q}8miXkc1a6cY#_?D!qi~#>PTwiz5n9y zS`BFIqXWP<6brBG0PLIDw?_{35BhWWi$zJGg(ACwNd}>ZYhb}~I|~Jz!RyMHYp3Yn zRDCn})MsRL>3N}4t6sun(e{!9+f%tEgY{EzVWoU5%N1Nh9BSGW2Ao~&W|W|r3Us~9 zNKJ2h4`fSM4vdpju7(dJcW2W^sohba4At0a(83u}%iJJo_KAQ>v2;G)5zOLMbE(C5 z$py*Dw-_(%Z0jO-d>3Z+FF)_lsD>E&LP^kQJXA~d+8O^X(`phPEXm?UNC@z16gqbZ zS_LWYx2h-JEB3PcZqd4u20X)I7?`MEU3-g{;z~-pLu?=j=`T2JK`njF7e#AH|^!1EZ zMXej2O1*En47-EYJE}_4s{$@(ses>3Z|6+oQ6;Mxw+15kYio3vDoZ<`wUL7%cjii; z&&@a7zhhi7Ok{{T61ei)Z)I=!iwbw5arJvwwM{*{W>(*KW&du{0C5G!*~t6RRv(3& zL@feQGxr?)cI4r=q#^ho`oekMR+t#`$yFLYuXMY><;jv;#7`$;faO}PilIFOHb0>69tS3F(YgKOMZZ?Y>xS|ZBGtO-W} zjMWO32b>jtS zsAx#b`=L4yyO>gO2%Lw_-lJgaZr8Wnt_KIs2wfe*er34h(5$GW)wvH%KRyM5*dGR? zb5R&`(ZZ0T!`56_K0Ft#m3k-Fse8nty_a_`(^Khd9sIRoT1aQ(9FbW&F>)MqPPs{9 zfJLApV~wUzI#gwGqsl=Pia2i3?y4XW_p=QXiUf90)kT2ftA}_yu9n{*8we+dwYi5T z)RvSG_>Fy_XFVm>$8Nm8Wn$*{$p73uKR-Rcc<=R=_#|6E&J{E`3p-SR)X zb&!<+>aR{`fW9!&bxgbh4Z$jLA#8i>;f4(oz}qj^ZQ#+^@^H*E8`y;n49kl-_*8m8 z`{j*gx&9sz)yPkZH?%mXGaD%2?U(0aYo{LIzpA_}ZD5zgG{5R+kt z{k4TD_Bx3*-Y6h|waq;6-~||+5j;eqvV5Gjei-H$t__V@bL!ywFslXP1V$%g#w9-1 z);8&+O8Gl*#&A6W?enL;z;_G!f9(<9?Qp&Fzqgu-`~ApvdH(MAOS=BGpS1S+`uA#J zyFA)G>)i%OM~c0@DHuW@z z{+>h~qQ4hbqkvX+TV{wVhsfR~OL6kyx{QEyXt+>$PIku{E#ierJ`pXEWVKelL~g3T zW}lgNAqnAb&J@qLV~B4jJX5^Cr9L=|4-K<}sXrZ*#vC~8-=Qliy;!wy)@l_OuOovI zi5$domb94MnNjIk{b+%sqfV@H8qYZ0|7h5s*YT=*`<{&`jotcjjXhEed{VnakTK&KH*8D#!Yiof2r{)CuVgG$6`>$OGS>|7JaTfp2$UGISKb6ce4+05} ziP!49)0}{m#=$JI7S`R(X^guFs+%2SB*GaUc-i7eOIyvJw(rRlg@=@#QIK7`-4P)Z% z0inN$j*FZ>Zg-8%i~142y_Gd)JFH3>>GAPJ$F#rBhY}yt9ulu<0w8Tblklv7YbbD2 zpNCTyvTj+9&6%wS%1z#H-B|C0j3#O5Im){lr$WX~W`#8E?CMuOms6OD$?z9ZvP1-< z8AvcfJW0H{kEo}@lSi^X?WPR*ht%`=wNq)cXM|xDIL1N;V_{}^{jT3zDHz~7H^IPc z!f`?15jHA;BUBlcjN{g!+bpN$g?<3|2m#IVm52%VDGLT`**H%=1x1IE?+Rf02DU}f zyMI+}saRJIwbDyLq!+73;_DuJTj-^{(?ow6xl>vRX@SmUnW5=NxBz`Xg1<|`^cFM- zqQWJ0_}aRJVij;n&8n~bbG8xIde>4@G|M@v6#F1sOiglcH`aStzX9!u?vkjsvB01S z#}b(@VU`JpGT>b(O8PZXhFJ7Jg9SQR!yEejo1&-64=UmpBQgg$2UC**HbW6TFQ1Fw z>PKFTxf$9wXLPkf2Y?x zq9|s%P(p@?LtA==`gJEmSif$uXib*^-cN-GE?p11l`$B0X#K^&Wp8;_*dJ1ii#1Qh zV`~|+U^(Km#dzJj#8LgMb(>Ms6Bf1TuX!1~YP+kr83+k%e13z-c+1ut8Mz z#wM;J@(VZDYz$tBa#=C=JW4r1#cI)bWMl-l2fEp|&h%-bQUxd^_MI41TnZ$)V+R8FedKo!+ zaC*FHcW3>NvxrGktUOWu2L+u)tdKB9+z{HpV@i(@G@Pf+F$&DZKc$jC2@ZzfhzQkK zHmHG=Bb34g`4bw<&dI1p%|)j|EDo4f8Wgjo=&OnO zQ(|LJ63loFtTlq$Ggl;JsXd(%M<(5(9+ar&8agP&qY`B5b+I9k>-OZp)&iDRH$gT7 zM424hH2w$y|Iv565r-pGgUCs)moj^`h}{$CR-P$n#xvS1fSnnWkWrMI(}VzjZ}F_h zMH=L~3S&a}4e5R8v`82CNSA(^I(QN#or0q+k+hl zWpXcvt<@a3TzA3Ud{1)tLM?%bvMOIK~#A#E*-(S}Sa>IkY4XPf*s)^7QI@~zu>qt+|>vAUG z=}(qSDpP0Mv-MIcwHZA?}JC4lI3$pt=h#jik^)3 z*SIUb;>N8Yi-I8fk{ytdmX>YV`WL15yiR-c@I;`*b@}`L8+&QoPZ_$n8E0 z%w%`VLWH#8G5QbTNz51aum{M|O;pnXD~fx%Z(GwYovMv?c{=bd3W|Md&L!UdO_dhP z3l|kn;{2h3iv+t+Pu+~DwF^$UvZHgL?pa3nv>6aTwPCX2*Wgek9}#cMrR`rHlNd3v zR2s8XHe$4tl}~54I8~c+rY*5&yTp=UOtxBjguvJo8LlX%q&hAdoB&x*o+G(I98yzR zmngGlkxE#ck6^BmS|KnAzJn@n+S!@%I-d=79{;dBc^5h;B!HNtG)fxs#Mhl?Yaz4f&#Yj)=j?CF<1xu7 z!}n(}d|$7R4>Be4jvkBjA&w5MH00$TA$*RxxE#&mDme?s*V}7jcP#Sp4T|cdy+Ge$ zOz-3rS`M5-Efd5@F>y zwU-v}15my{o{D!oWx8Wnt@UB|;9smUzjP1Yx-4KDBrBKRvO(D6F#RlC9w8(uPRqU> z!g&|@>){Hht`e&&$O3_<29y7nyBC)SCwmWl4y5J(;_}jxl>aM(!P*b`|JRrQ{s`df zrtc`2U#W~)akK-g6=YsOa_S6=xvWM06`eXlp$Nf*UpDhEx+DS`-*G50K!t-r+xtKpLjX8E)UMC^1Rp?WB;mZ*= z;^DaHYS$i)0+wQWkAtjD1Y7%4b~K+_2LcRzG$Jm9RHDw?eNLX(-5_{mBa{mY4XE^w zKG!@e07Ygg3Y7h9U5(##ezCy3`1oA|Q$w*lfKF8Wg7FqBt6b-U|3izHZKJ-bG@0 zATJJLxA7_Wa(HJPio11QEJ-!gD+{YqLvN}?%eBC(N3h23nrA8n`+s+RgPs2$cfWiE z`nUA?KUiE^UXJ`f%Zoqw-@f1Z@7F<=`^R0I#s218K$AWKIPMmxOE0TRLg;Kbwu7QY zI2#vQB`u*j2Yj3{r+Q<<`%Ov%B3YWbiiU?+r=OKB9nl}-_i2wROKJMiKoh8C-X*R% zyjU8u=tg@Gj&>;LMXD-!GCFU%BT27h!J=0+Rf9|^Y8bqDT&v-SN|2g0jTAA$2$2yc z1Js@(rt0nVsCq#OGEq+hB(`e-f&Fk|ox=%b%b_4@s2uTJD4$tPH__0G=OPFfq~f5T zfvzX-LwsU_K}u$%i@0ICdYxDInnH%xZYA$T7#jhT0D(Wt0!(pkZ95X<4E=s1nsv0B zXJPsFLjC8*TlLrr7-um_vRDVj%8m-cLkZh0x?4x5;FA~U!9=Mc)_jIvEAvO0sj zz6kc_P=9l}4X9LgZW~l|MSo=9W%y!D$-^`?4dKXW;8RD4k4}}2Gq9fgcMXpBl`$V9 zm_5aQjmx2@M-Yo+El9(9M_Q7sukH{b!Wijd0H$G?FPo5!LJUy`^*1`NyC_nC8=*+` zum|fV+Cahu<=ZOB&gZz?94(huA&MYkOd*-aw~#;g{YM$f|6QVD+#g>{ZQQ~C8dXxP z9UAGjjz^rRBMRR#Npx;I+L>npv_}Kwz>s0V7Y7}ly1UU96W`Iq5S#}g1sQ+KSs9ZV zvAUpYV<)1qDt^UNZq_a@v?TeThO$Q4SECR?C{}64vaBdrn7d!%#N##v9OdMTj;k+; zflOblj^T;%lglH>w;E$lHu~~ZcXrn!9I3cxj2d_;LZe5oK?&u!te4| z1dU!zaw|8MspLuN6pT#?U*<-VRK7$H8a|JV66CyamV|B5S+f-B%)<5ct?fxPeI|2sOk+Wpr4zs1_|7y18|)_=tRzn}lluY)ZApSw2g|NCss{4b?3 zzc^>nMkqU8c@q40koc$SYIQpWz37rDUq`BYt72SR{?lz(e~eY;IkX5;TL1DArM}jf z+INhazIX{aKPY!LgSQqbhbT@Fr~Bi?9#mL&Otce%5wU8{(&A64Zl_s>0%;z=+lR({ zbZFzFVNmQ#j6pP}JT-y&7j`_YfJh;UGhRMWW}0XIO22i_c60?z+6^gDIQ>qI9C6OH zBMV>5FKV+M5ze1(6d-q)>)tb8NSVrMnyXUQJC2|dh`Ic!qGmSL58RbIdiH@caq0zy_trqD` zp(ie@X&U`PA_&Kp;e4kz0Ldb-0rrc(;|BO45x%NK5Sx7xQbLxnQYj!lad948cNJCt znFx;}$Qt!kgNcZeWyWm#7y5&B;ttlBGFQ=?CL#4LyYO`s5ka!X`?F$-`t+{Pyw2yP zW;{^kYt#~JhCLMfqGQ=@?Qes7eVMQE)OkMN-O7dd@8jL$)AKL?Jl{P#I@sC1w?&w; z{{~Bg)jHQl;6K+_e&9d8pZyoj|Exf8@qk=-NCOd>-ls;OS`QFa0pykXDu6WEhV=jw zVAVP((>~_fN=GtJ7b!0LvTMNOB^gqjZM0bKN5%BUoyYJ+_vyJ0j_nKb^__cFrwxif zcbQm?zZMbaP}HYE=LlukbPR?R-(mDFt^?J+XmqWkOr%vel_fAg4(RRy$<&n^+{mIW_Zbd$o1|70l`u|4ZIj?DZ)=uaZ7P1qK zYvY#KVR_iqcHzX`s2;3ZixTi8)IWUC4B7YE_$-ozdtUnLl`o80>k7x1rihZefyH+3;;ThhV~w(tL#3 z&3`|%GQnI0!w9$p@;g-$Psv|)W+By>`~ARnc^+ZXkkf2bg#x&iZVKHy0-VCczY6W(xp?H)y`+SiB5n58iS4brm(J{^Q*Jkvk;izdRCjn(= z9Hl7@QpX@D%f)@JYw%WHJ#vu3Nw=BQ!yeZ>Qyl0bm^5~I4*_#~eX6+z+=P@`&wW#_ zu%D6FLb7T>GaqIX41{Qu-RkWoS$ff8T+N^gJ5kiQZGTL%nOht=Kjnz4^#?Bb^2X{E zQC#eMteHj`RsZ}J5{`2Bx_^vdpGCM%WakkHVhl%Xi%Fm`3VZ!gD1AXe4nHom&Ygtb z>IhpW{rs%L&xycmIi;A<&Ek~g*$7gMG_ilBGNUi0-qvdo>~m^eu-J~f3^&uUtJ&>q zdNTslJ>t3A4MvT2OUPluIHPWddSMH-Fc_qsRLuiIt9txtP%`@_qUIBA-~*Nummz8{ znNE^+uZsgtB?#vdxYR9rw0h&cbr_W!c3oy+UnO{?hD{HG=$6}d%`;Hy1TSle6HLWk z5rZdw6+$P5fn(0#c)~U+80iw7M!edH0Z0Rag!`*g-5SbNF@MQg$AYx7kWXz>_9Kc) zU}rj`Kg5=FI)8OmakZ`~e-h^iAm5>y3o{Ye1A#Mlr#2{R7(iF1ouVShRzufD4|A^& zisFbu-4Kcr&3c-dQ7BpcnQqPtF`8|NQ`E(m*nuKsO=bi-kgmvq($r7xAIwGrwPHA1 z*pD`#?)N`nPJDRagi()e7imm0Ah)iBJ?kszPD4=m7&5wcl~^uY__F)gp>1<$eOGEJ z?<=@rm$tTh8+*^cf9^)vR`5tD(w#LLr}!_0b+c zAFW4o3!aF~DsT183t_H>sz;NYY-(}}vd|gcw`|oU9RG8ho$^c`+@jWef*RYBl2s9v<8E`R7X^=Cf<&y@4=hX+ zZGS$oQ1(VDajg;|<32+b6fsocu1MDwow~$6Vb40~O6IC>xX;;i`1gFQ=H>PODKI->WxL| zRETk2=zGAODw`9!5Hic4c&7xiCXieS4P<5E$1NU|u*xqJDK9#$dne4~&s9-kqxrB$ z3*@HTVYKQ{U0${RU`7!tp*l_7u*|eZW*4Z!jTCEwS5>eN5P#K_(^qNb&KozZg?p}( z3hts05ON#lXwh;?S77QdJqId@E>~Y?%+t2nB(%*Gani2*TW_qKIaI^1nNQW*351_7?W}oH8x=>S<)VwxHbR-EoUulf=&yb)+h?2q=bUx;WMx{AaW_JywOCocRTUacI9cX{{1hL%q2`WnCRN|&Uhaj!ngvIDH zMoVdj9*g9iy(u-f16~#!r{;-|SkU{kK(3sXYNzTVB}n!NUvzohZ;{y zMBpda3!>1Nsvh#%m3=#eK>{8y9v~;^l4XpL8GTXw% z-n3G?_*ImYbI(U0mtK=43>)BDmi%8=P!r$UZOUt!n>>Uu8GS;)F&FvX46@jyq(e~4 zJqz;KG9pASl4a$Z1Uy-7K%On0i3fcn#9%kf?wTX38}&1-WcRe25(SIC*ZOUn)Psx{ zko^P47!zQ@(+n3G$VnfQP_f=>+8#oOq4M)3I0y^90%?*?)(OKxF1!xMv2Wpcq!r=z z&+E8siF%@G%*P*Q)4`J_2g|Z(pEyoF=h_M#sKNL|1y7Jgk?X9~z=Tk5N617>Yh(jyhXr|J zhjJ@P)m;oZ0-LD&3L%)FcQtxYWv1RE zcVK5!14*jhqxVb><&HKe&lB;M-gWGfSaXrZ)VhdKK2Y52)`{VYM-_l*{{_Mq)wD#N z`vpZd(gP$+`)f<|u}PXDcjbqi*MRfc-Mkvsjv&a#isdP9)LX#*by=eI_bklh%2?}p zVv5lLSVznu<8HDA!x8ZBo0D-$nu%#ggpuE#ArZ zp{YYXNcSq!9pU1s6`9g7?KzSi5ruwQef1?qRV6Z=Ifs(+c#R_n5C%B##!Recd$X1d zei$tjI2H}3sg>0ry63m~_J|^&HGgaAFWMr;8o-Yk@ur5;59ZV@$b&{MvE}hhE?s~f zV{{T|orG#ia-Cd0z&eR&4`wwxbQ5Hu%29#h8X=V)w-oH6MYhXKoqlDXUqw2zqX_T^ z;Y^ykI8dol!+qVOuALRFv(|~6&WC*v`+g(g^XqeOr%mHo)T)!L$UDIXm@0%~+-Zo2 zXeqh_acC8Qbt&!k2JdY~aHt1sL?Vup-nM6ribDY%8Oby?Il<*!z2n-sv0lL7aLXC3 zqWJ3Hf7eAouGWC|+<`2hjHG^_4vnP#tPzvf@mOhW(!Wco+Pha?D^%2I@@8oyr>@Dc zMnb8J#_Db%i}Qr|3MDDH$o-Ur@}Aafw2*2SOe+|{EDkovgc5ueqMmYBP@FolZUv5p zr!K{~5&*FBnI$4hNVbr=+yqc1uKKW%Ky{_5sl|AU7J(Xv5Zx0iDT9zn!n+GsM5yWzIUKKe>IQkF=Y7+`i*|4t zDuGxyyE8r6TJV``MwnFzom_yD$;vXAsL3j^X~T*to-ixj*m_t^lc<7Nv6GF234$gl zZs%2@M{w1ZE3?DYr9mS^e^hevnU+gTRk1#l%Z08Xu9rBwZMB38CB1D`V>|7cBmp6q ze&{=uwO>ioUmx{QnPs#{9wlilH(shTC2dEiEE8x2Nr$0s4W*XM0*)hU_J2hFpmO1% zdc~5`3PF`bW0huK6{BFKj;`guwD$QkwV0IhmP-zii$}9 zWQKMSY61waoo;H97*zkG9RMU7Mfvxz=CUWW6ZvXL(h5i)^5-#0*vfP|dsHYweCnPR zP@T#>xTY>NiL{}n>?88gA*iJ7$b;SlUZnP(DTVN?z>#d~&?hzMteSAtx%l1+F*Y>e zph|j8(~=O;%H6RDXv>al|Oq-|i`k9#HGsWjCBI`x(kKi`Dz!I+Vd_~&9pAz~kGgP7D337`NHK|t5TM+T+2;orSPIKwFMKjFV1Bk#B zxSme#tRgfdYq0={{5d52GccAO;h&3?JiP-_62<;Qlx7A}@-zb}DexN!pi>}VXQfcC zLQgBrMTWqbbU}nk7h+I}sgH-o9J}(y%N1|WYsKHvK5v_!%IkLixHRUtMC6k~r}~*J z@k^X8R&koB+#tH@y2!YA7HVeF`*B%D!9W4&C&89&YbYZ^qf^I9Dufi!nSJHcgcBfg zUDc1ijmmC{mZpC|44MeXkm5k-8lqMlcxVDH;olQlr67+=XG^NZA~;<8d{ei#X$%E5 z6rZW9K&F*FOPmAG8CS2MTfKI{inbDS@oP_fMeTj^68GqZ1T7am2iO(v?g1u|5yb|s z8yp&UIeQd5KQtXK@Vyw~O-Wg!Z8o~aJLNs+u`GeCZ)o9(N^WgdhFe51ukCD?x7GC@N*9S8%^isug;Ofd>gVBxr&akdd2zI-14lh)>QGFt-+E+nkCaie` z?zj#>fXHgk$WXp+tQ7GGME8)6t*A}|uu&EYy*l-Z)pEx(PPmy0cV?9Ogr;BtDx)A# zRrmC2GkZ+}ReaTk8*}z`L(&MWo7)FjH!5u#s=2)qWZIw#=NaT7_nC0fgSs^*)g9(1 zHxnVfh-YqaFz&MifIxaOmBDoi$qG8jQB+0{ zAUm-#gc%e#Hjnfuo2$@UVWfR;ysYBY3DKgm{M(OGhCA6ipGWJ9L`+0gw} zH3SZIllL}zBtU}Zjinlje?-a*Dm(`w4UK&f_0+@p`W+{O5~6cuMbXm+YfsW#B>1I` zHWeSE*<1@V0!5;v*Q7N6GpA^CI<=8M@LUY?PB1?!mRH5Ak7C8Q6dxixfzYP|fT1Mz z9EQ&!PFE~dBVKVU2`wG9v+3zW`}x{-VboZxKfIZ%7eGhFt5g>V)v3E~>Kf3ENa2gx~{~bsC zn^Eo~?qOt5puUTvrRs6#i5ruSy_>r@_Mmq{rCH8E$2fOH`OteEWkj9!WJINrRH$@v z0W_l-(7&e*1Cn~CrOg}^*gh2rlKK%61fLLh5?7iJ?Y$Z)paXvB60?U=eZ#`ofA!fz zczd+$?N^xl#ISne%u}MCZZ34+*6&KQ?-R(45mAr<0pIsMjP6gN>(YPARe0g)f5OP+8x7yYOTjVUW* z7%eUaxbebBvHq-Evk)%%(BWus3Oj zv`1-4BIGC&YKOAqY4`KgC;6p;TOzq$0Sq1XQPES`Um<+ctQBIHfP`GyK-?XC8C zH3~XGTXUxILDesXY@N86bRMv{H)%l}cjJ!Jk221~fNbR$aR%gD3Rq?SKy#97qBpyQY?LRuh%2=dP&K&Mt1 z*p1atmSIB3dBkF?Qt|SHnX+fFGlP&hs|;;bM*H5o`~)7EEx*-KfHmDKjoB;fuWy73 z@=DhI9UF6DBlTDi*-v7!v28uc4AlWnKDvUTY8C7-(a6!A-ag~@aoBeYrL0O@9#k#K zWnu?OBuCK-5hyzE_?i8H$snmlJ{nX8rvm)ih}&ATz*k4295yL%eEmM_Atf1wddN;# z4l{%E8-)}(6G;KfRj8IK0>}x~P|XePuM8+E0jj0Ihk7p51D3%+eAZ3L&<6z9sK1JP z3D$uE)2O;yGG)#kxY01)vE{LtIxqA}o*drmX zRI+Mu4k0O!rr+!@n^Je-XfvRNX*cOhy;5kpT6~)RIAhZQ#XM3+W@^IhZx@>@_ck?u zv$uzbBdBl*DJNE6y4bFoS({EUWz9PArxUpe5F}9$gJ`HlKa*-CL-mbk{17`J4~IRQ zev+~2C&cU~%OT}%UpndTSl4Lc@NdF_?n!gvBMfw)kX3Kd&9v~k>zwNM5I580WH(kl zDR;t3e%YYbPVqVSuJpnb!zZTJ-nFpt+<*P!FZkct_Rh!cz1@ZP2N#zMC#UasA01vi zI^I5e$agF*F0QSt{Di(=TpX+|tu6lakLA_1#lc{0ZE@wNe=H7`me$sP`p4q87=X_g zm)qz6SX_L7PA)Dk{?z^dzsZ09XYOkE{NmvBWbX0u{M?6wqusg3bABDn&Cgw&em>vX z{p0lXa_;e5_wS3j$N!pp^l0JYe5YA6mlt#Yo}W9}K6XF-dw%YC`|RxCWbb0`@tiTn zJPH3BneEXN(*dQ(4iZ>mC+MgyopB&ox-A$BEV+hwF ztS*wbann`{xba0vY)bVpPMV>嚿mD%eoF}f`i3>ZcvX)YV@WNICUigFQ!n)7x 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 literal 0 HcmV?d00001 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e10d11d..44cb99d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,9 @@ importers: '@mdbase-dev/connect': specifier: 0.1.0-beta.125 version: 0.1.0-beta.125 + '@mdbase-dev/sdk': + specifier: file:vendor/mdbase-dev-sdk-c573c96.tgz + version: file:packages/connect/vendor/mdbase-dev-sdk-c573c96.tgz '@mdbase-reader/core': specifier: workspace:* version: link:../core @@ -1135,6 +1138,11 @@ packages: '@mdbase-dev/connect@0.1.0-beta.125': resolution: {integrity: sha512-vEH4NQfu3dBQgMw0YQxG7LDtOOLxDyxET4DQhrWuHqtgr9qoch/H0QLzel9tFSqkyH+QWxuvW3Bet6ae1+qVyQ==} + '@mdbase-dev/sdk@file:packages/connect/vendor/mdbase-dev-sdk-c573c96.tgz': + resolution: {integrity: sha512-AnSmYSVUQJpXofFuN5QuZX0XHL1y/LBhkkechQhvvqiFixs+xA/xxu6rjxHOt7atx67fUH8VvvRjwelw47eQJg==, tarball: file:packages/connect/vendor/mdbase-dev-sdk-c573c96.tgz} + version: 0.0.0 + engines: {node: '>=20'} + '@mdbase-dev/ui@0.1.0-beta.125': resolution: {integrity: sha512-K2UAAzwW4yuPJxx65LofVDQXKLgs4lOgrAB61YMuiNwV/jOc1yuNhGugy7Fx++VwxBx5bZyG+Pk7tMQlOat+tg==} peerDependencies: @@ -1155,6 +1163,18 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + '@noble/ciphers@2.4.0': + resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} + engines: {node: '>= 20.19.0'} + + '@noble/curves@2.4.0': + resolution: {integrity: sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@nodable/entities@3.0.0': resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} @@ -4299,6 +4319,12 @@ snapshots: dependencies: '@mdbase-dev/connect-protocol': 0.1.0-beta.125 + '@mdbase-dev/sdk@file:packages/connect/vendor/mdbase-dev-sdk-c573c96.tgz': + dependencies: + '@noble/ciphers': 2.4.0 + '@noble/curves': 2.4.0 + '@noble/hashes': 2.4.0 + '@mdbase-dev/ui@0.1.0-beta.125(@codemirror/view@6.43.8)(react@19.2.8)': dependencies: '@fontsource/atkinson-hyperlegible': 5.3.0 @@ -4316,6 +4342,14 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@noble/ciphers@2.4.0': {} + + '@noble/curves@2.4.0': + dependencies: + '@noble/hashes': 2.4.0 + + '@noble/hashes@2.4.0': {} + '@nodable/entities@3.0.0': {} '@oxc-project/types@0.149.0': {} From 9c4fb4826594d1ccea3a8850026590c160089885 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 4 Oct 2026 20:32:51 +1100 Subject: [PATCH 2/5] connect: ReaderConnectClient and file seams over the mdbase-next SDK nextReaderClient maps read to get, query/queryPages to cursor-paged query (offsets emulated), and create/update/delete to field-level intents. Writes with ifRevision wait for confirmation so a CAS refusal reaches Reader as concurrent_modification; others return optimistically. The 15 replica error codes map onto Connect problems, including a waiting-for-device message for unavailable/no_device_online. nextReaderFiles implements stat/list/upload/download over FilesApi. Tests run against MemoryReplica. --- packages/connect/src/next/client.test.ts | 269 ++++++++++++++++ packages/connect/src/next/client.ts | 377 +++++++++++++++++++++++ packages/connect/src/next/errors.ts | 156 ++++++++++ packages/connect/src/next/files.test.ts | 94 ++++++ packages/connect/src/next/files.ts | 168 ++++++++++ packages/connect/src/next/records.ts | 102 ++++++ 6 files changed, 1166 insertions(+) create mode 100644 packages/connect/src/next/client.test.ts create mode 100644 packages/connect/src/next/client.ts create mode 100644 packages/connect/src/next/errors.ts create mode 100644 packages/connect/src/next/files.test.ts create mode 100644 packages/connect/src/next/files.ts create mode 100644 packages/connect/src/next/records.ts diff --git a/packages/connect/src/next/client.test.ts b/packages/connect/src/next/client.test.ts new file mode 100644 index 0000000..17abd38 --- /dev/null +++ b/packages/connect/src/next/client.test.ts @@ -0,0 +1,269 @@ +import { connect, mdbaseError, toPlain, type MdbaseClient } from "@mdbase-dev/sdk"; +import { MemoryReplica, type MemoryRecord } from "@mdbase-dev/sdk/testing"; +import { collectionId, sourceId } from "@mdbase-reader/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ConnectContentSearchRepository } from "../content-search-repository.js"; +import { sourceContract } from "../contracts.js"; +import { ConnectRepositoryError, outcomeValue } from "../repository-client.js"; +import { ConnectSourceRepository } from "../source-repository.js"; + +import { nextReaderClient } from "./client.js"; +import { nextErrorOf, nextProblem, waitingForDeviceMessage } from "./errors.js"; + +import type { QueryPage } from "@mdbase-dev/connect"; + +/** Just enough of the expression language for Reader's own filters. */ +function where(expression: string, record: MemoryRecord): boolean { + const equals = /^(file\.path|id) == "([^"]*)"$/u.exec(expression); + if (equals) { + const value = + equals[1] === "file.path" ? record.path : toPlain(record.frontmatter.get("id") ?? null); + return value === equals[2]; + } + const contains = /^file\.body\.lower\(\)\.contains\("([^"]*)"\)$/u.exec(expression); + if (contains) { + return record.body.toLowerCase().includes(contains[1] ?? ""); + } + throw new Error(`unsupported test expression: ${expression}`); +} + +const clients: MdbaseClient[] = []; + +interface Opened { + readonly replica: MemoryReplica; + readonly db: MdbaseClient; + readonly client: ReturnType; +} + +async function open(options: ConstructorParameters[0] = {}): Promise { + const replica = new MemoryReplica({ confirmDelayMs: null, where, ...options }); + const db = await connect({ + app: { name: "mdbase-reader-test", version: "0" }, + connector: replica.connector(), + reconnect: false, + }); + clients.push(db); + return { replica, db, client: nextReaderClient(db) }; +} + +function seedSource(replica: MemoryReplica, index: number, body = ""): MemoryRecord { + return replica.seed({ + path: `sources/source-${String(index)}.md`, + types: ["reader-source"], + frontmatter: { id: `src-${String(index)}`, title: `Source ${String(index)}` }, + body, + }); +} + +afterEach(() => { + for (const db of clients.splice(0)) { + db.close(); + } +}); + +describe("mdbase-next Reader client", () => { + it("reads a record by path as a Connect record document", async () => { + const { replica, client } = await open(); + seedSource(replica, 1, "Notes"); + + const document = outcomeValue( + await client.read({ path: "sources/source-1.md", includeDocument: true }), + "read", + ); + + expect(document).toMatchObject({ + path: "sources/source-1.md", + types: ["reader-source"], + frontmatter: { id: "src-1", title: "Source 1" }, + effectiveFrontmatter: { id: "src-1", title: "Source 1" }, + body: "Notes", + file: { path: "sources/source-1.md", name: "source-1.md", folder: "sources" }, + }); + expect(document.revision).toMatch(/^sha256:[0-9a-f]{64}$/u); + expect(document.document).toContain("Notes"); + }); + + it("pages queries by cursor and keeps Reader's page sizes", async () => { + const { replica, client } = await open(); + for (let index = 1; index <= 5; index += 1) { + seedSource(replica, index); + } + + const pages: QueryPage[] = []; + for await (const outcome of client.queryPages( + { contract: sourceContract, frontmatterMode: "effective" }, + { firstPageSize: 2, pageSize: 2 }, + )) { + pages.push(outcomeValue(outcome, "query")); + } + + expect(pages.map((page) => page.results.length)).toEqual([2, 2, 1]); + expect(pages.map((page) => page.complete)).toEqual([false, false, true]); + expect(pages.at(-1)).toMatchObject({ page: 2, offset: 4, loaded: 5 }); + expect(pages.flatMap((page) => page.results.map((row) => row.path))).toHaveLength(5); + }); + + it("emulates offset pages for Reader's library list", async () => { + const { replica, client } = await open(); + for (let index = 1; index <= 5; index += 1) { + seedSource(replica, index); + } + + const page = outcomeValue( + await client.query({ contract: sourceContract, limit: 2, offset: 2 }), + "query", + ); + + expect(page.results.map((row) => row.path)).toEqual([ + "sources/source-3.md", + "sources/source-4.md", + ]); + expect(page.meta?.hasMore).toBe(true); + }); +}); + +describe("mdbase-next Reader client writes", () => { + it("returns created and updated records optimistically, then they confirm", async () => { + const { replica, db, client } = await open(); + + const created = outcomeValue( + await client.create({ + path: "sources/new.md", + contract: sourceContract, + frontmatter: { id: "src-new", title: "New" }, + body: "Draft", + }), + "create", + ); + expect(created).toMatchObject({ + path: "sources/new.md", + types: ["reader-source"], + body: "Draft", + }); + expect((await db.get({ path: "sources/new.md" })).state.state).toBe("pending"); + + const updated = outcomeValue( + await client.update({ path: "sources/new.md", patch: { title: "Renamed" }, body: "Final" }), + "update", + ); + expect(updated).toMatchObject({ frontmatter: { title: "Renamed" }, body: "Final" }); + + replica.confirmAll(); + await vi.waitFor(async () => { + expect((await db.get({ path: "sources/new.md" })).state.state).toBe("confirmed"); + }); + }); + + it("waits for confirmation when Reader asks for CAS", async () => { + const { replica, client } = await open({ confirmDelayMs: 5 }); + seedSource(replica, 1, "Before"); + const current = outcomeValue(await client.read({ path: "sources/source-1.md" }), "read"); + + const updated = outcomeValue( + await client.update({ + path: current.path, + ifRevision: current.revision, + patch: {}, + body: "After", + }), + "update", + ); + + expect(updated.body).toBe("After"); + expect(replica.allRecords[0]?.pending).toBe(false); + }); + + it("maps a CAS refusal to Reader's concurrent-modification problem", async () => { + const { replica, client } = await open(); + seedSource(replica, 1, "Before"); + const stale = outcomeValue(await client.read({ path: "sources/source-1.md" }), "read"); + outcomeValue( + await client.update({ path: stale.path, patch: { title: "Elsewhere" } }), + "concurrent edit", + ); + + const outcome = await client.update({ + path: stale.path, + ifRevision: stale.revision, + patch: {}, + body: "Mine", + }); + + expect(outcome.ok).toBe(false); + if (outcome.ok) { + return; + } + expect(outcome.problem.code).toBe("concurrent_modification"); + expect(nextErrorOf(outcome.problem)).toMatchObject({ code: "conflict", reason: "revision" }); + expect(() => outcomeValue(outcome, "save source note")).toThrow(ConnectRepositoryError); + }); + + it("serves Reader's source repository and content search unchanged", async () => { + const { replica, client } = await open(); + seedSource(replica, 1, "An essay about Marginalia."); + seedSource(replica, 2, "Unrelated"); + const collection = collectionId(replica.collection); + + const source = await new ConnectSourceRepository(client).get(collection, sourceId("src-1")); + const matches = await new ConnectContentSearchRepository(client).search( + collection, + "marginalia", + ); + + expect(source).toMatchObject({ id: "src-1", title: "Source 1", path: "sources/source-1.md" }); + expect(matches.map((match) => match.sourceId)).toEqual(["src-1"]); + expect(matches[0]?.passages?.[0]?.text).toContain("Marginalia"); + }); + + it("deletes with progress and preflights with a dry run", async () => { + const { replica, client } = await open(); + const record = seedSource(replica, 1); + const revision = outcomeValue(await client.read({ path: record.path }), "read").revision; + + const preflight = outcomeValue( + await client.preflightDelete({ path: record.path, ifRevision: revision }), + "preflight", + ); + expect(preflight).toEqual({ + path: record.path, + deleted: false, + dryRun: true, + wouldDelete: true, + }); + expect(replica.allRecords).toHaveLength(1); + + const progress = vi.fn(); + const deleted = outcomeValue( + await client.deleteWithProgress({ path: record.path }, { onProgress: progress }), + "delete", + ); + expect(deleted).toEqual({ path: record.path, deleted: true }); + expect(progress).toHaveBeenLastCalledWith(expect.objectContaining({ state: "completed" })); + const missing = await client.read({ path: record.path }); + expect(missing.ok ? null : missing.problem.code).toBe("file_not_found"); + }); +}); + +describe("mdbase-next problems", () => { + it("shows a waiting state when none of the person's devices is online", () => { + const problem = nextProblem(mdbaseError("unavailable", "no device online", "no_device_online")); + + expect(problem.code).toBe("connector_offline"); + expect(problem.message).toBe(waitingForDeviceMessage); + }); + + it.each([ + ["invalid_request", "invalid_request"], + ["not_found", "file_not_found"], + ["unauthenticated", "authorization_expired"], + ["forbidden", "insufficient_access"], + ["rate_limited", "rate_limited"], + ["upgrade_required", "connector_upgrade_required"], + ["outcome_unknown", "operation_outcome_unknown"], + ["cancelled", "operation_cancelled"], + ["internal", "operation_failed"], + ] as const)("maps %s to %s", (code, expected) => { + expect(nextProblem(mdbaseError(code, "detail")).code).toBe(expected); + }); +}); diff --git a/packages/connect/src/next/client.ts b/packages/connect/src/next/client.ts new file mode 100644 index 0000000..1dfb3c2 --- /dev/null +++ b/packages/connect/src/next/client.ts @@ -0,0 +1,377 @@ +// mdbase-next backend: ReaderConnectClient over the new SDK's MdbaseClient. +import { connectFailure, connectSuccess } from "@mdbase-dev/connect/advanced"; +import { + isMdbaseError, + mdbaseError, + type MdbaseClient, + type Include, + type MdbaseError, + type PlainValue, + type Query, + type RecordView, + type Write, +} from "@mdbase-dev/sdk"; + +import { readerDiagnostics } from "../diagnostics.js"; +import { mapConcurrent } from "../repository-client.js"; + +import { asMdbaseError, nextOutcome, nextProblem } from "./errors.js"; +import { nextQuery, queryRecord, readInclude, recordDocument, typeForContract } from "./records.js"; + +import type { ReaderConnectClient, ReaderQueryPagesOptions } from "../repository-client.js"; +import type { + ConnectOutcome, + CreateInput, + DeleteInput, + DeletePreflightResult, + DeleteProgressOptions, + DeleteResult, + JsonObject, + QueryInput, + QueryMetadataInput, + QueryMetadataPage, + QueryMetadataRecord, + QueryPage, + QueryRecord, + QueryResult, + ReadManyEntry, + ReadManyOptions, + ReadManyResult, + RecordDocument, + UpdateInput, +} from "@mdbase-dev/connect"; +import type { ReaderRequestOptions } from "@mdbase-reader/core"; + +export interface NextReaderClientOptions { + /** + * Writes that carry `ifRevision` wait for confirmation (default), so a CAS refusal + * at head still reaches Reader as a `conflict`. Other writes return optimistically. + */ + readonly confirmCasWrites?: boolean; + /** An optimistic write was rejected after it returned. */ + readonly onRejected?: (error: MdbaseError, path: string) => void; +} + +/** Reader features this backend answers; read-many returns revision-qualified records. */ +const supportedFeatures = new Set(["read-many-documents-v1"]); + +const route = (): string => "relay"; + +function signalOf(options?: ReaderRequestOptions): AbortSignal | undefined { + return options?.signal; +} + +/** + * Keeps Reader's repositories independent of SDK types. Reads map to `get`/`query`, + * writes to field-level `create`/`update`/`delete` with opt-in CAS. + */ +export function nextReaderClient( + db: MdbaseClient, + options: NextReaderClientOptions = {}, +): ReaderConnectClient { + const writes = new NextWrites(db, options); + function queryPages( + input: QueryMetadataInput, + paging?: ReaderQueryPagesOptions, + ): AsyncIterable>; + function queryPages( + input: QueryInput, + paging?: ReaderQueryPagesOptions, + ): AsyncIterable>; + function queryPages( + input: QueryInput | QueryMetadataInput, + paging?: ReaderQueryPagesOptions, + ): AsyncIterable> { + return readerDiagnostics.pages(route, nextPages(db, input, paging)); + } + return { + supportsAuthorityFeature: (id) => Promise.resolve(connectSuccess(supportedFeatures.has(id))), + read: (input, request) => + readerDiagnostics.measure("read", route, () => + nextOutcome(async () => + recordDocument( + await db.get( + { path: input.path }, + readInclude({ includeBody: true, includeDocument: input.includeDocument ?? false }), + signalOf(request), + ), + ), + ), + ), + readMany: (paths, request) => + readerDiagnostics.measure("read-many", route, () => readMany(db, paths, request)), + query: (input, request) => + readerDiagnostics.measure("query", route, () => + nextOutcome(() => queryOnce(db, input, signalOf(request))), + ), + queryPages, + create: (input) => + readerDiagnostics.measure("create", route, () => nextOutcome(() => writes.create(input))), + update: (input) => + readerDiagnostics.measure("update", route, () => nextOutcome(() => writes.update(input))), + preflightDelete: (input) => + readerDiagnostics.measure("delete-preflight", route, () => + nextOutcome(() => writes.preflightDelete(input)), + ), + deleteWithProgress: (input, progress) => + readerDiagnostics.measure("delete", route, () => + nextOutcome(() => writes.delete(input, progress)), + ), + }; +} + +type BatchFailure = ReadManyResult["errors"][number]["failure"]; + +async function readMany( + db: MdbaseClient, + paths: readonly string[], + options: ReadManyOptions = {}, +): Promise> { + const include = readInclude(options); + const errors: ReadManyResult["errors"] = []; + const results = await mapConcurrent( + paths, + Math.min(Math.max(options.concurrency ?? 4, 1), 4), + async (path, batch): Promise => { + try { + const view = await db.find({ path }, include, options.signal); + const wanted = !options.types || view?.types.some((type) => options.types?.includes(type)); + return view && wanted + ? { status: "found", path, record: queryRecord(view) } + : { status: "missing", path }; + } catch (error) { + const problem = nextProblem(asMdbaseError(error)); + if (problem.code === "operation_cancelled") { + throw error; + } + errors.push({ + batch, + paths: [path], + failure: connectFailure(problem as BatchFailure["problem"]), + }); + return { status: "error", path, batch }; + } + }, + ); + return connectSuccess({ results, errors }); +} + +/** One query page. Offsets are emulated by reading past them: the replica pages by cursor. */ +async function queryOnce( + db: MdbaseClient, + input: QueryInput, + signal?: AbortSignal, +): Promise { + const offset = input.offset ?? 0; + const query: Query = { + ...nextQuery(input), + ...(input.limit === undefined ? {} : { limit: offset + input.limit }), + ...(input.cursor ? { cursor: input.cursor } : {}), + }; + const page = await db.query(query, readInclude(input), signal); + const results = page.records.slice(offset).map(queryRecord); + const hasMore = page.cursor !== undefined; + return { + results, + meta: { + hasMore, + ...(page.cursor ? { cursor: page.cursor } : {}), + ...(hasMore ? {} : { totalCount: offset + results.length }), + }, + }; +} + +function metadataRecord(row: QueryRecord, select: QueryInput["select"]): QueryMetadataRecord { + const fields = row.effectiveFrontmatter ?? row.frontmatter ?? {}; + const values: JsonObject = {}; + for (const entry of select ?? []) { + const name = typeof entry === "string" ? entry : entry.name; + const value = fields[name]; + if (!name.startsWith("projection.") && value !== undefined) { + values[name] = value; + } + } + return { path: row.path, types: row.types, revision: row.revision ?? "", values }; +} + +async function* nextPages( + db: MdbaseClient, + input: QueryInput | QueryMetadataInput, + options: ReaderQueryPagesOptions = {}, +): AsyncGenerator> { + const query = nextQuery(input); + const include = input.output === "metadata" ? {} : readInclude(input); + const firstSize = options.firstPageSize ?? options.pageSize ?? 200; + let cursor: string | undefined; + let loaded = 0; + for (let page = 0; ; page += 1) { + let result; + try { + result = await db.query( + { + ...query, + limit: page === 0 ? firstSize : (options.pageSize ?? firstSize), + ...(cursor ? { cursor } : {}), + }, + include, + options.signal, + ); + } catch (error) { + yield connectFailure(nextProblem(asMdbaseError(error))); + return; + } + const rows = result.records.map(queryRecord); + const offset = loaded; + loaded += rows.length; + cursor = result.cursor; + const common = { + page, + offset, + loaded, + complete: cursor === undefined, + meta: { hasMore: cursor !== undefined, ...(cursor ? { cursor } : {}) }, + ...(cursor ? { cursor } : {}), + }; + yield input.output === "metadata" + ? connectSuccess({ + ...common, + output: "metadata" as const, + results: rows.map((row) => metadataRecord(row, input.select)), + }) + : connectSuccess({ ...common, results: rows }); + if (!cursor) { + return; + } + } +} + +/** Field-level writes. The SDK fills in base values and body edits from the view it was given. */ +class NextWrites { + constructor( + private readonly db: MdbaseClient, + private readonly options: NextReaderClientOptions, + ) {} + + async create(input: CreateInput): Promise { + const type = input.type ?? typeForContract(input.contract); + const write = await this.db.create( + { + ...(type ? { type } : {}), + ...(input.path ? { path: input.path } : {}), + frontmatter: (input.frontmatter ?? {}) as Record, + ...(input.body === undefined ? {} : { body: input.body }), + }, + { + include: readInclude({ + includeBody: true, + includeDocument: input.includeDocument ?? false, + }), + }, + ); + return recordDocument(await this.settle(write, input.path ?? "", false)); + } + + async update(input: UpdateInput): Promise { + const replacing = input.document !== undefined; + const current = await this.db.get( + { path: input.path }, + { body: true, ...(replacing ? { document: true } : {}) }, + ); + const include = readInclude({ + includeBody: true, + includeDocument: replacing || (input.includeDocument ?? false), + }); + const cas = input.ifRevision ? { ifRevision: input.ifRevision } : {}; + const write = + input.document !== undefined + ? await this.db.replaceDocument(current, input.document, { ...cas, include }) + : await this.db.update( + current, + { + patch: input.patch as Record, + ...(input.body === undefined ? {} : { body: input.body }), + ...cas, + }, + { include }, + ); + return recordDocument(await this.settle(write, input.path, Boolean(input.ifRevision), include)); + } + + /** A dry-run delete: the replica plans it without capturing anything. */ + async preflightDelete(input: DeleteInput): Promise { + const current = await this.db.get({ path: input.path }); + const write = await this.db.delete(current, { + dryRun: true, + ...(input.ifRevision ? { ifRevision: input.ifRevision } : {}), + }); + await rejectedNow(write); + // Backlink discovery has no equivalent in the client API yet: no broken links are reported. + return { path: input.path, deleted: false, dryRun: true, wouldDelete: true } as const; + } + + async delete(input: DeleteInput, progress: DeleteProgressOptions = {}): Promise { + const started = Date.now(); + const report = (state: "applying" | "completed", completedUnits: number): void => + progress.onProgress?.({ + operation: "delete", + state, + elapsedMs: Date.now() - started, + cancellable: false, + resumed: false, + completedUnits, + }); + report("applying", 0); + const current = await this.db.get({ path: input.path }, undefined, progress.signal); + const write = await this.db.delete(current, { + ...(input.ifRevision ? { ifRevision: input.ifRevision } : {}), + }); + await rejectedNow(write); + if (input.ifRevision && this.options.confirmCasWrites !== false) { + await write.confirmed; + } else { + this.watch(write, input.path); + } + report("completed", 1); + return { path: input.path, deleted: true }; + } + + /** The record a write produced: confirmed for CAS writes, otherwise optimistic. */ + private async settle( + write: Write, + path: string, + cas: boolean, + include?: Include, + ): Promise { + await rejectedNow(write); + if (cas && this.options.confirmCasWrites !== false) { + await write.confirmed; + // Confirmed receipts carry no bodies: read the confirmed state back. + return this.db.get(firstRecord(write.records).id, include); + } + this.watch(write, path); + return firstRecord(write.records); + } + + private watch(write: Write, path: string): void { + write.confirmed.catch((error: unknown) => { + if (isMdbaseError(error)) { + this.options.onRejected?.(error, path); + } + }); + } +} + +/** A receipt rejected at submit throws its problem now. */ +async function rejectedNow(write: Write): Promise { + if (write.state === "rejected" || write.state === "unknown") { + await write.confirmed; + } +} + +function firstRecord(records: readonly RecordView[]): RecordView { + const [record] = records; + if (!record) { + throw mdbaseError("internal", "The replica returned no record for this write."); + } + return record; +} diff --git a/packages/connect/src/next/errors.ts b/packages/connect/src/next/errors.ts new file mode 100644 index 0000000..04ff7db --- /dev/null +++ b/packages/connect/src/next/errors.ts @@ -0,0 +1,156 @@ +// mdbase-next backend: the 15 replica error codes as Reader's Connect problems. +import { connectFailure, connectProblem, connectSuccess } from "@mdbase-dev/connect/advanced"; +import { isMdbaseError, mdbaseError, type ErrorCode, type MdbaseError } from "@mdbase-dev/sdk"; + +import { ConnectRepositoryError } from "../repository-client.js"; + +import type { ConnectFailure, ConnectOutcome } from "@mdbase-dev/connect"; + +type ConnectProblem = ConnectFailure["problem"]; + +/** Shown while a private collection has none of the person's devices online. */ +export const waitingForDeviceMessage = + "Waiting for one of your devices to come online. Reader opens the collection as soon as one does."; + +/** What a person reads for each code; apps show their own text keyed on code and reason. */ +const messages: Readonly> = { + invalid_request: "mdbase did not accept Reader’s request.", + invalid_record: "mdbase did not accept this record because it is not valid.", + not_found: "This record or file no longer exists.", + conflict: "The record changed before Reader saved it. Reload it and try again.", + unauthenticated: "Reader’s access to this collection has expired. Reconnect to keep reading.", + forbidden: "Reader’s access to this collection does not allow this.", + collection_invalid: "This collection’s configuration needs repair before Reader can write to it.", + unavailable: "mdbase is not reachable right now. Reader will retry.", + rate_limited: "mdbase asked Reader to slow down. Try again in a moment.", + quota_exceeded: "This collection is out of storage space.", + too_large: "This is larger than mdbase accepts.", + upgrade_required: "This collection needs a newer version of Reader.", + outcome_unknown: "Reader could not tell whether the last change was saved. Reload to check.", + cancelled: "The request was cancelled.", + internal: "mdbase hit an unexpected problem. Try again once.", +}; + +// The originating replica error for each mapped problem, for callers that branch on it. +const origins = new WeakMap(); + +/** True when `error` means "no device of this private collection is online". */ +export function isWaitingForDevice(error: unknown): boolean { + return isMdbaseError(error, "unavailable") && error.reason === "no_device_online"; +} + +/** Reader's text for a replica error. */ +export function nextProblemMessage(error: MdbaseError): string { + if (isWaitingForDevice(error)) { + return waitingForDeviceMessage; + } + if (error.code === "conflict" && error.reason === "path_taken") { + return "Another record already uses this path."; + } + return messages[error.code]; +} + +/** + * Maps a replica error onto the closest Connect problem so the existing + * `ConnectRepositoryError` and `connectProblemMessage` UI keep working. + */ +export function nextProblem(error: MdbaseError): ConnectProblem { + const message = nextProblemMessage(error); + const problem = mappedProblem(error, message); + origins.set(problem, error); + return problem; +} + +/** The replica error a problem was mapped from, if it came from the mdbase-next backend. */ +export function nextErrorOf(problem: ConnectProblem): MdbaseError | undefined { + return origins.get(problem); +} + +// One branch per code is the clearest form of the table. +// eslint-disable-next-line complexity +function mappedProblem(error: MdbaseError, message: string): ConnectProblem { + switch (error.code) { + case "invalid_request": + case "too_large": + return connectProblem("invalid_request", message); + case "invalid_record": + return connectProblem("operation_invalid", message, { + details: { diagnostics: (error.issues ?? []).map((issue) => ({ ...issue })) }, + }); + case "not_found": + return connectProblem("file_not_found", message); + case "conflict": + return error.reason === "path_taken" + ? connectProblem("path_occupied", message) + : connectProblem("concurrent_modification", message); + case "unauthenticated": + return connectProblem("authorization_expired", message); + case "forbidden": + return connectProblem("insufficient_access", message, { + details: { required_operations: [], granted_operations: [], missing_operations: [] }, + }); + case "collection_invalid": + return connectProblem("collection_invalid", message, { + details: { diagnostics: (error.issues ?? []).map((issue) => ({ ...issue })) }, + }); + case "unavailable": + return isWaitingForDevice(error) + ? connectProblem("connector_offline", message, { details: {} }) + : connectProblem("temporarily_unavailable", message); + case "rate_limited": + return connectProblem("rate_limited", message, { + ...(error.retryAfterMs === undefined + ? {} + : { details: { retry_after_ms: error.retryAfterMs } }), + }); + case "upgrade_required": + return connectProblem("connector_upgrade_required", message); + case "outcome_unknown": + return connectProblem("operation_outcome_unknown", message, { + details: { request_id: error.traceId ?? "" }, + }); + case "cancelled": + return connectProblem("operation_cancelled", message); + case "quota_exceeded": + case "internal": + return connectProblem("operation_failed", message); + } +} + +/** Any thrown value as a replica error (DOM aborts become `cancelled`). */ +export function asMdbaseError(error: unknown): MdbaseError { + if (isMdbaseError(error)) { + return error; + } + if (error instanceof DOMException && error.name === "AbortError") { + return mdbaseError("cancelled", error.message); + } + throw error; +} + +/** Runs an SDK call as a Connect outcome; replica errors become failures, bugs still throw. */ +export async function nextOutcome( + operation: () => Promise, +): Promise> { + try { + return connectSuccess(await operation()); + } catch (error) { + return connectFailure(nextProblem(asMdbaseError(error))); + } +} + +/** A Connect failure for something the mdbase-next client API has no equivalent for yet. */ +export function unsupportedOnNext(feature: string): ConnectFailure { + return connectFailure( + connectProblem( + "unsupported_operation", + `${feature} is not available on the mdbase-next backend yet.`, + ), + ); +} + +/** A replica error as the error Reader's repositories throw (bugs still throw as they are). */ +export function nextRepositoryError(operation: string, error: unknown): ConnectRepositoryError { + const problem = nextProblem(asMdbaseError(error)); + return new ConnectRepositoryError(operation, problem.code, problem.message, problem); +} diff --git a/packages/connect/src/next/files.test.ts b/packages/connect/src/next/files.test.ts new file mode 100644 index 0000000..8610172 --- /dev/null +++ b/packages/connect/src/next/files.test.ts @@ -0,0 +1,94 @@ +import { connect, type MdbaseClient } from "@mdbase-dev/sdk"; +import { MemoryReplica } from "@mdbase-dev/sdk/testing"; +import { collectionId, fileRevision } from "@mdbase-reader/core"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ConnectCollectionFileRepository } from "../collection-files.js"; +import { outcomeValue } from "../repository-client.js"; + +import { nextReaderFiles, transferUuid } from "./files.js"; + +import type { CollectionFileDescriptor } from "@mdbase-dev/connect"; + +const clients: MdbaseClient[] = []; + +async function open(): Promise<{ + readonly replica: MemoryReplica; + readonly files: ReturnType; +}> { + const replica = new MemoryReplica({ confirmDelayMs: null }); + const db = await connect({ + app: { name: "mdbase-reader-test", version: "0" }, + connector: replica.connector(), + reconnect: false, + }); + clients.push(db); + return { replica, files: nextReaderFiles(db) }; +} + +afterEach(() => { + for (const db of clients.splice(0)) { + db.close(); + } +}); + +const pdf = new TextEncoder().encode("%PDF-1.7 test document"); + +describe("mdbase-next Reader files", () => { + it("uploads, lists, stats and downloads a file", async () => { + const { files } = await open(); + + const uploaded = await files.upload("documents/paper.pdf", new Blob([pdf]), { + mediaType: "application/pdf", + transferId: "reader-import:paper", + }); + expect(uploaded).toMatchObject({ + path: "documents/paper.pdf", + size: pdf.length, + mediaType: "application/pdf", + mediaClass: "pdf", + }); + expect(uploaded.contentDigest).toMatch(/^sha256:[0-9a-f]{64}$/u); + + const listed: CollectionFileDescriptor[] = []; + for await (const file of files.list?.({ folder: "documents" }) ?? []) { + listed.push(file); + } + expect(listed.map((file) => file.path)).toEqual(["documents/paper.pdf"]); + + const byId = outcomeValue(await files.stat({ fileId: uploaded.fileId }), "stat"); + const byPath = outcomeValue(await files.stat({ path: "documents/paper.pdf" }), "stat"); + const missing = outcomeValue(await files.stat({ path: "documents/none.pdf" }), "stat"); + expect(byId?.fileId).toBe(uploaded.fileId); + expect(byPath?.fileId).toBe(uploaded.fileId); + expect(missing).toBeNull(); + + const blob = await files.download(uploaded); + expect(blob.type).toBe("application/pdf"); + expect(new Uint8Array(await blob.arrayBuffer())).toEqual(pdf); + }); + + it("feeds Reader's collection file repository", async () => { + const { replica, files } = await open(); + const uploaded = await files.upload("documents/paper.pdf", new Blob([pdf])); + + const exported = await new ConnectCollectionFileRepository(files).read( + collectionId(replica.collection), + "[[documents/paper.pdf]]", + fileRevision(uploaded.contentDigest), + ); + + expect(exported.mediaType).toBe("application/pdf"); + expect(exported.bytes).toEqual(pdf); + }); + + it("derives stable transfer UUIDs from Reader's retry keys", async () => { + const first = await transferUuid("reader-import:paper"); + + expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-8[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u); + expect(await transferUuid("reader-import:paper")).toBe(first); + expect(await transferUuid("0190A0B0-0000-7000-8000-000000000000")).toBe( + "0190a0b0-0000-7000-8000-000000000000", + ); + }); +}); diff --git a/packages/connect/src/next/files.ts b/packages/connect/src/next/files.ts new file mode 100644 index 0000000..0d396ab --- /dev/null +++ b/packages/connect/src/next/files.ts @@ -0,0 +1,168 @@ +// mdbase-next backend: Reader's file seams over the new SDK's FilesApi. +import { connectSuccess } from "@mdbase-dev/connect/advanced"; +import { isMdbaseError, uuidv7, type FileView, type MdbaseClient } from "@mdbase-dev/sdk"; + +import { asMdbaseError, nextOutcome, nextRepositoryError } from "./errors.js"; + +import type { ReaderAssetClient } from "../annotation-assets.js"; +import type { ReaderFileClient } from "../documents.js"; +import type { ReaderSourceFileClient } from "../source-files.js"; +import type { + CollectionFileDescriptor, + ConnectOutcome, + ConnectRequestOptions, + MdbaseFileListOptions, + MdbaseFileUploadOptions, +} from "@mdbase-dev/connect"; + +type StatTarget = Parameters[0]; + +const mediaTypes: Readonly> = { + pdf: "application/pdf", + epub: "application/epub+zip", + html: "text/html", + htm: "text/html", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", +}; + +function mediaTypeOf(path: string): string | undefined { + return mediaTypes[path.split(".").at(-1)?.toLowerCase() ?? ""]; +} + +/** + * A replica file view as a Connect file descriptor. The replica's revision is the + * content digest; file views carry no modification time. + */ +export function fileDescriptor(file: FileView): CollectionFileDescriptor { + const mediaType = mediaTypeOf(file.path); + return { + fileId: file.id, + path: file.path, + revision: file.digest, + contentDigest: file.digest as CollectionFileDescriptor["contentDigest"], + size: file.size, + ...(mediaType ? { mediaType } : {}), + mediaClass: file.media, + modifiedAt: "", + }; +} + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; + +/** + * Transfers are identified by UUID. Reader's stable retry keys are kept stable by + * deriving a UUID (version 8) from their SHA-256, so a retry resumes the same transfer. + */ +export async function transferUuid(key: string): Promise { + if (uuidPattern.test(key)) { + return key.toLowerCase(); + } + const digest = new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key)), + ).slice(0, 16); + digest[6] = ((digest[6] ?? 0) & 0x0f) | 0x80; + digest[8] = ((digest[8] ?? 0) & 0x3f) | 0x80; + const hex = [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +export type NextReaderFiles = ReaderFileClient & ReaderSourceFileClient & ReaderAssetClient; + +/** Reader's stat, list, upload and download over replica files. */ +export function nextReaderFiles(db: MdbaseClient): NextReaderFiles { + return { + stat: (target: StatTarget, options?: ConnectRequestOptions) => stat(db, target, options), + list: (options?: MdbaseFileListOptions) => list(db, options), + upload: (path: string, source: Blob, options?: MdbaseFileUploadOptions) => + upload(db, path, source, options).catch((error: unknown) => { + throw nextRepositoryError("upload a file", error); + }), + download: async (file: CollectionFileDescriptor, options?: { signal?: AbortSignal }) => { + const bytes = await db.files + .download(file.fileId, { ...(options?.signal ? { signal: options.signal } : {}) }) + .catch((error: unknown) => { + throw nextRepositoryError("download a file", error); + }); + return new Blob([bytes.slice().buffer], { + type: file.mediaType ?? mediaTypeOf(file.path) ?? "application/octet-stream", + }); + }, + }; +} + +async function stat( + db: MdbaseClient, + target: StatTarget, + options?: ConnectRequestOptions, +): Promise> { + try { + const file = await db.files.get( + target.path === undefined ? target.fileId : { path: target.path }, + options?.signal, + ); + return connectSuccess(fileDescriptor(file)); + } catch (error) { + if (isMdbaseError(error, "not_found") || isMdbaseError(error, "invalid_request")) { + // An unknown or malformed file ID is simply not a file here. + return connectSuccess(null); + } + return nextOutcome(() => Promise.reject(asMdbaseError(error))); + } +} + +async function* list( + db: MdbaseClient, + options: MdbaseFileListOptions = {}, +): AsyncGenerator { + for await (const file of db.files.list({ + ...(options.folder ? { folder: options.folder } : {}), + ...(options.pageSize ? { pageSize: options.pageSize } : {}), + ...(options.signal ? { signal: options.signal } : {}), + })) { + yield fileDescriptor(file); + } +} + +/** + * Uploads and returns the file as it now reads locally (pending until confirmed). + * A replace (`ifRevision`) targets the file currently at `path`. + */ +async function upload( + db: MdbaseClient, + path: string, + source: Blob, + options: MdbaseFileUploadOptions = {}, +): Promise { + const key = options.transferId ?? uuidv7(); + const transferId = await transferUuid(key); + const existing = options.ifRevision + ? await db.files.get({ path }, options.signal).catch(() => null) + : null; + const write = await db.files.upload(path, source, { + transferId, + // The same retry key resubmits the same mutation, so a retried commit is idempotent. + mutationId: await transferUuid(`${key}:commit`), + ...(existing ? { fileId: existing.id } : {}), + ...(options.ifRevision ? { ifRevision: options.ifRevision } : {}), + ...(options.signal ? { signal: options.signal } : {}), + ...(options.onProgress + ? { + onProgress: (progress) => + options.onProgress?.({ + phase: "uploading", + transferredBytes: progress.done, + totalBytes: progress.total, + }), + } + : {}), + }); + if (write.state === "rejected" || write.state === "unknown") { + await write.confirmed; + } + return fileDescriptor(await db.files.get({ path }, options.signal)); +} diff --git a/packages/connect/src/next/records.ts b/packages/connect/src/next/records.ts new file mode 100644 index 0000000..2f03b22 --- /dev/null +++ b/packages/connect/src/next/records.ts @@ -0,0 +1,102 @@ +// mdbase-next backend: record views and queries in the shapes Reader's repositories read. +import { toPlain, type Include, type Query, type RecordView } from "@mdbase-dev/sdk"; + +import { annotationContract, sourceContract } from "../contracts.js"; + +import type { + DataContractSelector, + JsonObject, + QueryInput, + QueryMetadataInput, + QueryRecord, + RecordDocument, +} from "@mdbase-dev/connect"; + +/** + * The new client API has no semantic contract views. Reader owns its types, so a + * contract selector reads the type that implements it in Reader's starter pack. + */ +const contractTypes: Readonly> = { + [sourceContract.id]: "reader-source", + [annotationContract.id]: "reader-annotation", +}; + +export function typeForContract(contract: DataContractSelector | undefined): string | undefined { + return contract ? (contract.type ?? contractTypes[contract.id]) : undefined; +} + +function plainObject(map: RecordView["frontmatter"] | undefined): JsonObject | undefined { + return map ? (toPlain(map) as JsonObject) : undefined; +} + +function fileFields(view: RecordView): RecordDocument["file"] { + const slash = view.path.lastIndexOf("/"); + const name = view.path.slice(slash + 1); + return { path: view.path, name, folder: slash < 0 ? "" : view.path.slice(0, slash) }; +} + +/** A replica record view as a Connect record document. */ +export function recordDocument(view: RecordView): RecordDocument { + const frontmatter = plainObject(view.frontmatter) ?? {}; + return { + path: view.path, + revision: view.revision, + types: [...view.types], + frontmatter, + effectiveFrontmatter: plainObject(view.effective) ?? frontmatter, + ...(view.body === undefined ? {} : { body: view.body }), + ...(view.document === undefined ? {} : { document: view.document }), + file: fileFields(view), + }; +} + +/** A replica record view as a Connect query row (always revision-qualified). */ +export function queryRecord(view: RecordView): QueryRecord { + const frontmatter = plainObject(view.frontmatter) ?? {}; + return { + path: view.path, + revision: view.revision, + types: [...view.types], + frontmatter, + effectiveFrontmatter: plainObject(view.effective) ?? frontmatter, + ...(view.body === undefined ? {} : { body: view.body }), + file: fileFields(view), + }; +} + +/** What to return with each record. Bodies only when the caller asked for them. */ +export function readInclude(options: { + readonly includeBody?: boolean; + readonly includeDocument?: boolean; + readonly frontmatterMode?: QueryInput["frontmatterMode"]; +}): Include { + return { + ...(options.includeBody ? { body: true } : {}), + ...(options.includeDocument ? { body: true, document: true } : {}), + ...(options.frontmatterMode === "persisted" ? {} : { effective: true }), + }; +} + +/** + * Connect query input as a spec 11 query. Projections, grouping and summaries have + * no equivalent in the replica's record query, so they are dropped here: callers + * that read projected `values` fall back to frontmatter. + */ +export function nextQuery(input: QueryInput | QueryMetadataInput): Query { + const type = typeForContract(input.contract); + const types = input.types ?? (type ? [type] : undefined); + return { + ...(types ? { types } : {}), + ...(input.where ? { where: input.where } : {}), + ...(input.orderBy?.length + ? { + order_by: input.orderBy.map(({ field, direction }) => ({ + field, + direction: direction ?? "asc", + })), + } + : {}), + ...(input.timezone ? { timezone: input.timezone } : {}), + ...(input.context ? { context: { this: { path: input.context.this.path } } } : {}), + }; +} From e5a3f930df1a56f7ef3472538970f4461706adec Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 4 Oct 2026 20:41:43 +1100 Subject: [PATCH 3/5] connect: mdbase-next session, collection and control-plane seam ReaderNextApplicationSession produces the same snapshots as the Connect session over a relay connection, keyed by loadOrCreateClientKey (a non-extractable WebCrypto key in IndexedDB). A private collection with no device online shows as blocked with a waiting-for-device message. The control plane (grants registered at consent, routes) sits behind ReaderNextControlPlane; ProposedHttpControlPlane targets the proposed GET /v1/next/collections/:id/route endpoint. nextReaderCollection assembles Reader's existing repositories on the new seams. Migration, interrupted-write recovery, direct access and collection setup are stubbed; saved views read listViews/executeView defensively. ReaderSession and ReaderPortableSession describe what the apps need, and the backend is exported as @mdbase-reader/connect/next. Core may not import the new SDK. --- eslint.config.js | 2 + packages/connect/package.json | 3 +- packages/connect/src/application-session.ts | 18 +- packages/connect/src/next/backend.ts | 15 + packages/connect/src/next/collection.ts | 167 +++++++++ packages/connect/src/next/control-plane.ts | 161 +++++++++ packages/connect/src/next/index.ts | 20 ++ packages/connect/src/next/session.test.ts | 128 +++++++ packages/connect/src/next/session.ts | 337 ++++++++++++++++++ .../src/portable-application-session.ts | 13 +- 10 files changed, 860 insertions(+), 4 deletions(-) create mode 100644 packages/connect/src/next/backend.ts create mode 100644 packages/connect/src/next/collection.ts create mode 100644 packages/connect/src/next/control-plane.ts create mode 100644 packages/connect/src/next/index.ts create mode 100644 packages/connect/src/next/session.test.ts create mode 100644 packages/connect/src/next/session.ts diff --git a/eslint.config.js b/eslint.config.js index eb9ae9d..319931c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -110,6 +110,8 @@ export default tseslint.config( "electron", "@capacitor/*", "@mdbase-dev/connect", + "@mdbase-dev/sdk", + "@mdbase-dev/sdk/*", "@embedpdf/*", "@readium/*", "@codemirror/*", diff --git a/packages/connect/package.json b/packages/connect/package.json index 9eb810e..3a275cd 100644 --- a/packages/connect/package.json +++ b/packages/connect/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./next": "./src/next/index.ts" }, "scripts": { "test": "vitest run --passWithNoTests", diff --git a/packages/connect/src/application-session.ts b/packages/connect/src/application-session.ts index afe2b41..81bac3b 100644 --- a/packages/connect/src/application-session.ts +++ b/packages/connect/src/application-session.ts @@ -39,6 +39,8 @@ import { } from "./repositories.js"; import { connectSourceImportRepository } from "./source-imports.js"; +import type { MigrationTarget } from "@mdbase-reader/migration"; + export type ReaderConnectSnapshot = MdbaseApplicationSessionSnapshot; export interface ReaderDirectAccessSnapshot { @@ -60,7 +62,7 @@ export interface ReaderConnectedCollection { readonly collectionName: string; readonly sources: SourceRepository; readonly sourceImports: SourceImportRepository; - readonly migration: ConnectMigrationTarget; + readonly migration: MigrationTarget; readonly annotations: AnnotationRepository; readonly annotationAssets: AnnotationAssetRepository; readonly documents: DocumentRepository; @@ -80,7 +82,19 @@ export interface ReaderApplicationSessionOptions { readonly timeouts?: MdbaseConnectTimeouts; } -export class ReaderApplicationSession { +/** What Reader's web app needs from a session, whichever SDK backs it. */ +export interface ReaderSession { + start(): Promise>; + destroy(): void; + getSnapshot(): ReaderConnectSnapshot; + subscribe(listener: () => void): () => void; + select(selectedCollectionId: string): ConnectOutcome; + authorize(target: "choose" | "selected", popup?: boolean): Promise>; + applyCollectionSetup(): Promise>; + connectedCollection(expectedCollectionId?: string): ReaderConnectedCollection | null; +} + +export class ReaderApplicationSession implements ReaderSession { readonly #session; public constructor(options: ReaderApplicationSessionOptions) { diff --git a/packages/connect/src/next/backend.ts b/packages/connect/src/next/backend.ts new file mode 100644 index 0000000..a652f4e --- /dev/null +++ b/packages/connect/src/next/backend.ts @@ -0,0 +1,15 @@ +// Which SDK backs Reader's session. The current Connect SDK stays the default until cutover. + +export type ReaderSdkBackend = "connect" | "next"; + +/** + * Picks the backend the way Reader picks its server: a `?sdk=next` URL flag first, + * then `VITE_MDBASE_SDK=next` at build time. Anything else is the Connect SDK. + */ +export function readerSdkBackend( + urlFlag: string | null | undefined, + buildSetting?: string, +): ReaderSdkBackend { + const choice = (urlFlag ?? buildSetting ?? "").trim().toLowerCase(); + return choice === "next" ? "next" : "connect"; +} diff --git a/packages/connect/src/next/collection.ts b/packages/connect/src/next/collection.ts new file mode 100644 index 0000000..a50c802 --- /dev/null +++ b/packages/connect/src/next/collection.ts @@ -0,0 +1,167 @@ +// mdbase-next backend: one opened collection, assembled from Reader's existing repositories. +import { connectFailure, connectProblem } from "@mdbase-dev/connect/advanced"; +import { toPlain, type MdbaseClient, type PlainValue } from "@mdbase-dev/sdk"; +import { collectionId } from "@mdbase-reader/core"; + +import { ConnectAnnotationAssetRepository } from "../annotation-assets.js"; +import { ConnectCollectionFileRepository } from "../collection-files.js"; +import { ConnectDocumentRepository } from "../documents.js"; +import { + ConnectAnnotationRepository, + ConnectContentSearchRepository, + ConnectRepositoryError, + ConnectSourceRepository, + outcomeValue, +} from "../repositories.js"; +import { ConnectSourceImportRepository } from "../source-imports.js"; + +import { nextReaderClient, type NextReaderClientOptions } from "./client.js"; +import { nextRepositoryError } from "./errors.js"; +import { nextReaderFiles } from "./files.js"; + +import type { + ReaderConnectedCollection, + ReaderDirectAccessController, +} from "../application-session.js"; +import type { LibraryViewRepository } from "../library-views.js"; +import type { ReaderConnectClient } from "../repository-client.js"; +import type { ReaderNextGrant } from "./control-plane.js"; +import type { + ConnectOutcome, + DirectAccessProblemCode, + DirectAccessStatus, + JsonObject, + SavedViewExecution, + SavedViewList, +} from "@mdbase-dev/connect"; +import type { BodyUpdateRecovery } from "@mdbase-reader/core"; +import type { MigrationTarget } from "@mdbase-reader/migration"; + +/** + * Reader's repositories over an mdbase-next client. Source, annotation, search, + * import and file repositories are the existing ones on the new seams; the rest + * are stubs until the new client API has an equivalent. + */ +export function nextReaderCollection( + db: MdbaseClient, + grant: Pick, + options: NextReaderClientOptions = {}, +): ReaderConnectedCollection { + const client = nextReaderClient(db, options); + const files = nextReaderFiles(db); + return { + collectionId: collectionId(grant.collectionId), + collectionName: grant.displayName, + sources: new ConnectSourceRepository(client), + sourceImports: new ConnectSourceImportRepository(client, files), + migration: unsupportedMigration(grant.collectionId), + annotations: new ConnectAnnotationRepository(client), + annotationAssets: new ConnectAnnotationAssetRepository(files), + documents: new ConnectDocumentRepository(files), + contentSearch: new ConnectContentSearchRepository(client), + files: new ConnectCollectionFileRepository(files), + libraryViews: nextLibraryViews(db, client), + directAccess: noDirectAccess, + bodyRecovery: noBodyRecovery, + }; +} + +function unsupported(feature: string): Error { + return new ConnectRepositoryError( + feature, + "unsupported_operation", + `${feature} is not available on the mdbase-next backend yet`, + ); +} + +/** + * STUB: the importer relies on the old SDK's durable pending-mutation journal. + * mdbase-next writes are idempotent by mutation ID, so this needs a redesign, not a port. + */ +function unsupportedMigration(id: string): MigrationTarget { + const refuse = (): Promise => Promise.reject(unsupported("Importing a Reader library")); + return { collectionId: id, existing: refuse, files: refuse, upload: refuse, create: refuse }; +} + +/** + * STUB: receipts replace interrupted-write recovery. Writes are resubmitted by mutation ID + * inside the SDK, so nothing is ever left for Reader to recover. + */ +const noBodyRecovery: BodyUpdateRecovery = { + recoverSource: () => Promise.reject(unsupported("Recovering an interrupted write")), + recoverAnnotation: () => Promise.reject(unsupported("Recovering an interrupted write")), + pending: () => false, +}; + +/** STUB: there is no loopback direct-access route; sessions go through the relay. */ +const noDirectAccess: ReaderDirectAccessController = { + getSnapshot: () => null, + subscribe: () => () => undefined, + disable: () => undefined, + check: () => Promise.resolve(directAccessUnavailable()), + request: () => Promise.resolve(directAccessUnavailable()), +}; + +function directAccessUnavailable(): ConnectOutcome { + return connectFailure( + connectProblem("operation_failed", "Direct access is not used by the mdbase-next backend."), + ); +} + +function object(value: unknown): Readonly> | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Readonly>) + : null; +} + +/** + * Saved views over `listViews`/`executeView`. The replica's view payloads are not + * pinned down in the client API contract yet, so they are read defensively: an + * unrecognized list is empty, and an unrecognized execution is refused. + */ +function nextLibraryViews(db: MdbaseClient, client: ReaderConnectClient): LibraryViewRepository { + return { + async list(options = {}) { + const body = object(toPlain(await db.listViews(options.signal).catch(rethrow("list views")))); + const views = Array.isArray(body?.["views"]) ? (body["views"] as SavedViewList["views"]) : []; + return { views, meta: { totalCount: views.length } }; + }, + async execute(input, options = {}) { + const body = object( + toPlain( + await db + .executeView(input as unknown as PlainValue, options.signal) + .catch(rethrow("run a library view")), + ), + ); + if (!Array.isArray(body?.["results"]) || !object(body["meta"])) { + throw unsupported("Running this library view"); + } + return body as unknown as SavedViewExecution; + }, + async save({ path, frontmatter, replace }) { + const saved = replace + ? outcomeValue( + await client.update({ + path, + document: await viewDocument(frontmatter), + ...(replace.revision ? { ifRevision: replace.revision } : {}), + }), + "save library view", + ) + : outcomeValue(await client.create({ path, frontmatter }), "create library view"); + return { path: saved.path, revision: saved.revision }; + }, + }; +} + +function rethrow(operation: string): (error: unknown) => never { + return (error) => { + throw nextRepositoryError(operation, error); + }; +} + +async function viewDocument(frontmatter: JsonObject): Promise { + const { stringify } = await import("yaml"); + return `---\n${stringify(frontmatter).trimEnd()}\n---\n\n`; +} diff --git a/packages/connect/src/next/control-plane.ts b/packages/connect/src/next/control-plane.ts new file mode 100644 index 0000000..ae873f0 --- /dev/null +++ b/packages/connect/src/next/control-plane.ts @@ -0,0 +1,161 @@ +// mdbase-next backend: the one place Reader talks to the control plane. +// +// DEPENDENCY (control workstream): neither the consent flow that registers this +// client's key (`client_pk`) in a grant nor the routing endpoint exists yet. The +// HTTP implementation below targets a *proposed* endpoint and must follow the +// control workstream's final contract. +import { mdbaseError, type RelayRoute } from "@mdbase-dev/sdk"; + +/** A grant this client's key holds for one collection. */ +export interface ReaderNextGrant { + readonly collectionId: string; + readonly grant: string; + readonly displayName: string; +} + +export interface ReaderNextControlPlane { + /** The grants registered for this client key. */ + grants(clientPublicKey: Uint8Array): Promise; + /** + * Ask the person to consent. Consent registers `clientPublicKey` as the grant's + * `client_pk`. Resolves `null` when no grant was made. + */ + authorize(clientPublicKey: Uint8Array): Promise; + /** + * Where the collection's replica can be reached now. `null` means no target is + * online: for a private collection, none of the person's devices. + */ + resolveRoute(collectionId: string): Promise; +} + +export const nextGrantsStorageKey = "mdbase-reader:next-grants"; + +export interface ProposedControlPlaneOptions { + readonly serverUrl: string; + /** Where grants made out of band are remembered until a consent flow exists. */ + readonly storage?: Storage | null; + readonly fetch?: typeof fetch; +} + +/** + * PROPOSED control-plane client. + * - Route: `GET {serverUrl}/v1/next/collections/:id/route` → + * `{targets: [{url, device, noise_pk}]}` (`noise_pk` base64 or base64url, 32 bytes). + * - Grants: read from local storage. The consent flow that registers `client_pk` + * is not defined yet, so `authorize` makes no grant. + */ +export class ProposedHttpControlPlane implements ReaderNextControlPlane { + readonly #serverUrl: string; + readonly #storage: Storage | null; + readonly #fetch: typeof fetch; + + public constructor(options: ProposedControlPlaneOptions) { + this.#serverUrl = options.serverUrl.replace(/\/+$/u, ""); + this.#storage = options.storage ?? null; + this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + } + + public grants(): Promise { + return Promise.resolve(parseGrants(this.#storage?.getItem(nextGrantsStorageKey) ?? null)); + } + + public authorize(): Promise { + return Promise.resolve(null); + } + + /** Remembers a grant made out of band (for example by a development consent tool). */ + public rememberGrant(grant: ReaderNextGrant): void { + const grants = parseGrants(this.#storage?.getItem(nextGrantsStorageKey) ?? null).filter( + (known) => known.collectionId !== grant.collectionId, + ); + this.#storage?.setItem(nextGrantsStorageKey, JSON.stringify([...grants, grant])); + } + + public async resolveRoute(collectionId: string): Promise { + const url = `${this.#serverUrl}/v1/next/collections/${encodeURIComponent(collectionId)}/route`; + let response: Response; + try { + response = await this.#fetch(url, { credentials: "include" }); + } catch (error) { + throw mdbaseError("unavailable", `control plane unreachable: ${String(error)}`, "no_route"); + } + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw mdbaseError( + "unavailable", + `route lookup failed: ${String(response.status)}`, + "no_route", + ); + } + return routeFrom(await response.json()); + } +} + +function record(value: unknown): Readonly> | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Readonly>) + : null; +} + +/** The first target of a route response, or `null` when none is online. */ +export function routeFrom(body: unknown): RelayRoute | null { + const targets = record(body)?.["targets"]; + if (!Array.isArray(targets)) { + throw mdbaseError("unavailable", "malformed route response", "no_route"); + } + for (const candidate of targets) { + const target = record(candidate); + const url = target?.["url"]; + const device = target?.["device"]; + const key = target?.["noise_pk"]; + if (typeof url === "string" && typeof device === "string" && typeof key === "string") { + const noisePublicKey = base64Bytes(key); + if (noisePublicKey.length === 32) { + return { url, targetDevice: device, noisePublicKey }; + } + } + } + return null; +} + +function base64Bytes(text: string): Uint8Array { + const normalized = text.replaceAll("-", "+").replaceAll("_", "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + try { + return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); + } catch { + return new Uint8Array(); + } +} + +function parseGrants(text: string | null): ReaderNextGrant[] { + if (!text) { + return []; + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return []; + } + if (!Array.isArray(parsed)) { + return []; + } + return parsed.flatMap((candidate: unknown) => { + const grant = record(candidate); + const collectionId = grant?.["collectionId"]; + const id = grant?.["grant"]; + const displayName = grant?.["displayName"]; + return typeof collectionId === "string" && typeof id === "string" + ? [ + { + collectionId, + grant: id, + displayName: typeof displayName === "string" ? displayName : collectionId, + }, + ] + : []; + }); +} diff --git a/packages/connect/src/next/index.ts b/packages/connect/src/next/index.ts new file mode 100644 index 0000000..4a068c4 --- /dev/null +++ b/packages/connect/src/next/index.ts @@ -0,0 +1,20 @@ +// The mdbase-next SDK backend (opt-in). Only this package imports `@mdbase-dev/sdk`. +export { readerSdkBackend, type ReaderSdkBackend } from "./backend.js"; +export { nextReaderClient, type NextReaderClientOptions } from "./client.js"; +export { nextReaderCollection } from "./collection.js"; +export { + ProposedHttpControlPlane, + nextGrantsStorageKey, + type ProposedControlPlaneOptions, + type ReaderNextControlPlane, + type ReaderNextGrant, +} from "./control-plane.js"; +export { + isWaitingForDevice, + nextErrorOf, + nextProblem, + nextProblemMessage, + waitingForDeviceMessage, +} from "./errors.js"; +export { nextReaderFiles, type NextReaderFiles } from "./files.js"; +export { ReaderNextApplicationSession, type ReaderNextSessionOptions } from "./session.js"; diff --git a/packages/connect/src/next/session.test.ts b/packages/connect/src/next/session.test.ts new file mode 100644 index 0000000..55044d1 --- /dev/null +++ b/packages/connect/src/next/session.test.ts @@ -0,0 +1,128 @@ +import { + loadOrCreateClientKey, + mdbaseError, + memoryKeyStorage, + type Connector, +} from "@mdbase-dev/sdk"; +import { MemoryReplica } from "@mdbase-dev/sdk/testing"; +import { afterEach, describe, expect, it } from "vitest"; + +import { readerSdkBackend } from "./backend.js"; +import { routeFrom, type ReaderNextControlPlane, type ReaderNextGrant } from "./control-plane.js"; +import { waitingForDeviceMessage } from "./errors.js"; +import { ReaderNextApplicationSession, type ReaderNextSessionOptions } from "./session.js"; + +const sessions: ReaderNextApplicationSession[] = []; + +function controlPlane(grants: readonly ReaderNextGrant[]): ReaderNextControlPlane { + return { + grants: () => Promise.resolve(grants), + authorize: () => Promise.resolve(null), + resolveRoute: () => Promise.resolve(null), + }; +} + +function session( + grants: readonly ReaderNextGrant[], + connector: () => Connector, +): ReaderNextApplicationSession { + const options: ReaderNextSessionOptions = { + serverUrl: "https://connect.example", + app: { name: "dev.mdbase.reader", version: "test" }, + controlPlane: controlPlane(grants), + connector, + clientKey: () => loadOrCreateClientKey("reader-test", { storage: memoryKeyStorage() }), + }; + const created = new ReaderNextApplicationSession(options); + sessions.push(created); + return created; +} + +afterEach(() => { + for (const created of sessions.splice(0)) { + created.destroy(); + } +}); + +describe("mdbase-next Reader session", () => { + it("opens the only granted collection and serves Reader's repositories", async () => { + const replica = new MemoryReplica({ confirmDelayMs: null }); + replica.seed({ + path: "sources/one.md", + types: ["reader-source"], + frontmatter: { id: "src-1", title: "One" }, + }); + const grant = { collectionId: replica.collection, grant: "g-1", displayName: "Library" }; + const reader = session([grant], () => replica.connector()); + + const outcome = await reader.start(); + + expect(outcome.ok && outcome.value.status).toBe("ready"); + const opened = reader.connectedCollection(replica.collection); + expect(opened?.collectionName).toBe("Library"); + const page = await opened?.sources.list({ collectionId: opened.collectionId, limit: 10 }); + expect(page?.items.map((source) => source.title)).toEqual(["One"]); + expect(reader.connectedCollection("another-collection")).toBeNull(); + }); + + it("waits for one of the person's devices instead of failing", async () => { + const replica = new MemoryReplica(); + const grant = { collectionId: replica.collection, grant: "g-1", displayName: "Private" }; + let attempts = 0; + const offlineOnce: Connector = { + description: "offline once", + open: (hello) => { + attempts += 1; + return attempts === 1 + ? Promise.reject(mdbaseError("unavailable", "no device", "no_device_online")) + : replica.connector().open(hello); + }, + }; + const reader = session([grant], () => offlineOnce); + const snapshots: string[] = []; + reader.subscribe(() => { + const snapshot = reader.getSnapshot(); + snapshots.push(snapshot.status === "blocked" ? snapshot.problem.message : snapshot.status); + }); + + const outcome = await reader.start(); + + expect(outcome.ok && outcome.value.status).toBe("ready"); + expect(snapshots).toContain(waitingForDeviceMessage); + expect(reader.waitingForDevice).toBe(false); + }); + + it("asks the person to choose and reports the missing consent flow", async () => { + const reader = session([], () => new MemoryReplica().connector()); + + const started = await reader.start(); + const authorized = await reader.authorize(); + + expect(started.ok && started.value.status).toBe("unselected"); + expect(authorized.ok ? null : authorized.problem.code).toBe("unsupported_operation"); + await expect(reader.recoverPendingMutations()).resolves.toEqual([]); + }); +}); + +describe("mdbase-next configuration", () => { + it("keeps Connect unless the URL flag or build setting asks for next", () => { + expect(readerSdkBackend(null)).toBe("connect"); + expect(readerSdkBackend(null, "next")).toBe("next"); + expect(readerSdkBackend("next", "connect")).toBe("next"); + expect(readerSdkBackend("connect", "next")).toBe("connect"); + }); + + it("reads the proposed route response", () => { + const key = btoa(String.fromCharCode(...new Uint8Array(32).fill(7))) + .replaceAll("+", "-") + .replaceAll("/", "_"); + const route = routeFrom({ + targets: [{ url: "wss://relay.example/s/1", device: "device-1", noise_pk: key }], + }); + + expect(route).toMatchObject({ url: "wss://relay.example/s/1", targetDevice: "device-1" }); + expect(route?.noisePublicKey).toEqual(new Uint8Array(32).fill(7)); + expect(routeFrom({ targets: [] })).toBeNull(); + expect(() => routeFrom({})).toThrow(); + }); +}); diff --git a/packages/connect/src/next/session.ts b/packages/connect/src/next/session.ts new file mode 100644 index 0000000..3032c70 --- /dev/null +++ b/packages/connect/src/next/session.ts @@ -0,0 +1,337 @@ +// mdbase-next backend: Reader's application session over a relay connection. +import { connectFailure, connectProblem, connectSuccess } from "@mdbase-dev/connect/advanced"; +import { + connect, + isMdbaseError, + loadOrCreateClientKey, + relayConnector, + type ClientKey, + type Connector, + type MdbaseClient, +} from "@mdbase-dev/sdk"; + +import { nextReaderCollection } from "./collection.js"; +import { + ProposedHttpControlPlane, + type ReaderNextControlPlane, + type ReaderNextGrant, +} from "./control-plane.js"; +import { + asMdbaseError, + isWaitingForDevice, + nextProblem, + waitingForDeviceMessage, +} from "./errors.js"; + +import type { + ReaderConnectedCollection, + ReaderConnectSnapshot, + ReaderSession, +} from "../application-session.js"; +import type { ReaderPortableSession } from "../portable-application-session.js"; +import type { + ConnectFailure, + ConnectOutcome, + MdbaseConnectionInfo, + MdbaseEffectiveCapabilities, +} from "@mdbase-dev/connect"; + +export interface ReaderNextSessionOptions { + readonly serverUrl: string; + readonly app: { readonly name: string; readonly version: string }; + /** Defaults to the proposed HTTP control plane at `serverUrl`. */ + readonly controlPlane?: ReaderNextControlPlane; + /** Name of this app's client key in IndexedDB (a non-extractable WebCrypto key). */ + readonly keyName?: string; + /** Remembers the selected collection. */ + readonly storage?: Storage | null; + /** Test seam: how to reach a collection. Defaults to the relay. */ + readonly connector?: (grant: ReaderNextGrant, key: ClientKey) => Connector; + /** Test seam: the client key. Defaults to `loadOrCreateClientKey(keyName)`. */ + readonly clientKey?: () => Promise; +} + +const selectionKey = "mdbase-reader:next-selected"; + +type SessionProblem = Extract["problem"]; + +const capabilities: MdbaseEffectiveCapabilities = { + contractVersion: 2, + values: {}, + requiredAvailable: true, +}; + +function connectionInfo(grant: ReaderNextGrant): MdbaseConnectionInfo { + return { + collectionId: grant.collectionId, + displayName: grant.displayName, + operations: [], + scope: { contracts: [], access: "full_collection" }, + authority: { kind: "connector", durability: "computer" }, + route: "relay", + directAccess: "disabled", + }; +} + +/** + * Reader's session on the mdbase-next SDK (opt-in). It produces the same snapshots as + * the Connect session so the app's screens are unchanged. Waiting for a device of a + * private collection shows as `blocked` with {@link waitingForDeviceMessage}. + */ +export class ReaderNextApplicationSession implements ReaderSession, ReaderPortableSession { + readonly #options: ReaderNextSessionOptions; + readonly #controlPlane: ReaderNextControlPlane; + readonly #listeners = new Set<() => void>(); + #snapshot: ReaderConnectSnapshot = { status: "not_started", connections: [] }; + #grants: readonly ReaderNextGrant[] = []; + #key: ClientKey | null = null; + #db: MdbaseClient | null = null; + #opened: { readonly db: MdbaseClient; readonly collection: ReaderConnectedCollection } | null = + null; + #abort: AbortController | null = null; + #waiting = false; + + public constructor(options: ReaderNextSessionOptions) { + this.#options = options; + this.#controlPlane = + options.controlPlane ?? + new ProposedHttpControlPlane({ + serverUrl: options.serverUrl, + storage: options.storage ?? null, + }); + } + + /** True while a private collection waits for one of the person's devices. */ + public get waitingForDevice(): boolean { + return this.#waiting; + } + + public async start(): Promise> { + this.#set({ status: "starting", connections: this.#connections() }); + try { + this.#grants = await this.#controlPlane.grants((await this.#clientKey()).publicKey); + } catch (error) { + return this.#failed(nextProblem(asMdbaseError(error))); + } + const stored = this.#options.storage?.getItem(selectionKey); + const selected = + this.#grants.find((grant) => grant.collectionId === stored) ?? + (this.#grants.length === 1 ? this.#grants[0] : undefined); + if (!selected) { + this.#set({ status: "unselected", connections: this.#connections() }); + return connectSuccess(this.#snapshot); + } + return this.#open(selected); + } + + public destroy(): void { + this.#close(); + this.#set({ status: "destroyed", connections: this.#connections() }); + this.#listeners.clear(); + } + + public getSnapshot(): ReaderConnectSnapshot { + return this.#snapshot; + } + + public subscribe(listener: () => void): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + public select(selectedCollectionId: string): ConnectOutcome { + const grant = this.#grants.find((known) => known.collectionId === selectedCollectionId); + if (!grant) { + return connectFailure( + connectProblem("unknown_collection", "Reader has no access to that collection."), + ); + } + this.#options.storage?.setItem(selectionKey, grant.collectionId); + void this.#open(grant); + return connectSuccess(null); + } + + public async authorize(): Promise> { + try { + const grant = await this.#controlPlane.authorize((await this.#clientKey()).publicKey); + if (!grant) { + return connectFailure( + connectProblem( + "unsupported_operation", + "Connecting a collection to Reader on mdbase-next needs the control plane’s consent flow, which is not available yet.", + ), + ); + } + this.#grants = [...this.#grants.filter((g) => g.collectionId !== grant.collectionId), grant]; + return this.select(grant.collectionId); + } catch (error) { + return connectFailure(nextProblem(asMdbaseError(error))); + } + } + + public clearSelection(): void { + this.#options.storage?.removeItem(selectionKey); + this.#close(); + this.#set({ status: "unselected", connections: this.#connections() }); + } + + /** Nothing to recover: the SDK resubmits pending writes by mutation ID itself. */ + public recoverPendingMutations(): Promise[]> { + return Promise.resolve([]); + } + + /** STUB: type-pack setup is not part of the replica client API yet. */ + public applyCollectionSetup(): Promise> { + return Promise.resolve(connectSuccess(this.#snapshot)); + } + + public connectedCollection(expectedCollectionId?: string): ReaderConnectedCollection | null { + const snapshot = this.#snapshot; + const db = this.#db; + if ( + snapshot.status !== "ready" || + !db || + (expectedCollectionId !== undefined && snapshot.collectionId !== expectedCollectionId) + ) { + return null; + } + if (this.#opened?.db !== db) { + this.#opened = { + db, + collection: nextReaderCollection(db, this.#selectedGrant(snapshot.collectionId)), + }; + } + return this.#opened.collection; + } + + async #open(grant: ReaderNextGrant): Promise> { + this.#close(); + const abort = new AbortController(); + this.#abort = abort; + const context = this.#context(grant); + this.#set({ status: "checking_setup", ...context }); + try { + const key = await this.#clientKey(); + const db = await connect({ + app: { name: this.#options.app.name, version: this.#options.app.version }, + connector: this.#connector(grant, key), + waitForDevice: true, + signal: abort.signal, + onWaiting: () => { + this.#waiting = true; + this.#set({ status: "blocked", ...context, problem: waitingProblem() }); + }, + }); + if (abort.signal.aborted) { + db.close(); + return connectSuccess(this.#snapshot); + } + this.#waiting = false; + this.#db = db; + db.onLink((state, why) => this.#linkChanged(db, grant, state, why)); + this.#set({ status: "ready", verification: "verified", ...context }); + return connectSuccess(this.#snapshot); + } catch (error) { + if (abort.signal.aborted) { + return connectSuccess(this.#snapshot); + } + this.#waiting = false; + return this.#failed(nextProblem(asMdbaseError(error))); + } + } + + #linkChanged( + db: MdbaseClient, + grant: ReaderNextGrant, + state: MdbaseClient["link"], + why: MdbaseClient["linkProblem"], + ): void { + if (db !== this.#db) { + return; + } + // A reconnecting link keeps the workspace open: reads wait and writes stay pending. + this.#waiting = state === "reconnecting" && isWaitingForDevice(why); + if (state === "closed" && why && isMdbaseError(why)) { + const problem = nextProblem(why); + this.#set({ + status: "blocked", + ...this.#context(grant), + problem: { code: problem.code, message: problem.message, recovery: problem.recovery }, + }); + } + } + + #connector(grant: ReaderNextGrant, key: ClientKey): Connector { + if (this.#options.connector) { + return this.#options.connector(grant, key); + } + return relayConnector({ + collection: grant.collectionId, + grant: grant.grant, + staticKey: key, + resolveRoute: () => this.#controlPlane.resolveRoute(grant.collectionId), + }); + } + + async #clientKey(): Promise { + this.#key ??= await (this.#options.clientKey?.() ?? + loadOrCreateClientKey(this.#options.keyName ?? "mdbase-reader")); + return this.#key; + } + + #close(): void { + this.#abort?.abort(); + this.#abort = null; + this.#db?.close(); + this.#db = null; + this.#waiting = false; + } + + #selectedGrant(id: string): ReaderNextGrant { + return ( + this.#grants.find((grant) => grant.collectionId === id) ?? { + collectionId: id, + grant: "", + displayName: id, + } + ); + } + + #context(grant: ReaderNextGrant): { + readonly collectionId: string; + readonly info: MdbaseConnectionInfo; + readonly capabilities: MdbaseEffectiveCapabilities; + readonly connections: MdbaseConnectionInfo[]; + } { + return { + collectionId: grant.collectionId, + info: connectionInfo(grant), + capabilities, + connections: this.#connections(), + }; + } + + #connections(): MdbaseConnectionInfo[] { + return this.#grants.map(connectionInfo); + } + + #failed(problem: ConnectFailure["problem"]): ConnectOutcome { + this.#set({ + status: "start_failed", + problem: problem as SessionProblem, + connections: this.#connections(), + }); + return connectFailure(problem); + } + + #set(snapshot: ReaderConnectSnapshot): void { + this.#snapshot = snapshot; + for (const listener of [...this.#listeners]) { + listener(); + } + } +} + +function waitingProblem(): { code: string; message: string; recovery: string } { + return { code: "no_device_online", message: waitingForDeviceMessage, recovery: "retry" }; +} diff --git a/packages/connect/src/portable-application-session.ts b/packages/connect/src/portable-application-session.ts index 32cf13d..3cd469b 100644 --- a/packages/connect/src/portable-application-session.ts +++ b/packages/connect/src/portable-application-session.ts @@ -18,6 +18,7 @@ import { connectedReaderCollection, type ReaderConnectedCollection, type ReaderConnectSnapshot, + type ReaderSession, } from "./application-session.js"; export interface ReaderPortableApplicationSessionOptions { @@ -30,8 +31,18 @@ export interface ReaderPortableApplicationSessionOptions { readonly timeouts?: MdbaseConnectTimeouts; } +/** What the extension needs from a portable session, whichever SDK backs it. */ +export interface ReaderPortableSession extends Omit { + clearSelection(): void; + recoverPendingMutations(): Promise[]>; + authorize( + target: "choose" | "selected", + options?: MdbaseAuthorizeOptions, + ): Promise>; +} + /** Extension/download-friendly Reader session using the SDK's device-code flow. */ -export class ReaderPortableApplicationSession { +export class ReaderPortableApplicationSession implements ReaderPortableSession { readonly #session; public constructor(options: ReaderPortableApplicationSessionOptions) { From 150b1850f36607e70343a7ea6acfe38564c73b45 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 4 Oct 2026 20:41:43 +1100 Subject: [PATCH 4/5] reader, extension: opt-in switch for the mdbase-next backend The web app reads ?sdk=next or VITE_MDBASE_SDK=next, beside its existing ?server= and VITE_MDBASE_CONNECT_URL settings. The extension reads MDBASE_SDK=next at build time, beside MDBASE_ENV. The Connect SDK stays the default. --- apps/extension/scripts/extension-manifest.mjs | 2 + apps/extension/src/connect-session.ts | 30 ++++++++----- apps/extension/src/environment.ts | 2 + apps/extension/src/page-status-connect.ts | 7 +-- apps/extension/src/save-capture.ts | 7 +-- .../src/testing/save-capture-fixture.ts | 7 +-- apps/extension/src/use-connect.ts | 6 +-- apps/reader/src/connect.ts | 45 ++++++++++++++----- apps/reader/src/env.d.ts | 2 + apps/reader/src/import-controller.ts | 4 +- 10 files changed, 71 insertions(+), 41 deletions(-) diff --git a/apps/extension/scripts/extension-manifest.mjs b/apps/extension/scripts/extension-manifest.mjs index 43dc930..befd94d 100644 --- a/apps/extension/scripts/extension-manifest.mjs +++ b/apps/extension/scripts/extension-manifest.mjs @@ -14,6 +14,8 @@ export function extensionEnvironment(environment = process.env) { connectUrl: deployment.connectUrl, loopbackUrl: deployment.loopbackUrl, readerOrigin: deployment.origin, + // Opt-in mdbase-next SDK backend (MDBASE_SDK=next); Connect stays the default. + sdk: environment.MDBASE_SDK === "next" ? "next" : "connect", }; } diff --git a/apps/extension/src/connect-session.ts b/apps/extension/src/connect-session.ts index b7300c2..de241da 100644 --- a/apps/extension/src/connect-session.ts +++ b/apps/extension/src/connect-session.ts @@ -2,7 +2,9 @@ import { ReaderPortableApplicationSession, type MdbaseAppManifest, type ReaderConnectSnapshot, + type ReaderPortableSession, } from "@mdbase-reader/connect"; +import { ReaderNextApplicationSession, readerSdkBackend } from "@mdbase-reader/connect/next"; import { chromeStorageMirror } from "./chrome-storage.js"; import { lastCollectionKey } from "./collection-memory.js"; @@ -14,21 +16,29 @@ import type { KeyValueStorage } from "@mdbase-reader/platform"; export { rememberCollection, rememberedCollection } from "./collection-memory.js"; export interface ExtensionSession { - readonly session: ReaderPortableApplicationSession; + readonly session: ReaderPortableSession; /** Reader's mutation journal, kept beside the grants so any extension context can recover it. */ readonly journalStorage: KeyValueStorage; } export async function createExtensionSession(): Promise { const storage = await chromeStorageMirror(); - const session = new ReaderPortableApplicationSession({ - serverUrl: environment.connectUrl, - loopbackUrl: environment.loopbackUrl, - manifest: manifest as MdbaseAppManifest, - storage, - // Interactive capture should not wait the SDK's ten-minute file-index default. - timeouts: { watchStartMs: 60_000, fileIndexMs: 30_000, uploadMs: 120_000 }, - }); + const session: ReaderPortableSession = + readerSdkBackend(null, environment.sdk) === "next" + ? // The client key is a non-extractable WebCrypto key kept in IndexedDB. + new ReaderNextApplicationSession({ + serverUrl: environment.connectUrl, + app: { name: manifest.id, version: chrome.runtime.getManifest().version }, + storage, + }) + : new ReaderPortableApplicationSession({ + serverUrl: environment.connectUrl, + loopbackUrl: environment.loopbackUrl, + manifest: manifest as MdbaseAppManifest, + storage, + // Interactive capture should not wait the SDK's ten-minute file-index default. + timeouts: { watchStartMs: 60_000, fileIndexMs: 30_000, uploadMs: 120_000 }, + }); return { session, journalStorage: { @@ -41,7 +51,7 @@ export async function createExtensionSession(): Promise { /** Selects the collection chosen last time, if it is still authorized. */ export async function restoreCollection( - session: ReaderPortableApplicationSession, + session: ReaderPortableSession, ): Promise { const snapshot = session.getSnapshot(); if (snapshot.status !== "unselected") { diff --git a/apps/extension/src/environment.ts b/apps/extension/src/environment.ts index 07fe5ee..0deaba9 100644 --- a/apps/extension/src/environment.ts +++ b/apps/extension/src/environment.ts @@ -5,6 +5,8 @@ export interface ExtensionEnvironment { readonly connectUrl: string; readonly loopbackUrl: string; readonly readerOrigin: string; + /** `next` opts into the mdbase-next SDK backend (MDBASE_SDK=next at build time). */ + readonly sdk?: "connect" | "next"; } declare const __READER_EXTENSION_ENVIRONMENT__: ExtensionEnvironment; diff --git a/apps/extension/src/page-status-connect.ts b/apps/extension/src/page-status-connect.ts index e411099..4fe7a97 100644 --- a/apps/extension/src/page-status-connect.ts +++ b/apps/extension/src/page-status-connect.ts @@ -6,10 +6,7 @@ import { } from "./connect-session.js"; import { annotationQuotes, drawPageQuotes, type PageQuote } from "./page-annotations.js"; -import type { - ReaderConnectedCollection, - ReaderPortableApplicationSession, -} from "@mdbase-reader/connect"; +import type { ReaderConnectedCollection, ReaderPortableSession } from "@mdbase-reader/connect"; /** * The part of page status that needs Connect. The service worker loads it (with the @@ -29,7 +26,7 @@ export interface PageStatusConnect { drawPageQuotes(tabId: number, quotes: readonly PageQuote[], url: string): Promise; } -let session: Promise | null = null; +let session: Promise | null = null; /** One Connect session per service-worker lifetime, sharing the panel's stored grants. */ async function selectedCollection(): Promise { diff --git a/apps/extension/src/save-capture.ts b/apps/extension/src/save-capture.ts index 2b69ff4..de79344 100644 --- a/apps/extension/src/save-capture.ts +++ b/apps/extension/src/save-capture.ts @@ -36,10 +36,7 @@ import { pageText } from "./page-annotations.js"; import { rememberSavedUrls } from "./saved-url-index.js"; import type { PageCapture, PdfCapture, SelectedWebCapture } from "./page-capture.js"; -import type { - ReaderConnectedCollection, - ReaderPortableApplicationSession, -} from "@mdbase-reader/connect"; +import type { ReaderConnectedCollection, ReaderPortableSession } from "@mdbase-reader/connect"; export const highlightColors = ["yellow", "green", "blue", "pink", "purple"] as const; export type HighlightColor = (typeof highlightColors)[number]; @@ -61,7 +58,7 @@ export interface SavedCapture { readonly notices: readonly string[]; } export interface SaveCaptureInput { - readonly session: ReaderPortableApplicationSession; + readonly session: ReaderPortableSession; readonly collection: ReaderConnectedCollection; readonly capture: PageCapture; readonly draft: CaptureDraft; diff --git a/apps/extension/src/testing/save-capture-fixture.ts b/apps/extension/src/testing/save-capture-fixture.ts index 0432bde..3db3efe 100644 --- a/apps/extension/src/testing/save-capture-fixture.ts +++ b/apps/extension/src/testing/save-capture-fixture.ts @@ -18,10 +18,7 @@ import { } from "../save-capture.js"; import type { PageCapture, SelectedWebCapture } from "../page-capture.js"; -import type { - ReaderConnectedCollection, - ReaderPortableApplicationSession, -} from "@mdbase-reader/connect"; +import type { ReaderConnectedCollection, ReaderPortableSession } from "@mdbase-reader/connect"; export const capture: SelectedWebCapture = { kind: "html", @@ -119,7 +116,7 @@ export function fixture() { } as unknown as ReaderConnectedCollection; const session = { recoverPendingMutations: vi.fn(() => Promise.resolve([])), - } as unknown as ReaderPortableApplicationSession; + } as unknown as ReaderPortableSession; const onSource = vi.fn(); const journal = new Map(); const storage = { diff --git a/apps/extension/src/use-connect.ts b/apps/extension/src/use-connect.ts index c5fd795..ef51068 100644 --- a/apps/extension/src/use-connect.ts +++ b/apps/extension/src/use-connect.ts @@ -2,7 +2,7 @@ import { connectProblemMessage, type ReaderConnectSnapshot, type ReaderDirectAccessController, - type ReaderPortableApplicationSession, + type ReaderPortableSession, } from "@mdbase-reader/connect"; import { useCallback, useEffect, useState } from "react"; @@ -23,7 +23,7 @@ export interface ConnectLink { readonly deviceCode: string | null; readonly directAccess: ReaderDirectAccessController | null; /** Creates the session (grants load from extension storage) and restores the last collection. */ - readonly open: () => Promise; + readonly open: () => Promise; readonly connect: (choose?: boolean) => Promise; readonly retry: () => Promise; readonly applySetup: () => Promise; @@ -48,7 +48,7 @@ export function useConnect(lock: ActionLock): ConnectLink { }; }, [extension]); - const open = useCallback(async (): Promise => { + const open = useCallback(async (): Promise => { const created = await createExtensionSession(); setExtension(created); const problem = connectProblemMessage(await created.session.start()); diff --git a/apps/reader/src/connect.ts b/apps/reader/src/connect.ts index bcf56e0..3affe70 100644 --- a/apps/reader/src/connect.ts +++ b/apps/reader/src/connect.ts @@ -2,7 +2,9 @@ import { ReaderApplicationSession, manifestForApplicationUrl, type ReaderApplicationSessionOptions, + type ReaderSession, } from "@mdbase-reader/connect"; +import { ReaderNextApplicationSession, readerSdkBackend } from "@mdbase-reader/connect/next"; import bundledManifest from "./generated/mdbase-app.json"; @@ -17,17 +19,38 @@ const manifest = isLoopbackApplication(location) ? manifestForApplicationUrl(declaredManifest, applicationUrl, callbackUrl.href) : declaredManifest; -export const readerSession = new ReaderApplicationSession({ - serverUrl: - serverParameter ?? import.meta.env.VITE_MDBASE_CONNECT_URL ?? "https://connect.mdbase.dev", - loopbackUrl: import.meta.env.VITE_MDBASE_CONNECT_LOOPBACK_URL ?? "http://127.0.0.1:28485", - manifest, - redirectUri: callbackUrl.href, - fallbackPath: import.meta.env.BASE_URL, - // Application startup includes collection setup verification. A remote connector - // may need more than the SDK's interactive 10-second discovery default. - timeouts: { watchStartMs: 60_000 }, -}); +const serverUrl = + serverParameter ?? import.meta.env.VITE_MDBASE_CONNECT_URL ?? "https://connect.mdbase.dev"; +// Opt-in mdbase-next backend: `?sdk=next` or VITE_MDBASE_SDK=next. Connect stays the default. +const sdkBackend = readerSdkBackend( + new URL(location.href).searchParams.get("sdk"), + import.meta.env.VITE_MDBASE_SDK, +); + +export const readerSession: ReaderSession = + sdkBackend === "next" + ? new ReaderNextApplicationSession({ + serverUrl, + app: { + name: declaredManifest.id, + version: import.meta.env.VITE_MDBASE_READER_BUILD_ID ?? "development", + }, + storage: localStorage, + }) + : connectSession(); + +function connectSession(): ReaderApplicationSession { + return new ReaderApplicationSession({ + serverUrl, + loopbackUrl: import.meta.env.VITE_MDBASE_CONNECT_LOOPBACK_URL ?? "http://127.0.0.1:28485", + manifest, + redirectUri: callbackUrl.href, + fallbackPath: import.meta.env.BASE_URL, + // Application startup includes collection setup verification. A remote connector + // may need more than the SDK's interactive 10-second discovery default. + timeouts: { watchStartMs: 60_000 }, + }); +} function isLoopbackApplication(current: Location): boolean { return ( diff --git a/apps/reader/src/env.d.ts b/apps/reader/src/env.d.ts index a8633e1..c6c8aee 100644 --- a/apps/reader/src/env.d.ts +++ b/apps/reader/src/env.d.ts @@ -6,6 +6,8 @@ interface ImportMetaEnv { readonly VITE_MDBASE_FEEDBACK_TURNSTILE_SITE_KEY?: string; readonly VITE_MDBASE_CONNECT_URL?: string; readonly VITE_MDBASE_CONNECT_LOOPBACK_URL?: string; + /** `next` opts into the mdbase-next SDK backend; Connect is the default. */ + readonly VITE_MDBASE_SDK?: string; readonly VITE_MDBASE_READER_BUILD_ID?: string; readonly VITE_MDBASE_EDITOR_URL?: string; readonly VITE_MDBASE_READER_URL?: string; diff --git a/apps/reader/src/import-controller.ts b/apps/reader/src/import-controller.ts index e732ca4..da13007 100644 --- a/apps/reader/src/import-controller.ts +++ b/apps/reader/src/import-controller.ts @@ -12,7 +12,7 @@ import { import { markFraction, withMarkProgress } from "./mark-activity.js"; -import type { ReaderApplicationSession } from "@mdbase-reader/connect"; +import type { ReaderSession } from "@mdbase-reader/connect"; export interface ImportState { plan: MigrationPlan | null; message: string; @@ -37,7 +37,7 @@ export class ImportController { private listeners = new Set<() => void>(); private active: AbortController | null = null; private client: ReadwiseClient | null = null; - constructor(private session: ReaderApplicationSession) {} + constructor(private session: ReaderSession) {} getSnapshot = (): ImportState => this.state; subscribe = (fn: () => void): (() => void) => { this.listeners.add(fn); From 6aaa6e5d2f712b0212f54f227202fd2adbafac48 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Mon, 5 Oct 2026 00:29:05 +1100 Subject: [PATCH 5/5] next: replace migration stub with confirmed idempotent import target --- packages/connect/src/next/collection.ts | 13 +- .../connect/src/next/migration-target.test.ts | 154 +++++++++++++++ packages/connect/src/next/migration-target.ts | 175 ++++++++++++++++++ 3 files changed, 331 insertions(+), 11 deletions(-) create mode 100644 packages/connect/src/next/migration-target.test.ts create mode 100644 packages/connect/src/next/migration-target.ts diff --git a/packages/connect/src/next/collection.ts b/packages/connect/src/next/collection.ts index a50c802..29c8df4 100644 --- a/packages/connect/src/next/collection.ts +++ b/packages/connect/src/next/collection.ts @@ -18,6 +18,7 @@ import { ConnectSourceImportRepository } from "../source-imports.js"; import { nextReaderClient, type NextReaderClientOptions } from "./client.js"; import { nextRepositoryError } from "./errors.js"; import { nextReaderFiles } from "./files.js"; +import { NextMigrationTarget } from "./migration-target.js"; import type { ReaderConnectedCollection, @@ -35,7 +36,6 @@ import type { SavedViewList, } from "@mdbase-dev/connect"; import type { BodyUpdateRecovery } from "@mdbase-reader/core"; -import type { MigrationTarget } from "@mdbase-reader/migration"; /** * Reader's repositories over an mdbase-next client. Source, annotation, search, @@ -54,7 +54,7 @@ export function nextReaderCollection( collectionName: grant.displayName, sources: new ConnectSourceRepository(client), sourceImports: new ConnectSourceImportRepository(client, files), - migration: unsupportedMigration(grant.collectionId), + migration: new NextMigrationTarget(db), annotations: new ConnectAnnotationRepository(client), annotationAssets: new ConnectAnnotationAssetRepository(files), documents: new ConnectDocumentRepository(files), @@ -74,15 +74,6 @@ function unsupported(feature: string): Error { ); } -/** - * STUB: the importer relies on the old SDK's durable pending-mutation journal. - * mdbase-next writes are idempotent by mutation ID, so this needs a redesign, not a port. - */ -function unsupportedMigration(id: string): MigrationTarget { - const refuse = (): Promise => Promise.reject(unsupported("Importing a Reader library")); - return { collectionId: id, existing: refuse, files: refuse, upload: refuse, create: refuse }; -} - /** * STUB: receipts replace interrupted-write recovery. Writes are resubmitted by mutation ID * inside the SDK, so nothing is ever left for Reader to recover. diff --git a/packages/connect/src/next/migration-target.test.ts b/packages/connect/src/next/migration-target.test.ts new file mode 100644 index 0000000..177daeb --- /dev/null +++ b/packages/connect/src/next/migration-target.test.ts @@ -0,0 +1,154 @@ +import { connect, mdbaseError, toPlain, type MdbaseClient } from "@mdbase-dev/sdk"; +import { MemoryReplica } from "@mdbase-dev/sdk/testing"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { NextMigrationTarget } from "./migration-target.js"; + +import type { Fields, MigrationRecord } from "@mdbase-reader/migration"; + +const clients: MdbaseClient[] = []; +const signal = (): AbortSignal => new AbortController().signal; +const record: MigrationRecord = { + id: "src_stable", + key: "zotero-item", + path: "sources/import.md", + fields: {}, + body: "Imported notes", +}; +const fields: Fields = { + id: record.id, + title: "A paper", + import: { namespace: "zotero:user:123", key: record.key }, +}; + +async function open(replica = new MemoryReplica({ confirmDelayMs: 0 })): Promise<{ + replica: MemoryReplica; + db: MdbaseClient; + target: NextMigrationTarget; +}> { + const db = await connect({ + app: { name: "reader-import-test", version: "0" }, + connector: replica.connector(), + reconnect: false, + }); + clients.push(db); + return { replica, db, target: new NextMigrationTarget(db) }; +} + +async function until(condition: () => boolean): Promise { + for (let i = 0; i < 100 && !condition(); i++) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + expect(condition()).toBe(true); +} + +afterEach(() => { + clients.splice(0).forEach((db) => db.close()); + vi.restoreAllMocks(); +}); + +describe("next Reader migration", () => { + it("waits for confirmation, then resumes with the same identity across sessions", async () => { + const replica = new MemoryReplica({ confirmDelayMs: null }); + const { db, target } = await open(replica); + let completed = false; + const operation = target.create(record, fields, "reader-source", signal()).then(() => { + completed = true; + }); + await until(() => replica.allRecords.length === 1); + expect(completed).toBe(false); + replica.confirmAll(); + await operation; + const id = replica.allRecords[0]?.id; + db.close(); + const reopened = await open(replica); + await reopened.target.create(record, fields, "reader-source", signal()); + expect(replica.allRecords).toHaveLength(1); + expect(replica.allRecords[0]?.id).toBe(id); + expect((await reopened.target.existing(signal())).get(record.id)).toEqual(fields); + }); + + it("reuses the original mutation when its confirmation response was lost", async () => { + const { db, target, replica } = await open(); + const submit = db.submit.bind(db); + vi.spyOn(db, "submit").mockImplementationOnce(async (...args) => { + await submit(...args); + throw mdbaseError("unavailable", "response lost"); + }); + await expect(target.create(record, fields, "reader-source", signal())).rejects.toMatchObject({ + code: "unavailable", + }); + await target.create(record, fields, "reader-source", signal()); + expect(replica.allRecords).toHaveLength(1); + }); + + it("canonicalizes field order but refuses changed payloads without overwriting", async () => { + const { target, replica } = await open(); + await target.create(record, fields, "reader-source", signal()); + await target.create( + record, + { import: fields["import"]!, title: "A paper", id: record.id }, + "reader-source", + signal(), + ); + await expect( + target.create(record, { ...fields, title: "Different paper" }, "reader-source", signal()), + ).rejects.toBeDefined(); + expect(replica.allRecords).toHaveLength(1); + expect(toPlain(replica.allRecords[0]!.frontmatter.get("title")!)).toBe("A paper"); + }); + + it("refuses collisions with existing paths and malformed provenance", async () => { + const { target, replica } = await open(); + replica.seed({ path: record.path, body: "User bytes" }); + await expect(target.create(record, fields, "reader-source", signal())).rejects.toMatchObject({ + code: "conflict", + reason: "path_taken", + }); + await expect( + target.create(record, { ...fields, import: null }, "reader-source", signal()), + ).rejects.toThrow("Invalid import identity"); + expect(replica.allRecords).toHaveLength(1); + expect(replica.allRecords[0]?.body).toBe("User bytes"); + }); + + it("stops on duplicate destination IDs and does not submit after cancellation", async () => { + const { target, replica, db } = await open(); + replica.seed({ path: "a.md", types: ["reader-source"], frontmatter: { id: record.id } }); + replica.seed({ path: "b.md", types: ["reader-annotation"], frontmatter: { id: record.id } }); + await expect(target.existing(signal())).rejects.toThrow("duplicate record IDs"); + const abort = new AbortController(); + abort.abort(); + const submit = vi.spyOn(db, "submit"); + await expect( + target.create(record, fields, "reader-source", abort.signal), + ).rejects.toBeDefined(); + expect(submit).not.toHaveBeenCalled(); + }); + + it("uploads confirmed files with collection-scoped stable transfer and file IDs", async () => { + const { target } = await open(); + const path = "files/reader/imports/paper.pdf"; + const bytes = new Blob(["PDF bytes"]); + const progress = vi.fn(); + const first = await target.upload( + path, + bytes, + "application/pdf", + "paper-key", + signal(), + progress, + ); + const again = await target.upload( + path, + bytes, + "application/pdf", + "paper-key", + signal(), + progress, + ); + expect(again).toEqual(first); + expect((await target.files(signal())).get(path)).toEqual(first); + expect(progress).toHaveBeenCalled(); + }); +}); diff --git a/packages/connect/src/next/migration-target.ts b/packages/connect/src/next/migration-target.ts new file mode 100644 index 0000000..1a59f61 --- /dev/null +++ b/packages/connect/src/next/migration-target.ts @@ -0,0 +1,175 @@ +import { encode, toPlain, toValue, type MdbaseClient } from "@mdbase-dev/sdk"; + +import { fileDescriptor, transferUuid } from "./files.js"; + +import type { + Fields, + ImportedFile, + Json, + MigrationRecord, + MigrationTarget, +} from "@mdbase-reader/migration"; + +// mdb-cbor/1 data maps preserve insertion order. Normalize importer-owned objects +// explicitly; arrays (including list fields) retain their semantic order. +function ordered(value: Json): Json { + if (Array.isArray(value)) { + return value.map(ordered); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, item]) => [key, ordered(item)]), + ); + } + return value; +} + +/** Browser imports use replica receipts, never the native legacy connector journal. */ +export class NextMigrationTarget implements MigrationTarget { + public readonly collectionId: string; + + constructor(private readonly db: MdbaseClient) { + this.collectionId = db.collection; + } + + async existing(signal: AbortSignal): Promise> { + const result = new Map(); + let cursor: string | undefined; + do { + const page = await this.db.query( + { + types: ["reader-source", "reader-annotation"], + limit: 1000, + ...(cursor ? { cursor } : {}), + }, + { effective: true }, + signal, + ); + for (const row of page.records) { + const persisted = toPlain(row.frontmatter) as Fields; + const effective = row.effective ? (toPlain(row.effective) as Fields) : persisted; + const id = effective["id"]; + if (typeof id !== "string") { + continue; + } + if (result.has(id)) { + throw new Error("The destination has duplicate record IDs. Import was stopped."); + } + // Provenance is a persisted extension, not a contract projection. + result.set(id, { ...effective, import: persisted["import"] ?? null }); + } + cursor = page.cursor; + } while (cursor); + return result; + } + + async files(signal: AbortSignal): Promise> { + const result = new Map(); + for await (const file of this.db.files.list({ folder: "files/reader/imports", signal })) { + result.set(file.path, fileDescriptor(file)); + } + return result; + } + + async upload( + path: string, + blob: Blob, + _mediaType: string, + key: string, + signal: AbortSignal, + progress: (bytes: number) => void, + ): Promise { + signal.throwIfAborted(); + const identity = JSON.stringify(["reader-import-file", this.collectionId, key]); + const fileId = await transferUuid(`${identity}:file`); + const write = await this.db.files.upload(path, blob, { + fileId, + transferId: await transferUuid(`${identity}:transfer`), + mutationId: await transferUuid(`${identity}:commit`), + signal, + onProgress: (p) => progress(p.done), + }); + // Do not let import verification mistake an optimistic upload for durable bytes. + await write.confirmed; + return fileDescriptor(await this.db.files.get(fileId, signal)); + } + + async create( + record: MigrationRecord, + fields: Fields, + type: "reader-source" | "reader-annotation", + signal: AbortSignal, + ): Promise { + signal.throwIfAborted(); + const provenance = fields["import"]; + if ( + !provenance || + typeof provenance !== "object" || + Array.isArray(provenance) || + typeof provenance["namespace"] !== "string" || + provenance["key"] !== record.key || + fields["id"] !== record.id + ) { + throw new Error("Invalid import identity or provenance."); + } + const identity = JSON.stringify([ + "reader-import-record", + this.collectionId, + provenance["namespace"], + record.id, + record.key, + ]); + const id = await transferUuid(identity); + const normalized = ordered(fields) as Fields; + // Normalized CBOR makes retries independent of object insertion order. A changed + // payload has a different mutation ID but the same record ID: it is refused as + // an existing record, never silently mistaken for the original write or updated. + const digest = new Uint8Array( + await crypto.subtle.digest( + "SHA-256", + encode(toValue([type, record.path, normalized, record.body])).slice().buffer, + ), + ); + const fingerprint = [...digest].map((b) => b.toString(16).padStart(2, "0")).join(""); + // MigrationTarget commits one record at a time. The SDK expands mutationId to + // the singleton mutation_ids array when allowPartial is true; no fresh ID is + // generated on retries or after a page reload. + const [write] = await this.db.submit( + [ + { + kind: "create", + id, + type, + path: record.path, + frontmatter: new Map(Object.entries(normalized).map(([k, v]) => [k, toValue(v)])), + body: record.body, + }, + ], + { + allowPartial: true, + mutationId: await transferUuid(`${identity}:mutation:${fingerprint}`), + wait: "confirmed", + signal, + }, + ); + if (!write) { + throw new Error("The replica returned no import receipt."); + } + await write.confirmed; + const saved = await this.db.get(id, { effective: true }, signal); + const actual = toPlain(saved.frontmatter) as Fields; + const imported = actual["import"]; + if ( + actual["id"] !== record.id || + !imported || + typeof imported !== "object" || + Array.isArray(imported) || + imported["namespace"] !== provenance["namespace"] || + imported["key"] !== record.key + ) { + throw new Error("Confirmed import identity mismatch."); + } + } +}