atagen

macltok

public
Star0
Watch0

Commit c9effeac326a

tarball

atagen <boss@atagen.co>2026-10-25 13:25 UTC

22 files changed+4455-7
search: index messages locally and search them

Encrypted rooms can't use the server's /search, so a tantivy index fed from the event cache and a paced /messages backfill answers SearchCommand for every room alike. The fork's matrix-sdk-search was bypassed because its schema is private and its IndexWriter and mmap can't run in wasm, so macltok keeps immutable SingleSegmentIndexWriter segments with tombstone bitsets and merges them itself. Segments are sealed blobs in macltok-search.sqlite3 or the ::macltok-search IndexedDB database and go with the store.
Modified file: Cargo.lock+517 additions−1 deletions
@@ -51,6 +51,12 @@ dependencies = [
5151 ]
5252
5353 [[package]]
54+name = "allocator-api2"
55+version = "0.2.21"
56+source = "registry+https://github.com/rust-lang/crates.io-index"
57+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
58+
59+[[package]]
5460 name = "android_system_properties"
5561 version = "0.1.6"
5662 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -86,6 +92,15 @@ dependencies = [
8692 ]
8793
8894 [[package]]
95+name = "arc-swap"
96+version = "1.9.3"
97+source = "registry+https://github.com/rust-lang/crates.io-index"
98+checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e"
99+dependencies = [
100+ "rustversion",
101+]
102+
103+[[package]]
89104 name = "archery"
90105 version = "1.2.3"
91106 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -290,6 +305,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
290305 checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6"
291306
292307 [[package]]
308+name = "bitpacking"
309+version = "0.9.3"
310+source = "registry+https://github.com/rust-lang/crates.io-index"
311+checksum = "96a7139abd3d9cebf8cd6f920a389cf3dc9576172e32f4563f188cae3c3eb019"
312+dependencies = [
313+ "crunchy",
314+]
315+
316+[[package]]
293317 name = "blake3"
294318 version = "1.8.7"
295319 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -321,6 +345,29 @@ dependencies = [
321345 ]
322346
323347 [[package]]
348+name = "bon"
349+version = "3.10.3"
350+source = "registry+https://github.com/rust-lang/crates.io-index"
351+checksum = "a05442cd7005532d0cfedec676b5b9333d2b4c7c3afabe2b1a9ce2742fbe2ee6"
352+dependencies = [
353+ "bon-macros",
354+]
355+
356+[[package]]
357+name = "bon-macros"
358+version = "3.10.3"
359+source = "registry+https://github.com/rust-lang/crates.io-index"
360+checksum = "93833e97ac7c46ba97045afcf247238c27057538fcf2f06344662ddd5f567be6"
361+dependencies = [
362+ "darling",
363+ "ident_case",
364+ "prettyplease",
365+ "proc-macro2",
366+ "quote",
367+ "syn 3.0.5",
368+]
369+
370+[[package]]
324371 name = "bs58"
325372 version = "0.5.1"
326373 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -381,6 +428,12 @@ dependencies = [
381428 ]
382429
383430 [[package]]
431+name = "census"
432+version = "0.4.2"
433+source = "registry+https://github.com/rust-lang/crates.io-index"
434+checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0"
435+
436+[[package]]
384437 name = "cfg-if"
385438 version = "1.0.4"
386439 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -573,12 +626,46 @@ dependencies = [
573626 ]
574627
575628 [[package]]
629+name = "crossbeam-channel"
630+version = "0.5.17"
631+source = "registry+https://github.com/rust-lang/crates.io-index"
632+checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
633+dependencies = [
634+ "crossbeam-utils",
635+]
636+
637+[[package]]
638+name = "crossbeam-deque"
639+version = "0.8.8"
640+source = "registry+https://github.com/rust-lang/crates.io-index"
641+checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
642+dependencies = [
643+ "crossbeam-epoch",
644+ "crossbeam-utils",
645+]
646+
647+[[package]]
648+name = "crossbeam-epoch"
649+version = "0.9.21"
650+source = "registry+https://github.com/rust-lang/crates.io-index"
651+checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
652+dependencies = [
653+ "crossbeam-utils",
654+]
655+
656+[[package]]
576657 name = "crossbeam-utils"
577658 version = "0.8.23"
578659 source = "registry+https://github.com/rust-lang/crates.io-index"
579660 checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
580661
581662 [[package]]
663+name = "crunchy"
664+version = "0.2.4"
665+source = "registry+https://github.com/rust-lang/crates.io-index"
666+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
667+
668+[[package]]
582669 name = "crypto-common"
583670 version = "0.1.7"
584671 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -637,6 +724,46 @@ dependencies = [
637724 ]
638725
639726 [[package]]
727+name = "darling"
728+version = "0.24.1"
729+source = "registry+https://github.com/rust-lang/crates.io-index"
730+checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
731+dependencies = [
732+ "darling_core",
733+ "darling_macro",
734+]
735+
736+[[package]]
737+name = "darling_core"
738+version = "0.24.1"
739+source = "registry+https://github.com/rust-lang/crates.io-index"
740+checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
741+dependencies = [
742+ "ident_case",
743+ "proc-macro2",
744+ "quote",
745+ "strsim",
746+ "syn 3.0.5",
747+]
748+
749+[[package]]
750+name = "darling_macro"
751+version = "0.24.1"
752+source = "registry+https://github.com/rust-lang/crates.io-index"
753+checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
754+dependencies = [
755+ "darling_core",
756+ "quote",
757+ "syn 3.0.5",
758+]
759+
760+[[package]]
761+name = "datasketches"
762+version = "0.2.0"
763+source = "registry+https://github.com/rust-lang/crates.io-index"
764+checksum = "c286de4e81ea2590afc24d754e0f83810c566f50a1388fa75ebd57928c0d9745"
765+
766+[[package]]
640767 name = "date_header"
641768 version = "1.0.5"
642769 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -723,6 +850,9 @@ name = "deranged"
723850 version = "0.5.8"
724851 source = "registry+https://github.com/rust-lang/crates.io-index"
725852 checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
853+dependencies = [
854+ "serde_core",
855+]
726856
727857 [[package]]
728858 name = "derivative"
@@ -800,6 +930,12 @@ dependencies = [
800930 ]
801931
802932 [[package]]
933+name = "downcast-rs"
934+version = "2.0.2"
935+source = "registry+https://github.com/rust-lang/crates.io-index"
936+checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
937+
938+[[package]]
803939 name = "dunce"
804940 version = "1.0.5"
805941 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -859,6 +995,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
859995 checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
860996
861997 [[package]]
998+name = "erased-serde"
999+version = "0.4.11"
1000+source = "registry+https://github.com/rust-lang/crates.io-index"
1001+checksum = "1e59b668157f32d3459d4bd634861a06ffa2503a461ca5f0916b9f0252605e66"
1002+dependencies = [
1003+ "serde",
1004+ "serde_core",
1005+ "typeid",
1006+]
1007+
1008+[[package]]
8621009 name = "errno"
8631010 version = "0.3.14"
8641011 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -953,6 +1100,12 @@ dependencies = [
9531100 ]
9541101
9551102 [[package]]
1103+name = "fastdivide"
1104+version = "0.4.2"
1105+source = "registry+https://github.com/rust-lang/crates.io-index"
1106+checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471"
1107+
1108+[[package]]
9561109 name = "fastrand"
9571110 version = "2.5.0"
9581111 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -994,6 +1147,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
9941147 checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
9951148
9961149 [[package]]
1150+name = "foldhash"
1151+version = "0.2.0"
1152+source = "registry+https://github.com/rust-lang/crates.io-index"
1153+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
1154+
1155+[[package]]
9971156 name = "form_urlencoded"
9981157 version = "1.2.2"
9991158 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1233,7 +1392,18 @@ version = "0.15.5"
12331392 source = "registry+https://github.com/rust-lang/crates.io-index"
12341393 checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
12351394 dependencies = [
1236- "foldhash",
1395+ "foldhash 0.1.5",
1396+]
1397+
1398+[[package]]
1399+name = "hashbrown"
1400+version = "0.16.1"
1401+source = "registry+https://github.com/rust-lang/crates.io-index"
1402+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
1403+dependencies = [
1404+ "allocator-api2",
1405+ "equivalent",
1406+ "foldhash 0.2.0",
12371407 ]
12381408
12391409 [[package]]
@@ -1286,6 +1456,12 @@ dependencies = [
12861456 ]
12871457
12881458 [[package]]
1459+name = "htmlescape"
1460+version = "0.3.1"
1461+source = "registry+https://github.com/rust-lang/crates.io-index"
1462+checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163"
1463+
1464+[[package]]
12891465 name = "http"
12901466 version = "1.5.0"
12911467 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1498,6 +1674,12 @@ dependencies = [
14981674 ]
14991675
15001676 [[package]]
1677+name = "ident_case"
1678+version = "1.0.1"
1679+source = "registry+https://github.com/rust-lang/crates.io-index"
1680+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
1681+
1682+[[package]]
15011683 name = "idna"
15021684 version = "1.1.0"
15031685 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1610,6 +1792,15 @@ dependencies = [
16101792 ]
16111793
16121794 [[package]]
1795+name = "inventory"
1796+version = "0.3.25"
1797+source = "registry+https://github.com/rust-lang/crates.io-index"
1798+checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c"
1799+dependencies = [
1800+ "rustversion",
1801+]
1802+
1803+[[package]]
16131804 name = "ipnet"
16141805 version = "2.12.2"
16151806 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1750,6 +1941,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
17501941 checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
17511942
17521943 [[package]]
1944+name = "levenshtein_automata"
1945+version = "0.2.1"
1946+source = "registry+https://github.com/rust-lang/crates.io-index"
1947+checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25"
1948+
1949+[[package]]
17531950 name = "libc"
17541951 version = "0.2.189"
17551952 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1813,12 +2010,27 @@ dependencies = [
18132010 ]
18142011
18152012 [[package]]
2013+name = "lru"
2014+version = "0.16.4"
2015+source = "registry+https://github.com/rust-lang/crates.io-index"
2016+checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
2017+dependencies = [
2018+ "hashbrown 0.16.1",
2019+]
2020+
2021+[[package]]
18162022 name = "lru-slab"
18172023 version = "0.1.2"
18182024 source = "registry+https://github.com/rust-lang/crates.io-index"
18192025 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
18202026
18212027 [[package]]
2028+name = "lz4_flex"
2029+version = "0.13.1"
2030+source = "registry+https://github.com/rust-lang/crates.io-index"
2031+checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e"
2032+
2033+[[package]]
18222034 name = "macltok"
18232035 version = "0.1.0"
18242036 dependencies = [
@@ -1829,6 +2041,7 @@ dependencies = [
18292041 "http",
18302042 "imbl",
18312043 "js-sys",
2044+ "log",
18322045 "matrix-sdk",
18332046 "matrix-sdk-base",
18342047 "matrix-sdk-crypto",
@@ -1843,6 +2056,8 @@ dependencies = [
18432056 "serde",
18442057 "serde_json",
18452058 "sha2",
2059+ "tantivy",
2060+ "tantivy-common",
18462061 "thiserror 2.0.20",
18472062 "tokio",
18482063 "tracing",
@@ -2346,6 +2561,15 @@ dependencies = [
23462561 ]
23472562
23482563 [[package]]
2564+name = "measure_time"
2565+version = "0.9.0"
2566+source = "registry+https://github.com/rust-lang/crates.io-index"
2567+checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e"
2568+dependencies = [
2569+ "log",
2570+]
2571+
2572+[[package]]
23492573 name = "memchr"
23502574 version = "2.8.3"
23512575 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2374,6 +2598,12 @@ dependencies = [
23742598 ]
23752599
23762600 [[package]]
2601+name = "minimal-lexical"
2602+version = "0.2.1"
2603+source = "registry+https://github.com/rust-lang/crates.io-index"
2604+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
2605+
2606+[[package]]
23772607 name = "miniz_oxide"
23782608 version = "0.9.1"
23792609 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2395,12 +2625,28 @@ dependencies = [
23952625 ]
23962626
23972627 [[package]]
2628+name = "murmurhash32"
2629+version = "0.3.1"
2630+source = "registry+https://github.com/rust-lang/crates.io-index"
2631+checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b"
2632+
2633+[[package]]
23982634 name = "new_debug_unreachable"
23992635 version = "1.0.6"
24002636 source = "registry+https://github.com/rust-lang/crates.io-index"
24012637 checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
24022638
24032639 [[package]]
2640+name = "nom"
2641+version = "7.1.3"
2642+source = "registry+https://github.com/rust-lang/crates.io-index"
2643+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
2644+dependencies = [
2645+ "memchr",
2646+ "minimal-lexical",
2647+]
2648+
2649+[[package]]
24042650 name = "nu-ansi-term"
24052651 version = "0.50.3"
24062652 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2471,6 +2717,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
24712717 checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
24722718
24732719 [[package]]
2720+name = "oneshot"
2721+version = "0.1.13"
2722+source = "registry+https://github.com/rust-lang/crates.io-index"
2723+checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107"
2724+
2725+[[package]]
24742726 name = "oorandom"
24752727 version = "11.1.5"
24762728 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2489,6 +2741,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
24892741 checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
24902742
24912743 [[package]]
2744+name = "ordered-float"
2745+version = "5.5.0"
2746+source = "registry+https://github.com/rust-lang/crates.io-index"
2747+checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0"
2748+dependencies = [
2749+ "num-traits",
2750+]
2751+
2752+[[package]]
2753+name = "ownedbytes"
2754+version = "0.9.0"
2755+source = "registry+https://github.com/rust-lang/crates.io-index"
2756+checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e"
2757+dependencies = [
2758+ "stable_deref_trait",
2759+]
2760+
2761+[[package]]
24922762 name = "parking"
24932763 version = "2.2.1"
24942764 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2677,6 +2947,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
26772947 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
26782948
26792949 [[package]]
2950+name = "prettyplease"
2951+version = "0.3.0"
2952+source = "registry+https://github.com/rust-lang/crates.io-index"
2953+checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
2954+dependencies = [
2955+ "proc-macro2",
2956+ "syn 3.0.5",
2957+]
2958+
2959+[[package]]
26802960 name = "proc-macro-crate"
26812961 version = "3.5.0"
26822962 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2911,6 +3191,26 @@ dependencies = [
29113191 ]
29123192
29133193 [[package]]
3194+name = "rayon"
3195+version = "1.12.0"
3196+source = "registry+https://github.com/rust-lang/crates.io-index"
3197+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
3198+dependencies = [
3199+ "either",
3200+ "rayon-core",
3201+]
3202+
3203+[[package]]
3204+name = "rayon-core"
3205+version = "1.13.0"
3206+source = "registry+https://github.com/rust-lang/crates.io-index"
3207+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
3208+dependencies = [
3209+ "crossbeam-deque",
3210+ "crossbeam-utils",
3211+]
3212+
3213+[[package]]
29143214 name = "readlock"
29153215 version = "0.1.11"
29163216 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3551,6 +3851,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
35513851 checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
35523852
35533853 [[package]]
3854+name = "sketches-ddsketch"
3855+version = "0.4.1"
3856+source = "registry+https://github.com/rust-lang/crates.io-index"
3857+checksum = "513c3f5f732bfd6fbb187619c2dfe9d2f25f1a2976f01d575f0fd329d565df56"
3858+dependencies = [
3859+ "serde",
3860+]
3861+
3862+[[package]]
35543863 name = "slab"
35553864 version = "0.4.12"
35563865 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3616,6 +3925,12 @@ dependencies = [
36163925 ]
36173926
36183927 [[package]]
3928+name = "strsim"
3929+version = "0.11.1"
3930+source = "registry+https://github.com/rust-lang/crates.io-index"
3931+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
3932+
3933+[[package]]
36193934 name = "subtle"
36203935 version = "2.6.1"
36213936 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3675,6 +3990,149 @@ dependencies = [
36753990 ]
36763991
36773992 [[package]]
3993+name = "tantivy"
3994+version = "0.26.2"
3995+source = "registry+https://github.com/rust-lang/crates.io-index"
3996+checksum = "861facfabd71044968f364837f9a083b56464ba5a59079f88706ee5c451ca069"
3997+dependencies = [
3998+ "aho-corasick",
3999+ "arc-swap",
4000+ "base64",
4001+ "bitpacking",
4002+ "bon",
4003+ "byteorder",
4004+ "census",
4005+ "crc32fast",
4006+ "crossbeam-channel",
4007+ "datasketches",
4008+ "downcast-rs",
4009+ "fastdivide",
4010+ "fnv",
4011+ "htmlescape",
4012+ "itertools 0.14.0",
4013+ "levenshtein_automata",
4014+ "log",
4015+ "lru",
4016+ "lz4_flex",
4017+ "measure_time",
4018+ "once_cell",
4019+ "oneshot",
4020+ "rayon",
4021+ "regex",
4022+ "rustc-hash",
4023+ "serde",
4024+ "serde_json",
4025+ "sketches-ddsketch",
4026+ "smallvec",
4027+ "tantivy-bitpacker",
4028+ "tantivy-columnar",
4029+ "tantivy-common",
4030+ "tantivy-fst",
4031+ "tantivy-query-grammar",
4032+ "tantivy-stacker",
4033+ "tantivy-tokenizer-api",
4034+ "thiserror 2.0.20",
4035+ "time",
4036+ "typetag",
4037+ "uuid",
4038+ "winapi",
4039+]
4040+
4041+[[package]]
4042+name = "tantivy-bitpacker"
4043+version = "0.10.0"
4044+source = "registry+https://github.com/rust-lang/crates.io-index"
4045+checksum = "4fed3d674429bcd2de5d0a6d1aa5495fed8afd9c5ecce993019caf7615f53fa4"
4046+dependencies = [
4047+ "bitpacking",
4048+]
4049+
4050+[[package]]
4051+name = "tantivy-columnar"
4052+version = "0.7.0"
4053+source = "registry+https://github.com/rust-lang/crates.io-index"
4054+checksum = "c57166f5bcfd478f370ab8445afb4678dce44801fa5ce5c451aaf8595583c5dc"
4055+dependencies = [
4056+ "downcast-rs",
4057+ "fastdivide",
4058+ "itertools 0.14.0",
4059+ "serde",
4060+ "tantivy-bitpacker",
4061+ "tantivy-common",
4062+ "tantivy-sstable",
4063+ "tantivy-stacker",
4064+]
4065+
4066+[[package]]
4067+name = "tantivy-common"
4068+version = "0.11.0"
4069+source = "registry+https://github.com/rust-lang/crates.io-index"
4070+checksum = "bbf10915aa75da3c3b0d58b58853d2e889efbaf32d4982a4c3715dde6bba23e5"
4071+dependencies = [
4072+ "async-trait",
4073+ "byteorder",
4074+ "ownedbytes",
4075+ "serde",
4076+ "time",
4077+]
4078+
4079+[[package]]
4080+name = "tantivy-fst"
4081+version = "0.5.0"
4082+source = "registry+https://github.com/rust-lang/crates.io-index"
4083+checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18"
4084+dependencies = [
4085+ "byteorder",
4086+ "regex-syntax",
4087+ "utf8-ranges",
4088+]
4089+
4090+[[package]]
4091+name = "tantivy-query-grammar"
4092+version = "0.26.0"
4093+source = "registry+https://github.com/rust-lang/crates.io-index"
4094+checksum = "dfadb8526b6da90704feb293b0701a6aae62ea14983143344be2dc5ce30f1d82"
4095+dependencies = [
4096+ "fnv",
4097+ "nom",
4098+ "ordered-float",
4099+ "serde",
4100+ "serde_json",
4101+]
4102+
4103+[[package]]
4104+name = "tantivy-sstable"
4105+version = "0.7.0"
4106+source = "registry+https://github.com/rust-lang/crates.io-index"
4107+checksum = "8a2cfc3ac5164cbadc28965ffb145a8f47582a60ae5897859ad8d4316596c606"
4108+dependencies = [
4109+ "futures-util",
4110+ "itertools 0.14.0",
4111+ "tantivy-bitpacker",
4112+ "tantivy-common",
4113+ "tantivy-fst",
4114+]
4115+
4116+[[package]]
4117+name = "tantivy-stacker"
4118+version = "0.7.0"
4119+source = "registry+https://github.com/rust-lang/crates.io-index"
4120+checksum = "6cbb051742da9d53ca9e8fff43a9b10e319338b24e2c0e15d0372df19ffeb951"
4121+dependencies = [
4122+ "murmurhash32",
4123+ "tantivy-common",
4124+]
4125+
4126+[[package]]
4127+name = "tantivy-tokenizer-api"
4128+version = "0.7.0"
4129+source = "registry+https://github.com/rust-lang/crates.io-index"
4130+checksum = "eac258c2c6390673f2685813afeeafcb8c4e0ee7de8dd3fc46838dcc37263f98"
4131+dependencies = [
4132+ "serde",
4133+]
4134+
4135+[[package]]
36784136 name = "tempfile"
36794137 version = "3.27.0"
36804138 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4024,12 +4482,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
40244482 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
40254483
40264484 [[package]]
4485+name = "typeid"
4486+version = "1.0.3"
4487+source = "registry+https://github.com/rust-lang/crates.io-index"
4488+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
4489+
4490+[[package]]
40274491 name = "typenum"
40284492 version = "1.20.1"
40294493 source = "registry+https://github.com/rust-lang/crates.io-index"
40304494 checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
40314495
40324496 [[package]]
4497+name = "typetag"
4498+version = "0.2.23"
4499+source = "registry+https://github.com/rust-lang/crates.io-index"
4500+checksum = "c90e86058a30d42a1a928dfb4b49bb33c98c3a2b4909492e6b0881cd94798ec2"
4501+dependencies = [
4502+ "erased-serde",
4503+ "inventory",
4504+ "once_cell",
4505+ "serde",
4506+ "typetag-impl",
4507+]
4508+
4509+[[package]]
4510+name = "typetag-impl"
4511+version = "0.2.23"
4512+source = "registry+https://github.com/rust-lang/crates.io-index"
4513+checksum = "f153acc4e99a5f2a5aefa09fb078be54e26271b2813f6041200b224c098d8328"
4514+dependencies = [
4515+ "proc-macro2",
4516+ "quote",
4517+ "syn 3.0.5",
4518+]
4519+
4520+[[package]]
40334521 name = "typewit"
40344522 version = "1.15.2"
40354523 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4114,6 +4602,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
41144602 checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
41154603
41164604 [[package]]
4605+name = "utf8-ranges"
4606+version = "1.0.5"
4607+source = "registry+https://github.com/rust-lang/crates.io-index"
4608+checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
4609+
4610+[[package]]
41174611 name = "utf8_iter"
41184612 version = "1.0.4"
41194613 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4396,6 +4890,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
43964890 checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68"
43974891
43984892 [[package]]
4893+name = "winapi"
4894+version = "0.3.9"
4895+source = "registry+https://github.com/rust-lang/crates.io-index"
4896+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
4897+dependencies = [
4898+ "winapi-i686-pc-windows-gnu",
4899+ "winapi-x86_64-pc-windows-gnu",
4900+]
4901+
4902+[[package]]
4903+name = "winapi-i686-pc-windows-gnu"
4904+version = "0.4.0"
4905+source = "registry+https://github.com/rust-lang/crates.io-index"
4906+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
4907+
4908+[[package]]
43994909 name = "winapi-util"
44004910 version = "0.1.11"
44014911 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4405,6 +4915,12 @@ dependencies = [
44054915 ]
44064916
44074917 [[package]]
4918+name = "winapi-x86_64-pc-windows-gnu"
4919+version = "0.4.0"
4920+source = "registry+https://github.com/rust-lang/crates.io-index"
4921+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
4922+
4923+[[package]]
44084924 name = "windows-core"
44094925 version = "0.62.2"
44104926 source = "registry+https://github.com/rust-lang/crates.io-index"
Modified file: Cargo.toml+6 additions
@@ -37,6 +37,9 @@ serde_json = { version = "1.0.143", features = ["raw_value"] }
3737 thiserror = "2.0.16"
3838 mime = "0.3.17"
3939 sha2 = "0.10.9"
40+# no mmap, which wasm can't have, and no zstd, which is C
41+tantivy = { version = "0.26.1", default-features = false, features = ["lz4-compression"] }
42+tantivy-common = "0.11"
4043 tokio = { version = "1.47.1", features = ["macros", "sync"] }
4144 tracing = "0.1.41"
4245 url = { version = "2.5.7", optional = true }
@@ -48,6 +51,9 @@ getrandom3 = { package = "getrandom", version = "0.3", features = ["wasm_js"] }
4851 getrandom4 = { package = "getrandom", version = "0.4", features = ["wasm_js"] }
4952 indexed_db_futures = { package = "matrix_indexed_db_futures", version = "0.7.0", optional = true, features = ["typed-arrays"] }
5053 js-sys = { version = "0.3.105", optional = true }
54+# tantivy 0.26's merger reads Instant::now() whenever debug logging is on, and that panics in
55+# wasm. both caps, since a release_max_level a host sets would otherwise win in release
56+log = { version = "0.4.29", features = ["max_level_info", "release_max_level_info"] }
5157 matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd", default-features = false, features = ["js"] }
5258 matrix-sdk-ui = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd", default-features = false, features = ["js"] }
5359 # the sdk's own wasm reqwest leaves out stream, which the bounded media download needs
Modified file: harness/web/Cargo.lock+517 additions−1 deletions
@@ -51,6 +51,12 @@ dependencies = [
5151 ]
5252
5353 [[package]]
54+name = "allocator-api2"
55+version = "0.2.21"
56+source = "registry+https://github.com/rust-lang/crates.io-index"
57+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
58+
59+[[package]]
5460 name = "android_system_properties"
5561 version = "0.1.6"
5662 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -86,6 +92,15 @@ dependencies = [
8692 ]
8793
8894 [[package]]
95+name = "arc-swap"
96+version = "1.9.3"
97+source = "registry+https://github.com/rust-lang/crates.io-index"
98+checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e"
99+dependencies = [
100+ "rustversion",
101+]
102+
103+[[package]]
89104 name = "archery"
90105 version = "1.2.3"
91106 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -268,6 +283,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
268283 checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6"
269284
270285 [[package]]
286+name = "bitpacking"
287+version = "0.9.3"
288+source = "registry+https://github.com/rust-lang/crates.io-index"
289+checksum = "96a7139abd3d9cebf8cd6f920a389cf3dc9576172e32f4563f188cae3c3eb019"
290+dependencies = [
291+ "crunchy",
292+]
293+
294+[[package]]
271295 name = "blake3"
272296 version = "1.8.7"
273297 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -299,6 +323,29 @@ dependencies = [
299323 ]
300324
301325 [[package]]
326+name = "bon"
327+version = "3.10.3"
328+source = "registry+https://github.com/rust-lang/crates.io-index"
329+checksum = "a05442cd7005532d0cfedec676b5b9333d2b4c7c3afabe2b1a9ce2742fbe2ee6"
330+dependencies = [
331+ "bon-macros",
332+]
333+
334+[[package]]
335+name = "bon-macros"
336+version = "3.10.3"
337+source = "registry+https://github.com/rust-lang/crates.io-index"
338+checksum = "93833e97ac7c46ba97045afcf247238c27057538fcf2f06344662ddd5f567be6"
339+dependencies = [
340+ "darling",
341+ "ident_case",
342+ "prettyplease",
343+ "proc-macro2",
344+ "quote",
345+ "syn 3.0.5",
346+]
347+
348+[[package]]
302349 name = "bs58"
303350 version = "0.5.1"
304351 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -359,6 +406,12 @@ dependencies = [
359406 ]
360407
361408 [[package]]
409+name = "census"
410+version = "0.4.2"
411+source = "registry+https://github.com/rust-lang/crates.io-index"
412+checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0"
413+
414+[[package]]
362415 name = "cfg-if"
363416 version = "1.0.4"
364417 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -540,12 +593,46 @@ dependencies = [
540593 ]
541594
542595 [[package]]
596+name = "crossbeam-channel"
597+version = "0.5.17"
598+source = "registry+https://github.com/rust-lang/crates.io-index"
599+checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
600+dependencies = [
601+ "crossbeam-utils",
602+]
603+
604+[[package]]
605+name = "crossbeam-deque"
606+version = "0.8.8"
607+source = "registry+https://github.com/rust-lang/crates.io-index"
608+checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
609+dependencies = [
610+ "crossbeam-epoch",
611+ "crossbeam-utils",
612+]
613+
614+[[package]]
615+name = "crossbeam-epoch"
616+version = "0.9.21"
617+source = "registry+https://github.com/rust-lang/crates.io-index"
618+checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
619+dependencies = [
620+ "crossbeam-utils",
621+]
622+
623+[[package]]
543624 name = "crossbeam-utils"
544625 version = "0.8.23"
545626 source = "registry+https://github.com/rust-lang/crates.io-index"
546627 checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
547628
548629 [[package]]
630+name = "crunchy"
631+version = "0.2.4"
632+source = "registry+https://github.com/rust-lang/crates.io-index"
633+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
634+
635+[[package]]
549636 name = "crypto-common"
550637 version = "0.1.7"
551638 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -594,6 +681,46 @@ dependencies = [
594681 ]
595682
596683 [[package]]
684+name = "darling"
685+version = "0.24.1"
686+source = "registry+https://github.com/rust-lang/crates.io-index"
687+checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
688+dependencies = [
689+ "darling_core",
690+ "darling_macro",
691+]
692+
693+[[package]]
694+name = "darling_core"
695+version = "0.24.1"
696+source = "registry+https://github.com/rust-lang/crates.io-index"
697+checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
698+dependencies = [
699+ "ident_case",
700+ "proc-macro2",
701+ "quote",
702+ "strsim",
703+ "syn 3.0.5",
704+]
705+
706+[[package]]
707+name = "darling_macro"
708+version = "0.24.1"
709+source = "registry+https://github.com/rust-lang/crates.io-index"
710+checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
711+dependencies = [
712+ "darling_core",
713+ "quote",
714+ "syn 3.0.5",
715+]
716+
717+[[package]]
718+name = "datasketches"
719+version = "0.2.0"
720+source = "registry+https://github.com/rust-lang/crates.io-index"
721+checksum = "c286de4e81ea2590afc24d754e0f83810c566f50a1388fa75ebd57928c0d9745"
722+
723+[[package]]
597724 name = "date_header"
598725 version = "1.0.5"
599726 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -662,6 +789,9 @@ name = "deranged"
662789 version = "0.5.8"
663790 source = "registry+https://github.com/rust-lang/crates.io-index"
664791 checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
792+dependencies = [
793+ "serde_core",
794+]
665795
666796 [[package]]
667797 name = "derivative"
@@ -739,6 +869,12 @@ dependencies = [
739869 ]
740870
741871 [[package]]
872+name = "downcast-rs"
873+version = "2.0.2"
874+source = "registry+https://github.com/rust-lang/crates.io-index"
875+checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
876+
877+[[package]]
742878 name = "dunce"
743879 version = "1.0.5"
744880 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -792,6 +928,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
792928 checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
793929
794930 [[package]]
931+name = "erased-serde"
932+version = "0.4.11"
933+source = "registry+https://github.com/rust-lang/crates.io-index"
934+checksum = "1e59b668157f32d3459d4bd634861a06ffa2503a461ca5f0916b9f0252605e66"
935+dependencies = [
936+ "serde",
937+ "serde_core",
938+ "typeid",
939+]
940+
941+[[package]]
795942 name = "errno"
796943 version = "0.3.14"
797944 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -886,6 +1033,12 @@ dependencies = [
8861033 ]
8871034
8881035 [[package]]
1036+name = "fastdivide"
1037+version = "0.4.2"
1038+source = "registry+https://github.com/rust-lang/crates.io-index"
1039+checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471"
1040+
1041+[[package]]
8891042 name = "fastrand"
8901043 version = "2.5.0"
8911044 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -927,6 +1080,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
9271080 checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
9281081
9291082 [[package]]
1083+name = "foldhash"
1084+version = "0.2.0"
1085+source = "registry+https://github.com/rust-lang/crates.io-index"
1086+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
1087+
1088+[[package]]
9301089 name = "form_urlencoded"
9311090 version = "1.2.2"
9321091 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1150,7 +1309,18 @@ version = "0.15.5"
11501309 source = "registry+https://github.com/rust-lang/crates.io-index"
11511310 checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
11521311 dependencies = [
1153- "foldhash",
1312+ "foldhash 0.1.5",
1313+]
1314+
1315+[[package]]
1316+name = "hashbrown"
1317+version = "0.16.1"
1318+source = "registry+https://github.com/rust-lang/crates.io-index"
1319+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
1320+dependencies = [
1321+ "allocator-api2",
1322+ "equivalent",
1323+ "foldhash 0.2.0",
11541324 ]
11551325
11561326 [[package]]
@@ -1203,6 +1373,12 @@ dependencies = [
12031373 ]
12041374
12051375 [[package]]
1376+name = "htmlescape"
1377+version = "0.3.1"
1378+source = "registry+https://github.com/rust-lang/crates.io-index"
1379+checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163"
1380+
1381+[[package]]
12061382 name = "http"
12071383 version = "1.5.0"
12081384 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1408,6 +1584,12 @@ dependencies = [
14081584 ]
14091585
14101586 [[package]]
1587+name = "ident_case"
1588+version = "1.0.1"
1589+source = "registry+https://github.com/rust-lang/crates.io-index"
1590+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
1591+
1592+[[package]]
14111593 name = "idna"
14121594 version = "1.1.0"
14131595 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1505,6 +1687,15 @@ dependencies = [
15051687 ]
15061688
15071689 [[package]]
1690+name = "inventory"
1691+version = "0.3.25"
1692+source = "registry+https://github.com/rust-lang/crates.io-index"
1693+checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c"
1694+dependencies = [
1695+ "rustversion",
1696+]
1697+
1698+[[package]]
15081699 name = "ipnet"
15091700 version = "2.12.2"
15101701 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1645,6 +1836,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
16451836 checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
16461837
16471838 [[package]]
1839+name = "levenshtein_automata"
1840+version = "0.2.1"
1841+source = "registry+https://github.com/rust-lang/crates.io-index"
1842+checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25"
1843+
1844+[[package]]
16481845 name = "libc"
16491846 version = "0.2.189"
16501847 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1707,12 +1904,27 @@ dependencies = [
17071904 ]
17081905
17091906 [[package]]
1907+name = "lru"
1908+version = "0.16.4"
1909+source = "registry+https://github.com/rust-lang/crates.io-index"
1910+checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
1911+dependencies = [
1912+ "hashbrown 0.16.1",
1913+]
1914+
1915+[[package]]
17101916 name = "lru-slab"
17111917 version = "0.1.2"
17121918 source = "registry+https://github.com/rust-lang/crates.io-index"
17131919 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
17141920
17151921 [[package]]
1922+name = "lz4_flex"
1923+version = "0.13.1"
1924+source = "registry+https://github.com/rust-lang/crates.io-index"
1925+checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e"
1926+
1927+[[package]]
17161928 name = "macltok"
17171929 version = "0.1.0"
17181930 dependencies = [
@@ -1723,6 +1935,7 @@ dependencies = [
17231935 "http",
17241936 "imbl",
17251937 "js-sys",
1938+ "log",
17261939 "matrix-sdk",
17271940 "matrix-sdk-base",
17281941 "matrix-sdk-crypto",
@@ -1735,6 +1948,8 @@ dependencies = [
17351948 "serde",
17361949 "serde_json",
17371950 "sha2",
1951+ "tantivy",
1952+ "tantivy-common",
17381953 "thiserror 2.0.20",
17391954 "tokio",
17401955 "tracing",
@@ -2211,6 +2426,15 @@ dependencies = [
22112426 ]
22122427
22132428 [[package]]
2429+name = "measure_time"
2430+version = "0.9.0"
2431+source = "registry+https://github.com/rust-lang/crates.io-index"
2432+checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e"
2433+dependencies = [
2434+ "log",
2435+]
2436+
2437+[[package]]
22142438 name = "memchr"
22152439 version = "2.8.3"
22162440 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2239,6 +2463,12 @@ dependencies = [
22392463 ]
22402464
22412465 [[package]]
2466+name = "minimal-lexical"
2467+version = "0.2.1"
2468+source = "registry+https://github.com/rust-lang/crates.io-index"
2469+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
2470+
2471+[[package]]
22422472 name = "miniz_oxide"
22432473 version = "0.9.1"
22442474 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2260,12 +2490,28 @@ dependencies = [
22602490 ]
22612491
22622492 [[package]]
2493+name = "murmurhash32"
2494+version = "0.3.1"
2495+source = "registry+https://github.com/rust-lang/crates.io-index"
2496+checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b"
2497+
2498+[[package]]
22632499 name = "new_debug_unreachable"
22642500 version = "1.0.6"
22652501 source = "registry+https://github.com/rust-lang/crates.io-index"
22662502 checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
22672503
22682504 [[package]]
2505+name = "nom"
2506+version = "7.1.3"
2507+source = "registry+https://github.com/rust-lang/crates.io-index"
2508+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
2509+dependencies = [
2510+ "memchr",
2511+ "minimal-lexical",
2512+]
2513+
2514+[[package]]
22692515 name = "nu-ansi-term"
22702516 version = "0.50.3"
22712517 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2336,6 +2582,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
23362582 checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
23372583
23382584 [[package]]
2585+name = "oneshot"
2586+version = "0.1.13"
2587+source = "registry+https://github.com/rust-lang/crates.io-index"
2588+checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107"
2589+
2590+[[package]]
23392591 name = "oorandom"
23402592 version = "11.1.5"
23412593 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2354,6 +2606,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
23542606 checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
23552607
23562608 [[package]]
2609+name = "ordered-float"
2610+version = "5.5.0"
2611+source = "registry+https://github.com/rust-lang/crates.io-index"
2612+checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0"
2613+dependencies = [
2614+ "num-traits",
2615+]
2616+
2617+[[package]]
2618+name = "ownedbytes"
2619+version = "0.9.0"
2620+source = "registry+https://github.com/rust-lang/crates.io-index"
2621+checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e"
2622+dependencies = [
2623+ "stable_deref_trait",
2624+]
2625+
2626+[[package]]
23572627 name = "parking"
23582628 version = "2.2.1"
23592629 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2500,6 +2770,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
25002770 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
25012771
25022772 [[package]]
2773+name = "prettyplease"
2774+version = "0.3.0"
2775+source = "registry+https://github.com/rust-lang/crates.io-index"
2776+checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
2777+dependencies = [
2778+ "proc-macro2",
2779+ "syn 3.0.5",
2780+]
2781+
2782+[[package]]
25032783 name = "proc-macro-crate"
25042784 version = "3.5.0"
25052785 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2734,6 +3014,26 @@ dependencies = [
27343014 ]
27353015
27363016 [[package]]
3017+name = "rayon"
3018+version = "1.12.0"
3019+source = "registry+https://github.com/rust-lang/crates.io-index"
3020+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
3021+dependencies = [
3022+ "either",
3023+ "rayon-core",
3024+]
3025+
3026+[[package]]
3027+name = "rayon-core"
3028+version = "1.13.0"
3029+source = "registry+https://github.com/rust-lang/crates.io-index"
3030+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
3031+dependencies = [
3032+ "crossbeam-deque",
3033+ "crossbeam-utils",
3034+]
3035+
3036+[[package]]
27373037 name = "readlock"
27383038 version = "0.1.11"
27393039 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3358,6 +3658,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
33583658 checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
33593659
33603660 [[package]]
3661+name = "sketches-ddsketch"
3662+version = "0.4.1"
3663+source = "registry+https://github.com/rust-lang/crates.io-index"
3664+checksum = "513c3f5f732bfd6fbb187619c2dfe9d2f25f1a2976f01d575f0fd329d565df56"
3665+dependencies = [
3666+ "serde",
3667+]
3668+
3669+[[package]]
33613670 name = "slab"
33623671 version = "0.4.12"
33633672 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3423,6 +3732,12 @@ dependencies = [
34233732 ]
34243733
34253734 [[package]]
3735+name = "strsim"
3736+version = "0.11.1"
3737+source = "registry+https://github.com/rust-lang/crates.io-index"
3738+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
3739+
3740+[[package]]
34263741 name = "subtle"
34273742 version = "2.6.1"
34283743 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3482,6 +3797,149 @@ dependencies = [
34823797 ]
34833798
34843799 [[package]]
3800+name = "tantivy"
3801+version = "0.26.2"
3802+source = "registry+https://github.com/rust-lang/crates.io-index"
3803+checksum = "861facfabd71044968f364837f9a083b56464ba5a59079f88706ee5c451ca069"
3804+dependencies = [
3805+ "aho-corasick",
3806+ "arc-swap",
3807+ "base64",
3808+ "bitpacking",
3809+ "bon",
3810+ "byteorder",
3811+ "census",
3812+ "crc32fast",
3813+ "crossbeam-channel",
3814+ "datasketches",
3815+ "downcast-rs",
3816+ "fastdivide",
3817+ "fnv",
3818+ "htmlescape",
3819+ "itertools 0.14.0",
3820+ "levenshtein_automata",
3821+ "log",
3822+ "lru",
3823+ "lz4_flex",
3824+ "measure_time",
3825+ "once_cell",
3826+ "oneshot",
3827+ "rayon",
3828+ "regex",
3829+ "rustc-hash",
3830+ "serde",
3831+ "serde_json",
3832+ "sketches-ddsketch",
3833+ "smallvec",
3834+ "tantivy-bitpacker",
3835+ "tantivy-columnar",
3836+ "tantivy-common",
3837+ "tantivy-fst",
3838+ "tantivy-query-grammar",
3839+ "tantivy-stacker",
3840+ "tantivy-tokenizer-api",
3841+ "thiserror 2.0.20",
3842+ "time",
3843+ "typetag",
3844+ "uuid",
3845+ "winapi",
3846+]
3847+
3848+[[package]]
3849+name = "tantivy-bitpacker"
3850+version = "0.10.0"
3851+source = "registry+https://github.com/rust-lang/crates.io-index"
3852+checksum = "4fed3d674429bcd2de5d0a6d1aa5495fed8afd9c5ecce993019caf7615f53fa4"
3853+dependencies = [
3854+ "bitpacking",
3855+]
3856+
3857+[[package]]
3858+name = "tantivy-columnar"
3859+version = "0.7.0"
3860+source = "registry+https://github.com/rust-lang/crates.io-index"
3861+checksum = "c57166f5bcfd478f370ab8445afb4678dce44801fa5ce5c451aaf8595583c5dc"
3862+dependencies = [
3863+ "downcast-rs",
3864+ "fastdivide",
3865+ "itertools 0.14.0",
3866+ "serde",
3867+ "tantivy-bitpacker",
3868+ "tantivy-common",
3869+ "tantivy-sstable",
3870+ "tantivy-stacker",
3871+]
3872+
3873+[[package]]
3874+name = "tantivy-common"
3875+version = "0.11.0"
3876+source = "registry+https://github.com/rust-lang/crates.io-index"
3877+checksum = "bbf10915aa75da3c3b0d58b58853d2e889efbaf32d4982a4c3715dde6bba23e5"
3878+dependencies = [
3879+ "async-trait",
3880+ "byteorder",
3881+ "ownedbytes",
3882+ "serde",
3883+ "time",
3884+]
3885+
3886+[[package]]
3887+name = "tantivy-fst"
3888+version = "0.5.0"
3889+source = "registry+https://github.com/rust-lang/crates.io-index"
3890+checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18"
3891+dependencies = [
3892+ "byteorder",
3893+ "regex-syntax",
3894+ "utf8-ranges",
3895+]
3896+
3897+[[package]]
3898+name = "tantivy-query-grammar"
3899+version = "0.26.0"
3900+source = "registry+https://github.com/rust-lang/crates.io-index"
3901+checksum = "dfadb8526b6da90704feb293b0701a6aae62ea14983143344be2dc5ce30f1d82"
3902+dependencies = [
3903+ "fnv",
3904+ "nom",
3905+ "ordered-float",
3906+ "serde",
3907+ "serde_json",
3908+]
3909+
3910+[[package]]
3911+name = "tantivy-sstable"
3912+version = "0.7.0"
3913+source = "registry+https://github.com/rust-lang/crates.io-index"
3914+checksum = "8a2cfc3ac5164cbadc28965ffb145a8f47582a60ae5897859ad8d4316596c606"
3915+dependencies = [
3916+ "futures-util",
3917+ "itertools 0.14.0",
3918+ "tantivy-bitpacker",
3919+ "tantivy-common",
3920+ "tantivy-fst",
3921+]
3922+
3923+[[package]]
3924+name = "tantivy-stacker"
3925+version = "0.7.0"
3926+source = "registry+https://github.com/rust-lang/crates.io-index"
3927+checksum = "6cbb051742da9d53ca9e8fff43a9b10e319338b24e2c0e15d0372df19ffeb951"
3928+dependencies = [
3929+ "murmurhash32",
3930+ "tantivy-common",
3931+]
3932+
3933+[[package]]
3934+name = "tantivy-tokenizer-api"
3935+version = "0.7.0"
3936+source = "registry+https://github.com/rust-lang/crates.io-index"
3937+checksum = "eac258c2c6390673f2685813afeeafcb8c4e0ee7de8dd3fc46838dcc37263f98"
3938+dependencies = [
3939+ "serde",
3940+]
3941+
3942+[[package]]
34853943 name = "tempfile"
34863944 version = "3.27.0"
34873945 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3829,12 +4287,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
38294287 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
38304288
38314289 [[package]]
4290+name = "typeid"
4291+version = "1.0.3"
4292+source = "registry+https://github.com/rust-lang/crates.io-index"
4293+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
4294+
4295+[[package]]
38324296 name = "typenum"
38334297 version = "1.20.1"
38344298 source = "registry+https://github.com/rust-lang/crates.io-index"
38354299 checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
38364300
38374301 [[package]]
4302+name = "typetag"
4303+version = "0.2.23"
4304+source = "registry+https://github.com/rust-lang/crates.io-index"
4305+checksum = "c90e86058a30d42a1a928dfb4b49bb33c98c3a2b4909492e6b0881cd94798ec2"
4306+dependencies = [
4307+ "erased-serde",
4308+ "inventory",
4309+ "once_cell",
4310+ "serde",
4311+ "typetag-impl",
4312+]
4313+
4314+[[package]]
4315+name = "typetag-impl"
4316+version = "0.2.23"
4317+source = "registry+https://github.com/rust-lang/crates.io-index"
4318+checksum = "f153acc4e99a5f2a5aefa09fb078be54e26271b2813f6041200b224c098d8328"
4319+dependencies = [
4320+ "proc-macro2",
4321+ "quote",
4322+ "syn 3.0.5",
4323+]
4324+
4325+[[package]]
38384326 name = "typewit"
38394327 version = "1.15.2"
38404328 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3913,6 +4401,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
39134401 checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
39144402
39154403 [[package]]
4404+name = "utf8-ranges"
4405+version = "1.0.5"
4406+source = "registry+https://github.com/rust-lang/crates.io-index"
4407+checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
4408+
4409+[[package]]
39164410 name = "utf8_iter"
39174411 version = "1.0.4"
39184412 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4195,6 +4689,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
41954689 checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68"
41964690
41974691 [[package]]
4692+name = "winapi"
4693+version = "0.3.9"
4694+source = "registry+https://github.com/rust-lang/crates.io-index"
4695+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
4696+dependencies = [
4697+ "winapi-i686-pc-windows-gnu",
4698+ "winapi-x86_64-pc-windows-gnu",
4699+]
4700+
4701+[[package]]
4702+name = "winapi-i686-pc-windows-gnu"
4703+version = "0.4.0"
4704+source = "registry+https://github.com/rust-lang/crates.io-index"
4705+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
4706+
4707+[[package]]
41984708 name = "winapi-util"
41994709 version = "0.1.11"
42004710 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4204,6 +4714,12 @@ dependencies = [
42044714 ]
42054715
42064716 [[package]]
4717+name = "winapi-x86_64-pc-windows-gnu"
4718+version = "0.4.0"
4719+source = "registry+https://github.com/rust-lang/crates.io-index"
4720+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
4721+
4722+[[package]]
42074723 name = "windows-core"
42084724 version = "0.62.2"
42094725 source = "registry+https://github.com/rust-lang/crates.io-index"
Modified file: src/account.rs+15 additions
@@ -324,6 +324,17 @@ impl StoreConfig {
324324 format!("{}::macltok-mentions", self.indexeddb_name())
325325 }
326326
327+ #[cfg(feature = "sqlite")]
328+ pub(crate) fn search_location(&self) -> PathBuf {
329+ let StoreLocation::Sqlite(path) = &self.location;
330+ path.join("macltok-search.sqlite3")
331+ }
332+
333+ #[cfg(feature = "indexeddb")]
334+ pub(crate) fn search_location(&self) -> String {
335+ format!("{}::macltok-search", self.indexeddb_name())
336+ }
337+
327338 #[cfg(feature = "indexeddb")]
328339 pub(crate) fn indexeddb_name(&self) -> &str {
329340 let StoreLocation::IndexedDb(name) = &self.location;
@@ -854,6 +865,9 @@ impl Session {
854865 .ok_or(AccountError::MissingSession)?
855866 .to_owned(),
856867 };
868+ let search =
869+ crate::search::store::SearchStore::open(self.tracking.store.store(), account.clone())
870+ .await;
857871 let opener = crate::mentions::store::Opener::new(
858872 self.tracking.store.store(),
859873 account,
@@ -878,6 +892,7 @@ impl Session {
878892 self.tracking.persistence,
879893 Some(self.store_lock),
880894 Some(mentions),
895+ Some(search),
881896 )
882897 .await
883898 .map_err(AccountError::from)
Modified file: src/engine/context.rs+21 additions
@@ -412,6 +412,27 @@ async fn fetch_side(
412412 })
413413 }
414414
415+/// a search hit as event context would show it, `values` its original and then its latest edit
416+pub(crate) async fn preview(
417+ room: &Room,
418+ current_user_id: Option<&ruma::UserId>,
419+ values: &[Value],
420+ highlight: bool,
421+) -> Option<(TimelineEvent, Vec<MediaRegistration>)> {
422+ let mut page = Assembly::new(
423+ room,
424+ current_user_id,
425+ values,
426+ &vec![highlight; values.len()],
427+ Some(0),
428+ )
429+ .await;
430+ match page.take(0..1).pop()? {
431+ TimelineItem::Event(event) => Some((*event, page.media)),
432+ _ => None,
433+ }
434+}
435+
415436 /// a window of raw events normalised together, so relations inside it land on their targets
416437 struct Assembly {
417438 items: Vec<(usize, TimelineItem)>,
Modified file: src/engine/mod.rs+62 additions−1 deletions
@@ -40,6 +40,11 @@ pub(crate) use crate::mentions::store::MentionsStore;
4040 #[cfg(not(any(feature = "sqlite", feature = "indexeddb")))]
4141 pub(crate) enum MentionsStore {}
4242
43+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
44+pub(crate) use crate::search::store::SearchStore;
45+#[cfg(not(any(feature = "sqlite", feature = "indexeddb")))]
46+pub(crate) enum SearchStore {}
47+
4348 use self::{
4449 commands::Job,
4550 executor::{EngineBoxFuture, TaskSet},
@@ -73,6 +78,13 @@ pub struct EngineConfig {
7378 pub max_mention_candidates: usize,
7479 /// warm a room up for sending when it's opened or someone starts typing in it
7580 pub prepare_to_send: bool,
81+ /// `/messages` pages each joined room gets per engine run for search backfill. 0 for none
82+ pub search_backfill_pages_per_room: u16,
83+ pub search_backfill_page_size: u16,
84+ /// the least time between two backfill requests, across every room
85+ pub search_backfill_interval_ms: u64,
86+ /// undecryptable messages kept to index once their keys turn up, newest first
87+ pub max_search_undecrypted: usize,
7688 }
7789
7890 impl EngineConfig {
@@ -135,6 +147,30 @@ impl EngineConfig {
135147 self.prepare_to_send = prepare;
136148 self
137149 }
150+
151+ #[must_use]
152+ pub const fn with_search_backfill_pages_per_room(mut self, pages: u16) -> Self {
153+ self.search_backfill_pages_per_room = pages;
154+ self
155+ }
156+
157+ #[must_use]
158+ pub const fn with_search_backfill_page_size(mut self, events: u16) -> Self {
159+ self.search_backfill_page_size = events;
160+ self
161+ }
162+
163+ #[must_use]
164+ pub const fn with_search_backfill_interval_ms(mut self, interval_ms: u64) -> Self {
165+ self.search_backfill_interval_ms = interval_ms;
166+ self
167+ }
168+
169+ #[must_use]
170+ pub const fn with_max_search_undecrypted(mut self, events: usize) -> Self {
171+ self.max_search_undecrypted = events;
172+ self
173+ }
138174 }
139175
140176 impl Default for EngineConfig {
@@ -150,6 +186,10 @@ impl Default for EngineConfig {
150186 max_mention_context_events: 25_000,
151187 max_mention_candidates: 5_000,
152188 prepare_to_send: true,
189+ search_backfill_pages_per_room: 10,
190+ search_backfill_page_size: 100,
191+ search_backfill_interval_ms: 2_000,
192+ max_search_undecrypted: 2_000,
153193 }
154194 }
155195 }
@@ -329,6 +369,8 @@ pub struct Engine {
329369 mentions: crate::mentions::service::Handle,
330370 mentions_service: Option<crate::mentions::service::Service>,
331371 mentions_updates: Option<watch::Receiver<crate::MentionsPage>>,
372+ search: crate::search::Handle,
373+ search_service: Option<crate::search::Service>,
332374 }
333375
334376 impl Engine {
@@ -338,7 +380,7 @@ impl Engine {
338380 config: EngineConfig,
339381 ) -> Result<(Self, ClientHandle, EventStream), EngineError> {
340382 let session_changes = client.subscribe_to_session_changes();
341- Self::with_persistence(client, config, session_changes, None, None, None).await
383+ Self::with_persistence(client, config, session_changes, None, None, None, None).await
342384 }
343385
344386 /// `session_changes` is taken when the client is built, since the sdk can refresh
@@ -350,6 +392,7 @@ impl Engine {
350392 persistence: Option<Persistence>,
351393 store_lock: Option<StoreLock>,
352394 mentions: Option<Result<MentionsStore, crate::mentions::service::Unopened>>,
395+ search: Option<Result<SearchStore, crate::Error>>,
353396 ) -> Result<(Self, ClientHandle, EventStream), EngineError> {
354397 if client.user_id().is_none() {
355398 return Err(EngineError::Unauthenticated);
@@ -379,6 +422,8 @@ impl Engine {
379422 .await
380423 // a missing user id is all it still refuses, and that was turned away above
381424 .map_err(|_| EngineError::Unauthenticated)?;
425+ let (search, search_service) =
426+ crate::search::Service::new(client.clone(), media.clone(), search, &config);
382427 let sync = Arc::new(
383428 SyncService::builder(client.clone())
384429 .with_offline_mode()
@@ -443,6 +488,8 @@ impl Engine {
443488 mentions,
444489 mentions_service: Some(mentions_service),
445490 mentions_updates: Some(mentions_updates),
491+ search,
492+ search_service: Some(search_service),
446493 };
447494 Ok((engine, handle, events))
448495 }
@@ -532,6 +579,10 @@ impl Engine {
532579 self.tasks
533580 .spawn(TaskContext::Watcher("mentions service"), service.run());
534581 }
582+ if let Some(service) = self.search_service.take() {
583+ self.tasks
584+ .spawn(TaskContext::Watcher("search service"), service.run());
585+ }
535586 if let Some(updates) = self.mentions_updates.take() {
536587 let events = self.port.event_sender();
537588 self.tasks.spawn(
@@ -855,6 +906,13 @@ impl Engine {
855906 reply.send(mentions.execute(command).await);
856907 });
857908 }
909+ Command::Search(command) => {
910+ let search = self.search.clone();
911+ self.tasks
912+ .spawn(TaskContext::Command("search command"), async move {
913+ reply.send(search.execute(command).await);
914+ });
915+ }
858916 Command::HistoryRepair(command) => self.route_repair(command, reply),
859917 Command::Media(MediaCommand::Upload(upload)) => self.upload(upload, reply),
860918 Command::Encryption(command) => enqueue(&self.encryption, Job { command, reply }),
@@ -1162,6 +1220,9 @@ impl Engine {
11621220 if self.mentions_service.is_none() {
11631221 self.mentions.stop().await;
11641222 }
1223+ if self.search_service.is_none() {
1224+ self.search.stop().await;
1225+ }
11651226 self.tasks.shutdown().await;
11661227 self.rooms.clear();
11671228 self.contexts.clear().await;
Modified file: src/error.rs+1 additions
@@ -54,6 +54,7 @@ pub enum ErrorKind {
5454 RoomNotOpen,
5555 TimelineNotOpen,
5656 EventContextNotOpen,
57+ SearchNotOpen,
5758 EventNotFound,
5859 RoomBusy,
5960 QueueFull,
Modified file: src/lib.rs+6 additions
@@ -10,6 +10,8 @@ mod mirror;
1010 mod model;
1111 mod normalise;
1212 mod repair_model;
13+mod search;
14+mod search_model;
1315 mod space_order;
1416 mod transport;
1517 mod uiaa;
@@ -79,6 +81,10 @@ pub use repair_model::{
7981 HistoryRepairReport, HistoryRepairStop,
8082 };
8183 pub use ruma;
84+pub use search_model::{
85+ RoomSearchCoverage, SearchBackfill, SearchCommand, SearchCoverage, SearchHit, SearchMatch,
86+ SearchPage, SearchScope, SearchSession,
87+};
8288 #[doc(hidden)]
8389 pub use transport::PublicationAck;
8490 pub use transport::{ClientHandle, EventStream, Pending, SpaceHierarchy};
Modified file: src/model.rs+4 additions
@@ -25,6 +25,7 @@ pub enum Command {
2525 Timeline(TimelineCommand),
2626 EventContext(EventContextCommand),
2727 Mentions(crate::MentionsCommand),
28+ Search(crate::SearchCommand),
2829 Media(MediaCommand),
2930 Encryption(EncryptionCommand),
3031 Notification(NotificationCommand),
@@ -697,6 +698,9 @@ pub enum CommandOutput {
697698 EventContextPage(EventContextPage),
698699 MentionsPage(crate::MentionsPage),
699700 MentionContext(crate::SavedMentionContext),
701+ SearchSession(crate::SearchSession),
702+ SearchPage(crate::SearchPage),
703+ SearchCoverage(crate::SearchCoverage),
700704 }
701705
702706 #[derive(Clone, Debug, Eq, PartialEq)]
Modified file: src/normalise/mod.rs+1 additions−1 deletions
@@ -16,7 +16,7 @@ pub(crate) use image_pack::{
1616 pub(crate) use link_preview::{ROOM_PREVIEW_URLS, server_preview, url_previews_allowed};
1717 pub(crate) use media::{MediaRegistration, MediaScope, MediaSourceInfo};
1818 pub(crate) use member::{member_sender, member_summary};
19-pub(crate) use observed::normalise_observed;
19+pub(crate) use observed::{normalise_observed, poll_question};
2020 pub(crate) use room::notification_mode;
2121 pub(crate) use room::{room_summaries, space_child, stored_space_order};
2222 pub(crate) use thread::{recount_thread_unreads, stored_thread_unreads};
Modified file: src/normalise/observed.rs+1 additions−1 deletions
@@ -219,7 +219,7 @@ fn raw_relation_refs(content: &Value) -> (Option<OwnedEventId>, Option<OwnedEven
219219 (reply_to, thread_root)
220220 }
221221
222-fn poll_question(content: &Value, event_type: &str) -> Option<String> {
222+pub(crate) fn poll_question(content: &Value, event_type: &str) -> Option<String> {
223223 let typed_question = match event_type {
224224 "m.poll.start" => serde_json::from_value::<PollStartEventContent>(content.clone())
225225 .ok()
Added file: src/search/feed.rs+289 additions
@@ -0,0 +1,289 @@
1+use std::collections::{HashSet, VecDeque};
2+
3+use ruma::events::room::message::sanitize::remove_plain_reply_fallback;
4+use serde_json::Value;
5+
6+use super::{MessageIndex, index::Doc};
7+use crate::normalise::poll_question;
8+
9+const REMEMBERED_REDACTIONS: usize = 10_000;
10+
11+#[derive(Clone, Debug, Eq, PartialEq)]
12+pub(crate) struct Edit {
13+ pub(crate) id: String,
14+ pub(crate) target: String,
15+ pub(crate) sender: String,
16+ pub(crate) ts: u64,
17+ pub(crate) text: String,
18+}
19+
20+#[derive(Clone, Debug, Eq, PartialEq)]
21+pub(crate) enum Observed {
22+ Message {
23+ key: String,
24+ sender: String,
25+ ts: u64,
26+ text: String,
27+ bundled: Option<Edit>,
28+ },
29+ Edit(Edit),
30+ Redaction {
31+ target: String,
32+ },
33+ Redacted {
34+ key: String,
35+ },
36+ Encrypted {
37+ key: String,
38+ session_id: String,
39+ ts: u64,
40+ },
41+ Other,
42+}
43+
44+#[derive(Debug, Eq, PartialEq)]
45+pub(crate) enum Effect {
46+ Indexed(u64),
47+ Removed,
48+ /// an edit was redacted out from under its original, which has to be read again
49+ Refetch(String),
50+ Nothing,
51+}
52+
53+/// redactions seen lately, so a stale copy of what they removed can't come back
54+#[derive(Default)]
55+pub(crate) struct Redactions {
56+ order: VecDeque<String>,
57+ seen: HashSet<String>,
58+}
59+
60+impl Redactions {
61+ fn insert(&mut self, key: String) {
62+ if self.seen.insert(key.clone()) {
63+ self.order.push_back(key);
64+ if self.order.len() > REMEMBERED_REDACTIONS
65+ && let Some(oldest) = self.order.pop_front()
66+ {
67+ self.seen.remove(&oldest);
68+ }
69+ }
70+ }
71+
72+ fn contains(&self, key: &str) -> bool {
73+ self.seen.contains(key)
74+ }
75+}
76+
77+fn text_of(event_type: &str, content: &Value) -> Option<String> {
78+ let text = match event_type {
79+ "m.room.message" => {
80+ let msgtype = content.get("msgtype")?.as_str()?;
81+ if msgtype == "m.key.verification.request" {
82+ return None;
83+ }
84+ let mut body = content.get("body")?.as_str()?.to_owned();
85+ if content.pointer("/m.relates_to/m.in_reply_to").is_some() {
86+ body = remove_plain_reply_fallback(&body).to_owned();
87+ }
88+ match content.get("filename").and_then(Value::as_str) {
89+ Some(filename) if filename != body => format!("{body}\n{filename}"),
90+ _ => body,
91+ }
92+ }
93+ "m.sticker" => content.get("body")?.as_str()?.to_owned(),
94+ "m.poll.start" | "org.matrix.msc3381.poll.start" => poll_question(content, event_type)?,
95+ _ => return None,
96+ };
97+ (!text.trim().is_empty()).then_some(text)
98+}
99+
100+fn edit_of(value: &Value, event_type: &str) -> Option<Edit> {
101+ let content = value.get("content")?;
102+ if content.pointer("/m.relates_to/rel_type")?.as_str()? != "m.replace" {
103+ return None;
104+ }
105+ Some(Edit {
106+ id: value.get("event_id")?.as_str()?.to_owned(),
107+ target: content
108+ .pointer("/m.relates_to/event_id")?
109+ .as_str()?
110+ .to_owned(),
111+ sender: value.get("sender")?.as_str()?.to_owned(),
112+ ts: value.get("origin_server_ts")?.as_u64()?,
113+ text: text_of(event_type, content.get("m.new_content")?)?,
114+ })
115+}
116+
117+pub(crate) fn observe(value: &Value) -> Observed {
118+ observe_inner(value).unwrap_or(Observed::Other)
119+}
120+
121+fn observe_inner(value: &Value) -> Option<Observed> {
122+ let event_type = value.get("type")?.as_str()?;
123+ let key = value.get("event_id")?.as_str()?.to_owned();
124+ if event_type == "m.room.redaction" {
125+ let target = value
126+ .get("redacts")
127+ .or_else(|| value.pointer("/content/redacts"))?
128+ .as_str()?
129+ .to_owned();
130+ return Some(Observed::Redaction { target });
131+ }
132+ if value.pointer("/unsigned/redacted_because").is_some() {
133+ return Some(Observed::Redacted { key });
134+ }
135+ let ts = value.get("origin_server_ts")?.as_u64()?;
136+ if event_type == "m.room.encrypted" {
137+ let session_id = value.pointer("/content/session_id")?.as_str()?.to_owned();
138+ return Some(Observed::Encrypted {
139+ key,
140+ session_id,
141+ ts,
142+ });
143+ }
144+ if value
145+ .pointer("/content/m.relates_to/rel_type")
146+ .and_then(Value::as_str)
147+ == Some("m.replace")
148+ {
149+ return edit_of(value, event_type).map(Observed::Edit);
150+ }
151+ let content = value.get("content")?;
152+ let text = text_of(event_type, content)?;
153+ let sender = value.get("sender")?.as_str()?.to_owned();
154+ let bundled = value
155+ .pointer("/unsigned/m.relations/m.replace")
156+ .and_then(|edit| edit_of(edit, event_type))
157+ .filter(|edit| edit.target == key);
158+ Some(Observed::Message {
159+ key,
160+ sender,
161+ ts,
162+ text,
163+ bundled,
164+ })
165+}
166+
167+fn newer(ts: u64, id: &str, than: &Doc) -> bool {
168+ (ts, id) > (than.content_ts, than.content.as_str())
169+}
170+
171+/// what `observed` does to `room`'s part of the index. edits land on their original under
172+/// its key, the latest by its own sender wins, and one arriving first holds the key
173+/// provisionally until the original turns up
174+pub(crate) fn apply(
175+ index: &mut dyn MessageIndex,
176+ redactions: &mut Redactions,
177+ room: &str,
178+ observed: Observed,
179+) -> Effect {
180+ match observed {
181+ Observed::Message {
182+ key,
183+ sender,
184+ ts,
185+ text,
186+ bundled,
187+ } => {
188+ if redactions.contains(&key) {
189+ return Effect::Nothing;
190+ }
191+ let mut doc = Doc {
192+ key: key.clone(),
193+ room: room.to_owned(),
194+ sender: sender.clone(),
195+ ts,
196+ content: key.clone(),
197+ content_ts: ts,
198+ text,
199+ provisional: false,
200+ };
201+ if let Some(edit) =
202+ bundled.filter(|edit| edit.sender == sender && !redactions.contains(&edit.id))
203+ {
204+ doc.content = edit.id;
205+ doc.content_ts = edit.ts;
206+ doc.text = edit.text;
207+ }
208+ match index.get(&key) {
209+ Some(existing) if !existing.provisional => {
210+ if !newer(doc.content_ts, &doc.content, &existing) {
211+ return Effect::Nothing;
212+ }
213+ }
214+ Some(existing) => {
215+ if existing.sender == sender && !newer(doc.content_ts, &doc.content, &existing)
216+ {
217+ doc.content = existing.content;
218+ doc.content_ts = existing.content_ts;
219+ doc.text = existing.text;
220+ }
221+ }
222+ None => {}
223+ }
224+ index.put(doc);
225+ Effect::Indexed(ts)
226+ }
227+ Observed::Edit(edit) => {
228+ if redactions.contains(&edit.target) || redactions.contains(&edit.id) {
229+ return Effect::Nothing;
230+ }
231+ let doc = match index.get(&edit.target) {
232+ Some(existing) => {
233+ if existing.sender != edit.sender || !newer(edit.ts, &edit.id, &existing) {
234+ return Effect::Nothing;
235+ }
236+ Doc {
237+ content: edit.id,
238+ content_ts: edit.ts,
239+ text: edit.text,
240+ ..existing
241+ }
242+ }
243+ None => Doc {
244+ key: edit.target,
245+ room: room.to_owned(),
246+ sender: edit.sender,
247+ ts: edit.ts,
248+ content: edit.id,
249+ content_ts: edit.ts,
250+ text: edit.text,
251+ provisional: true,
252+ },
253+ };
254+ let ts = doc.ts;
255+ index.put(doc);
256+ Effect::Indexed(ts)
257+ }
258+ Observed::Redaction { target } => {
259+ redactions.insert(target.clone());
260+ if index.remove(&target) {
261+ return Effect::Removed;
262+ }
263+ match index.by_content(&target) {
264+ Some(doc) => {
265+ index.remove(&doc.key);
266+ if doc.provisional {
267+ Effect::Removed
268+ } else {
269+ Effect::Refetch(doc.key)
270+ }
271+ }
272+ None => Effect::Nothing,
273+ }
274+ }
275+ Observed::Redacted { key } => {
276+ redactions.insert(key.clone());
277+ if index.remove(&key) {
278+ Effect::Removed
279+ } else {
280+ Effect::Nothing
281+ }
282+ }
283+ Observed::Encrypted { .. } | Observed::Other => Effect::Nothing,
284+ }
285+}
286+
287+#[cfg(test)]
288+#[path = "feed_tests.rs"]
289+mod tests;
Added file: src/search/feed_tests.rs+187 additions
@@ -0,0 +1,187 @@
1+use serde_json::json;
2+
3+use super::*;
4+use crate::search::index::KeywordIndex;
5+
6+const ROOM: &str = "!room:x";
7+
8+fn message(id: &str, sender: &str, ts: u64, body: &str) -> Value {
9+ json!({
10+ "type": "m.room.message",
11+ "event_id": id,
12+ "sender": sender,
13+ "origin_server_ts": ts,
14+ "content": { "msgtype": "m.text", "body": body },
15+ })
16+}
17+
18+fn edit(id: &str, sender: &str, ts: u64, target: &str, body: &str) -> Value {
19+ json!({
20+ "type": "m.room.message",
21+ "event_id": id,
22+ "sender": sender,
23+ "origin_server_ts": ts,
24+ "content": {
25+ "msgtype": "m.text",
26+ "body": format!("* {body}"),
27+ "m.new_content": { "msgtype": "m.text", "body": body },
28+ "m.relates_to": { "rel_type": "m.replace", "event_id": target },
29+ },
30+ })
31+}
32+
33+fn redaction(id: &str, target: &str) -> Value {
34+ json!({
35+ "type": "m.room.redaction",
36+ "event_id": id,
37+ "sender": "@a:x",
38+ "origin_server_ts": 99,
39+ "redacts": target,
40+ "content": {},
41+ })
42+}
43+
44+fn feed(index: &mut KeywordIndex, redactions: &mut Redactions, value: Value) -> Effect {
45+ apply(index, redactions, ROOM, observe(&value))
46+}
47+
48+fn keys(index: &mut KeywordIndex, query: &str) -> Vec<String> {
49+ let (hits, _) = index.search(query, None, None, 10).unwrap();
50+ hits.into_iter().map(|hit| hit.key).collect()
51+}
52+
53+/// backfill walks newest first, so an edit routinely lands before what it edits
54+#[test]
55+fn an_edit_before_its_original_keeps_the_edit_and_takes_the_original_timestamp() {
56+ let mut index = KeywordIndex::new(false);
57+ let mut redactions = Redactions::default();
58+ feed(
59+ &mut index,
60+ &mut redactions,
61+ edit("$e", "@a:x", 20, "$o", "fixed words"),
62+ );
63+ let provisional = index.get("$o").unwrap();
64+ assert!(provisional.provisional);
65+ assert_eq!(provisional.ts, 20);
66+ feed(
67+ &mut index,
68+ &mut redactions,
69+ message("$o", "@a:x", 10, "typo wrds"),
70+ );
71+ let doc = index.get("$o").unwrap();
72+ assert!(!doc.provisional);
73+ assert_eq!((doc.ts, doc.content.as_str()), (10, "$e"));
74+ assert_eq!(keys(&mut index, "fixed"), vec!["$o"]);
75+ assert!(keys(&mut index, "wrds").is_empty());
76+}
77+
78+#[test]
79+fn edits_from_someone_else_and_older_edits_lose() {
80+ let mut index = KeywordIndex::new(false);
81+ let mut redactions = Redactions::default();
82+ feed(
83+ &mut index,
84+ &mut redactions,
85+ message("$o", "@a:x", 10, "original"),
86+ );
87+ feed(
88+ &mut index,
89+ &mut redactions,
90+ edit("$e2", "@a:x", 30, "$o", "newest"),
91+ );
92+ assert_eq!(
93+ feed(
94+ &mut index,
95+ &mut redactions,
96+ edit("$e1", "@a:x", 20, "$o", "older")
97+ ),
98+ Effect::Nothing
99+ );
100+ assert_eq!(
101+ feed(
102+ &mut index,
103+ &mut redactions,
104+ edit("$h", "@b:x", 40, "$o", "hijack")
105+ ),
106+ Effect::Nothing
107+ );
108+ assert_eq!(keys(&mut index, "newest"), vec!["$o"]);
109+ assert!(keys(&mut index, "hijack").is_empty());
110+ assert!(keys(&mut index, "original").is_empty());
111+}
112+
113+#[test]
114+fn a_provisional_edit_from_someone_else_gives_way_to_the_original() {
115+ let mut index = KeywordIndex::new(false);
116+ let mut redactions = Redactions::default();
117+ feed(
118+ &mut index,
119+ &mut redactions,
120+ edit("$h", "@b:x", 20, "$o", "hijack"),
121+ );
122+ feed(
123+ &mut index,
124+ &mut redactions,
125+ message("$o", "@a:x", 10, "genuine"),
126+ );
127+ assert_eq!(keys(&mut index, "genuine"), vec!["$o"]);
128+ assert!(keys(&mut index, "hijack").is_empty());
129+}
130+
131+#[test]
132+fn a_redaction_removes_the_message_and_a_stale_copy_cannot_bring_it_back() {
133+ let mut index = KeywordIndex::new(false);
134+ let mut redactions = Redactions::default();
135+ feed(
136+ &mut index,
137+ &mut redactions,
138+ message("$o", "@a:x", 10, "secret"),
139+ );
140+ assert_eq!(
141+ feed(&mut index, &mut redactions, redaction("$r", "$o")),
142+ Effect::Removed
143+ );
144+ feed(
145+ &mut index,
146+ &mut redactions,
147+ message("$o", "@a:x", 10, "secret"),
148+ );
149+ assert!(keys(&mut index, "secret").is_empty());
150+}
151+
152+#[test]
153+fn redacting_the_latest_edit_asks_for_the_original_again() {
154+ let mut index = KeywordIndex::new(false);
155+ let mut redactions = Redactions::default();
156+ feed(
157+ &mut index,
158+ &mut redactions,
159+ message("$o", "@a:x", 10, "first"),
160+ );
161+ feed(
162+ &mut index,
163+ &mut redactions,
164+ edit("$e", "@a:x", 20, "$o", "second"),
165+ );
166+ assert_eq!(
167+ feed(&mut index, &mut redactions, redaction("$r", "$e")),
168+ Effect::Refetch("$o".to_owned())
169+ );
170+ let mut original = message("$o", "@a:x", 10, "first");
171+ original["unsigned"] =
172+ json!({ "m.relations": { "m.replace": edit("$e", "@a:x", 20, "$o", "second") } });
173+ feed(&mut index, &mut redactions, original);
174+ assert_eq!(keys(&mut index, "first"), vec!["$o"]);
175+ assert!(keys(&mut index, "second").is_empty());
176+}
177+
178+#[test]
179+fn reply_fallbacks_are_not_indexed() {
180+ let mut index = KeywordIndex::new(false);
181+ let mut redactions = Redactions::default();
182+ let mut reply = message("$r", "@a:x", 10, "> <@b:x> quoted stuff\n\nactual answer");
183+ reply["content"]["m.relates_to"] = json!({ "m.in_reply_to": { "event_id": "$q" } });
184+ feed(&mut index, &mut redactions, reply);
185+ assert_eq!(keys(&mut index, "answer"), vec!["$r"]);
186+ assert!(keys(&mut index, "quoted").is_empty());
187+}
Added file: src/search/index.rs+826 additions
@@ -0,0 +1,826 @@
1+use std::{collections::HashMap, path::PathBuf, sync::Arc};
2+
3+use sha2::{Digest, Sha256};
4+use tantivy::{
5+ DocAddress, DocId, DocSet, Index, IndexSettings, ReloadPolicy, Score, Searcher, SegmentReader,
6+ TantivyDocument, Term,
7+ collector::{Collector, SegmentCollector},
8+ directory::{Directory, OwnedBytes, RamDirectory},
9+ fastfield::{AliveBitSet, write_alive_bitset},
10+ indexer::{SingleSegmentIndexWriter, merge_filtered_segments},
11+ query::{BooleanQuery, Occur, Query, QueryParser, TermSetQuery},
12+ schema::{
13+ FAST, Field, IndexRecordOption, STORED, STRING, Schema, TextFieldIndexing, TextOptions,
14+ Value,
15+ },
16+ tokenizer::{
17+ AsciiFoldingFilter, LowerCaser, RemoveLongFilter, SimpleTokenizer, TextAnalyzer,
18+ TokenStream,
19+ },
20+};
21+use tantivy_common::BitSet;
22+
23+use super::MessageIndex;
24+use crate::{Error, ErrorKind};
25+
26+const TOKENIZER: &str = "macltok";
27+const WRITER_BUDGET: usize = 4_000_000;
28+/// segments of a size class merge once there are this many of them
29+const MERGE_FACTOR: usize = 6;
30+
31+#[derive(Clone, Debug, Eq, PartialEq)]
32+pub(crate) struct Doc {
33+ pub(crate) key: String,
34+ pub(crate) room: String,
35+ pub(crate) sender: String,
36+ /// the original's, or the edit's while the original hasn't turned up
37+ pub(crate) ts: u64,
38+ /// the event whose text is indexed, the original or its latest edit
39+ pub(crate) content: String,
40+ pub(crate) content_ts: u64,
41+ /// only kept for provisional docs, read back empty otherwise
42+ pub(crate) text: String,
43+ /// an edit seen before its original
44+ pub(crate) provisional: bool,
45+}
46+
47+#[derive(Clone, Debug, Eq, PartialEq)]
48+pub(crate) struct Hit {
49+ pub(crate) key: String,
50+ pub(crate) room: String,
51+ pub(crate) sender: String,
52+ pub(crate) content: String,
53+ pub(crate) ts: u64,
54+ pub(crate) tie: u64,
55+}
56+
57+struct SegmentRecord {
58+ id: u64,
59+ del_gen: u64,
60+}
61+
62+#[derive(Default)]
63+pub(crate) struct Changes {
64+ pub(crate) puts: Vec<(String, Vec<u8>)>,
65+ pub(crate) deletes: Vec<String>,
66+ files: Vec<u64>,
67+ dels: Vec<(u64, u64)>,
68+ removed: Vec<u64>,
69+}
70+
71+#[derive(Clone, Copy)]
72+struct Fields {
73+ key: Field,
74+ room: Field,
75+ sender: Field,
76+ content: Field,
77+ text: Field,
78+ ts: Field,
79+ tie: Field,
80+ content_ts: Field,
81+ pending: Field,
82+}
83+
84+struct Segment {
85+ id: u64,
86+ raw: RamDirectory,
87+ index: Index,
88+ searcher: Searcher,
89+ alive: Arc<BitSet>,
90+ max_doc: u32,
91+ del_gen: u64,
92+ persisted: bool,
93+ deletes_dirty: bool,
94+}
95+
96+impl Segment {
97+ fn live(&self) -> usize {
98+ self.alive.len()
99+ }
100+}
101+
102+pub(crate) struct KeywordIndex {
103+ schema: Schema,
104+ fields: Fields,
105+ settings: IndexSettings,
106+ parser: QueryParser,
107+ segments: Vec<Segment>,
108+ buffer: HashMap<String, Doc>,
109+ next_id: u64,
110+ persistent: bool,
111+ /// merged away since the last commit, with the delete generation they were persisted at
112+ removed: Vec<(u64, bool, u64)>,
113+}
114+
115+pub(crate) fn analyzer() -> TextAnalyzer {
116+ TextAnalyzer::builder(SimpleTokenizer::default())
117+ .filter(RemoveLongFilter::limit(40))
118+ .filter(LowerCaser)
119+ .filter(AsciiFoldingFilter)
120+ .build()
121+}
122+
123+/// the byte ranges in `text` whose analysed token is one of `terms`
124+pub(crate) fn match_ranges(text: &str, terms: &[String]) -> Vec<(usize, usize)> {
125+ if terms.is_empty() {
126+ return Vec::new();
127+ }
128+ let mut analyzer = analyzer();
129+ let mut stream = analyzer.token_stream(text);
130+ let mut ranges = Vec::new();
131+ while stream.advance() {
132+ let token = stream.token();
133+ if terms.iter().any(|term| *term == token.text) {
134+ ranges.push((token.offset_from, token.offset_to));
135+ }
136+ }
137+ ranges
138+}
139+
140+fn schema() -> (Schema, Fields) {
141+ let mut builder = Schema::builder();
142+ let text_options = TextOptions::default().set_indexing_options(
143+ TextFieldIndexing::default()
144+ .set_tokenizer(TOKENIZER)
145+ .set_index_option(IndexRecordOption::WithFreqsAndPositions),
146+ );
147+ let fields = Fields {
148+ key: builder.add_text_field("key", STRING | STORED),
149+ room: builder.add_text_field("room", STRING | STORED),
150+ sender: builder.add_text_field("sender", STRING | STORED),
151+ content: builder.add_text_field("content", STRING | STORED),
152+ text: builder.add_text_field("text", text_options),
153+ ts: builder.add_u64_field("ts", FAST | STORED),
154+ tie: builder.add_u64_field("tie", FAST),
155+ content_ts: builder.add_u64_field("content_ts", STORED),
156+ pending: builder.add_text_field("pending", STORED),
157+ };
158+ (builder.build(), fields)
159+}
160+
161+fn tie(key: &str) -> u64 {
162+ let digest = Sha256::digest(key.as_bytes());
163+ u64::from_le_bytes(digest[..8].try_into().expect("a sha256 digest is 32 bytes"))
164+}
165+
166+fn settings() -> IndexSettings {
167+ // the dedicated compressor is a thread, and wasm32-unknown-unknown has none
168+ IndexSettings {
169+ docstore_compress_dedicated_thread: false,
170+ ..IndexSettings::default()
171+ }
172+}
173+
174+fn tantivy_error(
175+ context: &'static str,
176+ error: impl std::error::Error + Send + Sync + 'static,
177+) -> Error {
178+ Error::with_source(ErrorKind::Store, context, error)
179+}
180+
181+fn open_segment(id: u64, raw: RamDirectory, max_doc_hint: Option<u32>) -> Result<Segment, Error> {
182+ let index = Index::open(raw.clone())
183+ .map_err(|error| tantivy_error("opening a search segment", error))?;
184+ index.tokenizers().register(TOKENIZER, analyzer());
185+ let reader = index
186+ .reader_builder()
187+ .reload_policy(ReloadPolicy::Manual)
188+ .try_into()
189+ .map_err(|error| tantivy_error("reading a search segment", error))?;
190+ let searcher = reader.searcher();
191+ let max_doc = searcher
192+ .segment_readers()
193+ .iter()
194+ .map(SegmentReader::max_doc)
195+ .sum();
196+ if searcher.segment_readers().len() > 1 || max_doc_hint.is_some_and(|hint| hint != max_doc) {
197+ return Err(Error::new(
198+ ErrorKind::Store,
199+ "a search segment has an unexpected shape",
200+ ));
201+ }
202+ Ok(Segment {
203+ id,
204+ raw,
205+ index,
206+ searcher,
207+ alive: Arc::new(BitSet::with_max_value_and_full(max_doc)),
208+ max_doc,
209+ del_gen: 0,
210+ persisted: false,
211+ deletes_dirty: false,
212+ })
213+}
214+
215+fn pack(files: &[(String, Vec<u8>)]) -> Vec<u8> {
216+ let mut packed = Vec::with_capacity(
217+ files
218+ .iter()
219+ .map(|(name, bytes)| name.len() + bytes.len() + 12)
220+ .sum(),
221+ );
222+ packed.extend_from_slice(&(files.len() as u32).to_le_bytes());
223+ for (name, bytes) in files {
224+ packed.extend_from_slice(&(name.len() as u32).to_le_bytes());
225+ packed.extend_from_slice(name.as_bytes());
226+ packed.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
227+ packed.extend_from_slice(bytes);
228+ }
229+ packed
230+}
231+
232+fn unpack(mut bytes: &[u8]) -> Option<Vec<(String, Vec<u8>)>> {
233+ fn take<'a>(bytes: &mut &'a [u8], len: usize) -> Option<&'a [u8]> {
234+ if bytes.len() < len {
235+ return None;
236+ }
237+ let (head, tail) = bytes.split_at(len);
238+ *bytes = tail;
239+ Some(head)
240+ }
241+ let count = u32::from_le_bytes(take(&mut bytes, 4)?.try_into().ok()?);
242+ let mut files = Vec::new();
243+ for _ in 0..count {
244+ let name_len = u32::from_le_bytes(take(&mut bytes, 4)?.try_into().ok()?) as usize;
245+ let name = std::str::from_utf8(take(&mut bytes, name_len)?)
246+ .ok()?
247+ .to_owned();
248+ let len =
249+ usize::try_from(u64::from_le_bytes(take(&mut bytes, 8)?.try_into().ok()?)).ok()?;
250+ files.push((name, take(&mut bytes, len)?.to_vec()));
251+ }
252+ bytes.is_empty().then_some(files)
253+}
254+
255+pub(crate) fn segment_key(id: u64) -> String {
256+ format!("seg/{id}")
257+}
258+
259+pub(crate) fn deletes_key(id: u64, generation: u64) -> String {
260+ format!("del/{id}/{generation}")
261+}
262+
263+impl KeywordIndex {
264+ pub(crate) fn new(persistent: bool) -> Self {
265+ let (schema, fields) = schema();
266+ let parser_index = Index::create_in_ram(schema.clone());
267+ parser_index.tokenizers().register(TOKENIZER, analyzer());
268+ let mut parser = QueryParser::for_index(&parser_index, vec![fields.text]);
269+ parser.set_conjunction_by_default();
270+ Self {
271+ schema,
272+ fields,
273+ settings: settings(),
274+ parser,
275+ segments: Vec::new(),
276+ buffer: HashMap::new(),
277+ next_id: 1,
278+ persistent,
279+ removed: Vec::new(),
280+ }
281+ }
282+
283+ /// `blob` hands back what an earlier commit put, any of it missing or unreadable fails the load
284+ pub(crate) fn load(
285+ manifest: &serde_json::Value,
286+ mut blob: impl FnMut(&str) -> Option<Vec<u8>>,
287+ ) -> Result<Self, Error> {
288+ let invalid = || Error::new(ErrorKind::Store, "the search manifest is unreadable");
289+ let records = manifest["segments"]
290+ .as_array()
291+ .ok_or_else(invalid)?
292+ .iter()
293+ .map(|record| {
294+ Some(SegmentRecord {
295+ id: record.get(0)?.as_u64()?,
296+ del_gen: record.get(1)?.as_u64()?,
297+ })
298+ })
299+ .collect::<Option<Vec<_>>>()
300+ .ok_or_else(invalid)?;
301+ let next_id = manifest["next"].as_u64().ok_or_else(invalid)?;
302+ let mut index = Self::new(true);
303+ let missing = || {
304+ Error::new(
305+ ErrorKind::Store,
306+ "a search segment is missing or unreadable",
307+ )
308+ };
309+ for record in &records {
310+ let files = blob(&segment_key(record.id))
311+ .and_then(|bytes| unpack(&bytes))
312+ .ok_or_else(missing)?;
313+ let raw = RamDirectory::create();
314+ for (name, bytes) in files {
315+ raw.atomic_write(&PathBuf::from(name), &bytes)
316+ .map_err(|error| tantivy_error("loading a search segment", error))?;
317+ }
318+ let mut segment = open_segment(record.id, raw, None)?;
319+ if segment.index.schema() != index.schema {
320+ return Err(Error::new(
321+ ErrorKind::Store,
322+ "a search segment has another schema",
323+ ));
324+ }
325+ if record.del_gen > 0 {
326+ let bytes = blob(&deletes_key(record.id, record.del_gen)).ok_or_else(missing)?;
327+ let alive = AliveBitSet::open(OwnedBytes::new(bytes));
328+ let mut bits = BitSet::with_max_value(segment.max_doc);
329+ for doc in alive.iter_alive().filter(|doc| *doc < segment.max_doc) {
330+ bits.insert(doc);
331+ }
332+ segment.alive = Arc::new(bits);
333+ }
334+ segment.del_gen = record.del_gen;
335+ segment.persisted = true;
336+ index.segments.push(segment);
337+ }
338+ index.next_id = next_id.max(
339+ records
340+ .iter()
341+ .map(|record| record.id + 1)
342+ .max()
343+ .unwrap_or(1),
344+ );
345+ Ok(index)
346+ }
347+
348+ fn records(&self) -> Vec<SegmentRecord> {
349+ self.segments
350+ .iter()
351+ .filter(|segment| segment.persisted)
352+ .map(|segment| SegmentRecord {
353+ id: segment.id,
354+ del_gen: segment.del_gen,
355+ })
356+ .collect()
357+ }
358+
359+ fn document(&self, doc: &Doc) -> TantivyDocument {
360+ let fields = self.fields;
361+ let mut document = TantivyDocument::default();
362+ document.add_text(fields.key, &doc.key);
363+ document.add_text(fields.room, &doc.room);
364+ document.add_text(fields.sender, &doc.sender);
365+ document.add_text(fields.content, &doc.content);
366+ document.add_text(fields.text, &doc.text);
367+ document.add_u64(fields.ts, doc.ts);
368+ document.add_u64(fields.tie, tie(&doc.key));
369+ document.add_u64(fields.content_ts, doc.content_ts);
370+ if doc.provisional {
371+ document.add_text(fields.pending, &doc.text);
372+ }
373+ document
374+ }
375+
376+ fn stored(&self, document: &TantivyDocument) -> Option<Doc> {
377+ let fields = self.fields;
378+ let text = |field| {
379+ document
380+ .get_first(field)
381+ .and_then(|value| value.as_str())
382+ .map(str::to_owned)
383+ };
384+ let number = |field| document.get_first(field).and_then(|value| value.as_u64());
385+ let pending = text(fields.pending);
386+ Some(Doc {
387+ key: text(fields.key)?,
388+ room: text(fields.room)?,
389+ sender: text(fields.sender)?,
390+ ts: number(fields.ts)?,
391+ content: text(fields.content)?,
392+ content_ts: number(fields.content_ts)?,
393+ provisional: pending.is_some(),
394+ text: pending.unwrap_or_default(),
395+ })
396+ }
397+
398+ /// the live docs in `segment` whose `field` holds `value`
399+ fn find(&self, segment: &Segment, field: Field, value: &str) -> Vec<DocId> {
400+ let term = Term::from_field_text(field, value);
401+ let mut found = Vec::new();
402+ for reader in segment.searcher.segment_readers() {
403+ let Ok(inverted) = reader.inverted_index(field) else {
404+ continue;
405+ };
406+ let Ok(Some(mut postings)) = inverted.read_postings(&term, IndexRecordOption::Basic)
407+ else {
408+ continue;
409+ };
410+ let mut doc = postings.doc();
411+ while doc != tantivy::TERMINATED {
412+ if segment.alive.contains(doc) {
413+ found.push(doc);
414+ }
415+ doc = postings.advance();
416+ }
417+ }
418+ found
419+ }
420+
421+ fn lookup(&self, field: Field, value: &str) -> Option<Doc> {
422+ self.segments.iter().rev().find_map(|segment| {
423+ let doc = *self.find(segment, field, value).first()?;
424+ let document: TantivyDocument = segment.searcher.doc(DocAddress::new(0, doc)).ok()?;
425+ self.stored(&document)
426+ })
427+ }
428+
429+ fn flush_buffer(&mut self) -> Result<(), Error> {
430+ if self.buffer.is_empty() {
431+ return Ok(());
432+ }
433+ let raw = RamDirectory::create();
434+ let index = Index::create(raw.clone(), self.schema.clone(), self.settings.clone())
435+ .map_err(|error| tantivy_error("creating a search segment", error))?;
436+ index.tokenizers().register(TOKENIZER, analyzer());
437+ let mut writer = SingleSegmentIndexWriter::new(index, WRITER_BUDGET)
438+ .map_err(|error| tantivy_error("writing a search segment", error))?;
439+ let mut docs: Vec<_> = self.buffer.values().collect();
440+ docs.sort_by_key(|doc| doc.ts);
441+ for doc in docs {
442+ writer
443+ .add_document(self.document(doc))
444+ .map_err(|error| tantivy_error("writing a search segment", error))?;
445+ }
446+ writer
447+ .finalize()
448+ .map_err(|error| tantivy_error("finishing a search segment", error))?;
449+ let segment = open_segment(self.next_id, raw, Some(self.buffer.len() as u32))?;
450+ self.next_id += 1;
451+ self.segments.push(segment);
452+ self.buffer.clear();
453+ Ok(())
454+ }
455+
456+ fn class(live: usize) -> u32 {
457+ live.max(1).ilog(MERGE_FACTOR)
458+ }
459+
460+ /// merges a size class once it fills up, and rewrites a segment that's mostly deleted
461+ fn compact(&mut self) -> Result<(), Error> {
462+ self.segments.retain(|segment| {
463+ let empty = segment.live() == 0;
464+ if empty && self.persistent {
465+ self.removed
466+ .push((segment.id, segment.persisted, segment.del_gen));
467+ }
468+ !empty
469+ });
470+ loop {
471+ let mut classes: HashMap<u32, Vec<usize>> = HashMap::new();
472+ for (position, segment) in self.segments.iter().enumerate() {
473+ classes
474+ .entry(Self::class(segment.live()))
475+ .or_default()
476+ .push(position);
477+ }
478+ let full = classes
479+ .into_values()
480+ .find(|positions| positions.len() >= MERGE_FACTOR);
481+ let sparse = self
482+ .segments
483+ .iter()
484+ .position(|segment| {
485+ segment.max_doc >= 1_000 && segment.live() * 2 < segment.max_doc as usize
486+ })
487+ .map(|position| vec![position]);
488+ let Some(positions) = full.or(sparse) else {
489+ return Ok(());
490+ };
491+ self.merge(&positions)?;
492+ }
493+ }
494+
495+ fn merge(&mut self, positions: &[usize]) -> Result<(), Error> {
496+ let mut inputs = Vec::new();
497+ let mut alive = Vec::new();
498+ for position in positions {
499+ let segment = &self.segments[*position];
500+ let searchable = segment
501+ .index
502+ .searchable_segments()
503+ .map_err(|error| tantivy_error("merging search segments", error))?;
504+ let mut bytes = Vec::new();
505+ write_alive_bitset(&segment.alive, &mut bytes)
506+ .map_err(|error| tantivy_error("merging search segments", error))?;
507+ for tantivy_segment in searchable {
508+ inputs.push(tantivy_segment);
509+ alive.push(Some(AliveBitSet::open(OwnedBytes::new(bytes.clone()))));
510+ }
511+ }
512+ let raw = RamDirectory::create();
513+ merge_filtered_segments(&inputs, self.settings.clone(), alive, raw.clone())
514+ .map_err(|error| tantivy_error("merging search segments", error))?;
515+ let merged = open_segment(self.next_id, raw, None)?;
516+ self.next_id += 1;
517+ let mut position = 0;
518+ self.segments.retain(|segment| {
519+ let merging = positions.contains(&position);
520+ position += 1;
521+ if merging && self.persistent {
522+ self.removed
523+ .push((segment.id, segment.persisted, segment.del_gen));
524+ }
525+ !merging
526+ });
527+ self.segments.push(merged);
528+ Ok(())
529+ }
530+
531+ fn segment_files(segment: &Segment) -> Result<Vec<u8>, Error> {
532+ let mut names: Vec<PathBuf> = segment
533+ .index
534+ .searchable_segment_metas()
535+ .map_err(|error| tantivy_error("reading a search segment", error))?
536+ .iter()
537+ .flat_map(|meta| meta.list_files())
538+ .collect();
539+ names.push(PathBuf::from("meta.json"));
540+ let mut files = Vec::new();
541+ for name in names {
542+ if !segment.raw.exists(&name).unwrap_or(false) {
543+ continue;
544+ }
545+ let bytes = segment
546+ .raw
547+ .open_read(&name)
548+ .map_err(|error| tantivy_error("reading a search segment", error))?
549+ .read_bytes()
550+ .map_err(|error| tantivy_error("reading a search segment", error))?;
551+ files.push((
552+ name.to_string_lossy().into_owned(),
553+ bytes.as_slice().to_vec(),
554+ ));
555+ }
556+ Ok(pack(&files))
557+ }
558+}
559+
560+impl MessageIndex for KeywordIndex {
561+ fn get(&self, key: &str) -> Option<Doc> {
562+ if let Some(doc) = self.buffer.get(key) {
563+ return Some(doc.clone());
564+ }
565+ self.lookup(self.fields.key, key)
566+ }
567+
568+ fn by_content(&self, content: &str) -> Option<Doc> {
569+ if let Some(doc) = self.buffer.values().find(|doc| doc.content == content) {
570+ return Some(doc.clone());
571+ }
572+ self.lookup(self.fields.content, content)
573+ }
574+
575+ fn put(&mut self, doc: Doc) {
576+ self.remove(&doc.key);
577+ self.buffer.insert(doc.key.clone(), doc);
578+ }
579+
580+ fn remove(&mut self, key: &str) -> bool {
581+ let mut removed = self.buffer.remove(key).is_some();
582+ let key_field = self.fields.key;
583+ for position in 0..self.segments.len() {
584+ let docs = self.find(&self.segments[position], key_field, key);
585+ if docs.is_empty() {
586+ continue;
587+ }
588+ let segment = &mut self.segments[position];
589+ let alive = Arc::make_mut(&mut segment.alive);
590+ for doc in docs {
591+ alive.remove(doc);
592+ }
593+ segment.deletes_dirty = true;
594+ removed = true;
595+ }
596+ removed
597+ }
598+
599+ fn buffered(&self) -> usize {
600+ self.buffer.len()
601+ }
602+
603+ fn search(
604+ &mut self,
605+ query: &str,
606+ rooms: Option<&[String]>,
607+ before: Option<(u64, u64)>,
608+ limit: usize,
609+ ) -> Result<(Vec<Hit>, Vec<String>), Error> {
610+ self.flush_buffer()?;
611+ let (text_query, _) = self.parser.parse_query_lenient(query);
612+ let mut terms = Vec::new();
613+ text_query.query_terms(&mut |term, _| {
614+ if term.field() == self.fields.text
615+ && let Some(text) = term.value().as_str()
616+ {
617+ terms.push(text.to_owned());
618+ }
619+ });
620+ let query: Box<dyn Query> = match rooms {
621+ None => text_query,
622+ Some(rooms) => Box::new(BooleanQuery::new(vec![
623+ (Occur::Must, text_query),
624+ (
625+ Occur::Must,
626+ Box::new(TermSetQuery::new(
627+ rooms
628+ .iter()
629+ .map(|room| Term::from_field_text(self.fields.room, room)),
630+ )),
631+ ),
632+ ])),
633+ };
634+ let mut found = Vec::new();
635+ for segment in &self.segments {
636+ let collector = Newest {
637+ alive: segment.alive.clone(),
638+ before,
639+ limit,
640+ };
641+ let newest = segment
642+ .searcher
643+ .search(&*query, &collector)
644+ .map_err(|error| tantivy_error("searching messages", error))?;
645+ for (ts, tie, doc) in newest {
646+ found.push((ts, tie, segment.id, doc));
647+ }
648+ }
649+ found.sort_by(|a, b| (b.0, b.1).cmp(&(a.0, a.1)));
650+ found.truncate(limit);
651+ let mut hits = Vec::new();
652+ for (ts, tie, id, doc) in found {
653+ let segment = self
654+ .segments
655+ .iter()
656+ .find(|segment| segment.id == id)
657+ .expect("the segment was just searched");
658+ let document: TantivyDocument = segment
659+ .searcher
660+ .doc(doc)
661+ .map_err(|error| tantivy_error("reading a search hit", error))?;
662+ let Some(stored) = self.stored(&document) else {
663+ continue;
664+ };
665+ hits.push(Hit {
666+ key: stored.key,
667+ room: stored.room,
668+ sender: stored.sender,
669+ content: stored.content,
670+ ts,
671+ tie,
672+ });
673+ }
674+ terms.sort();
675+ terms.dedup();
676+ Ok((hits, terms))
677+ }
678+
679+ fn maintain(&mut self) -> Result<(), Error> {
680+ self.flush_buffer()?;
681+ self.compact()
682+ }
683+
684+ fn changes(&mut self) -> Result<Changes, Error> {
685+ self.maintain()?;
686+ let mut changes = Changes::default();
687+ for (id, persisted, del_gen) in &self.removed {
688+ if *persisted {
689+ changes.deletes.push(segment_key(*id));
690+ }
691+ if *del_gen > 0 {
692+ changes.deletes.push(deletes_key(*id, *del_gen));
693+ }
694+ changes.removed.push(*id);
695+ }
696+ for segment in &self.segments {
697+ if !segment.persisted {
698+ changes
699+ .puts
700+ .push((segment_key(segment.id), Self::segment_files(segment)?));
701+ changes.files.push(segment.id);
702+ }
703+ if segment.deletes_dirty && segment.live() < segment.max_doc as usize {
704+ let generation = segment.del_gen + 1;
705+ let mut bytes = Vec::new();
706+ write_alive_bitset(&segment.alive, &mut bytes)
707+ .map_err(|error| tantivy_error("saving search deletes", error))?;
708+ changes
709+ .puts
710+ .push((deletes_key(segment.id, generation), bytes));
711+ if segment.del_gen > 0 {
712+ changes
713+ .deletes
714+ .push(deletes_key(segment.id, segment.del_gen));
715+ }
716+ changes.dels.push((segment.id, generation));
717+ }
718+ }
719+ Ok(changes)
720+ }
721+
722+ fn committed(&mut self, changes: &Changes) {
723+ self.removed
724+ .retain(|(id, ..)| !changes.removed.contains(id));
725+ for segment in &mut self.segments {
726+ if changes.files.contains(&segment.id) {
727+ segment.persisted = true;
728+ }
729+ if let Some((_, generation)) = changes.dels.iter().find(|(id, _)| *id == segment.id) {
730+ segment.del_gen = *generation;
731+ segment.deletes_dirty = false;
732+ }
733+ }
734+ }
735+
736+ fn manifest(&self) -> serde_json::Value {
737+ let segments: Vec<_> = self
738+ .records()
739+ .into_iter()
740+ .map(|record| serde_json::json!([record.id, record.del_gen]))
741+ .collect();
742+ serde_json::json!({ "segments": segments, "next": self.next_id })
743+ }
744+}
745+
746+/// the `limit` newest live docs older than `before`, by timestamp and then key hash
747+struct Newest {
748+ alive: Arc<BitSet>,
749+ before: Option<(u64, u64)>,
750+ limit: usize,
751+}
752+
753+struct NewestSegment {
754+ alive: Arc<BitSet>,
755+ before: Option<(u64, u64)>,
756+ limit: usize,
757+ ts: tantivy::columnar::Column<u64>,
758+ tie: tantivy::columnar::Column<u64>,
759+ segment: u32,
760+ top: std::collections::BinaryHeap<std::cmp::Reverse<(u64, u64, DocAddress)>>,
761+}
762+
763+impl Collector for Newest {
764+ type Fruit = Vec<(u64, u64, DocAddress)>;
765+ type Child = NewestSegment;
766+
767+ fn for_segment(&self, segment: u32, reader: &SegmentReader) -> tantivy::Result<NewestSegment> {
768+ Ok(NewestSegment {
769+ alive: self.alive.clone(),
770+ before: self.before,
771+ limit: self.limit,
772+ ts: reader.fast_fields().u64("ts")?,
773+ tie: reader.fast_fields().u64("tie")?,
774+ segment,
775+ top: std::collections::BinaryHeap::new(),
776+ })
777+ }
778+
779+ fn requires_scoring(&self) -> bool {
780+ false
781+ }
782+
783+ fn merge_fruits(&self, fruits: Vec<Self::Fruit>) -> tantivy::Result<Self::Fruit> {
784+ let mut all: Vec<_> = fruits.into_iter().flatten().collect();
785+ all.sort_by(|a, b| (b.0, b.1).cmp(&(a.0, a.1)));
786+ all.truncate(self.limit);
787+ Ok(all)
788+ }
789+}
790+
791+impl SegmentCollector for NewestSegment {
792+ type Fruit = Vec<(u64, u64, DocAddress)>;
793+
794+ fn collect(&mut self, doc: DocId, _: Score) {
795+ if !self.alive.contains(doc) {
796+ return;
797+ }
798+ let (Some(ts), Some(tie)) = (self.ts.first(doc), self.tie.first(doc)) else {
799+ return;
800+ };
801+ if self.before.is_some_and(|before| (ts, tie) >= before) {
802+ return;
803+ }
804+ let entry = std::cmp::Reverse((ts, tie, DocAddress::new(self.segment, doc)));
805+ if self.top.len() < self.limit {
806+ self.top.push(entry);
807+ } else if self.top.peek().is_some_and(|smallest| entry < *smallest) {
808+ self.top.pop();
809+ self.top.push(entry);
810+ }
811+ }
812+
813+ fn harvest(self) -> Self::Fruit {
814+ let mut top: Vec<_> = self
815+ .top
816+ .into_iter()
817+ .map(|std::cmp::Reverse(entry)| entry)
818+ .collect();
819+ top.sort_by(|a, b| (b.0, b.1).cmp(&(a.0, a.1)));
820+ top
821+ }
822+}
823+
824+#[cfg(test)]
825+#[path = "index_tests.rs"]
826+mod tests;
Added file: src/search/index_tests.rs+132 additions
@@ -0,0 +1,132 @@
1+use std::collections::BTreeMap;
2+
3+use super::*;
4+
5+fn doc(key: &str, room: &str, ts: u64, text: &str) -> Doc {
6+ Doc {
7+ key: key.to_owned(),
8+ room: room.to_owned(),
9+ sender: "@a:x".to_owned(),
10+ ts,
11+ content: key.to_owned(),
12+ content_ts: ts,
13+ text: text.to_owned(),
14+ provisional: false,
15+ }
16+}
17+
18+fn keys(hits: &[Hit]) -> Vec<&str> {
19+ hits.iter().map(|hit| hit.key.as_str()).collect()
20+}
21+
22+fn commit(index: &mut KeywordIndex, blobs: &mut BTreeMap<String, Vec<u8>>) -> serde_json::Value {
23+ let changes = index.changes().unwrap();
24+ for key in &changes.deletes {
25+ blobs.remove(key);
26+ }
27+ for (key, value) in &changes.puts {
28+ blobs.insert(key.clone(), value.clone());
29+ }
30+ index.committed(&changes);
31+ index.manifest()
32+}
33+
34+#[test]
35+fn pages_run_newest_first_without_repeats_across_segments() {
36+ let mut index = KeywordIndex::new(false);
37+ for ts in 0..30 {
38+ index.put(doc(&format!("$e{ts}"), "!r:x", ts, "hello there"));
39+ if ts % 7 == 0 {
40+ index.maintain().unwrap();
41+ }
42+ }
43+ let mut seen = Vec::new();
44+ let mut before = None;
45+ loop {
46+ let (hits, _) = index.search("hello", None, before, 8).unwrap();
47+ let Some(last) = hits.last() else { break };
48+ before = Some((last.ts, last.tie));
49+ seen.extend(hits.iter().map(|hit| hit.ts));
50+ }
51+ assert_eq!(seen, (0..30).rev().collect::<Vec<_>>());
52+}
53+
54+#[test]
55+fn a_replaced_doc_is_found_once_by_its_new_text() {
56+ let mut index = KeywordIndex::new(false);
57+ index.put(doc("$a", "!r:x", 1, "before"));
58+ index.maintain().unwrap();
59+ index.put(doc("$a", "!r:x", 1, "after"));
60+ assert!(index.search("before", None, None, 10).unwrap().0.is_empty());
61+ assert_eq!(
62+ keys(&index.search("after", None, None, 10).unwrap().0),
63+ vec!["$a"]
64+ );
65+}
66+
67+#[test]
68+fn rooms_filter_the_results() {
69+ let mut index = KeywordIndex::new(false);
70+ index.put(doc("$a", "!one:x", 1, "word"));
71+ index.put(doc("$b", "!two:x", 2, "word"));
72+ let rooms = vec!["!one:x".to_owned()];
73+ assert_eq!(
74+ keys(&index.search("word", Some(&rooms), None, 10).unwrap().0),
75+ vec!["$a"]
76+ );
77+ assert!(
78+ index
79+ .search("word", Some(&[]), None, 10)
80+ .unwrap()
81+ .0
82+ .is_empty()
83+ );
84+}
85+
86+/// a lost delete brings a redacted message back, and a lost segment loses messages
87+#[test]
88+fn deletes_and_merges_survive_a_reload() {
89+ let mut blobs = BTreeMap::new();
90+ let mut index = KeywordIndex::new(true);
91+ for ts in 0..(MERGE_FACTOR as u64 + 2) {
92+ index.put(doc(&format!("$e{ts}"), "!r:x", ts, "kept"));
93+ commit(&mut index, &mut blobs);
94+ }
95+ index.put(doc("$gone", "!r:x", 100, "kept"));
96+ commit(&mut index, &mut blobs);
97+ index.remove("$gone");
98+ index.remove("$e0");
99+ let manifest = commit(&mut index, &mut blobs);
100+ let mut reloaded = KeywordIndex::load(&manifest, |key| blobs.get(key).cloned()).unwrap();
101+ let (hits, _) = reloaded.search("kept", None, None, 50).unwrap();
102+ let mut found = keys(&hits);
103+ found.sort();
104+ let mut expected: Vec<String> = (1..(MERGE_FACTOR as u64 + 2))
105+ .map(|ts| format!("$e{ts}"))
106+ .collect();
107+ expected.sort();
108+ assert_eq!(
109+ found,
110+ expected.iter().map(String::as_str).collect::<Vec<_>>()
111+ );
112+ let referenced: Vec<_> = blobs.keys().filter(|key| key.starts_with("seg/")).collect();
113+ assert!(
114+ referenced.len() < MERGE_FACTOR,
115+ "merged segments were not dropped: {referenced:?}"
116+ );
117+}
118+
119+#[test]
120+fn match_ranges_follow_the_analyser() {
121+ assert_eq!(
122+ match_ranges("Café, CAFE and cafés", &["cafe".to_owned()]),
123+ vec![(0, 5), (7, 11)]
124+ );
125+}
126+
127+#[test]
128+fn packed_segments_reject_truncation() {
129+ let packed = pack(&[("a".to_owned(), vec![1, 2, 3])]);
130+ assert!(unpack(&packed).is_some());
131+ assert!(unpack(&packed[..packed.len() - 1]).is_none());
132+}
Added file: src/search/mod.rs+1234 additions
@@ -0,0 +1,1234 @@
1+mod feed;
2+#[cfg_attr(not(any(feature = "sqlite", feature = "indexeddb")), allow(dead_code))]
3+mod index;
4+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
5+pub(crate) mod store;
6+
7+use std::{
8+ collections::{BTreeMap, HashMap, HashSet, VecDeque},
9+ sync::Arc,
10+ time::Duration,
11+};
12+
13+use futures_util::{
14+ StreamExt,
15+ future::join_all,
16+ stream::{AbortHandle, FuturesUnordered, SelectAll, abortable},
17+};
18+use matrix_sdk::{
19+ Client, Room, RoomState, SendOutsideWasm,
20+ deserialized_responses::TimelineEvent as SdkTimelineEvent,
21+ event_cache::{RoomEventCacheSubscriber, RoomEventCacheUpdate},
22+ room::MessagesOptions,
23+};
24+use matrix_sdk_base::RoomInfoNotableUpdateReasons;
25+use matrix_sdk_ui::eyeball_im::VectorDiff;
26+use ruma::{
27+ OwnedRoomId, OwnedUserId, UInt,
28+ events::{
29+ ignored_user_list::IgnoredUserListEventContent,
30+ room::encrypted::OriginalSyncRoomEncryptedEvent,
31+ },
32+ serde::Raw,
33+};
34+use serde_json::Value;
35+use tokio::sync::{Mutex, broadcast, mpsc, oneshot};
36+
37+use self::{
38+ feed::{Effect, Observed, Redactions},
39+ index::{Changes, Doc, Hit, KeywordIndex},
40+};
41+use crate::{
42+ CommandOutput, EngineConfig, Error, ErrorKind, RoomSearchCoverage, SearchBackfill,
43+ SearchCommand, SearchCoverage, SearchHit, SearchMatch, SearchPage, SearchScope, SearchSession,
44+ engine::{MediaService, SearchStore, context, executor::EngineBoxFuture},
45+ normalise::{MediaRegistration, MediaScope},
46+};
47+
48+const MAX_LIMIT: u16 = 50;
49+/// a consumer that never closes its searches still holds at most this many
50+const MAX_SEARCHES: usize = 8;
51+const COMMIT_AFTER_MS: u64 = 5_000;
52+const COMMIT_BUFFERED: usize = 2_000;
53+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
54+const COMMIT_RETRY_MS: u64 = 60_000;
55+const IDLE_SUBSCRIPTION_MS: u64 = 5 * 60_000;
56+const BACKFILL_RETRY_MS: u64 = 60_000;
57+const BACKFILL_RETRY_MAX_MS: u64 = 15 * 60_000;
58+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
59+const SCHEMA: u64 = 1;
60+
61+#[cfg(not(target_family = "wasm"))]
62+type BoxStream<T> = futures_util::stream::BoxStream<'static, T>;
63+#[cfg(target_family = "wasm")]
64+type BoxStream<T> = futures_util::stream::LocalBoxStream<'static, T>;
65+
66+/// a local index of message text. keyword search over tantivy is the only one today, an
67+/// embedding index can stand beside it later without the public api noticing
68+#[cfg_attr(not(any(feature = "sqlite", feature = "indexeddb")), allow(dead_code))]
69+pub(crate) trait MessageIndex: SendOutsideWasm {
70+ fn get(&self, key: &str) -> Option<Doc>;
71+ fn by_content(&self, content: &str) -> Option<Doc>;
72+ /// replaces whatever `doc.key` held
73+ fn put(&mut self, doc: Doc);
74+ fn remove(&mut self, key: &str) -> bool;
75+ fn buffered(&self) -> usize;
76+ /// newest first, strictly older than `before`. also hands back the analysed query terms
77+ fn search(
78+ &mut self,
79+ query: &str,
80+ rooms: Option<&[String]>,
81+ before: Option<(u64, u64)>,
82+ limit: usize,
83+ ) -> Result<(Vec<Hit>, Vec<String>), Error>;
84+ fn maintain(&mut self) -> Result<(), Error>;
85+ fn changes(&mut self) -> Result<Changes, Error>;
86+ fn committed(&mut self, changes: &Changes);
87+ fn manifest(&self) -> Value;
88+}
89+
90+#[derive(Clone, Debug, Default)]
91+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
92+struct RoomProgress {
93+ oldest: Option<u64>,
94+ newest: Option<u64>,
95+ /// where the next backfill page starts, `None` before the first one
96+ tail: Option<String>,
97+ started: bool,
98+ start_reached: bool,
99+}
100+
101+#[derive(Clone, Debug)]
102+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
103+struct Pending {
104+ room: String,
105+ session_id: String,
106+ ts: u64,
107+ raw: String,
108+}
109+
110+#[derive(Clone, Copy)]
111+struct Budget {
112+ pages_per_room: u16,
113+ page_size: u16,
114+ interval_ms: u64,
115+ max_pending: usize,
116+}
117+
118+impl Budget {
119+ fn from_config(config: &EngineConfig) -> Self {
120+ Self {
121+ pages_per_room: config.search_backfill_pages_per_room,
122+ page_size: config.search_backfill_page_size.max(1),
123+ interval_ms: config.search_backfill_interval_ms,
124+ max_pending: config.max_search_undecrypted,
125+ }
126+ }
127+}
128+
129+struct RawPage {
130+ hits: Vec<Hit>,
131+ terms: Vec<String>,
132+ more: bool,
133+ coverage: SearchCoverage,
134+}
135+
136+enum Request {
137+ Search {
138+ query: String,
139+ rooms: Option<Vec<String>>,
140+ before: Option<(u64, u64)>,
141+ limit: usize,
142+ coverage: Vec<OwnedRoomId>,
143+ reply: oneshot::Sender<Result<RawPage, Error>>,
144+ },
145+ Coverage(Vec<OwnedRoomId>, oneshot::Sender<SearchCoverage>),
146+ /// a hit that turned out redacted when its preview was read
147+ Forget(String),
148+ Stop(oneshot::Sender<()>),
149+}
150+
151+#[derive(Clone)]
152+pub(crate) struct Handle {
153+ requests: mpsc::Sender<Request>,
154+ client: Client,
155+ media: Arc<MediaService>,
156+ sessions: Arc<Sessions>,
157+}
158+
159+enum Work {
160+ Backfill {
161+ room: OwnedRoomId,
162+ head: bool,
163+ result: Result<matrix_sdk::room::Messages, Error>,
164+ },
165+ Decrypted {
166+ key: String,
167+ room: OwnedRoomId,
168+ value: Option<Value>,
169+ },
170+ Refetched {
171+ room: OwnedRoomId,
172+ value: Option<Value>,
173+ },
174+}
175+
176+struct Failure {
177+ message: String,
178+ retry_at_ms: u64,
179+}
180+
181+pub(crate) struct Service {
182+ client: Client,
183+ index: Box<dyn MessageIndex>,
184+ store: Option<Result<SearchStore, Error>>,
185+ rooms: BTreeMap<String, RoomProgress>,
186+ pending: BTreeMap<String, Pending>,
187+ pending_dirty: bool,
188+ redactions: Redactions,
189+ dirty_since: Option<u64>,
190+ commit_after: u64,
191+ budget: Budget,
192+ requests: mpsc::Receiver<Request>,
193+ subscriptions: HashMap<OwnedRoomId, (AbortHandle, u64)>,
194+ updates: SelectAll<BoxStream<(OwnedRoomId, Option<Vec<SdkTimelineEvent>>)>>,
195+ work: FuturesUnordered<EngineBoxFuture<'static, Work>>,
196+ pages: HashMap<OwnedRoomId, u16>,
197+ heads: VecDeque<OwnedRoomId>,
198+ headed: HashSet<OwnedRoomId>,
199+ head_tokens: HashMap<OwnedRoomId, String>,
200+ backfilling: Option<OwnedRoomId>,
201+ next_backfill: u64,
202+ retry_delay: u64,
203+ failures: HashMap<OwnedRoomId, Failure>,
204+ turn: usize,
205+}
206+
207+fn now() -> u64 {
208+ ruma::MilliSecondsSinceUnixEpoch::now().get().into()
209+}
210+
211+fn value_of(event: &SdkTimelineEvent) -> Option<Value> {
212+ serde_json::from_str(event.raw().json().get()).ok()
213+}
214+
215+fn encode_cursor((ts, tie): (u64, u64)) -> String {
216+ format!("{ts:x}.{tie:x}")
217+}
218+
219+fn decode_cursor(cursor: &str) -> Option<(u64, u64)> {
220+ let (ts, tie) = cursor.split_once('.')?;
221+ Some((
222+ u64::from_str_radix(ts, 16).ok()?,
223+ u64::from_str_radix(tie, 16).ok()?,
224+ ))
225+}
226+
227+impl Handle {
228+ pub(crate) async fn execute(&self, command: SearchCommand) -> Result<CommandOutput, Error> {
229+ match command {
230+ SearchCommand::Search {
231+ query,
232+ scope,
233+ limit,
234+ } => {
235+ let (page, media) = self.search(&query, &scope, None, limit).await?;
236+ let search_id = self.sessions.open(&self.media, query, scope, media).await;
237+ Ok(CommandOutput::SearchSession(SearchSession { search_id, page }))
238+ }
239+ SearchCommand::More {
240+ search_id,
241+ cursor,
242+ limit,
243+ } => {
244+ let (query, scope) = self.sessions.get(&search_id).await?;
245+ let (page, media) = self.search(&query, &scope, Some(cursor.as_str()), limit).await?;
246+ self.sessions.register(&self.media, &search_id, media).await?;
247+ Ok(CommandOutput::SearchPage(page))
248+ }
249+ SearchCommand::Close { search_id } => {
250+ self.sessions.close(&self.media, &search_id).await?;
251+ Ok(CommandOutput::Done)
252+ }
253+ SearchCommand::Coverage(scope) => {
254+ let (reply, response) = oneshot::channel();
255+ self.requests
256+ .send(Request::Coverage(self.scope_rooms(&scope), reply))
257+ .await
258+ .map_err(|_| Error::interrupted())?;
259+ Ok(CommandOutput::SearchCoverage(
260+ response.await.map_err(|_| Error::interrupted())?,
261+ ))
262+ }
263+ }
264+ }
265+
266+ /// the media service is cleared beside this, so nothing is released one by one
267+ pub(crate) async fn stop(&self) {
268+ let (reply, response) = oneshot::channel();
269+ if self.requests.send(Request::Stop(reply)).await.is_ok() {
270+ let _ = response.await;
271+ }
272+ self.sessions.open.lock().await.clear();
273+ }
274+
275+ fn scope_rooms(&self, scope: &SearchScope) -> Vec<OwnedRoomId> {
276+ match scope {
277+ SearchScope::Room(room_id) => vec![room_id.clone()],
278+ SearchScope::Rooms(room_ids) => room_ids.clone(),
279+ SearchScope::All => self
280+ .client
281+ .joined_rooms()
282+ .into_iter()
283+ .map(|room| room.room_id().to_owned())
284+ .collect(),
285+ }
286+ }
287+
288+ async fn search(
289+ &self,
290+ query: &str,
291+ scope: &SearchScope,
292+ cursor: Option<&str>,
293+ limit: u16,
294+ ) -> Result<(SearchPage, Vec<MediaRegistration>), Error> {
295+ if limit == 0 || limit > MAX_LIMIT {
296+ return Err(Error::new(
297+ ErrorKind::Invalid,
298+ "search limit must be between 1 and 50",
299+ ));
300+ }
301+ if query.trim().is_empty() {
302+ return Err(Error::new(ErrorKind::Invalid, "search query is empty"));
303+ }
304+ let before = match cursor {
305+ Some(cursor) => Some(
306+ decode_cursor(cursor)
307+ .ok_or_else(|| Error::new(ErrorKind::Invalid, "search cursor is invalid"))?,
308+ ),
309+ None => None,
310+ };
311+ let rooms = self.scope_rooms(scope);
312+ let filter = Some(rooms.iter().map(ToString::to_string).collect());
313+ let (reply, response) = oneshot::channel();
314+ self.requests
315+ .send(Request::Search {
316+ query: query.to_owned(),
317+ rooms: filter,
318+ before,
319+ limit: usize::from(limit),
320+ coverage: rooms,
321+ reply,
322+ })
323+ .await
324+ .map_err(|_| Error::interrupted())?;
325+ let raw = response.await.map_err(|_| Error::interrupted())??;
326+ let next_cursor = raw
327+ .more
328+ .then(|| raw.hits.last().map(|hit| encode_cursor((hit.ts, hit.tie))))
329+ .flatten();
330+ let ignored = self.ignored_users().await;
331+ let current_user_id = self.client.user_id().map(ToOwned::to_owned);
332+ let previews = join_all(
333+ raw.hits
334+ .into_iter()
335+ .filter(|hit| !ignored.iter().any(|user| user.as_str() == hit.sender))
336+ .map(|hit| self.preview(hit, current_user_id.clone(), &raw.terms)),
337+ )
338+ .await;
339+ let mut hits = Vec::new();
340+ let mut media = Vec::new();
341+ for preview in previews {
342+ match preview {
343+ Preview::Shown(hit, registrations) => {
344+ hits.push(hit);
345+ media.extend(registrations);
346+ }
347+ Preview::Redacted(key) => {
348+ let _ = self.requests.send(Request::Forget(key)).await;
349+ }
350+ Preview::Unavailable => {}
351+ }
352+ }
353+ let page = SearchPage {
354+ hits,
355+ next_cursor,
356+ coverage: raw.coverage,
357+ };
358+ Ok((page, media))
359+ }
360+
361+ async fn ignored_users(&self) -> Vec<OwnedUserId> {
362+ self.client
363+ .account()
364+ .account_data::<IgnoredUserListEventContent>()
365+ .await
366+ .ok()
367+ .flatten()
368+ .and_then(|raw| raw.deserialize().ok())
369+ .map(|content| content.ignored_users.into_keys().collect())
370+ .unwrap_or_default()
371+ }
372+
373+ async fn preview(
374+ &self,
375+ hit: Hit,
376+ current_user_id: Option<OwnedUserId>,
377+ terms: &[String],
378+ ) -> Preview {
379+ let (Ok(room_id), Ok(event_id)) = (
380+ OwnedRoomId::try_from(hit.room.as_str()),
381+ ruma::OwnedEventId::try_from(hit.key.as_str()),
382+ ) else {
383+ return Preview::Unavailable;
384+ };
385+ let Some(room) = self.client.get_room(&room_id) else {
386+ return Preview::Unavailable;
387+ };
388+ let Some((original, highlight)) = load(&room, &hit.key).await else {
389+ return Preview::Unavailable;
390+ };
391+ if original.pointer("/unsigned/redacted_because").is_some() {
392+ return Preview::Redacted(hit.key);
393+ }
394+ let mut values = vec![original];
395+ if hit.content != hit.key
396+ && let Some((edit, _)) = load(&room, &hit.content).await
397+ {
398+ values.push(edit);
399+ }
400+ let Some((event, registrations)) =
401+ context::preview(&room, current_user_id.as_deref(), &values, highlight).await
402+ else {
403+ return Preview::Unavailable;
404+ };
405+ let text = match &event.content {
406+ crate::TimelineContent::Message(message) => Some(message.body.as_str()),
407+ crate::TimelineContent::Sticker { body, .. } => Some(body.as_str()),
408+ crate::TimelineContent::Poll { question } => Some(question.as_str()),
409+ _ => None,
410+ };
411+ let matches = text
412+ .map(|text| index::match_ranges(text, terms))
413+ .unwrap_or_default()
414+ .into_iter()
415+ .filter_map(|(start, end)| {
416+ Some(SearchMatch {
417+ start: u32::try_from(start).ok()?,
418+ end: u32::try_from(end).ok()?,
419+ })
420+ })
421+ .collect();
422+ Preview::Shown(
423+ SearchHit {
424+ room_id,
425+ event_id,
426+ timestamp_ms: event.timestamp_ms,
427+ event,
428+ matches,
429+ },
430+ registrations,
431+ )
432+ }
433+}
434+
435+enum Preview {
436+ Shown(SearchHit, Vec<MediaRegistration>),
437+ Redacted(String),
438+ Unavailable,
439+}
440+
441+/// open searches, oldest first, each owning the media of the hits it handed out
442+#[derive(Default)]
443+struct Sessions {
444+ open: Mutex<VecDeque<Session>>,
445+}
446+
447+struct Session {
448+ id: String,
449+ query: String,
450+ scope: SearchScope,
451+ media: MediaScope,
452+}
453+
454+impl Sessions {
455+ async fn open(
456+ &self,
457+ media: &MediaService,
458+ query: String,
459+ scope: SearchScope,
460+ registrations: Vec<MediaRegistration>,
461+ ) -> String {
462+ let media_scope = media.context_scope();
463+ let id = format!("search-{}", media_scope.nonce());
464+ let mut open = self.open.lock().await;
465+ media.register(&media_scope, registrations).await;
466+ open.push_back(Session {
467+ id: id.clone(),
468+ query,
469+ scope,
470+ media: media_scope,
471+ });
472+ while open.len() > MAX_SEARCHES {
473+ if let Some(oldest) = open.pop_front() {
474+ media.release(&oldest.media).await;
475+ }
476+ }
477+ id
478+ }
479+
480+ async fn get(&self, id: &str) -> Result<(String, SearchScope), Error> {
481+ let open = self.open.lock().await;
482+ let session = open.iter().find(|session| session.id == id).ok_or_else(not_open)?;
483+ Ok((session.query.clone(), session.scope.clone()))
484+ }
485+
486+ /// under the lock, so a page that lands after its close registers nothing
487+ async fn register(
488+ &self,
489+ media: &MediaService,
490+ id: &str,
491+ registrations: Vec<MediaRegistration>,
492+ ) -> Result<(), Error> {
493+ let open = self.open.lock().await;
494+ let session = open.iter().find(|session| session.id == id).ok_or_else(not_open)?;
495+ media.register(&session.media, registrations).await;
496+ Ok(())
497+ }
498+
499+ async fn close(&self, media: &MediaService, id: &str) -> Result<(), Error> {
500+ let mut open = self.open.lock().await;
501+ let index = open
502+ .iter()
503+ .position(|session| session.id == id)
504+ .ok_or_else(not_open)?;
505+ if let Some(session) = open.remove(index) {
506+ media.release(&session.media).await;
507+ }
508+ Ok(())
509+ }
510+}
511+
512+fn not_open() -> Error {
513+ Error::new(ErrorKind::SearchNotOpen, "search is closed or was never opened")
514+}
515+
516+async fn load(room: &Room, event_id: &str) -> Option<(Value, bool)> {
517+ let event_id = ruma::EventId::parse(event_id).ok()?;
518+ let event = room
519+ .load_or_fetch_event(&event_id, None)
520+ .await
521+ .inspect_err(|error| tracing::debug!(%event_id, %error, "loading a search hit failed"))
522+ .ok()?;
523+ let highlight = event
524+ .push_actions()
525+ .is_some_and(|actions| actions.iter().any(ruma::push::Action::is_highlight));
526+ Some((value_of(&event)?, highlight))
527+}
528+
529+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
530+fn decode_meta(
531+ store: &SearchStore,
532+ blobs: &BTreeMap<String, Vec<u8>>,
533+) -> Option<(
534+ Value,
535+ BTreeMap<String, RoomProgress>,
536+ BTreeMap<String, Pending>,
537+)> {
538+ let meta: Value = serde_json::from_slice(&store.open_blob("meta", blobs.get("meta")?)?).ok()?;
539+ if meta["schema"].as_u64() != Some(SCHEMA) {
540+ return None;
541+ }
542+ let rooms = serde_json::from_value(meta["rooms"].clone()).ok()?;
543+ let pending = match blobs.get("utd") {
544+ Some(sealed) => serde_json::from_slice(&store.open_blob("utd", sealed)?).ok()?,
545+ None => BTreeMap::new(),
546+ };
547+ Some((meta["index"].clone(), rooms, pending))
548+}
549+
550+impl Service {
551+ pub(crate) fn new(
552+ client: Client,
553+ media: Arc<MediaService>,
554+ store: Option<Result<SearchStore, Error>>,
555+ config: &EngineConfig,
556+ ) -> (Handle, Self) {
557+ let (sender, requests) = mpsc::channel(config.command_capacity.max(1));
558+ let service = Self {
559+ client: client.clone(),
560+ index: Box::new(KeywordIndex::new(store.as_ref().is_some_and(Result::is_ok))),
561+ store,
562+ rooms: BTreeMap::new(),
563+ pending: BTreeMap::new(),
564+ pending_dirty: false,
565+ redactions: Redactions::default(),
566+ dirty_since: None,
567+ commit_after: 0,
568+ budget: Budget::from_config(config),
569+ requests,
570+ subscriptions: HashMap::new(),
571+ updates: SelectAll::new(),
572+ work: FuturesUnordered::new(),
573+ pages: HashMap::new(),
574+ heads: VecDeque::new(),
575+ headed: HashSet::new(),
576+ head_tokens: HashMap::new(),
577+ backfilling: None,
578+ next_backfill: 0,
579+ retry_delay: BACKFILL_RETRY_MS,
580+ failures: HashMap::new(),
581+ turn: 0,
582+ };
583+ (
584+ Handle {
585+ requests: sender,
586+ client,
587+ media,
588+ sessions: Arc::default(),
589+ },
590+ service,
591+ )
592+ }
593+
594+ fn persistent(&self) -> bool {
595+ matches!(self.store, Some(Ok(_)))
596+ }
597+
598+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
599+ async fn load(&mut self) {
600+ let Some(Ok(store)) = &self.store else {
601+ if let Some(Err(error)) = &self.store {
602+ tracing::error!(%error, "search store unavailable, the index lives in memory");
603+ }
604+ return;
605+ };
606+ let blobs = match store.load().await {
607+ Ok(blobs) => blobs,
608+ Err(error) => {
609+ tracing::error!(%error, "reading the search store failed, the index lives in memory");
610+ self.store = Some(Err(error));
611+ self.index = Box::new(KeywordIndex::new(false));
612+ return;
613+ }
614+ };
615+ if blobs.is_empty() {
616+ return;
617+ }
618+ let restored = decode_meta(store, &blobs).and_then(|(manifest, rooms, pending)| {
619+ let index = KeywordIndex::load(&manifest, |key| {
620+ blobs
621+ .get(key)
622+ .and_then(|sealed| store.open_blob(key, sealed))
623+ })
624+ .inspect_err(|error| tracing::warn!(%error, "search segments are unreadable"))
625+ .ok()?;
626+ Some((index, rooms, pending))
627+ });
628+ drop(blobs);
629+ match restored {
630+ Some((index, rooms, pending)) => {
631+ self.index = Box::new(index);
632+ self.rooms = rooms;
633+ self.pending = pending;
634+ }
635+ None => {
636+ tracing::warn!("resetting an unreadable search index");
637+ if let Err(error) = store.reset().await {
638+ tracing::error!(%error, "search store unavailable, the index lives in memory");
639+ self.store = Some(Err(error));
640+ self.index = Box::new(KeywordIndex::new(false));
641+ }
642+ }
643+ }
644+ }
645+
646+ #[cfg(not(any(feature = "sqlite", feature = "indexeddb")))]
647+ async fn load(&mut self) {
648+ if let Some(Err(error)) = &self.store {
649+ tracing::error!(%error, "search store unavailable, the index lives in memory");
650+ }
651+ }
652+
653+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
654+ async fn commit(&mut self) {
655+ if !self.persistent() {
656+ if let Err(error) = self.index.maintain() {
657+ tracing::warn!(%error, "maintaining the search index failed");
658+ }
659+ self.dirty_since = None;
660+ return;
661+ }
662+ let changes = match self.index.changes() {
663+ Ok(changes) => changes,
664+ Err(error) => {
665+ tracing::warn!(%error, "preparing a search commit failed");
666+ self.commit_after = now() + COMMIT_RETRY_MS;
667+ return;
668+ }
669+ };
670+ let mut puts = changes.puts.clone();
671+ let meta = serde_json::json!({
672+ "schema": SCHEMA,
673+ "index": self.index.manifest(),
674+ "rooms": self.rooms,
675+ });
676+ puts.push(("meta".to_owned(), meta.to_string().into_bytes()));
677+ if self.pending_dirty {
678+ puts.push((
679+ "utd".to_owned(),
680+ serde_json::to_vec(&self.pending).unwrap_or_default(),
681+ ));
682+ }
683+ let Some(Ok(store)) = &self.store else {
684+ return;
685+ };
686+ match store.commit(puts, changes.deletes.clone()).await {
687+ Ok(()) => {
688+ self.index.committed(&changes);
689+ self.pending_dirty = false;
690+ self.dirty_since = None;
691+ }
692+ Err(error) => {
693+ tracing::warn!(%error, "saving the search index failed, retrying later");
694+ self.commit_after = now() + COMMIT_RETRY_MS;
695+ }
696+ }
697+ }
698+
699+ #[cfg(not(any(feature = "sqlite", feature = "indexeddb")))]
700+ async fn commit(&mut self) {
701+ if let Err(error) = self.index.maintain() {
702+ tracing::warn!(%error, "maintaining the search index failed");
703+ }
704+ self.dirty_since = None;
705+ }
706+
707+ fn touch(&mut self) {
708+ self.dirty_since.get_or_insert_with(now);
709+ }
710+
711+ fn observe(&mut self, room: &OwnedRoomId, value: &Value) {
712+ let observed = feed::observe(value);
713+ let key = match &observed {
714+ Observed::Message { key, .. }
715+ | Observed::Redacted { key }
716+ | Observed::Encrypted { key, .. } => Some(key.clone()),
717+ Observed::Edit(edit) => Some(edit.id.clone()),
718+ Observed::Redaction { .. } | Observed::Other => None,
719+ };
720+ if let Observed::Encrypted {
721+ key,
722+ session_id,
723+ ts,
724+ } = &observed
725+ {
726+ if self.index.get(key).is_none() && !self.pending.contains_key(key) {
727+ self.pending.insert(
728+ key.clone(),
729+ Pending {
730+ room: room.to_string(),
731+ session_id: session_id.clone(),
732+ ts: *ts,
733+ raw: value.to_string(),
734+ },
735+ );
736+ while self.pending.len() > self.budget.max_pending {
737+ let Some(oldest) = self
738+ .pending
739+ .iter()
740+ .min_by_key(|(_, pending)| pending.ts)
741+ .map(|(key, _)| key.clone())
742+ else {
743+ break;
744+ };
745+ self.pending.remove(&oldest);
746+ }
747+ self.pending_dirty = true;
748+ self.touch();
749+ }
750+ return;
751+ }
752+ if let Some(key) = key
753+ && self.pending.remove(&key).is_some()
754+ {
755+ self.pending_dirty = true;
756+ self.touch();
757+ }
758+ match feed::apply(
759+ self.index.as_mut(),
760+ &mut self.redactions,
761+ room.as_str(),
762+ observed,
763+ ) {
764+ Effect::Indexed(ts) => {
765+ let progress = self.rooms.entry(room.to_string()).or_default();
766+ progress.oldest = Some(progress.oldest.map_or(ts, |oldest| oldest.min(ts)));
767+ progress.newest = Some(progress.newest.map_or(ts, |newest| newest.max(ts)));
768+ self.touch();
769+ }
770+ Effect::Removed => self.touch(),
771+ Effect::Refetch(key) => {
772+ self.touch();
773+ let room_id = room.clone();
774+ let client = self.client.clone();
775+ self.work.push(Box::pin(async move {
776+ let value = match client.get_room(&room_id) {
777+ Some(room) => load(&room, &key).await.map(|(value, _)| value),
778+ None => None,
779+ };
780+ Work::Refetched {
781+ room: room_id,
782+ value,
783+ }
784+ }));
785+ }
786+ Effect::Nothing => {}
787+ }
788+ }
789+
790+ fn observe_all(&mut self, room: &OwnedRoomId, events: &[SdkTimelineEvent]) {
791+ for event in events {
792+ if let Some(value) = value_of(event) {
793+ self.observe(room, &value);
794+ }
795+ }
796+ }
797+
798+ async fn subscribe(&mut self, room_id: OwnedRoomId) {
799+ if let Some((_, seen)) = self.subscriptions.get_mut(&room_id) {
800+ *seen = now();
801+ return;
802+ }
803+ let Some(room) = self.client.get_room(&room_id) else {
804+ return;
805+ };
806+ if room.state() != RoomState::Joined {
807+ return;
808+ }
809+ let Ok((cache, drop_handles)) = room.event_cache().await else {
810+ return;
811+ };
812+ let Ok((initial, subscriber)) = cache.subscribe().await else {
813+ return;
814+ };
815+ self.observe_all(&room_id, &initial);
816+ let stream = updates(room_id.clone(), subscriber, drop_handles);
817+ let (stream, abort) = abortable(stream);
818+ self.updates.push(Box::pin(stream));
819+ self.subscriptions.insert(room_id.clone(), (abort, now()));
820+ if self.headed.insert(room_id.clone()) {
821+ self.heads.push_back(room_id);
822+ }
823+ }
824+
825+ fn drop_idle(&mut self) {
826+ let cutoff = now().saturating_sub(IDLE_SUBSCRIPTION_MS);
827+ self.subscriptions.retain(|_, (abort, seen)| {
828+ let keep = *seen >= cutoff;
829+ if !keep {
830+ abort.abort();
831+ }
832+ keep
833+ });
834+ }
835+
836+ fn coverage(&self, rooms: &[OwnedRoomId]) -> SearchCoverage {
837+ let rooms = rooms
838+ .iter()
839+ .map(|room_id| {
840+ let progress = self
841+ .rooms
842+ .get(room_id.as_str())
843+ .cloned()
844+ .unwrap_or_default();
845+ let backfill = if progress.start_reached {
846+ SearchBackfill::Complete
847+ } else if self.budget.pages_per_room == 0 {
848+ SearchBackfill::Disabled
849+ } else if self.backfilling.as_ref() == Some(room_id) {
850+ SearchBackfill::Running
851+ } else if let Some(Failure {
852+ message,
853+ retry_at_ms,
854+ }) = self.failures.get(room_id)
855+ {
856+ SearchBackfill::Failed {
857+ message: message.clone(),
858+ retry_at_ms: *retry_at_ms,
859+ }
860+ } else if self.pages.get(room_id).copied().unwrap_or(0)
861+ >= self.budget.pages_per_room
862+ {
863+ SearchBackfill::Paused
864+ } else {
865+ SearchBackfill::Waiting
866+ };
867+ RoomSearchCoverage {
868+ room_id: room_id.clone(),
869+ oldest_indexed_ms: progress.oldest,
870+ newest_indexed_ms: progress.newest,
871+ start_reached: progress.start_reached,
872+ backfill,
873+ }
874+ })
875+ .collect();
876+ SearchCoverage {
877+ rooms,
878+ persistent: self.persistent(),
879+ undecrypted: self.pending.len(),
880+ }
881+ }
882+
883+ /// head passes first, so what arrived while we were away is found before anything older
884+ fn schedule_backfill(&mut self) {
885+ if self.backfilling.is_some()
886+ || self.budget.pages_per_room == 0
887+ || now() < self.next_backfill
888+ {
889+ return;
890+ }
891+ let budget = self.budget.pages_per_room;
892+ let spent = |pages: &HashMap<OwnedRoomId, u16>, room: &OwnedRoomId| {
893+ pages.get(room).copied().unwrap_or(0) >= budget
894+ };
895+ let blocked = |failures: &HashMap<OwnedRoomId, Failure>, room: &OwnedRoomId| {
896+ failures
897+ .get(room)
898+ .is_some_and(|failure| failure.retry_at_ms > now())
899+ };
900+ let mut chosen = None;
901+ while let Some(room_id) = self.heads.pop_front() {
902+ let started = self
903+ .rooms
904+ .get(room_id.as_str())
905+ .is_some_and(|progress| progress.started);
906+ if started && !spent(&self.pages, &room_id) && !blocked(&self.failures, &room_id) {
907+ chosen = Some((room_id, true));
908+ break;
909+ }
910+ }
911+ if chosen.is_none() {
912+ let mut joined: Vec<_> = self
913+ .client
914+ .joined_rooms()
915+ .into_iter()
916+ .map(|room| room.room_id().to_owned())
917+ .collect();
918+ joined.sort();
919+ let count = joined.len();
920+ for offset in 0..count {
921+ let room_id = &joined[(self.turn + offset) % count];
922+ let done = self
923+ .rooms
924+ .get(room_id.as_str())
925+ .is_some_and(|progress| progress.start_reached);
926+ if !done && !spent(&self.pages, room_id) && !blocked(&self.failures, room_id) {
927+ self.turn = (self.turn + offset + 1) % count;
928+ chosen = Some((room_id.clone(), false));
929+ break;
930+ }
931+ }
932+ }
933+ let Some((room_id, head)) = chosen else {
934+ return;
935+ };
936+ let Some(room) = self.client.get_room(&room_id) else {
937+ return;
938+ };
939+ let from = if head {
940+ self.head_tokens.get(&room_id).cloned()
941+ } else {
942+ self.rooms
943+ .get(room_id.as_str())
944+ .and_then(|progress| progress.tail.clone())
945+ };
946+ *self.pages.entry(room_id.clone()).or_default() += 1;
947+ self.backfilling = Some(room_id.clone());
948+ let mut options = MessagesOptions::backward();
949+ options.from = from;
950+ options.limit = UInt::from(self.budget.page_size);
951+ self.work.push(Box::pin(async move {
952+ let result = room
953+ .messages(options)
954+ .await
955+ .map_err(|error| Error::from_sdk("backfilling search history failed", error));
956+ Work::Backfill {
957+ room: room_id,
958+ head,
959+ result,
960+ }
961+ }));
962+ }
963+
964+ fn backfilled(
965+ &mut self,
966+ room_id: OwnedRoomId,
967+ head: bool,
968+ result: Result<matrix_sdk::room::Messages, Error>,
969+ ) {
970+ self.backfilling = None;
971+ let messages = match result {
972+ Ok(messages) => messages,
973+ Err(error) => {
974+ let retry_at_ms = now() + self.retry_delay;
975+ self.next_backfill = retry_at_ms;
976+ self.retry_delay = (self.retry_delay * 2).min(BACKFILL_RETRY_MAX_MS);
977+ self.failures.insert(
978+ room_id,
979+ Failure {
980+ message: error.message.clone(),
981+ retry_at_ms,
982+ },
983+ );
984+ return;
985+ }
986+ };
987+ self.retry_delay = BACKFILL_RETRY_MS;
988+ self.failures.remove(&room_id);
989+ self.next_backfill = now() + self.budget.interval_ms;
990+ let mut overlapped = false;
991+ for event in &messages.chunk {
992+ let Some(value) = value_of(event) else {
993+ continue;
994+ };
995+ if head
996+ && let Some(key) = value.get("event_id").and_then(Value::as_str)
997+ && self.index.get(key).is_some_and(|doc| !doc.provisional)
998+ {
999+ overlapped = true;
1000+ }
1001+ self.observe(&room_id, &value);
1002+ }
1003+ let ended = messages.end.is_none() || messages.chunk.is_empty();
1004+ if head {
1005+ match messages.end {
1006+ Some(end) if !overlapped && !ended => {
1007+ self.head_tokens.insert(room_id.clone(), end);
1008+ self.heads.push_front(room_id);
1009+ }
1010+ _ => {
1011+ self.head_tokens.remove(&room_id);
1012+ }
1013+ }
1014+ return;
1015+ }
1016+ let progress = self.rooms.entry(room_id.to_string()).or_default();
1017+ progress.started = true;
1018+ progress.tail = messages.end;
1019+ if ended {
1020+ progress.start_reached = true;
1021+ progress.tail = None;
1022+ }
1023+ self.touch();
1024+ }
1025+
1026+ fn retry_pending(&mut self, room_id: Option<&OwnedRoomId>, session_id: Option<&str>) {
1027+ let due: Vec<_> = self
1028+ .pending
1029+ .iter()
1030+ .filter(|(_, pending)| {
1031+ room_id.is_none_or(|room_id| pending.room == room_id.as_str())
1032+ && session_id.is_none_or(|session_id| pending.session_id == session_id)
1033+ })
1034+ .map(|(key, pending)| (key.clone(), pending.clone()))
1035+ .collect();
1036+ for (key, pending) in due {
1037+ let Ok(room_id) = OwnedRoomId::try_from(pending.room.as_str()) else {
1038+ continue;
1039+ };
1040+ let Some(room) = self.client.get_room(&room_id) else {
1041+ continue;
1042+ };
1043+ self.work.push(Box::pin(async move {
1044+ let value = async {
1045+ let raw: Raw<OriginalSyncRoomEncryptedEvent> =
1046+ Raw::from_json_string(pending.raw).ok()?;
1047+ let event = room.decrypt_event(&raw, None).await.ok()?;
1048+ value_of(&event).filter(|value| value["type"] != "m.room.encrypted")
1049+ }
1050+ .await;
1051+ Work::Decrypted {
1052+ key,
1053+ room: room_id,
1054+ value,
1055+ }
1056+ }));
1057+ }
1058+ }
1059+
1060+ fn complete(&mut self, work: Work) {
1061+ match work {
1062+ Work::Backfill { room, head, result } => self.backfilled(room, head, result),
1063+ Work::Decrypted { key, room, value } => {
1064+ if let Some(value) = value {
1065+ self.pending.remove(&key);
1066+ self.pending_dirty = true;
1067+ self.observe(&room, &value);
1068+ self.touch();
1069+ }
1070+ }
1071+ Work::Refetched { room, value } => {
1072+ if let Some(value) = value {
1073+ self.observe(&room, &value);
1074+ }
1075+ }
1076+ }
1077+ }
1078+
1079+ fn search(
1080+ &mut self,
1081+ query: &str,
1082+ rooms: Option<&[String]>,
1083+ before: Option<(u64, u64)>,
1084+ limit: usize,
1085+ coverage: &[OwnedRoomId],
1086+ ) -> Result<RawPage, Error> {
1087+ let (mut hits, terms) = self.index.search(query, rooms, before, limit + 1)?;
1088+ let more = hits.len() > limit;
1089+ hits.truncate(limit);
1090+ Ok(RawPage {
1091+ hits,
1092+ terms,
1093+ more,
1094+ coverage: self.coverage(coverage),
1095+ })
1096+ }
1097+
1098+ pub(crate) async fn run(mut self) {
1099+ self.load().await;
1100+ let client = self.client.clone();
1101+ let mut generic = client.event_cache().subscribe_to_room_generic_updates();
1102+ let mut keys = client
1103+ .encryption()
1104+ .room_keys_received_stream()
1105+ .await
1106+ .map(Box::pin);
1107+ let mut room_info = client.room_info_notable_update_receiver();
1108+ self.retry_pending(None, None);
1109+ let mut tick = Box::pin(matrix_sdk::sleep::sleep(Duration::from_secs(1)));
1110+ loop {
1111+ self.schedule_backfill();
1112+ tokio::select! {
1113+ biased;
1114+ request = self.requests.recv() => match request {
1115+ Some(Request::Stop(reply)) => {
1116+ if self.dirty_since.is_some() || self.index.buffered() > 0 {
1117+ self.commit().await;
1118+ }
1119+ for (abort, _) in self.subscriptions.values() {
1120+ abort.abort();
1121+ }
1122+ let _ = reply.send(());
1123+ return;
1124+ }
1125+ Some(Request::Search { query, rooms, before, limit, coverage, reply }) => {
1126+ let _ = reply.send(self.search(&query, rooms.as_deref(), before, limit, &coverage));
1127+ }
1128+ Some(Request::Coverage(rooms, reply)) => {
1129+ let _ = reply.send(self.coverage(&rooms));
1130+ }
1131+ Some(Request::Forget(key)) => {
1132+ let observed = Observed::Redacted { key };
1133+ if feed::apply(self.index.as_mut(), &mut self.redactions, "", observed) == Effect::Removed {
1134+ self.touch();
1135+ }
1136+ }
1137+ None => return,
1138+ },
1139+ Some(work) = self.work.next(), if !self.work.is_empty() => self.complete(work),
1140+ Some((room_id, events)) = self.updates.next(), if !self.updates.is_empty() => {
1141+ if let Some((_, seen)) = self.subscriptions.get_mut(&room_id) {
1142+ *seen = now();
1143+ }
1144+ match events {
1145+ Some(events) => self.observe_all(&room_id, &events),
1146+ None => {
1147+ if let Some((abort, _)) = self.subscriptions.remove(&room_id) {
1148+ abort.abort();
1149+ }
1150+ self.subscribe(room_id).await;
1151+ }
1152+ }
1153+ }
1154+ update = generic.recv() => match update {
1155+ Ok(update) => self.subscribe(update.room_id).await,
1156+ Err(broadcast::error::RecvError::Lagged(_)) => {}
1157+ Err(broadcast::error::RecvError::Closed) => return,
1158+ },
1159+ Some(update) = async { keys.as_mut()?.next().await }, if keys.is_some() => match update {
1160+ Ok(infos) => {
1161+ for info in infos {
1162+ self.retry_pending(Some(&info.room_id), Some(&info.session_id));
1163+ }
1164+ }
1165+ Err(_) => self.retry_pending(None, None),
1166+ },
1167+ update = room_info.recv() => match update {
1168+ Ok(update) if update.reasons.contains(RoomInfoNotableUpdateReasons::MEMBERSHIP) => {
1169+ let left = self.client.get_room(&update.room_id).is_none_or(|room| room.state() != RoomState::Joined);
1170+ if left && let Some((abort, _)) = self.subscriptions.remove(&update.room_id) {
1171+ abort.abort();
1172+ }
1173+ }
1174+ Ok(_) | Err(broadcast::error::RecvError::Lagged(_)) => {}
1175+ Err(broadcast::error::RecvError::Closed) => return,
1176+ },
1177+ () = &mut tick => {
1178+ tick = Box::pin(matrix_sdk::sleep::sleep(Duration::from_secs(1)));
1179+ self.drop_idle();
1180+ let due = self.dirty_since.is_some_and(|since| now() >= since + COMMIT_AFTER_MS)
1181+ || self.index.buffered() >= COMMIT_BUFFERED;
1182+ if due && now() >= self.commit_after {
1183+ self.commit().await;
1184+ }
1185+ }
1186+ }
1187+ }
1188+ }
1189+}
1190+
1191+fn updates(
1192+ room_id: OwnedRoomId,
1193+ subscriber: RoomEventCacheSubscriber,
1194+ drop_handles: Arc<matrix_sdk::event_cache::EventCacheDropHandles>,
1195+) -> BoxStream<(OwnedRoomId, Option<Vec<SdkTimelineEvent>>)> {
1196+ Box::pin(futures_util::stream::unfold(
1197+ (subscriber, drop_handles),
1198+ move |(mut subscriber, drop_handles)| {
1199+ let room_id = room_id.clone();
1200+ async move {
1201+ loop {
1202+ match subscriber.recv().await {
1203+ Ok(RoomEventCacheUpdate::UpdateTimelineEvents(update)) => {
1204+ let events = update.diffs.into_iter().flat_map(added).collect();
1205+ return Some(((room_id, Some(events)), (subscriber, drop_handles)));
1206+ }
1207+ Ok(_) => {}
1208+ Err(broadcast::error::RecvError::Lagged(_)) => {
1209+ return Some(((room_id, None), (subscriber, drop_handles)));
1210+ }
1211+ Err(broadcast::error::RecvError::Closed) => return None,
1212+ }
1213+ }
1214+ }
1215+ },
1216+ ))
1217+}
1218+
1219+fn added(diff: VectorDiff<SdkTimelineEvent>) -> Vec<SdkTimelineEvent> {
1220+ match diff {
1221+ VectorDiff::Append { values } | VectorDiff::Reset { values } => {
1222+ values.into_iter().collect()
1223+ }
1224+ VectorDiff::PushFront { value }
1225+ | VectorDiff::PushBack { value }
1226+ | VectorDiff::Insert { value, .. }
1227+ | VectorDiff::Set { value, .. } => vec![value],
1228+ VectorDiff::Clear
1229+ | VectorDiff::PopFront
1230+ | VectorDiff::PopBack
1231+ | VectorDiff::Remove { .. }
1232+ | VectorDiff::Truncate { .. } => Vec::new(),
1233+ }
1234+}
Added file: src/search/store/indexeddb.rs+167 additions
@@ -0,0 +1,167 @@
1+use std::{collections::BTreeMap, ops::Deref};
2+
3+use indexed_db_futures::{
4+ Build,
5+ database::Database,
6+ error::{Error as IndexedDbError, OpenDbError},
7+ prelude::*,
8+ transaction::{Transaction, TransactionDurability, TransactionMode, TransactionOptions},
9+ typed_array::{Uint8Array, Uint8ArraySlice},
10+};
11+
12+use super::storage_error;
13+use crate::Error;
14+
15+const BLOBS: &str = "blobs";
16+
17+struct DatabaseHandle(Database);
18+
19+impl Deref for DatabaseHandle {
20+ type Target = Database;
21+
22+ fn deref(&self) -> &Self::Target {
23+ &self.0
24+ }
25+}
26+
27+impl Drop for DatabaseHandle {
28+ fn drop(&mut self) {
29+ self.0.clone().close();
30+ }
31+}
32+
33+pub(super) struct Backend {
34+ name: String,
35+}
36+
37+impl Backend {
38+ pub(super) async fn open(name: String) -> Result<Self, Error> {
39+ open_database(&name)
40+ .await
41+ .map_err(|error| storage_error("opening the search database", error))?;
42+ Ok(Self { name })
43+ }
44+
45+ async fn database(&self) -> Result<DatabaseHandle, Error> {
46+ open_database(&self.name)
47+ .await
48+ .map_err(|error| storage_error("opening the search database", error))
49+ }
50+
51+ pub(super) async fn load(&self, keys: &[&str]) -> Result<BTreeMap<String, Vec<u8>>, Error> {
52+ let database = self.database().await?;
53+ let read = async {
54+ let transaction = database
55+ .transaction(BLOBS)
56+ .with_mode(TransactionMode::Readonly)
57+ .build()?;
58+ let store = transaction.object_store(BLOBS)?;
59+ let mut found = BTreeMap::new();
60+ for key in keys {
61+ if let Some(value) = store.get::<Uint8Array, _, _>(*key).await? {
62+ found.insert((*key).to_owned(), Vec::<u8>::from(value));
63+ }
64+ }
65+ Ok::<_, IndexedDbError>(found)
66+ };
67+ read.await
68+ .map_err(|error| storage_error("reading the search database", error))
69+ }
70+
71+ pub(super) async fn load_all(&self) -> Result<BTreeMap<String, Vec<u8>>, Error> {
72+ let database = self.database().await?;
73+ let read = async {
74+ let transaction = database
75+ .transaction(BLOBS)
76+ .with_mode(TransactionMode::Readonly)
77+ .build()?;
78+ let store = transaction.object_store(BLOBS)?;
79+ let keys = store.get_all_keys::<String>().build()?;
80+ let values = store.get_all::<Uint8Array>().build()?;
81+ let keys = keys.await?.collect::<Result<Vec<_>, _>>()?;
82+ let values = values.await?.collect::<Result<Vec<_>, _>>()?;
83+ Ok::<_, IndexedDbError>((keys, values))
84+ };
85+ let (keys, values) = read
86+ .await
87+ .map_err(|error| storage_error("reading the search database", error))?;
88+ if keys.len() != values.len() {
89+ return Err(Error::new(
90+ crate::ErrorKind::Store,
91+ "search database keys and values disagree",
92+ ));
93+ }
94+ Ok(keys
95+ .into_iter()
96+ .zip(values)
97+ .map(|(key, value)| (key, Vec::<u8>::from(value)))
98+ .collect())
99+ }
100+
101+ pub(super) async fn commit(
102+ &self,
103+ puts: Vec<(String, Vec<u8>)>,
104+ deletes: Vec<String>,
105+ ) -> Result<(), Error> {
106+ let database = self.database().await?;
107+ let write = async {
108+ let mut options = TransactionOptions::new();
109+ options.set_durability(TransactionDurability::Relaxed);
110+ let transaction = database
111+ .transaction(BLOBS)
112+ .with_mode(TransactionMode::Readwrite)
113+ .with_options(options)
114+ .build()?;
115+ let requests = (|| -> Result<(), IndexedDbError> {
116+ let store = transaction.object_store(BLOBS)?;
117+ for key in deletes {
118+ store.delete(key).build()?;
119+ }
120+ for (key, value) in &puts {
121+ store
122+ .put(Uint8ArraySlice::new(value))
123+ .with_key(key.as_str())
124+ .build()?;
125+ }
126+ Ok(())
127+ })();
128+ if let Err(error) = requests {
129+ let _ = transaction.abort().await;
130+ return Err(error);
131+ }
132+ transaction.commit().await
133+ };
134+ write
135+ .await
136+ .map_err(|error| storage_error("writing the search database", error))
137+ }
138+
139+ pub(super) async fn clear(&self) -> Result<(), Error> {
140+ let database = self.database().await?;
141+ let clear = async {
142+ let transaction = database
143+ .transaction(BLOBS)
144+ .with_mode(TransactionMode::Readwrite)
145+ .build()?;
146+ transaction.object_store(BLOBS)?.clear()?.await?;
147+ transaction.commit().await
148+ };
149+ clear
150+ .await
151+ .map_err(|error| storage_error("clearing the search database", error))
152+ }
153+}
154+
155+async fn open_database(name: &str) -> Result<DatabaseHandle, OpenDbError> {
156+ Database::open(name)
157+ .with_version(1u32)
158+ .with_on_upgrade_needed(
159+ |_, transaction: &Transaction<'_>| -> Result<(), IndexedDbError> {
160+ transaction.db().create_object_store(BLOBS).build()?;
161+ Ok(())
162+ },
163+ )
164+ .build()?
165+ .await
166+ .map(DatabaseHandle)
167+}
Added file: src/search/store/mod.rs+145 additions
@@ -0,0 +1,145 @@
1+use std::collections::BTreeMap;
2+
3+use matrix_sdk::{SendOutsideWasm, SyncOutsideWasm};
4+use matrix_sdk_store_encryption::{EncryptedValueBase64, StoreCipher};
5+use serde::{Deserialize, Serialize};
6+
7+use crate::{Error, ErrorKind, StoreConfig, mentions::store::Account};
8+
9+#[cfg(feature = "indexeddb")]
10+mod indexeddb;
11+#[cfg(feature = "indexeddb")]
12+use indexeddb::Backend;
13+#[cfg(feature = "sqlite")]
14+mod sqlite;
15+#[cfg(feature = "sqlite")]
16+use sqlite::Backend;
17+
18+const CIPHER: &str = "!cipher";
19+const ACCOUNT: &str = "!account";
20+
21+#[derive(Serialize, Deserialize)]
22+struct Owner {
23+ homeserver: String,
24+ user_id: String,
25+}
26+
27+/// blobs sealed with a `StoreCipher` under the store's passphrase. every blob carries its
28+/// own key inside the seal, so one can't be swapped for another
29+pub(crate) struct SearchStore {
30+ backend: Backend,
31+ cipher: StoreCipher,
32+}
33+
34+impl std::fmt::Debug for SearchStore {
35+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36+ formatter
37+ .debug_struct("SearchStore")
38+ .finish_non_exhaustive()
39+ }
40+}
41+
42+impl SearchStore {
43+ /// an unreadable or foreign store is emptied rather than refused
44+ pub(crate) async fn open(config: &StoreConfig, account: Account) -> Result<Self, Error> {
45+ let backend = Backend::open(config.search_location()).await?;
46+ let owner = Owner {
47+ homeserver: account.homeserver,
48+ user_id: account.user_id.to_string(),
49+ };
50+ let mut blobs = backend.load(&[CIPHER, ACCOUNT]).await?;
51+ let opened = blobs.remove(CIPHER).and_then(|exported| {
52+ let cipher = StoreCipher::import(config.passphrase(), &exported).ok()?;
53+ let stored: Owner =
54+ serde_json::from_slice(&unseal(&cipher, ACCOUNT, &blobs.remove(ACCOUNT)?)?).ok()?;
55+ (stored.homeserver == owner.homeserver && stored.user_id == owner.user_id)
56+ .then_some(cipher)
57+ });
58+ if let Some(cipher) = opened {
59+ return Ok(Self { backend, cipher });
60+ }
61+ let cipher = StoreCipher::new()
62+ .map_err(|error| storage_error("creating the search cipher", error))?;
63+ let exported = cipher
64+ .export(config.passphrase())
65+ .map_err(|error| storage_error("sealing the search cipher", error))?;
66+ let account = serde_json::to_vec(&owner)
67+ .map_err(|error| storage_error("encoding the search owner", error))?;
68+ let store = Self { backend, cipher };
69+ store.backend.clear().await?;
70+ let sealed = store.seal(ACCOUNT, account)?;
71+ store
72+ .backend
73+ .commit(
74+ vec![(CIPHER.to_owned(), exported), (ACCOUNT.to_owned(), sealed)],
75+ Vec::new(),
76+ )
77+ .await?;
78+ Ok(store)
79+ }
80+
81+ /// every blob but the store's own, still sealed
82+ pub(crate) async fn load(&self) -> Result<BTreeMap<String, Vec<u8>>, Error> {
83+ let mut blobs = self.backend.load_all().await?;
84+ blobs.remove(CIPHER);
85+ blobs.remove(ACCOUNT);
86+ Ok(blobs)
87+ }
88+
89+ pub(crate) fn open_blob(&self, key: &str, sealed: &[u8]) -> Option<Vec<u8>> {
90+ unseal(&self.cipher, key, sealed)
91+ }
92+
93+ pub(crate) async fn commit(
94+ &self,
95+ puts: Vec<(String, Vec<u8>)>,
96+ deletes: Vec<String>,
97+ ) -> Result<(), Error> {
98+ let mut sealed = Vec::with_capacity(puts.len());
99+ for (key, value) in puts {
100+ let value = self.seal(&key, value)?;
101+ sealed.push((key, value));
102+ }
103+ self.backend.commit(sealed, deletes).await
104+ }
105+
106+ /// keeps the cipher and owner, drops everything else
107+ pub(crate) async fn reset(&self) -> Result<(), Error> {
108+ let keep = self.backend.load(&[CIPHER, ACCOUNT]).await?;
109+ self.backend.clear().await?;
110+ self.backend
111+ .commit(keep.into_iter().collect(), Vec::new())
112+ .await
113+ }
114+
115+ fn seal(&self, key: &str, value: Vec<u8>) -> Result<Vec<u8>, Error> {
116+ let mut plain = Vec::with_capacity(key.len() + 1 + value.len());
117+ plain.extend_from_slice(key.as_bytes());
118+ plain.push(0);
119+ plain.extend_from_slice(&value);
120+ drop(value);
121+ let encrypted = self
122+ .cipher
123+ .encrypt_value_base64_data(plain)
124+ .map_err(|error| storage_error("sealing a search blob", error))?;
125+ serde_json::to_vec(&encrypted)
126+ .map_err(|error| storage_error("encoding a search blob", error))
127+ }
128+}
129+
130+fn unseal(cipher: &StoreCipher, key: &str, sealed: &[u8]) -> Option<Vec<u8>> {
131+ let encrypted: EncryptedValueBase64 = serde_json::from_slice(sealed).ok()?;
132+ let mut plain = cipher.decrypt_value_base64_data(encrypted).ok()?;
133+ let prefix = key.len() + 1;
134+ if plain.len() < prefix || &plain[..key.len()] != key.as_bytes() || plain[key.len()] != 0 {
135+ return None;
136+ }
137+ Some(plain.split_off(prefix))
138+}
139+
140+pub(super) fn storage_error(
141+ context: &'static str,
142+ source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,
143+) -> Error {
144+ Error::with_source(ErrorKind::Store, context, source)
145+}
Added file: src/search/store/sqlite.rs+149 additions
@@ -0,0 +1,149 @@
1+use std::{
2+ collections::BTreeMap,
3+ fs,
4+ path::{Path, PathBuf},
5+ time::Duration,
6+};
7+
8+use rusqlite::{Connection, TransactionBehavior, params};
9+
10+use super::storage_error;
11+use crate::Error;
12+
13+pub(super) struct Backend {
14+ path: PathBuf,
15+}
16+
17+impl Backend {
18+ pub(super) async fn open(path: PathBuf) -> Result<Self, Error> {
19+ let setup = path.clone();
20+ blocking("opening the search store", move || initialise(&setup)).await?;
21+ Ok(Self { path })
22+ }
23+
24+ pub(super) async fn load(&self, keys: &[&str]) -> Result<BTreeMap<String, Vec<u8>>, Error> {
25+ let path = self.path.clone();
26+ let keys: Vec<String> = keys.iter().map(|key| (*key).to_owned()).collect();
27+ blocking("reading the search store", move || {
28+ let connection = connect(&path)?;
29+ let mut statement = connection
30+ .prepare("SELECT value FROM blobs WHERE key = ?1")
31+ .map_err(|error| storage_error("reading the search store", error))?;
32+ let mut found = BTreeMap::new();
33+ for key in keys {
34+ let mut rows = statement
35+ .query([&key])
36+ .map_err(|error| storage_error("reading the search store", error))?;
37+ if let Some(row) = rows
38+ .next()
39+ .map_err(|error| storage_error("reading the search store", error))?
40+ {
41+ let value: Vec<u8> = row
42+ .get(0)
43+ .map_err(|error| storage_error("reading the search store", error))?;
44+ found.insert(key, value);
45+ }
46+ }
47+ Ok(found)
48+ })
49+ .await
50+ }
51+
52+ pub(super) async fn load_all(&self) -> Result<BTreeMap<String, Vec<u8>>, Error> {
53+ let path = self.path.clone();
54+ blocking("reading the search store", move || {
55+ let connection = connect(&path)?;
56+ let mut statement = connection
57+ .prepare("SELECT key, value FROM blobs")
58+ .map_err(|error| storage_error("reading the search store", error))?;
59+ let rows = statement
60+ .query_map([], |row| {
61+ Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
62+ })
63+ .map_err(|error| storage_error("reading the search store", error))?;
64+ rows.collect::<Result<_, _>>()
65+ .map_err(|error| storage_error("reading the search store", error))
66+ })
67+ .await
68+ }
69+
70+ pub(super) async fn commit(
71+ &self,
72+ puts: Vec<(String, Vec<u8>)>,
73+ deletes: Vec<String>,
74+ ) -> Result<(), Error> {
75+ let path = self.path.clone();
76+ blocking("writing the search store", move || {
77+ let mut connection = connect(&path)?;
78+ let transaction = connection
79+ .transaction_with_behavior(TransactionBehavior::Immediate)
80+ .map_err(|error| storage_error("writing the search store", error))?;
81+ for key in deletes {
82+ transaction
83+ .execute("DELETE FROM blobs WHERE key = ?1", [key])
84+ .map_err(|error| storage_error("writing the search store", error))?;
85+ }
86+ for (key, value) in puts {
87+ transaction
88+ .execute(
89+ "INSERT INTO blobs (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
90+ params![key, value],
91+ )
92+ .map_err(|error| storage_error("writing the search store", error))?;
93+ }
94+ transaction
95+ .commit()
96+ .map_err(|error| storage_error("writing the search store", error))
97+ })
98+ .await
99+ }
100+
101+ pub(super) async fn clear(&self) -> Result<(), Error> {
102+ let path = self.path.clone();
103+ blocking("clearing the search store", move || {
104+ connect(&path)?
105+ .execute("DELETE FROM blobs", [])
106+ .map(drop)
107+ .map_err(|error| storage_error("clearing the search store", error))
108+ })
109+ .await
110+ }
111+}
112+
113+async fn blocking<T: Send + 'static>(
114+ context: &'static str,
115+ work: impl FnOnce() -> Result<T, Error> + Send + 'static,
116+) -> Result<T, Error> {
117+ tokio::task::spawn_blocking(work)
118+ .await
119+ .map_err(|error| storage_error(context, error))?
120+}
121+
122+fn initialise(path: &Path) -> Result<(), Error> {
123+ if let Some(parent) = path
124+ .parent()
125+ .filter(|parent| !parent.as_os_str().is_empty())
126+ {
127+ fs::create_dir_all(parent)
128+ .map_err(|error| storage_error("creating the search store directory", error))?;
129+ }
130+ connect(path)?
131+ .execute(
132+ "CREATE TABLE IF NOT EXISTS blobs (key TEXT PRIMARY KEY, value BLOB NOT NULL)",
133+ [],
134+ )
135+ .map(drop)
136+ .map_err(|error| storage_error("initialising the search store", error))
137+}
138+
139+fn connect(path: &Path) -> Result<Connection, Error> {
140+ let connection =
141+ Connection::open(path).map_err(|error| storage_error("opening the search store", error))?;
142+ connection
143+ .busy_timeout(Duration::from_secs(5))
144+ .map_err(|error| storage_error("configuring the search store", error))?;
145+ connection
146+ .execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL")
147+ .map_err(|error| storage_error("configuring the search store", error))?;
148+ Ok(connection)
149+}
Added file: src/search_model.rs+113 additions
@@ -0,0 +1,113 @@
1+use ruma::{OwnedEventId, OwnedRoomId};
2+#[cfg(feature = "serde")]
3+use serde::{Deserialize, Serialize};
4+
5+use crate::TimelineEvent;
6+
7+#[derive(Clone, Debug, Eq, PartialEq)]
8+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
9+#[non_exhaustive]
10+pub enum SearchScope {
11+ Room(OwnedRoomId),
12+ /// a space's rooms, resolved by the host
13+ Rooms(Vec<OwnedRoomId>),
14+ /// every joined room
15+ All,
16+}
17+
18+#[derive(Clone, Debug, Eq, PartialEq)]
19+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
20+#[non_exhaustive]
21+pub enum SearchCommand {
22+ /// opens a search that owns the media of every hit it hands out. at most 8 stay open,
23+ /// opening another closes the oldest. words must all match, `"quoted phrases"` match
24+ /// in order and `-word` excludes, newest first
25+ Search {
26+ query: String,
27+ scope: SearchScope,
28+ limit: u16,
29+ },
30+ /// `cursor` is the previous page's `next_cursor`
31+ More {
32+ search_id: String,
33+ cursor: String,
34+ limit: u16,
35+ },
36+ /// frees the search's media. its hits' media ids stop resolving
37+ Close { search_id: String },
38+ Coverage(SearchScope),
39+}
40+
41+/// a search's media stay resolvable until it closes, and past `EngineConfig::max_context_media`
42+/// its least recently used are dropped
43+#[derive(Clone, Debug, Eq, PartialEq)]
44+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
45+pub struct SearchSession {
46+ pub search_id: String,
47+ pub page: SearchPage,
48+}
49+
50+#[derive(Clone, Debug, Eq, PartialEq)]
51+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
52+pub struct SearchPage {
53+ pub hits: Vec<SearchHit>,
54+ /// `None` once there's nothing older that matches
55+ pub next_cursor: Option<String>,
56+ pub coverage: SearchCoverage,
57+}
58+
59+#[derive(Clone, Debug, Eq, PartialEq)]
60+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
61+pub struct SearchHit {
62+ pub room_id: OwnedRoomId,
63+ pub event_id: OwnedEventId,
64+ pub timestamp_ms: u64,
65+ /// as event context shows it, with the latest edit applied
66+ pub event: TimelineEvent,
67+ /// utf-8 byte ranges of matched words in the content's text, the message body, a
68+ /// sticker's body or a poll's question
69+ pub matches: Vec<SearchMatch>,
70+}
71+
72+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
74+pub struct SearchMatch {
75+ pub start: u32,
76+ pub end: u32,
77+}
78+
79+#[derive(Clone, Debug, Eq, PartialEq)]
80+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
81+pub struct SearchCoverage {
82+ pub rooms: Vec<RoomSearchCoverage>,
83+ /// false when the index lives in memory and starts over with the next engine
84+ pub persistent: bool,
85+ /// messages that couldn't be decrypted yet, so can't be found yet
86+ pub undecrypted: usize,
87+}
88+
89+#[derive(Clone, Debug, Eq, PartialEq)]
90+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
91+pub struct RoomSearchCoverage {
92+ pub room_id: OwnedRoomId,
93+ pub oldest_indexed_ms: Option<u64>,
94+ pub newest_indexed_ms: Option<u64>,
95+ /// backfill walked back to the oldest event the server would hand over
96+ pub start_reached: bool,
97+ pub backfill: SearchBackfill,
98+}
99+
100+#[derive(Clone, Debug, Eq, PartialEq)]
101+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
102+pub enum SearchBackfill {
103+ Waiting,
104+ Running,
105+ /// this engine's page budget for the room is spent, the next one carries on
106+ Paused,
107+ Complete,
108+ Disabled,
109+ Failed {
110+ message: String,
111+ retry_at_ms: u64,
112+ },
113+}
Modified file: src/session_store.rs+3 additions−1 deletions
@@ -473,12 +473,13 @@ pub(crate) fn delete_sqlite_store(path: &Path) -> std::io::Result<()> {
473473 use std::{fs, io::ErrorKind};
474474
475475 // matrix-sdk-sqlite 0.18's database names, which it keeps private
476- const DATABASES: [&str; 5] = [
476+ const DATABASES: [&str; 6] = [
477477 "matrix-sdk-state.sqlite3",
478478 "matrix-sdk-crypto.sqlite3",
479479 "matrix-sdk-event-cache.sqlite3",
480480 "matrix-sdk-media.sqlite3",
481481 "macltok-mentions.sqlite3",
482+ "macltok-search.sqlite3",
482483 ];
483484 for database in DATABASES {
484485 for suffix in ["", "-wal", "-shm", "-journal"] {
@@ -509,6 +510,7 @@ pub(crate) async fn delete_indexeddb_store(name: &str) -> Result<(), DeleteError
509510 "::event_cache",
510511 "::media",
511512 "::macltok-mentions",
513+ "::macltok-search",
512514 ] {
513515 delete_database(&format!("{name}{suffix}")).await?;
514516 }
Modified file: src/transport.rs+59 additions−1 deletions
@@ -36,7 +36,8 @@ use crate::{
3636 NotificationCommand, OwnDevice, PackCommand, PackImageInfo, PackUsage, Patch,
3737 PowerLevelChanges, Profile, PushNotification, PushNotificationEvent, PushNotificationStatus,
3838 RoomCommand, RoomListFilter, RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels,
39- RoomSummary, SavedMentionContext, SpaceChild, ThreadListCommand, TimelineCommand, TimelineId,
39+ RoomSummary, SavedMentionContext, SearchCommand, SearchCoverage, SearchPage, SearchScope,
40+ SearchSession, SpaceChild, ThreadListCommand, TimelineCommand, TimelineId,
4041 VerificationAction, VerificationTarget,
4142 encryption::encryption_state,
4243 engine::{MediaService, StoreLock},
@@ -517,6 +518,63 @@ impl ClientHandle {
517518 self.run(Command::Mentions(MentionsCommand::Recover)).await
518519 }
519520
521+ /// newest first, at most 50 a page. close the search when its results go, or its media
522+ /// stay registered
523+ pub async fn search_messages(
524+ &self,
525+ query: &str,
526+ scope: SearchScope,
527+ limit: u16,
528+ ) -> Result<SearchSession, Error> {
529+ match self
530+ .execute(Command::Search(SearchCommand::Search {
531+ query: query.to_owned(),
532+ scope,
533+ limit,
534+ }))
535+ .await?
536+ {
537+ CommandOutput::SearchSession(session) => Ok(session),
538+ output => Err(unexpected("search session", &output)),
539+ }
540+ }
541+
542+ pub async fn search_more(
543+ &self,
544+ search_id: &str,
545+ cursor: &str,
546+ limit: u16,
547+ ) -> Result<SearchPage, Error> {
548+ match self
549+ .execute(Command::Search(SearchCommand::More {
550+ search_id: search_id.to_owned(),
551+ cursor: cursor.to_owned(),
552+ limit,
553+ }))
554+ .await?
555+ {
556+ CommandOutput::SearchPage(page) => Ok(page),
557+ output => Err(unexpected("search page", &output)),
558+ }
559+ }
560+
561+ pub async fn close_search(&self, search_id: &str) -> Result<(), Error> {
562+ self.run(Command::Search(SearchCommand::Close {
563+ search_id: search_id.to_owned(),
564+ }))
565+ .await
566+ }
567+
568+ pub async fn search_coverage(&self, scope: SearchScope) -> Result<SearchCoverage, Error> {
569+ match self
570+ .execute(Command::Search(SearchCommand::Coverage(scope)))
571+ .await?
572+ {
573+ CommandOutput::SearchCoverage(coverage) => Ok(coverage),
574+ output => Err(unexpected("search coverage", &output)),
575+ }
576+ }
577+
520578 pub async fn send_message(
521579 &self,
522580 timeline_id: TimelineId,