Compare commits
7 Commits
5ee9fd88f1
...
f0e00fba40
| Author | SHA1 | Date | |
|---|---|---|---|
| f0e00fba40 | |||
| fac4b703ff | |||
| 4bafac397a | |||
| 7b91989411 | |||
| b8d198f150 | |||
| dc903989f7 | |||
| 851181d91d |
Generated
+311
-10
@@ -88,6 +88,17 @@ dependencies = [
|
|||||||
"utoipa",
|
"utoipa",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-trait"
|
||||||
|
version = "0.1.89"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atoi"
|
name = "atoi"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -109,6 +120,29 @@ version = "1.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aws-lc-rs"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
|
||||||
|
dependencies = [
|
||||||
|
"aws-lc-sys",
|
||||||
|
"untrusted 0.7.1",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aws-lc-sys"
|
||||||
|
version = "0.41.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cmake",
|
||||||
|
"dunce",
|
||||||
|
"fs_extra",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum"
|
name = "axum"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -216,6 +250,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -271,6 +307,15 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cmake"
|
||||||
|
version = "0.1.58"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -292,6 +337,15 @@ version = "0.9.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "convert_case"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-segmentation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -348,7 +402,7 @@ dependencies = [
|
|||||||
"domain",
|
"domain",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -414,6 +468,12 @@ version = "0.15.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dunce"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.16.0"
|
version = "1.16.0"
|
||||||
@@ -478,6 +538,12 @@ dependencies = [
|
|||||||
"spin",
|
"spin",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fnv"
|
||||||
|
version = "1.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -493,6 +559,12 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs_extra"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -537,6 +609,17 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -557,6 +640,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
|
"futures-macro",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -614,6 +698,25 @@ dependencies = [
|
|||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -734,6 +837,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -916,12 +1020,31 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iso8601"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46"
|
||||||
|
dependencies = [
|
||||||
|
"nom",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jobserver"
|
||||||
|
version = "0.1.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.99"
|
version = "0.3.99"
|
||||||
@@ -934,6 +1057,21 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonwebtoken"
|
||||||
|
version = "10.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1"
|
||||||
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
|
"base64",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"js-sys",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"signature",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -1035,6 +1173,49 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "meilisearch-index-setting-macro"
|
||||||
|
version = "0.33.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93b5b21df781c820a9cc387b808d4128cbc164dd28d67ac6ed666a00996f8f15"
|
||||||
|
dependencies = [
|
||||||
|
"convert_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"structmeta",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "meilisearch-sdk"
|
||||||
|
version = "0.33.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "19e6e3646ba2a9a306296c1edf4a050508a408c1b59ca456d9ad4965ec6e91e9"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"either",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"futures-util",
|
||||||
|
"iso8601",
|
||||||
|
"jsonwebtoken",
|
||||||
|
"log",
|
||||||
|
"meilisearch-index-setting-macro",
|
||||||
|
"pin-project-lite",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"uuid",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"yaup",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.1"
|
version = "2.8.1"
|
||||||
@@ -1058,6 +1239,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "8.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -1271,7 +1461,7 @@ dependencies = [
|
|||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2",
|
"socket2",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@@ -1292,7 +1482,7 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"slab",
|
"slab",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@@ -1436,6 +1626,8 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -1455,12 +1647,14 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots 1.0.7",
|
"webpki-roots 1.0.7",
|
||||||
]
|
]
|
||||||
@@ -1475,7 +1669,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"libc",
|
"libc",
|
||||||
"untrusted",
|
"untrusted 0.9.0",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1537,7 +1731,7 @@ checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"untrusted",
|
"untrusted 0.9.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1558,6 +1752,21 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "search"
|
||||||
|
version = "0.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"db",
|
||||||
|
"domain",
|
||||||
|
"meilisearch-sdk",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sqlx",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.28"
|
version = "1.0.28"
|
||||||
@@ -1790,7 +1999,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
@@ -1875,7 +2084,7 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -1914,7 +2123,7 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -1940,7 +2149,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
@@ -1970,6 +2179,29 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "structmeta"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"structmeta-derive",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "structmeta-derive"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -2016,13 +2248,33 @@ dependencies = [
|
|||||||
"parking_lot",
|
"parking_lot",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2150,6 +2402,19 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-util"
|
||||||
|
version = "0.7.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -2297,12 +2562,24 @@ version = "0.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-segmentation"
|
||||||
|
version = "1.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2503,6 +2780,19 @@ dependencies = [
|
|||||||
"wasmparser",
|
"wasmparser",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-streams"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasmparser"
|
name = "wasmparser"
|
||||||
version = "0.244.0"
|
version = "0.244.0"
|
||||||
@@ -2900,6 +3190,17 @@ version = "0.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yaup"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b0144f1a16a199846cb21024da74edd930b43443463292f536b7110b4855b5c6"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"serde",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["crates/domain", "crates/db", "crates/api", "crates/server"]
|
members = ["crates/domain", "crates/db", "crates/api", "crates/server", "crates/search"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
@@ -23,3 +23,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
meilisearch-sdk = "0.33"
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "search"
|
||||||
|
version = "0.0.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
meilisearch-sdk.workspace = true
|
||||||
|
serde = { workspace = true }
|
||||||
|
thiserror.workspace = true
|
||||||
|
domain = { path = "../domain" }
|
||||||
|
db = { path = "../db" }
|
||||||
|
sqlx.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sqlx.workspace = true
|
||||||
|
domain = { path = "../domain" }
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
//! Full-text search over catalogue objects, backed by Meilisearch.
|
||||||
|
//!
|
||||||
|
//! This crate provides the search *capability* plus a `reindex_all` rebuild path.
|
||||||
|
//! On-write index sync (calling `index_object`/`remove_object` after a catalogue
|
||||||
|
//! mutation commits) is wired at the API/service layer (Plan 7+). Meilisearch is not
|
||||||
|
//! transactional with Postgres, so the index is eventually consistent; `reindex_all`
|
||||||
|
//! is the recovery path.
|
||||||
|
|
||||||
|
use db::Db;
|
||||||
|
use domain::{CatalogueObject, ObjectId};
|
||||||
|
use meilisearch_sdk::tasks::Task;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Errors from the search subsystem.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SearchError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Meili(#[from] meilisearch_sdk::errors::Error),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
|
||||||
|
#[error("invalid object id in index: {0}")]
|
||||||
|
BadId(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The indexed shape of a catalogue object.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SearchDocument {
|
||||||
|
pub id: String,
|
||||||
|
pub object_number: String,
|
||||||
|
pub object_name: String,
|
||||||
|
pub brief_description: Option<String>,
|
||||||
|
pub current_owner: Option<String>,
|
||||||
|
pub recorder: Option<String>,
|
||||||
|
/// Filterable: "draft" | "internal" | "public".
|
||||||
|
pub visibility: String,
|
||||||
|
/// Flexible field values flattened to searchable text.
|
||||||
|
pub fields_text: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Meilisearch-backed search client scoped to one index.
|
||||||
|
pub struct SearchClient {
|
||||||
|
client: meilisearch_sdk::client::Client,
|
||||||
|
index_uid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn a completed task into an error if Meilisearch rejected it.
|
||||||
|
fn check_task(task: Task) -> Result<(), SearchError> {
|
||||||
|
match task {
|
||||||
|
Task::Failed { content } => Err(SearchError::Meili(
|
||||||
|
meilisearch_sdk::errors::Error::Meilisearch(content.error),
|
||||||
|
)),
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SearchClient {
|
||||||
|
pub fn connect(url: &str, api_key: &str, index_uid: &str) -> Result<Self, SearchError> {
|
||||||
|
let client = meilisearch_sdk::client::Client::new(url, Some(api_key))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
index_uid: index_uid.to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_index(&self) -> Result<(), SearchError> {
|
||||||
|
let task = self
|
||||||
|
.client
|
||||||
|
.create_index(&self.index_uid, Some("id"))
|
||||||
|
.await?
|
||||||
|
.wait_for_completion(&self.client, None, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Tolerate "index already exists"; surface any other task failure.
|
||||||
|
if let Task::Failed { content } = &task {
|
||||||
|
if content.error.error_code != meilisearch_sdk::errors::ErrorCode::IndexAlreadyExists {
|
||||||
|
return Err(SearchError::Meili(
|
||||||
|
meilisearch_sdk::errors::Error::Meilisearch(content.error.clone()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// set_filterable_attributes is idempotent on an existing index
|
||||||
|
let task = self
|
||||||
|
.client
|
||||||
|
.index(&self.index_uid)
|
||||||
|
.set_filterable_attributes(["visibility"])
|
||||||
|
.await?
|
||||||
|
.wait_for_completion(&self.client, None, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
check_task(task)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn index_object(&self, doc: &SearchDocument) -> Result<(), SearchError> {
|
||||||
|
let task = self
|
||||||
|
.client
|
||||||
|
.index(&self.index_uid)
|
||||||
|
.add_or_replace(std::slice::from_ref(doc), Some("id"))
|
||||||
|
.await?
|
||||||
|
.wait_for_completion(&self.client, None, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
check_task(task)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_object(&self, id: ObjectId) -> Result<(), SearchError> {
|
||||||
|
let task = self
|
||||||
|
.client
|
||||||
|
.index(&self.index_uid)
|
||||||
|
.delete_document(id.to_string())
|
||||||
|
.await?
|
||||||
|
.wait_for_completion(&self.client, None, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
check_task(task)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search(&self, query: &str) -> Result<Vec<ObjectId>, SearchError> {
|
||||||
|
let index = self.client.index(&self.index_uid);
|
||||||
|
|
||||||
|
let results = index
|
||||||
|
.search()
|
||||||
|
.with_query(query)
|
||||||
|
.build()
|
||||||
|
.execute::<SearchDocument>()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
results
|
||||||
|
.hits
|
||||||
|
.into_iter()
|
||||||
|
.map(|hit| {
|
||||||
|
hit.result
|
||||||
|
.id
|
||||||
|
.parse::<ObjectId>()
|
||||||
|
.map_err(|_| SearchError::BadId(hit.result.id))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the whole index from the database (clears then re-adds all objects).
|
||||||
|
pub async fn reindex_all(&self, db: &Db) -> Result<(), SearchError> {
|
||||||
|
let index = self.client.index(&self.index_uid);
|
||||||
|
|
||||||
|
let task = index
|
||||||
|
.delete_all_documents()
|
||||||
|
.await?
|
||||||
|
.wait_for_completion(&self.client, None, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
check_task(task)?;
|
||||||
|
|
||||||
|
let objects = db::catalog::list_objects(db.pool()).await?;
|
||||||
|
|
||||||
|
let mut docs = Vec::with_capacity(objects.len());
|
||||||
|
|
||||||
|
for object in &objects {
|
||||||
|
docs.push(build_document(db, object).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !docs.is_empty() {
|
||||||
|
let task = index
|
||||||
|
.add_or_replace(&docs, Some("id"))
|
||||||
|
.await?
|
||||||
|
.wait_for_completion(&self.client, None, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
check_task(task)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a [`SearchDocument`] from a catalogue object, resolving term and authority
|
||||||
|
/// references to their human-readable labels so Meilisearch can match on them.
|
||||||
|
pub async fn build_document(
|
||||||
|
db: &Db,
|
||||||
|
object: &CatalogueObject,
|
||||||
|
) -> Result<SearchDocument, SearchError> {
|
||||||
|
let mut fields_text = Vec::new();
|
||||||
|
|
||||||
|
if let Some(map) = object.fields.as_object() {
|
||||||
|
for (key, value) in map {
|
||||||
|
let Some(def) = db::fields::field_definition_by_key(db.pool(), key).await? else {
|
||||||
|
// Stale field with no definition — skip.
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match def.field_type {
|
||||||
|
domain::FieldType::Text | domain::FieldType::Date => {
|
||||||
|
if let Some(s) = value.as_str() {
|
||||||
|
fields_text.push(s.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
domain::FieldType::Integer | domain::FieldType::Boolean => {
|
||||||
|
fields_text.push(value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
domain::FieldType::LocalizedText => {
|
||||||
|
if let Some(obj) = value.as_object() {
|
||||||
|
for v in obj.values() {
|
||||||
|
if let Some(s) = v.as_str() {
|
||||||
|
fields_text.push(s.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
domain::FieldType::Term { .. } => {
|
||||||
|
if let Some(term_id) = value
|
||||||
|
.as_str()
|
||||||
|
.and_then(|s| s.parse::<domain::TermId>().ok())
|
||||||
|
{
|
||||||
|
if let Some(term) = db::vocab::term_by_id(db.pool(), term_id).await? {
|
||||||
|
fields_text.extend(term.labels.into_iter().map(|l| l.label));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
domain::FieldType::Authority { .. } => {
|
||||||
|
if let Some(authority_id) = value
|
||||||
|
.as_str()
|
||||||
|
.and_then(|s| s.parse::<domain::AuthorityId>().ok())
|
||||||
|
{
|
||||||
|
if let Some(authority) =
|
||||||
|
db::authority::authority_by_id(db.pool(), authority_id).await?
|
||||||
|
{
|
||||||
|
fields_text.extend(authority.labels.into_iter().map(|l| l.label));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SearchDocument {
|
||||||
|
id: object.id.to_string(),
|
||||||
|
object_number: object.object_number.clone(),
|
||||||
|
object_name: object.object_name.clone(),
|
||||||
|
brief_description: object.brief_description.clone(),
|
||||||
|
current_owner: object.current_owner.clone(),
|
||||||
|
recorder: object.recorder.clone(),
|
||||||
|
visibility: object.visibility.as_str().to_owned(),
|
||||||
|
fields_text,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
use db::{Db, catalog, fields, vocab};
|
||||||
|
use domain::{
|
||||||
|
AuditActor, FieldType, LocalizedLabel, NewFieldDefinition, NewTerm, ObjectInput, Visibility,
|
||||||
|
};
|
||||||
|
use search::SearchClient;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
fn meili() -> (String, String) {
|
||||||
|
(
|
||||||
|
std::env::var("MEILI_URL").expect("MEILI_URL must be set"),
|
||||||
|
std::env::var("MEILI_MASTER_KEY").expect("MEILI_MASTER_KEY must be set"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_index() -> String {
|
||||||
|
format!("reindex_test_{}", uuid::Uuid::new_v4().simple())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path is relative to this crate's root; the schema lives in the `db` crate.
|
||||||
|
// If the workspace layout changes, update this path.
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn reindex_resolves_term_labels_and_finds_by_label(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
|
||||||
|
// a material vocabulary with a "wood" term
|
||||||
|
let material = vocab::create_vocabulary(db.pool(), "material")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
|
||||||
|
let wood = vocab::add_term(
|
||||||
|
&mut tx,
|
||||||
|
&NewTerm {
|
||||||
|
vocabulary_id: material.id,
|
||||||
|
external_uri: None,
|
||||||
|
labels: vec![LocalizedLabel {
|
||||||
|
lang: "en".into(),
|
||||||
|
label: "wood".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
fields::create_field_definition(
|
||||||
|
&mut tx,
|
||||||
|
&NewFieldDefinition {
|
||||||
|
key: "material".into(),
|
||||||
|
field_type: FieldType::Term {
|
||||||
|
vocabulary_id: material.id,
|
||||||
|
},
|
||||||
|
required: false,
|
||||||
|
group_key: None,
|
||||||
|
labels: vec![LocalizedLabel {
|
||||||
|
lang: "en".into(),
|
||||||
|
label: "material".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let object_id = catalog::create_object(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&ObjectInput {
|
||||||
|
object_number: "LM-1".into(),
|
||||||
|
object_name: "vase".into(),
|
||||||
|
number_of_objects: 1,
|
||||||
|
brief_description: None,
|
||||||
|
current_location: None,
|
||||||
|
current_owner: None,
|
||||||
|
recorder: None,
|
||||||
|
recording_date: None,
|
||||||
|
visibility: Visibility::Public,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
// set the material field to the wood term
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
|
||||||
|
catalog::set_object_fields(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
object_id,
|
||||||
|
serde_json::json!({ "material": wood.to_string() })
|
||||||
|
.as_object()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let (url, key) = meili();
|
||||||
|
let client = SearchClient::connect(&url, &key, &unique_index()).unwrap();
|
||||||
|
|
||||||
|
client.ensure_index().await.unwrap();
|
||||||
|
client.reindex_all(&db).await.unwrap();
|
||||||
|
|
||||||
|
// found by the object name
|
||||||
|
assert_eq!(client.search("vase").await.unwrap(), vec![object_id]);
|
||||||
|
// found by the resolved TERM LABEL (not the uuid)
|
||||||
|
assert_eq!(client.search("wood").await.unwrap(), vec![object_id]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
use search::{SearchClient, SearchDocument};
|
||||||
|
|
||||||
|
fn meili() -> (String, String) {
|
||||||
|
(
|
||||||
|
std::env::var("MEILI_URL").expect("MEILI_URL must be set"),
|
||||||
|
std::env::var("MEILI_MASTER_KEY").expect("MEILI_MASTER_KEY must be set"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_index() -> String {
|
||||||
|
format!("objects_test_{}", uuid::Uuid::new_v4().simple())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doc(id: &str, object_name: &str, fields_text: &[&str]) -> SearchDocument {
|
||||||
|
SearchDocument {
|
||||||
|
id: id.to_string(),
|
||||||
|
object_number: format!("N-{id}"),
|
||||||
|
object_name: object_name.to_string(),
|
||||||
|
brief_description: None,
|
||||||
|
current_owner: None,
|
||||||
|
recorder: None,
|
||||||
|
visibility: "draft".to_string(),
|
||||||
|
fields_text: fields_text.iter().map(|s| s.to_string()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn index_search_and_remove() {
|
||||||
|
let (url, key) = meili();
|
||||||
|
let client = SearchClient::connect(&url, &key, &unique_index()).unwrap();
|
||||||
|
client.ensure_index().await.unwrap();
|
||||||
|
|
||||||
|
let vase = domain::ObjectId::new();
|
||||||
|
let chair = domain::ObjectId::new();
|
||||||
|
client
|
||||||
|
.index_object(&doc(&vase.to_string(), "vase", &["wood", "trä"]))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
client
|
||||||
|
.index_object(&doc(&chair.to_string(), "chair", &["oak"]))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let hits = client.search("wood").await.unwrap();
|
||||||
|
assert_eq!(hits, vec![vase]);
|
||||||
|
|
||||||
|
let hits = client.search("chair").await.unwrap();
|
||||||
|
assert_eq!(hits, vec![chair]);
|
||||||
|
|
||||||
|
client.remove_object(vase).await.unwrap();
|
||||||
|
assert!(client.search("wood").await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ensure_index_is_idempotent() {
|
||||||
|
let (url, key) = meili();
|
||||||
|
let index = unique_index();
|
||||||
|
let client = SearchClient::connect(&url, &key, &index).unwrap();
|
||||||
|
client.ensure_index().await.unwrap();
|
||||||
|
// second call against the now-existing index must succeed
|
||||||
|
client.ensure_index().await.unwrap();
|
||||||
|
|
||||||
|
// and the client still works
|
||||||
|
let id = domain::ObjectId::new();
|
||||||
|
client
|
||||||
|
.index_object(&doc(&id.to_string(), "lamp", &[]))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(client.search("lamp").await.unwrap(), vec![id]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
# Search (Meilisearch) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** A `search` crate that indexes catalogue objects (core + flexible fields, with term/authority values resolved to their labels) into Meilisearch and runs full-text search, plus a `reindex_all` rebuild. On-write sync orchestration is deferred to the API/service layer (Plan 7+); this plan builds the capability and `reindex_all`.
|
||||||
|
|
||||||
|
**Architecture:** A new role-named crate `search` depending on `db` + `domain` (cycle-free: `search → db → domain`). It exposes a `SearchClient` (Meilisearch adapter behind our own type, so the engine stays swappable), a `SearchDocument` (the indexed shape), `build_document` (reads `db` to resolve a `CatalogueObject`'s flexible fields to searchable text), and `reindex_all`. Search returns object ids; callers load full objects from `db`. `visibility` is a filterable attribute (for the future public API).
|
||||||
|
|
||||||
|
**Tech Stack:** Rust 2024, `meilisearch-sdk` (async client), `serde` (document), `thiserror` (SearchError), tokio. Tests run against a real Meilisearch (Docker) + Postgres.
|
||||||
|
|
||||||
|
## Design decisions (approved)
|
||||||
|
- `search` crate: `SearchClient` wrapping `meilisearch-sdk`, swappable behind our type.
|
||||||
|
- Index doc = core text + flexible values flattened to searchable text; **term/authority resolved to labels**; `localized_text` → all language strings; `visibility` filterable. Search returns object ids.
|
||||||
|
- Build the capability + `reindex_all` now; **on-write sync is wired at the API/service layer (Plan 7+)**. Eventual consistency (Meili not transactional with Postgres).
|
||||||
|
- Integration tests use a real Meilisearch in Docker, each test on a **unique index** for isolation.
|
||||||
|
|
||||||
|
## ⚠️ Implementer note on the Meilisearch SDK
|
||||||
|
The `meilisearch-sdk` API (method names, async task handling) varies by version. The **code blocks below are the intended shape**; adapt the exact SDK calls to the installed version while preserving behavior. **The tests are the contract** — make them pass. Key behaviors: indexing operations must `wait_for_completion` (Meilisearch indexes asynchronously) so a subsequent search sees the document. Verify the current `meilisearch-sdk` version via the cratesio tooling and pin it.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Postgres (as before) AND a Meilisearch instance. The controller will start Meilisearch in Docker (e.g. `getmeili/meilisearch`) with a master key. Tests read `MEILI_URL` (e.g. `http://localhost:7700`) and `MEILI_MASTER_KEY`; pass them inline alongside `DATABASE_URL`. Pass transaction connections as `&mut tx`.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
```
|
||||||
|
Cargo.toml + search member; meilisearch-sdk in workspace deps
|
||||||
|
crates/search/
|
||||||
|
Cargo.toml
|
||||||
|
src/lib.rs SearchError, SearchDocument, SearchClient, build_document, reindex_all
|
||||||
|
tests/search.rs (Meili only) index/search/remove
|
||||||
|
tests/reindex.rs (Meili + Postgres) build_document + reindex_all
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `search` crate — client, document, index/search/remove
|
||||||
|
|
||||||
|
**Files:** modify root `Cargo.toml`; create `crates/search/Cargo.toml`, `crates/search/src/lib.rs`, `crates/search/tests/search.rs`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Workspace + crate setup.**
|
||||||
|
- In root `Cargo.toml`, add `"crates/search"` to `members`, and add to `[workspace.dependencies]` (verify the latest version via cratesio):
|
||||||
|
```toml
|
||||||
|
meilisearch-sdk = "0.28"
|
||||||
|
```
|
||||||
|
- Create `crates/search/Cargo.toml`:
|
||||||
|
```toml
|
||||||
|
[package]
|
||||||
|
name = "search"
|
||||||
|
version = "0.0.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
meilisearch-sdk.workspace = true
|
||||||
|
serde = { workspace = true }
|
||||||
|
thiserror.workspace = true
|
||||||
|
domain = { path = "../domain" }
|
||||||
|
db = { path = "../db" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sqlx.workspace = true
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing test** `crates/search/tests/search.rs` (Meilisearch only — hand-built documents, no Postgres):
|
||||||
|
```rust
|
||||||
|
use search::{SearchClient, SearchDocument};
|
||||||
|
|
||||||
|
fn meili() -> (String, String) {
|
||||||
|
(
|
||||||
|
std::env::var("MEILI_URL").expect("MEILI_URL must be set"),
|
||||||
|
std::env::var("MEILI_MASTER_KEY").expect("MEILI_MASTER_KEY must be set"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_index() -> String {
|
||||||
|
format!("objects_test_{}", uuid::Uuid::new_v4().simple())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doc(id: &str, object_name: &str, fields_text: &[&str]) -> SearchDocument {
|
||||||
|
SearchDocument {
|
||||||
|
id: id.to_string(),
|
||||||
|
object_number: format!("N-{id}"),
|
||||||
|
object_name: object_name.to_string(),
|
||||||
|
brief_description: None,
|
||||||
|
current_owner: None,
|
||||||
|
recorder: None,
|
||||||
|
visibility: "draft".to_string(),
|
||||||
|
fields_text: fields_text.iter().map(|s| s.to_string()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn index_search_and_remove() {
|
||||||
|
let (url, key) = meili();
|
||||||
|
let client = SearchClient::connect(&url, &key, &unique_index()).unwrap();
|
||||||
|
client.ensure_index().await.unwrap();
|
||||||
|
|
||||||
|
let vase = domain::ObjectId::new();
|
||||||
|
let chair = domain::ObjectId::new();
|
||||||
|
client.index_object(&doc(&vase.to_string(), "vase", &["wood", "trä"])).await.unwrap();
|
||||||
|
client.index_object(&doc(&chair.to_string(), "chair", &["oak"])).await.unwrap();
|
||||||
|
|
||||||
|
// full-text on a flexible value
|
||||||
|
let hits = client.search("wood").await.unwrap();
|
||||||
|
assert_eq!(hits, vec![vase]);
|
||||||
|
|
||||||
|
// full-text on the object name
|
||||||
|
let hits = client.search("chair").await.unwrap();
|
||||||
|
assert_eq!(hits, vec![chair]);
|
||||||
|
|
||||||
|
// remove
|
||||||
|
client.remove_object(vase).await.unwrap();
|
||||||
|
assert!(client.search("wood").await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run to verify it fails.** `MEILI_URL=<url> MEILI_MASTER_KEY=<key> cargo test -p search --test search` → FAIL (crate/types missing).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement** `crates/search/src/lib.rs` (adapt the SDK calls to the installed version; keep behavior + signatures):
|
||||||
|
```rust
|
||||||
|
//! Full-text search over catalogue objects, backed by Meilisearch.
|
||||||
|
|
||||||
|
use db::Db;
|
||||||
|
use domain::{CatalogueObject, ObjectId};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Errors from the search subsystem.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SearchError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Meili(#[from] meilisearch_sdk::errors::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
#[error("invalid object id in index: {0}")]
|
||||||
|
BadId(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The indexed shape of a catalogue object.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SearchDocument {
|
||||||
|
pub id: String,
|
||||||
|
pub object_number: String,
|
||||||
|
pub object_name: String,
|
||||||
|
pub brief_description: Option<String>,
|
||||||
|
pub current_owner: Option<String>,
|
||||||
|
pub recorder: Option<String>,
|
||||||
|
/// Filterable: "draft" | "internal" | "public".
|
||||||
|
pub visibility: String,
|
||||||
|
/// Flexible field values flattened to searchable text (term/authority labels,
|
||||||
|
/// localized strings, and scalar values).
|
||||||
|
pub fields_text: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Meilisearch-backed search client scoped to one index.
|
||||||
|
pub struct SearchClient {
|
||||||
|
client: meilisearch_sdk::client::Client,
|
||||||
|
index_uid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SearchClient {
|
||||||
|
/// Connect to Meilisearch at `url` with `api_key`, scoped to `index_uid`.
|
||||||
|
pub fn connect(url: &str, api_key: &str, index_uid: &str) -> Result<Self, SearchError> {
|
||||||
|
let client = meilisearch_sdk::client::Client::new(url, Some(api_key))?;
|
||||||
|
Ok(Self { client, index_uid: index_uid.to_owned() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the index (primary key "id") if absent and set filterable attributes.
|
||||||
|
pub async fn ensure_index(&self) -> Result<(), SearchError> {
|
||||||
|
// Create the index if it doesn't exist (ignore "index already exists").
|
||||||
|
let task = self.client.create_index(&self.index_uid, Some("id")).await?;
|
||||||
|
task.wait_for_completion(&self.client, None, None).await?;
|
||||||
|
let index = self.client.index(&self.index_uid);
|
||||||
|
index
|
||||||
|
.set_filterable_attributes(["visibility"])
|
||||||
|
.await?
|
||||||
|
.wait_for_completion(&self.client, None, None)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upsert one object document (waits for indexing to complete).
|
||||||
|
pub async fn index_object(&self, doc: &SearchDocument) -> Result<(), SearchError> {
|
||||||
|
self.client
|
||||||
|
.index(&self.index_uid)
|
||||||
|
.add_or_replace_documents(std::slice::from_ref(doc), Some("id"))
|
||||||
|
.await?
|
||||||
|
.wait_for_completion(&self.client, None, None)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove one object from the index by id (waits for completion).
|
||||||
|
pub async fn remove_object(&self, id: ObjectId) -> Result<(), SearchError> {
|
||||||
|
self.client
|
||||||
|
.index(&self.index_uid)
|
||||||
|
.delete_document(id.to_string())
|
||||||
|
.await?
|
||||||
|
.wait_for_completion(&self.client, None, None)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full-text search; returns matching object ids (in Meilisearch ranking order).
|
||||||
|
pub async fn search(&self, query: &str) -> Result<Vec<ObjectId>, SearchError> {
|
||||||
|
let results = self
|
||||||
|
.client
|
||||||
|
.index(&self.index_uid)
|
||||||
|
.search()
|
||||||
|
.with_query(query)
|
||||||
|
.execute::<SearchDocument>()
|
||||||
|
.await?;
|
||||||
|
results
|
||||||
|
.hits
|
||||||
|
.into_iter()
|
||||||
|
.map(|hit| hit.result.id.parse::<ObjectId>().map_err(|_| SearchError::BadId(hit.result.id)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the whole index from the database (clears then re-adds all objects).
|
||||||
|
pub async fn reindex_all(&self, db: &Db) -> Result<(), SearchError> {
|
||||||
|
let index = self.client.index(&self.index_uid);
|
||||||
|
index.delete_all_documents().await?.wait_for_completion(&self.client, None, None).await?;
|
||||||
|
|
||||||
|
let objects = db::catalog::list_objects(db.pool()).await?;
|
||||||
|
let mut docs = Vec::with_capacity(objects.len());
|
||||||
|
for object in &objects {
|
||||||
|
docs.push(build_document(db, object).await?);
|
||||||
|
}
|
||||||
|
if !docs.is_empty() {
|
||||||
|
index
|
||||||
|
.add_or_replace_documents(&docs, Some("id"))
|
||||||
|
.await?
|
||||||
|
.wait_for_completion(&self.client, None, None)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a [`SearchDocument`] from an object, resolving its flexible fields to
|
||||||
|
/// searchable text (term/authority → labels, localized text → all values).
|
||||||
|
/// Implemented in Task 2; declared here so the crate compiles.
|
||||||
|
pub async fn build_document(
|
||||||
|
_db: &Db,
|
||||||
|
_object: &CatalogueObject,
|
||||||
|
) -> Result<SearchDocument, SearchError> {
|
||||||
|
unimplemented!("implemented in Task 2")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
NOTE: `ObjectId: FromStr` (Err = `uuid::Error`) exists from the id macro. `reindex_all`/`build_document` are needed for compilation now (Task 1 test doesn't call them) — `build_document` is a stub `unimplemented!()` filled in Task 2. If clippy flags the stub's unused params, the leading underscores suppress that; if it flags `unimplemented!` in a non-test fn, add `#[allow(clippy::unimplemented)]` to `build_document` with a `// Task 2` note, OR move `reindex_all`+`build_document` entirely into Task 2 (preferred if it keeps Task 1 clippy-clean — in that case omit them here and add `pub mod`-level items in Task 2).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run to verify it passes.** `MEILI_URL=<url> MEILI_MASTER_KEY=<key> cargo test -p search --test search` → PASS. (You may need to adapt SDK calls; iterate until the test passes.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Lint.** `cargo +nightly fmt`; `cargo clippy -p search --all-targets -- -D warnings` → clean.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit.**
|
||||||
|
```bash
|
||||||
|
git add Cargo.toml crates/search
|
||||||
|
git commit -m "feat(search): add Meilisearch-backed SearchClient (index, search, remove)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `build_document` + `reindex_all` (db integration)
|
||||||
|
|
||||||
|
**Files:** modify `crates/search/src/lib.rs`; create `crates/search/tests/reindex.rs`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** `crates/search/tests/reindex.rs` (Meilisearch + Postgres):
|
||||||
|
```rust
|
||||||
|
use db::{Db, catalog, fields, vocab};
|
||||||
|
use domain::{
|
||||||
|
AuditActor, FieldType, LocalizedLabel, NewFieldDefinition, NewTerm, ObjectInput, Visibility,
|
||||||
|
};
|
||||||
|
use search::SearchClient;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
fn meili() -> (String, String) {
|
||||||
|
(
|
||||||
|
std::env::var("MEILI_URL").expect("MEILI_URL must be set"),
|
||||||
|
std::env::var("MEILI_MASTER_KEY").expect("MEILI_MASTER_KEY must be set"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_index() -> String {
|
||||||
|
format!("reindex_test_{}", uuid::Uuid::new_v4().simple())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn reindex_resolves_term_labels_and_finds_by_label(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
|
||||||
|
// a material vocabulary with a "wood" term
|
||||||
|
let material = vocab::create_vocabulary(db.pool(), "material").await.unwrap();
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let wood = vocab::add_term(
|
||||||
|
&mut tx,
|
||||||
|
&NewTerm {
|
||||||
|
vocabulary_id: material.id,
|
||||||
|
external_uri: None,
|
||||||
|
labels: vec![LocalizedLabel { lang: "en".into(), label: "wood".into() }],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
fields::create_field_definition(
|
||||||
|
&mut tx,
|
||||||
|
&NewFieldDefinition {
|
||||||
|
key: "material".into(),
|
||||||
|
field_type: FieldType::Term { vocabulary_id: material.id },
|
||||||
|
required: false,
|
||||||
|
group_key: None,
|
||||||
|
labels: vec![LocalizedLabel { lang: "en".into(), label: "material".into() }],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let object_id = catalog::create_object(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&ObjectInput {
|
||||||
|
object_number: "LM-1".into(),
|
||||||
|
object_name: "vase".into(),
|
||||||
|
number_of_objects: 1,
|
||||||
|
brief_description: None,
|
||||||
|
current_location: None,
|
||||||
|
current_owner: None,
|
||||||
|
recorder: None,
|
||||||
|
recording_date: None,
|
||||||
|
visibility: Visibility::Public,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
// set the material field to the wood term
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
catalog::set_object_fields(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
object_id,
|
||||||
|
serde_json::json!({ "material": wood.to_string() }).as_object().unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let (url, key) = meili();
|
||||||
|
let client = SearchClient::connect(&url, &key, &unique_index()).unwrap();
|
||||||
|
client.ensure_index().await.unwrap();
|
||||||
|
client.reindex_all(&db).await.unwrap();
|
||||||
|
|
||||||
|
// found by the object name
|
||||||
|
assert_eq!(client.search("vase").await.unwrap(), vec![object_id]);
|
||||||
|
// found by the resolved TERM LABEL (not the uuid)
|
||||||
|
assert_eq!(client.search("wood").await.unwrap(), vec![object_id]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails.** With both env vars + `DATABASE_URL`: `... cargo test -p search --test reindex` → FAIL (`build_document` is `unimplemented!`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `build_document`** in `crates/search/src/lib.rs` — replace the stub body with a real implementation that flattens the object's flexible fields to searchable text, resolving term/authority values to labels:
|
||||||
|
```rust
|
||||||
|
pub async fn build_document(
|
||||||
|
db: &Db,
|
||||||
|
object: &CatalogueObject,
|
||||||
|
) -> Result<SearchDocument, SearchError> {
|
||||||
|
let mut fields_text = Vec::new();
|
||||||
|
|
||||||
|
if let Some(map) = object.fields.as_object() {
|
||||||
|
for (key, value) in map {
|
||||||
|
let Some(def) = db::fields::field_definition_by_key(db.pool(), key).await? else {
|
||||||
|
continue; // a field with no definition (stale) — skip
|
||||||
|
};
|
||||||
|
match def.field_type {
|
||||||
|
domain::FieldType::Text | domain::FieldType::Date => {
|
||||||
|
if let Some(s) = value.as_str() {
|
||||||
|
fields_text.push(s.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
domain::FieldType::Integer | domain::FieldType::Boolean => {
|
||||||
|
fields_text.push(value.to_string());
|
||||||
|
}
|
||||||
|
domain::FieldType::LocalizedText => {
|
||||||
|
if let Some(obj) = value.as_object() {
|
||||||
|
for v in obj.values() {
|
||||||
|
if let Some(s) = v.as_str() {
|
||||||
|
fields_text.push(s.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
domain::FieldType::Term { .. } => {
|
||||||
|
if let Some(term_id) = value.as_str().and_then(|s| s.parse().ok()) {
|
||||||
|
if let Some(term) = db::vocab::term_by_id(db.pool(), term_id).await? {
|
||||||
|
fields_text.extend(term.labels.into_iter().map(|l| l.label));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
domain::FieldType::Authority { .. } => {
|
||||||
|
if let Some(authority_id) = value.as_str().and_then(|s| s.parse().ok()) {
|
||||||
|
if let Some(authority) =
|
||||||
|
db::authority::authority_by_id(db.pool(), authority_id).await?
|
||||||
|
{
|
||||||
|
fields_text.extend(authority.labels.into_iter().map(|l| l.label));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SearchDocument {
|
||||||
|
id: object.id.to_string(),
|
||||||
|
object_number: object.object_number.clone(),
|
||||||
|
object_name: object.object_name.clone(),
|
||||||
|
brief_description: object.brief_description.clone(),
|
||||||
|
current_owner: object.current_owner.clone(),
|
||||||
|
recorder: object.recorder.clone(),
|
||||||
|
visibility: object.visibility.as_str().to_owned(),
|
||||||
|
fields_text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(`db::vocab::term_by_id` takes a `TermId`; `db::authority::authority_by_id` takes an `AuthorityId` — `value.as_str().and_then(|s| s.parse().ok())` parses into the inferred id type. If type inference needs help, annotate: `let term_id: domain::TermId = ...`.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes.** `MEILI_URL=<url> MEILI_MASTER_KEY=<key> DATABASE_URL=<url> cargo test -p search --test reindex` → PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Full workspace check.**
|
||||||
|
```bash
|
||||||
|
cargo +nightly fmt --check
|
||||||
|
DATABASE_URL=<url> MEILI_URL=<url> MEILI_MASTER_KEY=<key> cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
DATABASE_URL=<url> MEILI_URL=<url> MEILI_MASTER_KEY=<key> cargo test --workspace
|
||||||
|
```
|
||||||
|
Expected: all green. (The `search` tests need the MEILI env vars; the rest need `DATABASE_URL`.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit.**
|
||||||
|
```bash
|
||||||
|
git add crates/search
|
||||||
|
git commit -m "feat(search): build documents resolving term/authority labels; reindex_all"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
|
||||||
|
**Spec coverage (Plan 6 / VISION search MVP):**
|
||||||
|
- `search` crate, Meilisearch adapter behind `SearchClient`, swappable → Task 1. ✓
|
||||||
|
- Index core + flexible text; term/authority resolved to labels; localized → all values; visibility filterable; search returns object ids → Tasks 1–2. ✓
|
||||||
|
- Build capability + `reindex_all` now; on-write sync deferred to API/service → this plan + notes. ✓
|
||||||
|
- `search → db → domain` (no cycle); SQL stays in `db` (search calls db repos) → Cargo deps. ✓
|
||||||
|
- Real-Meili integration tests, unique index per test → Tasks 1–2. ✓
|
||||||
|
|
||||||
|
**Placeholder scan:** the only `unimplemented!` is the Task 1 `build_document` stub, explicitly filled in Task 2 (with a fallback instruction). `<url>`/`<key>` are documented env values. No other placeholders.
|
||||||
|
|
||||||
|
**Type consistency:** `SearchDocument` fields used identically in tests + `build_document`; `SearchClient::{connect, ensure_index, index_object, remove_object, search, reindex_all}` signatures consistent across tasks/tests; `search` returns `Vec<ObjectId>` parsed via `ObjectId: FromStr`; `build_document` matches on `domain::FieldType` (Plan 4) and calls `db::vocab::term_by_id`/`db::authority::authority_by_id`/`db::fields::field_definition_by_key`/`db::catalog::list_objects` as defined.
|
||||||
|
|
||||||
|
## Notes for follow-on plans
|
||||||
|
- **On-write sync (API/service, Plan 7+):** after a catalogue create/update/delete/set_fields commits, call `index_object`/`remove_object` best-effort (log failures; `reindex_all` is the recovery path). Meili is not transactional with Postgres — eventual consistency.
|
||||||
|
- **Public API (Plan 7):** `search` already stores `visibility` as filterable; add a `with_filter("visibility = public")` search variant for the public surface.
|
||||||
|
- **Per-deployment index/credentials:** production uses a fixed index uid (e.g. `objects`) with a scoped Meili key per the single-tenant deployment; only tests use unique index names.
|
||||||
|
- **Reindex cost:** `reindex_all` is N+1 over objects×fields (resolves labels per field) — fine for now; batch when collections grow (relates to #12).
|
||||||
Reference in New Issue
Block a user