From 19c15f7e966c252dffe8518f79b74868b1331a6b Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Fri, 28 Jun 2024 23:40:44 -1000 Subject: [PATCH] refactor: :recycle: Replace Meilisearch with Sonic --- .github/config.workflow.toml | 4 +- CHANGELOG.md | 2 + bun.lockb | Bin 250876 -> 249052 bytes classes/search/search-manager.ts | 283 +++++++++++++++++++++++++ config/config.example.toml | 6 +- docker-compose.yml | 17 +- docs/installation.md | 4 +- package.json | 2 +- packages/config-manager/config.type.ts | 4 +- packages/database-interface/user.ts | 10 +- server/api/api/v2/search/index.ts | 49 +++-- setup.ts | 6 +- tests/utils.ts | 5 + utils/loggers.ts | 2 +- utils/meilisearch.ts | 155 -------------- 15 files changed, 338 insertions(+), 211 deletions(-) create mode 100644 classes/search/search-manager.ts delete mode 100644 utils/meilisearch.ts diff --git a/.github/config.workflow.toml b/.github/config.workflow.toml index 447a42f6..01323099 100644 --- a/.github/config.workflow.toml +++ b/.github/config.workflow.toml @@ -19,10 +19,10 @@ password = "" database = 1 enabled = false -[meilisearch] +[sonic] host = "localhost" port = 40007 -api_key = "" +password = "" enabled = false [signups] diff --git a/CHANGELOG.md b/CHANGELOG.md index 59585295..d9e9a692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Lysand Server `0.7.0` is backwards compatible with `0.6.0`. However, some new fe ## Features - Upgrade Bun to `1.1.17`. This brings performance upgrades and better stability. +- Added support for the [Sonic](https://github.com/valeriansaliou/sonic) search indexer. - Note deletions are now federated. - Note edits are now federated. - Added option for more federation debug logging. @@ -38,6 +39,7 @@ Lysand Server `0.7.0` is backwards compatible with `0.6.0`. However, some new fe ## Removals - Remove old logging system, to be replaced by a new one. +- Removed Meilisearch support, in favor of Sonic. Follow instructions in the [installation guide](docs/installation.md) to set up Sonic. ## Miscellaneous diff --git a/bun.lockb b/bun.lockb index 31acd8552dfcd95fbfd406a54649a375ac35e9d2..89d216c0b8d22bb26c767c3201c2651238e8b4c9 100755 GIT binary patch delta 44273 zcmeFa33yFc+Xj5jmO~C=RtYgr2@(XEb7CeW=6OyC35i5v3L-Jn8oRN?JkK*l5Upza z(xR%RD6OFbE!xsSRP?{^z1B`rulDWtf5UbC|JBppdDe5UXFY4JXAOJpoqgolmg3hl ziZAr`Y}$P7FViNkI@Y+NTfH(R3VrfjtJ`}jd|qwvC+Vw)R&lqT3_n}I!q3n}Ju9bm z7^oSF#S)j29654`#j+QkI1NsS zOW;r(LM;rihUmg)dN)YskCNkOc^^9So1pc~_ke_p^q9!xgmEJ+miLu@6O!dVh2(%& zR`M0f#vlfwyBvZ~AibQAaNL4g-<{Io>n<66`f*0$B&NYG_xc^ zXZ2B#Mst-+9Oe}}a4j~T+7(AF92zG`n#ae*rJ(Vami1+I ziIJmYxTFUsj2s%3Fy8V+wYFgci=_hWzk|-9ywyNYvN_sSZX`#fG$cD!NXeglWT(G| zF@gJPi?i*KK7N3j*E^Q%-Qtxe5 zeusIDPDzYTt{y5IZmtx$LfrF~NJ7+@p{%HzS}Ja#dSD@Mtwu9_c%fR_ug&$>3$;$G z+e)s^N{}3sQji=44x-nH=#+v=H>dY+EoJw|#l#Mdf%Dkp=<(5zS{pe^mmulKEN}My z5y~+tFDwter*lc|NOOI-bL&3!!llW$grp%1PF3iPbZJPA5OyA~p>dJJ3M;!65wgl3 zAenDfJf``$xY*>;W`E2b$Lw!pa>__f;j3`W3aa&x3ng-7axAMj2firyCq4ASg)5~M>Lo8- zBO!SOD2DmOWm>GaTt`pAb4UCg^N8!|2k;ErZ6(d{XZDfJ+YQNTH$k$WOCTAqX^^EL zlOXB;A^bt=TME0S&Fyco)ByJjB%2m8K-QJXI6xnr?3I`_20LW3SA0TJVocmX>7ySc zeHR)e?aM=FopA|6B9k%Ah9@VCYyuy}!Lv;%k?|N{Y%!72uXB{)CoTOu5SZ@i(Md@O zNnX+8&8ylq@N8@v0?LZ6K~{tO2$B`O7<+TBnKA#fQ$|ntWk)w)r4{Anb}15^{|&K4 z(Q#{4v3U+k7#tVNRarY$j#foTMy4nv8#r*dY+zSNI_8EQm140Z#~FdJv;xmj3x=eB zZ>3j-bV&p83&QmZiGsG&)R2f(Fk<$w-(5u$hdd;})2^9K*ZF=X^AGh;z_+ zd036|Z0LL?lObK82ak~rZUo7m_XW@T?xMr2Z)mbtTJ-qQNs)6<4Kp}txW*rpwcA3pijyroSfl2QTQHq?KUj4tZ{_ zJnbHYq{Cv!uM8QZ{1k*_1CPNU=SZOivYuDL)9+sB5E}5s|5216v*aKMw^0F#FW=!l5PS)kntAd{k$w`(3iHg$uK(d}NNJgvy zq#M_N}}4@j1?D*g5pS?)MwE%39}BK{nU3=q{J$12$!vMTgF>tzp`KPfxBdl_nUD+lh)^WQG?FvW^i9aMG8N5SIw#|@*p|5}}0vVkgH6}K0 zh^5$W#Gi&wcFEa&TgkIZ9#V20Bzrawl4F;mWVDi}|+;iGNa`b#uzWa#-`%8OUod*^@w)NwToJ9w2 zmKmfSNG#`E!upP$QajXIM$g307J4>*PS*oGLalG=DITHrr%o1&Hyl~?Dee6AZ1Ayq zfM=+6i=Kj?&-F~tPHrCdOF+f&e9tBwf*efpfyLnW_Vw#XV(q2Ue^Qag<7lWDfl@{ z&&1DTdNzJO)&smkZOt&DJL#zbLDofjrdO!-Q$5=&R4ZLXKkDhQcGOe6L+xiV*ZaV! zY+kLR7E3Rq3ahuDeKNFm(5y!Fr=hWWt8Vr3(+a!j6Y4u`wTfFT;d*NQAX^er9gNgl zNVR54D_cUJ(7<8ss%JI`wavhs>_*yt6{*37r)FM$wqR_Sy$pUaQp0lj-=G<;?QJl- z`{wfNp`lao^9ZSdxqN3#F@v|QLu#NN5)kZ!btqkgBbDpIz6Bc2vgisLV0Xf*jWjh| zG&I?d14wl-`cdi?{b^%|y)`Bl2T#+RdHUI=L-Wx?>I7Ne)-#)gY9-3)Pn$Tb1N8vk zQ0po^1wSwAnZBV~3s?QAufsZ95AX}MeTPZfSP$_EvRB6Rl<_?2;ipBq>4BaO+ZvF8 zdWa{2#FWu0jJ;O6ynfW*VNb%&*1;gHzJB)m&^TH~H>@r7lz>qCLM(;`(qe|c6O>MR zPTe4_gu5OX=&&Z~DS@HZgL-CQsQs=xS^`TL)||t(Ens!47*{?C<0X=+-8F*2#KG zNT~fhIIb#Z&S_f*?EFpj9IqhTdZaLz4KT<^g~FmRC!wt&w*1z5PLm+}IHVll3mPlw z4QQR9VRCNpcdjNIY-15;KWNO#ZJhHgFn?&S^#eViX{fzO^?b|G-W?jPjKyi+2#wVh z&`6K*SFq4M{OqnZ%-CVoSUc&N%|fx@vhnl09?(40ZmWssUxue}+F z;Ov*6(Nh7vSsg#ELLEJ@twWnoN1p((-_UH2>u?sMt#+@vGKerv@OOs7s5x={*!Mx> z=7$-F7Clggn$h*<_2m3R9yY$Y&=`9e?f0Q^-ohokID1Jig$yrUpmEHM4cNX08kZ3A z0{m=Oq511sm|W#C;~ME9fk9e?w;mYo&?b886Cf^m>qjBV)YqSeJM0nla~EK&zcUoZ z9P=5AHM72cG{T{M4?~FX26|uzhkXr}9oMtbF55$BL3&nTklnMP^y8$T#AY)Xnw(W9 z2lzWf;f^n}zJZ2PXgtGQ4%M;om~fb4ouM%V*!!U^fTpHjJwJP+MskA7&`pCzpT>r6 zJ4MY{M+Fcw&NQq(%&m^lIEZrbFM-C88XmMWjrG7T4*PFl5NcIr?IspWTWCfAYzfes z(XF<(i5}S1VZQ@{wPGFzVl(sAkAhr|HgaC!lIiQG-SE`|yE*J`ezKPqy;)m7dl)qK z%h}k8*FfWxm2LbK8YhU&80gZ-VBm}?;oMVcY7HdXok1{MPP{tVEILW% zdN}O;&<+(pPd{yMfc~_HL;E>E5A5mC0t5945EBFSqY$S8^`|`@cBde7^tt#P&>9$F z9Ep@{6nD+-#P2BO_u9_T_tYy>U2ciYDx#m!h=qThtZX@;4A6@{2HC^D-RG`7YV zBm0=#Jh;;`o9a*dO}76G5>uHb&HU_*o5|f*&Z;PAoa4sj&Au2KofXur_55t_Kx@mr z-d+@y(+VEoHmte+bb!M?GneF^aHhFFVW7jVVTX}1M+mj1E%c`#$Agr603v4J4UJuv z4ftAV#;#=dK*wYlr+E80_fuN#rl&1zsZWS>*xy11+hbfo?2cCQ0x1JB7aC(F9iE4V z?o-32QVi2yMw?q;YiP{Nb#I>uhONhh!g_f}X@!hSf892ylNWL4{!HaB=ez(6jPrTA z4Ti0dZK{D)#CoA&+v^97(+Tr`ioY`yx#Ss}i_jP;d4Y2d%hz0cQ)tYSd+^vWJ#eVQ zz7?drXio|8(;kKCM~6DJ+U@kG$csXT3g#+*XDAp?mPQjkg{H;;TJiR>L0Gf6cD9Fx z|MPWbDO9XWgoW3^CfHH(cGv`H{>CKPf)s-%7xGuoI7@NpMGc-2`Fj!xjUJ4R!@eCF zgNONmz5O{fmXpV{<~aDs=x__11dSb%=ao#A2W_CA{V_BKTwb}GbhKEoJEa#f268eq z4wxK_z0hP>PGTedQfb(Lu~WNtGV5!Gv(_YN94MpT+L2CrV7x;s+*zLx@34n-mZO1l zGA4R*XZ>ls!}bnHx$~CpB0Gg{)bX?ThQ@0=x`<#bgC=JKJA4%yyC@r8x@*4mWA6+N zGlCakRI;I~{xrd1zX%dn4IGY6;*wFIo7rMsq(ZvsM-v_P`5@VFixJZIp|L)rsdlZq zEPx$$sGmI;nh)~OMFe;xGzK3R&jxlb~p$3lzqrsWz)f6)gv0%W$gcg#zMF@AW~I&$+avG0n?yy;vuiM zpZzE_S{Bkz!hMzAdf*s`ePC}HXoIwGf)--9KADKQ1dX9Z$8p*5?;|xVJ3l}BDWzeh zq2)Qy7%*&bIFbhRMURa+Jq{^)kQ>52XmSm4tv-T=-35)q46yt4la0e+bfdpB6lsWN z*p5JJ%k!&d>8~Fh@31%MFVBWX0JOdR^}rN|rVY?1K!gv_k3!5Jpg&D<*e?$-ucp?X ze)j4ErRHK>98;ljofe^{ogSz^o#3#)GAMW64fl73B3q9&s4X3&ADsx7AUR~%@$2~6 zoFmy)oW8aANPWU2hkZ2&MgkjV13#Nv6t)*V#4pG;7^x`Zc=s++ayoI1Kokc0 zX+eYar>PFxg27mfdQNJP?HN)-nX*R>$!!dRw-TCa1?IqcXf2IOt*!m+rJ~LCfiTs21Eb8z+} zv>@aamd#C&X9pLfNl&2R!iLGgMs!S+oyM*<*54UQ2V}`B*JEgT{qh@Sp6s#UwJD?Y zqq7}$ds1#25Qs2nvH-&}4;qI{Uf(}ddB#v`rIYoia~$>#$>wBWQ|3a$X3f#YOuwGY zhaEQ8(L4~QdSiHy;y!BJ6xa_#W0=vc-A(T5B*|7}#KD`#V8ts%Low*)J(tHs58OesrG0IeeVFjn6B+5e#Ry+=*L_ zmlI76=@e-6EVWE%zNTi&rls+4p-GDDiD6}%1+A^Ye~1)&AxqmQ%{!tBcRDz=P(5;+3t6tu@}acs}-1}2fpsG`%c2|_>D?<$=L}l z5Sno$vlUBaGkMeB11VM~ZxYr)qa#=i^s{}zJYy56JlU*{6D1j1FIdUb!Uxdk**HmP zwlw|dVuyW5njCxd5Xbd5)AYb44tue5xp!mz;OyEpT|c_Sp)E?+pCWJWOu1B00E7N5 zH2Q>w>qL!Na-ON30~)7};o5#aH;*lAGCTjFbQ-kQ(h`Ba4XsOVxyEyH^H6RAG?~Y} z;~Qu_3`;J}Zm;I|xgJ_Ot%U5eaPIknjKrYa%FjdNhGN`W+x-`r&17&FLK~9Xl%Jq+gT=6S z#ZmM1{0}#$L6bgN?hZ8BOq?L>O?7#XVX{36ruvj`8sZB^lGi9%|GIyl~`q_p;>rP(#c%>e=*3bl8S$G}^Z$$o>XW%$D)~4H_dXtDUil7n+dGLH6rN^~vRZH=E&l8>!Al)up$v zYWB4cQn5zr6jCy*HMX*`sq2HCkT59wU8KTuT{hmvxS%)MoNfAq-FP0gO+O0Jcf0;{ zx5Kt$yRn<%mi;kOVJL_A_R|;p>bZ}(1KfsCOh>BdmXl# zyN!X}8)O@eRD_<2)XCgbu{|c&H#fBvsrH85W27);P$GD*nVONCI-i>=x6jNSl$+X? zn|hR+3fgbP3jU@Vshl^0opPxX2h7Z#NVPJmU74G@m7A({Q03Z3Al23g3C=*;@q_w= zLk?TPH;n!s3bJ)a3jIZDy^#tz9_*A$1s^gq=OBgd!t4{I&|Rb|9yU`$b5nbAQ%`bJ z&5oFMujZyc&P};z8a;x)!A2_QSg=zr^(O$z2wg zT3p0eK?^eG&XjOJ?N*k4^n}B9^Mu9HThBTXWD9tc)0C8+7u((UZ-s2f{DMXlWLFPVommlp63ZP!RY4;OAx13jVB- z`$x(0=M|q{76Nw(a00HXe8?yxNx~Vpr7|d4!EL4Im*np#o|5{PN~fg$m6Bgm>J{HB zm&Rs&3-I$-lFfSv6aan%_{lHn{gJ^+N&T_X^GotS0hB-U9cO-!Fh2RE9n5pZQ?kJC zO8+ZKJHz-tNE`BPR@Bb|1@VKCa5hT+QL^Vn!L#SZA!$-V*-?@&t#nHIDXVl!mUn|B zRbI&oRPd>gh6D>#R0JiHm6T4&WM%xIT~(!5BZ&_slhyH~JY)zY?LrmbRLN$LY-meJ z&baoF{G^$Q{E~U$%HU;^jyfXW1+pI`D;%Kg|BmF09twLbr8G+%5-c09gVM+zLs~O+4QW>D;s;p`_NeSENxzpImCyoBg z3|AObQwI4Z7k(YZ=a=kpeZ~JB$&UEIo<$lf>1$ldK=>+0luY{L2U{GVc*-KsTPnVl z;wfp@TIu;Ex6BBY-$CWSD5FWxK}TiqUy&@*S(W=MSwIgtUpbAn^ni6y$S6n_9HQh< zC1W6I7YoUF#VI{r1UG4pR8e~kuc*;$pM@W$*KP;BtMk2 zdrj&2C8>Fe&o4RE7pZcut9(lO(IHvSGM>!>k+=wgExn`+E<@sf%M~TBDR~`|4sJkl z27aaVuOV6f9wZ$FyWkf^L;V`B1drQ>glpG03M+r(F z1&RMHqw#}7GD-21A=zVobB_LJDft>CBe)2%5D$&ZkYEqjLDKPhWv~^J4t7D}f8!g5 z9D>78=uwX{)o9h6;1B|9m;CnS5=AF>wYG)Q)2HY6)v49O0!f=mM}>+pjGwm{Z{{1}oI zU4~@nuRyXvS0VYKB>$O`Hx!>=zJmP6ivJ5Kcly6DV10kb(f><3S^pm@V9$O=%WGi^`roVo9pKQpsE$xlFQ)V~GeOCW;!2i)WF$&KayFNT$*mV4AcH_FBhG`H)2Zj^BdM<957`R7JC{|zz% zV%#|M;+_8nnLKq~&;QO1GVN){4af(Qhk}1@lw}@0X&LoXV~`{NDU)zu&bFuRd#fzfZd_PhK>6(SfJmr_HR; z-t8T?%MBh4|2Qcqw!+;Hx?8J;e&EvK&7wYHuKE#?;&z2YzJt)6=9FFkeFUtRQb(CX@LzxLEmLreX&z12%U z18w{h7v1Ygdux3?8YIaX+W4+{N5g4iS7M+&n3=Q7d;DFt0i`8&m~8!E=%IA z?X7K=WLkTQ7^~GqRJ1}56R}nht{RAQB-#r%yaX;zlStJ-M2Isa#uotLRRBaskx~GJ zhZBe!BsvREClFUi%yR8%^cP{yAfAxe=Ga%N`V+7GD?B)F9{-t#5fUF z8pK@^yGnyd5!ob`l>*Vf42X$hTNw~-OM`GJ3nEqYE(_ubi7XOn!v2c2r!`$fQcMw< z5TaLEWLGSQ>}evl90=!EK%65nL%6wuI6@-T6~ruYhD1y`5MFK|=7FAm)jyB*wde2&n*KftXVPghzQ04@fK$0q!8KkXYpoLKpW)%&q_;q9TYT zBBLS*e|Kv!@w}q7omGgiN+9mK!(vw@SY(K763Z%r=wBJc3bCy+h_;nLxKshLO7yM* z;t7c?5^IFLDu}I>LBv-Du})-?=v4(o#cCinh}dc%oU4L3M`Dw3s}AA_iPY*Kwumz% zVyc1gssUn~NT~tBwK|9!Bz6eTnjlV-m{$|TE^(E__!=NWYJu1z=F|e=Q4_=i68l6z zZ4g&Ttf~#-fVf9ub}bMQ9w6Qj86F_~YlFxkaae?Tg1Ad!mnVo!kxgQm2Z;W4KpYd> z>VRnL3Bsi=h%C{&E{G>2vPirs?Darwtpg&y9*C17lSHq&AS!x+I3;4eKseU}agM~h z!p$4R5fZ81ApRxJkcjaD;Z+~R8Ie*SgsV4*8zepuo(({pCNZx8h;!m9iShM8gfs;4 zp_tPUghvAq4@i6@0(?MRA+gE_#K*#V#TqX9AGa0}vm1hqXax3>$Y=yJe;*JzBt8{k zjX~V?u@)6O8(YJzS4B37WsP9bzX>cp6Wf}AXxkWsi!X>9qPH)ICnT~+d?D<9AhtFE z5$^}$mdGU0%NImNe-L*>tUm~6KM?0gd?nlhKpY{F8UW%OafU>UKM1ct5cfn%APCn0 z5I0DCCp?2doF*|Z2*d+%mBjc!5Fx=Jz87CAhJa^iDeED{hM0bYrk2<_NLYzqHQP`mu6sc zETT^{Fi*&2k@?*s3O5I{wJDhR=5S*bna$y*S2GY5TYxAaVq1W4ZVuuc37c?h3E~Ke z)RrI$i8CZ(T7d9s1;Q>;T7htF3E~EcBEqvZh|?tIwFcoLu96tv3aOAbAc~7QZ9sUm z2JwJINfFQ%#1#^&+JY!8?va??21G;{h_WIh41|AM5IH2uiLiDc?vmKm4uqS?Cb29G zM1NfBtQEvIUW?kc1K|=5qN3;>4&n)kEE1K4Jp#m5HZeW|L{*VVqE|SGiXA{y7qJ~c zI7fguN1~>1>j>fqiPVlDYKt=@Vmg5E>IA}5q;vw|+7ZMJ5_N@VXAq}J%AQkxgP*HxT`MfCv)XdVpx#9fV6y5Fw&>PY_Q?WRVCJ_Ff>i_5cyz3q&)K zNupOz5EXlaXdz;IgK+Kz;v9)q!mSU8BP3G$fM_GmkcjCG!mBTcFp<(1gliuVH%PP> zp8Y_aCNZxchzN0&#Q44-Li&T~DCYDB;n5Go0}`D@zyJ_eNURzFqN}(^Vs?KJ5d%SV z7a0RV_zwV)L!ze$8-yQsN$eT~qPNH(z;s=9>6qzJ?MS-X|1jJwwI|PLDU=Zg>L<_fQ5JyO)MuQk8&X9;10>W!3 zh**&_6ohLuh#MqE2+v_4PLr573`D%RN@Dy_5Fs%j62zPs5FW!oJRmVj1jK^4LSj`c zh-7h(#OxRl5yL@@5gEfl_{W0CAu&#bjR0|%#I6w_Qbab1Wy3-Aj{`ALY>NZYb_58Q zco3GUkSHGAdZkoT?FDAafU?9LJ(fB zgSaPBUI*d22*eE%-w970#Ay=qbPx~3RTAT02NALu#P?#(Vh|oWhzBHo6ah;>Tp_V) z35dty9*NnDK}0MC@w3QS3c`O0h#V5XiZB7yj-v5M`>cmxq( zTrzkBv5Gz!Jc5wPBJ;ad6kg6F$TBeT%i+c;R)KI`3E~EcBEoYuh|?tItp?#Du96tP3aOAaAc~7Q zYe0Cc2JwJINfEFX#1#^&)`BQ4?va?i21LX<5M@QiIuQPALFAAqC&Jc)xJzQ!dJt|R zo5ZqpAo_0rQ9*3m0HW=B5H1@*R2028f_OqAi$rB%-vnao1`zR^KvWf(Agt9y$;}Ye zMJz=Pagw5@aN7b=ON^qZEzVGQh^kv5JVgpc9q|!GUE#S6qMn#S;U%t8c#DSHA?k}c z6b-~JiiRR!2ZWDUMA1mxqi8Ie?SyC|GAMk-j}(3)Y!`&TSVs{cvMB;Z=iLxNVjD%U zuk22mY3(xj=f#{{%zs4OhS!T+4o65Vs?#J>0Nw2JxWKNS6*_hIid*8Mg&M{`+# zztb8PJ1lxMr|^-Mpv1`ddz>0*WhFqM2RcbgQDf9)+)C52mGOS^WU32I|vRA zqxL&iqx$PtQT>m@RFjO}*^Ga=_3*xn)?_PV(yNeRdH*&n8|iuBLS6Nwlf$k*zYDUQD^Wshe zRdylLF3ouNt+^r{lH|WtXkqXfs8GeZA!o;O~15r z2UZ}#*tJkxMWj#grA&TWDzXyNeC3eQ;G5NKab+M(MWL^CK+_0Q@=t^OBTZ zBcux`j&Fd|VPn9CH0?(#?k2;5&%t={ev($}@bwt2s5Oo674khN{u-q;PzERqyaJR1 zTmd(L%bV{i@)u6cVBZ4Z`>LM+mw>`>UIZuxlmJQsrGU~vS>P3*9N-GL0p$T~1)}3{ z%`J^@l6(z(1Kb0?1^DF*?g2LeepBQF;0(ZT<;($I1zrQ@0}FtKz#`yvKnJ*0ECH4R z0^l~mZ~nXjlmlD=#>#5N-l8E<0B{2M4Ypr^CqOpv6!;Bz2IK(efe(QTz(>GE;A7wu z;9cN7;9tOL;0*A-h#aA9NaIUnoq*0j7oaP^UkddAdIG(G-asFKFM*baJd5Jz0Dk4G z70?=J1GELgfOY_1nf(R$6?g(<1N;|Ae*>NYIlyx#ix?57HL1u=?Nfjo*;Zg1upQU| z>=apX+KM#pXKR49z&c<9uo>6_Yz1}z2Y`dX8^B>86F3Ge1_ZDW;2wxC8d%;$8+$z*bJ-%Rs)?dTAcxY7pxLc0dNOO10{gMfDL#An;(I1flq-ezzN`}_;IA> zmv$A&Yrto~b>If@Iq(H=6Bqy&1A#~&3Wx^!1MPup7)!o6-WK3i-5z)aa0B=b{B__4 z@Hy}Wa1*!<+yTA>HWk1i%s}F21gisN5YPxNb%!3&W;z}KG|0fhi(;1?A9 z6?g(<1N>&z55N{+E3gKr3{(Ls0tJCWRxvqI^G^E;(YpiO29m%J1!90$U^p-W;QRI0 zfhFh+zZAe1{5u1Eq0dGAuL2zaKcF#S2e|d~n_c{d-X5STa1wPa0WyH)z}I;1-!dK< z698TVCIP9yae%)Vw!qPT$i2WfP}3mOfhoWNU>YzLC<8klXEp*IfHpu$>=|z%kLQqD z0FMIKfG)`6HxUU1tW%_}{RSG?GEL>UurA4d@nFa(pf#Lv!{O$`O^AC+ zH-P&I_Z99h-mrNeWjGCabs7epn=H$=0Gb0$fl#0c-~%)S>H}VY1^6A}IY*Zlh$w&; z3tl+b7+w%Efg`|9UN8-as#ayDH}*=R0p|xYPCodH~%4>>0*f?+VEXaTJCE(WsyQ?2BbEFbEh3^aCmY z1AzW2%>t3YFd!C)0fqzdz<7W|G7cCEi~&Xi$v~3A1jrO%8L$+19asb`ME#Zpz$9Qk zKz%Zh3d{rM0JDLa09VR1U=W?&Pr5!e8%2iB?dR!D9!hk!SLeZVeYH?SAj0~`bn0Q&((g?1)KsnTIa zCJpI;6`usMfTO@M;5hImZ~}M>cpG>ZI0d|;WCWx*IZktTejllSDJ@7`7>9qe=?v7< z03Ffc2TF3{aFSdEJ_0@j&I9Lwv%m%56F>v30EdR*`xM9qJ_D`+4Chr~F>nQ7s4oGW zh_FqwT!!*1@C(4gPk=80v(R;ork}6Cu|kgNPrzf~5pW;) z4)_+h3w*<7e~rXF;78zl-~sRu_yPDCV8ea`iUM`f)jEI&5DlF}!ov!$l`cS0fCm*j zP>?$EogfPU8ej!Vf+Js;l40YirV!u^6ajeZDG!tcc=9O@6a(AdJ%lgu);YSUC4PeV^L9#)fKs|ta{6z3^fH~F; zz%>RM0e*ll;7~FIlJh7SlF?$6f*=Ed0D$3V&;5}`eQCL&U?J04Gi0)|mXNK0p+HBV z0}ug(18sqJ08jIr8;llv)*grkB7x2TBhU%x0r0M&E6@e#4g~Pf+7pRBKyQPPw-W=9 z?ho_@`T+xhLBL=j3K#;ELkkZc- z8SqDW9;A4niUE>;yQe_5h)PS$03t2b9i9ZLW^GNCyDCr!NW=;e#7K z0O0wo5a0yx+{W`8m%#5Zeh%aS&wyWm-+-q;Ht+=a3AhV91|9)F0zUws0N(=-fd{~s zz&pS#;3n_|koGx#+yJfv=Yea$Mc^E88h8sh1)K$r02hEafvdn3;4<(5@Gsz9APYDO z90nNCOh}evnlRJHB$sBK;LS|7jup~~mC)z}K!>IcIa8-|Htr-~I;MOZFzrn~za4!u z|2>YsX+X&yoBI1mpUJ12yeS#pj{%mUtr;OYW+WLwR!VzDg}hmo5oWnFY4|~sVf_%u zZ}gGM%g>n(%u3C&v@sihDW5O$`5X7Cs)Ia{#zJi6dw|&kHiTnIgU=w%UQlO+X86s< zyvXGrqC2p^4VboO=;`Y#u75M*>wHGvs5Hl(hW8-vsxDdOBxh%*uFLAxGT|p{aA`vm!1{a&-JNV3s|u((DZD;p8+Uh+oLd8*)}= z7Bq)}<4DH@9n*$R^N%liu6vd>DdhSq^(zfa6{ITym4QlP-b}5HTTLWu0M&tNKviH1 z7K*=kFjH$E#mCuvtj)*Ue9X;fc;k`17T_HPpPQSvxRFQ?0{Fa~&&~P#oX^quJiQMP z4d8zZB4U|6l_{K+3mu+0OWSTMhkKTE{K8ob_#pBp%{suxyP{!4E0xeL^N+>%v9A2A^{;CZVc!Jy$azbAME4(v z?)FLXpq&>c}Ju5`WBG?@eJr`+>v=A3Dcai3U_eS0X+r<2v z?QcJ+_hirM2kV`zh)^TeV*XY47KeVj@zL=$$xhaW@aKae)rHIJXrTFL;QzJ$`Gvk; zf3-2s;H+o{1M9b2PTNeffv8XJ2=2 zGiqvHTPEODE*O}9p#JG=&8koSIActn!Ddks9;`=2T{<%V@V$G5?AN|(RQz(D-F-0- z2EIAPj9&$(CvJ8enL6>iHF*Z5isJ%`nc9AwwO!}O*GuH(6f7Zj>j<1|tftyt;m>;~ zyKj}j_={+%aQ=C`<%F;v5+5*}|2{9URE+tfm0P+5(d;Jn9n;EGHCE62<#07bcU)e1 z(&vY{H}Rz=!`K%$P_Ci_thLIn;^9(A4_C34vWcr$nUA-=FFss?kbdfFyakY6(LE<^ z(e`ifwJ5_8ru9uxU@4Z7w8OhbotJ8ErdP$9&V2`#H=gRI|8RKr!VKrH{0%SI39x98 z!?<5^f9rbJgYRrKa*Wxo8u)@M5h$?6KS3!pEPZ{kWtn5<9c!vug-NQ~p?X!p16D3D zyuT-8U70t!dnor<(SK~P_yKOU5O+~@8HT#E2wkQ{;Dx4n%P_fO#TIB4%s*-V&g@Ul z_xb4pzA-l0N5(%&6wg4Y|Q8YZF`!y;`lr802tmV{ZzrBD|Mt zt`#2HmNr_~O_ASinDb*e4#M+bYe+-F$ItWuFD!-_Mg3y?y*F#XZIPRa&}xZ%>?W;bTkmgGDW`KJ9JzFm~w}PvKatg=<~wh`Fl~;Q@8T z=GAClTpe)@yzjSlG3d7e@ZV_IVrAa7$g|;aYm(RkyB9FLbAok%Mg)zoe)q z`mKSF=k>(GH5jdyUU;U6+MRm&bURmY`@67I`~Onp*pXAGe%ohH`j&Zw95oiG|8tbp zzO5sktU>!)))%eUq9+Xz*$OC;T{3#a^Ty{gPy$omyI~W{tp?)uT3Ej8BO0tj$;&>X z-8%H(Ys8a%pIG$wkV+koIH5#i^ZfC zJ)uI?&$1&b^;%+B7;~UW$*0? z!S8+BdHFG{&L-YIzF3~S14YqIusj?nYHdQyuLT_*y-Dk2bvOU2>%{xR&do0MU9~b+ z%pc=(DOlXxj0oKfmQKr;tyywP^{!{jSREk^{b_O^Ih^QYVt0Ssrd8`ld1bSMh4&Ve zEfgYS_~Xy@w$F50m(8+%hWE0_;UH(7eWSH=M(e(LWov|pNhs^y7#1AtzI&Z}MaNE$ zE2Ab3e*YCB_R@PFSd@iD`oMk*3T?Ph(`cFDJwfG^h!`k-?6Nv2&-?Tc@tkEdV8Pwh zHmu!<^JzJh?TJqhkMz1h4 z@$q$tbXfR)8z48eMfLm4tMx`m0QXV&@bUKl^C6Oh=wl8hHs}i5aHeq!6`i(eu1#LD zHFHW!)>gB*6j-QI8F<;kdwIhcltV%0WWRa0S5675ZD{z1AWdBDWHMNvIeT->G& zweCF}uw5%))oL^ojvbgLSiHS&f%@2F*)HJL1YxBePc4_WbU-NIHU+&O)%XbTI zHO}*8{=IpRA3y&-x!ezjk>f8L_*+Xcc{eWJ86xBenp3cqoKPP)->cQG!~17ps1{bK zR^luS-7CR@6Km7b)%R^x1~r2P_aF4AZmXqxFnzT)q66inHe%%-v|k=$3mF^ax%Q&c zUTm^g+siRG5hl@mTTGepz>OOd>7cNfFMjnNwsX8LNse&S4zgF3OMTDICz2AIj zTlx6|Ua#4IH0*~65&i~T?(HDv?}r@TQEc50*JC=0>-(|cbm@d!WyIyRA&d7Nzjo#e zRT2%1>LdmnKnYwG#vee5q)y_8ceJwNWBx8$45{I!#KdH@^jzq?`mO)!6tbe2Oi ztB~jFhzIjJ9Z*R1MZ$MU3ya6hDl<|OELp1gdSOOm$Zt+k z{_*`96B0`B#NjKix-Xm1<}USO1VpV&t+Dn?f6*@!C%2FRa;6t<|EP4E`iY%1s}C49 zHAyVU#4)k>K(Rd&>$vbi=_prZ!_bX2Crv9^-iU<{ z=h92(pK%Z^G)U}*58u|2axAVL8nLTf%H+)`t0qEESGs3tranmyUh_f)%(gusK zQPBOP!LkE)x4v5Z^5ueDCaMvp<+Z`0!*MkCB@?@mJh%P(gf=D>)+?s`%Huf8w-Fy6 z$DQB5-#xK$ys9l7DmT;}ReP;}I^*{mM!Ss-b;D3mDGSv%93~t3UC}CTMW)vDF=Ap^ z)Q`a<7)0y9-lbC)i1^t?PLlwv$FLX?mxa;m8zYWpAxa}+#6$W@MTv4K(NFU^xWD9< zg+>WuZ(fX?TF9C3dY7oOZ69A&wKNQ{9E}mdC*b4V7?F4aJ}<mCdXo(AjBOeo|R>c+z=&U4&dS`8N^-a>o24i_yc2M!nG zDX}&0d<(Y;hvGz;lbEsZ#u;ydr?2>-^o~>4Gh9>;af8$>Ue3A+P3zun>d=fk1|EQSR-I((A+ zjJG^ERGPHE)0 zjGEt~{GGW^kfUz=?j#8JQ)pReqO7+;fqA1B#YGCEtTA3+C5le4@I5z5-ds%UWUn@U z^|&3d;PQg^Kaa@WQDXHe%%rd2o#*>w&vujyZM<=qDvMq{8zt_;yQU(fnQ>^5EH|%s z&q`6-7e{r}tm*`2X*5R8o6dJSr?s>nc?w@m7(+aM93w`)17DNJirMdAzRVdbjzf2! zJXZShZ}z=(Ih$p)&p3_TA19vu3qGHW6OMP` z(>7itLAsY1FH7wGVCS_uU*M%YdF8;|D?eVm^)BL76BeA`N4`9?d_lGP=K6z$H*z=& zo{oLEu+7HQdU<7o#*1R_Y0I_m$BXUnY3bUy6jASAI2+1V3{|?Tm~rd^&TuSdS;A)Z@m4mZ^As`YA!NlVqUc$)vdI)Vw}ZZ{ z^iF~4Uwp`!;@f~-Y&%6Xg@x9Air9@3?qgwD9sTitv*JUK!QwbAn*`wCY{{4+=F``4 zSa8q$F=+XPV4LU1c^0>)h?~{|_$Pj*itDuWm@0Sb(q7;8vnBck$s5N$~ zi2D+j(_qP)@{iI&cE2{Y@z^}e)l4yA|zb94F^+*;IHJ~myX!$P|} zT{O6@m931q_fWa*I_^r)o;nY_@{9}45a(%JX~xpaT3H*~95_Qy32ds-2lFirqxxRI5aKj#ge)XjQlMCup*n`KRT~w)$w(UlZZiU*gXg%Die0&J`Ul!|dE#aUlmojFy;{yeMCrCMtc3 zc>OxhVyS}I4;gl6*OpB?_~1z08Y$nV&lqEJl(k`snZD%gD{I4{4Rh-u+^LzAuVxOq z2)=^hQl;GgWP+lts>Xk7y)hGuUWNNY3&h^LxKj+eiaSNK8G9Fq`OGx+7v&2bk=eOE z#rA8MTYm_otLDB)l;FmZzdku9{xlq}V)tiSjOi%fn3`kvZvo78&vBAVq^vk|UBh1$ zHP&OkD4Nmx<1Brl$1lyq=4dRJKW6&2ULyC@r}Jxm8y)MmPwf&oPedZ88YYBK(yk*B zzs>(7FUO3l+z!IEjHSZyMZUOK6JlS~c>T&zqW6-6~je z`!26Bxh+IZM$7VF68>#Mn}uJj*5vVgxK|VC@#ZaN5`YRx}<(nrEs7>qQ)VXnWR+VvM+I%AaljI2)K_BX?tC zp8R1onE7Vxn@kb)a7O&IQ>OO9SyEHy!M|}1eDQQ=hVf5Mb${bDr_N}aJc()Qy!HS6 zbCz5$u9|t?`oksgzw=di-{QcX6e}*=1SN$J5LjUE^zHmi# zcg9^+sYTF zVD29B&)&g)d2!$TuRmt_-`hX`3lBWZ*!}KzaAm+vMZB*oy6Z4_x`J zxp6Cm$Mg8Q$Tsl)?pCnC?=`)B8d_HD5OWI_aCP4d3qE@M^8V5C zZ&k_p$gsd;XMBHZr+5n%zG*w9k8hTaezY$1mt%ZMuA#rTPXo&$m2>i|MO#akYx8Sf z&W4?$SRs^s0~UOw-JtpMfrkqg*YYe*tDN=QCJ%q;T=QaH&SyJCKa_QUs4SdII_e&= zE}Xvlp*i=9j#jlwG2pGg>xJS)BeQz4D}u-Ss?+b-TnMSoj`-1s_v& zaa^eDUa{nsJd3j`$E94$d#iVk{a0Sj?OkFwy+2VFaf8|RNwmHj|8}0iONY)V z>bXmFVA&8=_Ooe2TQ^*K?POlrmkym#HhPy>iL&k~s;nM#zxwcb%V+16owrL|p!apK z;OS*`g$74IzBjl}p2bUt&hUPAm#9<(WpAS_2X{y7*#1eOwO8hqed*8{WifPdD9e}V zW){J72lEA`7vDD0@ZK%{!|!wd^P4m7CuBQzUVEj<$zM8r#8++ojo)tioDjiy2@s zggC)Rtfx+hD@6;0*VtP?F2f~#E-ejj5p}N`&sKPNjBl+JLKZ&G&SLw;0u{tymjX5a zpRs@hrg!i%8%Q<9LCUWd3)3@<6@33JI3UVToZNnim${u;DKj@!x1_QlwKzYqv?Rl% zJT)n?AhS3>FEd#;IU_MIFEwX-r66-6ryR17zOFus5?#aT55<|4x8Iav{v*D<+=RJ) z4j0EMh)o)&rd?!Ko33$@S%IqnDrRsR;@I4a%#XPePD2znoCYgwxCB&q0xI?btT2sB Q;S5BL&zb2`mzf^{00Y58ng9R* delta 45647 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 H2m11FZq^~F diff --git a/classes/search/search-manager.ts b/classes/search/search-manager.ts new file mode 100644 index 00000000..f1f5b601 --- /dev/null +++ b/classes/search/search-manager.ts @@ -0,0 +1,283 @@ +/** + * @file search-manager.ts + * @description Sonic search integration for indexing and searching accounts and statuses + */ + +import { getLogger } from "@logtape/logtape"; +import { + Ingest as SonicChannelIngest, + Search as SonicChannelSearch, +} from "sonic-channel"; +import { db } from "~/drizzle/db"; +import { type Config, config } from "~/packages/config-manager"; +import { Note } from "~/packages/database-interface/note"; +import { User } from "~/packages/database-interface/user"; + +/** + * Enum for Sonic index types + */ +export enum SonicIndexType { + Accounts = "accounts", + Statuses = "statuses", +} + +/** + * Class for managing Sonic search operations + */ +export class SonicSearchManager { + private searchChannel: SonicChannelSearch; + private ingestChannel: SonicChannelIngest; + private logger = getLogger("sonic"); + + /** + * @param config Configuration for Sonic + */ + constructor(private config: Config) { + this.searchChannel = new SonicChannelSearch({ + host: config.sonic.host, + port: config.sonic.port, + auth: config.sonic.password, + }); + + this.ingestChannel = new SonicChannelIngest({ + host: config.sonic.host, + port: config.sonic.port, + auth: config.sonic.password, + }); + } + + /** + * Connect to Sonic + */ + async connect(): Promise { + if (!this.config.sonic.enabled) { + this.logger.info`Sonic search is disabled`; + return; + } + + this.logger.info`Connecting to Sonic...`; + + // Connect to Sonic + await new Promise((resolve, reject) => { + this.searchChannel.connect({ + connected: () => { + this.logger.info`Connected to Sonic Search Channel`; + resolve(true); + }, + disconnected: () => + this.logger + .error`Disconnected from Sonic Search Channel. You might be using an incorrect password.`, + timeout: () => + this.logger + .error`Sonic Search Channel connection timed out`, + retrying: () => + this.logger + .warn`Retrying connection to Sonic Search Channel`, + error: (error) => { + this.logger + .error`Failed to connect to Sonic Search Channel: ${error}`; + reject(error); + }, + }); + }); + + await new Promise((resolve, reject) => { + this.ingestChannel.connect({ + connected: () => { + this.logger.info`Connected to Sonic Ingest Channel`; + resolve(true); + }, + disconnected: () => + this.logger.error`Disconnected from Sonic Ingest Channel`, + timeout: () => + this.logger + .error`Sonic Ingest Channel connection timed out`, + retrying: () => + this.logger + .warn`Retrying connection to Sonic Ingest Channel`, + error: (error) => { + this.logger + .error`Failed to connect to Sonic Ingest Channel: ${error}`; + reject(error); + }, + }); + }); + + try { + await Promise.all([ + this.searchChannel.ping(), + this.ingestChannel.ping(), + ]); + this.logger.info`Connected to Sonic`; + } catch (error) { + this.logger.fatal`Error while connecting to Sonic: ${error}`; + throw error; + } + } + + /** + * Add a user to Sonic + * @param user User to add + */ + async addUser(user: User): Promise { + if (!this.config.sonic.enabled) { + return; + } + + try { + await this.ingestChannel.push( + SonicIndexType.Accounts, + "users", + user.id, + `${user.data.username} ${user.data.displayName} ${user.data.note}`, + ); + } catch (error) { + this.logger.error`Failed to add user to Sonic: ${error}`; + } + } + + /** + * Get a batch of accounts from the database + * @param n Batch number + * @param batchSize Size of the batch + */ + private async getNthDatabaseAccountBatch( + n: number, + batchSize = 1000, + ): Promise[]> { + return db.query.Users.findMany({ + offset: n * batchSize, + limit: batchSize, + columns: { + id: true, + username: true, + displayName: true, + note: true, + createdAt: true, + }, + orderBy: (user, { asc }) => asc(user.createdAt), + }); + } + + /** + * Get a batch of statuses from the database + * @param n Batch number + * @param batchSize Size of the batch + */ + private async getNthDatabaseStatusBatch( + n: number, + batchSize = 1000, + ): Promise[]> { + return db.query.Notes.findMany({ + offset: n * batchSize, + limit: batchSize, + columns: { + id: true, + content: true, + createdAt: true, + }, + orderBy: (status, { asc }) => asc(status.createdAt), + }); + } + + /** + * Rebuild search indexes + * @param indexes Indexes to rebuild + * @param batchSize Size of each batch + */ + async rebuildSearchIndexes( + indexes: SonicIndexType[], + batchSize = 100, + ): Promise { + for (const index of indexes) { + if (index === SonicIndexType.Accounts) { + await this.rebuildAccountsIndex(batchSize); + } else if (index === SonicIndexType.Statuses) { + await this.rebuildStatusesIndex(batchSize); + } + } + } + + /** + * Rebuild accounts index + * @param batchSize Size of each batch + */ + private async rebuildAccountsIndex(batchSize: number): Promise { + const accountCount = await User.getCount(); + const batchCount = Math.ceil(accountCount / batchSize); + + for (let i = 0; i < batchCount; i++) { + const accounts = await this.getNthDatabaseAccountBatch( + i, + batchSize, + ); + await Promise.all( + accounts.map((account) => + this.ingestChannel.push( + SonicIndexType.Accounts, + "users", + account.id as string, + `${account.username} ${account.displayName} ${account.note}`, + ), + ), + ); + this.logger.info`Indexed accounts batch ${i + 1}/${batchCount}`; + } + } + + /** + * Rebuild statuses index + * @param batchSize Size of each batch + */ + private async rebuildStatusesIndex(batchSize: number): Promise { + const statusCount = await Note.getCount(); + const batchCount = Math.ceil(statusCount / batchSize); + + for (let i = 0; i < batchCount; i++) { + const statuses = await this.getNthDatabaseStatusBatch(i, batchSize); + await Promise.all( + statuses.map((status) => + this.ingestChannel.push( + SonicIndexType.Statuses, + "notes", + status.id as string, + status.content as string, + ), + ), + ); + this.logger.info`Indexed statuses batch ${i + 1}/${batchCount}`; + } + } + + /** + * Search for accounts + * @param query Search query + * @param limit Maximum number of results + * @param offset Offset for pagination + */ + searchAccounts(query: string, limit = 10, offset = 0): Promise { + return this.searchChannel.query( + SonicIndexType.Accounts, + "users", + query, + { limit, offset }, + ); + } + + /** + * Search for statuses + * @param query Search query + * @param limit Maximum number of results + * @param offset Offset for pagination + */ + searchStatuses(query: string, limit = 10, offset = 0): Promise { + return this.searchChannel.query( + SonicIndexType.Statuses, + "notes", + query, + { limit, offset }, + ); + } +} + +export const searchManager = new SonicSearchManager(config); diff --git a/config/config.example.toml b/config/config.example.toml index fc9854dd..c80b4d80 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -24,11 +24,11 @@ password = "" database = 1 enabled = false -[meilisearch] -# If Meilisearch is not configured, search will not be enabled +[sonic] +# If Sonic is not configured, search will not be enabled host = "localhost" port = 40007 -api_key = "" +password = "" enabled = true [signups] diff --git a/docker-compose.yml b/docker-compose.yml index c6535641..f8bbda8e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: depends_on: - db - redis - - meilisearch + - sonic - fe fe: @@ -48,18 +48,11 @@ services: networks: - lysand-net - meilisearch: - stdin_open: true - environment: - - MEILI_MASTER_KEY=__________________ - tty: true - networks: - - lysand-net + sonic: volumes: - - ./meili-data:/meili_data - image: getmeili/meilisearch:v1.7 - container_name: lysand-meilisearch - restart: unless-stopped + - ./config.cfg:/etc/sonic.cfg + - ./store/:/var/lib/sonic/store/ + image: valeriansaliou/sonic:v1.4.9 networks: lysand-net: \ No newline at end of file diff --git a/docs/installation.md b/docs/installation.md index f1a2ea35..6b7cdf36 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -6,7 +6,7 @@ - Lysand will work on lower versions than 1.1.17, but only the latest version is supported - A PostgreSQL database - (Optional but recommended) A Linux-based operating system -- (Optional if you want search) A working Meilisearch instance +- (Optional if you want search) A working [Sonic](https://github.com/valeriansaliou/sonic) instance > [!WARNING] > Lysand has not been tested on Windows or macOS. It is recommended to use a Linux-based operating system to run Lysand. @@ -59,7 +59,7 @@ bun install 1. Set up a PostgreSQL database (you need a special extension, please look at [the database documentation](database.md)) 2. (If you want search) -Create a Meilisearch instance (using Docker is recommended). For a [`docker-compose`] file, copy the `meilisearch` service from the [`docker-compose.yml`](../docker-compose.yml) file. +Create a [Sonic](https://github.com/valeriansaliou/sonic) instance (using Docker is recommended). For a [`docker-compose`] file, copy the `sonic` service from the [`docker-compose.yml`](../docker-compose.yml) file. Don't forget to fill in the `config.cfg` for Sonic! 1. Build everything: diff --git a/package.json b/package.json index b461201b..4e7252d8 100644 --- a/package.json +++ b/package.json @@ -130,13 +130,13 @@ "markdown-it-anchor": "^9.0.1", "markdown-it-container": "^4.0.0", "markdown-it-toc-done-right": "^4.2.0", - "meilisearch": "^0.40.0", "mime-types": "^2.1.35", "oauth4webapi": "^2.11.1", "ora": "^8.0.1", "pg": "^8.12.0", "qs": "^6.12.1", "sharp": "^0.33.4", + "sonic-channel": "^1.3.1", "string-comparison": "^1.3.0", "stringify-entities": "^4.0.4", "strip-ansi": "^7.1.0", diff --git a/packages/config-manager/config.type.ts b/packages/config-manager/config.type.ts index 7bc4867e..af9d202d 100644 --- a/packages/config-manager/config.type.ts +++ b/packages/config-manager/config.type.ts @@ -76,7 +76,7 @@ export const configValidator = z.object({ enabled: false, }), }), - meilisearch: z.object({ + sonic: z.object({ host: z.string().min(1).default("localhost"), port: z .number() @@ -84,7 +84,7 @@ export const configValidator = z.object({ .min(1) .max(2 ** 16 - 1) .default(7700), - api_key: z.string(), + password: z.string(), enabled: z.boolean().default(false), }), signups: z.object({ diff --git a/packages/database-interface/user.ts b/packages/database-interface/user.ts index 9469b106..10da1395 100644 --- a/packages/database-interface/user.ts +++ b/packages/database-interface/user.ts @@ -1,7 +1,6 @@ import { idValidator } from "@/api"; import { getBestContentType, urlToContentFormat } from "@/content_types"; import { randomString } from "@/math"; -import { addUserToMeilisearch } from "@/meilisearch"; import { proxyUrl } from "@/response"; import type { Account as ApiAccount, @@ -31,6 +30,7 @@ import { type UserWithRelations, findManyUsers, } from "~/classes/functions/user"; +import { searchManager } from "~/classes/search/search-manager"; import { db } from "~/drizzle/db"; import { EmojiToUser, @@ -294,8 +294,8 @@ export class User extends BaseInterface { throw new Error("Failed to save user from remote"); } - // Add to Meilisearch - await addUserToMeilisearch(finalUser); + // Add to search index + await searchManager.addUser(finalUser); return finalUser; } @@ -477,8 +477,8 @@ export class User extends BaseInterface { throw new Error("Failed to create user"); } - // Add to Meilisearch - await addUserToMeilisearch(finalUser); + // Add to search index + await searchManager.addUser(finalUser); return finalUser; } diff --git a/server/api/api/v2/search/index.ts b/server/api/api/v2/search/index.ts index 57952c9d..b2b0636a 100644 --- a/server/api/api/v2/search/index.ts +++ b/server/api/api/v2/search/index.ts @@ -1,5 +1,4 @@ import { applyConfig, auth, handleZodError, userAddressValidator } from "@/api"; -import { MeiliIndexType, meilisearch } from "@/meilisearch"; import { errorResponse, jsonResponse } from "@/response"; import { zValidator } from "@hono/zod-validator"; import { getLogger } from "@logtape/logtape"; @@ -7,6 +6,7 @@ import { and, eq, inArray, sql } from "drizzle-orm"; import type { Hono } from "hono"; import { z } from "zod"; import { resolveWebFinger } from "~/classes/functions/user"; +import { searchManager } from "~/classes/search/search-manager"; import { db } from "~/drizzle/db"; import { Instances, Notes, RolePermissions, Users } from "~/drizzle/schema"; import { config } from "~/packages/config-manager"; @@ -65,12 +65,19 @@ export default (app: Hono) => ); } - if (!config.meilisearch.enabled) { - return errorResponse("Meilisearch is not enabled", 501); + if (!q) { + return errorResponse("Query is required", 400); } - let accountResults: { id: string }[] = []; - let statusResults: { id: string }[] = []; + if (!config.sonic.enabled) { + return errorResponse( + "Search is not enabled by your server administrator", + 501, + ); + } + + let accountResults: string[] = []; + let statusResults: string[] = []; if (!type || type === "accounts") { // Check if q is matching format username@domain.com or @username@domain.com @@ -132,34 +139,26 @@ export default (app: Hono) => } } - accountResults = ( - await meilisearch.index(MeiliIndexType.Accounts).search<{ - id: string; - }>(q, { - limit: Number(limit) || 10, - offset: Number(offset) || 0, - sort: ["createdAt:desc"], - }) - ).hits; + accountResults = await searchManager.searchAccounts( + q, + Number(limit) || 10, + Number(offset) || 0, + ); } if (!type || type === "statuses") { - statusResults = ( - await meilisearch.index(MeiliIndexType.Statuses).search<{ - id: string; - }>(q, { - limit: Number(limit) || 10, - offset: Number(offset) || 0, - sort: ["createdAt:desc"], - }) - ).hits; + statusResults = await searchManager.searchStatuses( + q, + Number(limit) || 10, + Number(offset) || 0, + ); } const accounts = await User.manyFromSql( and( inArray( Users.id, - accountResults.map((hit) => hit.id), + accountResults.map((hit) => hit), ), self ? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${ @@ -175,7 +174,7 @@ export default (app: Hono) => and( inArray( Notes.id, - statusResults.map((hit) => hit.id), + statusResults.map((hit) => hit), ), account_id ? eq(Notes.authorId, account_id) : undefined, self diff --git a/setup.ts b/setup.ts index f468bec3..4f7bf6a1 100644 --- a/setup.ts +++ b/setup.ts @@ -1,10 +1,10 @@ import { checkConfig } from "@/init"; import { configureLoggers } from "@/loggers"; -import { connectMeili } from "@/meilisearch"; import { getLogger } from "@logtape/logtape"; import { config } from "config-manager"; import { setupDatabase } from "~/drizzle/db"; import { Note } from "~/packages/database-interface/note"; +import { searchManager } from "./classes/search/search-manager"; const timeAtStart = performance.now(); @@ -16,8 +16,8 @@ serverLogger.info`Starting Lysand...`; await setupDatabase(); -if (config.meilisearch.enabled) { - await connectMeili(); +if (config.sonic.enabled) { + await searchManager.connect(); } process.on("SIGINT", () => { diff --git a/tests/utils.ts b/tests/utils.ts index 07de9a5b..31e0e331 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -4,13 +4,18 @@ import { solveChallenge } from "altcha-lib"; import { asc, inArray, like } from "drizzle-orm"; import { appFactory } from "~/app"; import type { Status } from "~/classes/functions/status"; +import { searchManager } from "~/classes/search/search-manager"; import { db } from "~/drizzle/db"; import { setupDatabase } from "~/drizzle/db"; import { Notes, Tokens, Users } from "~/drizzle/schema"; +import { config } from "~/packages/config-manager"; import { Note } from "~/packages/database-interface/note"; import { User } from "~/packages/database-interface/user"; await setupDatabase(); +if (config.sonic.enabled) { + await searchManager.connect(); +} /** * This allows us to send a test request to the server even when it isnt running diff --git a/utils/loggers.ts b/utils/loggers.ts index 0e372a31..c4377f8d 100644 --- a/utils/loggers.ts +++ b/utils/loggers.ts @@ -221,7 +221,7 @@ export const configureLoggers = (silent = false) => filters: ["configFilter"], }, { - category: "meilisearch", + category: "sonic", sinks: ["console", "file"], filters: ["configFilter"], }, diff --git a/utils/meilisearch.ts b/utils/meilisearch.ts deleted file mode 100644 index 5a75f205..00000000 --- a/utils/meilisearch.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { getLogger } from "@logtape/logtape"; -import { config } from "config-manager"; -import { count } from "drizzle-orm"; -import { Meilisearch } from "meilisearch"; -import { db } from "~/drizzle/db"; -import { Notes, Users } from "~/drizzle/schema"; -import type { User } from "~/packages/database-interface/user"; - -export const meilisearch = new Meilisearch({ - host: `${config.meilisearch.host}:${config.meilisearch.port}`, - apiKey: config.meilisearch.api_key, -}); - -export const connectMeili = async () => { - const logger = getLogger("meilisearch"); - if (!config.meilisearch.enabled) { - return; - } - - if (await meilisearch.isHealthy()) { - await meilisearch - .index(MeiliIndexType.Accounts) - .updateSortableAttributes(["createdAt"]); - - await meilisearch - .index(MeiliIndexType.Accounts) - .updateSearchableAttributes(["username", "displayName", "note"]); - - await meilisearch - .index(MeiliIndexType.Statuses) - .updateSortableAttributes(["createdAt"]); - - await meilisearch - .index(MeiliIndexType.Statuses) - .updateSearchableAttributes(["content"]); - - logger.info`Connected to Meilisearch`; - } else { - logger.fatal`Error while connecting to Meilisearch`; - // Hang until Ctrl+C is pressed - await Bun.sleep(Number.POSITIVE_INFINITY); - } -}; - -export enum MeiliIndexType { - Accounts = "accounts", - Statuses = "statuses", -} - -export const addUserToMeilisearch = async (user: User) => { - if (!config.meilisearch.enabled) { - return; - } - - await meilisearch.index(MeiliIndexType.Accounts).addDocuments([ - { - id: user.id, - username: user.data.username, - displayName: user.data.displayName, - note: user.data.note, - createdAt: user.data.createdAt, - }, - ]); -}; - -export const getNthDatabaseAccountBatch = ( - n: number, - batchSize = 1000, -): Promise[]> => { - return db.query.Users.findMany({ - offset: n * batchSize, - limit: batchSize, - columns: { - id: true, - username: true, - displayName: true, - note: true, - createdAt: true, - }, - orderBy: (user, { asc }) => asc(user.createdAt), - }); -}; - -export const getNthDatabaseStatusBatch = ( - n: number, - batchSize = 1000, -): Promise[]> => { - return db.query.Notes.findMany({ - offset: n * batchSize, - limit: batchSize, - columns: { - id: true, - content: true, - createdAt: true, - }, - orderBy: (status, { asc }) => asc(status.createdAt), - }); -}; - -export const rebuildSearchIndexes = async ( - indexes: MeiliIndexType[], - batchSize = 100, -) => { - if (indexes.includes(MeiliIndexType.Accounts)) { - const accountCount = ( - await db - .select({ - count: count(), - }) - .from(Users) - )[0].count; - - for (let i = 0; i < accountCount / batchSize; i++) { - const accounts = await getNthDatabaseAccountBatch(i, batchSize); - - /* const _progress = Math.round( - (i / (accountCount / batchSize)) * 100, - ); - */ - // Sync with Meilisearch - await meilisearch - .index(MeiliIndexType.Accounts) - .addDocuments(accounts); - } - - /* const _meiliAccountCount = ( - await meilisearch.index(MeiliIndexType.Accounts).getStats() - ).numberOfDocuments; */ - } - - if (indexes.includes(MeiliIndexType.Statuses)) { - const statusCount = ( - await db - .select({ - count: count(), - }) - .from(Notes) - )[0].count; - - for (let i = 0; i < statusCount / batchSize; i++) { - const statuses = await getNthDatabaseStatusBatch(i, batchSize); - - /* const _progress = Math.round((i / (statusCount / batchSize)) * 100); */ - - // Sync with Meilisearch - await meilisearch - .index(MeiliIndexType.Statuses) - .addDocuments(statuses); - } - - /* const _meiliStatusCount = ( - await meilisearch.index(MeiliIndexType.Statuses).getStats() - ).numberOfDocuments; */ - } -};