Commit c9effeac326a
tarball22 files changed+4455-7
@@ -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"@@ -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 C41
+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 in55
+# wasm. both caps, since a release_max_level a host sets would otherwise win in release56
+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@@ -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"@@ -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
.await883898
.map_err(AccountError::from)@@ -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 edit416
+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 targets416437
struct Assembly {417438
items: Vec<(usize, TimelineItem)>,@@ -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 it7580
pub prepare_to_send: bool,81
+ /// `/messages` pages each joined room gets per engine run for search backfill. 0 for none82
+ pub search_backfill_pages_per_room: u16,83
+ pub search_backfill_page_size: u16,84
+ /// the least time between two backfill requests, across every room85
+ pub search_backfill_interval_ms: u64,86
+ /// undecryptable messages kept to index once their keys turn up, newest first87
+ pub max_search_undecrypted: usize,7688
}7789
7890
impl EngineConfig {@@ -135,6 +147,30 @@ impl EngineConfig {
135147
self.prepare_to_send = prepare;136148
self137149
}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
+ self155
+ }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
+ self161
+ }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
+ self167
+ }168
+169
+ #[must_use]170
+ pub const fn with_max_search_undecrypted(mut self, events: usize) -> Self {171
+ self.max_search_undecrypted = events;172
+ self173
+ }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).await383
+ Self::with_persistence(client, config, session_changes, None, None, None, None).await342384
}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
.await380423
// a missing user id is all it still refuses, and that was turned away above381424
.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.tasks533580
.spawn(TaskContext::Watcher("mentions service"), service.run());534581
}582
+ if let Some(service) = self.search_service.take() {583
+ self.tasks584
+ .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.tasks912
+ .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;@@ -54,6 +54,7 @@ pub enum ErrorKind {
5454
RoomNotOpen,5555
TimelineNotOpen,5656
EventContextNotOpen,57
+ SearchNotOpen,5758
EventNotFound,5859
RoomBusy,5960
QueueFull,@@ -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};@@ -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)]@@ -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};@@ -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()@@ -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 again49
+ Refetch(String),50
+ Nothing,51
+}52
+53
+/// redactions seen lately, so a stale copy of what they removed can't come back54
+#[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_REDACTIONS65
+ && 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: content108
+ .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 = value126
+ .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 value145
+ .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 = value155
+ .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 under172
+/// its key, the latest by its own sender wins, and one arriving first holds the key173
+/// provisionally until the original turns up174
+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
+ ..existing241
+ }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::Removed268
+ } 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::Removed279
+ } else {280
+ Effect::Nothing281
+ }282
+ }283
+ Observed::Encrypted { .. } | Observed::Other => Effect::Nothing,284
+ }285
+}286
+287
+#[cfg(test)]288
+#[path = "feed_tests.rs"]289
+mod tests;@@ -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 edits54
+#[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::Nothing99
+ );100
+ assert_eq!(101
+ feed(102
+ &mut index,103
+ &mut redactions,104
+ edit("$h", "@b:x", 40, "$o", "hijack")105
+ ),106
+ Effect::Nothing107
+ );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::Removed143
+ );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
+}@@ -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 them29
+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 up37
+ pub(crate) ts: u64,38
+ /// the event whose text is indexed, the original or its latest edit39
+ pub(crate) content: String,40
+ pub(crate) content_ts: u64,41
+ /// only kept for provisional docs, read back empty otherwise42
+ pub(crate) text: String,43
+ /// an edit seen before its original44
+ 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 at112
+ 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
+ ranges138
+}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 none168
+ 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 = index186
+ .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 = searcher192
+ .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
+ files218
+ .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
+ packed230
+}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 load284
+ 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
+ records340
+ .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.segments350
+ .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
+ document374
+ }375
+376
+ fn stored(&self, document: &TantivyDocument) -> Option<Doc> {377
+ let fields = self.fields;378
+ let text = |field| {379
+ document380
+ .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
+ found419
+ }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
+ writer443
+ .add_document(self.document(doc))444
+ .map_err(|error| tantivy_error("writing a search segment", error))?;445
+ }446
+ writer447
+ .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 deleted461
+ fn compact(&mut self) -> Result<(), Error> {462
+ self.segments.retain(|segment| {463
+ let empty = segment.live() == 0;464
+ if empty && self.persistent {465
+ self.removed466
+ .push((segment.id, segment.persisted, segment.del_gen));467
+ }468
+ !empty469
+ });470
+ loop {471
+ let mut classes: HashMap<u32, Vec<usize>> = HashMap::new();472
+ for (position, segment) in self.segments.iter().enumerate() {473
+ classes474
+ .entry(Self::class(segment.live()))475
+ .or_default()476
+ .push(position);477
+ }478
+ let full = classes479
+ .into_values()480
+ .find(|positions| positions.len() >= MERGE_FACTOR);481
+ let sparse = self482
+ .segments483
+ .iter()484
+ .position(|segment| {485
+ segment.max_doc >= 1_000 && segment.live() * 2 < segment.max_doc as usize486
+ })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 = segment501
+ .index502
+ .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.removed523
+ .push((segment.id, segment.persisted, segment.del_gen));524
+ }525
+ !merging526
+ });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> = segment533
+ .index534
+ .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 = segment546
+ .raw547
+ .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
+ removed597
+ }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.text615
+ && 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
+ rooms628
+ .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 = segment642
+ .searcher643
+ .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 = self654
+ .segments655
+ .iter()656
+ .find(|segment| segment.id == id)657
+ .expect("the segment was just searched");658
+ let document: TantivyDocument = segment659
+ .searcher660
+ .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
+ changes699
+ .puts700
+ .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
+ changes709
+ .puts710
+ .push((deletes_key(segment.id, generation), bytes));711
+ if segment.del_gen > 0 {712
+ changes713
+ .deletes714
+ .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.removed724
+ .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<_> = self738
+ .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 hash747
+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
+ false781
+ }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<_> = self815
+ .top816
+ .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
+ top821
+ }822
+}823
+824
+#[cfg(test)]825
+#[path = "index_tests.rs"]826
+mod tests;@@ -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
+ index79
+ .search("word", Some(&[]), None, 10)80
+ .unwrap()81
+ .082
+ .is_empty()83
+ );84
+}85
+86
+/// a lost delete brings a redacted message back, and a lost segment loses messages87
+#[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
+}@@ -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 many50
+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, an67
+/// embedding index can stand beside it later without the public api noticing68
+#[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` held73
+ 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 terms77
+ 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 one96
+ 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 read147
+ 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.requests256
+ .send(Request::Coverage(self.scope_rooms(&scope), reply))257
+ .await258
+ .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 one267
+ 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 => self280
+ .client281
+ .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.requests315
+ .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
+ .await324
+ .map_err(|_| Error::interrupted())?;325
+ let raw = response.await.map_err(|_| Error::interrupted())??;326
+ let next_cursor = raw327
+ .more328
+ .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.hits334
+ .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.client363
+ .account()364
+ .account_data::<IgnoredUserListEventContent>()365
+ .await366
+ .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.key396
+ && let Some((edit, _)) = load(&room, &hit.content).await397
+ {398
+ values.push(edit);399
+ }400
+ let Some((event, registrations)) =401
+ context::preview(&room, current_user_id.as_deref(), &values, highlight).await402
+ 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 = text412
+ .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 out442
+#[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
+ id478
+ }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 nothing487
+ 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 = open502
+ .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 = room519
+ .load_or_fetch_event(&event_id, None)520
+ .await521
+ .inspect_err(|error| tracing::debug!(%event_id, %error, "loading a search hit failed"))522
+ .ok()?;523
+ let highlight = event524
+ .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
+ blobs621
+ .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
+ } = &observed725
+ {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) = self738
+ .pending739
+ .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) = key753
+ && 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
+ keep833
+ });834
+ }835
+836
+ fn coverage(&self, rooms: &[OwnedRoomId]) -> SearchCoverage {837
+ let rooms = rooms838
+ .iter()839
+ .map(|room_id| {840
+ let progress = self841
+ .rooms842
+ .get(room_id.as_str())843
+ .cloned()844
+ .unwrap_or_default();845
+ let backfill = if progress.start_reached {846
+ SearchBackfill::Complete847
+ } else if self.budget.pages_per_room == 0 {848
+ SearchBackfill::Disabled849
+ } else if self.backfilling.as_ref() == Some(room_id) {850
+ SearchBackfill::Running851
+ } 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_room862
+ {863
+ SearchBackfill::Paused864
+ } else {865
+ SearchBackfill::Waiting866
+ };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 older884
+ fn schedule_backfill(&mut self) {885
+ if self.backfilling.is_some()886
+ || self.budget.pages_per_room == 0887
+ || now() < self.next_backfill888
+ {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) >= budget894
+ };895
+ let blocked = |failures: &HashMap<OwnedRoomId, Failure>, room: &OwnedRoomId| {896
+ failures897
+ .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 = self903
+ .rooms904
+ .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<_> = self913
+ .client914
+ .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 = self923
+ .rooms924
+ .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.rooms943
+ .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 = room953
+ .messages(options)954
+ .await955
+ .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 head996
+ && 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<_> = self1028
+ .pending1029
+ .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 = client1103
+ .encryption()1104
+ .room_keys_received_stream()1105
+ .await1106
+ .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::Clear1229
+ | VectorDiff::PopFront1230
+ | VectorDiff::PopBack1231
+ | VectorDiff::Remove { .. }1232
+ | VectorDiff::Truncate { .. } => Vec::new(),1233
+ }1234
+}@@ -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.024
+ }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
+ .await41
+ .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
+ .await48
+ .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 = database55
+ .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.await68
+ .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 = database75
+ .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) = read86
+ .await87
+ .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(keys95
+ .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 = database111
+ .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
+ store122
+ .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().await133
+ };134
+ write135
+ .await136
+ .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 = database143
+ .transaction(BLOBS)144
+ .with_mode(TransactionMode::Readwrite)145
+ .build()?;146
+ transaction.object_store(BLOBS)?.clear()?.await?;147
+ transaction.commit().await148
+ };149
+ clear150
+ .await151
+ .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
+ .await166
+ .map(DatabaseHandle)167
+}@@ -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 its28
+/// own key inside the seal, so one can't be swapped for another29
+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
+ formatter37
+ .debug_struct("SearchStore")38
+ .finish_non_exhaustive()39
+ }40
+}41
+42
+impl SearchStore {43
+ /// an unreadable or foreign store is emptied rather than refused44
+ 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 = cipher64
+ .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
+ store72
+ .backend73
+ .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 sealed82
+ 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).await104
+ }105
+106
+ /// keeps the cipher and owner, drops everything else107
+ pub(crate) async fn reset(&self) -> Result<(), Error> {108
+ let keep = self.backend.load(&[CIPHER, ACCOUNT]).await?;109
+ self.backend.clear().await?;110
+ self.backend111
+ .commit(keep.into_iter().collect(), Vec::new())112
+ .await113
+ }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 = self122
+ .cipher123
+ .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
+}@@ -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 = connection30
+ .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 = statement35
+ .query([&key])36
+ .map_err(|error| storage_error("reading the search store", error))?;37
+ if let Some(row) = rows38
+ .next()39
+ .map_err(|error| storage_error("reading the search store", error))?40
+ {41
+ let value: Vec<u8> = row42
+ .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
+ .await50
+ }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 = connection57
+ .prepare("SELECT key, value FROM blobs")58
+ .map_err(|error| storage_error("reading the search store", error))?;59
+ let rows = statement60
+ .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
+ .await68
+ }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 = connection79
+ .transaction_with_behavior(TransactionBehavior::Immediate)80
+ .map_err(|error| storage_error("writing the search store", error))?;81
+ for key in deletes {82
+ transaction83
+ .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
+ transaction88
+ .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
+ transaction95
+ .commit()96
+ .map_err(|error| storage_error("writing the search store", error))97
+ })98
+ .await99
+ }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
+ .await110
+ }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
+ .await119
+ .map_err(|error| storage_error(context, error))?120
+}121
+122
+fn initialise(path: &Path) -> Result<(), Error> {123
+ if let Some(parent) = path124
+ .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
+ connection143
+ .busy_timeout(Duration::from_secs(5))144
+ .map_err(|error| storage_error("configuring the search store", error))?;145
+ connection146
+ .execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL")147
+ .map_err(|error| storage_error("configuring the search store", error))?;148
+ Ok(connection)149
+}@@ -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 host13
+ Rooms(Vec<OwnedRoomId>),14
+ /// every joined room15
+ 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"` match24
+ /// in order and `-word` excludes, newest first25
+ 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 resolving37
+ 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 dropped43
+#[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 matches55
+ 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 applied66
+ pub event: TimelineEvent,67
+ /// utf-8 byte ranges of matched words in the content's text, the message body, a68
+ /// sticker's body or a poll's question69
+ 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 engine84
+ pub persistent: bool,85
+ /// messages that couldn't be decrypted yet, so can't be found yet86
+ 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 over96
+ 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 on106
+ Paused,107
+ Complete,108
+ Disabled,109
+ Failed {110
+ message: String,111
+ retry_at_ms: u64,112
+ },113
+}@@ -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 private476
- 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
}@@ -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)).await518519
}519520
521
+ /// newest first, at most 50 a page. close the search when its results go, or its media522
+ /// stay registered523
+ pub async fn search_messages(524
+ &self,525
+ query: &str,526
+ scope: SearchScope,527
+ limit: u16,528
+ ) -> Result<SearchSession, Error> {529
+ match self530
+ .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 self549
+ .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
+ .await566
+ }567
+568
+ pub async fn search_coverage(&self, scope: SearchScope) -> Result<SearchCoverage, Error> {569
+ match self570
+ .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,