From f10e840c68618bd5a5b5cb23842c5088c2da82fd Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Fri, 17 Jul 2026 03:59:05 -0700 Subject: [PATCH 01/36] chore(deps): upgrade thiserror to 2.0 and consolidate on workspace dep Bump thiserror 1.0 -> 2.0 (major). Migration is source-compatible here: all 26 derive sites use #[from]/#[error("...")] forms unchanged in 2.0, no #[error(transparent)] or method-call format strings. Also convert the six crates that pinned `thiserror = "1.0"` directly to `thiserror.workspace = true` so the version lives in one place. Lockfile also refreshed to latest semver-compatible transitive versions. Workspace `cargo check --all-targets` green. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 3424 +++++++++++++++------------- Cargo.toml | 2 +- orbit/cli/Cargo.toml | 3 +- orbit/client-spring/Cargo.toml | 2 +- orbit/compute/Cargo.toml | 3 +- orbit/engine/Cargo.toml | 2 +- orbit/server-etcd/Cargo.toml | 2 +- orbit/server-prometheus/Cargo.toml | 2 +- 8 files changed, 1805 insertions(+), 1635 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 13d5f417..303b1456 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,7 +64,7 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "once_cell", "version_check", ] @@ -116,9 +116,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -152,9 +152,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -167,15 +167,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -186,7 +186,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -197,14 +197,14 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "apache-avro" @@ -218,7 +218,7 @@ dependencies = [ "log", "num-bigint", "quad-rand", - "rand 0.8.5", + "rand 0.8.7", "regex-lite", "serde", "serde_bytes", @@ -243,7 +243,7 @@ dependencies = [ "miniz_oxide", "num-bigint", "quad-rand", - "rand 0.9.4", + "rand 0.9.5", "regex-lite", "serde", "serde_bytes", @@ -275,9 +275,12 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] [[package]] name = "argon2" @@ -305,9 +308,9 @@ checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "arrow" @@ -321,7 +324,7 @@ dependencies = [ "arrow-cast 58.3.0", "arrow-data 58.3.0", "arrow-ord 58.3.0", - "arrow-row", + "arrow-row 58.3.0", "arrow-schema 58.3.0", "arrow-select 58.3.0", "arrow-string 58.3.0", @@ -329,14 +332,14 @@ dependencies = [ [[package]] name = "arrow-arith" -version = "57.1.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f377dcd19e440174596d83deb49cd724886d91060c07fec4f67014ef9d54049" +checksum = "7c7bbd679c5418b8639b92be01f361d60013c4906574b578b77b63c78356594c" dependencies = [ - "arrow-array 57.1.0", - "arrow-buffer 57.1.0", - "arrow-data 57.1.0", - "arrow-schema 57.1.0", + "arrow-array 57.3.1", + "arrow-buffer 57.3.1", + "arrow-data 57.3.1", + "arrow-schema 57.3.1", "chrono", "num-traits", ] @@ -355,16 +358,30 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arrow-arith" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a13b8d3008c4e9063c597a08f46446fe3fd5789277127672d6c0bdbb43b1ff" +dependencies = [ + "arrow-array 59.1.0", + "arrow-buffer 59.1.0", + "arrow-data 59.1.0", + "arrow-schema 59.1.0", + "chrono", + "num-traits", +] + [[package]] name = "arrow-array" -version = "57.1.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eaff85a44e9fa914660fb0d0bb00b79c4a3d888b5334adb3ea4330c84f002" +checksum = "c8a4ab47b3f3eac60f7fd31b81e9028fda018607bcc63451aca4f2b755269862" dependencies = [ "ahash 0.8.12", - "arrow-buffer 57.1.0", - "arrow-data 57.1.0", - "arrow-schema 57.1.0", + "arrow-buffer 57.3.1", + "arrow-data 57.3.1", + "arrow-schema 57.3.1", "chrono", "half", "hashbrown 0.16.1", @@ -391,11 +408,29 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arrow-array" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9486151b2f0785bafc6fa04fc5c99fcb4495455662e58787ea32eaaed33c4192" +dependencies = [ + "ahash 0.8.12", + "arrow-buffer 59.1.0", + "arrow-data 59.1.0", + "arrow-schema 59.1.0", + "chrono", + "half", + "hashbrown 0.17.1", + "num-complex", + "num-integer", + "num-traits", +] + [[package]] name = "arrow-buffer" -version = "57.1.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2819d893750cb3380ab31ebdc8c68874dd4429f90fd09180f3c93538bd21626" +checksum = "0d18b89b4c4f4811d0858175e79541fe98e33e18db3b011708bc287b1240593f" dependencies = [ "bytes", "half", @@ -415,18 +450,30 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arrow-buffer" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4776577a87794bfdf0b4e90e2ea12454fa7738ea2823c4be5b9d1851da7b434" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + [[package]] name = "arrow-cast" -version = "57.1.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3d131abb183f80c450d4591dc784f8d7750c50c6e2bc3fcaad148afc8361271" +checksum = "722b5c41dd1d14d0a879a1bce92c6fe33f546101bb2acce57a209825edd075b3" dependencies = [ - "arrow-array 57.1.0", - "arrow-buffer 57.1.0", - "arrow-data 57.1.0", - "arrow-ord 57.1.0", - "arrow-schema 57.1.0", - "arrow-select 57.1.0", + "arrow-array 57.3.1", + "arrow-buffer 57.3.1", + "arrow-data 57.3.1", + "arrow-ord 57.3.1", + "arrow-schema 57.3.1", + "arrow-select 57.3.1", "atoi", "base64 0.22.1", "chrono", @@ -457,14 +504,35 @@ dependencies = [ "ryu", ] +[[package]] +name = "arrow-cast" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9ad451ce4f98710828a455b96991b8f031deb2e67f5fcad6773f017e4a69c3a" +dependencies = [ + "arrow-array 59.1.0", + "arrow-buffer 59.1.0", + "arrow-data 59.1.0", + "arrow-ord 59.1.0", + "arrow-schema 59.1.0", + "arrow-select 59.1.0", + "atoi", + "base64 0.22.1", + "chrono", + "half", + "lexical-core", + "num-traits", + "ryu", +] + [[package]] name = "arrow-data" -version = "57.1.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05738f3d42cb922b9096f7786f606fcb8669260c2640df8490533bb2fa38c9d3" +checksum = "c1683705c63dcf0d18972759eda48489028cbbff67af7d6bef2c6b7b74ab778a" dependencies = [ - "arrow-buffer 57.1.0", - "arrow-schema 57.1.0", + "arrow-buffer 57.3.1", + "arrow-schema 57.3.1", "half", "num-integer", "num-traits", @@ -483,45 +551,58 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arrow-data" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38fe43e2e8704360f1464e6e8cc4fc381ef02cc4fb0192afa8df1aaa0115c66" +dependencies = [ + "arrow-buffer 59.1.0", + "arrow-schema 59.1.0", + "half", + "num-integer", + "num-traits", +] + [[package]] name = "arrow-ipc" -version = "57.1.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d09446e8076c4b3f235603d9ea7c5494e73d441b01cd61fb33d7254c11964b3" +checksum = "8cf72d04c07229fbf4dbebe7145cac37d7cf7ec582fe705c6b92cb314af096ab" dependencies = [ - "arrow-array 57.1.0", - "arrow-buffer 57.1.0", - "arrow-data 57.1.0", - "arrow-schema 57.1.0", - "arrow-select 57.1.0", + "arrow-array 57.3.1", + "arrow-buffer 57.3.1", + "arrow-data 57.3.1", + "arrow-schema 57.3.1", + "arrow-select 57.3.1", "flatbuffers", ] [[package]] name = "arrow-ipc" -version = "58.3.0" +version = "59.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "238438f0834483703d88896db6fe5a7138b2230debc31b34c0336c2996e3c64f" +checksum = "29dac499fcbc6ba74ee0324057821d381929a48526a3966bd9dffb44aa06d98c" dependencies = [ - "arrow-array 58.3.0", - "arrow-buffer 58.3.0", - "arrow-data 58.3.0", - "arrow-schema 58.3.0", - "arrow-select 58.3.0", + "arrow-array 59.1.0", + "arrow-buffer 59.1.0", + "arrow-data 59.1.0", + "arrow-schema 59.1.0", + "arrow-select 59.1.0", "flatbuffers", ] [[package]] name = "arrow-ord" -version = "57.1.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc94fc7adec5d1ba9e8cd1b1e8d6f72423b33fe978bf1f46d970fafab787521" +checksum = "082342947d4e5a2bcccf029a0a0397e21cb3bb8421edd9571d34fb5dd2670256" dependencies = [ - "arrow-array 57.1.0", - "arrow-buffer 57.1.0", - "arrow-data 57.1.0", - "arrow-schema 57.1.0", - "arrow-select 57.1.0", + "arrow-array 57.3.1", + "arrow-buffer 57.3.1", + "arrow-data 57.3.1", + "arrow-schema 57.3.1", + "arrow-select 57.3.1", ] [[package]] @@ -537,6 +618,19 @@ dependencies = [ "arrow-select 58.3.0", ] +[[package]] +name = "arrow-ord" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e13dbdc2a9c053c10c7baa6e30faee04a180aa7ce88e471835850ce37abd20b" +dependencies = [ + "arrow-array 59.1.0", + "arrow-buffer 59.1.0", + "arrow-data 59.1.0", + "arrow-schema 59.1.0", + "arrow-select 59.1.0", +] + [[package]] name = "arrow-row" version = "58.3.0" @@ -550,11 +644,24 @@ dependencies = [ "half", ] +[[package]] +name = "arrow-row" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d5a1f8c733d15260b305683472ee8ad89c62cbd706703ca873b90d051b41592" +dependencies = [ + "arrow-array 59.1.0", + "arrow-buffer 59.1.0", + "arrow-data 59.1.0", + "arrow-schema 59.1.0", + "half", +] + [[package]] name = "arrow-schema" -version = "57.1.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27609cd7dd45f006abae27995c2729ef6f4b9361cde1ddd019dc31a5aa017e0" +checksum = "e4cf0d4a6609679e03002167a61074a21d7b1ad9ea65e462b2c0a97f8a3b2bc6" [[package]] name = "arrow-schema" @@ -562,17 +669,23 @@ version = "58.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f633dbfdf39c039ada1bf9e34c694816eb71fbb7dc78f613993b7245e078a1ed" +[[package]] +name = "arrow-schema" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9e4969dc350d571766247143ab36a5187d095d3d3690970408bc630d47c69e5" + [[package]] name = "arrow-select" -version = "57.1.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae980d021879ea119dd6e2a13912d81e64abed372d53163e804dfe84639d8010" +checksum = "0b320d86a9806923663bb0fd9baa65ecaba81cb0cd77ff8c1768b9716b4ef891" dependencies = [ "ahash 0.8.12", - "arrow-array 57.1.0", - "arrow-buffer 57.1.0", - "arrow-data 57.1.0", - "arrow-schema 57.1.0", + "arrow-array 57.3.1", + "arrow-buffer 57.3.1", + "arrow-data 57.3.1", + "arrow-schema 57.3.1", "num-traits", ] @@ -590,21 +703,35 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arrow-select" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402770dba90865359d98d1ef92ef16e23d75c0cca9c2c880c8a05468b7743bf9" +dependencies = [ + "ahash 0.8.12", + "arrow-array 59.1.0", + "arrow-buffer 59.1.0", + "arrow-data 59.1.0", + "arrow-schema 59.1.0", + "num-traits", +] + [[package]] name = "arrow-string" -version = "57.1.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf35e8ef49dcf0c5f6d175edee6b8af7b45611805333129c541a8b89a0fc0534" +checksum = "b493e99162e5764077e7823e50ba284858d365922631c7aaefe9487b1abd02c2" dependencies = [ - "arrow-array 57.1.0", - "arrow-buffer 57.1.0", - "arrow-data 57.1.0", - "arrow-schema 57.1.0", - "arrow-select 57.1.0", + "arrow-array 57.3.1", + "arrow-buffer 57.3.1", + "arrow-data 57.3.1", + "arrow-schema 57.3.1", + "arrow-select 57.3.1", "memchr", "num-traits", "regex", - "regex-syntax 0.8.8", + "regex-syntax 0.8.11", ] [[package]] @@ -621,7 +748,7 @@ dependencies = [ "memchr", "num-traits", "regex", - "regex-syntax 0.8.8", + "regex-syntax 0.8.11", ] [[package]] @@ -659,9 +786,9 @@ dependencies = [ [[package]] name = "async-lock" -version = "3.4.1" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ "event-listener", "event-listener-strategy", @@ -676,7 +803,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -698,7 +825,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -709,7 +836,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -735,36 +862,36 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", - "untrusted 0.7.1", "zeroize", ] [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -777,8 +904,8 @@ dependencies = [ "axum-core 0.4.5", "bytes", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body 1.1.0", "http-body-util", "itoa", "matchit 0.7.3", @@ -791,27 +918,27 @@ dependencies = [ "serde_json", "serde_path_to_error", "sync_wrapper 1.0.2", - "tower 0.5.2", + "tower 0.5.3", "tower-layer", "tower-service", ] [[package]] name = "axum" -version = "0.8.7" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "axum-core 0.5.5", + "axum-core 0.5.6", "axum-macros", "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body 1.1.0", "http-body-util", - "hyper 1.8.1", + "hyper 1.10.1", "hyper-util", "itoa", "matchit 0.8.4", @@ -823,11 +950,11 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1 0.10.6", + "sha1 0.10.7", "sync_wrapper 1.0.2", "tokio", - "tokio-tungstenite 0.28.0", - "tower 0.5.2", + "tokio-tungstenite 0.29.0", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -842,8 +969,8 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body 1.1.0", "http-body-util", "mime", "pin-project-lite", @@ -855,14 +982,14 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body 1.1.0", "http-body-util", "mime", "pin-project-lite", @@ -874,13 +1001,13 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -914,9 +1041,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.0" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bcrypt" @@ -926,16 +1053,16 @@ checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" dependencies = [ "base64 0.22.1", "blowfish", - "getrandom 0.2.16", + "getrandom 0.2.17", "subtle", "zeroize", ] [[package]] name = "bigdecimal" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "560f42649de9fa436b73517378a147ec21f6c997a546581df4b4b31677828934" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" dependencies = [ "autocfg", "libm", @@ -966,18 +1093,18 @@ version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.12.1", "lazy_static", "lazycell", "proc-macro2", "quote", "regex", "rustc-hash 1.1.0", - "shlex", - "syn 2.0.111", + "shlex 1.3.0", + "syn 2.0.119", ] [[package]] @@ -1003,27 +1130,27 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] [[package]] name = "bitpacking" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c1d3e2bfd8d06048a179f7b17afc3188effa10385e7b00dc65af6aae732ea92" +checksum = "96a7139abd3d9cebf8cd6f920a389cf3dc9576172e32f4563f188cae3c3eb019" dependencies = [ "crunchy", ] [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -1057,9 +1184,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -1104,28 +1231,28 @@ dependencies = [ [[package]] name = "boa_ast" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc119a5ad34c3f459062a96907f53358989b173d104258891bb74f95d93747e8" +checksum = "6339a700715bda376f5ea65c76e8fe8fc880930d8b0638cea68e7f3da6538e0a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "boa_interner", "boa_macros", "boa_string", "indexmap 2.14.0", "num-bigint", - "rustc-hash 2.1.1", + "rustc-hash 2.1.3", ] [[package]] name = "boa_engine" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e637ec52ea66d76b0ca86180c259d6c7bb6e6a6e14b2f36b85099306d8b00cc3" +checksum = "1521be326f8a5c8887e95d4ce7f002917a002a23f7b93b9a6a2bf50ed4157824" dependencies = [ "aligned-vec 0.6.4", "arrayvec", - "bitflags 2.10.0", + "bitflags 2.13.1", "boa_ast", "boa_gc", "boa_interner", @@ -1135,7 +1262,7 @@ dependencies = [ "bytemuck", "cfg-if", "cow-utils", - "dashmap 6.1.0", + "dashmap 6.2.1", "dynify", "fast-float2", "float16", @@ -1153,9 +1280,9 @@ dependencies = [ "num_enum", "paste", "portable-atomic", - "rand 0.9.4", + "rand 0.9.5", "regress", - "rustc-hash 2.1.1", + "rustc-hash 2.1.3", "ryu-js", "serde", "serde_json", @@ -1171,9 +1298,9 @@ dependencies = [ [[package]] name = "boa_gc" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1179f690cbfcbe5364cceee5f1cb577265bb6f07b0be6f210aabe270adcf9da" +checksum = "17323a98cf2e631afacf1a6d659c1212c48a68bacfa85afab0a66ade80582e51" dependencies = [ "boa_macros", "boa_string", @@ -1183,9 +1310,9 @@ dependencies = [ [[package]] name = "boa_interner" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9626505d33dc63d349662437297df1d3afd9d5fc4a2b3ad34e5e1ce879a78848" +checksum = "20510b8b02bcde9b0a01cf34c0c308c56156503d1d91cdab4c8cfbd292b747ea" dependencies = [ "boa_gc", "boa_macros", @@ -1193,31 +1320,31 @@ dependencies = [ "indexmap 2.14.0", "once_cell", "phf 0.13.1", - "rustc-hash 2.1.1", + "rustc-hash 2.1.3", "static_assertions", ] [[package]] name = "boa_macros" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f36418a46544b152632c141b0a0b7a453cd69ca150caeef83aee9e2f4b48b7d" +checksum = "5822cb4f146d243060e588bc5a5f2e709683fdad3d7111f42c48e6b5c921d23d" dependencies = [ "cfg-if", "cow-utils", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", "synstructure", ] [[package]] name = "boa_parser" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02f99bf5b684f0de946378fcfe5f38c3a0fbd51cbf83a0f39ff773a0e218541f" +checksum = "35bd957fa9fa93e3a001a8aba5a5cd40c2bbfde486378be4c4b472fd304aaddb" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "boa_ast", "boa_interner", "boa_macros", @@ -1226,28 +1353,28 @@ dependencies = [ "num-bigint", "num-traits", "regress", - "rustc-hash 2.1.1", + "rustc-hash 2.1.3", ] [[package]] name = "boa_string" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45ce9d7aa5563a2e14eab111e2ae1a06a69a812f6c0c3d843196c9d03fbef440" +checksum = "ca2da1d7f4a76fd9040788a122f0d807910800a7b86f5952e9244848c36511de" dependencies = [ "fast-float2", "itoa", "paste", - "rustc-hash 2.1.1", + "rustc-hash 2.1.3", "ryu-js", "static_assertions", ] [[package]] name = "bon" -version = "3.8.1" +version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebeb9aaf9329dff6ceb65c689ca3db33dbf15f324909c60e4e5eef5701ce31b1" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" dependencies = [ "bon-macros", "rustversion", @@ -1255,47 +1382,48 @@ dependencies = [ [[package]] name = "bon-macros" -version = "3.8.1" +version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "ident_case", "prettyplease", "proc-macro2", "quote", "rustversion", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "borsh" -version = "1.6.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" dependencies = [ "borsh-derive", - "cfg_aliases 0.2.1", + "bytes", + "cfg_aliases 0.2.2", ] [[package]] name = "borsh-derive" -version = "1.6.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" dependencies = [ "once_cell", "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1304,14 +1432,23 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bson" version = "2.15.0" @@ -1321,13 +1458,13 @@ dependencies = [ "ahash 0.8.12", "base64 0.22.1", "bitvec", - "getrandom 0.2.16", + "getrandom 0.2.17", "getrandom 0.3.4", "hex", "indexmap 2.14.0", "js-sys", "once_cell", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_bytes", "serde_json", @@ -1337,12 +1474,12 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", - "serde", + "serde_core", ] [[package]] @@ -1393,22 +1530,22 @@ checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.10.2" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -1419,9 +1556,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bzip2-sys" @@ -1443,7 +1580,7 @@ dependencies = [ "candle-kernels", "candle-metal-kernels", "candle-ug", - "cudarc 0.19.7", + "cudarc 0.19.8", "float8", "gemm 0.19.0", "half", @@ -1453,13 +1590,13 @@ dependencies = [ "num_cpus", "objc2-foundation", "objc2-metal", - "rand 0.9.4", + "rand 0.9.5", "rand_distr 0.5.1", "rayon", "safetensors 0.7.0", "thiserror 2.0.18", "tokenizers", - "yoke 0.8.1", + "yoke 0.8.3", "zip 7.2.0", ] @@ -1610,14 +1747,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.48" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -1664,15 +1801,15 @@ checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1681,9 +1818,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1753,9 +1890,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.53" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" dependencies = [ "clap_builder", "clap_derive", @@ -1763,9 +1900,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -1776,21 +1913,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clipboard-win" @@ -1803,18 +1940,18 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.54" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] [[package]] name = "cmov" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "cobs" @@ -1827,9 +1964,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" @@ -1847,9 +1984,9 @@ dependencies = [ [[package]] name = "comfy-table" -version = "7.2.1" +version = "7.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" dependencies = [ "crossterm", "unicode-segmentation", @@ -1858,9 +1995,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -1882,9 +2019,9 @@ dependencies = [ [[package]] name = "config" -version = "0.15.19" +version = "0.15.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b30fa8254caad766fc03cb0ccae691e14bf3bd72bfff27f72802ce729551b3d6" +checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139" dependencies = [ "async-trait", "convert_case", @@ -1895,8 +2032,8 @@ dependencies = [ "serde-untagged", "serde_core", "serde_json", - "toml 0.9.8", - "winnow 0.7.14", + "toml 1.1.3+spec-1.1.0", + "winnow 1.0.4", "yaml-rust2", ] @@ -1919,6 +2056,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -1934,7 +2077,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "once_cell", "tiny-keccak", ] @@ -1985,15 +2128,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - [[package]] name = "cow-utils" version = "0.1.3" @@ -2029,27 +2163,27 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.132.0" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c80cf55a351448317210f26c434be761bcb25e7b36116ec92f89540b73e2833" +checksum = "3d521bdbc6098937af83ef4ab6d5c07398126bc71878f7ef4ea9499977978ef5" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.132.0" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07937ca8617b340162fe3a4716be885b5847e9b56d6c7a89abbe4d42340fdc91" +checksum = "3dde0b83164d4a497860af4236271178bc640512b067101e0853e4f74eaa4df5" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.132.0" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88217b08180882436d54c0133274885c590698ae854e352bede1cda041230800" +checksum = "0111d110b72b4efad69a372e29e21628652fd0bcab66967e5c8350ab679affd5" dependencies = [ "cranelift-entity", "wasmtime-internal-core", @@ -2057,9 +2191,9 @@ dependencies = [ [[package]] name = "cranelift-bitset" -version = "0.132.0" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5c3cf7ba29fa56e56040848e34835d4e45988b2760ef212413409af95ffd8c1" +checksum = "cf01ecc92fc5499789d79c3b817299d5a8ddd828d31dcf0f9cc3cc66f38dbb36" dependencies = [ "serde", "serde_derive", @@ -2068,9 +2202,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.132.0" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe1aac2efd4cba2047845fce38a68519935a30e20c8a6294ba7e2f448fe722d" +checksum = "6cd2563bead0090c3879a7ff7327f7550c9d59bb5d05ecc8cd7886977aa3125a" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -2087,7 +2221,7 @@ dependencies = [ "log", "pulley-interpreter", "regalloc2", - "rustc-hash 2.1.1", + "rustc-hash 2.1.3", "serde", "smallvec", "target-lexicon", @@ -2096,9 +2230,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.132.0" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0909eaf9d6f18f5bf802d50608cb4368ac340fbd03cc44f2888d1cfcc3faa64e" +checksum = "9640d250d26f9381a73dc7f9862b27928d4809119aec73be0e556612e67b6a99" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -2109,24 +2243,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.132.0" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c95a8da8be283f49cda7d0ef228c94f10d791e517b27b0c7e282dadd2e79ce45" +checksum = "a07f156b90efc94371ddb3536f76e4ab671ad2e093bfa5511e10198cadbb0c47" [[package]] name = "cranelift-control" -version = "0.132.0" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5b19c81145146da1f7afda2e7f52111842fe6793512e740ad5cf3f5639e6212" +checksum = "d75a76fd9dd37dcbc3d2d2e00abe3281a7e88adc035fba3b8114cc69981576ec" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.132.0" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a55309b47e6633ab05821304206cb1e92952e845b1224985562bb7ac1e92323" +checksum = "2eea22522144ba08c7e7ef94bfc25d474ef016c2771974b8ab1986734ac85965" dependencies = [ "cranelift-bitset", "serde", @@ -2136,9 +2270,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.132.0" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "064d2d3533d9608f1cf44c8899cf2f7f33feb70300b0fb83e687b0d9e7b91147" +checksum = "efa2826c80dff1d93b19b3cfbcaf9fae44c78d739a98d146dd5bd89c51e14367" dependencies = [ "cranelift-codegen", "log", @@ -2148,15 +2282,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.132.0" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac4e0bc095b2dab2212d1e99d7a74b62afc1485db023f1c0cb34a68758f7bd1" +checksum = "e238a69b95c5415456f22189494a12db94f313bb72b6ec9cc88ddf2f1056e28e" [[package]] name = "cranelift-native" -version = "0.132.0" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a40053f5cb925451dd1d57393d14ad3145c8e0786701c27b5415ebb9a3ba4f" +checksum = "eceb0ebd8d6aef6bb287e8d532a164b0db1c0062961211e9c22a91f67521c098" dependencies = [ "cranelift-codegen", "libc", @@ -2165,9 +2299,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.132.0" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3ceab9a53f7d362c89841fbaa8e63e44d47c40e91dc96ee6f777fca5d6b323b" +checksum = "004643f39a7bec553de5263d650db30e5b9caec1d5cbe065fd732b7ec9ae40d0" [[package]] name = "crc" @@ -2180,9 +2314,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32c" @@ -2298,18 +2432,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -2317,27 +2451,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crossterm" @@ -2345,7 +2479,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "crossterm_winapi", "document-features", "parking_lot", @@ -2419,6 +2553,16 @@ dependencies = [ "dtor", ] +[[package]] +name = "ctor" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a394189d59f9befacce833f337f7b1eca5e9a91221bcdd4d28e0114d96e597b3" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + [[package]] name = "ctor-proc-macro" version = "0.0.7" @@ -2495,7 +2639,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.111", + "syn 2.0.119", "synthez", ] @@ -2515,9 +2659,9 @@ dependencies = [ [[package]] name = "cudaforge" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f7a0d45b139b5beeeb1c34188717e12241c44a0120afb498815ce7f5373c691" +checksum = "8d475ff95b9e4096878f47fe0ca5dbad0e23849a79fc225305ea06c2f25771cd" dependencies = [ "anyhow", "fs2", @@ -2553,9 +2697,9 @@ dependencies = [ [[package]] name = "cudarc" -version = "0.19.7" +version = "0.19.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cea5f10a99e025c1b44ae2354c2d8326b25ddbd0baf76bde8e55cfd4018a2cc" +checksum = "42310153e06cf4cd532901f7096beb27504d681736a29ee90728ae4e2d93b2a8" dependencies = [ "float8", "half", @@ -2584,12 +2728,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -2617,21 +2761,20 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "darling_core" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2653,25 +2796,25 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "darling_macro" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.21.3", + "darling_core 0.23.0", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "dary_heap" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" dependencies = [ "serde", ] @@ -2691,9 +2834,9 @@ dependencies = [ [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -2705,9 +2848,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "debugid" @@ -2718,24 +2861,54 @@ dependencies = [ "uuid", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "der" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -2747,7 +2920,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2789,7 +2962,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2809,7 +2982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core 0.20.2", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2820,7 +2993,7 @@ checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2830,7 +3003,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -2841,7 +3014,8 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -2890,30 +3064,30 @@ dependencies = [ [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "objc2", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "dissimilar" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8975ffdaa0ef3661bfe02dbdcc06c9f829dfafe6a3c474de366a8d5e44276921" +checksum = "aeda16ab4059c5fd2a83f2b9c9e9c981327b18aa8e3b313f7e6563799d4f093e" [[package]] name = "dlv-list" @@ -3017,7 +3191,7 @@ checksum = "1ec431cd708430d5029356535259c5d645d60edd3d39c54e5eea9782d46caa7d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3029,14 +3203,14 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" dependencies = [ "serde", ] @@ -3083,7 +3257,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3108,29 +3282,29 @@ dependencies = [ [[package]] name = "enum-ordinalize" -version = "4.3.2" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" dependencies = [ "enum-ordinalize-derive", ] [[package]] name = "enum-ordinalize-derive" -version = "4.3.2" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "env_filter" -version = "0.1.4" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ "log", "regex", @@ -3144,9 +3318,9 @@ checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", @@ -3182,7 +3356,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3193,9 +3367,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "erased-serde" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" dependencies = [ "serde", "serde_core", @@ -3209,7 +3383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3230,7 +3404,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ed900ba953ca6bf1fadb75e0c6b73d8463b9e2bb6bdb7b4573e8e7295852fbe" dependencies = [ - "http 1.4.0", + "http 1.4.2", "prost", "tokio", "tokio-stream", @@ -3238,7 +3412,7 @@ dependencies = [ "tonic-build", "tonic-prost", "tonic-prost-build", - "tower 0.5.2", + "tower 0.5.3", "tower-service", ] @@ -3273,6 +3447,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "evmap" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8" +dependencies = [ + "hashbag", + "left-right", + "smallvec", +] + [[package]] name = "expect-test" version = "1.5.1" @@ -3330,9 +3515,9 @@ checksum = "2d7e9bc68be4cdabbb8938140b01a8b5bc1191937f2c7e7ecc2fcebbe2d749df" [[package]] name = "fastnum" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4089ab2dfd45d8ddc92febb5ca80644389d5ebb954f40231274a3f18341762e2" +checksum = "020d1b59a944bc239d79903fbac2eda2365138b44890c27979562f6592059dcd" dependencies = [ "bnum", "num-integer", @@ -3342,9 +3527,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fd-lock" @@ -3359,9 +3544,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixedbitset" @@ -3377,24 +3562,24 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flatbuffers" -version = "25.9.23" +version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b6620799e7340ebd9968d2e0708eb82cf1971e9a16821e2091b6d6e475eed5" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "rustc_version 0.4.1", ] [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", - "libz-rs-sys", "libz-sys", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -3415,7 +3600,7 @@ checksum = "c2d1f04709a8ac06e8e8042875a3c466cc4832d3c1a18dbcb9dba3c6e83046bc" dependencies = [ "half", "num-traits", - "rand 0.9.4", + "rand 0.9.5", "rand_distr 0.5.1", ] @@ -3475,7 +3660,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3501,9 +3686,12 @@ dependencies = [ [[package]] name = "fragile" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" +checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" +dependencies = [ + "futures-core", +] [[package]] name = "fs-set-times" @@ -3559,9 +3747,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -3650,7 +3838,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3697,9 +3885,9 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "debugid", - "rustc-hash 2.1.1", + "rustc-hash 2.1.3", "serde", "serde_derive", "serde_json", @@ -3840,7 +4028,7 @@ dependencies = [ "num-traits", "once_cell", "paste", - "pulp 0.22.2", + "pulp 0.22.3", "raw-cpuid", "rayon", "seq-macro", @@ -3944,8 +4132,23 @@ dependencies = [ ] [[package]] -name = "generic-array" -version = "0.14.7" +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link 0.2.1", + "windows-result 0.4.1", +] + +[[package]] +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ @@ -3955,9 +4158,9 @@ dependencies = [ [[package]] name = "geo-types" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24f8647af4005fa11da47cd56252c6ef030be8fa97bdbf355e7dfb6348f0a82c" +checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" dependencies = [ "approx", "num-traits", @@ -3966,9 +4169,9 @@ dependencies = [ [[package]] name = "geohash" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fb94b1a65401d6cbf22958a9040aa364812c26674f841bee538b12c135db1e6" +checksum = "7f58890382f70caccc5fa388981f7ac80c913795042afce9f3e065695d8f7464" dependencies = [ "geo-types", "libm", @@ -3997,9 +4200,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -4024,16 +4227,16 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", + "wasm-bindgen", ] [[package]] @@ -4057,7 +4260,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.111", + "syn 2.0.119", "textwrap", "thiserror 1.0.69", "typed-builder 0.15.2", @@ -4167,9 +4370,21 @@ checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" [[package]] name = "glam" -version = "0.30.9" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" + +[[package]] +name = "glam" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd47b05dddf0005d850e5644cae7f2b14ac3df487979dbfff3b56f20b1a6ae46" +checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7" + +[[package]] +name = "glam" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" [[package]] name = "glob" @@ -4179,15 +4394,15 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "globset" -version = "0.4.18" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" dependencies = [ "aho-corasick", "bstr", "log", "regex-automata", - "regex-syntax 0.8.8", + "regex-syntax 0.8.11", ] [[package]] @@ -4196,7 +4411,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "ignore", "walkdir", ] @@ -4234,16 +4449,16 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.12" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.0", + "http 1.4.2", "indexmap 2.14.0", "slab", "tokio", @@ -4261,11 +4476,17 @@ dependencies = [ "cfg-if", "crunchy", "num-traits", - "rand 0.9.4", + "rand 0.9.5", "rand_distr 0.5.1", "zerocopy", ] +[[package]] +name = "hashbag" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064" + [[package]] name = "hashbrown" version = "0.12.3" @@ -4318,18 +4539,9 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - -[[package]] -name = "hashlink" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ "hashbrown 0.16.1", ] @@ -4343,10 +4555,10 @@ dependencies = [ "base64 0.22.1", "bytes", "headers-core", - "http 1.4.0", + "http 1.4.2", "httpdate", "mime", - "sha1 0.10.6", + "sha1 0.10.7", ] [[package]] @@ -4355,7 +4567,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http 1.4.0", + "http 1.4.2", ] [[package]] @@ -4448,9 +4660,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -4469,24 +4681,24 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", - "http 1.4.0", + "http 1.4.2", ] [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body 1.1.0", "pin-project-lite", ] @@ -4504,15 +4716,15 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] @@ -4543,22 +4755,21 @@ dependencies = [ [[package]] name = "hyper" -version = "1.8.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "h2 0.4.12", - "http 1.4.0", - "http-body 1.0.1", + "h2 0.4.15", + "http 1.4.2", + "http-body 1.1.0", "httparse", "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -4566,19 +4777,18 @@ dependencies = [ [[package]] name = "hyper-http-proxy" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ad4b0a1e37510028bc4ba81d0e38d239c39671b0f0ce9e02dfa93a8133f7c08" +checksum = "8021e0ae20c08eadc94d0bdafdeda66d4f0858541c146ae6e46b219bfe58497e" dependencies = [ "bytes", "futures-util", "headers", - "http 1.4.0", - "hyper 1.8.1", + "http 1.4.2", + "hyper 1.10.1", "hyper-rustls", "hyper-util", "pin-project-lite", - "rustls-native-certs 0.7.3", "tokio", "tokio-rustls", "tower-service", @@ -4590,8 +4800,8 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527d4d619ca2c2aafa31ec139a3d1d60bf557bf7578a1f20f743637eccd9ca19" dependencies = [ - "http 1.4.0", - "hyper 1.8.1", + "http 1.4.2", + "hyper 1.10.1", "hyper-util", "linked_hash_set", "once_cell", @@ -4605,17 +4815,16 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.0", - "hyper 1.8.1", + "http 1.4.2", + "hyper 1.10.1", "hyper-util", "log", "rustls", - "rustls-native-certs 0.8.2", - "rustls-pki-types", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -4627,7 +4836,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper 1.8.1", + "hyper 1.10.1", "hyper-util", "pin-project-lite", "tokio", @@ -4642,7 +4851,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.8.1", + "hyper 1.10.1", "hyper-util", "native-tls", "tokio", @@ -4652,24 +4861,23 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "hyper 1.8.1", + "http 1.4.2", + "http-body 1.1.0", + "hyper 1.10.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", - "system-configuration", + "socket2 0.6.5", + "system-configuration 0.7.0", "tokio", "tower-service", "tracing", @@ -4678,9 +4886,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -4709,14 +4917,14 @@ dependencies = [ "anyhow", "apache-avro 0.21.0", "array-init", - "arrow-arith 57.1.0", - "arrow-array 57.1.0", - "arrow-buffer 57.1.0", - "arrow-cast 57.1.0", - "arrow-ord 57.1.0", - "arrow-schema 57.1.0", - "arrow-select 57.1.0", - "arrow-string 57.1.0", + "arrow-arith 57.3.1", + "arrow-array 57.3.1", + "arrow-buffer 57.3.1", + "arrow-cast 57.3.1", + "arrow-ord 57.3.1", + "arrow-schema 57.3.1", + "arrow-select 57.3.1", + "arrow-string 57.3.1", "as-any", "async-trait", "backon", @@ -4735,9 +4943,9 @@ dependencies = [ "murmur3", "once_cell", "ordered-float 4.6.0", - "parquet 57.1.0", - "rand 0.9.4", - "reqwest 0.12.24", + "parquet 57.3.1", + "rand 0.9.5", + "reqwest 0.12.28", "roaring", "serde", "serde_bytes", @@ -4762,10 +4970,10 @@ checksum = "a49dfef578060c3a2a3f619522dcb25382a7ce85336dcb500495d4b8d72ae714" dependencies = [ "async-trait", "chrono", - "http 1.4.0", + "http 1.4.2", "iceberg", "itertools 0.13.0", - "reqwest 0.12.24", + "reqwest 0.12.28", "serde", "serde_derive", "serde_json", @@ -4783,14 +4991,14 @@ checksum = "b968ba0c30b388b27813133f2428f4eca950f25770477865062a31a1b50c39f4" dependencies = [ "anyhow", "apache-avro 0.17.0", - "arrow-arith 58.3.0", - "arrow-array 58.3.0", - "arrow-buffer 58.3.0", - "arrow-cast 58.3.0", - "arrow-ord 58.3.0", - "arrow-row", - "arrow-schema 58.3.0", - "arrow-select 58.3.0", + "arrow-arith 59.1.0", + "arrow-array 59.1.0", + "arrow-buffer 59.1.0", + "arrow-cast 59.1.0", + "arrow-ord 59.1.0", + "arrow-row 59.1.0", + "arrow-schema 59.1.0", + "arrow-select 59.1.0", "async-trait", "bitvec", "bytes", @@ -4805,9 +5013,9 @@ dependencies = [ "log", "murmur3", "once_cell", - "opendal", + "opendal 0.57.0", "ordered-float 3.9.2", - "parquet 58.3.0", + "parquet 59.1.0", "regex", "reqwest 0.11.27", "rust_decimal", @@ -4830,16 +5038,16 @@ checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" dependencies = [ "displaydoc", "potential_utf", - "yoke 0.8.1", + "yoke 0.8.3", "zerofrom", "zerovec", ] [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -4896,16 +5104,16 @@ checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", "serde", "stable_deref_trait", "writeable", - "yoke 0.8.1", + "yoke 0.8.3", "zerofrom", "zerotrie", "zerovec", @@ -4946,9 +5154,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.25" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +checksum = "d4ffa3a0547a138e59ddd6fa3b7c672ed47e6ad6a3cd177984ff1116aa5ba742" dependencies = [ "crossbeam-deque", "globset", @@ -4962,36 +5170,32 @@ dependencies = [ [[package]] name = "include-flate" -version = "0.3.1" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01b7cb6ca682a621e7cda1c358c9724b53a7b4409be9be1dd443b7f3a26f998" +checksum = "48f173716febb1ad596c16ea5637b5f1790ea32de8e627493ff82bc73b0876ce" dependencies = [ "include-flate-codegen", "include-flate-compress", - "libflate", - "zstd", ] [[package]] name = "include-flate-codegen" -version = "0.3.1" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f49bf5274aebe468d6e6eba14a977eaf1efa481dc173f361020de70c1c48050" +checksum = "4a7875b62a72ad3f3203cdd8950d4cf9947db036030b974b8b37ceae90c8d8c0" dependencies = [ "include-flate-compress", - "libflate", - "proc-macro-error", + "proc-macro-error3", "proc-macro2", "quote", - "syn 2.0.111", - "zstd", + "syn 2.0.119", ] [[package]] name = "include-flate-compress" -version = "0.3.1" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae6a40e716bcd5931f5dbb79cd921512a4f647e2e9413fded3171fca3824dbc" +checksum = "44fbb9c5ccb9a5b67b4afa2974c27e5507ea1bf6d22828cef418e4dfaeca51dd" dependencies = [ "libflate", "zstd", @@ -5074,9 +5278,9 @@ dependencies = [ [[package]] name = "inventory" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" dependencies = [ "rustversion", ] @@ -5099,9 +5303,9 @@ checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is-terminal" @@ -5111,7 +5315,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5167,9 +5371,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "ittapi" @@ -5217,10 +5421,11 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.25" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6835eea34fb6321b9b3aa7b685c2b433948c09447e389dc017fdf687d5d11e65" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" dependencies = [ + "defmt", "jiff-static", "jiff-tzdb-platform", "js-sys", @@ -5229,25 +5434,25 @@ dependencies = [ "portable-atomic-util", "serde_core", "wasm-bindgen", - "windows-sys 0.60.2", + "windows-link 0.2.1", ] [[package]] name = "jiff-static" -version = "0.2.25" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c22e04db9c58f5136eb1757f3d5c49a7b187f49e52185228cbd2f5acdfcc08c" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "jiff-tzdb" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" [[package]] name = "jiff-tzdb-platform" @@ -5267,7 +5472,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys 0.3.0", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", @@ -5301,14 +5506,17 @@ dependencies = [ "quote", "rustc_version 0.4.1", "simd_cesu8", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] [[package]] name = "jni-sys" @@ -5326,41 +5534,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] [[package]] name = "json-patch" -version = "4.1.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f300e415e2134745ef75f04562dd0145405c2f7fd92065db029ac4b16b57fe90" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" dependencies = [ "jsonptr", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] @@ -5397,24 +5604,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "jsonwebtoken" -version = "10.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" -dependencies = [ - "aws-lc-rs", - "base64 0.22.1", - "getrandom 0.2.16", - "js-sys", - "pem", - "serde", - "serde_json", - "signature", - "simple_asn1", - "zeroize", -] - [[package]] name = "k8s-openapi" version = "0.24.0" @@ -5462,10 +5651,10 @@ dependencies = [ "either", "futures", "home", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body 1.1.0", "http-body-util", - "hyper 1.8.1", + "hyper 1.10.1", "hyper-http-proxy", "hyper-openssl", "hyper-rustls", @@ -5485,7 +5674,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.26.2", "tokio-util", - "tower 0.5.2", + "tower 0.5.3", "tower-http", "tracing", ] @@ -5498,7 +5687,7 @@ checksum = "ff0d0793db58e70ca6d689489183816cb3aa481673e7433dc618cf7e8007c675" dependencies = [ "chrono", "form_urlencoded", - "http 1.4.0", + "http 1.4.2", "json-patch", "k8s-openapi", "schemars 0.8.22", @@ -5519,7 +5708,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -5552,9 +5741,9 @@ dependencies = [ [[package]] name = "lazy-regex" -version = "3.4.2" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "191898e17ddee19e60bccb3945aa02339e81edd4a8c50e21fd4d48cdecda7b29" +checksum = "6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496" dependencies = [ "lazy-regex-proc_macros", "once_cell", @@ -5563,14 +5752,14 @@ dependencies = [ [[package]] name = "lazy-regex-proc_macros" -version = "3.4.2" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c35dc8b0da83d1a9507e12122c80dea71a9c7c613014347392483a83ea593e04" +checksum = "4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358" dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -5590,9 +5779,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "leb128" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" [[package]] name = "leb128fmt" @@ -5600,6 +5789,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "left-right" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0c21e4c8ff95f487fb34e6f9182875f42c84cef966d29216bf115d9bba835a" +dependencies = [ + "crossbeam-utils", + "loom", + "slab", +] + [[package]] name = "levenshtein_automata" version = "0.2.1" @@ -5671,25 +5871,25 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libflate" -version = "2.2.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3248b8d211bd23a104a42d81b4fa8bb8ac4a3b75e7a43d85d2c9ccb6179cd74" +checksum = "cd96e993e5f3368b0cb8497dae6c860c22af8ff18388c61c6c0b86c58d86b5df" dependencies = [ "adler32", - "core2", "crc32fast", "dary_heap", "libflate_lz77", + "no_std_io2", ] [[package]] name = "libflate_lz77" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a599cb10a9cd92b1300debcef28da8f70b935ec937f44fcd1b70a7c986a11c5c" +checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd" dependencies = [ - "core2", "hashbrown 0.16.1", + "no_std_io2", "rle-decode-fast", ] @@ -5721,13 +5921,11 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ - "bitflags 2.10.0", "libc", - "redox_syscall", ] [[package]] @@ -5746,35 +5944,32 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", "pkg-config", "vcpkg", ] -[[package]] -name = "libz-rs-sys" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" -dependencies = [ - "zlib-rs", -] - [[package]] name = "libz-sys" -version = "1.1.23" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" dependencies = [ "cc", "pkg-config", "vcpkg", ] +[[package]] +name = "link-section" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e333fe507b738576d6da5bb3f1a7d7a1c80307ed9ef31624c057d844c19c93e9" + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -5790,6 +5985,12 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "linktime-proc-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -5804,9 +6005,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "litrs" @@ -5825,9 +6026,22 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] [[package]] name = "lru" @@ -5943,6 +6157,29 @@ dependencies = [ "libc", ] +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + [[package]] name = "matchers" version = "0.2.0" @@ -5966,9 +6203,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "matrixmultiply" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" dependencies = [ "autocfg", "rawpointer", @@ -6002,15 +6239,15 @@ dependencies = [ [[package]] name = "md5" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] name = "mea" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6747f54621d156e1b47eb6b25f39a941b9fc347f98f67d25d8881ff99e8ed832" +checksum = "2640d335e7273dacdcf51044026139b2e269c3bb0dfc3f8cb3496b85e3f6a42c" dependencies = [ "slab", ] @@ -6027,9 +6264,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memfd" @@ -6042,9 +6279,9 @@ dependencies = [ [[package]] name = "memmap2" -version = "0.9.9" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", "stable_deref_trait", @@ -6065,7 +6302,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block", "core-graphics-types", "foreign-types 0.5.0", @@ -6076,23 +6313,24 @@ dependencies = [ [[package]] name = "metrics" -version = "0.24.3" +version = "0.24.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" dependencies = [ - "ahash 0.8.12", "portable-atomic", + "rapidhash", ] [[package]] name = "metrics-exporter-prometheus" -version = "0.18.0" +version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bac37bd902eaf3f9028708c4fbeed677e738cb2b34c2da8524c4731ebeb301e" +checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" dependencies = [ "base64 0.22.1", + "evmap", "http-body-util", - "hyper 1.8.1", + "hyper 1.10.1", "hyper-rustls", "hyper-util", "indexmap 2.14.0", @@ -6108,18 +6346,19 @@ dependencies = [ [[package]] name = "metrics-util" -version = "0.20.1" +version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdfb1365fea27e6dd9dc1dbc19f570198bc86914533ad639dae939635f096be4" +checksum = "96f8722f8562635f92f8ed992f26df0532266eb03d5202607c20c0d7e9745e13" dependencies = [ "crossbeam-epoch", "crossbeam-utils", "hashbrown 0.16.1", "metrics", "quanta", - "rand 0.9.4", + "rand 0.9.5", "rand_xoshiro", - "sketches-ddsketch 0.3.0", + "rapidhash", + "sketches-ddsketch 0.3.1", ] [[package]] @@ -6156,9 +6395,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -6178,7 +6417,7 @@ dependencies = [ "mlua-sys", "num-traits", "parking_lot", - "rustc-hash 2.1.1", + "rustc-hash 2.1.3", "rustversion", "serde", "serde-value", @@ -6220,14 +6459,14 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "moka" -version = "0.12.11" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" dependencies = [ "async-lock", "crossbeam-channel", @@ -6238,7 +6477,6 @@ dependencies = [ "futures-util", "parking_lot", "portable-atomic", - "rustc_version 0.4.1", "smallvec", "tagptr", "uuid", @@ -6263,7 +6501,7 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -6286,18 +6524,18 @@ checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" [[package]] name = "mysql-common-derive" -version = "0.32.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66f62cad7623a9cb6f8f64037f0c4f69c8db8e82914334a83c9788201c2c1bfa" +checksum = "a4db8a44120571277accfaa3f3d91e7d3989d601d817c2fc01a9391b86135666" dependencies = [ - "darling 0.20.11", + "darling 0.23.0", "heck 0.5.0", + "manyhow", "num-bigint", "proc-macro-crate", - "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", "termcolor", "thiserror 2.0.18", ] @@ -6319,7 +6557,7 @@ dependencies = [ "mysql_common", "pem", "percent-encoding", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_json", "socket2 0.5.10", @@ -6337,7 +6575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbb9f371618ce723f095c61fbcdc36e8936956d2b62832f9c7648689b338e052" dependencies = [ "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.13.1", "btoi", "byteorder", "bytes", @@ -6351,7 +6589,7 @@ dependencies = [ "saturating", "serde", "serde_json", - "sha1 0.10.6", + "sha1 0.10.7", "sha2 0.10.9", "thiserror 2.0.18", "uuid", @@ -6359,16 +6597,16 @@ dependencies = [ [[package]] name = "nalgebra" -version = "0.33.2" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26aecdf64b707efd1310e3544d709c5c0ac61c13756046aaaba41be5c4f66a3b" +checksum = "9d43ddcacf343185dfd6de2ee786d9e8b1c2301622afab66b6c73baf9882abfd" dependencies = [ "approx", "matrixmultiply", "num-complex", "num-rational", "num-traits", - "rand 0.8.5", + "rand 0.8.7", "rand_distr 0.4.3", "simba", "typenum", @@ -6376,9 +6614,9 @@ dependencies = [ [[package]] name = "nalgebra" -version = "0.34.1" +version = "0.34.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4d5b3eff5cd580f93da45e64715e8c20a3996342f1e466599cf7a267a0c2f5f" +checksum = "df76ea0ff5c7e6b88689085804d6132ded0ddb9de5ca5b8aeb9eeadc0508a70a" dependencies = [ "approx", "glam 0.14.0", @@ -6396,7 +6634,9 @@ dependencies = [ "glam 0.27.0", "glam 0.28.0", "glam 0.29.3", - "glam 0.30.9", + "glam 0.30.10", + "glam 0.31.1", + "glam 0.32.1", "matrixmultiply", "nalgebra-macros", "num-complex", @@ -6414,14 +6654,14 @@ checksum = "973e7178a678cfd059ccec50887658d482ce16b0aa9da3888ddeab5cd5eb4889" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", @@ -6429,7 +6669,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework", "security-framework-sys", "tempfile", ] @@ -6463,12 +6703,21 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases 0.1.1", "libc", ] +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "nom" version = "7.1.3" @@ -6492,9 +6741,9 @@ dependencies = [ [[package]] name = "ntapi" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" dependencies = [ "winapi", ] @@ -6505,7 +6754,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6524,9 +6773,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -6544,7 +6793,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -6573,7 +6822,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -6587,11 +6836,10 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -6629,9 +6877,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -6639,14 +6887,14 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -6669,9 +6917,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -6682,7 +6930,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "dispatch2", "objc2", ] @@ -6699,7 +6947,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -6722,7 +6970,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block2", "dispatch2", "objc2", @@ -6736,13 +6984,22 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", "objc2-foundation", "objc2-metal", ] +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.39.1" @@ -6757,9 +7014,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -6775,11 +7032,11 @@ checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" [[package]] name = "onig" -version = "6.5.1" +version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "libc", "once_cell", "onig_sys", @@ -6787,9 +7044,9 @@ dependencies = [ [[package]] name = "onig_sys" -version = "69.9.1" +version = "69.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" dependencies = [ "cc", "pkg-config", @@ -6813,17 +7070,31 @@ version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b31d3d8e99a85d83b73ec26647f5607b80578ed9375810b6e44ffa3590a236" dependencies = [ - "ctor", - "opendal-core", - "opendal-layer-concurrent-limit", - "opendal-layer-logging", - "opendal-layer-prometheus", - "opendal-layer-retry", - "opendal-layer-timeout", + "ctor 0.6.3", + "opendal-core 0.56.0", + "opendal-layer-concurrent-limit 0.56.0", + "opendal-layer-logging 0.56.0", + "opendal-layer-retry 0.56.0", + "opendal-layer-timeout 0.56.0", "opendal-service-azblob", "opendal-service-s3", ] +[[package]] +name = "opendal" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c9c85ce253ff87225e7669979d877a20c98a06604ec9d6dd5f4473e08f1ae1" +dependencies = [ + "ctor 1.0.9", + "opendal-core 0.57.0", + "opendal-layer-concurrent-limit 0.57.0", + "opendal-layer-logging 0.57.0", + "opendal-layer-prometheus", + "opendal-layer-retry 0.57.0", + "opendal-layer-timeout 0.57.0", +] + [[package]] name = "opendal-core" version = "0.56.0" @@ -6834,8 +7105,8 @@ dependencies = [ "base64 0.22.1", "bytes", "futures", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body 1.1.0", "jiff", "log", "md-5 0.10.6", @@ -6843,7 +7114,35 @@ dependencies = [ "percent-encoding", "quick-xml 0.38.4", "reqsign-core", - "reqwest 0.13.3", + "reqwest 0.13.4", + "serde", + "serde_json", + "tokio", + "url", + "uuid", + "web-time", +] + +[[package]] +name = "opendal-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4f8607c90e2c963a91467f50fb49fbc7fb3d573f88cea219ca59ccd3740b309" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bytes", + "futures", + "http 1.4.2", + "http-body 1.1.0", + "jiff", + "log", + "md-5 0.11.0", + "mea", + "percent-encoding", + "quick-xml 0.39.4", + "reqsign-core", + "reqwest 0.13.4", "serde", "serde_json", "tokio", @@ -6859,9 +7158,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "048b1b29c503263bdd80a9afe46a68cd02ea9bd361185b1feab4b151078998e9" dependencies = [ "futures", - "http 1.4.0", + "http 1.4.2", "mea", - "opendal-core", + "opendal-core 0.56.0", +] + +[[package]] +name = "opendal-layer-concurrent-limit" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6f81ba6960e3fae1882f253b114b21d7e444e1534f209c7737a79f6243eb6f" +dependencies = [ + "futures", + "http 1.4.2", + "mea", + "opendal-core 0.57.0", ] [[package]] @@ -6871,27 +7182,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2645adc988b12eda106e2679ae529facfbbaa868ceb706f6f8125c6af15c47b" dependencies = [ "log", - "opendal-core", + "opendal-core 0.56.0", +] + +[[package]] +name = "opendal-layer-logging" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58ada45c6d81d1aa4c9305d0c7d4bc317c59c85866a0908a2d75a7a978aa5ee2" +dependencies = [ + "log", + "opendal-core 0.57.0", ] [[package]] name = "opendal-layer-observe-metrics-common" -version = "0.56.0" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9130f0ac11569edc0f70b0e64078b9a12e37a128849d27ea62b0ca7568e8eb97" +checksum = "628b0228fdbd13c3d9d50eee4341f2eb82ca5b44991e4c68f07c84cc823e2d12" dependencies = [ "futures", - "http 1.4.0", - "opendal-core", + "http 1.4.2", + "opendal-core 0.57.0", ] [[package]] name = "opendal-layer-prometheus" -version = "0.56.0" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eef98056f8198b5053005e1fbe7d163562f56d9f3be1b73a3792667e7cbaf7be" +checksum = "0487bdb1357097ec8654781bad03ef310282517738e2864ebde69e27aaafc5ec" dependencies = [ - "opendal-core", + "opendal-core 0.57.0", "opendal-layer-observe-metrics-common", "prometheus", ] @@ -6904,7 +7225,18 @@ checksum = "4eac134ffa4ddda6131a640a84a5315996424b9416c85052f8c64c1a33b70ad4" dependencies = [ "backon", "log", - "opendal-core", + "opendal-core 0.56.0", +] + +[[package]] +name = "opendal-layer-retry" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2a25a718afb81fad81cb9a0580a1cb989221fa2317f888c6a37f8dad408eb7" +dependencies = [ + "backon", + "log", + "opendal-core 0.57.0", ] [[package]] @@ -6913,7 +7245,17 @@ version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "619586ab7480c2e3009f6d18eabab18957bc094778fd130bcc38924970a90f4c" dependencies = [ - "opendal-core", + "opendal-core 0.56.0", + "tokio", +] + +[[package]] +name = "opendal-layer-timeout" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e91f731724c213af81e9d03517859c8fc47b4578e64ad61ae4f099f10fe36e3" +dependencies = [ + "opendal-core 0.57.0", "tokio", ] @@ -6925,9 +7267,9 @@ checksum = "7452bf3ec61cfd81ac9ad9ada17825931e9e371d44a045c6bfab9596c0a2ac3b" dependencies = [ "base64 0.22.1", "bytes", - "http 1.4.0", + "http 1.4.2", "log", - "opendal-core", + "opendal-core 0.56.0", "opendal-service-azure-common", "quick-xml 0.38.4", "reqsign-azure-storage", @@ -6944,8 +7286,8 @@ version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffb0e45d6c8dcf66ce2da20e241bcb80e6e540e109a4ff20f318f6c9b4c54e0c" dependencies = [ - "http 1.4.0", - "opendal-core", + "http 1.4.2", + "opendal-core 0.56.0", ] [[package]] @@ -6957,10 +7299,10 @@ dependencies = [ "base64 0.22.1", "bytes", "crc32c", - "http 1.4.0", + "http 1.4.2", "log", "md-5 0.10.6", - "opendal-core", + "opendal-core 0.56.0", "quick-xml 0.38.4", "reqsign-aws-v4", "reqsign-core", @@ -6971,15 +7313,14 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.75" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "foreign-types 0.3.2", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -6992,20 +7333,20 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -7025,13 +7366,13 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.7", + "axum 0.8.9", "chrono", "config", - "dashmap 6.1.0", + "dashmap 6.2.1", "envmnt", "futures", - "hyper 1.8.1", + "hyper 1.10.1", "libloading 0.9.0", "metrics", "once_cell", @@ -7042,11 +7383,11 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-cron-scheduler", "tokio-util", - "tower 0.5.2", + "tower 0.5.3", "tower-http", "tracing", "tracing-subscriber", @@ -7068,12 +7409,12 @@ dependencies = [ "orbit-server", "owo-colors", "redis", - "reqwest 0.12.24", + "reqwest 0.12.28", "rustyline", "serde", "serde_json", "syntect", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-postgres", "tracing", @@ -7086,7 +7427,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "dashmap 6.1.0", + "dashmap 6.2.1", "futures", "mockall", "once_cell", @@ -7095,7 +7436,7 @@ dependencies = [ "orbit-util", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-test", @@ -7109,12 +7450,12 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.7", + "axum 0.8.9", "bytes", "chrono", - "dashmap 6.1.0", + "dashmap 6.2.1", "futures", - "hyper 1.8.1", + "hyper 1.10.1", "jni 0.21.1", "once_cell", "orbit-client", @@ -7124,12 +7465,12 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-util", "tonic", - "tower 0.5.2", + "tower 0.5.3", "tower-http", "tracing", "uuid", @@ -7151,7 +7492,7 @@ dependencies = [ "memmap2", "metal", "metrics", - "nalgebra 0.34.1", + "nalgebra 0.34.2", "ndarray", "num-complex", "num-traits", @@ -7160,14 +7501,14 @@ dependencies = [ "orbit-shared", "orbit-util", "proptest", - "rand 0.9.4", + "rand 0.9.5", "raw-cpuid", "rayon", "serde", "serde_json", "sysinfo", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tracing", "vulkano", @@ -7189,7 +7530,7 @@ dependencies = [ "chrono", "criterion 0.7.0", "crossbeam", - "dashmap 6.1.0", + "dashmap 6.2.1", "fastrand", "futures", "iceberg", @@ -7197,21 +7538,21 @@ dependencies = [ "icelake", "md5", "metrics", - "opendal", + "opendal 0.56.0", "orbit-client", "orbit-compute", "orbit-shared", "parking_lot", "parquet 58.3.0", "prost", - "rand 0.9.4", + "rand 0.9.5", "regex", "rocksdb", "serde", "serde_json", "sqlx", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-test", "tokio-util", @@ -7239,7 +7580,7 @@ dependencies = [ "orbit-shared", "orbit-util", "proptest", - "rand 0.8.5", + "rand 0.8.7", "serde", "serde_json", "tokio", @@ -7264,7 +7605,7 @@ dependencies = [ "criterion 0.7.0", "crossbeam", "cucumber", - "dashmap 6.1.0", + "dashmap 6.2.1", "futures", "mockall", "ndarray", @@ -7274,7 +7615,7 @@ dependencies = [ "proptest", "prost-build", "pyo3", - "rand 0.9.4", + "rand 0.9.5", "rand_distr 0.5.1", "rayon", "rmp-serde", @@ -7282,7 +7623,7 @@ dependencies = [ "serde_json", "statrs", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-test", "tonic-build", @@ -7300,7 +7641,7 @@ dependencies = [ "clap", "futures", "http-body-util", - "hyper 1.8.1", + "hyper 1.10.1", "hyper-util", "k8s-openapi", "kube", @@ -7312,7 +7653,7 @@ dependencies = [ "serde_json", "serde_yaml_ng", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -7350,7 +7691,7 @@ dependencies = [ "async-recursion", "async-stream", "async-trait", - "axum 0.8.7", + "axum 0.8.9", "base64 0.22.1", "bcrypt", "bincode", @@ -7362,7 +7703,7 @@ dependencies = [ "clap", "comfy-table", "criterion 0.5.1", - "dashmap 6.1.0", + "dashmap 6.2.1", "env_logger", "fastbloom-rs", "flate2", @@ -7371,7 +7712,7 @@ dependencies = [ "hex", "hmac 0.12.1", "http-body-util", - "hyper 1.8.1", + "hyper 1.10.1", "hyper-util", "lazy_static", "lru 0.12.5", @@ -7395,10 +7736,10 @@ dependencies = [ "petgraph 0.8.3", "postgres-protocol", "postgres-types", - "rand 0.9.4", + "rand 0.9.5", "redis", "regex", - "reqwest 0.12.24", + "reqwest 0.12.28", "rmp-serde", "rocksdb", "rquickjs", @@ -7407,22 +7748,22 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", - "sha1 0.10.6", + "sha1 0.10.7", "sha2 0.10.9", "snap", "tantivy", "tantivy-jieba", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-rustls", "tokio-stream", "tokio-test", "tokio-util", - "toml 0.9.8", + "toml 0.9.12+spec-1.1.0", "tonic", "tonic-reflection", - "tower 0.5.2", + "tower 0.5.3", "tower-http", "tracing", "tracing-subscriber", @@ -7443,7 +7784,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "chrono", - "dashmap 6.1.0", + "dashmap 6.2.1", "etcd-client", "futures", "once_cell", @@ -7452,7 +7793,7 @@ dependencies = [ "orbit-util", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -7465,10 +7806,10 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.7", + "axum 0.8.9", "chrono", - "dashmap 6.1.0", - "hyper 1.8.1", + "dashmap 6.2.1", + "hyper 1.10.1", "metrics", "metrics-exporter-prometheus", "once_cell", @@ -7478,11 +7819,11 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-util", - "toml 0.9.8", - "tower 0.5.2", + "toml 0.9.12+spec-1.1.0", + "tower 0.5.3", "tower-http", "tracing", ] @@ -7500,18 +7841,18 @@ dependencies = [ "bytes", "chrono", "crc32fast", - "dashmap 6.1.0", + "dashmap 6.2.1", "fastrand", "futures", "hex", - "hyper 1.8.1", + "hyper 1.10.1", "md5", "metrics", "orbit-util", "paste", "prost", "prost-build", - "rand 0.9.4", + "rand 0.9.5", "rayon", "regex", "serde", @@ -7519,7 +7860,7 @@ dependencies = [ "sha2 0.10.9", "sqlx", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-util", @@ -7527,7 +7868,7 @@ dependencies = [ "tonic-build", "tonic-prost", "tonic-prost-build", - "tower 0.5.2", + "tower 0.5.3", "tower-lsp", "tracing", "tracing-subscriber", @@ -7541,12 +7882,12 @@ dependencies = [ "anyhow", "async-trait", "chrono", - "dashmap 6.1.0", + "dashmap 6.2.1", "metrics", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tracing", "tracing-subscriber", @@ -7578,7 +7919,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" dependencies = [ "num-traits", - "rand 0.8.5", + "rand 0.8.7", "serde", ] @@ -7603,9 +7944,9 @@ dependencies = [ [[package]] name = "owo-colors" -version = "4.2.3" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "parking" @@ -7638,18 +7979,18 @@ dependencies = [ [[package]] name = "parquet" -version = "57.1.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be3e4f6d320dd92bfa7d612e265d7d08bba0a240bab86af3425e1d255a511d89" +checksum = "2e832c6aa20310fc6de7ea5a3f4e20d34fd83e3b43229d32b81ffe5c14d74692" dependencies = [ "ahash 0.8.12", - "arrow-array 57.1.0", - "arrow-buffer 57.1.0", - "arrow-cast 57.1.0", - "arrow-data 57.1.0", - "arrow-ipc 57.1.0", - "arrow-schema 57.1.0", - "arrow-select 57.1.0", + "arrow-array 57.3.1", + "arrow-buffer 57.3.1", + "arrow-cast 57.3.1", + "arrow-data 57.3.1", + "arrow-ipc 57.3.1", + "arrow-schema 57.3.1", + "arrow-select 57.3.1", "base64 0.22.1", "brotli", "bytes", @@ -7679,12 +8020,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dafa7d01085b62a47dd0c1829550a0a36710ea9c4fe358a05a85477cec8a908" dependencies = [ "ahash 0.8.12", - "arrow-array 58.3.0", - "arrow-buffer 58.3.0", - "arrow-data 58.3.0", - "arrow-ipc 58.3.0", - "arrow-schema 58.3.0", - "arrow-select 58.3.0", + "bytes", + "chrono", + "half", + "hashbrown 0.17.1", + "num-bigint", + "num-integer", + "num-traits", + "paste", + "seq-macro", + "thrift", + "twox-hash", +] + +[[package]] +name = "parquet" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5302d4da74d6596a1f11f9928767995b53bca657cbeea1e4e8c5074f8a1157dd" +dependencies = [ + "ahash 0.8.12", + "arrow-array 59.1.0", + "arrow-buffer 59.1.0", + "arrow-data 59.1.0", + "arrow-ipc 59.1.0", + "arrow-schema 59.1.0", + "arrow-select 59.1.0", "base64 0.22.1", "brotli", "bytes", @@ -7701,7 +8062,6 @@ dependencies = [ "seq-macro", "simdutf8", "snap", - "thrift", "tokio", "twox-hash", "zstd", @@ -7796,9 +8156,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.4" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbcfd20a6d4eeba40179f05735784ad32bdaef05ce8e8af05f180d45bb3e7e22" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" dependencies = [ "memchr", "ucd-trie", @@ -7806,9 +8166,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.4" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51f72981ade67b1ca6adc26ec221be9f463f2b5839c7508998daa17c23d94d7f" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" dependencies = [ "pest", "pest_generator", @@ -7816,25 +8176,24 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.4" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee9efd8cdb50d719a80088b76f81aec7c41ed6d522ee750178f83883d271625" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "pest_meta" -version = "2.8.4" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf1d70880e76bdc13ba52eafa6239ce793d85c8e43896507e43dd8984ff05b82" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" dependencies = [ "pest", - "sha2 0.10.9", ] [[package]] @@ -7847,16 +8206,6 @@ dependencies = [ "indexmap 2.14.0", ] -[[package]] -name = "petgraph" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" -dependencies = [ - "fixedbitset 0.5.7", - "indexmap 2.14.0", -] - [[package]] name = "petgraph" version = "0.8.3" @@ -7915,7 +8264,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.5", + "rand 0.8.7", ] [[package]] @@ -7938,7 +8287,7 @@ dependencies = [ "phf_shared 0.13.1", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -7970,35 +8319,29 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkcs1" @@ -8040,19 +8383,19 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plist" -version = "1.8.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml 0.38.4", + "quick-xml 0.41.0", "serde", "time", ] @@ -8099,15 +8442,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] @@ -8126,27 +8469,27 @@ dependencies = [ [[package]] name = "postgres-protocol" -version = "0.6.9" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbef655056b916eb868048276cfd5d6a7dea4f81560dfd047f97c8c6fe3fcfd4" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" dependencies = [ "base64 0.22.1", "byteorder", "bytes", "fallible-iterator", - "hmac 0.12.1", - "md-5 0.10.6", + "hmac 0.13.0", + "md-5 0.11.0", "memchr", - "rand 0.9.4", - "sha2 0.10.9", + "rand 0.10.2", + "sha2 0.11.0", "stringprep", ] [[package]] name = "postgres-types" -version = "0.2.11" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4605b7c057056dd35baeb6ac0c0338e4975b1f2bef0f65da953285eb007095" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" dependencies = [ "bytes", "fallible-iterator", @@ -8155,9 +8498,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -8179,9 +8522,9 @@ dependencies = [ [[package]] name = "predicates" -version = "3.1.3" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ "anstyle", "predicates-core", @@ -8189,15 +8532,15 @@ dependencies = [ [[package]] name = "predicates-core" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" [[package]] name = "predicates-tree" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" dependencies = [ "predicates-core", "termtree", @@ -8210,69 +8553,56 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "proc-macro-crate" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" -dependencies = [ - "toml_edit 0.23.7", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] -name = "proc-macro-error-attr" -version = "1.0.4" +name = "proc-macro-error-attr3" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +checksum = "34e4dd828515431dd6c4a030d26f7eaed7dd4778226e9d2bb968d65ca4ec3d4d" dependencies = [ "proc-macro2", "quote", - "version_check", ] [[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" +name = "proc-macro-error3" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +checksum = "5ee475e440453418ff1335189eddf7101ba502cd818ab7ae04209bc83aa925aa" dependencies = [ + "proc-macro-error-attr3", "proc-macro2", "quote", + "syn 2.0.119", ] [[package]] -name = "proc-macro-error2" -version = "2.0.1" +name = "proc-macro-utils" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" dependencies = [ - "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.111", + "smallvec", ] [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -8283,7 +8613,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "hex", "procfs-core", "rustix 0.38.44", @@ -8295,7 +8625,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "hex", ] @@ -8318,18 +8648,18 @@ dependencies = [ [[package]] name = "proptest" -version = "1.9.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.10.0", + "bitflags 2.13.1", "num-traits", - "rand 0.9.4", + "rand 0.9.5", "rand_chacha 0.9.0", "rand_xorshift", - "regex-syntax 0.8.8", + "regex-syntax 0.8.11", "rusty-fork", "tempfile", "unarray", @@ -8337,9 +8667,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.1" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -8347,44 +8677,43 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.14.1" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools 0.14.0", "log", "multimap", - "once_cell", - "petgraph 0.7.1", + "petgraph 0.8.3", "prettyplease", "prost", "prost-types", "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", - "syn 2.0.111", + "syn 2.0.119", "tempfile", ] [[package]] name = "prost-derive" -version = "0.14.1" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "prost-types" -version = "0.14.1" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ "prost", ] @@ -8431,29 +8760,29 @@ dependencies = [ [[package]] name = "pulldown-cmark" -version = "0.13.0" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "memchr", "unicase", ] [[package]] name = "pulldown-cmark-to-cmark" -version = "21.1.0" +version = "22.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8246feae3db61428fd0bb94285c690b460e4517d83152377543ca802357785f1" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" dependencies = [ "pulldown-cmark", ] [[package]] name = "pulley-interpreter" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9204ad9435f2a6fe3bd13bba52389fb8488fa20ba497e35c5d2db638166019d" +checksum = "b04adf8f93264685f2c70a3edb8f828c791e71b22659253319c33f29d1165aef" dependencies = [ "cranelift-bitset", "log", @@ -8463,13 +8792,13 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53009b033747e0d79a76549a744da58e84c9da8076492c7e6d491fdc6cc41b95" +checksum = "2c120a6af1a574a42efcd9df2ad27cb78bc04118c5e0c69e90599f17301a1c7a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -8488,9 +8817,9 @@ dependencies = [ [[package]] name = "pulp" -version = "0.22.2" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e205bb30d5b916c55e584c22201771bcf2bad9aabd5d4127f38387140c38632" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" dependencies = [ "bytemuck", "cfg-if", @@ -8505,9 +8834,9 @@ dependencies = [ [[package]] name = "pulp-wasm-simd-flag" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40e24eee682d89fb193496edf918a7f407d30175b2e785fe057e4392dfd182e0" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" [[package]] name = "pyo3" @@ -8556,7 +8885,7 @@ dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -8569,7 +8898,7 @@ dependencies = [ "proc-macro2", "pyo3-build-config", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -8619,20 +8948,30 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.1", + "rustc-hash 2.1.3", "rustls", - "socket2 0.6.3", + "socket2 0.6.5", "thiserror 2.0.18", "tokio", "tracing", @@ -8641,17 +8980,18 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", - "rustc-hash 2.1.1", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", @@ -8663,23 +9003,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.6.5", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -8727,9 +9067,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -8739,22 +9079,22 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -8785,7 +9125,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -8803,15 +9143,15 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "serde", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -8829,7 +9169,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" dependencies = [ "num-traits", - "rand 0.8.5", + "rand 0.8.7", ] [[package]] @@ -8839,7 +9179,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand 0.9.4", + "rand 0.9.5", ] [[package]] @@ -8851,13 +9191,22 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -8866,7 +9215,16 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" dependencies = [ - "rand_core 0.9.3", + "rand_core 0.9.5", +] + +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", ] [[package]] @@ -8875,7 +9233,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", ] [[package]] @@ -8904,9 +9262,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -8955,7 +9313,7 @@ dependencies = [ "pin-project-lite", "ryu", "sha1_smol", - "socket2 0.6.3", + "socket2 0.6.5", "tokio", "tokio-util", "url", @@ -8967,7 +9325,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", ] [[package]] @@ -8976,7 +9334,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", "thiserror 1.0.69", ] @@ -8998,7 +9356,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -9011,38 +9369,38 @@ dependencies = [ "bumpalo", "hashbrown 0.17.1", "log", - "rustc-hash 2.1.1", + "rustc-hash 2.1.3", "smallvec", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax 0.8.8", + "regex-syntax 0.8.11", ] [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.8", + "regex-syntax 0.8.11", ] [[package]] name = "regex-lite" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" @@ -9052,9 +9410,9 @@ checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "regress" @@ -9086,37 +9444,37 @@ dependencies = [ [[package]] name = "reqsign-aws-v4" -version = "3.0.0" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44eaca382e94505a49f1a4849658d153aebf79d9c1a58e5dd3b10361511e9f43" +checksum = "4e9e1168fab3883ec6afed1c2e20c25b2a09f366cdb662ac3e0878ae0332d63e" dependencies = [ "anyhow", "bytes", "form_urlencoded", - "http 1.4.0", + "hex", + "http 1.4.2", "log", "percent-encoding", - "quick-xml 0.39.4", + "quick-xml 0.41.0", "reqsign-core", "rust-ini", "serde", "serde_json", "serde_urlencoded", - "sha1 0.10.6", + "sha1 0.11.0", ] [[package]] name = "reqsign-azure-storage" -version = "3.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a321980405d596bd34aaf95c4722a3de4128a67fd19e74a81a83aa3fdf082e6" +checksum = "dee8b9e5d0fc927551a6ac25ba5dc6518860c54ddb592fecf685523e528e77bd" dependencies = [ "anyhow", "base64 0.22.1", "bytes", "form_urlencoded", - "http 1.4.0", - "jsonwebtoken", + "http 1.4.2", "log", "pem", "percent-encoding", @@ -9124,14 +9482,14 @@ dependencies = [ "rsa", "serde", "serde_json", - "sha1 0.10.6", + "sha1 0.11.0", ] [[package]] name = "reqsign-core" -version = "3.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b10302cf0a7d7e7352ba211fc92c3c5bebf1286153e49cc5aa87348078a8e102" +checksum = "514a1e0b4aa288652a3fdbda4f0a610f379cdf5374e55a37c9edd03d57ed856b" dependencies = [ "anyhow", "base64 0.22.1", @@ -9139,21 +9497,24 @@ dependencies = [ "form_urlencoded", "futures", "hex", - "hmac 0.12.1", - "http 1.4.0", + "hmac 0.13.0", + "http 1.4.2", "jiff", "log", "percent-encoding", - "sha1 0.10.6", - "sha2 0.10.9", + "rsa", + "serde", + "serde_json", + "sha1 0.11.0", + "sha2 0.11.0", "windows-sys 0.61.2", ] [[package]] name = "reqsign-file-read-tokio" -version = "3.0.0" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2d89295b3d17abea31851cc8de55d843d89c52132c864963c38d41920613dc5" +checksum = "2b472a8d1f2e5a4be8ce13bb7bdf4b59e9bee613ce124aca23959ddb42176b39" dependencies = [ "anyhow", "reqsign-core", @@ -9186,7 +9547,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper 0.1.2", - "system-configuration", + "system-configuration 0.5.1", "tokio", "tower-service", "url", @@ -9198,19 +9559,19 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", "futures-core", - "h2 0.4.12", - "http 1.4.0", - "http-body 1.0.1", + "h2 0.4.15", + "http 1.4.2", + "http-body 1.1.0", "http-body-util", - "hyper 1.8.1", + "hyper 1.10.1", "hyper-rustls", "hyper-tls", "hyper-util", @@ -9227,7 +9588,7 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", - "tower 0.5.2", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -9238,18 +9599,18 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", "futures-core", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body 1.1.0", "http-body-util", - "hyper 1.8.1", + "hyper 1.10.1", "hyper-rustls", "hyper-util", "js-sys", @@ -9264,7 +9625,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-util", - "tower 0.5.2", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -9282,9 +9643,9 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", - "untrusted 0.9.0", + "untrusted", "windows-sys 0.52.0", ] @@ -9325,31 +9686,28 @@ checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" [[package]] name = "rmp" -version = "0.8.14" +version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" dependencies = [ - "byteorder", "num-traits", - "paste", ] [[package]] name = "rmp-serde" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" dependencies = [ - "byteorder", "rmp", "serde", ] [[package]] name = "roaring" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f08d6a905edb32d74a5d5737a0c9d7e950c312f3c46cb0ca0a2ca09ea11878a0" +checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9" dependencies = [ "bytemuck", "byteorder", @@ -9367,11 +9725,11 @@ dependencies = [ [[package]] name = "ron" -version = "0.12.0" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd490c5b18261893f14449cbd28cb9c0b637aebf161cd77900bfdedaff21ec32" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "once_cell", "serde", "serde_derive", @@ -9411,11 +9769,11 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.9" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a0376c50d0358279d9d643e4bf7b7be212f1f4ff1da9070a7b54d22ef75c88" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", + "const-oid 0.9.6", "digest 0.10.7", "num-bigint-dig", "num-integer", @@ -9432,9 +9790,9 @@ dependencies = [ [[package]] name = "rust-embed" -version = "8.9.0" +version = "8.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "947d7f3fad52b283d261c4c99a084937e2fe492248cb9a68a8435a861b8798ca" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -9443,24 +9801,25 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.9.0" +version = "8.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fa2c8c9e8711e10f9c4fd2d64317ef13feaab820a4c51541f1a8c8e2e851ab2" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" dependencies = [ + "mime_guess", "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.111", + "syn 2.0.119", "walkdir", ] [[package]] name = "rust-embed-utils" -version = "8.9.0" +version = "8.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b161f275cb337fe0a44d924a5f4df0ed69c2c39519858f931ce61c779d3475" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" dependencies = [ - "sha2 0.10.9", + "sha2 0.11.0", "walkdir", ] @@ -9486,25 +9845,26 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.39.0" +version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35affe401787a9bd846712274d97654355d21b2a2c092a3139aabe31e9022282" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" dependencies = [ "arrayvec", "borsh", "bytes", "num-traits", - "rand 0.8.5", + "rand 0.8.7", "rkyv", "serde", "serde_json", + "wasm-bindgen", ] [[package]] name = "rustc-demangle" -version = "0.1.26" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc-hash" @@ -9514,9 +9874,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -9533,7 +9893,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver 1.0.27", + "semver 1.0.28", ] [[package]] @@ -9542,7 +9902,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -9555,11 +9915,11 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9574,9 +9934,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "aws-lc-rs", "log", @@ -9590,27 +9950,14 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" -dependencies = [ - "openssl-probe", - "rustls-pemfile", - "rustls-pki-types", - "schannel", - "security-framework 2.11.1", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.5.1", + "security-framework", ] [[package]] @@ -9624,9 +9971,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -9644,13 +9991,13 @@ dependencies = [ "log", "once_cell", "rustls", - "rustls-native-certs 0.8.2", + "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework 3.5.1", + "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9668,14 +10015,14 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted 0.9.0", + "untrusted", ] [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" @@ -9695,7 +10042,7 @@ version = "14.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "clipboard-win", "fd-lock", @@ -9713,15 +10060,15 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "ryu-js" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" [[package]] name = "safe_arch" @@ -9779,9 +10126,9 @@ checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -9813,9 +10160,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", @@ -9832,9 +10179,15 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.111", + "syn 2.0.119", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -9867,7 +10220,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -9881,24 +10234,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -9907,9 +10247,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -9926,9 +10266,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", @@ -10014,7 +10354,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -10025,21 +10365,21 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "indexmap 2.14.0", "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -10061,7 +10401,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -10075,9 +10415,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.3" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -10096,17 +10436,18 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.16.1" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", "indexmap 2.14.0", "schemars 0.9.0", - "schemars 1.1.0", + "schemars 1.2.1", "serde_core", "serde_json", "serde_with_macros", @@ -10115,14 +10456,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.16.1" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -10153,9 +10494,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -10216,12 +10557,19 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" -version = "1.4.7" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -10250,15 +10598,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version 0.4.1", "simdutf8", @@ -10270,23 +10618,11 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" -[[package]] -name = "simple_asn1" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.18", - "time", -] - [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "sketches-ddsketch" @@ -10299,15 +10635,15 @@ dependencies = [ [[package]] name = "sketches-ddsketch" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1e9a774a6c28142ac54bb25d25562e6bcf957493a184f15ad4eebccb23e410a" +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slabbin" @@ -10326,9 +10662,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] @@ -10341,20 +10677,20 @@ checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "smawk" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" [[package]] name = "snap" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" [[package]] name = "socket2" @@ -10368,19 +10704,19 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -10439,7 +10775,7 @@ dependencies = [ "futures-io", "futures-util", "hashbrown 0.16.1", - "hashlink 0.11.0", + "hashlink", "indexmap 2.14.0", "log", "memchr", @@ -10468,7 +10804,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -10491,7 +10827,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.111", + "syn 2.0.119", "thiserror 2.0.18", "tokio", "url", @@ -10503,7 +10839,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "byteorder", "bytes", "chrono", @@ -10533,7 +10869,7 @@ checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.13.1", "byteorder", "chrono", "crc", @@ -10549,7 +10885,7 @@ dependencies = [ "log", "md-5 0.11.0", "memchr", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_json", "sha2 0.11.0", @@ -10559,7 +10895,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "uuid", - "whoami 2.1.2", + "whoami", ] [[package]] @@ -10607,9 +10943,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" dependencies = [ "approx", - "nalgebra 0.33.2", + "nalgebra 0.33.3", "num-traits", - "rand 0.8.5", + "rand 0.8.7", ] [[package]] @@ -10660,7 +10996,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -10672,7 +11008,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -10694,9 +11030,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -10726,7 +11062,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -10741,7 +11077,7 @@ dependencies = [ "once_cell", "onig", "plist", - "regex-syntax 0.8.8", + "regex-syntax 0.8.11", "serde", "serde_derive", "serde_json", @@ -10756,7 +11092,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3d2c2202510a1e186e63e596d9318c91a8cbe85cd1a56a7be0c333e5f59ec8d" dependencies = [ - "syn 2.0.111", + "syn 2.0.119", "synthez-codegen", "synthez-core", ] @@ -10767,7 +11103,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f724aa6d44b7162f3158a57bccd871a77b39a4aef737e01bcdff41f4772c7746" dependencies = [ - "syn 2.0.111", + "syn 2.0.119", "synthez-core", ] @@ -10780,7 +11116,7 @@ dependencies = [ "proc-macro2", "quote", "sealed", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -10789,7 +11125,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "byteorder", "enum-as-inner", "libc", @@ -10815,11 +11151,22 @@ dependencies = [ name = "system-configuration" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.1", "core-foundation 0.9.4", - "system-configuration-sys", + "system-configuration-sys 0.6.0", ] [[package]] @@ -10832,6 +11179,16 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tag_ptr" version = "0.1.0" @@ -10940,7 +11297,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" dependencies = [ "byteorder", - "regex-syntax 0.8.8", + "regex-syntax 0.8.11", "utf8-ranges", ] @@ -11004,9 +11361,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "target-lexicon" -version = "0.13.3" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tempfile" @@ -11015,10 +11372,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11032,12 +11389,12 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix 1.1.4", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11089,7 +11446,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -11100,14 +11457,14 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -11125,13 +11482,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", - "js-sys", "libc", "num-conv", "num_threads", @@ -11143,15 +11498,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -11168,9 +11523,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "serde_core", @@ -11189,9 +11544,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -11221,11 +11576,11 @@ dependencies = [ "monostate", "onig", "paste", - "rand 0.9.4", + "rand 0.9.5", "rayon", "rayon-cond", "regex", - "regex-syntax 0.8.8", + "regex-syntax 0.8.11", "serde", "serde_json", "spm_precompiled", @@ -11237,9 +11592,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.3" +version = "1.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "317fafbbe3f02fc663dad00ea6186197de963cd4190e86a26d8d0fae095539af" dependencies = [ "bytes", "libc", @@ -11247,7 +11602,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] @@ -11276,7 +11631,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -11291,9 +11646,9 @@ dependencies = [ [[package]] name = "tokio-postgres" -version = "0.7.15" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b40d66d9b2cfe04b628173409368e58247e8eddbbd3b0e6c6ba1d09f20f6c9e" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" dependencies = [ "async-trait", "byteorder", @@ -11308,11 +11663,11 @@ dependencies = [ "pin-project-lite", "postgres-protocol", "postgres-types", - "rand 0.9.4", - "socket2 0.6.3", + "rand 0.10.2", + "socket2 0.6.5", "tokio", "tokio-util", - "whoami 1.6.1", + "whoami", ] [[package]] @@ -11327,9 +11682,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -11339,12 +11694,10 @@ dependencies = [ [[package]] name = "tokio-test" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" dependencies = [ - "async-stream", - "bytes", "futures-core", "tokio", "tokio-stream", @@ -11364,21 +11717,21 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.28.0", + "tungstenite 0.29.0", ] [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -11402,17 +11755,30 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.8" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap 2.14.0", "serde_core", - "serde_spanned 1.0.3", - "toml_datetime 0.7.3", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 0.7.14", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", ] [[package]] @@ -11426,9 +11792,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -11448,56 +11823,56 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap 2.14.0", - "toml_datetime 0.7.3", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 0.7.14", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 0.7.14", + "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.0.4" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tonic" -version = "0.14.2" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", - "axum 0.8.7", + "axum 0.8.9", "base64 0.22.1", "bytes", - "h2 0.4.12", - "http 1.4.0", - "http-body 1.0.1", + "h2 0.4.15", + "http 1.4.2", + "http-body 1.1.0", "http-body-util", - "hyper 1.8.1", + "hyper 1.10.1", "hyper-timeout", "hyper-util", "percent-encoding", "pin-project", - "socket2 0.6.3", + "socket2 0.6.5", "sync_wrapper 1.0.2", "tokio", "tokio-rustls", "tokio-stream", - "tower 0.5.2", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -11505,21 +11880,21 @@ dependencies = [ [[package]] name = "tonic-build" -version = "0.14.2" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c40aaccc9f9eccf2cd82ebc111adc13030d23e887244bc9cfa5d1d636049de3" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "tonic-prost" -version = "0.14.2" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", "prost", @@ -11528,25 +11903,25 @@ dependencies = [ [[package]] name = "tonic-prost-build" -version = "0.14.2" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a16cba4043dc3ff43fcb3f96b4c5c154c64cbd18ca8dce2ab2c6a451d058a2" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" dependencies = [ "prettyplease", "proc-macro2", "prost-build", "prost-types", "quote", - "syn 2.0.111", + "syn 2.0.119", "tempfile", "tonic-build", ] [[package]] name = "tonic-reflection" -version = "0.14.2" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34da53e8387581d66db16ff01f98a70b426b091fdf76856e289d5c1bd386ed7b" +checksum = "acccd136a4bf19810a1fde9c74edc6129b42a66b44d0c1c8aaa67aeb49a146a7" dependencies = [ "prost", "prost-types", @@ -11572,9 +11947,9 @@ dependencies = [ [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -11596,14 +11971,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.13.1", "bytes", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body 1.1.0", "mime", "pin-project-lite", - "tower 0.5.2", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -11647,7 +12022,7 @@ checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -11658,9 +12033,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -11676,14 +12051,14 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -11712,9 +12087,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -11745,30 +12120,29 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http 1.4.0", + "http 1.4.2", "httparse", "log", - "rand 0.9.4", - "sha1 0.10.6", + "rand 0.9.5", + "sha1 0.10.7", "thiserror 2.0.18", "utf-8", ] [[package]] name = "tungstenite" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http 1.4.0", + "http 1.4.2", "httparse", "log", - "rand 0.9.4", - "sha1 0.10.6", + "rand 0.9.5", + "sha1 0.10.7", "thiserror 2.0.18", - "utf-8", ] [[package]] @@ -11812,7 +12186,7 @@ checksum = "29a3151c41d0b13e3d011f98adc24434560ef06673a155a6c7f66b9879eecce2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -11823,7 +12197,7 @@ checksum = "f9534daa9fd3ed0bd911d462a37f172228077e7abf18c18a5f67199d959205f8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -11834,7 +12208,7 @@ checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -11851,9 +12225,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typetag" @@ -11876,7 +12250,7 @@ checksum = "cf808357c6ed7e13ba0f3277ec8d8f21b2d501274895104263985330c726c1c5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -11941,9 +12315,9 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicase" -version = "2.8.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-bidi" @@ -11953,9 +12327,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-linebreak" @@ -11989,9 +12363,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -12039,12 +12413,6 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - [[package]] name = "untrusted" version = "0.9.0" @@ -12053,14 +12421,15 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -12101,9 +12470,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utoipa" -version = "5.4.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" dependencies = [ "indexmap 2.14.0", "serde", @@ -12113,14 +12482,14 @@ dependencies = [ [[package]] name = "utoipa-gen" -version = "5.4.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -12143,11 +12512,11 @@ dependencies = [ [[package]] name = "uuid" -version = "1.19.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -12221,7 +12590,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -12265,47 +12634,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" +name = "wasi" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" dependencies = [ - "wit-bindgen 0.46.0", + "wasip2", ] [[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +name = "wasip2" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasite" -version = "0.1.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -12313,9 +12686,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -12323,22 +12696,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] @@ -12360,16 +12733,6 @@ dependencies = [ "wat", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser 0.244.0", -] - [[package]] name = "wasm-encoder" version = "0.248.0" @@ -12382,24 +12745,12 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.250.0" +version = "0.253.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2271adb766023046af314460f1fae02cc34ea16d736d93404d3b65be44270923" +checksum = "59972d6cd272259de647b7c1f1912e45e289c75ffd4be04e10695507cd7e1b59" dependencies = [ "leb128fmt", - "wasmparser 0.250.0", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder 0.244.0", - "wasmparser 0.244.0", + "wasmparser 0.253.0", ] [[package]] @@ -12415,40 +12766,28 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.10.0", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver 1.0.27", -] - [[package]] name = "wasmparser" version = "0.248.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "hashbrown 0.17.1", "indexmap 2.14.0", - "semver 1.0.27", + "semver 1.0.28", "serde", ] [[package]] name = "wasmparser" -version = "0.250.0" +version = "0.253.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071d99cdfb8111603ed05500506c3298a940b58d609dd0259d3981785dd33556" +checksum = "19db11f87d2486580e1e8b6f494c54df7e0566b87d0b599db843c24019667339" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "indexmap 2.14.0", - "semver 1.0.27", + "semver 1.0.28", ] [[package]] @@ -12464,13 +12803,13 @@ dependencies = [ [[package]] name = "wasmtime" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35aec1e932d00a7c941f816ad589e65ad8db948b9e971bf8ec655a1669f1f67" +checksum = "8a83ef84ac8bab735c89e27b0268b0586099fbe81281ae45be2b8e4f407b2daa" dependencies = [ "addr2line", "async-trait", - "bitflags 2.10.0", + "bitflags 2.13.1", "bumpalo", "cc", "cfg-if", @@ -12489,7 +12828,7 @@ dependencies = [ "pulley-interpreter", "rayon", "rustix 1.1.4", - "semver 1.0.27", + "semver 1.0.28", "serde", "serde_derive", "serde_json", @@ -12517,9 +12856,9 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7da3dcce82a7e784121c19c8c9c5f69a743088264ff5212033e4a1f1b9dfaaf" +checksum = "439686d3deb38525c86b88bbc90f7ba669f70c8edc7d6b35147c738bbef5ff76" dependencies = [ "anyhow", "cpp_demangle", @@ -12533,7 +12872,7 @@ dependencies = [ "object", "postcard", "rustc-demangle", - "semver 1.0.27", + "semver 1.0.28", "serde", "serde_derive", "sha2 0.10.9", @@ -12548,9 +12887,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cache" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef87f84d976e2f98a541eaf5837df0424e2039837fc20bd6cd4b4b5a322939c0" +checksum = "8f2799e6c35393c2a38999f13e4c80ab5d5d9756082bb554cbd1f60485a18293" dependencies = [ "base64 0.22.1", "directories-next", @@ -12560,7 +12899,7 @@ dependencies = [ "serde", "serde_derive", "sha2 0.10.9", - "toml 0.9.8", + "toml 0.9.12+spec-1.1.0", "wasmtime-environ", "windows-sys 0.61.2", "zstd", @@ -12568,30 +12907,30 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86991f201391afc1504e4fc363dc29b66f92af0287b4ac2efc3c0b0c19435eeb" +checksum = "81d2c5850fb8c448792453765d97f6f01096a32708f3c36798e86220763c90c0" dependencies = [ "anyhow", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", "wasmtime-internal-component-util", "wasmtime-internal-wit-bindgen", - "wit-parser 0.248.0", + "wit-parser", ] [[package]] name = "wasmtime-internal-component-util" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47fda091250d7ab839ea51e4d98190b6eee37e9de4ab2462e8fe8465369c1986" +checksum = "0ed79c4e9ab416dcc5e46b9d1ec9b7c2e3c730deab525996b0de45a35fc8b2c3" [[package]] name = "wasmtime-internal-core" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bdae4b55b15a23d774b15f6e7cd90ae0d0aa17c47c12b4db098b3dd11ba9d58" +checksum = "3073c03f97f871fe6400e68621c863b93ba79296f8e570285231952d23fcc804" dependencies = [ "anyhow", "hashbrown 0.17.1", @@ -12601,9 +12940,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cranelift" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5773b36b87566239b020f1d01aa753a35626df85030485e40e36fc42a97acf4f" +checksum = "7a15981261d361f9c93b42929f446b4009840853ceea181ad6e17730f4016a0b" dependencies = [ "cfg-if", "cranelift-codegen", @@ -12628,9 +12967,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402cce4bba4c8c92a6fbaff39a6b23f8aa626d64b218ecf6dd3eeee8705cf096" +checksum = "33aed9d91d54c9f861ba7e3c3130bded8a7a63e01fe58c3a9a36b45d6ee0fb57" dependencies = [ "cc", "cfg-if", @@ -12643,9 +12982,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b426a5d0ec9c11a1a4525ed4e973b7caf40223b6d392588bb9f6468e4ae9d29" +checksum = "944942118aab67e7b4febfe88a65d0af4cfd10af8ed0e79fd6cf39d75359ab7e" dependencies = [ "cc", "object", @@ -12655,9 +12994,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a312ba8bb77955dcd44294a223e7f124c3071ff966583d385d3f6a4639c62e3" +checksum = "37dee05e8c35759826f6b926cd949c51f771b6a58619d8e60c63c3f3d3e5e59b" dependencies = [ "cfg-if", "libc", @@ -12667,9 +13006,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-unwinder" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a62ad422ee3cbf1e87c2242dc0717a01c7a5878fbc3a68abc4b4d2fff3e85e1" +checksum = "a83f7ebe911a6f1605ff0d3a678472ddb1fcc57c3e2f6bae5dd4d170b625b3ed" dependencies = [ "cfg-if", "cranelift-codegen", @@ -12680,20 +13019,20 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c660c5b091648cffdd84a34dc24ffcdb9d027f9048fe7bd5e01896adbd0935f" +checksum = "d75980644456dc003238766254b0e5f002704630237ccd0728747991fe2739d7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "wasmtime-internal-winch" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2aceb92b48b6e3a5cc2a05ab7a2dcb565eaf86fb870d04664b7f12cf9bba39a" +checksum = "81af36995b4720b32e3d667541b548a27184a859d09a6ee745eaba095f5a6f6e" dependencies = [ "cranelift-codegen", "gimli", @@ -12708,25 +13047,25 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce382df367ad2a2d48e139b191dbca3329f7232a43057dc9efc889dac54f1b0b" +checksum = "ada06667b7948892703fcc9f0b9b337c2e78c334d74d4fa62e2f75f07fbb3659" dependencies = [ "anyhow", - "bitflags 2.10.0", + "bitflags 2.13.1", "heck 0.5.0", "indexmap 2.14.0", - "wit-parser 0.248.0", + "wit-parser", ] [[package]] name = "wasmtime-wasi" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb1e92a304eaafd672718011c69084041db74fa0fcc3532c5920f492c557b721" +checksum = "fd8e9db77f7b60f4c5f6f72847f55eb646ed7ed2f68e362559ee07dc543fb408" dependencies = [ "async-trait", - "bitflags 2.10.0", + "bitflags 2.13.1", "bytes", "cap-fs-ext", "cap-net-ext", @@ -12737,7 +13076,7 @@ dependencies = [ "futures", "io-extras", "io-lifetimes", - "rand 0.10.1", + "rand 0.10.2", "rustix 1.1.4", "thiserror 2.0.18", "tokio", @@ -12751,9 +13090,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi-io" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e0013e1f37d2e0e1b030fa186972f6f5819f69814bb07d3b6d3cab0c40b50e2" +checksum = "3805f02be91374a4fd02c0f947daf07bbaa6fbebb5909de5305fa5b0e9568f30" dependencies = [ "async-trait", "bytes", @@ -12773,31 +13112,31 @@ dependencies = [ [[package]] name = "wast" -version = "250.0.0" +version = "253.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69e9294a1f0204aeb5c47e95165517f43ef3cc895918c4f3e939380d4c290f4a" +checksum = "d3264542f8965c5d84fb1085d924bfba9a6314bb228eff13a2de14d7627664d0" dependencies = [ "bumpalo", "leb128fmt", "memchr", "unicode-width 0.2.2", - "wasm-encoder 0.250.0", + "wasm-encoder 0.253.0", ] [[package]] name = "wat" -version = "1.250.0" +version = "1.253.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a549ed329a70e444e0f7796391ab2a87d0aef30ddde9f60e16e429224fafd02" +checksum = "4bfc5ce906144200c972ec617470aa35bd847472e170b26dde3e80541c674055" dependencies = [ - "wast 250.0.0", + "wast 253.0.0", ] [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -12815,18 +13154,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.4" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -12857,21 +13196,17 @@ dependencies = [ [[package]] name = "whoami" -version = "1.6.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" dependencies = [ + "libc", "libredox", + "objc2-system-configuration", "wasite", "web-sys", ] -[[package]] -name = "whoami" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" - [[package]] name = "wide" version = "0.7.33" @@ -12884,11 +13219,11 @@ dependencies = [ [[package]] name = "wiggle" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c048e5d47058ff8b60cef771dd6276f2cf4508bc7f604b93c8d5d643ad41fc" +checksum = "4aca130e25a57e29bbb541441b6e261970a8469580bffebe904d96f0df67e41d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "thiserror 2.0.18", "tracing", "wasmtime", @@ -12898,27 +13233,27 @@ dependencies = [ [[package]] name = "wiggle-generate" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b867ae624c2006976985444321d429ee2d0c6784ca5c6e45bc140c48141bb0c" +checksum = "1f0a45b47e893521cad81819f2e0db18a8b05086fd4bfb35369ed8830a8f0148" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", "wasmtime-environ", "witx", ] [[package]] name = "wiggle-macro" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "463d6d4c0c100180fdfc586d555ed1c22376114944841629cff0d746666e30a4" +checksum = "e297e0325cffa6d368386b6e672e9d6c6a7ad34bdb8a5f49319db4decef2a990" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", "wiggle-generate", ] @@ -12944,7 +13279,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -12955,9 +13290,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "winch-codegen" -version = "45.0.0" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3128bd53313b132e8737d7d318edbc438bab1abe525ac037bbf9857839e717e2" +checksum = "d73bc25d27222248f9bafc7e9945f61e74275360c3ea0d34008810d436140a53" dependencies = [ "cranelift-assembler-x64", "cranelift-codegen", @@ -13090,7 +13425,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -13101,7 +13436,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -13219,15 +13554,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -13276,30 +13602,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.1.0" @@ -13336,12 +13645,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -13360,12 +13663,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -13384,24 +13681,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -13420,12 +13705,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -13444,12 +13723,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -13468,12 +13741,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -13492,12 +13759,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.5.40" @@ -13509,9 +13770,15 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -13538,103 +13805,15 @@ version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "windows-sys 0.59.0", ] [[package]] name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser 0.244.0", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.111", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.111", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.10.0", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder 0.244.0", - "wasm-metadata", - "wasmparser 0.244.0", - "wit-parser 0.244.0", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver 1.0.27", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser 0.244.0", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "wit-parser" @@ -13647,7 +13826,7 @@ dependencies = [ "id-arena", "indexmap 2.14.0", "log", - "semver 1.0.27", + "semver 1.0.28", "serde", "serde_derive", "serde_json", @@ -13669,9 +13848,9 @@ dependencies = [ [[package]] name = "wmi" -version = "0.18.0" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71d1d435f7745ba9ed55c43049d47b5fbd1104449beaa2afbc80a1e10a4a018" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" dependencies = [ "chrono", "futures", @@ -13690,9 +13869,9 @@ checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" @@ -13753,9 +13932,9 @@ checksum = "0637d3a5566a82fa5214bae89087bc8c9fb94cd8e8a3c07feb691bb8d9c632db" [[package]] name = "xxhash-rust" -version = "0.8.15" +version = "0.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +checksum = "985eec839aaf2a1270af8f4ebcf63cf9401cfd90f0902f97c28d9f104ffbde72" [[package]] name = "yaml-rust" @@ -13768,13 +13947,13 @@ dependencies = [ [[package]] name = "yaml-rust2" -version = "0.10.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" +checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", - "hashlink 0.10.0", + "hashlink", ] [[package]] @@ -13791,12 +13970,12 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", - "yoke-derive 0.8.1", + "yoke-derive 0.8.2", "zerofrom", ] @@ -13808,115 +13987,102 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", "synstructure", ] [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", - "yoke 0.8.1", + "yoke 0.8.3", "zerofrom", + "zerovec", ] [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "serde", - "yoke 0.8.1", + "yoke 0.8.3", "zerofrom", "zerovec-derive", ] [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -13950,9 +14116,15 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.5.2" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + +[[package]] +name = "zmij" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" diff --git a/Cargo.toml b/Cargo.toml index 9e1891e2..6adca202 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,7 +94,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Error handling anyhow = "1.0" -thiserror = "1.0" +thiserror = "2.0" # Time and UUID chrono = { version = "0.4", features = ["serde"] } diff --git a/orbit/cli/Cargo.toml b/orbit/cli/Cargo.toml index 6f2a7d02..da55a6ed 100644 --- a/orbit/cli/Cargo.toml +++ b/orbit/cli/Cargo.toml @@ -41,8 +41,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Error handling anyhow = "1.0" -thiserror = "1.0" - +thiserror.workspace = true # Utilities chrono = "0.4" dirs = "5.0" diff --git a/orbit/client-spring/Cargo.toml b/orbit/client-spring/Cargo.toml index a6f1dcd8..0f7719a5 100644 --- a/orbit/client-spring/Cargo.toml +++ b/orbit/client-spring/Cargo.toml @@ -17,7 +17,7 @@ tokio = { workspace = true, features = ["full"] } serde = { workspace = true, features = ["derive"] } anyhow.workspace = true async-trait = "0.1" -thiserror = "1.0" +thiserror.workspace = true tracing = "0.1" once_cell.workspace = true dashmap.workspace = true diff --git a/orbit/compute/Cargo.toml b/orbit/compute/Cargo.toml index 6e4e8ee3..e1e2e45e 100644 --- a/orbit/compute/Cargo.toml +++ b/orbit/compute/Cargo.toml @@ -68,8 +68,7 @@ serde_json = "1.0" # Error handling anyhow = "1.0" -thiserror = "1.0" - +thiserror.workspace = true # Mathematics and algorithms nalgebra = "0.34" ndarray = "0.15" diff --git a/orbit/engine/Cargo.toml b/orbit/engine/Cargo.toml index 1640517a..c255f117 100644 --- a/orbit/engine/Cargo.toml +++ b/orbit/engine/Cargo.toml @@ -25,7 +25,7 @@ bytes = "1.7" base64 = "0.22" # Error handling -thiserror = "1.0" +thiserror.workspace = true anyhow = "1.0" # Utilities diff --git a/orbit/server-etcd/Cargo.toml b/orbit/server-etcd/Cargo.toml index 628cc472..c5e4fe15 100644 --- a/orbit/server-etcd/Cargo.toml +++ b/orbit/server-etcd/Cargo.toml @@ -15,7 +15,7 @@ tokio = { workspace = true, features = ["full"] } serde = { workspace = true, features = ["derive"] } anyhow.workspace = true async-trait = "0.1" -thiserror = "1.0" +thiserror.workspace = true tracing = "0.1" chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.19", features = ["v4", "serde"] } diff --git a/orbit/server-prometheus/Cargo.toml b/orbit/server-prometheus/Cargo.toml index 9cabe778..e3f70f5d 100644 --- a/orbit/server-prometheus/Cargo.toml +++ b/orbit/server-prometheus/Cargo.toml @@ -16,7 +16,7 @@ tokio = { workspace = true, features = ["full"] } serde = { workspace = true, features = ["derive"] } anyhow.workspace = true async-trait = "0.1" -thiserror = "1.0" +thiserror.workspace = true tracing = "0.1" axum = "0.8" tower = "0.5" From fa1043c758389a3b1c194cc957b4c613f192188c Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Fri, 17 Jul 2026 04:35:56 -0700 Subject: [PATCH 02/36] chore(deps): upgrade rand to 0.10 and migrate the API rand 0.10 splits the RNG traits: the convenience methods (random, random_range, random_bool, fill, sample, sample_iter) moved to a new `RngExt` trait, while the core `Rng` trait (formerly `RngCore`, now deprecated) keeps fill_bytes/next_u*. Migration: - Method callers: `use rand::Rng` -> `use rand::RngExt` across ml (neural nets, transformers, industry models), server (postgres auth, aql, mongodb, protocol), compute benches, and orbit-util. - Byte-buffer fills (security/encryption, field_encryption): switch to the core `use rand::Rng` for fill_bytes. - `Rng::gen::()` -> `random::()` and the deprecated `choose_multiple` -> `sample` renames. - time_series_models uses Rng as a generic bound too, so imports both. Workspace `cargo check --all-targets` green, zero warnings. Note: compute/monitoring/windows.rs is cfg-gated to Windows and is verified by inspection here (not compiled on this host). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- orbit/compute/benches/columnar_analytics_bench.rs | 2 +- orbit/compute/benches/spatial_operations_bench.rs | 2 +- orbit/compute/benches/vector_similarity_bench.rs | 4 ++-- orbit/compute/src/monitoring/windows.rs | 12 ++++++------ .../cross_cutting_horizontal/anomaly_detection.rs | 2 +- .../reinforcement_learning.rs | 2 +- .../cross_cutting_horizontal/time_series_models.rs | 2 +- .../cross_cutting_horizontal/tree_ensembles.rs | 4 ++-- orbit/ml/src/neural_networks/gru.rs | 2 +- orbit/ml/src/neural_networks/lstm.rs | 2 +- orbit/ml/src/neural_networks/recurrent.rs | 2 +- orbit/ml/src/transformers/attention.rs | 2 +- orbit/server/src/protocols/aql/query_engine.rs | 4 ++-- orbit/server/src/protocols/mongodb/server.rs | 4 ++-- orbit/server/src/protocols/postgres_wire/auth.rs | 2 +- orbit/server/src/protocols/postgres_wire/protocol.rs | 2 +- .../postgres_wire/sql/expression_evaluator.rs | 2 +- orbit/shared/src/security/encryption.rs | 2 +- orbit/shared/src/security/field_encryption.rs | 2 +- orbit/util/src/misc.rs | 2 +- 22 files changed, 36 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 303b1456..2ca52bf9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7501,7 +7501,7 @@ dependencies = [ "orbit-shared", "orbit-util", "proptest", - "rand 0.9.5", + "rand 0.10.2", "raw-cpuid", "rayon", "serde", @@ -7545,7 +7545,7 @@ dependencies = [ "parking_lot", "parquet 58.3.0", "prost", - "rand 0.9.5", + "rand 0.10.2", "regex", "rocksdb", "serde", @@ -7615,7 +7615,7 @@ dependencies = [ "proptest", "prost-build", "pyo3", - "rand 0.9.5", + "rand 0.10.2", "rand_distr 0.5.1", "rayon", "rmp-serde", @@ -7736,7 +7736,7 @@ dependencies = [ "petgraph 0.8.3", "postgres-protocol", "postgres-types", - "rand 0.9.5", + "rand 0.10.2", "redis", "regex", "reqwest 0.12.28", @@ -7852,7 +7852,7 @@ dependencies = [ "paste", "prost", "prost-build", - "rand 0.9.5", + "rand 0.10.2", "rayon", "regex", "serde", @@ -7884,7 +7884,7 @@ dependencies = [ "chrono", "dashmap 6.2.1", "metrics", - "rand 0.9.5", + "rand 0.10.2", "serde", "serde_json", "thiserror 2.0.18", diff --git a/Cargo.toml b/Cargo.toml index 6adca202..30149e43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,7 +122,7 @@ mockall = "0.13" cucumber = "0.21" proptest = "1.4" tokio-test = "0.4" -rand = "0.9" +rand = "0.10" # Security patches # Note: tikv-client has been removed from dependencies due to unresolved protobuf vulnerabilities diff --git a/orbit/compute/benches/columnar_analytics_bench.rs b/orbit/compute/benches/columnar_analytics_bench.rs index accf8c18..86907492 100644 --- a/orbit/compute/benches/columnar_analytics_bench.rs +++ b/orbit/compute/benches/columnar_analytics_bench.rs @@ -7,7 +7,7 @@ use criterion::{black_box, criterion_group, criterion_main, Criterion}; use orbit_compute::columnar_analytics::{ AggregateFunction, ColumnarAnalyticsConfig, GPUColumnarAnalytics, }; -use rand::Rng; +use rand::RngExt; /// Generate random i32 values for testing fn generate_random_i32(count: usize) -> Vec { diff --git a/orbit/compute/benches/spatial_operations_bench.rs b/orbit/compute/benches/spatial_operations_bench.rs index c8147ce3..8c093bb2 100644 --- a/orbit/compute/benches/spatial_operations_bench.rs +++ b/orbit/compute/benches/spatial_operations_bench.rs @@ -7,7 +7,7 @@ use criterion::{black_box, criterion_group, criterion_main, Criterion}; use orbit_compute::spatial_operations::{ GPUPoint, GPUPolygon, GPUSpatialOperations, SpatialOperationsConfig, }; -use rand::Rng; +use rand::RngExt; /// Generate random points for testing fn generate_random_points(count: usize) -> Vec { diff --git a/orbit/compute/benches/vector_similarity_bench.rs b/orbit/compute/benches/vector_similarity_bench.rs index 422757e8..34a9a820 100644 --- a/orbit/compute/benches/vector_similarity_bench.rs +++ b/orbit/compute/benches/vector_similarity_bench.rs @@ -10,7 +10,7 @@ use orbit_compute::vector_similarity::{ /// Generate random vectors for testing fn generate_random_vectors(count: usize, dimension: usize) -> Vec> { - use rand::Rng; + use rand::RngExt; let mut rng = rand::rng(); (0..count) .map(|_| { @@ -23,7 +23,7 @@ fn generate_random_vectors(count: usize, dimension: usize) -> Vec> { /// Generate a random query vector fn generate_query_vector(dimension: usize) -> Vec { - use rand::Rng; + use rand::RngExt; let mut rng = rand::rng(); (0..dimension) .map(|_| rng.random_range(-1.0..1.0)) diff --git a/orbit/compute/src/monitoring/windows.rs b/orbit/compute/src/monitoring/windows.rs index 2cd346dc..a364f7d8 100644 --- a/orbit/compute/src/monitoring/windows.rs +++ b/orbit/compute/src/monitoring/windows.rs @@ -718,15 +718,15 @@ impl WindowsSystemMonitor { // In a real implementation, this would query performance counters // For now, we'll simulate realistic values with some variation - use rand::Rng; + use rand::RngExt; let mut rng = rand::rng(); Ok(SystemConditions { - cpu_temperature_c: Some(45.0 + rng.gen::() * 10.0), - gpu_temperature_c: Some(55.0 + rng.gen::() * 15.0), - cpu_utilization: 20.0 + rng.gen::() * 30.0, - gpu_utilization: 10.0 + rng.gen::() * 20.0, - memory_utilization: 60.0 + rng.gen::() * 20.0, + cpu_temperature_c: Some(45.0 + rng.random::() * 10.0), + gpu_temperature_c: Some(55.0 + rng.random::() * 15.0), + cpu_utilization: 20.0 + rng.random::() * 30.0, + gpu_utilization: 10.0 + rng.random::() * 20.0, + memory_utilization: 60.0 + rng.random::() * 20.0, power_state: PowerState::Balanced, thermal_throttling: false, concurrent_workloads: rng.random_range(0..4), diff --git a/orbit/ml/src/industry_models/cross_cutting_horizontal/anomaly_detection.rs b/orbit/ml/src/industry_models/cross_cutting_horizontal/anomaly_detection.rs index 5d8251b7..43073381 100644 --- a/orbit/ml/src/industry_models/cross_cutting_horizontal/anomaly_detection.rs +++ b/orbit/ml/src/industry_models/cross_cutting_horizontal/anomaly_detection.rs @@ -232,7 +232,7 @@ impl IndustryModel for IsolationForestDetector { for _ in 0..self.num_trees { // Subsample let subsample: Vec> = samples - .choose_multiple(&mut rng, self.training_samples) + .sample(&mut rng, self.training_samples) .cloned() .collect(); diff --git a/orbit/ml/src/industry_models/cross_cutting_horizontal/reinforcement_learning.rs b/orbit/ml/src/industry_models/cross_cutting_horizontal/reinforcement_learning.rs index 66b2023a..64483ced 100644 --- a/orbit/ml/src/industry_models/cross_cutting_horizontal/reinforcement_learning.rs +++ b/orbit/ml/src/industry_models/cross_cutting_horizontal/reinforcement_learning.rs @@ -10,7 +10,7 @@ use super::super::common::{IndustryModel, IndustryModelError, ModelMetrics, Result}; use rand::distr::Uniform; -use rand::Rng; +use rand::RngExt; use serde::{Deserialize, Serialize}; // ============================================================================ diff --git a/orbit/ml/src/industry_models/cross_cutting_horizontal/time_series_models.rs b/orbit/ml/src/industry_models/cross_cutting_horizontal/time_series_models.rs index e2f84cdf..93382faf 100644 --- a/orbit/ml/src/industry_models/cross_cutting_horizontal/time_series_models.rs +++ b/orbit/ml/src/industry_models/cross_cutting_horizontal/time_series_models.rs @@ -10,7 +10,7 @@ use super::super::common::{IndustryModel, IndustryModelError, ModelMetrics, Result}; use rand::distr::Uniform; -use rand::Rng; +use rand::{Rng, RngExt}; use serde::{Deserialize, Serialize}; // LSTM layer weights for time series forecasting diff --git a/orbit/ml/src/industry_models/cross_cutting_horizontal/tree_ensembles.rs b/orbit/ml/src/industry_models/cross_cutting_horizontal/tree_ensembles.rs index 2179f9ac..faa731b7 100644 --- a/orbit/ml/src/industry_models/cross_cutting_horizontal/tree_ensembles.rs +++ b/orbit/ml/src/industry_models/cross_cutting_horizontal/tree_ensembles.rs @@ -64,7 +64,7 @@ impl TreeNode { let n_features_to_try = ((n_features as f64).sqrt().ceil() as usize).max(1); let feature_indices: Vec = (0..n_features).collect(); let sampled_features: Vec = feature_indices - .choose_multiple(rng, n_features_to_try.min(n_features)) + .sample(rng, n_features_to_try.min(n_features)) .cloned() .collect(); @@ -422,7 +422,7 @@ impl IndustryModel for GradientBoostingModel { let n_subsample = ((n_samples as f64 * self.subsample) as usize).max(1); (0..n_samples) .collect::>() - .choose_multiple(&mut rng, n_subsample) + .sample(&mut rng, n_subsample) .cloned() .collect() } else { diff --git a/orbit/ml/src/neural_networks/gru.rs b/orbit/ml/src/neural_networks/gru.rs index da0a0104..d66489a9 100644 --- a/orbit/ml/src/neural_networks/gru.rs +++ b/orbit/ml/src/neural_networks/gru.rs @@ -5,7 +5,7 @@ use crate::neural_networks::{NetworkArchitecture, NeuralNetwork, Optimizer}; use async_trait::async_trait; use ndarray::{Array1, Array2, Axis}; use rand::distr::Uniform; -use rand::Rng; +use rand::RngExt; use serde::{Deserialize, Serialize}; use std::sync::{Arc, RwLock}; diff --git a/orbit/ml/src/neural_networks/lstm.rs b/orbit/ml/src/neural_networks/lstm.rs index 8e932b7c..25980a37 100644 --- a/orbit/ml/src/neural_networks/lstm.rs +++ b/orbit/ml/src/neural_networks/lstm.rs @@ -12,7 +12,7 @@ use crate::neural_networks::{NetworkArchitecture, NeuralNetwork, Optimizer}; /// dependencies through gating mechanisms (forget, input, output gates). use ndarray::{Array1, Axis}; use rand::distr::Uniform; -use rand::Rng; +use rand::RngExt; use serde::{Deserialize, Serialize}; use std::sync::{Arc, RwLock}; diff --git a/orbit/ml/src/neural_networks/recurrent.rs b/orbit/ml/src/neural_networks/recurrent.rs index 82339ce0..e4533c06 100644 --- a/orbit/ml/src/neural_networks/recurrent.rs +++ b/orbit/ml/src/neural_networks/recurrent.rs @@ -12,7 +12,7 @@ use crate::neural_networks::{NetworkArchitecture, NeuralNetwork, Optimizer}; /// sequences of data by maintaining internal state across time steps. use ndarray::{Array1, Axis}; use rand::distr::Uniform; -use rand::Rng; +use rand::RngExt; use serde::{Deserialize, Serialize}; use std::sync::{Arc, RwLock}; diff --git a/orbit/ml/src/transformers/attention.rs b/orbit/ml/src/transformers/attention.rs index 1efbf456..f6085580 100644 --- a/orbit/ml/src/transformers/attention.rs +++ b/orbit/ml/src/transformers/attention.rs @@ -5,7 +5,7 @@ //! - Multi-Head Attention //! - Sparse Attention //! - Cross Attention -use rand::Rng; +use rand::RngExt; use ndarray::{Array2, Array3, Array4}; use serde::{Deserialize, Serialize}; diff --git a/orbit/server/src/protocols/aql/query_engine.rs b/orbit/server/src/protocols/aql/query_engine.rs index 51d01d69..9fa24e70 100644 --- a/orbit/server/src/protocols/aql/query_engine.rs +++ b/orbit/server/src/protocols/aql/query_engine.rs @@ -2164,7 +2164,7 @@ impl AqlQueryEngine { } } "RAND" => { - use rand::Rng; + use rand::RngExt; let mut rng = rand::rng(); Ok(AqlValue::Number( serde_json::Number::from_f64(rng.random::()) @@ -2172,7 +2172,7 @@ impl AqlQueryEngine { )) } "RANDOM_TOKEN" => { - use rand::Rng; + use rand::RngExt; let length = args .first() .and_then(|v| { diff --git a/orbit/server/src/protocols/mongodb/server.rs b/orbit/server/src/protocols/mongodb/server.rs index 488354f5..0f4d3a64 100644 --- a/orbit/server/src/protocols/mongodb/server.rs +++ b/orbit/server/src/protocols/mongodb/server.rs @@ -4389,12 +4389,12 @@ pub(crate) fn evaluate_expression(expr: &Bson, doc: &Document) -> Bson { // Miscellaneous expressions: $rand, $meta, bitwise ops, $sortArray "$rand" => { - use rand::Rng; + use rand::RngExt; let mut rng = rand::rng(); Bson::Double(rng.random::()) } "$sampleRate" => { - use rand::Rng; + use rand::RngExt; if let Some(rate) = bson_to_f64(args) { let mut rng = rand::rng(); Bson::Boolean(rng.random::() < rate) diff --git a/orbit/server/src/protocols/postgres_wire/auth.rs b/orbit/server/src/protocols/postgres_wire/auth.rs index c793dbc9..23a2cdb7 100644 --- a/orbit/server/src/protocols/postgres_wire/auth.rs +++ b/orbit/server/src/protocols/postgres_wire/auth.rs @@ -8,7 +8,7 @@ use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use md5; -use rand::Rng; +use rand::RngExt; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::sync::Arc; diff --git a/orbit/server/src/protocols/postgres_wire/protocol.rs b/orbit/server/src/protocols/postgres_wire/protocol.rs index e38e48cd..614b47f6 100644 --- a/orbit/server/src/protocols/postgres_wire/protocol.rs +++ b/orbit/server/src/protocols/postgres_wire/protocol.rs @@ -822,7 +822,7 @@ impl Default for PostgresWireProtocol { } // Add rand dependency for secret_key generation -use rand::Rng; +use rand::RngExt; impl PostgresWireProtocol { /// Generate a random cancel key /// PostgreSQL 18 (protocol 3.2): Supports 4-256 bytes diff --git a/orbit/server/src/protocols/postgres_wire/sql/expression_evaluator.rs b/orbit/server/src/protocols/postgres_wire/sql/expression_evaluator.rs index 1b357488..d2778ac8 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/expression_evaluator.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/expression_evaluator.rs @@ -4047,7 +4047,7 @@ impl ExpressionEvaluator { use rand::seq::IndexedRandom; let mut rng = rand::rng(); let count = (*n).max(0) as usize; - let sample: Vec = arr.choose_multiple(&mut rng, count).cloned().collect(); + let sample: Vec = arr.sample(&mut rng, count).cloned().collect(); Ok(SqlValue::Array(sample)) } (SqlValue::Null, _) | (_, SqlValue::Null) => Ok(SqlValue::Null), diff --git a/orbit/shared/src/security/encryption.rs b/orbit/shared/src/security/encryption.rs index 154e74e5..2f417864 100644 --- a/orbit/shared/src/security/encryption.rs +++ b/orbit/shared/src/security/encryption.rs @@ -11,7 +11,7 @@ use aes_gcm::{ aead::{Aead, KeyInit}, Aes256Gcm, Nonce, }; -use rand::RngCore; +use rand::Rng; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; diff --git a/orbit/shared/src/security/field_encryption.rs b/orbit/shared/src/security/field_encryption.rs index abdd50b5..7e17b1e7 100644 --- a/orbit/shared/src/security/field_encryption.rs +++ b/orbit/shared/src/security/field_encryption.rs @@ -33,7 +33,7 @@ use aes_gcm::{ Aes256Gcm, Nonce, }; use base64::{engine::general_purpose::STANDARD, Engine as _}; -use rand::RngCore; +use rand::Rng; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; diff --git a/orbit/util/src/misc.rs b/orbit/util/src/misc.rs index a1a5b667..6d506821 100644 --- a/orbit/util/src/misc.rs +++ b/orbit/util/src/misc.rs @@ -1,4 +1,4 @@ -use rand::{distr::Alphanumeric, Rng}; +use rand::{distr::Alphanumeric, RngExt}; /// Utility functions for random generation, equivalent to Kotlin's RNGUtils pub struct RngUtils; From 1c84c95d744fccd266419ade10d2b1dc9d7487a7 Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Fri, 17 Jul 2026 04:41:11 -0700 Subject: [PATCH 03/36] chore(deps): upgrade mockall to 0.15 and cucumber to 0.23 (test-only) Bump the BDD/mocking dev-dependencies in both the workspace deps and tests/Cargo.toml. No source changes required: the #[given]/#[when]/ #[then] step macros, World derive/run, and mockall predicates are source-compatible across these versions. Pulls in transitive derive_more 2.1 / gherkin 0.16. orbit-integration-tests `cargo check --all-targets` green. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 203 ++++++++++++++++++++++------------------------- Cargo.toml | 4 +- tests/Cargo.toml | 4 +- 3 files changed, 101 insertions(+), 110 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2ca52bf9..9906e3f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -731,7 +731,7 @@ dependencies = [ "memchr", "num-traits", "regex", - "regex-syntax 0.8.11", + "regex-syntax", ] [[package]] @@ -748,7 +748,7 @@ dependencies = [ "memchr", "num-traits", "regex", - "regex-syntax 0.8.11", + "regex-syntax", ] [[package]] @@ -1784,7 +1784,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -2024,7 +2024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139" dependencies = [ "async-trait", - "convert_case", + "convert_case 0.6.0", "json5", "pathdiff", "ron", @@ -2039,15 +2039,14 @@ dependencies = [ [[package]] name = "console" -version = "0.15.11" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", - "once_cell", "unicode-width 0.2.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2091,6 +2090,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -2600,9 +2608,9 @@ dependencies = [ [[package]] name = "cucumber" -version = "0.21.1" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cd12917efc3a8b069a4975ef3cb2f2d835d42d04b3814d90838488f9dd9bf69" +checksum = "96a87e18d925b19ebe0fd47ea45316abd216d81ec0879c2448c3f9a0e9da62be" dependencies = [ "anyhow", "clap", @@ -2610,18 +2618,16 @@ dependencies = [ "cucumber-codegen", "cucumber-expressions", "derive_more", - "drain_filter_polyfill", "either", "futures", "gherkin", "globwalk", "humantime", "inventory", - "itertools 0.13.0", - "lazy-regex", + "itertools 0.14.0", "linked-hash-map", - "once_cell", "pin-project", + "ref-cast", "regex", "sealed", "smart-default", @@ -2629,13 +2635,13 @@ dependencies = [ [[package]] name = "cucumber-codegen" -version = "0.21.1" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e19cd9e8e7cfd79fbf844eb6a7334117973c01f6bad35571262b00891e60f1c" +checksum = "ed2fc8a8bbb73af3230db699e8690c5c786655f75eb89e5f18d76055fa1a9a4d" dependencies = [ "cucumber-expressions", "inflections", - "itertools 0.13.0", + "itertools 0.14.0", "proc-macro2", "quote", "regex", @@ -2645,16 +2651,16 @@ dependencies = [ [[package]] name = "cucumber-expressions" -version = "0.3.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d794fed319eea24246fb5f57632f7ae38d61195817b7eb659455aa5bdd7c1810" +checksum = "6401038de3af44fe74e6fccdb8a5b7db7ba418f480c8e9ad584c6f65c05a27a6" dependencies = [ "derive_more", "either", - "nom", + "nom 8.0.0", "nom_locate", "regex", - "regex-syntax 0.7.5", + "regex-syntax", ] [[package]] @@ -2987,13 +2993,25 @@ dependencies = [ [[package]] name = "derive_more" -version = "0.99.20" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", "proc-macro2", "quote", + "rustc_version 0.4.1", "syn 2.0.119", + "unicode-xid", ] [[package]] @@ -3125,12 +3143,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "drain_filter_polyfill" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" - [[package]] name = "dtor" version = "0.1.1" @@ -3275,7 +3287,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4be2cf2fe7b971b1865febbacd4d8df544aa6bd377cca011a6d69dcf4c60d94" dependencies = [ - "convert_case", + "convert_case 0.6.0", "quote", "syn 1.0.109", ] @@ -4251,19 +4263,19 @@ dependencies = [ [[package]] name = "gherkin" -version = "0.14.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20b79820c0df536d1f3a089a2fa958f61cb96ce9e0f3f8f507f5a31179567755" +checksum = "9e2c0d8c632f8a251ce9a8198079b1022adc586ff4e3d33e18debd40eb463b31" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "peg", "quote", "serde", "serde_json", "syn 2.0.119", "textwrap", - "thiserror 1.0.69", - "typed-builder 0.15.2", + "thiserror 2.0.18", + "typed-builder 0.23.2", ] [[package]] @@ -4402,7 +4414,7 @@ dependencies = [ "bstr", "log", "regex-automata", - "regex-syntax 0.8.11", + "regex-syntax", ] [[package]] @@ -5739,29 +5751,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "lazy-regex" -version = "3.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496" -dependencies = [ - "lazy-regex-proc_macros", - "once_cell", - "regex", -] - -[[package]] -name = "lazy-regex-proc_macros" -version = "3.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358" -dependencies = [ - "proc-macro2", - "quote", - "regex", - "syn 2.0.119", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -6438,9 +6427,9 @@ dependencies = [ [[package]] name = "mockall" -version = "0.13.1" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +checksum = "1a6ceddfe3ce334925e96bf420fdb2dcee5bed6c632a168ece622676dadeaf8a" dependencies = [ "cfg-if", "downcast", @@ -6452,9 +6441,9 @@ dependencies = [ [[package]] name = "mockall_derive" -version = "0.13.1" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +checksum = "9cfe16fbe8a314aeec0b861ac24e60b1e123e97634bab045475b9d6a18416fd8" dependencies = [ "cfg-if", "proc-macro2", @@ -6728,15 +6717,24 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nom_locate" -version = "4.2.0" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" dependencies = [ "bytecount", "memchr", - "nom", + "nom 8.0.0", ] [[package]] @@ -7722,7 +7720,7 @@ dependencies = [ "metrics-exporter-prometheus", "mlua", "mockall", - "nom", + "nom 7.1.3", "orbit-client", "orbit-compute", "orbit-engine", @@ -8659,7 +8657,7 @@ dependencies = [ "rand 0.9.5", "rand_chacha 0.9.0", "rand_xorshift", - "regex-syntax 0.8.11", + "regex-syntax", "rusty-fork", "tempfile", "unarray", @@ -9382,7 +9380,7 @@ dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax 0.8.11", + "regex-syntax", ] [[package]] @@ -9393,7 +9391,7 @@ checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.11", + "regex-syntax", ] [[package]] @@ -9402,12 +9400,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" -[[package]] -name = "regex-syntax" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" - [[package]] name = "regex-syntax" version = "0.8.11" @@ -10213,11 +10205,10 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" [[package]] name = "sealed" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a8caec23b7800fb97971a1c6ae365b6239aaeddfb934d6265f8505e795699d" +checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" dependencies = [ - "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.119", @@ -10738,7 +10729,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" dependencies = [ "base64 0.13.1", - "nom", + "nom 7.1.3", "serde", "unicode-segmentation", ] @@ -11077,7 +11068,7 @@ dependencies = [ "once_cell", "onig", "plist", - "regex-syntax 0.8.11", + "regex-syntax", "serde", "serde_derive", "serde_json", @@ -11088,9 +11079,9 @@ dependencies = [ [[package]] name = "synthez" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d2c2202510a1e186e63e596d9318c91a8cbe85cd1a56a7be0c333e5f59ec8d" +checksum = "6d8a928f38f1bc873f28e0d2ba8298ad65374a6ac2241dabd297271531a736cd" dependencies = [ "syn 2.0.119", "synthez-codegen", @@ -11099,9 +11090,9 @@ dependencies = [ [[package]] name = "synthez-codegen" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f724aa6d44b7162f3158a57bccd871a77b39a4aef737e01bcdff41f4772c7746" +checksum = "8fb83b8df4238e11746984dfb3819b155cd270de0e25847f45abad56b3671047" dependencies = [ "syn 2.0.119", "synthez-core", @@ -11109,9 +11100,9 @@ dependencies = [ [[package]] name = "synthez-core" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bfa6ec52465e2425fd43ce5bbbe0f0b623964f7c63feb6b10980e816c654ea" +checksum = "906fba967105d822e7c7ed60477b5e76116724d33de68a585681fb253fc30d5c" dependencies = [ "proc-macro2", "quote", @@ -11297,7 +11288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" dependencies = [ "byteorder", - "regex-syntax 0.8.11", + "regex-syntax", "utf8-ranges", ] @@ -11318,7 +11309,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "847434d4af57b32e309f4ab1b4f1707a6c566656264caa427ff4285c4d9d0b82" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -11580,7 +11571,7 @@ dependencies = [ "rayon", "rayon-cond", "regex", - "regex-syntax 0.8.11", + "regex-syntax", "serde", "serde_json", "spm_precompiled", @@ -12151,15 +12142,6 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" -[[package]] -name = "typed-builder" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe83c85a85875e8c4cb9ce4a890f05b23d38cd0d47647db7895d3d2a79566d2" -dependencies = [ - "typed-builder-macro 0.15.2", -] - [[package]] name = "typed-builder" version = "0.19.1" @@ -12179,14 +12161,12 @@ dependencies = [ ] [[package]] -name = "typed-builder-macro" -version = "0.15.2" +name = "typed-builder" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29a3151c41d0b13e3d011f98adc24434560ef06673a155a6c7f66b9879eecce2" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "typed-builder-macro 0.23.2", ] [[package]] @@ -12211,6 +12191,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "typed-path" version = "0.12.3" @@ -12563,7 +12554,7 @@ dependencies = [ "heck 0.4.1", "indexmap 2.14.0", "libloading 0.8.9", - "nom", + "nom 7.1.3", "once_cell", "parking_lot", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 30149e43..c5dbc948 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -118,8 +118,8 @@ paste = "1.0" exponential-backoff = "2.0" # Replaces unmaintained backoff # Testing -mockall = "0.13" -cucumber = "0.21" +mockall = "0.15" +cucumber = "0.23" proptest = "1.4" tokio-test = "0.4" rand = "0.10" diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 4828c969..e129a2ff 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -20,9 +20,9 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" anyhow = "1.0" uuid = { version = "1.19", features = ["v4"] } -cucumber = "0.21" +cucumber = "0.23" proptest = { version = "1.4", optional = true } -mockall = "0.13" +mockall = "0.15" rand = "0.8" futures = "0.3" From 1f2815ce26d178458fcbbe7d57f69a0d0f7200de Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Fri, 17 Jul 2026 04:42:13 -0700 Subject: [PATCH 04/36] chore(deps): upgrade pyo3 to 0.29 (optional python feature) pyo3 is optional and gated behind orbit-ml's `python` feature. The only usage is `MLError::Python(#[from] pyo3::PyErr)`, which is unchanged in 0.29. Verified with `cargo check -p orbit-ml --features python`. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 41 ++++++++++------------------------------- Cargo.toml | 2 +- 2 files changed, 11 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9906e3f7..356c6336 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5236,15 +5236,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - [[package]] name = "inflections" version = "1.1.1" @@ -8838,37 +8829,32 @@ checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" [[package]] name = "pyo3" -version = "0.24.2" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5203598f366b11a02b13aa20cab591229ff0a89fd121a308a5df751d5fc9219" +checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c" dependencies = [ - "cfg-if", - "indoc", "libc", - "memoffset", "once_cell", "portable-atomic", "pyo3-build-config", "pyo3-ffi", "pyo3-macros", - "unindent", ] [[package]] name = "pyo3-build-config" -version = "0.24.2" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999" +checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078" dependencies = [ - "once_cell", "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.24.2" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33" +checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b" dependencies = [ "libc", "pyo3-build-config", @@ -8876,9 +8862,9 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.24.2" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b999cb1a6ce21f9a6b147dcf1be9ffedf02e0043aec74dc390f3007047cecd9" +checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -8888,13 +8874,12 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.24.2" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a" +checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362" dependencies = [ "heck 0.5.0", "proc-macro2", - "pyo3-build-config", "quote", "syn 2.0.119", ] @@ -12382,12 +12367,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" -[[package]] -name = "unindent" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" - [[package]] name = "universal-hash" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index c5dbc948..f0291fc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,7 +65,7 @@ repository = "https://github.com/TuringWorks/orbit-rs" cognitive-complexity-threshold = 15 [workspace.dependencies] -pyo3 = "0.24.1" +pyo3 = "0.29" # Kubernetes operator dependencies kube = "0.99" k8s-openapi = "0.24" From 2843fa79ae3765e353a8e011506d4b864fd0fff3 Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Fri, 17 Jul 2026 06:24:05 -0700 Subject: [PATCH 05/36] refactor(shared): functional idioms in error module + tests First increment of the incremental functional-style refactor, on the highest-leverage module (orbit-shared error handling). Behaviour is preserved; verified by the existing 12 tests plus 5 new ones (17 total, all green). - Independent generic type params on the paired constructors (configuration_with_key, internal_with_context, io_with_source, parse_with_input, parse_with_position, storage_with_operation, auth_with_user) so callers can mix &str and String instead of being forced to a single Into type. - SecurityValidator: replace per-call pattern arrays with module-level consts, and the imperative for/return loops with an iterator-combinator helper (reject_if_contains). String and &str now delegate to shared pure functions, removing the &str path's needless to_string() alloc. - Add tests: mixed-type constructors, &str validation, matched-pattern naming, boundary/empty-input edge cases, and str/String agreement. orbit-shared: cargo test (17 error tests) green, clippy clean, fmt clean; workspace check green (widened signatures are backward compatible). Co-Authored-By: Claude Opus 4.8 (1M context) --- orbit/shared/src/error.rs | 232 ++++++++++++++++++++++++++------------ 1 file changed, 163 insertions(+), 69 deletions(-) diff --git a/orbit/shared/src/error.rs b/orbit/shared/src/error.rs index 7981f59b..67cc8436 100644 --- a/orbit/shared/src/error.rs +++ b/orbit/shared/src/error.rs @@ -130,7 +130,7 @@ impl OrbitError { } /// Create a configuration error with key - pub fn configuration_with_key>(msg: S, key: S) -> Self { + pub fn configuration_with_key, K: Into>(msg: M, key: K) -> Self { OrbitError::ConfigurationError { message: msg.into(), key: Some(key.into()), @@ -151,7 +151,7 @@ impl OrbitError { } /// Create an internal error with context - pub fn internal_with_context>(msg: S, context: S) -> Self { + pub fn internal_with_context, C: Into>(msg: M, context: C) -> Self { OrbitError::Internal { message: msg.into(), context: Some(context.into()), @@ -167,7 +167,7 @@ impl OrbitError { } /// Create an IO error with source - pub fn io_with_source>(msg: S, source: S) -> Self { + pub fn io_with_source, S: Into>(msg: M, source: S) -> Self { OrbitError::IoError { message: msg.into(), source_info: Some(source.into()), @@ -184,7 +184,7 @@ impl OrbitError { } /// Create a parse error with input - pub fn parse_with_input>(msg: S, input: S) -> Self { + pub fn parse_with_input, I: Into>(msg: M, input: I) -> Self { OrbitError::ParseError { message: msg.into(), input: Some(input.into()), @@ -193,7 +193,11 @@ impl OrbitError { } /// Create a parse error with input and position - pub fn parse_with_position>(msg: S, input: S, position: usize) -> Self { + pub fn parse_with_position, I: Into>( + msg: M, + input: I, + position: usize, + ) -> Self { OrbitError::ParseError { message: msg.into(), input: Some(input.into()), @@ -210,7 +214,7 @@ impl OrbitError { } /// Create a storage error with operation - pub fn storage_with_operation>(msg: S, operation: S) -> Self { + pub fn storage_with_operation, O: Into>(msg: M, operation: O) -> Self { OrbitError::StorageError { message: msg.into(), operation: Some(operation.into()), @@ -226,7 +230,7 @@ impl OrbitError { } /// Create an authentication error with user - pub fn auth_with_user>(msg: S, user: S) -> Self { + pub fn auth_with_user, U: Into>(msg: M, user: U) -> Self { OrbitError::AuthError { message: msg.into(), user: Some(user.into()), @@ -376,103 +380,121 @@ impl ErrorLog for OrbitResult { // ===== Security Validation Traits ===== -/// Security-focused input validation traits +/// SQL keywords and comment/terminator sequences treated as potentially dangerous +/// when they appear in untrusted input. Compared case-insensitively. +const DANGEROUS_SQL_PATTERNS: [&str; 12] = [ + "DROP", "DELETE", "INSERT", "UPDATE", "UNION", "SELECT", "--", "/*", "*/", ";", "xp_", "sp_", +]; + +/// Markup and script sequences treated as potentially dangerous (XSS) when they +/// appear in untrusted input. Compared case-insensitively. +const DANGEROUS_XSS_PATTERNS: [&str; 8] = [ + "", + "javascript:", + "vbscript:", + "onload=", + "onerror=", + "onclick=", + "onmouseover=", +]; + +/// Reject `haystack` if it contains any of `patterns`, naming the first match in the +/// error. `haystack` must already be normalised to the case the patterns are written +/// in. Pure helper shared by every [`SecurityValidator`] implementation. +fn reject_if_contains(haystack: &str, patterns: &[&str], kind: &str) -> OrbitResult<()> { + patterns + .iter() + .find(|pattern| haystack.contains(**pattern)) + .map_or(Ok(()), |pattern| { + Err(OrbitError::internal(format!( + "Input contains potentially dangerous {kind} pattern: {pattern}" + ))) + }) +} + +fn validate_sql_safe_str(input: &str) -> OrbitResult<()> { + reject_if_contains(&input.to_uppercase(), &DANGEROUS_SQL_PATTERNS, "SQL") +} + +fn validate_xss_safe_str(input: &str) -> OrbitResult<()> { + reject_if_contains(&input.to_lowercase(), &DANGEROUS_XSS_PATTERNS, "XSS") +} + +fn validate_length_str(input: &str, max_len: usize) -> OrbitResult<()> { + if input.len() <= max_len { + Ok(()) + } else { + Err(OrbitError::internal(format!( + "Input length {} exceeds maximum allowed length {}", + input.len(), + max_len + ))) + } +} + +fn validate_allowed_chars_str(input: &str, allowed_pattern: &str) -> OrbitResult<()> { + let regex = regex::Regex::new(allowed_pattern) + .map_err(|e| OrbitError::internal(format!("Invalid regex pattern: {e}")))?; + if regex.is_match(input) { + Ok(()) + } else { + Err(OrbitError::internal(format!( + "Input contains invalid characters. Allowed pattern: {allowed_pattern}" + ))) + } +} + +/// Security-focused input validation, implemented for the common owned and borrowed +/// string types so a check reads the same whether the caller holds a `String` or a +/// `&str`. All implementations delegate to the same pure functions above. pub trait SecurityValidator { - /// Validate input for SQL injection patterns + /// Reject input containing SQL keywords or comment/terminator sequences. fn validate_sql_safe(&self) -> OrbitResult<()>; - /// Validate input for XSS patterns + /// Reject input containing markup/script (XSS) sequences. fn validate_xss_safe(&self) -> OrbitResult<()>; - /// Validate input length constraints + /// Reject input longer than `max_len` bytes. fn validate_length(&self, max_len: usize) -> OrbitResult<()>; - /// Validate input against allowed characters + /// Reject input that does not fully match `allowed_pattern` (a regex). fn validate_allowed_chars(&self, allowed_pattern: &str) -> OrbitResult<()>; } impl SecurityValidator for String { fn validate_sql_safe(&self) -> OrbitResult<()> { - let dangerous_patterns = [ - "DROP", "DELETE", "INSERT", "UPDATE", "UNION", "SELECT", "--", "/*", "*/", ";", "xp_", - "sp_", - ]; - - let upper_self = self.to_uppercase(); - for pattern in &dangerous_patterns { - if upper_self.contains(pattern) { - return Err(OrbitError::internal(format!( - "Input contains potentially dangerous SQL pattern: {pattern}" - ))); - } - } - Ok(()) + validate_sql_safe_str(self) } fn validate_xss_safe(&self) -> OrbitResult<()> { - let dangerous_patterns = [ - "", - "javascript:", - "vbscript:", - "onload=", - "onerror=", - "onclick=", - "onmouseover=", - ]; - - let lower_self = self.to_lowercase(); - for pattern in &dangerous_patterns { - if lower_self.contains(pattern) { - return Err(OrbitError::internal(format!( - "Input contains potentially dangerous XSS pattern: {pattern}" - ))); - } - } - Ok(()) + validate_xss_safe_str(self) } fn validate_length(&self, max_len: usize) -> OrbitResult<()> { - if self.len() > max_len { - return Err(OrbitError::internal(format!( - "Input length {} exceeds maximum allowed length {}", - self.len(), - max_len - ))); - } - Ok(()) + validate_length_str(self, max_len) } fn validate_allowed_chars(&self, allowed_pattern: &str) -> OrbitResult<()> { - use regex::Regex; - - let regex = Regex::new(allowed_pattern) - .map_err(|e| OrbitError::internal(format!("Invalid regex pattern: {e}")))?; - - if !regex.is_match(self) { - return Err(OrbitError::internal(format!( - "Input contains invalid characters. Allowed pattern: {allowed_pattern}" - ))); - } - Ok(()) + validate_allowed_chars_str(self, allowed_pattern) } } impl SecurityValidator for &str { fn validate_sql_safe(&self) -> OrbitResult<()> { - self.to_string().validate_sql_safe() + validate_sql_safe_str(self) } fn validate_xss_safe(&self) -> OrbitResult<()> { - self.to_string().validate_xss_safe() + validate_xss_safe_str(self) } fn validate_length(&self, max_len: usize) -> OrbitResult<()> { - self.to_string().validate_length(max_len) + validate_length_str(self, max_len) } fn validate_allowed_chars(&self, allowed_pattern: &str) -> OrbitResult<()> { - self.to_string().validate_allowed_chars(allowed_pattern) + validate_allowed_chars_str(self, allowed_pattern) } } @@ -674,4 +696,76 @@ mod tests { let size = std::mem::size_of::(); assert!(size <= 256, "OrbitError is too large: {} bytes", size); } + + #[test] + fn test_paired_constructors_accept_mixed_string_types() { + // Independent generic params: the message and the second argument need not + // be the same string type, so callers can freely mix &str and String. + let a = OrbitError::configuration_with_key("missing", String::from("db.url")); + assert!(matches!( + a, + OrbitError::ConfigurationError { key: Some(_), .. } + )); + + let b = OrbitError::io_with_source(String::from("read failed"), "disk"); + assert!(matches!( + b, + OrbitError::IoError { + source_info: Some(_), + .. + } + )); + + let c = OrbitError::parse_with_position("bad token", String::from("SELECT"), 3); + assert!(matches!( + c, + OrbitError::ParseError { + position: Some(3), + .. + } + )); + + let d = OrbitError::auth_with_user("denied", String::from("alice")); + assert!(d.to_string().contains("alice")); + } + + #[test] + fn test_security_validator_works_on_str_slices() { + // The &str impl now shares the String logic without an intermediate alloc. + assert!("user_name".validate_sql_safe().is_ok()); + assert!("'; DROP TABLE users; --".validate_sql_safe().is_err()); + assert!("".validate_xss_safe().is_err()); + assert!("safe".validate_length(10).is_ok()); + } + + #[test] + fn test_validator_error_names_the_matched_pattern() { + let err = "value UNION select".validate_sql_safe().unwrap_err(); + assert!(err.to_string().contains("UNION")); + } + + #[test] + fn test_validator_edge_cases() { + // Empty input is trivially safe; length check is inclusive at the boundary. + assert!("".validate_sql_safe().is_ok()); + assert!("".validate_xss_safe().is_ok()); + assert!("abcd".validate_length(4).is_ok()); + assert!("abcde".validate_length(4).is_err()); + } + + #[test] + fn test_str_and_string_validators_agree() { + for case in ["clean", "DROP", "onload=", ""] { + assert_eq!( + case.validate_sql_safe().is_ok(), + case.to_string().validate_sql_safe().is_ok(), + "SQL verdict differs for {case:?}" + ); + assert_eq!( + case.validate_xss_safe().is_ok(), + case.to_string().validate_xss_safe().is_ok(), + "XSS verdict differs for {case:?}" + ); + } + } } From c1f2efe135399412ebcb99a16b1831f4ae80ecff Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Fri, 17 Jul 2026 07:50:44 -0700 Subject: [PATCH 06/36] refactor(shared): idiomatic conversions/constructors for addressable types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second refactor increment, on the core addressing types. Purely additive and behaviour-preserving (existing 13 tests untouched, 4 new = 17 total, all green; clippy + fmt clean). - `From<&str>`, `From`, `From`, `From` for `Key`, plus `Key::string/int32/int64` constructors and `Key::is_no_key()`. - `AddressableReference::new(type, impl Into)` — build a reference from a &str/String/i32/i64/Key without a struct literal. - `NamespacedAddressableReference::new(..)` and a `Display` impl (`namespace/type:key`) for parity with `AddressableReference`. Applies the "conversions via traits" / `impl Into<_>` idioms from the Code Design Principles so call sites can drop the verbose enum-struct literals. Existing struct-literal construction still compiles. Co-Authored-By: Claude Opus 4.8 (1M context) --- orbit/shared/src/addressable.rs | 127 ++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/orbit/shared/src/addressable.rs b/orbit/shared/src/addressable.rs index 4f2c32eb..5081af72 100644 --- a/orbit/shared/src/addressable.rs +++ b/orbit/shared/src/addressable.rs @@ -24,6 +24,54 @@ impl fmt::Display for Key { } } +impl Key { + /// Construct a string key from anything convertible into a `String`. + pub fn string(key: impl Into) -> Self { + Key::StringKey { key: key.into() } + } + + /// Construct a 32-bit integer key. + pub fn int32(key: i32) -> Self { + Key::Int32Key { key } + } + + /// Construct a 64-bit integer key. + pub fn int64(key: i64) -> Self { + Key::Int64Key { key } + } + + /// Returns `true` if this is [`Key::NoKey`]. + pub fn is_no_key(&self) -> bool { + matches!(self, Key::NoKey) + } +} + +impl From<&str> for Key { + fn from(key: &str) -> Self { + Key::StringKey { + key: key.to_owned(), + } + } +} + +impl From for Key { + fn from(key: String) -> Self { + Key::StringKey { key } + } +} + +impl From for Key { + fn from(key: i32) -> Self { + Key::Int32Key { key } + } +} + +impl From for Key { + fn from(key: i64) -> Self { + Key::Int64Key { key } + } +} + /// Reference to an addressable (type + key) #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct AddressableReference { @@ -31,6 +79,17 @@ pub struct AddressableReference { pub key: Key, } +impl AddressableReference { + /// Build a reference from an addressable type name and any value convertible + /// into a [`Key`] (`&str`, `String`, `i32`, `i64`, or an existing `Key`). + pub fn new(addressable_type: impl Into, key: impl Into) -> Self { + Self { + addressable_type: addressable_type.into(), + key: key.into(), + } + } +} + impl fmt::Display for AddressableReference { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}:{}", self.addressable_type, self.key) @@ -44,6 +103,22 @@ pub struct NamespacedAddressableReference { pub addressable_reference: AddressableReference, } +impl NamespacedAddressableReference { + /// Build a namespaced reference from a namespace and an existing reference. + pub fn new(namespace: impl Into, addressable_reference: AddressableReference) -> Self { + Self { + namespace: namespace.into(), + addressable_reference, + } + } +} + +impl fmt::Display for NamespacedAddressableReference { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{}", self.namespace, self.addressable_reference) + } +} + /// Arguments for an addressable invocation /// Each argument is a tuple of (value, type_name) for serialization purposes pub type AddressableInvocationArguments = Vec; @@ -429,4 +504,56 @@ mod tests { fn test_addressable_trait() { assert_eq!(TestActor::addressable_type(), "TestActor"); } + + #[test] + fn test_key_from_conversions() { + assert_eq!(Key::from("abc"), Key::StringKey { key: "abc".into() }); + assert_eq!(Key::from(String::from("abc")), Key::string("abc")); + assert_eq!(Key::from(42i32), Key::int32(42)); + assert_eq!(Key::from(42i64), Key::int64(42)); + } + + #[test] + fn test_key_constructors_and_is_no_key() { + assert_eq!(Key::string("x").to_string(), "x"); + assert_eq!(Key::int32(7).to_string(), "7"); + assert_eq!(Key::int64(-1).to_string(), "-1"); + assert!(Key::NoKey.is_no_key()); + assert!(!Key::string("x").is_no_key()); + } + + #[test] + fn test_addressable_reference_new_accepts_any_key_type() { + // `impl Into` lets &str / i32 / i64 / Key all be passed directly. + assert_eq!( + AddressableReference::new("StringActor", "test").to_string(), + "StringActor:test" + ); + assert_eq!( + AddressableReference::new("Int32Actor", 42i32).to_string(), + "Int32Actor:42" + ); + assert_eq!( + AddressableReference::new("SingletonActor", Key::NoKey).to_string(), + "SingletonActor:no-key" + ); + // Equivalent to the struct-literal form it replaces. + assert_eq!( + AddressableReference::new("A", "k"), + AddressableReference { + addressable_type: "A".to_string(), + key: Key::StringKey { + key: "k".to_string() + }, + } + ); + } + + #[test] + fn test_namespaced_reference_new_and_display() { + let ns = + NamespacedAddressableReference::new("prod", AddressableReference::new("Bank", 7i64)); + assert_eq!(ns.namespace, "prod"); + assert_eq!(ns.to_string(), "prod/Bank:7"); + } } From 8371737619927a208f8da24dc07abccf7abd5680 Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Fri, 17 Jul 2026 07:54:51 -0700 Subject: [PATCH 07/36] refactor(shared): functional style in event_sourcing replay/query Third refactor increment, replacing imperative accumulation with iterator combinators. Behaviour-preserving (existing 7 tests untouched, 1 new = 8 total, all green; clippy + fmt clean). - get_events_by_type: `let mut result = Vec::new(); for .. { result.extend }` becomes a single `values().flat_map(..).collect()`. - rebuild_state: the two branches each ran an identical `for event in events { state = apply_event(state, &event)? }` loop. Collapse to computing the replay start sequence once (Option::map over the snapshot) and a single point-free `events.iter().try_fold(initial, apply_event)`, removing the duplication and the `mut state`. - Add a test pinning try_fold's short-circuit: an apply_event error now propagates out of rebuild_state. Co-Authored-By: Claude Opus 4.8 (1M context) --- orbit/shared/src/event_sourcing.rs | 67 +++++++++++++++++------------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/orbit/shared/src/event_sourcing.rs b/orbit/shared/src/event_sourcing.rs index 99ae3565..73910f98 100644 --- a/orbit/shared/src/event_sourcing.rs +++ b/orbit/shared/src/event_sourcing.rs @@ -229,15 +229,15 @@ impl EventStore { pub async fn get_events_by_type(&self, aggregate_type: &str) -> OrbitResult> { let events = self.events.read().await; - let mut result = Vec::new(); - for aggregate_events in events.values() { - result.extend( + let mut result: Vec = events + .values() + .flat_map(|aggregate_events| { aggregate_events .iter() .filter(|e| e.aggregate_type == aggregate_type) - .cloned(), - ); - } + .cloned() + }) + .collect(); // Sort by timestamp result.sort_by_key(|e| e.timestamp); @@ -280,32 +280,17 @@ impl EventStore { T: Clone, F: Fn(T, &DomainEvent) -> OrbitResult, { - let mut state = initial_state; - - // Check for snapshot first - if let Some(snapshot) = self.get_snapshot(aggregate_id).await { - // In a real implementation, we would deserialize state from snapshot - // For now, we'll start from initial state + // Replay from just after a snapshot's sequence when one exists, otherwise + // from the beginning. (Snapshot state deserialization is not yet implemented, + // so we always begin from `initial_state` and replay the remaining events.) + let from_sequence = self.get_snapshot(aggregate_id).await.map(|snapshot| { debug!("Using snapshot at sequence {}", snapshot.last_sequence); + snapshot.last_sequence + 1 + }); - // Get events after snapshot - let events = self - .get_events(aggregate_id, Some(snapshot.last_sequence + 1)) - .await?; - - for event in events { - state = apply_event(state, &event)?; - } - } else { - // No snapshot, replay all events - let events = self.get_events(aggregate_id, None).await?; - - for event in events { - state = apply_event(state, &event)?; - } - } + let events = self.get_events(aggregate_id, from_sequence).await?; - Ok(state) + events.iter().try_fold(initial_state, apply_event) } /// Get event store statistics @@ -469,6 +454,30 @@ mod tests { assert_eq!(final_state, 15); // 1+2+3+4+5 } + #[tokio::test] + async fn test_rebuild_state_propagates_apply_error() { + let store = EventStore::new(EventStoreConfig::default()); + for i in 1..=3 { + let event = DomainEvent::new( + "counter-err".to_string(), + "Counter".to_string(), + "Incremented".to_string(), + serde_json::json!({ "amount": i }), + ); + store.append_event(event).await.unwrap(); + } + + // try_fold must short-circuit on the first Err returned by apply_event. + let result: OrbitResult = store + .rebuild_state("counter-err", 0i64, |_state, _event| { + Err(crate::error::OrbitError::internal("apply failed")) + }) + .await; + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("apply failed")); + } + #[test] fn test_domain_event_creation() { let event = DomainEvent::new( From 7f6818ff50cd5f6c54975662c4c12fd390a848a4 Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Fri, 17 Jul 2026 18:49:44 -0700 Subject: [PATCH 08/36] refactor(shared): data-driven attack detection in security_patterns Fourth refactor increment. Behaviour-preserving (5 existing tests untouched, 1 new = 6 total, all green; clippy + fmt clean). - Pattern compilation: `let mut v = Vec::new(); for .. { v.push(Regex::new (..)?) }` becomes `patterns.into_iter().map(..).collect::, _>>()?`. - Attack detection previously coupled the attack type and confidence to each pattern's array index via `match i { 0 => .. }` and `0.8 + i*0.05`. Store `(attack_type, confidence, regex)` tuples instead so the classification data lives next to the pattern, and detect with a single `iter().find(|(_, _, re)| re.is_match(input))`. Same types and confidences (0.80/0.85/0.90/0.95), same first-match semantics. - Add a test asserting xss_attempt and path_traversal classify correctly. Co-Authored-By: Claude Opus 4.8 (1M context) --- orbit/shared/src/security_patterns.rs | 95 +++++++++++++++++---------- 1 file changed, 60 insertions(+), 35 deletions(-) diff --git a/orbit/shared/src/security_patterns.rs b/orbit/shared/src/security_patterns.rs index cce8089b..e5e9ed65 100644 --- a/orbit/shared/src/security_patterns.rs +++ b/orbit/shared/src/security_patterns.rs @@ -339,33 +339,50 @@ impl SecurityAuditLogger { /// Attack detection patterns pub struct AttackDetector { - suspicious_patterns: Vec, + /// Each suspicious pattern paired with the attack type and confidence it + /// indicates, so classification does not depend on the vector's ordering. + suspicious_patterns: Vec<(&'static str, f32, regex::Regex)>, rate_limiter: RateLimiter, } impl AttackDetector { pub fn new() -> Result { - let patterns = vec![ - // SQL injection patterns - r"(?i)(union|select|insert|delete|drop|alter|create|exec|execute)", - // XSS patterns - r"(?i)(, _>>()?; Ok(Self { - suspicious_patterns: compiled_patterns, + suspicious_patterns, rate_limiter: RateLimiter::new(100, Duration::from_secs(60)), // 100 requests per minute }) } @@ -386,24 +403,18 @@ impl AttackDetector { }); } - // Check for suspicious patterns - for (i, pattern) in self.suspicious_patterns.iter().enumerate() { - if pattern.is_match(input) { - let attack_type = match i { - 0 => "sql_injection", - 1 => "xss_attempt", - 2 => "command_injection", - 3 => "path_traversal", - _ => "unknown", - }; - - return Ok(AttackDetectionResult { - is_attack: true, - attack_type: attack_type.to_string(), - confidence: 0.8 + (i as f32 * 0.05), // Varying confidence levels - details: format!("Suspicious pattern detected: {}", pattern.as_str()), - }); - } + // Return the first pattern that matches, with its paired attack type. + if let Some((attack_type, confidence, pattern)) = self + .suspicious_patterns + .iter() + .find(|(_, _, pattern)| pattern.is_match(input)) + { + return Ok(AttackDetectionResult { + is_attack: true, + attack_type: (*attack_type).to_string(), + confidence: *confidence, + details: format!("Suspicious pattern detected: {}", pattern.as_str()), + }); } Ok(AttackDetectionResult { @@ -511,6 +522,20 @@ mod tests { assert!(!result.is_attack); } + #[tokio::test] + async fn test_attack_detector_classifies_each_type() { + let detector = AttackDetector::new().unwrap(); + let cases = [ + ("", "xss_attempt"), + ("../../etc/passwd", "path_traversal"), + ]; + for (input, expected) in cases { + let result = detector.detect_attack(input, "client").await.unwrap(); + assert!(result.is_attack, "expected an attack for {input:?}"); + assert_eq!(result.attack_type, expected, "wrong type for {input:?}"); + } + } + #[test] fn test_security_audit_logger() { let logger = SecurityAuditLogger::new(); From 568fdba2bbff9e020efdd627bc14258f1f28dbaa Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Fri, 17 Jul 2026 18:51:03 -0700 Subject: [PATCH 09/36] refactor(shared): collect fallible query rows in transaction_log Fifth refactor increment. Behaviour-preserving (existing 3 tests green; clippy + fmt clean). Both get_entries_by_transaction and get_entries_by_time_range built their result with `let mut entries = Vec::new(); for row in rows { entries.push (self.row_to_persistent_entry(row)?) }`. Replace each with the fallible collect idiom `rows.into_iter().map(|row| self.row_to_persistent_entry (row)).collect()`, which propagates the first conversion error identically. Co-Authored-By: Claude Opus 4.8 (1M context) --- orbit/shared/src/transaction_log.rs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/orbit/shared/src/transaction_log.rs b/orbit/shared/src/transaction_log.rs index 2e72e2a4..825a7c31 100644 --- a/orbit/shared/src/transaction_log.rs +++ b/orbit/shared/src/transaction_log.rs @@ -395,12 +395,9 @@ impl PersistentTransactionLogger for SqliteTransactionLogger { .await .map_err(|e| OrbitError::internal(format!("Failed to query transaction log: {e}")))?; - let mut entries = Vec::new(); - for row in rows { - entries.push(self.row_to_persistent_entry(row)?); - } - - Ok(entries) + rows.into_iter() + .map(|row| self.row_to_persistent_entry(row)) + .collect() } async fn get_entries_by_time_range( @@ -417,12 +414,9 @@ impl PersistentTransactionLogger for SqliteTransactionLogger { .await .map_err(|e| OrbitError::internal(format!("Failed to query time range: {e}")))?; - let mut entries = Vec::new(); - for row in rows { - entries.push(self.row_to_persistent_entry(row)?); - } - - Ok(entries) + rows.into_iter() + .map(|row| self.row_to_persistent_entry(row)) + .collect() } async fn archive_old_entries(&self, before_timestamp: i64) -> OrbitResult { From 2f4c4ecf5c2a7b4f38b211c28a45e250de7a385b Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Fri, 17 Jul 2026 19:08:49 -0700 Subject: [PATCH 10/36] refactor(engine): functional vectorized projection in query execution Sixth refactor increment, first in orbit-engine. Behaviour-preserving (11 existing query::execution tests kept, 2 new = 13 green; clippy + fmt clean). execute_projection built three parallel accumulators with a single `for &idx in column_indices` loop that also bounds-checked and conditionally pushed column names. Refactor: - Validate indices up front (`iter().find(|idx| idx >= len)`) so the projection body is infallible. - Build columns/null_bitmaps with `map(..).collect()` inlined into the ColumnBatch literal (field types drive inference). - Carry column names with `as_ref().filter(|_| !indices.is_empty()).map(..)`, preserving the original quirk that an empty projection drops names. - Add tests for the out-of-bounds error path and the empty-indices name-dropping edge case. Co-Authored-By: Claude Opus 4.8 (1M context) --- orbit/engine/src/query/execution.rs | 84 +++++++++++++++++++---------- 1 file changed, 57 insertions(+), 27 deletions(-) diff --git a/orbit/engine/src/query/execution.rs b/orbit/engine/src/query/execution.rs index 58e73eb2..f0738028 100644 --- a/orbit/engine/src/query/execution.rs +++ b/orbit/engine/src/query/execution.rs @@ -324,37 +324,38 @@ impl VectorizedExecutor { batch: &ColumnBatch, column_indices: &[usize], ) -> EngineResult { - let mut new_columns = Vec::new(); - let mut new_null_bitmaps = Vec::new(); - let mut new_column_names = Vec::new(); - - for &idx in column_indices { - if idx >= batch.columns.len() { - return Err(EngineError::storage(format!( - "Column index {} out of bounds", - idx - ))); - } - - new_columns.push(batch.columns[idx].clone()); - new_null_bitmaps.push(batch.null_bitmaps[idx].clone()); - - if let Some(ref names) = batch.column_names { - new_column_names.push(names[idx].clone()); - } + // Validate all indices up front so the projection itself is infallible. + if let Some(&idx) = column_indices + .iter() + .find(|&&idx| idx >= batch.columns.len()) + { + return Err(EngineError::storage(format!( + "Column index {idx} out of bounds" + ))); } - let column_names = if new_column_names.is_empty() { - None - } else { - Some(new_column_names) - }; - Ok(ColumnBatch { - columns: new_columns, - null_bitmaps: new_null_bitmaps, + columns: column_indices + .iter() + .map(|&idx| batch.columns[idx].clone()) + .collect(), + null_bitmaps: column_indices + .iter() + .map(|&idx| batch.null_bitmaps[idx].clone()) + .collect(), row_count: batch.row_count, - column_names, + // Names are carried only when the source batch has them and at least + // one column is projected (matches the original accumulation logic). + column_names: batch + .column_names + .as_ref() + .filter(|_| !column_indices.is_empty()) + .map(|names| { + column_indices + .iter() + .map(|&idx| names[idx].clone()) + .collect() + }), }) } @@ -2014,6 +2015,35 @@ mod tests { } } + #[test] + fn test_projection_out_of_bounds_errors() { + let executor = VectorizedExecutor::new(); + let batch = ColumnBatch { + columns: vec![Column::Int32(vec![1, 2, 3])], + null_bitmaps: vec![NullBitmap::new_all_valid(3)], + row_count: 3, + column_names: Some(vec!["id".to_string()]), + }; + // Index 5 is out of range for a single-column batch. + assert!(executor.execute_projection(&batch, &[0, 5]).is_err()); + } + + #[test] + fn test_projection_empty_indices_drops_names() { + let executor = VectorizedExecutor::new(); + let batch = ColumnBatch { + columns: vec![Column::Int32(vec![1, 2, 3])], + null_bitmaps: vec![NullBitmap::new_all_valid(3)], + row_count: 3, + column_names: Some(vec!["id".to_string()]), + }; + let projected = executor.execute_projection(&batch, &[]).unwrap(); + assert_eq!(projected.columns.len(), 0); + assert_eq!(projected.row_count, 3); + // No columns selected => no names carried, even though the source had them. + assert!(projected.column_names.is_none()); + } + #[test] fn test_aggregation_sum() { let executor = VectorizedExecutor::new(); From 9512768ccf91c3364d6e2d3878ca91ee2a80fe4d Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Fri, 17 Jul 2026 19:10:09 -0700 Subject: [PATCH 11/36] refactor(engine): functional select_rows in query execution Seventh refactor increment. Behaviour-preserving (13 query::execution tests green; clippy + fmt clean). select_rows built two parallel accumulators in one enumerate loop, using the column index to reach into batch.null_bitmaps. Split into a fallible `columns.iter().map(select_column_rows).collect::>>()?` and an infallible `null_bitmaps.iter().map(select_null_bitmap_rows) .collect()`, dropping the manual index (columns and null_bitmaps are 1:1 by the ColumnBatch invariant). Co-Authored-By: Claude Opus 4.8 (1M context) --- orbit/engine/src/query/execution.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/orbit/engine/src/query/execution.rs b/orbit/engine/src/query/execution.rs index f0738028..d185c95a 100644 --- a/orbit/engine/src/query/execution.rs +++ b/orbit/engine/src/query/execution.rs @@ -604,17 +604,17 @@ impl VectorizedExecutor { /// Extract specific rows from a batch by index fn select_rows(&self, batch: &ColumnBatch, indices: &[usize]) -> EngineResult { - let mut new_columns = Vec::new(); - let mut new_null_bitmaps = Vec::new(); - - for (col_idx, column) in batch.columns.iter().enumerate() { - let new_column = self.select_column_rows(column, indices)?; - let new_null_bitmap = - self.select_null_bitmap_rows(&batch.null_bitmaps[col_idx], indices); + let new_columns = batch + .columns + .iter() + .map(|column| self.select_column_rows(column, indices)) + .collect::>>()?; - new_columns.push(new_column); - new_null_bitmaps.push(new_null_bitmap); - } + let new_null_bitmaps = batch + .null_bitmaps + .iter() + .map(|bitmap| self.select_null_bitmap_rows(bitmap, indices)) + .collect(); Ok(ColumnBatch { columns: new_columns, From 92e2af9ac68595928798f06513dcd243d3628bb0 Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Wed, 5 Aug 2026 19:44:32 -0700 Subject: [PATCH 12/36] docs: add performance, allocation, modelling-honesty and verification rules Extend the code design principles in CLAUDE.md, AGENTS.md and .cursorrules with four areas the existing guidance did not cover: - Modelling honesty: what a default asserts, clamps are not values, decorative parameters - Performance: measure -> attribute from the call tree -> fix in yield order -> re-measure like-for-like -> verify the feature still works - Allocation discipline for measured hot paths - Verification: a green build proves almost nothing; run it, exercise the path, audit enum/registry affordances, diff duplicated contracts Co-Authored-By: Claude Opus 5 (1M context) --- .cursorrules | 25 +++++++++++++++++++++++++ AGENTS.md | 30 ++++++++++++++++++++++++++++++ CLAUDE.md | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+) diff --git a/.cursorrules b/.cursorrules index e049336a..1ea7c264 100644 --- a/.cursorrules +++ b/.cursorrules @@ -94,6 +94,31 @@ When making breaking changes to protocols/APIs, also update: - Small, single-responsibility functions (cognitive complexity ≤ 15); document public items with `///`. - `unsafe` only as a last resort, with a `// SAFETY:` comment and tests. +### Modelling Honesty +- Ask what a default asserts: `unwrap_or(0)` on a count asserts "none"; `unwrap_or_else(Utc::now)` on a record timestamp asserts "this happened now" — a claim nobody checked. Keep absent data absent (`Option`), and refuse to derive from it. +- A clamp is not a value — when a guard rail binds, report it in the type instead of returning the bound as if it were a measurement. +- A parameter that can be removed without changing any output is not modelling anything — delete it or wire it up. + +### Performance (measure first) +- Measure a number, not a hunch; idle CPU is the cheapest health check. +- Attribute from the **call tree**, not the leaf histogram (`cargo flamegraph`, `dhat`, `tokio-console`). +- Fix in yield order: cadence → eager work → per-iteration rebuilds → redundant notifications → algorithms/allocation. +- Re-measure like-for-like, then **verify the feature still works** — a metric that improved because a path stopped working is a regression. +- Don't optimize what wasn't measured as a problem; record what you deliberately left alone. + +### Allocation (hot paths only) +- Reuse buffers over recreating them; `with_capacity`/`reserve` when the size is known. +- Borrow, don't clone; take `&str`/`&[T]`/`impl AsRef<_>` at boundaries. +- Flat over pointer-chasing; indices over pointers; compound keys over nested maps. +- Cheap reject before expensive check; batch to amortize overhead; sample high-frequency metrics; bound anything that grows. + +### Verification — a green build proves almost nothing +- Run it, read stderr, exercise the changed path with a real client (`psql`, `redis-cli`, `cqlsh`, `curl`). +- Reconcile at least one number against an external reference, not your own expectation. +- For enum/registry dispatch, confirm every variant appears at a call site — the compiler is silent when the enum is data, not control flow. +- Diff duplicated contracts (`.proto`, schemas, command tables) that have no codegen between them. +- "An error appeared after my change" ≠ "my change caused it" — check provenance and say which it was. + ### 12-Factor App (where applicable) Build on existing `tracing`/`tracing-subscriber`, TOML config, `clap`, and graceful shutdown: - Config in the environment (env vars over `config/orbit-server.toml`); no hardcoded ports/hosts/secrets. diff --git a/AGENTS.md b/AGENTS.md index 88321037..d12be43c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -197,6 +197,36 @@ Beyond passing `make check`, write code that is idiomatic, functional-leaning, a - **`unsafe` is a last resort** — justify each block with a `// SAFETY:` comment and cover it with tests. - **Test the contract, not the implementation** — prefer property/table-driven tests for pure logic; keep async tests deterministic. +#### Modelling Honesty +A model that cannot be wrong is not a model. These are correctness rules, not style: +- **Ask what a default asserts.** `unwrap_or(0)` on a count asserts "none" — usually true. `unwrap_or_else(Utc::now)` on a record's timestamp asserts "this happened now", a claim about the world nobody checked. Absent data stays absent: model it (`Option` or a documented sentinel), refuse to derive from it, and surface it as unknown. +- **A clamp is not a value.** When a guard rail binds, report that in the type instead of silently substituting a bound that reads as a real measurement. +- **Decorative parameters invite false confidence.** A config knob that can be removed without changing any output is not modelling anything — delete it or wire it up. + +#### Performance (measure first) +1. **Measure** a number, not a hunch — CPU, memory, or latency? Idle CPU is the cheapest health check and almost nothing watches it. +2. **Attribute from the call tree**, not the leaf histogram — the leaf says what is expensive, only the tree says who asked for it (`cargo flamegraph`, `dhat`, `tokio-console`). +3. **Fix in yield order** — cadence (polling faster than the data changes?), eager work, per-iteration rebuilds, redundant notifications — *then* algorithms and allocation. +4. **Re-measure like-for-like**; quote the stable extreme of a noisy counter and say it is noisy. +5. **Verify the feature still works.** A number that improved because a code path stopped doing its job is a regression being celebrated. + +Do not optimize what has not been measured as a problem; when leaving a latent issue alone, write down why. + +#### Allocation Discipline (hot paths, after measuring) +- Reuse over recreate (hoist buffers out of loops); `with_capacity`/`reserve` when the size is known. +- Borrow, don't clone — clone for ownership, never to quiet the borrow checker; take `&str`/`&[T]`/`impl AsRef<_>` at boundaries. +- Flat over pointer-chasing; store indices instead of pointers; flatten nested maps behind a compound key. +- Cheap reject before expensive check; fast path first, cold handling out-of-line. +- Batch to amortize per-call overhead; sample high-frequency metrics so instrumentation does not dominate what it measures. +- Bound anything that grows — a cache that ignores stale entries but never evicts them grows forever. + +#### Verification — a green build proves almost nothing +- **Run it and read stderr**, then exercise the changed path with a real client (`psql`, `redis-cli`, `cqlsh`, `curl`). Code reachable only from an untested path is unverified however green the build. +- **Reconcile one number against an external reference** — real protocol behavior, not your own expectation. +- **Audit affordances**: for enum/registry dispatch, confirm every variant appears at a call site; the compiler stays silent when the enum is data rather than control flow. A schema, config key, or trait impl nothing calls is not a feature. +- **Duplicated contracts drift silently** — diff any `.proto`/schema/command table that exists in two places without codegen between them. +- **"An error appeared after my change" ≠ "my change caused it"** — check provenance before assuming causation, and say which it was. + #### 12-Factor App Principles (where applicable) Orbit-RS already uses `tracing` + `tracing-subscriber` (env-filter), `serde`/TOML config, `clap`, and graceful shutdown — build on these: - **III. Config in the environment** — read tunables from env vars layered over `config/orbit-server.toml`; never hardcode ports, hosts, credentials, or paths; keep secrets out of source. diff --git a/CLAUDE.md b/CLAUDE.md index 6456f6d5..c97aad10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,6 +85,42 @@ Beyond passing `make check`, write code that is idiomatic, functional-leaning, a - **`unsafe` is a last resort** — justify each block with a `// SAFETY:` comment and cover it with tests. - **Test the contract, not the implementation.** Prefer property/table-driven tests for pure logic; keep async tests deterministic. +### Modelling Honesty +A model that cannot be wrong is not a model. These are correctness rules, not style: +- **Ask what a default asserts.** `unwrap_or(0)` on a count asserts "none" — usually true. `unwrap_or_else(Utc::now)` on a record's timestamp asserts "this happened now" — a claim about the world nobody checked, and it stamps every imported row with the import time. Absent data stays absent: model it (`Option`, or a documented sentinel), refuse to derive from it, and surface it as unknown. +- **A clamp is not a value.** When a guard rail binds, say so in the type (return the clamped flag alongside the value) rather than silently substituting a bound that reads as a real measurement. +- **Decorative parameters invite false confidence.** If a config knob or tuning parameter can be removed without changing any output, it is not doing anything — delete it or wire it up. +- **Prefer unit-free derivations.** Where two provider/config fields meet in one expression, cross-check against a ratio that carries no units. + +### Performance +Measure before optimizing; the order matters more than the micro-work: +1. **Measure** a number, not a hunch — CPU, memory, or latency? Idle CPU is the cheapest health check and almost nothing watches it. +2. **Attribute from the call tree**, not the leaf histogram. A flat "hottest functions" list names symptoms; only the tree says who asked for the work. Use `cargo flamegraph`, `dhat` for allocations, `tokio-console` for task stalls. +3. **Fix in yield order** — cadence (is this poll/tick running more often than the data changes?), eager work (built before it is needed?), reuse (rebuilt per iteration?), redundant notification (does it wake watchers when nothing changed?) — *then* algorithms and allocation. +4. **Re-measure like-for-like**, same protocol and warm-up; quote the stable extreme of a noisy counter and say it is noisy. +5. **Verify the feature still works.** A performance number that improved because a code path stopped doing its job is the easiest way to ship a regression while celebrating it. + +Do not optimize what has not been measured as a problem. When leaving a latent issue alone, write down why. + +### Allocation Discipline (hot paths only, after measuring) +- **Reuse over recreate** — hoist buffers/`Vec`s out of loops; keep scratch space on the struct. +- **`with_capacity`/`reserve`** whenever the size is known or estimable. +- **Borrow, don't clone.** Clone for ownership, never to quiet the borrow checker. Take `&str`/`&[T]`/`impl AsRef<_>` at boundaries. +- **Flat over pointer-chasing** — `Vec` and flat maps beat node-per-entry trees; store indices (`u32`) rather than pointers in transient containers; flatten nested maps behind a compound key. +- **Cheap reject before expensive check** — a length or first-byte test before a regex, hash, or allocation; fast path first, cold handling `#[cold]`/out-of-line. +- **Batch** to amortize per-call overhead, and **sample** high-frequency metrics (one in 32 via a power-of-two mask) so instrumentation does not dominate what it measures. +- **Bound anything that grows.** A cache that only ignores stale entries but never evicts them grows forever. + +### Verification — a green build proves almost nothing +`make check` passing is the floor, not evidence the change works. In yield order: +- **Run it and read stderr.** `make dev` / start the server and watch the log. +- **Exercise the path you changed** with a real client (`psql`, `redis-cli`, `cqlsh`, `curl`) — code reachable only from an untested path is unverified no matter how green the build. +- **Reconcile one number against an external reference** — protocol conformance against the real server's behavior, not against your own expectation. +- **Prove the artifact carries the change** when packaging or deploying; verify the binary, not that files were copied. +- **Audit affordances.** For enum/registry dispatch, confirm every variant appears at a call site — the compiler will not tell you when the enum is data rather than control flow. A schema, a config key, or a trait impl that nothing calls is not a feature. +- **Duplicated contracts drift silently.** If a `.proto`, schema, or command table exists in two places with no codegen between them, diff them in CI. +- **"An error appeared after my change" ≠ "my change caused it."** Check provenance before assuming causation, and say which it was. + ### 12-Factor App Principles (where applicable) Orbit-RS already uses `tracing` + `tracing-subscriber` (env-filter), `serde`/TOML config, `clap`, and graceful shutdown — build on these: - **III. Config in the environment.** Read tunables from env vars layered over `config/orbit-server.toml`; never hardcode ports, hosts, credentials, or paths. Secrets come from env or a secret store, never source. From 1d818ba0be0fda9464d2b43e75884eeb549a7edb Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Wed, 5 Aug 2026 19:48:11 -0700 Subject: [PATCH 13/36] refactor(proto): trait-based conversions and honest timestamp decoding - Express every proto<->domain conversion as From/TryFrom so they compose with `?`, `.into()`, and iterator adapters; the *Converter structs stay as thin documented wrappers over the trait impls. - TimestampConverter::from_proto no longer falls back to Utc::now() for an unrepresentable timestamp. That default asserted "this happened now" and silently restamped malformed records with their decode time; it now returns OrbitError::Internal naming the offending seconds/nanos. Negative nanos are rejected instead of wrapping through `as u32`. - Add #[must_use] and `# Errors` docs to the public converter API. - Tests: replace the fallback-is-close-to-now assertion with an out-of-range rejection table, and cover the trait forms against the converter structs for every Key variant. Co-Authored-By: Claude Opus 5 (1M context) --- orbit/proto/src/converters.rs | 319 ++++++++++++++++++++++++++-------- 1 file changed, 247 insertions(+), 72 deletions(-) diff --git a/orbit/proto/src/converters.rs b/orbit/proto/src/converters.rs index 16e0c776..94ad7bd6 100644 --- a/orbit/proto/src/converters.rs +++ b/orbit/proto/src/converters.rs @@ -1,4 +1,13 @@ -//! Protocol buffer converters between Rust domain objects and protobuf messages +//! Protocol buffer conversions between Rust domain objects and protobuf messages. +//! +//! Conversions are expressed as [`From`]/[`TryFrom`] implementations so they compose +//! with `?`, `.into()`, and iterator adapters. The `*Converter` structs are thin, +//! stable wrappers kept for call sites that prefer a named function. +//! +//! Fallible directions (`proto -> domain`) return [`OrbitError`] rather than +//! substituting a default: a protobuf message with a missing `oneof` or an +//! out-of-range timestamp is malformed input, and inventing a plausible value for +//! it would hide the corruption instead of reporting it. use crate::{ key_proto, AddressableReferenceProto, InvocationReasonProto, KeyProto, NoKeyProto, NodeIdProto, @@ -10,80 +19,176 @@ use orbit_shared::{ }; use prost_types::Timestamp; -/// Convert between Rust Key enum and KeyProto -pub struct KeyConverter; - -impl KeyConverter { - pub fn to_proto(key: &Key) -> KeyProto { +impl From<&Key> for KeyProto { + fn from(key: &Key) -> Self { let key_oneof = match key { Key::StringKey { key } => key_proto::Key::StringKey(key.clone()), Key::Int32Key { key } => key_proto::Key::Int32Key(*key), Key::Int64Key { key } => key_proto::Key::Int64Key(*key), Key::NoKey => key_proto::Key::NoKey(NoKeyProto {}), }; - KeyProto { + Self { key: Some(key_oneof), } } +} - pub fn from_proto(proto: &KeyProto) -> OrbitResult { +impl TryFrom<&KeyProto> for Key { + type Error = OrbitError; + + fn try_from(proto: &KeyProto) -> Result { match &proto.key { - Some(key_proto::Key::StringKey(k)) => Ok(Key::StringKey { key: k.clone() }), - Some(key_proto::Key::Int32Key(k)) => Ok(Key::Int32Key { key: *k }), - Some(key_proto::Key::Int64Key(k)) => Ok(Key::Int64Key { key: *k }), - Some(key_proto::Key::NoKey(_)) => Ok(Key::NoKey), + Some(key_proto::Key::StringKey(k)) => Ok(Self::StringKey { key: k.clone() }), + Some(key_proto::Key::Int32Key(k)) => Ok(Self::Int32Key { key: *k }), + Some(key_proto::Key::Int64Key(k)) => Ok(Self::Int64Key { key: *k }), + Some(key_proto::Key::NoKey(_)) => Ok(Self::NoKey), None => Err(OrbitError::internal("Missing key in KeyProto")), } } } -/// Convert between Rust NodeId and NodeIdProto -pub struct NodeIdConverter; - -impl NodeIdConverter { - pub fn to_proto(node_id: &NodeId) -> NodeIdProto { - NodeIdProto { +impl From<&NodeId> for NodeIdProto { + fn from(node_id: &NodeId) -> Self { + Self { key: node_id.key.clone(), namespace: node_id.namespace.clone(), } } +} - pub fn from_proto(proto: &NodeIdProto) -> NodeId { - NodeId { +impl From<&NodeIdProto> for NodeId { + fn from(proto: &NodeIdProto) -> Self { + Self { key: proto.key.clone(), namespace: proto.namespace.clone(), } } } -/// Convert between Rust AddressableReference and AddressableReferenceProto -pub struct AddressableReferenceConverter; - -impl AddressableReferenceConverter { - pub fn to_proto(reference: &AddressableReference) -> AddressableReferenceProto { - AddressableReferenceProto { +impl From<&AddressableReference> for AddressableReferenceProto { + fn from(reference: &AddressableReference) -> Self { + Self { addressable_type: reference.addressable_type.clone(), - key: Some(KeyConverter::to_proto(&reference.key)), + key: Some((&reference.key).into()), } } +} - pub fn from_proto(proto: &AddressableReferenceProto) -> OrbitResult { - let key = proto +impl TryFrom<&AddressableReferenceProto> for AddressableReference { + type Error = OrbitError; + + fn try_from(proto: &AddressableReferenceProto) -> Result { + proto .key .as_ref() - .ok_or_else(|| OrbitError::internal("Missing key in AddressableReferenceProto"))?; + .ok_or_else(|| OrbitError::internal("Missing key in AddressableReferenceProto")) + .and_then(Key::try_from) + .map(|key| Self { + addressable_type: proto.addressable_type.clone(), + key, + }) + } +} + +impl From<&InvocationReason> for InvocationReasonProto { + fn from(reason: &InvocationReason) -> Self { + match reason { + InvocationReason::Invocation => Self::Invocation, + InvocationReason::Rerouted => Self::Rerouted, + } + } +} + +impl From for InvocationReason { + fn from(proto: InvocationReasonProto) -> Self { + match proto { + InvocationReasonProto::Invocation => Self::Invocation, + InvocationReasonProto::Rerouted => Self::Rerouted, + } + } +} + +impl From<&NodeStatus> for NodeStatusProto { + fn from(status: &NodeStatus) -> Self { + match status { + NodeStatus::Active => Self::Active, + NodeStatus::Draining => Self::Draining, + NodeStatus::Stopped => Self::Stopped, + } + } +} + +impl From for NodeStatus { + fn from(proto: NodeStatusProto) -> Self { + match proto { + NodeStatusProto::Active => Self::Active, + NodeStatusProto::Draining => Self::Draining, + NodeStatusProto::Stopped => Self::Stopped, + } + } +} + +/// Convert between Rust [`Key`] and [`KeyProto`]. +pub struct KeyConverter; + +impl KeyConverter { + /// Encode a domain key as its protobuf representation. + #[must_use] + pub fn to_proto(key: &Key) -> KeyProto { + key.into() + } + + /// Decode a protobuf key. + /// + /// # Errors + /// Returns [`OrbitError::Internal`] if the `key` oneof is unset. + pub fn from_proto(proto: &KeyProto) -> OrbitResult { + Key::try_from(proto) + } +} + +/// Convert between Rust [`NodeId`] and [`NodeIdProto`]. +pub struct NodeIdConverter; + +impl NodeIdConverter { + /// Encode a node id as its protobuf representation. + #[must_use] + pub fn to_proto(node_id: &NodeId) -> NodeIdProto { + node_id.into() + } + + /// Decode a protobuf node id. This conversion is total. + #[must_use] + pub fn from_proto(proto: &NodeIdProto) -> NodeId { + proto.into() + } +} + +/// Convert between Rust [`AddressableReference`] and [`AddressableReferenceProto`]. +pub struct AddressableReferenceConverter; - Ok(AddressableReference { - addressable_type: proto.addressable_type.clone(), - key: KeyConverter::from_proto(key)?, - }) +impl AddressableReferenceConverter { + /// Encode an addressable reference as its protobuf representation. + #[must_use] + pub fn to_proto(reference: &AddressableReference) -> AddressableReferenceProto { + reference.into() + } + + /// Decode a protobuf addressable reference. + /// + /// # Errors + /// Returns [`OrbitError::Internal`] if the nested key is missing or malformed. + pub fn from_proto(proto: &AddressableReferenceProto) -> OrbitResult { + AddressableReference::try_from(proto) } } -/// Convert between Rust `DateTime` and protobuf Timestamp +/// Convert between Rust `DateTime` and protobuf [`Timestamp`]. pub struct TimestampConverter; impl TimestampConverter { + /// Encode a UTC timestamp as its protobuf representation. + #[must_use] pub fn to_proto(dt: &DateTime) -> Timestamp { Timestamp { seconds: dt.timestamp(), @@ -91,48 +196,57 @@ impl TimestampConverter { } } - pub fn from_proto(timestamp: &Timestamp) -> DateTime { - DateTime::from_timestamp(timestamp.seconds, timestamp.nanos as u32).unwrap_or_else(Utc::now) + /// Decode a protobuf timestamp. + /// + /// # Errors + /// Returns [`OrbitError::Internal`] when the seconds/nanos pair is not a + /// representable instant. An unrepresentable timestamp is malformed input; it + /// is reported rather than replaced with the current time, which would silently + /// restamp the record with its decode time. + pub fn from_proto(timestamp: &Timestamp) -> OrbitResult> { + u32::try_from(timestamp.nanos) + .ok() + .and_then(|nanos| DateTime::from_timestamp(timestamp.seconds, nanos)) + .ok_or_else(|| { + OrbitError::internal(format!( + "Timestamp out of range: seconds={}, nanos={}", + timestamp.seconds, timestamp.nanos + )) + }) } } -/// Convert between Rust InvocationReason and InvocationReasonProto +/// Convert between Rust [`InvocationReason`] and [`InvocationReasonProto`]. pub struct InvocationReasonConverter; impl InvocationReasonConverter { + /// Encode an invocation reason as its protobuf representation. + #[must_use] pub fn to_proto(reason: &InvocationReason) -> InvocationReasonProto { - match reason { - InvocationReason::Invocation => InvocationReasonProto::Invocation, - InvocationReason::Rerouted => InvocationReasonProto::Rerouted, - } + reason.into() } + /// Decode a protobuf invocation reason. This conversion is total. + #[must_use] pub fn from_proto(proto: InvocationReasonProto) -> InvocationReason { - match proto { - InvocationReasonProto::Invocation => InvocationReason::Invocation, - InvocationReasonProto::Rerouted => InvocationReason::Rerouted, - } + proto.into() } } -/// Convert between Rust NodeStatus and NodeStatusProto +/// Convert between Rust [`NodeStatus`] and [`NodeStatusProto`]. pub struct NodeStatusConverter; impl NodeStatusConverter { + /// Encode a node status as its protobuf representation. + #[must_use] pub fn to_proto(status: &NodeStatus) -> NodeStatusProto { - match status { - NodeStatus::Active => NodeStatusProto::Active, - NodeStatus::Draining => NodeStatusProto::Draining, - NodeStatus::Stopped => NodeStatusProto::Stopped, - } + status.into() } + /// Decode a protobuf node status. This conversion is total. + #[must_use] pub fn from_proto(proto: NodeStatusProto) -> NodeStatus { - match proto { - NodeStatusProto::Active => NodeStatus::Active, - NodeStatusProto::Draining => NodeStatus::Draining, - NodeStatusProto::Stopped => NodeStatus::Stopped, - } + proto.into() } } @@ -315,7 +429,7 @@ mod tests { let dt = Utc::now(); let proto = TimestampConverter::to_proto(&dt); - let converted_back = TimestampConverter::from_proto(&proto); + let converted_back = TimestampConverter::from_proto(&proto).unwrap(); // Allow for small differences due to precision let diff = (dt.timestamp_millis() - converted_back.timestamp_millis()).abs(); @@ -330,7 +444,7 @@ mod tests { .with_timezone(&Utc); let proto = TimestampConverter::to_proto(&dt); - let converted_back = TimestampConverter::from_proto(&proto); + let converted_back = TimestampConverter::from_proto(&proto).unwrap(); assert_eq!(dt.timestamp(), converted_back.timestamp()); // Check nanoseconds separately due to potential precision differences @@ -344,24 +458,85 @@ mod tests { } #[test] - fn test_timestamp_converter_invalid_timestamp() { - // Test with invalid timestamp (should fall back to current time) - let invalid_proto = Timestamp { - seconds: -1, - nanos: -1, + fn test_trait_conversions_roundtrip_every_key_variant() { + let keys = [ + Key::StringKey { + key: "k".to_string(), + }, + Key::Int32Key { key: -7 }, + Key::Int64Key { key: i64::MIN }, + Key::NoKey, + ]; + + for key in keys { + let proto: KeyProto = (&key).into(); + assert_eq!(key, Key::try_from(&proto).unwrap()); + } + } + + #[test] + fn test_trait_conversions_match_converter_structs() { + let reference = AddressableReference { + addressable_type: "Actor".to_string(), + key: Key::Int64Key { key: 42 }, }; + let node_id = NodeId { + key: "node".to_string(), + namespace: "ns".to_string(), + }; + + let via_trait: AddressableReferenceProto = (&reference).into(); + assert_eq!( + via_trait, + AddressableReferenceConverter::to_proto(&reference) + ); + assert_eq!( + reference, + AddressableReference::try_from(&via_trait).unwrap() + ); - let converted = TimestampConverter::from_proto(&invalid_proto); + let node_proto: NodeIdProto = (&node_id).into(); + assert_eq!(node_proto, NodeIdConverter::to_proto(&node_id)); + assert_eq!(node_id, NodeId::from(&node_proto)); - // Should not panic and should return a valid datetime - let now = Utc::now(); - let diff = (now.timestamp() - converted.timestamp()).abs(); - assert!( - diff < 10, - "Fallback timestamp should be close to current time" + assert_eq!( + NodeStatusProto::from(&NodeStatus::Draining), + NodeStatusConverter::to_proto(&NodeStatus::Draining) + ); + assert_eq!( + InvocationReasonProto::from(&InvocationReason::Rerouted), + InvocationReasonConverter::to_proto(&InvocationReason::Rerouted) ); } + #[test] + fn test_timestamp_converter_rejects_malformed_timestamp() { + // Negative nanos are not representable; the decoder must report that rather + // than substitute the current time, which would restamp the record. + let cases = [ + Timestamp { + seconds: -1, + nanos: -1, + }, + Timestamp { + seconds: i64::MAX, + nanos: 0, + }, + ]; + + for invalid_proto in cases { + match TimestampConverter::from_proto(&invalid_proto) { + Err(OrbitError::Internal { message, .. }) => { + assert!( + message.contains("Timestamp out of range"), + "unexpected message: {message}" + ); + } + other => panic!("Expected an out-of-range error, got {other:?}"), + } + } + } + #[test] fn test_invocation_reason_converter() { let test_cases = vec![ @@ -427,7 +602,7 @@ mod tests { // Convert back let ref_back = AddressableReferenceConverter::from_proto(&ref_proto).unwrap(); let node_back = NodeIdConverter::from_proto(&node_proto); - let dt_back = TimestampConverter::from_proto(&dt_proto); + let dt_back = TimestampConverter::from_proto(&dt_proto).unwrap(); let reason_back = InvocationReasonConverter::from_proto(reason_proto); let status_back = NodeStatusConverter::from_proto(status_proto); From 02c298e30796c5bcdc59eb2d89d468f12836866c Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Wed, 5 Aug 2026 19:56:58 -0700 Subject: [PATCH 14/36] refactor: add bounds-checked cursor for decompression Introduce a `Cursor` struct to manage safe, bounds-checked reads of compressed buffers, eliminating manual position tracking and preventing panics on truncated or corrupt input. Key changes: - Add `Cursor<'a>` with methods for safe primitives (`take_u8`, `take_varint`, `take_string`, etc.) - Add `XorBlock` to encapsulate Gorilla block validation - Refactor all three compressors (Delta, DoubleDelta, Gorilla) to use `Cursor` instead of manual offset tracking - Remove old `decode_varint` and `decode_varint_signed` functions - Add `capacity_for()` to prevent huge allocations from impossible counts - Simplify error messages and use inline formatting - Add three new security-focused tests: truncation, corruption, and impossible widths --- orbit/shared/src/timeseries/compression.rs | 734 ++++++++++++--------- 1 file changed, 434 insertions(+), 300 deletions(-) diff --git a/orbit/shared/src/timeseries/compression.rs b/orbit/shared/src/timeseries/compression.rs index abb32923..82657ee4 100644 --- a/orbit/shared/src/timeseries/compression.rs +++ b/orbit/shared/src/timeseries/compression.rs @@ -33,13 +33,6 @@ fn encode_varint_signed(value: i64, output: &mut Vec) { encode_varint(unsigned, output); } -/// Decode a signed integer from variable-length encoding -fn decode_varint_signed(input: &[u8], pos: &mut usize) -> Result { - let unsigned = decode_varint(input, pos)?; - // Zigzag decode: map unsigned back to signed - Ok(((unsigned >> 1) as i64) ^ (-((unsigned & 1) as i64))) -} - /// Encode an unsigned integer using variable-length encoding fn encode_varint(mut value: u64, output: &mut Vec) { while value >= 0x80 { @@ -49,26 +42,167 @@ fn encode_varint(mut value: u64, output: &mut Vec) { output.push(value as u8); } -/// Decode an unsigned integer from variable-length encoding -fn decode_varint(input: &[u8], pos: &mut usize) -> Result { - let mut result: u64 = 0; - let mut shift = 0; - loop { - if *pos >= input.len() { - return Err(anyhow::anyhow!("Unexpected end of input")); +// ============================================================================ +// Decoding cursor +// ============================================================================ + +/// Smallest number of bytes any encoder can spend on one data point: a timestamp +/// varint, a value-type marker, and a label count, one byte each. +const MIN_ENCODED_POINT_BYTES: usize = 3; + +/// Smallest number of bytes one label can occupy: a zero-length key and value. +const MIN_ENCODED_LABEL_BYTES: usize = 2; + +/// A bounds-checked read cursor over a compressed buffer. +/// +/// Compressed input is untrusted — it arrives from disk, the network, or a +/// partially written segment. Every primitive here is therefore total: truncated +/// or corrupt input yields an `Err`, never a slice panic. Keeping the check and +/// the read in one place also removes the `pos + len > data.len()` idiom, which +/// silently wraps for a hostile length; [`Cursor::remaining`] subtracts instead. +struct Cursor<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Cursor<'a> { + const fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + + /// Bytes not yet consumed. + const fn remaining(&self) -> usize { + self.data.len() - self.pos + } + + /// Take the next `len` bytes, advancing the cursor. + /// + /// # Errors + /// Returns an error if fewer than `len` bytes remain. + fn take(&mut self, len: usize) -> Result<&'a [u8]> { + if len > self.remaining() { + return Err(anyhow::anyhow!( + "Unexpected end of data: need {len} bytes, {} remain", + self.remaining() + )); + } + let bytes = &self.data[self.pos..self.pos + len]; + self.pos += len; + Ok(bytes) + } + + /// Take the next `N` bytes as a fixed-size array. + /// + /// # Errors + /// Returns an error if fewer than `N` bytes remain. + fn take_array(&mut self) -> Result<[u8; N]> { + let bytes = self.take(N)?; + <[u8; N]>::try_from(bytes) + .map_err(|_| anyhow::anyhow!("Expected {N} bytes, got {}", bytes.len())) + } + + /// Take one byte. + /// + /// # Errors + /// Returns an error at end of input. + fn take_u8(&mut self) -> Result { + self.take_array::<1>().map(|[byte]| byte) + } + + /// Take a little-endian `u64`. + /// + /// # Errors + /// Returns an error if fewer than 8 bytes remain. + fn take_u64_le(&mut self) -> Result { + self.take_array::<8>().map(u64::from_le_bytes) + } + + /// Take a little-endian `f64`. + /// + /// # Errors + /// Returns an error if fewer than 8 bytes remain. + fn take_f64_le(&mut self) -> Result { + self.take_array::<8>().map(f64::from_le_bytes) + } + + /// Take `len` bytes (at most 8) as the low bytes of a little-endian `u64`. + /// + /// # Errors + /// Returns an error if `len` exceeds 8 — a `u64` cannot hold more — or if + /// fewer than `len` bytes remain. + fn take_padded_u64(&mut self, len: usize) -> Result { + if len > 8 { + return Err(anyhow::anyhow!( + "Invalid meaningful-bit width: {len} bytes exceeds 8" + )); } - let byte = input[*pos]; - *pos += 1; - result |= ((byte & 0x7F) as u64) << shift; - if byte & 0x80 == 0 { - break; + let mut buf = [0u8; 8]; + buf[..len].copy_from_slice(self.take(len)?); + Ok(u64::from_le_bytes(buf)) + } + + /// Decode an unsigned varint. + /// + /// # Errors + /// Returns an error at end of input or if the varint exceeds 64 bits. + fn take_varint(&mut self) -> Result { + // A byte-at-a-time LEB128 decode is inherently sequential; a local + // accumulator states that more plainly than an iterator adapter would. + let mut value = 0u64; + for shift in (0..64).step_by(7) { + let byte = self.take_u8()?; + value |= u64::from(byte & 0x7F) << shift; + if byte & 0x80 == 0 { + return Ok(value); + } } - shift += 7; - if shift >= 64 { - return Err(anyhow::anyhow!("Varint too long")); + Err(anyhow::anyhow!("Varint too long")) + } + + /// Decode a zigzag-encoded signed varint. + /// + /// # Errors + /// Returns an error at end of input or if the varint exceeds 64 bits. + fn take_varint_signed(&mut self) -> Result { + self.take_varint() + .map(|unsigned| ((unsigned >> 1) as i64) ^ (-((unsigned & 1) as i64))) + } + + /// Decode a varint-prefixed UTF-8 string. + /// + /// # Errors + /// Returns an error if the length runs past the end of input or the bytes are + /// not valid UTF-8. + fn take_string(&mut self) -> Result { + let len = usize::try_from(self.take_varint()?) + .map_err(|_| anyhow::anyhow!("String length exceeds addressable memory"))?; + let bytes = self.take(len)?; + String::from_utf8(bytes.to_vec()).map_err(Into::into) + } + + /// Decode an element count. + /// + /// # Errors + /// Returns an error at end of input or if the count exceeds `usize`. + fn take_count(&mut self) -> Result { + usize::try_from(self.take_varint()?) + .map_err(|_| anyhow::anyhow!("Element count exceeds addressable memory")) + } + + /// Capacity to preallocate for `count` elements of at least `min_bytes` each. + /// + /// A decoded count is untrusted: a ten-byte payload can claim to hold billions + /// of points. Bounding the hint by what the remaining bytes could physically + /// contain keeps a corrupt header from requesting a huge allocation before the + /// first truncated read is even attempted; the decode loop then fails normally. + const fn capacity_for(&self, count: usize, min_bytes: usize) -> usize { + let affordable = self.remaining() / min_bytes; + if count < affordable { + count + } else { + affordable } } - Ok(result) } // ============================================================================ @@ -136,14 +270,7 @@ impl TimeSeriesCompressor for DeltaCompressor { } } - // Encode labels count (simplified - no labels for compression efficiency) - encode_varint(point.labels.len() as u64, &mut output); - for (key, value) in &point.labels { - encode_varint(key.len() as u64, &mut output); - output.extend_from_slice(key.as_bytes()); - encode_varint(value.len() as u64, &mut output); - output.extend_from_slice(value.as_bytes()); - } + encode_labels(&point.labels, &mut output); } Ok(output) @@ -154,96 +281,39 @@ impl TimeSeriesCompressor for DeltaCompressor { return Ok(Vec::new()); } - let mut pos = 0; - - // Read header: number of points - let num_points = decode_varint(compressed_data, &mut pos)? as usize; + let mut cursor = Cursor::new(compressed_data); - // Read base timestamp - let base_ts = decode_varint_signed(compressed_data, &mut pos)?; + // Header: number of points, then the base timestamp deltas are relative to. + let num_points = cursor.take_count()?; + let base_ts = cursor.take_varint_signed()?; - let mut points = Vec::with_capacity(num_points); + let mut points = + Vec::with_capacity(cursor.capacity_for(num_points, MIN_ENCODED_POINT_BYTES)); let mut prev_timestamp = base_ts; let mut prev_value: f64 = 0.0; for _ in 0..num_points { - // Decode timestamp delta - let ts_delta = decode_varint_signed(compressed_data, &mut pos)?; - let timestamp = prev_timestamp + ts_delta; + let timestamp = prev_timestamp + cursor.take_varint_signed()?; prev_timestamp = timestamp; - // Decode value - if pos >= compressed_data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let value_type = compressed_data[pos]; - pos += 1; - + let value_type = cursor.take_u8()?; let value = match value_type { + // Float, delta encoded against the running previous value. 0 => { - // Float (delta encoded) - if pos + 8 > compressed_data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let delta = - f64::from_le_bytes(compressed_data[pos..pos + 8].try_into().unwrap()); - pos += 8; - prev_value += delta; + prev_value += cursor.take_f64_le()?; TimeSeriesValue::Float(prev_value) } - 1 => { - // Integer - let v = decode_varint_signed(compressed_data, &mut pos)?; - TimeSeriesValue::Integer(v) - } - 2 => { - // String - let len = decode_varint(compressed_data, &mut pos)? as usize; - if pos + len > compressed_data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let s = String::from_utf8(compressed_data[pos..pos + len].to_vec())?; - pos += len; - TimeSeriesValue::String(s) - } - 3 => { - // Boolean - if pos >= compressed_data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let b = compressed_data[pos] != 0; - pos += 1; - TimeSeriesValue::Boolean(b) - } + 1 => TimeSeriesValue::Integer(cursor.take_varint_signed()?), + 2 => TimeSeriesValue::String(cursor.take_string()?), + 3 => TimeSeriesValue::Boolean(cursor.take_u8()? != 0), 4 => TimeSeriesValue::Null, - _ => return Err(anyhow::anyhow!("Unknown value type: {}", value_type)), + other => return Err(anyhow::anyhow!("Unknown value type: {other}")), }; - // Decode labels - let num_labels = decode_varint(compressed_data, &mut pos)? as usize; - let mut labels = HashMap::with_capacity(num_labels); - for _ in 0..num_labels { - let key_len = decode_varint(compressed_data, &mut pos)? as usize; - if pos + key_len > compressed_data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let key = String::from_utf8(compressed_data[pos..pos + key_len].to_vec())?; - pos += key_len; - - let val_len = decode_varint(compressed_data, &mut pos)? as usize; - if pos + val_len > compressed_data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let val = String::from_utf8(compressed_data[pos..pos + val_len].to_vec())?; - pos += val_len; - - labels.insert(key, val); - } - points.push(DataPoint { timestamp, value, - labels, + labels: decode_labels(&mut cursor)?, }); } @@ -277,7 +347,7 @@ impl TimeSeriesCompressor for DoubleDeltaCompressor { // First point: write full timestamp let first = &data_points[0]; encode_varint_signed(first.timestamp, &mut output); - encode_value(&first.value, &mut output)?; + encode_value(&first.value, &mut output); encode_labels(&first.labels, &mut output); if data_points.len() == 1 { @@ -288,7 +358,7 @@ impl TimeSeriesCompressor for DoubleDeltaCompressor { let second = &data_points[1]; let delta1 = second.timestamp - first.timestamp; encode_varint_signed(delta1, &mut output); - encode_value(&second.value, &mut output)?; + encode_value(&second.value, &mut output); encode_labels(&second.labels, &mut output); // Remaining points: write delta-of-delta @@ -311,7 +381,7 @@ impl TimeSeriesCompressor for DoubleDeltaCompressor { encode_varint_signed(delta_of_delta, &mut output); } - encode_value(&point.value, &mut output)?; + encode_value(&point.value, &mut output); encode_labels(&point.labels, &mut output); prev_delta = delta; @@ -326,69 +396,53 @@ impl TimeSeriesCompressor for DoubleDeltaCompressor { return Ok(Vec::new()); } - let mut pos = 0; - let num_points = decode_varint(compressed_data, &mut pos)? as usize; - let mut points = Vec::with_capacity(num_points); + let mut cursor = Cursor::new(compressed_data); + let num_points = cursor.take_count()?; + let mut points = + Vec::with_capacity(cursor.capacity_for(num_points, MIN_ENCODED_POINT_BYTES)); if num_points == 0 { return Ok(points); } - // First point - let first_ts = decode_varint_signed(compressed_data, &mut pos)?; - let first_value = decode_value(compressed_data, &mut pos)?; - let first_labels = decode_labels(compressed_data, &mut pos)?; + // First point carries a full timestamp; the second carries a delta. + let first_ts = cursor.take_varint_signed()?; points.push(DataPoint { timestamp: first_ts, - value: first_value, - labels: first_labels, + value: decode_value(&mut cursor)?, + labels: decode_labels(&mut cursor)?, }); if num_points == 1 { return Ok(points); } - // Second point - let delta1 = decode_varint_signed(compressed_data, &mut pos)?; + let delta1 = cursor.take_varint_signed()?; let second_ts = first_ts + delta1; - let second_value = decode_value(compressed_data, &mut pos)?; - let second_labels = decode_labels(compressed_data, &mut pos)?; points.push(DataPoint { timestamp: second_ts, - value: second_value, - labels: second_labels, + value: decode_value(&mut cursor)?, + labels: decode_labels(&mut cursor)?, }); - // Remaining points + // Remaining points carry a delta-of-delta under one of three markers. let mut prev_delta = delta1; let mut prev_ts = second_ts; for _ in 2..num_points { - if pos >= compressed_data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - - let marker = compressed_data[pos]; - pos += 1; - - let delta_of_delta = if marker == 0 { - 0 - } else if marker == 0xFF { - decode_varint_signed(compressed_data, &mut pos)? - } else { - ((marker & 0x7F) as i64) - 63 + let delta_of_delta = match cursor.take_u8()? { + 0 => 0, + 0xFF => cursor.take_varint_signed()?, + packed => i64::from(packed & 0x7F) - 63, }; let delta = prev_delta + delta_of_delta; let timestamp = prev_ts + delta; - let value = decode_value(compressed_data, &mut pos)?; - let labels = decode_labels(compressed_data, &mut pos)?; - points.push(DataPoint { timestamp, - value, - labels, + value: decode_value(&mut cursor)?, + labels: decode_labels(&mut cursor)?, }); prev_delta = delta; @@ -407,6 +461,52 @@ impl TimeSeriesCompressor for DoubleDeltaCompressor { // Gorilla Compression (for floating point values) // ============================================================================ +/// The window of significant bits shared by a run of Gorilla-encoded XOR values. +/// +/// Both widths come from the compressed stream, so the pair may describe a block +/// that cannot exist (wider than 64 bits, or empty). [`XorBlock::read_xor`] is the +/// single place that validates them, which keeps the shift below out of reach of +/// malformed input — an unchecked `64 - leading - meaningful` underflows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct XorBlock { + leading_zeros: u32, + meaningful_bits: u32, +} + +impl Default for XorBlock { + /// The widest possible block: no leading zeros, all 64 bits meaningful. + fn default() -> Self { + Self { + leading_zeros: 0, + meaningful_bits: 64, + } + } +} + +impl XorBlock { + /// Read this block's meaningful bytes and shift them back into position. + /// + /// # Errors + /// Returns an error if the widths do not describe a non-empty 64-bit block, or + /// if the payload runs past the end of input. + fn read_xor(self, cursor: &mut Cursor<'_>) -> Result { + let trailing_zeros = 64_u32 + .checked_sub(self.leading_zeros) + .and_then(|rest| rest.checked_sub(self.meaningful_bits)) + .filter(|_| self.meaningful_bits > 0) + .ok_or_else(|| { + anyhow::anyhow!( + "Invalid Gorilla block: {} leading + {} meaningful bits", + self.leading_zeros, + self.meaningful_bits + ) + })?; + + let meaningful = cursor.take_padded_u64(self.meaningful_bits.div_ceil(8) as usize)?; + Ok(meaningful << trailing_zeros) + } +} + /// Gorilla compression for floating point values /// Uses XOR of consecutive values and variable-length encoding pub struct GorillaCompressor; @@ -505,123 +605,59 @@ impl TimeSeriesCompressor for GorillaCompressor { return Ok(Vec::new()); } - let mut pos = 0; - let num_points = decode_varint(compressed_data, &mut pos)? as usize; - let mut points = Vec::with_capacity(num_points); + let mut cursor = Cursor::new(compressed_data); + let num_points = cursor.take_count()?; + let mut points = + Vec::with_capacity(cursor.capacity_for(num_points, MIN_ENCODED_POINT_BYTES)); let mut prev_ts: i64 = 0; let mut prev_value_bits: u64 = 0; - let mut prev_leading_zeros: u32 = 0; - let mut prev_meaningful_bits: u32 = 64; + let mut block = XorBlock::default(); for i in 0..num_points { - // Decode timestamp - let ts_delta = decode_varint_signed(compressed_data, &mut pos)?; - let timestamp = prev_ts + ts_delta; + let timestamp = prev_ts + cursor.take_varint_signed()?; prev_ts = timestamp; - // Decode value - if pos >= compressed_data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let value_type = compressed_data[pos]; - pos += 1; - + let value_type = cursor.take_u8()?; let value = match value_type { 0 => { - // Float with Gorilla compression + // Float: the first value is stored whole, the rest as an XOR + // against the previous value under one of three markers. let value_bits = if i == 0 { - if pos + 8 > compressed_data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let bits = - u64::from_le_bytes(compressed_data[pos..pos + 8].try_into().unwrap()); - pos += 8; - bits + cursor.take_u64_le()? } else { - if pos >= compressed_data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let marker = compressed_data[pos]; - pos += 1; - - match marker { - 0 => prev_value_bits, // Same value - 1 => { - // Same block - let bytes_needed = prev_meaningful_bits.div_ceil(8) as usize; - if pos + bytes_needed > compressed_data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let mut meaningful_bytes = [0u8; 8]; - meaningful_bytes[..bytes_needed] - .copy_from_slice(&compressed_data[pos..pos + bytes_needed]); - pos += bytes_needed; - let meaningful = u64::from_le_bytes(meaningful_bytes); - let trailing = 64 - prev_leading_zeros - prev_meaningful_bits; - let xor = meaningful << trailing; - prev_value_bits ^ xor - } + match cursor.take_u8()? { + // Unchanged value. + 0 => prev_value_bits, + // Reuse the previous block's leading/meaningful widths. + 1 => prev_value_bits ^ block.read_xor(&mut cursor)?, + // New block: widths are re-stated before the payload. 2 => { - // New block - if pos + 2 > compressed_data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let leading = compressed_data[pos] as u32; - let meaningful_bits = compressed_data[pos + 1] as u32; - pos += 2; - let bytes_needed = meaningful_bits.div_ceil(8) as usize; - if pos + bytes_needed > compressed_data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let mut meaningful_bytes = [0u8; 8]; - meaningful_bytes[..bytes_needed] - .copy_from_slice(&compressed_data[pos..pos + bytes_needed]); - pos += bytes_needed; - let meaningful = u64::from_le_bytes(meaningful_bytes); - let trailing = 64 - leading - meaningful_bits; - let xor = meaningful << trailing; - prev_leading_zeros = leading; - prev_meaningful_bits = meaningful_bits; - prev_value_bits ^ xor + block = XorBlock { + leading_zeros: u32::from(cursor.take_u8()?), + meaningful_bits: u32::from(cursor.take_u8()?), + }; + prev_value_bits ^ block.read_xor(&mut cursor)? + } + other => { + return Err(anyhow::anyhow!("Invalid Gorilla marker: {other}")) } - _ => return Err(anyhow::anyhow!("Invalid Gorilla marker")), } }; prev_value_bits = value_bits; TimeSeriesValue::Float(f64::from_bits(value_bits)) } - 1 => { - let v = decode_varint_signed(compressed_data, &mut pos)?; - TimeSeriesValue::Integer(v) - } - 2 => { - let len = decode_varint(compressed_data, &mut pos)? as usize; - if pos + len > compressed_data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let s = String::from_utf8(compressed_data[pos..pos + len].to_vec())?; - pos += len; - TimeSeriesValue::String(s) - } - 3 => { - if pos >= compressed_data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let b = compressed_data[pos] != 0; - pos += 1; - TimeSeriesValue::Boolean(b) - } + 1 => TimeSeriesValue::Integer(cursor.take_varint_signed()?), + 2 => TimeSeriesValue::String(cursor.take_string()?), + 3 => TimeSeriesValue::Boolean(cursor.take_u8()? != 0), 4 => TimeSeriesValue::Null, - _ => return Err(anyhow::anyhow!("Unknown value type")), + other => return Err(anyhow::anyhow!("Unknown value type: {other}")), }; - let labels = decode_labels(compressed_data, &mut pos)?; - points.push(DataPoint { timestamp, value, - labels, + labels: decode_labels(&mut cursor)?, }); } @@ -637,7 +673,8 @@ impl TimeSeriesCompressor for GorillaCompressor { // Helper functions // ============================================================================ -fn encode_value(value: &TimeSeriesValue, output: &mut Vec) -> Result<()> { +/// Encode a value with its one-byte type marker, absolute (not delta encoded). +fn encode_value(value: &TimeSeriesValue, output: &mut Vec) { match value { TimeSeriesValue::Float(v) => { output.push(0); @@ -649,92 +686,60 @@ fn encode_value(value: &TimeSeriesValue, output: &mut Vec) -> Result<()> { } TimeSeriesValue::String(s) => { output.push(2); - encode_varint(s.len() as u64, output); - output.extend_from_slice(s.as_bytes()); + encode_string(s, output); } TimeSeriesValue::Boolean(b) => { output.push(3); - output.push(if *b { 1 } else { 0 }); + output.push(u8::from(*b)); } TimeSeriesValue::Null => { output.push(4); } } - Ok(()) } -fn decode_value(data: &[u8], pos: &mut usize) -> Result { - if *pos >= data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let value_type = data[*pos]; - *pos += 1; - - match value_type { - 0 => { - if *pos + 8 > data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let v = f64::from_le_bytes(data[*pos..*pos + 8].try_into().unwrap()); - *pos += 8; - Ok(TimeSeriesValue::Float(v)) - } - 1 => { - let v = decode_varint_signed(data, pos)?; - Ok(TimeSeriesValue::Integer(v)) - } - 2 => { - let len = decode_varint(data, pos)? as usize; - if *pos + len > data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let s = String::from_utf8(data[*pos..*pos + len].to_vec())?; - *pos += len; - Ok(TimeSeriesValue::String(s)) - } - 3 => { - if *pos >= data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let b = data[*pos] != 0; - *pos += 1; - Ok(TimeSeriesValue::Boolean(b)) - } +/// Decode a value written by [`encode_value`]. +/// +/// # Errors +/// Returns an error on a truncated payload or an unknown type marker. +fn decode_value(cursor: &mut Cursor<'_>) -> Result { + match cursor.take_u8()? { + 0 => cursor.take_f64_le().map(TimeSeriesValue::Float), + 1 => cursor.take_varint_signed().map(TimeSeriesValue::Integer), + 2 => cursor.take_string().map(TimeSeriesValue::String), + 3 => cursor.take_u8().map(|b| TimeSeriesValue::Boolean(b != 0)), 4 => Ok(TimeSeriesValue::Null), - _ => Err(anyhow::anyhow!("Unknown value type: {}", value_type)), + other => Err(anyhow::anyhow!("Unknown value type: {other}")), } } +/// Write a varint-prefixed UTF-8 string. +fn encode_string(value: &str, output: &mut Vec) { + encode_varint(value.len() as u64, output); + output.extend_from_slice(value.as_bytes()); +} + fn encode_labels(labels: &HashMap, output: &mut Vec) { encode_varint(labels.len() as u64, output); - for (key, value) in labels { - encode_varint(key.len() as u64, output); - output.extend_from_slice(key.as_bytes()); - encode_varint(value.len() as u64, output); - output.extend_from_slice(value.as_bytes()); - } + labels.iter().for_each(|(key, value)| { + encode_string(key, output); + encode_string(value, output); + }); } -fn decode_labels(data: &[u8], pos: &mut usize) -> Result> { - let num_labels = decode_varint(data, pos)? as usize; - let mut labels = HashMap::with_capacity(num_labels); +/// Decode a label map written by [`encode_labels`]. +/// +/// # Errors +/// Returns an error on a truncated payload or non-UTF-8 key or value. +fn decode_labels(cursor: &mut Cursor<'_>) -> Result> { + let num_labels = cursor.take_count()?; + let mut labels = + HashMap::with_capacity(cursor.capacity_for(num_labels, MIN_ENCODED_LABEL_BYTES)); for _ in 0..num_labels { - let key_len = decode_varint(data, pos)? as usize; - if *pos + key_len > data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let key = String::from_utf8(data[*pos..*pos + key_len].to_vec())?; - *pos += key_len; - - let val_len = decode_varint(data, pos)? as usize; - if *pos + val_len > data.len() { - return Err(anyhow::anyhow!("Unexpected end of data")); - } - let val = String::from_utf8(data[*pos..*pos + val_len].to_vec())?; - *pos += val_len; - - labels.insert(key, val); + let key = cursor.take_string()?; + let value = cursor.take_string()?; + labels.insert(key, value); } Ok(labels) @@ -902,6 +907,135 @@ mod tests { assert_eq!(decompressed[1].value, TimeSeriesValue::Integer(-50)); } + /// One instance of each codec, for tests that must hold across all of them. + fn all_compressors() -> Vec> { + vec![ + Box::new(DeltaCompressor::new(0)), + Box::new(DoubleDeltaCompressor), + Box::new(GorillaCompressor), + ] + } + + fn data_with_labels() -> Vec { + create_test_data() + .into_iter() + .map(|point| DataPoint { + labels: HashMap::from([ + ("host".to_string(), "server1".to_string()), + ("region".to_string(), "us-east".to_string()), + ]), + ..point + }) + .collect() + } + + /// Every prefix of a valid stream must decode or error — never panic. + #[test] + fn test_decompress_survives_truncation_at_every_offset() { + for compressor in all_compressors() { + let compressed = compressor.compress(&data_with_labels()).unwrap(); + + for len in 0..compressed.len() { + // A panic here fails the test; the contract is "no panic", not "Err". + let _ = compressor.decompress(&compressed[..len]); + } + } + } + + /// Single-byte corruption must not panic either: lengths, counts, type markers + /// and Gorilla bit widths are all attacker-reachable through this path. + #[test] + fn test_decompress_survives_single_byte_corruption() { + for compressor in all_compressors() { + let compressed = compressor.compress(&data_with_labels()).unwrap(); + + for index in 0..compressed.len() { + for replacement in [0x00, 0x01, 0x02, 0x7F, 0x80, 0xFE, 0xFF] { + let corrupted: Vec = compressed + .iter() + .enumerate() + .map(|(i, &byte)| if i == index { replacement } else { byte }) + .collect(); + let _ = compressor.decompress(&corrupted); + } + } + } + } + + /// A tiny payload claiming a huge point count must fail, not try to allocate it. + #[test] + fn test_decompress_rejects_impossible_counts() { + let mut header = Vec::new(); + encode_varint(u64::MAX, &mut header); + + for compressor in all_compressors() { + assert!( + compressor.decompress(&header).is_err(), + "an impossible point count must be rejected" + ); + } + + // The count is only ever a capacity *hint*, bounded by the bytes present. + let cursor = Cursor::new(&[0u8; 12]); + assert_eq!(cursor.capacity_for(usize::MAX, MIN_ENCODED_POINT_BYTES), 4); + assert_eq!(cursor.capacity_for(2, MIN_ENCODED_POINT_BYTES), 2); + } + + /// Gorilla bit widths come from the stream; an impossible pair must be rejected + /// rather than underflowing `64 - leading - meaningful`. + #[test] + fn test_gorilla_block_rejects_impossible_widths() { + let cases = [ + XorBlock { + leading_zeros: 200, + meaningful_bits: 200, + }, + XorBlock { + leading_zeros: 60, + meaningful_bits: 60, + }, + // Empty block: would shift by 64 and overflow the shift. + XorBlock { + leading_zeros: 0, + meaningful_bits: 0, + }, + ]; + + for block in cases { + let payload = [0xFFu8; 8]; + assert!( + block.read_xor(&mut Cursor::new(&payload)).is_err(), + "expected {block:?} to be rejected" + ); + } + + // A well-formed block reads back the bits it describes. + let block = XorBlock { + leading_zeros: 56, + meaningful_bits: 8, + }; + assert_eq!(block.read_xor(&mut Cursor::new(&[0xAB])).unwrap(), 0xAB); + } + + #[test] + fn test_varint_roundtrip_and_limits() { + for value in [0u64, 1, 127, 128, 300, u64::MAX / 2, u64::MAX] { + let mut buf = Vec::new(); + encode_varint(value, &mut buf); + assert_eq!(Cursor::new(&buf).take_varint().unwrap(), value); + } + + for value in [0i64, -1, 1, i64::MIN, i64::MAX] { + let mut buf = Vec::new(); + encode_varint_signed(value, &mut buf); + assert_eq!(Cursor::new(&buf).take_varint_signed().unwrap(), value); + } + + // A continuation bit that never terminates must be rejected, not looped on. + assert!(Cursor::new(&[0xFFu8; 16]).take_varint().is_err()); + assert!(Cursor::new(&[]).take_varint().is_err()); + } + #[test] fn test_compression_ratio() { let compressor = GorillaCompressor; From 2aded3b840fade3d99ce1ec62343731d66cbf50a Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Wed, 5 Aug 2026 19:59:11 -0700 Subject: [PATCH 15/36] refactor(shared): guard capacity_for against a zero element size min_bytes is a per-format constant today, but dividing by a caller-supplied value that could be zero is a panic waiting for the next caller. Co-Authored-By: Claude Opus 5 (1M context) --- orbit/shared/src/timeseries/compression.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/orbit/shared/src/timeseries/compression.rs b/orbit/shared/src/timeseries/compression.rs index 82657ee4..6f9d0a3a 100644 --- a/orbit/shared/src/timeseries/compression.rs +++ b/orbit/shared/src/timeseries/compression.rs @@ -196,7 +196,13 @@ impl<'a> Cursor<'a> { /// contain keeps a corrupt header from requesting a huge allocation before the /// first truncated read is even attempted; the decode loop then fails normally. const fn capacity_for(&self, count: usize, min_bytes: usize) -> usize { - let affordable = self.remaining() / min_bytes; + // `min_bytes` is a per-format constant, but dividing by a parameter that + // could be zero is a panic waiting for the next caller. + let affordable = if min_bytes == 0 { + count + } else { + self.remaining() / min_bytes + }; if count < affordable { count } else { From 092db66df3716c471de37e41032131eea327ac17 Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Wed, 5 Aug 2026 20:12:35 -0700 Subject: [PATCH 16/36] Refactor shared patterns & update Cargo.lock Expose patterns module and refactor many pattern examples in orbit/shared: convert serialization from a trait to a Serialization enum with generic serialize/deserialize, add a simple RunLengthCompression, introduce Default impls for several helper types, tighten error mappings in conversions, fix interior mutability and RAII guard tests (use Arc+Atomic flags), clean up visitors/iterators/phantom-types code and tests. Large Cargo.lock changes: dependency updates and additions (tokio, rustls, hyper, reqwest, redis, rand, quinn, tower, etc.). These changes improve ergonomics, remove object-safety issues, and refresh dependencies. --- orbit/desktop/src-tauri/Cargo.lock | 538 +++++++++++++----- orbit/shared/src/lib.rs | 1 + orbit/shared/src/patterns/conversions.rs | 32 +- .../src/patterns/interior_mutability.rs | 30 +- orbit/shared/src/patterns/iterators.rs | 14 +- orbit/shared/src/patterns/phantom_types.rs | 27 +- orbit/shared/src/patterns/raii_guards.rs | 52 +- orbit/shared/src/patterns/sealed_traits.rs | 7 +- orbit/shared/src/patterns/strategy.rs | 235 ++++---- orbit/shared/src/patterns/typestate.rs | 17 +- orbit/shared/src/patterns/visitors.rs | 84 ++- 11 files changed, 707 insertions(+), 330 deletions(-) diff --git a/orbit/desktop/src-tauri/Cargo.lock b/orbit/desktop/src-tauri/Cargo.lock index 65840b2b..742b5411 100644 --- a/orbit/desktop/src-tauri/Cargo.lock +++ b/orbit/desktop/src-tauri/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -35,7 +26,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -288,18 +279,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "backtrace" -version = "0.3.76" +name = "backon" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link 0.2.1", + "fastrand", ] [[package]] @@ -627,6 +612,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.42" @@ -785,6 +781,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1362,21 +1367,6 @@ dependencies = [ "new_debug_unreachable", ] -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.31" @@ -1452,7 +1442,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1600,8 +1589,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1612,10 +1603,24 @@ checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasi 0.14.7+wasi-0.2.4", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + [[package]] name = "ghash" version = "0.5.1" @@ -1626,12 +1631,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - [[package]] name = "gio" version = "0.15.12" @@ -1803,7 +1802,7 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", + "http 0.2.12", "indexmap 2.11.4", "slab", "tokio", @@ -1898,6 +1897,16 @@ dependencies = [ "itoa 1.0.15", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa 1.0.15", +] + [[package]] name = "http-body" version = "0.4.6" @@ -1905,7 +1914,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http 1.5.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", "pin-project-lite", ] @@ -1938,8 +1970,8 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 0.2.12", + "http-body 0.4.6", "httparse", "httpdate", "itoa 1.0.15", @@ -1951,18 +1983,40 @@ dependencies = [ "want", ] +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "httparse", + "itoa 1.0.15", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + [[package]] name = "hyper-rustls" -version = "0.24.2" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "futures-util", - "http", - "hyper", + "http 1.5.0", + "hyper 1.11.0", + "hyper-util", "rustls", "tokio", "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -1972,12 +2026,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ "bytes", - "hyper", + "hyper 0.14.32", "native-tls", "tokio", "tokio-native-tls", ] +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "hyper 1.11.0", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.1", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -2203,17 +2280,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "io-uring" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" -dependencies = [ - "bitflags 2.9.4", - "cfg-if", - "libc", -] - [[package]] name = "ipnet" version = "2.11.0" @@ -2450,6 +2516,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -2910,15 +2982,6 @@ dependencies = [ "objc", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -2991,12 +3054,12 @@ version = "0.1.0" dependencies = [ "aes-gcm", "anyhow", - "base64 0.21.7", + "base64 0.22.1", "chrono", "mysql_async", "rand 0.8.5", "redis", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "sqlparser", @@ -3345,7 +3408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -3531,6 +3594,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.1", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg 0.10.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.1", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.41" @@ -3546,6 +3665,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -3563,7 +3688,7 @@ dependencies = [ "rand_chacha 0.2.2", "rand_core 0.5.1", "rand_hc", - "rand_pcg", + "rand_pcg 0.2.1", ] [[package]] @@ -3587,6 +3712,17 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -3644,6 +3780,12 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_hc" version = "0.2.0" @@ -3662,6 +3804,15 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "raw-window-handle" version = "0.5.2" @@ -3670,24 +3821,25 @@ checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" [[package]] name = "redis" -version = "0.24.0" +version = "0.32.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c580d9cbbe1d1b479e8d67cf9daf6a62c957e6846048408b80b43ac3f6af84cd" +checksum = "014cc767fefab6a3e798ca45112bccad9c6e0e218fbd49720042716c73cfef44" dependencies = [ "arc-swap", - "async-trait", + "backon", "bytes", + "cfg-if", "combine", - "futures", + "futures-channel", "futures-util", "itoa 1.0.15", + "num-bigint", "percent-encoding", "pin-project-lite", "ryu", "sha1_smol", - "socket2 0.4.10", + "socket2 0.6.1", "tokio", - "tokio-retry", "tokio-util", "url", ] @@ -3782,10 +3934,9 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", - "hyper", - "hyper-rustls", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", "hyper-tls", "ipnet", "js-sys", @@ -3795,16 +3946,14 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls", "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 0.1.2", "system-configuration", "tokio", "tokio-native-tls", - "tokio-rustls", "tokio-util", "tower-service", "url", @@ -3812,10 +3961,47 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", "winreg 0.50.0", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper 1.11.0", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "rfd" version = "0.10.0" @@ -3899,12 +4085,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "rustc-demangle" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" - [[package]] name = "rustc-hash" version = "2.1.1" @@ -3935,14 +4115,16 @@ dependencies = [ [[package]] name = "rustls" -version = "0.21.12" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ - "log", + "once_cell", "ring", + "rustls-pki-types", "rustls-webpki", - "sct", + "subtle", + "zeroize", ] [[package]] @@ -3954,13 +4136,24 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + [[package]] name = "rustls-webpki" -version = "0.101.7" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", + "rustls-pki-types", "untrusted", ] @@ -4036,16 +4229,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "seahash" version = "4.1.0" @@ -4251,7 +4434,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4268,7 +4451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4332,16 +4515,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "socket2" version = "0.5.10" @@ -4506,6 +4679,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -4665,7 +4847,7 @@ dependencies = [ "glob", "gtk", "heck 0.5.0", - "http", + "http 0.2.12", "ignore", "indexmap 1.9.3", "log", @@ -4679,7 +4861,7 @@ dependencies = [ "rand 0.8.5", "raw-window-handle", "regex", - "reqwest", + "reqwest 0.11.27", "rfd", "semver", "serde", @@ -4768,7 +4950,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8066855882f00172935e3fa7d945126580c34dcbabab43f5d4f0c2398a67d47b" dependencies = [ "gtk", - "http", + "http 0.2.12", "http-range", "rand 0.8.5", "raw-window-handle", @@ -5000,29 +5182,26 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.47.1" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", "socket2 0.6.1", "tokio-macros", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", @@ -5065,22 +5244,11 @@ dependencies = [ "whoami", ] -[[package]] -name = "tokio-retry" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f57eb36ecbe0fc510036adff84824dd3c24bb781e21bfa67b69d556aa85214f" -dependencies = [ - "pin-project", - "rand 0.8.5", - "tokio", -] - [[package]] name = "tokio-rustls" -version = "0.24.1" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -5204,6 +5372,45 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.9.4", + "bytes", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -5380,13 +5587,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.18.1" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.4.3", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -5590,6 +5797,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webkit2gtk" version = "0.18.2" @@ -5639,9 +5856,12 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.25.4" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] [[package]] name = "webview2-com" @@ -6363,7 +6583,7 @@ dependencies = [ "glib", "gtk", "html5ever", - "http", + "http 0.2.12", "kuchikiki", "libc", "log", @@ -6549,6 +6769,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.2" diff --git a/orbit/shared/src/lib.rs b/orbit/shared/src/lib.rs index 5df02108..f928e45c 100644 --- a/orbit/shared/src/lib.rs +++ b/orbit/shared/src/lib.rs @@ -26,6 +26,7 @@ pub mod k8s_election; pub mod mesh; pub mod net; pub mod orbitql; +pub mod patterns; pub mod persistence; pub mod pooling; pub mod raft_transport; diff --git a/orbit/shared/src/patterns/conversions.rs b/orbit/shared/src/patterns/conversions.rs index 82d8d5a1..5fd32257 100644 --- a/orbit/shared/src/patterns/conversions.rs +++ b/orbit/shared/src/patterns/conversions.rs @@ -3,7 +3,7 @@ //! Demonstrates type conversions, Cow (Clone-on-Write), and other //! zero-cost abstraction patterns in Rust. -use crate::error::{OrbitError, OrbitResult}; +use crate::error::OrbitError; use std::borrow::Cow; use std::sync::Arc; @@ -32,9 +32,9 @@ impl From for QueryError { impl From for OrbitError { fn from(err: QueryError) -> Self { match err { - QueryError::ParseError(msg) => OrbitError::query(msg), - QueryError::ValidationError(msg) => OrbitError::validation(msg), - QueryError::ExecutionError(msg) => OrbitError::internal(msg), + QueryError::ParseError(msg) => OrbitError::parse(msg), + QueryError::ValidationError(msg) => OrbitError::configuration(msg), + QueryError::ExecutionError(msg) => OrbitError::execution(msg), } } } @@ -155,11 +155,10 @@ impl SharedResource { } } -impl From for Arc { - fn from(resource: SharedResource) -> Self { - Arc::new(resource) - } -} +// No `impl From for Arc` here: the standard +// library already provides `impl From for Arc`, so `resource.into()` +// works out of the box and a local impl would collide with it. Reach for the +// blanket impl before writing a conversion by hand. // ===== AsRef/AsMut Pattern ===== @@ -210,7 +209,9 @@ impl From for Vec { pub fn hash_data>(data: T) -> u64 { let bytes = data.as_ref(); // Simple hash for demonstration - bytes.iter().fold(0u64, |acc, &b| acc.wrapping_mul(31).wrapping_add(b as u64)) + bytes + .iter() + .fold(0u64, |acc, &b| acc.wrapping_mul(31).wrapping_add(b as u64)) } // ===== TryFrom/TryInto Pattern ===== @@ -246,7 +247,10 @@ impl TryFrom for Percentage { fn try_from(value: f64) -> Result { if !(0.0..=100.0).contains(&value) { - Err(format!("Percentage must be between 0 and 100, got {}", value)) + Err(format!( + "Percentage must be between 0 and 100, got {}", + value + )) } else { Ok(Percentage(value)) } @@ -390,9 +394,9 @@ mod tests { let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); let query_err: QueryError = io_err.into(); - match query_err { + match &query_err { QueryError::ExecutionError(msg) => assert!(msg.contains("IO error")), - _ => panic!("Wrong error type"), + other => panic!("Wrong error type: {other:?}"), } let orbit_err: OrbitError = query_err.into(); @@ -444,7 +448,7 @@ mod tests { assert_eq!(shared.id, "res1"); assert_eq!(Arc::strong_count(&shared), 1); - let cloned = Arc::clone(&shared); + let _cloned = Arc::clone(&shared); assert_eq!(Arc::strong_count(&shared), 2); } diff --git a/orbit/shared/src/patterns/interior_mutability.rs b/orbit/shared/src/patterns/interior_mutability.rs index 70693964..80ae4404 100644 --- a/orbit/shared/src/patterns/interior_mutability.rs +++ b/orbit/shared/src/patterns/interior_mutability.rs @@ -3,10 +3,8 @@ //! Demonstrates Cell, RefCell, and other interior mutability patterns for //! scenarios where mutation is needed through shared references. -use crate::error::{OrbitError, OrbitResult}; use std::cell::{Cell, RefCell}; use std::collections::HashMap; -use std::rc::Rc; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, RwLock}; @@ -63,6 +61,16 @@ pub struct Cache { misses: Cell, } +impl Default for Cache +where + K: std::hash::Hash + Eq + Clone, + V: Clone, + { + fn default() -> Self { + Self::new() + } +} + impl Cache where K: std::hash::Hash + Eq + Clone, @@ -250,6 +258,12 @@ struct ConfigData { enabled_features: Vec, } +impl Default for SharedConfig { + fn default() -> Self { + Self::new() + } +} + impl SharedConfig { pub fn new() -> Self { Self { @@ -329,6 +343,12 @@ pub struct Observable { observers: RwLock>>, } +impl Default for Observable { + fn default() -> Self { + Self::new() + } +} + impl Observable { pub fn new() -> Self { Self { @@ -357,6 +377,12 @@ pub struct EventLogger { events: Mutex>, } +impl Default for EventLogger { + fn default() -> Self { + Self::new() + } +} + impl EventLogger { pub fn new() -> Self { Self { diff --git a/orbit/shared/src/patterns/iterators.rs b/orbit/shared/src/patterns/iterators.rs index b69b2cd4..6dcc6e35 100644 --- a/orbit/shared/src/patterns/iterators.rs +++ b/orbit/shared/src/patterns/iterators.rs @@ -3,7 +3,6 @@ //! Demonstrates implementing custom iterators, iterator adaptors, and //! advanced iteration patterns in Rust. -use crate::error::{OrbitError, OrbitResult}; use std::collections::VecDeque; // ===== Window Iterator ===== @@ -39,14 +38,19 @@ impl<'a, T> Iterator for WindowIterator<'a, T> { } fn size_hint(&self) -> (usize, Option) { - let remaining = self.data.len().saturating_sub(self.position + self.window_size - 1); + let remaining = self + .data + .len() + .saturating_sub(self.position + self.window_size - 1); (remaining, Some(remaining)) } } impl<'a, T> ExactSizeIterator for WindowIterator<'a, T> { fn len(&self) -> usize { - self.data.len().saturating_sub(self.position + self.window_size - 1) + self.data + .len() + .saturating_sub(self.position + self.window_size - 1) } } @@ -95,7 +99,7 @@ impl<'a, T> Iterator for ChunkIterator<'a, T> { } fn size_hint(&self) -> (usize, Option) { - let remaining = (self.data.len() - self.position + self.chunk_size - 1) / self.chunk_size; + let remaining = (self.data.len() - self.position).div_ceil(self.chunk_size); (remaining, Some(remaining)) } } @@ -476,7 +480,7 @@ mod tests { #[test] fn test_window_iterator() { - let data = vec![1, 2, 3, 4, 5]; + let data = [1, 2, 3, 4, 5]; let windows: Vec<_> = data.windows_iter(3).collect(); assert_eq!(windows.len(), 3); diff --git a/orbit/shared/src/patterns/phantom_types.rs b/orbit/shared/src/patterns/phantom_types.rs index 48785a24..3be46a51 100644 --- a/orbit/shared/src/patterns/phantom_types.rs +++ b/orbit/shared/src/patterns/phantom_types.rs @@ -339,11 +339,17 @@ impl FileHandle { // ===== Zero-Sized Type Markers ===== -/// Marker trait for sorted collections -pub trait Sorted {} - -/// Marker trait for unsorted collections -pub trait Unsorted {} +/// Marker type for sorted collections. +/// +/// A marker used as a type parameter must be a *type*, not a trait: `Collection` names a concrete state, whereas a bare trait in that position is only +/// ever a trait object. Zero-sized structs also cost nothing at runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Sorted; + +/// Marker type for unsorted collections. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Unsorted; /// Collection with sort state tracked at compile time pub struct Collection { @@ -417,11 +423,12 @@ mod tests { assert_eq!(user_id.value(), "user-123"); assert_eq!(session_id.value(), "session-456"); - // These IDs have different types and cannot be confused - assert_ne!( - std::mem::discriminant(&user_id), - std::mem::discriminant(&session_id) - ); + // The guarantee is a compile-time one: `user_id == session_id` and + // `takes_user_id(session_id)` are both type errors, so there is no runtime + // assertion to make. Identical payloads stay distinguishable by type alone. + let same_payload_user = UserId::new("shared".to_string()); + let same_payload_session = SessionId::new("shared".to_string()); + assert_eq!(same_payload_user.value(), same_payload_session.value()); } #[test] diff --git a/orbit/shared/src/patterns/raii_guards.rs b/orbit/shared/src/patterns/raii_guards.rs index 29339c7f..de4a496d 100644 --- a/orbit/shared/src/patterns/raii_guards.rs +++ b/orbit/shared/src/patterns/raii_guards.rs @@ -5,7 +5,7 @@ use crate::error::{OrbitError, OrbitResult}; use std::ops::{Deref, DerefMut}; use std::sync::Arc; -use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; +use tokio::sync::{RwLock, RwLockWriteGuard}; use tracing::{debug, warn}; /// Metric guard that automatically records operation duration on drop @@ -229,10 +229,17 @@ impl<'a, T> TimedLockGuard<'a, T> { let guard = tokio::time::timeout(timeout, lock.write()) .await .map_err(|_| { - OrbitError::timeout(format!("Failed to acquire lock '{}' within {:?}", lock_name, timeout)) + OrbitError::timeout(format!( + "Failed to acquire lock '{}' within {:?}", + lock_name, timeout + )) })?; - debug!(lock = lock_name, timeout_ms = timeout.as_millis(), "Lock acquired"); + debug!( + lock = lock_name, + timeout_ms = timeout.as_millis(), + "Lock acquired" + ); Ok(Self { guard: Some(guard), @@ -278,7 +285,11 @@ impl Drop for TimedLockGuard<'_, T> { "Lock held for significant portion of timeout" ); } - debug!(lock = self.lock_name, held_ms = held.as_millis(), "Lock released"); + debug!( + lock = self.lock_name, + held_ms = held.as_millis(), + "Lock released" + ); } } @@ -331,6 +342,7 @@ impl Drop for PooledResource { #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; #[tokio::test] async fn test_metrics_guard_success() { @@ -360,34 +372,34 @@ mod tests { #[test] fn test_transaction_guard_commit() { - let mut rolled_back = false; + // The rollback hook must live as long as the guard, so the flag is shared + // rather than borrowed from the stack frame. + let rolled_back = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&rolled_back); { - let guard = TransactionGuard::new( - "tx-123".to_string(), - 42, - |_| rolled_back = true, - ); + let guard = TransactionGuard::new("tx-123".to_string(), 42, move |_| { + flag.store(true, Ordering::SeqCst); + }); let _value = guard.commit(); } - assert!(!rolled_back); + assert!(!rolled_back.load(Ordering::SeqCst)); } #[test] fn test_transaction_guard_rollback() { - let mut rolled_back = false; + let rolled_back = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&rolled_back); { - let _guard = TransactionGuard::new( - "tx-123".to_string(), - 42, - |_| rolled_back = true, - ); + let _guard = TransactionGuard::new("tx-123".to_string(), 42, move |_| { + flag.store(true, Ordering::SeqCst); + }); // Dropped without commit } - assert!(rolled_back); + assert!(rolled_back.load(Ordering::SeqCst)); } #[test] @@ -422,8 +434,8 @@ mod tests { .await .unwrap(); - assert_eq!(**guard, 42); - **guard = 100; + assert_eq!(*guard, 42); + *guard = 100; assert!(!guard.is_approaching_timeout()); } } diff --git a/orbit/shared/src/patterns/sealed_traits.rs b/orbit/shared/src/patterns/sealed_traits.rs index 601b2fc4..83974eb8 100644 --- a/orbit/shared/src/patterns/sealed_traits.rs +++ b/orbit/shared/src/patterns/sealed_traits.rs @@ -4,8 +4,6 @@ //! while still allowing the trait to be publicly used. This gives library authors //! control over which types can implement a trait. -use crate::error::{OrbitError, OrbitResult}; - // ===== Basic Sealed Trait Pattern ===== mod private { @@ -403,10 +401,7 @@ pub fn get_protocol_info(protocol: &P) -> String { } /// Select storage backend based on requirements -pub fn select_backend( - needs_transactions: bool, - max_key_size: usize, -) -> Box { +pub fn select_backend(needs_transactions: bool, max_key_size: usize) -> Box { if max_key_size > 8 * 1024 * 1024 { Box::new(RedisBackend) } else if needs_transactions { diff --git a/orbit/shared/src/patterns/strategy.rs b/orbit/shared/src/patterns/strategy.rs index ee373051..fc007da8 100644 --- a/orbit/shared/src/patterns/strategy.rs +++ b/orbit/shared/src/patterns/strategy.rs @@ -49,7 +49,11 @@ impl RetryStrategy for ExponentialBackoff { } fn should_retry(&self, attempt: u32, error: &OrbitError) -> bool { - attempt < self.max_retries && matches!(error, OrbitError::NetworkError(_) | OrbitError::Timeout { .. }) + attempt < self.max_retries + && matches!( + error, + OrbitError::NetworkError(_) | OrbitError::Timeout { .. } + ) } fn max_retries(&self) -> u32 { @@ -133,56 +137,63 @@ where // ===== Serialization Strategy ===== -/// Trait for serialization strategies -pub trait SerializationStrategy: Send + Sync { - /// Serialize data to bytes - fn serialize(&self, data: &T) -> OrbitResult>; - - /// Deserialize bytes to data - fn deserialize(&self, bytes: &[u8]) -> OrbitResult; - - /// Get content type - fn content_type(&self) -> &str; +/// Serialization formats, as a closed set. +/// +/// A strategy whose operations are generic (`serialize`) cannot be a trait +/// object: there is no vtable slot for a method that is monomorphized per type. +/// Rust's answer is a sum type. Dispatch is still chosen at runtime, the methods +/// stay generic, and adding a format turns every `match` into a compile error +/// instead of a silently missing case. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Serialization { + /// Self-describing and human-readable. + Json, + /// Compact binary. Not self-describing — decoding requires the target type, + /// which is precisely why an erased `serde_json::Value` cannot stand in for + /// `T` here. + Bincode, } -/// JSON serialization -pub struct JsonSerialization; - -impl SerializationStrategy for JsonSerialization { - fn serialize(&self, data: &T) -> OrbitResult> { - serde_json::to_vec(data).map_err(|e| OrbitError::internal(format!("JSON serialization failed: {}", e))) - } - - fn deserialize(&self, bytes: &[u8]) -> OrbitResult { - serde_json::from_slice(bytes).map_err(|e| OrbitError::internal(format!("JSON deserialization failed: {}", e))) - } - - fn content_type(&self) -> &str { - "application/json" - } -} - -/// Bincode serialization (more efficient) -pub struct BincodeSerialization; - -impl SerializationStrategy for BincodeSerialization { - fn serialize(&self, data: &T) -> OrbitResult> { - bincode::serialize(data).map_err(|e| OrbitError::internal(format!("Bincode serialization failed: {}", e))) +impl Serialization { + /// Serialize a value in this format. + /// + /// # Errors + /// Returns an error if the value cannot be encoded in this format. + pub fn serialize(self, data: &T) -> OrbitResult> { + match self { + Self::Json => serde_json::to_vec(data) + .map_err(|e| OrbitError::internal(format!("JSON serialization failed: {e}"))), + Self::Bincode => bincode::serialize(data) + .map_err(|e| OrbitError::internal(format!("Bincode serialization failed: {e}"))), + } } - fn deserialize(&self, bytes: &[u8]) -> OrbitResult { - bincode::deserialize(bytes).map_err(|e| OrbitError::internal(format!("Bincode deserialization failed: {}", e))) + /// Deserialize a value from this format. + /// + /// # Errors + /// Returns an error if the bytes are malformed or do not match `T`. + pub fn deserialize(self, bytes: &[u8]) -> OrbitResult { + match self { + Self::Json => serde_json::from_slice(bytes) + .map_err(|e| OrbitError::internal(format!("JSON deserialization failed: {e}"))), + Self::Bincode => bincode::deserialize(bytes) + .map_err(|e| OrbitError::internal(format!("Bincode deserialization failed: {e}"))), + } } - fn content_type(&self) -> &str { - "application/octet-stream" + /// MIME type produced by this format. + #[must_use] + pub const fn content_type(self) -> &'static str { + match self { + Self::Json => "application/json", + Self::Bincode => "application/octet-stream", + } } } // ===== Compression Strategy ===== -use std::io::{Read, Write}; - /// Trait for compression strategies pub trait CompressionStrategy: Send + Sync { /// Compress data @@ -212,54 +223,62 @@ impl CompressionStrategy for NoCompression { } } -/// Gzip compression -pub struct GzipCompression { - pub level: u32, -} - -impl Default for GzipCompression { - fn default() -> Self { - Self { level: 6 } - } -} - -impl CompressionStrategy for GzipCompression { +/// Run-length compression: `(count, byte)` pairs, counts capped at 255. +/// +/// Named for what it does. A second strategy is needed to show the context +/// swapping algorithms at runtime, and run-length encoding earns that role +/// without pulling a compression crate into the dependency graph — real +/// deployments should reach for the codecs in `timeseries::compression` or a +/// dedicated crate instead. +#[derive(Debug, Clone, Copy, Default)] +pub struct RunLengthCompression; + +impl CompressionStrategy for RunLengthCompression { fn compress(&self, data: &[u8]) -> OrbitResult> { - use flate2::write::GzEncoder; - use flate2::Compression; + let runs = data.iter().fold(Vec::<(u8, u8)>::new(), |mut runs, &byte| { + match runs.last_mut() { + Some((value, count)) if *value == byte && *count < u8::MAX => *count += 1, + _ => runs.push((byte, 1)), + } + runs + }); - let mut encoder = GzEncoder::new(Vec::new(), Compression::new(self.level)); - encoder.write_all(data).map_err(|e| OrbitError::internal(format!("Gzip compression failed: {}", e)))?; - encoder.finish().map_err(|e| OrbitError::internal(format!("Gzip finalization failed: {}", e))) + Ok(runs + .into_iter() + .flat_map(|(value, count)| [count, value]) + .collect()) } fn decompress(&self, data: &[u8]) -> OrbitResult> { - use flate2::read::GzDecoder; + if !data.len().is_multiple_of(2) { + return Err(OrbitError::internal(format!( + "Run-length payload must be (count, byte) pairs, got {} bytes", + data.len() + ))); + } - let mut decoder = GzDecoder::new(data); - let mut decompressed = Vec::new(); - decoder.read_to_end(&mut decompressed).map_err(|e| OrbitError::internal(format!("Gzip decompression failed: {}", e)))?; - Ok(decompressed) + Ok(data + .chunks_exact(2) + .flat_map(|pair| std::iter::repeat_n(pair[1], usize::from(pair[0]))) + .collect()) } fn algorithm(&self) -> &str { - "gzip" + "run-length" } } // ===== Strategy Context ===== -/// Context that uses strategies +/// Context that combines both dispatch styles: a sum type where the operations +/// are generic, and a trait object where they are not. pub struct DataProcessor { - serialization: Arc, + serialization: Serialization, compression: Arc, } impl DataProcessor { - pub fn new( - serialization: Arc, - compression: Arc, - ) -> Self { + pub fn new(serialization: Serialization, compression: Arc) -> Self { Self { serialization, compression, @@ -279,7 +298,7 @@ impl DataProcessor { } /// Change strategies at runtime - pub fn set_serialization(&mut self, strategy: Arc) { + pub fn set_serialization(&mut self, strategy: Serialization) { self.serialization = strategy; } @@ -291,6 +310,7 @@ impl DataProcessor { #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; #[tokio::test] async fn test_exponential_backoff() { @@ -308,20 +328,26 @@ mod tests { #[tokio::test] async fn test_with_retry_success() { let strategy = Arc::new(ExponentialBackoff::default()); - let mut attempts = 0; - - let result = with_retry(strategy, || async { - attempts += 1; - if attempts < 3 { - Err(OrbitError::network("temporary error")) - } else { - Ok(42) + // The operation returns a future that outlives each closure call, so the + // attempt counter is shared rather than mutably borrowed by the closure. + let attempts = Arc::new(AtomicU32::new(0)); + let counter = Arc::clone(&attempts); + + let result = with_retry(strategy, move || { + let counter = Arc::clone(&counter); + async move { + let attempt = counter.fetch_add(1, Ordering::SeqCst) + 1; + if attempt < 3 { + Err(OrbitError::network("temporary error")) + } else { + Ok(42) + } } }) .await; assert_eq!(result.unwrap(), 42); - assert_eq!(attempts, 3); + assert_eq!(attempts.load(Ordering::SeqCst), 3); } #[test] @@ -339,17 +365,19 @@ mod tests { value: 42, }; - // JSON - let json_strategy = JsonSerialization; - let serialized = json_strategy.serialize(&data).unwrap(); - let deserialized: TestData = json_strategy.deserialize(&serialized).unwrap(); - assert_eq!(data, deserialized); - - // Bincode - let bincode_strategy = BincodeSerialization; - let serialized = bincode_strategy.serialize(&data).unwrap(); - let deserialized: TestData = bincode_strategy.deserialize(&serialized).unwrap(); - assert_eq!(data, deserialized); + // Every format round-trips, and each reports its own content type. + for format in [Serialization::Json, Serialization::Bincode] { + let serialized = format.serialize(&data).unwrap(); + let deserialized: TestData = format.deserialize(&serialized).unwrap(); + assert_eq!(data, deserialized, "{format:?} did not round-trip"); + assert!(!format.content_type().is_empty()); + } + + // Bincode is the more compact of the two for this payload. + assert!( + Serialization::Bincode.serialize(&data).unwrap().len() + < Serialization::Json.serialize(&data).unwrap().len() + ); } #[test] @@ -361,12 +389,18 @@ mod tests { let compressed = no_compression.compress(data).unwrap(); assert_eq!(compressed, data); - // Gzip - let gzip = GzipCompression::default(); - let compressed = gzip.compress(data).unwrap(); - assert!(compressed.len() < data.len()); // Should be smaller - let decompressed = gzip.decompress(&compressed).unwrap(); + // Run-length: round-trips, and shrinks input that actually has runs. + let rle = RunLengthCompression; + let decompressed = rle.decompress(&rle.compress(data).unwrap()).unwrap(); assert_eq!(decompressed, data); + + let runs = vec![b'a'; 300]; + let compressed = rle.compress(&runs).unwrap(); + assert!(compressed.len() < runs.len()); + assert_eq!(rle.decompress(&compressed).unwrap(), runs); + + // A truncated payload is rejected, not silently half-decoded. + assert!(rle.decompress(&[3]).is_err()); } #[test] @@ -382,14 +416,17 @@ mod tests { value: "test data".to_string(), }; - let processor = DataProcessor::new( - Arc::new(JsonSerialization), - Arc::new(NoCompression), - ); + let mut processor = DataProcessor::new(Serialization::Json, Arc::new(NoCompression)); let processed = processor.process(&data).unwrap(); let unprocessed: TestData = processor.unprocess(&processed).unwrap(); + assert_eq!(data, unprocessed); + // Both strategies are swappable at runtime. + processor.set_serialization(Serialization::Bincode); + processor.set_compression(Arc::new(RunLengthCompression)); + let processed = processor.process(&data).unwrap(); + let unprocessed: TestData = processor.unprocess(&processed).unwrap(); assert_eq!(data, unprocessed); } } diff --git a/orbit/shared/src/patterns/typestate.rs b/orbit/shared/src/patterns/typestate.rs index 44fb58b3..36505bd1 100644 --- a/orbit/shared/src/patterns/typestate.rs +++ b/orbit/shared/src/patterns/typestate.rs @@ -60,7 +60,9 @@ impl DatabaseConnection { /// Attempt to connect (transitions to Connected state) pub fn connect(mut self) -> OrbitResult> { if self.connection_string.is_empty() { - return Err(OrbitError::configuration("Connection string cannot be empty")); + return Err(OrbitError::configuration( + "Connection string cannot be empty", + )); } // Simulate connection @@ -82,9 +84,10 @@ impl DatabaseConnection { impl DatabaseConnection { /// Execute a query (only available in Connected state) pub fn execute(&self, query: &str) -> OrbitResult { - let handle = self.handle.as_ref().ok_or_else(|| { - OrbitError::internal("Connection handle not available") - })?; + let handle = self + .handle + .as_ref() + .ok_or_else(|| OrbitError::internal("Connection handle not available"))?; Ok(format!("Executed '{}' on {}", query, handle)) } @@ -134,6 +137,12 @@ pub struct ConfigBuilder { _state: PhantomData, } +impl Default for ConfigBuilder { + fn default() -> Self { + Self::new() + } +} + impl ConfigBuilder { pub fn new() -> Self { Self { diff --git a/orbit/shared/src/patterns/visitors.rs b/orbit/shared/src/patterns/visitors.rs index 11907766..0dc5e395 100644 --- a/orbit/shared/src/patterns/visitors.rs +++ b/orbit/shared/src/patterns/visitors.rs @@ -3,7 +3,6 @@ //! Separates algorithms from the objects they operate on, allowing new //! operations without modifying existing structures. -use crate::error::{OrbitError, OrbitResult}; use serde::{Deserialize, Serialize}; // ===== Query AST for demonstration ===== @@ -43,7 +42,8 @@ pub trait QueryVisitor { fn visit_table(&mut self, name: &str) -> Self::Output; fn visit_filter(&mut self, source: &QueryNode, condition: &str) -> Self::Output; fn visit_join(&mut self, left: &QueryNode, right: &QueryNode, condition: &str) -> Self::Output; - fn visit_aggregate(&mut self, source: &QueryNode, function: &str, column: &str) -> Self::Output; + fn visit_aggregate(&mut self, source: &QueryNode, function: &str, column: &str) + -> Self::Output; } impl QueryNode { @@ -52,8 +52,16 @@ impl QueryNode { QueryNode::Select { columns, from } => visitor.visit_select(columns, from), QueryNode::Table { name } => visitor.visit_table(name), QueryNode::Filter { source, condition } => visitor.visit_filter(source, condition), - QueryNode::Join { left, right, condition } => visitor.visit_join(left, right, condition), - QueryNode::Aggregate { source, function, column } => visitor.visit_aggregate(source, function, column), + QueryNode::Join { + left, + right, + condition, + } => visitor.visit_join(left, right, condition), + QueryNode::Aggregate { + source, + function, + column, + } => visitor.visit_aggregate(source, function, column), } } } @@ -64,6 +72,12 @@ pub struct SqlGenerator { indent_level: usize, } +impl Default for SqlGenerator { + fn default() -> Self { + Self::new() + } +} + impl SqlGenerator { pub fn new() -> Self { Self { indent_level: 0 } @@ -104,7 +118,14 @@ impl QueryVisitor for SqlGenerator { let right_sql = right.accept(self); self.indent_level -= 1; - format!("{}\n{}JOIN {}\n{}ON {}", left_sql, self.indent(), right_sql, self.indent(), condition) + format!( + "{}\n{}JOIN {}\n{}ON {}", + left_sql, + self.indent(), + right_sql, + self.indent(), + condition + ) } fn visit_aggregate(&mut self, source: &QueryNode, function: &str, column: &str) -> String { @@ -112,7 +133,13 @@ impl QueryVisitor for SqlGenerator { let source_sql = source.accept(self); self.indent_level -= 1; - format!("SELECT {}({})\n{}FROM {}", function, column, self.indent(), source_sql) + format!( + "SELECT {}({})\n{}FROM {}", + function, + column, + self.indent(), + source_sql + ) } } @@ -122,6 +149,12 @@ pub struct QueryOptimizer { optimizations_applied: usize, } +impl Default for QueryOptimizer { + fn default() -> Self { + Self::new() + } +} + impl QueryOptimizer { pub fn new() -> Self { Self { @@ -201,6 +234,12 @@ pub struct QueryValidator { errors: Vec, } +impl Default for QueryValidator { + fn default() -> Self { + Self::new() + } +} + impl QueryValidator { pub fn new() -> Self { Self { errors: Vec::new() } @@ -220,7 +259,8 @@ impl QueryVisitor for QueryValidator { fn visit_select(&mut self, columns: &[String], from: &QueryNode) { if columns.is_empty() { - self.errors.push("SELECT must have at least one column".to_string()); + self.errors + .push("SELECT must have at least one column".to_string()); } from.accept(self); @@ -234,7 +274,8 @@ impl QueryVisitor for QueryValidator { fn visit_filter(&mut self, source: &QueryNode, condition: &str) { if condition.is_empty() { - self.errors.push("Filter condition cannot be empty".to_string()); + self.errors + .push("Filter condition cannot be empty".to_string()); } source.accept(self); @@ -242,7 +283,8 @@ impl QueryVisitor for QueryValidator { fn visit_join(&mut self, left: &QueryNode, right: &QueryNode, condition: &str) { if condition.is_empty() { - self.errors.push("Join condition cannot be empty".to_string()); + self.errors + .push("Join condition cannot be empty".to_string()); } left.accept(self); @@ -252,11 +294,13 @@ impl QueryVisitor for QueryValidator { fn visit_aggregate(&mut self, source: &QueryNode, function: &str, column: &str) { let valid_functions = ["COUNT", "SUM", "AVG", "MIN", "MAX"]; if !valid_functions.contains(&function.to_uppercase().as_str()) { - self.errors.push(format!("Invalid aggregate function: {}", function)); + self.errors + .push(format!("Invalid aggregate function: {}", function)); } if column.is_empty() { - self.errors.push("Aggregate column cannot be empty".to_string()); + self.errors + .push("Aggregate column cannot be empty".to_string()); } source.accept(self); @@ -269,6 +313,12 @@ pub struct CostEstimator { estimated_cost: f64, } +impl Default for CostEstimator { + fn default() -> Self { + Self::new() + } +} + impl CostEstimator { pub fn new() -> Self { Self { @@ -448,7 +498,7 @@ mod tests { invalid_query.accept(&mut validator); assert!(!validator.is_valid()); - assert!(validator.errors().len() > 0); + assert!(!validator.errors().is_empty()); } #[test] @@ -472,8 +522,14 @@ mod tests { #[test] fn test_shape_visitors() { let circle = Circle { radius: 5.0 }; - let rectangle = Rectangle { width: 4.0, height: 6.0 }; - let triangle = Triangle { base: 3.0, height: 4.0 }; + let rectangle = Rectangle { + width: 4.0, + height: 6.0, + }; + let triangle = Triangle { + base: 3.0, + height: 4.0, + }; let mut area_calc = AreaCalculator; assert!((circle.accept(&mut area_calc) - 78.539).abs() < 0.01); From 89d6bcc6497a5b28ca534e428c85ee3e2d461cf8 Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Wed, 5 Aug 2026 20:13:31 -0700 Subject: [PATCH 17/36] Refactor to use checked_div and improve type clarity Replace conditional division checks with idiomatic checked_div() in interior_mutability and compression modules for safer, more expressive code. Also extract FallibleMapper type alias for clarity, convert test vectors to array literals for efficiency, and adjust documentation style in typestate from doc comments to implementation comments. --- orbit/shared/src/patterns/interior_mutability.rs | 15 +++++++-------- orbit/shared/src/patterns/iterators.rs | 9 ++++++--- orbit/shared/src/patterns/typestate.rs | 4 ++-- orbit/shared/src/timeseries/compression.rs | 7 +++---- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/orbit/shared/src/patterns/interior_mutability.rs b/orbit/shared/src/patterns/interior_mutability.rs index 80ae4404..8b5b0ddf 100644 --- a/orbit/shared/src/patterns/interior_mutability.rs +++ b/orbit/shared/src/patterns/interior_mutability.rs @@ -186,14 +186,13 @@ impl AtomicMetrics { MetricsSnapshot { total_operations: self.operations.load(Ordering::Relaxed), total_errors: self.errors.load(Ordering::Relaxed), - average_duration_ns: { - let ops = self.operations.load(Ordering::Relaxed); - if ops == 0 { - 0 - } else { - self.total_duration_ns.load(Ordering::Relaxed) / ops - } - }, + // No operations recorded means "no average", reported as 0 rather + // than dividing by zero. + average_duration_ns: self + .total_duration_ns + .load(Ordering::Relaxed) + .checked_div(self.operations.load(Ordering::Relaxed)) + .unwrap_or(0), active_operations: self.active_operations.load(Ordering::Relaxed), circuit_open: self.circuit_open.load(Ordering::Relaxed), } diff --git a/orbit/shared/src/patterns/iterators.rs b/orbit/shared/src/patterns/iterators.rs index 6dcc6e35..e45b6937 100644 --- a/orbit/shared/src/patterns/iterators.rs +++ b/orbit/shared/src/patterns/iterators.rs @@ -375,13 +375,16 @@ where // ===== Result Iterator (Fallible) ===== +/// Boxed mapping closure used by [`ResultIterator`]. +type FallibleMapper = Box::Item) -> Result<::Item, E>>; + /// Iterator that can fail during iteration pub struct ResultIterator where I: Iterator, { inner: I, - mapper: Box Result>, + mapper: FallibleMapper, } impl ResultIterator @@ -534,7 +537,7 @@ mod tests { #[test] fn test_batch_iterator() { - let data = vec![1, 2, 3, 4, 5]; + let data = [1, 2, 3, 4, 5]; let batches: Vec<_> = data.into_iter().batched(2).collect(); assert_eq!(batches.len(), 3); @@ -545,7 +548,7 @@ mod tests { #[test] fn test_peekable_n() { - let data = vec![1, 2, 3, 4, 5]; + let data = [1, 2, 3, 4, 5]; let mut peekable = PeekableN::new(data.into_iter()); assert_eq!(peekable.peek_nth(0), Some(&1)); diff --git a/orbit/shared/src/patterns/typestate.rs b/orbit/shared/src/patterns/typestate.rs index 36505bd1..e9d24f7a 100644 --- a/orbit/shared/src/patterns/typestate.rs +++ b/orbit/shared/src/patterns/typestate.rs @@ -108,8 +108,8 @@ impl DatabaseConnection { } impl DatabaseConnection { - /// Cannot execute queries on closed connection (compile error if attempted) - /// This demonstrates the power of typestate - invalid operations don't exist + // A closed connection has no `execute`: the method simply does not exist in + // this state, so misuse is a compile error rather than a runtime check. /// Get final statistics pub fn final_stats(&self) -> String { diff --git a/orbit/shared/src/timeseries/compression.rs b/orbit/shared/src/timeseries/compression.rs index 6f9d0a3a..24359cbd 100644 --- a/orbit/shared/src/timeseries/compression.rs +++ b/orbit/shared/src/timeseries/compression.rs @@ -198,10 +198,9 @@ impl<'a> Cursor<'a> { const fn capacity_for(&self, count: usize, min_bytes: usize) -> usize { // `min_bytes` is a per-format constant, but dividing by a parameter that // could be zero is a panic waiting for the next caller. - let affordable = if min_bytes == 0 { - count - } else { - self.remaining() / min_bytes + let affordable = match self.remaining().checked_div(min_bytes) { + Some(affordable) => affordable, + None => count, }; if count < affordable { count From 964adb83a664785bd0d107d64010d0d1b03837b3 Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Wed, 5 Aug 2026 20:13:51 -0700 Subject: [PATCH 18/36] Add Default impl for types with new() Implement Default trait for types that have parameterless constructors. This follows Rust conventions where types with new() should derive/implement Default. Affected types: Observable, EventLogger, DatabaseConnection, ConfigBuilder, SqlGenerator, QueryOptimizer, QueryValidator, CostEstimator. Also fixes minor formatting issues (line breaks, brace positioning). --- .../src/patterns/interior_mutability.rs | 14 ++++++++++- orbit/shared/src/patterns/iterators.rs | 3 ++- orbit/shared/src/patterns/typestate.rs | 12 ++++++++++ orbit/shared/src/patterns/visitors.rs | 24 +++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/orbit/shared/src/patterns/interior_mutability.rs b/orbit/shared/src/patterns/interior_mutability.rs index 8b5b0ddf..67d5984f 100644 --- a/orbit/shared/src/patterns/interior_mutability.rs +++ b/orbit/shared/src/patterns/interior_mutability.rs @@ -65,7 +65,7 @@ impl Default for Cache where K: std::hash::Hash + Eq + Clone, V: Clone, - { +{ fn default() -> Self { Self::new() } @@ -348,6 +348,12 @@ impl Default for Observable { } } +impl Default for Observable { + fn default() -> Self { + Self::new() + } +} + impl Observable { pub fn new() -> Self { Self { @@ -382,6 +388,12 @@ impl Default for EventLogger { } } +impl Default for EventLogger { + fn default() -> Self { + Self::new() + } +} + impl EventLogger { pub fn new() -> Self { Self { diff --git a/orbit/shared/src/patterns/iterators.rs b/orbit/shared/src/patterns/iterators.rs index e45b6937..7baa126c 100644 --- a/orbit/shared/src/patterns/iterators.rs +++ b/orbit/shared/src/patterns/iterators.rs @@ -376,7 +376,8 @@ where // ===== Result Iterator (Fallible) ===== /// Boxed mapping closure used by [`ResultIterator`]. -type FallibleMapper = Box::Item) -> Result<::Item, E>>; +type FallibleMapper = + Box::Item) -> Result<::Item, E>>; /// Iterator that can fail during iteration pub struct ResultIterator diff --git a/orbit/shared/src/patterns/typestate.rs b/orbit/shared/src/patterns/typestate.rs index e9d24f7a..a04d7feb 100644 --- a/orbit/shared/src/patterns/typestate.rs +++ b/orbit/shared/src/patterns/typestate.rs @@ -36,6 +36,12 @@ pub struct DatabaseConnection { _state: PhantomData, } +impl Default for DatabaseConnection { + fn default() -> Self { + Self::new() + } +} + impl DatabaseConnection { /// Create a new uninitialized connection pub fn new() -> Self { @@ -143,6 +149,12 @@ impl Default for ConfigBuilder { } } +impl Default for ConfigBuilder { + fn default() -> Self { + Self::new() + } +} + impl ConfigBuilder { pub fn new() -> Self { Self { diff --git a/orbit/shared/src/patterns/visitors.rs b/orbit/shared/src/patterns/visitors.rs index 0dc5e395..8c2d6e8b 100644 --- a/orbit/shared/src/patterns/visitors.rs +++ b/orbit/shared/src/patterns/visitors.rs @@ -78,6 +78,12 @@ impl Default for SqlGenerator { } } +impl Default for SqlGenerator { + fn default() -> Self { + Self::new() + } +} + impl SqlGenerator { pub fn new() -> Self { Self { indent_level: 0 } @@ -155,6 +161,12 @@ impl Default for QueryOptimizer { } } +impl Default for QueryOptimizer { + fn default() -> Self { + Self::new() + } +} + impl QueryOptimizer { pub fn new() -> Self { Self { @@ -240,6 +252,12 @@ impl Default for QueryValidator { } } +impl Default for QueryValidator { + fn default() -> Self { + Self::new() + } +} + impl QueryValidator { pub fn new() -> Self { Self { errors: Vec::new() } @@ -319,6 +337,12 @@ impl Default for CostEstimator { } } +impl Default for CostEstimator { + fn default() -> Self { + Self::new() + } +} + impl CostEstimator { pub fn new() -> Self { Self { From b36bf4c8486d1bd0447a6fe2cde1691e511ccb44 Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Wed, 5 Aug 2026 20:14:56 -0700 Subject: [PATCH 19/36] refactor(shared): drop duplicate Default impls in patterns cargo clippy --fix had already added these; keep one impl per type. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/patterns/interior_mutability.rs | 12 ---------- orbit/shared/src/patterns/typestate.rs | 6 ----- orbit/shared/src/patterns/visitors.rs | 24 ------------------- 3 files changed, 42 deletions(-) diff --git a/orbit/shared/src/patterns/interior_mutability.rs b/orbit/shared/src/patterns/interior_mutability.rs index 67d5984f..4e454f46 100644 --- a/orbit/shared/src/patterns/interior_mutability.rs +++ b/orbit/shared/src/patterns/interior_mutability.rs @@ -348,12 +348,6 @@ impl Default for Observable { } } -impl Default for Observable { - fn default() -> Self { - Self::new() - } -} - impl Observable { pub fn new() -> Self { Self { @@ -388,12 +382,6 @@ impl Default for EventLogger { } } -impl Default for EventLogger { - fn default() -> Self { - Self::new() - } -} - impl EventLogger { pub fn new() -> Self { Self { diff --git a/orbit/shared/src/patterns/typestate.rs b/orbit/shared/src/patterns/typestate.rs index a04d7feb..7f899786 100644 --- a/orbit/shared/src/patterns/typestate.rs +++ b/orbit/shared/src/patterns/typestate.rs @@ -149,12 +149,6 @@ impl Default for ConfigBuilder { } } -impl Default for ConfigBuilder { - fn default() -> Self { - Self::new() - } -} - impl ConfigBuilder { pub fn new() -> Self { Self { diff --git a/orbit/shared/src/patterns/visitors.rs b/orbit/shared/src/patterns/visitors.rs index 8c2d6e8b..0dc5e395 100644 --- a/orbit/shared/src/patterns/visitors.rs +++ b/orbit/shared/src/patterns/visitors.rs @@ -78,12 +78,6 @@ impl Default for SqlGenerator { } } -impl Default for SqlGenerator { - fn default() -> Self { - Self::new() - } -} - impl SqlGenerator { pub fn new() -> Self { Self { indent_level: 0 } @@ -161,12 +155,6 @@ impl Default for QueryOptimizer { } } -impl Default for QueryOptimizer { - fn default() -> Self { - Self::new() - } -} - impl QueryOptimizer { pub fn new() -> Self { Self { @@ -252,12 +240,6 @@ impl Default for QueryValidator { } } -impl Default for QueryValidator { - fn default() -> Self { - Self::new() - } -} - impl QueryValidator { pub fn new() -> Self { Self { errors: Vec::new() } @@ -337,12 +319,6 @@ impl Default for CostEstimator { } } -impl Default for CostEstimator { - fn default() -> Self { - Self::new() - } -} - impl CostEstimator { pub fn new() -> Self { Self { From 1ba0343dce8b17f19685f74763ded1f2892678aa Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Wed, 5 Aug 2026 20:17:06 -0700 Subject: [PATCH 20/36] docs: record the shared patterns module in PRD.md orbit/shared/src/patterns/ is now compiled (pub mod patterns; in lib.rs), so it belongs in the module reference. Co-Authored-By: Claude Opus 5 (1M context) --- specifications/PRD.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/specifications/PRD.md b/specifications/PRD.md index b46fa06f..120a722b 100644 --- a/specifications/PRD.md +++ b/specifications/PRD.md @@ -296,6 +296,17 @@ orbit/shared/src/ ├── graphrag.rs # GraphRAG types ├── mesh.rs # Service mesh types ├── net.rs # Network utilities +├── patterns/ # Reusable Rust idiom implementations +│ ├── mod.rs # Pattern exports +│ ├── conversions.rs # From/TryFrom, Cow, AsRef boundaries +│ ├── interior_mutability.rs # Cell/RefCell/RwLock ownership +│ ├── iterators.rs # Custom iterator adapters +│ ├── phantom_types.rs # Units, branded IDs, capability markers +│ ├── raii_guards.rs # Drop-based transaction/metric guards +│ ├── sealed_traits.rs # Sealed traits for evolvable APIs +│ ├── strategy.rs # Retry/serialization/compression strategies +│ ├── typestate.rs # Compile-time state machines +│ └── visitors.rs # AST visitors (SQL gen, optimize, validate) ├── pooling/ # Connection pooling │ ├── mod.rs # Pool management │ ├── circuit_breaker.rs # Circuit breaker pattern From cae34bcb69359ca293d7b3746400afacd92b5f93 Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Wed, 5 Aug 2026 20:22:26 -0700 Subject: [PATCH 21/36] Add LLM crate and refactor desktop app architecture Introduces orbit-llm workspace crate with provider abstraction, retry policy, circuit breaker, and error handling for multi-provider LLM integration. Refactors Orbit Desktop to use persistent sessions instead of per-query connections, adds cluster lifecycle management (start/stop/status via scripts/start-cluster.sh), and replaces trait-based connection handling with async-trait for cleaner protocol support. Includes AI/LLM roadmap and competitive analysis documentation. Changes enable runtime model switching, fallback chains, and preparation for semantic caching and auto-embedding features. --- orbit/desktop/src-tauri/Cargo.toml | 3 + orbit/desktop/src-tauri/src/cluster.rs | 554 ++++++ orbit/desktop/src-tauri/src/connections.rs | 2074 ++++++++++---------- orbit/desktop/src-tauri/src/main.rs | 633 +++--- orbit/desktop/src-tauri/src/queries.rs | 1144 ++++------- orbit/llm/Cargo.toml | 30 + orbit/llm/src/breaker.rs | 348 ++++ orbit/llm/src/error.rs | 279 +++ orbit/llm/src/provider.rs | 257 +++ orbit/llm/src/retry.rs | 214 ++ orbit/llm/src/secret.rs | 129 ++ orbit/llm/src/types.rs | 429 ++++ specifications/AI_LLM_ROADMAP.md | 175 ++ specifications/COMPETITIVE_ANALYSIS.md | 297 +++ 14 files changed, 4549 insertions(+), 2017 deletions(-) create mode 100644 orbit/desktop/src-tauri/src/cluster.rs create mode 100644 orbit/llm/Cargo.toml create mode 100644 orbit/llm/src/breaker.rs create mode 100644 orbit/llm/src/error.rs create mode 100644 orbit/llm/src/provider.rs create mode 100644 orbit/llm/src/retry.rs create mode 100644 orbit/llm/src/secret.rs create mode 100644 orbit/llm/src/types.rs create mode 100644 specifications/AI_LLM_ROADMAP.md create mode 100644 specifications/COMPETITIVE_ANALYSIS.md diff --git a/orbit/desktop/src-tauri/Cargo.toml b/orbit/desktop/src-tauri/Cargo.toml index 89b24933..13e403e5 100644 --- a/orbit/desktop/src-tauri/Cargo.toml +++ b/orbit/desktop/src-tauri/Cargo.toml @@ -17,6 +17,7 @@ tauri-build = { version = "1.5", features = [] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } tokio = { version = "1.48", features = ["full"] } +async-trait = "0.1" tauri = { version = "1.5", features = [ "dialog-message", "notification-all", "http-request", "window-unminimize", "window-unmaximize", "fs-rename-file", "fs-create-dir", "fs-remove-file", "dialog-ask", "fs-read-dir", "window-minimize", "fs-read-file", "fs-write-file", "dialog-open", "window-close", "window-maximize", "fs-remove-dir", "fs-exists", "window-show", "window-start-dragging", "shell-open", "window-hide", "fs-copy-file", "dialog-save", "dialog-confirm"] } uuid = { version = "1.19", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } @@ -26,6 +27,8 @@ tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-serde_j redis = { version = "0.32", features = ["tokio-comp", "connection-manager"] } mysql_async = { version = "0.34", features = ["chrono", "native-tls"] } sqlparser = "0.39" +# NUMERIC/DECIMAL decoding for PostgreSQL result sets +rust_decimal = { version = "1.39", features = ["db-tokio-postgres"] } # HTTP client for API calls reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } diff --git a/orbit/desktop/src-tauri/src/cluster.rs b/orbit/desktop/src-tauri/src/cluster.rs new file mode 100644 index 00000000..6ccc2144 --- /dev/null +++ b/orbit/desktop/src-tauri/src/cluster.rs @@ -0,0 +1,554 @@ +//! Local cluster lifecycle: start, stop, and observe an Orbit-RS dev cluster. +//! +//! Everything here is derived from things that can be checked: +//! +//! * which PID files `scripts/start-cluster.sh` wrote, +//! * whether those processes are actually alive, and for how long, from `ps`, +//! * which ports each live process was told to listen on, read from its own +//! command line rather than recomputed from the script's port arithmetic, +//! * whether those ports currently accept a TCP connection. +//! +//! Nothing is reported that was not observed. In particular this module does +//! not read `orbit-server`'s `/api/v1/cluster/*` endpoints: those handlers +//! return fixed values (`cpu_usage: 45.2`, `uptime_seconds: 86400`, +//! `actor_count: 150`, `replication_factor: 3`) that are not measurements, and +//! surfacing them in a UI would present invented numbers as telemetry. Fields +//! this module cannot observe — a node's role, its actor count — are absent +//! rather than filled in. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// How long to wait for a port to accept a connection before calling it closed. +const PORT_PROBE_TIMEOUT: Duration = Duration::from_millis(400); + +/// Where `start-cluster.sh` keeps its state, relative to the repository root. +const PID_SUBDIR: &str = "cluster-data/pids"; +const LOG_SUBDIR: &str = "cluster-data/logs"; +const CLUSTER_SCRIPT: &str = "scripts/start-cluster.sh"; +/// Combined stdout/stderr of the most recent start/stop invocation. +const CONTROL_LOG: &str = "cluster-data/logs/cluster-control.log"; + +/// The protocol flags `start-cluster.sh` passes to each node, paired with the +/// label shown in the UI. Read back off the live process's command line. +const PORT_FLAGS: [(&str, &str); 7] = [ + ("--postgres-port", "PostgreSQL"), + ("--redis-port", "Redis"), + ("--mysql-port", "MySQL"), + ("--cql-port", "CQL"), + ("--http-port", "HTTP"), + ("--grpc-port", "gRPC"), + ("--metrics-port", "Metrics"), +]; + +/// Failures managing a local cluster. +#[derive(Debug, thiserror::Error)] +pub enum ClusterError { + #[error("Orbit-RS repository root not found. Set it in the cluster panel.")] + RootNotSet, + #[error("{0} is not an Orbit-RS checkout: {1} is missing")] + NotARepository(PathBuf, &'static str), + #[error("Cluster script failed: {0}")] + ScriptFailed(String), + #[error("IO error: {0}")] + Io(String), +} + +/// Whether a node's process is alive, and for how long. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum ProcessState { + /// The PID from the pid file is alive. `uptime_seconds` comes from `ps`. + Running { pid: u32, uptime_seconds: u64 }, + /// A pid file exists but that process is gone — a crash or an unclean stop. + Exited { pid: u32 }, +} + +/// One listening port and whether it answered just now. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Endpoint { + pub protocol: String, + pub port: u16, + pub reachable: bool, +} + +/// A node discovered from the cluster's pid directory. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClusterNode { + pub node_id: String, + pub process: ProcessState, + /// Ports read from the running process's own command line. + /// + /// Empty when the process is not running: the ports it *would* use are a + /// guess, and a guess rendered next to live ones would not be + /// distinguishable from a measurement. + pub endpoints: Vec, + pub log_file: String, +} + +impl ClusterNode { + /// A node counts as healthy only if it is alive *and* answering. + /// + /// A process that is up but whose listeners refuse connections is exactly + /// the state worth spotting, so the two facts stay separate in the payload. + pub fn is_serving(&self) -> bool { + matches!(self.process, ProcessState::Running { .. }) + && !self.endpoints.is_empty() + && self.endpoints.iter().any(|e| e.reachable) + } +} + +/// A point-in-time observation of the local cluster. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClusterStatus { + pub root: String, + /// False when no pid directory exists — no cluster has been started here. + pub initialized: bool, + pub nodes: Vec, + pub running_nodes: usize, + pub serving_nodes: usize, + pub checked_at: DateTime, +} + +/// Locates the repository and drives `start-cluster.sh`. +pub struct ClusterManager { + root: PathBuf, +} + +impl ClusterManager { + /// Verify `root` is an Orbit-RS checkout with the cluster script present. + /// + /// # Errors + /// Returns [`ClusterError::NotARepository`] when a required path is absent. + pub fn new(root: impl Into) -> Result { + let root = root.into(); + for (relative, label) in [ + ("Cargo.toml", "Cargo.toml"), + ("orbit", "the orbit/ directory"), + (CLUSTER_SCRIPT, "scripts/start-cluster.sh"), + ] { + if !root.join(relative).exists() { + return Err(ClusterError::NotARepository(root, label)); + } + } + Ok(Self { root }) + } + + /// Walk up from `start` looking for an Orbit-RS checkout. + pub fn discover(start: &Path) -> Option { + start + .ancestors() + .find_map(|candidate| Self::new(candidate).ok()) + } + + pub fn root(&self) -> &Path { + &self.root + } + + fn pid_dir(&self) -> PathBuf { + self.root.join(PID_SUBDIR) + } + + /// Observe the cluster: which nodes exist, which are alive, what answers. + /// + /// # Errors + /// Returns [`ClusterError::Io`] when the pid directory exists but cannot + /// be read. + pub async fn status(&self) -> Result { + let pid_dir = self.pid_dir(); + let checked_at = Utc::now(); + + if !pid_dir.is_dir() { + return Ok(ClusterStatus { + root: self.root.display().to_string(), + initialized: false, + nodes: Vec::new(), + running_nodes: 0, + serving_nodes: 0, + checked_at, + }); + } + + let mut pids = read_pid_files(&pid_dir)?; + pids.sort_by(|a, b| a.0.cmp(&b.0)); + + let live = inspect_processes(pids.iter().map(|(_, pid)| *pid)).await; + + let mut nodes = Vec::with_capacity(pids.len()); + for (node_id, pid) in pids { + let log_file = self + .root + .join(LOG_SUBDIR) + .join(format!("{node_id}.log")) + .display() + .to_string(); + + let node = match live.get(&pid) { + Some(process) => ClusterNode { + node_id, + process: ProcessState::Running { + pid, + uptime_seconds: process.uptime_seconds, + }, + endpoints: probe_endpoints(&process.command_line).await, + log_file, + }, + None => ClusterNode { + node_id, + process: ProcessState::Exited { pid }, + endpoints: Vec::new(), + log_file, + }, + }; + nodes.push(node); + } + + let running_nodes = nodes + .iter() + .filter(|n| matches!(n.process, ProcessState::Running { .. })) + .count(); + let serving_nodes = nodes.iter().filter(|n| n.is_serving()).count(); + + Ok(ClusterStatus { + root: self.root.display().to_string(), + initialized: true, + nodes, + running_nodes, + serving_nodes, + checked_at, + }) + } + + /// Start an `size`-node cluster. + /// + /// Returns as soon as the script is running, not when the cluster is up: + /// the script builds `orbit-server` in release mode first, which can take + /// minutes. Poll [`ClusterManager::status`] to see nodes come up, and read + /// [`ClusterManager::control_log`] for the script's own output. + /// + /// # Errors + /// Returns [`ClusterError::ScriptFailed`] when the script cannot be spawned. + pub async fn start(&self, size: u8) -> Result<(), ClusterError> { + if size == 0 { + return Err(ClusterError::ScriptFailed( + "cluster size must be at least 1".to_string(), + )); + } + self.spawn_script(&[size.to_string()]).await + } + + /// Stop every node recorded in the pid directory. + /// + /// # Errors + /// Returns [`ClusterError::ScriptFailed`] when the script exits non-zero. + pub async fn stop(&self) -> Result<(), ClusterError> { + self.run_script(&["--stop".to_string()]).await + } + + /// Last `lines` lines of a node's log. + /// + /// # Errors + /// Returns [`ClusterError::Io`] when the log file cannot be read. + pub fn node_log(&self, node_id: &str, lines: usize) -> Result { + let path = self.root.join(LOG_SUBDIR).join(format!("{node_id}.log")); + read_tail(&path, lines) + } + + /// Last `lines` lines of output from the most recent start/stop. + /// + /// # Errors + /// Returns [`ClusterError::Io`] when the log exists but cannot be read. + pub fn control_log(&self, lines: usize) -> Result { + read_tail(&self.root.join(CONTROL_LOG), lines) + } + + /// Run the script and wait for it, surfacing its output on failure. + async fn run_script(&self, args: &[String]) -> Result<(), ClusterError> { + let output = tokio::process::Command::new("bash") + .arg(CLUSTER_SCRIPT) + .args(args) + .current_dir(&self.root) + .output() + .await + .map_err(|e| ClusterError::ScriptFailed(e.to_string()))?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + Err(ClusterError::ScriptFailed(format!( + "{} exited with {}: {}", + CLUSTER_SCRIPT, + output.status, + if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + } + ))) + } + + /// Spawn the script detached, tee-ing its output into the control log. + async fn spawn_script(&self, args: &[String]) -> Result<(), ClusterError> { + let log_path = self.root.join(CONTROL_LOG); + if let Some(parent) = log_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| ClusterError::Io(e.to_string()))?; + } + + let log = std::fs::File::create(&log_path).map_err(|e| ClusterError::Io(e.to_string()))?; + let errors = log + .try_clone() + .map_err(|e| ClusterError::Io(e.to_string()))?; + + tokio::process::Command::new("bash") + .arg(CLUSTER_SCRIPT) + .args(args) + .current_dir(&self.root) + .stdout(log) + .stderr(errors) + .stdin(std::process::Stdio::null()) + .spawn() + .map_err(|e| ClusterError::ScriptFailed(e.to_string()))?; + + Ok(()) + } +} + +/// A live process as `ps` reported it. +struct LiveProcess { + uptime_seconds: u64, + command_line: String, +} + +/// Read `node-N.pid` files, skipping anything unparseable. +fn read_pid_files(pid_dir: &Path) -> Result, ClusterError> { + let entries = std::fs::read_dir(pid_dir).map_err(|e| ClusterError::Io(e.to_string()))?; + + Ok(entries + .filter_map(Result::ok) + .filter_map(|entry| { + let path = entry.path(); + if path.extension()? != "pid" { + return None; + } + let node_id = path.file_stem()?.to_str()?.to_string(); + let pid = std::fs::read_to_string(&path).ok()?.trim().parse().ok()?; + Some((node_id, pid)) + }) + .collect()) +} + +/// Ask `ps` which of `pids` are alive, how long they have run, and with what +/// arguments. One invocation covers every node. +async fn inspect_processes( + pids: impl IntoIterator, +) -> HashMap { + let list: Vec = pids.into_iter().map(|pid| pid.to_string()).collect(); + if list.is_empty() { + return HashMap::new(); + } + + let output = tokio::process::Command::new("ps") + .args(["-o", "pid=,etime=,args=", "-p", &list.join(",")]) + .output() + .await; + + let Ok(output) = output else { + tracing::warn!("could not run ps to inspect cluster processes"); + return HashMap::new(); + }; + + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(parse_ps_line) + .collect() +} + +/// Parse one `pid etime args...` line. +fn parse_ps_line(line: &str) -> Option<(u32, LiveProcess)> { + let mut fields = line.trim().splitn(3, char::is_whitespace); + let pid = fields.next()?.trim().parse().ok()?; + let elapsed = fields.next()?.trim(); + let command_line = fields.next().unwrap_or_default().trim().to_string(); + + Some(( + pid, + LiveProcess { + uptime_seconds: parse_etime(elapsed)?, + command_line, + }, + )) +} + +/// Convert `ps` elapsed time — `mm:ss`, `hh:mm:ss` or `dd-hh:mm:ss` — to seconds. +fn parse_etime(etime: &str) -> Option { + let (days, clock) = match etime.split_once('-') { + Some((days, rest)) => (days.parse::().ok()?, rest), + None => (0, etime), + }; + + let parts: Vec = clock + .split(':') + .map(|part| part.parse::()) + .collect::>() + .ok()?; + + let clock_seconds = match parts.as_slice() { + [minutes, seconds] => minutes * 60 + seconds, + [hours, minutes, seconds] => hours * 3600 + minutes * 60 + seconds, + _ => return None, + }; + + Some(days * 86_400 + clock_seconds) +} + +/// Read the `--*-port` flags out of a node's command line and probe each one. +async fn probe_endpoints(command_line: &str) -> Vec { + let args: Vec<&str> = command_line.split_whitespace().collect(); + + let declared: Vec<(&str, u16)> = PORT_FLAGS + .iter() + .filter_map(|(flag, label)| { + let index = args.iter().position(|arg| arg == flag)?; + let port = args.get(index + 1)?.parse().ok()?; + Some((*label, port)) + }) + .collect(); + + // Probed concurrently: a node with several closed ports would otherwise + // cost one full timeout per port, and the status call is on the UI's + // refresh path. + let mut probes = tokio::task::JoinSet::new(); + for (index, (protocol, port)) in declared.into_iter().enumerate() { + probes.spawn(async move { + ( + index, + Endpoint { + protocol: protocol.to_string(), + port, + reachable: is_port_open(port).await, + }, + ) + }); + } + + let mut probed: Vec<(usize, Endpoint)> = Vec::with_capacity(probes.len()); + while let Some(joined) = probes.join_next().await { + match joined { + Ok(result) => probed.push(result), + Err(e) => tracing::warn!("port probe task failed: {e}"), + } + } + + // Restore the PORT_FLAGS order, which completion order does not preserve. + probed.sort_by_key(|(index, _)| *index); + probed.into_iter().map(|(_, endpoint)| endpoint).collect() +} + +/// Does something accept a TCP connection on this port right now? +async fn is_port_open(port: u16) -> bool { + tokio::time::timeout( + PORT_PROBE_TIMEOUT, + tokio::net::TcpStream::connect(("127.0.0.1", port)), + ) + .await + .map(|result| result.is_ok()) + .unwrap_or(false) +} + +/// Last `lines` lines of a file, or a clear message when there is no file yet. +fn read_tail(path: &Path, lines: usize) -> Result { + if !path.exists() { + return Ok(format!("No log at {}", path.display())); + } + + let content = std::fs::read_to_string(path).map_err(|e| ClusterError::Io(e.to_string()))?; + let all: Vec<&str> = content.lines().collect(); + let start = all.len().saturating_sub(lines); + Ok(all[start..].join("\n")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn etime_parses_every_ps_layout() { + assert_eq!(parse_etime("00:42"), Some(42)); + assert_eq!(parse_etime("06:49:18"), Some(6 * 3600 + 49 * 60 + 18)); + assert_eq!(parse_etime("2-03:00:00"), Some(2 * 86_400 + 3 * 3600)); + assert_eq!(parse_etime(""), None); + assert_eq!(parse_etime("not-a-time"), None); + } + + #[test] + fn ps_lines_split_into_pid_uptime_and_command() { + let (pid, process) = + parse_ps_line(" 1234 01:02:03 ./target/release/orbit-server --node-id node-1") + .expect("a well formed ps line parses"); + assert_eq!(pid, 1234); + assert_eq!(process.uptime_seconds, 3723); + assert!(process.command_line.ends_with("--node-id node-1")); + } + + #[test] + fn a_ps_line_without_a_command_still_yields_uptime() { + let (pid, process) = parse_ps_line("77 00:05").expect("pid and etime suffice"); + assert_eq!(pid, 77); + assert_eq!(process.uptime_seconds, 5); + assert!(process.command_line.is_empty()); + } + + #[tokio::test] + async fn endpoints_come_from_the_command_line_not_from_port_arithmetic() { + let command = "./target/release/orbit-server --node-id node-2 --grpc-port 50052 \ + --http-port 8081 --postgres-port 5433 --redis-port 6380 \ + --mysql-port 3307 --cql-port 9043 --metrics-port 9091"; + let endpoints = probe_endpoints(command).await; + + let by_protocol: HashMap<&str, u16> = endpoints + .iter() + .map(|e| (e.protocol.as_str(), e.port)) + .collect(); + + assert_eq!(by_protocol.get("PostgreSQL"), Some(&5433)); + assert_eq!(by_protocol.get("Redis"), Some(&6380)); + assert_eq!(by_protocol.get("CQL"), Some(&9043)); + assert_eq!(endpoints.len(), PORT_FLAGS.len()); + } + + #[tokio::test] + async fn a_command_line_without_port_flags_reports_no_endpoints() { + assert!(probe_endpoints("./target/release/orbit-server").await.is_empty()); + } + + #[test] + fn a_node_that_is_up_but_answering_nothing_is_not_serving() { + let node = ClusterNode { + node_id: "node-1".to_string(), + process: ProcessState::Running { + pid: 1, + uptime_seconds: 10, + }, + endpoints: vec![Endpoint { + protocol: "Redis".to_string(), + port: 6379, + reachable: false, + }], + log_file: String::new(), + }; + assert!(!node.is_serving()); + } + + #[test] + fn rejecting_a_directory_that_is_not_an_orbit_checkout() { + let error = ClusterManager::new(std::env::temp_dir()) + .expect_err("the temp dir is not an Orbit-RS checkout"); + assert!(matches!(error, ClusterError::NotARepository(..))); + } +} diff --git a/orbit/desktop/src-tauri/src/connections.rs b/orbit/desktop/src-tauri/src/connections.rs index b1963b2e..5a7e7979 100644 --- a/orbit/desktop/src-tauri/src/connections.rs +++ b/orbit/desktop/src-tauri/src/connections.rs @@ -1,18 +1,129 @@ -//! Connection management for Orbit Desktop +//! Connection management for Orbit Desktop. //! -//! This module handles connections to various database types including: -//! - PostgreSQL (standard SQL) -//! - OrbitQL (native Orbit protocol) -//! - Redis (key-value operations) -//! - Arrow Flight SQL (high-performance columnar protocol) -//! - OrbitWire (native binary protocol) +//! Two things are deliberately kept apart: +//! +//! * A [`Connection`] is the saved *description* of an endpoint — host, port, +//! credentials, protocol. Descriptions are persisted by [`crate::storage`] and +//! survive restarts. +//! * A [`DatabaseSession`] is a *live* handle opened from a description. Sessions +//! never outlive the process. +//! +//! [`ConnectionManager`] owns both and opens sessions lazily, so a connection +//! saved in a previous run is usable on the next query without the user having +//! to recreate it. Because a session is held open across queries, protocol-level +//! session state (`SET`, temporary tables, open transactions, `USE `, +//! `SELECT ` on Redis) behaves the way the user expects. +use async_trait::async_trait; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::fmt; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{Mutex, RwLock}; use uuid::Uuid; -/// Connection information provided by user +use crate::queries::{ColumnInfo, QueryPayload, StatementOutcome}; + +/// How long to wait for a TCP connect / handshake when the user has not said. +const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Types of database endpoints the desktop app can talk to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ConnectionType { + PostgreSQL, + OrbitQL, + Redis, + MySQL, + CQL, + Cypher, + AQL, + FlightSQL, + OrbitWire, +} + +impl ConnectionType { + /// Every variant, in the order the connection dialog should offer them. + pub const ALL: [ConnectionType; 9] = [ + ConnectionType::PostgreSQL, + ConnectionType::MySQL, + ConnectionType::Redis, + ConnectionType::CQL, + ConnectionType::OrbitQL, + ConnectionType::Cypher, + ConnectionType::AQL, + ConnectionType::FlightSQL, + ConnectionType::OrbitWire, + ]; + + /// The port Orbit-RS listens on for this protocol by default. + pub fn default_port(self) -> u16 { + match self { + ConnectionType::PostgreSQL => 5432, + ConnectionType::MySQL => 3306, + ConnectionType::Redis => 6379, + ConnectionType::CQL => 9042, + ConnectionType::OrbitQL + | ConnectionType::Cypher + | ConnectionType::AQL + | ConnectionType::FlightSQL + | ConnectionType::OrbitWire => 8080, + } + } + + /// Stable identifier used in persisted files and on the IPC boundary. + pub fn as_str(self) -> &'static str { + match self { + ConnectionType::PostgreSQL => "PostgreSQL", + ConnectionType::OrbitQL => "OrbitQL", + ConnectionType::Redis => "Redis", + ConnectionType::MySQL => "MySQL", + ConnectionType::CQL => "CQL", + ConnectionType::Cypher => "Cypher", + ConnectionType::AQL => "AQL", + ConnectionType::FlightSQL => "FlightSQL", + ConnectionType::OrbitWire => "OrbitWire", + } + } + + /// Whether this protocol is served by Orbit-RS's native wire implementation. + /// + /// The HTTP-backed protocols currently reach `orbit-server`'s REST API, + /// whose SQL and catalog handlers still return canned example rows. The UI + /// uses this to label those results rather than presenting them as data. + pub fn is_native_wire_protocol(self) -> bool { + matches!( + self, + ConnectionType::PostgreSQL + | ConnectionType::MySQL + | ConnectionType::Redis + | ConnectionType::CQL + ) + } +} + +impl fmt::Display for ConnectionType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for ConnectionType { + type Err = ConnectionError; + + fn from_str(s: &str) -> Result { + ConnectionType::ALL + .into_iter() + .find(|candidate| candidate.as_str().eq_ignore_ascii_case(s)) + .ok_or_else(|| { + ConnectionError::InvalidConfiguration(format!("unknown connection type: {s}")) + }) + } +} + +/// Everything needed to open a session, as supplied by the user. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConnectionInfo { pub name: String, @@ -23,1271 +134,1140 @@ pub struct ConnectionInfo { pub username: Option, pub password: Option, pub ssl_mode: Option, + /// Connect/handshake timeout in milliseconds. pub connection_timeout: Option, + #[serde(default)] pub additional_params: HashMap, } -/// Types of database connections supported -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ConnectionType { - PostgreSQL, - OrbitQL, - Redis, - MySQL, - CQL, - Cypher, - AQL, - FlightSQL, - OrbitWire, +impl ConnectionInfo { + fn connect_timeout(&self) -> Duration { + self.connection_timeout + .map(Duration::from_millis) + .unwrap_or(DEFAULT_CONNECT_TIMEOUT) + } + + fn http_base_url(&self) -> String { + format!("http://{}:{}", self.host, self.port) + } + + fn socket_addr(&self) -> String { + format!("{}:{}", self.host, self.port) + } + + /// Reject a TLS request we cannot honour instead of silently connecting in + /// the clear. + /// + /// # Errors + /// Returns [`ConnectionError::InvalidConfiguration`] when `ssl_mode` asks + /// for encryption, which this build does not implement. + fn ensure_ssl_mode_supported(&self) -> Result<(), ConnectionError> { + let mode = match self.ssl_mode.as_deref().map(str::trim) { + None | Some("") => return Ok(()), + Some(mode) => mode, + }; + + if matches!( + mode.to_ascii_lowercase().as_str(), + "disable" | "disabled" | "off" | "none" | "prefer" | "allow" + ) { + return Ok(()); + } + + Err(ConnectionError::InvalidConfiguration(format!( + "ssl_mode '{mode}' requests an encrypted connection, which this build does not \ + support. Refusing rather than connecting in plaintext." + ))) + } } -/// Connection status -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Whether a saved connection currently has a live session behind it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ConnectionStatus { Connected, Disconnected, - Connecting, Error(String), } -/// A managed database connection +/// A saved connection plus the usage counters shown in the UI. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Connection { pub id: String, pub info: ConnectionInfo, pub status: ConnectionStatus, - pub created_at: DateTime, + /// `None` when a persisted record carried a timestamp that would not parse. + /// + /// Deliberately not defaulted to "now": that would stamp every unreadable + /// record with the time the app happened to start, which reads as a real + /// creation date and is one nobody measured. + pub created_at: Option>, pub last_used: Option>, pub query_count: u64, } -/// Connection manager handles all database connections -#[derive(Default)] -pub struct ConnectionManager { - connections: HashMap, - active_connections: HashMap>, -} - -/// Trait for database connections -pub trait DatabaseConnection: Send + Sync { - fn connection_type(&self) -> ConnectionType; - fn is_connected(&self) -> bool; - fn disconnect(&mut self) -> Result<(), ConnectionError>; -} - -/// Connection errors +/// Failures opening or using a connection. #[derive(Debug, thiserror::Error)] pub enum ConnectionError { #[error("Connection failed: {0}")] ConnectionFailed(String), - #[error("Authentication failed: {0}")] - AuthenticationFailed(String), #[error("Network error: {0}")] NetworkError(String), #[error("Invalid configuration: {0}")] InvalidConfiguration(String), #[error("Connection not found: {0}")] ConnectionNotFound(String), - #[error("Connection timeout")] - Timeout, + #[error("Query failed: {0}")] + QueryFailed(String), + #[error("Timed out after {0:?}")] + Timeout(Duration), +} + +/// A live handle to one endpoint. +/// +/// Implementations own whatever the protocol needs to keep a session alive and +/// are responsible for turning a raw response into a [`QueryPayload`]. +#[async_trait] +pub trait DatabaseSession: Send + Sync { + fn connection_type(&self) -> ConnectionType; + + /// Run one statement and shape the response for the results grid. + /// + /// # Errors + /// Returns [`ConnectionError::QueryFailed`] when the server rejects the + /// statement, or a transport variant when the session has dropped. + async fn execute(&mut self, statement: &str) -> Result; + + /// Cheap liveness probe used to decide whether a cached session is reusable. + async fn ping(&mut self) -> Result<(), ConnectionError>; +} + +/// A session shared between the manager and whoever is currently querying it. +/// +/// The inner [`Mutex`] serialises statements on a single session — which is +/// what a database session requires — while leaving other connections free to +/// run concurrently. +pub type SessionHandle = Arc>>; + +/// Owns saved connections and their live sessions. +#[derive(Default)] +pub struct ConnectionManager { + connections: RwLock>, + sessions: RwLock>, } impl ConnectionManager { + #[must_use] pub fn new() -> Self { Self::default() } - /// Create a new connection + /// Add a connection description without contacting the server. + /// + /// This is the startup path: descriptions restored from disk become + /// queryable immediately, and the session opens on first use. + pub async fn register(&self, connection: Connection) { + self.connections + .write() + .await + .insert(connection.id.clone(), connection); + } + + /// Create a connection, verifying it can actually be opened first. + /// + /// # Errors + /// Propagates whatever prevented the session from opening; nothing is + /// stored when the endpoint is unreachable. pub async fn create_connection( - &mut self, + &self, info: ConnectionInfo, ) -> Result { - let connection_id = Uuid::new_v4().to_string(); - - // Test the connection first - let status = self.test_connection(&info).await?; + let session = open_session(&info).await?; + let id = Uuid::new_v4().to_string(); let connection = Connection { - id: connection_id.clone(), - info: info.clone(), - status, - created_at: Utc::now(), + id: id.clone(), + info, + status: ConnectionStatus::Connected, + created_at: Some(Utc::now()), last_used: None, query_count: 0, }; - // Store the connection - self.connections.insert(connection_id.clone(), connection); - - // Create the actual database connection - let db_connection = self.create_database_connection(&info).await?; - self.active_connections - .insert(connection_id.clone(), db_connection); + self.connections + .write() + .await + .insert(id.clone(), connection); + self.sessions + .write() + .await + .insert(id.clone(), Arc::new(Mutex::new(session))); - Ok(connection_id) + Ok(id) } - /// Test a connection without storing it - pub async fn test_connection( - &self, - info: &ConnectionInfo, - ) -> Result { - match info.connection_type { - ConnectionType::PostgreSQL => self.test_postgresql_connection(info).await, - ConnectionType::OrbitQL => self.test_orbitql_connection(info).await, - ConnectionType::Redis => self.test_redis_connection(info).await, - ConnectionType::MySQL => self.test_mysql_connection(info).await, - ConnectionType::CQL => self.test_cql_connection(info).await, - ConnectionType::Cypher => self.test_cypher_connection(info).await, - ConnectionType::AQL => self.test_aql_connection(info).await, - ConnectionType::FlightSQL => self.test_flightsql_connection(info).await, - ConnectionType::OrbitWire => self.test_orbitwire_connection(info).await, + /// Open a throwaway session to check the details are usable. + pub async fn test_connection(&self, info: &ConnectionInfo) -> ConnectionStatus { + match open_session(info).await { + Ok(_) => ConnectionStatus::Connected, + Err(e) => ConnectionStatus::Error(e.to_string()), } } - /// List all stored connections + /// All saved connections, with `status` reflecting live session state. pub async fn list_connections(&self) -> Vec { - self.connections.values().cloned().collect() - } + let sessions = self.sessions.read().await; + self.connections + .read() + .await + .values() + .map(|connection| { + let status = if sessions.contains_key(&connection.id) { + ConnectionStatus::Connected + } else { + connection.status.clone() + }; + Connection { + status, + ..connection.clone() + } + }) + .collect() + } + + /// A single saved connection. + pub async fn get_connection(&self, connection_id: &str) -> Option { + self.connections.read().await.get(connection_id).cloned() + } + + /// Return a live session for `connection_id`, opening one if needed. + /// + /// A cached session that has since dropped is discarded and replaced rather + /// than handed out, so a server restart costs one failed ping, not a dead + /// connection the user has to notice and clear by hand. + /// + /// # Errors + /// Returns [`ConnectionError::ConnectionNotFound`] for an unknown id, or + /// the underlying failure when the endpoint cannot be reached. + pub async fn session(&self, connection_id: &str) -> Result { + if let Some(handle) = self.sessions.read().await.get(connection_id).cloned() { + let alive = handle.lock().await.ping().await.is_ok(); + if alive { + return Ok(handle); + } + tracing::info!(connection_id, "cached session is dead, reopening"); + self.sessions.write().await.remove(connection_id); + } - /// Get a specific connection - pub async fn get_connection(&self, connection_id: &str) -> Option<&Connection> { - self.connections.get(connection_id) - } + let info = self + .get_connection(connection_id) + .await + .ok_or_else(|| ConnectionError::ConnectionNotFound(connection_id.to_string()))? + .info; + + // Opened outside the map lock so a slow handshake cannot stall queries + // against other connections. + let session = match open_session(&info).await { + Ok(session) => session, + Err(e) => { + self.mark_error(connection_id, &e).await; + return Err(e); + } + }; - /// Disconnect a connection - pub async fn disconnect(&mut self, connection_id: &str) -> Result<(), ConnectionError> { - if let Some(mut db_connection) = self.active_connections.remove(connection_id) { - db_connection.disconnect()?; + let handle: SessionHandle = Arc::new(Mutex::new(session)); + let mut sessions = self.sessions.write().await; + // Another task may have opened one while we were connecting; prefer + // whichever landed first so both callers share a single session. + let handle = sessions + .entry(connection_id.to_string()) + .or_insert(handle) + .clone(); + + if let Some(connection) = self.connections.write().await.get_mut(connection_id) { + connection.status = ConnectionStatus::Connected; } - if let Some(connection) = self.connections.get_mut(connection_id) { + Ok(handle) + } + + /// Drop the live session but keep the saved description. + pub async fn disconnect(&self, connection_id: &str) { + self.sessions.write().await.remove(connection_id); + if let Some(connection) = self.connections.write().await.get_mut(connection_id) { connection.status = ConnectionStatus::Disconnected; } - - Ok(()) } - /// Delete a connection entirely - pub async fn delete_connection(&mut self, connection_id: &str) -> Result<(), ConnectionError> { - // First disconnect if connected - self.disconnect(connection_id).await.ok(); - - // Remove from storage - self.connections.remove(connection_id); - - Ok(()) + /// Forget the connection entirely. + pub async fn delete_connection(&self, connection_id: &str) { + self.sessions.write().await.remove(connection_id); + self.connections.write().await.remove(connection_id); } - /// Update connection usage statistics - pub async fn update_usage(&mut self, connection_id: &str) { - if let Some(connection) = self.connections.get_mut(connection_id) { + /// Record that a query ran, so the UI's counters mean something. + pub async fn record_use(&self, connection_id: &str) { + if let Some(connection) = self.connections.write().await.get_mut(connection_id) { connection.last_used = Some(Utc::now()); connection.query_count += 1; } } - /// Get active database connection for query execution - pub fn get_database_connection( - &self, - connection_id: &str, - ) -> Option<&Box> { - self.active_connections.get(connection_id) + async fn mark_error(&self, connection_id: &str, error: &ConnectionError) { + if let Some(connection) = self.connections.write().await.get_mut(connection_id) { + connection.status = ConnectionStatus::Error(error.to_string()); + } } +} - // Private helper methods - - async fn create_database_connection( - &self, - info: &ConnectionInfo, - ) -> Result, ConnectionError> { - match info.connection_type { - ConnectionType::PostgreSQL => { - let conn = PostgreSQLConnection::new(info).await?; - Ok(Box::new(conn)) - } - ConnectionType::OrbitQL => { - let conn = OrbitQLConnection::new(info).await?; - Ok(Box::new(conn)) - } - ConnectionType::Redis => { - let conn = RedisConnection::new(info).await?; - Ok(Box::new(conn)) - } - ConnectionType::MySQL => { - let conn = MySQLConnection::new(info).await?; - Ok(Box::new(conn)) - } - ConnectionType::CQL => { - let conn = CQLConnection::new(info).await?; - Ok(Box::new(conn)) - } - ConnectionType::Cypher => { - let conn = CypherConnection::new(info).await?; - Ok(Box::new(conn)) - } - ConnectionType::AQL => { - let conn = AQLConnection::new(info).await?; - Ok(Box::new(conn)) - } - ConnectionType::FlightSQL => { - let conn = FlightSQLConnection::new(info).await?; - Ok(Box::new(conn)) - } - ConnectionType::OrbitWire => { - let conn = OrbitWireConnection::new(info).await?; - Ok(Box::new(conn)) - } - } +/// Open a live session for `info`, dispatching on protocol. +async fn open_session( + info: &ConnectionInfo, +) -> Result, ConnectionError> { + info.ensure_ssl_mode_supported()?; + + match info.connection_type { + ConnectionType::PostgreSQL => Ok(Box::new(PostgresSession::connect(info).await?)), + ConnectionType::MySQL => Ok(Box::new(MySqlSession::connect(info).await?)), + ConnectionType::Redis => Ok(Box::new(RedisSession::connect(info).await?)), + ConnectionType::CQL => Ok(Box::new(TcpProbeSession::connect(info).await?)), + ConnectionType::OrbitWire => Ok(Box::new(OrbitWireSession::connect(info).await?)), + ConnectionType::OrbitQL + | ConnectionType::Cypher + | ConnectionType::AQL + | ConnectionType::FlightSQL => Ok(Box::new(HttpSession::connect(info).await?)), } +} - async fn test_postgresql_connection( - &self, - info: &ConnectionInfo, - ) -> Result { - // Build connection string - let connection_string = format!( - "host={} port={} user={} password={} dbname={}", - info.host, - info.port, - info.username.as_ref().unwrap_or(&"postgres".to_string()), - info.password.as_ref().unwrap_or(&"".to_string()), - info.database.as_ref().unwrap_or(&"postgres".to_string()) - ); +// --------------------------------------------------------------------------- +// PostgreSQL +// --------------------------------------------------------------------------- - match tokio_postgres::connect(&connection_string, tokio_postgres::NoTls).await { - Ok((client, connection)) => { - // Spawn the connection task - tokio::spawn(async move { - if let Err(e) = connection.await { - tracing::error!("PostgreSQL connection error: {}", e); - } - }); - - // Test a simple query - match client.simple_query("SELECT 1").await { - Ok(_) => Ok(ConnectionStatus::Connected), - Err(e) => Ok(ConnectionStatus::Error(format!("Query test failed: {}", e))), - } - } - Err(e) => Ok(ConnectionStatus::Error(format!("Connection failed: {}", e))), - } - } +/// A PostgreSQL session held open across statements. +pub struct PostgresSession { + client: tokio_postgres::Client, +} - async fn test_orbitql_connection( - &self, - info: &ConnectionInfo, - ) -> Result { - // For now, just test if we can reach the host and port - let addr = format!("{}:{}", info.host, info.port); - - match tokio::time::timeout( - std::time::Duration::from_secs(5), - tokio::net::TcpStream::connect(&addr), - ) - .await - { - Ok(Ok(_)) => Ok(ConnectionStatus::Connected), - Ok(Err(e)) => Ok(ConnectionStatus::Error(format!("Connection failed: {}", e))), - Err(_) => Ok(ConnectionStatus::Error("Connection timeout".to_string())), +impl PostgresSession { + async fn connect(info: &ConnectionInfo) -> Result { + // Built through `Config` rather than a connection string so that + // passwords containing spaces, quotes or backslashes cannot corrupt + // (or inject into) the parameter list. + let mut config = tokio_postgres::Config::new(); + config + .host(&info.host) + .port(info.port) + .connect_timeout(info.connect_timeout()) + .application_name("orbit-desktop"); + config.user(info.username.as_deref().unwrap_or("orbit")); + if let Some(password) = &info.password { + config.password(password); } - } - - async fn test_redis_connection( - &self, - info: &ConnectionInfo, - ) -> Result { - let redis_url = format!("redis://{}:{}/", info.host, info.port); - - match redis::Client::open(redis_url) { - Ok(client) => { - match client.get_connection() { - Ok(mut conn) => { - // Test with PING command - match redis::cmd("PING").query::(&mut conn) { - Ok(response) if response == "PONG" => Ok(ConnectionStatus::Connected), - Ok(response) => Ok(ConnectionStatus::Error(format!( - "Unexpected response: {}", - response - ))), - Err(e) => Ok(ConnectionStatus::Error(format!( - "Redis command failed: {}", - e - ))), - } - } - Err(e) => Ok(ConnectionStatus::Error(format!("Connection failed: {}", e))), - } - } - Err(e) => Ok(ConnectionStatus::Error(format!("Invalid Redis URL: {}", e))), + if let Some(database) = &info.database { + config.dbname(database); } - } - async fn test_mysql_connection( - &self, - info: &ConnectionInfo, - ) -> Result { - use mysql_async::prelude::*; + let (client, connection) = config + .connect(tokio_postgres::NoTls) + .await + .map_err(|e| ConnectionError::ConnectionFailed(e.to_string()))?; - let opts = mysql_async::OptsBuilder::default() - .ip_or_hostname(info.host.clone()) - .tcp_port(info.port) - .user(info.username.as_deref()) - .pass(info.password.as_deref()) - .db_name(info.database.as_deref()); - - match mysql_async::Conn::new(opts).await { - Ok(mut conn) => { - // Test with a simple query - match conn.query_first::("SELECT 1").await { - Ok(_) => Ok(ConnectionStatus::Connected), - Err(e) => Ok(ConnectionStatus::Error(format!("Query test failed: {}", e))), - } + // The connection future drives the socket; it ends when the client is + // dropped, which is what closes the session. + tokio::spawn(async move { + if let Err(e) = connection.await { + tracing::debug!("PostgreSQL connection closed: {e}"); } - Err(e) => Ok(ConnectionStatus::Error(format!("Connection failed: {}", e))), - } - } + }); - async fn test_cql_connection( - &self, - info: &ConnectionInfo, - ) -> Result { - // CQL uses TCP connection, test basic connectivity - let addr = format!("{}:{}", info.host, info.port); - - match tokio::time::timeout( - std::time::Duration::from_secs(5), - tokio::net::TcpStream::connect(&addr), - ) - .await - { - Ok(Ok(_)) => Ok(ConnectionStatus::Connected), - Ok(Err(e)) => Ok(ConnectionStatus::Error(format!("Connection failed: {}", e))), - Err(_) => Ok(ConnectionStatus::Error("Connection timeout".to_string())), - } + Ok(Self { client }) } +} - async fn test_cypher_connection( - &self, - info: &ConnectionInfo, - ) -> Result { - // Cypher/Neo4j uses HTTP REST API for queries - let base_url = format!("http://{}:{}", info.host, info.port); - let client = reqwest::Client::new(); - - // Test with a simple query endpoint - let test_url = format!("{}/db/data/transaction/commit", base_url); - let auth = if let (Some(user), Some(pass)) = (&info.username, &info.password) { - Some(format!("{}:{}", user, pass)) - } else { - None - }; - - let mut request = client.post(&test_url); - if let Some(auth_str) = auth { - request = request.basic_auth( - info.username.as_deref().unwrap_or(""), - info.password.as_deref(), - ); - } +#[async_trait] +impl DatabaseSession for PostgresSession { + fn connection_type(&self) -> ConnectionType { + ConnectionType::PostgreSQL + } - match request - .json(&serde_json::json!({ - "statements": [{"statement": "RETURN 1 as result"}] - })) - .send() + async fn execute(&mut self, statement: &str) -> Result { + // Preparing first tells us whether the statement returns a result set, + // and gives real column types instead of guessing from a Debug string. + let prepared = self + .client + .prepare(statement) .await - { - Ok(response) if response.status().is_success() => Ok(ConnectionStatus::Connected), - Ok(response) => Ok(ConnectionStatus::Error(format!( - "HTTP {}: {}", - response.status(), - response.status().canonical_reason().unwrap_or("Unknown") - ))), - Err(e) => Ok(ConnectionStatus::Error(format!("Connection failed: {}", e))), - } - } + .map_err(|e| ConnectionError::QueryFailed(e.to_string()))?; - async fn test_aql_connection( - &self, - info: &ConnectionInfo, - ) -> Result { - // AQL/ArangoDB uses HTTP REST API - let base_url = format!("http://{}:{}", info.host, info.port); - let client = reqwest::Client::new(); - - // Test with version endpoint - let version_url = format!("{}/_api/version", base_url); - let mut request = client.get(&version_url); - - if let (Some(user), Some(pass)) = (&info.username, &info.password) { - request = request.basic_auth(user, Some(pass)); + if prepared.columns().is_empty() { + let affected = self + .client + .execute(&prepared, &[]) + .await + .map_err(|e| ConnectionError::QueryFailed(e.to_string()))?; + return Ok(QueryPayload::affected(affected)); } - match request.send().await { - Ok(response) if response.status().is_success() => Ok(ConnectionStatus::Connected), - Ok(response) => Ok(ConnectionStatus::Error(format!( - "HTTP {}: {}", - response.status(), - response.status().canonical_reason().unwrap_or("Unknown") - ))), - Err(e) => Ok(ConnectionStatus::Error(format!("Connection failed: {}", e))), - } - } + let columns: Vec = prepared + .columns() + .iter() + .map(|column| ColumnInfo::new(column.name(), column.type_().name())) + .collect(); - async fn test_flightsql_connection( - &self, - info: &ConnectionInfo, - ) -> Result { - // Arrow Flight SQL uses gRPC, test via HTTP REST adapter - let base_url = format!("http://{}:{}", info.host, info.port); - let client = reqwest::Client::new(); - - // Try health endpoint or just test TCP connectivity - let health_url = format!("{}/health", base_url); - match tokio::time::timeout( - std::time::Duration::from_secs(5), - client.get(&health_url).send(), - ) - .await - { - Ok(Ok(response)) - if response.status().is_success() || response.status().as_u16() == 404 => - { - // 404 is acceptable - means server is responding - Ok(ConnectionStatus::Connected) - } - Ok(Ok(response)) => Ok(ConnectionStatus::Error(format!( - "HTTP {}: {}", - response.status(), - response.status().canonical_reason().unwrap_or("Unknown") - ))), - Ok(Err(e)) => Ok(ConnectionStatus::Error(format!("Connection failed: {}", e))), - Err(_) => Ok(ConnectionStatus::Error("Connection timeout".to_string())), + let rows = self + .client + .query(&prepared, &[]) + .await + .map_err(|e| ConnectionError::QueryFailed(e.to_string()))?; + + let shaped = rows + .iter() + .map(|row| { + columns + .iter() + .enumerate() + .map(|(index, column)| { + (column.name.clone(), postgres_value_to_json(row, index)) + }) + .collect() + }) + .collect(); + + Ok(QueryPayload::returned(columns, shaped)) + } + + async fn ping(&mut self) -> Result<(), ConnectionError> { + if self.client.is_closed() { + return Err(ConnectionError::NetworkError("session closed".to_string())); } + self.client + .simple_query("") + .await + .map(|_| ()) + .map_err(|e| ConnectionError::NetworkError(e.to_string())) } +} - async fn test_orbitwire_connection( - &self, - info: &ConnectionInfo, - ) -> Result { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - let addr = format!("{}:{}", info.host, info.port); - - match tokio::time::timeout( - std::time::Duration::from_secs(5), - tokio::net::TcpStream::connect(&addr), - ) - .await - { - Ok(Ok(mut stream)) => { - // Send OrbitWire handshake: magic bytes + version - let handshake = [0x4F, 0x52, 0x42, 0x54, 0x01]; // "ORBT" + version 1 - if let Err(e) = stream.write_all(&handshake).await { - return Ok(ConnectionStatus::Error(format!( - "Handshake write failed: {}", - e - ))); - } - - // Read handshake response - let mut response = [0u8; 5]; - match tokio::time::timeout( - std::time::Duration::from_secs(3), - stream.read_exact(&mut response), - ) - .await - { - Ok(Ok(_)) if &response[..4] == b"ORBT" => Ok(ConnectionStatus::Connected), - Ok(Ok(_)) => Ok(ConnectionStatus::Error( - "Invalid handshake response".to_string(), - )), - Ok(Err(e)) => Ok(ConnectionStatus::Error(format!( - "Handshake read failed: {}", - e - ))), - Err(_) => Ok(ConnectionStatus::Error("Handshake timeout".to_string())), - } - } - Ok(Err(e)) => Ok(ConnectionStatus::Error(format!("Connection failed: {}", e))), - Err(_) => Ok(ConnectionStatus::Error("Connection timeout".to_string())), - } +/// Decode one PostgreSQL column into JSON, keeping SQL `NULL` distinct from +/// "this client could not decode it". +fn postgres_value_to_json(row: &tokio_postgres::Row, index: usize) -> serde_json::Value { + use serde_json::Value; + + /// `Ok(Some)` decoded, `Ok(None)` SQL NULL, `Err` not decodable here. + fn decode<'a, T>(row: &'a tokio_postgres::Row, index: usize) -> Result, ()> + where + T: tokio_postgres::types::FromSql<'a>, + { + row.try_get::<_, Option>(index).map_err(|_| ()) + } + + fn number(value: f64) -> Value { + serde_json::Number::from_f64(value) + .map(Value::Number) + .unwrap_or_else(|| Value::String(value.to_string())) + } + + let column_type = row.columns()[index].type_(); + + let decoded = match column_type.name() { + "bool" => decode::(row, index).map(|v| v.map(Value::Bool)), + "int2" => decode::(row, index).map(|v| v.map(|n| Value::Number(n.into()))), + "int4" => decode::(row, index).map(|v| v.map(|n| Value::Number(n.into()))), + "int8" => decode::(row, index).map(|v| v.map(|n| Value::Number(n.into()))), + "oid" => decode::(row, index).map(|v| v.map(|n| Value::Number(n.into()))), + "float4" => decode::(row, index).map(|v| v.map(|n| number(f64::from(n)))), + "float8" => decode::(row, index).map(|v| v.map(number)), + // Rendered as text: f64 cannot hold every NUMERIC exactly, and silently + // rounding a monetary column is worse than making the caller parse it. + "numeric" => decode::(row, index) + .map(|v| v.map(|d| Value::String(d.to_string()))), + "uuid" => decode::(row, index).map(|v| v.map(|u| Value::String(u.to_string()))), + "json" | "jsonb" => decode::(row, index), + "timestamptz" => decode::>(row, index) + .map(|v| v.map(|t| Value::String(t.to_rfc3339()))), + "timestamp" => decode::(row, index) + .map(|v| v.map(|t| Value::String(t.to_string()))), + "date" => decode::(row, index) + .map(|v| v.map(|t| Value::String(t.to_string()))), + "time" => decode::(row, index) + .map(|v| v.map(|t| Value::String(t.to_string()))), + "bytea" => decode::>(row, index) + .map(|v| v.map(|bytes| Value::String(format!("\\x{}", hex_encode(&bytes))))), + _ => decode::(row, index).map(|v| v.map(Value::String)), + }; + + match decoded { + Ok(Some(value)) => value, + Ok(None) => Value::Null, + // Not null, but this client has no decoder. Saying so beats rendering + // it as NULL, which would read as "the database has no value here". + Err(()) => Value::String(format!("", column_type.name())), } } -// Concrete database connection implementations +fn hex_encode(bytes: &[u8]) -> String { + use fmt::Write as _; + bytes.iter().fold(String::with_capacity(bytes.len() * 2), |mut acc, byte| { + // Writing to a String cannot fail; the Result is discarded deliberately. + let _ = write!(acc, "{byte:02x}"); + acc + }) +} + +// --------------------------------------------------------------------------- +// MySQL +// --------------------------------------------------------------------------- -/// PostgreSQL connection -pub struct PostgreSQLConnection { - client: Option, - connected: bool, +/// A MySQL session held open across statements. +pub struct MySqlSession { + conn: mysql_async::Conn, } -impl PostgreSQLConnection { - pub async fn new(info: &ConnectionInfo) -> Result { - let connection_string = format!( - "host={} port={} user={} password={} dbname={}", - info.host, - info.port, - info.username.as_ref().unwrap_or(&"postgres".to_string()), - info.password.as_ref().unwrap_or(&"".to_string()), - info.database.as_ref().unwrap_or(&"postgres".to_string()) - ); +impl MySqlSession { + async fn connect(info: &ConnectionInfo) -> Result { + let opts = mysql_async::OptsBuilder::default() + .ip_or_hostname(info.host.clone()) + .tcp_port(info.port) + .user(info.username.clone()) + .pass(info.password.clone()) + .db_name(info.database.clone()); - match tokio_postgres::connect(&connection_string, tokio_postgres::NoTls).await { - Ok((client, connection)) => { - // Spawn the connection task - tokio::spawn(async move { - if let Err(e) = connection.await { - tracing::error!("PostgreSQL connection error: {}", e); - } - }); - - Ok(Self { - client: Some(client), - connected: true, - }) - } - Err(e) => Err(ConnectionError::ConnectionFailed(e.to_string())), - } - } + let conn = tokio::time::timeout( + info.connect_timeout(), + mysql_async::Conn::new(opts), + ) + .await + .map_err(|_| ConnectionError::Timeout(info.connect_timeout()))? + .map_err(|e| ConnectionError::ConnectionFailed(e.to_string()))?; - pub async fn execute_query( - &self, - query: &str, - ) -> Result, ConnectionError> { - if let Some(client) = &self.client { - match client.query(query, &[]).await { - Ok(rows) => Ok(rows), - Err(e) => Err(ConnectionError::ConnectionFailed(e.to_string())), - } - } else { - Err(ConnectionError::ConnectionNotFound( - "PostgreSQL client not available".to_string(), - )) - } + Ok(Self { conn }) } } -impl DatabaseConnection for PostgreSQLConnection { +#[async_trait] +impl DatabaseSession for MySqlSession { fn connection_type(&self) -> ConnectionType { - ConnectionType::PostgreSQL - } - - fn is_connected(&self) -> bool { - self.connected && self.client.is_some() - } - - fn disconnect(&mut self) -> Result<(), ConnectionError> { - self.client = None; - self.connected = false; - Ok(()) + ConnectionType::MySQL } -} -/// OrbitQL connection -pub struct OrbitQLConnection { - // This would connect to the actual Orbit instance - // For now, we'll use HTTP client as a placeholder - client: reqwest::Client, - base_url: String, - connected: bool, -} + async fn execute(&mut self, statement: &str) -> Result { + use mysql_async::prelude::Queryable; -impl OrbitQLConnection { - pub async fn new(info: &ConnectionInfo) -> Result { - let base_url = format!("http://{}:{}", info.host, info.port); - let client = reqwest::Client::new(); - - // Test connection with a health check - let health_url = format!("{}/health", base_url); - match client.get(&health_url).send().await { - Ok(response) if response.status().is_success() => Ok(Self { - client, - base_url, - connected: true, - }), - Ok(response) => Err(ConnectionError::ConnectionFailed(format!( - "Health check failed: {}", - response.status() - ))), - Err(e) => Err(ConnectionError::NetworkError(e.to_string())), - } - } + let mut result = self + .conn + .query_iter(statement) + .await + .map_err(|e| ConnectionError::QueryFailed(e.to_string()))?; + + // Column metadata has to be taken before the result set is consumed. + let columns: Vec = result + .columns_ref() + .iter() + .map(|column| { + ColumnInfo::new( + column.name_str().as_ref(), + &format!("{:?}", column.column_type()).to_lowercase(), + ) + }) + .collect(); - pub async fn execute_orbitql(&self, query: &str) -> Result { - if !self.connected { - return Err(ConnectionError::ConnectionNotFound( - "Not connected".to_string(), - )); + if columns.is_empty() { + let affected = result.affected_rows(); + result + .drop_result() + .await + .map_err(|e| ConnectionError::QueryFailed(e.to_string()))?; + return Ok(QueryPayload::affected(affected)); } - let query_url = format!("{}/query", self.base_url); - let request_body = serde_json::json!({ - "query": query - }); - - match self - .client - .post(&query_url) - .json(&request_body) - .send() + let rows: Vec = result + .collect() .await - { - Ok(response) => { - if response.status().is_success() { - match response.json::().await { - Ok(result) => Ok(result), - Err(e) => Err(ConnectionError::NetworkError(e.to_string())), - } - } else { - Err(ConnectionError::ConnectionFailed(format!( - "Query failed: {}", - response.status() - ))) - } - } - Err(e) => Err(ConnectionError::NetworkError(e.to_string())), - } + .map_err(|e| ConnectionError::QueryFailed(e.to_string()))?; + + let shaped = rows + .into_iter() + .map(|row| { + columns + .iter() + .enumerate() + .map(|(index, column)| { + let value = row + .as_ref(index) + .map(mysql_value_to_json) + .unwrap_or(serde_json::Value::Null); + (column.name.clone(), value) + }) + .collect() + }) + .collect(); + + Ok(QueryPayload::returned(columns, shaped)) + } + + async fn ping(&mut self) -> Result<(), ConnectionError> { + self.conn + .ping() + .await + .map_err(|e| ConnectionError::NetworkError(e.to_string())) } } -impl DatabaseConnection for OrbitQLConnection { - fn connection_type(&self) -> ConnectionType { - ConnectionType::OrbitQL - } - - fn is_connected(&self) -> bool { - self.connected - } - - fn disconnect(&mut self) -> Result<(), ConnectionError> { - self.connected = false; - Ok(()) +/// Decode one MySQL column into JSON. +fn mysql_value_to_json(value: &mysql_async::Value) -> serde_json::Value { + use mysql_async::Value as My; + use serde_json::Value; + + match value { + My::NULL => Value::Null, + My::Int(n) => Value::Number((*n).into()), + My::UInt(n) => Value::Number((*n).into()), + My::Float(n) => serde_json::Number::from_f64(f64::from(*n)) + .map(Value::Number) + .unwrap_or_else(|| Value::String(n.to_string())), + My::Double(n) => serde_json::Number::from_f64(*n) + .map(Value::Number) + .unwrap_or_else(|| Value::String(n.to_string())), + My::Bytes(bytes) => match std::str::from_utf8(bytes) { + Ok(text) => Value::String(text.to_string()), + Err(_) => Value::String(format!("\\x{}", hex_encode(bytes))), + }, + My::Date(year, month, day, hour, minute, second, micros) => Value::String(format!( + "{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}.{micros:06}" + )), + My::Time(negative, days, hours, minutes, seconds, micros) => { + let sign = if *negative { "-" } else { "" }; + Value::String(format!( + "{sign}{days}d {hours:02}:{minutes:02}:{seconds:02}.{micros:06}" + )) + } } } -/// Redis connection -pub struct RedisConnection { - client: Option, - connected: bool, +// --------------------------------------------------------------------------- +// Redis +// --------------------------------------------------------------------------- + +/// A Redis session held open across commands, so `SELECT ` and `MULTI` +/// apply to subsequent commands the way `redis-cli` behaves. +pub struct RedisSession { + conn: redis::aio::MultiplexedConnection, } -impl RedisConnection { - pub async fn new(info: &ConnectionInfo) -> Result { - let redis_url = format!("redis://{}:{}/", info.host, info.port); - - match redis::Client::open(redis_url) { - Ok(client) => { - // Test connection - let mut conn = client - .get_async_connection() - .await - .map_err(|e| ConnectionError::ConnectionFailed(e.to_string()))?; - - // Test with PING - redis::cmd("PING") - .query_async::<_, String>(&mut conn) - .await - .map_err(|e| ConnectionError::ConnectionFailed(e.to_string()))?; - - Ok(Self { - client: Some(client), - connected: true, - }) - } - Err(e) => Err(ConnectionError::InvalidConfiguration(e.to_string())), +impl RedisSession { + async fn connect(info: &ConnectionInfo) -> Result { + let mut url = format!("redis://{}:{}", info.host, info.port); + if let Some(database) = info.database.as_deref().filter(|d| !d.is_empty()) { + url.push('/'); + url.push_str(database); } - } - pub async fn execute_redis_command( - &self, - cmd: &str, - args: &[&str], - ) -> Result { - if let Some(client) = &self.client { - let mut conn = client - .get_async_connection() - .await - .map_err(|e| ConnectionError::ConnectionFailed(e.to_string()))?; + let client = redis::Client::open(url) + .map_err(|e| ConnectionError::InvalidConfiguration(e.to_string()))?; - let mut redis_cmd = redis::cmd(cmd); - for arg in args { - redis_cmd.arg(*arg); - } + let mut conn = tokio::time::timeout( + info.connect_timeout(), + client.get_multiplexed_async_connection(), + ) + .await + .map_err(|_| ConnectionError::Timeout(info.connect_timeout()))? + .map_err(|e| ConnectionError::ConnectionFailed(e.to_string()))?; - redis_cmd - .query_async::<_, redis::Value>(&mut conn) + if let Some(password) = info.password.as_deref().filter(|p| !p.is_empty()) { + let mut auth = redis::cmd("AUTH"); + if let Some(user) = info.username.as_deref().filter(|u| !u.is_empty()) { + auth.arg(user); + } + auth.arg(password); + auth.query_async::(&mut conn) .await - .map_err(|e| ConnectionError::ConnectionFailed(e.to_string())) - } else { - Err(ConnectionError::ConnectionNotFound( - "Redis connection not available".to_string(), - )) + .map_err(|e| ConnectionError::ConnectionFailed(format!("AUTH failed: {e}")))?; } + + Ok(Self { conn }) } } -impl DatabaseConnection for RedisConnection { +#[async_trait] +impl DatabaseSession for RedisSession { fn connection_type(&self) -> ConnectionType { ConnectionType::Redis } - fn is_connected(&self) -> bool { - self.connected && self.client.is_some() - } - - fn disconnect(&mut self) -> Result<(), ConnectionError> { - self.client = None; - self.connected = false; - Ok(()) - } -} - -/// MySQL connection -pub struct MySQLConnection { - pool: Option, - connected: bool, -} - -impl MySQLConnection { - pub async fn new(info: &ConnectionInfo) -> Result { - use mysql_async::prelude::*; + async fn execute(&mut self, statement: &str) -> Result { + let args = split_redis_command(statement); + let (name, rest) = args + .split_first() + .ok_or_else(|| ConnectionError::QueryFailed("empty Redis command".to_string()))?; - let opts = mysql_async::OptsBuilder::default() - .ip_or_hostname(info.host.clone()) - .tcp_port(info.port) - .user(info.username.as_deref()) - .pass(info.password.as_deref()) - .db_name(info.database.as_deref()); - - let pool = mysql_async::Pool::new(opts); - - // Test connection - match pool.get_conn().await { - Ok(mut conn) => match conn.query_first::("SELECT 1").await { - Ok(_) => Ok(Self { - pool: Some(pool), - connected: true, - }), - Err(e) => Err(ConnectionError::ConnectionFailed(e.to_string())), - }, - Err(e) => Err(ConnectionError::ConnectionFailed(e.to_string())), + let mut command = redis::cmd(&name.to_uppercase()); + for arg in rest { + command.arg(arg.as_str()); } - } - - pub async fn execute_query( - &self, - query: &str, - ) -> Result, ConnectionError> { - use mysql_async::prelude::Queryable; - - if let Some(pool) = &self.pool { - let mut conn = pool - .get_conn() - .await - .map_err(|e| ConnectionError::ConnectionFailed(e.to_string()))?; - conn.query::(query) - .await - .map_err(|e| ConnectionError::ConnectionFailed(e.to_string())) - } else { - Err(ConnectionError::ConnectionNotFound( - "MySQL pool not available".to_string(), - )) - } - } -} - -impl DatabaseConnection for MySQLConnection { - fn connection_type(&self) -> ConnectionType { - ConnectionType::MySQL - } + let value = command + .query_async::(&mut self.conn) + .await + .map_err(|e| ConnectionError::QueryFailed(e.to_string()))?; - fn is_connected(&self) -> bool { - self.connected && self.pool.is_some() + Ok(QueryPayload::single_value( + "result", + "redis", + redis_value_to_json(value), + )) } - fn disconnect(&mut self) -> Result<(), ConnectionError> { - self.pool = None; - self.connected = false; - Ok(()) + async fn ping(&mut self) -> Result<(), ConnectionError> { + redis::cmd("PING") + .query_async::(&mut self.conn) + .await + .map(|_| ()) + .map_err(|e| ConnectionError::NetworkError(e.to_string())) } } -/// CQL (Cassandra) connection -pub struct CQLConnection { - base_url: String, - client: reqwest::Client, - connected: bool, -} - -impl CQLConnection { - pub async fn new(info: &ConnectionInfo) -> Result { - // CQL uses binary protocol, but we'll use HTTP REST API if available - let base_url = format!("http://{}:{}", info.host, info.port); - let client = reqwest::Client::new(); - - // Test connection - let test_url = format!("{}/health", base_url); - match client.get(&test_url).send().await { - Ok(response) if response.status().is_success() => Ok(Self { - base_url, - client, - connected: true, - }), - Ok(_) => { - // If health endpoint doesn't exist, assume connection is OK - Ok(Self { - base_url, - client, - connected: true, - }) +/// Split a Redis command line, honouring single and double quoted arguments so +/// that `SET greeting "hello world"` is two arguments rather than three. +fn split_redis_command(line: &str) -> Vec { + let mut args = Vec::new(); + let mut current = String::new(); + let mut quote: Option = None; + let mut started = false; + + for ch in line.trim().chars() { + match (quote, ch) { + (Some(q), c) if c == q => { + quote = None; } - Err(e) => Err(ConnectionError::ConnectionFailed(e.to_string())), - } - } - - pub async fn execute_cql(&self, query: &str) -> Result { - if !self.connected { - return Err(ConnectionError::ConnectionNotFound( - "Not connected".to_string(), - )); - } - - // Execute CQL query via HTTP REST API - let query_url = format!("{}/api/v1/query", self.base_url); - let request_body = serde_json::json!({ - "query": query - }); - - match self - .client - .post(&query_url) - .json(&request_body) - .send() - .await - { - Ok(response) => { - if response.status().is_success() { - response - .json::() - .await - .map_err(|e| ConnectionError::NetworkError(e.to_string())) - } else { - Err(ConnectionError::ConnectionFailed(format!( - "Query failed: {}", - response.status() - ))) + (Some(_), c) => current.push(c), + (None, c @ ('"' | '\'')) => { + quote = Some(c); + started = true; + } + (None, c) if c.is_whitespace() => { + if started || !current.is_empty() { + args.push(std::mem::take(&mut current)); + started = false; } } - Err(e) => Err(ConnectionError::NetworkError(e.to_string())), + (None, c) => current.push(c), } } -} -impl DatabaseConnection for CQLConnection { - fn connection_type(&self) -> ConnectionType { - ConnectionType::CQL + if started || !current.is_empty() { + args.push(current); } - fn is_connected(&self) -> bool { - self.connected - } + args +} - fn disconnect(&mut self) -> Result<(), ConnectionError> { - self.connected = false; - Ok(()) +/// Convert a RESP value into JSON, recursing into aggregates rather than +/// falling back to a `Debug` rendering. +fn redis_value_to_json(value: redis::Value) -> serde_json::Value { + use redis::Value as Resp; + use serde_json::Value; + + match value { + Resp::Nil => Value::Null, + Resp::Int(n) => Value::Number(n.into()), + Resp::BulkString(bytes) => match String::from_utf8(bytes) { + Ok(text) => Value::String(text), + Err(e) => Value::String(format!("\\x{}", hex_encode(e.as_bytes()))), + }, + Resp::Array(values) | Resp::Set(values) => { + Value::Array(values.into_iter().map(redis_value_to_json).collect()) + } + Resp::SimpleString(text) => Value::String(text), + Resp::Okay => Value::String("OK".to_string()), + Resp::Map(pairs) => Value::Object( + pairs + .into_iter() + .map(|(key, value)| { + let key = match redis_value_to_json(key) { + Value::String(text) => text, + other => other.to_string(), + }; + (key, redis_value_to_json(value)) + }) + .collect(), + ), + Resp::Attribute { data, .. } => redis_value_to_json(*data), + Resp::Double(n) => serde_json::Number::from_f64(n) + .map(Value::Number) + .unwrap_or_else(|| Value::String(n.to_string())), + Resp::Boolean(b) => Value::Bool(b), + Resp::VerbatimString { text, .. } => Value::String(text), + Resp::BigNumber(n) => Value::String(n.to_string()), + Resp::Push { kind, data } => Value::Object( + [ + ("kind".to_string(), Value::String(format!("{kind:?}"))), + ( + "data".to_string(), + Value::Array(data.into_iter().map(redis_value_to_json).collect()), + ), + ] + .into_iter() + .collect(), + ), + Resp::ServerError(error) => Value::String(format!("{error:?}")), } } -/// Cypher (Neo4j) connection -pub struct CypherConnection { +// --------------------------------------------------------------------------- +// HTTP-backed protocols +// --------------------------------------------------------------------------- + +/// A session for the protocols Orbit-RS exposes over HTTP. +/// +/// The reqwest client is kept so that keep-alive applies across statements. +pub struct HttpSession { client: reqwest::Client, base_url: String, + flavor: ConnectionType, username: Option, password: Option, - connected: bool, } -impl CypherConnection { - pub async fn new(info: &ConnectionInfo) -> Result { - let base_url = format!("http://{}:{}", info.host, info.port); - let client = reqwest::Client::new(); - - // Test connection with a simple query - let test_url = format!("{}/db/data/transaction/commit", base_url); - let mut request = client.post(&test_url); +impl HttpSession { + async fn connect(info: &ConnectionInfo) -> Result { + let client = reqwest::Client::builder() + .connect_timeout(info.connect_timeout()) + .build() + .map_err(|e| ConnectionError::InvalidConfiguration(e.to_string()))?; + + let session = Self { + client, + base_url: info.http_base_url(), + flavor: info.connection_type, + username: info.username.clone(), + password: info.password.clone(), + }; - if let (Some(user), Some(pass)) = (&info.username, &info.password) { - request = request.basic_auth(user, Some(pass)); - } - - match request - .json(&serde_json::json!({ - "statements": [{"statement": "RETURN 1 as result"}] - })) - .send() - .await - { - Ok(response) if response.status().is_success() => Ok(Self { - client, - base_url, - username: info.username.clone(), - password: info.password.clone(), - connected: true, - }), - Ok(response) => Err(ConnectionError::ConnectionFailed(format!( - "HTTP {}: {}", - response.status(), - response.status().canonical_reason().unwrap_or("Unknown") - ))), - Err(e) => Err(ConnectionError::NetworkError(e.to_string())), + session.probe().await?; + Ok(session) + } + + /// Endpoint and body for one statement, per protocol. + fn request_for(&self, statement: &str) -> (String, serde_json::Value) { + match self.flavor { + ConnectionType::Cypher => ( + format!("{}/db/data/transaction/commit", self.base_url), + serde_json::json!({ "statements": [{ "statement": statement }] }), + ), + ConnectionType::AQL => ( + format!("{}/_api/cursor", self.base_url), + serde_json::json!({ "query": statement, "count": true }), + ), + ConnectionType::FlightSQL => ( + format!("{}/api/v1/flight/sql", self.base_url), + serde_json::json!({ "query": statement }), + ), + _ => ( + format!("{}/api/v1/sql", self.base_url), + serde_json::json!({ "query": statement }), + ), } } - pub async fn execute_cypher(&self, query: &str) -> Result { - if !self.connected { - return Err(ConnectionError::ConnectionNotFound( - "Not connected".to_string(), - )); - } + /// Confirm something is listening and answering before declaring success. + async fn probe(&self) -> Result<(), ConnectionError> { + let url = match self.flavor { + ConnectionType::AQL => format!("{}/_api/version", self.base_url), + _ => format!("{}/health", self.base_url), + }; - let query_url = format!("{}/db/data/transaction/commit", self.base_url); - let mut request = self.client.post(&query_url); + let response = self + .authenticated(self.client.get(&url)) + .send() + .await + .map_err(|e| ConnectionError::NetworkError(e.to_string()))?; - if let (Some(user), Some(pass)) = (&self.username, &self.password) { - request = request.basic_auth(user, Some(pass)); + // A 404 still proves a server answered; the health path merely differs. + if response.status().is_success() || response.status() == reqwest::StatusCode::NOT_FOUND { + Ok(()) + } else { + Err(ConnectionError::ConnectionFailed(format!( + "health probe returned HTTP {}", + response.status() + ))) } + } - let request_body = serde_json::json!({ - "statements": [{"statement": query}] - }); - - match request.json(&request_body).send().await { - Ok(response) => { - if response.status().is_success() { - response - .json::() - .await - .map_err(|e| ConnectionError::NetworkError(e.to_string())) - } else { - Err(ConnectionError::ConnectionFailed(format!( - "Query failed: {}", - response.status() - ))) - } - } - Err(e) => Err(ConnectionError::NetworkError(e.to_string())), + fn authenticated(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + match (&self.username, &self.password) { + (Some(user), password) => builder.basic_auth(user, password.as_ref()), + (None, _) => builder, } } } -impl DatabaseConnection for CypherConnection { +#[async_trait] +impl DatabaseSession for HttpSession { fn connection_type(&self) -> ConnectionType { - ConnectionType::Cypher + self.flavor } - fn is_connected(&self) -> bool { - self.connected - } - - fn disconnect(&mut self) -> Result<(), ConnectionError> { - self.connected = false; - Ok(()) - } -} - -/// AQL (ArangoDB) connection -pub struct AQLConnection { - client: reqwest::Client, - base_url: String, - username: Option, - password: Option, - database: Option, - connected: bool, -} + async fn execute(&mut self, statement: &str) -> Result { + let (url, body) = self.request_for(statement); -impl AQLConnection { - pub async fn new(info: &ConnectionInfo) -> Result { - let base_url = format!("http://{}:{}", info.host, info.port); - let client = reqwest::Client::new(); + let response = self + .authenticated(self.client.post(&url)) + .json(&body) + .send() + .await + .map_err(|e| ConnectionError::NetworkError(e.to_string()))?; - // Test connection - let version_url = format!("{}/_api/version", base_url); - let mut request = client.get(&version_url); + let status = response.status(); + let payload: serde_json::Value = response + .json() + .await + .map_err(|e| ConnectionError::QueryFailed(format!("HTTP {status}: {e}")))?; - if let (Some(user), Some(pass)) = (&info.username, &info.password) { - request = request.basic_auth(user, Some(pass)); + if !status.is_success() { + return Err(ConnectionError::QueryFailed(format!( + "HTTP {status}: {payload}" + ))); } - match request.send().await { - Ok(response) if response.status().is_success() => Ok(Self { - client, - base_url, - username: info.username.clone(), - password: info.password.clone(), - database: info.database.clone(), - connected: true, - }), - Ok(response) => Err(ConnectionError::ConnectionFailed(format!( - "HTTP {}: {}", - response.status(), - response.status().canonical_reason().unwrap_or("Unknown") - ))), - Err(e) => Err(ConnectionError::NetworkError(e.to_string())), - } + Ok(QueryPayload::from_json(&payload)) } - pub async fn execute_aql(&self, query: &str) -> Result { - if !self.connected { - return Err(ConnectionError::ConnectionNotFound( - "Not connected".to_string(), - )); - } - - let db = self.database.as_deref().unwrap_or("_system"); - let query_url = format!("{}/_api/cursor", self.base_url); - let mut request = self.client.post(&query_url); + async fn ping(&mut self) -> Result<(), ConnectionError> { + self.probe().await + } +} - if let (Some(user), Some(pass)) = (&self.username, &self.password) { - request = request.basic_auth(user, Some(pass)); - } +// --------------------------------------------------------------------------- +// Raw TCP protocols +// --------------------------------------------------------------------------- + +/// A protocol we can prove is listening but cannot yet speak. +/// +/// Used for CQL: the desktop app has no CQL binary-protocol client, so it +/// verifies reachability and then refuses statements rather than pretending to +/// have run them. +pub struct TcpProbeSession { + addr: String, + flavor: ConnectionType, + timeout: Duration, +} - let request_body = serde_json::json!({ - "query": query, - "count": true - }); +impl TcpProbeSession { + async fn connect(info: &ConnectionInfo) -> Result { + let session = Self { + addr: info.socket_addr(), + flavor: info.connection_type, + timeout: info.connect_timeout(), + }; + session.probe().await?; + Ok(session) + } - match request.json(&request_body).send().await { - Ok(response) => { - if response.status().is_success() { - response - .json::() - .await - .map_err(|e| ConnectionError::NetworkError(e.to_string())) - } else { - Err(ConnectionError::ConnectionFailed(format!( - "Query failed: {}", - response.status() - ))) - } - } - Err(e) => Err(ConnectionError::NetworkError(e.to_string())), - } + async fn probe(&self) -> Result<(), ConnectionError> { + tokio::time::timeout(self.timeout, tokio::net::TcpStream::connect(&self.addr)) + .await + .map_err(|_| ConnectionError::Timeout(self.timeout))? + .map(|_| ()) + .map_err(|e| ConnectionError::ConnectionFailed(e.to_string())) } } -impl DatabaseConnection for AQLConnection { +#[async_trait] +impl DatabaseSession for TcpProbeSession { fn connection_type(&self) -> ConnectionType { - ConnectionType::AQL + self.flavor } - fn is_connected(&self) -> bool { - self.connected + async fn execute(&mut self, _statement: &str) -> Result { + Err(ConnectionError::QueryFailed(format!( + "{} statements cannot be executed from the desktop app yet: the {0} binary protocol \ + has no client here. The endpoint at {} is reachable — use cqlsh against it, or \ + connect over PostgreSQL/MySQL instead.", + self.flavor, self.addr + ))) } - fn disconnect(&mut self) -> Result<(), ConnectionError> { - self.connected = false; - Ok(()) + async fn ping(&mut self) -> Result<(), ConnectionError> { + self.probe().await } } -/// Arrow Flight SQL connection -pub struct FlightSQLConnection { - client: reqwest::Client, - endpoint: String, - database: Option, - connected: bool, +/// OrbitWire: handshake over TCP to prove the server speaks the protocol, then +/// carry statements over the REST endpoint on the same host. +pub struct OrbitWireSession { + http: HttpSession, } -impl FlightSQLConnection { - pub async fn new(info: &ConnectionInfo) -> Result { - let endpoint = format!("http://{}:{}", info.host, info.port); - let client = reqwest::Client::new(); +impl OrbitWireSession { + /// `"ORBT"` plus protocol version 1. + const HANDSHAKE: [u8; 5] = [0x4F, 0x52, 0x42, 0x54, 0x01]; - // Test connection by checking if server responds - let health_url = format!("{}/health", endpoint); - match tokio::time::timeout( - std::time::Duration::from_secs(5), - client.get(&health_url).send(), - ) - .await - { - Ok(Ok(response)) - if response.status().is_success() || response.status().as_u16() == 404 => - { - Ok(Self { - client, - endpoint, - database: info.database.clone(), - connected: true, - }) - } - Ok(Ok(response)) => Err(ConnectionError::ConnectionFailed(format!( - "HTTP {}: {}", - response.status(), - response.status().canonical_reason().unwrap_or("Unknown") - ))), - Ok(Err(e)) => Err(ConnectionError::NetworkError(e.to_string())), - Err(_) => Err(ConnectionError::Timeout), - } - } + async fn connect(info: &ConnectionInfo) -> Result { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let timeout = info.connect_timeout(); + let mut stream = + tokio::time::timeout(timeout, tokio::net::TcpStream::connect(info.socket_addr())) + .await + .map_err(|_| ConnectionError::Timeout(timeout))? + .map_err(|e| ConnectionError::ConnectionFailed(e.to_string()))?; + + stream + .write_all(&Self::HANDSHAKE) + .await + .map_err(|e| ConnectionError::NetworkError(e.to_string()))?; + + let mut response = [0u8; 5]; + tokio::time::timeout(timeout, stream.read_exact(&mut response)) + .await + .map_err(|_| ConnectionError::Timeout(timeout))? + .map_err(|e| ConnectionError::NetworkError(e.to_string()))?; - pub async fn execute_query(&self, query: &str) -> Result { - if !self.connected { - return Err(ConnectionError::ConnectionNotFound( - "Not connected".to_string(), + if &response[..4] != b"ORBT" { + return Err(ConnectionError::ConnectionFailed( + "peer did not answer the OrbitWire handshake".to_string(), )); } - let url = format!("{}/api/v1/flight/sql", self.endpoint); - let request_body = serde_json::json!({ - "query": query, - "database": self.database - }); + // Statements travel over REST; `http_port` says where, defaulting to the + // REST listener rather than assuming it shares the wire port. + let rest_port = info + .additional_params + .get("http_port") + .and_then(|value| value.parse::().ok()) + .unwrap_or(8080); - match self.client.post(&url).json(&request_body).send().await { - Ok(response) => { - if response.status().is_success() { - response - .json::() - .await - .map_err(|e| ConnectionError::NetworkError(e.to_string())) - } else { - Err(ConnectionError::ConnectionFailed(format!( - "Query failed: {}", - response.status() - ))) - } - } - Err(e) => Err(ConnectionError::NetworkError(e.to_string())), - } + let http = HttpSession::connect(&ConnectionInfo { + port: rest_port, + connection_type: ConnectionType::OrbitWire, + ..info.clone() + }) + .await?; + + Ok(Self { http }) } } -impl DatabaseConnection for FlightSQLConnection { +#[async_trait] +impl DatabaseSession for OrbitWireSession { fn connection_type(&self) -> ConnectionType { - ConnectionType::FlightSQL + ConnectionType::OrbitWire } - fn is_connected(&self) -> bool { - self.connected + async fn execute(&mut self, statement: &str) -> Result { + self.http.execute(statement).await } - fn disconnect(&mut self) -> Result<(), ConnectionError> { - self.connected = false; - Ok(()) + async fn ping(&mut self) -> Result<(), ConnectionError> { + self.http.ping().await } } -/// OrbitWire (native binary protocol) connection -pub struct OrbitWireConnection { - client: reqwest::Client, - base_url: String, - database: Option, - connected: bool, -} - -impl OrbitWireConnection { - pub async fn new(info: &ConnectionInfo) -> Result { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - let addr = format!("{}:{}", info.host, info.port); - - // Test connection with OrbitWire handshake - match tokio::time::timeout( - std::time::Duration::from_secs(5), - tokio::net::TcpStream::connect(&addr), - ) - .await - { - Ok(Ok(mut stream)) => { - // Send handshake - let handshake = [0x4F, 0x52, 0x42, 0x54, 0x01]; // "ORBT" + version 1 - stream - .write_all(&handshake) - .await - .map_err(|e| ConnectionError::NetworkError(e.to_string()))?; - - // Read response - let mut response = [0u8; 5]; - match tokio::time::timeout( - std::time::Duration::from_secs(3), - stream.read_exact(&mut response), - ) - .await - { - Ok(Ok(_)) if &response[..4] == b"ORBT" => { - // Connection successful, we'll use HTTP fallback for queries - // since maintaining raw TCP state is complex for this use case - let http_port = 8080; // Default REST API port - let base_url = format!("http://{}:{}", info.host, http_port); - let client = reqwest::Client::new(); - - Ok(Self { - client, - base_url, - database: info.database.clone(), - connected: true, - }) - } - Ok(Ok(_)) => Err(ConnectionError::ConnectionFailed( - "Invalid handshake response".to_string(), - )), - Ok(Err(e)) => Err(ConnectionError::NetworkError(e.to_string())), - Err(_) => Err(ConnectionError::Timeout), - } - } - Ok(Err(e)) => Err(ConnectionError::ConnectionFailed(e.to_string())), - Err(_) => Err(ConnectionError::Timeout), +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn connection_type_round_trips_through_its_string_form() { + for variant in ConnectionType::ALL { + let parsed: ConnectionType = variant + .as_str() + .parse() + .expect("every variant's own string must parse back"); + assert_eq!(variant, parsed); } } - pub async fn execute_query(&self, query: &str) -> Result { - if !self.connected { - return Err(ConnectionError::ConnectionNotFound( - "Not connected".to_string(), - )); + #[test] + fn connection_type_parsing_is_case_insensitive_and_rejects_junk() { + assert_eq!( + "postgresql".parse::().ok(), + Some(ConnectionType::PostgreSQL) + ); + assert!("Postgres".parse::().is_err()); + } + + #[test] + fn default_ports_match_the_documented_protocol_ports() { + assert_eq!(ConnectionType::PostgreSQL.default_port(), 5432); + assert_eq!(ConnectionType::MySQL.default_port(), 3306); + assert_eq!(ConnectionType::Redis.default_port(), 6379); + assert_eq!(ConnectionType::CQL.default_port(), 9042); + assert_eq!(ConnectionType::OrbitQL.default_port(), 8080); + } + + fn info_with_ssl(mode: Option<&str>) -> ConnectionInfo { + ConnectionInfo { + name: "test".to_string(), + connection_type: ConnectionType::PostgreSQL, + host: "localhost".to_string(), + port: 5432, + database: None, + username: None, + password: None, + ssl_mode: mode.map(str::to_string), + connection_timeout: None, + additional_params: HashMap::new(), } + } - let url = format!("{}/api/v1/sql", self.base_url); - let request_body = serde_json::json!({ - "query": query, - "protocol": "orbitwire" - }); + #[test] + fn plaintext_ssl_modes_are_accepted() { + for mode in [None, Some(""), Some("disable"), Some("prefer"), Some("allow")] { + assert!(info_with_ssl(mode).ensure_ssl_mode_supported().is_ok()); + } + } - match self.client.post(&url).json(&request_body).send().await { - Ok(response) => { - if response.status().is_success() { - response - .json::() - .await - .map_err(|e| ConnectionError::NetworkError(e.to_string())) - } else { - Err(ConnectionError::ConnectionFailed(format!( - "Query failed: {}", - response.status() - ))) - } - } - Err(e) => Err(ConnectionError::NetworkError(e.to_string())), + #[test] + fn encrypting_ssl_modes_are_refused_rather_than_downgraded() { + for mode in ["require", "verify-ca", "verify-full"] { + assert!(info_with_ssl(Some(mode)).ensure_ssl_mode_supported().is_err()); } } -} -impl DatabaseConnection for OrbitWireConnection { - fn connection_type(&self) -> ConnectionType { - ConnectionType::OrbitWire + #[test] + fn connect_timeout_falls_back_to_the_default_when_unset() { + assert_eq!( + info_with_ssl(None).connect_timeout(), + DEFAULT_CONNECT_TIMEOUT + ); + let mut info = info_with_ssl(None); + info.connection_timeout = Some(250); + assert_eq!(info.connect_timeout(), Duration::from_millis(250)); } - fn is_connected(&self) -> bool { - self.connected + #[test] + fn redis_command_splitting_keeps_quoted_arguments_whole() { + assert_eq!( + split_redis_command("SET greeting \"hello world\""), + vec!["SET", "greeting", "hello world"] + ); + assert_eq!( + split_redis_command(" GET key "), + vec!["GET", "key"] + ); + assert_eq!( + split_redis_command("SET empty \"\""), + vec!["SET", "empty", ""] + ); + assert!(split_redis_command(" ").is_empty()); + } + + #[test] + fn redis_aggregates_convert_without_debug_fallbacks() { + use redis::Value as Resp; + let nested = Resp::Array(vec![ + Resp::Int(1), + Resp::BulkString(b"two".to_vec()), + Resp::Array(vec![Resp::Okay]), + ]); + assert_eq!( + redis_value_to_json(nested), + serde_json::json!([1, "two", ["OK"]]) + ); } - fn disconnect(&mut self) -> Result<(), ConnectionError> { - self.connected = false; - Ok(()) + #[test] + fn hex_encoding_pads_every_byte() { + assert_eq!(hex_encode(&[0x00, 0x0f, 0xff]), "000fff"); } } diff --git a/orbit/desktop/src-tauri/src/main.rs b/orbit/desktop/src-tauri/src/main.rs index a2cfb7b4..c71d6293 100644 --- a/orbit/desktop/src-tauri/src/main.rs +++ b/orbit/desktop/src-tauri/src/main.rs @@ -1,43 +1,49 @@ -//! Orbit Desktop - Desktop UI for Orbit-RS Database Management +//! Orbit Desktop — a database client for Orbit-RS. //! -//! This is a Tauri-based desktop application that provides a UI similar to RedisInsights -//! for managing Orbit-RS databases, running PostgreSQL queries, OrbitQL queries, and Redis commands. +//! Provides connection management, statement execution against Orbit's wire +//! protocols, and lifecycle control for a local development cluster. #![cfg_attr( all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows" )] -use chrono; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tauri::{Manager, State}; use tokio::sync::RwLock; + +mod cluster; mod connections; mod encryption; mod models; mod queries; mod storage; -use connections::{ - Connection, ConnectionInfo, ConnectionManager, ConnectionStatus, ConnectionType, -}; +use cluster::{ClusterManager, ClusterStatus}; +use connections::{Connection, ConnectionInfo, ConnectionManager, ConnectionStatus, ConnectionType}; use encryption::EncryptionManager; use models::{MLFunctionInfo, ModelInfo, ModelManager}; -use queries::{QueryExecutor, QueryRequest, QueryResult}; -use storage::{AppStorage, StorageManager}; - -/// Application state +use queries::{QueryExecutor, QueryHistoryEntry, QueryRequest, QueryResult}; +use storage::StorageManager; + +/// Shared application state. +/// +/// [`ConnectionManager`] locks internally, so it is held behind an `Arc` rather +/// than an outer lock: opening a session for one connection must not block +/// queries running against another. struct AppState { - connections: RwLock, + connections: Arc, query_executor: RwLock, model_manager: RwLock, storage: StorageManager, encryption: EncryptionManager, + /// The Orbit-RS checkout whose cluster this app manages, once located. + cluster_root: RwLock>, } -/// Response wrapper for all API calls +/// Uniform envelope for every command result. #[derive(Debug, Clone, Serialize, Deserialize)] struct ApiResponse { success: bool, @@ -56,48 +62,66 @@ impl ApiResponse { } } - fn error(message: String) -> Self { + fn error(message: impl std::fmt::Display) -> Self { Self { success: false, data: None, - error: Some(message), + error: Some(message.to_string()), timestamp: chrono::Utc::now(), } } } -/// Connection Management Commands +/// Run a fallible body, turning its error into an [`ApiResponse::error`]. +/// +/// The outer `Result` is `Ok` for both outcomes: Tauri's `Err` channel loses +/// the envelope, so failures travel in the payload where the UI can show them. +macro_rules! respond { + ($body:expr) => { + match $body { + Ok(value) => Ok(ApiResponse::success(value)), + Err(e) => Ok(ApiResponse::error(e)), + } + }; +} + +// ============================ Connections ============================ + +/// Persist the current in-memory connection list. +async fn persist_connections(state: &AppState) -> Result<(), String> { + let mut storage = state + .storage + .load() + .map_err(|e| format!("Failed to load storage: {e}"))?; + + let connections = state.connections.list_connections().await; + storage.connections = connections + .iter() + .map(|connection| connection.to_stored(&state.encryption)) + .collect::, _>>() + .map_err(|e| format!("Failed to encrypt connections: {e}"))?; + + state + .storage + .save(&storage) + .map_err(|e| format!("Failed to save storage: {e}")) +} #[tauri::command] async fn create_connection( connection_info: ConnectionInfo, state: State<'_, AppState>, ) -> Result, String> { - let mut manager = state.connections.write().await; - let connection_id = match manager.create_connection(connection_info.clone()).await { + let id = match state.connections.create_connection(connection_info).await { Ok(id) => id, - Err(e) => return Ok(ApiResponse::error(e.to_string())), + Err(e) => return Ok(ApiResponse::error(e)), }; - // Save to storage - let mut storage = state - .storage - .load() - .map_err(|e| format!("Failed to load storage: {}", e))?; - - if let Some(conn) = manager.get_connection(&connection_id).await { - let stored_conn = conn - .to_stored(&state.encryption) - .map_err(|e| format!("Failed to encrypt connection: {}", e))?; - storage.connections.push(stored_conn); - - state - .storage - .save(&storage) - .map_err(|e| format!("Failed to save storage: {}", e))?; + if let Err(e) = persist_connections(&state).await { + return Ok(ApiResponse::error(e)); } - Ok(ApiResponse::success(connection_id)) + Ok(ApiResponse::success(id)) } #[tauri::command] @@ -105,43 +129,31 @@ async fn test_connection( connection_info: ConnectionInfo, state: State<'_, AppState>, ) -> Result, String> { - let manager = state.connections.read().await; - match manager.test_connection(&connection_info).await { - Ok(status) => Ok(ApiResponse::success(status)), - Err(e) => Ok(ApiResponse::error(e.to_string())), - } + Ok(ApiResponse::success( + state.connections.test_connection(&connection_info).await, + )) } #[tauri::command] async fn get_connections( - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result>, String> { - // Load connections from storage - let storage = state - .storage - .load() - .map_err(|e| format!("Failed to load storage: {}", e))?; - - let mut connections = Vec::new(); - for stored_conn in &storage.connections { - match stored_conn.to_connection(&state.encryption) { - Ok(conn) => connections.push(conn), - Err(e) => { - tracing::warn!("Failed to load connection {}: {}", stored_conn.id, e); - } - } - } + Ok(ApiResponse::success( + state.connections.list_connections().await, + )) +} - // Update connection manager with loaded connections - { - let mut manager = state.connections.write().await; - for conn in &connections { - // Store connection metadata (without active connection) - // Active connections will be created on demand - } +/// Open a session now rather than on first query, so the UI can report whether +/// a saved connection is actually usable. +#[tauri::command] +async fn connect( + connection_id: String, + state: State<'_, AppState>, +) -> Result, String> { + match state.connections.session(&connection_id).await { + Ok(_) => Ok(ApiResponse::success(ConnectionStatus::Connected)), + Err(e) => Ok(ApiResponse::error(e)), } - - Ok(ApiResponse::success(connections)) } #[tauri::command] @@ -149,11 +161,8 @@ async fn disconnect( connection_id: String, state: State<'_, AppState>, ) -> Result, String> { - let mut manager = state.connections.write().await; - match manager.disconnect(&connection_id).await { - Ok(_) => Ok(ApiResponse::success(true)), - Err(e) => Ok(ApiResponse::error(e.to_string())), - } + state.connections.disconnect(&connection_id).await; + Ok(ApiResponse::success(true)) } #[tauri::command] @@ -161,41 +170,59 @@ async fn delete_connection( connection_id: String, state: State<'_, AppState>, ) -> Result, String> { - let mut manager = state.connections.write().await; - match manager.delete_connection(&connection_id).await { - Ok(_) => {} - Err(e) => return Ok(ApiResponse::error(e.to_string())), - } - - // Remove from storage - let mut storage = state - .storage - .load() - .map_err(|e| format!("Failed to load storage: {}", e))?; - - storage.connections.retain(|c| c.id != connection_id); + state.connections.delete_connection(&connection_id).await; + persist_connections(&state).await?; + Ok(ApiResponse::success(true)) +} - state - .storage - .save(&storage) - .map_err(|e| format!("Failed to save storage: {}", e))?; +/// The connection types the UI can offer, with their default ports. +#[tauri::command] +async fn list_connection_types() -> Result>, String> { + Ok(ApiResponse::success( + ConnectionType::ALL + .into_iter() + .map(|connection_type| ConnectionTypeInfo { + id: connection_type.as_str().to_string(), + default_port: connection_type.default_port(), + native_wire_protocol: connection_type.is_native_wire_protocol(), + }) + .collect(), + )) +} - Ok(ApiResponse::success(true)) +/// A connection type as offered in the connection dialog. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ConnectionTypeInfo { + id: String, + default_port: u16, + /// False for the HTTP-backed protocols, whose server handlers still return + /// canned rows; the dialog warns instead of implying they return data. + native_wire_protocol: bool, } -/// Query Execution Commands +// ============================ Queries ============================ #[tauri::command] async fn execute_query( request: QueryRequest, state: State<'_, AppState>, ) -> Result, String> { - let connection_manager = state.connections.read().await; let mut executor = state.query_executor.write().await; - match executor.execute_query(request, &connection_manager).await { - Ok(result) => Ok(ApiResponse::success(result)), - Err(e) => Ok(ApiResponse::error(e.to_string())), - } + respond!(executor.execute(request, &state.connections).await) +} + +#[tauri::command] +async fn explain_query( + request: QueryRequest, + analyze: Option, + state: State<'_, AppState>, +) -> Result, String> { + let mut executor = state.query_executor.write().await; + respond!( + executor + .explain(request, analyze.unwrap_or(false), &state.connections) + .await + ) } #[tauri::command] @@ -203,37 +230,96 @@ async fn get_query_history( connection_id: String, limit: Option, state: State<'_, AppState>, -) -> Result>, String> { +) -> Result>, String> { let executor = state.query_executor.read().await; - let history = executor - .get_history(&connection_id, limit.unwrap_or(50)) - .await - .map_err(|e| e.to_string())?; - Ok(ApiResponse::success(history)) + Ok(ApiResponse::success( + executor.history(&connection_id, limit.unwrap_or(50)), + )) +} + +// ============================ Cluster ============================ + +/// Resolve the cluster manager, or explain why there is none. +async fn with_cluster(state: &AppState, action: F) -> Result, String> +where + F: FnOnce(&ClusterManager) -> Fut, + Fut: std::future::Future>, +{ + let guard = state.cluster_root.read().await; + let Some(manager) = guard.as_ref() else { + return Ok(ApiResponse::error(cluster::ClusterError::RootNotSet)); + }; + respond!(action(manager).await) } #[tauri::command] -async fn explain_query( - request: QueryRequest, +async fn get_cluster_status(state: State<'_, AppState>) -> Result, String> { + with_cluster(&state, |manager| async move { manager.status().await }).await +} + +/// Point the cluster panel at an Orbit-RS checkout. +#[tauri::command] +async fn set_cluster_root( + path: String, state: State<'_, AppState>, -) -> Result, String> { - let connection_manager = state.connections.read().await; - let mut executor = state.query_executor.write().await; - match executor.explain_query(request, &connection_manager).await { - Ok(result) => Ok(ApiResponse::success(result)), - Err(e) => Ok(ApiResponse::error(e.to_string())), - } +) -> Result, String> { + let manager = match ClusterManager::new(&path) { + Ok(manager) => manager, + Err(e) => return Ok(ApiResponse::error(e)), + }; + + let status = match manager.status().await { + Ok(status) => status, + Err(e) => return Ok(ApiResponse::error(e)), + }; + + *state.cluster_root.write().await = Some(manager); + Ok(ApiResponse::success(status)) +} + +#[tauri::command] +async fn start_cluster( + size: u8, + state: State<'_, AppState>, +) -> Result, String> { + with_cluster(&state, |manager| async move { + manager.start(size).await.map(|()| true) + }) + .await +} + +#[tauri::command] +async fn stop_cluster(state: State<'_, AppState>) -> Result, String> { + with_cluster(&state, |manager| async move { + manager.stop().await.map(|()| true) + }) + .await +} + +#[tauri::command] +async fn get_cluster_log( + node_id: Option, + lines: Option, + state: State<'_, AppState>, +) -> Result, String> { + let lines = lines.unwrap_or(200); + with_cluster(&state, |manager| async move { + match node_id { + Some(node_id) => manager.node_log(&node_id, lines), + None => manager.control_log(lines), + } + }) + .await } -/// ML Model Management Commands +// ============================ ML models ============================ #[tauri::command] async fn list_ml_functions( state: State<'_, AppState>, ) -> Result>, String> { let manager = state.model_manager.read().await; - let functions = manager.get_ml_functions().await?; - Ok(ApiResponse::success(functions)) + respond!(manager.get_ml_functions().await) } #[tauri::command] @@ -242,10 +328,7 @@ async fn list_models( state: State<'_, AppState>, ) -> Result>, String> { let manager = state.model_manager.read().await; - match manager.get_models(&connection_id).await { - Ok(models) => Ok(ApiResponse::success(models)), - Err(e) => Ok(ApiResponse::error(e.to_string())), - } + respond!(manager.get_models(&connection_id).await) } #[tauri::command] @@ -255,10 +338,7 @@ async fn get_model_info( state: State<'_, AppState>, ) -> Result, String> { let manager = state.model_manager.read().await; - match manager.get_model_info(&connection_id, &model_name).await { - Ok(model_info) => Ok(ApiResponse::success(model_info)), - Err(e) => Ok(ApiResponse::error(e.to_string())), - } + respond!(manager.get_model_info(&connection_id, &model_name).await) } #[tauri::command] @@ -268,34 +348,25 @@ async fn delete_model( state: State<'_, AppState>, ) -> Result, String> { let manager = state.model_manager.read().await; - match manager.delete_model(&connection_id, &model_name).await { - Ok(_) => Ok(ApiResponse::success(true)), - Err(e) => Ok(ApiResponse::error(e.to_string())), - } + respond!(manager + .delete_model(&connection_id, &model_name) + .await + .map(|()| true)) } -/// System Info Commands +// ============================ System ============================ #[tauri::command] async fn get_system_info() -> Result>, String> { - let mut info = HashMap::new(); - - info.insert( - "version".to_string(), - serde_json::Value::String("0.1.0".to_string()), - ); - info.insert( - "os".to_string(), - serde_json::Value::String(std::env::consts::OS.to_string()), - ); - info.insert( - "arch".to_string(), - serde_json::Value::String(std::env::consts::ARCH.to_string()), - ); - info.insert( - "timestamp".to_string(), - serde_json::Value::String(chrono::Utc::now().to_rfc3339()), - ); + let info = [ + ("version", env!("CARGO_PKG_VERSION").to_string()), + ("os", std::env::consts::OS.to_string()), + ("arch", std::env::consts::ARCH.to_string()), + ("timestamp", chrono::Utc::now().to_rfc3339()), + ] + .into_iter() + .map(|(key, value)| (key.to_string(), serde_json::Value::String(value))) + .collect(); Ok(ApiResponse::success(info)) } @@ -308,35 +379,44 @@ async fn save_settings( let mut storage = state .storage .load() - .map_err(|e| format!("Failed to load storage: {}", e))?; + .map_err(|e| format!("Failed to load storage: {e}"))?; - // Update settings from provided values - if let Some(theme) = settings.get("theme").and_then(|v| v.as_str()) { - storage.settings.theme = theme.to_string(); + let current = &mut storage.settings; + if let Some(value) = settings.get("theme").and_then(|v| v.as_str()) { + current.theme = value.to_string(); } - if let Some(auto_save) = settings.get("auto_save").and_then(|v| v.as_bool()) { - storage.settings.auto_save = auto_save; + if let Some(value) = settings.get("auto_save").and_then(serde_json::Value::as_bool) { + current.auto_save = value; } - if let Some(timeout) = settings.get("query_timeout").and_then(|v| v.as_u64()) { - storage.settings.query_timeout = timeout; + if let Some(value) = settings.get("query_timeout").and_then(serde_json::Value::as_u64) { + current.query_timeout = value; } - if let Some(font_size) = settings.get("editor_font_size").and_then(|v| v.as_u64()) { - storage.settings.editor_font_size = font_size as u16; + if let Some(value) = settings + .get("editor_font_size") + .and_then(serde_json::Value::as_u64) + { + current.editor_font_size = value as u16; } - if let Some(editor_theme) = settings.get("editor_theme").and_then(|v| v.as_str()) { - storage.settings.editor_theme = editor_theme.to_string(); + if let Some(value) = settings.get("editor_theme").and_then(|v| v.as_str()) { + current.editor_theme = value.to_string(); } - if let Some(show_line_numbers) = settings.get("show_line_numbers").and_then(|v| v.as_bool()) { - storage.settings.show_line_numbers = show_line_numbers; + if let Some(value) = settings + .get("show_line_numbers") + .and_then(serde_json::Value::as_bool) + { + current.show_line_numbers = value; } - if let Some(word_wrap) = settings.get("word_wrap").and_then(|v| v.as_bool()) { - storage.settings.word_wrap = word_wrap; + if let Some(value) = settings.get("word_wrap").and_then(serde_json::Value::as_bool) { + current.word_wrap = value; + } + if let Some(value) = settings.get("cluster_root").and_then(|v| v.as_str()) { + current.cluster_root = Some(value.to_string()); } state .storage .save(&storage) - .map_err(|e| format!("Failed to save settings: {}", e))?; + .map_err(|e| format!("Failed to save settings: {e}"))?; Ok(ApiResponse::success(true)) } @@ -348,117 +428,187 @@ async fn load_settings( let storage = state .storage .load() - .map_err(|e| format!("Failed to load storage: {}", e))?; - - let mut settings = HashMap::new(); - settings.insert( - "theme".to_string(), - serde_json::Value::String(storage.settings.theme), - ); - settings.insert( - "auto_save".to_string(), - serde_json::Value::Bool(storage.settings.auto_save), - ); - settings.insert( - "query_timeout".to_string(), - serde_json::Value::Number(storage.settings.query_timeout.into()), - ); - settings.insert( - "editor_font_size".to_string(), - serde_json::Value::Number(storage.settings.editor_font_size.into()), - ); - settings.insert( - "editor_theme".to_string(), - serde_json::Value::String(storage.settings.editor_theme), - ); - settings.insert( - "show_line_numbers".to_string(), - serde_json::Value::Bool(storage.settings.show_line_numbers), - ); - settings.insert( - "word_wrap".to_string(), - serde_json::Value::Bool(storage.settings.word_wrap), - ); - - Ok(ApiResponse::success(settings)) + .map_err(|e| format!("Failed to load storage: {e}"))?; + + let settings = storage.settings; + let map = [ + ("theme", serde_json::Value::String(settings.theme)), + ("auto_save", serde_json::Value::Bool(settings.auto_save)), + ( + "query_timeout", + serde_json::Value::Number(settings.query_timeout.into()), + ), + ( + "editor_font_size", + serde_json::Value::Number(settings.editor_font_size.into()), + ), + ( + "editor_theme", + serde_json::Value::String(settings.editor_theme), + ), + ( + "show_line_numbers", + serde_json::Value::Bool(settings.show_line_numbers), + ), + ("word_wrap", serde_json::Value::Bool(settings.word_wrap)), + ( + "cluster_root", + settings + .cluster_root + .map(serde_json::Value::String) + .unwrap_or(serde_json::Value::Null), + ), + ] + .into_iter() + .map(|(key, value)| (key.to_string(), value)) + .collect(); + + Ok(ApiResponse::success(map)) } -/// Menu event handlers #[tauri::command] async fn show_about_dialog(app: tauri::AppHandle) { - let window = app.get_window("main").unwrap(); + let Some(window) = app.get_window("main") else { + tracing::warn!("about dialog requested but the main window is gone"); + return; + }; tauri::api::dialog::message( Some(&window), "About Orbit Desktop", - "Orbit Desktop v0.1.0\n\nA powerful desktop interface for Orbit-RS database management with support for PostgreSQL, OrbitQL, and Redis commands.\n\nBuilt with ❤️ using Tauri and React." + format!( + "Orbit Desktop v{}\n\nA desktop client for Orbit-RS: connection management, \ + SQL and Redis execution, and local cluster lifecycle control.", + env!("CARGO_PKG_VERSION") + ), ); } -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - // Initialize tracing +// ============================ Startup ============================ + +/// Restore saved connections so they are queryable straight after launch. +/// +/// Descriptions are registered without contacting any server; the session opens +/// on first use. A connection whose stored form cannot be decoded is reported +/// and skipped rather than aborting startup. +async fn restore_connections( + storage: &StorageManager, + encryption: &EncryptionManager, + connections: &ConnectionManager, +) { + let stored = match storage.load() { + Ok(storage) => storage.connections, + Err(e) => { + tracing::error!("could not load saved connections: {e}"); + return; + } + }; + + let mut restored = 0usize; + for entry in &stored { + match entry.to_connection(encryption) { + Ok(connection) => { + connections.register(connection).await; + restored += 1; + } + Err(e) => tracing::warn!("skipping saved connection {}: {e}", entry.id), + } + } + + tracing::info!("restored {restored} of {} saved connections", stored.len()); +} + +/// Locate the Orbit-RS checkout to manage: the saved path if it still verifies, +/// otherwise a search upward from the working directory. +fn locate_cluster_root(saved: Option<&str>) -> Option { + if let Some(path) = saved { + match ClusterManager::new(path) { + Ok(manager) => return Some(manager), + Err(e) => tracing::warn!("saved cluster root is unusable: {e}"), + } + } + + std::env::current_dir() + .ok() + .and_then(|cwd| ClusterManager::discover(&cwd)) +} + +fn main() { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); - tracing::info!("Starting Orbit Desktop application"); + tracing::info!("starting Orbit Desktop"); let context = tauri::generate_context!(); - let config = context.config(); - - // Initialize storage and encryption - let storage = StorageManager::new(config).expect("Failed to initialize storage manager"); - let encryption = - EncryptionManager::new(config).expect("Failed to initialize encryption manager"); - - // Load connections from storage - let storage_data = storage.load().unwrap_or_default(); - let mut connection_manager = ConnectionManager::new(); - - // Load connections into manager - for stored_conn in &storage_data.connections { - if let Ok(conn) = stored_conn.to_connection(&encryption) { - // Connection will be created on-demand when needed - // For now, just store the metadata + + let storage = match StorageManager::new(context.config()) { + Ok(storage) => storage, + Err(e) => { + tracing::error!("cannot initialise storage: {e}"); + std::process::exit(1); + } + }; + let encryption = match EncryptionManager::new(context.config()) { + Ok(encryption) => encryption, + Err(e) => { + tracing::error!("cannot initialise encryption: {e}"); + std::process::exit(1); } + }; + + let saved_root = storage + .load() + .ok() + .and_then(|storage| storage.settings.cluster_root); + let cluster_root = locate_cluster_root(saved_root.as_deref()); + match &cluster_root { + Some(manager) => tracing::info!("managing cluster at {}", manager.root().display()), + None => tracing::info!("no Orbit-RS checkout found; set one in the cluster panel"), } + let connections = Arc::new(ConnectionManager::new()); + let app_state = AppState { - connections: RwLock::new(connection_manager), + connections: Arc::clone(&connections), query_executor: RwLock::new(QueryExecutor::new()), model_manager: RwLock::new(ModelManager::new()), storage, encryption, + cluster_root: RwLock::new(cluster_root), }; tauri::Builder::default() .manage(app_state) .menu(create_menu()) .on_menu_event(|event| match event.menu_item_id() { - "quit" => { - std::process::exit(0); - } + "quit" => std::process::exit(0), "about" => { let app = event.window().app_handle(); - tauri::async_runtime::spawn(async move { - show_about_dialog(app).await; - }); + tauri::async_runtime::spawn(show_about_dialog(app)); } _ => {} }) .invoke_handler(tauri::generate_handler![ - // Connection management + // Connections create_connection, test_connection, get_connections, + connect, disconnect, delete_connection, - // Query execution + list_connection_types, + // Queries execute_query, - get_query_history, explain_query, - // ML model management + get_query_history, + // Cluster + get_cluster_status, + set_cluster_root, + start_cluster, + stop_cluster, + get_cluster_log, + // ML models list_ml_functions, list_models, get_model_info, @@ -469,31 +619,32 @@ pub fn run() { load_settings, show_about_dialog, ]) - .setup(|app| { - tracing::info!("Application setup complete"); + .setup(move |app| { + let state = app.state::(); + let storage = state.storage.clone(); + let encryption = state.encryption.clone(); + let connections = Arc::clone(&connections); + + tauri::async_runtime::spawn(async move { + restore_connections(&storage, &encryption, &connections).await; + }); + Ok(()) }) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .run(context) + .expect("Tauri failed to start"); } fn create_menu() -> tauri::Menu { use tauri::{CustomMenuItem, Menu, MenuItem, Submenu}; - let quit = CustomMenuItem::new("quit", "Quit"); - let about = CustomMenuItem::new("about", "About"); - let app_menu = Submenu::new( "Orbit Desktop", Menu::new() - .add_item(about) + .add_item(CustomMenuItem::new("about", "About")) .add_native_item(MenuItem::Separator) - .add_item(quit), + .add_item(CustomMenuItem::new("quit", "Quit")), ); Menu::new().add_submenu(app_menu) } - -fn main() { - run(); -} diff --git a/orbit/desktop/src-tauri/src/queries.rs b/orbit/desktop/src-tauri/src/queries.rs index 183a92dc..f7b72e13 100644 --- a/orbit/desktop/src-tauri/src/queries.rs +++ b/orbit/desktop/src-tauri/src/queries.rs @@ -1,810 +1,496 @@ +//! Statement execution and history. +//! +//! The executor is deliberately thin: it resolves a connection to a live +//! session, applies the caller's timeout, times the call and records what +//! happened. Everything protocol-specific — how a statement is sent and how a +//! response becomes rows — lives with the session in [`crate::connections`]. + +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::time::Instant; -use tracing::{error, info, warn}; +use std::collections::{HashMap, VecDeque}; +use std::time::{Duration, Instant}; -use crate::connections::{Connection, ConnectionError, ConnectionManager, ConnectionType}; +use crate::connections::{ConnectionError, ConnectionManager}; -#[derive(Debug, Serialize, Deserialize, Clone)] +/// Upper bound on retained history entries, so a long session cannot grow the +/// executor without limit. +const MAX_HISTORY_ENTRIES: usize = 500; + +/// Timeout applied when the caller does not specify one. +const DEFAULT_STATEMENT_TIMEOUT: Duration = Duration::from_secs(30); + +/// One statement to run. +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct QueryRequest { pub connection_id: String, pub query: String, - pub query_type: QueryType, - pub timeout: u64, + /// Statement timeout in milliseconds. Falls back to 30s when absent. + #[serde(default)] + pub timeout_ms: Option, } -#[derive(Debug, Serialize, Deserialize)] -pub struct QueryResult { - pub success: bool, - pub data: Option, - pub error: Option, - pub execution_time: f64, - pub rows_affected: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct QueryResultData { - pub columns: Vec, - pub rows: Vec>, +impl QueryRequest { + fn timeout(&self) -> Duration { + self.timeout_ms + .filter(|ms| *ms > 0) + .map(Duration::from_millis) + .unwrap_or(DEFAULT_STATEMENT_TIMEOUT) + } } -#[derive(Debug, Serialize, Deserialize)] +/// A result set column. +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ColumnInfo { pub name: String, + /// The server's own type name, not a guess derived from a Debug rendering. #[serde(rename = "type")] pub column_type: String, } -#[derive(Debug, Serialize, Deserialize, Clone)] -pub enum QueryType { - SQL, - OrbitQL, - Redis, - MySQL, - CQL, - Cypher, - AQL, -} - -impl Default for QueryResult { - fn default() -> Self { - QueryResult { - success: false, - data: None, - error: None, - execution_time: 0.0, - rows_affected: None, +impl ColumnInfo { + pub fn new(name: impl Into, column_type: impl Into) -> Self { + Self { + name: name.into(), + column_type: column_type.into(), } } } -// Query executor for handling database queries -#[derive(Default)] -pub struct QueryExecutor { - history: Vec, +/// What a statement did. +/// +/// Kept distinct because "10 rows came back" and "10 rows were modified" are +/// different facts, and a single `rows_affected` field cannot tell them apart. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum StatementOutcome { + /// A result set of `rows` rows was returned. + Returned { rows: u64 }, + /// `rows` rows were inserted, updated or deleted. + Affected { rows: u64 }, + /// Completed with no row count of either kind (DDL, `SET`, Redis replies). + Completed, } -impl QueryExecutor { - pub fn new() -> Self { - QueryExecutor { - history: Vec::new(), +/// Rows and columns produced by one statement. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryPayload { + pub columns: Vec, + pub rows: Vec>, + pub outcome: StatementOutcome, +} + +impl QueryPayload { + /// A statement that returned a result set. + pub fn returned( + columns: Vec, + rows: Vec>, + ) -> Self { + let count = rows.len() as u64; + Self { + columns, + rows, + outcome: StatementOutcome::Returned { rows: count }, } } - pub async fn execute_query( - &mut self, - request: QueryRequest, - connection_manager: &ConnectionManager, - ) -> Result { - let start_time = Instant::now(); - - // Get connection info - let connection = connection_manager - .get_connection(&request.connection_id) - .await - .ok_or_else(|| "Connection not found".to_string())?; - - let result = match connection.info.connection_type { - ConnectionType::PostgreSQL => { - self.execute_postgresql_query(&request, &connection).await - } - ConnectionType::OrbitQL => self.execute_orbitql_query(&request, &connection).await, - ConnectionType::Redis => self.execute_redis_query(&request, &connection).await, - ConnectionType::MySQL => self.execute_mysql_query(&request, &connection).await, - ConnectionType::CQL => self.execute_cql_query(&request, &connection).await, - ConnectionType::Cypher => self.execute_cypher_query(&request, &connection).await, - ConnectionType::AQL => self.execute_aql_query(&request, &connection).await, - ConnectionType::FlightSQL => self.execute_flightsql_query(&request, &connection).await, - ConnectionType::OrbitWire => self.execute_orbitwire_query(&request, &connection).await, - }; - - let execution_time = start_time.elapsed().as_secs_f64() * 1000.0; // Convert to milliseconds - - // Store in history - self.history.push(request.clone()); - if self.history.len() > 1000 { - self.history.remove(0); + /// A statement that modified rows without returning any. + pub fn affected(rows: u64) -> Self { + Self { + columns: Vec::new(), + rows: Vec::new(), + outcome: StatementOutcome::Affected { rows }, } + } - match result { - Ok(mut res) => { - res.execution_time = execution_time; - Ok(res) - } - Err(e) => Ok(QueryResult { - success: false, - data: None, - error: Some(e), - execution_time, - rows_affected: None, - }), + /// A single scalar reply rendered as a one-cell grid. + pub fn single_value( + column: &str, + column_type: &str, + value: serde_json::Value, + ) -> Self { + let row = std::iter::once((column.to_string(), value)).collect(); + Self { + columns: vec![ColumnInfo::new(column, column_type)], + rows: vec![row], + outcome: StatementOutcome::Completed, } } - async fn execute_postgresql_query( - &self, - request: &QueryRequest, - connection: &crate::connections::Connection, - ) -> Result { - use crate::connections::PostgreSQLConnection; - - // Create connection - let pg_conn = PostgreSQLConnection::new(&connection.info) - .await - .map_err(|e| format!("Failed to connect: {}", e))?; - - // Execute query - let rows = pg_conn - .execute_query(&request.query) - .await - .map_err(|e| format!("Query execution failed: {}", e))?; - - if rows.is_empty() { - return Ok(QueryResult { - success: true, - data: Some(QueryResultData { - columns: vec![], - rows: vec![], - }), - error: None, - execution_time: 0.0, - rows_affected: Some(0), - }); + /// Shape a JSON response from an HTTP-backed protocol. + /// + /// Handles the two layouts Orbit's REST surface and the ArangoDB/Neo4j + /// compatible endpoints use: `data.rows` as positional arrays alongside + /// `data.columns`, or a plain array of objects under `data`/`result`. + pub fn from_json(payload: &serde_json::Value) -> Self { + let body = payload + .get("data") + .or_else(|| payload.get("result")) + .unwrap_or(payload); + + if let Some(rows) = body.as_array() { + return Self::from_object_array(rows); } - // Extract column information from first row - let columns: Vec = rows[0] - .columns() + let columns: Vec = body + .get("columns") + .and_then(|c| c.as_array()) + .map(|columns| { + columns + .iter() + .map(|column| { + let name = column + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or_default(); + let ty = column + .get("type") + .or_else(|| column.get("data_type")) + .and_then(|t| t.as_str()) + .unwrap_or("unknown"); + ColumnInfo::new(name, ty) + }) + .collect() + }) + .unwrap_or_default(); + + let Some(rows) = body.get("rows").and_then(|r| r.as_array()) else { + return Self { + columns, + rows: Vec::new(), + outcome: StatementOutcome::Completed, + }; + }; + + // Positional rows need the column list to be named; objects carry their + // own keys and are taken as-is. + let shaped: Vec> = rows .iter() - .map(|col| ColumnInfo { - name: col.name().to_string(), - column_type: format!("{:?}", col.type_()), + .map(|row| match row { + serde_json::Value::Array(values) => values + .iter() + .enumerate() + .map(|(index, value)| { + let name = columns + .get(index) + .map(|c| c.name.clone()) + .unwrap_or_else(|| format!("column_{index}")); + (name, value.clone()) + }) + .collect(), + serde_json::Value::Object(fields) => { + fields.iter().map(|(k, v)| (k.clone(), v.clone())).collect() + } + other => std::iter::once(("result".to_string(), other.clone())).collect(), }) .collect(); - // Convert rows to JSON - let mut result_rows = Vec::new(); + Self::returned(columns, shaped) + } + + fn from_object_array(rows: &[serde_json::Value]) -> Self { + // Column order follows first appearance across all rows, so a key that + // only shows up in a later row still gets a column. + let mut columns: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for row in rows { - let mut row_map = HashMap::new(); - for col in &columns { - // Try to extract value based on type - let value = if col.column_type.contains("Int4") || col.column_type.contains("Int8") - { - // Try i64 first, then i32 - row.try_get::<_, i64>(col.name.as_str()) - .map(|v| serde_json::Value::Number(v.into())) - .or_else(|_| { - row.try_get::<_, i32>(col.name.as_str()) - .map(|v| serde_json::Value::Number(v.into())) - }) - .unwrap_or(serde_json::Value::Null) - } else if col.column_type.contains("Float4") || col.column_type.contains("Float8") { - row.try_get::<_, f64>(col.name.as_str()) - .map(|v| { - serde_json::Number::from_f64(v) - .map(serde_json::Value::Number) - .unwrap_or(serde_json::Value::Null) - }) - .unwrap_or(serde_json::Value::Null) - } else if col.column_type.contains("Bool") { - row.try_get::<_, bool>(col.name.as_str()) - .map(serde_json::Value::Bool) - .unwrap_or(serde_json::Value::Null) - } else { - // Default to string - row.try_get::<_, String>(col.name.as_str()) - .map(serde_json::Value::String) - .unwrap_or(serde_json::Value::Null) - }; - row_map.insert(col.name.clone(), value); + if let Some(fields) = row.as_object() { + for key in fields.keys() { + if seen.insert(key.clone()) { + columns.push(ColumnInfo::new(key.clone(), "unknown")); + } + } } - result_rows.push(row_map); } - let rows_count = result_rows.len(); - Ok(QueryResult { - success: true, - data: Some(QueryResultData { - columns, - rows: result_rows, - }), - error: None, - execution_time: 0.0, - rows_affected: Some(rows_count as u64), - }) - } - - async fn execute_orbitql_query( - &self, - request: &QueryRequest, - connection: &crate::connections::Connection, - ) -> Result { - use crate::connections::OrbitQLConnection; - - // Create connection - let orbit_conn = OrbitQLConnection::new(&connection.info) - .await - .map_err(|e| format!("Failed to connect: {}", e))?; - - // Execute query - let result = orbit_conn - .execute_orbitql(&request.query) - .await - .map_err(|e| format!("Query execution failed: {}", e))?; - - // Parse result - if let Some(data) = result.get("data") { - if let Some(rows) = data.as_array() { - let columns = if let Some(first_row) = rows.first().and_then(|r| r.as_object()) { - first_row - .keys() - .map(|k| ColumnInfo { - name: k.clone(), - column_type: "unknown".to_string(), - }) - .collect() - } else { - vec![] - }; - - let result_rows: Vec> = rows - .iter() - .filter_map(|r| r.as_object().map(|o| o.clone().into_iter().collect())) - .collect(); - - Ok(QueryResult { - success: true, - data: Some(QueryResultData { - columns, - rows: result_rows, - }), - error: None, - execution_time: 0.0, - rows_affected: Some(rows.len() as u64), - }) - } else { - Ok(QueryResult { - success: true, - data: Some(QueryResultData { - columns: vec![], - rows: vec![], - }), - error: None, - execution_time: 0.0, - rows_affected: Some(0), - }) - } - } else { - Err("Invalid response format".to_string()) + if columns.is_empty() { + columns.push(ColumnInfo::new("result", "unknown")); } + + let shaped = rows + .iter() + .map(|row| match row.as_object() { + Some(fields) => fields.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + None => std::iter::once(("result".to_string(), row.clone())).collect(), + }) + .collect(); + + Self::returned(columns, shaped) } +} + +/// The outcome of one execution, as sent to the UI. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryResult { + pub success: bool, + pub data: Option, + pub error: Option, + /// Wall-clock time for the statement, in milliseconds. + pub execution_time_ms: f64, + /// Set when the result needs a caveat the grid alone cannot convey. + pub notice: Option, +} - async fn execute_redis_query( - &self, - request: &QueryRequest, - connection: &crate::connections::Connection, - ) -> Result { - use crate::connections::RedisConnection; - - // Create connection - let redis_conn = RedisConnection::new(&connection.info) - .await - .map_err(|e| format!("Failed to connect: {}", e))?; - - // Parse Redis command - let parts: Vec<&str> = request.query.trim().split_whitespace().collect(); - if parts.is_empty() { - return Err("Empty Redis command".to_string()); +impl QueryResult { + fn failure(error: String, elapsed: Duration) -> Self { + Self { + success: false, + data: None, + error: Some(error), + execution_time_ms: elapsed.as_secs_f64() * 1000.0, + notice: None, } + } +} - let cmd = parts[0].to_uppercase(); - let args: Vec<&str> = parts[1..].to_vec(); - - // Execute command - let value = redis_conn - .execute_redis_command(&cmd, &args) - .await - .map_err(|e| format!("Redis command failed: {}", e))?; - - // Convert Redis value to JSON - let json_value = match value { - redis::Value::Nil => serde_json::Value::Null, - redis::Value::Int(i) => serde_json::Value::Number(i.into()), - redis::Value::Data(data) => String::from_utf8(data) - .map(serde_json::Value::String) - .unwrap_or(serde_json::Value::Null), - redis::Value::Bulk(arr) => serde_json::Value::Array( - arr.into_iter() - .map(|v| match v { - redis::Value::Int(i) => serde_json::Value::Number(i.into()), - redis::Value::Data(d) => String::from_utf8(d) - .map(serde_json::Value::String) - .unwrap_or(serde_json::Value::Null), - _ => serde_json::Value::String(format!("{:?}", v)), - }) - .collect(), - ), - redis::Value::Status(s) => serde_json::Value::String(s), - redis::Value::Okay => serde_json::Value::String("OK".to_string()), - }; +/// One past execution, retained for the history panel. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryHistoryEntry { + pub id: String, + pub connection_id: String, + pub query: String, + pub executed_at: DateTime, + pub execution_time_ms: f64, + pub success: bool, + pub error: Option, + pub outcome: Option, +} - let mut row = HashMap::new(); - row.insert("result".to_string(), json_value); +/// Runs statements and remembers what was run. +#[derive(Default)] +pub struct QueryExecutor { + history: VecDeque, +} - Ok(QueryResult { - success: true, - data: Some(QueryResultData { - columns: vec![ColumnInfo { - name: "result".to_string(), - column_type: "redis_value".to_string(), - }], - rows: vec![row], - }), - error: None, - execution_time: 0.0, - rows_affected: Some(1), - }) +impl QueryExecutor { + #[must_use] + pub fn new() -> Self { + Self::default() } - async fn execute_mysql_query( - &self, - request: &QueryRequest, - connection: &Connection, - ) -> Result { - use crate::connections::MySQLConnection; - - // Create connection - let mysql_conn = MySQLConnection::new(&connection.info) - .await - .map_err(|e| format!("Failed to connect: {}", e))?; - - // Execute query - let rows = mysql_conn - .execute_query(&request.query) - .await - .map_err(|e| format!("Query execution failed: {}", e))?; - - if rows.is_empty() { - return Ok(QueryResult { + /// Execute one statement against the request's connection. + /// + /// A failing statement is reported as an unsuccessful [`QueryResult`], not + /// an `Err`: the UI needs the timing and the message either way. `Err` is + /// reserved for not being able to reach the connection at all. + /// + /// # Errors + /// Returns the connection failure when no session could be opened. + pub async fn execute( + &mut self, + request: QueryRequest, + connections: &ConnectionManager, + ) -> Result { + let session = connections.session(&request.connection_id).await?; + let connection_type = session.lock().await.connection_type(); + let timeout = request.timeout(); + + let started = Instant::now(); + let outcome = { + let mut guard = session.lock().await; + tokio::time::timeout(timeout, guard.execute(&request.query)).await + }; + let elapsed = started.elapsed(); + + connections.record_use(&request.connection_id).await; + + let result = match outcome { + Ok(Ok(payload)) => QueryResult { success: true, - data: Some(QueryResultData { - columns: vec![], - rows: vec![], + execution_time_ms: elapsed.as_secs_f64() * 1000.0, + // Results that came from a server endpoint known to answer with + // canned rows are labelled, so example data is never mistaken + // for the contents of the database. + notice: (!connection_type.is_native_wire_protocol()).then(|| { + format!( + "{connection_type} runs over the REST API, whose SQL and catalog \ + handlers in orbit-server still return fixed example rows. Treat these \ + results as a protocol check, not as data." + ) }), + data: Some(payload), error: None, - execution_time: 0.0, - rows_affected: Some(0), - }); - } + }, + Ok(Err(e)) => QueryResult::failure(e.to_string(), elapsed), + Err(_) => QueryResult::failure( + format!("Statement timed out after {timeout:?}"), + elapsed, + ), + }; - // Extract column information and convert rows - // MySQL rows need to be converted to JSON format - let mut result_rows = Vec::new(); - let mut columns = Vec::new(); - - // Get column info from first row - if let Some(first_row) = rows.first() { - // MySQL rows have columns accessible via index - // We'll need to extract column names from the row structure - for i in 0..first_row.len() { - columns.push(ColumnInfo { - name: format!("column_{}", i), - column_type: "unknown".to_string(), - }); - } - } + self.record(&request, &result); + Ok(result) + } - // Convert rows to JSON - for row in rows { - let mut row_map = HashMap::new(); - for (i, col) in columns.iter().enumerate() { - // Try to extract value as string first - let value = if let Some(val) = row.get::(i) { - serde_json::Value::String(val) - } else if let Some(val) = row.get::(i) { - serde_json::Value::Number(val.into()) - } else if let Some(val) = row.get::(i) { - serde_json::Number::from_f64(val) - .map(serde_json::Value::Number) - .unwrap_or(serde_json::Value::Null) - } else if let Some(val) = row.get::(i) { - serde_json::Value::Bool(val) - } else { - serde_json::Value::Null - }; - row_map.insert(col.name.clone(), value); - } - result_rows.push(row_map); + /// Ask the server for a plan without running the statement. + /// + /// Plain `EXPLAIN` — not `EXPLAIN ANALYZE`, which would execute the + /// statement and so could delete rows the user only wanted to inspect. + /// `analyze` opts into that behaviour explicitly. + /// + /// # Errors + /// Returns the connection failure when no session could be opened. + pub async fn explain( + &mut self, + request: QueryRequest, + analyze: bool, + connections: &ConnectionManager, + ) -> Result { + use crate::connections::ConnectionType; + + let session = connections.session(&request.connection_id).await?; + let connection_type = session.lock().await.connection_type(); + + if !matches!( + connection_type, + ConnectionType::PostgreSQL | ConnectionType::MySQL + ) { + return Ok(QueryResult::failure( + format!("EXPLAIN is not supported for {connection_type} connections"), + Duration::ZERO, + )); } - let rows_count = result_rows.len(); - Ok(QueryResult { - success: true, - data: Some(QueryResultData { - columns, - rows: result_rows, - }), - error: None, - execution_time: 0.0, - rows_affected: Some(rows_count as u64), - }) + let prefix = if analyze { "EXPLAIN ANALYZE" } else { "EXPLAIN" }; + self.execute( + QueryRequest { + query: format!("{prefix} {}", request.query), + ..request + }, + connections, + ) + .await } - async fn execute_cql_query( - &self, - request: &QueryRequest, - connection: &Connection, - ) -> Result { - use crate::connections::CQLConnection; - - // Create connection - let cql_conn = CQLConnection::new(&connection.info) - .await - .map_err(|e| format!("Failed to connect: {}", e))?; - - // Execute query - let result = cql_conn - .execute_cql(&request.query) - .await - .map_err(|e| format!("Query execution failed: {}", e))?; - - // Parse CQL result - if let Some(data) = result.get("data") { - if let Some(rows) = data.as_array() { - let columns = if let Some(first_row) = rows.first().and_then(|r| r.as_object()) { - first_row - .keys() - .map(|k| ColumnInfo { - name: k.clone(), - column_type: "unknown".to_string(), - }) - .collect() - } else { - vec![] - }; - - let result_rows: Vec> = rows - .iter() - .filter_map(|r| r.as_object().map(|o| o.clone().into_iter().collect())) - .collect(); - - Ok(QueryResult { - success: true, - data: Some(QueryResultData { - columns, - rows: result_rows, - }), - error: None, - execution_time: 0.0, - rows_affected: Some(rows.len() as u64), - }) - } else { - Ok(QueryResult { - success: true, - data: Some(QueryResultData { - columns: vec![], - rows: vec![], - }), - error: None, - execution_time: 0.0, - rows_affected: Some(0), - }) - } - } else { - Err("Invalid CQL response format".to_string()) - } + /// Most recent entries for a connection, newest first. + pub fn history(&self, connection_id: &str, limit: usize) -> Vec { + self.history + .iter() + .rev() + .filter(|entry| entry.connection_id == connection_id) + .take(limit) + .cloned() + .collect() } - async fn execute_cypher_query( - &self, - request: &QueryRequest, - connection: &Connection, - ) -> Result { - use crate::connections::CypherConnection; - - // Create connection - let cypher_conn = CypherConnection::new(&connection.info) - .await - .map_err(|e| format!("Failed to connect: {}", e))?; - - // Execute query - let result = cypher_conn - .execute_cypher(&request.query) - .await - .map_err(|e| format!("Query execution failed: {}", e))?; - - // Parse Cypher result - if let Some(results) = result.get("results").and_then(|r| r.as_array()) { - if let Some(first_result) = results.first() { - if let Some(data) = first_result.get("data").and_then(|d| d.as_array()) { - let mut result_rows = Vec::new(); - let mut columns = Vec::new(); - - for row_data in data { - if let Some(row) = row_data.get("row").and_then(|r| r.as_array()) { - let mut row_map = HashMap::new(); - for (i, value) in row.iter().enumerate() { - let col_name = format!("column_{}", i); - if !columns.iter().any(|c: &ColumnInfo| c.name == col_name) { - columns.push(ColumnInfo { - name: col_name.clone(), - column_type: "unknown".to_string(), - }); - } - row_map.insert(col_name, value.clone()); - } - result_rows.push(row_map); - } - } - - let rows_count = result_rows.len(); - Ok(QueryResult { - success: true, - data: Some(QueryResultData { - columns, - rows: result_rows, - }), - error: None, - execution_time: 0.0, - rows_affected: Some(rows_count as u64), - }) - } else { - Ok(QueryResult { - success: true, - data: Some(QueryResultData { - columns: vec![], - rows: vec![], - }), - error: None, - execution_time: 0.0, - rows_affected: Some(0), - }) - } - } else { - Ok(QueryResult { - success: true, - data: Some(QueryResultData { - columns: vec![], - rows: vec![], - }), - error: None, - execution_time: 0.0, - rows_affected: Some(0), - }) - } - } else { - Err("Invalid Cypher response format".to_string()) + fn record(&mut self, request: &QueryRequest, result: &QueryResult) { + if self.history.len() >= MAX_HISTORY_ENTRIES { + self.history.pop_front(); } + + self.history.push_back(QueryHistoryEntry { + id: uuid::Uuid::new_v4().to_string(), + connection_id: request.connection_id.clone(), + query: request.query.clone(), + executed_at: Utc::now(), + execution_time_ms: result.execution_time_ms, + success: result.success, + error: result.error.clone(), + outcome: result.data.as_ref().map(|d| d.outcome.clone()), + }); } +} - async fn execute_aql_query( - &self, - request: &QueryRequest, - connection: &Connection, - ) -> Result { - use crate::connections::AQLConnection; - - // Create connection - let aql_conn = AQLConnection::new(&connection.info) - .await - .map_err(|e| format!("Failed to connect: {}", e))?; - - // Execute query - let result = aql_conn - .execute_aql(&request.query) - .await - .map_err(|e| format!("Query execution failed: {}", e))?; - - // Parse AQL result - if let Some(result_array) = result.get("result").and_then(|r| r.as_array()) { - let mut result_rows = Vec::new(); - let mut columns = Vec::new(); - - for (idx, row_value) in result_array.iter().enumerate() { - if let Some(row_obj) = row_value.as_object() { - let mut row_map = HashMap::new(); - for (key, value) in row_obj { - if !columns.iter().any(|c: &ColumnInfo| &c.name == key) { - columns.push(ColumnInfo { - name: key.clone(), - column_type: "unknown".to_string(), - }); - } - row_map.insert(key.clone(), value.clone()); - } - result_rows.push(row_map); - } else { - // Single value result - let col_name = "result".to_string(); - if idx == 0 && columns.is_empty() { - columns.push(ColumnInfo { - name: col_name.clone(), - column_type: "unknown".to_string(), - }); - } - let mut row_map = HashMap::new(); - row_map.insert(col_name, row_value.clone()); - result_rows.push(row_map); - } - } +#[cfg(test)] +mod tests { + use super::*; - let rows_count = result_rows.len(); - Ok(QueryResult { - success: true, - data: Some(QueryResultData { - columns, - rows: result_rows, - }), - error: None, - execution_time: 0.0, - rows_affected: Some(rows_count as u64), - }) - } else { - Ok(QueryResult { - success: true, - data: Some(QueryResultData { - columns: vec![], - rows: vec![], - }), - error: None, - execution_time: 0.0, - rows_affected: Some(0), - }) + fn request(connection_id: &str, query: &str) -> QueryRequest { + QueryRequest { + connection_id: connection_id.to_string(), + query: query.to_string(), + timeout_ms: None, } } - async fn execute_flightsql_query( - &self, - request: &QueryRequest, - connection: &Connection, - ) -> Result { - use crate::connections::FlightSQLConnection; - - // Create connection - let flight_conn = FlightSQLConnection::new(&connection.info) - .await - .map_err(|e| format!("Failed to connect: {}", e))?; - - // Execute query - let result = flight_conn - .execute_query(&request.query) - .await - .map_err(|e| format!("Query execution failed: {}", e))?; - - // Parse result - self.parse_json_result(result) + fn ok_result() -> QueryResult { + QueryResult { + success: true, + data: Some(QueryPayload::affected(3)), + error: None, + execution_time_ms: 1.0, + notice: None, + } } - async fn execute_orbitwire_query( - &self, - request: &QueryRequest, - connection: &Connection, - ) -> Result { - use crate::connections::OrbitWireConnection; - - // Create connection - let wire_conn = OrbitWireConnection::new(&connection.info) - .await - .map_err(|e| format!("Failed to connect: {}", e))?; - - // Execute query - let result = wire_conn - .execute_query(&request.query) - .await - .map_err(|e| format!("Query execution failed: {}", e))?; - - // Parse result - self.parse_json_result(result) - } + #[test] + fn timeout_falls_back_to_the_default_and_ignores_zero() { + assert_eq!(request("c", "SELECT 1").timeout(), DEFAULT_STATEMENT_TIMEOUT); - fn parse_json_result(&self, result: serde_json::Value) -> Result { - if let Some(data) = result.get("data") { - let mut columns = Vec::new(); - let mut result_rows = Vec::new(); - - // Extract columns - if let Some(cols) = data.get("columns").and_then(|c| c.as_array()) { - for col in cols { - if let Some(name) = col.get("name").and_then(|n| n.as_str()) { - columns.push(ColumnInfo { - name: name.to_string(), - column_type: col - .get("type") - .and_then(|t| t.as_str()) - .unwrap_or("unknown") - .to_string(), - }); - } - } - } + let mut req = request("c", "SELECT 1"); + req.timeout_ms = Some(0); + assert_eq!(req.timeout(), DEFAULT_STATEMENT_TIMEOUT); - // Extract rows - if let Some(rows) = data.get("rows").and_then(|r| r.as_array()) { - for row in rows { - if let Some(row_arr) = row.as_array() { - let mut row_map = std::collections::HashMap::new(); - for (i, value) in row_arr.iter().enumerate() { - let col_name = columns - .get(i) - .map(|c| c.name.clone()) - .unwrap_or_else(|| format!("column_{}", i)); - row_map.insert(col_name, value.clone()); - } - result_rows.push(row_map); - } - } - } + req.timeout_ms = Some(1500); + assert_eq!(req.timeout(), Duration::from_millis(1500)); + } - let rows_count = result_rows.len(); - Ok(QueryResult { - success: true, - data: Some(QueryResultData { - columns, - rows: result_rows, - }), - error: None, - execution_time: 0.0, - rows_affected: Some(rows_count as u64), - }) - } else if let Some(error) = result.get("error") { - Err(error.to_string()) - } else { - Ok(QueryResult { - success: true, - data: Some(QueryResultData { - columns: vec![], - rows: vec![], - }), - error: None, - execution_time: 0.0, - rows_affected: Some(0), - }) + #[test] + fn returned_and_affected_row_counts_stay_distinguishable() { + let returned = QueryPayload::returned( + vec![ColumnInfo::new("id", "int4")], + vec![std::iter::once(("id".to_string(), serde_json::json!(1))).collect()], + ); + assert_eq!(returned.outcome, StatementOutcome::Returned { rows: 1 }); + assert_eq!( + QueryPayload::affected(7).outcome, + StatementOutcome::Affected { rows: 7 } + ); + } + + #[test] + fn history_is_bounded_and_filtered_by_connection() { + let mut executor = QueryExecutor::new(); + for i in 0..(MAX_HISTORY_ENTRIES + 25) { + executor.record(&request("a", &format!("SELECT {i}")), &ok_result()); } + executor.record(&request("b", "SELECT 'other'"), &ok_result()); + + assert_eq!(executor.history.len(), MAX_HISTORY_ENTRIES); + assert_eq!(executor.history("b", 10).len(), 1); + + let newest = executor.history("a", 3); + assert_eq!(newest.len(), 3); + // Newest first: the last statement recorded for "a" leads. + assert_eq!( + newest[0].query, + format!("SELECT {}", MAX_HISTORY_ENTRIES + 24) + ); } - pub async fn explain_query( - &mut self, - request: QueryRequest, - connection_manager: &ConnectionManager, - ) -> Result { - // For PostgreSQL, prepend EXPLAIN ANALYZE - let connection = connection_manager - .get_connection(&request.connection_id) - .await - .ok_or_else(|| "Connection not found".to_string())?; - - match connection.info.connection_type { - ConnectionType::PostgreSQL | ConnectionType::MySQL => { - let explain_query = format!("EXPLAIN ANALYZE {}", request.query); - let mut explain_request = request; - explain_request.query = explain_query; - self.execute_query(explain_request, connection_manager) - .await + #[test] + fn positional_json_rows_are_named_from_the_column_list() { + let payload = QueryPayload::from_json(&serde_json::json!({ + "data": { + "columns": [ + { "name": "id", "data_type": "integer" }, + { "name": "label", "type": "varchar" } + ], + "rows": [[1, "one"], [2, "two"]] } - _ => Ok(QueryResult { - success: false, - data: None, - error: Some("EXPLAIN not supported for this connection type".to_string()), - execution_time: 0.0, - rows_affected: None, - }), - } + })); + + assert_eq!(payload.columns.len(), 2); + assert_eq!(payload.columns[0].column_type, "integer"); + assert_eq!(payload.outcome, StatementOutcome::Returned { rows: 2 }); + assert_eq!(payload.rows[1].get("label"), Some(&serde_json::json!("two"))); } - pub async fn get_history( - &self, - connection_id: &str, - limit: usize, - ) -> Result, String> { - let filtered: Vec = self - .history - .iter() - .filter(|q| q.connection_id == connection_id) - .rev() - .take(limit) - .cloned() - .collect(); + #[test] + fn arrays_of_objects_keep_every_key_as_a_column() { + let payload = QueryPayload::from_json(&serde_json::json!({ + "result": [ { "a": 1 }, { "a": 2, "b": 3 } ] + })); + + let names: Vec<&str> = payload.columns.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, vec!["a", "b"]); + assert_eq!(payload.outcome, StatementOutcome::Returned { rows: 2 }); + } - Ok(filtered) + #[test] + fn a_response_with_no_rows_reports_completion_not_an_empty_result_set() { + let payload = QueryPayload::from_json(&serde_json::json!({ "data": { "ok": true } })); + assert_eq!(payload.outcome, StatementOutcome::Completed); + assert!(payload.rows.is_empty()); } } diff --git a/orbit/llm/Cargo.toml b/orbit/llm/Cargo.toml new file mode 100644 index 00000000..252799a6 --- /dev/null +++ b/orbit/llm/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "orbit-llm" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +description = "Provider-agnostic LLM and embedding layer for Orbit: registry, router, fallback, and cost accounting" +repository.workspace = true + +[dependencies] +orbit-shared = { path = "../shared" } + +tokio = { workspace = true } +async-trait.workspace = true +futures.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true +chrono.workspace = true +reqwest = { version = "0.12", features = ["json"] } +fastrand = "2.0" +toml = "0.9" + +[dev-dependencies] +tokio-test.workspace = true + +[lib] +name = "orbit_llm" +path = "src/lib.rs" diff --git a/orbit/llm/src/breaker.rs b/orbit/llm/src/breaker.rs new file mode 100644 index 00000000..7f0523a1 --- /dev/null +++ b/orbit/llm/src/breaker.rs @@ -0,0 +1,348 @@ +//! Per-profile circuit breaker. +//! +//! Without one, a dead provider costs every request its full timeout before failing over. With one, +//! the first few requests pay that cost and the rest fail immediately, which is the difference +//! between a degraded system and a stalled one. +//! +//! State is held in atomics rather than behind a lock: the hot path is a read on every request and +//! a write only on a state transition. + +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +/// Breaker tuning. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct BreakerConfig { + /// Consecutive unhealthy failures that open the circuit. `0` disables the breaker. + pub failure_threshold: u32, + /// How long the circuit stays open before admitting a probe request. + pub open_duration_ms: u64, + /// Consecutive successes in half-open state required to close the circuit again. + pub success_threshold: u32, +} + +impl Default for BreakerConfig { + fn default() -> Self { + Self { + failure_threshold: 5, + open_duration_ms: 30_000, + success_threshold: 2, + } + } +} + +impl BreakerConfig { + /// A configuration that never trips. + #[must_use] + pub const fn disabled() -> Self { + Self { + failure_threshold: 0, + open_duration_ms: 0, + success_threshold: 0, + } + } + + /// Whether this configuration will ever open a circuit. + #[must_use] + pub const fn is_enabled(&self) -> bool { + self.failure_threshold > 0 + } +} + +/// Observable breaker state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BreakerState { + /// Requests pass through. + Closed, + /// Requests are rejected without being sent. + Open, + /// A limited number of probe requests are admitted. + HalfOpen, +} + +impl BreakerState { + /// Stable identifier for metrics and `LLM.STATS`. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + BreakerState::Closed => "closed", + BreakerState::Open => "open", + BreakerState::HalfOpen => "half_open", + } + } +} + +/// A circuit breaker for one model profile. +/// +/// Time is injected through [`CircuitBreaker::state_at`] / [`CircuitBreaker::record_failure_at`] so +/// the state machine is testable without sleeping. The convenience wrappers stamp `Instant::now()` +/// at the edge. +#[derive(Debug)] +pub struct CircuitBreaker { + config: BreakerConfig, + consecutive_failures: AtomicU32, + half_open_successes: AtomicU32, + /// Millis since `origin` at which the circuit opened; `0` means "not open". + opened_at_ms: AtomicU64, + origin: Instant, +} + +impl CircuitBreaker { + /// Build a breaker with the given configuration. + #[must_use] + pub fn new(config: BreakerConfig) -> Self { + Self { + config, + consecutive_failures: AtomicU32::new(0), + half_open_successes: AtomicU32::new(0), + opened_at_ms: AtomicU64::new(0), + origin: Instant::now(), + } + } + + fn elapsed_ms(&self, now: Instant) -> u64 { + now.saturating_duration_since(self.origin).as_millis() as u64 + } + + /// Current state as of `now`. + #[must_use] + pub fn state_at(&self, now: Instant) -> BreakerState { + if !self.config.is_enabled() { + return BreakerState::Closed; + } + let opened_at = self.opened_at_ms.load(Ordering::Acquire); + if opened_at == 0 { + return BreakerState::Closed; + } + let open_for = self.elapsed_ms(now).saturating_sub(opened_at); + if open_for >= self.config.open_duration_ms { + BreakerState::HalfOpen + } else { + BreakerState::Open + } + } + + /// Current state. + #[must_use] + pub fn state(&self) -> BreakerState { + self.state_at(Instant::now()) + } + + /// Whether a request should be admitted as of `now`. + #[must_use] + pub fn allows_request_at(&self, now: Instant) -> bool { + !matches!(self.state_at(now), BreakerState::Open) + } + + /// Whether a request should be admitted. + #[must_use] + pub fn allows_request(&self) -> bool { + self.allows_request_at(Instant::now()) + } + + /// Consecutive failures recorded since the last success. + #[must_use] + pub fn consecutive_failures(&self) -> u32 { + self.consecutive_failures.load(Ordering::Relaxed) + } + + /// Record a successful call as of `now`. + pub fn record_success_at(&self, now: Instant) { + if !self.config.is_enabled() { + return; + } + match self.state_at(now) { + BreakerState::HalfOpen => { + let successes = self.half_open_successes.fetch_add(1, Ordering::AcqRel) + 1; + if successes >= self.config.success_threshold.max(1) { + self.close(); + } + } + BreakerState::Closed => { + self.consecutive_failures.store(0, Ordering::Release); + } + // A success cannot be observed while open, because no request was sent. + BreakerState::Open => {} + } + } + + /// Record a successful call. + pub fn record_success(&self) { + self.record_success_at(Instant::now()); + } + + /// Record a failure that indicates provider ill-health, as of `now`. + /// + /// Callers must filter on [`crate::LlmError::indicates_provider_unhealthy`] first: a 400 means + /// the caller sent a bad prompt, and tripping a breaker on it removes a healthy provider from + /// service because one query was malformed. + pub fn record_failure_at(&self, now: Instant) { + if !self.config.is_enabled() { + return; + } + if self.state_at(now) == BreakerState::HalfOpen { + // A probe failed: re-open for another full interval rather than accumulating toward + // the threshold again. + self.half_open_successes.store(0, Ordering::Release); + self.open_at(now); + return; + } + let failures = self.consecutive_failures.fetch_add(1, Ordering::AcqRel) + 1; + if failures >= self.config.failure_threshold { + self.open_at(now); + } + } + + /// Record a failure that indicates provider ill-health. + pub fn record_failure(&self) { + self.record_failure_at(Instant::now()); + } + + fn open_at(&self, now: Instant) { + // `max(1)` keeps 0 as the unambiguous "not open" sentinel even if the breaker opens within + // the first millisecond of process life. + self.opened_at_ms + .store(self.elapsed_ms(now).max(1), Ordering::Release); + } + + fn close(&self) { + self.opened_at_ms.store(0, Ordering::Release); + self.consecutive_failures.store(0, Ordering::Release); + self.half_open_successes.store(0, Ordering::Release); + } + + /// Force the circuit closed, discarding accumulated failures. + /// + /// Used when a profile is re-registered: the new configuration has not failed yet, and + /// inheriting the old one's failure count would open a circuit on a provider never called. + pub fn reset(&self) { + self.close(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn breaker() -> CircuitBreaker { + CircuitBreaker::new(BreakerConfig { + failure_threshold: 3, + open_duration_ms: 1_000, + success_threshold: 2, + }) + } + + #[test] + fn opens_after_threshold_consecutive_failures() { + let b = breaker(); + let t0 = b.origin + Duration::from_millis(10); + + assert_eq!(b.state_at(t0), BreakerState::Closed); + b.record_failure_at(t0); + b.record_failure_at(t0); + assert_eq!(b.state_at(t0), BreakerState::Closed, "below threshold"); + b.record_failure_at(t0); + assert_eq!(b.state_at(t0), BreakerState::Open); + assert!(!b.allows_request_at(t0)); + } + + #[test] + fn a_success_resets_the_failure_run() { + let b = breaker(); + let t0 = b.origin + Duration::from_millis(10); + + b.record_failure_at(t0); + b.record_failure_at(t0); + b.record_success_at(t0); + assert_eq!(b.consecutive_failures(), 0); + b.record_failure_at(t0); + b.record_failure_at(t0); + assert_eq!( + b.state_at(t0), + BreakerState::Closed, + "the run restarted, so two failures is still below threshold" + ); + } + + #[test] + fn transitions_to_half_open_after_the_open_interval() { + let b = breaker(); + let t0 = b.origin + Duration::from_millis(10); + for _ in 0..3 { + b.record_failure_at(t0); + } + assert_eq!(b.state_at(t0), BreakerState::Open); + assert_eq!( + b.state_at(t0 + Duration::from_millis(999)), + BreakerState::Open + ); + assert_eq!( + b.state_at(t0 + Duration::from_millis(1_000)), + BreakerState::HalfOpen + ); + assert!(b.allows_request_at(t0 + Duration::from_millis(1_000))); + } + + #[test] + fn half_open_closes_after_enough_successes() { + let b = breaker(); + let t0 = b.origin + Duration::from_millis(10); + for _ in 0..3 { + b.record_failure_at(t0); + } + let probe = t0 + Duration::from_millis(1_500); + b.record_success_at(probe); + assert_eq!( + b.state_at(probe), + BreakerState::HalfOpen, + "one success is not enough" + ); + b.record_success_at(probe); + assert_eq!(b.state_at(probe), BreakerState::Closed); + } + + #[test] + fn a_failed_probe_reopens_for_a_full_interval() { + let b = breaker(); + let t0 = b.origin + Duration::from_millis(10); + for _ in 0..3 { + b.record_failure_at(t0); + } + let probe = t0 + Duration::from_millis(1_500); + assert_eq!(b.state_at(probe), BreakerState::HalfOpen); + b.record_failure_at(probe); + assert_eq!(b.state_at(probe), BreakerState::Open); + assert_eq!( + b.state_at(probe + Duration::from_millis(999)), + BreakerState::Open, + "the open interval restarted from the failed probe" + ); + } + + #[test] + fn disabled_breaker_never_opens() { + let b = CircuitBreaker::new(BreakerConfig::disabled()); + let t0 = b.origin + Duration::from_millis(10); + for _ in 0..1_000 { + b.record_failure_at(t0); + } + assert_eq!(b.state_at(t0), BreakerState::Closed); + assert!(b.allows_request_at(t0)); + } + + #[test] + fn reset_clears_an_open_circuit() { + let b = breaker(); + let t0 = b.origin + Duration::from_millis(10); + for _ in 0..3 { + b.record_failure_at(t0); + } + assert_eq!(b.state_at(t0), BreakerState::Open); + b.reset(); + assert_eq!(b.state_at(t0), BreakerState::Closed); + assert_eq!(b.consecutive_failures(), 0); + } +} diff --git a/orbit/llm/src/error.rs b/orbit/llm/src/error.rs new file mode 100644 index 00000000..165782b1 --- /dev/null +++ b/orbit/llm/src/error.rs @@ -0,0 +1,279 @@ +//! Errors for the LLM layer. +//! +//! The important property here is [`LlmError::is_retryable`]: the router must not retry a 401, and +//! must retry a 429. Encoding that classification in the error type — rather than in the router's +//! `match` — keeps it in one place as providers are added. + +use orbit_shared::OrbitError; +use std::time::Duration; + +/// Result alias for LLM operations. +pub type LlmResult = Result; + +/// Failure modes of the LLM layer. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum LlmError { + /// The named model profile is not registered. + #[error("model profile '{name}' is not registered")] + UnknownProfile { + /// Profile name that was requested. + name: String, + }, + + /// No profile was named and no default is configured. + #[error("no model profile requested and no default is configured")] + NoDefaultProfile, + + /// Configuration is invalid or incomplete. + #[error("LLM configuration error: {message}")] + Configuration { + /// What is wrong with the configuration. + message: String, + }, + + /// The transport failed before an HTTP status was seen. + #[error("transport error calling {provider}: {message}")] + Transport { + /// Provider being called. + provider: String, + /// Underlying transport failure. + message: String, + }, + + /// The request exceeded its deadline. + #[error("request to {provider} timed out after {}ms", .elapsed.as_millis())] + Timeout { + /// Provider being called. + provider: String, + /// How long was spent before giving up. + elapsed: Duration, + }, + + /// The provider returned a non-success HTTP status. + #[error("{provider} returned HTTP {status}: {body}")] + Api { + /// Provider being called. + provider: String, + /// HTTP status code. + status: u16, + /// Response body, truncated for logging. + body: String, + /// Server-suggested wait before retrying, when supplied via `Retry-After`. + retry_after: Option, + }, + + /// The provider's response did not match the shape its API documents. + #[error("{provider} returned an unexpected response shape: {detail}")] + MalformedResponse { + /// Provider being called. + provider: String, + /// What was expected and not found. + detail: String, + }, + + /// The circuit breaker for this provider is open. + #[error("circuit breaker open for '{profile}'; {failures} consecutive failures")] + CircuitOpen { + /// Profile whose breaker is open. + profile: String, + /// Consecutive failures that tripped it. + failures: u32, + }, + + /// Every profile in the fallback chain failed. + /// + /// Carries the chain that was attempted so operators can see the failover actually ran, rather + /// than only the last error. + #[error("all {} profiles failed: {}", .attempted.len(), .attempted.join(" -> "))] + AllProvidersFailed { + /// Profiles attempted, in order. + attempted: Vec, + /// The final error encountered. + last: Box, + }, + + /// A capability was requested that this provider does not offer. + #[error("{provider} does not support {capability}")] + Unsupported { + /// Provider being called. + provider: String, + /// Capability requested. + capability: &'static str, + }, +} + +impl LlmError { + /// Whether retrying this request could plausibly succeed. + /// + /// Retrying a `401` burns latency and quota to arrive at the same answer; retrying a `429` or a + /// `503` is the whole point of having a retry policy. `408`/`409` are included because both are + /// used by LLM gateways for transient contention. + #[must_use] + pub fn is_retryable(&self) -> bool { + match self { + LlmError::Transport { .. } | LlmError::Timeout { .. } => true, + LlmError::Api { status, .. } => { + matches!(status, 408 | 409 | 425 | 429 | 500 | 502 | 503 | 504) + } + LlmError::AllProvidersFailed { last, .. } => last.is_retryable(), + LlmError::UnknownProfile { .. } + | LlmError::NoDefaultProfile + | LlmError::Configuration { .. } + | LlmError::MalformedResponse { .. } + | LlmError::CircuitOpen { .. } + | LlmError::Unsupported { .. } => false, + } + } + + /// Whether this failure should count against the circuit breaker. + /// + /// A malformed *request* (400) says the caller is wrong, not that the provider is unhealthy; + /// tripping a breaker on it would take a working provider out of service because one caller + /// sent a bad prompt. + #[must_use] + pub fn indicates_provider_unhealthy(&self) -> bool { + match self { + LlmError::Transport { .. } | LlmError::Timeout { .. } => true, + LlmError::Api { status, .. } => *status == 429 || *status >= 500, + LlmError::MalformedResponse { .. } => true, + LlmError::UnknownProfile { .. } + | LlmError::NoDefaultProfile + | LlmError::Configuration { .. } + | LlmError::CircuitOpen { .. } + | LlmError::AllProvidersFailed { .. } + | LlmError::Unsupported { .. } => false, + } + } + + /// Server-suggested delay before the next attempt, when the provider supplied one. + /// + /// Absent means the provider said nothing — the caller should fall back to its own backoff + /// rather than assume zero. + #[must_use] + pub fn retry_after(&self) -> Option { + match self { + LlmError::Api { retry_after, .. } => *retry_after, + LlmError::AllProvidersFailed { last, .. } => last.retry_after(), + _ => None, + } + } + + /// Construct a configuration error. + pub fn configuration(message: impl Into) -> Self { + LlmError::Configuration { + message: message.into(), + } + } +} + +impl From for OrbitError { + fn from(err: LlmError) -> Self { + match err { + LlmError::Configuration { ref message } => OrbitError::ConfigurationError { + message: message.clone(), + key: Some("llm".to_string()), + }, + LlmError::Timeout { ref provider, .. } => OrbitError::Timeout { + operation: format!("llm::{provider}"), + }, + LlmError::Transport { .. } => OrbitError::NetworkError(err.to_string()), + other => OrbitError::internal_with_context(other.to_string(), "llm"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn api(status: u16) -> LlmError { + LlmError::Api { + provider: "test".into(), + status, + body: String::new(), + retry_after: None, + } + } + + #[test] + fn retryable_classification_is_table_driven() { + let cases = [ + (400, false), + (401, false), + (403, false), + (404, false), + (408, true), + (422, false), + (429, true), + (500, true), + (502, true), + (503, true), + (504, true), + ]; + for (status, expected) in cases { + assert_eq!( + api(status).is_retryable(), + expected, + "status {status} retryable classification" + ); + } + } + + #[test] + fn client_errors_do_not_trip_the_breaker() { + assert!(!api(400).indicates_provider_unhealthy()); + assert!(!api(401).indicates_provider_unhealthy()); + assert!(api(429).indicates_provider_unhealthy()); + assert!(api(503).indicates_provider_unhealthy()); + } + + #[test] + fn transport_and_timeout_are_retryable_and_unhealthy() { + let transport = LlmError::Transport { + provider: "test".into(), + message: "connection reset".into(), + }; + assert!(transport.is_retryable()); + assert!(transport.indicates_provider_unhealthy()); + + let timeout = LlmError::Timeout { + provider: "test".into(), + elapsed: Duration::from_secs(30), + }; + assert!(timeout.is_retryable()); + assert!(timeout.indicates_provider_unhealthy()); + } + + #[test] + fn circuit_open_is_not_retryable_at_this_layer() { + let open = LlmError::CircuitOpen { + profile: "p".into(), + failures: 5, + }; + assert!(!open.is_retryable()); + assert!(!open.indicates_provider_unhealthy()); + } + + #[test] + fn all_providers_failed_delegates_to_last() { + let err = LlmError::AllProvidersFailed { + attempted: vec!["a".into(), "b".into()], + last: Box::new(api(429)), + }; + assert!(err.is_retryable()); + assert!(err.to_string().contains("a -> b")); + } + + #[test] + fn retry_after_is_absent_when_unreported() { + assert_eq!(api(429).retry_after(), None); + let with_hint = LlmError::Api { + provider: "test".into(), + status: 429, + body: String::new(), + retry_after: Some(Duration::from_secs(7)), + }; + assert_eq!(with_hint.retry_after(), Some(Duration::from_secs(7))); + } +} diff --git a/orbit/llm/src/provider.rs b/orbit/llm/src/provider.rs new file mode 100644 index 00000000..5141503f --- /dev/null +++ b/orbit/llm/src/provider.rs @@ -0,0 +1,257 @@ +//! Provider traits and the provider-shape taxonomy. +//! +//! Orbit-RS deliberately implements four *shapes* rather than chasing a provider count. The +//! OpenAI-compatible shape alone covers Azure OpenAI, vLLM, Groq, Together, OpenRouter, LM Studio, +//! DeepSeek, and Fireworks, because all of them speak `/chat/completions`. Adding a genuinely new +//! shape behind [`LlmProvider`] is roughly eighty lines. + +use crate::error::LlmResult; +use crate::types::{ChatRequest, ChatResponse, EmbeddingRequest, EmbeddingResponse}; +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// The wire shapes this build knows how to speak. +/// +/// This enum is control flow, not decoration: every variant is constructed by +/// [`crate::config::ProviderConfig::kind`] and dispatched by +/// [`crate::providers::build_provider`]. A variant with no construction site would be a +/// configuration option that silently does nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ProviderKind { + /// OpenAI's own API. + OpenAi, + /// Anthropic's Messages API. + Anthropic, + /// Ollama's native API. + Ollama, + /// Any server exposing OpenAI-compatible `/chat/completions`. + Compatible, +} + +impl ProviderKind { + /// Every shape this build supports, for `LLM.PROVIDERS`. + #[must_use] + pub const fn all() -> &'static [ProviderKind] { + &[ + ProviderKind::OpenAi, + ProviderKind::Anthropic, + ProviderKind::Ollama, + ProviderKind::Compatible, + ] + } + + /// Stable identifier used in config files and commands. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + ProviderKind::OpenAi => "openai", + ProviderKind::Anthropic => "anthropic", + ProviderKind::Ollama => "ollama", + ProviderKind::Compatible => "compatible", + } + } + + /// Whether this shape can produce embeddings. + /// + /// Anthropic has no embeddings endpoint; a profile pointed at Anthropic will report + /// [`crate::LlmError::Unsupported`] rather than quietly returning a zero vector. + #[must_use] + pub const fn supports_embeddings(self) -> bool { + match self { + ProviderKind::OpenAi | ProviderKind::Ollama | ProviderKind::Compatible => true, + ProviderKind::Anthropic => false, + } + } +} + +impl fmt::Display for ProviderKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for ProviderKind { + type Err = crate::error::LlmError; + + fn from_str(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "openai" | "open_ai" => Ok(ProviderKind::OpenAi), + "anthropic" | "claude" => Ok(ProviderKind::Anthropic), + "ollama" => Ok(ProviderKind::Ollama), + "compatible" | "openai_compatible" | "local" | "azure" | "azure_openai" | "vllm" + | "groq" | "together" | "openrouter" | "lmstudio" | "deepseek" | "fireworks" => { + Ok(ProviderKind::Compatible) + } + other => Err(crate::error::LlmError::configuration(format!( + "unknown provider '{other}'; expected one of: openai, anthropic, ollama, compatible" + ))), + } + } +} + +/// A backend that can generate text. +/// +/// Implementations own only HTTP shaping: no retries, no timeouts, no fallback. Those belong to +/// [`crate::router::Router`], so their behavior is identical across providers instead of being +/// reinvented per backend. +#[async_trait::async_trait] +pub trait LlmProvider: Send + Sync { + /// Wire shape this provider speaks. + fn kind(&self) -> ProviderKind; + + /// Model identifier this provider was configured with. + fn model(&self) -> &str; + + /// Perform one generation attempt. + /// + /// # Errors + /// + /// Returns [`crate::LlmError::Transport`] if the request never reached the provider, + /// [`crate::LlmError::Api`] for a non-success status, and + /// [`crate::LlmError::MalformedResponse`] if the body did not match the documented shape. + async fn generate(&self, request: &ChatRequest) -> LlmResult; +} + +/// A backend that can produce embeddings. +#[async_trait::async_trait] +pub trait EmbeddingProvider: Send + Sync { + /// Wire shape this provider speaks. + fn kind(&self) -> ProviderKind; + + /// Embedding model identifier this provider was configured with. + fn embedding_model(&self) -> &str; + + /// Perform one embedding attempt. + /// + /// # Errors + /// + /// As [`LlmProvider::generate`], plus [`crate::LlmError::Unsupported`] where the provider has + /// no embeddings endpoint. + async fn embed(&self, request: &EmbeddingRequest) -> LlmResult; +} + +/// What a provider returns before the router adds cost, profile identity, and failover history. +/// +/// Kept separate from [`ChatResponse`] so providers cannot fabricate the fields only the router +/// can know — a provider has no way to report which fallbacks fired. +#[derive(Debug, Clone)] +pub struct ProviderChatOutput { + /// Generated text. + pub text: String, + /// Model reported by the provider, falling back to the configured name. + pub model: String, + /// Token counts, where reported. + pub usage: crate::types::TokenUsage, + /// Stop reason, where reported. + pub finish_reason: Option, +} + +/// What an embedding provider returns before the router adds profile identity. +#[derive(Debug, Clone)] +pub struct ProviderEmbeddingOutput { + /// Vectors, aligned with the request inputs. + pub embeddings: Vec>, + /// Model reported by the provider, falling back to the configured name. + pub model: String, + /// Token counts, where reported. + pub usage: crate::types::TokenUsage, +} + +/// A provider that can do both jobs, which is how every backend here is configured. +pub trait CompleteProvider: LlmProvider + EmbeddingProvider {} + +impl CompleteProvider for T {} + +/// Marker used by the router to attach the produced response to its originating profile. +#[allow(dead_code)] +pub(crate) fn finalize_chat( + output: ProviderChatOutput, + profile: String, + cost: Option, + latency: std::time::Duration, + fallbacks_used: Vec, +) -> ChatResponse { + ChatResponse { + text: output.text, + model: output.model, + profile, + usage: output.usage, + cost, + finish_reason: output.finish_reason, + latency, + fallbacks_used, + } +} + +#[allow(dead_code)] +pub(crate) fn finalize_embedding( + output: ProviderEmbeddingOutput, + profile: String, + latency: std::time::Duration, +) -> EmbeddingResponse { + EmbeddingResponse { + embeddings: output.embeddings, + model: output.model, + profile, + usage: output.usage, + latency, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn every_kind_round_trips_through_its_string_form() { + for kind in ProviderKind::all() { + let parsed = ProviderKind::from_str(kind.as_str()).expect("kind parses"); + assert_eq!(parsed, *kind, "round trip for {kind}"); + } + } + + #[test] + fn named_services_map_onto_the_compatible_shape() { + for alias in [ + "azure", + "azure_openai", + "vllm", + "groq", + "together", + "openrouter", + "lmstudio", + "deepseek", + "fireworks", + "local", + ] { + assert_eq!( + ProviderKind::from_str(alias).expect("alias parses"), + ProviderKind::Compatible, + "alias {alias}" + ); + } + } + + #[test] + fn unknown_provider_is_rejected_at_parse_time() { + let err = ProviderKind::from_str("cohere").expect_err("unknown provider rejected"); + assert!(err.to_string().contains("unknown provider 'cohere'")); + } + + #[test] + fn anthropic_declares_no_embedding_support() { + assert!(!ProviderKind::Anthropic.supports_embeddings()); + assert!(ProviderKind::OpenAi.supports_embeddings()); + assert!(ProviderKind::Ollama.supports_embeddings()); + assert!(ProviderKind::Compatible.supports_embeddings()); + } + + #[test] + fn all_lists_every_variant() { + // Guards against a variant being added without being surfaced by LLM.PROVIDERS. + assert_eq!(ProviderKind::all().len(), 4); + } +} diff --git a/orbit/llm/src/retry.rs b/orbit/llm/src/retry.rs new file mode 100644 index 00000000..5a3f64e0 --- /dev/null +++ b/orbit/llm/src/retry.rs @@ -0,0 +1,214 @@ +//! Retry policy: exponential backoff with full jitter. +//! +//! Full jitter (`sleep = rand(0, backoff)`) rather than the naive `backoff ± 10%`: when a provider +//! returns 429 to a whole fleet at once, correlated retries reproduce the thundering herd that +//! caused the 429. Randomizing over the *whole* interval decorrelates them. +//! +//! The delay computation is a pure function ([`RetryPolicy::backoff_ceiling`]) so it is testable +//! without sleeping; jitter is applied at the edge in [`RetryPolicy::delay_for_attempt`]. + +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// How the router retries a failed attempt. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct RetryPolicy { + /// Attempts *after* the first. `0` disables retrying. + pub max_retries: u32, + /// Delay ceiling for the first retry. + pub initial_backoff_ms: u64, + /// Upper bound on any single delay, before jitter. + pub max_backoff_ms: u64, + /// Growth factor per attempt, as a percentage (200 = double each time). + /// + /// Expressed as an integer percentage rather than a float so the policy stays `Eq` and + /// comparable in config diffs. + pub backoff_multiplier_pct: u32, +} + +impl Default for RetryPolicy { + fn default() -> Self { + Self { + max_retries: 2, + initial_backoff_ms: 250, + max_backoff_ms: 8_000, + backoff_multiplier_pct: 200, + } + } +} + +impl RetryPolicy { + /// A policy that never retries, for callers that own their own retry loop. + #[must_use] + pub const fn none() -> Self { + Self { + max_retries: 0, + initial_backoff_ms: 0, + max_backoff_ms: 0, + backoff_multiplier_pct: 100, + } + } + + /// Upper bound on the delay before `attempt`, before jitter is applied. + /// + /// `attempt` is 1-based: attempt 1 is the first *retry*. + #[must_use] + pub fn backoff_ceiling(&self, attempt: u32) -> Duration { + if attempt == 0 { + return Duration::ZERO; + } + let multiplier = f64::from(self.backoff_multiplier_pct.max(100)) / 100.0; + let scaled = self.initial_backoff_ms as f64 * multiplier.powi((attempt - 1) as i32); + // `as u64` saturates at u64::MAX for large finite values and yields 0 for NaN; neither can + // occur here because `scaled` is a product of finite non-negative values, but the min() + // bound makes the result independent of that reasoning. + let capped = scaled.min(self.max_backoff_ms as f64).max(0.0); + Duration::from_millis(capped as u64) + } + + /// Actual delay to sleep before `attempt`, with full jitter applied. + /// + /// A `server_hint` from a `Retry-After` header wins outright: the provider has told us when it + /// will be ready, and guessing earlier only wastes a request. + #[must_use] + pub fn delay_for_attempt(&self, attempt: u32, server_hint: Option) -> Duration { + if let Some(hint) = server_hint { + return hint.min(Duration::from_millis(self.max_backoff_ms.max(1))); + } + let ceiling = self.backoff_ceiling(attempt); + let ceiling_ms = ceiling.as_millis() as u64; + if ceiling_ms == 0 { + return Duration::ZERO; + } + Duration::from_millis(fastrand::u64(0..=ceiling_ms)) + } + + /// Whether another attempt is permitted after `attempts_made` total attempts. + #[must_use] + pub fn should_retry(&self, attempts_made: u32) -> bool { + attempts_made <= self.max_retries + } + + /// Total attempts this policy permits, including the first. + #[must_use] + pub fn total_attempts(&self) -> u32 { + self.max_retries.saturating_add(1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backoff_grows_geometrically_then_saturates() { + let policy = RetryPolicy { + max_retries: 10, + initial_backoff_ms: 100, + max_backoff_ms: 1_000, + backoff_multiplier_pct: 200, + }; + assert_eq!(policy.backoff_ceiling(0), Duration::ZERO); + assert_eq!(policy.backoff_ceiling(1), Duration::from_millis(100)); + assert_eq!(policy.backoff_ceiling(2), Duration::from_millis(200)); + assert_eq!(policy.backoff_ceiling(3), Duration::from_millis(400)); + assert_eq!(policy.backoff_ceiling(4), Duration::from_millis(800)); + assert_eq!( + policy.backoff_ceiling(5), + Duration::from_millis(1_000), + "capped at max_backoff_ms" + ); + assert_eq!(policy.backoff_ceiling(50), Duration::from_millis(1_000)); + } + + #[test] + fn jitter_stays_within_the_ceiling() { + let policy = RetryPolicy { + max_retries: 5, + initial_backoff_ms: 200, + max_backoff_ms: 5_000, + backoff_multiplier_pct: 200, + }; + for attempt in 1..=4 { + let ceiling = policy.backoff_ceiling(attempt); + for _ in 0..64 { + let delay = policy.delay_for_attempt(attempt, None); + assert!( + delay <= ceiling, + "attempt {attempt}: {delay:?} exceeded ceiling {ceiling:?}" + ); + } + } + } + + #[test] + fn full_jitter_actually_varies() { + let policy = RetryPolicy { + max_retries: 5, + initial_backoff_ms: 1_000, + max_backoff_ms: 5_000, + backoff_multiplier_pct: 200, + }; + let samples: Vec<_> = (0..32).map(|_| policy.delay_for_attempt(2, None)).collect(); + let distinct = samples.iter().collect::>(); + assert!( + distinct.len() > 1, + "full jitter must decorrelate retries, got {samples:?}" + ); + } + + #[test] + fn server_hint_overrides_computed_backoff() { + let policy = RetryPolicy::default(); + let hint = Duration::from_millis(1_500); + assert_eq!(policy.delay_for_attempt(1, Some(hint)), hint); + } + + #[test] + fn server_hint_is_still_bounded_by_max_backoff() { + let policy = RetryPolicy { + max_backoff_ms: 2_000, + ..RetryPolicy::default() + }; + let absurd_hint = Duration::from_secs(3_600); + assert_eq!( + policy.delay_for_attempt(1, Some(absurd_hint)), + Duration::from_millis(2_000), + "a provider cannot pin a query open for an hour" + ); + } + + #[test] + fn none_policy_permits_exactly_one_attempt() { + let policy = RetryPolicy::none(); + assert_eq!(policy.total_attempts(), 1); + assert!(policy.should_retry(0)); + assert!(!policy.should_retry(1)); + assert_eq!(policy.delay_for_attempt(1, None), Duration::ZERO); + } + + #[test] + fn should_retry_respects_the_budget() { + let policy = RetryPolicy { + max_retries: 2, + ..RetryPolicy::default() + }; + assert!(policy.should_retry(1)); + assert!(policy.should_retry(2)); + assert!(!policy.should_retry(3), "budget of 2 retries is exhausted"); + } + + #[test] + fn degenerate_multiplier_does_not_shrink_the_backoff() { + let policy = RetryPolicy { + max_retries: 3, + initial_backoff_ms: 100, + max_backoff_ms: 1_000, + backoff_multiplier_pct: 0, + }; + // A multiplier below 100% would make later retries fire sooner than earlier ones. + assert_eq!(policy.backoff_ceiling(1), Duration::from_millis(100)); + assert_eq!(policy.backoff_ceiling(3), Duration::from_millis(100)); + } +} diff --git a/orbit/llm/src/secret.rs b/orbit/llm/src/secret.rs new file mode 100644 index 00000000..7d376c00 --- /dev/null +++ b/orbit/llm/src/secret.rs @@ -0,0 +1,129 @@ +//! Redacting wrapper for credentials. +//! +//! The pre-existing `orbit_shared::graphrag::LLMProvider` stored API keys in a plain `String` on a +//! `#[derive(Serialize)]` type, which means any config dump, `{:?}` log line, or error context +//! could print a live credential. [`SecretString`] closes that path: the value is only reachable +//! through [`SecretString::expose`], which is deliberately awkward to type and easy to grep for. + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::fmt; + +/// Marker written in place of a secret when serializing. +pub const REDACTED: &str = "***REDACTED***"; + +/// A string that will not print itself. +/// +/// `Debug` and `Display` render [`REDACTED`]. `Serialize` also renders [`REDACTED`], so a config +/// round-trip through TOML/JSON cannot exfiltrate the value — a deserialized-then-reserialized +/// config is safe to log but is *not* a usable config, which is the intended trade. +/// +/// # Examples +/// +/// ``` +/// use orbit_llm::SecretString; +/// +/// let key = SecretString::new("sk-live-abc123"); +/// assert_eq!(format!("{key:?}"), "***REDACTED***"); +/// assert_eq!(key.expose(), "sk-live-abc123"); +/// ``` +#[derive(Clone, Default, PartialEq, Eq)] +pub struct SecretString(String); + +impl SecretString { + /// Wrap a credential. + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Read the underlying credential. + /// + /// Every call site is a place a secret can escape; keep them few and short-lived. + #[must_use] + pub fn expose(&self) -> &str { + &self.0 + } + + /// Whether the credential is empty. + /// + /// An empty credential is distinct from an absent one: absent is `Option::None`, empty is a + /// configured-but-blank value, which is almost always a misconfiguration worth reporting. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl fmt::Debug for SecretString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(REDACTED) + } +} + +impl fmt::Display for SecretString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(REDACTED) + } +} + +impl From for SecretString { + fn from(value: String) -> Self { + Self(value) + } +} + +impl From<&str> for SecretString { + fn from(value: &str) -> Self { + Self(value.to_owned()) + } +} + +impl Serialize for SecretString { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(REDACTED) + } +} + +impl<'de> Deserialize<'de> for SecretString { + fn deserialize>(deserializer: D) -> Result { + String::deserialize(deserializer).map(Self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_and_display_redact() { + let secret = SecretString::new("sk-should-never-appear"); + assert_eq!(format!("{secret:?}"), REDACTED); + assert_eq!(format!("{secret}"), REDACTED); + assert!(!format!("{secret:?} {secret}").contains("should-never-appear")); + } + + #[test] + fn serialize_redacts_but_expose_does_not() { + let secret = SecretString::new("sk-live"); + let json = serde_json::to_string(&secret).expect("secret serializes"); + assert_eq!(json, format!("\"{REDACTED}\"")); + assert_eq!(secret.expose(), "sk-live"); + } + + #[test] + fn deserialize_accepts_plain_string() { + let secret: SecretString = serde_json::from_str("\"sk-from-config\"").expect("parses"); + assert_eq!(secret.expose(), "sk-from-config"); + } + + #[test] + fn nested_struct_debug_does_not_leak() { + #[derive(Debug)] + struct Config { + api_key: SecretString, + } + let cfg = Config { + api_key: SecretString::new("sk-nested"), + }; + assert!(!format!("{cfg:?}").contains("sk-nested")); + } +} diff --git a/orbit/llm/src/types.rs b/orbit/llm/src/types.rs new file mode 100644 index 00000000..e32684b4 --- /dev/null +++ b/orbit/llm/src/types.rs @@ -0,0 +1,429 @@ +//! Request and response types shared by every provider. +//! +//! # Modelling honesty +//! +//! Two choices here are deliberate and worth reading before changing them: +//! +//! * [`TokenUsage`] fields are `Option`. Ollama and most local servers do not report token counts. +//! Defaulting those to `0` would assert "this request used no tokens", which is a claim about the +//! world that nobody measured. Absent stays absent. +//! * [`Cost`] is only produced when a price is *configured* for the model. Orbit-RS ships no +//! built-in price table, because a price table baked into a database binary goes stale silently +//! and then reports confident wrong numbers. + +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// Who authored a message in a conversation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum Role { + /// Instructions that frame the whole exchange. + System, + /// Input from the caller. + User, + /// A prior model response. + Assistant, +} + +impl Role { + /// Wire name used by OpenAI-shaped and Anthropic APIs alike. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Role::System => "system", + Role::User => "user", + Role::Assistant => "assistant", + } + } +} + +/// One turn in a conversation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Message { + /// Author of this turn. + pub role: Role, + /// Message body. + pub content: String, +} + +impl Message { + /// Build a system message. + pub fn system(content: impl Into) -> Self { + Self { + role: Role::System, + content: content.into(), + } + } + + /// Build a user message. + pub fn user(content: impl Into) -> Self { + Self { + role: Role::User, + content: content.into(), + } + } + + /// Build an assistant message. + pub fn assistant(content: impl Into) -> Self { + Self { + role: Role::Assistant, + content: content.into(), + } + } +} + +/// Generation parameters that override the profile's defaults for a single request. +/// +/// Every field here is applied by every provider that supports it, and a provider that cannot +/// honor a field reports [`crate::LlmError::Unsupported`] rather than dropping it silently. A +/// parameter that can be removed without changing any output is not a parameter. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct GenerationParams { + /// Sampling temperature. + pub temperature: Option, + /// Upper bound on generated tokens. + pub max_tokens: Option, + /// Nucleus sampling cutoff. + pub top_p: Option, + /// Sequences that end generation. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub stop: Vec, + /// Deterministic seed, where the provider supports one. + pub seed: Option, +} + +impl GenerationParams { + /// Overlay `override_with` on top of `self`, field by field. + /// + /// Per-request values win; unset per-request fields inherit the profile default. `stop` is + /// replaced rather than concatenated, because a caller that specifies stop sequences means + /// *those* sequences, not those plus whatever the profile happened to carry. + #[must_use] + pub fn merged_with(&self, override_with: &GenerationParams) -> GenerationParams { + GenerationParams { + temperature: override_with.temperature.or(self.temperature), + max_tokens: override_with.max_tokens.or(self.max_tokens), + top_p: override_with.top_p.or(self.top_p), + stop: if override_with.stop.is_empty() { + self.stop.clone() + } else { + override_with.stop.clone() + }, + seed: override_with.seed.or(self.seed), + } + } +} + +/// A chat/completion request, provider-independent. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ChatRequest { + /// Conversation turns, oldest first. + pub messages: Vec, + /// Per-request parameter overrides. + #[serde(default)] + pub params: GenerationParams, +} + +impl ChatRequest { + /// Build a single-turn request from a prompt, with an optional system message. + pub fn prompt(prompt: impl Into, system: Option) -> Self { + let messages = system + .map(Message::system) + .into_iter() + .chain(std::iter::once(Message::user(prompt))) + .collect(); + Self { + messages, + params: GenerationParams::default(), + } + } + + /// Apply parameter overrides, returning the modified request. + #[must_use] + pub fn with_params(mut self, params: GenerationParams) -> Self { + self.params = params; + self + } + + /// The system message, if the caller supplied one. + /// + /// Anthropic takes the system prompt as a top-level field rather than a message, so providers + /// need to split it out. + #[must_use] + pub fn system_message(&self) -> Option<&str> { + self.messages + .iter() + .find(|m| m.role == Role::System) + .map(|m| m.content.as_str()) + } + + /// The non-system turns, in order. + pub fn conversation(&self) -> impl Iterator { + self.messages.iter().filter(|m| m.role != Role::System) + } +} + +/// Why generation stopped. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum FinishReason { + /// The model finished naturally or hit a stop sequence. + Stop, + /// The token budget was exhausted before the model was done. + Length, + /// The provider's safety system intervened. + ContentFilter, + /// A reason the provider reported that does not map onto the above. + Other(String), +} + +impl FinishReason { + /// Map a provider's wire value onto a [`FinishReason`]. + /// + /// Unrecognized values are preserved verbatim in [`FinishReason::Other`] rather than collapsed + /// into `Stop` — a response truncated for a reason we do not model should not read as complete. + pub fn from_wire(value: &str) -> Self { + match value { + "stop" | "end_turn" | "stop_sequence" | "eos" => FinishReason::Stop, + "length" | "max_tokens" | "model_length" => FinishReason::Length, + "content_filter" | "refusal" => FinishReason::ContentFilter, + other => FinishReason::Other(other.to_owned()), + } + } +} + +/// Token counts for one request. +/// +/// Every field is optional because not every provider reports them. See the module docs. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenUsage { + /// Tokens consumed by the prompt. + pub prompt_tokens: Option, + /// Tokens produced by the model. + pub completion_tokens: Option, +} + +impl TokenUsage { + /// Total tokens, when both halves are known. + /// + /// Returns `None` if either half is unreported: a partial sum presented as a total is a wrong + /// number, and a wrong number in a billing column is worse than a missing one. + #[must_use] + pub fn total(&self) -> Option { + self.prompt_tokens + .zip(self.completion_tokens) + .map(|(p, c)| p.saturating_add(c)) + } + + /// Whether the provider reported anything at all. + #[must_use] + pub fn is_reported(&self) -> bool { + self.prompt_tokens.is_some() || self.completion_tokens.is_some() + } +} + +/// Money spent on one request, in USD. +/// +/// Only produced when the model profile carries a configured price. See the module docs. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Cost { + /// Cost attributable to input tokens. + pub prompt_usd: f64, + /// Cost attributable to output tokens. + pub completion_usd: f64, +} + +impl Cost { + /// Total spend for the request. + #[must_use] + pub fn total_usd(&self) -> f64 { + self.prompt_usd + self.completion_usd + } +} + +/// A completed generation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatResponse { + /// Generated text. + pub text: String, + /// Model that produced it, as reported by the provider where available. + pub model: String, + /// Profile name that was ultimately used — may differ from the requested one if a fallback + /// fired. + pub profile: String, + /// Token counts, where reported. + pub usage: TokenUsage, + /// Computed spend, where the profile carries prices. + pub cost: Option, + /// Why generation stopped, where reported. + pub finish_reason: Option, + /// Wall-clock time for the successful attempt. + #[serde(with = "duration_millis")] + pub latency: Duration, + /// Profiles tried and rejected before this one succeeded. + /// + /// Empty on the happy path. Non-empty means a failover fired, and a failover nobody can see is + /// an outage nobody can see. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fallbacks_used: Vec, +} + +/// An embedding request. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct EmbeddingRequest { + /// Inputs to embed. Batched in one call where the provider supports it. + pub inputs: Vec, + /// Requested output dimensionality, where the provider supports truncation. + pub dimensions: Option, +} + +impl EmbeddingRequest { + /// Build a request for a batch of inputs. + pub fn new(inputs: impl IntoIterator>) -> Self { + Self { + inputs: inputs.into_iter().map(Into::into).collect(), + dimensions: None, + } + } +} + +/// Embedding vectors, one per input, in input order. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmbeddingResponse { + /// Vectors, aligned with [`EmbeddingRequest::inputs`]. + pub embeddings: Vec>, + /// Model that produced them. + pub model: String, + /// Profile used. + pub profile: String, + /// Token counts, where reported. + pub usage: TokenUsage, + /// Wall-clock time for the successful attempt. + #[serde(with = "duration_millis")] + pub latency: Duration, +} + +impl EmbeddingResponse { + /// Dimensionality of the returned vectors, when at least one was returned. + #[must_use] + pub fn dimensions(&self) -> Option { + self.embeddings.first().map(Vec::len) + } +} + +/// Serialize `Duration` as whole milliseconds, which is the resolution operators reason in. +mod duration_millis { + use serde::{Deserialize, Deserializer, Serializer}; + use std::time::Duration; + + pub fn serialize(value: &Duration, s: S) -> Result { + s.serialize_u64(value.as_millis() as u64) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + u64::deserialize(d).map(Duration::from_millis) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn params_merge_prefers_per_request_values() { + let profile = GenerationParams { + temperature: Some(0.2), + max_tokens: Some(1024), + top_p: Some(0.9), + stop: vec!["PROFILE".into()], + seed: Some(7), + }; + let request = GenerationParams { + temperature: Some(0.9), + max_tokens: None, + top_p: None, + stop: vec![], + seed: None, + }; + let merged = profile.merged_with(&request); + + assert_eq!(merged.temperature, Some(0.9), "request wins"); + assert_eq!(merged.max_tokens, Some(1024), "profile fills the gap"); + assert_eq!(merged.top_p, Some(0.9)); + assert_eq!(merged.stop, vec!["PROFILE".to_string()]); + assert_eq!(merged.seed, Some(7)); + } + + #[test] + fn stop_sequences_are_replaced_not_merged() { + let profile = GenerationParams { + stop: vec!["A".into(), "B".into()], + ..Default::default() + }; + let request = GenerationParams { + stop: vec!["C".into()], + ..Default::default() + }; + assert_eq!(profile.merged_with(&request).stop, vec!["C".to_string()]); + } + + #[test] + fn prompt_helper_places_system_first() { + let req = ChatRequest::prompt("hello", Some("be terse".into())); + assert_eq!(req.messages.len(), 2); + assert_eq!(req.messages[0].role, Role::System); + assert_eq!(req.messages[1].role, Role::User); + assert_eq!(req.system_message(), Some("be terse")); + assert_eq!(req.conversation().count(), 1); + } + + #[test] + fn prompt_helper_omits_absent_system_message() { + let req = ChatRequest::prompt("hello", None); + assert_eq!(req.messages.len(), 1); + assert_eq!(req.system_message(), None); + } + + #[test] + fn unreported_usage_has_no_total() { + let partial = TokenUsage { + prompt_tokens: Some(10), + completion_tokens: None, + }; + assert_eq!(partial.total(), None, "a partial sum is not a total"); + assert!(partial.is_reported()); + + let none = TokenUsage::default(); + assert_eq!(none.total(), None); + assert!(!none.is_reported()); + + let full = TokenUsage { + prompt_tokens: Some(10), + completion_tokens: Some(5), + }; + assert_eq!(full.total(), Some(15)); + } + + #[test] + fn finish_reason_preserves_unknown_wire_values() { + assert_eq!(FinishReason::from_wire("stop"), FinishReason::Stop); + assert_eq!(FinishReason::from_wire("end_turn"), FinishReason::Stop); + assert_eq!(FinishReason::from_wire("max_tokens"), FinishReason::Length); + assert_eq!(FinishReason::from_wire("length"), FinishReason::Length); + assert_eq!( + FinishReason::from_wire("tool_use"), + FinishReason::Other("tool_use".into()), + "an unmodelled stop reason must not read as a clean finish" + ); + } + + #[test] + fn role_wire_names_match_both_api_families() { + assert_eq!(Role::System.as_str(), "system"); + assert_eq!(Role::User.as_str(), "user"); + assert_eq!(Role::Assistant.as_str(), "assistant"); + } +} diff --git a/specifications/AI_LLM_ROADMAP.md b/specifications/AI_LLM_ROADMAP.md new file mode 100644 index 00000000..2c129646 --- /dev/null +++ b/specifications/AI_LLM_ROADMAP.md @@ -0,0 +1,175 @@ +# Orbit-RS AI/LLM Roadmap + +**Date:** 2026-08-05 +**Companion:** [`COMPETITIVE_ANALYSIS.md`](COMPETITIVE_ANALYSIS.md) — the gap analysis this roadmap answers +**Owner:** Core team + +--- + +## 0. Decisions Taken (no further input required) + +These were decided unilaterally under an explicit mandate to proceed. Each is recorded with its +reasoning so it can be overturned deliberately rather than by accident. + +| # | Decision | Reasoning | Reversal cost | +|---|---|---|---| +| D1 | **Build `orbit/llm` as a new workspace crate** rather than depend on `rig` or `genai` | The value is the router (fallback, breaker, cost, hot-swap) and its integration with `OrbitError`/`tracing`/Prometheus — none of which a third-party crate provides. HTTP shaping over `reqwest` (already a dependency) is the cheap part. | Low — the provider trait is the seam; a `genai`-backed provider could be added behind it | +| D2 | **Four provider shapes, not twenty** — OpenAI, Anthropic, Ollama, OpenAI-compatible | The compatible shape covers Azure, vLLM, Groq, Together, OpenRouter, LM Studio, DeepSeek, Fireworks, and any local server. Chasing a provider count is vanity; the trait makes each new one ~80 LOC. | None — additive | +| D3 | **No bundled model price table** | A price map baked into a database binary goes stale silently and then reports confident wrong costs. Prices are configured per model profile; cost is `None` when unpriced. | None | +| D4 | **`SecretString` for all credentials**, redacting `Debug`/`Display` and refusing `Serialize` | `LLMProvider` today is `Serialize` with a plain `String` api_key. Config dumps and error paths can print it. | None | +| D5 | **Registry is hot-swappable at runtime** via `RwLock`-guarded snapshot, exposed over RESP `LLM.*` | The explicit ask. Read-mostly access pattern; a write is a config change, a read is every request. | None | +| D6 | **Keep `graphrag::LLMProvider` as a compatibility shim** that converts into an `orbit-llm` profile | It is public API re-exported from `orbit_shared::lib`. Breaking it would ripple through the RESP/Cypher/AQL/Postgres GraphRAG engines for no user benefit. | n/a | +| D7 | **Gate `orbit/ml::industry_models` behind an `experimental-industry-models` feature**, default off | 470 stub bodies shipping in a default-on crate is package-level overclaiming (§2.5 of the analysis). Feature-gating is reversible and immediately stops the overclaim. | Low | +| D8 | **Milestones M1–M5 are implemented in this workstream; M6–M8 are specified but not built** | M1–M5 form a coherent shippable unit: provider abstraction → providers → router → integration → control surface. M6+ each depend on M1–M5 landing first. | n/a | + +--- + +## 1. Milestones + +### M1 — `orbit-llm` core ✅ *(this workstream)* + +The crate skeleton and everything provider-independent. + +- `LlmProvider` / `EmbeddingProvider` traits (`async_trait`, object-safe, sealed-adjacent) +- Request/response types: `ChatRequest`, `ChatResponse`, `Message`, `Role`, `TokenUsage`, + `FinishReason`, `EmbeddingRequest`, `EmbeddingResponse` +- `SecretString` — redacting `Debug`/`Display`, `Serialize` emits a redaction marker, `Deserialize` + supported so config can carry it +- `LlmError` (`thiserror`, `#[non_exhaustive]`) with a `is_retryable()` classification +- Shared `reqwest::Client` with connection pooling and a bounded timeout +- `RetryPolicy` — exponential backoff with full jitter, retry-budget capped +- `CircuitBreaker` — closed/open/half-open, per-provider +- `UsageAccountant` — token and cost aggregation, `None` when unknown + +**Acceptance:** `cargo test -p orbit-llm` green; no `unwrap`/`expect` outside tests; a `SecretString` +round-trips through `Debug` without revealing its contents (test asserts this). + +### M2 — Providers ✅ *(this workstream)* + +- **OpenAI** — chat completions + embeddings, org/project headers, configurable base URL +- **Anthropic** — Messages API, `system` as a top-level field (not a message), `anthropic-version` + header, `max_tokens` **required** by the API and therefore non-optional in the profile. + *Closes the `Err("Anthropic client not yet implemented")` defect.* +- **Ollama** — `/api/chat` + `/api/embed`, honest about not reporting cost +- **OpenAI-compatible** — one implementation, a `flavor` for header/path differences, covering + Azure OpenAI (`api-key` header + `api-version` query), vLLM, Groq, Together, OpenRouter, + LM Studio, DeepSeek, Fireworks + +**Acceptance:** each provider's request body is asserted by a table-driven test against the shape +the vendor documents (system-message placement, required fields, header names). Every parameter in +the profile appears in the emitted body — verified by test, not by inspection. + +### M3 — Registry + Router ✅ *(this workstream)* + +- `ModelProfile` — provider + model + params + optional pricing + fallback chain +- `LlmRegistry` — named profiles, a default, `register`/`remove`/`set_default`, all at runtime +- `Router` — the request path: resolve → timeout → retry → breaker → fallback → account +- Config layering: `LLM_*` env vars over `[llm]` TOML, per 12-factor III +- Prometheus-shaped metrics: requests, failures, fallbacks fired, breaker state, tokens, latency + +**Acceptance:** tests cover — fallback fires on primary failure and is *counted*; breaker opens +after threshold and rejects fast; retry respects the budget; a non-retryable error (401) does not +retry; env overrides TOML; registry swap is visible to an in-flight-adjacent read. + +### M4 — GraphRAG integration ✅ *(this workstream)* + +- `graphrag/llm_client.rs` becomes a thin adapter over `orbit-llm` +- `orbit_shared::graphrag::LLMProvider` gains `TryFrom` → `ModelProfile` (D6) +- The double-`match` parameter re-extraction in `graph_rag_actor.rs` is deleted — the profile + carries its own parameters +- `[llm]` section added to `config/orbit-server.toml` with documented env overrides +- The inline `std::env::var("OPENAI_API_KEY")` + hardcoded `"gpt-4"` in `resp/commands/graphrag.rs` + is replaced by registry lookup + +**Acceptance:** GraphRAG RAG query runs end-to-end against Ollama with no code change from the +pre-existing path; Anthropic now works where it previously returned an error. + +### M5 — `LLM.*` control surface ✅ *(this workstream)* + +Runtime switchability, exposed over RESP (the protocol with the cleanest command-module structure): + +| Command | Effect | +|---|---| +| `LLM.PROVIDERS` | List provider shapes the build supports | +| `LLM.MODELS` | List registered profiles, marking the default | +| `LLM.INFO ` | Full profile detail, secrets redacted | +| `LLM.REGISTER [KEY v]…` | Add/replace a profile at runtime | +| `LLM.UNREGISTER ` | Remove a profile | +| `LLM.USE ` | **Switch the default model with no restart** | +| `LLM.GENERATE [MODEL p] [SYSTEM s] [MAXTOKENS n] [TEMPERATURE t]` | One-shot generation | +| `LLM.EMBED [MODEL p]` | Embeddings | +| `LLM.STATS [profile]` | Requests, failures, fallbacks, tokens, cost, breaker state | + +**Acceptance:** `redis-cli` session demonstrates registering a second profile, switching to it with +`LLM.USE`, and seeing `LLM.STATS` attribute the next generation to the new profile — all without +restarting the server. + +### M6 — Streaming *(specified, not built)* + +`generate_stream` returning `impl Stream>`; SSE parsing for OpenAI and +Anthropic (different event shapes), NDJSON for Ollama. Surfaces: RESP push, HTTP SSE at +`/v1/llm/stream`, gRPC server-streaming. Blocked on M1–M3. + +### M7 — Semantic cache + budgets *(specified, not built)* + +The highest-leverage item in the analysis (§3.3). Cache key = embedding of the normalized prompt + +profile identity; lookup is a similarity query against the **existing in-process HNSW index** with a +configurable threshold and TTL. Bounded by entry count *and* byte size — an unbounded cache is the +`CLAUDE.md` "cache that never evicts" antipattern. Plus per-tenant API keys, rate limits, and spend +budgets that reject rather than silently exceed. + +**Why this matters competitively:** LiteLLM needs Redis + an external vector store for semantic +caching. Orbit-RS has both in-process. This is a differentiator available for the cost of a query. + +### M8 — Auto-embedding on write *(specified, not built)* + +`ALTER TABLE t ADD EMBEDDING col USING FROM (expr)`. On insert/update, the row's embedding +is generated through the M1–M3 stack and indexed transactionally with the row. This is the Weaviate +vectorizer feature, and after M1–M3 it is mostly plumbing. Requires M1–M3 + batching (embedding one +row per HTTP call is not viable — batch by transaction). + +--- + +## 2. Sequencing + +``` +M1 core ──▶ M2 providers ──▶ M3 registry+router ──┬──▶ M4 graphrag ──▶ M5 LLM.* surface + ├──▶ M6 streaming + ├──▶ M7 semantic cache + budgets + └──▶ M8 auto-embedding on write +``` + +M1→M5 are strictly sequential (each consumes the last). M6/M7/M8 are independent of each other and +can be parallelized once M3 lands. + +Out of this workstream but tracked in `COMPETITIVE_ANALYSIS.md` §3: vector quantization and +pre-filtered search (§3.4), managed cloud (§3.5), `orbit/ml` remediation (§3.6, partially addressed +by D7), and benchmarking `orbit/compute` (§3.7). + +--- + +## 3. Non-Goals + +- **Not** chasing a provider count. Four shapes, ~15 services. Adding a fifteenth is ~80 LOC when + someone actually needs it. +- **Not** an agent framework. Orbit-RS is the memory and retrieval layer agents call, not the loop. + This is the boundary that makes `rig` the wrong dependency (D1). +- **Not** a bundled price table (D3). +- **Not** fine-tuning or training orchestration. `orbit/ml` has not earned more surface area (D7). + +--- + +## 4. Verification Plan + +Per `CLAUDE.md` → *Verification*: a green build proves almost nothing. In yield order: + +1. `cargo test -p orbit-llm` — router, registry, config layering, secret redaction, request shaping +2. `make check` + `make format` — zero warnings +3. **Run it.** `make dev`, then a `redis-cli` session against a local Ollama exercising + `LLM.REGISTER` → `LLM.USE` → `LLM.GENERATE` → `LLM.STATS` +4. **Reconcile against an external reference** — the Anthropic request body is checked against the + documented Messages API shape (system as top-level field, `max_tokens` required), not against + our own expectation of it +5. **Audit affordances** — every `Provider` enum variant must appear at a construction site, and + every registered command must appear in the dispatch table. An unreachable variant is data + pretending to be control flow. diff --git a/specifications/COMPETITIVE_ANALYSIS.md b/specifications/COMPETITIVE_ANALYSIS.md new file mode 100644 index 00000000..259cbe1c --- /dev/null +++ b/specifications/COMPETITIVE_ANALYSIS.md @@ -0,0 +1,297 @@ +# Orbit-RS Competitive Landscape & Fit-Gap Analysis + +**Date:** 2026-08-05 +**Status:** Living document — refresh alongside `PRD.md` when capabilities change +**Scope:** Competitor landscape, feature-by-feature fit-gap, prioritized improvement areas, with +particular depth on the AI/LLM surface (provider and model switchability). + +> **Method note.** Every "Orbit-RS today" claim below was checked against the tree at +> commit `2aded3b8` (branch `refactor/functional-idioms-and-deps`) — by reading the module, not by +> reading the docs about the module. Where a capability exists as scaffolding rather than working +> code, it is marked **Scaffold**, not **Yes**. A green build is not evidence a feature works +> (see `CLAUDE.md` → *Verification*), and neither is a heading in a design doc. + +--- + +## 1. Where Orbit-RS Actually Sits + +Orbit-RS is an unusual shape. Almost nothing in the market occupies the same square: + +- **Multi-protocol at the wire level.** PostgreSQL, MySQL, CQL, RESP, Cypher/Bolt, AQL, MongoDB, + gRPC, REST, Flight, and OrbitQL are served from *one process over one storage layer*. Competitors + generally pick one wire protocol and offer drivers for the rest. +- **Actor-model core.** Virtual actors are the unit of state and addressing, not tables or + documents. This is a Microsoft-Orleans lineage, not a database lineage. +- **Multi-model storage.** Relational, document, graph, time-series, vector, and key-value share + one engine. +- **Written in Rust**, with a heterogeneous-compute crate (`orbit/compute`, 28.7k LOC) targeting + SIMD/GPU acceleration. + +The nearest single competitor is **SurrealDB**; the realistic competitive set is a *stack* of +four to six products that Orbit-RS proposes to replace with one. + +### 1.1 Competitor set + +| Tier | Products | Why they compete | +|------|----------|------------------| +| **Direct — multi-model, AI-native** | SurrealDB 3.0, ArangoDB, Fauna (EOL), EdgeDB/Gel | Same "one database, many models, one query language" pitch | +| **Vector-first** | Pinecone, Qdrant, Weaviate, Milvus/Zilliz, Chroma, LanceDB | Own the RAG retrieval layer Orbit-RS wants | +| **Incumbent + extension** | Postgres + pgvector/pgvectorscale/AGE/TimescaleDB, MongoDB Atlas (Vector Search), Redis 8 (RediSearch), Oracle AI Database, SingleStore | The default choice; "good enough and already deployed" | +| **Graph** | Neo4j (+ GraphRAG package), TigerGraph, Memgraph, Kuzu | Own the graph + GraphRAG narrative | +| **Analytics/lakehouse** | ClickHouse, DuckDB, Databricks, Snowflake | Own the analytical half of HTAP | +| **AI gateway (adjacent, and the model for §4)** | LiteLLM, Portkey, OpenRouter, Cloudflare AI Gateway, Bedrock/Vertex | Define what "provider and model switchability" means in 2026 | +| **Rust LLM libraries** | `rig`, `genai`, `async-openai`, `llm-connector` | What Orbit-RS would otherwise depend on | + +### 1.2 What the market moved to in 2025–2026 + +Three shifts matter, and Orbit-RS is positioned for all three but delivers on none of them fully: + +1. **Agent memory is the new workload.** SurrealDB 3.0 (GA 2026-02-17, $23M raise) reframed the + multi-model database explicitly as *AI agent memory and context graphs* — one ACID transaction + spanning graph traversal, structured filter, and vector similarity. This is the single most + direct competitive threat, because it is Orbit-RS's architecture with a finished AI story + bolted on top. +2. **Inference moved inside the database boundary.** Oracle AI Database, SingleStore, MongoDB, and + Postgres extensions now generate embeddings *during query execution* — no application round + trip, no separate embedding service, data never leaves the security boundary. ONNX is the de + facto handoff format. +3. **The model gateway became a required component.** LiteLLM/Portkey/OpenRouter normalized a + feature set — unified API over 100+ providers, fallback chains, retries, circuit breakers, + load balancing, semantic caching, per-tenant keys, budget limits, cost attribution. Any product + that calls an LLM is now measured against that list. + +--- + +## 2. Feature-by-Feature Fit-Gap + +Legend: **Yes** = working and exercised · **Partial** = works with material limits · +**Scaffold** = types/signatures exist, behavior does not · **No** = absent + +### 2.1 Core database + +| Feature | Orbit-RS | Best-in-class | Gap | +|---|---|---|---| +| Multi-protocol wire compatibility | **Yes** (10+) | Nobody | **Orbit-RS advantage — the differentiator** | +| Multi-model in one engine | **Yes** | SurrealDB, ArangoDB | Parity | +| ACID transactions | **Yes** (MVCC, 2PC, Saga) | SurrealDB, Postgres | Parity | +| Distributed consensus | **Partial** (Raft present) | CockroachDB, TiKV | Maturity/scale-test gap | +| Virtual actor model | **Yes** | Orleans (not a DB) | Unique | +| SIMD/GPU query acceleration | **Partial** | ClickHouse (SIMD), HeavyDB (GPU) | Unproven at benchmark level | +| Storage tiering (S3/Iceberg) | **Partial** | Databricks, Snowflake | Cold-tier maturity | +| Managed cloud offering | **No** | Every competitor | **Adoption blocker** | + +### 2.2 Vector / retrieval + +| Feature | Orbit-RS | Best-in-class | Gap | +|---|---|---|---| +| HNSW index | **Yes** (`protocols/vector_index.rs`) | Qdrant, Weaviate | Parity on algorithm | +| IVFFlat index | **Yes** | pgvector | Parity | +| Quantization (SQ/PQ/binary) | **No** | Qdrant, Milvus, pgvectorscale | **Memory cost 4–32× worse at scale** | +| Filtered vector search (pre-filter) | **Partial** | Qdrant (best-in-class) | Post-filter only ⇒ recall collapses under selective filters | +| Hybrid search (BM25 + vector + RRF) | **Partial** | Weaviate, Elastic | Full-text side is the weak half; GraphRAG context builder has a literal `// TODO: Add full-text search context` | +| Multi-vector / late interaction (ColBERT) | **No** | Vespa, Qdrant | Emerging table stakes | +| Reranking (cross-encoder) | **No** | Cohere, Weaviate, Vespa | Quality gap in RAG | +| **Automatic embedding generation on write** | **No** | Weaviate (vectorizers), MongoDB, Oracle | **Highest-leverage retrieval gap** | +| Vector index persistence/recovery | **Partial** | All | Rebuild cost on restart | + +### 2.3 AI / LLM (the focus area) + +This is where the distance from the market is largest, and it is also the cheapest to close. + +| Feature | Orbit-RS **before** this work | Best-in-class | Gap severity | +|---|---|---|---| +| LLM provider abstraction | **Partial** — `LLMClient` trait, 348 LOC, GraphRAG-internal | `rig` (20+), `genai` (26+), LiteLLM (100+) | High | +| Anthropic support | **No** — `Err("Anthropic client not yet implemented")` | Universal | **High — a named enum variant that returns an error** | +| Azure OpenAI / Bedrock / Vertex / Gemini | **No** | LiteLLM, Portkey | High (enterprise procurement blocker) | +| OpenAI-compatible endpoints (vLLM, Groq, Together, OpenRouter, LM Studio) | **Partial** — one hardcoded `Local` variant | LiteLLM | Medium | +| **Runtime model switching (no restart)** | **No** — provider baked into actor construction | LiteLLM, Portkey | **High — the explicit ask** | +| Fallback chains / failover | **No** | LiteLLM, Portkey, OpenRouter | High (availability) | +| Retries with backoff + jitter | **No** — single attempt, error on failure | Universal | High | +| Circuit breaker | **No** | Portkey, LiteLLM | Medium | +| Request timeouts | **No** — unbounded `reqwest` default | Universal | **High — a hung provider hangs a query** | +| Streaming responses | **No** — `"stream": false` hardcoded | Universal | Medium | +| Embeddings via provider API | **No** abstraction | Universal | High | +| Token usage accounting | **Partial** — captured, never aggregated | LiteLLM | Medium | +| Cost tracking / budgets | **No** | LiteLLM, Portkey | Medium | +| Semantic caching | **No** | LiteLLM, Portkey | Medium — *and Orbit-RS already owns a vector index, so this is nearly free* | +| Per-tenant keys / rate limits | **No** | LiteLLM, Portkey | Medium (multi-tenant blocker) | +| Connection pooling | **No** — `Client::new()` **per request**, 3 sites | Universal | **High — new TLS handshake and connection pool per LLM call** | +| Secret handling | **No** — `api_key: String`, printable via `Debug` derive on config paths | Universal | **High — credential leak into logs** | +| Config honesty | **Broken** — `temperature`/`max_tokens` accepted by `create_llm_client`, then bound to `_` and discarded; the caller re-extracts them by matching the enum a second time | n/a | **Correctness. Direct violation of `CLAUDE.md` → Modelling Honesty: "decorative parameters invite false confidence"** | +| In-database inference (SQL-callable) | **Scaffold** (`orbit/ml/sql_extensions`) | Oracle, SingleStore, MindsDB | High | +| ONNX runtime | **No** | Oracle, SingleStore | Medium | +| GraphRAG | **Partial** — entity extraction, multi-hop reasoning, KG build all present | Neo4j GraphRAG, SurrealDB 3.0 | **Real asset; underserved by the LLM layer beneath it** | +| MCP server | **Partial** (`protocols/mcp/`, 12 modules) | Growing | Good position | + +### 2.4 Operations + +| Feature | Orbit-RS | Gap | +|---|---|---| +| Structured logging (`tracing`) | **Yes** | — | +| Prometheus metrics | **Yes** (`orbit-server-prometheus`) | LLM/AI metrics absent | +| K8s operator + Helm | **Yes** | — | +| Config from env over TOML | **Partial** | AI subsystem read env ad hoc (`std::env::var("OPENAI_API_KEY")` inline in a RESP handler) | +| Backup/PITR | **Partial** | Maturity | +| Multi-region | **Partial** | Maturity | + +### 2.5 Honest weak spots outside AI + +Measured, not guessed — `TODO`/`unimplemented!`/`FIXME` density per crate: + +| Crate | TODOs | LOC | Read | +|---|---:|---:|---| +| `orbit/ml` | **470** | 31,094 | **Largely scaffolding.** `industry_models/` is a directory tree of `// TODO: Implement …` method bodies across healthcare/fintech/adtech/defense/logistics/banking/insurance. `graph_neural_networks/mod.rs` is a one-line placeholder. This crate promises far more than it does. | +| `orbit/server` | 296 | 258,484 | Normal density for its size | +| `orbit/shared` | 169 | 86,771 | Normal | +| `orbit/compute` | 44 | 28,745 | Reasonable | +| `orbit/engine` | 30 | 36,743 | Good | + +**The `orbit/ml` verdict is the most important non-LLM finding in this document.** Seven industry +verticals' worth of model APIs exist as signatures with empty bodies. That is a liability, not an +asset: it inflates the apparent surface area, it cannot be tested, and any user who calls into it +gets silence or a default. The recommendation is in §3, item 6. + +--- + +## 3. Prioritized Improvement Areas + +Ranked by (competitive damage if unfixed) ÷ (effort to fix). + +1. **LLM provider/model switchability** — *addressed by this workstream.* Small, self-contained, + unblocks every AI feature above it, and directly closes the Anthropic/Azure/Bedrock enterprise + procurement objection. **Doing now.** +2. **Automatic embedding generation on write.** Orbit-RS has HNSW *and* (after item 1) an embedding + provider abstraction. Wiring "column X of table Y is auto-embedded by model Z" turns two + components into the feature Weaviate charges for. Requires item 1 first. +3. **Semantic cache over the existing vector index.** Once items 1–2 land, this is a lookup against + an index Orbit-RS already ships. LiteLLM needs a bolt-on Redis + vector store for this; Orbit-RS + needs a query. Highest ratio of competitive-story to code in the whole list. +4. **Vector quantization + pre-filtered search.** The pure scale/quality gap vs Qdrant. Larger + effort, no dependency on items 1–3, can run in parallel. +5. **Managed cloud offering.** Largest adoption blocker, entirely outside this workstream. +6. **Decide `orbit/ml`'s fate.** Either (a) gate `industry_models` behind a `experimental-` + feature flag and remove it from public docs until real, or (b) delete it and reintroduce + verticals one at a time with tests. Leaving 470 stub bodies in a shipped crate is a + modelling-honesty failure at the package level. Recommendation: **(a) now, (b) as capacity + allows** — cheaper, reversible, and immediately stops overclaiming. +7. **Benchmark the SIMD/GPU claim.** `orbit/compute` is 28.7k LOC of differentiator with no + published number against ClickHouse or DuckDB. Unmeasured performance work is indistinguishable + from no performance work. + +--- + +## 4. Deep Dive — LLM Provider & Model Switchability + +### 4.1 What the code did before this workstream + +`orbit/server/src/protocols/graphrag/llm_client.rs`, 348 lines, three clients (OpenAI, Ollama, +Local) and a factory. Reading it against the market list produces eleven concrete defects: + +| # | Defect | Evidence | Consequence | +|---|---|---|---| +| 1 | Anthropic returns an error | `create_llm_client` → `Err("Anthropic client not yet implemented")` | A configured provider fails at call time, not config time | +| 2 | Decorative config | `LLMProvider::OpenAI { temperature: _, max_tokens: _ }` in the factory | Config knobs that change nothing; caller compensates with a second `match` | +| 3 | New HTTP client per call | `Client::new()` inside each `generate()` | No pooling; TLS handshake per LLM call | +| 4 | No timeout | `reqwest` default is none | A hung provider hangs the calling query indefinitely | +| 5 | No retry | Single attempt | A 429 or transient 503 fails the user's query | +| 6 | No fallback | One provider per call | Provider outage = feature outage | +| 7 | No streaming | `"stream": false` literal | Cannot support incremental UX | +| 8 | Secrets in plain `String` | `api_key: String` on a `Serialize` type | Credential reachable by log/serialize path | +| 9 | No runtime switching | Provider chosen at actor construction | Changing model requires a restart | +| 10 | Env read inline in a handler | `std::env::var("OPENAI_API_KEY")` in `resp/commands/graphrag.rs` with a hardcoded `"gpt-4"` | 12-factor violation; model name unconfigurable | +| 11 | No embeddings abstraction | Absent | GraphRAG's embedding path has no provider story | + +### 4.2 Build vs. buy + +| Option | Verdict | +|---|---| +| Depend on `rig` | Agent framework — brings a vector-store abstraction Orbit-RS *is*, and an agent loop it does not want. Impedance mismatch. | +| Depend on `genai` | Closest fit, 26+ providers. But: no fallback/circuit-breaker/cost layer, no `OrbitError` integration, and it owns the retry policy Orbit-RS must own. | +| Depend on `async-openai` | OpenAI-shaped only. | +| **Build `orbit-llm`** | **Chosen.** ~1.5k LOC of HTTP shaping over `reqwest` (already a dependency). The value is not the HTTP calls — it is the *router*: fallback, breaker, cost, registry, and hot-swap, all of which need to be Orbit-native to integrate with `OrbitError`, `tracing` spans, Prometheus, and the config layering. A wrapper around `genai` would still need all of that, plus a translation layer. | + +**Decision recorded:** build `orbit/llm` as a first-class workspace crate, depending only on +`reqwest`/`serde`/`tokio`/`orbit-shared`. No new heavyweight dependency. Providers are +`OpenAI-compatible`-shaped where possible so one implementation covers Azure, vLLM, Groq, Together, +OpenRouter, LM Studio, and DeepSeek. + +### 4.3 Target design + +``` + ┌──────────────────────────────────────────┐ + GraphRAG ───────────▶│ LlmRegistry │ + RESP LLM.* ────────▶│ name → ModelProfile (provider+model+ │ + SQL/OrbitQL ────────▶│ params+fallbacks), hot-swappable │ + MCP ────────▶│ Arc-style read-mostly access │ + └────────────────┬─────────────────────────┘ + │ resolve(name | default) + ▼ + ┌──────────────────────────────────────────┐ + │ Router │ + │ timeout → retry(backoff+jitter) → │ + │ circuit breaker → fallback chain → │ + │ usage/cost accounting │ + └────────────────┬─────────────────────────┘ + ▼ + ┌───────────┬───────────┬──────────────┬──────────────────┐ + │ OpenAI │ Anthropic │ Ollama │ OpenAI-compatible│ + │ │ │ │ (Azure/vLLM/Groq │ + │ │ │ │ /Together/…) │ + └───────────┴───────────┴──────────────┴──────────────────┘ + shared reqwest::Client (pooled, timeout-bounded) +``` + +**Design rules adopted (all traceable to `CLAUDE.md`):** + +- Every parameter accepted is a parameter used. No `temperature: _`. If a provider cannot honor a + parameter, the response says so rather than silently dropping it. +- API keys are `SecretString` — `Debug`/`Display` redact, `Serialize` refuses. +- Absent usage data stays absent. Ollama does not report token counts; `TokenUsage` is `Option`, + never `unwrap_or(0)`. A zero token count is a claim that zero tokens were used. +- Cost is `Option` and only present when a price is *configured* for that model. Orbit-RS + does not ship a guessed price table that silently goes stale. +- Every fallback that fires is logged and counted. A silent failover is an outage you cannot see. +- Illegal states unrepresentable: a `ModelProfile` cannot exist without a resolvable provider. + +### 4.4 Post-implementation position + +After M1–M5 (see `AI_LLM_ROADMAP.md`), the row-by-row standing versus LiteLLM — the reference +implementation for this category: + +| Capability | LiteLLM | Orbit-RS after M5 | +|---|---|---| +| Provider count | 100+ | 4 native shapes covering ~15 named services | +| Unified API | Yes | Yes | +| Fallback chains | Yes | Yes | +| Retries + backoff | Yes | Yes (+ jitter) | +| Circuit breaker | Yes | Yes | +| Timeouts | Yes | Yes | +| Streaming | Yes | M6 | +| Embeddings | Yes | Yes | +| Cost tracking | Yes (bundled price map) | Yes (configured prices only — honest, not automatic) | +| Semantic caching | Bolt-on Redis + vector store | M7 — **native, over the vector index already in-process** | +| Per-tenant budgets | Yes | M7 | +| **Runs inside the database** | No | **Yes — the whole point** | + +The last row is the strategic claim. LiteLLM is a proxy you deploy next to your database. +Orbit-RS's version is a subsystem of the database, which means retrieval, embedding, cache, and +generation share one process, one transaction boundary, and one security boundary. That is the +same argument Oracle and SingleStore are making about inference, applied to the generation half. + +--- + +## 5. Sources + +- [SurrealDB 3.0 GA / $23M raise — Tech.eu](https://tech.eu/2026/02/17/surrealdb-secures-23m-and-launches-surrealdb-3-0-to-address-ai-agent-memory-challenges/) +- [SurrealDB 3.0 replaces the five-database RAG stack — VentureBeat](https://venturebeat.com/data/surrealdb-3-0-wants-to-replace-your-five-database-rag-stack-with-one) +- [SurrealDB AI-native multi-model — SiliconANGLE](https://siliconangle.com/2026/02/17/surrealdb-raises-23m-expand-ai-native-multi-model-database/) +- [Loading embedding models into Oracle AI Database (2026)](https://blogs.oracle.com/developers/how-to-load-embedding-models-into-oracle-ai-database-in-2026) +- [Redis vector benchmark vs Aurora pgvector and MongoDB Atlas](https://redis.io/blog/benchmarking-results-for-vector-databases/) +- [Vector database comparison — Zilliz](https://zilliz.com/comparison) +- [Best vector databases 2026 — DataCamp](https://www.datacamp.com/blog/the-top-5-vector-databases) +- [LiteLLM vs Portkey vs OpenRouter — Developers Digest](https://www.developersdigest.tech/blog/llm-router-comparison-2026) +- [Building an LLM router gateway: fallbacks, semantic caching, per-tenant keys, cost tracking](https://www.codersarts.com/post/how-to-build-an-llm-router-gateway-with-litellm-fallbacks-semantic-caching-per-tenant-keys-and-c) +- [Best LLM gateways 2026 — Contabo](https://contabo.com/blog/best-llm-gateways/) +- [rig — Rust LLM/agent framework](https://rig.rs/) +- [genai — Rust multi-provider generative AI client](https://github.com/jeremychone/rust-genai) +- [llm-connector — crates.io](https://crates.io/crates/llm-connector/0.1.0) From 7de45ebd443613f2f436c935495cb3d92c612c86 Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Wed, 5 Aug 2026 20:23:24 -0700 Subject: [PATCH 22/36] Storage: optional timestamps; add LLM config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make stored connection timestamps optional and more robust in the desktop storage: created_at is now Option (RFC3339) with safe parse that warns on unreadable stamps; last_used likewise parsed safely; password decryption failures are logged and dropped instead of failing; ConnectionType parsed via FromStr; added cluster_root setting and derived Clone for StorageManager. Add a new llm configuration module (orbit/llm/src/config.rs) implementing provider configs, model profiles, pricing, env-layering per 12‑factor, validation, defaults, and tests (OpenAI/Anthropic/Ollama/Compatible flavors, timeouts, retries, breakers). --- orbit/desktop/src-tauri/src/storage.rs | 100 +-- orbit/llm/src/config.rs | 837 +++++++++++++++++++++++++ 2 files changed, 888 insertions(+), 49 deletions(-) create mode 100644 orbit/llm/src/config.rs diff --git a/orbit/desktop/src-tauri/src/storage.rs b/orbit/desktop/src-tauri/src/storage.rs index 9271d0f6..b13956f9 100644 --- a/orbit/desktop/src-tauri/src/storage.rs +++ b/orbit/desktop/src-tauri/src/storage.rs @@ -8,10 +8,9 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use tauri::api::path::app_data_dir; use tauri::Config; -use tracing::{error, info, warn}; +use tracing::info; -use crate::connections::{Connection, ConnectionInfo}; -use crate::queries::QueryRequest; +use crate::connections::Connection; /// Application data storage #[derive(Debug, Clone, Serialize, Deserialize)] @@ -28,8 +27,13 @@ pub struct AppStorage { pub struct StoredConnection { pub id: String, pub info: StoredConnectionInfo, - pub created_at: String, + /// RFC 3339. Absent for records written before this field existed, and for + /// records whose stamp could not be read back. + #[serde(default)] + pub created_at: Option, + #[serde(default)] pub last_used: Option, + #[serde(default)] pub query_count: u64, } @@ -61,6 +65,11 @@ pub struct AppSettings { pub word_wrap: bool, pub max_query_history: usize, pub connection_timeout: u64, + /// Orbit-RS checkout whose local cluster the cluster panel manages. + /// + /// `None` means "look for one next to the working directory". + #[serde(default)] + pub cluster_root: Option, } impl Default for AppSettings { @@ -75,6 +84,7 @@ impl Default for AppSettings { word_wrap: false, max_query_history: 1000, connection_timeout: 5000, + cluster_root: None, } } } @@ -104,6 +114,7 @@ impl Default for AppStorage { } /// Storage manager for persisting application data +#[derive(Clone)] pub struct StorageManager { storage_dir: PathBuf, storage_file: PathBuf, @@ -217,6 +228,9 @@ pub enum StorageError { impl StoredConnection { /// Convert to Connection with password decryption + /// # Errors + /// Returns [`StorageError::ParseError`] when the stored protocol name is + /// not one this build knows. pub fn to_connection( &self, enc_manager: &crate::encryption::EncryptionManager, @@ -224,33 +238,37 @@ impl StoredConnection { use crate::connections::{ConnectionInfo, ConnectionStatus, ConnectionType}; use chrono::DateTime; - // Decrypt password if present - let password = if let Some(encrypted) = &self.info.password_encrypted { + // A password that will not decrypt is dropped rather than guessed at; + // the connection is still usable for endpoints that need no password. + let password = self.info.password_encrypted.as_ref().and_then(|encrypted| { enc_manager .decrypt(encrypted) - .map_err(|e| StorageError::ParseError(format!("Failed to decrypt password: {}", e))) + .map_err(|e| tracing::warn!("could not decrypt password for {}: {e}", self.id)) .ok() - } else { - None - }; + }); + + let connection_type: ConnectionType = self + .info + .connection_type + .parse() + .map_err(|e| StorageError::ParseError(format!("{e}")))?; + + /// Parse an RFC 3339 stamp, reporting `None` instead of substituting + /// the current time for one that will not parse. + fn timestamp(raw: &str) -> Option> { + DateTime::parse_from_rfc3339(raw) + .ok() + .map(|dt| dt.with_timezone(&chrono::Utc)) + } - let connection_type = match self.info.connection_type.as_str() { - "PostgreSQL" => ConnectionType::PostgreSQL, - "OrbitQL" => ConnectionType::OrbitQL, - "Redis" => ConnectionType::Redis, - "MySQL" => ConnectionType::MySQL, - "CQL" => ConnectionType::CQL, - "Cypher" => ConnectionType::Cypher, - "AQL" => ConnectionType::AQL, - "FlightSQL" => ConnectionType::FlightSQL, - "OrbitWire" => ConnectionType::OrbitWire, - _ => { - return Err(StorageError::ParseError(format!( - "Unknown connection type: {}", - self.info.connection_type - ))) - } - }; + let created_at = self.created_at.as_deref().and_then(timestamp); + if created_at.is_none() && self.created_at.is_some() { + tracing::warn!( + "connection {} has an unreadable created_at ({:?}); reporting it as unknown", + self.id, + self.created_at + ); + } Ok(Connection { id: self.id.clone(), @@ -267,14 +285,8 @@ impl StoredConnection { additional_params: self.info.additional_params.clone(), }, status: ConnectionStatus::Disconnected, - created_at: DateTime::parse_from_rfc3339(&self.created_at) - .map(|dt| dt.with_timezone(&chrono::Utc)) - .unwrap_or_else(|_| chrono::Utc::now()), - last_used: self - .last_used - .as_ref() - .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) - .map(|dt| dt.with_timezone(&chrono::Utc)), + created_at, + last_used: self.last_used.as_deref().and_then(timestamp), query_count: self.query_count, }) } @@ -295,23 +307,13 @@ impl Connection { None }; - let connection_type = match self.info.connection_type { - crate::connections::ConnectionType::PostgreSQL => "PostgreSQL", - crate::connections::ConnectionType::OrbitQL => "OrbitQL", - crate::connections::ConnectionType::Redis => "Redis", - crate::connections::ConnectionType::MySQL => "MySQL", - crate::connections::ConnectionType::CQL => "CQL", - crate::connections::ConnectionType::Cypher => "Cypher", - crate::connections::ConnectionType::AQL => "AQL", - crate::connections::ConnectionType::FlightSQL => "FlightSQL", - crate::connections::ConnectionType::OrbitWire => "OrbitWire", - }; - Ok(StoredConnection { id: self.id.clone(), info: StoredConnectionInfo { name: self.info.name.clone(), - connection_type: connection_type.to_string(), + // Same table as `to_connection` reads back, via Display/FromStr, + // so the two directions cannot drift apart. + connection_type: self.info.connection_type.to_string(), host: self.info.host.clone(), port: self.info.port, database: self.info.database.clone(), @@ -321,7 +323,7 @@ impl Connection { connection_timeout: self.info.connection_timeout, additional_params: self.info.additional_params.clone(), }, - created_at: self.created_at.to_rfc3339(), + created_at: self.created_at.map(|dt| dt.to_rfc3339()), last_used: self.last_used.map(|dt| dt.to_rfc3339()), query_count: self.query_count, }) diff --git a/orbit/llm/src/config.rs b/orbit/llm/src/config.rs new file mode 100644 index 00000000..96fbde92 --- /dev/null +++ b/orbit/llm/src/config.rs @@ -0,0 +1,837 @@ +//! Configuration: provider settings, model profiles, and env layering. +//! +//! Per 12-factor III, config comes from the environment layered over `config/orbit-server.toml`. +//! Credentials in particular should arrive through env, never through a file in the repo — which is +//! why [`ProviderConfig`] holds [`SecretString`] and why [`LlmConfig::apply_env_overrides`] can +//! fill a profile's key from env without the file naming it at all. + +use crate::breaker::BreakerConfig; +use crate::error::{LlmError, LlmResult}; +use crate::provider::ProviderKind; +use crate::retry::RetryPolicy; +use crate::secret::SecretString; +use crate::types::{Cost, GenerationParams, TokenUsage}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::time::Duration; + +/// Default request timeout. Unbounded was the pre-existing behavior, and it let a hung provider +/// pin a database query open indefinitely. +pub const DEFAULT_TIMEOUT_MS: u64 = 60_000; + +/// Named variants of the OpenAI-compatible shape. +/// +/// These differ only in authentication header and URL construction; the request and response bodies +/// are identical. Keeping them as a flavor rather than separate providers is what makes "support +/// another OpenAI-compatible service" a one-line change. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum CompatibleFlavor { + /// `Authorization: Bearer`, `{base_url}/chat/completions`. Covers vLLM, Groq, Together, + /// OpenRouter, LM Studio, DeepSeek, Fireworks, and any local OpenAI-compatible server. + #[default] + Generic, + /// Azure OpenAI: `api-key` header, deployment in the path, `api-version` in the query string. + AzureOpenAi, +} + +impl CompatibleFlavor { + /// Stable identifier for config and commands. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + CompatibleFlavor::Generic => "generic", + CompatibleFlavor::AzureOpenAi => "azure_openai", + } + } + + /// Parse a flavor, including the service aliases that map onto it. + pub fn parse(value: &str) -> LlmResult { + match value.to_ascii_lowercase().as_str() { + "azure" | "azure_openai" | "azureopenai" => Ok(CompatibleFlavor::AzureOpenAi), + "generic" | "compatible" | "local" | "vllm" | "groq" | "together" | "openrouter" + | "lmstudio" | "lm_studio" | "deepseek" | "fireworks" => Ok(CompatibleFlavor::Generic), + other => Err(LlmError::configuration(format!( + "unknown compatible flavor '{other}'; expected 'generic' or 'azure_openai'" + ))), + } + } +} + +/// How to reach a provider. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "provider", rename_all = "snake_case")] +#[non_exhaustive] +pub enum ProviderConfig { + /// OpenAI's own API. + OpenAi { + /// API credential. + #[serde(default)] + api_key: SecretString, + /// API root; override for a proxy or a pinned region. + #[serde(default = "default_openai_base")] + base_url: String, + /// `OpenAI-Organization` header, when the account requires one. + #[serde(default)] + organization: Option, + /// `OpenAI-Project` header, when the account requires one. + #[serde(default)] + project: Option, + }, + + /// Anthropic's Messages API. + Anthropic { + /// API credential. + #[serde(default)] + api_key: SecretString, + /// API root. + #[serde(default = "default_anthropic_base")] + base_url: String, + /// `anthropic-version` header. Pinned rather than tracking latest, because the Messages + /// API's response shape is versioned and a silent bump would change parsing. + #[serde(default = "default_anthropic_version")] + version: String, + }, + + /// A local Ollama daemon. + Ollama { + /// Daemon root. + #[serde(default = "default_ollama_base")] + base_url: String, + }, + + /// Any OpenAI-compatible endpoint. + Compatible { + /// Header and URL convention to use. + #[serde(default)] + flavor: CompatibleFlavor, + /// Credential; absent for servers that require no authentication. + #[serde(default)] + api_key: Option, + /// API root. + base_url: String, + /// `api-version` query parameter, required by Azure OpenAI. + #[serde(default)] + api_version: Option, + }, +} + +fn default_openai_base() -> String { + "https://api.openai.com/v1".to_string() +} + +fn default_anthropic_base() -> String { + "https://api.anthropic.com/v1".to_string() +} + +fn default_anthropic_version() -> String { + "2023-06-01".to_string() +} + +fn default_ollama_base() -> String { + "http://localhost:11434".to_string() +} + +impl ProviderConfig { + /// Which wire shape this configuration selects. + #[must_use] + pub const fn kind(&self) -> ProviderKind { + match self { + ProviderConfig::OpenAi { .. } => ProviderKind::OpenAi, + ProviderConfig::Anthropic { .. } => ProviderKind::Anthropic, + ProviderConfig::Ollama { .. } => ProviderKind::Ollama, + ProviderConfig::Compatible { .. } => ProviderKind::Compatible, + } + } + + /// Endpoint root, for display in `LLM.INFO`. + #[must_use] + pub fn base_url(&self) -> &str { + match self { + ProviderConfig::OpenAi { base_url, .. } + | ProviderConfig::Anthropic { base_url, .. } + | ProviderConfig::Ollama { base_url } + | ProviderConfig::Compatible { base_url, .. } => base_url, + } + } + + /// Build a default configuration for a provider kind. + /// + /// [`ProviderKind::Compatible`] has no defensible default base URL — an OpenAI-compatible + /// server could be anywhere — so it is rejected here rather than guessed at. + pub fn default_for(kind: ProviderKind) -> LlmResult { + match kind { + ProviderKind::OpenAi => Ok(ProviderConfig::OpenAi { + api_key: SecretString::default(), + base_url: default_openai_base(), + organization: None, + project: None, + }), + ProviderKind::Anthropic => Ok(ProviderConfig::Anthropic { + api_key: SecretString::default(), + base_url: default_anthropic_base(), + version: default_anthropic_version(), + }), + ProviderKind::Ollama => Ok(ProviderConfig::Ollama { + base_url: default_ollama_base(), + }), + ProviderKind::Compatible => Err(LlmError::configuration( + "an OpenAI-compatible provider requires an explicit base_url", + )), + } + } + + /// Overwrite the credential. + pub fn set_api_key(&mut self, key: SecretString) { + match self { + ProviderConfig::OpenAi { api_key, .. } | ProviderConfig::Anthropic { api_key, .. } => { + *api_key = key; + } + ProviderConfig::Compatible { api_key, .. } => *api_key = Some(key), + // Ollama is a local daemon with no credential of its own. + ProviderConfig::Ollama { .. } => {} + } + } + + /// Overwrite the endpoint root. + pub fn set_base_url(&mut self, url: String) { + match self { + ProviderConfig::OpenAi { base_url, .. } + | ProviderConfig::Anthropic { base_url, .. } + | ProviderConfig::Ollama { base_url } + | ProviderConfig::Compatible { base_url, .. } => *base_url = url, + } + } + + /// Validate that this configuration can actually be used. + /// + /// Catching a missing credential here means the failure surfaces at config load with a clear + /// message, rather than as a 401 during a user's query. + /// + /// # Errors + /// + /// Returns [`LlmError::Configuration`] when a required field is empty. + pub fn validate(&self) -> LlmResult<()> { + match self { + ProviderConfig::OpenAi { api_key, .. } if api_key.is_empty() => Err( + LlmError::configuration("OpenAI provider requires an api_key (set OPENAI_API_KEY)"), + ), + ProviderConfig::Anthropic { api_key, .. } if api_key.is_empty() => { + Err(LlmError::configuration( + "Anthropic provider requires an api_key (set ANTHROPIC_API_KEY)", + )) + } + ProviderConfig::Compatible { + flavor: CompatibleFlavor::AzureOpenAi, + api_version, + .. + } if api_version.is_none() => Err(LlmError::configuration( + "Azure OpenAI requires an api_version (for example '2024-10-21')", + )), + ProviderConfig::Compatible { base_url, .. } if base_url.is_empty() => Err( + LlmError::configuration("OpenAI-compatible provider requires a base_url"), + ), + _ => Ok(()), + } + } +} + +/// Per-token prices for a model, in USD per million tokens. +/// +/// Only present when an operator configured it. Orbit-RS ships no built-in price table: a table +/// baked into a database binary goes stale silently, and a confidently wrong cost figure is worse +/// than an absent one. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct ModelPricing { + /// USD per million input tokens. + pub prompt_usd_per_million: f64, + /// USD per million output tokens. + pub completion_usd_per_million: f64, +} + +impl ModelPricing { + /// Compute the cost of a request. + /// + /// Returns `None` when the provider reported no usage at all — charging for a request whose + /// token count nobody measured would be inventing a number. + #[must_use] + pub fn cost_of(&self, usage: &TokenUsage) -> Option { + if !usage.is_reported() { + return None; + } + let per_token = |tokens: Option, per_million: f64| { + f64::from(tokens.unwrap_or(0)) * per_million / 1_000_000.0 + }; + Some(Cost { + prompt_usd: per_token(usage.prompt_tokens, self.prompt_usd_per_million), + completion_usd: per_token(usage.completion_tokens, self.completion_usd_per_million), + }) + } +} + +/// A named, switchable model configuration. +/// +/// This is the unit `LLM.USE` switches between and the unit a fallback chain is built from. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ModelProfile { + /// Profile name, unique within a registry. + pub name: String, + /// How to reach the provider. + #[serde(flatten)] + pub provider: ProviderConfig, + /// Model identifier passed to the provider. + pub model: String, + /// Embedding model, when this profile is also used for embeddings. + #[serde(default)] + pub embedding_model: Option, + /// Default generation parameters, overridable per request. + #[serde(default)] + pub params: GenerationParams, + /// Prices, when configured. + #[serde(default)] + pub pricing: Option, + /// Profiles to try, in order, if this one fails. + #[serde(default)] + pub fallbacks: Vec, + /// Deadline for a single attempt. + #[serde(default = "default_timeout_ms")] + pub timeout_ms: u64, + /// Retry behavior for this profile. + #[serde(default)] + pub retry: RetryPolicy, + /// Circuit breaker tuning for this profile. + #[serde(default)] + pub breaker: BreakerConfig, +} + +fn default_timeout_ms() -> u64 { + DEFAULT_TIMEOUT_MS +} + +impl ModelProfile { + /// Build a profile with defaults for everything but the essentials. + pub fn new( + name: impl Into, + provider: ProviderConfig, + model: impl Into, + ) -> Self { + Self { + name: name.into(), + provider, + model: model.into(), + embedding_model: None, + params: GenerationParams::default(), + pricing: None, + fallbacks: Vec::new(), + timeout_ms: DEFAULT_TIMEOUT_MS, + retry: RetryPolicy::default(), + breaker: BreakerConfig::default(), + } + } + + /// Set default generation parameters. + #[must_use] + pub fn with_params(mut self, params: GenerationParams) -> Self { + self.params = params; + self + } + + /// Set the fallback chain. + #[must_use] + pub fn with_fallbacks(mut self, fallbacks: Vec) -> Self { + self.fallbacks = fallbacks; + self + } + + /// Set the embedding model. + #[must_use] + pub fn with_embedding_model(mut self, model: impl Into) -> Self { + self.embedding_model = Some(model.into()); + self + } + + /// Set prices. + #[must_use] + pub fn with_pricing(mut self, pricing: ModelPricing) -> Self { + self.pricing = Some(pricing); + self + } + + /// Attempt deadline. + #[must_use] + pub fn timeout(&self) -> Duration { + Duration::from_millis(self.timeout_ms.max(1)) + } + + /// Validate the profile end to end. + /// + /// # Errors + /// + /// Returns [`LlmError::Configuration`] for an empty name or model, a self-referential fallback, + /// or an unusable provider configuration. + pub fn validate(&self) -> LlmResult<()> { + if self.name.trim().is_empty() { + return Err(LlmError::configuration("model profile requires a name")); + } + if self.model.trim().is_empty() { + return Err(LlmError::configuration(format!( + "profile '{}' requires a model", self.name + ))); + } + if self.fallbacks.iter().any(|f| f == &self.name) { + return Err(LlmError::configuration(format!( + "profile '{}' lists itself as a fallback, which would loop", self.name + ))); + } + self.provider.validate() + } +} + +/// The `[llm]` configuration section. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct LlmConfig { + /// Whether the LLM subsystem is active. + pub enabled: bool, + /// Profile used when a request names none. + pub default_profile: Option, + /// Profiles by name. + /// + /// `BTreeMap` rather than `HashMap` so `LLM.MODELS` and config dumps have a stable order — + /// diffable output matters more here than lookup speed on a map of tens of entries. + pub profiles: BTreeMap, +} + +impl LlmConfig { + /// Parse an `[llm]` section from TOML. + /// + /// # Errors + /// + /// Returns [`LlmError::Configuration`] if the document does not parse or a profile is invalid. + pub fn from_toml_str(toml_str: &str) -> LlmResult { + let mut config: LlmConfig = toml::from_str(toml_str) + .map_err(|e| LlmError::configuration(format!("invalid [llm] configuration: {e}")))?; + // The map key is authoritative: a profile keyed `[llm.profiles.fast]` is named `fast`, + // whatever the body says, so the two cannot drift. + for (key, profile) in config.profiles.iter_mut() { + profile.name.clone_from(key); + } + Ok(config) + } + + /// Layer environment variables over the parsed file. + /// + /// Recognized, in increasing precedence: + /// + /// | Variable | Effect | + /// |---|---| + /// | `OPENAI_API_KEY` | credential for every OpenAI profile lacking one | + /// | `ANTHROPIC_API_KEY` | credential for every Anthropic profile lacking one | + /// | `OLLAMA_HOST` | base URL for every Ollama profile | + /// | `ORBIT_LLM_DEFAULT_PROFILE` | the default profile | + /// | `ORBIT_LLM__API_KEY` | credential for one profile | + /// | `ORBIT_LLM__BASE_URL` | base URL for one profile | + /// | `ORBIT_LLM__MODEL` | model for one profile | + /// + /// `` is the profile name uppercased with `-` and `.` replaced by `_`. + pub fn apply_env_overrides(&mut self) { + self.apply_env_overrides_from(&|key| std::env::var(key).ok()); + } + + /// Env layering against an injected lookup, so the precedence rules are testable without + /// mutating the process environment (which races across parallel tests). + pub fn apply_env_overrides_from(&mut self, lookup: &dyn Fn(&str) -> Option) { + let shared_openai = lookup("OPENAI_API_KEY"); + let shared_anthropic = lookup("ANTHROPIC_API_KEY"); + let ollama_host = lookup("OLLAMA_HOST"); + + for (name, profile) in self.profiles.iter_mut() { + let slug = env_slug(name); + + match &mut profile.provider { + ProviderConfig::OpenAi { api_key, .. } => { + if api_key.is_empty() { + if let Some(shared) = &shared_openai { + *api_key = SecretString::new(shared.clone()); + } + } + } + ProviderConfig::Anthropic { api_key, .. } => { + if api_key.is_empty() { + if let Some(shared) = &shared_anthropic { + *api_key = SecretString::new(shared.clone()); + } + } + } + ProviderConfig::Ollama { base_url } => { + if let Some(host) = &ollama_host { + *base_url = host.clone(); + } + } + ProviderConfig::Compatible { .. } => {} + } + + // Per-profile variables win over the shared ones above. + if let Some(key) = lookup(&format!("ORBIT_LLM_{slug}_API_KEY")) { + profile.provider.set_api_key(SecretString::new(key)); + } + if let Some(url) = lookup(&format!("ORBIT_LLM_{slug}_BASE_URL")) { + profile.provider.set_base_url(url); + } + if let Some(model) = lookup(&format!("ORBIT_LLM_{slug}_MODEL")) { + profile.model = model; + } + } + + if let Some(default) = lookup("ORBIT_LLM_DEFAULT_PROFILE") { + self.default_profile = Some(default); + } + } + + /// Validate every profile and the default selection. + /// + /// # Errors + /// + /// Returns [`LlmError::Configuration`] if a profile is invalid, the default names a profile + /// that does not exist, or a fallback points at an unregistered profile. + pub fn validate(&self) -> LlmResult<()> { + for profile in self.profiles.values() { + profile.validate()?; + // A fallback naming a profile that does not exist is a failover that will not fire — + // exactly the kind of affordance that looks configured and does nothing. + if let Some(missing) = profile + .fallbacks + .iter() + .find(|f| !self.profiles.contains_key(*f)) + { + return Err(LlmError::configuration(format!( + "profile '{}' falls back to '{missing}', which is not configured", + profile.name + ))); + } + } + if let Some(default) = &self.default_profile { + if !self.profiles.contains_key(default) { + return Err(LlmError::configuration(format!( + "default_profile '{default}' is not configured" + ))); + } + } + Ok(()) + } +} + +/// Normalize a profile name into the env-var fragment it maps to. +fn env_slug(name: &str) -> String { + name.chars() + .map(|c| match c { + 'a'..='z' => c.to_ascii_uppercase(), + 'A'..='Z' | '0'..='9' => c, + _ => '_', + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#" +enabled = true +default_profile = "fast" + +[profiles.fast] +provider = "ollama" +model = "llama3.2" +embedding_model = "nomic-embed-text" + +[profiles.fast.params] +temperature = 0.3 +max_tokens = 1024 + +[profiles.smart] +provider = "anthropic" +api_key = "sk-ant-from-file" +model = "claude-sonnet-4-5" +fallbacks = ["fast"] +timeout_ms = 45000 + +[profiles.smart.pricing] +prompt_usd_per_million = 3.0 +completion_usd_per_million = 15.0 +"#; + + fn sample() -> LlmConfig { + LlmConfig::from_toml_str(SAMPLE).expect("sample config parses") + } + + #[test] + fn toml_parses_into_profiles() { + let cfg = sample(); + assert!(cfg.enabled); + assert_eq!(cfg.default_profile.as_deref(), Some("fast")); + assert_eq!(cfg.profiles.len(), 2); + + let fast = &cfg.profiles["fast"]; + assert_eq!(fast.provider.kind(), ProviderKind::Ollama); + assert_eq!(fast.model, "llama3.2"); + assert_eq!(fast.embedding_model.as_deref(), Some("nomic-embed-text")); + assert_eq!(fast.params.temperature, Some(0.3)); + assert_eq!(fast.params.max_tokens, Some(1024)); + assert_eq!(fast.timeout_ms, DEFAULT_TIMEOUT_MS, "default applied"); + + let smart = &cfg.profiles["smart"]; + assert_eq!(smart.provider.kind(), ProviderKind::Anthropic); + assert_eq!(smart.fallbacks, vec!["fast".to_string()]); + assert_eq!(smart.timeout_ms, 45_000); + } + + #[test] + fn profile_name_comes_from_the_map_key() { + let cfg = sample(); + assert_eq!(cfg.profiles["fast"].name, "fast"); + assert_eq!(cfg.profiles["smart"].name, "smart"); + } + + #[test] + fn sample_config_validates() { + sample().validate().expect("sample config is valid"); + } + + #[test] + fn env_fills_a_missing_credential() { + let mut cfg = LlmConfig::from_toml_str( + r#" +enabled = true +[profiles.gpt] +provider = "openai" +model = "gpt-4o-mini" +"#, + ) + .expect("parses"); + + assert!(cfg.profiles["gpt"].validate().is_err(), "no key yet"); + + cfg.apply_env_overrides_from(&|key| match key { + "OPENAI_API_KEY" => Some("sk-from-env".into()), + _ => None, + }); + + cfg.profiles["gpt"] + .validate() + .expect("credential arrived from env"); + } + + #[test] + fn per_profile_env_beats_shared_env_and_file() { + let mut cfg = sample(); + cfg.apply_env_overrides_from(&|key| match key { + "ANTHROPIC_API_KEY" => Some("sk-shared".into()), + "ORBIT_LLM_SMART_API_KEY" => Some("sk-specific".into()), + _ => None, + }); + + let ProviderConfig::Anthropic { api_key, .. } = &cfg.profiles["smart"].provider else { + panic!("smart profile should be Anthropic"); + }; + assert_eq!( + api_key.expose(), + "sk-specific", + "per-profile env must win over both the shared env var and the file value" + ); + } + + #[test] + fn shared_env_does_not_clobber_a_file_credential() { + let mut cfg = sample(); + cfg.apply_env_overrides_from(&|key| match key { + "ANTHROPIC_API_KEY" => Some("sk-shared".into()), + _ => None, + }); + + let ProviderConfig::Anthropic { api_key, .. } = &cfg.profiles["smart"].provider else { + panic!("smart profile should be Anthropic"); + }; + assert_eq!( + api_key.expose(), + "sk-ant-from-file", + "the shared variable only fills gaps" + ); + } + + #[test] + fn env_overrides_base_url_model_and_default() { + let mut cfg = sample(); + cfg.apply_env_overrides_from(&|key| match key { + "OLLAMA_HOST" => Some("http://gpu-box:11434".into()), + "ORBIT_LLM_FAST_MODEL" => Some("qwen3".into()), + "ORBIT_LLM_DEFAULT_PROFILE" => Some("smart".into()), + _ => None, + }); + + assert_eq!(cfg.profiles["fast"].provider.base_url(), "http://gpu-box:11434"); + assert_eq!(cfg.profiles["fast"].model, "qwen3"); + assert_eq!(cfg.default_profile.as_deref(), Some("smart")); + } + + #[test] + fn env_slug_normalizes_punctuation() { + assert_eq!(env_slug("fast"), "FAST"); + assert_eq!(env_slug("gpt-4o-mini"), "GPT_4O_MINI"); + assert_eq!(env_slug("team.smart"), "TEAM_SMART"); + } + + #[test] + fn dangling_fallback_is_rejected() { + let cfg = LlmConfig::from_toml_str( + r#" +enabled = true +[profiles.a] +provider = "ollama" +model = "llama3.2" +fallbacks = ["ghost"] +"#, + ) + .expect("parses"); + + let err = cfg.validate().expect_err("dangling fallback rejected"); + assert!(err.to_string().contains("'ghost'"), "got: {err}"); + } + + #[test] + fn self_referential_fallback_is_rejected() { + let profile = ModelProfile::new( + "loop", + ProviderConfig::Ollama { + base_url: default_ollama_base(), + }, + "llama3.2", + ) + .with_fallbacks(vec!["loop".into()]); + + let err = profile.validate().expect_err("self-fallback rejected"); + assert!(err.to_string().contains("itself as a fallback")); + } + + #[test] + fn missing_default_profile_is_rejected() { + let cfg = LlmConfig::from_toml_str( + r#" +enabled = true +default_profile = "nope" +[profiles.a] +provider = "ollama" +model = "llama3.2" +"#, + ) + .expect("parses"); + assert!(cfg.validate().is_err()); + } + + #[test] + fn azure_requires_an_api_version() { + let without = ProviderConfig::Compatible { + flavor: CompatibleFlavor::AzureOpenAi, + api_key: Some(SecretString::new("k")), + base_url: "https://x.openai.azure.com".into(), + api_version: None, + }; + assert!(without.validate().is_err()); + + let with = ProviderConfig::Compatible { + flavor: CompatibleFlavor::AzureOpenAi, + api_key: Some(SecretString::new("k")), + base_url: "https://x.openai.azure.com".into(), + api_version: Some("2024-10-21".into()), + }; + with.validate().expect("api_version supplied"); + } + + #[test] + fn compatible_has_no_guessed_default_base_url() { + assert!( + ProviderConfig::default_for(ProviderKind::Compatible).is_err(), + "guessing where a compatible server lives would be a fabricated default" + ); + for kind in [ + ProviderKind::OpenAi, + ProviderKind::Anthropic, + ProviderKind::Ollama, + ] { + ProviderConfig::default_for(kind).expect("well-known default exists"); + } + } + + #[test] + fn serialized_config_does_not_contain_the_credential() { + let cfg = sample(); + let dumped = toml::to_string(&cfg).expect("config serializes"); + assert!( + !dumped.contains("sk-ant-from-file"), + "a config dump must not print a live credential:\n{dumped}" + ); + assert!(dumped.contains(crate::secret::REDACTED)); + } + + #[test] + fn pricing_is_absent_when_usage_is_unreported() { + let pricing = ModelPricing { + prompt_usd_per_million: 3.0, + completion_usd_per_million: 15.0, + }; + assert!( + pricing.cost_of(&TokenUsage::default()).is_none(), + "billing a request nobody counted invents a number" + ); + + let usage = TokenUsage { + prompt_tokens: Some(1_000_000), + completion_tokens: Some(1_000_000), + }; + let cost = pricing.cost_of(&usage).expect("usage reported"); + assert!((cost.prompt_usd - 3.0).abs() < f64::EPSILON); + assert!((cost.completion_usd - 15.0).abs() < f64::EPSILON); + assert!((cost.total_usd() - 18.0).abs() < f64::EPSILON); + } + + #[test] + fn partial_usage_prices_only_the_reported_half() { + let pricing = ModelPricing { + prompt_usd_per_million: 3.0, + completion_usd_per_million: 15.0, + }; + let usage = TokenUsage { + prompt_tokens: Some(1_000_000), + completion_tokens: None, + }; + let cost = pricing.cost_of(&usage).expect("prompt half reported"); + assert!((cost.prompt_usd - 3.0).abs() < f64::EPSILON); + assert_eq!(cost.completion_usd, 0.0); + } + + #[test] + fn compatible_flavor_aliases_resolve() { + assert_eq!( + CompatibleFlavor::parse("azure").expect("parses"), + CompatibleFlavor::AzureOpenAi + ); + for alias in ["groq", "vllm", "together", "openrouter", "lmstudio"] { + assert_eq!( + CompatibleFlavor::parse(alias).expect("parses"), + CompatibleFlavor::Generic, + "alias {alias}" + ); + } + assert!(CompatibleFlavor::parse("bedrock").is_err()); + } + + #[test] + fn ollama_ignores_credentials_because_it_has_none() { + let mut provider = ProviderConfig::Ollama { + base_url: default_ollama_base(), + }; + provider.set_api_key(SecretString::new("ignored")); + provider.validate().expect("still valid"); + } +} From 3b89881b01d0edb6e9e416828803a92f64df2c0f Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Wed, 5 Aug 2026 21:47:53 -0700 Subject: [PATCH 23/36] fix(postgres): exclude comma from the SCRAM server nonce RFC 5802 defines the nonce alphabet as printable ASCII excluding comma (%x21-2B / %x2D-7E). The comma is the field separator in `r=,s=,i=`, so a comma inside the nonce splits the message into an extra field and the client rejects the handshake with a parse error such as "expected `s`". The nonce was drawn from the full 33..127 range, so at 16 characters roughly one login in six contained a comma and failed intermittently. Draws from a 93-character alphabet and shifts past the comma, with a test asserting the generated nonce never contains one. --- .../src/protocols/postgres_wire/auth.rs | 87 ++++++++++++++++++- 1 file changed, 84 insertions(+), 3 deletions(-) diff --git a/orbit/server/src/protocols/postgres_wire/auth.rs b/orbit/server/src/protocols/postgres_wire/auth.rs index 23a2cdb7..4bb6d037 100644 --- a/orbit/server/src/protocols/postgres_wire/auth.rs +++ b/orbit/server/src/protocols/postgres_wire/auth.rs @@ -258,6 +258,15 @@ pub struct ScramAuth { server_first_message: String, } +/// First code point of the RFC 5802 nonce alphabet (`!`). +const SCRAM_NONCE_FIRST: u32 = 0x21; +/// Last code point of the RFC 5802 nonce alphabet (`~`). +const SCRAM_NONCE_LAST: u32 = 0x7E; +/// The one excluded code point: `,` separates fields in a SCRAM message. +const SCRAM_NONCE_COMMA: u32 = b',' as u32; +/// Size of the alphabet once the comma is removed. +const SCRAM_NONCE_ALPHABET_LEN: u32 = SCRAM_NONCE_LAST - SCRAM_NONCE_FIRST; + impl ScramAuth { /// Create new SCRAM authentication session pub fn new( @@ -268,11 +277,25 @@ impl ScramAuth { stored_key: Vec, server_key: Vec, ) -> Self { - // Generate server nonce by appending random data to client nonce + // Generate server nonce by appending random data to client nonce. + // + // RFC 5802 defines the nonce alphabet as printable ASCII *excluding* + // comma (`%x21-2B / %x2D-7E`). The comma is the field separator in + // `r=,s=,i=`, so a comma inside the nonce + // splits the message into an extra field and the client rejects the + // handshake with a parse error such as "expected `s`". At 16 characters + // drawn from the full 33..127 range that happened to roughly one login + // in six, making PostgreSQL authentication intermittently fail. let server_nonce_suffix: String = (0..16) .map(|_| { - let ch = rand::rng().random_range(33..127) as u8; - ch as char + let ch = rand::rng().random_range(0..SCRAM_NONCE_ALPHABET_LEN); + // Skip the comma by shifting everything at or above it up one. + let ch = if ch + SCRAM_NONCE_FIRST >= SCRAM_NONCE_COMMA { + ch + SCRAM_NONCE_FIRST + 1 + } else { + ch + SCRAM_NONCE_FIRST + }; + ch as u8 as char }) .collect(); let server_nonce = format!("{}{}", client_nonce, server_nonce_suffix); @@ -505,4 +528,62 @@ mod tests { .await; assert!(!result.unwrap()); } + + /// The server nonce must never contain a comma. + /// + /// `server-first-message` is `r=,s=,i=` and the + /// client splits it on commas. A comma inside the nonce creates a spurious + /// field, and the client fails the handshake with a parse error. Drawing + /// from the full printable range made that happen for roughly one login in + /// six, so this is checked over enough samples to catch a regression. + #[test] + fn scram_server_nonce_never_contains_a_comma() { + for _ in 0..2_000 { + let auth = ScramAuth::new( + "user".to_string(), + "clientnonce".to_string(), + vec![0u8; 16], + 4096, + vec![0u8; 32], + vec![0u8; 32], + ); + assert!( + !auth.server_nonce.contains(','), + "nonce must exclude the field separator: {:?}", + auth.server_nonce + ); + assert!( + auth.server_nonce + .chars() + .all(|c| ('\x21'..='\x7e').contains(&c)), + "nonce must stay in the RFC 5802 printable range: {:?}", + auth.server_nonce + ); + } + } + + /// A server-first-message must parse into exactly the three fields the + /// client expects, in order. + #[test] + fn scram_server_first_message_has_three_parseable_fields() { + for _ in 0..500 { + let mut auth = ScramAuth::new( + "user".to_string(), + "clientnonce".to_string(), + vec![1u8; 16], + 4096, + vec![0u8; 32], + vec![0u8; 32], + ); + let message = auth + .process_client_first("n,,n=user,r=clientnonce") + .expect("a well formed client-first-message is accepted"); + + let fields: Vec<&str> = message.split(',').collect(); + assert_eq!(fields.len(), 3, "unexpected field count in {message:?}"); + assert!(fields[0].starts_with("r="), "{message:?}"); + assert!(fields[1].starts_with("s="), "{message:?}"); + assert!(fields[2].starts_with("i="), "{message:?}"); + } + } } From f263d19bbf56b17447961deb3dff14c4451dbfc2 Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Wed, 5 Aug 2026 21:48:00 -0700 Subject: [PATCH 24/36] feat(desktop): rework connection management, add cluster panel Reworks the Tauri desktop client: - New ClusterPanel component and cluster.rs backend command surface - Connection management rewritten across connections.rs, ConnectionDialog, and ConnectionManager - Query formatting extracted into utils/queryFormatter.ts, with the safety test suite reworked against it - Encryption, storage, and model types updated to match - Dependency bumps in package.json / package-lock.json - dist/ rebuilt (index.html now references index-CuG3nLo2.js) --- orbit/desktop/dist/assets/index-CuG3nLo2.js | 1582 +++++++++++++++++ orbit/desktop/dist/assets/index-DQVIKfVi.js | 1032 ----------- orbit/desktop/dist/index.html | 2 +- orbit/desktop/package-lock.json | 401 ++++- orbit/desktop/package.json | 7 +- orbit/desktop/src-tauri/Cargo.lock | 3 + orbit/desktop/src-tauri/src/cluster.rs | 157 ++ orbit/desktop/src-tauri/src/connections.rs | 327 +++- orbit/desktop/src-tauri/src/encryption.rs | 78 +- orbit/desktop/src-tauri/src/main.rs | 55 +- orbit/desktop/src-tauri/src/models.rs | 133 +- orbit/desktop/src-tauri/src/storage.rs | 51 +- orbit/desktop/src/App.tsx | 293 ++- orbit/desktop/src/components/ClusterPanel.tsx | 397 +++++ .../src/components/ConnectionDialog.tsx | 90 +- .../src/components/ConnectionManager.tsx | 146 +- .../src/components/DataVisualization.tsx | 5 - .../desktop/src/components/MLModelManager.tsx | 143 +- orbit/desktop/src/components/QueryEditor.tsx | 64 +- .../src/components/QueryHistoryPanel.tsx | 67 +- .../src/components/QueryResultsTable.tsx | 41 +- orbit/desktop/src/hooks/useQueryTabs.ts | 35 +- orbit/desktop/src/services/tauri.ts | 333 ++-- orbit/desktop/src/types/index.ts | 190 +- orbit/desktop/src/utils/queryFormatter.ts | 89 + .../src/utils/queryFormatterSafety.test.ts | 259 +-- 26 files changed, 3867 insertions(+), 2113 deletions(-) create mode 100644 orbit/desktop/dist/assets/index-CuG3nLo2.js delete mode 100644 orbit/desktop/dist/assets/index-DQVIKfVi.js create mode 100644 orbit/desktop/src/components/ClusterPanel.tsx create mode 100644 orbit/desktop/src/utils/queryFormatter.ts diff --git a/orbit/desktop/dist/assets/index-CuG3nLo2.js b/orbit/desktop/dist/assets/index-CuG3nLo2.js new file mode 100644 index 00000000..f9bfd9cb --- /dev/null +++ b/orbit/desktop/dist/assets/index-CuG3nLo2.js @@ -0,0 +1,1582 @@ +var _$=Object.defineProperty;var Q$=(n,e,t)=>e in n?_$(n,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):n[e]=t;var me=(n,e,t)=>Q$(n,typeof e!="symbol"?e+"":e,t);(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))i(r);new MutationObserver(r=>{for(const s of r)if(s.type==="childList")for(const o of s.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function t(r){const s={};return r.integrity&&(s.integrity=r.integrity),r.referrerPolicy&&(s.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?s.credentials="include":r.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function i(r){if(r.ep)return;r.ep=!0;const s=t(r);fetch(r.href,s)}})();function HO(n){return n&&n.__esModule&&Object.prototype.hasOwnProperty.call(n,"default")?n.default:n}var ig={exports:{}},Gl={},rg={exports:{}},Ae={};var rv;function C$(){if(rv)return Ae;rv=1;var n=Symbol.for("react.element"),e=Symbol.for("react.portal"),t=Symbol.for("react.fragment"),i=Symbol.for("react.strict_mode"),r=Symbol.for("react.profiler"),s=Symbol.for("react.provider"),o=Symbol.for("react.context"),a=Symbol.for("react.forward_ref"),c=Symbol.for("react.suspense"),h=Symbol.for("react.memo"),f=Symbol.for("react.lazy"),p=Symbol.iterator;function m(D){return D===null||typeof D!="object"?null:(D=p&&D[p]||D["@@iterator"],typeof D=="function"?D:null)}var O={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},v=Object.assign,b={};function S(D,B,xe){this.props=D,this.context=B,this.refs=b,this.updater=xe||O}S.prototype.isReactComponent={},S.prototype.setState=function(D,B){if(typeof D!="object"&&typeof D!="function"&&D!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,D,B,"setState")},S.prototype.forceUpdate=function(D){this.updater.enqueueForceUpdate(this,D,"forceUpdate")};function w(){}w.prototype=S.prototype;function T(D,B,xe){this.props=D,this.context=B,this.refs=b,this.updater=xe||O}var k=T.prototype=new w;k.constructor=T,v(k,S.prototype),k.isPureReactComponent=!0;var _=Array.isArray,C=Object.prototype.hasOwnProperty,M={current:null},R={key:!0,ref:!0,__self:!0,__source:!0};function L(D,B,xe){var Oe,ke={},Pe=null,Le=null;if(B!=null)for(Oe in B.ref!==void 0&&(Le=B.ref),B.key!==void 0&&(Pe=""+B.key),B)C.call(B,Oe)&&!R.hasOwnProperty(Oe)&&(ke[Oe]=B[Oe]);var ee=arguments.length-2;if(ee===1)ke.children=xe;else if(1>>1,B=q[D];if(0>>1;Dr(ke,J))Per(Le,ke)?(q[D]=Le,q[Pe]=J,D=Pe):(q[D]=ke,q[Oe]=J,D=Oe);else if(Per(Le,J))q[D]=Le,q[Pe]=J,D=Pe;else break e}}return U}function r(q,U){var J=q.sortIndex-U.sortIndex;return J!==0?J:q.id-U.id}if(typeof performance=="object"&&typeof performance.now=="function"){var s=performance;n.unstable_now=function(){return s.now()}}else{var o=Date,a=o.now();n.unstable_now=function(){return o.now()-a}}var c=[],h=[],f=1,p=null,m=3,O=!1,v=!1,b=!1,S=typeof setTimeout=="function"?setTimeout:null,w=typeof clearTimeout=="function"?clearTimeout:null,T=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function k(q){for(var U=t(h);U!==null;){if(U.callback===null)i(h);else if(U.startTime<=q)i(h),U.sortIndex=U.expirationTime,e(c,U);else break;U=t(h)}}function _(q){if(b=!1,k(q),!v)if(t(c)!==null)v=!0,ce(C);else{var U=t(h);U!==null&&ue(_,U.startTime-q)}}function C(q,U){v=!1,b&&(b=!1,w(L),L=-1),O=!0;var J=m;try{for(k(U),p=t(c);p!==null&&(!(p.expirationTime>U)||q&&!Y());){var D=p.callback;if(typeof D=="function"){p.callback=null,m=p.priorityLevel;var B=D(p.expirationTime<=U);U=n.unstable_now(),typeof B=="function"?p.callback=B:p===t(c)&&i(c),k(U)}else i(c);p=t(c)}if(p!==null)var xe=!0;else{var Oe=t(h);Oe!==null&&ue(_,Oe.startTime-U),xe=!1}return xe}finally{p=null,m=J,O=!1}}var M=!1,R=null,L=-1,X=5,ie=-1;function Y(){return!(n.unstable_now()-ieq||125D?(q.sortIndex=J,e(h,q),t(c)===null&&q===t(h)&&(b?(w(L),L=-1):b=!0,ue(_,J-D))):(q.sortIndex=B,e(c,q),v||O||(v=!0,ce(C))),q},n.unstable_shouldYield=Y,n.unstable_wrapCallback=function(q){var U=m;return function(){var J=m;m=U;try{return q.apply(this,arguments)}finally{m=J}}}})(lg)),lg}var cv;function R$(){return cv||(cv=1,og.exports=M$()),og.exports}var uv;function A$(){if(uv)return gn;uv=1;var n=GO(),e=R$();function t(l){for(var u="https://reactjs.org/docs/error-decoder.html?invariant="+l,d=1;d"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),c=Object.prototype.hasOwnProperty,h=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,f={},p={};function m(l){return c.call(p,l)?!0:c.call(f,l)?!1:h.test(l)?p[l]=!0:(f[l]=!0,!1)}function O(l,u,d,g){if(d!==null&&d.type===0)return!1;switch(typeof u){case"function":case"symbol":return!0;case"boolean":return g?!1:d!==null?!d.acceptsBooleans:(l=l.toLowerCase().slice(0,5),l!=="data-"&&l!=="aria-");default:return!1}}function v(l,u,d,g){if(u===null||typeof u>"u"||O(l,u,d,g))return!0;if(g)return!1;if(d!==null)switch(d.type){case 3:return!u;case 4:return u===!1;case 5:return isNaN(u);case 6:return isNaN(u)||1>u}return!1}function b(l,u,d,g,y,x,P){this.acceptsBooleans=u===2||u===3||u===4,this.attributeName=g,this.attributeNamespace=y,this.mustUseProperty=d,this.propertyName=l,this.type=u,this.sanitizeURL=x,this.removeEmptyString=P}var S={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(l){S[l]=new b(l,0,!1,l,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(l){var u=l[0];S[u]=new b(u,1,!1,l[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(l){S[l]=new b(l,2,!1,l.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(l){S[l]=new b(l,2,!1,l,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(l){S[l]=new b(l,3,!1,l.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(l){S[l]=new b(l,3,!0,l,null,!1,!1)}),["capture","download"].forEach(function(l){S[l]=new b(l,4,!1,l,null,!1,!1)}),["cols","rows","size","span"].forEach(function(l){S[l]=new b(l,6,!1,l,null,!1,!1)}),["rowSpan","start"].forEach(function(l){S[l]=new b(l,5,!1,l.toLowerCase(),null,!1,!1)});var w=/[\-:]([a-z])/g;function T(l){return l[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(l){var u=l.replace(w,T);S[u]=new b(u,1,!1,l,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(l){var u=l.replace(w,T);S[u]=new b(u,1,!1,l,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(l){var u=l.replace(w,T);S[u]=new b(u,1,!1,l,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(l){S[l]=new b(l,1,!1,l.toLowerCase(),null,!1,!1)}),S.xlinkHref=new b("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(l){S[l]=new b(l,1,!1,l.toLowerCase(),null,!0,!0)});function k(l,u,d,g){var y=S.hasOwnProperty(u)?S[u]:null;(y!==null?y.type!==0:g||!(2$||y[P]!==x[$]){var E=` +`+y[P].replace(" at new "," at ");return l.displayName&&E.includes("")&&(E=E.replace("",l.displayName)),E}while(1<=P&&0<=$);break}}}finally{xe=!1,Error.prepareStackTrace=d}return(l=l?l.displayName||l.name:"")?B(l):""}function ke(l){switch(l.tag){case 5:return B(l.type);case 16:return B("Lazy");case 13:return B("Suspense");case 19:return B("SuspenseList");case 0:case 2:case 15:return l=Oe(l.type,!1),l;case 11:return l=Oe(l.type.render,!1),l;case 1:return l=Oe(l.type,!0),l;default:return""}}function Pe(l){if(l==null)return null;if(typeof l=="function")return l.displayName||l.name||null;if(typeof l=="string")return l;switch(l){case R:return"Fragment";case M:return"Portal";case X:return"Profiler";case L:return"StrictMode";case F:return"Suspense";case re:return"SuspenseList"}if(typeof l=="object")switch(l.$$typeof){case Y:return(l.displayName||"Context")+".Consumer";case ie:return(l._context.displayName||"Context")+".Provider";case H:var u=l.render;return l=l.displayName,l||(l=u.displayName||u.name||"",l=l!==""?"ForwardRef("+l+")":"ForwardRef"),l;case oe:return u=l.displayName||null,u!==null?u:Pe(l.type)||"Memo";case ce:u=l._payload,l=l._init;try{return Pe(l(u))}catch{}}return null}function Le(l){var u=l.type;switch(l.tag){case 24:return"Cache";case 9:return(u.displayName||"Context")+".Consumer";case 10:return(u._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return l=u.render,l=l.displayName||l.name||"",u.displayName||(l!==""?"ForwardRef("+l+")":"ForwardRef");case 7:return"Fragment";case 5:return u;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return Pe(u);case 8:return u===L?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof u=="function")return u.displayName||u.name||null;if(typeof u=="string")return u}return null}function ee(l){switch(typeof l){case"boolean":case"number":case"string":case"undefined":return l;case"object":return l;default:return""}}function W(l){var u=l.type;return(l=l.nodeName)&&l.toLowerCase()==="input"&&(u==="checkbox"||u==="radio")}function se(l){var u=W(l)?"checked":"value",d=Object.getOwnPropertyDescriptor(l.constructor.prototype,u),g=""+l[u];if(!l.hasOwnProperty(u)&&typeof d<"u"&&typeof d.get=="function"&&typeof d.set=="function"){var y=d.get,x=d.set;return Object.defineProperty(l,u,{configurable:!0,get:function(){return y.call(this)},set:function(P){g=""+P,x.call(this,P)}}),Object.defineProperty(l,u,{enumerable:d.enumerable}),{getValue:function(){return g},setValue:function(P){g=""+P},stopTracking:function(){l._valueTracker=null,delete l[u]}}}}function Se(l){l._valueTracker||(l._valueTracker=se(l))}function We(l){if(!l)return!1;var u=l._valueTracker;if(!u)return!0;var d=u.getValue(),g="";return l&&(g=W(l)?l.checked?"true":"false":l.value),l=g,l!==d?(u.setValue(l),!0):!1}function Je(l){if(l=l||(typeof document<"u"?document:void 0),typeof l>"u")return null;try{return l.activeElement||l.body}catch{return l.body}}function Zt(l,u){var d=u.checked;return J({},u,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:d!=null?d:l._wrapperState.initialChecked})}function gi(l,u){var d=u.defaultValue==null?"":u.defaultValue,g=u.checked!=null?u.checked:u.defaultChecked;d=ee(u.value!=null?u.value:d),l._wrapperState={initialChecked:g,initialValue:d,controlled:u.type==="checkbox"||u.type==="radio"?u.checked!=null:u.value!=null}}function is(l,u){u=u.checked,u!=null&&k(l,"checked",u,!1)}function hr(l,u){is(l,u);var d=ee(u.value),g=u.type;if(d!=null)g==="number"?(d===0&&l.value===""||l.value!=d)&&(l.value=""+d):l.value!==""+d&&(l.value=""+d);else if(g==="submit"||g==="reset"){l.removeAttribute("value");return}u.hasOwnProperty("value")?fd(l,u.type,d):u.hasOwnProperty("defaultValue")&&fd(l,u.type,ee(u.defaultValue)),u.checked==null&&u.defaultChecked!=null&&(l.defaultChecked=!!u.defaultChecked)}function dy(l,u,d){if(u.hasOwnProperty("value")||u.hasOwnProperty("defaultValue")){var g=u.type;if(!(g!=="submit"&&g!=="reset"||u.value!==void 0&&u.value!==null))return;u=""+l._wrapperState.initialValue,d||u===l.value||(l.value=u),l.defaultValue=u}d=l.name,d!==""&&(l.name=""),l.defaultChecked=!!l._wrapperState.initialChecked,d!==""&&(l.name=d)}function fd(l,u,d){(u!=="number"||Je(l.ownerDocument)!==l)&&(d==null?l.defaultValue=""+l._wrapperState.initialValue:l.defaultValue!==""+d&&(l.defaultValue=""+d))}var fl=Array.isArray;function Gs(l,u,d,g){if(l=l.options,u){u={};for(var y=0;y"+u.valueOf().toString()+"",u=_c.firstChild;l.firstChild;)l.removeChild(l.firstChild);for(;u.firstChild;)l.appendChild(u.firstChild)}});function dl(l,u){if(u){var d=l.firstChild;if(d&&d===l.lastChild&&d.nodeType===3){d.nodeValue=u;return}}l.textContent=u}var pl={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},$T=["Webkit","ms","Moz","O"];Object.keys(pl).forEach(function(l){$T.forEach(function(u){u=u+l.charAt(0).toUpperCase()+l.substring(1),pl[u]=pl[l]})});function xy(l,u,d){return u==null||typeof u=="boolean"||u===""?"":d||typeof u!="number"||u===0||pl.hasOwnProperty(l)&&pl[l]?(""+u).trim():u+"px"}function vy(l,u){l=l.style;for(var d in u)if(u.hasOwnProperty(d)){var g=d.indexOf("--")===0,y=xy(d,u[d],g);d==="float"&&(d="cssFloat"),g?l.setProperty(d,y):l[d]=y}}var MT=J({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function gd(l,u){if(u){if(MT[l]&&(u.children!=null||u.dangerouslySetInnerHTML!=null))throw Error(t(137,l));if(u.dangerouslySetInnerHTML!=null){if(u.children!=null)throw Error(t(60));if(typeof u.dangerouslySetInnerHTML!="object"||!("__html"in u.dangerouslySetInnerHTML))throw Error(t(61))}if(u.style!=null&&typeof u.style!="object")throw Error(t(62))}}function md(l,u){if(l.indexOf("-")===-1)return typeof u.is=="string";switch(l){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Od=null;function yd(l){return l=l.target||l.srcElement||window,l.correspondingUseElement&&(l=l.correspondingUseElement),l.nodeType===3?l.parentNode:l}var xd=null,Ks=null,Js=null;function by(l){if(l=Dl(l)){if(typeof xd!="function")throw Error(t(280));var u=l.stateNode;u&&(u=Uc(u),xd(l.stateNode,l.type,u))}}function Sy(l){Ks?Js?Js.push(l):Js=[l]:Ks=l}function wy(){if(Ks){var l=Ks,u=Js;if(Js=Ks=null,by(l),u)for(l=0;l>>=0,l===0?32:31-(BT(l)/XT|0)|0}var Mc=64,Rc=4194304;function yl(l){switch(l&-l){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return l&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return l&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return l}}function Ac(l,u){var d=l.pendingLanes;if(d===0)return 0;var g=0,y=l.suspendedLanes,x=l.pingedLanes,P=d&268435455;if(P!==0){var $=P&~y;$!==0?g=yl($):(x&=P,x!==0&&(g=yl(x)))}else P=d&~y,P!==0?g=yl(P):x!==0&&(g=yl(x));if(g===0)return 0;if(u!==0&&u!==g&&(u&y)===0&&(y=g&-g,x=u&-u,y>=x||y===16&&(x&4194240)!==0))return u;if((g&4)!==0&&(g|=d&16),u=l.entangledLanes,u!==0)for(l=l.entanglements,u&=g;0d;d++)u.push(l);return u}function xl(l,u,d){l.pendingLanes|=u,u!==536870912&&(l.suspendedLanes=0,l.pingedLanes=0),l=l.eventTimes,u=31-Gn(u),l[u]=d}function YT(l,u){var d=l.pendingLanes&~u;l.pendingLanes=u,l.suspendedLanes=0,l.pingedLanes=0,l.expiredLanes&=u,l.mutableReadLanes&=u,l.entangledLanes&=u,u=l.entanglements;var g=l.eventTimes;for(l=l.expirationTimes;0=Ql),Gy=" ",Ky=!1;function Jy(l,u){switch(l){case"keyup":return b2.indexOf(u.keyCode)!==-1;case"keydown":return u.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function ex(l){return l=l.detail,typeof l=="object"&&"data"in l?l.data:null}var no=!1;function w2(l,u){switch(l){case"compositionend":return ex(u);case"keypress":return u.which!==32?null:(Ky=!0,Gy);case"textInput":return l=u.data,l===Gy&&Ky?null:l;default:return null}}function k2(l,u){if(no)return l==="compositionend"||!zd&&Jy(l,u)?(l=Vy(),jc=Md=mr=null,no=!1,l):null;switch(l){case"paste":return null;case"keypress":if(!(u.ctrlKey||u.altKey||u.metaKey)||u.ctrlKey&&u.altKey){if(u.char&&1=u)return{node:d,offset:u-l};l=g}e:{for(;d;){if(d.nextSibling){d=d.nextSibling;break e}d=d.parentNode}d=void 0}d=lx(d)}}function cx(l,u){return l&&u?l===u?!0:l&&l.nodeType===3?!1:u&&u.nodeType===3?cx(l,u.parentNode):"contains"in l?l.contains(u):l.compareDocumentPosition?!!(l.compareDocumentPosition(u)&16):!1:!1}function ux(){for(var l=window,u=Je();u instanceof l.HTMLIFrameElement;){try{var d=typeof u.contentWindow.location.href=="string"}catch{d=!1}if(d)l=u.contentWindow;else break;u=Je(l.document)}return u}function Id(l){var u=l&&l.nodeName&&l.nodeName.toLowerCase();return u&&(u==="input"&&(l.type==="text"||l.type==="search"||l.type==="tel"||l.type==="url"||l.type==="password")||u==="textarea"||l.contentEditable==="true")}function A2(l){var u=ux(),d=l.focusedElem,g=l.selectionRange;if(u!==d&&d&&d.ownerDocument&&cx(d.ownerDocument.documentElement,d)){if(g!==null&&Id(d)){if(u=g.start,l=g.end,l===void 0&&(l=u),"selectionStart"in d)d.selectionStart=u,d.selectionEnd=Math.min(l,d.value.length);else if(l=(u=d.ownerDocument||document)&&u.defaultView||window,l.getSelection){l=l.getSelection();var y=d.textContent.length,x=Math.min(g.start,y);g=g.end===void 0?x:Math.min(g.end,y),!l.extend&&x>g&&(y=g,g=x,x=y),y=ax(d,x);var P=ax(d,g);y&&P&&(l.rangeCount!==1||l.anchorNode!==y.node||l.anchorOffset!==y.offset||l.focusNode!==P.node||l.focusOffset!==P.offset)&&(u=u.createRange(),u.setStart(y.node,y.offset),l.removeAllRanges(),x>g?(l.addRange(u),l.extend(P.node,P.offset)):(u.setEnd(P.node,P.offset),l.addRange(u)))}}for(u=[],l=d;l=l.parentNode;)l.nodeType===1&&u.push({element:l,left:l.scrollLeft,top:l.scrollTop});for(typeof d.focus=="function"&&d.focus(),d=0;d=document.documentMode,io=null,Nd=null,Ml=null,Bd=!1;function hx(l,u,d){var g=d.window===d?d.document:d.nodeType===9?d:d.ownerDocument;Bd||io==null||io!==Je(g)||(g=io,"selectionStart"in g&&Id(g)?g={start:g.selectionStart,end:g.selectionEnd}:(g=(g.ownerDocument&&g.ownerDocument.defaultView||window).getSelection(),g={anchorNode:g.anchorNode,anchorOffset:g.anchorOffset,focusNode:g.focusNode,focusOffset:g.focusOffset}),Ml&&$l(Ml,g)||(Ml=g,g=Fc(Nd,"onSelect"),0ao||(l.current=ep[ao],ep[ao]=null,ao--)}function Ke(l,u){ao++,ep[ao]=l.current,l.current=u}var vr={},Ft=xr(vr),un=xr(!1),os=vr;function co(l,u){var d=l.type.contextTypes;if(!d)return vr;var g=l.stateNode;if(g&&g.__reactInternalMemoizedUnmaskedChildContext===u)return g.__reactInternalMemoizedMaskedChildContext;var y={},x;for(x in d)y[x]=u[x];return g&&(l=l.stateNode,l.__reactInternalMemoizedUnmaskedChildContext=u,l.__reactInternalMemoizedMaskedChildContext=y),y}function hn(l){return l=l.childContextTypes,l!=null}function Hc(){tt(un),tt(Ft)}function _x(l,u,d){if(Ft.current!==vr)throw Error(t(168));Ke(Ft,u),Ke(un,d)}function Qx(l,u,d){var g=l.stateNode;if(u=u.childContextTypes,typeof g.getChildContext!="function")return d;g=g.getChildContext();for(var y in g)if(!(y in u))throw Error(t(108,Le(l)||"Unknown",y));return J({},d,g)}function Gc(l){return l=(l=l.stateNode)&&l.__reactInternalMemoizedMergedChildContext||vr,os=Ft.current,Ke(Ft,l),Ke(un,un.current),!0}function Cx(l,u,d){var g=l.stateNode;if(!g)throw Error(t(169));d?(l=Qx(l,u,os),g.__reactInternalMemoizedMergedChildContext=l,tt(un),tt(Ft),Ke(Ft,l)):tt(un),Ke(un,d)}var Ni=null,Kc=!1,tp=!1;function Tx(l){Ni===null?Ni=[l]:Ni.push(l)}function V2(l){Kc=!0,Tx(l)}function br(){if(!tp&&Ni!==null){tp=!0;var l=0,u=Ve;try{var d=Ni;for(Ve=1;l>=P,y-=P,Bi=1<<32-Gn(u)+y|d<Te?(Et=Ce,Ce=null):Et=Ce.sibling;var Ne=G(j,Ce,Z[Te],le);if(Ne===null){Ce===null&&(Ce=Et);break}l&&Ce&&Ne.alternate===null&&u(j,Ce),z=x(Ne,z,Te),Qe===null?we=Ne:Qe.sibling=Ne,Qe=Ne,Ce=Et}if(Te===Z.length)return d(j,Ce),ot&&as(j,Te),we;if(Ce===null){for(;TeTe?(Et=Ce,Ce=null):Et=Ce.sibling;var $r=G(j,Ce,Ne.value,le);if($r===null){Ce===null&&(Ce=Et);break}l&&Ce&&$r.alternate===null&&u(j,Ce),z=x($r,z,Te),Qe===null?we=$r:Qe.sibling=$r,Qe=$r,Ce=Et}if(Ne.done)return d(j,Ce),ot&&as(j,Te),we;if(Ce===null){for(;!Ne.done;Te++,Ne=Z.next())Ne=te(j,Ne.value,le),Ne!==null&&(z=x(Ne,z,Te),Qe===null?we=Ne:Qe.sibling=Ne,Qe=Ne);return ot&&as(j,Te),we}for(Ce=g(j,Ce);!Ne.done;Te++,Ne=Z.next())Ne=de(Ce,j,Te,Ne.value,le),Ne!==null&&(l&&Ne.alternate!==null&&Ce.delete(Ne.key===null?Te:Ne.key),z=x(Ne,z,Te),Qe===null?we=Ne:Qe.sibling=Ne,Qe=Ne);return l&&Ce.forEach(function(P$){return u(j,P$)}),ot&&as(j,Te),we}function gt(j,z,Z,le){if(typeof Z=="object"&&Z!==null&&Z.type===R&&Z.key===null&&(Z=Z.props.children),typeof Z=="object"&&Z!==null){switch(Z.$$typeof){case C:e:{for(var we=Z.key,Qe=z;Qe!==null;){if(Qe.key===we){if(we=Z.type,we===R){if(Qe.tag===7){d(j,Qe.sibling),z=y(Qe,Z.props.children),z.return=j,j=z;break e}}else if(Qe.elementType===we||typeof we=="object"&&we!==null&&we.$$typeof===ce&&Lx(we)===Qe.type){d(j,Qe.sibling),z=y(Qe,Z.props),z.ref=zl(j,Qe,Z),z.return=j,j=z;break e}d(j,Qe);break}else u(j,Qe);Qe=Qe.sibling}Z.type===R?(z=ms(Z.props.children,j.mode,le,Z.key),z.return=j,j=z):(le=_u(Z.type,Z.key,Z.props,null,j.mode,le),le.ref=zl(j,z,Z),le.return=j,j=le)}return P(j);case M:e:{for(Qe=Z.key;z!==null;){if(z.key===Qe)if(z.tag===4&&z.stateNode.containerInfo===Z.containerInfo&&z.stateNode.implementation===Z.implementation){d(j,z.sibling),z=y(z,Z.children||[]),z.return=j,j=z;break e}else{d(j,z);break}else u(j,z);z=z.sibling}z=Kp(Z,j.mode,le),z.return=j,j=z}return P(j);case ce:return Qe=Z._init,gt(j,z,Qe(Z._payload),le)}if(fl(Z))return ye(j,z,Z,le);if(U(Z))return be(j,z,Z,le);nu(j,Z)}return typeof Z=="string"&&Z!==""||typeof Z=="number"?(Z=""+Z,z!==null&&z.tag===6?(d(j,z.sibling),z=y(z,Z),z.return=j,j=z):(d(j,z),z=Gp(Z,j.mode,le),z.return=j,j=z),P(j)):d(j,z)}return gt}var po=Dx(!0),zx=Dx(!1),iu=xr(null),ru=null,go=null,lp=null;function ap(){lp=go=ru=null}function cp(l){var u=iu.current;tt(iu),l._currentValue=u}function up(l,u,d){for(;l!==null;){var g=l.alternate;if((l.childLanes&u)!==u?(l.childLanes|=u,g!==null&&(g.childLanes|=u)):g!==null&&(g.childLanes&u)!==u&&(g.childLanes|=u),l===d)break;l=l.return}}function mo(l,u){ru=l,lp=go=null,l=l.dependencies,l!==null&&l.firstContext!==null&&((l.lanes&u)!==0&&(fn=!0),l.firstContext=null)}function zn(l){var u=l._currentValue;if(lp!==l)if(l={context:l,memoizedValue:u,next:null},go===null){if(ru===null)throw Error(t(308));go=l,ru.dependencies={lanes:0,firstContext:l}}else go=go.next=l;return u}var cs=null;function hp(l){cs===null?cs=[l]:cs.push(l)}function jx(l,u,d,g){var y=u.interleaved;return y===null?(d.next=d,hp(u)):(d.next=y.next,y.next=d),u.interleaved=d,Wi(l,g)}function Wi(l,u){l.lanes|=u;var d=l.alternate;for(d!==null&&(d.lanes|=u),d=l,l=l.return;l!==null;)l.childLanes|=u,d=l.alternate,d!==null&&(d.childLanes|=u),d=l,l=l.return;return d.tag===3?d.stateNode:null}var Sr=!1;function fp(l){l.updateQueue={baseState:l.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Zx(l,u){l=l.updateQueue,u.updateQueue===l&&(u.updateQueue={baseState:l.baseState,firstBaseUpdate:l.firstBaseUpdate,lastBaseUpdate:l.lastBaseUpdate,shared:l.shared,effects:l.effects})}function Vi(l,u){return{eventTime:l,lane:u,tag:0,payload:null,callback:null,next:null}}function wr(l,u,d){var g=l.updateQueue;if(g===null)return null;if(g=g.shared,(je&2)!==0){var y=g.pending;return y===null?u.next=u:(u.next=y.next,y.next=u),g.pending=u,Wi(l,d)}return y=g.interleaved,y===null?(u.next=u,hp(g)):(u.next=y.next,y.next=u),g.interleaved=u,Wi(l,d)}function su(l,u,d){if(u=u.updateQueue,u!==null&&(u=u.shared,(d&4194240)!==0)){var g=u.lanes;g&=l.pendingLanes,d|=g,u.lanes=d,_d(l,d)}}function Ix(l,u){var d=l.updateQueue,g=l.alternate;if(g!==null&&(g=g.updateQueue,d===g)){var y=null,x=null;if(d=d.firstBaseUpdate,d!==null){do{var P={eventTime:d.eventTime,lane:d.lane,tag:d.tag,payload:d.payload,callback:d.callback,next:null};x===null?y=x=P:x=x.next=P,d=d.next}while(d!==null);x===null?y=x=u:x=x.next=u}else y=x=u;d={baseState:g.baseState,firstBaseUpdate:y,lastBaseUpdate:x,shared:g.shared,effects:g.effects},l.updateQueue=d;return}l=d.lastBaseUpdate,l===null?d.firstBaseUpdate=u:l.next=u,d.lastBaseUpdate=u}function ou(l,u,d,g){var y=l.updateQueue;Sr=!1;var x=y.firstBaseUpdate,P=y.lastBaseUpdate,$=y.shared.pending;if($!==null){y.shared.pending=null;var E=$,N=E.next;E.next=null,P===null?x=N:P.next=N,P=E;var K=l.alternate;K!==null&&(K=K.updateQueue,$=K.lastBaseUpdate,$!==P&&($===null?K.firstBaseUpdate=N:$.next=N,K.lastBaseUpdate=E))}if(x!==null){var te=y.baseState;P=0,K=N=E=null,$=x;do{var G=$.lane,de=$.eventTime;if((g&G)===G){K!==null&&(K=K.next={eventTime:de,lane:0,tag:$.tag,payload:$.payload,callback:$.callback,next:null});e:{var ye=l,be=$;switch(G=u,de=d,be.tag){case 1:if(ye=be.payload,typeof ye=="function"){te=ye.call(de,te,G);break e}te=ye;break e;case 3:ye.flags=ye.flags&-65537|128;case 0:if(ye=be.payload,G=typeof ye=="function"?ye.call(de,te,G):ye,G==null)break e;te=J({},te,G);break e;case 2:Sr=!0}}$.callback!==null&&$.lane!==0&&(l.flags|=64,G=y.effects,G===null?y.effects=[$]:G.push($))}else de={eventTime:de,lane:G,tag:$.tag,payload:$.payload,callback:$.callback,next:null},K===null?(N=K=de,E=te):K=K.next=de,P|=G;if($=$.next,$===null){if($=y.shared.pending,$===null)break;G=$,$=G.next,G.next=null,y.lastBaseUpdate=G,y.shared.pending=null}}while(!0);if(K===null&&(E=te),y.baseState=E,y.firstBaseUpdate=N,y.lastBaseUpdate=K,u=y.shared.interleaved,u!==null){y=u;do P|=y.lane,y=y.next;while(y!==u)}else x===null&&(y.shared.lanes=0);fs|=P,l.lanes=P,l.memoizedState=te}}function Nx(l,u,d){if(l=u.effects,u.effects=null,l!==null)for(u=0;ud?d:4,l(!0);var g=Op.transition;Op.transition={};try{l(!1),u()}finally{Ve=d,Op.transition=g}}function o1(){return jn().memoizedState}function U2(l,u,d){var g=Qr(l);if(d={lane:g,action:d,hasEagerState:!1,eagerState:null,next:null},l1(l))a1(u,d);else if(d=jx(l,u,d,g),d!==null){var y=on();ii(d,l,g,y),c1(d,u,g)}}function H2(l,u,d){var g=Qr(l),y={lane:g,action:d,hasEagerState:!1,eagerState:null,next:null};if(l1(l))a1(u,y);else{var x=l.alternate;if(l.lanes===0&&(x===null||x.lanes===0)&&(x=u.lastRenderedReducer,x!==null))try{var P=u.lastRenderedState,$=x(P,d);if(y.hasEagerState=!0,y.eagerState=$,Kn($,P)){var E=u.interleaved;E===null?(y.next=y,hp(u)):(y.next=E.next,E.next=y),u.interleaved=y;return}}catch{}finally{}d=jx(l,u,y,g),d!==null&&(y=on(),ii(d,l,g,y),c1(d,u,g))}}function l1(l){var u=l.alternate;return l===ct||u!==null&&u===ct}function a1(l,u){Nl=cu=!0;var d=l.pending;d===null?u.next=u:(u.next=d.next,d.next=u),l.pending=u}function c1(l,u,d){if((d&4194240)!==0){var g=u.lanes;g&=l.pendingLanes,d|=g,u.lanes=d,_d(l,d)}}var fu={readContext:zn,useCallback:Yt,useContext:Yt,useEffect:Yt,useImperativeHandle:Yt,useInsertionEffect:Yt,useLayoutEffect:Yt,useMemo:Yt,useReducer:Yt,useRef:Yt,useState:Yt,useDebugValue:Yt,useDeferredValue:Yt,useTransition:Yt,useMutableSource:Yt,useSyncExternalStore:Yt,useId:Yt,unstable_isNewReconciler:!1},G2={readContext:zn,useCallback:function(l,u){return xi().memoizedState=[l,u===void 0?null:u],l},useContext:zn,useEffect:Kx,useImperativeHandle:function(l,u,d){return d=d!=null?d.concat([l]):null,uu(4194308,4,t1.bind(null,u,l),d)},useLayoutEffect:function(l,u){return uu(4194308,4,l,u)},useInsertionEffect:function(l,u){return uu(4,2,l,u)},useMemo:function(l,u){var d=xi();return u=u===void 0?null:u,l=l(),d.memoizedState=[l,u],l},useReducer:function(l,u,d){var g=xi();return u=d!==void 0?d(u):u,g.memoizedState=g.baseState=u,l={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:l,lastRenderedState:u},g.queue=l,l=l.dispatch=U2.bind(null,ct,l),[g.memoizedState,l]},useRef:function(l){var u=xi();return l={current:l},u.memoizedState=l},useState:Hx,useDebugValue:kp,useDeferredValue:function(l){return xi().memoizedState=l},useTransition:function(){var l=Hx(!1),u=l[0];return l=q2.bind(null,l[1]),xi().memoizedState=l,[u,l]},useMutableSource:function(){},useSyncExternalStore:function(l,u,d){var g=ct,y=xi();if(ot){if(d===void 0)throw Error(t(407));d=d()}else{if(d=u(),At===null)throw Error(t(349));(hs&30)!==0||Vx(g,u,d)}y.memoizedState=d;var x={value:d,getSnapshot:u};return y.queue=x,Kx(Yx.bind(null,g,x,l),[l]),g.flags|=2048,Wl(9,Fx.bind(null,g,x,d,u),void 0,null),d},useId:function(){var l=xi(),u=At.identifierPrefix;if(ot){var d=Xi,g=Bi;d=(g&~(1<<32-Gn(g)-1)).toString(32)+d,u=":"+u+"R"+d,d=Bl++,0<\/script>",l=l.removeChild(l.firstChild)):typeof g.is=="string"?l=P.createElement(d,{is:g.is}):(l=P.createElement(d),d==="select"&&(P=l,g.multiple?P.multiple=!0:g.size&&(P.size=g.size))):l=P.createElementNS(l,d),l[Oi]=u,l[Ll]=g,T1(l,u,!1,!1),u.stateNode=l;e:{switch(P=md(d,g),d){case"dialog":et("cancel",l),et("close",l),y=g;break;case"iframe":case"object":case"embed":et("load",l),y=g;break;case"video":case"audio":for(y=0;ybo&&(u.flags|=128,g=!0,Vl(x,!1),u.lanes=4194304)}else{if(!g)if(l=lu(P),l!==null){if(u.flags|=128,g=!0,d=l.updateQueue,d!==null&&(u.updateQueue=d,u.flags|=4),Vl(x,!0),x.tail===null&&x.tailMode==="hidden"&&!P.alternate&&!ot)return qt(u),null}else 2*pt()-x.renderingStartTime>bo&&d!==1073741824&&(u.flags|=128,g=!0,Vl(x,!1),u.lanes=4194304);x.isBackwards?(P.sibling=u.child,u.child=P):(d=x.last,d!==null?d.sibling=P:u.child=P,x.last=P)}return x.tail!==null?(u=x.tail,x.rendering=u,x.tail=u.sibling,x.renderingStartTime=pt(),u.sibling=null,d=at.current,Ke(at,g?d&1|2:d&1),u):(qt(u),null);case 22:case 23:return qp(),g=u.memoizedState!==null,l!==null&&l.memoizedState!==null!==g&&(u.flags|=8192),g&&(u.mode&1)!==0?(Cn&1073741824)!==0&&(qt(u),u.subtreeFlags&6&&(u.flags|=8192)):qt(u),null;case 24:return null;case 25:return null}throw Error(t(156,u.tag))}function s$(l,u){switch(ip(u),u.tag){case 1:return hn(u.type)&&Hc(),l=u.flags,l&65536?(u.flags=l&-65537|128,u):null;case 3:return Oo(),tt(un),tt(Ft),mp(),l=u.flags,(l&65536)!==0&&(l&128)===0?(u.flags=l&-65537|128,u):null;case 5:return pp(u),null;case 13:if(tt(at),l=u.memoizedState,l!==null&&l.dehydrated!==null){if(u.alternate===null)throw Error(t(340));fo()}return l=u.flags,l&65536?(u.flags=l&-65537|128,u):null;case 19:return tt(at),null;case 4:return Oo(),null;case 10:return cp(u.type._context),null;case 22:case 23:return qp(),null;case 24:return null;default:return null}}var mu=!1,Ut=!1,o$=typeof WeakSet=="function"?WeakSet:Set,ge=null;function xo(l,u){var d=l.ref;if(d!==null)if(typeof d=="function")try{d(null)}catch(g){ht(l,u,g)}else d.current=null}function Dp(l,u,d){try{d()}catch(g){ht(l,u,g)}}var R1=!1;function l$(l,u){if(qd=Dc,l=ux(),Id(l)){if("selectionStart"in l)var d={start:l.selectionStart,end:l.selectionEnd};else e:{d=(d=l.ownerDocument)&&d.defaultView||window;var g=d.getSelection&&d.getSelection();if(g&&g.rangeCount!==0){d=g.anchorNode;var y=g.anchorOffset,x=g.focusNode;g=g.focusOffset;try{d.nodeType,x.nodeType}catch{d=null;break e}var P=0,$=-1,E=-1,N=0,K=0,te=l,G=null;t:for(;;){for(var de;te!==d||y!==0&&te.nodeType!==3||($=P+y),te!==x||g!==0&&te.nodeType!==3||(E=P+g),te.nodeType===3&&(P+=te.nodeValue.length),(de=te.firstChild)!==null;)G=te,te=de;for(;;){if(te===l)break t;if(G===d&&++N===y&&($=P),G===x&&++K===g&&(E=P),(de=te.nextSibling)!==null)break;te=G,G=te.parentNode}te=de}d=$===-1||E===-1?null:{start:$,end:E}}else d=null}d=d||{start:0,end:0}}else d=null;for(Ud={focusedElem:l,selectionRange:d},Dc=!1,ge=u;ge!==null;)if(u=ge,l=u.child,(u.subtreeFlags&1028)!==0&&l!==null)l.return=u,ge=l;else for(;ge!==null;){u=ge;try{var ye=u.alternate;if((u.flags&1024)!==0)switch(u.tag){case 0:case 11:case 15:break;case 1:if(ye!==null){var be=ye.memoizedProps,gt=ye.memoizedState,j=u.stateNode,z=j.getSnapshotBeforeUpdate(u.elementType===u.type?be:ei(u.type,be),gt);j.__reactInternalSnapshotBeforeUpdate=z}break;case 3:var Z=u.stateNode.containerInfo;Z.nodeType===1?Z.textContent="":Z.nodeType===9&&Z.documentElement&&Z.removeChild(Z.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(t(163))}}catch(le){ht(u,u.return,le)}if(l=u.sibling,l!==null){l.return=u.return,ge=l;break}ge=u.return}return ye=R1,R1=!1,ye}function Fl(l,u,d){var g=u.updateQueue;if(g=g!==null?g.lastEffect:null,g!==null){var y=g=g.next;do{if((y.tag&l)===l){var x=y.destroy;y.destroy=void 0,x!==void 0&&Dp(u,d,x)}y=y.next}while(y!==g)}}function Ou(l,u){if(u=u.updateQueue,u=u!==null?u.lastEffect:null,u!==null){var d=u=u.next;do{if((d.tag&l)===l){var g=d.create;d.destroy=g()}d=d.next}while(d!==u)}}function zp(l){var u=l.ref;if(u!==null){var d=l.stateNode;switch(l.tag){case 5:l=d;break;default:l=d}typeof u=="function"?u(l):u.current=l}}function A1(l){var u=l.alternate;u!==null&&(l.alternate=null,A1(u)),l.child=null,l.deletions=null,l.sibling=null,l.tag===5&&(u=l.stateNode,u!==null&&(delete u[Oi],delete u[Ll],delete u[Jd],delete u[X2],delete u[W2])),l.stateNode=null,l.return=null,l.dependencies=null,l.memoizedProps=null,l.memoizedState=null,l.pendingProps=null,l.stateNode=null,l.updateQueue=null}function E1(l){return l.tag===5||l.tag===3||l.tag===4}function L1(l){e:for(;;){for(;l.sibling===null;){if(l.return===null||E1(l.return))return null;l=l.return}for(l.sibling.return=l.return,l=l.sibling;l.tag!==5&&l.tag!==6&&l.tag!==18;){if(l.flags&2||l.child===null||l.tag===4)continue e;l.child.return=l,l=l.child}if(!(l.flags&2))return l.stateNode}}function jp(l,u,d){var g=l.tag;if(g===5||g===6)l=l.stateNode,u?d.nodeType===8?d.parentNode.insertBefore(l,u):d.insertBefore(l,u):(d.nodeType===8?(u=d.parentNode,u.insertBefore(l,d)):(u=d,u.appendChild(l)),d=d._reactRootContainer,d!=null||u.onclick!==null||(u.onclick=qc));else if(g!==4&&(l=l.child,l!==null))for(jp(l,u,d),l=l.sibling;l!==null;)jp(l,u,d),l=l.sibling}function Zp(l,u,d){var g=l.tag;if(g===5||g===6)l=l.stateNode,u?d.insertBefore(l,u):d.appendChild(l);else if(g!==4&&(l=l.child,l!==null))for(Zp(l,u,d),l=l.sibling;l!==null;)Zp(l,u,d),l=l.sibling}var It=null,ti=!1;function kr(l,u,d){for(d=d.child;d!==null;)D1(l,u,d),d=d.sibling}function D1(l,u,d){if(mi&&typeof mi.onCommitFiberUnmount=="function")try{mi.onCommitFiberUnmount($c,d)}catch{}switch(d.tag){case 5:Ut||xo(d,u);case 6:var g=It,y=ti;It=null,kr(l,u,d),It=g,ti=y,It!==null&&(ti?(l=It,d=d.stateNode,l.nodeType===8?l.parentNode.removeChild(d):l.removeChild(d)):It.removeChild(d.stateNode));break;case 18:It!==null&&(ti?(l=It,d=d.stateNode,l.nodeType===8?Kd(l.parentNode,d):l.nodeType===1&&Kd(l,d),kl(l)):Kd(It,d.stateNode));break;case 4:g=It,y=ti,It=d.stateNode.containerInfo,ti=!0,kr(l,u,d),It=g,ti=y;break;case 0:case 11:case 14:case 15:if(!Ut&&(g=d.updateQueue,g!==null&&(g=g.lastEffect,g!==null))){y=g=g.next;do{var x=y,P=x.destroy;x=x.tag,P!==void 0&&((x&2)!==0||(x&4)!==0)&&Dp(d,u,P),y=y.next}while(y!==g)}kr(l,u,d);break;case 1:if(!Ut&&(xo(d,u),g=d.stateNode,typeof g.componentWillUnmount=="function"))try{g.props=d.memoizedProps,g.state=d.memoizedState,g.componentWillUnmount()}catch($){ht(d,u,$)}kr(l,u,d);break;case 21:kr(l,u,d);break;case 22:d.mode&1?(Ut=(g=Ut)||d.memoizedState!==null,kr(l,u,d),Ut=g):kr(l,u,d);break;default:kr(l,u,d)}}function z1(l){var u=l.updateQueue;if(u!==null){l.updateQueue=null;var d=l.stateNode;d===null&&(d=l.stateNode=new o$),u.forEach(function(g){var y=m$.bind(null,l,g);d.has(g)||(d.add(g),g.then(y,y))})}}function ni(l,u){var d=u.deletions;if(d!==null)for(var g=0;gy&&(y=P),g&=~x}if(g=y,g=pt()-g,g=(120>g?120:480>g?480:1080>g?1080:1920>g?1920:3e3>g?3e3:4320>g?4320:1960*c$(g/1960))-g,10l?16:l,_r===null)var g=!1;else{if(l=_r,_r=null,Su=0,(je&6)!==0)throw Error(t(331));var y=je;for(je|=4,ge=l.current;ge!==null;){var x=ge,P=x.child;if((ge.flags&16)!==0){var $=x.deletions;if($!==null){for(var E=0;E<$.length;E++){var N=$[E];for(ge=N;ge!==null;){var K=ge;switch(K.tag){case 0:case 11:case 15:Fl(8,K,x)}var te=K.child;if(te!==null)te.return=K,ge=te;else for(;ge!==null;){K=ge;var G=K.sibling,de=K.return;if(A1(K),K===N){ge=null;break}if(G!==null){G.return=de,ge=G;break}ge=de}}}var ye=x.alternate;if(ye!==null){var be=ye.child;if(be!==null){ye.child=null;do{var gt=be.sibling;be.sibling=null,be=gt}while(be!==null)}}ge=x}}if((x.subtreeFlags&2064)!==0&&P!==null)P.return=x,ge=P;else e:for(;ge!==null;){if(x=ge,(x.flags&2048)!==0)switch(x.tag){case 0:case 11:case 15:Fl(9,x,x.return)}var j=x.sibling;if(j!==null){j.return=x.return,ge=j;break e}ge=x.return}}var z=l.current;for(ge=z;ge!==null;){P=ge;var Z=P.child;if((P.subtreeFlags&2064)!==0&&Z!==null)Z.return=P,ge=Z;else e:for(P=z;ge!==null;){if($=ge,($.flags&2048)!==0)try{switch($.tag){case 0:case 11:case 15:Ou(9,$)}}catch(we){ht($,$.return,we)}if($===P){ge=null;break e}var le=$.sibling;if(le!==null){le.return=$.return,ge=le;break e}ge=$.return}}if(je=y,br(),mi&&typeof mi.onPostCommitFiberRoot=="function")try{mi.onPostCommitFiberRoot($c,l)}catch{}g=!0}return g}finally{Ve=d,Zn.transition=u}}return!1}function U1(l,u,d){u=yo(d,u),u=d1(l,u,1),l=wr(l,u,1),u=on(),l!==null&&(xl(l,1,u),pn(l,u))}function ht(l,u,d){if(l.tag===3)U1(l,l,d);else for(;u!==null;){if(u.tag===3){U1(u,l,d);break}else if(u.tag===1){var g=u.stateNode;if(typeof u.type.getDerivedStateFromError=="function"||typeof g.componentDidCatch=="function"&&(Pr===null||!Pr.has(g))){l=yo(d,l),l=p1(u,l,1),u=wr(u,l,1),l=on(),u!==null&&(xl(u,1,l),pn(u,l));break}}u=u.return}}function p$(l,u,d){var g=l.pingCache;g!==null&&g.delete(u),u=on(),l.pingedLanes|=l.suspendedLanes&d,At===l&&(Nt&d)===d&&(Qt===4||Qt===3&&(Nt&130023424)===Nt&&500>pt()-Bp?ps(l,0):Np|=d),pn(l,u)}function H1(l,u){u===0&&((l.mode&1)===0?u=1:(u=Rc,Rc<<=1,(Rc&130023424)===0&&(Rc=4194304)));var d=on();l=Wi(l,u),l!==null&&(xl(l,u,d),pn(l,d))}function g$(l){var u=l.memoizedState,d=0;u!==null&&(d=u.retryLane),H1(l,d)}function m$(l,u){var d=0;switch(l.tag){case 13:var g=l.stateNode,y=l.memoizedState;y!==null&&(d=y.retryLane);break;case 19:g=l.stateNode;break;default:throw Error(t(314))}g!==null&&g.delete(u),H1(l,d)}var G1;G1=function(l,u,d){if(l!==null)if(l.memoizedProps!==u.pendingProps||un.current)fn=!0;else{if((l.lanes&d)===0&&(u.flags&128)===0)return fn=!1,i$(l,u,d);fn=(l.flags&131072)!==0}else fn=!1,ot&&(u.flags&1048576)!==0&&$x(u,eu,u.index);switch(u.lanes=0,u.tag){case 2:var g=u.type;gu(l,u),l=u.pendingProps;var y=co(u,Ft.current);mo(u,d),y=xp(null,u,g,l,y,d);var x=vp();return u.flags|=1,typeof y=="object"&&y!==null&&typeof y.render=="function"&&y.$$typeof===void 0?(u.tag=1,u.memoizedState=null,u.updateQueue=null,hn(g)?(x=!0,Gc(u)):x=!1,u.memoizedState=y.state!==null&&y.state!==void 0?y.state:null,fp(u),y.updater=du,u.stateNode=y,y._reactInternals=u,_p(u,g,l,d),u=$p(null,u,g,!0,x,d)):(u.tag=0,ot&&x&&np(u),sn(null,u,y,d),u=u.child),u;case 16:g=u.elementType;e:{switch(gu(l,u),l=u.pendingProps,y=g._init,g=y(g._payload),u.type=g,y=u.tag=y$(g),l=ei(g,l),y){case 0:u=Tp(null,u,g,l,d);break e;case 1:u=w1(null,u,g,l,d);break e;case 11:u=y1(null,u,g,l,d);break e;case 14:u=x1(null,u,g,ei(g.type,l),d);break e}throw Error(t(306,g,""))}return u;case 0:return g=u.type,y=u.pendingProps,y=u.elementType===g?y:ei(g,y),Tp(l,u,g,y,d);case 1:return g=u.type,y=u.pendingProps,y=u.elementType===g?y:ei(g,y),w1(l,u,g,y,d);case 3:e:{if(k1(u),l===null)throw Error(t(387));g=u.pendingProps,x=u.memoizedState,y=x.element,Zx(l,u),ou(u,g,null,d);var P=u.memoizedState;if(g=P.element,x.isDehydrated)if(x={element:g,isDehydrated:!1,cache:P.cache,pendingSuspenseBoundaries:P.pendingSuspenseBoundaries,transitions:P.transitions},u.updateQueue.baseState=x,u.memoizedState=x,u.flags&256){y=yo(Error(t(423)),u),u=P1(l,u,g,d,y);break e}else if(g!==y){y=yo(Error(t(424)),u),u=P1(l,u,g,d,y);break e}else for(Qn=yr(u.stateNode.containerInfo.firstChild),_n=u,ot=!0,Jn=null,d=zx(u,null,g,d),u.child=d;d;)d.flags=d.flags&-3|4096,d=d.sibling;else{if(fo(),g===y){u=Fi(l,u,d);break e}sn(l,u,g,d)}u=u.child}return u;case 5:return Bx(u),l===null&&sp(u),g=u.type,y=u.pendingProps,x=l!==null?l.memoizedProps:null,P=y.children,Hd(g,y)?P=null:x!==null&&Hd(g,x)&&(u.flags|=32),S1(l,u),sn(l,u,P,d),u.child;case 6:return l===null&&sp(u),null;case 13:return _1(l,u,d);case 4:return dp(u,u.stateNode.containerInfo),g=u.pendingProps,l===null?u.child=po(u,null,g,d):sn(l,u,g,d),u.child;case 11:return g=u.type,y=u.pendingProps,y=u.elementType===g?y:ei(g,y),y1(l,u,g,y,d);case 7:return sn(l,u,u.pendingProps,d),u.child;case 8:return sn(l,u,u.pendingProps.children,d),u.child;case 12:return sn(l,u,u.pendingProps.children,d),u.child;case 10:e:{if(g=u.type._context,y=u.pendingProps,x=u.memoizedProps,P=y.value,Ke(iu,g._currentValue),g._currentValue=P,x!==null)if(Kn(x.value,P)){if(x.children===y.children&&!un.current){u=Fi(l,u,d);break e}}else for(x=u.child,x!==null&&(x.return=u);x!==null;){var $=x.dependencies;if($!==null){P=x.child;for(var E=$.firstContext;E!==null;){if(E.context===g){if(x.tag===1){E=Vi(-1,d&-d),E.tag=2;var N=x.updateQueue;if(N!==null){N=N.shared;var K=N.pending;K===null?E.next=E:(E.next=K.next,K.next=E),N.pending=E}}x.lanes|=d,E=x.alternate,E!==null&&(E.lanes|=d),up(x.return,d,u),$.lanes|=d;break}E=E.next}}else if(x.tag===10)P=x.type===u.type?null:x.child;else if(x.tag===18){if(P=x.return,P===null)throw Error(t(341));P.lanes|=d,$=P.alternate,$!==null&&($.lanes|=d),up(P,d,u),P=x.sibling}else P=x.child;if(P!==null)P.return=x;else for(P=x;P!==null;){if(P===u){P=null;break}if(x=P.sibling,x!==null){x.return=P.return,P=x;break}P=P.return}x=P}sn(l,u,y.children,d),u=u.child}return u;case 9:return y=u.type,g=u.pendingProps.children,mo(u,d),y=zn(y),g=g(y),u.flags|=1,sn(l,u,g,d),u.child;case 14:return g=u.type,y=ei(g,u.pendingProps),y=ei(g.type,y),x1(l,u,g,y,d);case 15:return v1(l,u,u.type,u.pendingProps,d);case 17:return g=u.type,y=u.pendingProps,y=u.elementType===g?y:ei(g,y),gu(l,u),u.tag=1,hn(g)?(l=!0,Gc(u)):l=!1,mo(u,d),h1(u,g,y),_p(u,g,y,d),$p(null,u,g,!0,l,d);case 19:return C1(l,u,d);case 22:return b1(l,u,d)}throw Error(t(156,u.tag))};function K1(l,u){return My(l,u)}function O$(l,u,d,g){this.tag=l,this.key=d,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=u,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=g,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function In(l,u,d,g){return new O$(l,u,d,g)}function Hp(l){return l=l.prototype,!(!l||!l.isReactComponent)}function y$(l){if(typeof l=="function")return Hp(l)?1:0;if(l!=null){if(l=l.$$typeof,l===H)return 11;if(l===oe)return 14}return 2}function Tr(l,u){var d=l.alternate;return d===null?(d=In(l.tag,u,l.key,l.mode),d.elementType=l.elementType,d.type=l.type,d.stateNode=l.stateNode,d.alternate=l,l.alternate=d):(d.pendingProps=u,d.type=l.type,d.flags=0,d.subtreeFlags=0,d.deletions=null),d.flags=l.flags&14680064,d.childLanes=l.childLanes,d.lanes=l.lanes,d.child=l.child,d.memoizedProps=l.memoizedProps,d.memoizedState=l.memoizedState,d.updateQueue=l.updateQueue,u=l.dependencies,d.dependencies=u===null?null:{lanes:u.lanes,firstContext:u.firstContext},d.sibling=l.sibling,d.index=l.index,d.ref=l.ref,d}function _u(l,u,d,g,y,x){var P=2;if(g=l,typeof l=="function")Hp(l)&&(P=1);else if(typeof l=="string")P=5;else e:switch(l){case R:return ms(d.children,y,x,u);case L:P=8,y|=8;break;case X:return l=In(12,d,u,y|2),l.elementType=X,l.lanes=x,l;case F:return l=In(13,d,u,y),l.elementType=F,l.lanes=x,l;case re:return l=In(19,d,u,y),l.elementType=re,l.lanes=x,l;case ue:return Qu(d,y,x,u);default:if(typeof l=="object"&&l!==null)switch(l.$$typeof){case ie:P=10;break e;case Y:P=9;break e;case H:P=11;break e;case oe:P=14;break e;case ce:P=16,g=null;break e}throw Error(t(130,l==null?l:typeof l,""))}return u=In(P,d,u,y),u.elementType=l,u.type=g,u.lanes=x,u}function ms(l,u,d,g){return l=In(7,l,g,u),l.lanes=d,l}function Qu(l,u,d,g){return l=In(22,l,g,u),l.elementType=ue,l.lanes=d,l.stateNode={isHidden:!1},l}function Gp(l,u,d){return l=In(6,l,null,u),l.lanes=d,l}function Kp(l,u,d){return u=In(4,l.children!==null?l.children:[],l.key,u),u.lanes=d,u.stateNode={containerInfo:l.containerInfo,pendingChildren:null,implementation:l.implementation},u}function x$(l,u,d,g,y){this.tag=u,this.containerInfo=l,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Pd(0),this.expirationTimes=Pd(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Pd(0),this.identifierPrefix=g,this.onRecoverableError=y,this.mutableSourceEagerHydrationData=null}function Jp(l,u,d,g,y,x,P,$,E){return l=new x$(l,u,d,$,E),u===1?(u=1,x===!0&&(u|=8)):u=0,x=In(3,null,null,u),l.current=x,x.stateNode=l,x.memoizedState={element:g,isDehydrated:d,cache:null,transitions:null,pendingSuspenseBoundaries:null},fp(x),l}function v$(l,u,d){var g=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}return n(),sg.exports=A$(),sg.exports}var fv;function L$(){if(fv)return Eu;fv=1;var n=E$();return Eu.createRoot=n.createRoot,Eu.hydrateRoot=n.hydrateRoot,Eu}var D$=L$();const z$=HO(D$);var zt=function(){return zt=Object.assign||function(e){for(var t,i=1,r=arguments.length;i0?Lt(sl,--Yn):0,Fo--,vt===10&&(Fo=1,jf--),vt}function ci(){return vt=Yn2||bm(vt)>3?"":" "}function Y$(n,e){for(;--e&&ci()&&!(vt<48||vt>102||vt>57&&vt<65||vt>70&&vt<97););return If(n,Ph()+(e<6&&Ds()==32&&ci()==32))}function Sm(n){for(;ci();)switch(vt){case n:return Yn;case 34:case 39:n!==34&&n!==39&&Sm(vt);break;case 40:n===41&&Sm(n);break;case 92:ci();break}return Yn}function q$(n,e){for(;ci()&&n+vt!==57;)if(n+vt===84&&Ds()===47)break;return"/*"+If(e,Yn-1)+"*"+JO(n===47?n:ci())}function U$(n){for(;!bm(Ds());)ci();return If(n,Yn)}function H$(n){return V$(_h("",null,null,null,[""],n=W$(n),0,[0],n))}function _h(n,e,t,i,r,s,o,a,c){for(var h=0,f=0,p=o,m=0,O=0,v=0,b=1,S=1,w=1,T=0,k="",_=r,C=s,M=i,R=k;S;)switch(v=T,T=ci()){case 40:if(v!=108&&Lt(R,p-1)==58){kh(R+=Me(ag(T),"&","&\f"),"&\f",$k(h?a[h-1]:0))!=-1&&(w=-1);break}case 34:case 39:case 91:R+=ag(T);break;case 9:case 10:case 13:case 32:R+=F$(v);break;case 92:R+=Y$(Ph()-1,7);continue;case 47:switch(Ds()){case 42:case 47:ha(G$(q$(ci(),Ph()),e,t,c),c);break;default:R+="/"}break;case 123*b:a[h++]=Ci(R)*w;case 125*b:case 59:case 0:switch(T){case 0:case 125:S=0;case 59+f:w==-1&&(R=Me(R,/\f/g,"")),O>0&&Ci(R)-p&&ha(O>32?gv(R+";",i,t,p-1,c):gv(Me(R," ","")+";",i,t,p-2,c),c);break;case 59:R+=";";default:if(ha(M=pv(R,e,t,h,f,r,a,k,_=[],C=[],p,s),s),T===123)if(f===0)_h(R,e,M,M,_,s,p,a,C);else switch(m===99&&Lt(R,3)===110?100:m){case 100:case 108:case 109:case 115:_h(n,M,M,i&&ha(pv(n,M,M,0,0,r,a,k,r,_=[],p,C),C),r,C,p,a,i?_:C);break;default:_h(R,M,M,M,[""],C,0,a,C)}}h=f=O=0,b=w=1,k=R="",p=o;break;case 58:p=1+Ci(R),O=v;default:if(b<1){if(T==123)--b;else if(T==125&&b++==0&&X$()==125)continue}switch(R+=JO(T),T*b){case 38:w=f>0?1:(R+="\f",-1);break;case 44:a[h++]=(Ci(R)-1)*w,w=1;break;case 64:Ds()===45&&(R+=ag(ci())),m=Ds(),f=p=Ci(k=R+=U$(Ph())),T++;break;case 45:v===45&&Ci(R)==2&&(b=0)}}return s}function pv(n,e,t,i,r,s,o,a,c,h,f,p){for(var m=r-1,O=r===0?s:[""],v=Rk(O),b=0,S=0,w=0;b0?O[T]+" "+k:Me(k,/&\f/g,O[T])))&&(c[w++]=_);return Zf(n,e,t,r===0?zf:a,c,h,f,p)}function G$(n,e,t,i){return Zf(n,e,t,Ck,JO(B$()),Vo(n,2,-2),0,i)}function gv(n,e,t,i,r){return Zf(n,e,t,KO,Vo(n,0,i),Vo(n,i+1,-1),i,r)}function Ek(n,e,t){switch(I$(n,e)){case 5103:return Xe+"print-"+n+n;case 5737:case 4201:case 3177:case 3433:case 1641:case 4457:case 2921:case 5572:case 6356:case 5844:case 3191:case 6645:case 3005:case 6391:case 5879:case 5623:case 6135:case 4599:case 4855:case 4215:case 6389:case 5109:case 5365:case 5621:case 3829:return Xe+n+n;case 4789:return Sa+n+n;case 5349:case 4246:case 4810:case 6968:case 2756:return Xe+n+Sa+n+nt+n+n;case 5936:switch(Lt(n,e+11)){case 114:return Xe+n+nt+Me(n,/[svh]\w+-[tblr]{2}/,"tb")+n;case 108:return Xe+n+nt+Me(n,/[svh]\w+-[tblr]{2}/,"tb-rl")+n;case 45:return Xe+n+nt+Me(n,/[svh]\w+-[tblr]{2}/,"lr")+n}case 6828:case 4268:case 2903:return Xe+n+nt+n+n;case 6165:return Xe+n+nt+"flex-"+n+n;case 5187:return Xe+n+Me(n,/(\w+).+(:[^]+)/,Xe+"box-$1$2"+nt+"flex-$1$2")+n;case 5443:return Xe+n+nt+"flex-item-"+Me(n,/flex-|-self/g,"")+(Ki(n,/flex-|baseline/)?"":nt+"grid-row-"+Me(n,/flex-|-self/g,""))+n;case 4675:return Xe+n+nt+"flex-line-pack"+Me(n,/align-content|flex-|-self/g,"")+n;case 5548:return Xe+n+nt+Me(n,"shrink","negative")+n;case 5292:return Xe+n+nt+Me(n,"basis","preferred-size")+n;case 6060:return Xe+"box-"+Me(n,"-grow","")+Xe+n+nt+Me(n,"grow","positive")+n;case 4554:return Xe+Me(n,/([^-])(transform)/g,"$1"+Xe+"$2")+n;case 6187:return Me(Me(Me(n,/(zoom-|grab)/,Xe+"$1"),/(image-set)/,Xe+"$1"),n,"")+n;case 5495:case 3959:return Me(n,/(image-set\([^]*)/,Xe+"$1$`$1");case 4968:return Me(Me(n,/(.+:)(flex-)?(.*)/,Xe+"box-pack:$3"+nt+"flex-pack:$3"),/s.+-b[^;]+/,"justify")+Xe+n+n;case 4200:if(!Ki(n,/flex-|baseline/))return nt+"grid-column-align"+Vo(n,e)+n;break;case 2592:case 3360:return nt+Me(n,"template-","")+n;case 4384:case 3616:return t&&t.some(function(i,r){return e=r,Ki(i.props,/grid-\w+-end/)})?~kh(n+(t=t[e].value),"span",0)?n:nt+Me(n,"-start","")+n+nt+"grid-row-span:"+(~kh(t,"span",0)?Ki(t,/\d+/):+Ki(t,/\d+/)-+Ki(n,/\d+/))+";":nt+Me(n,"-start","")+n;case 4896:case 4128:return t&&t.some(function(i){return Ki(i.props,/grid-\w+-start/)})?n:nt+Me(Me(n,"-end","-span"),"span ","")+n;case 4095:case 3583:case 4068:case 2532:return Me(n,/(.+)-inline(.+)/,Xe+"$1$2")+n;case 8116:case 7059:case 5753:case 5535:case 5445:case 5701:case 4933:case 4677:case 5533:case 5789:case 5021:case 4765:if(Ci(n)-1-e>6)switch(Lt(n,e+1)){case 109:if(Lt(n,e+4)!==45)break;case 102:return Me(n,/(.+:)(.+)-([^]+)/,"$1"+Xe+"$2-$3$1"+Sa+(Lt(n,e+3)==108?"$3":"$2-$3"))+n;case 115:return~kh(n,"stretch",0)?Ek(Me(n,"stretch","fill-available"),e,t)+n:n}break;case 5152:case 5920:return Me(n,/(.+?):(\d+)(\s*\/\s*(span)?\s*(\d+))?(.*)/,function(i,r,s,o,a,c,h){return nt+r+":"+s+h+(o?nt+r+"-span:"+(a?c:+c-+s)+h:"")+n});case 4949:if(Lt(n,e+6)===121)return Me(n,":",":"+Xe)+n;break;case 6444:switch(Lt(n,Lt(n,14)===45?18:11)){case 120:return Me(n,/(.+:)([^;\s!]+)(;|(\s+)?!.+)?/,"$1"+Xe+(Lt(n,14)===45?"inline-":"")+"box$3$1"+Xe+"$2$3$1"+nt+"$2box$3")+n;case 100:return Me(n,":",":"+nt)+n}break;case 5719:case 2647:case 2135:case 3927:case 2391:return Me(n,"scroll-","scroll-snap-")+n}return n}function qh(n,e){for(var t="",i=0;i-1&&!n.return)switch(n.type){case KO:n.return=Ek(n.value,n.length,t);return;case Tk:return qh([Rr(n,{value:Me(n.value,"@","@"+Xe)})],i);case zf:if(n.length)return N$(t=n.props,function(r){switch(Ki(r,i=/(::plac\w+|:read-\w+)/)){case":read-only":case":read-write":wo(Rr(n,{props:[Me(r,/:(read-\w+)/,":"+Sa+"$1")]})),wo(Rr(n,{props:[r]})),vm(n,{props:dv(t,i)});break;case"::placeholder":wo(Rr(n,{props:[Me(r,/:(plac\w+)/,":"+Xe+"input-$1")]})),wo(Rr(n,{props:[Me(r,/:(plac\w+)/,":"+Sa+"$1")]})),wo(Rr(n,{props:[Me(r,/:(plac\w+)/,nt+"input-$1")]})),wo(Rr(n,{props:[r]})),vm(n,{props:dv(t,i)});break}return""})}}var nM={animationIterationCount:1,aspectRatio:1,borderImageOutset:1,borderImageSlice:1,borderImageWidth:1,boxFlex:1,boxFlexGroup:1,boxOrdinalGroup:1,columnCount:1,columns:1,flex:1,flexGrow:1,flexPositive:1,flexShrink:1,flexNegative:1,flexOrder:1,gridRow:1,gridRowEnd:1,gridRowSpan:1,gridRowStart:1,gridColumn:1,gridColumnEnd:1,gridColumnSpan:1,gridColumnStart:1,msGridRow:1,msGridRowSpan:1,msGridColumn:1,msGridColumnSpan:1,fontWeight:1,lineHeight:1,opacity:1,order:1,orphans:1,tabSize:1,widows:1,zIndex:1,zoom:1,WebkitLineClamp:1,fillOpacity:1,floodOpacity:1,stopOpacity:1,strokeDasharray:1,strokeDashoffset:1,strokeMiterlimit:1,strokeOpacity:1,strokeWidth:1},Mn={},Yo=typeof process<"u"&&Mn!==void 0&&(Mn.REACT_APP_SC_ATTR||Mn.SC_ATTR)||"data-styled",Lk="active",Dk="data-styled-version",Nf="6.1.19",e0=`/*!sc*/ +`,Uh=typeof window<"u"&&typeof document<"u",iM=!!(typeof SC_DISABLE_SPEEDY=="boolean"?SC_DISABLE_SPEEDY:typeof process<"u"&&Mn!==void 0&&Mn.REACT_APP_SC_DISABLE_SPEEDY!==void 0&&Mn.REACT_APP_SC_DISABLE_SPEEDY!==""?Mn.REACT_APP_SC_DISABLE_SPEEDY!=="false"&&Mn.REACT_APP_SC_DISABLE_SPEEDY:typeof process<"u"&&Mn!==void 0&&Mn.SC_DISABLE_SPEEDY!==void 0&&Mn.SC_DISABLE_SPEEDY!==""&&Mn.SC_DISABLE_SPEEDY!=="false"&&Mn.SC_DISABLE_SPEEDY),rM={},Bf=Object.freeze([]),qo=Object.freeze({});function zk(n,e,t){return t===void 0&&(t=qo),n.theme!==t.theme&&n.theme||e||t.theme}var jk=new Set(["a","abbr","address","area","article","aside","audio","b","base","bdi","bdo","big","blockquote","body","br","button","canvas","caption","cite","code","col","colgroup","data","datalist","dd","del","details","dfn","dialog","div","dl","dt","em","embed","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","hr","html","i","iframe","img","input","ins","kbd","keygen","label","legend","li","link","main","map","mark","menu","menuitem","meta","meter","nav","noscript","object","ol","optgroup","option","output","p","param","picture","pre","progress","q","rp","rt","ruby","s","samp","script","section","select","small","source","span","strong","style","sub","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","track","u","ul","use","var","video","wbr","circle","clipPath","defs","ellipse","foreignObject","g","image","line","linearGradient","marker","mask","path","pattern","polygon","polyline","radialGradient","rect","stop","svg","text","tspan"]),sM=/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~-]+/g,oM=/(^-|-$)/g;function mv(n){return n.replace(sM,"-").replace(oM,"")}var lM=/(a)(d)/gi,Lu=52,Ov=function(n){return String.fromCharCode(n+(n>25?39:97))};function wm(n){var e,t="";for(e=Math.abs(n);e>Lu;e=e/Lu|0)t=Ov(e%Lu)+t;return(Ov(e%Lu)+t).replace(lM,"$1-$2")}var cg,Zk=5381,Mo=function(n,e){for(var t=e.length;t;)n=33*n^e.charCodeAt(--t);return n},Ik=function(n){return Mo(Zk,n)};function Nk(n){return wm(Ik(n)>>>0)}function aM(n){return n.displayName||n.name||"Component"}function ug(n){return typeof n=="string"&&!0}var Bk=typeof Symbol=="function"&&Symbol.for,Xk=Bk?Symbol.for("react.memo"):60115,cM=Bk?Symbol.for("react.forward_ref"):60112,uM={childContextTypes:!0,contextType:!0,contextTypes:!0,defaultProps:!0,displayName:!0,getDefaultProps:!0,getDerivedStateFromError:!0,getDerivedStateFromProps:!0,mixins:!0,propTypes:!0,type:!0},hM={name:!0,length:!0,prototype:!0,caller:!0,callee:!0,arguments:!0,arity:!0},Wk={$$typeof:!0,compare:!0,defaultProps:!0,displayName:!0,propTypes:!0,type:!0},fM=((cg={})[cM]={$$typeof:!0,render:!0,defaultProps:!0,displayName:!0,propTypes:!0},cg[Xk]=Wk,cg);function yv(n){return("type"in(e=n)&&e.type.$$typeof)===Xk?Wk:"$$typeof"in n?fM[n.$$typeof]:uM;var e}var dM=Object.defineProperty,pM=Object.getOwnPropertyNames,xv=Object.getOwnPropertySymbols,gM=Object.getOwnPropertyDescriptor,mM=Object.getPrototypeOf,vv=Object.prototype;function Vk(n,e,t){if(typeof e!="string"){if(vv){var i=mM(e);i&&i!==vv&&Vk(n,i,t)}var r=pM(e);xv&&(r=r.concat(xv(e)));for(var s=yv(n),o=yv(e),a=0;a0?" Args: ".concat(e.join(", ")):""))}var OM=(function(){function n(e){this.groupSizes=new Uint32Array(512),this.length=512,this.tag=e}return n.prototype.indexOfGroup=function(e){for(var t=0,i=0;i=this.groupSizes.length){for(var i=this.groupSizes,r=i.length,s=r;e>=s;)if((s<<=1)<0)throw Bs(16,"".concat(e));this.groupSizes=new Uint32Array(s),this.groupSizes.set(i),this.length=s;for(var o=r;o=this.length||this.groupSizes[e]===0)return t;for(var i=this.groupSizes[e],r=this.indexOfGroup(e),s=r+i,o=r;o=0){var i=document.createTextNode(t);return this.element.insertBefore(i,this.nodes[e]||null),this.length++,!0}return!1},n.prototype.deleteRule=function(e){this.element.removeChild(this.nodes[e]),this.length--},n.prototype.getRule=function(e){return e0&&(S+="".concat(w,","))}),c+="".concat(v).concat(b,'{content:"').concat(S,'"}').concat(e0)},f=0;f0?".".concat(e):m},f=c.slice();f.push(function(m){m.type===zf&&m.value.includes("&")&&(m.props[0]=m.props[0].replace(CM,t).replace(i,h))}),o.prefix&&f.push(tM),f.push(K$);var p=function(m,O,v,b){O===void 0&&(O=""),v===void 0&&(v=""),b===void 0&&(b="&"),e=b,t=O,i=new RegExp("\\".concat(t,"\\b"),"g");var S=m.replace(TM,""),w=H$(v||O?"".concat(v," ").concat(O," { ").concat(S," }"):S);o.namespace&&(w=Yk(w,o.namespace));var T=[];return qh(w,J$(f.concat(eM(function(k){return T.push(k)})))),T};return p.hash=c.length?c.reduce(function(m,O){return O.name||Bs(15),Mo(m,O.name)},Zk).toString():"",p}var MM=new Gh,_m=$M(),qk=dt.createContext({shouldForwardProp:void 0,styleSheet:MM,stylis:_m});qk.Consumer;dt.createContext(void 0);function Qm(){return ne.useContext(qk)}var RM=(function(){function n(e,t){var i=this;this.inject=function(r,s){s===void 0&&(s=_m);var o=i.name+s.hash;r.hasNameForId(i.id,o)||r.insertRules(i.id,o,s(i.rules,o,"@keyframes"))},this.name=e,this.id="sc-keyframes-".concat(e),this.rules=t,n0(this,function(){throw Bs(12,String(i.name))})}return n.prototype.getName=function(e){return e===void 0&&(e=_m),this.name+e.hash},n})(),AM=function(n){return n>="A"&&n<="Z"};function wv(n){for(var e="",t=0;t>>0);if(!t.hasNameForId(this.componentId,o)){var a=i(s,".".concat(o),void 0,this.componentId);t.insertRules(this.componentId,o,a)}r=Rs(r,o),this.staticRulesId=o}else{for(var c=Mo(this.baseHash,i.hash),h="",f=0;f>>0);t.hasNameForId(this.componentId,O)||t.insertRules(this.componentId,O,i(h,".".concat(O),void 0,this.componentId)),r=Rs(r,O)}}return r},n})(),ja=dt.createContext(void 0);ja.Consumer;function DM(n){var e=dt.useContext(ja),t=ne.useMemo(function(){return(function(i,r){if(!i)throw Bs(14);if(Ns(i)){var s=i(r);return s}if(Array.isArray(i)||typeof i!="object")throw Bs(8);return r?zt(zt({},r),i):i})(n.theme,e)},[n.theme,e]);return n.children?dt.createElement(ja.Provider,{value:t},n.children):null}var hg={};function zM(n,e,t){var i=t0(n),r=n,s=!ug(n),o=e.attrs,a=o===void 0?Bf:o,c=e.componentId,h=c===void 0?(function(_,C){var M=typeof _!="string"?"sc":mv(_);hg[M]=(hg[M]||0)+1;var R="".concat(M,"-").concat(Nk(Nf+M+hg[M]));return C?"".concat(C,"-").concat(R):R})(e.displayName,e.parentComponentId):c,f=e.displayName,p=f===void 0?(function(_){return ug(_)?"styled.".concat(_):"Styled(".concat(aM(_),")")})(n):f,m=e.displayName&&e.componentId?"".concat(mv(e.displayName),"-").concat(e.componentId):e.componentId||h,O=i&&r.attrs?r.attrs.concat(a).filter(Boolean):a,v=e.shouldForwardProp;if(i&&r.shouldForwardProp){var b=r.shouldForwardProp;if(e.shouldForwardProp){var S=e.shouldForwardProp;v=function(_,C){return b(_,C)&&S(_,C)}}else v=b}var w=new LM(t,m,i?r.componentStyle:void 0);function T(_,C){return(function(M,R,L){var X=M.attrs,ie=M.componentStyle,Y=M.defaultProps,H=M.foldedComponentIds,F=M.styledComponentId,re=M.target,oe=dt.useContext(ja),ce=Qm(),ue=M.shouldForwardProp||ce.shouldForwardProp,q=zk(R,oe,Y)||qo,U=(function(ke,Pe,Le){for(var ee,W=zt(zt({},Pe),{className:void 0,theme:Le}),se=0;se2&&Gh.registerId(this.componentId+e),this.removeStyles(e,i),this.createStyles(e,t,i,r)},n})();function ZM(n){for(var e=[],t=1;t1&&(W=Math.round(W/R)*R),W<=se.minSize+se.snapOffset+this[oi]?W=se.minSize+this[oi]:W>=this.size-(Se.minSize+Se.snapOffset+this[qi])&&(W=this.size-(Se.minSize+this[qi])),W>=se.maxSize-se.snapOffset+this[oi]?W=se.maxSize+this[oi]:W<=this.size-(Se.maxSize-Se.snapOffset+this[qi])&&(W=this.size-(Se.maxSize+this[qi])),ue.call(this,W),Ht(e,"onDrag",li)(oe()))}function U(){var ee=c[this.a].element,W=c[this.b].element,se=ee[gg](),Se=W[gg]();this.size=se[i]+Se[i]+this[oi]+this[qi],this.start=se[s],this.end=se[o]}function J(ee){if(!getComputedStyle)return null;var W=getComputedStyle(ee);if(!W)return null;var se=ee[a];return se===0?null:(L===zu?se-=parseFloat(W.paddingLeft)+parseFloat(W.paddingRight):se-=parseFloat(W.paddingTop)+parseFloat(W.paddingBottom),se)}function D(ee){var W=J(f);if(W===null||b.reduce(function(Je,Zt){return Je+Zt},0)>W)return ee;var se=0,Se=[],We=ee.map(function(Je,Zt){var gi=W*Je/100,is=ju(k,Zt===0,Zt===ee.length-1,_),hr=b[Zt]+is;return gi0&&Se[Zt]-se>0){var is=Math.min(se,Se[Zt]-se);se-=is,gi=Je-is}return gi/W*100})}function B(){var ee=this,W=c[ee.a].element,se=c[ee.b].element;ee.dragging&&Ht(e,"onDragEnd",li)(oe()),ee.dragging=!1,Bn[si]("mouseup",ee.stop),Bn[si]("touchend",ee.stop),Bn[si]("touchcancel",ee.stop),Bn[si]("mousemove",ee.move),Bn[si]("touchmove",ee.move),ee.stop=null,ee.move=null,W[si]("selectstart",li),W[si]("dragstart",li),se[si]("selectstart",li),se[si]("dragstart",li),W.style.userSelect="",W.style.webkitUserSelect="",W.style.MozUserSelect="",W.style.pointerEvents="",se.style.userSelect="",se.style.webkitUserSelect="",se.style.MozUserSelect="",se.style.pointerEvents="",ee.gutter.style.cursor="",ee.parent.style.cursor="",Za.body.style.cursor=""}function xe(ee){if(!("button"in ee&&ee.button!==0)){var W=this,se=c[W.a].element,Se=c[W.b].element;W.dragging||Ht(e,"onDragStart",li)(oe()),ee.preventDefault(),W.dragging=!0,W.move=q.bind(W),W.stop=B.bind(W),Bn[ri]("mouseup",W.stop),Bn[ri]("touchend",W.stop),Bn[ri]("touchcancel",W.stop),Bn[ri]("mousemove",W.move),Bn[ri]("touchmove",W.move),se[ri]("selectstart",li),se[ri]("dragstart",li),Se[ri]("selectstart",li),Se[ri]("dragstart",li),se.style.userSelect="none",se.style.webkitUserSelect="none",se.style.MozUserSelect="none",se.style.pointerEvents="none",Se.style.userSelect="none",Se.style.webkitUserSelect="none",Se.style.MozUserSelect="none",Se.style.pointerEvents="none",W.gutter.style.cursor=X,W.parent.style.cursor=X,Za.body.style.cursor=X,U.call(W),W.dragOffset=ce(ee)-W.end}}O=D(O);var Oe=[];c=t.map(function(ee,W){var se={element:Tv(ee),size:O[W],minSize:b[W],maxSize:w[W],snapOffset:M[W],i:W},Se;if(W>0&&(Se={a:W-1,b:W,dragging:!1,direction:L,parent:f},Se[oi]=ju(k,W-1===0,!1,_),Se[qi]=ju(k,!1,W===t.length-1,_),m==="row-reverse"||m==="column-reverse")){var We=Se.a;Se.a=Se.b,Se.b=We}if(W>0){var Je=ie(W,L,se.element);re(Je,k,W),Se[Kl]=xe.bind(Se),Je[ri]("mousedown",Se[Kl]),Je[ri]("touchstart",Se[Kl]),f.insertBefore(Je,se.element),Se.gutter=Je}return F(se.element,se.size,ju(k,W===0,W===t.length-1,_),W),W>0&&Oe.push(Se),se});function ke(ee){var W=ee.i===Oe.length,se=W?Oe[ee.i-1]:Oe[ee.i];U.call(se);var Se=W?se.size-ee.minSize-se[qi]:ee.minSize+se[oi];ue.call(se,Se)}c.forEach(function(ee){var W=ee.element[gg]()[i];W0){var We=Oe[Se-1],Je=c[We.a],Zt=c[We.b];Je.size=W[Se-1],Zt.size=se,F(Je.element,Je.size,We[oi],Je.i),F(Zt.element,Zt.size,We[qi],Zt.i)}})}function Le(ee,W){Oe.forEach(function(se){if(W!==!0?se.parent.removeChild(se.gutter):(se.gutter[si]("mousedown",se[Kl]),se.gutter[si]("touchstart",se[Kl])),ee!==!0){var Se=Y(i,se.a.size,se[oi]);Object.keys(Se).forEach(function(We){c[se.a].element.style[We]="",c[se.b].element.style[We]=""})}})}return{setSizes:Pe,getSizes:oe,collapse:function(W){ke(c[W])},destroy:Le,parent:f,pairs:Oe}};function mg(n,e){var t={};for(var i in n)Object.prototype.hasOwnProperty.call(n,i)&&e.indexOf(i)===-1&&(t[i]=n[i]);return t}var Kh=(function(n){function e(){n.apply(this,arguments)}return n&&(e.__proto__=n),e.prototype=Object.create(n&&n.prototype),e.prototype.constructor=e,e.prototype.componentDidMount=function(){var i=this.props;i.children;var r=i.gutter,s=mg(i,["children","gutter"]),o=s;o.gutter=function(a,c){var h;return r?h=r(a,c):(h=document.createElement("div"),h.className="gutter gutter-"+c),h.__isSplitGutter=!0,h},this.split=$v(this.parent.children,o)},e.prototype.componentDidUpdate=function(i){var r=this,s=this.props;s.children;var o=s.minSize,a=s.sizes,c=s.collapsed,h=mg(s,["children","minSize","sizes","collapsed"]),f=h,p=i.minSize,m=i.sizes,O=i.collapsed,v=["maxSize","expandToMin","gutterSize","gutterAlign","snapOffset","dragInterval","direction","cursor"],b=v.map(function(T){return r.props[T]!==i[T]}).reduce(function(T,k){return T||k},!1);if(Array.isArray(o)&&Array.isArray(p)){var S=!1;o.forEach(function(T,k){S=S||T!==p[k]}),b=b||S}else Array.isArray(o)||Array.isArray(p)?b=!0:b=b||o!==p;if(b)f.minSize=o,f.sizes=a||this.split.getSizes(),this.split.destroy(!0,!0),f.gutter=function(T,k,_){return _.previousSibling},this.split=$v(Array.from(this.parent.children).filter(function(T){return!T.__isSplitGutter}),f);else if(a){var w=!1;a.forEach(function(T,k){w=w||T!==m[k]}),w&&this.split.setSizes(this.props.sizes)}Number.isInteger(c)&&(c!==O||b)&&this.split.collapse(c)},e.prototype.componentWillUnmount=function(){this.split.destroy(),delete this.split},e.prototype.render=function(){var i=this,r=this.props;r.sizes,r.minSize,r.maxSize,r.expandToMin,r.gutterSize,r.gutterAlign,r.snapOffset,r.dragInterval,r.direction,r.cursor,r.gutter,r.elementStyle,r.gutterStyle,r.onDrag,r.onDragStart,r.onDragEnd,r.collapsed;var s=r.children,o=mg(r,["sizes","minSize","maxSize","expandToMin","gutterSize","gutterAlign","snapOffset","dragInterval","direction","cursor","gutter","elementStyle","gutterStyle","onDrag","onDragStart","onDragEnd","collapsed","children"]),a=o;return dt.createElement("div",Object.assign({},{ref:function(c){i.parent=c}},a),s)},e})(dt.Component);Kh.propTypes={sizes:Fe.arrayOf(Fe.number),minSize:Fe.oneOfType([Fe.number,Fe.arrayOf(Fe.number)]),maxSize:Fe.oneOfType([Fe.number,Fe.arrayOf(Fe.number)]),expandToMin:Fe.bool,gutterSize:Fe.number,gutterAlign:Fe.string,snapOffset:Fe.oneOfType([Fe.number,Fe.arrayOf(Fe.number)]),dragInterval:Fe.number,direction:Fe.string,cursor:Fe.string,gutter:Fe.func,elementStyle:Fe.func,gutterStyle:Fe.func,onDrag:Fe.func,onDragStart:Fe.func,onDragEnd:Fe.func,collapsed:Fe.number,children:Fe.arrayOf(Fe.element)};Kh.defaultProps={sizes:void 0,minSize:void 0,maxSize:void 0,expandToMin:void 0,gutterSize:void 0,gutterAlign:void 0,snapOffset:void 0,dragInterval:void 0,direction:void 0,cursor:void 0,gutter:void 0,elementStyle:void 0,gutterStyle:void 0,onDrag:void 0,onDragStart:void 0,onDragEnd:void 0,collapsed:void 0,children:void 0};function s0(n){return e=>!!e.type&&e.type.tabsRole===n}const dc=s0("Tab"),Xf=s0("TabList"),Wf=s0("TabPanel");function YM(n){return dc(n)||Xf(n)||Wf(n)}function Tm(n,e){return ne.Children.map(n,t=>t===null?null:YM(t)?e(t):t.props&&t.props.children&&typeof t.props.children=="object"?ne.cloneElement(t,Object.assign({},t.props,{children:Tm(t.props.children,e)})):t)}function Jh(n,e){return ne.Children.forEach(n,t=>{t!==null&&(dc(t)||Wf(t)?e(t):t.props&&t.props.children&&typeof t.props.children=="object"&&(Xf(t)&&e(t),Jh(t.props.children,e)))})}function tP(n,e,t){let i,r=0,s=0,o=!1;const a=[],c=n[e];return Jh(c,h=>{Xf(h)&&(h.props&&h.props.children&&typeof h.props.children=="object"&&Jh(h.props.children,f=>a.push(f)),o&&(i=new Error("Found multiple 'TabList' components inside 'Tabs'. Only one is allowed.")),o=!0),dc(h)?((!o||a.indexOf(h)===-1)&&(i=new Error("Found a 'Tab' component outside of the 'TabList' component. 'Tab' components have to be inside the 'TabList' component.")),r++):Wf(h)&&s++}),!i&&r!==s&&(i=new Error(`There should be an equal number of 'Tab' and 'TabPanel' in \`${t}\`. Received ${r} 'Tab' and ${s} 'TabPanel'.`)),i}function qM(n,e,t,i,r){const s=n[e],o=r||e;let a=null;return s&&typeof s!="function"?a=new Error(`Invalid ${i} \`${o}\` of type \`${typeof s}\` supplied to \`${t}\`, expected \`function\`.`):n.selectedIndex!=null&&s==null&&(a=new Error(`The ${i} \`${o}\` is marked as required in \`${t}\`, but its value is \`undefined\` or \`null\`. +\`onSelect\` is required when \`selectedIndex\` is also set. Not doing so will make the tabs not do anything, as \`selectedIndex\` indicates that you want to handle the selected tab yourself. +If you only want to set the inital tab replace \`selectedIndex\` with \`defaultIndex\`.`)),a}function UM(n,e,t,i,r){const s=n[e],o=r||e;let a=null;if(s!=null&&typeof s!="number")a=new Error(`Invalid ${i} \`${o}\` of type \`${typeof s}\` supplied to \`${t}\`, expected \`number\`.`);else if(n.defaultIndex!=null&&s!=null)return new Error(`The ${i} \`${o}\` cannot be used together with \`defaultIndex\` in \`${t}\`. +Either remove \`${o}\` to let \`${t}\` handle the selected tab internally or remove \`defaultIndex\` to handle it yourself.`);return a}function nP(n){var e,t,i="";if(typeof n=="string"||typeof n=="number")i+=n;else if(typeof n=="object")if(Array.isArray(n)){var r=n.length;for(e=0;e{dc(t)&&e++}),e}const HM=["children","className","disabledTabClassName","domRef","focus","forceRenderTabPanel","onSelect","selectedIndex","selectedTabClassName","selectedTabPanelClassName","environment","disableUpDownKeys","disableLeftRightKeys"];function GM(n,e){if(n==null)return{};var t={};for(var i in n)if({}.hasOwnProperty.call(n,i)){if(e.includes(i))continue;t[i]=n[i]}return t}function rP(n){return n&&"getAttribute"in n}function Mv(n){return rP(n)&&n.getAttribute("data-rttab")}function Os(n){return rP(n)&&n.getAttribute("aria-disabled")==="true"}let ef;function KM(n){const e=n||(typeof window<"u"?window:void 0);try{ef=!!(typeof e<"u"&&e.document&&e.document.activeElement)}catch{ef=!1}}const JM={className:"react-tabs",focus:!1},eR={children:tP},tR=n=>{i0.checkPropTypes(eR,n,"prop","UncontrolledTabs");let e=ne.useRef([]),t=ne.useRef([]);const i=ne.useRef();function r(k,_){if(k<0||k>=h())return;const{onSelect:C,selectedIndex:M}=n;C(k,M,_)}function s(k){const _=h();for(let C=k+1;C<_;C++)if(!Os(f(C)))return C;for(let C=0;Ck;)if(!Os(f(_)))return _;return k}function a(){const k=h();for(let _=0;_{let oe=re;if(Xf(re)){let ce=0,ue=!1;ef==null&&KM(Y);const q=Y||(typeof window<"u"?window:void 0);ef&&q&&(ue=dt.Children.toArray(re.props.children).filter(dc).some((U,J)=>q.document.activeElement===f(J))),oe=ne.cloneElement(re,{children:Tm(re.props.children,U=>{const J=`tabs-${ce}`,D=L===ce,B={tabRef:xe=>{e.current[J]=xe},id:t.current[ce],selected:D,focus:D&&(M||ue)};return X&&(B.selectedClassName=X),C&&(B.disabledClassName=C),ce++,ne.cloneElement(U,B)})})}else if(Wf(re)){const ce={id:t.current[k],selected:L===k};R&&(ce.forceRender=R),ie&&(ce.selectedClassName=ie),k++,oe=ne.cloneElement(re,ce)}return oe})}function m(k){const{direction:_,disableUpDownKeys:C,disableLeftRightKeys:M}=n;if(v(k.target)){let{selectedIndex:R}=n,L=!1,X=!1;(k.code==="Space"||k.keyCode===32||k.code==="Enter"||k.keyCode===13)&&(L=!0,X=!1,O(k)),!M&&(k.keyCode===37||k.code==="ArrowLeft")||!C&&(k.keyCode===38||k.code==="ArrowUp")?(_==="rtl"?R=s(R):R=o(R),L=!0,X=!0):!M&&(k.keyCode===39||k.code==="ArrowRight")||!C&&(k.keyCode===40||k.code==="ArrowDown")?(_==="rtl"?R=o(R):R=s(R),L=!0,X=!0):k.keyCode===35||k.code==="End"?(R=c(),L=!0,X=!0):(k.keyCode===36||k.code==="Home")&&(R=a(),L=!0,X=!0),L&&k.preventDefault(),X&&r(R,k)}}function O(k){let _=k.target;do if(v(_)){if(Os(_))return;const C=[].slice.call(_.parentNode.children).filter(Mv).indexOf(_);r(C,k);return}while((_=_.parentNode)!=null)}function v(k){if(!Mv(k))return!1;let _=k.parentElement;do{if(_===i.current)return!0;if(_.getAttribute("data-rttabs"))break;_=_.parentElement}while(_);return!1}const b=Object.assign({},JM,n),{className:S,domRef:w}=b,T=GM(b,HM);return dt.createElement("div",Object.assign({},T,{className:Vf(S),onClick:O,onKeyDown:m,ref:k=>{i.current=k,w&&w(k)},"data-rttabs":!0}),p())},nR=["children","defaultFocus","defaultIndex","focusTabOnClick","onSelect"];function iR(n,e){if(n==null)return{};var t={};for(var i in n)if({}.hasOwnProperty.call(n,i)){if(e.includes(i))continue;t[i]=n[i]}return t}const rR=0,Th=1,sR={children:tP,onSelect:qM,selectedIndex:UM},oR={defaultFocus:!1,focusTabOnClick:!0,forceRenderTabPanel:!1,selectedIndex:null,defaultIndex:null,environment:null,disableUpDownKeys:!1,disableLeftRightKeys:!1},lR=n=>n.selectedIndex===null?Th:rR,sP=n=>{i0.checkPropTypes(sR,n,"prop","Tabs");const e=Object.assign({},oR,n),{children:t,defaultFocus:i,defaultIndex:r,focusTabOnClick:s,onSelect:o}=e,a=iR(e,nR),[c,h]=ne.useState(i),[f]=ne.useState(lR(a)),[p,m]=ne.useState(f===Th?r||0:null);if(ne.useEffect(()=>{h(!1)},[]),f===Th){const b=iP(t);ne.useEffect(()=>{if(p!=null){const S=Math.max(0,b-1);m(Math.min(p,S))}},[b])}const O=(b,S,w)=>{typeof o=="function"&&o(b,S,w)===!1||(s&&h(!0),f===Th&&m(b))};let v=Object.assign({},n,a);return v.focus=c,v.onSelect=O,p!=null&&(v.selectedIndex=p),delete v.defaultFocus,delete v.defaultIndex,delete v.focusTabOnClick,dt.createElement(tR,v,t)};sP.tabsRole="Tabs";const aR=["children","className"];function cR(n,e){if(n==null)return{};var t={};for(var i in n)if({}.hasOwnProperty.call(n,i)){if(e.includes(i))continue;t[i]=n[i]}return t}const uR={className:"react-tabs__tab-list"},oP=n=>{const e=Object.assign({},uR,n),{children:t,className:i}=e,r=cR(e,aR);return dt.createElement("ul",Object.assign({},r,{className:Vf(i),role:"tablist"}),t)};oP.tabsRole="TabList";const hR=["children","className","disabled","disabledClassName","focus","id","selected","selectedClassName","tabIndex","tabRef"];function fR(n,e){if(n==null)return{};var t={};for(var i in n)if({}.hasOwnProperty.call(n,i)){if(e.includes(i))continue;t[i]=n[i]}return t}const Og="react-tabs__tab",dR={className:Og,disabledClassName:`${Og}--disabled`,focus:!1,id:null,selected:!1,selectedClassName:`${Og}--selected`},lP=n=>{let e=ne.useRef();const t=Object.assign({},dR,n),{children:i,className:r,disabled:s,disabledClassName:o,focus:a,id:c,selected:h,selectedClassName:f,tabIndex:p,tabRef:m}=t,O=fR(t,hR);return ne.useEffect(()=>{h&&a&&e.current.focus()},[h,a]),dt.createElement("li",Object.assign({},O,{className:Vf(r,{[f]:h,[o]:s}),ref:v=>{e.current=v,m&&m(v)},role:"tab",id:`tab${c}`,"aria-selected":h?"true":"false","aria-disabled":s?"true":"false","aria-controls":`panel${c}`,tabIndex:p||(h?"0":null),"data-rttab":!0}),i)};lP.tabsRole="Tab";const pR=["children","className","forceRender","id","selected","selectedClassName"];function gR(n,e){if(n==null)return{};var t={};for(var i in n)if({}.hasOwnProperty.call(n,i)){if(e.includes(i))continue;t[i]=n[i]}return t}const Rv="react-tabs__tab-panel",mR={className:Rv,forceRender:!1,selectedClassName:`${Rv}--selected`},aP=n=>{const e=Object.assign({},mR,n),{children:t,className:i,forceRender:r,id:s,selected:o,selectedClassName:a}=e,c=gR(e,pR);return dt.createElement("div",Object.assign({},c,{className:Vf(i,{[a]:o}),role:"tabpanel",id:`panel${s}`,"aria-labelledby":`tab${s}`}),r||o?t:null)};aP.tabsRole="TabPanel";let $m=[],cP=[];(()=>{let n="lc,34,7n,7,7b,19,,,,2,,2,,,20,b,1c,l,g,,2t,7,2,6,2,2,,4,z,,u,r,2j,b,1m,9,9,,o,4,,9,,3,,5,17,3,3b,f,,w,1j,,,,4,8,4,,3,7,a,2,t,,1m,,,,2,4,8,,9,,a,2,q,,2,2,1l,,4,2,4,2,2,3,3,,u,2,3,,b,2,1l,,4,5,,2,4,,k,2,m,6,,,1m,,,2,,4,8,,7,3,a,2,u,,1n,,,,c,,9,,14,,3,,1l,3,5,3,,4,7,2,b,2,t,,1m,,2,,2,,3,,5,2,7,2,b,2,s,2,1l,2,,,2,4,8,,9,,a,2,t,,20,,4,,2,3,,,8,,29,,2,7,c,8,2q,,2,9,b,6,22,2,r,,,,,,1j,e,,5,,2,5,b,,10,9,,2u,4,,6,,2,2,2,p,2,4,3,g,4,d,,2,2,6,,f,,jj,3,qa,3,t,3,t,2,u,2,1s,2,,7,8,,2,b,9,,19,3,3b,2,y,,3a,3,4,2,9,,6,3,63,2,2,,1m,,,7,,,,,2,8,6,a,2,,1c,h,1r,4,1c,7,,,5,,14,9,c,2,w,4,2,2,,3,1k,,,2,3,,,3,1m,8,2,2,48,3,,d,,7,4,,6,,3,2,5i,1m,,5,ek,,5f,x,2da,3,3x,,2o,w,fe,6,2x,2,n9w,4,,a,w,2,28,2,7k,,3,,4,,p,2,5,,47,2,q,i,d,,12,8,p,b,1a,3,1c,,2,4,2,2,13,,1v,6,2,2,2,2,c,,8,,1b,,1f,,,3,2,2,5,2,,,16,2,8,,6m,,2,,4,,fn4,,kh,g,g,g,a6,2,gt,,6a,,45,5,1ae,3,,2,5,4,14,3,4,,4l,2,fx,4,ar,2,49,b,4w,,1i,f,1k,3,1d,4,2,2,1x,3,10,5,,8,1q,,c,2,1g,9,a,4,2,,2n,3,2,,,2,6,,4g,,3,8,l,2,1l,2,,,,,m,,e,7,3,5,5f,8,2,3,,,n,,29,,2,6,,,2,,,2,,2,6j,,2,4,6,2,,2,r,2,2d,8,2,,,2,2y,,,,2,6,,,2t,3,2,4,,5,77,9,,2,6t,,a,2,,,4,,40,4,2,2,4,,w,a,14,6,2,4,8,,9,6,2,3,1a,d,,2,ba,7,,6,,,2a,m,2,7,,2,,2,3e,6,3,,,2,,7,,,20,2,3,,,,9n,2,f0b,5,1n,7,t4,,1r,4,29,,f5k,2,43q,,,3,4,5,8,8,2,7,u,4,44,3,1iz,1j,4,1e,8,,e,,m,5,,f,11s,7,,h,2,7,,2,,5,79,7,c5,4,15s,7,31,7,240,5,gx7k,2o,3k,6o".split(",").map(e=>e?parseInt(e,36):1);for(let e=0,t=0;e>1;if(n<$m[i])t=i;else if(n>=cP[i])e=i+1;else return!0;if(e==t)return!1}}function Av(n){return n>=127462&&n<=127487}const Ev=8205;function yR(n,e,t=!0,i=!0){return(t?uP:xR)(n,e,i)}function uP(n,e,t){if(e==n.length)return e;e&&hP(n.charCodeAt(e))&&fP(n.charCodeAt(e-1))&&e--;let i=yg(n,e);for(e+=Lv(i);e=0&&Av(yg(n,o));)s++,o-=2;if(s%2==0)break;e+=2}else break}return e}function xR(n,e,t){for(;e>0;){let i=uP(n,e-2,t);if(i=56320&&n<57344}function fP(n){return n>=55296&&n<56320}function Lv(n){return n<65536?1:2}class ze{lineAt(e){if(e<0||e>this.length)throw new RangeError(`Invalid position ${e} in document of length ${this.length}`);return this.lineInner(e,!1,1,0)}line(e){if(e<1||e>this.lines)throw new RangeError(`Invalid line number ${e} in ${this.lines}-line document`);return this.lineInner(e,!0,1,0)}replace(e,t,i){[e,t]=Uo(this,e,t);let r=[];return this.decompose(0,e,r,2),i.length&&i.decompose(0,i.length,r,3),this.decompose(t,this.length,r,1),Ti.from(r,this.length-(t-e)+i.length)}append(e){return this.replace(this.length,this.length,e)}slice(e,t=this.length){[e,t]=Uo(this,e,t);let i=[];return this.decompose(e,t,i,0),Ti.from(i,t-e)}eq(e){if(e==this)return!0;if(e.length!=this.length||e.lines!=this.lines)return!1;let t=this.scanIdentical(e,1),i=this.length-this.scanIdentical(e,-1),r=new wa(this),s=new wa(e);for(let o=t,a=t;;){if(r.next(o),s.next(o),o=0,r.lineBreak!=s.lineBreak||r.done!=s.done||r.value!=s.value)return!1;if(a+=r.value.length,r.done||a>=i)return!0}}iter(e=1){return new wa(this,e)}iterRange(e,t=this.length){return new dP(this,e,t)}iterLines(e,t){let i;if(e==null)i=this.iter();else{t==null&&(t=this.lines+1);let r=this.line(e).from;i=this.iterRange(r,Math.max(r,t==this.lines+1?this.length:t<=1?0:this.line(t-1).to))}return new pP(i)}toString(){return this.sliceString(0)}toJSON(){let e=[];return this.flatten(e),e}constructor(){}static of(e){if(e.length==0)throw new RangeError("A document must have at least one line");return e.length==1&&!e[0]?ze.empty:e.length<=32?new mt(e):Ti.from(mt.split(e,[]))}}class mt extends ze{constructor(e,t=vR(e)){super(),this.text=e,this.length=t}get lines(){return this.text.length}get children(){return null}lineInner(e,t,i,r){for(let s=0;;s++){let o=this.text[s],a=r+o.length;if((t?i:a)>=e)return new bR(r,a,i,o);r=a+1,i++}}decompose(e,t,i,r){let s=e<=0&&t>=this.length?this:new mt(Dv(this.text,e,t),Math.min(t,this.length)-Math.max(0,e));if(r&1){let o=i.pop(),a=$h(s.text,o.text.slice(),0,s.length);if(a.length<=32)i.push(new mt(a,o.length+s.length));else{let c=a.length>>1;i.push(new mt(a.slice(0,c)),new mt(a.slice(c)))}}else i.push(s)}replace(e,t,i){if(!(i instanceof mt))return super.replace(e,t,i);[e,t]=Uo(this,e,t);let r=$h(this.text,$h(i.text,Dv(this.text,0,e)),t),s=this.length+i.length-(t-e);return r.length<=32?new mt(r,s):Ti.from(mt.split(r,[]),s)}sliceString(e,t=this.length,i=` +`){[e,t]=Uo(this,e,t);let r="";for(let s=0,o=0;s<=t&&oe&&o&&(r+=i),es&&(r+=a.slice(Math.max(0,e-s),t-s)),s=c+1}return r}flatten(e){for(let t of this.text)e.push(t)}scanIdentical(){return 0}static split(e,t){let i=[],r=-1;for(let s of e)i.push(s),r+=s.length+1,i.length==32&&(t.push(new mt(i,r)),i=[],r=-1);return r>-1&&t.push(new mt(i,r)),t}}class Ti extends ze{constructor(e,t){super(),this.children=e,this.length=t,this.lines=0;for(let i of e)this.lines+=i.lines}lineInner(e,t,i,r){for(let s=0;;s++){let o=this.children[s],a=r+o.length,c=i+o.lines-1;if((t?c:a)>=e)return o.lineInner(e,t,i,r);r=a+1,i=c+1}}decompose(e,t,i,r){for(let s=0,o=0;o<=t&&s=o){let h=r&((o<=e?1:0)|(c>=t?2:0));o>=e&&c<=t&&!h?i.push(a):a.decompose(e-o,t-o,i,h)}o=c+1}}replace(e,t,i){if([e,t]=Uo(this,e,t),i.lines=s&&t<=a){let c=o.replace(e-s,t-s,i),h=this.lines-o.lines+c.lines;if(c.lines>4&&c.lines>h>>6){let f=this.children.slice();return f[r]=c,new Ti(f,this.length-(t-e)+i.length)}return super.replace(s,a,c)}s=a+1}return super.replace(e,t,i)}sliceString(e,t=this.length,i=` +`){[e,t]=Uo(this,e,t);let r="";for(let s=0,o=0;se&&s&&(r+=i),eo&&(r+=a.sliceString(e-o,t-o,i)),o=c+1}return r}flatten(e){for(let t of this.children)t.flatten(e)}scanIdentical(e,t){if(!(e instanceof Ti))return 0;let i=0,[r,s,o,a]=t>0?[0,0,this.children.length,e.children.length]:[this.children.length-1,e.children.length-1,-1,-1];for(;;r+=t,s+=t){if(r==o||s==a)return i;let c=this.children[r],h=e.children[s];if(c!=h)return i+c.scanIdentical(h,t);i+=c.length+1}}static from(e,t=e.reduce((i,r)=>i+r.length+1,-1)){let i=0;for(let O of e)i+=O.lines;if(i<32){let O=[];for(let v of e)v.flatten(O);return new mt(O,t)}let r=Math.max(32,i>>5),s=r<<1,o=r>>1,a=[],c=0,h=-1,f=[];function p(O){let v;if(O.lines>s&&O instanceof Ti)for(let b of O.children)p(b);else O.lines>o&&(c>o||!c)?(m(),a.push(O)):O instanceof mt&&c&&(v=f[f.length-1])instanceof mt&&O.lines+v.lines<=32?(c+=O.lines,h+=O.length+1,f[f.length-1]=new mt(v.text.concat(O.text),v.length+1+O.length)):(c+O.lines>r&&m(),c+=O.lines,h+=O.length+1,f.push(O))}function m(){c!=0&&(a.push(f.length==1?f[0]:Ti.from(f,h)),h=-1,c=f.length=0)}for(let O of e)p(O);return m(),a.length==1?a[0]:new Ti(a,t)}}ze.empty=new mt([""],0);function vR(n){let e=-1;for(let t of n)e+=t.length+1;return e}function $h(n,e,t=0,i=1e9){for(let r=0,s=0,o=!0;s=t&&(c>i&&(a=a.slice(0,i-r)),r0?1:(e instanceof mt?e.text.length:e.children.length)<<1]}nextInner(e,t){for(this.done=this.lineBreak=!1;;){let i=this.nodes.length-1,r=this.nodes[i],s=this.offsets[i],o=s>>1,a=r instanceof mt?r.text.length:r.children.length;if(o==(t>0?a:0)){if(i==0)return this.done=!0,this.value="",this;t>0&&this.offsets[i-1]++,this.nodes.pop(),this.offsets.pop()}else if((s&1)==(t>0?0:1)){if(this.offsets[i]+=t,e==0)return this.lineBreak=!0,this.value=` +`,this;e--}else if(r instanceof mt){let c=r.text[o+(t<0?-1:0)];if(this.offsets[i]+=t,c.length>Math.max(0,e))return this.value=e==0?c:t>0?c.slice(e):c.slice(0,c.length-e),this;e-=c.length}else{let c=r.children[o+(t<0?-1:0)];e>c.length?(e-=c.length,this.offsets[i]+=t):(t<0&&this.offsets[i]--,this.nodes.push(c),this.offsets.push(t>0?1:(c instanceof mt?c.text.length:c.children.length)<<1))}}}next(e=0){return e<0&&(this.nextInner(-e,-this.dir),e=this.value.length),this.nextInner(e,this.dir)}}class dP{constructor(e,t,i){this.value="",this.done=!1,this.cursor=new wa(e,t>i?-1:1),this.pos=t>i?e.length:0,this.from=Math.min(t,i),this.to=Math.max(t,i)}nextInner(e,t){if(t<0?this.pos<=this.from:this.pos>=this.to)return this.value="",this.done=!0,this;e+=Math.max(0,t<0?this.pos-this.to:this.from-this.pos);let i=t<0?this.pos-this.from:this.to-this.pos;e>i&&(e=i),i-=e;let{value:r}=this.cursor.next(e);return this.pos+=(r.length+e)*t,this.value=r.length<=i?r:t<0?r.slice(r.length-i):r.slice(0,i),this.done=!this.value,this}next(e=0){return e<0?e=Math.max(e,this.from-this.pos):e>0&&(e=Math.min(e,this.to-this.pos)),this.nextInner(e,this.cursor.dir)}get lineBreak(){return this.cursor.lineBreak&&this.value!=""}}class pP{constructor(e){this.inner=e,this.afterBreak=!0,this.value="",this.done=!1}next(e=0){let{done:t,lineBreak:i,value:r}=this.inner.next(e);return t&&this.afterBreak?(this.value="",this.afterBreak=!1):t?(this.done=!0,this.value=""):i?this.afterBreak?this.value="":(this.afterBreak=!0,this.next()):(this.value=r,this.afterBreak=!1),this}get lineBreak(){return!1}}typeof Symbol<"u"&&(ze.prototype[Symbol.iterator]=function(){return this.iter()},wa.prototype[Symbol.iterator]=dP.prototype[Symbol.iterator]=pP.prototype[Symbol.iterator]=function(){return this});let bR=class{constructor(e,t,i,r){this.from=e,this.to=t,this.number=i,this.text=r}get length(){return this.to-this.from}};function Uo(n,e,t){return e=Math.max(0,Math.min(n.length,e)),[e,Math.max(e,Math.min(n.length,t))]}function Vt(n,e,t=!0,i=!0){return yR(n,e,t,i)}function SR(n){return n>=56320&&n<57344}function wR(n){return n>=55296&&n<56320}function yn(n,e){let t=n.charCodeAt(e);if(!wR(t)||e+1==n.length)return t;let i=n.charCodeAt(e+1);return SR(i)?(t-55296<<10)+(i-56320)+65536:t}function o0(n){return n<=65535?String.fromCharCode(n):(n-=65536,String.fromCharCode((n>>10)+55296,(n&1023)+56320))}function $i(n){return n<65536?1:2}const Mm=/\r\n?|\n/;var Wt=(function(n){return n[n.Simple=0]="Simple",n[n.TrackDel=1]="TrackDel",n[n.TrackBefore=2]="TrackBefore",n[n.TrackAfter=3]="TrackAfter",n})(Wt||(Wt={}));class Li{constructor(e){this.sections=e}get length(){let e=0;for(let t=0;te)return s+(e-r);s+=a}else{if(i!=Wt.Simple&&h>=e&&(i==Wt.TrackDel&&re||i==Wt.TrackBefore&&re))return null;if(h>e||h==e&&t<0&&!a)return e==r||t<0?s:s+c;s+=c}r=h}if(e>r)throw new RangeError(`Position ${e} is out of range for changeset of length ${r}`);return s}touchesRange(e,t=e){for(let i=0,r=0;i=0&&r<=t&&a>=e)return rt?"cover":!0;r=a}return!1}toString(){let e="";for(let t=0;t=0?":"+r:"")}return e}toJSON(){return this.sections}static fromJSON(e){if(!Array.isArray(e)||e.length%2||e.some(t=>typeof t!="number"))throw new RangeError("Invalid JSON representation of ChangeDesc");return new Li(e)}static create(e){return new Li(e)}}class Ct extends Li{constructor(e,t){super(e),this.inserted=t}apply(e){if(this.length!=e.length)throw new RangeError("Applying change set to a document with the wrong length");return Rm(this,(t,i,r,s,o)=>e=e.replace(r,r+(i-t),o),!1),e}mapDesc(e,t=!1){return Am(this,e,t,!0)}invert(e){let t=this.sections.slice(),i=[];for(let r=0,s=0;r=0){t[r]=a,t[r+1]=o;let c=r>>1;for(;i.length0&&zr(i,t,s.text),s.forward(f),a+=f}let h=e[o++];for(;a>1].toJSON()))}return e}static of(e,t,i){let r=[],s=[],o=0,a=null;function c(f=!1){if(!f&&!r.length)return;om||p<0||m>t)throw new RangeError(`Invalid change range ${p} to ${m} (in doc of length ${t})`);let v=O?typeof O=="string"?ze.of(O.split(i||Mm)):O:ze.empty,b=v.length;if(p==m&&b==0)return;po&&Kt(r,p-o,-1),Kt(r,m-p,b),zr(s,r,v),o=m}}return h(e),c(!a),a}static empty(e){return new Ct(e?[e,-1]:[],[])}static fromJSON(e){if(!Array.isArray(e))throw new RangeError("Invalid JSON representation of ChangeSet");let t=[],i=[];for(let r=0;ra&&typeof o!="string"))throw new RangeError("Invalid JSON representation of ChangeSet");if(s.length==1)t.push(s[0],0);else{for(;i.length=0&&t<=0&&t==n[r+1]?n[r]+=e:r>=0&&e==0&&n[r]==0?n[r+1]+=t:i?(n[r]+=e,n[r+1]+=t):n.push(e,t)}function zr(n,e,t){if(t.length==0)return;let i=e.length-2>>1;if(i>1])),!(t||o==n.sections.length||n.sections[o+1]<0);)a=n.sections[o++],c=n.sections[o++];e(r,h,s,f,p),r=h,s=f}}}function Am(n,e,t,i=!1){let r=[],s=i?[]:null,o=new Ia(n),a=new Ia(e);for(let c=-1;;){if(o.done&&a.len||a.done&&o.len)throw new Error("Mismatched change set lengths");if(o.ins==-1&&a.ins==-1){let h=Math.min(o.len,a.len);Kt(r,h,-1),o.forward(h),a.forward(h)}else if(a.ins>=0&&(o.ins<0||c==o.i||o.off==0&&(a.len=0&&c=0){let h=0,f=o.len;for(;f;)if(a.ins==-1){let p=Math.min(f,a.len);h+=p,f-=p,a.forward(p)}else if(a.ins==0&&a.lenc||o.ins>=0&&o.len>c)&&(a||i.length>h),s.forward2(c),o.forward(c)}}}}class Ia{constructor(e){this.set=e,this.i=0,this.next()}next(){let{sections:e}=this.set;this.i>1;return t>=e.length?ze.empty:e[t]}textBit(e){let{inserted:t}=this.set,i=this.i-2>>1;return i>=t.length&&!e?ze.empty:t[i].slice(this.off,e==null?void 0:this.off+e)}forward(e){e==this.len?this.next():(this.len-=e,this.off+=e)}forward2(e){this.ins==-1?this.forward(e):e==this.ins?this.next():(this.ins-=e,this.off+=e)}}class As{constructor(e,t,i){this.from=e,this.to=t,this.flags=i}get anchor(){return this.flags&32?this.to:this.from}get head(){return this.flags&32?this.from:this.to}get empty(){return this.from==this.to}get assoc(){return this.flags&8?-1:this.flags&16?1:0}get bidiLevel(){let e=this.flags&7;return e==7?null:e}get goalColumn(){let e=this.flags>>6;return e==16777215?void 0:e}map(e,t=-1){let i,r;return this.empty?i=r=e.mapPos(this.from,t):(i=e.mapPos(this.from,1),r=e.mapPos(this.to,-1)),i==this.from&&r==this.to?this:new As(i,r,this.flags)}extend(e,t=e){if(e<=this.anchor&&t>=this.anchor)return V.range(e,t);let i=Math.abs(e-this.anchor)>Math.abs(t-this.anchor)?e:t;return V.range(this.anchor,i)}eq(e,t=!1){return this.anchor==e.anchor&&this.head==e.head&&(!t||!this.empty||this.assoc==e.assoc)}toJSON(){return{anchor:this.anchor,head:this.head}}static fromJSON(e){if(!e||typeof e.anchor!="number"||typeof e.head!="number")throw new RangeError("Invalid JSON representation for SelectionRange");return V.range(e.anchor,e.head)}static create(e,t,i){return new As(e,t,i)}}class V{constructor(e,t){this.ranges=e,this.mainIndex=t}map(e,t=-1){return e.empty?this:V.create(this.ranges.map(i=>i.map(e,t)),this.mainIndex)}eq(e,t=!1){if(this.ranges.length!=e.ranges.length||this.mainIndex!=e.mainIndex)return!1;for(let i=0;ie.toJSON()),main:this.mainIndex}}static fromJSON(e){if(!e||!Array.isArray(e.ranges)||typeof e.main!="number"||e.main>=e.ranges.length)throw new RangeError("Invalid JSON representation for EditorSelection");return new V(e.ranges.map(t=>As.fromJSON(t)),e.main)}static single(e,t=e){return new V([V.range(e,t)],0)}static create(e,t=0){if(e.length==0)throw new RangeError("A selection needs at least one range");for(let i=0,r=0;re?8:0)|s)}static normalized(e,t=0){let i=e[t];e.sort((r,s)=>r.from-s.from),t=e.indexOf(i);for(let r=1;rs.head?V.range(c,a):V.range(a,c))}}return new V(e,t)}}function mP(n,e){for(let t of n.ranges)if(t.to>e)throw new RangeError("Selection points outside of document")}let l0=0;class pe{constructor(e,t,i,r,s){this.combine=e,this.compareInput=t,this.compare=i,this.isStatic=r,this.id=l0++,this.default=e([]),this.extensions=typeof s=="function"?s(this):s}get reader(){return this}static define(e={}){return new pe(e.combine||(t=>t),e.compareInput||((t,i)=>t===i),e.compare||(e.combine?(t,i)=>t===i:a0),!!e.static,e.enables)}of(e){return new Mh([],this,0,e)}compute(e,t){if(this.isStatic)throw new Error("Can't compute a static facet");return new Mh(e,this,1,t)}computeN(e,t){if(this.isStatic)throw new Error("Can't compute a static facet");return new Mh(e,this,2,t)}from(e,t){return t||(t=i=>i),this.compute([e],i=>t(i.field(e)))}}function a0(n,e){return n==e||n.length==e.length&&n.every((t,i)=>t===e[i])}class Mh{constructor(e,t,i,r){this.dependencies=e,this.facet=t,this.type=i,this.value=r,this.id=l0++}dynamicSlot(e){var t;let i=this.value,r=this.facet.compareInput,s=this.id,o=e[s]>>1,a=this.type==2,c=!1,h=!1,f=[];for(let p of this.dependencies)p=="doc"?c=!0:p=="selection"?h=!0:(((t=e[p.id])!==null&&t!==void 0?t:1)&1)==0&&f.push(e[p.id]);return{create(p){return p.values[o]=i(p),1},update(p,m){if(c&&m.docChanged||h&&(m.docChanged||m.selection)||Em(p,f)){let O=i(p);if(a?!zv(O,p.values[o],r):!r(O,p.values[o]))return p.values[o]=O,1}return 0},reconfigure:(p,m)=>{let O,v=m.config.address[s];if(v!=null){let b=nf(m,v);if(this.dependencies.every(S=>S instanceof pe?m.facet(S)===p.facet(S):S instanceof jt?m.field(S,!1)==p.field(S,!1):!0)||(a?zv(O=i(p),b,r):r(O=i(p),b)))return p.values[o]=b,0}else O=i(p);return p.values[o]=O,1}}}}function zv(n,e,t){if(n.length!=e.length)return!1;for(let i=0;in[c.id]),r=t.map(c=>c.type),s=i.filter(c=>!(c&1)),o=n[e.id]>>1;function a(c){let h=[];for(let f=0;fi===r),e);return e.provide&&(t.provides=e.provide(t)),t}create(e){let t=e.facet(Zu).find(i=>i.field==this);return((t==null?void 0:t.create)||this.createF)(e)}slot(e){let t=e[this.id]>>1;return{create:i=>(i.values[t]=this.create(i),1),update:(i,r)=>{let s=i.values[t],o=this.updateF(s,r);return this.compareF(s,o)?0:(i.values[t]=o,1)},reconfigure:(i,r)=>{let s=i.facet(Zu),o=r.facet(Zu),a;return(a=s.find(c=>c.field==this))&&a!=o.find(c=>c.field==this)?(i.values[t]=a.create(i),1):r.config.address[this.id]!=null?(i.values[t]=r.field(this),0):(i.values[t]=this.create(i),1)}}}init(e){return[this,Zu.of({field:this,create:e})]}get extension(){return this}}const $s={lowest:4,low:3,default:2,high:1,highest:0};function Jl(n){return e=>new OP(e,n)}const ts={highest:Jl($s.highest),high:Jl($s.high),default:Jl($s.default),low:Jl($s.low),lowest:Jl($s.lowest)};class OP{constructor(e,t){this.inner=e,this.prec=t}}class Ff{of(e){return new Lm(this,e)}reconfigure(e){return Ff.reconfigure.of({compartment:this,extension:e})}get(e){return e.config.compartments.get(this)}}class Lm{constructor(e,t){this.compartment=e,this.inner=t}}class tf{constructor(e,t,i,r,s,o){for(this.base=e,this.compartments=t,this.dynamicSlots=i,this.address=r,this.staticValues=s,this.facets=o,this.statusTemplate=[];this.statusTemplate.length>1]}static resolve(e,t,i){let r=[],s=Object.create(null),o=new Map;for(let m of PR(e,t,o))m instanceof jt?r.push(m):(s[m.facet.id]||(s[m.facet.id]=[])).push(m);let a=Object.create(null),c=[],h=[];for(let m of r)a[m.id]=h.length<<1,h.push(O=>m.slot(O));let f=i==null?void 0:i.config.facets;for(let m in s){let O=s[m],v=O[0].facet,b=f&&f[m]||[];if(O.every(S=>S.type==0))if(a[v.id]=c.length<<1|1,a0(b,O))c.push(i.facet(v));else{let S=v.combine(O.map(w=>w.value));c.push(i&&v.compare(S,i.facet(v))?i.facet(v):S)}else{for(let S of O)S.type==0?(a[S.id]=c.length<<1|1,c.push(S.value)):(a[S.id]=h.length<<1,h.push(w=>S.dynamicSlot(w)));a[v.id]=h.length<<1,h.push(S=>kR(S,v,O))}}let p=h.map(m=>m(a));return new tf(e,o,p,a,c,s)}}function PR(n,e,t){let i=[[],[],[],[],[]],r=new Map;function s(o,a){let c=r.get(o);if(c!=null){if(c<=a)return;let h=i[c].indexOf(o);h>-1&&i[c].splice(h,1),o instanceof Lm&&t.delete(o.compartment)}if(r.set(o,a),Array.isArray(o))for(let h of o)s(h,a);else if(o instanceof Lm){if(t.has(o.compartment))throw new RangeError("Duplicate use of compartment in extensions");let h=e.get(o.compartment)||o.inner;t.set(o.compartment,h),s(h,a)}else if(o instanceof OP)s(o.inner,o.prec);else if(o instanceof jt)i[a].push(o),o.provides&&s(o.provides,a);else if(o instanceof Mh)i[a].push(o),o.facet.extensions&&s(o.facet.extensions,$s.default);else{let h=o.extension;if(!h)throw new Error(`Unrecognized extension value in extension set (${o}). This sometimes happens because multiple instances of @codemirror/state are loaded, breaking instanceof checks.`);s(h,a)}}return s(n,$s.default),i.reduce((o,a)=>o.concat(a))}function ka(n,e){if(e&1)return 2;let t=e>>1,i=n.status[t];if(i==4)throw new Error("Cyclic dependency between fields and/or facets");if(i&2)return i;n.status[t]=4;let r=n.computeSlot(n,n.config.dynamicSlots[t]);return n.status[t]=2|r}function nf(n,e){return e&1?n.config.staticValues[e>>1]:n.values[e>>1]}const yP=pe.define(),Dm=pe.define({combine:n=>n.some(e=>e),static:!0}),xP=pe.define({combine:n=>n.length?n[0]:void 0,static:!0}),vP=pe.define(),bP=pe.define(),SP=pe.define(),wP=pe.define({combine:n=>n.length?n[0]:!1});class cr{constructor(e,t){this.type=e,this.value=t}static define(){return new _R}}class _R{of(e){return new cr(this,e)}}class QR{constructor(e){this.map=e}of(e){return new $e(this,e)}}class $e{constructor(e,t){this.type=e,this.value=t}map(e){let t=this.type.map(this.value,e);return t===void 0?void 0:t==this.value?this:new $e(this.type,t)}is(e){return this.type==e}static define(e={}){return new QR(e.map||(t=>t))}static mapEffects(e,t){if(!e.length)return e;let i=[];for(let r of e){let s=r.map(t);s&&i.push(s)}return i}}$e.reconfigure=$e.define();$e.appendConfig=$e.define();class St{constructor(e,t,i,r,s,o){this.startState=e,this.changes=t,this.selection=i,this.effects=r,this.annotations=s,this.scrollIntoView=o,this._doc=null,this._state=null,i&&mP(i,t.newLength),s.some(a=>a.type==St.time)||(this.annotations=s.concat(St.time.of(Date.now())))}static create(e,t,i,r,s,o){return new St(e,t,i,r,s,o)}get newDoc(){return this._doc||(this._doc=this.changes.apply(this.startState.doc))}get newSelection(){return this.selection||this.startState.selection.map(this.changes)}get state(){return this._state||this.startState.applyTransaction(this),this._state}annotation(e){for(let t of this.annotations)if(t.type==e)return t.value}get docChanged(){return!this.changes.empty}get reconfigured(){return this.startState.config!=this.state.config}isUserEvent(e){let t=this.annotation(St.userEvent);return!!(t&&(t==e||t.length>e.length&&t.slice(0,e.length)==e&&t[e.length]=="."))}}St.time=cr.define();St.userEvent=cr.define();St.addToHistory=cr.define();St.remote=cr.define();function CR(n,e){let t=[];for(let i=0,r=0;;){let s,o;if(i=n[i]))s=n[i++],o=n[i++];else if(r=0;r--){let s=i[r](n);s instanceof St?n=s:Array.isArray(s)&&s.length==1&&s[0]instanceof St?n=s[0]:n=PP(e,zo(s),!1)}return n}function $R(n){let e=n.startState,t=e.facet(SP),i=n;for(let r=t.length-1;r>=0;r--){let s=t[r](n);s&&Object.keys(s).length&&(i=kP(i,zm(e,s,n.changes.newLength),!0))}return i==n?n:St.create(e,n.changes,n.selection,i.effects,i.annotations,i.scrollIntoView)}const MR=[];function zo(n){return n==null?MR:Array.isArray(n)?n:[n]}var lt=(function(n){return n[n.Word=0]="Word",n[n.Space=1]="Space",n[n.Other=2]="Other",n})(lt||(lt={}));const RR=/[\u00df\u0587\u0590-\u05f4\u0600-\u06ff\u3040-\u309f\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcc\uac00-\ud7af]/;let jm;try{jm=new RegExp("[\\p{Alphabetic}\\p{Number}_]","u")}catch{}function AR(n){if(jm)return jm.test(n);for(let e=0;e"€"&&(t.toUpperCase()!=t.toLowerCase()||RR.test(t)))return!0}return!1}function ER(n){return e=>{if(!/\S/.test(e))return lt.Space;if(AR(e))return lt.Word;for(let t=0;t-1)return lt.Word;return lt.Other}}class Ze{constructor(e,t,i,r,s,o){this.config=e,this.doc=t,this.selection=i,this.values=r,this.status=e.statusTemplate.slice(),this.computeSlot=s,o&&(o._state=this);for(let a=0;ar.set(h,c)),t=null),r.set(a.value.compartment,a.value.extension)):a.is($e.reconfigure)?(t=null,i=a.value):a.is($e.appendConfig)&&(t=null,i=zo(i).concat(a.value));let s;t?s=e.startState.values.slice():(t=tf.resolve(i,r,this),s=new Ze(t,this.doc,this.selection,t.dynamicSlots.map(()=>null),(c,h)=>h.reconfigure(c,this),null).values);let o=e.startState.facet(Dm)?e.newSelection:e.newSelection.asSingle();new Ze(t,e.newDoc,o,s,(a,c)=>c.update(a,e),e)}replaceSelection(e){return typeof e=="string"&&(e=this.toText(e)),this.changeByRange(t=>({changes:{from:t.from,to:t.to,insert:e},range:V.cursor(t.from+e.length)}))}changeByRange(e){let t=this.selection,i=e(t.ranges[0]),r=this.changes(i.changes),s=[i.range],o=zo(i.effects);for(let a=1;ao.spec.fromJSON(a,c)))}}return Ze.create({doc:e.doc,selection:V.fromJSON(e.selection),extensions:t.extensions?r.concat([t.extensions]):r})}static create(e={}){let t=tf.resolve(e.extensions||[],new Map),i=e.doc instanceof ze?e.doc:ze.of((e.doc||"").split(t.staticFacet(Ze.lineSeparator)||Mm)),r=e.selection?e.selection instanceof V?e.selection:V.single(e.selection.anchor,e.selection.head):V.single(0);return mP(r,i.length),t.staticFacet(Dm)||(r=r.asSingle()),new Ze(t,i,r,t.dynamicSlots.map(()=>null),(s,o)=>o.create(s),null)}get tabSize(){return this.facet(Ze.tabSize)}get lineBreak(){return this.facet(Ze.lineSeparator)||` +`}get readOnly(){return this.facet(wP)}phrase(e,...t){for(let i of this.facet(Ze.phrases))if(Object.prototype.hasOwnProperty.call(i,e)){e=i[e];break}return t.length&&(e=e.replace(/\$(\$|\d*)/g,(i,r)=>{if(r=="$")return"$";let s=+(r||1);return!s||s>t.length?i:t[s-1]})),e}languageDataAt(e,t,i=-1){let r=[];for(let s of this.facet(yP))for(let o of s(this,t,i))Object.prototype.hasOwnProperty.call(o,e)&&r.push(o[e]);return r}charCategorizer(e){return ER(this.languageDataAt("wordChars",e).join(""))}wordAt(e){let{text:t,from:i,length:r}=this.doc.lineAt(e),s=this.charCategorizer(e),o=e-i,a=e-i;for(;o>0;){let c=Vt(t,o,!1);if(s(t.slice(c,o))!=lt.Word)break;o=c}for(;an.length?n[0]:4});Ze.lineSeparator=xP;Ze.readOnly=wP;Ze.phrases=pe.define({compare(n,e){let t=Object.keys(n),i=Object.keys(e);return t.length==i.length&&t.every(r=>n[r]==e[r])}});Ze.languageData=yP;Ze.changeFilter=vP;Ze.transactionFilter=bP;Ze.transactionExtender=SP;Ff.reconfigure=$e.define();function Zi(n,e,t={}){let i={};for(let r of n)for(let s of Object.keys(r)){let o=r[s],a=i[s];if(a===void 0)i[s]=o;else if(!(a===o||o===void 0))if(Object.hasOwnProperty.call(t,s))i[s]=t[s](a,o);else throw new Error("Config merge conflict for field "+s)}for(let r in e)i[r]===void 0&&(i[r]=e[r]);return i}class Xs{eq(e){return this==e}range(e,t=e){return Zm.create(e,t,this)}}Xs.prototype.startSide=Xs.prototype.endSide=0;Xs.prototype.point=!1;Xs.prototype.mapMode=Wt.TrackDel;let Zm=class _P{constructor(e,t,i){this.from=e,this.to=t,this.value=i}static create(e,t,i){return new _P(e,t,i)}};function Im(n,e){return n.from-e.from||n.value.startSide-e.value.startSide}class c0{constructor(e,t,i,r){this.from=e,this.to=t,this.value=i,this.maxPoint=r}get length(){return this.to[this.to.length-1]}findIndex(e,t,i,r=0){let s=i?this.to:this.from;for(let o=r,a=s.length;;){if(o==a)return o;let c=o+a>>1,h=s[c]-e||(i?this.value[c].endSide:this.value[c].startSide)-t;if(c==o)return h>=0?o:a;h>=0?a=c:o=c+1}}between(e,t,i,r){for(let s=this.findIndex(t,-1e9,!0),o=this.findIndex(i,1e9,!1,s);sO||m==O&&h.startSide>0&&h.endSide<=0)continue;(O-m||h.endSide-h.startSide)<0||(o<0&&(o=m),h.point&&(a=Math.max(a,O-m)),i.push(h),r.push(m-o),s.push(O-o))}return{mapped:i.length?new c0(r,s,i,a):null,pos:o}}}class Ie{constructor(e,t,i,r){this.chunkPos=e,this.chunk=t,this.nextLayer=i,this.maxPoint=r}static create(e,t,i,r){return new Ie(e,t,i,r)}get length(){let e=this.chunk.length-1;return e<0?0:Math.max(this.chunkEnd(e),this.nextLayer.length)}get size(){if(this.isEmpty)return 0;let e=this.nextLayer.size;for(let t of this.chunk)e+=t.value.length;return e}chunkEnd(e){return this.chunkPos[e]+this.chunk[e].length}update(e){let{add:t=[],sort:i=!1,filterFrom:r=0,filterTo:s=this.length}=e,o=e.filter;if(t.length==0&&!o)return this;if(i&&(t=t.slice().sort(Im)),this.isEmpty)return t.length?Ie.of(t):this;let a=new QP(this,null,-1).goto(0),c=0,h=[],f=new or;for(;a.value||c=0){let p=t[c++];f.addInner(p.from,p.to,p.value)||h.push(p)}else a.rangeIndex==1&&a.chunkIndexthis.chunkEnd(a.chunkIndex)||sa.to||s=s&&e<=s+o.length&&o.between(s,e-s,t-s,i)===!1)return}this.nextLayer.between(e,t,i)}}iter(e=0){return Na.from([this]).goto(e)}get isEmpty(){return this.nextLayer==this}static iter(e,t=0){return Na.from(e).goto(t)}static compare(e,t,i,r,s=-1){let o=e.filter(p=>p.maxPoint>0||!p.isEmpty&&p.maxPoint>=s),a=t.filter(p=>p.maxPoint>0||!p.isEmpty&&p.maxPoint>=s),c=jv(o,a,i),h=new ea(o,c,s),f=new ea(a,c,s);i.iterGaps((p,m,O)=>Zv(h,p,f,m,O,r)),i.empty&&i.length==0&&Zv(h,0,f,0,0,r)}static eq(e,t,i=0,r){r==null&&(r=999999999);let s=e.filter(f=>!f.isEmpty&&t.indexOf(f)<0),o=t.filter(f=>!f.isEmpty&&e.indexOf(f)<0);if(s.length!=o.length)return!1;if(!s.length)return!0;let a=jv(s,o),c=new ea(s,a,0).goto(i),h=new ea(o,a,0).goto(i);for(;;){if(c.to!=h.to||!Nm(c.active,h.active)||c.point&&(!h.point||!c.point.eq(h.point)))return!1;if(c.to>r)return!0;c.next(),h.next()}}static spans(e,t,i,r,s=-1){let o=new ea(e,null,s).goto(t),a=t,c=o.openStart;for(;;){let h=Math.min(o.to,i);if(o.point){let f=o.activeForPoint(o.to),p=o.pointFroma&&(r.span(a,h,o.active,c),c=o.openEnd(h));if(o.to>i)return c+(o.point&&o.to>i?1:0);a=o.to,o.next()}}static of(e,t=!1){let i=new or;for(let r of e instanceof Zm?[e]:t?LR(e):e)i.add(r.from,r.to,r.value);return i.finish()}static join(e){if(!e.length)return Ie.empty;let t=e[e.length-1];for(let i=e.length-2;i>=0;i--)for(let r=e[i];r!=Ie.empty;r=r.nextLayer)t=new Ie(r.chunkPos,r.chunk,t,Math.max(r.maxPoint,t.maxPoint));return t}}Ie.empty=new Ie([],[],null,-1);function LR(n){if(n.length>1)for(let e=n[0],t=1;t0)return n.slice().sort(Im);e=i}return n}Ie.empty.nextLayer=Ie.empty;class or{finishChunk(e){this.chunks.push(new c0(this.from,this.to,this.value,this.maxPoint)),this.chunkPos.push(this.chunkStart),this.chunkStart=-1,this.setMaxPoint=Math.max(this.setMaxPoint,this.maxPoint),this.maxPoint=-1,e&&(this.from=[],this.to=[],this.value=[])}constructor(){this.chunks=[],this.chunkPos=[],this.chunkStart=-1,this.last=null,this.lastFrom=-1e9,this.lastTo=-1e9,this.from=[],this.to=[],this.value=[],this.maxPoint=-1,this.setMaxPoint=-1,this.nextLayer=null}add(e,t,i){this.addInner(e,t,i)||(this.nextLayer||(this.nextLayer=new or)).add(e,t,i)}addInner(e,t,i){let r=e-this.lastTo||i.startSide-this.last.endSide;if(r<=0&&(e-this.lastFrom||i.startSide-this.last.startSide)<0)throw new Error("Ranges must be added sorted by `from` position and `startSide`");return r<0?!1:(this.from.length==250&&this.finishChunk(!0),this.chunkStart<0&&(this.chunkStart=e),this.from.push(e-this.chunkStart),this.to.push(t-this.chunkStart),this.last=i,this.lastFrom=e,this.lastTo=t,this.value.push(i),i.point&&(this.maxPoint=Math.max(this.maxPoint,t-e)),!0)}addChunk(e,t){if((e-this.lastTo||t.value[0].startSide-this.last.endSide)<0)return!1;this.from.length&&this.finishChunk(!0),this.setMaxPoint=Math.max(this.setMaxPoint,t.maxPoint),this.chunks.push(t),this.chunkPos.push(e);let i=t.value.length-1;return this.last=t.value[i],this.lastFrom=t.from[i]+e,this.lastTo=t.to[i]+e,!0}finish(){return this.finishInner(Ie.empty)}finishInner(e){if(this.from.length&&this.finishChunk(!1),this.chunks.length==0)return e;let t=Ie.create(this.chunkPos,this.chunks,this.nextLayer?this.nextLayer.finishInner(e):e,this.setMaxPoint);return this.from=null,t}}function jv(n,e,t){let i=new Map;for(let s of n)for(let o=0;o=this.minPoint)break}}setRangeIndex(e){if(e==this.layer.chunk[this.chunkIndex].value.length){if(this.chunkIndex++,this.skip)for(;this.chunkIndex=i&&r.push(new QP(o,t,i,s));return r.length==1?r[0]:new Na(r)}get startSide(){return this.value?this.value.startSide:0}goto(e,t=-1e9){for(let i of this.heap)i.goto(e,t);for(let i=this.heap.length>>1;i>=0;i--)xg(this.heap,i);return this.next(),this}forward(e,t){for(let i of this.heap)i.forward(e,t);for(let i=this.heap.length>>1;i>=0;i--)xg(this.heap,i);(this.to-e||this.value.endSide-t)<0&&this.next()}next(){if(this.heap.length==0)this.from=this.to=1e9,this.value=null,this.rank=-1;else{let e=this.heap[0];this.from=e.from,this.to=e.to,this.value=e.value,this.rank=e.rank,e.value&&e.next(),xg(this.heap,0)}}}function xg(n,e){for(let t=n[e];;){let i=(e<<1)+1;if(i>=n.length)break;let r=n[i];if(i+1=0&&(r=n[i+1],i++),t.compare(r)<0)break;n[i]=t,n[e]=r,e=i}}class ea{constructor(e,t,i){this.minPoint=i,this.active=[],this.activeTo=[],this.activeRank=[],this.minActive=-1,this.point=null,this.pointFrom=0,this.pointRank=0,this.to=-1e9,this.endSide=0,this.openStart=-1,this.cursor=Na.from(e,t,i)}goto(e,t=-1e9){return this.cursor.goto(e,t),this.active.length=this.activeTo.length=this.activeRank.length=0,this.minActive=-1,this.to=e,this.endSide=t,this.openStart=-1,this.next(),this}forward(e,t){for(;this.minActive>-1&&(this.activeTo[this.minActive]-e||this.active[this.minActive].endSide-t)<0;)this.removeActive(this.minActive);this.cursor.forward(e,t)}removeActive(e){Iu(this.active,e),Iu(this.activeTo,e),Iu(this.activeRank,e),this.minActive=Iv(this.active,this.activeTo)}addActive(e){let t=0,{value:i,to:r,rank:s}=this.cursor;for(;t0;)t++;Nu(this.active,t,i),Nu(this.activeTo,t,r),Nu(this.activeRank,t,s),e&&Nu(e,t,this.cursor.from),this.minActive=Iv(this.active,this.activeTo)}next(){let e=this.to,t=this.point;this.point=null;let i=this.openStart<0?[]:null;for(;;){let r=this.minActive;if(r>-1&&(this.activeTo[r]-this.cursor.from||this.active[r].endSide-this.cursor.startSide)<0){if(this.activeTo[r]>e){this.to=this.activeTo[r],this.endSide=this.active[r].endSide;break}this.removeActive(r),i&&Iu(i,r)}else if(this.cursor.value)if(this.cursor.from>e){this.to=this.cursor.from,this.endSide=this.cursor.startSide;break}else{let s=this.cursor.value;if(!s.point)this.addActive(i),this.cursor.next();else if(t&&this.cursor.to==this.to&&this.cursor.from=0&&i[r]=0&&!(this.activeRank[i]e||this.activeTo[i]==e&&this.active[i].endSide>=this.point.endSide)&&t.push(this.active[i]);return t.reverse()}openEnd(e){let t=0;for(let i=this.activeTo.length-1;i>=0&&this.activeTo[i]>e;i--)t++;return t}}function Zv(n,e,t,i,r,s){n.goto(e),t.goto(i);let o=i+r,a=i,c=i-e;for(;;){let h=n.to+c-t.to,f=h||n.endSide-t.endSide,p=f<0?n.to+c:t.to,m=Math.min(p,o);if(n.point||t.point?n.point&&t.point&&(n.point==t.point||n.point.eq(t.point))&&Nm(n.activeForPoint(n.to),t.activeForPoint(t.to))||s.comparePoint(a,m,n.point,t.point):m>a&&!Nm(n.active,t.active)&&s.compareRange(a,m,n.active,t.active),p>o)break;(h||n.openEnd!=t.openEnd)&&s.boundChange&&s.boundChange(p),a=p,f<=0&&n.next(),f>=0&&t.next()}}function Nm(n,e){if(n.length!=e.length)return!1;for(let t=0;t=e;i--)n[i+1]=n[i];n[e]=t}function Iv(n,e){let t=-1,i=1e9;for(let r=0;r=e)return r;if(r==n.length)break;s+=n.charCodeAt(r)==9?t-s%t:1,r=Vt(n,r)}return i===!0?-1:n.length}const Xm="ͼ",Nv=typeof Symbol>"u"?"__"+Xm:Symbol.for(Xm),Wm=typeof Symbol>"u"?"__styleSet"+Math.floor(Math.random()*1e8):Symbol("styleSet"),Bv=typeof globalThis<"u"?globalThis:typeof window<"u"?window:{};class Ur{constructor(e,t){this.rules=[];let{finish:i}=t||{};function r(o){return/^@/.test(o)?[o]:o.split(/,\s*/)}function s(o,a,c,h){let f=[],p=/^@(\w+)\b/.exec(o[0]),m=p&&p[1]=="keyframes";if(p&&a==null)return c.push(o[0]+";");for(let O in a){let v=a[O];if(/&/.test(O))s(O.split(/,\s*/).map(b=>o.map(S=>b.replace(/&/,S))).reduce((b,S)=>b.concat(S)),v,c);else if(v&&typeof v=="object"){if(!p)throw new RangeError("The value of a property ("+O+") should be a primitive value.");s(r(O),v,f,m)}else v!=null&&f.push(O.replace(/_.*/,"").replace(/[A-Z]/g,b=>"-"+b.toLowerCase())+": "+v+";")}(f.length||m)&&c.push((i&&!p&&!h?o.map(i):o).join(", ")+" {"+f.join(" ")+"}")}for(let o in e)s(r(o),e[o],this.rules)}getRules(){return this.rules.join(` +`)}static newName(){let e=Bv[Nv]||1;return Bv[Nv]=e+1,Xm+e.toString(36)}static mount(e,t,i){let r=e[Wm],s=i&&i.nonce;r?s&&r.setNonce(s):r=new DR(e,s),r.mount(Array.isArray(t)?t:[t],e)}}let Xv=new Map;class DR{constructor(e,t){let i=e.ownerDocument||e,r=i.defaultView;if(!e.head&&e.adoptedStyleSheets&&r.CSSStyleSheet){let s=Xv.get(i);if(s)return e[Wm]=s;this.sheet=new r.CSSStyleSheet,Xv.set(i,this)}else this.styleTag=i.createElement("style"),t&&this.styleTag.setAttribute("nonce",t);this.modules=[],e[Wm]=this}mount(e,t){let i=this.sheet,r=0,s=0;for(let o=0;o-1&&(this.modules.splice(c,1),s--,c=-1),c==-1){if(this.modules.splice(s++,0,a),i)for(let h=0;h",191:"?",192:"~",219:"{",220:"|",221:"}",222:'"'},zR=typeof navigator<"u"&&/Mac/.test(navigator.platform),jR=typeof navigator<"u"&&/MSIE \d|Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(navigator.userAgent);for(var Xt=0;Xt<10;Xt++)Hr[48+Xt]=Hr[96+Xt]=String(Xt);for(var Xt=1;Xt<=24;Xt++)Hr[Xt+111]="F"+Xt;for(var Xt=65;Xt<=90;Xt++)Hr[Xt]=String.fromCharCode(Xt+32),Ba[Xt]=String.fromCharCode(Xt);for(var vg in Hr)Ba.hasOwnProperty(vg)||(Ba[vg]=Hr[vg]);function ZR(n){var e=zR&&n.metaKey&&n.shiftKey&&!n.ctrlKey&&!n.altKey||jR&&n.shiftKey&&n.key&&n.key.length==1||n.key=="Unidentified",t=!e&&n.key||(n.shiftKey?Ba:Hr)[n.keyCode]||n.key||"Unidentified";return t=="Esc"&&(t="Escape"),t=="Del"&&(t="Delete"),t=="Left"&&(t="ArrowLeft"),t=="Up"&&(t="ArrowUp"),t=="Right"&&(t="ArrowRight"),t=="Down"&&(t="ArrowDown"),t}function He(){var n=arguments[0];typeof n=="string"&&(n=document.createElement(n));var e=1,t=arguments[1];if(t&&typeof t=="object"&&t.nodeType==null&&!Array.isArray(t)){for(var i in t)if(Object.prototype.hasOwnProperty.call(t,i)){var r=t[i];typeof r=="string"?n.setAttribute(i,r):r!=null&&(n[i]=r)}e++}for(;e2);var he={mac:Vv||/Mac/.test(ln.platform),windows:/Win/.test(ln.platform),linux:/Linux|X11/.test(ln.platform),ie:Yf,ie_version:TP?Vm.documentMode||6:Ym?+Ym[1]:Fm?+Fm[1]:0,gecko:Wv,gecko_version:Wv?+(/Firefox\/(\d+)/.exec(ln.userAgent)||[0,0])[1]:0,chrome:!!bg,chrome_version:bg?+bg[1]:0,ios:Vv,android:/Android\b/.test(ln.userAgent),webkit_version:IR?+(/\bAppleWebKit\/(\d+)/.exec(ln.userAgent)||[0,0])[1]:0,safari:qm,safari_version:qm?+(/\bVersion\/(\d+(\.\d+)?)/.exec(ln.userAgent)||[0,0])[1]:0,tabSize:Vm.documentElement.style.tabSize!=null?"tab-size":"-moz-tab-size"};function Xa(n){let e;return n.nodeType==11?e=n.getSelection?n:n.ownerDocument:e=n,e.getSelection()}function Um(n,e){return e?n==e||n.contains(e.nodeType!=1?e.parentNode:e):!1}function Rh(n,e){if(!e.anchorNode)return!1;try{return Um(n,e.anchorNode)}catch{return!1}}function Wa(n){return n.nodeType==3?Vs(n,0,n.nodeValue.length).getClientRects():n.nodeType==1?n.getClientRects():[]}function Pa(n,e,t,i){return t?Fv(n,e,t,i,-1)||Fv(n,e,t,i,1):!1}function Ws(n){for(var e=0;;e++)if(n=n.previousSibling,!n)return e}function rf(n){return n.nodeType==1&&/^(DIV|P|LI|UL|OL|BLOCKQUOTE|DD|DT|H\d|SECTION|PRE)$/.test(n.nodeName)}function Fv(n,e,t,i,r){for(;;){if(n==t&&e==i)return!0;if(e==(r<0?0:ji(n))){if(n.nodeName=="DIV")return!1;let s=n.parentNode;if(!s||s.nodeType!=1)return!1;e=Ws(n)+(r<0?0:1),n=s}else if(n.nodeType==1){if(n=n.childNodes[e+(r<0?-1:0)],n.nodeType==1&&n.contentEditable=="false")return!1;e=r<0?ji(n):0}else return!1}}function ji(n){return n.nodeType==3?n.nodeValue.length:n.childNodes.length}function qf(n,e){let t=e?n.left:n.right;return{left:t,right:t,top:n.top,bottom:n.bottom}}function NR(n){let e=n.visualViewport;return e?{left:0,right:e.width,top:0,bottom:e.height}:{left:0,right:n.innerWidth,top:0,bottom:n.innerHeight}}function $P(n,e){let t=e.width/n.offsetWidth,i=e.height/n.offsetHeight;return(t>.995&&t<1.005||!isFinite(t)||Math.abs(e.width-n.offsetWidth)<1)&&(t=1),(i>.995&&i<1.005||!isFinite(i)||Math.abs(e.height-n.offsetHeight)<1)&&(i=1),{scaleX:t,scaleY:i}}function BR(n,e,t,i,r,s,o,a){let c=n.ownerDocument,h=c.defaultView||window;for(let f=n,p=!1;f&&!p;)if(f.nodeType==1){let m,O=f==c.body,v=1,b=1;if(O)m=NR(h);else{if(/^(fixed|sticky)$/.test(getComputedStyle(f).position)&&(p=!0),f.scrollHeight<=f.clientHeight&&f.scrollWidth<=f.clientWidth){f=f.assignedSlot||f.parentNode;continue}let T=f.getBoundingClientRect();({scaleX:v,scaleY:b}=$P(f,T)),m={left:T.left,right:T.left+f.clientWidth*v,top:T.top,bottom:T.top+f.clientHeight*b}}let S=0,w=0;if(r=="nearest")e.top0&&e.bottom>m.bottom+w&&(w=e.bottom-m.bottom+o)):e.bottom>m.bottom&&(w=e.bottom-m.bottom+o,t<0&&e.top-w0&&e.right>m.right+S&&(S=e.right-m.right+s)):e.right>m.right&&(S=e.right-m.right+s,t<0&&e.leftm.bottom||e.leftm.right)&&(e={left:Math.max(e.left,m.left),right:Math.min(e.right,m.right),top:Math.max(e.top,m.top),bottom:Math.min(e.bottom,m.bottom)}),f=f.assignedSlot||f.parentNode}else if(f.nodeType==11)f=f.host;else break}function XR(n){let e=n.ownerDocument,t,i;for(let r=n.parentNode;r&&!(r==e.body||t&&i);)if(r.nodeType==1)!i&&r.scrollHeight>r.clientHeight&&(i=r),!t&&r.scrollWidth>r.clientWidth&&(t=r),r=r.assignedSlot||r.parentNode;else if(r.nodeType==11)r=r.host;else break;return{x:t,y:i}}class WR{constructor(){this.anchorNode=null,this.anchorOffset=0,this.focusNode=null,this.focusOffset=0}eq(e){return this.anchorNode==e.anchorNode&&this.anchorOffset==e.anchorOffset&&this.focusNode==e.focusNode&&this.focusOffset==e.focusOffset}setRange(e){let{anchorNode:t,focusNode:i}=e;this.set(t,Math.min(e.anchorOffset,t?ji(t):0),i,Math.min(e.focusOffset,i?ji(i):0))}set(e,t,i,r){this.anchorNode=e,this.anchorOffset=t,this.focusNode=i,this.focusOffset=r}}let _s=null;he.safari&&he.safari_version>=26&&(_s=!1);function MP(n){if(n.setActive)return n.setActive();if(_s)return n.focus(_s);let e=[];for(let t=n;t&&(e.push(t,t.scrollTop,t.scrollLeft),t!=t.ownerDocument);t=t.parentNode);if(n.focus(_s==null?{get preventScroll(){return _s={preventScroll:!0},!0}}:void 0),!_s){_s=!1;for(let t=0;tMath.max(1,n.scrollHeight-n.clientHeight-4)}function EP(n,e){for(let t=n,i=e;;){if(t.nodeType==3&&i>0)return{node:t,offset:i};if(t.nodeType==1&&i>0){if(t.contentEditable=="false")return null;t=t.childNodes[i-1],i=ji(t)}else if(t.parentNode&&!rf(t))i=Ws(t),t=t.parentNode;else return null}}function LP(n,e){for(let t=n,i=e;;){if(t.nodeType==3&&it)return p.domBoundsAround(e,t,h);if(m>=e&&r==-1&&(r=c,s=h),h>t&&p.dom.parentNode==this.dom){o=c,a=f;break}f=m,h=m+p.breakAfter}return{from:s,to:a<0?i+this.length:a,startDOM:(r?this.children[r-1].dom.nextSibling:null)||this.dom.firstChild,endDOM:o=0?this.children[o].dom:null}}markDirty(e=!1){this.flags|=2,this.markParentsDirty(e)}markParentsDirty(e){for(let t=this.parent;t;t=t.parent){if(e&&(t.flags|=2),t.flags&1)return;t.flags|=1,e=!1}}setParent(e){this.parent!=e&&(this.parent=e,this.flags&7&&this.markParentsDirty(!0))}setDOM(e){this.dom!=e&&(this.dom&&(this.dom.cmView=null),this.dom=e,e.cmView=this)}get rootView(){for(let e=this;;){let t=e.parent;if(!t)return e;e=t}}replaceChildren(e,t,i=u0){this.markDirty();for(let r=e;rthis.pos||e==this.pos&&(t>0||this.i==0||this.children[this.i-1].breakAfter))return this.off=e-this.pos,this;let i=this.children[--this.i];this.pos-=i.length+i.breakAfter}}}function zP(n,e,t,i,r,s,o,a,c){let{children:h}=n,f=h.length?h[e]:null,p=s.length?s[s.length-1]:null,m=p?p.breakAfter:o;if(!(e==i&&f&&!o&&!m&&s.length<2&&f.merge(t,r,s.length?p:null,t==0,a,c))){if(i0&&(!o&&s.length&&f.merge(t,f.length,s[0],!1,a,0)?f.breakAfter=s.shift().breakAfter:(tYR||i.flags&8)?!1:(this.text=this.text.slice(0,e)+(i?i.text:"")+this.text.slice(t),this.markDirty(),!0)}split(e){let t=new ui(this.text.slice(e));return this.text=this.text.slice(0,e),this.markDirty(),t.flags|=this.flags&8,t}localPosFromDOM(e,t){return e==this.dom?t:t?this.text.length:0}domAtPos(e){return new Jt(this.dom,e)}domBoundsAround(e,t,i){return{from:i,to:i+this.length,startDOM:this.dom,endDOM:this.dom.nextSibling}}coordsAt(e,t){return qR(this.dom,e,t)}}class lr extends Ue{constructor(e,t=[],i=0){super(),this.mark=e,this.children=t,this.length=i;for(let r of t)r.setParent(this)}setAttrs(e){if(RP(e),this.mark.class&&(e.className=this.mark.class),this.mark.attrs)for(let t in this.mark.attrs)e.setAttribute(t,this.mark.attrs[t]);return e}canReuseDOM(e){return super.canReuseDOM(e)&&!((this.flags|e.flags)&8)}reuseDOM(e){e.nodeName==this.mark.tagName.toUpperCase()&&(this.setDOM(e),this.flags|=6)}sync(e,t){this.dom?this.flags&4&&this.setAttrs(this.dom):this.setDOM(this.setAttrs(document.createElement(this.mark.tagName))),super.sync(e,t)}merge(e,t,i,r,s,o){return i&&(!(i instanceof lr&&i.mark.eq(this.mark))||e&&s<=0||te&&t.push(i=e&&(r=s),i=c,s++}let o=this.length-e;return this.length=e,r>-1&&(this.children.length=r,this.markDirty()),new lr(this.mark,t,o)}domAtPos(e){return ZP(this,e)}coordsAt(e,t){return NP(this,e,t)}}function qR(n,e,t){let i=n.nodeValue.length;e>i&&(e=i);let r=e,s=e,o=0;e==0&&t<0||e==i&&t>=0?he.chrome||he.gecko||(e?(r--,o=1):s=0)?0:a.length-1];return he.safari&&!o&&c.width==0&&(c=Array.prototype.find.call(a,h=>h.width)||c),o?qf(c,o<0):c||null}class jr extends Ue{static create(e,t,i){return new jr(e,t,i)}constructor(e,t,i){super(),this.widget=e,this.length=t,this.side=i,this.prevWidget=null}split(e){let t=jr.create(this.widget,this.length-e,this.side);return this.length-=e,t}sync(e){(!this.dom||!this.widget.updateDOM(this.dom,e))&&(this.dom&&this.prevWidget&&this.prevWidget.destroy(this.dom),this.prevWidget=null,this.setDOM(this.widget.toDOM(e)),this.widget.editable||(this.dom.contentEditable="false"))}getSide(){return this.side}merge(e,t,i,r,s,o){return i&&(!(i instanceof jr)||!this.widget.compare(i.widget)||e>0&&s<=0||t0)?Jt.before(this.dom):Jt.after(this.dom,e==this.length)}domBoundsAround(){return null}coordsAt(e,t){let i=this.widget.coordsAt(this.dom,e,t);if(i)return i;let r=this.dom.getClientRects(),s=null;if(!r.length)return null;let o=this.side?this.side<0:e>0;for(let a=o?r.length-1:0;s=r[a],!(e>0?a==0:a==r.length-1||s.top0?Jt.before(this.dom):Jt.after(this.dom)}localPosFromDOM(){return 0}domBoundsAround(){return null}coordsAt(e){return this.dom.getBoundingClientRect()}get overrideDOMText(){return ze.empty}get isHidden(){return!0}}ui.prototype.children=jr.prototype.children=Ho.prototype.children=u0;function ZP(n,e){let t=n.dom,{children:i}=n,r=0;for(let s=0;rs&&e0;s--){let o=i[s-1];if(o.dom.parentNode==t)return o.domAtPos(o.length)}for(let s=r;s0&&e instanceof lr&&r.length&&(i=r[r.length-1])instanceof lr&&i.mark.eq(e.mark)?IP(i,e.children[0],t-1):(r.push(e),e.setParent(n)),n.length+=e.length}function NP(n,e,t){let i=null,r=-1,s=null,o=-1;function a(h,f){for(let p=0,m=0;p=f&&(O.children.length?a(O,f-m):(!s||s.isHidden&&(t>0||HR(s,O)))&&(v>f||m==v&&O.getSide()>0)?(s=O,o=f-m):(m-1?1:0)!=r.length-(t&&r.indexOf(t)>-1?1:0))return!1;for(let s of i)if(s!=t&&(r.indexOf(s)==-1||n[s]!==e[s]))return!1;return!0}function Gm(n,e,t){let i=!1;if(e)for(let r in e)t&&r in t||(i=!0,r=="style"?n.style.cssText="":n.removeAttribute(r));if(t)for(let r in t)e&&e[r]==t[r]||(i=!0,r=="style"?n.style.cssText=t[r]:n.setAttribute(r,t[r]));return i}function GR(n){let e=Object.create(null);for(let t=0;t0?3e8:-4e8:t>0?1e8:-1e8,new Gr(e,t,t,i,e.widget||null,!1)}static replace(e){let t=!!e.block,i,r;if(e.isBlockGap)i=-5e8,r=4e8;else{let{start:s,end:o}=BP(e,t);i=(s?t?-3e8:-1:5e8)-1,r=(o?t?2e8:1:-6e8)+1}return new Gr(e,i,r,t,e.widget||null,!0)}static line(e){return new gc(e)}static set(e,t=!1){return Ie.of(e,t)}hasHeight(){return this.widget?this.widget.estimatedHeight>-1:!1}}_e.none=Ie.empty;class pc extends _e{constructor(e){let{start:t,end:i}=BP(e);super(t?-1:5e8,i?1:-6e8,null,e),this.tagName=e.tagName||"span",this.class=e.class||"",this.attrs=e.attributes||null}eq(e){var t,i;return this==e||e instanceof pc&&this.tagName==e.tagName&&(this.class||((t=this.attrs)===null||t===void 0?void 0:t.class))==(e.class||((i=e.attrs)===null||i===void 0?void 0:i.class))&&sf(this.attrs,e.attrs,"class")}range(e,t=e){if(e>=t)throw new RangeError("Mark decorations may not be empty");return super.range(e,t)}}pc.prototype.point=!1;class gc extends _e{constructor(e){super(-2e8,-2e8,null,e)}eq(e){return e instanceof gc&&this.spec.class==e.spec.class&&sf(this.spec.attributes,e.spec.attributes)}range(e,t=e){if(t!=e)throw new RangeError("Line decoration ranges must be zero-length");return super.range(e,t)}}gc.prototype.mapMode=Wt.TrackBefore;gc.prototype.point=!0;class Gr extends _e{constructor(e,t,i,r,s,o){super(t,i,s,e),this.block=r,this.isReplace=o,this.mapMode=r?t<=0?Wt.TrackBefore:Wt.TrackAfter:Wt.TrackDel}get type(){return this.startSide!=this.endSide?an.WidgetRange:this.startSide<=0?an.WidgetBefore:an.WidgetAfter}get heightRelevant(){return this.block||!!this.widget&&(this.widget.estimatedHeight>=5||this.widget.lineBreaks>0)}eq(e){return e instanceof Gr&&KR(this.widget,e.widget)&&this.block==e.block&&this.startSide==e.startSide&&this.endSide==e.endSide}range(e,t=e){if(this.isReplace&&(e>t||e==t&&this.startSide>0&&this.endSide<=0))throw new RangeError("Invalid range for replacement decoration");if(!this.isReplace&&t!=e)throw new RangeError("Widget decorations can only have zero-length ranges");return super.range(e,t)}}Gr.prototype.point=!0;function BP(n,e=!1){let{inclusiveStart:t,inclusiveEnd:i}=n;return t==null&&(t=n.inclusive),i==null&&(i=n.inclusive),{start:t!=null?t:e,end:i!=null?i:e}}function KR(n,e){return n==e||!!(n&&e&&n.compare(e))}function Ah(n,e,t,i=0){let r=t.length-1;r>=0&&t[r]+i>=n?t[r]=Math.max(t[r],e):t.push(n,e)}class xt extends Ue{constructor(){super(...arguments),this.children=[],this.length=0,this.prevAttrs=void 0,this.attrs=null,this.breakAfter=0}merge(e,t,i,r,s,o){if(i){if(!(i instanceof xt))return!1;this.dom||i.transferDOM(this)}return r&&this.setDeco(i?i.attrs:null),jP(this,e,t,i?i.children.slice():[],s,o),!0}split(e){let t=new xt;if(t.breakAfter=this.breakAfter,this.length==0)return t;let{i,off:r}=this.childPos(e);r&&(t.append(this.children[i].split(r),0),this.children[i].merge(r,this.children[i].length,null,!1,0,0),i++);for(let s=i;s0&&this.children[i-1].length==0;)this.children[--i].destroy();return this.children.length=i,this.markDirty(),this.length=e,t}transferDOM(e){this.dom&&(this.markDirty(),e.setDOM(this.dom),e.prevAttrs=this.prevAttrs===void 0?this.attrs:this.prevAttrs,this.prevAttrs=void 0,this.dom=null)}setDeco(e){sf(this.attrs,e)||(this.dom&&(this.prevAttrs=this.attrs,this.markDirty()),this.attrs=e)}append(e,t){IP(this,e,t)}addLineDeco(e){let t=e.spec.attributes,i=e.spec.class;t&&(this.attrs=Hm(t,this.attrs||{})),i&&(this.attrs=Hm({class:i},this.attrs||{}))}domAtPos(e){return ZP(this,e)}reuseDOM(e){e.nodeName=="DIV"&&(this.setDOM(e),this.flags|=6)}sync(e,t){var i;this.dom?this.flags&4&&(RP(this.dom),this.dom.className="cm-line",this.prevAttrs=this.attrs?null:void 0):(this.setDOM(document.createElement("div")),this.dom.className="cm-line",this.prevAttrs=this.attrs?null:void 0),this.prevAttrs!==void 0&&(Gm(this.dom,this.prevAttrs,this.attrs),this.dom.classList.add("cm-line"),this.prevAttrs=void 0),super.sync(e,t);let r=this.dom.lastChild;for(;r&&Ue.get(r)instanceof lr;)r=r.lastChild;if(!r||!this.length||r.nodeName!="BR"&&((i=Ue.get(r))===null||i===void 0?void 0:i.isEditable)==!1&&(!he.ios||!this.children.some(s=>s instanceof ui))){let s=document.createElement("BR");s.cmIgnore=!0,this.dom.appendChild(s)}}measureTextSize(){if(this.children.length==0||this.length>20)return null;let e=0,t;for(let i of this.children){if(!(i instanceof ui)||/[^ -~]/.test(i.text))return null;let r=Wa(i.dom);if(r.length!=1)return null;e+=r[0].width,t=r[0].height}return e?{lineHeight:this.dom.getBoundingClientRect().height,charWidth:e/this.length,textHeight:t}:null}coordsAt(e,t){let i=NP(this,e,t);if(!this.children.length&&i&&this.parent){let{heightOracle:r}=this.parent.view.viewState,s=i.bottom-i.top;if(Math.abs(s-r.lineHeight)<2&&r.textHeight=t){if(s instanceof xt)return s;if(o>t)break}r=o+s.breakAfter}return null}}class sr extends Ue{constructor(e,t,i){super(),this.widget=e,this.length=t,this.deco=i,this.breakAfter=0,this.prevWidget=null}merge(e,t,i,r,s,o){return i&&(!(i instanceof sr)||!this.widget.compare(i.widget)||e>0&&s<=0||t0}}class Km extends ur{constructor(e){super(),this.height=e}toDOM(){let e=document.createElement("div");return e.className="cm-gap",this.updateDOM(e),e}eq(e){return e.height==this.height}updateDOM(e){return e.style.height=this.height+"px",!0}get editable(){return!0}get estimatedHeight(){return this.height}ignoreEvent(){return!1}}class _a{constructor(e,t,i,r){this.doc=e,this.pos=t,this.end=i,this.disallowBlockEffectsFor=r,this.content=[],this.curLine=null,this.breakAtStart=0,this.pendingBuffer=0,this.bufferMarks=[],this.atCursorPos=!0,this.openStart=-1,this.openEnd=-1,this.text="",this.textOff=0,this.cursor=e.iter(),this.skip=t}posCovered(){if(this.content.length==0)return!this.breakAtStart&&this.doc.lineAt(this.pos).from!=this.pos;let e=this.content[this.content.length-1];return!(e.breakAfter||e instanceof sr&&e.deco.endSide<0)}getLine(){return this.curLine||(this.content.push(this.curLine=new xt),this.atCursorPos=!0),this.curLine}flushBuffer(e=this.bufferMarks){this.pendingBuffer&&(this.curLine.append(Bu(new Ho(-1),e),e.length),this.pendingBuffer=0)}addBlockWidget(e){this.flushBuffer(),this.curLine=null,this.content.push(e)}finish(e){this.pendingBuffer&&e<=this.bufferMarks.length?this.flushBuffer():this.pendingBuffer=0,!this.posCovered()&&!(e&&this.content.length&&this.content[this.content.length-1]instanceof sr)&&this.getLine()}buildText(e,t,i){for(;e>0;){if(this.textOff==this.text.length){let{value:o,lineBreak:a,done:c}=this.cursor.next(this.skip);if(this.skip=0,c)throw new Error("Ran out of text content when drawing inline views");if(a){this.posCovered()||this.getLine(),this.content.length?this.content[this.content.length-1].breakAfter=1:this.breakAtStart=1,this.flushBuffer(),this.curLine=null,this.atCursorPos=!0,e--;continue}else this.text=o,this.textOff=0}let r=Math.min(this.text.length-this.textOff,e),s=Math.min(r,512);this.flushBuffer(t.slice(t.length-i)),this.getLine().append(Bu(new ui(this.text.slice(this.textOff,this.textOff+s)),t),i),this.atCursorPos=!0,this.textOff+=s,e-=s,i=r<=s?0:t.length}}span(e,t,i,r){this.buildText(t-e,i,r),this.pos=t,this.openStart<0&&(this.openStart=r)}point(e,t,i,r,s,o){if(this.disallowBlockEffectsFor[o]&&i instanceof Gr){if(i.block)throw new RangeError("Block decorations may not be specified via plugins");if(t>this.doc.lineAt(this.pos).to)throw new RangeError("Decorations that replace line breaks may not be specified via plugins")}let a=t-e;if(i instanceof Gr)if(i.block)i.startSide>0&&!this.posCovered()&&this.getLine(),this.addBlockWidget(new sr(i.widget||Go.block,a,i));else{let c=jr.create(i.widget||Go.inline,a,a?0:i.startSide),h=this.atCursorPos&&!c.isEditable&&s<=r.length&&(e0),f=!c.isEditable&&(er.length||i.startSide<=0),p=this.getLine();this.pendingBuffer==2&&!h&&!c.isEditable&&(this.pendingBuffer=0),this.flushBuffer(r),h&&(p.append(Bu(new Ho(1),r),s),s=r.length+Math.max(0,s-r.length)),p.append(Bu(c,r),s),this.atCursorPos=f,this.pendingBuffer=f?er.length?1:2:0,this.pendingBuffer&&(this.bufferMarks=r.slice())}else this.doc.lineAt(this.pos).from==this.pos&&this.getLine().addLineDeco(i);a&&(this.textOff+a<=this.text.length?this.textOff+=a:(this.skip+=a-(this.text.length-this.textOff),this.text="",this.textOff=0),this.pos=t),this.openStart<0&&(this.openStart=s)}static build(e,t,i,r,s){let o=new _a(e,t,i,s);return o.openEnd=Ie.spans(r,t,i,o),o.openStart<0&&(o.openStart=o.openEnd),o.finish(o.openEnd),o}}function Bu(n,e){for(let t of e)n=new lr(t,[n],n.length);return n}class Go extends ur{constructor(e){super(),this.tag=e}eq(e){return e.tag==this.tag}toDOM(){return document.createElement(this.tag)}updateDOM(e){return e.nodeName.toLowerCase()==this.tag}get isHidden(){return!0}}Go.inline=new Go("span");Go.block=new Go("div");var st=(function(n){return n[n.LTR=0]="LTR",n[n.RTL=1]="RTL",n})(st||(st={}));const Fs=st.LTR,h0=st.RTL;function XP(n){let e=[];for(let t=0;t=t){if(a.level==i)return o;(s<0||(r!=0?r<0?a.fromt:e[s].level>a.level))&&(s=o)}}if(s<0)throw new RangeError("Index out of range");return s}}function VP(n,e){if(n.length!=e.length)return!1;for(let t=0;t=0;b-=3)if(bi[b+1]==-O){let S=bi[b+2],w=S&2?r:S&4?S&1?s:r:0;w&&(Ge[p]=Ge[bi[b]]=w),a=b;break}}else{if(bi.length==189)break;bi[a++]=p,bi[a++]=m,bi[a++]=c}else if((v=Ge[p])==2||v==1){let b=v==r;c=b?0:1;for(let S=a-3;S>=0;S-=3){let w=bi[S+2];if(w&2)break;if(b)bi[S+2]|=2;else{if(w&4)break;bi[S+2]|=4}}}}}function rA(n,e,t,i){for(let r=0,s=i;r<=t.length;r++){let o=r?t[r-1].to:n,a=rc;)v==S&&(v=t[--b].from,S=b?t[b-1].to:n),Ge[--v]=O;c=f}else s=h,c++}}}function eO(n,e,t,i,r,s,o){let a=i%2?2:1;if(i%2==r%2)for(let c=e,h=0;cc&&o.push(new Zr(c,b.from,O));let S=b.direction==Fs!=!(O%2);tO(n,S?i+1:i,r,b.inner,b.from,b.to,o),c=b.to}v=b.to}else{if(v==t||(f?Ge[v]!=a:Ge[v]==a))break;v++}m?eO(n,c,v,i+1,r,m,o):ce;){let f=!0,p=!1;if(!h||c>s[h-1].to){let b=Ge[c-1];b!=a&&(f=!1,p=b==16)}let m=!f&&a==1?[]:null,O=f?i:i+1,v=c;e:for(;;)if(h&&v==s[h-1].to){if(p)break e;let b=s[--h];if(!f)for(let S=b.from,w=h;;){if(S==e)break e;if(w&&s[w-1].to==S)S=s[--w].from;else{if(Ge[S-1]==a)break e;break}}if(m)m.push(b);else{b.toGe.length;)Ge[Ge.length]=256;let i=[],r=e==Fs?0:1;return tO(n,r,r,t,0,n.length,i),i}function FP(n){return[new Zr(0,n,0)]}let YP="";function oA(n,e,t,i,r){var s;let o=i.head-n.from,a=Zr.find(e,o,(s=i.bidiLevel)!==null&&s!==void 0?s:-1,i.assoc),c=e[a],h=c.side(r,t);if(o==h){let m=a+=r?1:-1;if(m<0||m>=e.length)return null;c=e[a=m],o=c.side(!r,t),h=c.side(r,t)}let f=Vt(n.text,o,c.forward(r,t));(fc.to)&&(f=h),YP=n.text.slice(Math.min(o,f),Math.max(o,f));let p=a==(r?e.length-1:0)?null:e[a+(r?1:-1)];return p&&f==h&&p.level+(r?0:1)n.some(e=>e)}),t_=pe.define({combine:n=>n.some(e=>e)}),n_=pe.define();class Zo{constructor(e,t="nearest",i="nearest",r=5,s=5,o=!1){this.range=e,this.y=t,this.x=i,this.yMargin=r,this.xMargin=s,this.isSnapshot=o}map(e){return e.empty?this:new Zo(this.range.map(e),this.y,this.x,this.yMargin,this.xMargin,this.isSnapshot)}clip(e){return this.range.to<=e.doc.length?this:new Zo(V.cursor(e.doc.length),this.y,this.x,this.yMargin,this.xMargin,this.isSnapshot)}}const Xu=$e.define({map:(n,e)=>n.map(e)}),i_=$e.define();function bn(n,e,t){let i=n.facet(GP);i.length?i[0](e):window.onerror&&window.onerror(String(e),t,void 0,void 0,e)||(t?console.error(t+":",e):console.error(e))}const nr=pe.define({combine:n=>n.length?n[0]:!0});let aA=0;const Ro=pe.define({combine(n){return n.filter((e,t)=>{for(let i=0;i{let c=[];return o&&c.push(Va.of(h=>{let f=h.plugin(a);return f?o(f):_e.none})),s&&c.push(s(a)),c})}static fromClass(e,t){return kt.define((i,r)=>new e(i,r),t)}}class Sg{constructor(e){this.spec=e,this.mustUpdate=null,this.value=null}get plugin(){return this.spec&&this.spec.plugin}update(e){if(this.value){if(this.mustUpdate){let t=this.mustUpdate;if(this.mustUpdate=null,this.value.update)try{this.value.update(t)}catch(i){if(bn(t.state,i,"CodeMirror plugin crashed"),this.value.destroy)try{this.value.destroy()}catch{}this.deactivate()}}}else if(this.spec)try{this.value=this.spec.plugin.create(e,this.spec.arg)}catch(t){bn(e.state,t,"CodeMirror plugin crashed"),this.deactivate()}return this}destroy(e){var t;if(!((t=this.value)===null||t===void 0)&&t.destroy)try{this.value.destroy()}catch(i){bn(e.state,i,"CodeMirror plugin crashed")}}deactivate(){this.spec=this.value=null}}const r_=pe.define(),p0=pe.define(),Va=pe.define(),s_=pe.define(),mc=pe.define(),o_=pe.define();function Hv(n,e){let t=n.state.facet(o_);if(!t.length)return t;let i=t.map(s=>s instanceof Function?s(n):s),r=[];return Ie.spans(i,e.from,e.to,{point(){},span(s,o,a,c){let h=s-e.from,f=o-e.from,p=r;for(let m=a.length-1;m>=0;m--,c--){let O=a[m].spec.bidiIsolate,v;if(O==null&&(O=lA(e.text,h,f)),c>0&&p.length&&(v=p[p.length-1]).to==h&&v.direction==O)v.to=f,p=v.inner;else{let b={from:h,to:f,direction:O,inner:[]};p.push(b),p=b.inner}}}}),r}const l_=pe.define();function g0(n){let e=0,t=0,i=0,r=0;for(let s of n.state.facet(l_)){let o=s(n);o&&(o.left!=null&&(e=Math.max(e,o.left)),o.right!=null&&(t=Math.max(t,o.right)),o.top!=null&&(i=Math.max(i,o.top)),o.bottom!=null&&(r=Math.max(r,o.bottom)))}return{left:e,right:t,top:i,bottom:r}}const fa=pe.define();class Fn{constructor(e,t,i,r){this.fromA=e,this.toA=t,this.fromB=i,this.toB=r}join(e){return new Fn(Math.min(this.fromA,e.fromA),Math.max(this.toA,e.toA),Math.min(this.fromB,e.fromB),Math.max(this.toB,e.toB))}addToSet(e){let t=e.length,i=this;for(;t>0;t--){let r=e[t-1];if(!(r.fromA>i.toA)){if(r.toAf)break;s+=2}if(!c)return i;new Fn(c.fromA,c.toA,c.fromB,c.toB).addToSet(i),o=c.toA,a=c.toB}}}class of{constructor(e,t,i){this.view=e,this.state=t,this.transactions=i,this.flags=0,this.startState=e.state,this.changes=Ct.empty(this.startState.doc.length);for(let s of i)this.changes=this.changes.compose(s.changes);let r=[];this.changes.iterChangedRanges((s,o,a,c)=>r.push(new Fn(s,o,a,c))),this.changedRanges=r}static create(e,t,i){return new of(e,t,i)}get viewportChanged(){return(this.flags&4)>0}get viewportMoved(){return(this.flags&8)>0}get heightChanged(){return(this.flags&2)>0}get geometryChanged(){return this.docChanged||(this.flags&18)>0}get focusChanged(){return(this.flags&1)>0}get docChanged(){return!this.changes.empty}get selectionSet(){return this.transactions.some(e=>e.selection)}get empty(){return this.flags==0&&this.transactions.length==0}}class Gv extends Ue{get length(){return this.view.state.doc.length}constructor(e){super(),this.view=e,this.decorations=[],this.dynamicDecorationMap=[!1],this.domChanged=null,this.hasComposition=null,this.markedForComposition=new Set,this.editContextFormatting=_e.none,this.lastCompositionAfterCursor=!1,this.minWidth=0,this.minWidthFrom=0,this.minWidthTo=0,this.impreciseAnchor=null,this.impreciseHead=null,this.forceSelection=!1,this.lastUpdate=Date.now(),this.setDOM(e.contentDOM),this.children=[new xt],this.children[0].setParent(this),this.updateDeco(),this.updateInner([new Fn(0,0,0,e.state.doc.length)],0,null)}update(e){var t;let i=e.changedRanges;this.minWidth>0&&i.length&&(i.every(({fromA:h,toA:f})=>fthis.minWidthTo)?(this.minWidthFrom=e.changes.mapPos(this.minWidthFrom,1),this.minWidthTo=e.changes.mapPos(this.minWidthTo,1)):this.minWidth=this.minWidthFrom=this.minWidthTo=0),this.updateEditContextFormatting(e);let r=-1;this.view.inputState.composing>=0&&!this.view.observer.editContext&&(!((t=this.domChanged)===null||t===void 0)&&t.newSel?r=this.domChanged.newSel.head:!gA(e.changes,this.hasComposition)&&!e.selectionSet&&(r=e.state.selection.main.head));let s=r>-1?uA(this.view,e.changes,r):null;if(this.domChanged=null,this.hasComposition){this.markedForComposition.clear();let{from:h,to:f}=this.hasComposition;i=new Fn(h,f,e.changes.mapPos(h,-1),e.changes.mapPos(f,1)).addToSet(i.slice())}this.hasComposition=s?{from:s.range.fromB,to:s.range.toB}:null,(he.ie||he.chrome)&&!s&&e&&e.state.doc.lines!=e.startState.doc.lines&&(this.forceSelection=!0);let o=this.decorations,a=this.updateDeco(),c=dA(o,a,e.changes);return i=Fn.extendWithRanges(i,c),!(this.flags&7)&&i.length==0?!1:(this.updateInner(i,e.startState.doc.length,s),e.transactions.length&&(this.lastUpdate=Date.now()),!0)}updateInner(e,t,i){this.view.viewState.mustMeasureContent=!0,this.updateChildren(e,t,i);let{observer:r}=this.view;r.ignore(()=>{this.dom.style.height=this.view.viewState.contentHeight/this.view.scaleY+"px",this.dom.style.flexBasis=this.minWidth?this.minWidth+"px":"";let o=he.chrome||he.ios?{node:r.selectionRange.focusNode,written:!1}:void 0;this.sync(this.view,o),this.flags&=-8,o&&(o.written||r.selectionRange.focusNode!=o.node)&&(this.forceSelection=!0),this.dom.style.height=""}),this.markedForComposition.forEach(o=>o.flags&=-9);let s=[];if(this.view.viewport.from||this.view.viewport.to=0?r[o]:null;if(!a)break;let{fromA:c,toA:h,fromB:f,toB:p}=a,m,O,v,b;if(i&&i.range.fromBf){let _=_a.build(this.view.state.doc,f,i.range.fromB,this.decorations,this.dynamicDecorationMap),C=_a.build(this.view.state.doc,i.range.toB,p,this.decorations,this.dynamicDecorationMap);O=_.breakAtStart,v=_.openStart,b=C.openEnd;let M=this.compositionView(i);C.breakAtStart?M.breakAfter=1:C.content.length&&M.merge(M.length,M.length,C.content[0],!1,C.openStart,0)&&(M.breakAfter=C.content[0].breakAfter,C.content.shift()),_.content.length&&M.merge(0,0,_.content[_.content.length-1],!0,0,_.openEnd)&&_.content.pop(),m=_.content.concat(M).concat(C.content)}else({content:m,breakAtStart:O,openStart:v,openEnd:b}=_a.build(this.view.state.doc,f,p,this.decorations,this.dynamicDecorationMap));let{i:S,off:w}=s.findPos(h,1),{i:T,off:k}=s.findPos(c,-1);zP(this,T,k,S,w,m,O,v,b)}i&&this.fixCompositionDOM(i)}updateEditContextFormatting(e){this.editContextFormatting=this.editContextFormatting.map(e.changes);for(let t of e.transactions)for(let i of t.effects)i.is(i_)&&(this.editContextFormatting=i.value)}compositionView(e){let t=new ui(e.text.nodeValue);t.flags|=8;for(let{deco:r}of e.marks)t=new lr(r,[t],t.length);let i=new xt;return i.append(t,0),i}fixCompositionDOM(e){let t=(s,o)=>{o.flags|=8|(o.children.some(c=>c.flags&7)?1:0),this.markedForComposition.add(o);let a=Ue.get(s);a&&a!=o&&(a.dom=null),o.setDOM(s)},i=this.childPos(e.range.fromB,1),r=this.children[i.i];t(e.line,r);for(let s=e.marks.length-1;s>=-1;s--)i=r.childPos(i.off,1),r=r.children[i.i],t(s>=0?e.marks[s].node:e.text,r)}updateSelection(e=!1,t=!1){(e||!this.view.observer.selectionRange.focusNode)&&this.view.observer.readSelectionRange();let i=this.view.root.activeElement,r=i==this.dom,s=!r&&!(this.view.state.facet(nr)||this.dom.tabIndex>-1)&&Rh(this.dom,this.view.observer.selectionRange)&&!(i&&this.dom.contains(i));if(!(r||t||s))return;let o=this.forceSelection;this.forceSelection=!1;let a=this.view.state.selection.main,c=this.moveToLine(this.domAtPos(a.anchor)),h=a.empty?c:this.moveToLine(this.domAtPos(a.head));if(he.gecko&&a.empty&&!this.hasComposition&&cA(c)){let p=document.createTextNode("");this.view.observer.ignore(()=>c.node.insertBefore(p,c.node.childNodes[c.offset]||null)),c=h=new Jt(p,0),o=!0}let f=this.view.observer.selectionRange;(o||!f.focusNode||(!Pa(c.node,c.offset,f.anchorNode,f.anchorOffset)||!Pa(h.node,h.offset,f.focusNode,f.focusOffset))&&!this.suppressWidgetCursorChange(f,a))&&(this.view.observer.ignore(()=>{he.android&&he.chrome&&this.dom.contains(f.focusNode)&&pA(f.focusNode,this.dom)&&(this.dom.blur(),this.dom.focus({preventScroll:!0}));let p=Xa(this.view.root);if(p)if(a.empty){if(he.gecko){let m=hA(c.node,c.offset);if(m&&m!=3){let O=(m==1?EP:LP)(c.node,c.offset);O&&(c=new Jt(O.node,O.offset))}}p.collapse(c.node,c.offset),a.bidiLevel!=null&&p.caretBidiLevel!==void 0&&(p.caretBidiLevel=a.bidiLevel)}else if(p.extend){p.collapse(c.node,c.offset);try{p.extend(h.node,h.offset)}catch{}}else{let m=document.createRange();a.anchor>a.head&&([c,h]=[h,c]),m.setEnd(h.node,h.offset),m.setStart(c.node,c.offset),p.removeAllRanges(),p.addRange(m)}s&&this.view.root.activeElement==this.dom&&(this.dom.blur(),i&&i.focus())}),this.view.observer.setSelectionRange(c,h)),this.impreciseAnchor=c.precise?null:new Jt(f.anchorNode,f.anchorOffset),this.impreciseHead=h.precise?null:new Jt(f.focusNode,f.focusOffset)}suppressWidgetCursorChange(e,t){return this.hasComposition&&t.empty&&Pa(e.focusNode,e.focusOffset,e.anchorNode,e.anchorOffset)&&this.posFromDOM(e.focusNode,e.focusOffset)==t.head}enforceCursorAssoc(){if(this.hasComposition)return;let{view:e}=this,t=e.state.selection.main,i=Xa(e.root),{anchorNode:r,anchorOffset:s}=e.observer.selectionRange;if(!i||!t.empty||!t.assoc||!i.modify)return;let o=xt.find(this,t.head);if(!o)return;let a=o.posAtStart;if(t.head==a||t.head==a+o.length)return;let c=this.coordsAt(t.head,-1),h=this.coordsAt(t.head,1);if(!c||!h||c.bottom>h.top)return;let f=this.domAtPos(t.head+t.assoc);i.collapse(f.node,f.offset),i.modify("move",t.assoc<0?"forward":"backward","lineboundary"),e.observer.readSelectionRange();let p=e.observer.selectionRange;e.docView.posFromDOM(p.anchorNode,p.anchorOffset)!=t.from&&i.collapse(r,s)}moveToLine(e){let t=this.dom,i;if(e.node!=t)return e;for(let r=e.offset;!i&&r=0;r--){let s=Ue.get(t.childNodes[r]);s instanceof xt&&(i=s.domAtPos(s.length))}return i?new Jt(i.node,i.offset,!0):e}nearest(e){for(let t=e;t;){let i=Ue.get(t);if(i&&i.rootView==this)return i;t=t.parentNode}return null}posFromDOM(e,t){let i=this.nearest(e);if(!i)throw new RangeError("Trying to find position for a DOM position outside of the document");return i.localPosFromDOM(e,t)+i.posAtStart}domAtPos(e){let{i:t,off:i}=this.childCursor().findPos(e,-1);for(;t=0;o--){let a=this.children[o],c=s-a.breakAfter,h=c-a.length;if(ce||a.covers(1))&&(!i||a instanceof xt&&!(i instanceof xt&&t>=0)))i=a,r=h;else if(i&&h==e&&c==e&&a instanceof sr&&Math.abs(t)<2){if(a.deco.startSide<0)break;o&&(i=null)}s=h}return i?i.coordsAt(e-r,t):null}coordsForChar(e){let{i:t,off:i}=this.childPos(e,1),r=this.children[t];if(!(r instanceof xt))return null;for(;r.children.length;){let{i:a,off:c}=r.childPos(i,1);for(;;a++){if(a==r.children.length)return null;if((r=r.children[a]).length)break}i=c}if(!(r instanceof ui))return null;let s=Vt(r.text,i);if(s==i)return null;let o=Vs(r.dom,i,s).getClientRects();for(let a=0;aMath.max(this.view.scrollDOM.clientWidth,this.minWidth)+1,a=-1,c=this.view.textDirection==st.LTR;for(let h=0,f=0;fr)break;if(h>=i){let O=p.dom.getBoundingClientRect();if(t.push(O.height),o){let v=p.dom.lastChild,b=v?Wa(v):[];if(b.length){let S=b[b.length-1],w=c?S.right-O.left:O.right-S.left;w>a&&(a=w,this.minWidth=s,this.minWidthFrom=h,this.minWidthTo=m)}}}h=m+p.breakAfter}return t}textDirectionAt(e){let{i:t}=this.childPos(e,1);return getComputedStyle(this.children[t].dom).direction=="rtl"?st.RTL:st.LTR}measureTextSize(){for(let s of this.children)if(s instanceof xt){let o=s.measureTextSize();if(o)return o}let e=document.createElement("div"),t,i,r;return e.className="cm-line",e.style.width="99999px",e.style.position="absolute",e.textContent="abc def ghi jkl mno pqr stu",this.view.observer.ignore(()=>{this.dom.appendChild(e);let s=Wa(e.firstChild)[0];t=e.getBoundingClientRect().height,i=s?s.width/27:7,r=s?s.height:t,e.remove()}),{lineHeight:t,charWidth:i,textHeight:r}}childCursor(e=this.length){let t=this.children.length;return t&&(e-=this.children[--t].length),new DP(this.children,e,t)}computeBlockGapDeco(){let e=[],t=this.view.viewState;for(let i=0,r=0;;r++){let s=r==t.viewports.length?null:t.viewports[r],o=s?s.from-1:this.length;if(o>i){let a=(t.lineBlockAt(o).bottom-t.lineBlockAt(i).top)/this.view.scaleY;e.push(_e.replace({widget:new Km(a),block:!0,inclusive:!0,isBlockGap:!0}).range(i,o))}if(!s)break;i=s.to+1}return _e.set(e)}updateDeco(){let e=1,t=this.view.state.facet(Va).map(s=>(this.dynamicDecorationMap[e++]=typeof s=="function")?s(this.view):s),i=!1,r=this.view.state.facet(s_).map((s,o)=>{let a=typeof s=="function";return a&&(i=!0),a?s(this.view):s});for(r.length&&(this.dynamicDecorationMap[e++]=i,t.push(Ie.join(r))),this.decorations=[this.editContextFormatting,...t,this.computeBlockGapDeco(),this.view.viewState.lineGapDeco];et.anchor?-1:1),r;if(!i)return;!t.empty&&(r=this.coordsAt(t.anchor,t.anchor>t.head?-1:1))&&(i={left:Math.min(i.left,r.left),top:Math.min(i.top,r.top),right:Math.max(i.right,r.right),bottom:Math.max(i.bottom,r.bottom)});let s=g0(this.view),o={left:i.left-s.left,top:i.top-s.top,right:i.right+s.right,bottom:i.bottom+s.bottom},{offsetWidth:a,offsetHeight:c}=this.view.scrollDOM;BR(this.view.scrollDOM,o,t.head{ie.from&&(t=!0)}),t}function mA(n,e,t=1){let i=n.charCategorizer(e),r=n.doc.lineAt(e),s=e-r.from;if(r.length==0)return V.cursor(e);s==0?t=1:s==r.length&&(t=-1);let o=s,a=s;t<0?o=Vt(r.text,s,!1):a=Vt(r.text,s);let c=i(r.text.slice(o,a));for(;o>0;){let h=Vt(r.text,o,!1);if(i(r.text.slice(h,o))!=c)break;o=h}for(;an?e.left-n:Math.max(0,n-e.right)}function yA(n,e){return e.top>n?e.top-n:Math.max(0,n-e.bottom)}function wg(n,e){return n.tope.top+1}function Kv(n,e){return en.bottom?{top:n.top,left:n.left,right:n.right,bottom:e}:n}function iO(n,e,t){let i,r,s,o,a=!1,c,h,f,p;for(let v=n.firstChild;v;v=v.nextSibling){let b=Wa(v);for(let S=0;Sk||o==k&&s>T)&&(i=v,r=w,s=T,o=k,a=T?e0:Sw.bottom&&(!f||f.bottomw.top)&&(h=v,p=w):f&&wg(f,w)?f=Jv(f,w.bottom):p&&wg(p,w)&&(p=Kv(p,w.top))}}if(f&&f.bottom>=t?(i=c,r=f):p&&p.top<=t&&(i=h,r=p),!i)return{node:n,offset:0};let m=Math.max(r.left,Math.min(r.right,e));if(i.nodeType==3)return eb(i,m,t);if(a&&i.contentEditable!="false")return iO(i,m,t);let O=Array.prototype.indexOf.call(n.childNodes,i)+(e>=(r.left+r.right)/2?1:0);return{node:n,offset:O}}function eb(n,e,t){let i=n.nodeValue.length,r=-1,s=1e9,o=0;for(let a=0;at?f.top-t:t-f.bottom)-1;if(f.left-1<=e&&f.right+1>=e&&p=(f.left+f.right)/2,O=m;if((he.chrome||he.gecko)&&Vs(n,a).getBoundingClientRect().left==f.right&&(O=!m),p<=0)return{node:n,offset:a+(O?1:0)};r=a+(O?1:0),s=p}}}return{node:n,offset:r>-1?r:o>0?n.nodeValue.length:0}}function c_(n,e,t,i=-1){var r,s;let o=n.contentDOM.getBoundingClientRect(),a=o.top+n.viewState.paddingTop,c,{docHeight:h}=n.viewState,{x:f,y:p}=e,m=p-a;if(m<0)return 0;if(m>h)return n.state.doc.length;for(let _=n.viewState.heightOracle.textHeight/2,C=!1;c=n.elementAtHeight(m),c.type!=an.Text;)for(;m=i>0?c.bottom+_:c.top-_,!(m>=0&&m<=h);){if(C)return t?null:0;C=!0,i=-i}p=a+m;let O=c.from;if(On.viewport.to)return n.viewport.to==n.state.doc.length?n.state.doc.length:t?null:tb(n,o,c,f,p);let v=n.dom.ownerDocument,b=n.root.elementFromPoint?n.root:v,S=b.elementFromPoint(f,p);S&&!n.contentDOM.contains(S)&&(S=null),S||(f=Math.max(o.left+1,Math.min(o.right-1,f)),S=b.elementFromPoint(f,p),S&&!n.contentDOM.contains(S)&&(S=null));let w,T=-1;if(S&&((r=n.docView.nearest(S))===null||r===void 0?void 0:r.isEditable)!=!1){if(v.caretPositionFromPoint){let _=v.caretPositionFromPoint(f,p);_&&({offsetNode:w,offset:T}=_)}else if(v.caretRangeFromPoint){let _=v.caretRangeFromPoint(f,p);_&&({startContainer:w,startOffset:T}=_)}w&&(!n.contentDOM.contains(w)||he.safari&&xA(w,T,f)||he.chrome&&vA(w,T,f))&&(w=void 0),w&&(T=Math.min(ji(w),T))}if(!w||!n.docView.dom.contains(w)){let _=xt.find(n.docView,O);if(!_)return m>c.top+c.height/2?c.to:c.from;({node:w,offset:T}=iO(_.dom,f,p))}let k=n.docView.nearest(w);if(!k)return null;if(k.isWidget&&((s=k.dom)===null||s===void 0?void 0:s.nodeType)==1){let _=k.dom.getBoundingClientRect();return e.y<_.top||e.y<=_.bottom&&e.x<=(_.left+_.right)/2?k.posAtStart:k.posAtEnd}else return k.localPosFromDOM(w,T)+k.posAtStart}function tb(n,e,t,i,r){let s=Math.round((i-e.left)*n.defaultCharacterWidth);if(n.lineWrapping&&t.height>n.defaultLineHeight*1.5){let a=n.viewState.heightOracle.textHeight,c=Math.floor((r-t.top-(n.defaultLineHeight-a)*.5)/a);s+=c*n.viewState.heightOracle.lineLength}let o=n.state.sliceDoc(t.from,t.to);return t.from+Bm(o,s,n.state.tabSize)}function u_(n,e,t){let i,r=n;if(n.nodeType!=3||e!=(i=n.nodeValue.length))return!1;for(;;){let s=r.nextSibling;if(s){if(s.nodeName=="BR")break;return!1}else{let o=r.parentNode;if(!o||o.nodeName=="DIV")break;r=o}}return Vs(n,i-1,i).getBoundingClientRect().right>t}function xA(n,e,t){return u_(n,e,t)}function vA(n,e,t){if(e!=0)return u_(n,e,t);for(let r=n;;){let s=r.parentNode;if(!s||s.nodeType!=1||s.firstChild!=r)return!1;if(s.classList.contains("cm-line"))break;r=s}let i=n.nodeType==1?n.getBoundingClientRect():Vs(n,0,Math.max(n.nodeValue.length,1)).getBoundingClientRect();return t-i.left>5}function rO(n,e,t){let i=n.lineBlockAt(e);if(Array.isArray(i.type)){let r;for(let s of i.type){if(s.from>e)break;if(!(s.toe)return s;(!r||s.type==an.Text&&(r.type!=s.type||(t<0?s.frome)))&&(r=s)}}return r||i}return i}function bA(n,e,t,i){let r=rO(n,e.head,e.assoc||-1),s=!i||r.type!=an.Text||!(n.lineWrapping||r.widgetLineBreaks)?null:n.coordsAtPos(e.assoc<0&&e.head>r.from?e.head-1:e.head);if(s){let o=n.dom.getBoundingClientRect(),a=n.textDirectionAt(r.from),c=n.posAtCoords({x:t==(a==st.LTR)?o.right-1:o.left+1,y:(s.top+s.bottom)/2});if(c!=null)return V.cursor(c,t?-1:1)}return V.cursor(t?r.to:r.from,t?-1:1)}function nb(n,e,t,i){let r=n.state.doc.lineAt(e.head),s=n.bidiSpans(r),o=n.textDirectionAt(r.from);for(let a=e,c=null;;){let h=oA(r,s,o,a,t),f=YP;if(!h){if(r.number==(t?n.state.doc.lines:1))return a;f=` +`,r=n.state.doc.line(r.number+(t?1:-1)),s=n.bidiSpans(r),h=n.visualLineSide(r,!t)}if(c){if(!c(f))return a}else{if(!i)return h;c=i(f)}a=h}}function SA(n,e,t){let i=n.state.charCategorizer(e),r=i(t);return s=>{let o=i(s);return r==lt.Space&&(r=o),r==o}}function wA(n,e,t,i){let r=e.head,s=t?1:-1;if(r==(t?n.state.doc.length:0))return V.cursor(r,e.assoc);let o=e.goalColumn,a,c=n.contentDOM.getBoundingClientRect(),h=n.coordsAtPos(r,e.assoc||-1),f=n.documentTop;if(h)o==null&&(o=h.left-c.left),a=s<0?h.top:h.bottom;else{let O=n.viewState.lineBlockAt(r);o==null&&(o=Math.min(c.right-c.left,n.defaultCharacterWidth*(r-O.from))),a=(s<0?O.top:O.bottom)+f}let p=c.left+o,m=i!=null?i:n.viewState.heightOracle.textHeight>>1;for(let O=0;;O+=10){let v=a+(m+O)*s,b=c_(n,{x:p,y:v},!1,s);if(vc.bottom||(s<0?br)){let S=n.docView.coordsForChar(b),w=!S||v{if(e>s&&er(n)),t.from,e.head>t.from?-1:1);return i==t.from?t:V.cursor(i,is)&&!_A(o,t)&&this.lineBreak(),r=o}return this.findPointBefore(i,t),this}readTextNode(e){let t=e.nodeValue;for(let i of this.points)i.node==e&&(i.pos=this.text.length+Math.min(i.offset,t.length));for(let i=0,r=this.lineSeparator?null:/\r\n?|\n/g;;){let s=-1,o=1,a;if(this.lineSeparator?(s=t.indexOf(this.lineSeparator,i),o=this.lineSeparator.length):(a=r.exec(t))&&(s=a.index,o=a[0].length),this.append(t.slice(i,s<0?t.length:s)),s<0)break;if(this.lineBreak(),o>1)for(let c of this.points)c.node==e&&c.pos>this.text.length&&(c.pos-=o-1);i=s+o}}readNode(e){if(e.cmIgnore)return;let t=Ue.get(e),i=t&&t.overrideDOMText;if(i!=null){this.findPointInside(e,i.length);for(let r=i.iter();!r.next().done;)r.lineBreak?this.lineBreak():this.append(r.value)}else e.nodeType==3?this.readTextNode(e):e.nodeName=="BR"?e.nextSibling&&this.lineBreak():e.nodeType==1&&this.readRange(e.firstChild,null)}findPointBefore(e,t){for(let i of this.points)i.node==e&&e.childNodes[i.offset]==t&&(i.pos=this.text.length)}findPointInside(e,t){for(let i of this.points)(e.nodeType==3?i.node==e:e.contains(i.node))&&(i.pos=this.text.length+(PA(e,i.node,i.offset)?t:0))}}function PA(n,e,t){for(;;){if(!e||t-1;let{impreciseHead:s,impreciseAnchor:o}=e.docView;if(e.state.readOnly&&t>-1)this.newSel=null;else if(t>-1&&(this.bounds=e.docView.domBoundsAround(t,i,0))){let a=s||o?[]:TA(e),c=new kA(a,e.state);c.readRange(this.bounds.startDOM,this.bounds.endDOM),this.text=c.text,this.newSel=$A(a,this.bounds.from)}else{let a=e.observer.selectionRange,c=s&&s.node==a.focusNode&&s.offset==a.focusOffset||!Um(e.contentDOM,a.focusNode)?e.state.selection.main.head:e.docView.posFromDOM(a.focusNode,a.focusOffset),h=o&&o.node==a.anchorNode&&o.offset==a.anchorOffset||!Um(e.contentDOM,a.anchorNode)?e.state.selection.main.anchor:e.docView.posFromDOM(a.anchorNode,a.anchorOffset),f=e.viewport;if((he.ios||he.chrome)&&e.state.selection.main.empty&&c!=h&&(f.from>0||f.toDate.now()-100?n.inputState.lastKeyCode:-1;if(e.bounds){let{from:o,to:a}=e.bounds,c=r.from,h=null;(s===8||he.android&&e.text.length=r.from&&t.to<=r.to&&(t.from!=r.from||t.to!=r.to)&&r.to-r.from-(t.to-t.from)<=4?t={from:r.from,to:r.to,insert:n.state.doc.slice(r.from,t.from).append(t.insert).append(n.state.doc.slice(t.to,r.to))}:he.chrome&&t&&t.from==t.to&&t.from==r.head&&t.insert.toString()==` + `&&n.lineWrapping&&(i&&(i=V.single(i.main.anchor-1,i.main.head-1)),t={from:r.from,to:r.to,insert:ze.of([" "])}),t)return m0(n,t,i,s);if(i&&!i.main.eq(r)){let o=!1,a="select";return n.inputState.lastSelectionTime>Date.now()-50&&(n.inputState.lastSelectionOrigin=="select"&&(o=!0),a=n.inputState.lastSelectionOrigin,a=="select.pointer"&&(i=h_(n.state.facet(mc).map(c=>c(n)),i))),n.dispatch({selection:i,scrollIntoView:o,userEvent:a}),!0}else return!1}function m0(n,e,t,i=-1){if(he.ios&&n.inputState.flushIOSKey(e))return!0;let r=n.state.selection.main;if(he.android&&(e.to==r.to&&(e.from==r.from||e.from==r.from-1&&n.state.sliceDoc(e.from,r.from)==" ")&&e.insert.length==1&&e.insert.lines==2&&jo(n.contentDOM,"Enter",13)||(e.from==r.from-1&&e.to==r.to&&e.insert.length==0||i==8&&e.insert.lengthr.head)&&jo(n.contentDOM,"Backspace",8)||e.from==r.from&&e.to==r.to+1&&e.insert.length==0&&jo(n.contentDOM,"Delete",46)))return!0;let s=e.insert.toString();n.inputState.composing>=0&&n.inputState.composing++;let o,a=()=>o||(o=CA(n,e,t));return n.state.facet(KP).some(c=>c(n,e.from,e.to,s,a))||n.dispatch(a()),!0}function CA(n,e,t){let i,r=n.state,s=r.selection.main,o=-1;if(e.from==e.to&&e.froms.to){let c=e.fromp(n)),h,c);e.from==f&&(o=f)}if(o>-1)i={changes:e,selection:V.cursor(e.from+e.insert.length,-1)};else if(e.from>=s.from&&e.to<=s.to&&e.to-e.from>=(s.to-s.from)/3&&(!t||t.main.empty&&t.main.from==e.from+e.insert.length)&&n.inputState.composing<0){let c=s.frome.to?r.sliceDoc(e.to,s.to):"";i=r.replaceSelection(n.state.toText(c+e.insert.sliceString(0,void 0,n.state.lineBreak)+h))}else{let c=r.changes(e),h=t&&t.main.to<=c.newLength?t.main:void 0;if(r.selection.ranges.length>1&&n.inputState.composing>=0&&e.to<=s.to&&e.to>=s.to-10){let f=n.state.sliceDoc(e.from,e.to),p,m=t&&a_(n,t.main.head);if(m){let b=e.insert.length-(e.to-e.from);p={from:m.from,to:m.to-b}}else p=n.state.doc.lineAt(s.head);let O=s.to-e.to,v=s.to-s.from;i=r.changeByRange(b=>{if(b.from==s.from&&b.to==s.to)return{changes:c,range:h||b.map(c)};let S=b.to-O,w=S-f.length;if(b.to-b.from!=v||n.state.sliceDoc(w,S)!=f||b.to>=p.from&&b.from<=p.to)return{range:b};let T=r.changes({from:w,to:S,insert:e.insert}),k=b.to-s.to;return{changes:T,range:h?V.range(Math.max(0,h.anchor+k),Math.max(0,h.head+k)):b.map(T)}})}else i={changes:c,selection:h&&r.selection.replaceRange(h)}}let a="input.type";return(n.composing||n.inputState.compositionPendingChange&&n.inputState.compositionEndedAt>Date.now()-50)&&(n.inputState.compositionPendingChange=!1,a+=".compose",n.inputState.compositionFirstChange&&(a+=".start",n.inputState.compositionFirstChange=!1)),r.update(i,{userEvent:a,scrollIntoView:!0})}function d_(n,e,t,i){let r=Math.min(n.length,e.length),s=0;for(;s0&&a>0&&n.charCodeAt(o-1)==e.charCodeAt(a-1);)o--,a--;if(i=="end"){let c=Math.max(0,s-Math.min(o,a));t-=o+c-s}if(o=o?s-t:0;s-=c,a=s+(a-o),o=s}else if(a=a?s-t:0;s-=c,o=s+(o-a),a=s}return{from:s,toA:o,toB:a}}function TA(n){let e=[];if(n.root.activeElement!=n.contentDOM)return e;let{anchorNode:t,anchorOffset:i,focusNode:r,focusOffset:s}=n.observer.selectionRange;return t&&(e.push(new ib(t,i)),(r!=t||s!=i)&&e.push(new ib(r,s))),e}function $A(n,e){if(n.length==0)return null;let t=n[0].pos,i=n.length==2?n[1].pos:t;return t>-1&&i>-1?V.single(t+e,i+e):null}class MA{setSelectionOrigin(e){this.lastSelectionOrigin=e,this.lastSelectionTime=Date.now()}constructor(e){this.view=e,this.lastKeyCode=0,this.lastKeyTime=0,this.lastTouchTime=0,this.lastFocusTime=0,this.lastScrollTop=0,this.lastScrollLeft=0,this.pendingIOSKey=void 0,this.tabFocusMode=-1,this.lastSelectionOrigin=null,this.lastSelectionTime=0,this.lastContextMenu=0,this.scrollHandlers=[],this.handlers=Object.create(null),this.composing=-1,this.compositionFirstChange=null,this.compositionEndedAt=0,this.compositionPendingKey=!1,this.compositionPendingChange=!1,this.mouseSelection=null,this.draggedContent=null,this.handleEvent=this.handleEvent.bind(this),this.notifiedFocused=e.hasFocus,he.safari&&e.contentDOM.addEventListener("input",()=>null),he.gecko&&YA(e.contentDOM.ownerDocument)}handleEvent(e){!ZA(this.view,e)||this.ignoreDuringComposition(e)||e.type=="keydown"&&this.keydown(e)||(this.view.updateState!=0?Promise.resolve().then(()=>this.runHandlers(e.type,e)):this.runHandlers(e.type,e))}runHandlers(e,t){let i=this.handlers[e];if(i){for(let r of i.observers)r(this.view,t);for(let r of i.handlers){if(t.defaultPrevented)break;if(r(this.view,t)){t.preventDefault();break}}}}ensureHandlers(e){let t=RA(e),i=this.handlers,r=this.view.contentDOM;for(let s in t)if(s!="scroll"){let o=!t[s].handlers.length,a=i[s];a&&o!=!a.handlers.length&&(r.removeEventListener(s,this.handleEvent),a=null),a||r.addEventListener(s,this.handleEvent,{passive:o})}for(let s in i)s!="scroll"&&!t[s]&&r.removeEventListener(s,this.handleEvent);this.handlers=t}keydown(e){if(this.lastKeyCode=e.keyCode,this.lastKeyTime=Date.now(),e.keyCode==9&&this.tabFocusMode>-1&&(!this.tabFocusMode||Date.now()<=this.tabFocusMode))return!0;if(this.tabFocusMode>0&&e.keyCode!=27&&g_.indexOf(e.keyCode)<0&&(this.tabFocusMode=-1),he.android&&he.chrome&&!e.synthetic&&(e.keyCode==13||e.keyCode==8))return this.view.observer.delayAndroidKey(e.key,e.keyCode),!0;let t;return he.ios&&!e.synthetic&&!e.altKey&&!e.metaKey&&((t=p_.find(i=>i.keyCode==e.keyCode))&&!e.ctrlKey||AA.indexOf(e.key)>-1&&e.ctrlKey&&!e.shiftKey)?(this.pendingIOSKey=t||e,setTimeout(()=>this.flushIOSKey(),250),!0):(e.keyCode!=229&&this.view.observer.forceFlush(),!1)}flushIOSKey(e){let t=this.pendingIOSKey;return!t||t.key=="Enter"&&e&&e.from0?!0:he.safari&&!he.ios&&this.compositionPendingKey&&Date.now()-this.compositionEndedAt<100?(this.compositionPendingKey=!1,!0):!1}startMouseSelection(e){this.mouseSelection&&this.mouseSelection.destroy(),this.mouseSelection=e}update(e){this.view.observer.update(e),this.mouseSelection&&this.mouseSelection.update(e),this.draggedContent&&e.docChanged&&(this.draggedContent=this.draggedContent.map(e.changes)),e.transactions.length&&(this.lastKeyCode=this.lastSelectionTime=0)}destroy(){this.mouseSelection&&this.mouseSelection.destroy()}}function rb(n,e){return(t,i)=>{try{return e.call(n,i,t)}catch(r){bn(t.state,r)}}}function RA(n){let e=Object.create(null);function t(i){return e[i]||(e[i]={observers:[],handlers:[]})}for(let i of n){let r=i.spec,s=r&&r.plugin.domEventHandlers,o=r&&r.plugin.domEventObservers;if(s)for(let a in s){let c=s[a];c&&t(a).handlers.push(rb(i.value,c))}if(o)for(let a in o){let c=o[a];c&&t(a).observers.push(rb(i.value,c))}}for(let i in hi)t(i).handlers.push(hi[i]);for(let i in qn)t(i).observers.push(qn[i]);return e}const p_=[{key:"Backspace",keyCode:8,inputType:"deleteContentBackward"},{key:"Enter",keyCode:13,inputType:"insertParagraph"},{key:"Enter",keyCode:13,inputType:"insertLineBreak"},{key:"Delete",keyCode:46,inputType:"deleteContentForward"}],AA="dthko",g_=[16,17,18,20,91,92,224,225],Wu=6;function Vu(n){return Math.max(0,n)*.7+8}function EA(n,e){return Math.max(Math.abs(n.clientX-e.clientX),Math.abs(n.clientY-e.clientY))}class LA{constructor(e,t,i,r){this.view=e,this.startEvent=t,this.style=i,this.mustSelect=r,this.scrollSpeed={x:0,y:0},this.scrolling=-1,this.lastEvent=t,this.scrollParents=XR(e.contentDOM),this.atoms=e.state.facet(mc).map(o=>o(e));let s=e.contentDOM.ownerDocument;s.addEventListener("mousemove",this.move=this.move.bind(this)),s.addEventListener("mouseup",this.up=this.up.bind(this)),this.extend=t.shiftKey,this.multiple=e.state.facet(Ze.allowMultipleSelections)&&DA(e,t),this.dragging=jA(e,t)&&y_(t)==1?null:!1}start(e){this.dragging===!1&&this.select(e)}move(e){if(e.buttons==0)return this.destroy();if(this.dragging||this.dragging==null&&EA(this.startEvent,e)<10)return;this.select(this.lastEvent=e);let t=0,i=0,r=0,s=0,o=this.view.win.innerWidth,a=this.view.win.innerHeight;this.scrollParents.x&&({left:r,right:o}=this.scrollParents.x.getBoundingClientRect()),this.scrollParents.y&&({top:s,bottom:a}=this.scrollParents.y.getBoundingClientRect());let c=g0(this.view);e.clientX-c.left<=r+Wu?t=-Vu(r-e.clientX):e.clientX+c.right>=o-Wu&&(t=Vu(e.clientX-o)),e.clientY-c.top<=s+Wu?i=-Vu(s-e.clientY):e.clientY+c.bottom>=a-Wu&&(i=Vu(e.clientY-a)),this.setScrollSpeed(t,i)}up(e){this.dragging==null&&this.select(this.lastEvent),this.dragging||e.preventDefault(),this.destroy()}destroy(){this.setScrollSpeed(0,0);let e=this.view.contentDOM.ownerDocument;e.removeEventListener("mousemove",this.move),e.removeEventListener("mouseup",this.up),this.view.inputState.mouseSelection=this.view.inputState.draggedContent=null}setScrollSpeed(e,t){this.scrollSpeed={x:e,y:t},e||t?this.scrolling<0&&(this.scrolling=setInterval(()=>this.scroll(),50)):this.scrolling>-1&&(clearInterval(this.scrolling),this.scrolling=-1)}scroll(){let{x:e,y:t}=this.scrollSpeed;e&&this.scrollParents.x&&(this.scrollParents.x.scrollLeft+=e,e=0),t&&this.scrollParents.y&&(this.scrollParents.y.scrollTop+=t,t=0),(e||t)&&this.view.win.scrollBy(e,t),this.dragging===!1&&this.select(this.lastEvent)}select(e){let{view:t}=this,i=h_(this.atoms,this.style.get(e,this.extend,this.multiple));(this.mustSelect||!i.eq(t.state.selection,this.dragging===!1))&&this.view.dispatch({selection:i,userEvent:"select.pointer"}),this.mustSelect=!1}update(e){e.transactions.some(t=>t.isUserEvent("input.type"))?this.destroy():this.style.update(e)&&setTimeout(()=>this.select(this.lastEvent),20)}}function DA(n,e){let t=n.state.facet(qP);return t.length?t[0](e):he.mac?e.metaKey:e.ctrlKey}function zA(n,e){let t=n.state.facet(UP);return t.length?t[0](e):he.mac?!e.altKey:!e.ctrlKey}function jA(n,e){let{main:t}=n.state.selection;if(t.empty)return!1;let i=Xa(n.root);if(!i||i.rangeCount==0)return!0;let r=i.getRangeAt(0).getClientRects();for(let s=0;s=e.clientX&&o.top<=e.clientY&&o.bottom>=e.clientY)return!0}return!1}function ZA(n,e){if(!e.bubbles)return!0;if(e.defaultPrevented)return!1;for(let t=e.target,i;t!=n.contentDOM;t=t.parentNode)if(!t||t.nodeType==11||(i=Ue.get(t))&&i.ignoreEvent(e))return!1;return!0}const hi=Object.create(null),qn=Object.create(null),m_=he.ie&&he.ie_version<15||he.ios&&he.webkit_version<604;function IA(n){let e=n.dom.parentNode;if(!e)return;let t=e.appendChild(document.createElement("textarea"));t.style.cssText="position: fixed; left: -10000px; top: 10px",t.focus(),setTimeout(()=>{n.focus(),t.remove(),O_(n,t.value)},50)}function Uf(n,e,t){for(let i of n.facet(e))t=i(t,n);return t}function O_(n,e){e=Uf(n.state,f0,e);let{state:t}=n,i,r=1,s=t.toText(e),o=s.lines==t.selection.ranges.length;if(sO!=null&&t.selection.ranges.every(c=>c.empty)&&sO==s.toString()){let c=-1;i=t.changeByRange(h=>{let f=t.doc.lineAt(h.from);if(f.from==c)return{range:h};c=f.from;let p=t.toText((o?s.line(r++).text:e)+t.lineBreak);return{changes:{from:f.from,insert:p},range:V.cursor(h.from+p.length)}})}else o?i=t.changeByRange(c=>{let h=s.line(r++);return{changes:{from:c.from,to:c.to,insert:h.text},range:V.cursor(c.from+h.length)}}):i=t.replaceSelection(s);n.dispatch(i,{userEvent:"input.paste",scrollIntoView:!0})}qn.scroll=n=>{n.inputState.lastScrollTop=n.scrollDOM.scrollTop,n.inputState.lastScrollLeft=n.scrollDOM.scrollLeft};hi.keydown=(n,e)=>(n.inputState.setSelectionOrigin("select"),e.keyCode==27&&n.inputState.tabFocusMode!=0&&(n.inputState.tabFocusMode=Date.now()+2e3),!1);qn.touchstart=(n,e)=>{n.inputState.lastTouchTime=Date.now(),n.inputState.setSelectionOrigin("select.pointer")};qn.touchmove=n=>{n.inputState.setSelectionOrigin("select.pointer")};hi.mousedown=(n,e)=>{if(n.observer.flush(),n.inputState.lastTouchTime>Date.now()-2e3)return!1;let t=null;for(let i of n.state.facet(HP))if(t=i(n,e),t)break;if(!t&&e.button==0&&(t=XA(n,e)),t){let i=!n.hasFocus;n.inputState.startMouseSelection(new LA(n,e,t,i)),i&&n.observer.ignore(()=>{MP(n.contentDOM);let s=n.root.activeElement;s&&!s.contains(n.contentDOM)&&s.blur()});let r=n.inputState.mouseSelection;if(r)return r.start(e),r.dragging===!1}else n.inputState.setSelectionOrigin("select.pointer");return!1};function sb(n,e,t,i){if(i==1)return V.cursor(e,t);if(i==2)return mA(n.state,e,t);{let r=xt.find(n.docView,e),s=n.state.doc.lineAt(r?r.posAtEnd:e),o=r?r.posAtStart:s.from,a=r?r.posAtEnd:s.to;return ae>=t.top&&e<=t.bottom&&n>=t.left&&n<=t.right;function NA(n,e,t,i){let r=xt.find(n.docView,e);if(!r)return 1;let s=e-r.posAtStart;if(s==0)return 1;if(s==r.length)return-1;let o=r.coordsAt(s,-1);if(o&&ob(t,i,o))return-1;let a=r.coordsAt(s,1);return a&&ob(t,i,a)?1:o&&o.bottom>=i?-1:1}function lb(n,e){let t=n.posAtCoords({x:e.clientX,y:e.clientY},!1);return{pos:t,bias:NA(n,t,e.clientX,e.clientY)}}const BA=he.ie&&he.ie_version<=11;let ab=null,cb=0,ub=0;function y_(n){if(!BA)return n.detail;let e=ab,t=ub;return ab=n,ub=Date.now(),cb=!e||t>Date.now()-400&&Math.abs(e.clientX-n.clientX)<2&&Math.abs(e.clientY-n.clientY)<2?(cb+1)%3:1}function XA(n,e){let t=lb(n,e),i=y_(e),r=n.state.selection;return{update(s){s.docChanged&&(t.pos=s.changes.mapPos(t.pos),r=r.map(s.changes))},get(s,o,a){let c=lb(n,s),h,f=sb(n,c.pos,c.bias,i);if(t.pos!=c.pos&&!o){let p=sb(n,t.pos,t.bias,i),m=Math.min(p.from,f.from),O=Math.max(p.to,f.to);f=m1&&(h=WA(r,c.pos))?h:a?r.addRange(f):V.create([f])}}}function WA(n,e){for(let t=0;t=e)return V.create(n.ranges.slice(0,t).concat(n.ranges.slice(t+1)),n.mainIndex==t?0:n.mainIndex-(n.mainIndex>t?1:0))}return null}hi.dragstart=(n,e)=>{let{selection:{main:t}}=n.state;if(e.target.draggable){let r=n.docView.nearest(e.target);if(r&&r.isWidget){let s=r.posAtStart,o=s+r.length;(s>=t.to||o<=t.from)&&(t=V.range(s,o))}}let{inputState:i}=n;return i.mouseSelection&&(i.mouseSelection.dragging=!0),i.draggedContent=t,e.dataTransfer&&(e.dataTransfer.setData("Text",Uf(n.state,d0,n.state.sliceDoc(t.from,t.to))),e.dataTransfer.effectAllowed="copyMove"),!1};hi.dragend=n=>(n.inputState.draggedContent=null,!1);function hb(n,e,t,i){if(t=Uf(n.state,f0,t),!t)return;let r=n.posAtCoords({x:e.clientX,y:e.clientY},!1),{draggedContent:s}=n.inputState,o=i&&s&&zA(n,e)?{from:s.from,to:s.to}:null,a={from:r,insert:t},c=n.state.changes(o?[o,a]:a);n.focus(),n.dispatch({changes:c,selection:{anchor:c.mapPos(r,-1),head:c.mapPos(r,1)},userEvent:o?"move.drop":"input.drop"}),n.inputState.draggedContent=null}hi.drop=(n,e)=>{if(!e.dataTransfer)return!1;if(n.state.readOnly)return!0;let t=e.dataTransfer.files;if(t&&t.length){let i=Array(t.length),r=0,s=()=>{++r==t.length&&hb(n,e,i.filter(o=>o!=null).join(n.state.lineBreak),!1)};for(let o=0;o{/[\x00-\x08\x0e-\x1f]{2}/.test(a.result)||(i[o]=a.result),s()},a.readAsText(t[o])}return!0}else{let i=e.dataTransfer.getData("Text");if(i)return hb(n,e,i,!0),!0}return!1};hi.paste=(n,e)=>{if(n.state.readOnly)return!0;n.observer.flush();let t=m_?null:e.clipboardData;return t?(O_(n,t.getData("text/plain")||t.getData("text/uri-list")),!0):(IA(n),!1)};function VA(n,e){let t=n.dom.parentNode;if(!t)return;let i=t.appendChild(document.createElement("textarea"));i.style.cssText="position: fixed; left: -10000px; top: 10px",i.value=e,i.focus(),i.selectionEnd=e.length,i.selectionStart=0,setTimeout(()=>{i.remove(),n.focus()},50)}function FA(n){let e=[],t=[],i=!1;for(let r of n.selection.ranges)r.empty||(e.push(n.sliceDoc(r.from,r.to)),t.push(r));if(!e.length){let r=-1;for(let{from:s}of n.selection.ranges){let o=n.doc.lineAt(s);o.number>r&&(e.push(o.text),t.push({from:o.from,to:Math.min(n.doc.length,o.to+1)})),r=o.number}i=!0}return{text:Uf(n,d0,e.join(n.lineBreak)),ranges:t,linewise:i}}let sO=null;hi.copy=hi.cut=(n,e)=>{let{text:t,ranges:i,linewise:r}=FA(n.state);if(!t&&!r)return!1;sO=r?t:null,e.type=="cut"&&!n.state.readOnly&&n.dispatch({changes:i,scrollIntoView:!0,userEvent:"delete.cut"});let s=m_?null:e.clipboardData;return s?(s.clearData(),s.setData("text/plain",t),!0):(VA(n,t),!1)};const x_=cr.define();function v_(n,e){let t=[];for(let i of n.facet(JP)){let r=i(n,e);r&&t.push(r)}return t.length?n.update({effects:t,annotations:x_.of(!0)}):null}function b_(n){setTimeout(()=>{let e=n.hasFocus;if(e!=n.inputState.notifiedFocused){let t=v_(n.state,e);t?n.dispatch(t):n.update([])}},10)}qn.focus=n=>{n.inputState.lastFocusTime=Date.now(),!n.scrollDOM.scrollTop&&(n.inputState.lastScrollTop||n.inputState.lastScrollLeft)&&(n.scrollDOM.scrollTop=n.inputState.lastScrollTop,n.scrollDOM.scrollLeft=n.inputState.lastScrollLeft),b_(n)};qn.blur=n=>{n.observer.clearSelectionRange(),b_(n)};qn.compositionstart=qn.compositionupdate=n=>{n.observer.editContext||(n.inputState.compositionFirstChange==null&&(n.inputState.compositionFirstChange=!0),n.inputState.composing<0&&(n.inputState.composing=0))};qn.compositionend=n=>{n.observer.editContext||(n.inputState.composing=-1,n.inputState.compositionEndedAt=Date.now(),n.inputState.compositionPendingKey=!0,n.inputState.compositionPendingChange=n.observer.pendingRecords().length>0,n.inputState.compositionFirstChange=null,he.chrome&&he.android?n.observer.flushSoon():n.inputState.compositionPendingChange?Promise.resolve().then(()=>n.observer.flush()):setTimeout(()=>{n.inputState.composing<0&&n.docView.hasComposition&&n.update([])},50))};qn.contextmenu=n=>{n.inputState.lastContextMenu=Date.now()};hi.beforeinput=(n,e)=>{var t,i;if(e.inputType=="insertReplacementText"&&n.observer.editContext){let s=(t=e.dataTransfer)===null||t===void 0?void 0:t.getData("text/plain"),o=e.getTargetRanges();if(s&&o.length){let a=o[0],c=n.posAtDOM(a.startContainer,a.startOffset),h=n.posAtDOM(a.endContainer,a.endOffset);return m0(n,{from:c,to:h,insert:n.state.toText(s)},null),!0}}let r;if(he.chrome&&he.android&&(r=p_.find(s=>s.inputType==e.inputType))&&(n.observer.delayAndroidKey(r.key,r.keyCode),r.key=="Backspace"||r.key=="Delete")){let s=((i=window.visualViewport)===null||i===void 0?void 0:i.height)||0;setTimeout(()=>{var o;(((o=window.visualViewport)===null||o===void 0?void 0:o.height)||0)>s+10&&n.hasFocus&&(n.contentDOM.blur(),n.focus())},100)}return he.ios&&e.inputType=="deleteContentForward"&&n.observer.flushSoon(),he.safari&&e.inputType=="insertText"&&n.inputState.composing>=0&&setTimeout(()=>qn.compositionend(n,e),20),!1};const fb=new Set;function YA(n){fb.has(n)||(fb.add(n),n.addEventListener("copy",()=>{}),n.addEventListener("cut",()=>{}))}const db=["pre-wrap","normal","pre-line","break-spaces"];let Ko=!1;function pb(){Ko=!1}class qA{constructor(e){this.lineWrapping=e,this.doc=ze.empty,this.heightSamples={},this.lineHeight=14,this.charWidth=7,this.textHeight=14,this.lineLength=30}heightForGap(e,t){let i=this.doc.lineAt(t).number-this.doc.lineAt(e).number+1;return this.lineWrapping&&(i+=Math.max(0,Math.ceil((t-e-i*this.lineLength*.5)/this.lineLength))),this.lineHeight*i}heightForLine(e){return this.lineWrapping?(1+Math.max(0,Math.ceil((e-this.lineLength)/Math.max(1,this.lineLength-5))))*this.lineHeight:this.lineHeight}setDoc(e){return this.doc=e,this}mustRefreshForWrapping(e){return db.indexOf(e)>-1!=this.lineWrapping}mustRefreshForHeights(e){let t=!1;for(let i=0;i-1,c=Math.round(t)!=Math.round(this.lineHeight)||this.lineWrapping!=a;if(this.lineWrapping=a,this.lineHeight=t,this.charWidth=i,this.textHeight=r,this.lineLength=s,c){this.heightSamples={};for(let h=0;h0}set outdated(e){this.flags=(e?2:0)|this.flags&-3}setHeight(e){this.height!=e&&(Math.abs(this.height-e)>Eh&&(Ko=!0),this.height=e)}replace(e,t,i){return cn.of(i)}decomposeLeft(e,t){t.push(this)}decomposeRight(e,t){t.push(this)}applyChanges(e,t,i,r){let s=this,o=i.doc;for(let a=r.length-1;a>=0;a--){let{fromA:c,toA:h,fromB:f,toB:p}=r[a],m=s.lineAt(c,rt.ByPosNoHeight,i.setDoc(t),0,0),O=m.to>=h?m:s.lineAt(h,rt.ByPosNoHeight,i,0,0);for(p+=O.to-h,h=O.to;a>0&&m.from<=r[a-1].toA;)c=r[a-1].fromA,f=r[a-1].fromB,a--,cs*2){let a=e[t-1];a.break?e.splice(--t,1,a.left,null,a.right):e.splice(--t,1,a.left,a.right),i+=1+a.break,r-=a.size}else if(s>r*2){let a=e[i];a.break?e.splice(i,1,a.left,null,a.right):e.splice(i,1,a.left,a.right),i+=2+a.break,s-=a.size}else break;else if(r=s&&o(this.blockAt(0,i,r,s))}updateHeight(e,t=0,i=!1,r){return r&&r.from<=t&&r.more&&this.setHeight(r.heights[r.index++]),this.outdated=!1,this}toString(){return`block(${this.length})`}}class Rn extends S_{constructor(e,t){super(e,t,null),this.collapsed=0,this.widgetHeight=0,this.breaks=0}blockAt(e,t,i,r){return new Mi(r,this.length,i,this.height,this.breaks)}replace(e,t,i){let r=i[0];return i.length==1&&(r instanceof Rn||r instanceof Bt&&r.flags&4)&&Math.abs(this.length-r.length)<10?(r instanceof Bt?r=new Rn(r.length,this.height):r.height=this.height,this.outdated||(r.outdated=!1),r):cn.of(i)}updateHeight(e,t=0,i=!1,r){return r&&r.from<=t&&r.more?this.setHeight(r.heights[r.index++]):(i||this.outdated)&&this.setHeight(Math.max(this.widgetHeight,e.heightForLine(this.length-this.collapsed))+this.breaks*e.lineHeight),this.outdated=!1,this}toString(){return`line(${this.length}${this.collapsed?-this.collapsed:""}${this.widgetHeight?":"+this.widgetHeight:""})`}}class Bt extends cn{constructor(e){super(e,0)}heightMetrics(e,t){let i=e.doc.lineAt(t).number,r=e.doc.lineAt(t+this.length).number,s=r-i+1,o,a=0;if(e.lineWrapping){let c=Math.min(this.height,e.lineHeight*s);o=c/s,this.length>s+1&&(a=(this.height-c)/(this.length-s-1))}else o=this.height/s;return{firstLine:i,lastLine:r,perLine:o,perChar:a}}blockAt(e,t,i,r){let{firstLine:s,lastLine:o,perLine:a,perChar:c}=this.heightMetrics(t,r);if(t.lineWrapping){let h=r+(e0){let s=i[i.length-1];s instanceof Bt?i[i.length-1]=new Bt(s.length+r):i.push(null,new Bt(r-1))}if(e>0){let s=i[0];s instanceof Bt?i[0]=new Bt(e+s.length):i.unshift(new Bt(e-1),null)}return cn.of(i)}decomposeLeft(e,t){t.push(new Bt(e-1),null)}decomposeRight(e,t){t.push(null,new Bt(this.length-e-1))}updateHeight(e,t=0,i=!1,r){let s=t+this.length;if(r&&r.from<=t+this.length&&r.more){let o=[],a=Math.max(t,r.from),c=-1;for(r.from>t&&o.push(new Bt(r.from-t-1).updateHeight(e,t));a<=s&&r.more;){let f=e.doc.lineAt(a).length;o.length&&o.push(null);let p=r.heights[r.index++];c==-1?c=p:Math.abs(p-c)>=Eh&&(c=-2);let m=new Rn(f,p);m.outdated=!1,o.push(m),a+=f+1}a<=s&&o.push(null,new Bt(s-a).updateHeight(e,a));let h=cn.of(o);return(c<0||Math.abs(h.height-this.height)>=Eh||Math.abs(c-this.heightMetrics(e,t).perLine)>=Eh)&&(Ko=!0),lf(this,h)}else(i||this.outdated)&&(this.setHeight(e.heightForGap(t,t+this.length)),this.outdated=!1);return this}toString(){return`gap(${this.length})`}}class HA extends cn{constructor(e,t,i){super(e.length+t+i.length,e.height+i.height,t|(e.outdated||i.outdated?2:0)),this.left=e,this.right=i,this.size=e.size+i.size}get break(){return this.flags&1}blockAt(e,t,i,r){let s=i+this.left.height;return ea))return h;let f=t==rt.ByPosNoHeight?rt.ByPosNoHeight:rt.ByPos;return c?h.join(this.right.lineAt(a,f,i,o,a)):this.left.lineAt(a,f,i,r,s).join(h)}forEachLine(e,t,i,r,s,o){let a=r+this.left.height,c=s+this.left.length+this.break;if(this.break)e=c&&this.right.forEachLine(e,t,i,a,c,o);else{let h=this.lineAt(c,rt.ByPos,i,r,s);e=e&&h.from<=t&&o(h),t>h.to&&this.right.forEachLine(h.to+1,t,i,a,c,o)}}replace(e,t,i){let r=this.left.length+this.break;if(tthis.left.length)return this.balanced(this.left,this.right.replace(e-r,t-r,i));let s=[];e>0&&this.decomposeLeft(e,s);let o=s.length;for(let a of i)s.push(a);if(e>0&&gb(s,o-1),t=i&&t.push(null)),e>i&&this.right.decomposeLeft(e-i,t)}decomposeRight(e,t){let i=this.left.length,r=i+this.break;if(e>=r)return this.right.decomposeRight(e-r,t);e2*t.size||t.size>2*e.size?cn.of(this.break?[e,null,t]:[e,t]):(this.left=lf(this.left,e),this.right=lf(this.right,t),this.setHeight(e.height+t.height),this.outdated=e.outdated||t.outdated,this.size=e.size+t.size,this.length=e.length+this.break+t.length,this)}updateHeight(e,t=0,i=!1,r){let{left:s,right:o}=this,a=t+s.length+this.break,c=null;return r&&r.from<=t+s.length&&r.more?c=s=s.updateHeight(e,t,i,r):s.updateHeight(e,t,i),r&&r.from<=a+o.length&&r.more?c=o=o.updateHeight(e,a,i,r):o.updateHeight(e,a,i),c?this.balanced(s,o):(this.height=this.left.height+this.right.height,this.outdated=!1,this)}toString(){return this.left+(this.break?" ":"-")+this.right}}function gb(n,e){let t,i;n[e]==null&&(t=n[e-1])instanceof Bt&&(i=n[e+1])instanceof Bt&&n.splice(e-1,3,new Bt(t.length+1+i.length))}const GA=5;class O0{constructor(e,t){this.pos=e,this.oracle=t,this.nodes=[],this.lineStart=-1,this.lineEnd=-1,this.covering=null,this.writtenTo=e}get isCovered(){return this.covering&&this.nodes[this.nodes.length-1]==this.covering}span(e,t){if(this.lineStart>-1){let i=Math.min(t,this.lineEnd),r=this.nodes[this.nodes.length-1];r instanceof Rn?r.length+=i-this.pos:(i>this.pos||!this.isCovered)&&this.nodes.push(new Rn(i-this.pos,-1)),this.writtenTo=i,t>i&&(this.nodes.push(null),this.writtenTo++,this.lineStart=-1)}this.pos=t}point(e,t,i){if(e=GA)&&this.addLineDeco(r,s,o)}else t>e&&this.span(e,t);this.lineEnd>-1&&this.lineEnd-1)return;let{from:e,to:t}=this.oracle.doc.lineAt(this.pos);this.lineStart=e,this.lineEnd=t,this.writtenToe&&this.nodes.push(new Rn(this.pos-e,-1)),this.writtenTo=this.pos}blankContent(e,t){let i=new Bt(t-e);return this.oracle.doc.lineAt(e).to==t&&(i.flags|=4),i}ensureLine(){this.enterLine();let e=this.nodes.length?this.nodes[this.nodes.length-1]:null;if(e instanceof Rn)return e;let t=new Rn(0,-1);return this.nodes.push(t),t}addBlock(e){this.enterLine();let t=e.deco;t&&t.startSide>0&&!this.isCovered&&this.ensureLine(),this.nodes.push(e),this.writtenTo=this.pos=this.pos+e.length,t&&t.endSide>0&&(this.covering=e)}addLineDeco(e,t,i){let r=this.ensureLine();r.length+=i,r.collapsed+=i,r.widgetHeight=Math.max(r.widgetHeight,e),r.breaks+=t,this.writtenTo=this.pos=this.pos+i}finish(e){let t=this.nodes.length==0?null:this.nodes[this.nodes.length-1];this.lineStart>-1&&!(t instanceof Rn)&&!this.isCovered?this.nodes.push(new Rn(0,-1)):(this.writtenTof.clientHeight||f.scrollWidth>f.clientWidth)&&p.overflow!="visible"){let m=f.getBoundingClientRect();s=Math.max(s,m.left),o=Math.min(o,m.right),a=Math.max(a,m.top),c=Math.min(h==n.parentNode?r.innerHeight:c,m.bottom)}h=p.position=="absolute"||p.position=="fixed"?f.offsetParent:f.parentNode}else if(h.nodeType==11)h=h.host;else break;return{left:s-t.left,right:Math.max(s,o)-t.left,top:a-(t.top+e),bottom:Math.max(a,c)-(t.top+e)}}function tE(n){let e=n.getBoundingClientRect(),t=n.ownerDocument.defaultView||window;return e.left0&&e.top0}function nE(n,e){let t=n.getBoundingClientRect();return{left:0,right:t.right-t.left,top:e,bottom:t.bottom-(t.top+e)}}class Pg{constructor(e,t,i,r){this.from=e,this.to=t,this.size=i,this.displaySize=r}static same(e,t){if(e.length!=t.length)return!1;for(let i=0;itypeof i!="function"&&i.class=="cm-lineWrapping");this.heightOracle=new qA(t),this.stateDeco=e.facet(Va).filter(i=>typeof i!="function"),this.heightMap=cn.empty().applyChanges(this.stateDeco,ze.empty,this.heightOracle.setDoc(e.doc),[new Fn(0,0,0,e.doc.length)]);for(let i=0;i<2&&(this.viewport=this.getViewport(0,null),!!this.updateForViewport());i++);this.updateViewportLines(),this.lineGaps=this.ensureLineGaps([]),this.lineGapDeco=_e.set(this.lineGaps.map(i=>i.draw(this,!1))),this.computeVisibleRanges()}updateForViewport(){let e=[this.viewport],{main:t}=this.state.selection;for(let i=0;i<=1;i++){let r=i?t.head:t.anchor;if(!e.some(({from:s,to:o})=>r>=s&&r<=o)){let{from:s,to:o}=this.lineBlockAt(r);e.push(new Fu(s,o))}}return this.viewports=e.sort((i,r)=>i.from-r.from),this.updateScaler()}updateScaler(){let e=this.scaler;return this.scaler=this.heightMap.height<=7e6?Ob:new y0(this.heightOracle,this.heightMap,this.viewports),e.eq(this.scaler)?0:2}updateViewportLines(){this.viewportLines=[],this.heightMap.forEachLine(this.viewport.from,this.viewport.to,this.heightOracle.setDoc(this.state.doc),0,0,e=>{this.viewportLines.push(pa(e,this.scaler))})}update(e,t=null){this.state=e.state;let i=this.stateDeco;this.stateDeco=this.state.facet(Va).filter(f=>typeof f!="function");let r=e.changedRanges,s=Fn.extendWithRanges(r,KA(i,this.stateDeco,e?e.changes:Ct.empty(this.state.doc.length))),o=this.heightMap.height,a=this.scrolledToBottom?null:this.scrollAnchorAt(this.scrollTop);pb(),this.heightMap=this.heightMap.applyChanges(this.stateDeco,e.startState.doc,this.heightOracle.setDoc(this.state.doc),s),(this.heightMap.height!=o||Ko)&&(e.flags|=2),a?(this.scrollAnchorPos=e.changes.mapPos(a.from,-1),this.scrollAnchorHeight=a.top):(this.scrollAnchorPos=-1,this.scrollAnchorHeight=o);let c=s.length?this.mapViewport(this.viewport,e.changes):this.viewport;(t&&(t.range.headc.to)||!this.viewportIsAppropriate(c))&&(c=this.getViewport(0,t));let h=c.from!=this.viewport.from||c.to!=this.viewport.to;this.viewport=c,e.flags|=this.updateForViewport(),(h||!e.changes.empty||e.flags&2)&&this.updateViewportLines(),(this.lineGaps.length||this.viewport.to-this.viewport.from>4e3)&&this.updateLineGaps(this.ensureLineGaps(this.mapLineGaps(this.lineGaps,e.changes))),e.flags|=this.computeVisibleRanges(e.changes),t&&(this.scrollTarget=t),!this.mustEnforceCursorAssoc&&e.selectionSet&&e.view.lineWrapping&&e.state.selection.main.empty&&e.state.selection.main.assoc&&!e.state.facet(t_)&&(this.mustEnforceCursorAssoc=!0)}measure(e){let t=e.contentDOM,i=window.getComputedStyle(t),r=this.heightOracle,s=i.whiteSpace;this.defaultTextDirection=i.direction=="rtl"?st.RTL:st.LTR;let o=this.heightOracle.mustRefreshForWrapping(s),a=t.getBoundingClientRect(),c=o||this.mustMeasureContent||this.contentDOMHeight!=a.height;this.contentDOMHeight=a.height,this.mustMeasureContent=!1;let h=0,f=0;if(a.width&&a.height){let{scaleX:_,scaleY:C}=$P(t,a);(_>.005&&Math.abs(this.scaleX-_)>.005||C>.005&&Math.abs(this.scaleY-C)>.005)&&(this.scaleX=_,this.scaleY=C,h|=16,o=c=!0)}let p=(parseInt(i.paddingTop)||0)*this.scaleY,m=(parseInt(i.paddingBottom)||0)*this.scaleY;(this.paddingTop!=p||this.paddingBottom!=m)&&(this.paddingTop=p,this.paddingBottom=m,h|=18),this.editorWidth!=e.scrollDOM.clientWidth&&(r.lineWrapping&&(c=!0),this.editorWidth=e.scrollDOM.clientWidth,h|=16);let O=e.scrollDOM.scrollTop*this.scaleY;this.scrollTop!=O&&(this.scrollAnchorHeight=-1,this.scrollTop=O),this.scrolledToBottom=AP(e.scrollDOM);let v=(this.printing?nE:eE)(t,this.paddingTop),b=v.top-this.pixelViewport.top,S=v.bottom-this.pixelViewport.bottom;this.pixelViewport=v;let w=this.pixelViewport.bottom>this.pixelViewport.top&&this.pixelViewport.right>this.pixelViewport.left;if(w!=this.inView&&(this.inView=w,w&&(c=!0)),!this.inView&&!this.scrollTarget&&!tE(e.dom))return 0;let T=a.width;if((this.contentDOMWidth!=T||this.editorHeight!=e.scrollDOM.clientHeight)&&(this.contentDOMWidth=a.width,this.editorHeight=e.scrollDOM.clientHeight,h|=16),c){let _=e.docView.measureVisibleLineHeights(this.viewport);if(r.mustRefreshForHeights(_)&&(o=!0),o||r.lineWrapping&&Math.abs(T-this.contentDOMWidth)>r.charWidth){let{lineHeight:C,charWidth:M,textHeight:R}=e.docView.measureTextSize();o=C>0&&r.refresh(s,C,M,R,Math.max(5,T/M),_),o&&(e.docView.minWidth=0,h|=16)}b>0&&S>0?f=Math.max(b,S):b<0&&S<0&&(f=Math.min(b,S)),pb();for(let C of this.viewports){let M=C.from==this.viewport.from?_:e.docView.measureVisibleLineHeights(C);this.heightMap=(o?cn.empty().applyChanges(this.stateDeco,ze.empty,this.heightOracle,[new Fn(0,0,0,e.state.doc.length)]):this.heightMap).updateHeight(r,0,o,new UA(C.from,M))}Ko&&(h|=2)}let k=!this.viewportIsAppropriate(this.viewport,f)||this.scrollTarget&&(this.scrollTarget.range.headthis.viewport.to);return k&&(h&2&&(h|=this.updateScaler()),this.viewport=this.getViewport(f,this.scrollTarget),h|=this.updateForViewport()),(h&2||k)&&this.updateViewportLines(),(this.lineGaps.length||this.viewport.to-this.viewport.from>4e3)&&this.updateLineGaps(this.ensureLineGaps(o?[]:this.lineGaps,e)),h|=this.computeVisibleRanges(),this.mustEnforceCursorAssoc&&(this.mustEnforceCursorAssoc=!1,e.docView.enforceCursorAssoc()),h}get visibleTop(){return this.scaler.fromDOM(this.pixelViewport.top)}get visibleBottom(){return this.scaler.fromDOM(this.pixelViewport.bottom)}getViewport(e,t){let i=.5-Math.max(-.5,Math.min(.5,e/1e3/2)),r=this.heightMap,s=this.heightOracle,{visibleTop:o,visibleBottom:a}=this,c=new Fu(r.lineAt(o-i*1e3,rt.ByHeight,s,0,0).from,r.lineAt(a+(1-i)*1e3,rt.ByHeight,s,0,0).to);if(t){let{head:h}=t.range;if(hc.to){let f=Math.min(this.editorHeight,this.pixelViewport.bottom-this.pixelViewport.top),p=r.lineAt(h,rt.ByPos,s,0,0),m;t.y=="center"?m=(p.top+p.bottom)/2-f/2:t.y=="start"||t.y=="nearest"&&h=a+Math.max(10,Math.min(i,250)))&&r>o-2*1e3&&s>1,o=r<<1;if(this.defaultTextDirection!=st.LTR&&!i)return[];let a=[],c=(f,p,m,O)=>{if(p-ff&&ww.from>=m.from&&w.to<=m.to&&Math.abs(w.from-f)w.fromT));if(!S){if(pk.from<=p&&k.to>=p)){let k=t.moveToLineBoundary(V.cursor(p),!1,!0).head;k>f&&(p=k)}let w=this.gapSize(m,f,p,O),T=i||w<2e6?w:2e6;S=new Pg(f,p,w,T)}a.push(S)},h=f=>{if(f.length2e6)for(let M of e)M.from>=f.from&&M.fromf.from&&c(f.from,O,f,p),vt.draw(this,this.heightOracle.lineWrapping))))}computeVisibleRanges(e){let t=this.stateDeco;this.lineGaps.length&&(t=t.concat(this.lineGapDeco));let i=[];Ie.spans(t,this.viewport.from,this.viewport.to,{span(s,o){i.push({from:s,to:o})},point(){}},20);let r=0;if(i.length!=this.visibleRanges.length)r=12;else for(let s=0;s=this.viewport.from&&e<=this.viewport.to&&this.viewportLines.find(t=>t.from<=e&&t.to>=e)||pa(this.heightMap.lineAt(e,rt.ByPos,this.heightOracle,0,0),this.scaler)}lineBlockAtHeight(e){return e>=this.viewportLines[0].top&&e<=this.viewportLines[this.viewportLines.length-1].bottom&&this.viewportLines.find(t=>t.top<=e&&t.bottom>=e)||pa(this.heightMap.lineAt(this.scaler.fromDOM(e),rt.ByHeight,this.heightOracle,0,0),this.scaler)}scrollAnchorAt(e){let t=this.lineBlockAtHeight(e+8);return t.from>=this.viewport.from||this.viewportLines[0].top-e>200?t:this.viewportLines[0]}elementAtHeight(e){return pa(this.heightMap.blockAt(this.scaler.fromDOM(e),this.heightOracle,0,0),this.scaler)}get docHeight(){return this.scaler.toDOM(this.heightMap.height)}get contentHeight(){return this.docHeight+this.paddingTop+this.paddingBottom}}class Fu{constructor(e,t){this.from=e,this.to=t}}function rE(n,e,t){let i=[],r=n,s=0;return Ie.spans(t,n,e,{span(){},point(o,a){o>r&&(i.push({from:r,to:o}),s+=o-r),r=a}},20),r=1)return e[e.length-1].to;let i=Math.floor(n*t);for(let r=0;;r++){let{from:s,to:o}=e[r],a=o-s;if(i<=a)return s+i;i-=a}}function qu(n,e){let t=0;for(let{from:i,to:r}of n.ranges){if(e<=r){t+=e-i;break}t+=r-i}return t/n.total}function sE(n,e){for(let t of n)if(e(t))return t}const Ob={toDOM(n){return n},fromDOM(n){return n},scale:1,eq(n){return n==this}};class y0{constructor(e,t,i){let r=0,s=0,o=0;this.viewports=i.map(({from:a,to:c})=>{let h=t.lineAt(a,rt.ByPos,e,0,0).top,f=t.lineAt(c,rt.ByPos,e,0,0).bottom;return r+=f-h,{from:a,to:c,top:h,bottom:f,domTop:0,domBottom:0}}),this.scale=(7e6-r)/(t.height-r);for(let a of this.viewports)a.domTop=o+(a.top-s)*this.scale,o=a.domBottom=a.domTop+(a.bottom-a.top),s=a.bottom}toDOM(e){for(let t=0,i=0,r=0;;t++){let s=tt.from==e.viewports[i].from&&t.to==e.viewports[i].to):!1}}function pa(n,e){if(e.scale==1)return n;let t=e.toDOM(n.top),i=e.toDOM(n.bottom);return new Mi(n.from,n.length,t,i-t,Array.isArray(n._content)?n._content.map(r=>pa(r,e)):n._content)}const Uu=pe.define({combine:n=>n.join(" ")}),oO=pe.define({combine:n=>n.indexOf(!0)>-1}),lO=Ur.newName(),w_=Ur.newName(),k_=Ur.newName(),P_={"&light":"."+w_,"&dark":"."+k_};function aO(n,e,t){return new Ur(e,{finish(i){return/&/.test(i)?i.replace(/&\w*/,r=>{if(r=="&")return n;if(!t||!t[r])throw new RangeError(`Unsupported selector: ${r}`);return t[r]}):n+" "+i}})}const oE=aO("."+lO,{"&":{position:"relative !important",boxSizing:"border-box","&.cm-focused":{outline:"1px dotted #212121"},display:"flex !important",flexDirection:"column"},".cm-scroller":{display:"flex !important",alignItems:"flex-start !important",fontFamily:"monospace",lineHeight:1.4,height:"100%",overflowX:"auto",position:"relative",zIndex:0,overflowAnchor:"none"},".cm-content":{margin:0,flexGrow:2,flexShrink:0,display:"block",whiteSpace:"pre",wordWrap:"normal",boxSizing:"border-box",minHeight:"100%",padding:"4px 0",outline:"none","&[contenteditable=true]":{WebkitUserModify:"read-write-plaintext-only"}},".cm-lineWrapping":{whiteSpace_fallback:"pre-wrap",whiteSpace:"break-spaces",wordBreak:"break-word",overflowWrap:"anywhere",flexShrink:1},"&light .cm-content":{caretColor:"black"},"&dark .cm-content":{caretColor:"white"},".cm-line":{display:"block",padding:"0 2px 0 6px"},".cm-layer":{position:"absolute",left:0,top:0,contain:"size style","& > *":{position:"absolute"}},"&light .cm-selectionBackground":{background:"#d9d9d9"},"&dark .cm-selectionBackground":{background:"#222"},"&light.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground":{background:"#d7d4f0"},"&dark.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground":{background:"#233"},".cm-cursorLayer":{pointerEvents:"none"},"&.cm-focused > .cm-scroller > .cm-cursorLayer":{animation:"steps(1) cm-blink 1.2s infinite"},"@keyframes cm-blink":{"0%":{},"50%":{opacity:0},"100%":{}},"@keyframes cm-blink2":{"0%":{},"50%":{opacity:0},"100%":{}},".cm-cursor, .cm-dropCursor":{borderLeft:"1.2px solid black",marginLeft:"-0.6px",pointerEvents:"none"},".cm-cursor":{display:"none"},"&dark .cm-cursor":{borderLeftColor:"#ddd"},".cm-dropCursor":{position:"absolute"},"&.cm-focused > .cm-scroller > .cm-cursorLayer .cm-cursor":{display:"block"},".cm-iso":{unicodeBidi:"isolate"},".cm-announced":{position:"fixed",top:"-10000px"},"@media print":{".cm-announced":{display:"none"}},"&light .cm-activeLine":{backgroundColor:"#cceeff44"},"&dark .cm-activeLine":{backgroundColor:"#99eeff33"},"&light .cm-specialChar":{color:"red"},"&dark .cm-specialChar":{color:"#f78"},".cm-gutters":{flexShrink:0,display:"flex",height:"100%",boxSizing:"border-box",zIndex:200},".cm-gutters-before":{insetInlineStart:0},".cm-gutters-after":{insetInlineEnd:0},"&light .cm-gutters":{backgroundColor:"#f5f5f5",color:"#6c6c6c",border:"0px solid #ddd","&.cm-gutters-before":{borderRightWidth:"1px"},"&.cm-gutters-after":{borderLeftWidth:"1px"}},"&dark .cm-gutters":{backgroundColor:"#333338",color:"#ccc"},".cm-gutter":{display:"flex !important",flexDirection:"column",flexShrink:0,boxSizing:"border-box",minHeight:"100%",overflow:"hidden"},".cm-gutterElement":{boxSizing:"border-box"},".cm-lineNumbers .cm-gutterElement":{padding:"0 3px 0 5px",minWidth:"20px",textAlign:"right",whiteSpace:"nowrap"},"&light .cm-activeLineGutter":{backgroundColor:"#e2f2ff"},"&dark .cm-activeLineGutter":{backgroundColor:"#222227"},".cm-panels":{boxSizing:"border-box",position:"sticky",left:0,right:0,zIndex:300},"&light .cm-panels":{backgroundColor:"#f5f5f5",color:"black"},"&light .cm-panels-top":{borderBottom:"1px solid #ddd"},"&light .cm-panels-bottom":{borderTop:"1px solid #ddd"},"&dark .cm-panels":{backgroundColor:"#333338",color:"white"},".cm-dialog":{padding:"2px 19px 4px 6px",position:"relative","& label":{fontSize:"80%"}},".cm-dialog-close":{position:"absolute",top:"3px",right:"4px",backgroundColor:"inherit",border:"none",font:"inherit",fontSize:"14px",padding:"0"},".cm-tab":{display:"inline-block",overflow:"hidden",verticalAlign:"bottom"},".cm-widgetBuffer":{verticalAlign:"text-top",height:"1em",width:0,display:"inline"},".cm-placeholder":{color:"#888",display:"inline-block",verticalAlign:"top",userSelect:"none"},".cm-highlightSpace":{backgroundImage:"radial-gradient(circle at 50% 55%, #aaa 20%, transparent 5%)",backgroundPosition:"center"},".cm-highlightTab":{backgroundImage:`url('data:image/svg+xml,')`,backgroundSize:"auto 100%",backgroundPosition:"right 90%",backgroundRepeat:"no-repeat"},".cm-trailingSpace":{backgroundColor:"#ff332255"},".cm-button":{verticalAlign:"middle",color:"inherit",fontSize:"70%",padding:".2em 1em",borderRadius:"1px"},"&light .cm-button":{backgroundImage:"linear-gradient(#eff1f5, #d9d9df)",border:"1px solid #888","&:active":{backgroundImage:"linear-gradient(#b4b4b4, #d0d3d6)"}},"&dark .cm-button":{backgroundImage:"linear-gradient(#393939, #111)",border:"1px solid #888","&:active":{backgroundImage:"linear-gradient(#111, #333)"}},".cm-textfield":{verticalAlign:"middle",color:"inherit",fontSize:"70%",border:"1px solid silver",padding:".2em .5em"},"&light .cm-textfield":{backgroundColor:"white"},"&dark .cm-textfield":{border:"1px solid #555",backgroundColor:"inherit"}},P_),lE={childList:!0,characterData:!0,subtree:!0,attributes:!0,characterDataOldValue:!0},_g=he.ie&&he.ie_version<=11;class aE{constructor(e){this.view=e,this.active=!1,this.editContext=null,this.selectionRange=new WR,this.selectionChanged=!1,this.delayedFlush=-1,this.resizeTimeout=-1,this.queue=[],this.delayedAndroidKey=null,this.flushingAndroidKey=-1,this.lastChange=0,this.scrollTargets=[],this.intersection=null,this.resizeScroll=null,this.intersecting=!1,this.gapIntersection=null,this.gaps=[],this.printQuery=null,this.parentCheck=-1,this.dom=e.contentDOM,this.observer=new MutationObserver(t=>{for(let i of t)this.queue.push(i);(he.ie&&he.ie_version<=11||he.ios&&e.composing)&&t.some(i=>i.type=="childList"&&i.removedNodes.length||i.type=="characterData"&&i.oldValue.length>i.target.nodeValue.length)?this.flushSoon():this.flush()}),window.EditContext&&he.android&&e.constructor.EDIT_CONTEXT!==!1&&!(he.chrome&&he.chrome_version<126)&&(this.editContext=new uE(e),e.state.facet(nr)&&(e.contentDOM.editContext=this.editContext.editContext)),_g&&(this.onCharData=t=>{this.queue.push({target:t.target,type:"characterData",oldValue:t.prevValue}),this.flushSoon()}),this.onSelectionChange=this.onSelectionChange.bind(this),this.onResize=this.onResize.bind(this),this.onPrint=this.onPrint.bind(this),this.onScroll=this.onScroll.bind(this),window.matchMedia&&(this.printQuery=window.matchMedia("print")),typeof ResizeObserver=="function"&&(this.resizeScroll=new ResizeObserver(()=>{var t;((t=this.view.docView)===null||t===void 0?void 0:t.lastUpdate){this.parentCheck<0&&(this.parentCheck=setTimeout(this.listenForScroll.bind(this),1e3)),t.length>0&&t[t.length-1].intersectionRatio>0!=this.intersecting&&(this.intersecting=!this.intersecting,this.intersecting!=this.view.inView&&this.onScrollChanged(document.createEvent("Event")))},{threshold:[0,.001]}),this.intersection.observe(this.dom),this.gapIntersection=new IntersectionObserver(t=>{t.length>0&&t[t.length-1].intersectionRatio>0&&this.onScrollChanged(document.createEvent("Event"))},{})),this.listenForScroll(),this.readSelectionRange()}onScrollChanged(e){this.view.inputState.runHandlers("scroll",e),this.intersecting&&this.view.measure()}onScroll(e){this.intersecting&&this.flush(!1),this.editContext&&this.view.requestMeasure(this.editContext.measureReq),this.onScrollChanged(e)}onResize(){this.resizeTimeout<0&&(this.resizeTimeout=setTimeout(()=>{this.resizeTimeout=-1,this.view.requestMeasure()},50))}onPrint(e){(e.type=="change"||!e.type)&&!e.matches||(this.view.viewState.printing=!0,this.view.measure(),setTimeout(()=>{this.view.viewState.printing=!1,this.view.requestMeasure()},500))}updateGaps(e){if(this.gapIntersection&&(e.length!=this.gaps.length||this.gaps.some((t,i)=>t!=e[i]))){this.gapIntersection.disconnect();for(let t of e)this.gapIntersection.observe(t);this.gaps=e}}onSelectionChange(e){let t=this.selectionChanged;if(!this.readSelectionRange()||this.delayedAndroidKey)return;let{view:i}=this,r=this.selectionRange;if(i.state.facet(nr)?i.root.activeElement!=this.dom:!Rh(this.dom,r))return;let s=r.anchorNode&&i.docView.nearest(r.anchorNode);if(s&&s.ignoreEvent(e)){t||(this.selectionChanged=!1);return}(he.ie&&he.ie_version<=11||he.android&&he.chrome)&&!i.state.selection.main.empty&&r.focusNode&&Pa(r.focusNode,r.focusOffset,r.anchorNode,r.anchorOffset)?this.flushSoon():this.flush(!1)}readSelectionRange(){let{view:e}=this,t=Xa(e.root);if(!t)return!1;let i=he.safari&&e.root.nodeType==11&&e.root.activeElement==this.dom&&cE(this.view,t)||t;if(!i||this.selectionRange.eq(i))return!1;let r=Rh(this.dom,i);return r&&!this.selectionChanged&&e.inputState.lastFocusTime>Date.now()-200&&e.inputState.lastTouchTime{let s=this.delayedAndroidKey;s&&(this.clearDelayedAndroidKey(),this.view.inputState.lastKeyCode=s.keyCode,this.view.inputState.lastKeyTime=Date.now(),!this.flush()&&s.force&&jo(this.dom,s.key,s.keyCode))};this.flushingAndroidKey=this.view.win.requestAnimationFrame(r)}(!this.delayedAndroidKey||e=="Enter")&&(this.delayedAndroidKey={key:e,keyCode:t,force:this.lastChange{this.delayedFlush=-1,this.flush()}))}forceFlush(){this.delayedFlush>=0&&(this.view.win.cancelAnimationFrame(this.delayedFlush),this.delayedFlush=-1),this.flush()}pendingRecords(){for(let e of this.observer.takeRecords())this.queue.push(e);return this.queue}processRecords(){let e=this.pendingRecords();e.length&&(this.queue=[]);let t=-1,i=-1,r=!1;for(let s of e){let o=this.readMutation(s);o&&(o.typeOver&&(r=!0),t==-1?{from:t,to:i}=o:(t=Math.min(o.from,t),i=Math.max(o.to,i)))}return{from:t,to:i,typeOver:r}}readChange(){let{from:e,to:t,typeOver:i}=this.processRecords(),r=this.selectionChanged&&Rh(this.dom,this.selectionRange);if(e<0&&!r)return null;e>-1&&(this.lastChange=Date.now()),this.view.inputState.lastFocusTime=0,this.selectionChanged=!1;let s=new QA(this.view,e,t,i);return this.view.docView.domChanged={newSel:s.newSel?s.newSel.main:null},s}flush(e=!0){if(this.delayedFlush>=0||this.delayedAndroidKey)return!1;e&&this.readSelectionRange();let t=this.readChange();if(!t)return this.view.requestMeasure(),!1;let i=this.view.state,r=f_(this.view,t);return this.view.state==i&&(t.domChanged||t.newSel&&!t.newSel.main.eq(this.view.state.selection.main))&&this.view.update([]),r}readMutation(e){let t=this.view.docView.nearest(e.target);if(!t||t.ignoreMutation(e))return null;if(t.markDirty(e.type=="attributes"),e.type=="attributes"&&(t.flags|=4),e.type=="childList"){let i=yb(t,e.previousSibling||e.target.previousSibling,-1),r=yb(t,e.nextSibling||e.target.nextSibling,1);return{from:i?t.posAfter(i):t.posAtStart,to:r?t.posBefore(r):t.posAtEnd,typeOver:!1}}else return e.type=="characterData"?{from:t.posAtStart,to:t.posAtEnd,typeOver:e.target.nodeValue==e.oldValue}:null}setWindow(e){e!=this.win&&(this.removeWindowListeners(this.win),this.win=e,this.addWindowListeners(this.win))}addWindowListeners(e){e.addEventListener("resize",this.onResize),this.printQuery?this.printQuery.addEventListener?this.printQuery.addEventListener("change",this.onPrint):this.printQuery.addListener(this.onPrint):e.addEventListener("beforeprint",this.onPrint),e.addEventListener("scroll",this.onScroll),e.document.addEventListener("selectionchange",this.onSelectionChange)}removeWindowListeners(e){e.removeEventListener("scroll",this.onScroll),e.removeEventListener("resize",this.onResize),this.printQuery?this.printQuery.removeEventListener?this.printQuery.removeEventListener("change",this.onPrint):this.printQuery.removeListener(this.onPrint):e.removeEventListener("beforeprint",this.onPrint),e.document.removeEventListener("selectionchange",this.onSelectionChange)}update(e){this.editContext&&(this.editContext.update(e),e.startState.facet(nr)!=e.state.facet(nr)&&(e.view.contentDOM.editContext=e.state.facet(nr)?this.editContext.editContext:null))}destroy(){var e,t,i;this.stop(),(e=this.intersection)===null||e===void 0||e.disconnect(),(t=this.gapIntersection)===null||t===void 0||t.disconnect(),(i=this.resizeScroll)===null||i===void 0||i.disconnect();for(let r of this.scrollTargets)r.removeEventListener("scroll",this.onScroll);this.removeWindowListeners(this.win),clearTimeout(this.parentCheck),clearTimeout(this.resizeTimeout),this.win.cancelAnimationFrame(this.delayedFlush),this.win.cancelAnimationFrame(this.flushingAndroidKey),this.editContext&&(this.view.contentDOM.editContext=null,this.editContext.destroy())}}function yb(n,e,t){for(;e;){let i=Ue.get(e);if(i&&i.parent==n)return i;let r=e.parentNode;e=r!=n.dom?r:t>0?e.nextSibling:e.previousSibling}return null}function xb(n,e){let t=e.startContainer,i=e.startOffset,r=e.endContainer,s=e.endOffset,o=n.docView.domAtPos(n.state.selection.main.anchor);return Pa(o.node,o.offset,r,s)&&([t,i,r,s]=[r,s,t,i]),{anchorNode:t,anchorOffset:i,focusNode:r,focusOffset:s}}function cE(n,e){if(e.getComposedRanges){let r=e.getComposedRanges(n.root)[0];if(r)return xb(n,r)}let t=null;function i(r){r.preventDefault(),r.stopImmediatePropagation(),t=r.getTargetRanges()[0]}return n.contentDOM.addEventListener("beforeinput",i,!0),n.dom.ownerDocument.execCommand("indent"),n.contentDOM.removeEventListener("beforeinput",i,!0),t?xb(n,t):null}class uE{constructor(e){this.from=0,this.to=0,this.pendingContextChange=null,this.handlers=Object.create(null),this.composing=null,this.resetRange(e.state);let t=this.editContext=new window.EditContext({text:e.state.doc.sliceString(this.from,this.to),selectionStart:this.toContextPos(Math.max(this.from,Math.min(this.to,e.state.selection.main.anchor))),selectionEnd:this.toContextPos(e.state.selection.main.head)});this.handlers.textupdate=i=>{let r=e.state.selection.main,{anchor:s,head:o}=r,a=this.toEditorPos(i.updateRangeStart),c=this.toEditorPos(i.updateRangeEnd);e.inputState.composing>=0&&!this.composing&&(this.composing={contextBase:i.updateRangeStart,editorBase:a,drifted:!1});let h=c-a>i.text.length;a==this.from&&sthis.to&&(c=s);let f=d_(e.state.sliceDoc(a,c),i.text,(h?r.from:r.to)-a,h?"end":null);if(!f){let m=V.single(this.toEditorPos(i.selectionStart),this.toEditorPos(i.selectionEnd));m.main.eq(r)||e.dispatch({selection:m,userEvent:"select"});return}let p={from:f.from+a,to:f.toA+a,insert:ze.of(i.text.slice(f.from,f.toB).split(` +`))};if((he.mac||he.android)&&p.from==o-1&&/^\. ?$/.test(i.text)&&e.contentDOM.getAttribute("autocorrect")=="off"&&(p={from:a,to:c,insert:ze.of([i.text.replace("."," ")])}),this.pendingContextChange=p,!e.state.readOnly){let m=this.to-this.from+(p.to-p.from+p.insert.length);m0(e,p,V.single(this.toEditorPos(i.selectionStart,m),this.toEditorPos(i.selectionEnd,m)))}this.pendingContextChange&&(this.revertPending(e.state),this.setSelection(e.state)),p.from=0&&!/[\\p{Alphabetic}\\p{Number}_]/.test(t.text.slice(Math.max(0,i.updateRangeStart-1),Math.min(t.text.length,i.updateRangeStart+1)))&&this.handlers.compositionend(i)},this.handlers.characterboundsupdate=i=>{let r=[],s=null;for(let o=this.toEditorPos(i.rangeStart),a=this.toEditorPos(i.rangeEnd);o{let r=[];for(let s of i.getTextFormats()){let o=s.underlineStyle,a=s.underlineThickness;if(!/none/i.test(o)&&!/none/i.test(a)){let c=this.toEditorPos(s.rangeStart),h=this.toEditorPos(s.rangeEnd);if(c{e.inputState.composing<0&&(e.inputState.composing=0,e.inputState.compositionFirstChange=!0)},this.handlers.compositionend=()=>{if(e.inputState.composing=-1,e.inputState.compositionFirstChange=null,this.composing){let{drifted:i}=this.composing;this.composing=null,i&&this.reset(e.state)}};for(let i in this.handlers)t.addEventListener(i,this.handlers[i]);this.measureReq={read:i=>{this.editContext.updateControlBounds(i.contentDOM.getBoundingClientRect());let r=Xa(i.root);r&&r.rangeCount&&this.editContext.updateSelectionBounds(r.getRangeAt(0).getBoundingClientRect())}}}applyEdits(e){let t=0,i=!1,r=this.pendingContextChange;return e.changes.iterChanges((s,o,a,c,h)=>{if(i)return;let f=h.length-(o-s);if(r&&o>=r.to)if(r.from==s&&r.to==o&&r.insert.eq(h)){r=this.pendingContextChange=null,t+=f,this.to+=f;return}else r=null,this.revertPending(e.state);if(s+=t,o+=t,o<=this.from)this.from+=f,this.to+=f;else if(sthis.to||this.to-this.from+h.length>3e4){i=!0;return}this.editContext.updateText(this.toContextPos(s),this.toContextPos(o),h.toString()),this.to+=f}t+=f}),r&&!i&&this.revertPending(e.state),!i}update(e){let t=this.pendingContextChange,i=e.startState.selection.main;this.composing&&(this.composing.drifted||!e.changes.touchesRange(i.from,i.to)&&e.transactions.some(r=>!r.isUserEvent("input.type")&&r.changes.touchesRange(this.from,this.to)))?(this.composing.drifted=!0,this.composing.editorBase=e.changes.mapPos(this.composing.editorBase)):!this.applyEdits(e)||!this.rangeIsValid(e.state)?(this.pendingContextChange=null,this.reset(e.state)):(e.docChanged||e.selectionSet||t)&&this.setSelection(e.state),(e.geometryChanged||e.docChanged||e.selectionSet)&&e.view.requestMeasure(this.measureReq)}resetRange(e){let{head:t}=e.selection.main;this.from=Math.max(0,t-1e4),this.to=Math.min(e.doc.length,t+1e4)}reset(e){this.resetRange(e),this.editContext.updateText(0,this.editContext.text.length,e.doc.sliceString(this.from,this.to)),this.setSelection(e)}revertPending(e){let t=this.pendingContextChange;this.pendingContextChange=null,this.editContext.updateText(this.toContextPos(t.from),this.toContextPos(t.from+t.insert.length),e.doc.sliceString(t.from,t.to))}setSelection(e){let{main:t}=e.selection,i=this.toContextPos(Math.max(this.from,Math.min(this.to,t.anchor))),r=this.toContextPos(t.head);(this.editContext.selectionStart!=i||this.editContext.selectionEnd!=r)&&this.editContext.updateSelection(i,r)}rangeIsValid(e){let{head:t}=e.selection.main;return!(this.from>0&&t-this.from<500||this.to1e4*3)}toEditorPos(e,t=this.to-this.from){e=Math.min(e,t);let i=this.composing;return i&&i.drifted?i.editorBase+(e-i.contextBase):e+this.from}toContextPos(e){let t=this.composing;return t&&t.drifted?t.contextBase+(e-t.editorBase):e-this.from}destroy(){for(let e in this.handlers)this.editContext.removeEventListener(e,this.handlers[e])}}class fe{get state(){return this.viewState.state}get viewport(){return this.viewState.viewport}get visibleRanges(){return this.viewState.visibleRanges}get inView(){return this.viewState.inView}get composing(){return!!this.inputState&&this.inputState.composing>0}get compositionStarted(){return!!this.inputState&&this.inputState.composing>=0}get root(){return this._root}get win(){return this.dom.ownerDocument.defaultView||window}constructor(e={}){var t;this.plugins=[],this.pluginMap=new Map,this.editorAttrs={},this.contentAttrs={},this.bidiCache=[],this.destroyed=!1,this.updateState=2,this.measureScheduled=-1,this.measureRequests=[],this.contentDOM=document.createElement("div"),this.scrollDOM=document.createElement("div"),this.scrollDOM.tabIndex=-1,this.scrollDOM.className="cm-scroller",this.scrollDOM.appendChild(this.contentDOM),this.announceDOM=document.createElement("div"),this.announceDOM.className="cm-announced",this.announceDOM.setAttribute("aria-live","polite"),this.dom=document.createElement("div"),this.dom.appendChild(this.announceDOM),this.dom.appendChild(this.scrollDOM),e.parent&&e.parent.appendChild(this.dom);let{dispatch:i}=e;this.dispatchTransactions=e.dispatchTransactions||i&&(r=>r.forEach(s=>i(s,this)))||(r=>this.update(r)),this.dispatch=this.dispatch.bind(this),this._root=e.root||VR(e.parent)||document,this.viewState=new mb(e.state||Ze.create(e)),e.scrollTo&&e.scrollTo.is(Xu)&&(this.viewState.scrollTarget=e.scrollTo.value.clip(this.viewState.state)),this.plugins=this.state.facet(Ro).map(r=>new Sg(r));for(let r of this.plugins)r.update(this);this.observer=new aE(this),this.inputState=new MA(this),this.inputState.ensureHandlers(this.plugins),this.docView=new Gv(this),this.mountStyles(),this.updateAttrs(),this.updateState=0,this.requestMeasure(),!((t=document.fonts)===null||t===void 0)&&t.ready&&document.fonts.ready.then(()=>this.requestMeasure())}dispatch(...e){let t=e.length==1&&e[0]instanceof St?e:e.length==1&&Array.isArray(e[0])?e[0]:[this.state.update(...e)];this.dispatchTransactions(t,this)}update(e){if(this.updateState!=0)throw new Error("Calls to EditorView.update are not allowed while an update is in progress");let t=!1,i=!1,r,s=this.state;for(let m of e){if(m.startState!=s)throw new RangeError("Trying to update state with a transaction that doesn't start from the previous state.");s=m.state}if(this.destroyed){this.viewState.state=s;return}let o=this.hasFocus,a=0,c=null;e.some(m=>m.annotation(x_))?(this.inputState.notifiedFocused=o,a=1):o!=this.inputState.notifiedFocused&&(this.inputState.notifiedFocused=o,c=v_(s,o),c||(a=1));let h=this.observer.delayedAndroidKey,f=null;if(h?(this.observer.clearDelayedAndroidKey(),f=this.observer.readChange(),(f&&!this.state.doc.eq(s.doc)||!this.state.selection.eq(s.selection))&&(f=null)):this.observer.clear(),s.facet(Ze.phrases)!=this.state.facet(Ze.phrases))return this.setState(s);r=of.create(this,s,e),r.flags|=a;let p=this.viewState.scrollTarget;try{this.updateState=2;for(let m of e){if(p&&(p=p.map(m.changes)),m.scrollIntoView){let{main:O}=m.state.selection;p=new Zo(O.empty?O:V.cursor(O.head,O.head>O.anchor?-1:1))}for(let O of m.effects)O.is(Xu)&&(p=O.value.clip(this.state))}this.viewState.update(r,p),this.bidiCache=af.update(this.bidiCache,r.changes),r.empty||(this.updatePlugins(r),this.inputState.update(r)),t=this.docView.update(r),this.state.facet(fa)!=this.styleModules&&this.mountStyles(),i=this.updateAttrs(),this.showAnnouncements(e),this.docView.updateSelection(t,e.some(m=>m.isUserEvent("select.pointer")))}finally{this.updateState=0}if(r.startState.facet(Uu)!=r.state.facet(Uu)&&(this.viewState.mustMeasureContent=!0),(t||i||p||this.viewState.mustEnforceCursorAssoc||this.viewState.mustMeasureContent)&&this.requestMeasure(),t&&this.docViewUpdate(),!r.empty)for(let m of this.state.facet(nO))try{m(r)}catch(O){bn(this.state,O,"update listener")}(c||f)&&Promise.resolve().then(()=>{c&&this.state==c.startState&&this.dispatch(c),f&&!f_(this,f)&&h.force&&jo(this.contentDOM,h.key,h.keyCode)})}setState(e){if(this.updateState!=0)throw new Error("Calls to EditorView.setState are not allowed while an update is in progress");if(this.destroyed){this.viewState.state=e;return}this.updateState=2;let t=this.hasFocus;try{for(let i of this.plugins)i.destroy(this);this.viewState=new mb(e),this.plugins=e.facet(Ro).map(i=>new Sg(i)),this.pluginMap.clear();for(let i of this.plugins)i.update(this);this.docView.destroy(),this.docView=new Gv(this),this.inputState.ensureHandlers(this.plugins),this.mountStyles(),this.updateAttrs(),this.bidiCache=[]}finally{this.updateState=0}t&&this.focus(),this.requestMeasure()}updatePlugins(e){let t=e.startState.facet(Ro),i=e.state.facet(Ro);if(t!=i){let r=[];for(let s of i){let o=t.indexOf(s);if(o<0)r.push(new Sg(s));else{let a=this.plugins[o];a.mustUpdate=e,r.push(a)}}for(let s of this.plugins)s.mustUpdate!=e&&s.destroy(this);this.plugins=r,this.pluginMap.clear()}else for(let r of this.plugins)r.mustUpdate=e;for(let r=0;r-1&&this.win.cancelAnimationFrame(this.measureScheduled),this.observer.delayedAndroidKey){this.measureScheduled=-1,this.requestMeasure();return}this.measureScheduled=0,e&&this.observer.forceFlush();let t=null,i=this.scrollDOM,r=i.scrollTop*this.scaleY,{scrollAnchorPos:s,scrollAnchorHeight:o}=this.viewState;Math.abs(r-this.viewState.scrollTop)>1&&(o=-1),this.viewState.scrollAnchorHeight=-1;try{for(let a=0;;a++){if(o<0)if(AP(i))s=-1,o=this.viewState.heightMap.height;else{let O=this.viewState.scrollAnchorAt(r);s=O.from,o=O.top}this.updateState=1;let c=this.viewState.measure(this);if(!c&&!this.measureRequests.length&&this.viewState.scrollTarget==null)break;if(a>5){console.warn(this.measureRequests.length?"Measure loop restarted more than 5 times":"Viewport failed to stabilize");break}let h=[];c&4||([this.measureRequests,h]=[h,this.measureRequests]);let f=h.map(O=>{try{return O.read(this)}catch(v){return bn(this.state,v),vb}}),p=of.create(this,this.state,[]),m=!1;p.flags|=c,t?t.flags|=c:t=p,this.updateState=2,p.empty||(this.updatePlugins(p),this.inputState.update(p),this.updateAttrs(),m=this.docView.update(p),m&&this.docViewUpdate());for(let O=0;O1||v<-1){r=r+v,i.scrollTop=r/this.scaleY,o=-1;continue}}break}}}finally{this.updateState=0,this.measureScheduled=-1}if(t&&!t.empty)for(let a of this.state.facet(nO))a(t)}get themeClasses(){return lO+" "+(this.state.facet(oO)?k_:w_)+" "+this.state.facet(Uu)}updateAttrs(){let e=bb(this,r_,{class:"cm-editor"+(this.hasFocus?" cm-focused ":" ")+this.themeClasses}),t={spellcheck:"false",autocorrect:"off",autocapitalize:"off",writingsuggestions:"false",translate:"no",contenteditable:this.state.facet(nr)?"true":"false",class:"cm-content",style:`${he.tabSize}: ${this.state.tabSize}`,role:"textbox","aria-multiline":"true"};this.state.readOnly&&(t["aria-readonly"]="true"),bb(this,p0,t);let i=this.observer.ignore(()=>{let r=Gm(this.contentDOM,this.contentAttrs,t),s=Gm(this.dom,this.editorAttrs,e);return r||s});return this.editorAttrs=e,this.contentAttrs=t,i}showAnnouncements(e){let t=!0;for(let i of e)for(let r of i.effects)if(r.is(fe.announce)){t&&(this.announceDOM.textContent=""),t=!1;let s=this.announceDOM.appendChild(document.createElement("div"));s.textContent=r.value}}mountStyles(){this.styleModules=this.state.facet(fa);let e=this.state.facet(fe.cspNonce);Ur.mount(this.root,this.styleModules.concat(oE).reverse(),e?{nonce:e}:void 0)}readMeasured(){if(this.updateState==2)throw new Error("Reading the editor layout isn't allowed during an update");this.updateState==0&&this.measureScheduled>-1&&this.measure(!1)}requestMeasure(e){if(this.measureScheduled<0&&(this.measureScheduled=this.win.requestAnimationFrame(()=>this.measure())),e){if(this.measureRequests.indexOf(e)>-1)return;if(e.key!=null){for(let t=0;ti.plugin==e)||null),t&&t.update(this).value}get documentTop(){return this.contentDOM.getBoundingClientRect().top+this.viewState.paddingTop}get documentPadding(){return{top:this.viewState.paddingTop,bottom:this.viewState.paddingBottom}}get scaleX(){return this.viewState.scaleX}get scaleY(){return this.viewState.scaleY}elementAtHeight(e){return this.readMeasured(),this.viewState.elementAtHeight(e)}lineBlockAtHeight(e){return this.readMeasured(),this.viewState.lineBlockAtHeight(e)}get viewportLineBlocks(){return this.viewState.viewportLines}lineBlockAt(e){return this.viewState.lineBlockAt(e)}get contentHeight(){return this.viewState.contentHeight}moveByChar(e,t,i){return kg(this,e,nb(this,e,t,i))}moveByGroup(e,t){return kg(this,e,nb(this,e,t,i=>SA(this,e.head,i)))}visualLineSide(e,t){let i=this.bidiSpans(e),r=this.textDirectionAt(e.from),s=i[t?i.length-1:0];return V.cursor(s.side(t,r)+e.from,s.forward(!t,r)?1:-1)}moveToLineBoundary(e,t,i=!0){return bA(this,e,t,i)}moveVertically(e,t,i){return kg(this,e,wA(this,e,t,i))}domAtPos(e){return this.docView.domAtPos(e)}posAtDOM(e,t=0){return this.docView.posFromDOM(e,t)}posAtCoords(e,t=!0){return this.readMeasured(),c_(this,e,t)}coordsAtPos(e,t=1){this.readMeasured();let i=this.docView.coordsAt(e,t);if(!i||i.left==i.right)return i;let r=this.state.doc.lineAt(e),s=this.bidiSpans(r),o=s[Zr.find(s,e-r.from,-1,t)];return qf(i,o.dir==st.LTR==t>0)}coordsForChar(e){return this.readMeasured(),this.docView.coordsForChar(e)}get defaultCharacterWidth(){return this.viewState.heightOracle.charWidth}get defaultLineHeight(){return this.viewState.heightOracle.lineHeight}get textDirection(){return this.viewState.defaultTextDirection}textDirectionAt(e){return!this.state.facet(e_)||ethis.viewport.to?this.textDirection:(this.readMeasured(),this.docView.textDirectionAt(e))}get lineWrapping(){return this.viewState.heightOracle.lineWrapping}bidiSpans(e){if(e.length>hE)return FP(e.length);let t=this.textDirectionAt(e.from),i;for(let s of this.bidiCache)if(s.from==e.from&&s.dir==t&&(s.fresh||VP(s.isolates,i=Hv(this,e))))return s.order;i||(i=Hv(this,e));let r=sA(e.text,t,i);return this.bidiCache.push(new af(e.from,e.to,t,i,!0,r)),r}get hasFocus(){var e;return(this.dom.ownerDocument.hasFocus()||he.safari&&((e=this.inputState)===null||e===void 0?void 0:e.lastContextMenu)>Date.now()-3e4)&&this.root.activeElement==this.contentDOM}focus(){this.observer.ignore(()=>{MP(this.contentDOM),this.docView.updateSelection()})}setRoot(e){this._root!=e&&(this._root=e,this.observer.setWindow((e.nodeType==9?e:e.ownerDocument).defaultView||window),this.mountStyles())}destroy(){this.root.activeElement==this.contentDOM&&this.contentDOM.blur();for(let e of this.plugins)e.destroy(this);this.plugins=[],this.inputState.destroy(),this.docView.destroy(),this.dom.remove(),this.observer.destroy(),this.measureScheduled>-1&&this.win.cancelAnimationFrame(this.measureScheduled),this.destroyed=!0}static scrollIntoView(e,t={}){return Xu.of(new Zo(typeof e=="number"?V.cursor(e):e,t.y,t.x,t.yMargin,t.xMargin))}scrollSnapshot(){let{scrollTop:e,scrollLeft:t}=this.scrollDOM,i=this.viewState.scrollAnchorAt(e);return Xu.of(new Zo(V.cursor(i.from),"start","start",i.top-e,t,!0))}setTabFocusMode(e){e==null?this.inputState.tabFocusMode=this.inputState.tabFocusMode<0?0:-1:typeof e=="boolean"?this.inputState.tabFocusMode=e?0:-1:this.inputState.tabFocusMode!=0&&(this.inputState.tabFocusMode=Date.now()+e)}static domEventHandlers(e){return kt.define(()=>({}),{eventHandlers:e})}static domEventObservers(e){return kt.define(()=>({}),{eventObservers:e})}static theme(e,t){let i=Ur.newName(),r=[Uu.of(i),fa.of(aO(`.${i}`,e))];return t&&t.dark&&r.push(oO.of(!0)),r}static baseTheme(e){return ts.lowest(fa.of(aO("."+lO,e,P_)))}static findFromDOM(e){var t;let i=e.querySelector(".cm-content"),r=i&&Ue.get(i)||Ue.get(e);return((t=r==null?void 0:r.rootView)===null||t===void 0?void 0:t.view)||null}}fe.styleModule=fa;fe.inputHandler=KP;fe.clipboardInputFilter=f0;fe.clipboardOutputFilter=d0;fe.scrollHandler=n_;fe.focusChangeEffect=JP;fe.perLineTextDirection=e_;fe.exceptionSink=GP;fe.updateListener=nO;fe.editable=nr;fe.mouseSelectionStyle=HP;fe.dragMovesSelection=UP;fe.clickAddsSelectionRange=qP;fe.decorations=Va;fe.outerDecorations=s_;fe.atomicRanges=mc;fe.bidiIsolatedRanges=o_;fe.scrollMargins=l_;fe.darkTheme=oO;fe.cspNonce=pe.define({combine:n=>n.length?n[0]:""});fe.contentAttributes=p0;fe.editorAttributes=r_;fe.lineWrapping=fe.contentAttributes.of({class:"cm-lineWrapping"});fe.announce=$e.define();const hE=4096,vb={};class af{constructor(e,t,i,r,s,o){this.from=e,this.to=t,this.dir=i,this.isolates=r,this.fresh=s,this.order=o}static update(e,t){if(t.empty&&!e.some(s=>s.fresh))return e;let i=[],r=e.length?e[e.length-1].dir:st.LTR;for(let s=Math.max(0,e.length-10);s=0;r--){let s=i[r],o=typeof s=="function"?s(n):s;o&&Hm(o,t)}return t}const fE=he.mac?"mac":he.windows?"win":he.linux?"linux":"key";function dE(n,e){const t=n.split(/-(?!$)/);let i=t[t.length-1];i=="Space"&&(i=" ");let r,s,o,a;for(let c=0;ci.concat(r),[]))),t}function gE(n,e,t){return Q_(__(n.state),e,n,t)}let Dr=null;const mE=4e3;function OE(n,e=fE){let t=Object.create(null),i=Object.create(null),r=(o,a)=>{let c=i[o];if(c==null)i[o]=a;else if(c!=a)throw new Error("Key binding "+o+" is used both as a regular binding and as a multi-stroke prefix")},s=(o,a,c,h,f)=>{var p,m;let O=t[o]||(t[o]=Object.create(null)),v=a.split(/ (?!$)/).map(w=>dE(w,e));for(let w=1;w{let _=Dr={view:k,prefix:T,scope:o};return setTimeout(()=>{Dr==_&&(Dr=null)},mE),!0}]})}let b=v.join(" ");r(b,!1);let S=O[b]||(O[b]={preventDefault:!1,stopPropagation:!1,run:((m=(p=O._any)===null||p===void 0?void 0:p.run)===null||m===void 0?void 0:m.slice())||[]});c&&S.run.push(c),h&&(S.preventDefault=!0),f&&(S.stopPropagation=!0)};for(let o of n){let a=o.scope?o.scope.split(" "):["editor"];if(o.any)for(let h of a){let f=t[h]||(t[h]=Object.create(null));f._any||(f._any={preventDefault:!1,stopPropagation:!1,run:[]});let{any:p}=o;for(let m in f)f[m].run.push(O=>p(O,cO))}let c=o[e]||o.key;if(c)for(let h of a)s(h,c,o.run,o.preventDefault,o.stopPropagation),o.shift&&s(h,"Shift-"+c,o.shift,o.preventDefault,o.stopPropagation)}return t}let cO=null;function Q_(n,e,t,i){cO=e;let r=ZR(e),s=yn(r,0),o=$i(s)==r.length&&r!=" ",a="",c=!1,h=!1,f=!1;Dr&&Dr.view==t&&Dr.scope==i&&(a=Dr.prefix+" ",g_.indexOf(e.keyCode)<0&&(h=!0,Dr=null));let p=new Set,m=S=>{if(S){for(let w of S.run)if(!p.has(w)&&(p.add(w),w(t)))return S.stopPropagation&&(f=!0),!0;S.preventDefault&&(S.stopPropagation&&(f=!0),h=!0)}return!1},O=n[i],v,b;return O&&(m(O[a+Hu(r,e,!o)])?c=!0:o&&(e.altKey||e.metaKey||e.ctrlKey)&&!(he.windows&&e.ctrlKey&&e.altKey)&&!(he.mac&&e.altKey&&!(e.ctrlKey||e.metaKey))&&(v=Hr[e.keyCode])&&v!=r?(m(O[a+Hu(v,e,!0)])||e.shiftKey&&(b=Ba[e.keyCode])!=r&&b!=v&&m(O[a+Hu(b,e,!1)]))&&(c=!0):o&&e.shiftKey&&m(O[a+Hu(r,e,!0)])&&(c=!0),!c&&m(O._any)&&(c=!0)),h&&(c=!0),c&&f&&e.stopPropagation(),cO=null,c}class yc{constructor(e,t,i,r,s){this.className=e,this.left=t,this.top=i,this.width=r,this.height=s}draw(){let e=document.createElement("div");return e.className=this.className,this.adjust(e),e}update(e,t){return t.className!=this.className?!1:(this.adjust(e),!0)}adjust(e){e.style.left=this.left+"px",e.style.top=this.top+"px",this.width!=null&&(e.style.width=this.width+"px"),e.style.height=this.height+"px"}eq(e){return this.left==e.left&&this.top==e.top&&this.width==e.width&&this.height==e.height&&this.className==e.className}static forRange(e,t,i){if(i.empty){let r=e.coordsAtPos(i.head,i.assoc||1);if(!r)return[];let s=C_(e);return[new yc(t,r.left-s.left,r.top-s.top,null,r.bottom-r.top)]}else return yE(e,t,i)}}function C_(n){let e=n.scrollDOM.getBoundingClientRect();return{left:(n.textDirection==st.LTR?e.left:e.right-n.scrollDOM.clientWidth*n.scaleX)-n.scrollDOM.scrollLeft*n.scaleX,top:e.top-n.scrollDOM.scrollTop*n.scaleY}}function wb(n,e,t,i){let r=n.coordsAtPos(e,t*2);if(!r)return i;let s=n.dom.getBoundingClientRect(),o=(r.top+r.bottom)/2,a=n.posAtCoords({x:s.left+1,y:o}),c=n.posAtCoords({x:s.right-1,y:o});return a==null||c==null?i:{from:Math.max(i.from,Math.min(a,c)),to:Math.min(i.to,Math.max(a,c))}}function yE(n,e,t){if(t.to<=n.viewport.from||t.from>=n.viewport.to)return[];let i=Math.max(t.from,n.viewport.from),r=Math.min(t.to,n.viewport.to),s=n.textDirection==st.LTR,o=n.contentDOM,a=o.getBoundingClientRect(),c=C_(n),h=o.querySelector(".cm-line"),f=h&&window.getComputedStyle(h),p=a.left+(f?parseInt(f.paddingLeft)+Math.min(0,parseInt(f.textIndent)):0),m=a.right-(f?parseInt(f.paddingRight):0),O=rO(n,i,1),v=rO(n,r,-1),b=O.type==an.Text?O:null,S=v.type==an.Text?v:null;if(b&&(n.lineWrapping||O.widgetLineBreaks)&&(b=wb(n,i,1,b)),S&&(n.lineWrapping||v.widgetLineBreaks)&&(S=wb(n,r,-1,S)),b&&S&&b.from==S.from&&b.to==S.to)return T(k(t.from,t.to,b));{let C=b?k(t.from,null,b):_(O,!1),M=S?k(null,t.to,S):_(v,!0),R=[];return(b||O).to<(S||v).from-(b&&S?1:0)||O.widgetLineBreaks>1&&C.bottom+n.defaultLineHeight/2H&&re.from=ce)break;J>oe&&Y(Math.max(U,oe),C==null&&U<=H,Math.min(J,ce),M==null&&J>=F,q.dir)}if(oe=ue.to+1,oe>=ce)break}return ie.length==0&&Y(H,C==null,F,M==null,n.textDirection),{top:L,bottom:X,horizontal:ie}}function _(C,M){let R=a.top+(M?C.top:C.bottom);return{top:R,bottom:R,horizontal:[]}}}function xE(n,e){return n.constructor==e.constructor&&n.eq(e)}class vE{constructor(e,t){this.view=e,this.layer=t,this.drawn=[],this.scaleX=1,this.scaleY=1,this.measureReq={read:this.measure.bind(this),write:this.draw.bind(this)},this.dom=e.scrollDOM.appendChild(document.createElement("div")),this.dom.classList.add("cm-layer"),t.above&&this.dom.classList.add("cm-layer-above"),t.class&&this.dom.classList.add(t.class),this.scale(),this.dom.setAttribute("aria-hidden","true"),this.setOrder(e.state),e.requestMeasure(this.measureReq),t.mount&&t.mount(this.dom,e)}update(e){e.startState.facet(Lh)!=e.state.facet(Lh)&&this.setOrder(e.state),(this.layer.update(e,this.dom)||e.geometryChanged)&&(this.scale(),e.view.requestMeasure(this.measureReq))}docViewUpdate(e){this.layer.updateOnDocViewUpdate!==!1&&e.requestMeasure(this.measureReq)}setOrder(e){let t=0,i=e.facet(Lh);for(;t!xE(t,this.drawn[i]))){let t=this.dom.firstChild,i=0;for(let r of e)r.update&&t&&r.constructor&&this.drawn[i].constructor&&r.update(t,this.drawn[i])?(t=t.nextSibling,i++):this.dom.insertBefore(r.draw(),t);for(;t;){let r=t.nextSibling;t.remove(),t=r}this.drawn=e,he.ios&&(this.dom.style.display=this.dom.firstChild?"":"none")}}destroy(){this.layer.destroy&&this.layer.destroy(this.dom,this.view),this.dom.remove()}}const Lh=pe.define();function T_(n){return[kt.define(e=>new vE(e,n)),Lh.of(n)]}const Fa=pe.define({combine(n){return Zi(n,{cursorBlinkRate:1200,drawRangeCursor:!0},{cursorBlinkRate:(e,t)=>Math.min(e,t),drawRangeCursor:(e,t)=>e||t})}});function bE(n={}){return[Fa.of(n),SE,wE,kE,t_.of(!0)]}function $_(n){return n.startState.facet(Fa)!=n.state.facet(Fa)}const SE=T_({above:!0,markers(n){let{state:e}=n,t=e.facet(Fa),i=[];for(let r of e.selection.ranges){let s=r==e.selection.main;if(r.empty||t.drawRangeCursor){let o=s?"cm-cursor cm-cursor-primary":"cm-cursor cm-cursor-secondary",a=r.empty?r:V.cursor(r.head,r.head>r.anchor?-1:1);for(let c of yc.forRange(n,o,a))i.push(c)}}return i},update(n,e){n.transactions.some(i=>i.selection)&&(e.style.animationName=e.style.animationName=="cm-blink"?"cm-blink2":"cm-blink");let t=$_(n);return t&&kb(n.state,e),n.docChanged||n.selectionSet||t},mount(n,e){kb(e.state,n)},class:"cm-cursorLayer"});function kb(n,e){e.style.animationDuration=n.facet(Fa).cursorBlinkRate+"ms"}const wE=T_({above:!1,markers(n){return n.state.selection.ranges.map(e=>e.empty?[]:yc.forRange(n,"cm-selectionBackground",e)).reduce((e,t)=>e.concat(t))},update(n,e){return n.docChanged||n.selectionSet||n.viewportChanged||$_(n)},class:"cm-selectionLayer"}),kE=ts.highest(fe.theme({".cm-line":{"& ::selection, &::selection":{backgroundColor:"transparent !important"},caretColor:"transparent !important"},".cm-content":{caretColor:"transparent !important","& :focus":{caretColor:"initial !important","&::selection, & ::selection":{backgroundColor:"Highlight !important"}}}})),M_=$e.define({map(n,e){return n==null?null:e.mapPos(n)}}),ga=jt.define({create(){return null},update(n,e){return n!=null&&(n=e.changes.mapPos(n)),e.effects.reduce((t,i)=>i.is(M_)?i.value:t,n)}}),PE=kt.fromClass(class{constructor(n){this.view=n,this.cursor=null,this.measureReq={read:this.readPos.bind(this),write:this.drawCursor.bind(this)}}update(n){var e;let t=n.state.field(ga);t==null?this.cursor!=null&&((e=this.cursor)===null||e===void 0||e.remove(),this.cursor=null):(this.cursor||(this.cursor=this.view.scrollDOM.appendChild(document.createElement("div")),this.cursor.className="cm-dropCursor"),(n.startState.field(ga)!=t||n.docChanged||n.geometryChanged)&&this.view.requestMeasure(this.measureReq))}readPos(){let{view:n}=this,e=n.state.field(ga),t=e!=null&&n.coordsAtPos(e);if(!t)return null;let i=n.scrollDOM.getBoundingClientRect();return{left:t.left-i.left+n.scrollDOM.scrollLeft*n.scaleX,top:t.top-i.top+n.scrollDOM.scrollTop*n.scaleY,height:t.bottom-t.top}}drawCursor(n){if(this.cursor){let{scaleX:e,scaleY:t}=this.view;n?(this.cursor.style.left=n.left/e+"px",this.cursor.style.top=n.top/t+"px",this.cursor.style.height=n.height/t+"px"):this.cursor.style.left="-100000px"}}destroy(){this.cursor&&this.cursor.remove()}setDropPos(n){this.view.state.field(ga)!=n&&this.view.dispatch({effects:M_.of(n)})}},{eventObservers:{dragover(n){this.setDropPos(this.view.posAtCoords({x:n.clientX,y:n.clientY}))},dragleave(n){(n.target==this.view.contentDOM||!this.view.contentDOM.contains(n.relatedTarget))&&this.setDropPos(null)},dragend(){this.setDropPos(null)},drop(){this.setDropPos(null)}}});function _E(){return[ga,PE]}function Pb(n,e,t,i,r){e.lastIndex=0;for(let s=n.iterRange(t,i),o=t,a;!s.next().done;o+=s.value.length)if(!s.lineBreak)for(;a=e.exec(s.value);)r(o+a.index,a)}function QE(n,e){let t=n.visibleRanges;if(t.length==1&&t[0].from==n.viewport.from&&t[0].to==n.viewport.to)return t;let i=[];for(let{from:r,to:s}of t)r=Math.max(n.state.doc.lineAt(r).from,r-e),s=Math.min(n.state.doc.lineAt(s).to,s+e),i.length&&i[i.length-1].to>=r?i[i.length-1].to=s:i.push({from:r,to:s});return i}class CE{constructor(e){const{regexp:t,decoration:i,decorate:r,boundary:s,maxLength:o=1e3}=e;if(!t.global)throw new RangeError("The regular expression given to MatchDecorator should have its 'g' flag set");if(this.regexp=t,r)this.addMatch=(a,c,h,f)=>r(f,h,h+a[0].length,a,c);else if(typeof i=="function")this.addMatch=(a,c,h,f)=>{let p=i(a,c,h);p&&f(h,h+a[0].length,p)};else if(i)this.addMatch=(a,c,h,f)=>f(h,h+a[0].length,i);else throw new RangeError("Either 'decorate' or 'decoration' should be provided to MatchDecorator");this.boundary=s,this.maxLength=o}createDeco(e){let t=new or,i=t.add.bind(t);for(let{from:r,to:s}of QE(e,this.maxLength))Pb(e.state.doc,this.regexp,r,s,(o,a)=>this.addMatch(a,e,o,i));return t.finish()}updateDeco(e,t){let i=1e9,r=-1;return e.docChanged&&e.changes.iterChanges((s,o,a,c)=>{c>=e.view.viewport.from&&a<=e.view.viewport.to&&(i=Math.min(a,i),r=Math.max(c,r))}),e.viewportMoved||r-i>1e3?this.createDeco(e.view):r>-1?this.updateRange(e.view,t.map(e.changes),i,r):t}updateRange(e,t,i,r){for(let s of e.visibleRanges){let o=Math.max(s.from,i),a=Math.min(s.to,r);if(a>=o){let c=e.state.doc.lineAt(o),h=c.toc.from;o--)if(this.boundary.test(c.text[o-1-c.from])){f=o;break}for(;am.push(w.range(b,S));if(c==h)for(this.regexp.lastIndex=f-c.from;(O=this.regexp.exec(c.text))&&O.indexthis.addMatch(S,e,b,v));t=t.update({filterFrom:f,filterTo:p,filter:(b,S)=>bp,add:m})}}return t}}const uO=/x/.unicode!=null?"gu":"g",TE=new RegExp(`[\0-\b +--Ÿ­؜​‎‏\u2028\u2029‭‮⁦⁧⁩\uFEFF-]`,uO),$E={0:"null",7:"bell",8:"backspace",10:"newline",11:"vertical tab",13:"carriage return",27:"escape",8203:"zero width space",8204:"zero width non-joiner",8205:"zero width joiner",8206:"left-to-right mark",8207:"right-to-left mark",8232:"line separator",8237:"left-to-right override",8238:"right-to-left override",8294:"left-to-right isolate",8295:"right-to-left isolate",8297:"pop directional isolate",8233:"paragraph separator",65279:"zero width no-break space",65532:"object replacement"};let Qg=null;function ME(){var n;if(Qg==null&&typeof document<"u"&&document.body){let e=document.body.style;Qg=((n=e.tabSize)!==null&&n!==void 0?n:e.MozTabSize)!=null}return Qg||!1}const Dh=pe.define({combine(n){let e=Zi(n,{render:null,specialChars:TE,addSpecialChars:null});return(e.replaceTabs=!ME())&&(e.specialChars=new RegExp(" |"+e.specialChars.source,uO)),e.addSpecialChars&&(e.specialChars=new RegExp(e.specialChars.source+"|"+e.addSpecialChars.source,uO)),e}});function RE(n={}){return[Dh.of(n),AE()]}let _b=null;function AE(){return _b||(_b=kt.fromClass(class{constructor(n){this.view=n,this.decorations=_e.none,this.decorationCache=Object.create(null),this.decorator=this.makeDecorator(n.state.facet(Dh)),this.decorations=this.decorator.createDeco(n)}makeDecorator(n){return new CE({regexp:n.specialChars,decoration:(e,t,i)=>{let{doc:r}=t.state,s=yn(e[0],0);if(s==9){let o=r.lineAt(i),a=t.state.tabSize,c=ol(o.text,a,i-o.from);return _e.replace({widget:new zE((a-c%a)*this.view.defaultCharacterWidth/this.view.scaleX)})}return this.decorationCache[s]||(this.decorationCache[s]=_e.replace({widget:new DE(n,s)}))},boundary:n.replaceTabs?void 0:/[^]/})}update(n){let e=n.state.facet(Dh);n.startState.facet(Dh)!=e?(this.decorator=this.makeDecorator(e),this.decorations=this.decorator.createDeco(n.view)):this.decorations=this.decorator.updateDeco(n,this.decorations)}},{decorations:n=>n.decorations}))}const EE="•";function LE(n){return n>=32?EE:n==10?"␤":String.fromCharCode(9216+n)}class DE extends ur{constructor(e,t){super(),this.options=e,this.code=t}eq(e){return e.code==this.code}toDOM(e){let t=LE(this.code),i=e.state.phrase("Control character")+" "+($E[this.code]||"0x"+this.code.toString(16)),r=this.options.render&&this.options.render(this.code,i,t);if(r)return r;let s=document.createElement("span");return s.textContent=t,s.title=i,s.setAttribute("aria-label",i),s.className="cm-specialChar",s}ignoreEvent(){return!1}}class zE extends ur{constructor(e){super(),this.width=e}eq(e){return e.width==this.width}toDOM(){let e=document.createElement("span");return e.textContent=" ",e.className="cm-tab",e.style.width=this.width+"px",e}ignoreEvent(){return!1}}function jE(){return IE}const ZE=_e.line({class:"cm-activeLine"}),IE=kt.fromClass(class{constructor(n){this.decorations=this.getDeco(n)}update(n){(n.docChanged||n.selectionSet)&&(this.decorations=this.getDeco(n.view))}getDeco(n){let e=-1,t=[];for(let i of n.state.selection.ranges){let r=n.lineBlockAt(i.head);r.from>e&&(t.push(ZE.range(r.from)),e=r.from)}return _e.set(t)}},{decorations:n=>n.decorations}),hO=2e3;function NE(n,e,t){let i=Math.min(e.line,t.line),r=Math.max(e.line,t.line),s=[];if(e.off>hO||t.off>hO||e.col<0||t.col<0){let o=Math.min(e.off,t.off),a=Math.max(e.off,t.off);for(let c=i;c<=r;c++){let h=n.doc.line(c);h.length<=a&&s.push(V.range(h.from+o,h.to+a))}}else{let o=Math.min(e.col,t.col),a=Math.max(e.col,t.col);for(let c=i;c<=r;c++){let h=n.doc.line(c),f=Bm(h.text,o,n.tabSize,!0);if(f<0)s.push(V.cursor(h.to));else{let p=Bm(h.text,a,n.tabSize);s.push(V.range(h.from+f,h.from+p))}}}return s}function BE(n,e){let t=n.coordsAtPos(n.viewport.from);return t?Math.round(Math.abs((t.left-e)/n.defaultCharacterWidth)):-1}function Qb(n,e){let t=n.posAtCoords({x:e.clientX,y:e.clientY},!1),i=n.state.doc.lineAt(t),r=t-i.from,s=r>hO?-1:r==i.length?BE(n,e.clientX):ol(i.text,n.state.tabSize,t-i.from);return{line:i.number,col:s,off:r}}function XE(n,e){let t=Qb(n,e),i=n.state.selection;return t?{update(r){if(r.docChanged){let s=r.changes.mapPos(r.startState.doc.line(t.line).from),o=r.state.doc.lineAt(s);t={line:o.number,col:t.col,off:Math.min(t.off,o.length)},i=i.map(r.changes)}},get(r,s,o){let a=Qb(n,r);if(!a)return i;let c=NE(n.state,t,a);return c.length?o?V.create(c.concat(i.ranges)):V.create(c):i}}:null}function WE(n){let e=(t=>t.altKey&&t.button==0);return fe.mouseSelectionStyle.of((t,i)=>e(i)?XE(t,i):null)}const VE={Alt:[18,n=>!!n.altKey],Control:[17,n=>!!n.ctrlKey],Shift:[16,n=>!!n.shiftKey],Meta:[91,n=>!!n.metaKey]},FE={style:"cursor: crosshair"};function YE(n={}){let[e,t]=VE[n.key||"Alt"],i=kt.fromClass(class{constructor(r){this.view=r,this.isDown=!1}set(r){this.isDown!=r&&(this.isDown=r,this.view.update([]))}},{eventObservers:{keydown(r){this.set(r.keyCode==e||t(r))},keyup(r){(r.keyCode==e||!t(r))&&this.set(!1)},mousemove(r){this.set(t(r))}}});return[i,fe.contentAttributes.of(r=>{var s;return!((s=r.plugin(i))===null||s===void 0)&&s.isDown?FE:null})]}const ta="-10000px";class R_{constructor(e,t,i,r){this.facet=t,this.createTooltipView=i,this.removeTooltipView=r,this.input=e.state.facet(t),this.tooltips=this.input.filter(o=>o);let s=null;this.tooltipViews=this.tooltips.map(o=>s=i(o,s))}update(e,t){var i;let r=e.state.facet(this.facet),s=r.filter(c=>c);if(r===this.input){for(let c of this.tooltipViews)c.update&&c.update(e);return!1}let o=[],a=t?[]:null;for(let c=0;ct[h]=c),t.length=a.length),this.input=r,this.tooltips=s,this.tooltipViews=o,!0}}function qE(n){let e=n.dom.ownerDocument.documentElement;return{top:0,left:0,bottom:e.clientHeight,right:e.clientWidth}}const Cg=pe.define({combine:n=>{var e,t,i;return{position:he.ios?"absolute":((e=n.find(r=>r.position))===null||e===void 0?void 0:e.position)||"fixed",parent:((t=n.find(r=>r.parent))===null||t===void 0?void 0:t.parent)||null,tooltipSpace:((i=n.find(r=>r.tooltipSpace))===null||i===void 0?void 0:i.tooltipSpace)||qE}}}),Cb=new WeakMap,x0=kt.fromClass(class{constructor(n){this.view=n,this.above=[],this.inView=!0,this.madeAbsolute=!1,this.lastTransaction=0,this.measureTimeout=-1;let e=n.state.facet(Cg);this.position=e.position,this.parent=e.parent,this.classes=n.themeClasses,this.createContainer(),this.measureReq={read:this.readMeasure.bind(this),write:this.writeMeasure.bind(this),key:this},this.resizeObserver=typeof ResizeObserver=="function"?new ResizeObserver(()=>this.measureSoon()):null,this.manager=new R_(n,v0,(t,i)=>this.createTooltip(t,i),t=>{this.resizeObserver&&this.resizeObserver.unobserve(t.dom),t.dom.remove()}),this.above=this.manager.tooltips.map(t=>!!t.above),this.intersectionObserver=typeof IntersectionObserver=="function"?new IntersectionObserver(t=>{Date.now()>this.lastTransaction-50&&t.length>0&&t[t.length-1].intersectionRatio<1&&this.measureSoon()},{threshold:[1]}):null,this.observeIntersection(),n.win.addEventListener("resize",this.measureSoon=this.measureSoon.bind(this)),this.maybeMeasure()}createContainer(){this.parent?(this.container=document.createElement("div"),this.container.style.position="relative",this.container.className=this.view.themeClasses,this.parent.appendChild(this.container)):this.container=this.view.dom}observeIntersection(){if(this.intersectionObserver){this.intersectionObserver.disconnect();for(let n of this.manager.tooltipViews)this.intersectionObserver.observe(n.dom)}}measureSoon(){this.measureTimeout<0&&(this.measureTimeout=setTimeout(()=>{this.measureTimeout=-1,this.maybeMeasure()},50))}update(n){n.transactions.length&&(this.lastTransaction=Date.now());let e=this.manager.update(n,this.above);e&&this.observeIntersection();let t=e||n.geometryChanged,i=n.state.facet(Cg);if(i.position!=this.position&&!this.madeAbsolute){this.position=i.position;for(let r of this.manager.tooltipViews)r.dom.style.position=this.position;t=!0}if(i.parent!=this.parent){this.parent&&this.container.remove(),this.parent=i.parent,this.createContainer();for(let r of this.manager.tooltipViews)this.container.appendChild(r.dom);t=!0}else this.parent&&this.view.themeClasses!=this.classes&&(this.classes=this.container.className=this.view.themeClasses);t&&this.maybeMeasure()}createTooltip(n,e){let t=n.create(this.view),i=e?e.dom:null;if(t.dom.classList.add("cm-tooltip"),n.arrow&&!t.dom.querySelector(".cm-tooltip > .cm-tooltip-arrow")){let r=document.createElement("div");r.className="cm-tooltip-arrow",t.dom.appendChild(r)}return t.dom.style.position=this.position,t.dom.style.top=ta,t.dom.style.left="0px",this.container.insertBefore(t.dom,i),t.mount&&t.mount(this.view),this.resizeObserver&&this.resizeObserver.observe(t.dom),t}destroy(){var n,e,t;this.view.win.removeEventListener("resize",this.measureSoon);for(let i of this.manager.tooltipViews)i.dom.remove(),(n=i.destroy)===null||n===void 0||n.call(i);this.parent&&this.container.remove(),(e=this.resizeObserver)===null||e===void 0||e.disconnect(),(t=this.intersectionObserver)===null||t===void 0||t.disconnect(),clearTimeout(this.measureTimeout)}readMeasure(){let n=1,e=1,t=!1;if(this.position=="fixed"&&this.manager.tooltipViews.length){let{dom:s}=this.manager.tooltipViews[0];if(he.gecko)t=s.offsetParent!=this.container.ownerDocument.body;else if(s.style.top==ta&&s.style.left=="0px"){let o=s.getBoundingClientRect();t=Math.abs(o.top+1e4)>1||Math.abs(o.left)>1}}if(t||this.position=="absolute")if(this.parent){let s=this.parent.getBoundingClientRect();s.width&&s.height&&(n=s.width/this.parent.offsetWidth,e=s.height/this.parent.offsetHeight)}else({scaleX:n,scaleY:e}=this.view.viewState);let i=this.view.scrollDOM.getBoundingClientRect(),r=g0(this.view);return{visible:{left:i.left+r.left,top:i.top+r.top,right:i.right-r.right,bottom:i.bottom-r.bottom},parent:this.parent?this.container.getBoundingClientRect():this.view.dom.getBoundingClientRect(),pos:this.manager.tooltips.map((s,o)=>{let a=this.manager.tooltipViews[o];return a.getCoords?a.getCoords(s.pos):this.view.coordsAtPos(s.pos)}),size:this.manager.tooltipViews.map(({dom:s})=>s.getBoundingClientRect()),space:this.view.state.facet(Cg).tooltipSpace(this.view),scaleX:n,scaleY:e,makeAbsolute:t}}writeMeasure(n){var e;if(n.makeAbsolute){this.madeAbsolute=!0,this.position="absolute";for(let a of this.manager.tooltipViews)a.dom.style.position="absolute"}let{visible:t,space:i,scaleX:r,scaleY:s}=n,o=[];for(let a=0;a=Math.min(t.bottom,i.bottom)||p.rightMath.min(t.right,i.right)+.1)){f.style.top=ta;continue}let O=c.arrow?h.dom.querySelector(".cm-tooltip-arrow"):null,v=O?7:0,b=m.right-m.left,S=(e=Cb.get(h))!==null&&e!==void 0?e:m.bottom-m.top,w=h.offset||HE,T=this.view.textDirection==st.LTR,k=m.width>i.right-i.left?T?i.left:i.right-m.width:T?Math.max(i.left,Math.min(p.left-(O?14:0)+w.x,i.right-b)):Math.min(Math.max(i.left,p.left-b+(O?14:0)-w.x),i.right-b),_=this.above[a];!c.strictSide&&(_?p.top-S-v-w.yi.bottom)&&_==i.bottom-p.bottom>p.top-i.top&&(_=this.above[a]=!_);let C=(_?p.top-i.top:i.bottom-p.bottom)-v;if(Ck&&L.topM&&(M=_?L.top-S-2-v:L.bottom+v+2);if(this.position=="absolute"?(f.style.top=(M-n.parent.top)/s+"px",Tb(f,(k-n.parent.left)/r)):(f.style.top=M/s+"px",Tb(f,k/r)),O){let L=p.left+(T?w.x:-w.x)-(k+14-7);O.style.left=L/r+"px"}h.overlap!==!0&&o.push({left:k,top:M,right:R,bottom:M+S}),f.classList.toggle("cm-tooltip-above",_),f.classList.toggle("cm-tooltip-below",!_),h.positioned&&h.positioned(n.space)}}maybeMeasure(){if(this.manager.tooltips.length&&(this.view.inView&&this.view.requestMeasure(this.measureReq),this.inView!=this.view.inView&&(this.inView=this.view.inView,!this.inView)))for(let n of this.manager.tooltipViews)n.dom.style.top=ta}},{eventObservers:{scroll(){this.maybeMeasure()}}});function Tb(n,e){let t=parseInt(n.style.left,10);(isNaN(t)||Math.abs(e-t)>1)&&(n.style.left=e+"px")}const UE=fe.baseTheme({".cm-tooltip":{zIndex:500,boxSizing:"border-box"},"&light .cm-tooltip":{border:"1px solid #bbb",backgroundColor:"#f5f5f5"},"&light .cm-tooltip-section:not(:first-child)":{borderTop:"1px solid #bbb"},"&dark .cm-tooltip":{backgroundColor:"#333338",color:"white"},".cm-tooltip-arrow":{height:"7px",width:"14px",position:"absolute",zIndex:-1,overflow:"hidden","&:before, &:after":{content:"''",position:"absolute",width:0,height:0,borderLeft:"7px solid transparent",borderRight:"7px solid transparent"},".cm-tooltip-above &":{bottom:"-7px","&:before":{borderTop:"7px solid #bbb"},"&:after":{borderTop:"7px solid #f5f5f5",bottom:"1px"}},".cm-tooltip-below &":{top:"-7px","&:before":{borderBottom:"7px solid #bbb"},"&:after":{borderBottom:"7px solid #f5f5f5",top:"1px"}}},"&dark .cm-tooltip .cm-tooltip-arrow":{"&:before":{borderTopColor:"#333338",borderBottomColor:"#333338"},"&:after":{borderTopColor:"transparent",borderBottomColor:"transparent"}}}),HE={x:0,y:0},v0=pe.define({enables:[x0,UE]}),cf=pe.define({combine:n=>n.reduce((e,t)=>e.concat(t),[])});class Hf{static create(e){return new Hf(e)}constructor(e){this.view=e,this.mounted=!1,this.dom=document.createElement("div"),this.dom.classList.add("cm-tooltip-hover"),this.manager=new R_(e,cf,(t,i)=>this.createHostedView(t,i),t=>t.dom.remove())}createHostedView(e,t){let i=e.create(this.view);return i.dom.classList.add("cm-tooltip-section"),this.dom.insertBefore(i.dom,t?t.dom.nextSibling:this.dom.firstChild),this.mounted&&i.mount&&i.mount(this.view),i}mount(e){for(let t of this.manager.tooltipViews)t.mount&&t.mount(e);this.mounted=!0}positioned(e){for(let t of this.manager.tooltipViews)t.positioned&&t.positioned(e)}update(e){this.manager.update(e)}destroy(){var e;for(let t of this.manager.tooltipViews)(e=t.destroy)===null||e===void 0||e.call(t)}passProp(e){let t;for(let i of this.manager.tooltipViews){let r=i[e];if(r!==void 0){if(t===void 0)t=r;else if(t!==r)return}}return t}get offset(){return this.passProp("offset")}get getCoords(){return this.passProp("getCoords")}get overlap(){return this.passProp("overlap")}get resize(){return this.passProp("resize")}}const GE=v0.compute([cf],n=>{let e=n.facet(cf);return e.length===0?null:{pos:Math.min(...e.map(t=>t.pos)),end:Math.max(...e.map(t=>{var i;return(i=t.end)!==null&&i!==void 0?i:t.pos})),create:Hf.create,above:e[0].above,arrow:e.some(t=>t.arrow)}});class KE{constructor(e,t,i,r,s){this.view=e,this.source=t,this.field=i,this.setHover=r,this.hoverTime=s,this.hoverTimeout=-1,this.restartTimeout=-1,this.pending=null,this.lastMove={x:0,y:0,target:e.dom,time:0},this.checkHover=this.checkHover.bind(this),e.dom.addEventListener("mouseleave",this.mouseleave=this.mouseleave.bind(this)),e.dom.addEventListener("mousemove",this.mousemove=this.mousemove.bind(this))}update(){this.pending&&(this.pending=null,clearTimeout(this.restartTimeout),this.restartTimeout=setTimeout(()=>this.startHover(),20))}get active(){return this.view.state.field(this.field)}checkHover(){if(this.hoverTimeout=-1,this.active.length)return;let e=Date.now()-this.lastMove.time;ea.bottom||t.xa.right+e.defaultCharacterWidth)return;let c=e.bidiSpans(e.state.doc.lineAt(r)).find(f=>f.from<=r&&f.to>=r),h=c&&c.dir==st.RTL?-1:1;s=t.x{this.pending==a&&(this.pending=null,c&&!(Array.isArray(c)&&!c.length)&&e.dispatch({effects:this.setHover.of(Array.isArray(c)?c:[c])}))},c=>bn(e.state,c,"hover tooltip"))}else o&&!(Array.isArray(o)&&!o.length)&&e.dispatch({effects:this.setHover.of(Array.isArray(o)?o:[o])})}get tooltip(){let e=this.view.plugin(x0),t=e?e.manager.tooltips.findIndex(i=>i.create==Hf.create):-1;return t>-1?e.manager.tooltipViews[t]:null}mousemove(e){var t,i;this.lastMove={x:e.clientX,y:e.clientY,target:e.target,time:Date.now()},this.hoverTimeout<0&&(this.hoverTimeout=setTimeout(this.checkHover,this.hoverTime));let{active:r,tooltip:s}=this;if(r.length&&s&&!JE(s.dom,e)||this.pending){let{pos:o}=r[0]||this.pending,a=(i=(t=r[0])===null||t===void 0?void 0:t.end)!==null&&i!==void 0?i:o;(o==a?this.view.posAtCoords(this.lastMove)!=o:!eL(this.view,o,a,e.clientX,e.clientY))&&(this.view.dispatch({effects:this.setHover.of([])}),this.pending=null)}}mouseleave(e){clearTimeout(this.hoverTimeout),this.hoverTimeout=-1;let{active:t}=this;if(t.length){let{tooltip:i}=this;i&&i.dom.contains(e.relatedTarget)?this.watchTooltipLeave(i.dom):this.view.dispatch({effects:this.setHover.of([])})}}watchTooltipLeave(e){let t=i=>{e.removeEventListener("mouseleave",t),this.active.length&&!this.view.dom.contains(i.relatedTarget)&&this.view.dispatch({effects:this.setHover.of([])})};e.addEventListener("mouseleave",t)}destroy(){clearTimeout(this.hoverTimeout),this.view.dom.removeEventListener("mouseleave",this.mouseleave),this.view.dom.removeEventListener("mousemove",this.mousemove)}}const Gu=4;function JE(n,e){let{left:t,right:i,top:r,bottom:s}=n.getBoundingClientRect(),o;if(o=n.querySelector(".cm-tooltip-arrow")){let a=o.getBoundingClientRect();r=Math.min(a.top,r),s=Math.max(a.bottom,s)}return e.clientX>=t-Gu&&e.clientX<=i+Gu&&e.clientY>=r-Gu&&e.clientY<=s+Gu}function eL(n,e,t,i,r,s){let o=n.scrollDOM.getBoundingClientRect(),a=n.documentTop+n.documentPadding.top+n.contentHeight;if(o.left>i||o.rightr||Math.min(o.bottom,a)=e&&c<=t}function tL(n,e={}){let t=$e.define(),i=jt.define({create(){return[]},update(r,s){if(r.length&&(e.hideOnChange&&(s.docChanged||s.selection)?r=[]:e.hideOn&&(r=r.filter(o=>!e.hideOn(s,o))),s.docChanged)){let o=[];for(let a of r){let c=s.changes.mapPos(a.pos,-1,Wt.TrackDel);if(c!=null){let h=Object.assign(Object.create(null),a);h.pos=c,h.end!=null&&(h.end=s.changes.mapPos(h.end)),o.push(h)}}r=o}for(let o of s.effects)o.is(t)&&(r=o.value),o.is(nL)&&(r=[]);return r},provide:r=>cf.from(r)});return{active:i,extension:[i,kt.define(r=>new KE(r,n,i,t,e.hoverTime||300)),GE]}}function A_(n,e){let t=n.plugin(x0);if(!t)return null;let i=t.manager.tooltips.indexOf(e);return i<0?null:t.manager.tooltipViews[i]}const nL=$e.define(),$b=pe.define({combine(n){let e,t;for(let i of n)e=e||i.topContainer,t=t||i.bottomContainer;return{topContainer:e,bottomContainer:t}}});function Ya(n,e){let t=n.plugin(E_),i=t?t.specs.indexOf(e):-1;return i>-1?t.panels[i]:null}const E_=kt.fromClass(class{constructor(n){this.input=n.state.facet(qa),this.specs=this.input.filter(t=>t),this.panels=this.specs.map(t=>t(n));let e=n.state.facet($b);this.top=new Ku(n,!0,e.topContainer),this.bottom=new Ku(n,!1,e.bottomContainer),this.top.sync(this.panels.filter(t=>t.top)),this.bottom.sync(this.panels.filter(t=>!t.top));for(let t of this.panels)t.dom.classList.add("cm-panel"),t.mount&&t.mount()}update(n){let e=n.state.facet($b);this.top.container!=e.topContainer&&(this.top.sync([]),this.top=new Ku(n.view,!0,e.topContainer)),this.bottom.container!=e.bottomContainer&&(this.bottom.sync([]),this.bottom=new Ku(n.view,!1,e.bottomContainer)),this.top.syncClasses(),this.bottom.syncClasses();let t=n.state.facet(qa);if(t!=this.input){let i=t.filter(c=>c),r=[],s=[],o=[],a=[];for(let c of i){let h=this.specs.indexOf(c),f;h<0?(f=c(n.view),a.push(f)):(f=this.panels[h],f.update&&f.update(n)),r.push(f),(f.top?s:o).push(f)}this.specs=i,this.panels=r,this.top.sync(s),this.bottom.sync(o);for(let c of a)c.dom.classList.add("cm-panel"),c.mount&&c.mount()}else for(let i of this.panels)i.update&&i.update(n)}destroy(){this.top.sync([]),this.bottom.sync([])}},{provide:n=>fe.scrollMargins.of(e=>{let t=e.plugin(n);return t&&{top:t.top.scrollMargin(),bottom:t.bottom.scrollMargin()}})});class Ku{constructor(e,t,i){this.view=e,this.top=t,this.container=i,this.dom=void 0,this.classes="",this.panels=[],this.syncClasses()}sync(e){for(let t of this.panels)t.destroy&&e.indexOf(t)<0&&t.destroy();this.panels=e,this.syncDOM()}syncDOM(){if(this.panels.length==0){this.dom&&(this.dom.remove(),this.dom=void 0);return}if(!this.dom){this.dom=document.createElement("div"),this.dom.className=this.top?"cm-panels cm-panels-top":"cm-panels cm-panels-bottom",this.dom.style[this.top?"top":"bottom"]="0";let t=this.container||this.view.dom;t.insertBefore(this.dom,this.top?t.firstChild:null)}let e=this.dom.firstChild;for(let t of this.panels)if(t.dom.parentNode==this.dom){for(;e!=t.dom;)e=Mb(e);e=e.nextSibling}else this.dom.insertBefore(t.dom,e);for(;e;)e=Mb(e)}scrollMargin(){return!this.dom||this.container?0:Math.max(0,this.top?this.dom.getBoundingClientRect().bottom-Math.max(0,this.view.scrollDOM.getBoundingClientRect().top):Math.min(innerHeight,this.view.scrollDOM.getBoundingClientRect().bottom)-this.dom.getBoundingClientRect().top)}syncClasses(){if(!(!this.container||this.classes==this.view.themeClasses)){for(let e of this.classes.split(" "))e&&this.container.classList.remove(e);for(let e of(this.classes=this.view.themeClasses).split(" "))e&&this.container.classList.add(e)}}}function Mb(n){let e=n.nextSibling;return n.remove(),e}const qa=pe.define({enables:E_});class ar extends Xs{compare(e){return this==e||this.constructor==e.constructor&&this.eq(e)}eq(e){return!1}destroy(e){}}ar.prototype.elementClass="";ar.prototype.toDOM=void 0;ar.prototype.mapMode=Wt.TrackBefore;ar.prototype.startSide=ar.prototype.endSide=-1;ar.prototype.point=!0;const zh=pe.define(),iL=pe.define(),rL={class:"",renderEmptyElements:!1,elementStyle:"",markers:()=>Ie.empty,lineMarker:()=>null,widgetMarker:()=>null,lineMarkerChange:null,initialSpacer:null,updateSpacer:null,domEventHandlers:{},side:"before"},Ca=pe.define();function sL(n){return[L_(),Ca.of({...rL,...n})]}const Rb=pe.define({combine:n=>n.some(e=>e)});function L_(n){return[oL]}const oL=kt.fromClass(class{constructor(n){this.view=n,this.domAfter=null,this.prevViewport=n.viewport,this.dom=document.createElement("div"),this.dom.className="cm-gutters cm-gutters-before",this.dom.setAttribute("aria-hidden","true"),this.dom.style.minHeight=this.view.contentHeight/this.view.scaleY+"px",this.gutters=n.state.facet(Ca).map(e=>new Eb(n,e)),this.fixed=!n.state.facet(Rb);for(let e of this.gutters)e.config.side=="after"?this.getDOMAfter().appendChild(e.dom):this.dom.appendChild(e.dom);this.fixed&&(this.dom.style.position="sticky"),this.syncGutters(!1),n.scrollDOM.insertBefore(this.dom,n.contentDOM)}getDOMAfter(){return this.domAfter||(this.domAfter=document.createElement("div"),this.domAfter.className="cm-gutters cm-gutters-after",this.domAfter.setAttribute("aria-hidden","true"),this.domAfter.style.minHeight=this.view.contentHeight/this.view.scaleY+"px",this.domAfter.style.position=this.fixed?"sticky":"",this.view.scrollDOM.appendChild(this.domAfter)),this.domAfter}update(n){if(this.updateGutters(n)){let e=this.prevViewport,t=n.view.viewport,i=Math.min(e.to,t.to)-Math.max(e.from,t.from);this.syncGutters(i<(t.to-t.from)*.8)}if(n.geometryChanged){let e=this.view.contentHeight/this.view.scaleY+"px";this.dom.style.minHeight=e,this.domAfter&&(this.domAfter.style.minHeight=e)}this.view.state.facet(Rb)!=!this.fixed&&(this.fixed=!this.fixed,this.dom.style.position=this.fixed?"sticky":"",this.domAfter&&(this.domAfter.style.position=this.fixed?"sticky":"")),this.prevViewport=n.view.viewport}syncGutters(n){let e=this.dom.nextSibling;n&&(this.dom.remove(),this.domAfter&&this.domAfter.remove());let t=Ie.iter(this.view.state.facet(zh),this.view.viewport.from),i=[],r=this.gutters.map(s=>new lL(s,this.view.viewport,-this.view.documentPadding.top));for(let s of this.view.viewportLineBlocks)if(i.length&&(i=[]),Array.isArray(s.type)){let o=!0;for(let a of s.type)if(a.type==an.Text&&o){fO(t,i,a.from);for(let c of r)c.line(this.view,a,i);o=!1}else if(a.widget)for(let c of r)c.widget(this.view,a)}else if(s.type==an.Text){fO(t,i,s.from);for(let o of r)o.line(this.view,s,i)}else if(s.widget)for(let o of r)o.widget(this.view,s);for(let s of r)s.finish();n&&(this.view.scrollDOM.insertBefore(this.dom,e),this.domAfter&&this.view.scrollDOM.appendChild(this.domAfter))}updateGutters(n){let e=n.startState.facet(Ca),t=n.state.facet(Ca),i=n.docChanged||n.heightChanged||n.viewportChanged||!Ie.eq(n.startState.facet(zh),n.state.facet(zh),n.view.viewport.from,n.view.viewport.to);if(e==t)for(let r of this.gutters)r.update(n)&&(i=!0);else{i=!0;let r=[];for(let s of t){let o=e.indexOf(s);o<0?r.push(new Eb(this.view,s)):(this.gutters[o].update(n),r.push(this.gutters[o]))}for(let s of this.gutters)s.dom.remove(),r.indexOf(s)<0&&s.destroy();for(let s of r)s.config.side=="after"?this.getDOMAfter().appendChild(s.dom):this.dom.appendChild(s.dom);this.gutters=r}return i}destroy(){for(let n of this.gutters)n.destroy();this.dom.remove(),this.domAfter&&this.domAfter.remove()}},{provide:n=>fe.scrollMargins.of(e=>{let t=e.plugin(n);if(!t||t.gutters.length==0||!t.fixed)return null;let i=t.dom.offsetWidth*e.scaleX,r=t.domAfter?t.domAfter.offsetWidth*e.scaleX:0;return e.textDirection==st.LTR?{left:i,right:r}:{right:i,left:r}})});function Ab(n){return Array.isArray(n)?n:[n]}function fO(n,e,t){for(;n.value&&n.from<=t;)n.from==t&&e.push(n.value),n.next()}class lL{constructor(e,t,i){this.gutter=e,this.height=i,this.i=0,this.cursor=Ie.iter(e.markers,t.from)}addElement(e,t,i){let{gutter:r}=this,s=(t.top-this.height)/e.scaleY,o=t.height/e.scaleY;if(this.i==r.elements.length){let a=new D_(e,o,s,i);r.elements.push(a),r.dom.appendChild(a.dom)}else r.elements[this.i].update(e,o,s,i);this.height=t.bottom,this.i++}line(e,t,i){let r=[];fO(this.cursor,r,t.from),i.length&&(r=r.concat(i));let s=this.gutter.config.lineMarker(e,t,r);s&&r.unshift(s);let o=this.gutter;r.length==0&&!o.config.renderEmptyElements||this.addElement(e,t,r)}widget(e,t){let i=this.gutter.config.widgetMarker(e,t.widget,t),r=i?[i]:null;for(let s of e.state.facet(iL)){let o=s(e,t.widget,t);o&&(r||(r=[])).push(o)}r&&this.addElement(e,t,r)}finish(){let e=this.gutter;for(;e.elements.length>this.i;){let t=e.elements.pop();e.dom.removeChild(t.dom),t.destroy()}}}class Eb{constructor(e,t){this.view=e,this.config=t,this.elements=[],this.spacer=null,this.dom=document.createElement("div"),this.dom.className="cm-gutter"+(this.config.class?" "+this.config.class:"");for(let i in t.domEventHandlers)this.dom.addEventListener(i,r=>{let s=r.target,o;if(s!=this.dom&&this.dom.contains(s)){for(;s.parentNode!=this.dom;)s=s.parentNode;let c=s.getBoundingClientRect();o=(c.top+c.bottom)/2}else o=r.clientY;let a=e.lineBlockAtHeight(o-e.documentTop);t.domEventHandlers[i](e,a,r)&&r.preventDefault()});this.markers=Ab(t.markers(e)),t.initialSpacer&&(this.spacer=new D_(e,0,0,[t.initialSpacer(e)]),this.dom.appendChild(this.spacer.dom),this.spacer.dom.style.cssText+="visibility: hidden; pointer-events: none")}update(e){let t=this.markers;if(this.markers=Ab(this.config.markers(e.view)),this.spacer&&this.config.updateSpacer){let r=this.config.updateSpacer(this.spacer.markers[0],e);r!=this.spacer.markers[0]&&this.spacer.update(e.view,0,0,[r])}let i=e.view.viewport;return!Ie.eq(this.markers,t,i.from,i.to)||(this.config.lineMarkerChange?this.config.lineMarkerChange(e):!1)}destroy(){for(let e of this.elements)e.destroy()}}class D_{constructor(e,t,i,r){this.height=-1,this.above=0,this.markers=[],this.dom=document.createElement("div"),this.dom.className="cm-gutterElement",this.update(e,t,i,r)}update(e,t,i,r){this.height!=t&&(this.height=t,this.dom.style.height=t+"px"),this.above!=i&&(this.dom.style.marginTop=(this.above=i)?i+"px":""),aL(this.markers,r)||this.setMarkers(e,r)}setMarkers(e,t){let i="cm-gutterElement",r=this.dom.firstChild;for(let s=0,o=0;;){let a=o,c=ss(a,c,h)||o(a,c,h):o}return i}})}});class Tg extends ar{constructor(e){super(),this.number=e}eq(e){return this.number==e.number}toDOM(){return document.createTextNode(this.number)}}function $g(n,e){return n.state.facet(Ao).formatNumber(e,n.state)}const hL=Ca.compute([Ao],n=>({class:"cm-lineNumbers",renderEmptyElements:!1,markers(e){return e.state.facet(cL)},lineMarker(e,t,i){return i.some(r=>r.toDOM)?null:new Tg($g(e,e.state.doc.lineAt(t.from).number))},widgetMarker:(e,t,i)=>{for(let r of e.state.facet(uL)){let s=r(e,t,i);if(s)return s}return null},lineMarkerChange:e=>e.startState.facet(Ao)!=e.state.facet(Ao),initialSpacer(e){return new Tg($g(e,Lb(e.state.doc.lines)))},updateSpacer(e,t){let i=$g(t.view,Lb(t.view.state.doc.lines));return i==e.number?e:new Tg(i)},domEventHandlers:n.facet(Ao).domEventHandlers,side:"before"}));function fL(n={}){return[Ao.of(n),L_(),hL]}function Lb(n){let e=9;for(;e{let e=[],t=-1;for(let i of n.selection.ranges){let r=n.doc.lineAt(i.head).from;r>t&&(t=r,e.push(dL.range(r)))}return Ie.of(e)});function gL(){return pL}const z_=1024;let mL=0;class Mg{constructor(e,t){this.from=e,this.to=t}}class Ee{constructor(e={}){this.id=mL++,this.perNode=!!e.perNode,this.deserialize=e.deserialize||(()=>{throw new Error("This node type doesn't define a deserialize function")})}add(e){if(this.perNode)throw new RangeError("Can't add per-node props to node types");return typeof e!="function"&&(e=kn.match(e)),t=>{let i=e(t);return i===void 0?null:[this,i]}}}Ee.closedBy=new Ee({deserialize:n=>n.split(" ")});Ee.openedBy=new Ee({deserialize:n=>n.split(" ")});Ee.group=new Ee({deserialize:n=>n.split(" ")});Ee.isolate=new Ee({deserialize:n=>{if(n&&n!="rtl"&&n!="ltr"&&n!="auto")throw new RangeError("Invalid value for isolate: "+n);return n||"auto"}});Ee.contextHash=new Ee({perNode:!0});Ee.lookAhead=new Ee({perNode:!0});Ee.mounted=new Ee({perNode:!0});class uf{constructor(e,t,i){this.tree=e,this.overlay=t,this.parser=i}static get(e){return e&&e.props&&e.props[Ee.mounted.id]}}const OL=Object.create(null);class kn{constructor(e,t,i,r=0){this.name=e,this.props=t,this.id=i,this.flags=r}static define(e){let t=e.props&&e.props.length?Object.create(null):OL,i=(e.top?1:0)|(e.skipped?2:0)|(e.error?4:0)|(e.name==null?8:0),r=new kn(e.name||"",t,e.id,i);if(e.props){for(let s of e.props)if(Array.isArray(s)||(s=s(r)),s){if(s[0].perNode)throw new RangeError("Can't store a per-node prop on a node type");t[s[0].id]=s[1]}}return r}prop(e){return this.props[e.id]}get isTop(){return(this.flags&1)>0}get isSkipped(){return(this.flags&2)>0}get isError(){return(this.flags&4)>0}get isAnonymous(){return(this.flags&8)>0}is(e){if(typeof e=="string"){if(this.name==e)return!0;let t=this.prop(Ee.group);return t?t.indexOf(e)>-1:!1}return this.id==e}static match(e){let t=Object.create(null);for(let i in e)for(let r of i.split(" "))t[r]=e[i];return i=>{for(let r=i.prop(Ee.group),s=-1;s<(r?r.length:0);s++){let o=t[s<0?i.name:r[s]];if(o)return o}}}}kn.none=new kn("",Object.create(null),0,8);class b0{constructor(e){this.types=e;for(let t=0;t0;for(let c=this.cursor(o|Tt.IncludeAnonymous);;){let h=!1;if(c.from<=s&&c.to>=r&&(!a&&c.type.isAnonymous||t(c)!==!1)){if(c.firstChild())continue;h=!0}for(;h&&i&&(a||!c.type.isAnonymous)&&i(c),!c.nextSibling();){if(!c.parent())return;h=!0}}}prop(e){return e.perNode?this.props?this.props[e.id]:void 0:this.type.prop(e)}get propValues(){let e=[];if(this.props)for(let t in this.props)e.push([+t,this.props[t]]);return e}balance(e={}){return this.children.length<=8?this:k0(kn.none,this.children,this.positions,0,this.children.length,0,this.length,(t,i,r)=>new wt(this.type,t,i,r,this.propValues),e.makeTree||((t,i,r)=>new wt(kn.none,t,i,r)))}static build(e){return bL(e)}}wt.empty=new wt(kn.none,[],[],0);class S0{constructor(e,t){this.buffer=e,this.index=t}get id(){return this.buffer[this.index-4]}get start(){return this.buffer[this.index-3]}get end(){return this.buffer[this.index-2]}get size(){return this.buffer[this.index-1]}get pos(){return this.index}next(){this.index-=4}fork(){return new S0(this.buffer,this.index)}}class Kr{constructor(e,t,i){this.buffer=e,this.length=t,this.set=i}get type(){return kn.none}toString(){let e=[];for(let t=0;t0));c=o[c+3]);return a}slice(e,t,i){let r=this.buffer,s=new Uint16Array(t-e),o=0;for(let a=e,c=0;a=e&&te;case 1:return t<=e&&i>e;case 2:return i>e;case 4:return!0}}function Ua(n,e,t,i){for(var r;n.from==n.to||(t<1?n.from>=e:n.from>e)||(t>-1?n.to<=e:n.to0?a.length:-1;e!=h;e+=t){let f=a[e],p=c[e]+o.from;if(j_(r,i,p,p+f.length)){if(f instanceof Kr){if(s&Tt.ExcludeBuffers)continue;let m=f.findChild(0,f.buffer.length,t,i-p,r);if(m>-1)return new Ri(new yL(o,f,e,p),null,m)}else if(s&Tt.IncludeAnonymous||!f.type.isAnonymous||w0(f)){let m;if(!(s&Tt.IgnoreMounts)&&(m=uf.get(f))&&!m.overlay)return new wn(m.tree,p,e,o);let O=new wn(f,p,e,o);return s&Tt.IncludeAnonymous||!O.type.isAnonymous?O:O.nextChild(t<0?f.children.length-1:0,t,i,r)}}}if(s&Tt.IncludeAnonymous||!o.type.isAnonymous||(o.index>=0?e=o.index+t:e=t<0?-1:o._parent._tree.children.length,o=o._parent,!o))return null}}get firstChild(){return this.nextChild(0,1,0,4)}get lastChild(){return this.nextChild(this._tree.children.length-1,-1,0,4)}childAfter(e){return this.nextChild(0,1,e,2)}childBefore(e){return this.nextChild(this._tree.children.length-1,-1,e,-2)}enter(e,t,i=0){let r;if(!(i&Tt.IgnoreOverlays)&&(r=uf.get(this._tree))&&r.overlay){let s=e-this.from;for(let{from:o,to:a}of r.overlay)if((t>0?o<=s:o=s:a>s))return new wn(r.tree,r.overlay[0].from+this.from,-1,this)}return this.nextChild(0,1,e,t,i)}nextSignificantParent(){let e=this;for(;e.type.isAnonymous&&e._parent;)e=e._parent;return e}get parent(){return this._parent?this._parent.nextSignificantParent():null}get nextSibling(){return this._parent&&this.index>=0?this._parent.nextChild(this.index+1,1,0,4):null}get prevSibling(){return this._parent&&this.index>=0?this._parent.nextChild(this.index-1,-1,0,4):null}get tree(){return this._tree}toTree(){return this._tree}toString(){return this._tree.toString()}}function zb(n,e,t,i){let r=n.cursor(),s=[];if(!r.firstChild())return s;if(t!=null){for(let o=!1;!o;)if(o=r.type.is(t),!r.nextSibling())return s}for(;;){if(i!=null&&r.type.is(i))return s;if(r.type.is(e)&&s.push(r.node),!r.nextSibling())return i==null?s:[]}}function dO(n,e,t=e.length-1){for(let i=n;t>=0;i=i.parent){if(!i)return!1;if(!i.type.isAnonymous){if(e[t]&&e[t]!=i.name)return!1;t--}}return!0}class yL{constructor(e,t,i,r){this.parent=e,this.buffer=t,this.index=i,this.start=r}}class Ri extends Z_{get name(){return this.type.name}get from(){return this.context.start+this.context.buffer.buffer[this.index+1]}get to(){return this.context.start+this.context.buffer.buffer[this.index+2]}constructor(e,t,i){super(),this.context=e,this._parent=t,this.index=i,this.type=e.buffer.set.types[e.buffer.buffer[i]]}child(e,t,i){let{buffer:r}=this.context,s=r.findChild(this.index+4,r.buffer[this.index+3],e,t-this.context.start,i);return s<0?null:new Ri(this.context,this,s)}get firstChild(){return this.child(1,0,4)}get lastChild(){return this.child(-1,0,4)}childAfter(e){return this.child(1,e,2)}childBefore(e){return this.child(-1,e,-2)}enter(e,t,i=0){if(i&Tt.ExcludeBuffers)return null;let{buffer:r}=this.context,s=r.findChild(this.index+4,r.buffer[this.index+3],t>0?1:-1,e-this.context.start,t);return s<0?null:new Ri(this.context,this,s)}get parent(){return this._parent||this.context.parent.nextSignificantParent()}externalSibling(e){return this._parent?null:this.context.parent.nextChild(this.context.index+e,e,0,4)}get nextSibling(){let{buffer:e}=this.context,t=e.buffer[this.index+3];return t<(this._parent?e.buffer[this._parent.index+3]:e.buffer.length)?new Ri(this.context,this._parent,t):this.externalSibling(1)}get prevSibling(){let{buffer:e}=this.context,t=this._parent?this._parent.index+4:0;return this.index==t?this.externalSibling(-1):new Ri(this.context,this._parent,e.findChild(t,this.index,-1,0,4))}get tree(){return null}toTree(){let e=[],t=[],{buffer:i}=this.context,r=this.index+4,s=i.buffer[this.index+3];if(s>r){let o=i.buffer[this.index+1];e.push(i.slice(r,s,o)),t.push(0)}return new wt(this.type,e,t,this.to-this.from)}toString(){return this.context.buffer.childString(this.index)}}function I_(n){if(!n.length)return null;let e=0,t=n[0];for(let s=1;st.from||o.to=e){let a=new wn(o.tree,o.overlay[0].from+s.from,-1,s);(r||(r=[i])).push(Ua(a,e,t,!1))}}return r?I_(r):i}class pO{get name(){return this.type.name}constructor(e,t=0){if(this.mode=t,this.buffer=null,this.stack=[],this.index=0,this.bufferNode=null,e instanceof wn)this.yieldNode(e);else{this._tree=e.context.parent,this.buffer=e.context;for(let i=e._parent;i;i=i._parent)this.stack.unshift(i.index);this.bufferNode=e,this.yieldBuf(e.index)}}yieldNode(e){return e?(this._tree=e,this.type=e.type,this.from=e.from,this.to=e.to,!0):!1}yieldBuf(e,t){this.index=e;let{start:i,buffer:r}=this.buffer;return this.type=t||r.set.types[r.buffer[e]],this.from=i+r.buffer[e+1],this.to=i+r.buffer[e+2],!0}yield(e){return e?e instanceof wn?(this.buffer=null,this.yieldNode(e)):(this.buffer=e.context,this.yieldBuf(e.index,e.type)):!1}toString(){return this.buffer?this.buffer.buffer.childString(this.index):this._tree.toString()}enterChild(e,t,i){if(!this.buffer)return this.yield(this._tree.nextChild(e<0?this._tree._tree.children.length-1:0,e,t,i,this.mode));let{buffer:r}=this.buffer,s=r.findChild(this.index+4,r.buffer[this.index+3],e,t-this.buffer.start,i);return s<0?!1:(this.stack.push(this.index),this.yieldBuf(s))}firstChild(){return this.enterChild(1,0,4)}lastChild(){return this.enterChild(-1,0,4)}childAfter(e){return this.enterChild(1,e,2)}childBefore(e){return this.enterChild(-1,e,-2)}enter(e,t,i=this.mode){return this.buffer?i&Tt.ExcludeBuffers?!1:this.enterChild(1,e,t):this.yield(this._tree.enter(e,t,i))}parent(){if(!this.buffer)return this.yieldNode(this.mode&Tt.IncludeAnonymous?this._tree._parent:this._tree.parent);if(this.stack.length)return this.yieldBuf(this.stack.pop());let e=this.mode&Tt.IncludeAnonymous?this.buffer.parent:this.buffer.parent.nextSignificantParent();return this.buffer=null,this.yieldNode(e)}sibling(e){if(!this.buffer)return this._tree._parent?this.yield(this._tree.index<0?null:this._tree._parent.nextChild(this._tree.index+e,e,0,4,this.mode)):!1;let{buffer:t}=this.buffer,i=this.stack.length-1;if(e<0){let r=i<0?0:this.stack[i]+4;if(this.index!=r)return this.yieldBuf(t.findChild(r,this.index,-1,0,4))}else{let r=t.buffer[this.index+3];if(r<(i<0?t.buffer.length:t.buffer[this.stack[i]+3]))return this.yieldBuf(r)}return i<0?this.yield(this.buffer.parent.nextChild(this.buffer.index+e,e,0,4,this.mode)):!1}nextSibling(){return this.sibling(1)}prevSibling(){return this.sibling(-1)}atLastNode(e){let t,i,{buffer:r}=this;if(r){if(e>0){if(this.index-1)for(let s=t+e,o=e<0?-1:i._tree.children.length;s!=o;s+=e){let a=i._tree.children[s];if(this.mode&Tt.IncludeAnonymous||a instanceof Kr||!a.type.isAnonymous||w0(a))return!1}return!0}move(e,t){if(t&&this.enterChild(e,0,4))return!0;for(;;){if(this.sibling(e))return!0;if(this.atLastNode(e)||!this.parent())return!1}}next(e=!0){return this.move(1,e)}prev(e=!0){return this.move(-1,e)}moveTo(e,t=0){for(;(this.from==this.to||(t<1?this.from>=e:this.from>e)||(t>-1?this.to<=e:this.to=0;){for(let o=e;o;o=o._parent)if(o.index==r){if(r==this.index)return o;t=o,i=s+1;break e}r=this.stack[--s]}for(let r=i;r=0;s--){if(s<0)return dO(this._tree,e,r);let o=i[t.buffer[this.stack[s]]];if(!o.isAnonymous){if(e[r]&&e[r]!=o.name)return!1;r--}}return!0}}function w0(n){return n.children.some(e=>e instanceof Kr||!e.type.isAnonymous||w0(e))}function bL(n){var e;let{buffer:t,nodeSet:i,maxBufferLength:r=z_,reused:s=[],minRepeatType:o=i.types.length}=n,a=Array.isArray(t)?new S0(t,t.length):t,c=i.types,h=0,f=0;function p(C,M,R,L,X,ie){let{id:Y,start:H,end:F,size:re}=a,oe=f,ce=h;for(;re<0;)if(a.next(),re==-1){let D=s[Y];R.push(D),L.push(H-C);return}else if(re==-3){h=Y;return}else if(re==-4){f=Y;return}else throw new RangeError(`Unrecognized record size: ${re}`);let ue=c[Y],q,U,J=H-C;if(F-H<=r&&(U=S(a.pos-M,X))){let D=new Uint16Array(U.size-U.skip),B=a.pos-U.size,xe=D.length;for(;a.pos>B;)xe=w(U.start,D,xe);q=new Kr(D,F-U.start,i),J=U.start-C}else{let D=a.pos-re;a.next();let B=[],xe=[],Oe=Y>=o?Y:-1,ke=0,Pe=F;for(;a.pos>D;)Oe>=0&&a.id==Oe&&a.size>=0?(a.end<=Pe-r&&(v(B,xe,H,ke,a.end,Pe,Oe,oe,ce),ke=B.length,Pe=a.end),a.next()):ie>2500?m(H,D,B,xe):p(H,D,B,xe,Oe,ie+1);if(Oe>=0&&ke>0&&ke-1&&ke>0){let Le=O(ue,ce);q=k0(ue,B,xe,0,B.length,0,F-H,Le,Le)}else q=b(ue,B,xe,F-H,oe-F,ce)}R.push(q),L.push(J)}function m(C,M,R,L){let X=[],ie=0,Y=-1;for(;a.pos>M;){let{id:H,start:F,end:re,size:oe}=a;if(oe>4)a.next();else{if(Y>-1&&F=0;re-=3)H[oe++]=X[re],H[oe++]=X[re+1]-F,H[oe++]=X[re+2]-F,H[oe++]=oe;R.push(new Kr(H,X[2]-F,i)),L.push(F-C)}}function O(C,M){return(R,L,X)=>{let ie=0,Y=R.length-1,H,F;if(Y>=0&&(H=R[Y])instanceof wt){if(!Y&&H.type==C&&H.length==X)return H;(F=H.prop(Ee.lookAhead))&&(ie=L[Y]+H.length+F)}return b(C,R,L,X,ie,M)}}function v(C,M,R,L,X,ie,Y,H,F){let re=[],oe=[];for(;C.length>L;)re.push(C.pop()),oe.push(M.pop()+R-X);C.push(b(i.types[Y],re,oe,ie-X,H-ie,F)),M.push(X-R)}function b(C,M,R,L,X,ie,Y){if(ie){let H=[Ee.contextHash,ie];Y=Y?[H].concat(Y):[H]}if(X>25){let H=[Ee.lookAhead,X];Y=Y?[H].concat(Y):[H]}return new wt(C,M,R,L,Y)}function S(C,M){let R=a.fork(),L=0,X=0,ie=0,Y=R.end-r,H={size:0,start:0,skip:0};e:for(let F=R.pos-C;R.pos>F;){let re=R.size;if(R.id==M&&re>=0){H.size=L,H.start=X,H.skip=ie,ie+=4,L+=4,R.next();continue}let oe=R.pos-re;if(re<0||oe=o?4:0,ue=R.start;for(R.next();R.pos>oe;){if(R.size<0)if(R.size==-3)ce+=4;else break e;else R.id>=o&&(ce+=4);R.next()}X=ue,L+=re,ie+=ce}return(M<0||L==C)&&(H.size=L,H.start=X,H.skip=ie),H.size>4?H:void 0}function w(C,M,R){let{id:L,start:X,end:ie,size:Y}=a;if(a.next(),Y>=0&&L4){let F=a.pos-(Y-4);for(;a.pos>F;)R=w(C,M,R)}M[--R]=H,M[--R]=ie-C,M[--R]=X-C,M[--R]=L}else Y==-3?h=L:Y==-4&&(f=L);return R}let T=[],k=[];for(;a.pos>0;)p(n.start||0,n.bufferStart||0,T,k,-1,0);let _=(e=n.length)!==null&&e!==void 0?e:T.length?k[0]+T[0].length:0;return new wt(c[n.topID],T.reverse(),k.reverse(),_)}const jb=new WeakMap;function jh(n,e){if(!n.isAnonymous||e instanceof Kr||e.type!=n)return 1;let t=jb.get(e);if(t==null){t=1;for(let i of e.children){if(i.type!=n||!(i instanceof wt)){t=1;break}t+=jh(n,i)}jb.set(e,t)}return t}function k0(n,e,t,i,r,s,o,a,c){let h=0;for(let v=i;v=f)break;M+=R}if(k==_+1){if(M>f){let R=v[_];O(R.children,R.positions,0,R.children.length,b[_]+T);continue}p.push(v[_])}else{let R=b[k-1]+v[k-1].length-C;p.push(k0(n,v,b,_,k,C,R,null,c))}m.push(C+T-s)}}return O(e,t,i,r,0),(a||c)(p,m,o)}class SL{constructor(){this.map=new WeakMap}setBuffer(e,t,i){let r=this.map.get(e);r||this.map.set(e,r=new Map),r.set(t,i)}getBuffer(e,t){let i=this.map.get(e);return i&&i.get(t)}set(e,t){e instanceof Ri?this.setBuffer(e.context.buffer,e.index,t):e instanceof wn&&this.map.set(e.tree,t)}get(e){return e instanceof Ri?this.getBuffer(e.context.buffer,e.index):e instanceof wn?this.map.get(e.tree):void 0}cursorSet(e,t){e.buffer?this.setBuffer(e.buffer.buffer,e.index,t):this.map.set(e.tree,t)}cursorGet(e){return e.buffer?this.getBuffer(e.buffer.buffer,e.index):this.map.get(e.tree)}}class zs{constructor(e,t,i,r,s=!1,o=!1){this.from=e,this.to=t,this.tree=i,this.offset=r,this.open=(s?1:0)|(o?2:0)}get openStart(){return(this.open&1)>0}get openEnd(){return(this.open&2)>0}static addTree(e,t=[],i=!1){let r=[new zs(0,e.length,e,0,!1,i)];for(let s of t)s.to>e.length&&r.push(s);return r}static applyChanges(e,t,i=128){if(!t.length)return e;let r=[],s=1,o=e.length?e[0]:null;for(let a=0,c=0,h=0;;a++){let f=a=i)for(;o&&o.from=m.from||p<=m.to||h){let O=Math.max(m.from,c)-h,v=Math.min(m.to,p)-h;m=O>=v?null:new zs(O,v,m.tree,m.offset+h,a>0,!!f)}if(m&&r.push(m),o.to>p)break;o=snew Mg(r.from,r.to)):[new Mg(0,0)]:[new Mg(0,e.length)],this.createParse(e,t||[],i)}parse(e,t,i){let r=this.startParse(e,t,i);for(;;){let s=r.advance();if(s)return s}}}class wL{constructor(e){this.string=e}get length(){return this.string.length}chunk(e){return this.string.slice(e)}get lineChunks(){return!1}read(e,t){return this.string.slice(e,t)}}new Ee({perNode:!0});let kL=0,Ji=class gO{constructor(e,t,i,r){this.name=e,this.set=t,this.base=i,this.modified=r,this.id=kL++}toString(){let{name:e}=this;for(let t of this.modified)t.name&&(e=`${t.name}(${e})`);return e}static define(e,t){let i=typeof e=="string"?e:"?";if(e instanceof gO&&(t=e),t!=null&&t.base)throw new Error("Can not derive from a modified tag");let r=new gO(i,[],null,[]);if(r.set.push(r),t)for(let s of t.set)r.set.push(s);return r}static defineModifier(e){let t=new hf(e);return i=>i.modified.indexOf(t)>-1?i:hf.get(i.base||i,i.modified.concat(t).sort((r,s)=>r.id-s.id))}},PL=0;class hf{constructor(e){this.name=e,this.instances=[],this.id=PL++}static get(e,t){if(!t.length)return e;let i=t[0].instances.find(a=>a.base==e&&_L(t,a.modified));if(i)return i;let r=[],s=new Ji(e.name,r,e,t);for(let a of t)a.instances.push(s);let o=QL(t);for(let a of e.set)if(!a.modified.length)for(let c of o)r.push(hf.get(a,c));return s}}function _L(n,e){return n.length==e.length&&n.every((t,i)=>t==e[i])}function QL(n){let e=[[]];for(let t=0;ti.length-t.length)}function P0(n){let e=Object.create(null);for(let t in n){let i=n[t];Array.isArray(i)||(i=[i]);for(let r of t.split(" "))if(r){let s=[],o=2,a=r;for(let p=0;;){if(a=="..."&&p>0&&p+3==r.length){o=1;break}let m=/^"(?:[^"\\]|\\.)*?"|[^\/!]+/.exec(a);if(!m)throw new RangeError("Invalid path: "+r);if(s.push(m[0]=="*"?"":m[0][0]=='"'?JSON.parse(m[0]):m[0]),p+=m[0].length,p==r.length)break;let O=r[p++];if(p==r.length&&O=="!"){o=0;break}if(O!="/")throw new RangeError("Invalid path: "+r);a=r.slice(p)}let c=s.length-1,h=s[c];if(!h)throw new RangeError("Invalid path: "+r);let f=new ff(i,o,c>0?s.slice(0,c):null);e[h]=f.sort(e[h])}}return B_.add(e)}const B_=new Ee;class ff{constructor(e,t,i,r){this.tags=e,this.mode=t,this.context=i,this.next=r}get opaque(){return this.mode==0}get inherit(){return this.mode==1}sort(e){return!e||e.depth{let o=r;for(let a of s)for(let c of a.set){let h=t[c.id];if(h){o=o?o+" "+h:h;break}}return o},scope:i}}function CL(n,e){let t=null;for(let i of n){let r=i.style(e);r&&(t=t?t+" "+r:r)}return t}function TL(n,e,t,i=0,r=n.length){let s=new $L(i,Array.isArray(e)?e:[e],t);s.highlightRange(n.cursor(),i,r,"",s.highlighters),s.flush(r)}class $L{constructor(e,t,i){this.at=e,this.highlighters=t,this.span=i,this.class=""}startSpan(e,t){t!=this.class&&(this.flush(e),e>this.at&&(this.at=e),this.class=t)}flush(e){e>this.at&&this.class&&this.span(this.at,e,this.class)}highlightRange(e,t,i,r,s){let{type:o,from:a,to:c}=e;if(a>=i||c<=t)return;o.isTop&&(s=this.highlighters.filter(O=>!O.scope||O.scope(o)));let h=r,f=ML(e)||ff.empty,p=CL(s,f.tags);if(p&&(h&&(h+=" "),h+=p,f.mode==1&&(r+=(r?" ":"")+p)),this.startSpan(Math.max(t,a),h),f.opaque)return;let m=e.tree&&e.tree.prop(Ee.mounted);if(m&&m.overlay){let O=e.node.enter(m.overlay[0].from+a,1),v=this.highlighters.filter(S=>!S.scope||S.scope(m.tree.type)),b=e.firstChild();for(let S=0,w=a;;S++){let T=S=k||!e.nextSibling())););if(!T||k>i)break;w=T.to+a,w>t&&(this.highlightRange(O.cursor(),Math.max(t,T.from+a),Math.min(i,w),"",v),this.startSpan(Math.min(i,w),h))}b&&e.parent()}else if(e.firstChild()){m&&(r="");do if(!(e.to<=t)){if(e.from>=i)break;this.highlightRange(e,t,i,r,s),this.startSpan(Math.min(i,e.to),h)}while(e.nextSibling());e.parent()}}}function ML(n){let e=n.type.prop(B_);for(;e&&e.context&&!n.matchContext(e.context);)e=e.next;return e||null}const ae=Ji.define,eh=ae(),Er=ae(),Zb=ae(Er),Ib=ae(Er),Lr=ae(),th=ae(Lr),Rg=ae(Lr),Pi=ae(),ys=ae(Pi),Si=ae(),wi=ae(),mO=ae(),na=ae(mO),nh=ae(),A={comment:eh,lineComment:ae(eh),blockComment:ae(eh),docComment:ae(eh),name:Er,variableName:ae(Er),typeName:Zb,tagName:ae(Zb),propertyName:Ib,attributeName:ae(Ib),className:ae(Er),labelName:ae(Er),namespace:ae(Er),macroName:ae(Er),literal:Lr,string:th,docString:ae(th),character:ae(th),attributeValue:ae(th),number:Rg,integer:ae(Rg),float:ae(Rg),bool:ae(Lr),regexp:ae(Lr),escape:ae(Lr),color:ae(Lr),url:ae(Lr),keyword:Si,self:ae(Si),null:ae(Si),atom:ae(Si),unit:ae(Si),modifier:ae(Si),operatorKeyword:ae(Si),controlKeyword:ae(Si),definitionKeyword:ae(Si),moduleKeyword:ae(Si),operator:wi,derefOperator:ae(wi),arithmeticOperator:ae(wi),logicOperator:ae(wi),bitwiseOperator:ae(wi),compareOperator:ae(wi),updateOperator:ae(wi),definitionOperator:ae(wi),typeOperator:ae(wi),controlOperator:ae(wi),punctuation:mO,separator:ae(mO),bracket:na,angleBracket:ae(na),squareBracket:ae(na),paren:ae(na),brace:ae(na),content:Pi,heading:ys,heading1:ae(ys),heading2:ae(ys),heading3:ae(ys),heading4:ae(ys),heading5:ae(ys),heading6:ae(ys),contentSeparator:ae(Pi),list:ae(Pi),quote:ae(Pi),emphasis:ae(Pi),strong:ae(Pi),link:ae(Pi),monospace:ae(Pi),strikethrough:ae(Pi),inserted:ae(),deleted:ae(),changed:ae(),invalid:ae(),meta:nh,documentMeta:ae(nh),annotation:ae(nh),processingInstruction:ae(nh),definition:Ji.defineModifier("definition"),constant:Ji.defineModifier("constant"),function:Ji.defineModifier("function"),standard:Ji.defineModifier("standard"),local:Ji.defineModifier("local"),special:Ji.defineModifier("special")};for(let n in A){let e=A[n];e instanceof Ji&&(e.name=n)}X_([{tag:A.link,class:"tok-link"},{tag:A.heading,class:"tok-heading"},{tag:A.emphasis,class:"tok-emphasis"},{tag:A.strong,class:"tok-strong"},{tag:A.keyword,class:"tok-keyword"},{tag:A.atom,class:"tok-atom"},{tag:A.bool,class:"tok-bool"},{tag:A.url,class:"tok-url"},{tag:A.labelName,class:"tok-labelName"},{tag:A.inserted,class:"tok-inserted"},{tag:A.deleted,class:"tok-deleted"},{tag:A.literal,class:"tok-literal"},{tag:A.string,class:"tok-string"},{tag:A.number,class:"tok-number"},{tag:[A.regexp,A.escape,A.special(A.string)],class:"tok-string2"},{tag:A.variableName,class:"tok-variableName"},{tag:A.local(A.variableName),class:"tok-variableName tok-local"},{tag:A.definition(A.variableName),class:"tok-variableName tok-definition"},{tag:A.special(A.variableName),class:"tok-variableName2"},{tag:A.definition(A.propertyName),class:"tok-propertyName tok-definition"},{tag:A.typeName,class:"tok-typeName"},{tag:A.namespace,class:"tok-namespace"},{tag:A.className,class:"tok-className"},{tag:A.macroName,class:"tok-macroName"},{tag:A.propertyName,class:"tok-propertyName"},{tag:A.operator,class:"tok-operator"},{tag:A.comment,class:"tok-comment"},{tag:A.meta,class:"tok-meta"},{tag:A.invalid,class:"tok-invalid"},{tag:A.punctuation,class:"tok-punctuation"}]);var Ag;const Eo=new Ee;function W_(n){return pe.define({combine:n?e=>e.concat(n):void 0})}const _0=new Ee;class ai{constructor(e,t,i=[],r=""){this.data=e,this.name=r,Ze.prototype.hasOwnProperty("tree")||Object.defineProperty(Ze.prototype,"tree",{get(){return Pt(this)}}),this.parser=t,this.extension=[Jr.of(this),Ze.languageData.of((s,o,a)=>{let c=Nb(s,o,a),h=c.type.prop(Eo);if(!h)return[];let f=s.facet(h),p=c.type.prop(_0);if(p){let m=c.resolve(o-c.from,a);for(let O of p)if(O.test(m,s)){let v=s.facet(O.facet);return O.type=="replace"?v:v.concat(f)}}return f})].concat(i)}isActiveAt(e,t,i=-1){return Nb(e,t,i).type.prop(Eo)==this.data}findRegions(e){let t=e.facet(Jr);if((t==null?void 0:t.data)==this.data)return[{from:0,to:e.doc.length}];if(!t||!t.allowsNesting)return[];let i=[],r=(s,o)=>{if(s.prop(Eo)==this.data){i.push({from:o,to:o+s.length});return}let a=s.prop(Ee.mounted);if(a){if(a.tree.prop(Eo)==this.data){if(a.overlay)for(let c of a.overlay)i.push({from:c.from+o,to:c.to+o});else i.push({from:o,to:o+s.length});return}else if(a.overlay){let c=i.length;if(r(a.tree,a.overlay[0].from+o),i.length>c)return}}for(let c=0;ci.isTop?t:void 0)]}),e.name)}configure(e,t){return new Ha(this.data,this.parser.configure(e),t||this.name)}get allowsNesting(){return this.parser.hasWrappers()}}function Pt(n){let e=n.field(ai.state,!1);return e?e.tree:wt.empty}class RL{constructor(e){this.doc=e,this.cursorPos=0,this.string="",this.cursor=e.iter()}get length(){return this.doc.length}syncTo(e){return this.string=this.cursor.next(e-this.cursorPos).value,this.cursorPos=e+this.string.length,this.cursorPos-this.string.length}chunk(e){return this.syncTo(e),this.string}get lineChunks(){return!0}read(e,t){let i=this.cursorPos-this.string.length;return e=this.cursorPos?this.doc.sliceString(e,t):this.string.slice(e-i,t-i)}}let ia=null;class df{constructor(e,t,i=[],r,s,o,a,c){this.parser=e,this.state=t,this.fragments=i,this.tree=r,this.treeLen=s,this.viewport=o,this.skipped=a,this.scheduleOn=c,this.parse=null,this.tempSkipped=[]}static create(e,t,i){return new df(e,t,[],wt.empty,0,i,[],null)}startParse(){return this.parser.startParse(new RL(this.state.doc),this.fragments)}work(e,t){return t!=null&&t>=this.state.doc.length&&(t=void 0),this.tree!=wt.empty&&this.isDone(t!=null?t:this.state.doc.length)?(this.takeTree(),!0):this.withContext(()=>{var i;if(typeof e=="number"){let r=Date.now()+e;e=()=>Date.now()>r}for(this.parse||(this.parse=this.startParse()),t!=null&&(this.parse.stoppedAt==null||this.parse.stoppedAt>t)&&t=this.treeLen&&((this.parse.stoppedAt==null||this.parse.stoppedAt>e)&&this.parse.stopAt(e),this.withContext(()=>{for(;!(t=this.parse.advance()););}),this.treeLen=e,this.tree=t,this.fragments=this.withoutTempSkipped(zs.addTree(this.tree,this.fragments,!0)),this.parse=null)}withContext(e){let t=ia;ia=this;try{return e()}finally{ia=t}}withoutTempSkipped(e){for(let t;t=this.tempSkipped.pop();)e=Bb(e,t.from,t.to);return e}changes(e,t){let{fragments:i,tree:r,treeLen:s,viewport:o,skipped:a}=this;if(this.takeTree(),!e.empty){let c=[];if(e.iterChangedRanges((h,f,p,m)=>c.push({fromA:h,toA:f,fromB:p,toB:m})),i=zs.applyChanges(i,c),r=wt.empty,s=0,o={from:e.mapPos(o.from,-1),to:e.mapPos(o.to,1)},this.skipped.length){a=[];for(let h of this.skipped){let f=e.mapPos(h.from,1),p=e.mapPos(h.to,-1);fe.from&&(this.fragments=Bb(this.fragments,r,s),this.skipped.splice(i--,1))}return this.skipped.length>=t?!1:(this.reset(),!0)}reset(){this.parse&&(this.takeTree(),this.parse=null)}skipUntilInView(e,t){this.skipped.push({from:e,to:t})}static getSkippingParser(e){return new class extends N_{createParse(t,i,r){let s=r[0].from,o=r[r.length-1].to;return{parsedPos:s,advance(){let c=ia;if(c){for(let h of r)c.tempSkipped.push(h);e&&(c.scheduleOn=c.scheduleOn?Promise.all([c.scheduleOn,e]):e)}return this.parsedPos=o,new wt(kn.none,[],[],o-s)},stoppedAt:null,stopAt(){}}}}}isDone(e){e=Math.min(e,this.state.doc.length);let t=this.fragments;return this.treeLen>=e&&t.length&&t[0].from==0&&t[0].to>=e}static get(){return ia}}function Bb(n,e,t){return zs.applyChanges(n,[{fromA:e,toA:t,fromB:e,toB:t}])}class Jo{constructor(e){this.context=e,this.tree=e.tree}apply(e){if(!e.docChanged&&this.tree==this.context.tree)return this;let t=this.context.changes(e.changes,e.state),i=this.context.treeLen==e.startState.doc.length?void 0:Math.max(e.changes.mapPos(this.context.treeLen),t.viewport.to);return t.work(20,i)||t.takeTree(),new Jo(t)}static init(e){let t=Math.min(3e3,e.doc.length),i=df.create(e.facet(Jr).parser,e,{from:0,to:t});return i.work(20,t)||i.takeTree(),new Jo(i)}}ai.state=jt.define({create:Jo.init,update(n,e){for(let t of e.effects)if(t.is(ai.setState))return t.value;return e.startState.facet(Jr)!=e.state.facet(Jr)?Jo.init(e.state):n.apply(e)}});let V_=n=>{let e=setTimeout(()=>n(),500);return()=>clearTimeout(e)};typeof requestIdleCallback<"u"&&(V_=n=>{let e=-1,t=setTimeout(()=>{e=requestIdleCallback(n,{timeout:400})},100);return()=>e<0?clearTimeout(t):cancelIdleCallback(e)});const Eg=typeof navigator<"u"&&(!((Ag=navigator.scheduling)===null||Ag===void 0)&&Ag.isInputPending)?()=>navigator.scheduling.isInputPending():null,AL=kt.fromClass(class{constructor(e){this.view=e,this.working=null,this.workScheduled=0,this.chunkEnd=-1,this.chunkBudget=-1,this.work=this.work.bind(this),this.scheduleWork()}update(e){let t=this.view.state.field(ai.state).context;(t.updateViewport(e.view.viewport)||this.view.viewport.to>t.treeLen)&&this.scheduleWork(),(e.docChanged||e.selectionSet)&&(this.view.hasFocus&&(this.chunkBudget+=50),this.scheduleWork()),this.checkAsyncSchedule(t)}scheduleWork(){if(this.working)return;let{state:e}=this.view,t=e.field(ai.state);(t.tree!=t.context.tree||!t.context.isDone(e.doc.length))&&(this.working=V_(this.work))}work(e){this.working=null;let t=Date.now();if(this.chunkEndr+1e3,c=s.context.work(()=>Eg&&Eg()||Date.now()>o,r+(a?0:1e5));this.chunkBudget-=Date.now()-t,(c||this.chunkBudget<=0)&&(s.context.takeTree(),this.view.dispatch({effects:ai.setState.of(new Jo(s.context))})),this.chunkBudget>0&&!(c&&!a)&&this.scheduleWork(),this.checkAsyncSchedule(s.context)}checkAsyncSchedule(e){e.scheduleOn&&(this.workScheduled++,e.scheduleOn.then(()=>this.scheduleWork()).catch(t=>bn(this.view.state,t)).then(()=>this.workScheduled--),e.scheduleOn=null)}destroy(){this.working&&this.working()}isWorking(){return!!(this.working||this.workScheduled>0)}},{eventHandlers:{focus(){this.scheduleWork()}}}),Jr=pe.define({combine(n){return n.length?n[0]:null},enables:n=>[ai.state,AL,fe.contentAttributes.compute([n],e=>{let t=e.facet(n);return t&&t.name?{"data-language":t.name}:{}})]});class F_{constructor(e,t=[]){this.language=e,this.support=t,this.extension=[e,t]}}const EL=pe.define(),Gf=pe.define({combine:n=>{if(!n.length)return" ";let e=n[0];if(!e||/\S/.test(e)||Array.from(e).some(t=>t!=e[0]))throw new Error("Invalid indent unit: "+JSON.stringify(n[0]));return e}});function pf(n){let e=n.facet(Gf);return e.charCodeAt(0)==9?n.tabSize*e.length:e.length}function Ga(n,e){let t="",i=n.tabSize,r=n.facet(Gf)[0];if(r==" "){for(;e>=i;)t+=" ",e-=i;r=" "}for(let s=0;s=e?LL(n,t,e):null}class Kf{constructor(e,t={}){this.state=e,this.options=t,this.unit=pf(e)}lineAt(e,t=1){let i=this.state.doc.lineAt(e),{simulateBreak:r,simulateDoubleBreak:s}=this.options;return r!=null&&r>=i.from&&r<=i.to?s&&r==e?{text:"",from:e}:(t<0?r-1&&(s+=o-this.countColumn(i,i.search(/\S|$/))),s}countColumn(e,t=e.length){return ol(e,this.state.tabSize,t)}lineIndent(e,t=1){let{text:i,from:r}=this.lineAt(e,t),s=this.options.overrideIndentation;if(s){let o=s(r);if(o>-1)return o}return this.countColumn(i,i.search(/\S|$/))}get simulatedBreak(){return this.options.simulateBreak||null}}const C0=new Ee;function LL(n,e,t){let i=e.resolveStack(t),r=e.resolveInner(t,-1).resolve(t,0).enterUnfinishedNodesBefore(t);if(r!=i.node){let s=[];for(let o=r;o&&!(o.fromi.node.to||o.from==i.node.from&&o.type==i.node.type);o=o.parent)s.push(o);for(let o=s.length-1;o>=0;o--)i={node:s[o],next:i}}return Y_(i,n,t)}function Y_(n,e,t){for(let i=n;i;i=i.next){let r=zL(i.node);if(r)return r(T0.create(e,t,i))}return 0}function DL(n){return n.pos==n.options.simulateBreak&&n.options.simulateDoubleBreak}function zL(n){let e=n.type.prop(C0);if(e)return e;let t=n.firstChild,i;if(t&&(i=t.type.prop(Ee.closedBy))){let r=n.lastChild,s=r&&i.indexOf(r.name)>-1;return o=>q_(o,!0,1,void 0,s&&!DL(o)?r.from:void 0)}return n.parent==null?jL:null}function jL(){return 0}class T0 extends Kf{constructor(e,t,i){super(e.state,e.options),this.base=e,this.pos=t,this.context=i}get node(){return this.context.node}static create(e,t,i){return new T0(e,t,i)}get textAfter(){return this.textAfterPos(this.pos)}get baseIndent(){return this.baseIndentFor(this.node)}baseIndentFor(e){let t=this.state.doc.lineAt(e.from);for(;;){let i=e.resolve(t.from);for(;i.parent&&i.parent.from==i.from;)i=i.parent;if(ZL(i,e))break;t=this.state.doc.lineAt(i.from)}return this.lineIndent(t.from)}continue(){return Y_(this.context.next,this.base,this.pos)}}function ZL(n,e){for(let t=e;t;t=t.parent)if(n==t)return!0;return!1}function IL(n){let e=n.node,t=e.childAfter(e.from),i=e.lastChild;if(!t)return null;let r=n.options.simulateBreak,s=n.state.doc.lineAt(t.from),o=r==null||r<=s.from?s.to:Math.min(s.to,r);for(let a=t.to;;){let c=e.childAfter(a);if(!c||c==i)return null;if(!c.type.isSkipped){if(c.from>=o)return null;let h=/^ */.exec(s.text.slice(t.to-s.from))[0].length;return{from:t.from,to:t.to+h}}a=c.to}}function NL({closing:n,align:e=!0,units:t=1}){return i=>q_(i,e,t,n)}function q_(n,e,t,i,r){let s=n.textAfter,o=s.match(/^\s*/)[0].length,a=i&&s.slice(o,o+i.length)==i||r==n.pos+o,c=e?IL(n):null;return c?a?n.column(c.from):n.column(c.to):n.baseIndent+(a?0:n.unit*t)}const BL=n=>n.baseIndent;function Zh({except:n,units:e=1}={}){return t=>{let i=n&&n.test(t.textAfter);return t.baseIndent+(i?0:e*t.unit)}}const XL=200;function WL(){return Ze.transactionFilter.of(n=>{if(!n.docChanged||!n.isUserEvent("input.type")&&!n.isUserEvent("input.complete"))return n;let e=n.startState.languageDataAt("indentOnInput",n.startState.selection.main.head);if(!e.length)return n;let t=n.newDoc,{head:i}=n.newSelection.main,r=t.lineAt(i);if(i>r.from+XL)return n;let s=t.sliceString(r.from,i);if(!e.some(h=>h.test(s)))return n;let{state:o}=n,a=-1,c=[];for(let{head:h}of o.selection.ranges){let f=o.doc.lineAt(h);if(f.from==a)continue;a=f.from;let p=Q0(o,f.from);if(p==null)continue;let m=/^\s*/.exec(f.text)[0],O=Ga(o,p);m!=O&&c.push({from:f.from,to:f.from+m.length,insert:O})}return c.length?[n,{changes:c,sequential:!0}]:n})}const VL=pe.define(),$0=new Ee;function FL(n){let e=n.firstChild,t=n.lastChild;return e&&e.tot)continue;if(s&&a.from=e&&h.to>t&&(s=h)}}return s}function qL(n){let e=n.lastChild;return e&&e.to==n.to&&e.type.isError}function gf(n,e,t){for(let i of n.facet(VL)){let r=i(n,e,t);if(r)return r}return YL(n,e,t)}function U_(n,e){let t=e.mapPos(n.from,1),i=e.mapPos(n.to,-1);return t>=i?void 0:{from:t,to:i}}const Jf=$e.define({map:U_}),xc=$e.define({map:U_});function H_(n){let e=[];for(let{head:t}of n.state.selection.ranges)e.some(i=>i.from<=t&&i.to>=t)||e.push(n.lineBlockAt(t));return e}const Ys=jt.define({create(){return _e.none},update(n,e){e.isUserEvent("delete")&&e.changes.iterChangedRanges((t,i)=>n=Xb(n,t,i)),n=n.map(e.changes);for(let t of e.effects)if(t.is(Jf)&&!UL(n,t.value.from,t.value.to)){let{preparePlaceholder:i}=e.state.facet(J_),r=i?_e.replace({widget:new n5(i(e.state,t.value))}):Wb;n=n.update({add:[r.range(t.value.from,t.value.to)]})}else t.is(xc)&&(n=n.update({filter:(i,r)=>t.value.from!=i||t.value.to!=r,filterFrom:t.value.from,filterTo:t.value.to}));return e.selection&&(n=Xb(n,e.selection.main.head)),n},provide:n=>fe.decorations.from(n),toJSON(n,e){let t=[];return n.between(0,e.doc.length,(i,r)=>{t.push(i,r)}),t},fromJSON(n){if(!Array.isArray(n)||n.length%2)throw new RangeError("Invalid JSON for fold state");let e=[];for(let t=0;t{re&&(i=!0)}),i?n.update({filterFrom:e,filterTo:t,filter:(r,s)=>r>=t||s<=e}):n}function mf(n,e,t){var i;let r=null;return(i=n.field(Ys,!1))===null||i===void 0||i.between(e,t,(s,o)=>{(!r||r.from>s)&&(r={from:s,to:o})}),r}function UL(n,e,t){let i=!1;return n.between(e,e,(r,s)=>{r==e&&s==t&&(i=!0)}),i}function G_(n,e){return n.field(Ys,!1)?e:e.concat($e.appendConfig.of(eQ()))}const HL=n=>{for(let e of H_(n)){let t=gf(n.state,e.from,e.to);if(t)return n.dispatch({effects:G_(n.state,[Jf.of(t),K_(n,t)])}),!0}return!1},GL=n=>{if(!n.state.field(Ys,!1))return!1;let e=[];for(let t of H_(n)){let i=mf(n.state,t.from,t.to);i&&e.push(xc.of(i),K_(n,i,!1))}return e.length&&n.dispatch({effects:e}),e.length>0};function K_(n,e,t=!0){let i=n.state.doc.lineAt(e.from).number,r=n.state.doc.lineAt(e.to).number;return fe.announce.of(`${n.state.phrase(t?"Folded lines":"Unfolded lines")} ${i} ${n.state.phrase("to")} ${r}.`)}const KL=n=>{let{state:e}=n,t=[];for(let i=0;i{let e=n.state.field(Ys,!1);if(!e||!e.size)return!1;let t=[];return e.between(0,n.state.doc.length,(i,r)=>{t.push(xc.of({from:i,to:r}))}),n.dispatch({effects:t}),!0},e5=[{key:"Ctrl-Shift-[",mac:"Cmd-Alt-[",run:HL},{key:"Ctrl-Shift-]",mac:"Cmd-Alt-]",run:GL},{key:"Ctrl-Alt-[",run:KL},{key:"Ctrl-Alt-]",run:JL}],t5={placeholderDOM:null,preparePlaceholder:null,placeholderText:"…"},J_=pe.define({combine(n){return Zi(n,t5)}});function eQ(n){return[Ys,s5]}function tQ(n,e){let{state:t}=n,i=t.facet(J_),r=o=>{let a=n.lineBlockAt(n.posAtDOM(o.target)),c=mf(n.state,a.from,a.to);c&&n.dispatch({effects:xc.of(c)}),o.preventDefault()};if(i.placeholderDOM)return i.placeholderDOM(n,r,e);let s=document.createElement("span");return s.textContent=i.placeholderText,s.setAttribute("aria-label",t.phrase("folded code")),s.title=t.phrase("unfold"),s.className="cm-foldPlaceholder",s.onclick=r,s}const Wb=_e.replace({widget:new class extends ur{toDOM(n){return tQ(n,null)}}});class n5 extends ur{constructor(e){super(),this.value=e}eq(e){return this.value==e.value}toDOM(e){return tQ(e,this.value)}}const i5={openText:"⌄",closedText:"›",markerDOM:null,domEventHandlers:{},foldingChanged:()=>!1};class Lg extends ar{constructor(e,t){super(),this.config=e,this.open=t}eq(e){return this.config==e.config&&this.open==e.open}toDOM(e){if(this.config.markerDOM)return this.config.markerDOM(this.open);let t=document.createElement("span");return t.textContent=this.open?this.config.openText:this.config.closedText,t.title=e.state.phrase(this.open?"Fold line":"Unfold line"),t}}function r5(n={}){let e={...i5,...n},t=new Lg(e,!0),i=new Lg(e,!1),r=kt.fromClass(class{constructor(o){this.from=o.viewport.from,this.markers=this.buildMarkers(o)}update(o){(o.docChanged||o.viewportChanged||o.startState.facet(Jr)!=o.state.facet(Jr)||o.startState.field(Ys,!1)!=o.state.field(Ys,!1)||Pt(o.startState)!=Pt(o.state)||e.foldingChanged(o))&&(this.markers=this.buildMarkers(o.view))}buildMarkers(o){let a=new or;for(let c of o.viewportLineBlocks){let h=mf(o.state,c.from,c.to)?i:gf(o.state,c.from,c.to)?t:null;h&&a.add(c.from,c.from,h)}return a.finish()}}),{domEventHandlers:s}=e;return[r,sL({class:"cm-foldGutter",markers(o){var a;return((a=o.plugin(r))===null||a===void 0?void 0:a.markers)||Ie.empty},initialSpacer(){return new Lg(e,!1)},domEventHandlers:{...s,click:(o,a,c)=>{if(s.click&&s.click(o,a,c))return!0;let h=mf(o.state,a.from,a.to);if(h)return o.dispatch({effects:xc.of(h)}),!0;let f=gf(o.state,a.from,a.to);return f?(o.dispatch({effects:Jf.of(f)}),!0):!1}}}),eQ()]}const s5=fe.baseTheme({".cm-foldPlaceholder":{backgroundColor:"#eee",border:"1px solid #ddd",color:"#888",borderRadius:".2em",margin:"0 1px",padding:"0 1px",cursor:"pointer"},".cm-foldGutter span":{padding:"0 1px",cursor:"pointer"}});class vc{constructor(e,t){this.specs=e;let i;function r(a){let c=Ur.newName();return(i||(i=Object.create(null)))["."+c]=a,c}const s=typeof t.all=="string"?t.all:t.all?r(t.all):void 0,o=t.scope;this.scope=o instanceof ai?a=>a.prop(Eo)==o.data:o?a=>a==o:void 0,this.style=X_(e.map(a=>({tag:a.tag,class:a.class||r(Object.assign({},a,{tag:null}))})),{all:s}).style,this.module=i?new Ur(i):null,this.themeType=t.themeType}static define(e,t){return new vc(e,t||{})}}const OO=pe.define(),nQ=pe.define({combine(n){return n.length?[n[0]]:null}});function Dg(n){let e=n.facet(OO);return e.length?e:n.facet(nQ)}function iQ(n,e){let t=[l5],i;return n instanceof vc&&(n.module&&t.push(fe.styleModule.of(n.module)),i=n.themeType),e!=null&&e.fallback?t.push(nQ.of(n)):i?t.push(OO.computeN([fe.darkTheme],r=>r.facet(fe.darkTheme)==(i=="dark")?[n]:[])):t.push(OO.of(n)),t}class o5{constructor(e){this.markCache=Object.create(null),this.tree=Pt(e.state),this.decorations=this.buildDeco(e,Dg(e.state)),this.decoratedTo=e.viewport.to}update(e){let t=Pt(e.state),i=Dg(e.state),r=i!=Dg(e.startState),{viewport:s}=e.view,o=e.changes.mapPos(this.decoratedTo,1);t.length=s.to?(this.decorations=this.decorations.map(e.changes),this.decoratedTo=o):(t!=this.tree||e.viewportChanged||r)&&(this.tree=t,this.decorations=this.buildDeco(e.view,i),this.decoratedTo=s.to)}buildDeco(e,t){if(!t||!this.tree.length)return _e.none;let i=new or;for(let{from:r,to:s}of e.visibleRanges)TL(this.tree,t,(o,a,c)=>{i.add(o,a,this.markCache[c]||(this.markCache[c]=_e.mark({class:c})))},r,s);return i.finish()}}const l5=ts.high(kt.fromClass(o5,{decorations:n=>n.decorations})),a5=vc.define([{tag:A.meta,color:"#404740"},{tag:A.link,textDecoration:"underline"},{tag:A.heading,textDecoration:"underline",fontWeight:"bold"},{tag:A.emphasis,fontStyle:"italic"},{tag:A.strong,fontWeight:"bold"},{tag:A.strikethrough,textDecoration:"line-through"},{tag:A.keyword,color:"#708"},{tag:[A.atom,A.bool,A.url,A.contentSeparator,A.labelName],color:"#219"},{tag:[A.literal,A.inserted],color:"#164"},{tag:[A.string,A.deleted],color:"#a11"},{tag:[A.regexp,A.escape,A.special(A.string)],color:"#e40"},{tag:A.definition(A.variableName),color:"#00f"},{tag:A.local(A.variableName),color:"#30a"},{tag:[A.typeName,A.namespace],color:"#085"},{tag:A.className,color:"#167"},{tag:[A.special(A.variableName),A.macroName],color:"#256"},{tag:A.definition(A.propertyName),color:"#00c"},{tag:A.comment,color:"#940"},{tag:A.invalid,color:"#f00"}]),c5=fe.baseTheme({"&.cm-focused .cm-matchingBracket":{backgroundColor:"#328c8252"},"&.cm-focused .cm-nonmatchingBracket":{backgroundColor:"#bb555544"}}),rQ=1e4,sQ="()[]{}",oQ=pe.define({combine(n){return Zi(n,{afterCursor:!0,brackets:sQ,maxScanDistance:rQ,renderMatch:f5})}}),u5=_e.mark({class:"cm-matchingBracket"}),h5=_e.mark({class:"cm-nonmatchingBracket"});function f5(n){let e=[],t=n.matched?u5:h5;return e.push(t.range(n.start.from,n.start.to)),n.end&&e.push(t.range(n.end.from,n.end.to)),e}const d5=jt.define({create(){return _e.none},update(n,e){if(!e.docChanged&&!e.selection)return n;let t=[],i=e.state.facet(oQ);for(let r of e.state.selection.ranges){if(!r.empty)continue;let s=Ai(e.state,r.head,-1,i)||r.head>0&&Ai(e.state,r.head-1,1,i)||i.afterCursor&&(Ai(e.state,r.head,1,i)||r.headfe.decorations.from(n)}),p5=[d5,c5];function g5(n={}){return[oQ.of(n),p5]}const m5=new Ee;function yO(n,e,t){let i=n.prop(e<0?Ee.openedBy:Ee.closedBy);if(i)return i;if(n.name.length==1){let r=t.indexOf(n.name);if(r>-1&&r%2==(e<0?1:0))return[t[r+e]]}return null}function xO(n){let e=n.type.prop(m5);return e?e(n.node):n}function Ai(n,e,t,i={}){let r=i.maxScanDistance||rQ,s=i.brackets||sQ,o=Pt(n),a=o.resolveInner(e,t);for(let c=a;c;c=c.parent){let h=yO(c.type,t,s);if(h&&c.from0?e>=f.from&&ef.from&&e<=f.to))return O5(n,e,t,c,f,h,s)}}return y5(n,e,t,o,a.type,r,s)}function O5(n,e,t,i,r,s,o){let a=i.parent,c={from:r.from,to:r.to},h=0,f=a==null?void 0:a.cursor();if(f&&(t<0?f.childBefore(i.from):f.childAfter(i.to)))do if(t<0?f.to<=i.from:f.from>=i.to){if(h==0&&s.indexOf(f.type.name)>-1&&f.from0)return null;let h={from:t<0?e-1:e,to:t>0?e+1:e},f=n.doc.iterRange(e,t>0?n.doc.length:0),p=0;for(let m=0;!f.next().done&&m<=s;){let O=f.value;t<0&&(m+=O.length);let v=e+m*t;for(let b=t>0?0:O.length-1,S=t>0?O.length:-1;b!=S;b+=t){let w=o.indexOf(O[b]);if(!(w<0||i.resolveInner(v+b,1).type!=r))if(w%2==0==t>0)p++;else{if(p==1)return{start:h,end:{from:v+b,to:v+b+1},matched:w>>1==c>>1};p--}}t>0&&(m+=O.length)}return f.done?{start:h,matched:!1}:null}const x5=Object.create(null),Vb=[kn.none],Fb=[],Yb=Object.create(null),v5=Object.create(null);for(let[n,e]of[["variable","variableName"],["variable-2","variableName.special"],["string-2","string.special"],["def","variableName.definition"],["tag","tagName"],["attribute","attributeName"],["type","typeName"],["builtin","variableName.standard"],["qualifier","modifier"],["error","invalid"],["header","heading"],["property","propertyName"]])v5[n]=b5(x5,e);function zg(n,e){Fb.indexOf(n)>-1||(Fb.push(n),console.warn(e))}function b5(n,e){let t=[];for(let a of e.split(" ")){let c=[];for(let h of a.split(".")){let f=n[h]||A[h];f?typeof f=="function"?c.length?c=c.map(f):zg(h,`Modifier ${h} used at start of tag`):c.length?zg(h,`Tag ${h} used as modifier`):c=Array.isArray(f)?f:[f]:zg(h,`Unknown highlighting tag ${h}`)}for(let h of c)t.push(h)}if(!t.length)return 0;let i=e.replace(/ /g,"_"),r=i+" "+t.map(a=>a.id),s=Yb[r];if(s)return s.id;let o=Yb[r]=kn.define({id:Vb.length,name:i,props:[P0({[i]:t})]});return Vb.push(o),o.id}st.RTL,st.LTR;const S5=n=>{let{state:e}=n,t=e.doc.lineAt(e.selection.main.from),i=R0(n.state,t.from);return i.line?w5(n):i.block?P5(n):!1};function M0(n,e){return({state:t,dispatch:i})=>{if(t.readOnly)return!1;let r=n(e,t);return r?(i(t.update(r)),!0):!1}}const w5=M0(C5,0),k5=M0(lQ,0),P5=M0((n,e)=>lQ(n,e,Q5(e)),0);function R0(n,e){let t=n.languageDataAt("commentTokens",e,1);return t.length?t[0]:{}}const ra=50;function _5(n,{open:e,close:t},i,r){let s=n.sliceDoc(i-ra,i),o=n.sliceDoc(r,r+ra),a=/\s*$/.exec(s)[0].length,c=/^\s*/.exec(o)[0].length,h=s.length-a;if(s.slice(h-e.length,h)==e&&o.slice(c,c+t.length)==t)return{open:{pos:i-a,margin:a&&1},close:{pos:r+c,margin:c&&1}};let f,p;r-i<=2*ra?f=p=n.sliceDoc(i,r):(f=n.sliceDoc(i,i+ra),p=n.sliceDoc(r-ra,r));let m=/^\s*/.exec(f)[0].length,O=/\s*$/.exec(p)[0].length,v=p.length-O-t.length;return f.slice(m,m+e.length)==e&&p.slice(v,v+t.length)==t?{open:{pos:i+m+e.length,margin:/\s/.test(f.charAt(m+e.length))?1:0},close:{pos:r-O-t.length,margin:/\s/.test(p.charAt(v-1))?1:0}}:null}function Q5(n){let e=[];for(let t of n.selection.ranges){let i=n.doc.lineAt(t.from),r=t.to<=i.to?i:n.doc.lineAt(t.to);r.from>i.from&&r.from==t.to&&(r=t.to==i.to+1?i:n.doc.lineAt(t.to-1));let s=e.length-1;s>=0&&e[s].to>i.from?e[s].to=r.to:e.push({from:i.from+/^\s*/.exec(i.text)[0].length,to:r.to})}return e}function lQ(n,e,t=e.selection.ranges){let i=t.map(s=>R0(e,s.from).block);if(!i.every(s=>s))return null;let r=t.map((s,o)=>_5(e,i[o],s.from,s.to));if(n!=2&&!r.every(s=>s))return{changes:e.changes(t.map((s,o)=>r[o]?[]:[{from:s.from,insert:i[o].open+" "},{from:s.to,insert:" "+i[o].close}]))};if(n!=1&&r.some(s=>s)){let s=[];for(let o=0,a;or&&(s==o||o>p.from)){r=p.from;let m=/^\s*/.exec(p.text)[0].length,O=m==p.length,v=p.text.slice(m,m+h.length)==h?m:-1;ms.comment<0&&(!s.empty||s.single))){let s=[];for(let{line:a,token:c,indent:h,empty:f,single:p}of i)(p||!f)&&s.push({from:a.from+h,insert:c+" "});let o=e.changes(s);return{changes:o,selection:e.selection.map(o,1)}}else if(n!=1&&i.some(s=>s.comment>=0)){let s=[];for(let{line:o,comment:a,token:c}of i)if(a>=0){let h=o.from+a,f=h+c.length;o.text[f-o.from]==" "&&f++,s.push({from:h,to:f})}return{changes:s}}return null}const vO=cr.define(),T5=cr.define(),$5=pe.define(),aQ=pe.define({combine(n){return Zi(n,{minDepth:100,newGroupDelay:500,joinToEvent:(e,t)=>t},{minDepth:Math.max,newGroupDelay:Math.min,joinToEvent:(e,t)=>(i,r)=>e(i,r)||t(i,r)})}}),cQ=jt.define({create(){return Ei.empty},update(n,e){let t=e.state.facet(aQ),i=e.annotation(vO);if(i){let c=Sn.fromTransaction(e,i.selection),h=i.side,f=h==0?n.undone:n.done;return c?f=Of(f,f.length,t.minDepth,c):f=fQ(f,e.startState.selection),new Ei(h==0?i.rest:f,h==0?f:i.rest)}let r=e.annotation(T5);if((r=="full"||r=="before")&&(n=n.isolate()),e.annotation(St.addToHistory)===!1)return e.changes.empty?n:n.addMapping(e.changes.desc);let s=Sn.fromTransaction(e),o=e.annotation(St.time),a=e.annotation(St.userEvent);return s?n=n.addChanges(s,o,a,t,e):e.selection&&(n=n.addSelection(e.startState.selection,o,a,t.newGroupDelay)),(r=="full"||r=="after")&&(n=n.isolate()),n},toJSON(n){return{done:n.done.map(e=>e.toJSON()),undone:n.undone.map(e=>e.toJSON())}},fromJSON(n){return new Ei(n.done.map(Sn.fromJSON),n.undone.map(Sn.fromJSON))}});function M5(n={}){return[cQ,aQ.of(n),fe.domEventHandlers({beforeinput(e,t){let i=e.inputType=="historyUndo"?uQ:e.inputType=="historyRedo"?bO:null;return i?(e.preventDefault(),i(t)):!1}})]}function ed(n,e){return function({state:t,dispatch:i}){if(!e&&t.readOnly)return!1;let r=t.field(cQ,!1);if(!r)return!1;let s=r.pop(n,t,e);return s?(i(s),!0):!1}}const uQ=ed(0,!1),bO=ed(1,!1),R5=ed(0,!0),A5=ed(1,!0);class Sn{constructor(e,t,i,r,s){this.changes=e,this.effects=t,this.mapped=i,this.startSelection=r,this.selectionsAfter=s}setSelAfter(e){return new Sn(this.changes,this.effects,this.mapped,this.startSelection,e)}toJSON(){var e,t,i;return{changes:(e=this.changes)===null||e===void 0?void 0:e.toJSON(),mapped:(t=this.mapped)===null||t===void 0?void 0:t.toJSON(),startSelection:(i=this.startSelection)===null||i===void 0?void 0:i.toJSON(),selectionsAfter:this.selectionsAfter.map(r=>r.toJSON())}}static fromJSON(e){return new Sn(e.changes&&Ct.fromJSON(e.changes),[],e.mapped&&Li.fromJSON(e.mapped),e.startSelection&&V.fromJSON(e.startSelection),e.selectionsAfter.map(V.fromJSON))}static fromTransaction(e,t){let i=Xn;for(let r of e.startState.facet($5)){let s=r(e);s.length&&(i=i.concat(s))}return!i.length&&e.changes.empty?null:new Sn(e.changes.invert(e.startState.doc),i,void 0,t||e.startState.selection,Xn)}static selection(e){return new Sn(void 0,Xn,void 0,void 0,e)}}function Of(n,e,t,i){let r=e+1>t+20?e-t-1:0,s=n.slice(r,e);return s.push(i),s}function E5(n,e){let t=[],i=!1;return n.iterChangedRanges((r,s)=>t.push(r,s)),e.iterChangedRanges((r,s,o,a)=>{for(let c=0;c=h&&o<=f&&(i=!0)}}),i}function L5(n,e){return n.ranges.length==e.ranges.length&&n.ranges.filter((t,i)=>t.empty!=e.ranges[i].empty).length===0}function hQ(n,e){return n.length?e.length?n.concat(e):n:e}const Xn=[],D5=200;function fQ(n,e){if(n.length){let t=n[n.length-1],i=t.selectionsAfter.slice(Math.max(0,t.selectionsAfter.length-D5));return i.length&&i[i.length-1].eq(e)?n:(i.push(e),Of(n,n.length-1,1e9,t.setSelAfter(i)))}else return[Sn.selection([e])]}function z5(n){let e=n[n.length-1],t=n.slice();return t[n.length-1]=e.setSelAfter(e.selectionsAfter.slice(0,e.selectionsAfter.length-1)),t}function jg(n,e){if(!n.length)return n;let t=n.length,i=Xn;for(;t;){let r=j5(n[t-1],e,i);if(r.changes&&!r.changes.empty||r.effects.length){let s=n.slice(0,t);return s[t-1]=r,s}else e=r.mapped,t--,i=r.selectionsAfter}return i.length?[Sn.selection(i)]:Xn}function j5(n,e,t){let i=hQ(n.selectionsAfter.length?n.selectionsAfter.map(a=>a.map(e)):Xn,t);if(!n.changes)return Sn.selection(i);let r=n.changes.map(e),s=e.mapDesc(n.changes,!0),o=n.mapped?n.mapped.composeDesc(s):s;return new Sn(r,$e.mapEffects(n.effects,e),o,n.startSelection.map(s),i)}const Z5=/^(input\.type|delete)($|\.)/;class Ei{constructor(e,t,i=0,r=void 0){this.done=e,this.undone=t,this.prevTime=i,this.prevUserEvent=r}isolate(){return this.prevTime?new Ei(this.done,this.undone):this}addChanges(e,t,i,r,s){let o=this.done,a=o[o.length-1];return a&&a.changes&&!a.changes.empty&&e.changes&&(!i||Z5.test(i))&&(!a.selectionsAfter.length&&t-this.prevTime0&&t-this.prevTimet.empty?n.moveByChar(t,e):td(t,e))}function rn(n){return n.textDirectionAt(n.state.selection.main.head)==st.LTR}const pQ=n=>dQ(n,!rn(n)),gQ=n=>dQ(n,rn(n));function mQ(n,e){return pi(n,t=>t.empty?n.moveByGroup(t,e):td(t,e))}const N5=n=>mQ(n,!rn(n)),B5=n=>mQ(n,rn(n));function X5(n,e,t){if(e.type.prop(t))return!0;let i=e.to-e.from;return i&&(i>2||/[^\s,.;:]/.test(n.sliceDoc(e.from,e.to)))||e.firstChild}function nd(n,e,t){let i=Pt(n).resolveInner(e.head),r=t?Ee.closedBy:Ee.openedBy;for(let c=e.head;;){let h=t?i.childAfter(c):i.childBefore(c);if(!h)break;X5(n,h,r)?i=h:c=t?h.to:h.from}let s=i.type.prop(r),o,a;return s&&(o=t?Ai(n,i.from,1):Ai(n,i.to,-1))&&o.matched?a=t?o.end.to:o.end.from:a=t?i.to:i.from,V.cursor(a,t?-1:1)}const W5=n=>pi(n,e=>nd(n.state,e,!rn(n))),V5=n=>pi(n,e=>nd(n.state,e,rn(n)));function OQ(n,e){return pi(n,t=>{if(!t.empty)return td(t,e);let i=n.moveVertically(t,e);return i.head!=t.head?i:n.moveToLineBoundary(t,e)})}const yQ=n=>OQ(n,!1),xQ=n=>OQ(n,!0);function vQ(n){let e=n.scrollDOM.clientHeighto.empty?n.moveVertically(o,e,t.height):td(o,e));if(r.eq(i.selection))return!1;let s;if(t.selfScroll){let o=n.coordsAtPos(i.selection.main.head),a=n.scrollDOM.getBoundingClientRect(),c=a.top+t.marginTop,h=a.bottom-t.marginBottom;o&&o.top>c&&o.bottombQ(n,!1),SO=n=>bQ(n,!0);function ns(n,e,t){let i=n.lineBlockAt(e.head),r=n.moveToLineBoundary(e,t);if(r.head==e.head&&r.head!=(t?i.to:i.from)&&(r=n.moveToLineBoundary(e,t,!1)),!t&&r.head==i.from&&i.length){let s=/^\s*/.exec(n.state.sliceDoc(i.from,Math.min(i.from+100,i.to)))[0].length;s&&e.head!=i.from+s&&(r=V.cursor(i.from+s))}return r}const F5=n=>pi(n,e=>ns(n,e,!0)),Y5=n=>pi(n,e=>ns(n,e,!1)),q5=n=>pi(n,e=>ns(n,e,!rn(n))),U5=n=>pi(n,e=>ns(n,e,rn(n))),H5=n=>pi(n,e=>V.cursor(n.lineBlockAt(e.head).from,1)),G5=n=>pi(n,e=>V.cursor(n.lineBlockAt(e.head).to,-1));function K5(n,e,t){let i=!1,r=ll(n.selection,s=>{let o=Ai(n,s.head,-1)||Ai(n,s.head,1)||s.head>0&&Ai(n,s.head-1,1)||s.headK5(n,e);function Hn(n,e){let t=ll(n.state.selection,i=>{let r=e(i);return V.range(i.anchor,r.head,r.goalColumn,r.bidiLevel||void 0)});return t.eq(n.state.selection)?!1:(n.dispatch(di(n.state,t)),!0)}function SQ(n,e){return Hn(n,t=>n.moveByChar(t,e))}const wQ=n=>SQ(n,!rn(n)),kQ=n=>SQ(n,rn(n));function PQ(n,e){return Hn(n,t=>n.moveByGroup(t,e))}const eD=n=>PQ(n,!rn(n)),tD=n=>PQ(n,rn(n)),nD=n=>Hn(n,e=>nd(n.state,e,!rn(n))),iD=n=>Hn(n,e=>nd(n.state,e,rn(n)));function _Q(n,e){return Hn(n,t=>n.moveVertically(t,e))}const QQ=n=>_Q(n,!1),CQ=n=>_Q(n,!0);function TQ(n,e){return Hn(n,t=>n.moveVertically(t,e,vQ(n).height))}const Ub=n=>TQ(n,!1),Hb=n=>TQ(n,!0),rD=n=>Hn(n,e=>ns(n,e,!0)),sD=n=>Hn(n,e=>ns(n,e,!1)),oD=n=>Hn(n,e=>ns(n,e,!rn(n))),lD=n=>Hn(n,e=>ns(n,e,rn(n))),aD=n=>Hn(n,e=>V.cursor(n.lineBlockAt(e.head).from)),cD=n=>Hn(n,e=>V.cursor(n.lineBlockAt(e.head).to)),Gb=({state:n,dispatch:e})=>(e(di(n,{anchor:0})),!0),Kb=({state:n,dispatch:e})=>(e(di(n,{anchor:n.doc.length})),!0),Jb=({state:n,dispatch:e})=>(e(di(n,{anchor:n.selection.main.anchor,head:0})),!0),eS=({state:n,dispatch:e})=>(e(di(n,{anchor:n.selection.main.anchor,head:n.doc.length})),!0),uD=({state:n,dispatch:e})=>(e(n.update({selection:{anchor:0,head:n.doc.length},userEvent:"select"})),!0),hD=({state:n,dispatch:e})=>{let t=id(n).map(({from:i,to:r})=>V.range(i,Math.min(r+1,n.doc.length)));return e(n.update({selection:V.create(t),userEvent:"select"})),!0},fD=({state:n,dispatch:e})=>{let t=ll(n.selection,i=>{let r=Pt(n),s=r.resolveStack(i.from,1);if(i.empty){let o=r.resolveStack(i.from,-1);o.node.from>=s.node.from&&o.node.to<=s.node.to&&(s=o)}for(let o=s;o;o=o.next){let{node:a}=o;if((a.from=i.to||a.to>i.to&&a.from<=i.from)&&o.next)return V.range(a.to,a.from)}return i});return t.eq(n.selection)?!1:(e(di(n,t)),!0)};function $Q(n,e){let{state:t}=n,i=t.selection,r=t.selection.ranges.slice();for(let s of t.selection.ranges){let o=t.doc.lineAt(s.head);if(e?o.to0)for(let a=s;;){let c=n.moveVertically(a,e);if(c.heado.to){r.some(h=>h.head==c.head)||r.push(c);break}else{if(c.head==a.head)break;a=c}}}return r.length==i.ranges.length?!1:(n.dispatch(di(t,V.create(r,r.length-1))),!0)}const dD=n=>$Q(n,!1),pD=n=>$Q(n,!0),gD=({state:n,dispatch:e})=>{let t=n.selection,i=null;return t.ranges.length>1?i=V.create([t.main]):t.main.empty||(i=V.create([V.cursor(t.main.head)])),i?(e(di(n,i)),!0):!1};function bc(n,e){if(n.state.readOnly)return!1;let t="delete.selection",{state:i}=n,r=i.changeByRange(s=>{let{from:o,to:a}=s;if(o==a){let c=e(s);co&&(t="delete.forward",c=ih(n,c,!0)),o=Math.min(o,c),a=Math.max(a,c)}else o=ih(n,o,!1),a=ih(n,a,!0);return o==a?{range:s}:{changes:{from:o,to:a},range:V.cursor(o,or(n)))i.between(e,e,(r,s)=>{re&&(e=t?s:r)});return e}const MQ=(n,e,t)=>bc(n,i=>{let r=i.from,{state:s}=n,o=s.doc.lineAt(r),a,c;if(t&&!e&&r>o.from&&rMQ(n,!1,!0),RQ=n=>MQ(n,!0,!1),AQ=(n,e)=>bc(n,t=>{let i=t.head,{state:r}=n,s=r.doc.lineAt(i),o=r.charCategorizer(i);for(let a=null;;){if(i==(e?s.to:s.from)){i==t.head&&s.number!=(e?r.doc.lines:1)&&(i+=e?1:-1);break}let c=Vt(s.text,i-s.from,e)+s.from,h=s.text.slice(Math.min(i,c)-s.from,Math.max(i,c)-s.from),f=o(h);if(a!=null&&f!=a)break;(h!=" "||i!=t.head)&&(a=f),i=c}return i}),EQ=n=>AQ(n,!1),mD=n=>AQ(n,!0),OD=n=>bc(n,e=>{let t=n.lineBlockAt(e.head).to;return e.headbc(n,e=>{let t=n.moveToLineBoundary(e,!1).head;return e.head>t?t:Math.max(0,e.head-1)}),xD=n=>bc(n,e=>{let t=n.moveToLineBoundary(e,!0).head;return e.head{if(n.readOnly)return!1;let t=n.changeByRange(i=>({changes:{from:i.from,to:i.to,insert:ze.of(["",""])},range:V.cursor(i.from)}));return e(n.update(t,{scrollIntoView:!0,userEvent:"input"})),!0},bD=({state:n,dispatch:e})=>{if(n.readOnly)return!1;let t=n.changeByRange(i=>{if(!i.empty||i.from==0||i.from==n.doc.length)return{range:i};let r=i.from,s=n.doc.lineAt(r),o=r==s.from?r-1:Vt(s.text,r-s.from,!1)+s.from,a=r==s.to?r+1:Vt(s.text,r-s.from,!0)+s.from;return{changes:{from:o,to:a,insert:n.doc.slice(r,a).append(n.doc.slice(o,r))},range:V.cursor(a)}});return t.changes.empty?!1:(e(n.update(t,{scrollIntoView:!0,userEvent:"move.character"})),!0)};function id(n){let e=[],t=-1;for(let i of n.selection.ranges){let r=n.doc.lineAt(i.from),s=n.doc.lineAt(i.to);if(!i.empty&&i.to==s.from&&(s=n.doc.lineAt(i.to-1)),t>=r.number){let o=e[e.length-1];o.to=s.to,o.ranges.push(i)}else e.push({from:r.from,to:s.to,ranges:[i]});t=s.number+1}return e}function LQ(n,e,t){if(n.readOnly)return!1;let i=[],r=[];for(let s of id(n)){if(t?s.to==n.doc.length:s.from==0)continue;let o=n.doc.lineAt(t?s.to+1:s.from-1),a=o.length+1;if(t){i.push({from:s.to,to:o.to},{from:s.from,insert:o.text+n.lineBreak});for(let c of s.ranges)r.push(V.range(Math.min(n.doc.length,c.anchor+a),Math.min(n.doc.length,c.head+a)))}else{i.push({from:o.from,to:s.from},{from:s.to,insert:n.lineBreak+o.text});for(let c of s.ranges)r.push(V.range(c.anchor-a,c.head-a))}}return i.length?(e(n.update({changes:i,scrollIntoView:!0,selection:V.create(r,n.selection.mainIndex),userEvent:"move.line"})),!0):!1}const SD=({state:n,dispatch:e})=>LQ(n,e,!1),wD=({state:n,dispatch:e})=>LQ(n,e,!0);function DQ(n,e,t){if(n.readOnly)return!1;let i=[];for(let r of id(n))t?i.push({from:r.from,insert:n.doc.slice(r.from,r.to)+n.lineBreak}):i.push({from:r.to,insert:n.lineBreak+n.doc.slice(r.from,r.to)});return e(n.update({changes:i,scrollIntoView:!0,userEvent:"input.copyline"})),!0}const kD=({state:n,dispatch:e})=>DQ(n,e,!1),PD=({state:n,dispatch:e})=>DQ(n,e,!0),_D=n=>{if(n.state.readOnly)return!1;let{state:e}=n,t=e.changes(id(e).map(({from:r,to:s})=>(r>0?r--:s{let s;if(n.lineWrapping){let o=n.lineBlockAt(r.head),a=n.coordsAtPos(r.head,r.assoc||1);a&&(s=o.bottom+n.documentTop-a.bottom+n.defaultLineHeight/2)}return n.moveVertically(r,!0,s)}).map(t);return n.dispatch({changes:t,selection:i,scrollIntoView:!0,userEvent:"delete.line"}),!0};function QD(n,e){if(/\(\)|\[\]|\{\}/.test(n.sliceDoc(e-1,e+1)))return{from:e,to:e};let t=Pt(n).resolveInner(e),i=t.childBefore(e),r=t.childAfter(e),s;return i&&r&&i.to<=e&&r.from>=e&&(s=i.type.prop(Ee.closedBy))&&s.indexOf(r.name)>-1&&n.doc.lineAt(i.to).from==n.doc.lineAt(r.from).from&&!/\S/.test(n.sliceDoc(i.to,r.from))?{from:i.to,to:r.from}:null}const tS=zQ(!1),CD=zQ(!0);function zQ(n){return({state:e,dispatch:t})=>{if(e.readOnly)return!1;let i=e.changeByRange(r=>{let{from:s,to:o}=r,a=e.doc.lineAt(s),c=!n&&s==o&&QD(e,s);n&&(s=o=(o<=a.to?a:e.doc.lineAt(o)).to);let h=new Kf(e,{simulateBreak:s,simulateDoubleBreak:!!c}),f=Q0(h,s);for(f==null&&(f=ol(/^\s*/.exec(e.doc.lineAt(s).text)[0],e.tabSize));oa.from&&s{let r=[];for(let o=i.from;o<=i.to;){let a=n.doc.lineAt(o);a.number>t&&(i.empty||i.to>a.from)&&(e(a,r,i),t=a.number),o=a.to+1}let s=n.changes(r);return{changes:r,range:V.range(s.mapPos(i.anchor,1),s.mapPos(i.head,1))}})}const TD=({state:n,dispatch:e})=>{if(n.readOnly)return!1;let t=Object.create(null),i=new Kf(n,{overrideIndentation:s=>{let o=t[s];return o==null?-1:o}}),r=A0(n,(s,o,a)=>{let c=Q0(i,s.from);if(c==null)return;/\S/.test(s.text)||(c=0);let h=/^\s*/.exec(s.text)[0],f=Ga(n,c);(h!=f||a.fromn.readOnly?!1:(e(n.update(A0(n,(t,i)=>{i.push({from:t.from,insert:n.facet(Gf)})}),{userEvent:"input.indent"})),!0),ZQ=({state:n,dispatch:e})=>n.readOnly?!1:(e(n.update(A0(n,(t,i)=>{let r=/^\s*/.exec(t.text)[0];if(!r)return;let s=ol(r,n.tabSize),o=0,a=Ga(n,Math.max(0,s-pf(n)));for(;o(n.setTabFocusMode(),!0),MD=[{key:"Ctrl-b",run:pQ,shift:wQ,preventDefault:!0},{key:"Ctrl-f",run:gQ,shift:kQ},{key:"Ctrl-p",run:yQ,shift:QQ},{key:"Ctrl-n",run:xQ,shift:CQ},{key:"Ctrl-a",run:H5,shift:aD},{key:"Ctrl-e",run:G5,shift:cD},{key:"Ctrl-d",run:RQ},{key:"Ctrl-h",run:wO},{key:"Ctrl-k",run:OD},{key:"Ctrl-Alt-h",run:EQ},{key:"Ctrl-o",run:vD},{key:"Ctrl-t",run:bD},{key:"Ctrl-v",run:SO}],RD=[{key:"ArrowLeft",run:pQ,shift:wQ,preventDefault:!0},{key:"Mod-ArrowLeft",mac:"Alt-ArrowLeft",run:N5,shift:eD,preventDefault:!0},{mac:"Cmd-ArrowLeft",run:q5,shift:oD,preventDefault:!0},{key:"ArrowRight",run:gQ,shift:kQ,preventDefault:!0},{key:"Mod-ArrowRight",mac:"Alt-ArrowRight",run:B5,shift:tD,preventDefault:!0},{mac:"Cmd-ArrowRight",run:U5,shift:lD,preventDefault:!0},{key:"ArrowUp",run:yQ,shift:QQ,preventDefault:!0},{mac:"Cmd-ArrowUp",run:Gb,shift:Jb},{mac:"Ctrl-ArrowUp",run:qb,shift:Ub},{key:"ArrowDown",run:xQ,shift:CQ,preventDefault:!0},{mac:"Cmd-ArrowDown",run:Kb,shift:eS},{mac:"Ctrl-ArrowDown",run:SO,shift:Hb},{key:"PageUp",run:qb,shift:Ub},{key:"PageDown",run:SO,shift:Hb},{key:"Home",run:Y5,shift:sD,preventDefault:!0},{key:"Mod-Home",run:Gb,shift:Jb},{key:"End",run:F5,shift:rD,preventDefault:!0},{key:"Mod-End",run:Kb,shift:eS},{key:"Enter",run:tS,shift:tS},{key:"Mod-a",run:uD},{key:"Backspace",run:wO,shift:wO,preventDefault:!0},{key:"Delete",run:RQ,preventDefault:!0},{key:"Mod-Backspace",mac:"Alt-Backspace",run:EQ,preventDefault:!0},{key:"Mod-Delete",mac:"Alt-Delete",run:mD,preventDefault:!0},{mac:"Mod-Backspace",run:yD,preventDefault:!0},{mac:"Mod-Delete",run:xD,preventDefault:!0}].concat(MD.map(n=>({mac:n.key,run:n.run,shift:n.shift}))),AD=[{key:"Alt-ArrowLeft",mac:"Ctrl-ArrowLeft",run:W5,shift:nD},{key:"Alt-ArrowRight",mac:"Ctrl-ArrowRight",run:V5,shift:iD},{key:"Alt-ArrowUp",run:SD},{key:"Shift-Alt-ArrowUp",run:kD},{key:"Alt-ArrowDown",run:wD},{key:"Shift-Alt-ArrowDown",run:PD},{key:"Mod-Alt-ArrowUp",run:dD},{key:"Mod-Alt-ArrowDown",run:pD},{key:"Escape",run:gD},{key:"Mod-Enter",run:CD},{key:"Alt-l",mac:"Ctrl-l",run:hD},{key:"Mod-i",run:fD,preventDefault:!0},{key:"Mod-[",run:ZQ},{key:"Mod-]",run:jQ},{key:"Mod-Alt-\\",run:TD},{key:"Shift-Mod-k",run:_D},{key:"Shift-Mod-\\",run:J5},{key:"Mod-/",run:S5},{key:"Alt-A",run:k5},{key:"Ctrl-m",mac:"Shift-Alt-m",run:$D}].concat(RD),ED={key:"Tab",run:jQ,shift:ZQ},nS=typeof String.prototype.normalize=="function"?n=>n.normalize("NFKD"):n=>n;class el{constructor(e,t,i=0,r=e.length,s,o){this.test=o,this.value={from:0,to:0},this.done=!1,this.matches=[],this.buffer="",this.bufferPos=0,this.iter=e.iterRange(i,r),this.bufferStart=i,this.normalize=s?a=>s(nS(a)):nS,this.query=this.normalize(t)}peek(){if(this.bufferPos==this.buffer.length){if(this.bufferStart+=this.buffer.length,this.iter.next(),this.iter.done)return-1;this.bufferPos=0,this.buffer=this.iter.value}return yn(this.buffer,this.bufferPos)}next(){for(;this.matches.length;)this.matches.pop();return this.nextOverlapping()}nextOverlapping(){for(;;){let e=this.peek();if(e<0)return this.done=!0,this;let t=o0(e),i=this.bufferStart+this.bufferPos;this.bufferPos+=$i(e);let r=this.normalize(t);if(r.length)for(let s=0,o=i;;s++){let a=r.charCodeAt(s),c=this.match(a,o,this.bufferPos+this.bufferStart);if(s==r.length-1){if(c)return this.value=c,this;break}o==i&&sthis.to&&(this.curLine=this.curLine.slice(0,this.to-this.curLineStart)),this.iter.next())}nextLine(){this.curLineStart=this.curLineStart+this.curLine.length+1,this.curLineStart>this.to?this.curLine="":this.getLine(0)}next(){for(let e=this.matchPos-this.curLineStart;;){this.re.lastIndex=e;let t=this.matchPos<=this.to&&this.re.exec(this.curLine);if(t){let i=this.curLineStart+t.index,r=i+t[0].length;if(this.matchPos=yf(this.text,r+(i==r?1:0)),i==this.curLineStart+this.curLine.length&&this.nextLine(),(ithis.value.to)&&(!this.test||this.test(i,r,t)))return this.value={from:i,to:r,match:t},this;e=this.matchPos-this.curLineStart}else if(this.curLineStart+this.curLine.length=i||r.to<=t){let a=new Io(t,e.sliceString(t,i));return Zg.set(e,a),a}if(r.from==t&&r.to==i)return r;let{text:s,from:o}=r;return o>t&&(s=e.sliceString(t,o)+s,o=t),r.to=this.to?this.to:this.text.lineAt(e).to}next(){for(;;){let e=this.re.lastIndex=this.matchPos-this.flat.from,t=this.re.exec(this.flat.text);if(t&&!t[0]&&t.index==e&&(this.re.lastIndex=e+1,t=this.re.exec(this.flat.text)),t){let i=this.flat.from+t.index,r=i+t[0].length;if((this.flat.to>=this.to||t.index+t[0].length<=this.flat.text.length-10)&&(!this.test||this.test(i,r,t)))return this.value={from:i,to:r,match:t},this.matchPos=yf(this.text,r+(i==r?1:0)),this}if(this.flat.to==this.to)return this.done=!0,this;this.flat=Io.get(this.text,this.flat.from,this.chunkEnd(this.flat.from+this.flat.text.length*2))}}}typeof Symbol<"u"&&(NQ.prototype[Symbol.iterator]=BQ.prototype[Symbol.iterator]=function(){return this});function LD(n){try{return new RegExp(n,E0),!0}catch{return!1}}function yf(n,e){if(e>=n.length)return e;let t=n.lineAt(e),i;for(;e=56320&&i<57344;)e++;return e}function kO(n){let e=String(n.state.doc.lineAt(n.state.selection.main.head).number),t=He("input",{class:"cm-textfield",name:"line",value:e}),i=He("form",{class:"cm-gotoLine",onkeydown:s=>{s.keyCode==27?(s.preventDefault(),n.dispatch({effects:Ta.of(!1)}),n.focus()):s.keyCode==13&&(s.preventDefault(),r())},onsubmit:s=>{s.preventDefault(),r()}},He("label",n.state.phrase("Go to line"),": ",t)," ",He("button",{class:"cm-button",type:"submit"},n.state.phrase("go")),He("button",{name:"close",onclick:()=>{n.dispatch({effects:Ta.of(!1)}),n.focus()},"aria-label":n.state.phrase("close"),type:"button"},["×"]));function r(){let s=/^([+-])?(\d+)?(:\d+)?(%)?$/.exec(t.value);if(!s)return;let{state:o}=n,a=o.doc.lineAt(o.selection.main.head),[,c,h,f,p]=s,m=f?+f.slice(1):0,O=h?+h:a.number;if(h&&p){let S=O/100;c&&(S=S*(c=="-"?-1:1)+a.number/o.doc.lines),O=Math.round(o.doc.lines*S)}else h&&c&&(O=O*(c=="-"?-1:1)+a.number);let v=o.doc.line(Math.max(1,Math.min(o.doc.lines,O))),b=V.cursor(v.from+Math.max(0,Math.min(m,v.length)));n.dispatch({effects:[Ta.of(!1),fe.scrollIntoView(b.from,{y:"center"})],selection:b}),n.focus()}return{dom:i}}const Ta=$e.define(),iS=jt.define({create(){return!0},update(n,e){for(let t of e.effects)t.is(Ta)&&(n=t.value);return n},provide:n=>qa.from(n,e=>e?kO:null)}),DD=n=>{let e=Ya(n,kO);if(!e){let t=[Ta.of(!0)];n.state.field(iS,!1)==null&&t.push($e.appendConfig.of([iS,zD])),n.dispatch({effects:t}),e=Ya(n,kO)}return e&&e.dom.querySelector("input").select(),!0},zD=fe.baseTheme({".cm-panel.cm-gotoLine":{padding:"2px 6px 4px",position:"relative","& label":{fontSize:"80%"},"& [name=close]":{position:"absolute",top:"0",bottom:"0",right:"4px",backgroundColor:"inherit",border:"none",font:"inherit",padding:"0"}}}),jD={highlightWordAroundCursor:!1,minSelectionLength:1,maxMatches:100,wholeWords:!1},ZD=pe.define({combine(n){return Zi(n,jD,{highlightWordAroundCursor:(e,t)=>e||t,minSelectionLength:Math.min,maxMatches:Math.min})}});function ID(n){return[VD,WD]}const ND=_e.mark({class:"cm-selectionMatch"}),BD=_e.mark({class:"cm-selectionMatch cm-selectionMatch-main"});function rS(n,e,t,i){return(t==0||n(e.sliceDoc(t-1,t))!=lt.Word)&&(i==e.doc.length||n(e.sliceDoc(i,i+1))!=lt.Word)}function XD(n,e,t,i){return n(e.sliceDoc(t,t+1))==lt.Word&&n(e.sliceDoc(i-1,i))==lt.Word}const WD=kt.fromClass(class{constructor(n){this.decorations=this.getDeco(n)}update(n){(n.selectionSet||n.docChanged||n.viewportChanged)&&(this.decorations=this.getDeco(n.view))}getDeco(n){let e=n.state.facet(ZD),{state:t}=n,i=t.selection;if(i.ranges.length>1)return _e.none;let r=i.main,s,o=null;if(r.empty){if(!e.highlightWordAroundCursor)return _e.none;let c=t.wordAt(r.head);if(!c)return _e.none;o=t.charCategorizer(r.head),s=t.sliceDoc(c.from,c.to)}else{let c=r.to-r.from;if(c200)return _e.none;if(e.wholeWords){if(s=t.sliceDoc(r.from,r.to),o=t.charCategorizer(r.head),!(rS(o,t,r.from,r.to)&&XD(o,t,r.from,r.to)))return _e.none}else if(s=t.sliceDoc(r.from,r.to),!s)return _e.none}let a=[];for(let c of n.visibleRanges){let h=new el(t.doc,s,c.from,c.to);for(;!h.next().done;){let{from:f,to:p}=h.value;if((!o||rS(o,t,f,p))&&(r.empty&&f<=r.from&&p>=r.to?a.push(BD.range(f,p)):(f>=r.to||p<=r.from)&&a.push(ND.range(f,p)),a.length>e.maxMatches))return _e.none}}return _e.set(a)}},{decorations:n=>n.decorations}),VD=fe.baseTheme({".cm-selectionMatch":{backgroundColor:"#99ff7780"},".cm-searchMatch .cm-selectionMatch":{backgroundColor:"transparent"}}),FD=({state:n,dispatch:e})=>{let{selection:t}=n,i=V.create(t.ranges.map(r=>n.wordAt(r.head)||V.cursor(r.head)),t.mainIndex);return i.eq(t)?!1:(e(n.update({selection:i})),!0)};function YD(n,e){let{main:t,ranges:i}=n.selection,r=n.wordAt(t.head),s=r&&r.from==t.from&&r.to==t.to;for(let o=!1,a=new el(n.doc,e,i[i.length-1].to);;)if(a.next(),a.done){if(o)return null;a=new el(n.doc,e,0,Math.max(0,i[i.length-1].from-1)),o=!0}else{if(o&&i.some(c=>c.from==a.value.from))continue;if(s){let c=n.wordAt(a.value.from);if(!c||c.from!=a.value.from||c.to!=a.value.to)continue}return a.value}}const qD=({state:n,dispatch:e})=>{let{ranges:t}=n.selection;if(t.some(s=>s.from===s.to))return FD({state:n,dispatch:e});let i=n.sliceDoc(t[0].from,t[0].to);if(n.selection.ranges.some(s=>n.sliceDoc(s.from,s.to)!=i))return!1;let r=YD(n,i);return r?(e(n.update({selection:n.selection.addRange(V.range(r.from,r.to),!1),effects:fe.scrollIntoView(r.to)})),!0):!1},al=pe.define({combine(n){return Zi(n,{top:!1,caseSensitive:!1,literal:!1,regexp:!1,wholeWord:!1,createPanel:e=>new oz(e),scrollToMatch:e=>fe.scrollIntoView(e)})}});class XQ{constructor(e){this.search=e.search,this.caseSensitive=!!e.caseSensitive,this.literal=!!e.literal,this.regexp=!!e.regexp,this.replace=e.replace||"",this.valid=!!this.search&&(!this.regexp||LD(this.search)),this.unquoted=this.unquote(this.search),this.wholeWord=!!e.wholeWord}unquote(e){return this.literal?e:e.replace(/\\([nrt\\])/g,(t,i)=>i=="n"?` +`:i=="r"?"\r":i=="t"?" ":"\\")}eq(e){return this.search==e.search&&this.replace==e.replace&&this.caseSensitive==e.caseSensitive&&this.regexp==e.regexp&&this.wholeWord==e.wholeWord}create(){return this.regexp?new KD(this):new HD(this)}getCursor(e,t=0,i){let r=e.doc?e:Ze.create({doc:e});return i==null&&(i=r.doc.length),this.regexp?$o(this,r,t,i):To(this,r,t,i)}}let WQ=class{constructor(e){this.spec=e}};function To(n,e,t,i){return new el(e.doc,n.unquoted,t,i,n.caseSensitive?void 0:r=>r.toLowerCase(),n.wholeWord?UD(e.doc,e.charCategorizer(e.selection.main.head)):void 0)}function UD(n,e){return(t,i,r,s)=>((s>t||s+r.length=t)return null;r.push(i.value)}return r}highlight(e,t,i,r){let s=To(this.spec,e,Math.max(0,t-this.spec.unquoted.length),Math.min(i+this.spec.unquoted.length,e.doc.length));for(;!s.next().done;)r(s.value.from,s.value.to)}}function $o(n,e,t,i){return new NQ(e.doc,n.search,{ignoreCase:!n.caseSensitive,test:n.wholeWord?GD(e.charCategorizer(e.selection.main.head)):void 0},t,i)}function xf(n,e){return n.slice(Vt(n,e,!1),e)}function vf(n,e){return n.slice(e,Vt(n,e))}function GD(n){return(e,t,i)=>!i[0].length||(n(xf(i.input,i.index))!=lt.Word||n(vf(i.input,i.index))!=lt.Word)&&(n(vf(i.input,i.index+i[0].length))!=lt.Word||n(xf(i.input,i.index+i[0].length))!=lt.Word)}class KD extends WQ{nextMatch(e,t,i){let r=$o(this.spec,e,i,e.doc.length).next();return r.done&&(r=$o(this.spec,e,0,t).next()),r.done?null:r.value}prevMatchInRange(e,t,i){for(let r=1;;r++){let s=Math.max(t,i-r*1e4),o=$o(this.spec,e,s,i),a=null;for(;!o.next().done;)a=o.value;if(a&&(s==t||a.from>s+10))return a;if(s==t)return null}}prevMatch(e,t,i){return this.prevMatchInRange(e,0,t)||this.prevMatchInRange(e,i,e.doc.length)}getReplacement(e){return this.spec.unquote(this.spec.replace).replace(/\$([$&]|\d+)/g,(t,i)=>{if(i=="&")return e.match[0];if(i=="$")return"$";for(let r=i.length;r>0;r--){let s=+i.slice(0,r);if(s>0&&s=t)return null;r.push(i.value)}return r}highlight(e,t,i,r){let s=$o(this.spec,e,Math.max(0,t-250),Math.min(i+250,e.doc.length));for(;!s.next().done;)r(s.value.from,s.value.to)}}const Ka=$e.define(),L0=$e.define(),Fr=jt.define({create(n){return new Ig(PO(n).create(),null)},update(n,e){for(let t of e.effects)t.is(Ka)?n=new Ig(t.value.create(),n.panel):t.is(L0)&&(n=new Ig(n.query,t.value?D0:null));return n},provide:n=>qa.from(n,e=>e.panel)});class Ig{constructor(e,t){this.query=e,this.panel=t}}const JD=_e.mark({class:"cm-searchMatch"}),ez=_e.mark({class:"cm-searchMatch cm-searchMatch-selected"}),tz=kt.fromClass(class{constructor(n){this.view=n,this.decorations=this.highlight(n.state.field(Fr))}update(n){let e=n.state.field(Fr);(e!=n.startState.field(Fr)||n.docChanged||n.selectionSet||n.viewportChanged)&&(this.decorations=this.highlight(e))}highlight({query:n,panel:e}){if(!e||!n.spec.valid)return _e.none;let{view:t}=this,i=new or;for(let r=0,s=t.visibleRanges,o=s.length;rs[r+1].from-500;)c=s[++r].to;n.highlight(t.state,a,c,(h,f)=>{let p=t.state.selection.ranges.some(m=>m.from==h&&m.to==f);i.add(h,f,p?ez:JD)})}return i.finish()}},{decorations:n=>n.decorations});function Sc(n){return e=>{let t=e.state.field(Fr,!1);return t&&t.query.spec.valid?n(e,t):YQ(e)}}const bf=Sc((n,{query:e})=>{let{to:t}=n.state.selection.main,i=e.nextMatch(n.state,t,t);if(!i)return!1;let r=V.single(i.from,i.to),s=n.state.facet(al);return n.dispatch({selection:r,effects:[z0(n,i),s.scrollToMatch(r.main,n)],userEvent:"select.search"}),FQ(n),!0}),Sf=Sc((n,{query:e})=>{let{state:t}=n,{from:i}=t.selection.main,r=e.prevMatch(t,i,i);if(!r)return!1;let s=V.single(r.from,r.to),o=n.state.facet(al);return n.dispatch({selection:s,effects:[z0(n,r),o.scrollToMatch(s.main,n)],userEvent:"select.search"}),FQ(n),!0}),nz=Sc((n,{query:e})=>{let t=e.matchAll(n.state,1e3);return!t||!t.length?!1:(n.dispatch({selection:V.create(t.map(i=>V.range(i.from,i.to))),userEvent:"select.search.matches"}),!0)}),iz=({state:n,dispatch:e})=>{let t=n.selection;if(t.ranges.length>1||t.main.empty)return!1;let{from:i,to:r}=t.main,s=[],o=0;for(let a=new el(n.doc,n.sliceDoc(i,r));!a.next().done;){if(s.length>1e3)return!1;a.value.from==i&&(o=s.length),s.push(V.range(a.value.from,a.value.to))}return e(n.update({selection:V.create(s,o),userEvent:"select.search.matches"})),!0},sS=Sc((n,{query:e})=>{let{state:t}=n,{from:i,to:r}=t.selection.main;if(t.readOnly)return!1;let s=e.nextMatch(t,i,i);if(!s)return!1;let o=s,a=[],c,h,f=[];o.from==i&&o.to==r&&(h=t.toText(e.getReplacement(o)),a.push({from:o.from,to:o.to,insert:h}),o=e.nextMatch(t,o.from,o.to),f.push(fe.announce.of(t.phrase("replaced match on line $",t.doc.lineAt(i).number)+".")));let p=n.state.changes(a);return o&&(c=V.single(o.from,o.to).map(p),f.push(z0(n,o)),f.push(t.facet(al).scrollToMatch(c.main,n))),n.dispatch({changes:p,selection:c,effects:f,userEvent:"input.replace"}),!0}),rz=Sc((n,{query:e})=>{if(n.state.readOnly)return!1;let t=e.matchAll(n.state,1e9).map(r=>{let{from:s,to:o}=r;return{from:s,to:o,insert:e.getReplacement(r)}});if(!t.length)return!1;let i=n.state.phrase("replaced $ matches",t.length)+".";return n.dispatch({changes:t,effects:fe.announce.of(i),userEvent:"input.replace.all"}),!0});function D0(n){return n.state.facet(al).createPanel(n)}function PO(n,e){var t,i,r,s,o;let a=n.selection.main,c=a.empty||a.to>a.from+100?"":n.sliceDoc(a.from,a.to);if(e&&!c)return e;let h=n.facet(al);return new XQ({search:((t=e==null?void 0:e.literal)!==null&&t!==void 0?t:h.literal)?c:c.replace(/\n/g,"\\n"),caseSensitive:(i=e==null?void 0:e.caseSensitive)!==null&&i!==void 0?i:h.caseSensitive,literal:(r=e==null?void 0:e.literal)!==null&&r!==void 0?r:h.literal,regexp:(s=e==null?void 0:e.regexp)!==null&&s!==void 0?s:h.regexp,wholeWord:(o=e==null?void 0:e.wholeWord)!==null&&o!==void 0?o:h.wholeWord})}function VQ(n){let e=Ya(n,D0);return e&&e.dom.querySelector("[main-field]")}function FQ(n){let e=VQ(n);e&&e==n.root.activeElement&&e.select()}const YQ=n=>{let e=n.state.field(Fr,!1);if(e&&e.panel){let t=VQ(n);if(t&&t!=n.root.activeElement){let i=PO(n.state,e.query.spec);i.valid&&n.dispatch({effects:Ka.of(i)}),t.focus(),t.select()}}else n.dispatch({effects:[L0.of(!0),e?Ka.of(PO(n.state,e.query.spec)):$e.appendConfig.of(az)]});return!0},qQ=n=>{let e=n.state.field(Fr,!1);if(!e||!e.panel)return!1;let t=Ya(n,D0);return t&&t.dom.contains(n.root.activeElement)&&n.focus(),n.dispatch({effects:L0.of(!1)}),!0},sz=[{key:"Mod-f",run:YQ,scope:"editor search-panel"},{key:"F3",run:bf,shift:Sf,scope:"editor search-panel",preventDefault:!0},{key:"Mod-g",run:bf,shift:Sf,scope:"editor search-panel",preventDefault:!0},{key:"Escape",run:qQ,scope:"editor search-panel"},{key:"Mod-Shift-l",run:iz},{key:"Mod-Alt-g",run:DD},{key:"Mod-d",run:qD,preventDefault:!0}];class oz{constructor(e){this.view=e;let t=this.query=e.state.field(Fr).query.spec;this.commit=this.commit.bind(this),this.searchField=He("input",{value:t.search,placeholder:Tn(e,"Find"),"aria-label":Tn(e,"Find"),class:"cm-textfield",name:"search",form:"","main-field":"true",onchange:this.commit,onkeyup:this.commit}),this.replaceField=He("input",{value:t.replace,placeholder:Tn(e,"Replace"),"aria-label":Tn(e,"Replace"),class:"cm-textfield",name:"replace",form:"",onchange:this.commit,onkeyup:this.commit}),this.caseField=He("input",{type:"checkbox",name:"case",form:"",checked:t.caseSensitive,onchange:this.commit}),this.reField=He("input",{type:"checkbox",name:"re",form:"",checked:t.regexp,onchange:this.commit}),this.wordField=He("input",{type:"checkbox",name:"word",form:"",checked:t.wholeWord,onchange:this.commit});function i(r,s,o){return He("button",{class:"cm-button",name:r,onclick:s,type:"button"},o)}this.dom=He("div",{onkeydown:r=>this.keydown(r),class:"cm-search"},[this.searchField,i("next",()=>bf(e),[Tn(e,"next")]),i("prev",()=>Sf(e),[Tn(e,"previous")]),i("select",()=>nz(e),[Tn(e,"all")]),He("label",null,[this.caseField,Tn(e,"match case")]),He("label",null,[this.reField,Tn(e,"regexp")]),He("label",null,[this.wordField,Tn(e,"by word")]),...e.state.readOnly?[]:[He("br"),this.replaceField,i("replace",()=>sS(e),[Tn(e,"replace")]),i("replaceAll",()=>rz(e),[Tn(e,"replace all")])],He("button",{name:"close",onclick:()=>qQ(e),"aria-label":Tn(e,"close"),type:"button"},["×"])])}commit(){let e=new XQ({search:this.searchField.value,caseSensitive:this.caseField.checked,regexp:this.reField.checked,wholeWord:this.wordField.checked,replace:this.replaceField.value});e.eq(this.query)||(this.query=e,this.view.dispatch({effects:Ka.of(e)}))}keydown(e){gE(this.view,e,"search-panel")?e.preventDefault():e.keyCode==13&&e.target==this.searchField?(e.preventDefault(),(e.shiftKey?Sf:bf)(this.view)):e.keyCode==13&&e.target==this.replaceField&&(e.preventDefault(),sS(this.view))}update(e){for(let t of e.transactions)for(let i of t.effects)i.is(Ka)&&!i.value.eq(this.query)&&this.setQuery(i.value)}setQuery(e){this.query=e,this.searchField.value=e.search,this.replaceField.value=e.replace,this.caseField.checked=e.caseSensitive,this.reField.checked=e.regexp,this.wordField.checked=e.wholeWord}mount(){this.searchField.select()}get pos(){return 80}get top(){return this.view.state.facet(al).top}}function Tn(n,e){return n.state.phrase(e)}const rh=30,sh=/[\s\.,:;?!]/;function z0(n,{from:e,to:t}){let i=n.state.doc.lineAt(e),r=n.state.doc.lineAt(t).to,s=Math.max(i.from,e-rh),o=Math.min(r,t+rh),a=n.state.sliceDoc(s,o);if(s!=i.from){for(let c=0;ca.length-rh;c--)if(!sh.test(a[c-1])&&sh.test(a[c])){a=a.slice(0,c);break}}return fe.announce.of(`${n.state.phrase("current match")}. ${a} ${n.state.phrase("on line")} ${i.number}.`)}const lz=fe.baseTheme({".cm-panel.cm-search":{padding:"2px 6px 4px",position:"relative","& [name=close]":{position:"absolute",top:"0",right:"4px",backgroundColor:"inherit",border:"none",font:"inherit",padding:0,margin:0},"& input, & button, & label":{margin:".2em .6em .2em 0"},"& input[type=checkbox]":{marginRight:".2em"},"& label":{fontSize:"80%",whiteSpace:"pre"}},"&light .cm-searchMatch":{backgroundColor:"#ffff0054"},"&dark .cm-searchMatch":{backgroundColor:"#00ffff8a"},"&light .cm-searchMatch-selected":{backgroundColor:"#ff6a0054"},"&dark .cm-searchMatch-selected":{backgroundColor:"#ff00ff8a"}}),az=[Fr,ts.low(tz),lz];class UQ{constructor(e,t,i,r){this.state=e,this.pos=t,this.explicit=i,this.view=r,this.abortListeners=[],this.abortOnDocChange=!1}tokenBefore(e){let t=Pt(this.state).resolveInner(this.pos,-1);for(;t&&e.indexOf(t.name)<0;)t=t.parent;return t?{from:t.from,to:this.pos,text:this.state.sliceDoc(t.from,this.pos),type:t.type}:null}matchBefore(e){let t=this.state.doc.lineAt(this.pos),i=Math.max(t.from,this.pos-250),r=t.text.slice(i-t.from,this.pos-t.from),s=r.search(GQ(e,!1));return s<0?null:{from:i+s,to:this.pos,text:r.slice(s)}}get aborted(){return this.abortListeners==null}addEventListener(e,t,i){e=="abort"&&this.abortListeners&&(this.abortListeners.push(t),i&&i.onDocChange&&(this.abortOnDocChange=!0))}}function oS(n){let e=Object.keys(n).join(""),t=/\w/.test(e);return t&&(e=e.replace(/\w/g,"")),`[${t?"\\w":""}${e.replace(/[^\w\s]/g,"\\$&")}]`}function cz(n){let e=Object.create(null),t=Object.create(null);for(let{label:r}of n){e[r[0]]=!0;for(let s=1;stypeof r=="string"?{label:r}:r),[t,i]=e.every(r=>/^\w+$/.test(r.label))?[/\w*$/,/\w+$/]:cz(e);return r=>{let s=r.matchBefore(i);return s||r.explicit?{from:s?s.from:r.pos,options:e,validFor:t}:null}}function HQ(n,e){return t=>{for(let i=Pt(t.state).resolveInner(t.pos,-1);i;i=i.parent){if(n.indexOf(i.name)>-1)return null;if(i.type.isTop)break}return e(t)}}class lS{constructor(e,t,i,r){this.completion=e,this.source=t,this.match=i,this.score=r}}function js(n){return n.selection.main.from}function GQ(n,e){var t;let{source:i}=n,r=e&&i[0]!="^",s=i[i.length-1]!="$";return!r&&!s?n:new RegExp(`${r?"^":""}(?:${i})${s?"$":""}`,(t=n.flags)!==null&&t!==void 0?t:n.ignoreCase?"i":"")}const Z0=cr.define();function uz(n,e,t,i){let{main:r}=n.selection,s=t-r.from,o=i-r.from;return{...n.changeByRange(a=>{if(a!=r&&t!=i&&n.sliceDoc(a.from+s,a.from+o)!=n.sliceDoc(t,i))return{range:a};let c=n.toText(e);return{changes:{from:a.from+s,to:i==r.from?a.to:a.from+o,insert:c},range:V.cursor(a.from+s+c.length)}}),scrollIntoView:!0,userEvent:"input.complete"}}const aS=new WeakMap;function hz(n){if(!Array.isArray(n))return n;let e=aS.get(n);return e||aS.set(n,e=j0(n)),e}const wf=$e.define(),Ja=$e.define();class fz{constructor(e){this.pattern=e,this.chars=[],this.folded=[],this.any=[],this.precise=[],this.byWord=[],this.score=0,this.matched=[];for(let t=0;t=48&&C<=57||C>=97&&C<=122?2:C>=65&&C<=90?1:0:(M=o0(C))!=M.toLowerCase()?1:M!=M.toUpperCase()?2:0;(!T||R==1&&S||_==0&&R!=0)&&(t[p]==C||i[p]==C&&(m=!0)?o[p++]=T:o.length&&(w=!1)),_=R,T+=$i(C)}return p==c&&o[0]==0&&w?this.result(-100+(m?-200:0),o,e):O==c&&v==0?this.ret(-200-e.length+(b==e.length?0:-100),[0,b]):a>-1?this.ret(-700-e.length,[a,a+this.pattern.length]):O==c?this.ret(-900-e.length,[v,b]):p==c?this.result(-100+(m?-200:0)+-700+(w?0:-1100),o,e):t.length==2?null:this.result((r[0]?-700:0)+-200+-1100,r,e)}result(e,t,i){let r=[],s=0;for(let o of t){let a=o+(this.astral?$i(yn(i,o)):1);s&&r[s-1]==o?r[s-1]=a:(r[s++]=o,r[s++]=a)}return this.ret(e-i.length,r)}}class dz{constructor(e){this.pattern=e,this.matched=[],this.score=0,this.folded=e.toLowerCase()}match(e){if(e.length!1,activateOnTypingDelay:100,selectOnOpen:!0,override:null,closeOnBlur:!0,maxRenderedOptions:100,defaultKeymap:!0,tooltipClass:()=>"",optionClass:()=>"",aboveCursor:!1,icons:!0,addToOptions:[],positionInfo:pz,filterStrict:!1,compareCompletions:(e,t)=>e.label.localeCompare(t.label),interactionDelay:75,updateSyncTime:100},{defaultKeymap:(e,t)=>e&&t,closeOnBlur:(e,t)=>e&&t,icons:(e,t)=>e&&t,tooltipClass:(e,t)=>i=>cS(e(i),t(i)),optionClass:(e,t)=>i=>cS(e(i),t(i)),addToOptions:(e,t)=>e.concat(t),filterStrict:(e,t)=>e||t})}});function cS(n,e){return n?e?n+" "+e:n:e}function pz(n,e,t,i,r,s){let o=n.textDirection==st.RTL,a=o,c=!1,h="top",f,p,m=e.left-r.left,O=r.right-e.right,v=i.right-i.left,b=i.bottom-i.top;if(a&&m=b||T>e.top?f=t.bottom-e.top:(h="bottom",f=e.bottom-t.top)}let S=(e.bottom-e.top)/s.offsetHeight,w=(e.right-e.left)/s.offsetWidth;return{style:`${h}: ${f/S}px; max-width: ${p/w}px`,class:"cm-completionInfo-"+(c?o?"left-narrow":"right-narrow":a?"left":"right")}}function gz(n){let e=n.addToOptions.slice();return n.icons&&e.push({render(t){let i=document.createElement("div");return i.classList.add("cm-completionIcon"),t.type&&i.classList.add(...t.type.split(/\s+/g).map(r=>"cm-completionIcon-"+r)),i.setAttribute("aria-hidden","true"),i},position:20}),e.push({render(t,i,r,s){let o=document.createElement("span");o.className="cm-completionLabel";let a=t.displayLabel||t.label,c=0;for(let h=0;hc&&o.appendChild(document.createTextNode(a.slice(c,f)));let m=o.appendChild(document.createElement("span"));m.appendChild(document.createTextNode(a.slice(f,p))),m.className="cm-completionMatchedText",c=p}return ct.position-i.position).map(t=>t.render)}function Ng(n,e,t){if(n<=t)return{from:0,to:n};if(e<0&&(e=0),e<=n>>1){let r=Math.floor(e/t);return{from:r*t,to:(r+1)*t}}let i=Math.floor((n-e)/t);return{from:n-(i+1)*t,to:n-i*t}}class mz{constructor(e,t,i){this.view=e,this.stateField=t,this.applyCompletion=i,this.info=null,this.infoDestroy=null,this.placeInfoReq={read:()=>this.measureInfo(),write:c=>this.placeInfo(c),key:this},this.space=null,this.currentClass="";let r=e.state.field(t),{options:s,selected:o}=r.open,a=e.state.facet(Dt);this.optionContent=gz(a),this.optionClass=a.optionClass,this.tooltipClass=a.tooltipClass,this.range=Ng(s.length,o,a.maxRenderedOptions),this.dom=document.createElement("div"),this.dom.className="cm-tooltip-autocomplete",this.updateTooltipClass(e.state),this.dom.addEventListener("mousedown",c=>{let{options:h}=e.state.field(t).open;for(let f=c.target,p;f&&f!=this.dom;f=f.parentNode)if(f.nodeName=="LI"&&(p=/-(\d+)$/.exec(f.id))&&+p[1]{let h=e.state.field(this.stateField,!1);h&&h.tooltip&&e.state.facet(Dt).closeOnBlur&&c.relatedTarget!=e.contentDOM&&e.dispatch({effects:Ja.of(null)})}),this.showOptions(s,r.id)}mount(){this.updateSel()}showOptions(e,t){this.list&&this.list.remove(),this.list=this.dom.appendChild(this.createListBox(e,t,this.range)),this.list.addEventListener("scroll",()=>{this.info&&this.view.requestMeasure(this.placeInfoReq)})}update(e){var t;let i=e.state.field(this.stateField),r=e.startState.field(this.stateField);if(this.updateTooltipClass(e.state),i!=r){let{options:s,selected:o,disabled:a}=i.open;(!r.open||r.open.options!=s)&&(this.range=Ng(s.length,o,e.state.facet(Dt).maxRenderedOptions),this.showOptions(s,i.id)),this.updateSel(),a!=((t=r.open)===null||t===void 0?void 0:t.disabled)&&this.dom.classList.toggle("cm-tooltip-autocomplete-disabled",!!a)}}updateTooltipClass(e){let t=this.tooltipClass(e);if(t!=this.currentClass){for(let i of this.currentClass.split(" "))i&&this.dom.classList.remove(i);for(let i of t.split(" "))i&&this.dom.classList.add(i);this.currentClass=t}}positioned(e){this.space=e,this.info&&this.view.requestMeasure(this.placeInfoReq)}updateSel(){let e=this.view.state.field(this.stateField),t=e.open;if((t.selected>-1&&t.selected=this.range.to)&&(this.range=Ng(t.options.length,t.selected,this.view.state.facet(Dt).maxRenderedOptions),this.showOptions(t.options,e.id)),this.updateSelectedOption(t.selected)){this.destroyInfo();let{completion:i}=t.options[t.selected],{info:r}=i;if(!r)return;let s=typeof r=="string"?document.createTextNode(r):r(i);if(!s)return;"then"in s?s.then(o=>{o&&this.view.state.field(this.stateField,!1)==e&&this.addInfoPane(o,i)}).catch(o=>bn(this.view.state,o,"completion info")):this.addInfoPane(s,i)}}addInfoPane(e,t){this.destroyInfo();let i=this.info=document.createElement("div");if(i.className="cm-tooltip cm-completionInfo",e.nodeType!=null)i.appendChild(e),this.infoDestroy=null;else{let{dom:r,destroy:s}=e;i.appendChild(r),this.infoDestroy=s||null}this.dom.appendChild(i),this.view.requestMeasure(this.placeInfoReq)}updateSelectedOption(e){let t=null;for(let i=this.list.firstChild,r=this.range.from;i;i=i.nextSibling,r++)i.nodeName!="LI"||!i.id?r--:r==e?i.hasAttribute("aria-selected")||(i.setAttribute("aria-selected","true"),t=i):i.hasAttribute("aria-selected")&&i.removeAttribute("aria-selected");return t&&yz(this.list,t),t}measureInfo(){let e=this.dom.querySelector("[aria-selected]");if(!e||!this.info)return null;let t=this.dom.getBoundingClientRect(),i=this.info.getBoundingClientRect(),r=e.getBoundingClientRect(),s=this.space;if(!s){let o=this.dom.ownerDocument.documentElement;s={left:0,top:0,right:o.clientWidth,bottom:o.clientHeight}}return r.top>Math.min(s.bottom,t.bottom)-10||r.bottom{o.target==r&&o.preventDefault()});let s=null;for(let o=i.from;oi.from||i.from==0))if(s=m,typeof h!="string"&&h.header)r.appendChild(h.header(h));else{let O=r.appendChild(document.createElement("completion-section"));O.textContent=m}}const f=r.appendChild(document.createElement("li"));f.id=t+"-"+o,f.setAttribute("role","option");let p=this.optionClass(a);p&&(f.className=p);for(let m of this.optionContent){let O=m(a,this.view.state,this.view,c);O&&f.appendChild(O)}}return i.from&&r.classList.add("cm-completionListIncompleteTop"),i.tonew mz(t,n,e)}function yz(n,e){let t=n.getBoundingClientRect(),i=e.getBoundingClientRect(),r=t.height/n.offsetHeight;i.topt.bottom&&(n.scrollTop+=(i.bottom-t.bottom)/r)}function uS(n){return(n.boost||0)*100+(n.apply?10:0)+(n.info?5:0)+(n.type?1:0)}function xz(n,e){let t=[],i=null,r=null,s=f=>{t.push(f);let{section:p}=f.completion;if(p){i||(i=[]);let m=typeof p=="string"?p:p.name;i.some(O=>O.name==m)||i.push(typeof p=="string"?{name:m}:p)}},o=e.facet(Dt);for(let f of n)if(f.hasResult()){let p=f.result.getMatch;if(f.result.filter===!1)for(let m of f.result.options)s(new lS(m,f.source,p?p(m):[],1e9-t.length));else{let m=e.sliceDoc(f.from,f.to),O,v=o.filterStrict?new dz(m):new fz(m);for(let b of f.result.options)if(O=v.match(b.label)){let S=b.displayLabel?p?p(b,O.matched):[]:O.matched,w=O.score+(b.boost||0);if(s(new lS(b,f.source,S,w)),typeof b.section=="object"&&b.section.rank==="dynamic"){let{name:T}=b.section;r||(r=Object.create(null)),r[T]=Math.max(w,r[T]||-1e9)}}}}if(i){let f=Object.create(null),p=0,m=(O,v)=>(O.rank==="dynamic"&&v.rank==="dynamic"?r[v.name]-r[O.name]:0)||(typeof O.rank=="number"?O.rank:1e9)-(typeof v.rank=="number"?v.rank:1e9)||(O.namem.score-p.score||h(p.completion,m.completion))){let p=f.completion;!c||c.label!=p.label||c.detail!=p.detail||c.type!=null&&p.type!=null&&c.type!=p.type||c.apply!=p.apply||c.boost!=p.boost?a.push(f):uS(f.completion)>uS(c)&&(a[a.length-1]=f),c=f.completion}return a}class Lo{constructor(e,t,i,r,s,o){this.options=e,this.attrs=t,this.tooltip=i,this.timestamp=r,this.selected=s,this.disabled=o}setSelected(e,t){return e==this.selected||e>=this.options.length?this:new Lo(this.options,hS(t,e),this.tooltip,this.timestamp,e,this.disabled)}static build(e,t,i,r,s,o){if(r&&!o&&e.some(h=>h.isPending))return r.setDisabled();let a=xz(e,t);if(!a.length)return r&&e.some(h=>h.isPending)?r.setDisabled():null;let c=t.facet(Dt).selectOnOpen?0:-1;if(r&&r.selected!=c&&r.selected!=-1){let h=r.options[r.selected].completion;for(let f=0;ff.hasResult()?Math.min(h,f.from):h,1e8),create:Pz,above:s.aboveCursor},r?r.timestamp:Date.now(),c,!1)}map(e){return new Lo(this.options,this.attrs,{...this.tooltip,pos:e.mapPos(this.tooltip.pos)},this.timestamp,this.selected,this.disabled)}setDisabled(){return new Lo(this.options,this.attrs,this.tooltip,this.timestamp,this.selected,!0)}}class kf{constructor(e,t,i){this.active=e,this.id=t,this.open=i}static start(){return new kf(wz,"cm-ac-"+Math.floor(Math.random()*2e6).toString(36),null)}update(e){let{state:t}=e,i=t.facet(Dt),s=(i.override||t.languageDataAt("autocomplete",js(t)).map(hz)).map(c=>(this.active.find(f=>f.source==c)||new Wn(c,this.active.some(f=>f.state!=0)?1:0)).update(e,i));s.length==this.active.length&&s.every((c,h)=>c==this.active[h])&&(s=this.active);let o=this.open,a=e.effects.some(c=>c.is(I0));o&&e.docChanged&&(o=o.map(e.changes)),e.selection||s.some(c=>c.hasResult()&&e.changes.touchesRange(c.from,c.to))||!vz(s,this.active)||a?o=Lo.build(s,t,this.id,o,i,a):o&&o.disabled&&!s.some(c=>c.isPending)&&(o=null),!o&&s.every(c=>!c.isPending)&&s.some(c=>c.hasResult())&&(s=s.map(c=>c.hasResult()?new Wn(c.source,0):c));for(let c of e.effects)c.is(JQ)&&(o=o&&o.setSelected(c.value,this.id));return s==this.active&&o==this.open?this:new kf(s,this.id,o)}get tooltip(){return this.open?this.open.tooltip:null}get attrs(){return this.open?this.open.attrs:this.active.length?bz:Sz}}function vz(n,e){if(n==e)return!0;for(let t=0,i=0;;){for(;t-1&&(t["aria-activedescendant"]=n+"-"+e),t}const wz=[];function KQ(n,e){if(n.isUserEvent("input.complete")){let i=n.annotation(Z0);if(i&&e.activateOnCompletion(i))return 12}let t=n.isUserEvent("input.type");return t&&e.activateOnTyping?5:t?1:n.isUserEvent("delete.backward")?2:n.selection?8:n.docChanged?16:0}class Wn{constructor(e,t,i=!1){this.source=e,this.state=t,this.explicit=i}hasResult(){return!1}get isPending(){return this.state==1}update(e,t){let i=KQ(e,t),r=this;(i&8||i&16&&this.touches(e))&&(r=new Wn(r.source,0)),i&4&&r.state==0&&(r=new Wn(this.source,1)),r=r.updateFor(e,i);for(let s of e.effects)if(s.is(wf))r=new Wn(r.source,1,s.value);else if(s.is(Ja))r=new Wn(r.source,0);else if(s.is(I0))for(let o of s.value)o.source==r.source&&(r=o);return r}updateFor(e,t){return this.map(e.changes)}map(e){return this}touches(e){return e.changes.touchesRange(js(e.state))}}class No extends Wn{constructor(e,t,i,r,s,o){super(e,3,t),this.limit=i,this.result=r,this.from=s,this.to=o}hasResult(){return!0}updateFor(e,t){var i;if(!(t&3))return this.map(e.changes);let r=this.result;r.map&&!e.changes.empty&&(r=r.map(r,e.changes));let s=e.changes.mapPos(this.from),o=e.changes.mapPos(this.to,1),a=js(e.state);if(a>o||!r||t&2&&(js(e.startState)==this.from||at.map(e))}}),JQ=$e.define(),xn=jt.define({create(){return kf.start()},update(n,e){return n.update(e)},provide:n=>[v0.from(n,e=>e.tooltip),fe.contentAttributes.from(n,e=>e.attrs)]});function N0(n,e){const t=e.completion.apply||e.completion.label;let i=n.state.field(xn).active.find(r=>r.source==e.source);return i instanceof No?(typeof t=="string"?n.dispatch({...uz(n.state,t,i.from,i.to),annotations:Z0.of(e.completion)}):t(n,e.completion,i.from,i.to),!0):!1}const Pz=Oz(xn,N0);function oh(n,e="option"){return t=>{let i=t.state.field(xn,!1);if(!i||!i.open||i.open.disabled||Date.now()-i.open.timestamp-1?i.open.selected+r*(n?1:-1):n?0:o-1;return a<0?a=e=="page"?0:o-1:a>=o&&(a=e=="page"?o-1:0),t.dispatch({effects:JQ.of(a)}),!0}}const _z=n=>{let e=n.state.field(xn,!1);return n.state.readOnly||!e||!e.open||e.open.selected<0||e.open.disabled||Date.now()-e.open.timestampn.state.field(xn,!1)?(n.dispatch({effects:wf.of(!0)}),!0):!1,Qz=n=>{let e=n.state.field(xn,!1);return!e||!e.active.some(t=>t.state!=0)?!1:(n.dispatch({effects:Ja.of(null)}),!0)};class Cz{constructor(e,t){this.active=e,this.context=t,this.time=Date.now(),this.updates=[],this.done=void 0}}const Tz=50,$z=1e3,Mz=kt.fromClass(class{constructor(n){this.view=n,this.debounceUpdate=-1,this.running=[],this.debounceAccept=-1,this.pendingStart=!1,this.composing=0;for(let e of n.state.field(xn).active)e.isPending&&this.startQuery(e)}update(n){let e=n.state.field(xn),t=n.state.facet(Dt);if(!n.selectionSet&&!n.docChanged&&n.startState.field(xn)==e)return;let i=n.transactions.some(s=>{let o=KQ(s,t);return o&8||(s.selection||s.docChanged)&&!(o&3)});for(let s=0;sTz&&Date.now()-o.time>$z){for(let a of o.context.abortListeners)try{a()}catch(c){bn(this.view.state,c)}o.context.abortListeners=null,this.running.splice(s--,1)}else o.updates.push(...n.transactions)}this.debounceUpdate>-1&&clearTimeout(this.debounceUpdate),n.transactions.some(s=>s.effects.some(o=>o.is(wf)))&&(this.pendingStart=!0);let r=this.pendingStart?50:t.activateOnTypingDelay;if(this.debounceUpdate=e.active.some(s=>s.isPending&&!this.running.some(o=>o.active.source==s.source))?setTimeout(()=>this.startUpdate(),r):-1,this.composing!=0)for(let s of n.transactions)s.isUserEvent("input.type")?this.composing=2:this.composing==2&&s.selection&&(this.composing=3)}startUpdate(){this.debounceUpdate=-1,this.pendingStart=!1;let{state:n}=this.view,e=n.field(xn);for(let t of e.active)t.isPending&&!this.running.some(i=>i.active.source==t.source)&&this.startQuery(t);this.running.length&&e.open&&e.open.disabled&&(this.debounceAccept=setTimeout(()=>this.accept(),this.view.state.facet(Dt).updateSyncTime))}startQuery(n){let{state:e}=this.view,t=js(e),i=new UQ(e,t,n.explicit,this.view),r=new Cz(n,i);this.running.push(r),Promise.resolve(n.source(i)).then(s=>{r.context.aborted||(r.done=s||null,this.scheduleAccept())},s=>{this.view.dispatch({effects:Ja.of(null)}),bn(this.view.state,s)})}scheduleAccept(){this.running.every(n=>n.done!==void 0)?this.accept():this.debounceAccept<0&&(this.debounceAccept=setTimeout(()=>this.accept(),this.view.state.facet(Dt).updateSyncTime))}accept(){var n;this.debounceAccept>-1&&clearTimeout(this.debounceAccept),this.debounceAccept=-1;let e=[],t=this.view.state.facet(Dt),i=this.view.state.field(xn);for(let r=0;ra.source==s.active.source);if(o&&o.isPending)if(s.done==null){let a=new Wn(s.active.source,0);for(let c of s.updates)a=a.update(c,t);a.isPending||e.push(a)}else this.startQuery(o)}(e.length||i.open&&i.open.disabled)&&this.view.dispatch({effects:I0.of(e)})}},{eventHandlers:{blur(n){let e=this.view.state.field(xn,!1);if(e&&e.tooltip&&this.view.state.facet(Dt).closeOnBlur){let t=e.open&&A_(this.view,e.open.tooltip);(!t||!t.dom.contains(n.relatedTarget))&&setTimeout(()=>this.view.dispatch({effects:Ja.of(null)}),10)}},compositionstart(){this.composing=1},compositionend(){this.composing==3&&setTimeout(()=>this.view.dispatch({effects:wf.of(!1)}),20),this.composing=0}}}),Rz=typeof navigator=="object"&&/Win/.test(navigator.platform),Az=ts.highest(fe.domEventHandlers({keydown(n,e){let t=e.state.field(xn,!1);if(!t||!t.open||t.open.disabled||t.open.selected<0||n.key.length>1||n.ctrlKey&&!(Rz&&n.altKey)||n.metaKey)return!1;let i=t.open.options[t.open.selected],r=t.active.find(o=>o.source==i.source),s=i.completion.commitCharacters||r.result.commitCharacters;return s&&s.indexOf(n.key)>-1&&N0(e,i),!1}})),eC=fe.baseTheme({".cm-tooltip.cm-tooltip-autocomplete":{"& > ul":{fontFamily:"monospace",whiteSpace:"nowrap",overflow:"hidden auto",maxWidth_fallback:"700px",maxWidth:"min(700px, 95vw)",minWidth:"250px",maxHeight:"10em",height:"100%",listStyle:"none",margin:0,padding:0,"& > li, & > completion-section":{padding:"1px 3px",lineHeight:1.2},"& > li":{overflowX:"hidden",textOverflow:"ellipsis",cursor:"pointer"},"& > completion-section":{display:"list-item",borderBottom:"1px solid silver",paddingLeft:"0.5em",opacity:.7}}},"&light .cm-tooltip-autocomplete ul li[aria-selected]":{background:"#17c",color:"white"},"&light .cm-tooltip-autocomplete-disabled ul li[aria-selected]":{background:"#777"},"&dark .cm-tooltip-autocomplete ul li[aria-selected]":{background:"#347",color:"white"},"&dark .cm-tooltip-autocomplete-disabled ul li[aria-selected]":{background:"#444"},".cm-completionListIncompleteTop:before, .cm-completionListIncompleteBottom:after":{content:'"···"',opacity:.5,display:"block",textAlign:"center"},".cm-tooltip.cm-completionInfo":{position:"absolute",padding:"3px 9px",width:"max-content",maxWidth:"400px",boxSizing:"border-box",whiteSpace:"pre-line"},".cm-completionInfo.cm-completionInfo-left":{right:"100%"},".cm-completionInfo.cm-completionInfo-right":{left:"100%"},".cm-completionInfo.cm-completionInfo-left-narrow":{right:"30px"},".cm-completionInfo.cm-completionInfo-right-narrow":{left:"30px"},"&light .cm-snippetField":{backgroundColor:"#00000022"},"&dark .cm-snippetField":{backgroundColor:"#ffffff22"},".cm-snippetFieldPosition":{verticalAlign:"text-top",width:0,height:"1.15em",display:"inline-block",margin:"0 -0.7px -.7em",borderLeft:"1.4px dotted #888"},".cm-completionMatchedText":{textDecoration:"underline"},".cm-completionDetail":{marginLeft:"0.5em",fontStyle:"italic"},".cm-completionIcon":{fontSize:"90%",width:".8em",display:"inline-block",textAlign:"center",paddingRight:".6em",opacity:"0.6",boxSizing:"content-box"},".cm-completionIcon-function, .cm-completionIcon-method":{"&:after":{content:"'ƒ'"}},".cm-completionIcon-class":{"&:after":{content:"'○'"}},".cm-completionIcon-interface":{"&:after":{content:"'◌'"}},".cm-completionIcon-variable":{"&:after":{content:"'𝑥'"}},".cm-completionIcon-constant":{"&:after":{content:"'𝐶'"}},".cm-completionIcon-type":{"&:after":{content:"'𝑡'"}},".cm-completionIcon-enum":{"&:after":{content:"'∪'"}},".cm-completionIcon-property":{"&:after":{content:"'□'"}},".cm-completionIcon-keyword":{"&:after":{content:"'🔑︎'"}},".cm-completionIcon-namespace":{"&:after":{content:"'▢'"}},".cm-completionIcon-text":{"&:after":{content:"'abc'",fontSize:"50%",verticalAlign:"middle"}}});class Ez{constructor(e,t,i,r){this.field=e,this.line=t,this.from=i,this.to=r}}class B0{constructor(e,t,i){this.field=e,this.from=t,this.to=i}map(e){let t=e.mapPos(this.from,-1,Wt.TrackDel),i=e.mapPos(this.to,1,Wt.TrackDel);return t==null||i==null?null:new B0(this.field,t,i)}}class X0{constructor(e,t){this.lines=e,this.fieldPositions=t}instantiate(e,t){let i=[],r=[t],s=e.doc.lineAt(t),o=/^\s*/.exec(s.text)[0];for(let c of this.lines){if(i.length){let h=o,f=/^\t*/.exec(c)[0].length;for(let p=0;pnew B0(c.field,r[c.line]+c.from,r[c.line]+c.to));return{text:i,ranges:a}}static parse(e){let t=[],i=[],r=[],s;for(let o of e.split(/\r\n?|\n/)){for(;s=/[#$]\{(?:(\d+)(?::([^{}]*))?|((?:\\[{}]|[^{}])*))\}/.exec(o);){let a=s[1]?+s[1]:null,c=s[2]||s[3]||"",h=-1,f=c.replace(/\\[{}]/g,p=>p[1]);for(let p=0;p=h&&m.field++}for(let p of r)if(p.line==i.length&&p.from>s.index){let m=s[2]?3+(s[1]||"").length:2;p.from-=m,p.to-=m}r.push(new Ez(h,i.length,s.index,s.index+f.length)),o=o.slice(0,s.index)+c+o.slice(s.index+s[0].length)}o=o.replace(/\\([{}])/g,(a,c,h)=>{for(let f of r)f.line==i.length&&f.from>h&&(f.from--,f.to--);return c}),i.push(o)}return new X0(i,r)}}let Lz=_e.widget({widget:new class extends ur{toDOM(){let n=document.createElement("span");return n.className="cm-snippetFieldPosition",n}ignoreEvent(){return!1}}}),Dz=_e.mark({class:"cm-snippetField"});class cl{constructor(e,t){this.ranges=e,this.active=t,this.deco=_e.set(e.map(i=>(i.from==i.to?Lz:Dz).range(i.from,i.to)),!0)}map(e){let t=[];for(let i of this.ranges){let r=i.map(e);if(!r)return null;t.push(r)}return new cl(t,this.active)}selectionInsideField(e){return e.ranges.every(t=>this.ranges.some(i=>i.field==this.active&&i.from<=t.from&&i.to>=t.to))}}const wc=$e.define({map(n,e){return n&&n.map(e)}}),zz=$e.define(),ec=jt.define({create(){return null},update(n,e){for(let t of e.effects){if(t.is(wc))return t.value;if(t.is(zz)&&n)return new cl(n.ranges,t.value)}return n&&e.docChanged&&(n=n.map(e.changes)),n&&e.selection&&!n.selectionInsideField(e.selection)&&(n=null),n},provide:n=>fe.decorations.from(n,e=>e?e.deco:_e.none)});function W0(n,e){return V.create(n.filter(t=>t.field==e).map(t=>V.range(t.from,t.to)))}function jz(n){let e=X0.parse(n);return(t,i,r,s)=>{let{text:o,ranges:a}=e.instantiate(t.state,r),{main:c}=t.state.selection,h={changes:{from:r,to:s==c.from?c.to:s,insert:ze.of(o)},scrollIntoView:!0,annotations:i?[Z0.of(i),St.userEvent.of("input.complete")]:void 0};if(a.length&&(h.selection=W0(a,0)),a.some(f=>f.field>0)){let f=new cl(a,0),p=h.effects=[wc.of(f)];t.state.field(ec,!1)===void 0&&p.push($e.appendConfig.of([ec,Xz,Wz,eC]))}t.dispatch(t.state.update(h))}}function tC(n){return({state:e,dispatch:t})=>{let i=e.field(ec,!1);if(!i||n<0&&i.active==0)return!1;let r=i.active+n,s=n>0&&!i.ranges.some(o=>o.field==r+n);return t(e.update({selection:W0(i.ranges,r),effects:wc.of(s?null:new cl(i.ranges,r)),scrollIntoView:!0})),!0}}const Zz=({state:n,dispatch:e})=>n.field(ec,!1)?(e(n.update({effects:wc.of(null)})),!0):!1,Iz=tC(1),Nz=tC(-1),Bz=[{key:"Tab",run:Iz,shift:Nz},{key:"Escape",run:Zz}],fS=pe.define({combine(n){return n.length?n[0]:Bz}}),Xz=ts.highest(Oc.compute([fS],n=>n.facet(fS)));function On(n,e){return{...e,apply:jz(n)}}const Wz=fe.domEventHandlers({mousedown(n,e){let t=e.state.field(ec,!1),i;if(!t||(i=e.posAtCoords({x:n.clientX,y:n.clientY}))==null)return!1;let r=t.ranges.find(s=>s.from<=i&&s.to>=i);return!r||r.field==t.active?!1:(e.dispatch({selection:W0(t.ranges,r.field),effects:wc.of(t.ranges.some(s=>s.field>r.field)?new cl(t.ranges,r.field):null),scrollIntoView:!0}),!0)}}),tc={brackets:["(","[","{","'",'"'],before:")]}:;>",stringPrefixes:[]},Es=$e.define({map(n,e){let t=e.mapPos(n,-1,Wt.TrackAfter);return t==null?void 0:t}}),V0=new class extends Xs{};V0.startSide=1;V0.endSide=-1;const nC=jt.define({create(){return Ie.empty},update(n,e){if(n=n.map(e.changes),e.selection){let t=e.state.doc.lineAt(e.selection.main.head);n=n.update({filter:i=>i>=t.from&&i<=t.to})}for(let t of e.effects)t.is(Es)&&(n=n.update({add:[V0.range(t.value,t.value+1)]}));return n}});function Vz(){return[Yz,nC]}const Xg="()[]{}<>«»»«[]{}";function iC(n){for(let e=0;e{if((Fz?n.composing:n.compositionStarted)||n.state.readOnly)return!1;let r=n.state.selection.main;if(i.length>2||i.length==2&&$i(yn(i,0))==1||e!=r.from||t!=r.to)return!1;let s=Hz(n.state,i);return s?(n.dispatch(s),!0):!1}),qz=({state:n,dispatch:e})=>{if(n.readOnly)return!1;let i=rC(n,n.selection.main.head).brackets||tc.brackets,r=null,s=n.changeByRange(o=>{if(o.empty){let a=Gz(n.doc,o.head);for(let c of i)if(c==a&&rd(n.doc,o.head)==iC(yn(c,0)))return{changes:{from:o.head-c.length,to:o.head+c.length},range:V.cursor(o.head-c.length)}}return{range:r=o}});return r||e(n.update(s,{scrollIntoView:!0,userEvent:"delete.backward"})),!r},Uz=[{key:"Backspace",run:qz}];function Hz(n,e){let t=rC(n,n.selection.main.head),i=t.brackets||tc.brackets;for(let r of i){let s=iC(yn(r,0));if(e==r)return s==r?e3(n,r,i.indexOf(r+r+r)>-1,t):Kz(n,r,s,t.before||tc.before);if(e==s&&sC(n,n.selection.main.from))return Jz(n,r,s)}return null}function sC(n,e){let t=!1;return n.field(nC).between(0,n.doc.length,i=>{i==e&&(t=!0)}),t}function rd(n,e){let t=n.sliceString(e,e+2);return t.slice(0,$i(yn(t,0)))}function Gz(n,e){let t=n.sliceString(e-2,e);return $i(yn(t,0))==t.length?t:t.slice(1)}function Kz(n,e,t,i){let r=null,s=n.changeByRange(o=>{if(!o.empty)return{changes:[{insert:e,from:o.from},{insert:t,from:o.to}],effects:Es.of(o.to+e.length),range:V.range(o.anchor+e.length,o.head+e.length)};let a=rd(n.doc,o.head);return!a||/\s/.test(a)||i.indexOf(a)>-1?{changes:{insert:e+t,from:o.head},effects:Es.of(o.head+e.length),range:V.cursor(o.head+e.length)}:{range:r=o}});return r?null:n.update(s,{scrollIntoView:!0,userEvent:"input.type"})}function Jz(n,e,t){let i=null,r=n.changeByRange(s=>s.empty&&rd(n.doc,s.head)==t?{changes:{from:s.head,to:s.head+t.length,insert:t},range:V.cursor(s.head+t.length)}:i={range:s});return i?null:n.update(r,{scrollIntoView:!0,userEvent:"input.type"})}function e3(n,e,t,i){let r=i.stringPrefixes||tc.stringPrefixes,s=null,o=n.changeByRange(a=>{if(!a.empty)return{changes:[{insert:e,from:a.from},{insert:e,from:a.to}],effects:Es.of(a.to+e.length),range:V.range(a.anchor+e.length,a.head+e.length)};let c=a.head,h=rd(n.doc,c),f;if(h==e){if(dS(n,c))return{changes:{insert:e+e,from:c},effects:Es.of(c+e.length),range:V.cursor(c+e.length)};if(sC(n,c)){let m=t&&n.sliceDoc(c,c+e.length*3)==e+e+e?e+e+e:e;return{changes:{from:c,to:c+m.length,insert:m},range:V.cursor(c+m.length)}}}else{if(t&&n.sliceDoc(c-2*e.length,c)==e+e&&(f=pS(n,c-2*e.length,r))>-1&&dS(n,f))return{changes:{insert:e+e+e+e,from:c},effects:Es.of(c+e.length),range:V.cursor(c+e.length)};if(n.charCategorizer(c)(h)!=lt.Word&&pS(n,c,r)>-1&&!t3(n,c,e,r))return{changes:{insert:e+e,from:c},effects:Es.of(c+e.length),range:V.cursor(c+e.length)}}return{range:s=a}});return s?null:n.update(o,{scrollIntoView:!0,userEvent:"input.type"})}function dS(n,e){let t=Pt(n).resolveInner(e+1);return t.parent&&t.from==e}function t3(n,e,t,i){let r=Pt(n).resolveInner(e,-1),s=i.reduce((o,a)=>Math.max(o,a.length),0);for(let o=0;o<5;o++){let a=n.sliceDoc(r.from,Math.min(r.to,r.from+t.length+s)),c=a.indexOf(t);if(!c||c>-1&&i.indexOf(a.slice(0,c))>-1){let f=r.firstChild;for(;f&&f.from==r.from&&f.to-f.from>t.length+c;){if(n.sliceDoc(f.to-t.length,f.to)==t)return!1;f=f.firstChild}return!0}let h=r.to==e&&r.parent;if(!h)break;r=h}return!1}function pS(n,e,t){let i=n.charCategorizer(e);if(i(n.sliceDoc(e-1,e))!=lt.Word)return e;for(let r of t){let s=e-r.length;if(n.sliceDoc(s,e)==r&&i(n.sliceDoc(s-1,s))!=lt.Word)return s}return-1}function oC(n={}){return[Az,xn,Dt.of(n),Mz,n3,eC]}const F0=[{key:"Ctrl-Space",run:Bg},{mac:"Alt-`",run:Bg},{mac:"Alt-i",run:Bg},{key:"Escape",run:Qz},{key:"ArrowDown",run:oh(!0)},{key:"ArrowUp",run:oh(!1)},{key:"PageDown",run:oh(!0,"page")},{key:"PageUp",run:oh(!1,"page")},{key:"Enter",run:_z}],n3=ts.highest(Oc.computeN([Dt],n=>n.facet(Dt).defaultKeymap?[F0]:[]));class gS{constructor(e,t,i){this.from=e,this.to=t,this.diagnostic=i}}class Ms{constructor(e,t,i){this.diagnostics=e,this.panel=t,this.selected=i}static init(e,t,i){let r=i.facet(nc).markerFilter;r&&(e=r(e,i));let s=e.slice().sort((f,p)=>f.from-p.from||f.to-p.to),o=new or,a=[],c=0;for(let f=0;;){let p=f==s.length?null:s[f];if(!p&&!a.length)break;let m,O;for(a.length?(m=c,O=a.reduce((b,S)=>Math.min(b,S.to),p&&p.from>m?p.from:1e8)):(m=p.from,O=p.to,a.push(p),f++);fb.from||b.to==m))a.push(b),f++,O=Math.min(b.to,O);else{O=Math.min(b.from,O);break}}let v=g3(a);if(a.some(b=>b.from==b.to||b.from==b.to-1&&i.doc.lineAt(b.from).to==b.from))o.add(m,m,_e.widget({widget:new h3(v),diagnostics:a.slice()}));else{let b=a.reduce((S,w)=>w.markClass?S+" "+w.markClass:S,"");o.add(m,O,_e.mark({class:"cm-lintRange cm-lintRange-"+v+b,diagnostics:a.slice(),inclusiveEnd:a.some(S=>S.to>O)}))}c=O;for(let b=0;b{if(!(e&&o.diagnostics.indexOf(e)<0))if(!i)i=new gS(r,s,e||o.diagnostics[0]);else{if(o.diagnostics.indexOf(i.diagnostic)<0)return!1;i=new gS(i.from,s,i.diagnostic)}}),i}function i3(n,e){let t=e.pos,i=e.end||t,r=n.state.facet(nc).hideOn(n,t,i);if(r!=null)return r;let s=n.startState.doc.lineAt(e.pos);return!!(n.effects.some(o=>o.is(lC))||n.changes.touchesRange(s.from,Math.max(s.to,i)))}function r3(n,e){return n.field(En,!1)?e:e.concat($e.appendConfig.of(m3))}const lC=$e.define(),Y0=$e.define(),aC=$e.define(),En=jt.define({create(){return new Ms(_e.none,null,null)},update(n,e){if(e.docChanged&&n.diagnostics.size){let t=n.diagnostics.map(e.changes),i=null,r=n.panel;if(n.selected){let s=e.changes.mapPos(n.selected.from,1);i=tl(t,n.selected.diagnostic,s)||tl(t,null,s)}!t.size&&r&&e.state.facet(nc).autoPanel&&(r=null),n=new Ms(t,r,i)}for(let t of e.effects)if(t.is(lC)){let i=e.state.facet(nc).autoPanel?t.value.length?ic.open:null:n.panel;n=Ms.init(t.value,i,e.state)}else t.is(Y0)?n=new Ms(n.diagnostics,t.value?ic.open:null,n.selected):t.is(aC)&&(n=new Ms(n.diagnostics,n.panel,t.value));return n},provide:n=>[qa.from(n,e=>e.panel),fe.decorations.from(n,e=>e.diagnostics)]}),s3=_e.mark({class:"cm-lintRange cm-lintRange-active"});function o3(n,e,t){let{diagnostics:i}=n.state.field(En),r,s=-1,o=-1;i.between(e-(t<0?1:0),e+(t>0?1:0),(c,h,{spec:f})=>{if(e>=c&&e<=h&&(c==h||(e>c||t>0)&&(euC(n,t,!1)))}const a3=n=>{let e=n.state.field(En,!1);(!e||!e.panel)&&n.dispatch({effects:r3(n.state,[Y0.of(!0)])});let t=Ya(n,ic.open);return t&&t.dom.querySelector(".cm-panel-lint ul").focus(),!0},mS=n=>{let e=n.state.field(En,!1);return!e||!e.panel?!1:(n.dispatch({effects:Y0.of(!1)}),!0)},c3=n=>{let e=n.state.field(En,!1);if(!e)return!1;let t=n.state.selection.main,i=e.diagnostics.iter(t.to+1);return!i.value&&(i=e.diagnostics.iter(0),!i.value||i.from==t.from&&i.to==t.to)?!1:(n.dispatch({selection:{anchor:i.from,head:i.to},scrollIntoView:!0}),!0)},u3=[{key:"Mod-Shift-m",run:a3,preventDefault:!0},{key:"F8",run:c3}],nc=pe.define({combine(n){return{sources:n.map(e=>e.source).filter(e=>e!=null),...Zi(n.map(e=>e.config),{delay:750,markerFilter:null,tooltipFilter:null,needsRefresh:null,hideOn:()=>null},{delay:Math.max,markerFilter:OS,tooltipFilter:OS,needsRefresh:(e,t)=>e?t?i=>e(i)||t(i):e:t,hideOn:(e,t)=>e?t?(i,r,s)=>e(i,r,s)||t(i,r,s):e:t,autoPanel:(e,t)=>e||t})}}});function OS(n,e){return n?e?(t,i)=>e(n(t,i),i):n:e}function cC(n){let e=[];if(n)e:for(let{name:t}of n){for(let i=0;is.toLowerCase()==r.toLowerCase())){e.push(r);continue e}}e.push("")}return e}function uC(n,e,t){var i;let r=t?cC(e.actions):[];return He("li",{class:"cm-diagnostic cm-diagnostic-"+e.severity},He("span",{class:"cm-diagnosticText"},e.renderMessage?e.renderMessage(n):e.message),(i=e.actions)===null||i===void 0?void 0:i.map((s,o)=>{let a=!1,c=O=>{if(O.preventDefault(),a)return;a=!0;let v=tl(n.state.field(En).diagnostics,e);v&&s.apply(n,v.from,v.to)},{name:h}=s,f=r[o]?h.indexOf(r[o]):-1,p=f<0?h:[h.slice(0,f),He("u",h.slice(f,f+1)),h.slice(f+1)],m=s.markClass?" "+s.markClass:"";return He("button",{type:"button",class:"cm-diagnosticAction"+m,onclick:c,onmousedown:c,"aria-label":` Action: ${h}${f<0?"":` (access key "${r[o]})"`}.`},p)}),e.source&&He("div",{class:"cm-diagnosticSource"},e.source))}class h3 extends ur{constructor(e){super(),this.sev=e}eq(e){return e.sev==this.sev}toDOM(){return He("span",{class:"cm-lintPoint cm-lintPoint-"+this.sev})}}class yS{constructor(e,t){this.diagnostic=t,this.id="item_"+Math.floor(Math.random()*4294967295).toString(16),this.dom=uC(e,t,!0),this.dom.id=this.id,this.dom.setAttribute("role","option")}}class ic{constructor(e){this.view=e,this.items=[];let t=r=>{if(r.keyCode==27)mS(this.view),this.view.focus();else if(r.keyCode==38||r.keyCode==33)this.moveSelection((this.selectedIndex-1+this.items.length)%this.items.length);else if(r.keyCode==40||r.keyCode==34)this.moveSelection((this.selectedIndex+1)%this.items.length);else if(r.keyCode==36)this.moveSelection(0);else if(r.keyCode==35)this.moveSelection(this.items.length-1);else if(r.keyCode==13)this.view.focus();else if(r.keyCode>=65&&r.keyCode<=90&&this.selectedIndex>=0){let{diagnostic:s}=this.items[this.selectedIndex],o=cC(s.actions);for(let a=0;a{for(let s=0;smS(this.view)},"×")),this.update()}get selectedIndex(){let e=this.view.state.field(En).selected;if(!e)return-1;for(let t=0;t{for(let f of h.diagnostics){if(o.has(f))continue;o.add(f);let p=-1,m;for(let O=i;Oi&&(this.items.splice(i,p-i),r=!0)),t&&m.diagnostic==t.diagnostic?m.dom.hasAttribute("aria-selected")||(m.dom.setAttribute("aria-selected","true"),s=m):m.dom.hasAttribute("aria-selected")&&m.dom.removeAttribute("aria-selected"),i++}});i({sel:s.dom.getBoundingClientRect(),panel:this.list.getBoundingClientRect()}),write:({sel:a,panel:c})=>{let h=c.height/this.list.offsetHeight;a.topc.bottom&&(this.list.scrollTop+=(a.bottom-c.bottom)/h)}})):this.selectedIndex<0&&this.list.removeAttribute("aria-activedescendant"),r&&this.sync()}sync(){let e=this.list.firstChild;function t(){let i=e;e=i.nextSibling,i.remove()}for(let i of this.items)if(i.dom.parentNode==this.list){for(;e!=i.dom;)t();e=i.dom.nextSibling}else this.list.insertBefore(i.dom,e);for(;e;)t()}moveSelection(e){if(this.selectedIndex<0)return;let t=this.view.state.field(En),i=tl(t.diagnostics,this.items[e].diagnostic);i&&this.view.dispatch({selection:{anchor:i.from,head:i.to},scrollIntoView:!0,effects:aC.of(i)})}static open(e){return new ic(e)}}function f3(n,e='viewBox="0 0 40 40"'){return`url('data:image/svg+xml,${encodeURIComponent(n)}')`}function lh(n){return f3(``,'width="6" height="3"')}const d3=fe.baseTheme({".cm-diagnostic":{padding:"3px 6px 3px 8px",marginLeft:"-1px",display:"block",whiteSpace:"pre-wrap"},".cm-diagnostic-error":{borderLeft:"5px solid #d11"},".cm-diagnostic-warning":{borderLeft:"5px solid orange"},".cm-diagnostic-info":{borderLeft:"5px solid #999"},".cm-diagnostic-hint":{borderLeft:"5px solid #66d"},".cm-diagnosticAction":{font:"inherit",border:"none",padding:"2px 4px",backgroundColor:"#444",color:"white",borderRadius:"3px",marginLeft:"8px",cursor:"pointer"},".cm-diagnosticSource":{fontSize:"70%",opacity:.7},".cm-lintRange":{backgroundPosition:"left bottom",backgroundRepeat:"repeat-x",paddingBottom:"0.7px"},".cm-lintRange-error":{backgroundImage:lh("#d11")},".cm-lintRange-warning":{backgroundImage:lh("orange")},".cm-lintRange-info":{backgroundImage:lh("#999")},".cm-lintRange-hint":{backgroundImage:lh("#66d")},".cm-lintRange-active":{backgroundColor:"#ffdd9980"},".cm-tooltip-lint":{padding:0,margin:0},".cm-lintPoint":{position:"relative","&:after":{content:'""',position:"absolute",bottom:0,left:"-2px",borderLeft:"3px solid transparent",borderRight:"3px solid transparent",borderBottom:"4px solid #d11"}},".cm-lintPoint-warning":{"&:after":{borderBottomColor:"orange"}},".cm-lintPoint-info":{"&:after":{borderBottomColor:"#999"}},".cm-lintPoint-hint":{"&:after":{borderBottomColor:"#66d"}},".cm-panel.cm-panel-lint":{position:"relative","& ul":{maxHeight:"100px",overflowY:"auto","& [aria-selected]":{backgroundColor:"#ddd","& u":{textDecoration:"underline"}},"&:focus [aria-selected]":{background_fallback:"#bdf",backgroundColor:"Highlight",color_fallback:"white",color:"HighlightText"},"& u":{textDecoration:"none"},padding:0,margin:0},"& [name=close]":{position:"absolute",top:"0",right:"2px",background:"inherit",border:"none",font:"inherit",padding:0,margin:0}}});function p3(n){return n=="error"?4:n=="warning"?3:n=="info"?2:1}function g3(n){let e="hint",t=1;for(let i of n){let r=p3(i.severity);r>t&&(t=r,e=i.severity)}return e}const m3=[En,fe.decorations.compute([En],n=>{let{selected:e,panel:t}=n.field(En);return!e||!t||e.from==e.to?_e.none:_e.set([s3.range(e.from,e.to)])}),tL(o3,{hideOn:i3}),d3],O3=[fL(),gL(),RE(),M5(),r5(),bE(),_E(),Ze.allowMultipleSelections.of(!0),WL(),iQ(a5,{fallback:!0}),g5(),Vz(),oC(),WE(),YE(),jE(),ID(),Oc.of([...Uz,...AD,...sz,...I5,...e5,...F0,...u3])];var xS={};class Pf{constructor(e,t,i,r,s,o,a,c,h,f=0,p){this.p=e,this.stack=t,this.state=i,this.reducePos=r,this.pos=s,this.score=o,this.buffer=a,this.bufferBase=c,this.curContext=h,this.lookAhead=f,this.parent=p}toString(){return`[${this.stack.filter((e,t)=>t%3==0).concat(this.state)}]@${this.pos}${this.score?"!"+this.score:""}`}static start(e,t,i=0){let r=e.parser.context;return new Pf(e,[],t,i,i,0,[],0,r?new vS(r,r.start):null,0,null)}get context(){return this.curContext?this.curContext.context:null}pushState(e,t){this.stack.push(this.state,t,this.bufferBase+this.buffer.length),this.state=e}reduce(e){var t;let i=e>>19,r=e&65535,{parser:s}=this.p,o=this.reducePos=2e3&&!(!((t=this.p.parser.nodeSet.types[r])===null||t===void 0)&&t.isAnonymous)&&(h==this.p.lastBigReductionStart?(this.p.bigReductionCount++,this.p.lastBigReductionSize=f):this.p.lastBigReductionSizec;)this.stack.pop();this.reduceContext(r,h)}storeNode(e,t,i,r=4,s=!1){if(e==0&&(!this.stack.length||this.stack[this.stack.length-1]0&&o.buffer[a-4]==0&&o.buffer[a-1]>-1){if(t==i)return;if(o.buffer[a-2]>=t){o.buffer[a-2]=i;return}}}if(!s||this.pos==i)this.buffer.push(e,t,i,r);else{let o=this.buffer.length;if(o>0&&this.buffer[o-4]!=0){let a=!1;for(let c=o;c>0&&this.buffer[c-2]>i;c-=4)if(this.buffer[c-1]>=0){a=!0;break}if(a)for(;o>0&&this.buffer[o-2]>i;)this.buffer[o]=this.buffer[o-4],this.buffer[o+1]=this.buffer[o-3],this.buffer[o+2]=this.buffer[o-2],this.buffer[o+3]=this.buffer[o-1],o-=4,r>4&&(r-=4)}this.buffer[o]=e,this.buffer[o+1]=t,this.buffer[o+2]=i,this.buffer[o+3]=r}}shift(e,t,i,r){if(e&131072)this.pushState(e&65535,this.pos);else if((e&262144)==0){let s=e,{parser:o}=this.p;(r>this.pos||t<=o.maxNode)&&(this.pos=r,o.stateFlag(s,1)||(this.reducePos=r)),this.pushState(s,i),this.shiftContext(t,i),t<=o.maxNode&&this.buffer.push(t,i,r,4)}else this.pos=r,this.shiftContext(t,i),t<=this.p.parser.maxNode&&this.buffer.push(t,i,r,4)}apply(e,t,i,r){e&65536?this.reduce(e):this.shift(e,t,i,r)}useNode(e,t){let i=this.p.reused.length-1;(i<0||this.p.reused[i]!=e)&&(this.p.reused.push(e),i++);let r=this.pos;this.reducePos=this.pos=r+e.length,this.pushState(t,r),this.buffer.push(i,r,this.reducePos,-1),this.curContext&&this.updateContext(this.curContext.tracker.reuse(this.curContext.context,e,this,this.p.stream.reset(this.pos-e.length)))}split(){let e=this,t=e.buffer.length;for(;t>0&&e.buffer[t-2]>e.reducePos;)t-=4;let i=e.buffer.slice(t),r=e.bufferBase+t;for(;e&&r==e.bufferBase;)e=e.parent;return new Pf(this.p,this.stack.slice(),this.state,this.reducePos,this.pos,this.score,i,r,this.curContext,this.lookAhead,e)}recoverByDelete(e,t){let i=e<=this.p.parser.maxNode;i&&this.storeNode(e,this.pos,t,4),this.storeNode(0,this.pos,t,i?8:4),this.pos=this.reducePos=t,this.score-=190}canShift(e){for(let t=new y3(this);;){let i=this.p.parser.stateSlot(t.state,4)||this.p.parser.hasAction(t.state,e);if(i==0)return!1;if((i&65536)==0)return!0;t.reduce(i)}}recoverByInsert(e){if(this.stack.length>=300)return[];let t=this.p.parser.nextStates(this.state);if(t.length>8||this.stack.length>=120){let r=[];for(let s=0,o;sc&1&&a==o)||r.push(t[s],o)}t=r}let i=[];for(let r=0;r>19,r=t&65535,s=this.stack.length-i*3;if(s<0||e.getGoto(this.stack[s],r,!1)<0){let o=this.findForcedReduction();if(o==null)return!1;t=o}this.storeNode(0,this.pos,this.pos,4,!0),this.score-=100}return this.reducePos=this.pos,this.reduce(t),!0}findForcedReduction(){let{parser:e}=this.p,t=[],i=(r,s)=>{if(!t.includes(r))return t.push(r),e.allActions(r,o=>{if(!(o&393216))if(o&65536){let a=(o>>19)-s;if(a>1){let c=o&65535,h=this.stack.length-a*3;if(h>=0&&e.getGoto(this.stack[h],c,!1)>=0)return a<<19|65536|c}}else{let a=i(o,s+1);if(a!=null)return a}})};return i(this.state,0)}forceAll(){for(;!this.p.parser.stateFlag(this.state,2);)if(!this.forceReduce()){this.storeNode(0,this.pos,this.pos,4,!0);break}return this}get deadEnd(){if(this.stack.length!=3)return!1;let{parser:e}=this.p;return e.data[e.stateSlot(this.state,1)]==65535&&!e.stateSlot(this.state,4)}restart(){this.storeNode(0,this.pos,this.pos,4,!0),this.state=this.stack[0],this.stack.length=0}sameState(e){if(this.state!=e.state||this.stack.length!=e.stack.length)return!1;for(let t=0;tthis.lookAhead&&(this.emitLookAhead(),this.lookAhead=e)}close(){this.curContext&&this.curContext.tracker.strict&&this.emitContext(),this.lookAhead>0&&this.emitLookAhead()}}class vS{constructor(e,t){this.tracker=e,this.context=t,this.hash=e.strict?e.hash(t):0}}class y3{constructor(e){this.start=e,this.state=e.state,this.stack=e.stack,this.base=this.stack.length}reduce(e){let t=e&65535,i=e>>19;i==0?(this.stack==this.start.stack&&(this.stack=this.stack.slice()),this.stack.push(this.state,0,0),this.base+=3):this.base-=(i-1)*3;let r=this.start.p.parser.getGoto(this.stack[this.base-3],t,!0);this.state=r}}class _f{constructor(e,t,i){this.stack=e,this.pos=t,this.index=i,this.buffer=e.buffer,this.index==0&&this.maybeNext()}static create(e,t=e.bufferBase+e.buffer.length){return new _f(e,t,t-e.bufferBase)}maybeNext(){let e=this.stack.parent;e!=null&&(this.index=this.stack.bufferBase-e.bufferBase,this.stack=e,this.buffer=e.buffer)}get id(){return this.buffer[this.index-4]}get start(){return this.buffer[this.index-3]}get end(){return this.buffer[this.index-2]}get size(){return this.buffer[this.index-1]}next(){this.index-=4,this.pos-=4,this.index==0&&this.maybeNext()}fork(){return new _f(this.stack,this.pos,this.index)}}function ma(n,e=Uint16Array){if(typeof n!="string")return n;let t=null;for(let i=0,r=0;i=92&&o--,o>=34&&o--;let c=o-32;if(c>=46&&(c-=46,a=!0),s+=c,a)break;s*=46}t?t[r++]=s:t=new e(s)}return t}class Ih{constructor(){this.start=-1,this.value=-1,this.end=-1,this.extended=-1,this.lookAhead=0,this.mask=0,this.context=0}}const bS=new Ih;class x3{constructor(e,t){this.input=e,this.ranges=t,this.chunk="",this.chunkOff=0,this.chunk2="",this.chunk2Pos=0,this.next=-1,this.token=bS,this.rangeIndex=0,this.pos=this.chunkPos=t[0].from,this.range=t[0],this.end=t[t.length-1].to,this.readNext()}resolveOffset(e,t){let i=this.range,r=this.rangeIndex,s=this.pos+e;for(;si.to:s>=i.to;){if(r==this.ranges.length-1)return null;let o=this.ranges[++r];s+=o.from-i.to,i=o}return s}clipPos(e){if(e>=this.range.from&&ee)return Math.max(e,t.from);return this.end}peek(e){let t=this.chunkOff+e,i,r;if(t>=0&&t=this.chunk2Pos&&ia.to&&(this.chunk2=this.chunk2.slice(0,a.to-i)),r=this.chunk2.charCodeAt(0)}}return i>=this.token.lookAhead&&(this.token.lookAhead=i+1),r}acceptToken(e,t=0){let i=t?this.resolveOffset(t,-1):this.pos;if(i==null||i=this.chunk2Pos&&this.posthis.range.to?e.slice(0,this.range.to-this.pos):e,this.chunkPos=this.pos,this.chunkOff=0}}readNext(){return this.chunkOff>=this.chunk.length&&(this.getChunk(),this.chunkOff==this.chunk.length)?this.next=-1:this.next=this.chunk.charCodeAt(this.chunkOff)}advance(e=1){for(this.chunkOff+=e;this.pos+e>=this.range.to;){if(this.rangeIndex==this.ranges.length-1)return this.setDone();e-=this.range.to-this.pos,this.range=this.ranges[++this.rangeIndex],this.pos=this.range.from}return this.pos+=e,this.pos>=this.token.lookAhead&&(this.token.lookAhead=this.pos+1),this.readNext()}setDone(){return this.pos=this.chunkPos=this.end,this.range=this.ranges[this.rangeIndex=this.ranges.length-1],this.chunk="",this.next=-1}reset(e,t){if(t?(this.token=t,t.start=e,t.lookAhead=e+1,t.value=t.extended=-1):this.token=bS,this.pos!=e){if(this.pos=e,e==this.end)return this.setDone(),this;for(;e=this.range.to;)this.range=this.ranges[++this.rangeIndex];e>=this.chunkPos&&e=this.chunkPos&&t<=this.chunkPos+this.chunk.length)return this.chunk.slice(e-this.chunkPos,t-this.chunkPos);if(e>=this.chunk2Pos&&t<=this.chunk2Pos+this.chunk2.length)return this.chunk2.slice(e-this.chunk2Pos,t-this.chunk2Pos);if(e>=this.range.from&&t<=this.range.to)return this.input.read(e,t);let i="";for(let r of this.ranges){if(r.from>=t)break;r.to>e&&(i+=this.input.read(Math.max(r.from,e),Math.min(r.to,t)))}return i}}class Bo{constructor(e,t){this.data=e,this.id=t}token(e,t){let{parser:i}=t.p;hC(this.data,e,t,this.id,i.data,i.tokenPrecTable)}}Bo.prototype.contextual=Bo.prototype.fallback=Bo.prototype.extend=!1;class _O{constructor(e,t,i){this.precTable=t,this.elseToken=i,this.data=typeof e=="string"?ma(e):e}token(e,t){let i=e.pos,r=0;for(;;){let s=e.next<0,o=e.resolveOffset(1,1);if(hC(this.data,e,t,0,this.data,this.precTable),e.token.value>-1)break;if(this.elseToken==null)return;if(s||r++,o==null)break;e.reset(o,e.token)}r&&(e.reset(i,e.token),e.acceptToken(this.elseToken,r))}}_O.prototype.contextual=Bo.prototype.fallback=Bo.prototype.extend=!1;class ul{constructor(e,t={}){this.token=e,this.contextual=!!t.contextual,this.fallback=!!t.fallback,this.extend=!!t.extend}}function hC(n,e,t,i,r,s){let o=0,a=1<0){let v=n[O];if(c.allows(v)&&(e.token.value==-1||e.token.value==v||v3(v,e.token.value,r,s))){e.acceptToken(v);break}}let f=e.next,p=0,m=n[o+2];if(e.next<0&&m>p&&n[h+m*3-3]==65535){o=n[h+m*3-1];continue e}for(;p>1,v=h+O+(O<<1),b=n[v],S=n[v+1]||65536;if(f=S)p=O+1;else{o=n[v+2],e.advance();continue e}}break}}function SS(n,e,t){for(let i=e,r;(r=n[i])!=65535;i++)if(r==t)return i-e;return-1}function v3(n,e,t,i){let r=SS(t,i,e);return r<0||SS(t,i,n)e)&&!i.type.isError)return t<0?Math.max(0,Math.min(i.to-1,e-25)):Math.min(n.length,Math.max(i.from+1,e+25));if(t<0?i.prevSibling():i.nextSibling())break;if(!i.parent())return t<0?0:n.length}}class b3{constructor(e,t){this.fragments=e,this.nodeSet=t,this.i=0,this.fragment=null,this.safeFrom=-1,this.safeTo=-1,this.trees=[],this.start=[],this.index=[],this.nextFragment()}nextFragment(){let e=this.fragment=this.i==this.fragments.length?null:this.fragments[this.i++];if(e){for(this.safeFrom=e.openStart?wS(e.tree,e.from+e.offset,1)-e.offset:e.from,this.safeTo=e.openEnd?wS(e.tree,e.to+e.offset,-1)-e.offset:e.to;this.trees.length;)this.trees.pop(),this.start.pop(),this.index.pop();this.trees.push(e.tree),this.start.push(-e.offset),this.index.push(0),this.nextStart=this.safeFrom}else this.nextStart=1e9}nodeAt(e){if(ee)return this.nextStart=o,null;if(s instanceof wt){if(o==e){if(o=Math.max(this.safeFrom,e)&&(this.trees.push(s),this.start.push(o),this.index.push(0))}else this.index[t]++,this.nextStart=o+s.length}}}class S3{constructor(e,t){this.stream=t,this.tokens=[],this.mainToken=null,this.actions=[],this.tokens=e.tokenizers.map(i=>new Ih)}getActions(e){let t=0,i=null,{parser:r}=e.p,{tokenizers:s}=r,o=r.stateSlot(e.state,3),a=e.curContext?e.curContext.hash:0,c=0;for(let h=0;hp.end+25&&(c=Math.max(p.lookAhead,c)),p.value!=0)){let m=t;if(p.extended>-1&&(t=this.addActions(e,p.extended,p.end,t)),t=this.addActions(e,p.value,p.end,t),!f.extend&&(i=p,t>m))break}}for(;this.actions.length>t;)this.actions.pop();return c&&e.setLookAhead(c),!i&&e.pos==this.stream.end&&(i=new Ih,i.value=e.p.parser.eofTerm,i.start=i.end=e.pos,t=this.addActions(e,i.value,i.end,t)),this.mainToken=i,this.actions}getMainToken(e){if(this.mainToken)return this.mainToken;let t=new Ih,{pos:i,p:r}=e;return t.start=i,t.end=Math.min(i+1,r.stream.end),t.value=i==r.stream.end?r.parser.eofTerm:0,t}updateCachedToken(e,t,i){let r=this.stream.clipPos(i.pos);if(t.token(this.stream.reset(r,e),i),e.value>-1){let{parser:s}=i.p;for(let o=0;o=0&&i.p.parser.dialect.allows(a>>1)){(a&1)==0?e.value=a>>1:e.extended=a>>1;break}}}else e.value=0,e.end=this.stream.clipPos(r+1)}putAction(e,t,i,r){for(let s=0;se.bufferLength*4?new b3(i,e.nodeSet):null}get parsedPos(){return this.minStackPos}advance(){let e=this.stacks,t=this.minStackPos,i=this.stacks=[],r,s;if(this.bigReductionCount>300&&e.length==1){let[o]=e;for(;o.forceReduce()&&o.stack.length&&o.stack[o.stack.length-2]>=this.lastBigReductionStart;);this.bigReductionCount=this.lastBigReductionSize=0}for(let o=0;ot)i.push(a);else{if(this.advanceStack(a,i,e))continue;{r||(r=[],s=[]),r.push(a);let c=this.tokens.getMainToken(a);s.push(c.value,c.end)}}break}}if(!i.length){let o=r&&_3(r);if(o)return $n&&console.log("Finish with "+this.stackID(o)),this.stackToTree(o);if(this.parser.strict)throw $n&&r&&console.log("Stuck with token "+(this.tokens.mainToken?this.parser.getName(this.tokens.mainToken.value):"none")),new SyntaxError("No parse at "+t);this.recovering||(this.recovering=5)}if(this.recovering&&r){let o=this.stoppedAt!=null&&r[0].pos>this.stoppedAt?r[0]:this.runRecovery(r,s,i);if(o)return $n&&console.log("Force-finish "+this.stackID(o)),this.stackToTree(o.forceAll())}if(this.recovering){let o=this.recovering==1?1:this.recovering*3;if(i.length>o)for(i.sort((a,c)=>c.score-a.score);i.length>o;)i.pop();i.some(a=>a.reducePos>t)&&this.recovering--}else if(i.length>1){e:for(let o=0;o500&&h.buffer.length>500)if((a.score-h.score||a.buffer.length-h.buffer.length)>0)i.splice(c--,1);else{i.splice(o--,1);continue e}}}i.length>12&&i.splice(12,i.length-12)}this.minStackPos=i[0].pos;for(let o=1;o ":"";if(this.stoppedAt!=null&&r>this.stoppedAt)return e.forceReduce()?e:null;if(this.fragments){let h=e.curContext&&e.curContext.tracker.strict,f=h?e.curContext.hash:0;for(let p=this.fragments.nodeAt(r);p;){let m=this.parser.nodeSet.types[p.type.id]==p.type?s.getGoto(e.state,p.type.id):-1;if(m>-1&&p.length&&(!h||(p.prop(Ee.contextHash)||0)==f))return e.useNode(p,m),$n&&console.log(o+this.stackID(e)+` (via reuse of ${s.getName(p.type.id)})`),!0;if(!(p instanceof wt)||p.children.length==0||p.positions[0]>0)break;let O=p.children[0];if(O instanceof wt&&p.positions[0]==0)p=O;else break}}let a=s.stateSlot(e.state,4);if(a>0)return e.reduce(a),$n&&console.log(o+this.stackID(e)+` (via always-reduce ${s.getName(a&65535)})`),!0;if(e.stack.length>=8400)for(;e.stack.length>6e3&&e.forceReduce(););let c=this.tokens.getActions(e);for(let h=0;hr?t.push(v):i.push(v)}return!1}advanceFully(e,t){let i=e.pos;for(;;){if(!this.advanceStack(e,null,null))return!1;if(e.pos>i)return kS(e,t),!0}}runRecovery(e,t,i){let r=null,s=!1;for(let o=0;o ":"";if(a.deadEnd&&(s||(s=!0,a.restart(),$n&&console.log(f+this.stackID(a)+" (restarted)"),this.advanceFully(a,i))))continue;let p=a.split(),m=f;for(let O=0;p.forceReduce()&&O<10&&($n&&console.log(m+this.stackID(p)+" (via force-reduce)"),!this.advanceFully(p,i));O++)$n&&(m=this.stackID(p)+" -> ");for(let O of a.recoverByInsert(c))$n&&console.log(f+this.stackID(O)+" (via recover-insert)"),this.advanceFully(O,i);this.stream.end>a.pos?(h==a.pos&&(h++,c=0),a.recoverByDelete(c,h),$n&&console.log(f+this.stackID(a)+` (via recover-delete ${this.parser.getName(c)})`),kS(a,i)):(!r||r.scoren;class P3{constructor(e){this.start=e.start,this.shift=e.shift||Vg,this.reduce=e.reduce||Vg,this.reuse=e.reuse||Vg,this.hash=e.hash||(()=>0),this.strict=e.strict!==!1}}class rc extends N_{constructor(e){if(super(),this.wrappers=[],e.version!=14)throw new RangeError(`Parser version (${e.version}) doesn't match runtime version (14)`);let t=e.nodeNames.split(" ");this.minRepeatTerm=t.length;for(let a=0;ae.topRules[a][1]),r=[];for(let a=0;a=0)s(f,c,a[h++]);else{let p=a[h+-f];for(let m=-f;m>0;m--)s(a[h++],c,p);h++}}}this.nodeSet=new b0(t.map((a,c)=>kn.define({name:c>=this.minRepeatTerm?void 0:a,id:c,props:r[c],top:i.indexOf(c)>-1,error:c==0,skipped:e.skippedNodes&&e.skippedNodes.indexOf(c)>-1}))),e.propSources&&(this.nodeSet=this.nodeSet.extend(...e.propSources)),this.strict=!1,this.bufferLength=z_;let o=ma(e.tokenData);this.context=e.context,this.specializerSpecs=e.specialized||[],this.specialized=new Uint16Array(this.specializerSpecs.length);for(let a=0;atypeof a=="number"?new Bo(o,a):a),this.topRules=e.topRules,this.dialects=e.dialects||{},this.dynamicPrecedences=e.dynamicPrecedences||null,this.tokenPrecTable=e.tokenPrec,this.termNames=e.termNames||null,this.maxNode=this.nodeSet.types.length-1,this.dialect=this.parseDialect(),this.top=this.topRules[Object.keys(this.topRules)[0]]}createParse(e,t,i){let r=new w3(this,e,t,i);for(let s of this.wrappers)r=s(r,e,t,i);return r}getGoto(e,t,i=!1){let r=this.goto;if(t>=r[0])return-1;for(let s=r[t+1];;){let o=r[s++],a=o&1,c=r[s++];if(a&&i)return c;for(let h=s+(o>>1);s0}validAction(e,t){return!!this.allActions(e,i=>i==t?!0:null)}allActions(e,t){let i=this.stateSlot(e,4),r=i?t(i):void 0;for(let s=this.stateSlot(e,1);r==null;s+=3){if(this.data[s]==65535)if(this.data[s+1]==1)s=er(this.data,s+2);else break;r=t(er(this.data,s+1))}return r}nextStates(e){let t=[];for(let i=this.stateSlot(e,1);;i+=3){if(this.data[i]==65535)if(this.data[i+1]==1)i=er(this.data,i+2);else break;if((this.data[i+2]&1)==0){let r=this.data[i+1];t.some((s,o)=>o&1&&s==r)||t.push(this.data[i],r)}}return t}configure(e){let t=Object.assign(Object.create(rc.prototype),this);if(e.props&&(t.nodeSet=this.nodeSet.extend(...e.props)),e.top){let i=this.topRules[e.top];if(!i)throw new RangeError(`Invalid top rule name ${e.top}`);t.top=i}return e.tokenizers&&(t.tokenizers=this.tokenizers.map(i=>{let r=e.tokenizers.find(s=>s.from==i);return r?r.to:i})),e.specializers&&(t.specializers=this.specializers.slice(),t.specializerSpecs=this.specializerSpecs.map((i,r)=>{let s=e.specializers.find(a=>a.from==i.external);if(!s)return i;let o=Object.assign(Object.assign({},i),{external:s.to});return t.specializers[r]=PS(o),o})),e.contextTracker&&(t.context=e.contextTracker),e.dialect&&(t.dialect=this.parseDialect(e.dialect)),e.strict!=null&&(t.strict=e.strict),e.wrap&&(t.wrappers=t.wrappers.concat(e.wrap)),e.bufferLength!=null&&(t.bufferLength=e.bufferLength),t}hasWrappers(){return this.wrappers.length>0}getName(e){return this.termNames?this.termNames[e]:String(e<=this.maxNode&&this.nodeSet.types[e].name||e)}get eofTerm(){return this.maxNode+1}get topNode(){return this.nodeSet.types[this.top[1]]}dynamicPrecedence(e){let t=this.dynamicPrecedences;return t==null?0:t[e]||0}parseDialect(e){let t=Object.keys(this.dialects),i=t.map(()=>!1);if(e)for(let s of e.split(" ")){let o=t.indexOf(s);o>=0&&(i[o]=!0)}let r=null;for(let s=0;si)&&t.p.parser.stateFlag(t.state,2)&&(!e||e.scoren.external(t,i)<<1|e}return n.get}const Q3=36,_S=1,C3=2,ko=3,Fg=4,T3=5,$3=6,M3=7,R3=8,A3=9,E3=10,L3=11,D3=12,z3=13,j3=14,Z3=15,I3=16,N3=17,QS=18,B3=19,fC=20,dC=21,CS=22,X3=23,W3=24;function QO(n){return n>=65&&n<=90||n>=97&&n<=122||n>=48&&n<=57}function V3(n){return n>=48&&n<=57||n>=97&&n<=102||n>=65&&n<=70}function Qs(n,e,t){for(let i=!1;;){if(n.next<0)return;if(n.next==e&&!i){n.advance();return}i=t&&!i&&n.next==92,n.advance()}}function F3(n,e){e:for(;;){if(n.next<0)return;if(n.next==36){n.advance();for(let t=0;t)".charCodeAt(t);for(;;){if(n.next<0)return;if(n.next==i&&n.peek(1)==39){n.advance(2);return}n.advance()}}function CO(n,e){for(;!(n.next!=95&&!QO(n.next));)e!=null&&(e+=String.fromCharCode(n.next)),n.advance();return e}function q3(n){if(n.next==39||n.next==34||n.next==96){let e=n.next;n.advance(),Qs(n,e,!1)}else CO(n)}function TS(n,e){for(;n.next==48||n.next==49;)n.advance();e&&n.next==e&&n.advance()}function $S(n,e){for(;;){if(n.next==46){if(e)break;e=!0}else if(n.next<48||n.next>57)break;n.advance()}if(n.next==69||n.next==101)for(n.advance(),(n.next==43||n.next==45)&&n.advance();n.next>=48&&n.next<=57;)n.advance()}function MS(n){for(;!(n.next<0||n.next==10);)n.advance()}function xs(n,e){for(let t=0;t!=&|~^/",specialVar:"?",identifierQuotes:'"',caseInsensitiveIdentifiers:!1,words:pC(H3,U3)};function G3(n,e,t,i){let r={};for(let s in TO)r[s]=(n.hasOwnProperty(s)?n:TO)[s];return e&&(r.words=pC(e,t||"",i)),r}function gC(n){return new ul(e=>{var t;let{next:i}=e;if(e.advance(),xs(i,Yg)){for(;xs(e.next,Yg);)e.advance();e.acceptToken(Q3)}else if(i==36&&n.doubleDollarQuotedStrings){let r=CO(e,"");e.next==36&&(e.advance(),F3(e,r),e.acceptToken(ko))}else if(i==39||i==34&&n.doubleQuotedStrings)Qs(e,i,n.backslashEscapes),e.acceptToken(ko);else if(i==35&&n.hashComments||i==47&&e.next==47&&n.slashComments)MS(e),e.acceptToken(_S);else if(i==45&&e.next==45&&(!n.spaceAfterDashes||e.peek(1)==32))MS(e),e.acceptToken(_S);else if(i==47&&e.next==42){e.advance();for(let r=1;;){let s=e.next;if(e.next<0)break;if(e.advance(),s==42&&e.next==47){if(r--,e.advance(),!r)break}else s==47&&e.next==42&&(r++,e.advance())}e.acceptToken(C3)}else if((i==101||i==69)&&e.next==39)e.advance(),Qs(e,39,!0),e.acceptToken(ko);else if((i==110||i==78)&&e.next==39&&n.charSetCasts)e.advance(),Qs(e,39,n.backslashEscapes),e.acceptToken(ko);else if(i==95&&n.charSetCasts)for(let r=0;;r++){if(e.next==39&&r>1){e.advance(),Qs(e,39,n.backslashEscapes),e.acceptToken(ko);break}if(!QO(e.next))break;e.advance()}else if(n.plsqlQuotingMechanism&&(i==113||i==81)&&e.next==39&&e.peek(1)>0&&!xs(e.peek(1),Yg)){let r=e.peek(1);e.advance(2),Y3(e,r),e.acceptToken(ko)}else if(xs(i,n.identifierQuotes)){const r=i==91?93:i;Qs(e,r,!1),e.acceptToken(B3)}else if(i==40)e.acceptToken(M3);else if(i==41)e.acceptToken(R3);else if(i==123)e.acceptToken(A3);else if(i==125)e.acceptToken(E3);else if(i==91)e.acceptToken(L3);else if(i==93)e.acceptToken(D3);else if(i==59)e.acceptToken(z3);else if(n.unquotedBitLiterals&&i==48&&e.next==98)e.advance(),TS(e),e.acceptToken(CS);else if((i==98||i==66)&&(e.next==39||e.next==34)){const r=e.next;e.advance(),n.treatBitsAsBytes?(Qs(e,r,n.backslashEscapes),e.acceptToken(X3)):(TS(e,r),e.acceptToken(CS))}else if(i==48&&(e.next==120||e.next==88)||(i==120||i==88)&&e.next==39){let r=e.next==39;for(e.advance();V3(e.next);)e.advance();r&&e.next==39&&e.advance(),e.acceptToken(Fg)}else if(i==46&&e.next>=48&&e.next<=57)$S(e,!0),e.acceptToken(Fg);else if(i==46)e.acceptToken(j3);else if(i>=48&&i<=57)$S(e,!1),e.acceptToken(Fg);else if(xs(i,n.operatorChars)){for(;xs(e.next,n.operatorChars);)e.advance();e.acceptToken(Z3)}else if(xs(i,n.specialVar))e.next==i&&e.advance(),q3(e),e.acceptToken(N3);else if(i==58||i==44)e.acceptToken(I3);else if(QO(i)){let r=CO(e,String.fromCharCode(i));e.acceptToken(e.next==46||e.peek(-r.length-1)==46?QS:(t=n.words[r.toLowerCase()])!==null&&t!==void 0?t:QS)}})}const mC=gC(TO),K3=rc.deserialize({version:14,states:"%vQ]QQOOO#wQRO'#DSO$OQQO'#CwO%eQQO'#CxO%lQQO'#CyO%sQQO'#CzOOQQ'#DS'#DSOOQQ'#C}'#C}O'UQRO'#C{OOQQ'#Cv'#CvOOQQ'#C|'#C|Q]QQOOQOQQOOO'`QQO'#DOO(xQRO,59cO)PQQO,59cO)UQQO'#DSOOQQ,59d,59dO)cQQO,59dOOQQ,59e,59eO)jQQO,59eOOQQ,59f,59fO)qQQO,59fOOQQ-E6{-E6{OOQQ,59b,59bOOQQ-E6z-E6zOOQQ,59j,59jOOQQ-E6|-E6|O+VQRO1G.}O+^QQO,59cOOQQ1G/O1G/OOOQQ1G/P1G/POOQQ1G/Q1G/QP+kQQO'#C}O+rQQO1G.}O)PQQO,59cO,PQQO'#Cw",stateData:",[~OtOSPOSQOS~ORUOSUOTUOUUOVROXSOZTO]XO^QO_UO`UOaPObPOcPOdUOeUOfUOgUOhUO~O^]ORvXSvXTvXUvXVvXXvXZvX]vX_vX`vXavXbvXcvXdvXevXfvXgvXhvX~OsvX~P!jOa_Ob_Oc_O~ORUOSUOTUOUUOVROXSOZTO^tO_UO`UOa`Ob`Oc`OdUOeUOfUOgUOhUO~OWaO~P$ZOYcO~P$ZO[eO~P$ZORUOSUOTUOUUOVROXSOZTO^QO_UO`UOaPObPOcPOdUOeUOfUOgUOhUO~O]hOsoX~P%zOajObjOcjO~O^]ORkaSkaTkaUkaVkaXkaZka]ka_ka`kaakabkackadkaekafkagkahka~Oska~P'kO^]O~OWvXYvX[vX~P!jOWnO~P$ZOYoO~P$ZO[pO~P$ZO^]ORkiSkiTkiUkiVkiXkiZki]ki_ki`kiakibkickidkiekifkigkihki~Oski~P)xOWkaYka[ka~P'kO]hO~P$ZOWkiYki[ki~P)xOasObsOcsO~O",goto:"#hwPPPPPPPPPPPPPPPPPPPPPPPPPPx||||!Y!^!d!xPPP#[TYOZeUORSTWZbdfqT[OZQZORiZSWOZQbRQdSQfTZgWbdfqQ^PWk^lmrQl_Qm`RrseVORSTWZbdfq",nodeNames:"⚠ LineComment BlockComment String Number Bool Null ( ) { } [ ] ; . Operator Punctuation SpecialVar Identifier QuotedIdentifier Keyword Type Bits Bytes Builtin Script Statement CompositeIdentifier Parens Braces Brackets Statement",maxTerm:38,nodeProps:[["isolate",-4,1,2,3,19,""]],skippedNodes:[0,1,2],repeatNodeCount:3,tokenData:"RORO",tokenizers:[0,mC],topRules:{Script:[0,25]},tokenPrec:0});function $O(n){let e=n.cursor().moveTo(n.from,-1);for(;/Comment/.test(e.name);)e.moveTo(e.from,-1);return e.node}function sc(n,e){let t=n.sliceString(e.from,e.to),i=/^([`'"\[])(.*)([`'"\]])$/.exec(t);return i?i[2]:t}function Qf(n){return n&&(n.name=="Identifier"||n.name=="QuotedIdentifier")}function J3(n,e){if(e.name=="CompositeIdentifier"){let t=[];for(let i=e.firstChild;i;i=i.nextSibling)Qf(i)&&t.push(sc(n,i));return t}return[sc(n,e)]}function RS(n,e){for(let t=[];;){if(!e||e.name!=".")return t;let i=$O(e);if(!Qf(i))return t;t.unshift(sc(n,i)),e=$O(i)}}function ej(n,e){let t=Pt(n).resolveInner(e,-1),i=nj(n.doc,t);return t.name=="Identifier"||t.name=="QuotedIdentifier"||t.name=="Keyword"?{from:t.from,quoted:t.name=="QuotedIdentifier"?n.doc.sliceString(t.from,t.from+1):null,parents:RS(n.doc,$O(t)),aliases:i}:t.name=="."?{from:e,quoted:null,parents:RS(n.doc,t),aliases:i}:{from:e,quoted:null,parents:[],empty:!0,aliases:i}}const tj=new Set("where group having order union intersect except all distinct limit offset fetch for".split(" "));function nj(n,e){let t;for(let r=e;!t;r=r.parent){if(!r)return null;r.name=="Statement"&&(t=r)}let i=null;for(let r=t.firstChild,s=!1,o=null;r;r=r.nextSibling){let a=r.name=="Keyword"?n.sliceString(r.from,r.to).toLowerCase():null,c=null;if(!s)s=a=="from";else if(a=="as"&&o&&Qf(r.nextSibling))c=sc(n,r.nextSibling);else{if(a&&tj.has(a))break;o&&Qf(r)&&(c=sc(n,r))}c&&(i||(i=Object.create(null)),i[c]=J3(n,o)),o=/Identifier$/.test(r.name)?r:null}return i}function ij(n,e,t){return t.map(i=>({...i,label:i.label[0]==n?i.label:n+i.label+e,apply:void 0}))}const rj=/^\w*$/,sj=/^[`'"\[]?\w*[`'"\]]?$/;function AS(n){return n.self&&typeof n.self.label=="string"}class q0{constructor(e,t){this.idQuote=e,this.idCaseInsensitive=t,this.list=[],this.children=void 0}child(e){let t=this.children||(this.children=Object.create(null)),i=t[e];return i||(e&&!this.list.some(r=>r.label==e)&&this.list.push(ES(e,"type",this.idQuote,this.idCaseInsensitive)),t[e]=new q0(this.idQuote,this.idCaseInsensitive))}maybeChild(e){return this.children?this.children[e]:null}addCompletion(e){let t=this.list.findIndex(i=>i.label==e.label);t>-1?this.list[t]=e:this.list.push(e)}addCompletions(e){for(let t of e)this.addCompletion(typeof t=="string"?ES(t,"property",this.idQuote,this.idCaseInsensitive):t)}addNamespace(e){Array.isArray(e)?this.addCompletions(e):AS(e)?this.addNamespace(e.children):this.addNamespaceObject(e)}addNamespaceObject(e){for(let t of Object.keys(e)){let i=e[t],r=null,s=t.replace(/\\?\./g,a=>a=="."?"\0":a).split("\0"),o=this;AS(i)&&(r=i.self,i=i.children);for(let a=0;a{let{parents:p,from:m,quoted:O,empty:v,aliases:b}=ej(f.state,f.pos);if(v&&!f.explicit)return null;b&&p.length==1&&(p=b[p[0]]||p);let S=c;for(let T of p){for(;!S.children||!S.children[T];)if(S==c&&h)S=h;else if(S==h&&i)S=S.child(i);else return null;let k=S.maybeChild(T);if(!k)return null;S=k}let w=S.list;if(S==c&&b&&(w=w.concat(Object.keys(b).map(T=>({label:T,type:"constant"})))),O){let T=O[0],k=OC(T),_=f.state.sliceDoc(f.pos,f.pos+1)==k;return{from:m,to:_?f.pos+1:void 0,options:ij(T,k,w),validFor:sj}}else return{from:m,options:w,validFor:rj}}}function lj(n){return n==dC?"type":n==fC?"keyword":"variable"}function aj(n,e,t){let i=Object.keys(n).map(r=>t(e?r.toUpperCase():r,lj(n[r])));return HQ(["QuotedIdentifier","String","LineComment","BlockComment","."],j0(i))}let cj=K3.configure({props:[C0.add({Statement:Zh()}),$0.add({Statement(n,e){return{from:Math.min(n.from+100,e.doc.lineAt(n.from).to),to:n.to}},BlockComment(n){return{from:n.from+2,to:n.to-2}}}),P0({Keyword:A.keyword,Type:A.typeName,Builtin:A.standard(A.name),Bits:A.number,Bytes:A.string,Bool:A.bool,Null:A.null,Number:A.number,String:A.string,Identifier:A.name,QuotedIdentifier:A.special(A.string),SpecialVar:A.special(A.name),LineComment:A.lineComment,BlockComment:A.blockComment,Operator:A.operator,"Semi Punctuation":A.punctuation,"( )":A.paren,"{ }":A.brace,"[ ]":A.squareBracket})]});class Cf{constructor(e,t,i){this.dialect=e,this.language=t,this.spec=i}get extension(){return this.language.extension}configureLanguage(e,t){return new Cf(this.dialect,this.language.configure(e,t),this.spec)}static define(e){let t=G3(e,e.keywords,e.types,e.builtin),i=Ha.define({name:"sql",parser:cj.configure({tokenizers:[{from:mC,to:gC(t)}]}),languageData:{commentTokens:{line:"--",block:{open:"/*",close:"*/"}},closeBrackets:{brackets:["(","[","{","'",'"',"`"]}}});return new Cf(t,i,e)}}function uj(n,e){return{label:n,type:e,boost:-1}}function hj(n,e=!1,t){return aj(n.dialect.words,e,t||uj)}function fj(n){return n.schema?oj(n.schema,n.tables,n.schemas,n.defaultTable,n.defaultSchema,n.dialect||U0):()=>null}function dj(n){return n.schema?(n.dialect||U0).language.data.of({autocomplete:fj(n)}):[]}function LS(n={}){let e=n.dialect||U0;return new F_(e.language,[dj(n),e.language.data.of({autocomplete:hj(e,n.upperCaseKeywords,n.keywordCompletion)})])}const U0=Cf.define({}),pj=316,gj=317,DS=1,mj=2,Oj=3,yj=4,xj=318,vj=320,bj=321,Sj=5,wj=6,kj=0,MO=[9,10,11,12,13,32,133,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288],yC=125,Pj=59,RO=47,_j=42,Qj=43,Cj=45,Tj=60,$j=44,Mj=63,Rj=46,Aj=91,Ej=new P3({start:!1,shift(n,e){return e==Sj||e==wj||e==vj?n:e==bj},strict:!1}),Lj=new ul((n,e)=>{let{next:t}=n;(t==yC||t==-1||e.context)&&n.acceptToken(xj)},{contextual:!0,fallback:!0}),Dj=new ul((n,e)=>{let{next:t}=n,i;MO.indexOf(t)>-1||t==RO&&((i=n.peek(1))==RO||i==_j)||t!=yC&&t!=Pj&&t!=-1&&!e.context&&n.acceptToken(pj)},{contextual:!0}),zj=new ul((n,e)=>{n.next==Aj&&!e.context&&n.acceptToken(gj)},{contextual:!0}),jj=new ul((n,e)=>{let{next:t}=n;if(t==Qj||t==Cj){if(n.advance(),t==n.next){n.advance();let i=!e.context&&e.canShift(DS);n.acceptToken(i?DS:mj)}}else t==Mj&&n.peek(1)==Rj&&(n.advance(),n.advance(),(n.next<48||n.next>57)&&n.acceptToken(Oj))},{contextual:!0});function qg(n,e){return n>=65&&n<=90||n>=97&&n<=122||n==95||n>=192||!e&&n>=48&&n<=57}const Zj=new ul((n,e)=>{if(n.next!=Tj||!e.dialectEnabled(kj)||(n.advance(),n.next==RO))return;let t=0;for(;MO.indexOf(n.next)>-1;)n.advance(),t++;if(qg(n.next,!0)){for(n.advance(),t++;qg(n.next,!1);)n.advance(),t++;for(;MO.indexOf(n.next)>-1;)n.advance(),t++;if(n.next==$j)return;for(let i=0;;i++){if(i==7){if(!qg(n.next,!0))return;break}if(n.next!="extends".charCodeAt(i))break;n.advance(),t++}}n.acceptToken(yj,-t)}),Ij=P0({"get set async static":A.modifier,"for while do if else switch try catch finally return throw break continue default case defer":A.controlKeyword,"in of await yield void typeof delete instanceof as satisfies":A.operatorKeyword,"let var const using function class extends":A.definitionKeyword,"import export from":A.moduleKeyword,"with debugger new":A.keyword,TemplateString:A.special(A.string),super:A.atom,BooleanLiteral:A.bool,this:A.self,null:A.null,Star:A.modifier,VariableName:A.variableName,"CallExpression/VariableName TaggedTemplateExpression/VariableName":A.function(A.variableName),VariableDefinition:A.definition(A.variableName),Label:A.labelName,PropertyName:A.propertyName,PrivatePropertyName:A.special(A.propertyName),"CallExpression/MemberExpression/PropertyName":A.function(A.propertyName),"FunctionDeclaration/VariableDefinition":A.function(A.definition(A.variableName)),"ClassDeclaration/VariableDefinition":A.definition(A.className),"NewExpression/VariableName":A.className,PropertyDefinition:A.definition(A.propertyName),PrivatePropertyDefinition:A.definition(A.special(A.propertyName)),UpdateOp:A.updateOperator,"LineComment Hashbang":A.lineComment,BlockComment:A.blockComment,Number:A.number,String:A.string,Escape:A.escape,ArithOp:A.arithmeticOperator,LogicOp:A.logicOperator,BitOp:A.bitwiseOperator,CompareOp:A.compareOperator,RegExp:A.regexp,Equals:A.definitionOperator,Arrow:A.function(A.punctuation),": Spread":A.punctuation,"( )":A.paren,"[ ]":A.squareBracket,"{ }":A.brace,"InterpolationStart InterpolationEnd":A.special(A.brace),".":A.derefOperator,", ;":A.separator,"@":A.meta,TypeName:A.typeName,TypeDefinition:A.definition(A.typeName),"type enum interface implements namespace module declare":A.definitionKeyword,"abstract global Privacy readonly override":A.modifier,"is keyof unique infer asserts":A.operatorKeyword,JSXAttributeValue:A.attributeValue,JSXText:A.content,"JSXStartTag JSXStartCloseTag JSXSelfCloseEndTag JSXEndTag":A.angleBracket,"JSXIdentifier JSXNameSpacedName":A.tagName,"JSXAttribute/JSXIdentifier JSXAttribute/JSXNameSpacedName":A.attributeName,"JSXBuiltin/JSXIdentifier":A.standard(A.tagName)}),Nj={__proto__:null,export:20,as:25,from:33,default:36,async:41,function:42,in:52,out:55,const:56,extends:60,this:64,true:72,false:72,null:84,void:88,typeof:92,super:108,new:142,delete:154,yield:163,await:167,class:172,public:235,private:235,protected:235,readonly:237,instanceof:256,satisfies:259,import:292,keyof:349,unique:353,infer:359,asserts:395,is:397,abstract:417,implements:419,type:421,let:424,var:426,using:429,interface:435,enum:439,namespace:445,module:447,declare:451,global:455,defer:471,for:476,of:485,while:488,with:492,do:496,if:500,else:502,switch:506,case:512,try:518,catch:522,finally:526,return:530,throw:534,break:538,continue:542,debugger:546},Bj={__proto__:null,async:129,get:131,set:133,declare:195,public:197,private:197,protected:197,static:199,abstract:201,override:203,readonly:209,accessor:211,new:401},Xj={__proto__:null,"<":193},Wj=rc.deserialize({version:14,states:"$F|Q%TQlOOO%[QlOOO'_QpOOP(lO`OOO*zQ!0MxO'#CiO+RO#tO'#CjO+aO&jO'#CjO+oO#@ItO'#DaO.QQlO'#DgO.bQlO'#DrO%[QlO'#DzO0fQlO'#ESOOQ!0Lf'#E['#E[O1PQ`O'#EXOOQO'#Ep'#EpOOQO'#Il'#IlO1XQ`O'#GsO1dQ`O'#EoO1iQ`O'#EoO3hQ!0MxO'#JrO6[Q!0MxO'#JsO6uQ`O'#F]O6zQ,UO'#FtOOQ!0Lf'#Ff'#FfO7VO7dO'#FfO9XQMhO'#F|O9`Q`O'#F{OOQ!0Lf'#Js'#JsOOQ!0Lb'#Jr'#JrO9eQ`O'#GwOOQ['#K_'#K_O9pQ`O'#IYO9uQ!0LrO'#IZOOQ['#J`'#J`OOQ['#I_'#I_Q`QlOOQ`QlOOO9}Q!L^O'#DvO:UQlO'#EOO:]QlO'#EQO9kQ`O'#GsO:dQMhO'#CoO:rQ`O'#EnO:}Q`O'#EyO;hQMhO'#FeO;xQ`O'#GsOOQO'#K`'#K`O;}Q`O'#K`O<]Q`O'#G{O<]Q`O'#G|O<]Q`O'#HOO9kQ`O'#HRO=SQ`O'#HUO>kQ`O'#CeO>{Q`O'#HcO?TQ`O'#HiO?TQ`O'#HkO`QlO'#HmO?TQ`O'#HoO?TQ`O'#HrO?YQ`O'#HxO?_Q!0LsO'#IOO%[QlO'#IQO?jQ!0LsO'#ISO?uQ!0LsO'#IUO9uQ!0LrO'#IWO@QQ!0MxO'#CiOASQpO'#DlQOQ`OOO%[QlO'#EQOAjQ`O'#ETO:dQMhO'#EnOAuQ`O'#EnOBQQ!bO'#FeOOQ['#Cg'#CgOOQ!0Lb'#Dq'#DqOOQ!0Lb'#Jv'#JvO%[QlO'#JvOOQO'#Jy'#JyOOQO'#Ih'#IhOCQQpO'#EgOOQ!0Lb'#Ef'#EfOOQ!0Lb'#J}'#J}OC|Q!0MSO'#EgODWQpO'#EWOOQO'#Jx'#JxODlQpO'#JyOEyQpO'#EWODWQpO'#EgPFWO&2DjO'#CbPOOO)CD})CD}OOOO'#I`'#I`OFcO#tO,59UOOQ!0Lh,59U,59UOOOO'#Ia'#IaOFqO&jO,59UOGPQ!L^O'#DcOOOO'#Ic'#IcOGWO#@ItO,59{OOQ!0Lf,59{,59{OGfQlO'#IdOGyQ`O'#JtOIxQ!fO'#JtO+}QlO'#JtOJPQ`O,5:ROJgQ`O'#EpOJtQ`O'#KTOKPQ`O'#KSOKPQ`O'#KSOKXQ`O,5;^OK^Q`O'#KROOQ!0Ln,5:^,5:^OKeQlO,5:^OMcQ!0MxO,5:fONSQ`O,5:nONmQ!0LrO'#KQONtQ`O'#KPO9eQ`O'#KPO! YQ`O'#KPO! bQ`O,5;]O! gQ`O'#KPO!#lQ!fO'#JsOOQ!0Lh'#Ci'#CiO%[QlO'#ESO!$[Q!fO,5:sOOQS'#Jz'#JzOOQO-EtOOQ['#Jh'#JhOOQ[,5>u,5>uOOQ[-E<]-E<]O!TO`QlO,5>VO!LOQ`O,5>XO`QlO,5>ZO!LTQ`O,5>^O!LYQlO,5>dOOQ[,5>j,5>jO%[QlO,5>jO9uQ!0LrO,5>lOOQ[,5>n,5>nO#!dQ`O,5>nOOQ[,5>p,5>pO#!dQ`O,5>pOOQ[,5>r,5>rO##QQpO'#D_O%[QlO'#JvO##sQpO'#JvO##}QpO'#DmO#$`QpO'#DmO#&qQlO'#DmO#&xQ`O'#JuO#'QQ`O,5:WO#'VQ`O'#EtO#'eQ`O'#KUO#'mQ`O,5;_O#'rQpO'#DmO#(PQpO'#EVOOQ!0Lf,5:o,5:oO%[QlO,5:oO#(WQ`O,5:oO?YQ`O,5;YO!CUQpO,5;YO!C^QMhO,5;YO:dQMhO,5;YO#(`Q`O,5@bO#(eQ07dO,5:sOOQO-EPO$6^Q`O,5>POOQ[1G3i1G3iO`QlO1G3iOOQ[1G3o1G3oOOQ[1G3q1G3qO?TQ`O1G3sO$6cQlO1G3uO$:gQlO'#HtOOQ[1G3x1G3xO$:tQ`O'#HzO?YQ`O'#H|OOQ[1G4O1G4OO$:|QlO1G4OO9uQ!0LrO1G4UOOQ[1G4W1G4WOOQ!0Lb'#G_'#G_O9uQ!0LrO1G4YO9uQ!0LrO1G4[O$?TQ`O,5@bO!)[QlO,5;`O9eQ`O,5;`O?YQ`O,5:XO!)[QlO,5:XO!CUQpO,5:XO$?YQ?MtO,5:XOOQO,5;`,5;`O$?dQpO'#IeO$?zQ`O,5@aOOQ!0Lf1G/r1G/rO$@SQpO'#IkO$@^Q`O,5@pOOQ!0Lb1G0y1G0yO#$`QpO,5:XOOQO'#Ig'#IgO$@fQpO,5:qOOQ!0Ln,5:q,5:qO#(ZQ`O1G0ZOOQ!0Lf1G0Z1G0ZO%[QlO1G0ZOOQ!0Lf1G0t1G0tO?YQ`O1G0tO!CUQpO1G0tO!C^QMhO1G0tOOQ!0Lb1G5|1G5|O!ByQ!0LrO1G0^OOQO1G0m1G0mO%[QlO1G0mO$@mQ!0LrO1G0mO$@xQ!0LrO1G0mO!CUQpO1G0^ODWQpO1G0^O$AWQ!0LrO1G0mOOQO1G0^1G0^O$AlQ!0MxO1G0mPOOO-E<[-E<[POOO1G.h1G.hOOOO1G/i1G/iO$AvQ!bO,5QQpO,5@}OOQ!0Lb1G3c1G3cOOQ[7+$V7+$VO@zQ`O7+$VO9uQ!0LrO7+$VO%>]Q`O7+$VO%[QlO1G6lO%[QlO1G6mO%>bQ!0LrO1G6lO%>lQlO1G3kO%>sQ`O1G3kO%>xQlO1G3kOOQ[7+)T7+)TO9uQ!0LrO7+)_O`QlO7+)aOOQ['#Kh'#KhOOQ['#JS'#JSO%?PQlO,5>`OOQ[,5>`,5>`O%[QlO'#HuO%?^Q`O'#HwOOQ[,5>f,5>fO9eQ`O,5>fOOQ[,5>h,5>hOOQ[7+)j7+)jOOQ[7+)p7+)pOOQ[7+)t7+)tOOQ[7+)v7+)vO%?cQpO1G5|O%?}Q?MtO1G0zO%@XQ`O1G0zOOQO1G/s1G/sO%@dQ?MtO1G/sO?YQ`O1G/sO!)[QlO'#DmOOQO,5?P,5?POOQO-ERQ`O7+,WO&>WQ`O7+,XO%[QlO7+,WO%[QlO7+,XOOQ[7+)V7+)VO&>]Q`O7+)VO&>bQlO7+)VO&>iQ`O7+)VOOQ[<nQ`O,5>aOOQ[,5>c,5>cO&>sQ`O1G4QO9eQ`O7+&fO!)[QlO7+&fOOQO7+%_7+%_O&>xQ?MtO1G6ZO?YQ`O7+%_OOQ!0Lf<yQ?MvO,5?aO'@|Q?MvO,5?cO'CPQ?MvO7+'|O'DuQMjOG27TOOQO<VO!l$xO#jROe!iOpkOrPO(T)]O(VTO(YUO(aVO(o[O~O!]$_Oa$qa'z$qa'w$qa!k$qa!Y$qa!_$qa%i$qa!g$qa~Ol)dO~P!&zOh%VOp%WOr%XOs$tOt$tOz%YO|%ZO!O%]O!S${O!_$|O!i%bO!l$xO#j%cO$W%`O$t%^O$v%_O$y%aO(T(vO(VTO(YUO(a$uO(y$}O(z%PO~Og(pP~P!,TO!Q)iO!g)hO!_$^X$Z$^X$]$^X$_$^X$f$^X~O!g)hO!_({X$Z({X$]({X$_({X$f({X~O!Q)iO~P!.^O!Q)iO!_({X$Z({X$]({X$_({X$f({X~O!_)kO$Z)oO$])jO$_)jO$f)pO~O![)sO~P!)[O$]$hO$_$gO$f)wO~On$zX!Q$zX#S$zX'y$zX(y$zX(z$zX~OgmXg$zXnmX!]mX#`mX~P!0SOx)yO(b)zO(c)|O~On*VO!Q*OO'y*PO(y$}O(z%PO~Og)}O~P!1WOg*WO~Oh%VOr%XOs$tOt$tOz%YO|%ZO!OVO!l$xO#jVO!l$xO#jROe!iOpkOrPO(VTO(YUO(aVO(o[O~O(T=QO~P#$qO!]-]O!^(iX~O!^-_O~O!g-VO#`-UO!]#hX!^#hX~O!]-`O!^(xX~O!^-bO~O!c-cO!d-cO(U!lO~P#$`O!^-fO~P'_On-iO!_'`O~O!Y-nO~Os!{a!b!{a!c!{a!d!{a#T!{a#U!{a#V!{a#W!{a#X!{a#[!{a#]!{a(U!{a(V!{a(Y!{a(e!{a(o!{a~P!#vO!p-sO#`-qO~PChO!c-uO!d-uO(U!lO~PDWOa%nO#`-qO'z%nO~Oa%nO!g#vO#`-qO'z%nO~Oa%nO!g#vO!p-sO#`-qO'z%nO(r'pO~O(P'xO(Q'xO(R-zO~Ov-{O~O!Y'Wa!]'Wa~P!:tO![.PO!Y'WX!]'WX~P%[O!](VO!Y(ha~O!Y(ha~PHRO!](^O!Y(va~O!S%hO![.TO!_%iO(T%gO!Y'^X!]'^X~O#`.VO!](ta!k(taa(ta'z(ta~O!g#vO~P#,wO!](jO!k(sa~O!S%hO!_%iO#j.ZO(T%gO~Op.`O!S%hO![.]O!_%iO!|]O#i._O#j.]O(T%gO!]'aX!k'aX~OR.dO!l#xO~Oh%VOn.gO!_'`O%i.fO~Oa#ci!]#ci'z#ci'w#ci!Y#ci!k#civ#ci!_#ci%i#ci!g#ci~P!:tOn>]O!Q*OO'y*PO(y$}O(z%PO~O#k#_aa#_a#`#_a'z#_a!]#_a!k#_a!_#_a!Y#_a~P#/sO#k(`XP(`XR(`X[(`Xa(`Xj(`Xr(`X!S(`X!l(`X!p(`X#R(`X#n(`X#o(`X#p(`X#q(`X#r(`X#s(`X#t(`X#u(`X#v(`X#x(`X#z(`X#{(`X'z(`X(a(`X(r(`X!k(`X!Y(`X'w(`Xv(`X!_(`X%i(`X!g(`X~P!6kO!].tO!k(kX~P!:tO!k.wO~O!Y.yO~OP$[OR#zO!Q#yO!S#{O!l#xO!p$[O(aVO[#mia#mij#mir#mi!]#mi#R#mi#o#mi#p#mi#q#mi#r#mi#s#mi#t#mi#u#mi#v#mi#x#mi#z#mi#{#mi'z#mi(r#mi(y#mi(z#mi'w#mi!Y#mi!k#miv#mi!_#mi%i#mi!g#mi~O#n#mi~P#3cO#n$OO~P#3cOP$[OR#zOr$aO!Q#yO!S#{O!l#xO!p$[O#n$OO#o$PO#p$PO#q$PO(aVO[#mia#mij#mi!]#mi#R#mi#s#mi#t#mi#u#mi#v#mi#x#mi#z#mi#{#mi'z#mi(r#mi(y#mi(z#mi'w#mi!Y#mi!k#miv#mi!_#mi%i#mi!g#mi~O#r#mi~P#6QO#r$QO~P#6QOP$[OR#zO[$cOj$ROr$aO!Q#yO!S#{O!l#xO!p$[O#R$RO#n$OO#o$PO#p$PO#q$PO#r$QO#s$RO#t$RO#u$bO(aVOa#mi!]#mi#x#mi#z#mi#{#mi'z#mi(r#mi(y#mi(z#mi'w#mi!Y#mi!k#miv#mi!_#mi%i#mi!g#mi~O#v#mi~P#8oOP$[OR#zO[$cOj$ROr$aO!Q#yO!S#{O!l#xO!p$[O#R$RO#n$OO#o$PO#p$PO#q$PO#r$QO#s$RO#t$RO#u$bO#v$SO(aVO(z#}Oa#mi!]#mi#z#mi#{#mi'z#mi(r#mi(y#mi'w#mi!Y#mi!k#miv#mi!_#mi%i#mi!g#mi~O#x$UO~P#;VO#x#mi~P#;VO#v$SO~P#8oOP$[OR#zO[$cOj$ROr$aO!Q#yO!S#{O!l#xO!p$[O#R$RO#n$OO#o$PO#p$PO#q$PO#r$QO#s$RO#t$RO#u$bO#v$SO#x$UO(aVO(y#|O(z#}Oa#mi!]#mi#{#mi'z#mi(r#mi'w#mi!Y#mi!k#miv#mi!_#mi%i#mi!g#mi~O#z#mi~P#={O#z$WO~P#={OP]XR]X[]Xj]Xr]X!Q]X!S]X!l]X!p]X#R]X#S]X#`]X#kfX#n]X#o]X#p]X#q]X#r]X#s]X#t]X#u]X#v]X#x]X#z]X#{]X$Q]X(a]X(r]X(y]X(z]X!]]X!^]X~O$O]X~P#@jOP$[OR#zO[]O!Q*OO'y*PO(y$}O(z%POP#miR#mi!S#mi!l#mi!p#mi#n#mi#o#mi#p#mi#q#mi(a#mi~P#EyO!]/POg(pX~P!1WOg/RO~Oa$Pi!]$Pi'z$Pi'w$Pi!Y$Pi!k$Piv$Pi!_$Pi%i$Pi!g$Pi~P!:tO$]/SO$_/SO~O$]/TO$_/TO~O!g)hO#`/UO!_$cX$Z$cX$]$cX$_$cX$f$cX~O![/VO~O!_)kO$Z/XO$])jO$_)jO$f/YO~O!]VO!l$xO#j^O!Q*OO'y*PO(y$}O(z%POP#miR#mi!S#mi!l#mi!p#mi#n#mi#o#mi#p#mi#q#mi(a#mi~P&,QO#S$dOP(`XR(`X[(`Xj(`Xn(`Xr(`X!Q(`X!S(`X!l(`X!p(`X#R(`X#n(`X#o(`X#p(`X#q(`X#r(`X#s(`X#t(`X#u(`X#v(`X#x(`X#z(`X#{(`X$O(`X'y(`X(a(`X(r(`X(y(`X(z(`X!](`X!^(`X~O$O$Pi!]$Pi!^$Pi~P#BwO$O!ri!^!ri~P$+oOg']a!]']a~P!1WO!^7nO~O!]'da!^'da~P#BwO!Y7oO~P#/sO!g#vO(r'pO!]'ea!k'ea~O!]/pO!k)Oi~O!]/pO!g#vO!k)Oi~Og$|q!]$|q#`$|q$O$|q~P!1WO!Y'ga!]'ga~P#/sO!g7vO~O!]/yO!Y)Pi~P#/sO!]/yO!Y)Pi~O!Y7yO~Oh%VOr8OO!l%eO(r'pO~Oj8QO!g#vO~Or8TO!g#vO(r'pO~O!Q*OO'y*PO(z%POn'ja(y'ja!]'ja#`'ja~Og'ja$O'ja~P&5RO!Q*OO'y*POn'la(y'la(z'la!]'la#`'la~Og'la$O'la~P&5tOg(_q!](_q~P!1WO#`8VOg(_q!](_q~P!1WO!Y8WO~Og%Oq!]%Oq#`%Oq$O%Oq~P!1WOa$oy!]$oy'z$oy'w$oy!Y$oy!k$oyv$oy!_$oy%i$oy!g$oy~P!:tO!g6rO~O!]5[O!_)Qa~O!_'`OP$TaR$Ta[$Taj$Tar$Ta!Q$Ta!S$Ta!]$Ta!l$Ta!p$Ta#R$Ta#n$Ta#o$Ta#p$Ta#q$Ta#r$Ta#s$Ta#t$Ta#u$Ta#v$Ta#x$Ta#z$Ta#{$Ta(a$Ta(r$Ta(y$Ta(z$Ta~O%i7WO~P&8fO%^8[Oa%[i!_%[i'z%[i!]%[i~Oa#cy!]#cy'z#cy'w#cy!Y#cy!k#cyv#cy!_#cy%i#cy!g#cy~P!:tO[8^O~Ob8`O(T+qO(VTO(YUO~O!]1TO!^)Xi~O`8dO~O(e(|O!]'pX!^'pX~O!]5uO!^)Ua~O!^8nO~P%;eO(o!sO~P$&YO#[8oO~O!_1oO~O!_1oO%i8qO~On8tO!_1oO%i8qO~O[8yO!]'sa!^'sa~O!]1zO!^)Vi~O!k8}O~O!k9OO~O!k9RO~O!k9RO~P%[Oa9TO~O!g9UO~O!k9VO~O!](wi!^(wi~P#BwOa%nO#`9_O'z%nO~O!](ty!k(tya(ty'z(ty~P!:tO!](jO!k(sy~O%i9bO~P&8fO!_'`O%i9bO~O#k$|qP$|qR$|q[$|qa$|qj$|qr$|q!S$|q!]$|q!l$|q!p$|q#R$|q#n$|q#o$|q#p$|q#q$|q#r$|q#s$|q#t$|q#u$|q#v$|q#x$|q#z$|q#{$|q'z$|q(a$|q(r$|q!k$|q!Y$|q'w$|q#`$|qv$|q!_$|q%i$|q!g$|q~P#/sO#k'jaP'jaR'ja['jaa'jaj'jar'ja!S'ja!l'ja!p'ja#R'ja#n'ja#o'ja#p'ja#q'ja#r'ja#s'ja#t'ja#u'ja#v'ja#x'ja#z'ja#{'ja'z'ja(a'ja(r'ja!k'ja!Y'ja'w'jav'ja!_'ja%i'ja!g'ja~P&5RO#k'laP'laR'la['laa'laj'lar'la!S'la!l'la!p'la#R'la#n'la#o'la#p'la#q'la#r'la#s'la#t'la#u'la#v'la#x'la#z'la#{'la'z'la(a'la(r'la!k'la!Y'la'w'lav'la!_'la%i'la!g'la~P&5tO#k%OqP%OqR%Oq[%Oqa%Oqj%Oqr%Oq!S%Oq!]%Oq!l%Oq!p%Oq#R%Oq#n%Oq#o%Oq#p%Oq#q%Oq#r%Oq#s%Oq#t%Oq#u%Oq#v%Oq#x%Oq#z%Oq#{%Oq'z%Oq(a%Oq(r%Oq!k%Oq!Y%Oq'w%Oq#`%Oqv%Oq!_%Oq%i%Oq!g%Oq~P#/sO!]'Yi!k'Yi~P!:tO$O#cq!]#cq!^#cq~P#BwO(y$}OP%aaR%aa[%aaj%aar%aa!S%aa!l%aa!p%aa#R%aa#n%aa#o%aa#p%aa#q%aa#r%aa#s%aa#t%aa#u%aa#v%aa#x%aa#z%aa#{%aa$O%aa(a%aa(r%aa!]%aa!^%aa~On%aa!Q%aa'y%aa(z%aa~P&IyO(z%POP%caR%ca[%caj%car%ca!S%ca!l%ca!p%ca#R%ca#n%ca#o%ca#p%ca#q%ca#r%ca#s%ca#t%ca#u%ca#v%ca#x%ca#z%ca#{%ca$O%ca(a%ca(r%ca!]%ca!^%ca~On%ca!Q%ca'y%ca(y%ca~P&LQOn>^O!Q*OO'y*PO(z%PO~P&IyOn>^O!Q*OO'y*PO(y$}O~P&LQOR0kO!Q0kO!S0lO#S$dOP}a[}aj}an}ar}a!l}a!p}a#R}a#n}a#o}a#p}a#q}a#r}a#s}a#t}a#u}a#v}a#x}a#z}a#{}a$O}a'y}a(a}a(r}a(y}a(z}a!]}a!^}a~O!Q*OO'y*POP$saR$sa[$saj$san$sar$sa!S$sa!l$sa!p$sa#R$sa#n$sa#o$sa#p$sa#q$sa#r$sa#s$sa#t$sa#u$sa#v$sa#x$sa#z$sa#{$sa$O$sa(a$sa(r$sa(y$sa(z$sa!]$sa!^$sa~O!Q*OO'y*POP$uaR$ua[$uaj$uan$uar$ua!S$ua!l$ua!p$ua#R$ua#n$ua#o$ua#p$ua#q$ua#r$ua#s$ua#t$ua#u$ua#v$ua#x$ua#z$ua#{$ua$O$ua(a$ua(r$ua(y$ua(z$ua!]$ua!^$ua~On>^O!Q*OO'y*PO(y$}O(z%PO~OP%TaR%Ta[%Taj%Tar%Ta!S%Ta!l%Ta!p%Ta#R%Ta#n%Ta#o%Ta#p%Ta#q%Ta#r%Ta#s%Ta#t%Ta#u%Ta#v%Ta#x%Ta#z%Ta#{%Ta$O%Ta(a%Ta(r%Ta!]%Ta!^%Ta~P''VO$O$mq!]$mq!^$mq~P#BwO$O$oq!]$oq!^$oq~P#BwO!^9oO~O$O9pO~P!1WO!g#vO!]'ei!k'ei~O!g#vO(r'pO!]'ei!k'ei~O!]/pO!k)Oq~O!Y'gi!]'gi~P#/sO!]/yO!Y)Pq~Or9wO!g#vO(r'pO~O[9yO!Y9xO~P#/sO!Y9xO~Oj:PO!g#vO~Og(_y!](_y~P!1WO!]'na!_'na~P#/sOa%[q!_%[q'z%[q!]%[q~P#/sO[:UO~O!]1TO!^)Xq~O`:YO~O#`:ZO!]'pa!^'pa~O!]5uO!^)Ui~P#BwO!S:]O~O!_1oO%i:`O~O(VTO(YUO(e:eO~O!]1zO!^)Vq~O!k:hO~O!k:iO~O!k:jO~O!k:jO~P%[O#`:mO!]#hy!^#hy~O!]#hy!^#hy~P#BwO%i:rO~P&8fO!_'`O%i:rO~O$O#|y!]#|y!^#|y~P#BwOP$|iR$|i[$|ij$|ir$|i!S$|i!l$|i!p$|i#R$|i#n$|i#o$|i#p$|i#q$|i#r$|i#s$|i#t$|i#u$|i#v$|i#x$|i#z$|i#{$|i$O$|i(a$|i(r$|i!]$|i!^$|i~P''VO!Q*OO'y*PO(z%POP'iaR'ia['iaj'ian'iar'ia!S'ia!l'ia!p'ia#R'ia#n'ia#o'ia#p'ia#q'ia#r'ia#s'ia#t'ia#u'ia#v'ia#x'ia#z'ia#{'ia$O'ia(a'ia(r'ia(y'ia!]'ia!^'ia~O!Q*OO'y*POP'kaR'ka['kaj'kan'kar'ka!S'ka!l'ka!p'ka#R'ka#n'ka#o'ka#p'ka#q'ka#r'ka#s'ka#t'ka#u'ka#v'ka#x'ka#z'ka#{'ka$O'ka(a'ka(r'ka(y'ka(z'ka!]'ka!^'ka~O(y$}OP%aiR%ai[%aij%ain%air%ai!Q%ai!S%ai!l%ai!p%ai#R%ai#n%ai#o%ai#p%ai#q%ai#r%ai#s%ai#t%ai#u%ai#v%ai#x%ai#z%ai#{%ai$O%ai'y%ai(a%ai(r%ai(z%ai!]%ai!^%ai~O(z%POP%ciR%ci[%cij%cin%cir%ci!Q%ci!S%ci!l%ci!p%ci#R%ci#n%ci#o%ci#p%ci#q%ci#r%ci#s%ci#t%ci#u%ci#v%ci#x%ci#z%ci#{%ci$O%ci'y%ci(a%ci(r%ci(y%ci!]%ci!^%ci~O$O$oy!]$oy!^$oy~P#BwO$O#cy!]#cy!^#cy~P#BwO!g#vO!]'eq!k'eq~O!]/pO!k)Oy~O!Y'gq!]'gq~P#/sOr:|O!g#vO(r'pO~O[;QO!Y;PO~P#/sO!Y;PO~Og(_!R!](_!R~P!1WOa%[y!_%[y'z%[y!]%[y~P#/sO!]1TO!^)Xy~O!]5uO!^)Uq~O(T;XO~O!_1oO%i;[O~O!k;_O~O%i;dO~P&8fOP$|qR$|q[$|qj$|qr$|q!S$|q!l$|q!p$|q#R$|q#n$|q#o$|q#p$|q#q$|q#r$|q#s$|q#t$|q#u$|q#v$|q#x$|q#z$|q#{$|q$O$|q(a$|q(r$|q!]$|q!^$|q~P''VO!Q*OO'y*PO(z%POP'jaR'ja['jaj'jan'jar'ja!S'ja!l'ja!p'ja#R'ja#n'ja#o'ja#p'ja#q'ja#r'ja#s'ja#t'ja#u'ja#v'ja#x'ja#z'ja#{'ja$O'ja(a'ja(r'ja(y'ja!]'ja!^'ja~O!Q*OO'y*POP'laR'la['laj'lan'lar'la!S'la!l'la!p'la#R'la#n'la#o'la#p'la#q'la#r'la#s'la#t'la#u'la#v'la#x'la#z'la#{'la$O'la(a'la(r'la(y'la(z'la!]'la!^'la~OP%OqR%Oq[%Oqj%Oqr%Oq!S%Oq!l%Oq!p%Oq#R%Oq#n%Oq#o%Oq#p%Oq#q%Oq#r%Oq#s%Oq#t%Oq#u%Oq#v%Oq#x%Oq#z%Oq#{%Oq$O%Oq(a%Oq(r%Oq!]%Oq!^%Oq~P''VOg%e!Z!]%e!Z#`%e!Z$O%e!Z~P!1WO!Y;hO~P#/sOr;iO!g#vO(r'pO~O[;kO!Y;hO~P#/sO!]'pq!^'pq~P#BwO!]#h!Z!^#h!Z~P#BwO#k%e!ZP%e!ZR%e!Z[%e!Za%e!Zj%e!Zr%e!Z!S%e!Z!]%e!Z!l%e!Z!p%e!Z#R%e!Z#n%e!Z#o%e!Z#p%e!Z#q%e!Z#r%e!Z#s%e!Z#t%e!Z#u%e!Z#v%e!Z#x%e!Z#z%e!Z#{%e!Z'z%e!Z(a%e!Z(r%e!Z!k%e!Z!Y%e!Z'w%e!Z#`%e!Zv%e!Z!_%e!Z%i%e!Z!g%e!Z~P#/sOr;tO!g#vO(r'pO~O!Y;uO~P#/sOr;|O!g#vO(r'pO~O!Y;}O~P#/sOP%e!ZR%e!Z[%e!Zj%e!Zr%e!Z!S%e!Z!l%e!Z!p%e!Z#R%e!Z#n%e!Z#o%e!Z#p%e!Z#q%e!Z#r%e!Z#s%e!Z#t%e!Z#u%e!Z#v%e!Z#x%e!Z#z%e!Z#{%e!Z$O%e!Z(a%e!Z(r%e!Z!]%e!Z!^%e!Z~P''VOrROe!iOpkOrPO(T)]O(VTO(YUO(aVO(o[O~O!]WO!l$xO#jgPPP!>oI[PPPPPPPPP!BOP!C]PPI[!DnPI[PI[I[I[I[I[PI[!FQP!I[P!LbP!Lf!Lp!Lt!LtP!IXP!Lx!LxP#!OP#!SI[PI[#!Y#%_CjA^PA^PA^A^P#&lA^A^#)OA^#+vA^#.SA^A^#.r#1W#1W#1]#1f#1W#1qPP#1WPA^#2ZA^#6YA^A^6mPPP#:_PPP#:x#:xP#:xP#;`#:xPP#;fP#;]P#;]#;y#;]#P#>V#>]#>k#>q#>{#?R#?]#?c#?s#?y#@k#@}#AT#AZ#Ai#BO#Cs#DR#DY#Et#FS#Gt#HS#HY#H`#Hf#Hp#Hv#H|#IW#Ij#IpPPPPPPPPPPP#IvPPPPPPP#Jk#Mx$ b$ i$ qPPP$']P$'f$*_$0x$0{$1O$1}$2Q$2X$2aP$2g$2jP$3W$3[$4S$5b$5g$5}PP$6S$6Y$6^$6a$6e$6i$7e$7|$8e$8i$8l$8o$8y$8|$9Q$9UR!|RoqOXst!Z#d%m&r&t&u&w,s,x2[2_Y!vQ'`-e1o5{Q%tvQ%|yQ&T|Q&j!VS'W!e-]Q'f!iS'l!r!yU*k$|*Z*oQ+o%}S+|&V&WQ,d&dQ-c'_Q-m'gQ-u'mQ0[*qQ1b,OQ1y,eR<{SU+P%]S!S!nQ!r!v!y!z$|'W'_'`'l'm'n*k*o*q*r-]-c-e-u0[0_1o5{5}%[$ti#v$b$c$d$x${%O%Q%^%_%c)y*R*T*V*Y*a*g*w*x+f+i,S,V.f/P/d/m/x/y/{0`0b0i0j0o1f1i1q3c4^4_4j4o5Q5[5_6S7W7v8Q8V8[8q9b9p9y:P:`:r;Q;[;d;kP>X>Y>]>^Q&X|Q'U!eS'[%i-`Q+t&PQ,P&WQ,f&gQ0n+SQ1Y+uQ1_+{Q2Q,jQ2R,kQ5f1TQ5o1aQ6[1zQ6_1|Q6`2PQ8`5gQ8c5lQ8|6bQ:X8dQ:f8yQ;V:YR<}*ZrnOXst!V!Z#d%m&i&r&t&u&w,s,x2[2_R,h&k&z^OPXYstuvwz!Z!`!g!j!o#S#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$Z$_$a$e$n%m%t&R&k&n&o&r&t&u&w&{'T'b'r(V(](d(x(z)O)s)}*i+X+]+g,p,s,x-U-X-i-q.P.V.g.t.{/V/n0]0l0r1S1r2S2T2V2X2[2_2a2p3Q3W3d3l4T4z5w6T6e6f6i6s6|7[8t9T9_:Z:mR>S[#]WZ#W#Z'X(T!b%jm#h#i#l$x%e%h(^(h(i(j*Y*^*b+Z+[+^,o-V.T.Z.[.]._/m/p2d3[3]4a6r7TQ%wxQ%{yW&Q|&V&W,OQ&_!TQ'c!hQ'e!iQ(q#sS+n%|%}Q+r&PQ,_&bQ,c&dS-l'f'gQ.i(rQ1R+oQ1X+uQ1Z+vQ1^+zQ1t,`S1x,d,eQ2|-mQ5e1TQ5i1WQ5n1`Q6Z1yQ8_5gQ8b5kQ8f5pQ:T8^R;T:U!U$zi$d%O%Q%^%_%c*R*T*a*w*x/P/x0`0b0i0j0o4_5Q8V9p>P>X>Y!^%yy!i!u%{%|%}'V'e'f'g'k'u*j+n+o-Y-l-m-t0R0U1R2u2|3T4r4s4v7}9{Q+h%wQ,T&[Q,W&]Q,b&dQ.h(qQ1s,_U1w,c,d,eQ3e.iQ6U1tS6Y1x1yQ8x6Z#f>T#v$b$c$x${)y*V*Y*g+f+i,S,V.f/d/m/y/{1f1i1q3c4^4j4o5[5_6S7W7v8Q8[8q9b9y:P:`:r;Q;[;d;k]>^o>UPS&[!Q&iQ&]!RQ&^!SU*}%[%d=sR,R&Y%]%Si#v$b$c$d$x${%O%Q%^%_%c)y*R*T*V*Y*a*g*w*x+f+i,S,V.f/P/d/m/x/y/{0`0b0i0j0o1f1i1q3c4^4_4j4o5Q5[5_6S7W7v8Q8V8[8q9b9p9y:P:`:r;Q;[;d;kP>X>Y>]>^T)z$u){V+P%]S$i$^c#Y#e%q%s%u(S(Y(t(y)R)S)T)U)V)W)X)Y)Z)[)^)`)b)g)q+d+x-Z-x-}.S.U.s.v.z.|.}/O/b0p2k2n3O3V3k3p3q3r3s3t3u3v3w3x3y3z3{3|4P4Q4X5X5c6u6{7Q7a7b7k7l8k9X9]9g9m9n:o;W;`SQ'Y!eR2q-]!W!nQ!e!r!v!y!z$|'W'_'`'l'm'n*Z*k*o*q*r-]-c-e-u0[0_1o5{5}R1l,ZnqOXst!Z#d%m&r&t&u&w,s,x2[2_Q&y!^Q'v!xS(s#u<^Q+l%zQ,]&_Q,^&aQ-j'dQ-w'oS.r(x=PS0q+X=ZQ1P+mQ1n,[Q2c,zQ2e,{Q2m-WQ2z-kQ2}-oS5Y0r=eQ5a1QS5d1S=fQ6t2oQ6x2{Q6}3SQ8]5bQ9Y6vQ9Z6yQ9^7OR:l9V$d$]c#Y#e%s%u(S(Y(t(y)R)S)T)U)V)W)X)Y)Z)[)^)`)b)g)q+d+x-Z-x-}.S.U.s.v.z.}/O/b0p2k2n3O3V3k3p3q3r3s3t3u3v3w3x3y3z3{3|4P4Q4X5X5c6u6{7Q7a7b7k7l8k9X9]9g9m9n:o;W;`SS#q]SU$fd)_,mS(p#p'iU*v%R(w4OU0m+O.n7gQ5^0xQ7V3`Q9d7YR:s9em!tQ!r!v!y!z'`'l'm'n-e-u1o5{5}Q't!uS(f#g2US-s'k'wQ/s*]Q0R*jQ3U-vQ4f/tQ4r0TQ4s0UQ4x0^Q7r4`S7}4t4vS8R4y4{Q9r7sQ9v7yQ9{8OQ:Q8TS:{9w9xS;g:|;PS;s;h;iS;{;t;uSSR=o>R%^bOPWXYZstuvw!Z!`!g!o#S#W#Z#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$_$a$e%m%t&R&k&n&o&r&t&u&w&{'T'b'r(T(V(](d(x(z)O)}*i+X+]+g,p,s,x-i-q.P.V.g.t.{/n0]0l0r1S1r2S2T2V2X2[2_2a3Q3W3d3l4z6T6e6f6i6|7[8t9T9_Q%fj!^%xy!i!u%{%|%}'V'e'f'g'k'u*j+n+o-Y-l-m-t0R0U1R2u2|3T4r4s4v7}9{S&Oz!jQ+k%yQ,a&dW1v,b,c,d,eU6X1w1x1yS8w6Y6ZQ:d8x!r=j$Z$n'X)s-U-X/V2p4T5w6s:Z:mSQ=t>QR=u>R%QeOPXYstuvw!Z!`!g!o#S#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$_$a$e%m%t&R&k&n&r&t&u&w&{'T'b'r(V(](d(x(z)O)}*i+X+]+g,p,s,x-i-q.P.V.g.t.{/n0]0l0r1S1r2S2T2V2X2[2_2a3Q3W3d3l4z6T6e6f6i6|7[8t9T9_Y#bWZ#W#Z(T!b%jm#h#i#l$x%e%h(^(h(i(j*Y*^*b+Z+[+^,o-V.T.Z.[.]._/m/p2d3[3]4a6r7TQ,n&o!p=k$Z$n)s-U-X/V2p4T5w6s:Z:mSR=n'XU']!e%i*ZR2s-`%SdOPWXYZstuvw!Z!`!g!o#S#W#Z#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$_$a$e%m%t&R&k&n&r&t&u&w&{'T'b'r(T(V(](d(x(z)O)}*i+X+],p,s,x-i-q.P.V.t.{/n0]0l0r1S1r2S2T2V2X2[2_2a3Q3W3l4z6T6e6f6i6|8t9T9_!r)_$Z$n'X)s-U-X/V2p4T5w6s:Z:mSQ,m&oQ0x+gQ3`.gQ7Y3dR9e7[!b$Tc#Y%q(S(Y(t(y)Z)[)`)g+x-x-}.S.U.s.v/b0p3O3V3k3{5X5c6{7Q7a9]:oS)^)q-Z.|2k2n3p4P4X6u7b7k7l8k9X9g9m9n;W;`=vQ>X>ZR>Y>['QkOPWXYZstuvw!Z!`!g!o#S#W#Z#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$Z$_$a$e$n%m%t&R&k&n&o&r&t&u&w&{'T'X'b'r(T(V(](d(x(z)O)s)}*i+X+]+g,p,s,x-U-X-i-q.P.V.g.t.{/V/n0]0l0r1S1r2S2T2V2X2[2_2a2p3Q3W3d3l4T4z5w6T6e6f6i6s6|7[8t9T9_:Z:mSS$oh$pR4U/U'XgOPWXYZhstuvw!Z!`!g!o#S#W#Z#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$Z$_$a$e$n$p%m%t&R&k&n&o&r&t&u&w&{'T'X'b'r(T(V(](d(x(z)O)s)}*i+X+]+g,p,s,x-U-X-i-q.P.V.g.t.{/U/V/n0]0l0r1S1r2S2T2V2X2[2_2a2p3Q3W3d3l4T4z5w6T6e6f6i6s6|7[8t9T9_:Z:mST$kf$qQ$ifS)j$l)nR)v$qT$jf$qT)l$l)n'XhOPWXYZhstuvw!Z!`!g!o#S#W#Z#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$Z$_$a$e$n$p%m%t&R&k&n&o&r&t&u&w&{'T'X'b'r(T(V(](d(x(z)O)s)}*i+X+]+g,p,s,x-U-X-i-q.P.V.g.t.{/U/V/n0]0l0r1S1r2S2T2V2X2[2_2a2p3Q3W3d3l4T4z5w6T6e6f6i6s6|7[8t9T9_:Z:mST$oh$pQ$rhR)u$p%^jOPWXYZstuvw!Z!`!g!o#S#W#Z#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$_$a$e%m%t&R&k&n&o&r&t&u&w&{'T'b'r(T(V(](d(x(z)O)}*i+X+]+g,p,s,x-i-q.P.V.g.t.{/n0]0l0r1S1r2S2T2V2X2[2_2a3Q3W3d3l4z6T6e6f6i6|7[8t9T9_!s>Q$Z$n'X)s-U-X/V2p4T5w6s:Z:mS#glOPXZst!Z!`!o#S#d#o#{$n%m&k&n&o&r&t&u&w&{'T'b)O)s*i+]+g,p,s,x-i.g/V/n0]0l1r2S2T2V2X2[2_2a3d4T4z6T6e6f6i7[8t9T!U%Ri$d%O%Q%^%_%c*R*T*a*w*x/P/x0`0b0i0j0o4_5Q8V9p>P>X>Y#f(w#v$b$c$x${)y*V*Y*g+f+i,S,V.f/d/m/y/{1f1i1q3c4^4j4o5[5_6S7W7v8Q8[8q9b9y:P:`:r;Q;[;d;k]>^Q+T%aQ/c*Oo4OP>X>YQ*c$zU*l$|*Z*oQ+U%bQ0W*m#f=q#v$b$c$x${)y*V*Y*g+f+i,S,V.f/d/m/y/{1f1i1q3c4^4j4o5[5_6S7W7v8Q8[8q9b9y:P:`:r;Q;[;d;k]>^n=rTQ=x>UQ=y>VR=z>W!U%Ri$d%O%Q%^%_%c*R*T*a*w*x/P/x0`0b0i0j0o4_5Q8V9p>P>X>Y#f(w#v$b$c$x${)y*V*Y*g+f+i,S,V.f/d/m/y/{1f1i1q3c4^4j4o5[5_6S7W7v8Q8[8q9b9y:P:`:r;Q;[;d;k]>^o4OP>X>Y>]>^Q,U&]Q1h,WQ5s1gR8h5tV*n$|*Z*oU*n$|*Z*oT5z1o5{S0P*i/nQ4w0]T8S4z:]Q+j%xQ0V*lQ1O+kQ1u,aQ6W1vQ8v6XQ:c8wR;^:d!U%Oi$d%O%Q%^%_%c*R*T*a*w*x/P/x0`0b0i0j0o4_5Q8V9p>P>X>Yx*R$v)e*S*u+V/v0d0e4R4g5R5S5W7p8U:R:x=p=}>OS0`*t0a#f]>^nZ>[`=T3}7c7f7j9h:t:w;yS=_.l3iT=`7e9k!U%Qi$d%O%Q%^%_%c*R*T*a*w*x/P/x0`0b0i0j0o4_5Q8V9p>P>X>Y|*T$v)e*U*t+V/g/v0d0e4R4g4|5R5S5W7p8U:R:x=p=}>OS0b*u0c#f]>^nZ>[d=V3}7d7e7j9h9i:t:u:w;yS=a.m3jT=b7f9lrnOXst!V!Z#d%m&i&r&t&u&w,s,x2[2_Q&f!UR,p&ornOXst!V!Z#d%m&i&r&t&u&w,s,x2[2_R&f!UQ,Y&^R1d,RsnOXst!V!Z#d%m&i&r&t&u&w,s,x2[2_Q1p,_S6R1s1tU8p6P6Q6US:_8r8sS;Y:^:aQ;m;ZR;w;nQ&m!VR,i&iR6_1|R:f8yW&Q|&V&W,OR1Z+vQ&r!WR,s&sR,y&xT2],x2_R,}&yQ,|&yR2f,}Q'y!{R-y'ySsOtQ#dXT%ps#dQ#OTR'{#OQ#RUR'}#RQ){$uR/`){Q#UVR(Q#UQ#XWU(W#X(X.QQ(X#YR.Q(YQ-^'YR2r-^Q.u(yS3m.u3nR3n.vQ-e'`R2v-eY!rQ'`-e1o5{R'j!rQ/Q)eR4S/QU#_W%h*YU(_#_(`.RQ(`#`R.R(ZQ-a']R2t-at`OXst!V!Z#d%m&i&k&r&t&u&w,s,x2[2_S#hZ%eU#r`#h.[R.[(jQ(k#jQ.X(gW.a(k.X3X7RQ3X.YR7R3YQ)n$lR/W)nQ$phR)t$pQ$`cU)a$`-|O>Z>[Q/z*eU4k/z4m7xQ4m/|R7x4lS*o$|*ZR0Y*ox*S$v)e*t*u+V/v0d0e4R4g5R5S5W7p8U:R:x=p=}>O!d.j(u)c*[*e.l.m.q/_/k/|0v1e3h4[4h4l5r7]7`7w7z8X8Z9t9|:S:};R;e;j;v>Z>[U/h*S.j7ca7c3}7e7f7j9h:t:w;yQ0a*tQ3i.lU4}0a3i9kR9k7e|*U$v)e*t*u+V/g/v0d0e4R4g4|5R5S5W7p8U:R:x=p=}>O!h.k(u)c*[*e.l.m.q/_/k/|0v1e3f3h4[4h4l5r7]7^7`7w7z8X8Z9t9|:S:};R;e;j;v>Z>[U/j*U.k7de7d3}7e7f7j9h9i:t:u:w;yQ0c*uQ3j.mU5P0c3j9lR9l7fQ*z%UR0g*zQ5]0vR8Y5]Q+_%kR0u+_Q5v1jS8j5v:[R:[8kQ,[&_R1m,[Q5{1oR8m5{Q1{,fS6]1{8zR8z6_Q1U+rW5h1U5j8a:VQ5j1XQ8a5iR:V8bQ+w&QR1[+wQ2_,xR6m2_YrOXst#dQ&v!ZQ+a%mQ,r&rQ,t&tQ,u&uQ,w&wQ2Y,sS2],x2_R6l2[Q%opQ&z!_Q&}!aQ'P!bQ'R!cQ'q!uQ+`%lQ+l%zQ,Q&XQ,h&mQ-P&|W-p'k's't'wQ-w'oQ0X*nQ1P+mQ1c,PS2O,i,lQ2g-OQ2h-RQ2i-SQ2}-oW3P-r-s-v-xQ5a1QQ5m1_Q5q1eQ6V1uQ6a2QQ6k2ZU6z3O3R3UQ6}3SQ8]5bQ8e5oQ8g5rQ8l5zQ8u6WQ8{6`S9[6{7PQ9^7OQ:W8cQ:b8vQ:g8|Q:n9]Q;U:XQ;]:cQ;a:oQ;l;VR;o;^Q%zyQ'd!iQ'o!uU+m%{%|%}Q-W'VU-k'e'f'gS-o'k'uQ0Q*jS1Q+n+oQ2o-YS2{-l-mQ3S-tS4p0R0UQ5b1RQ6v2uQ6y2|Q7O3TU7{4r4s4vQ9z7}R;O9{S$wi>PR*{%VU%Ui%V>PR0f*yQ$viS(u#v+iS)c$b$cQ)e$dQ*[$xS*e${*YQ*t%OQ*u%QQ+Q%^Q+R%_Q+V%cQ.lPQ=}>XQ>O>YQ>Z>]R>[>^Q+O%]Q.nSR#[WR'Z!el!tQ!r!v!y!z'`'l'm'n-e-u1o5{5}S'V!e-]U*j$|*Z*oS-Y'W'_S0U*k*qQ0^*rQ2u-cQ4v0[R4{0_R({#xQ!fQT-d'`-e]!qQ!r'`-e1o5{Q#p]R'i < TypeParamList in out const TypeDefinition extends ThisType this LiteralType ArithOp Number BooleanLiteral TemplateType InterpolationEnd Interpolation InterpolationStart NullType null VoidType void TypeofType typeof MemberExpression . PropertyName [ TemplateString Escape Interpolation super RegExp ] ArrayExpression Spread , } { ObjectExpression Property async get set PropertyDefinition Block : NewTarget new NewExpression ) ( ArgList UnaryExpression delete LogicOp BitOp YieldExpression yield AwaitExpression await ParenthesizedExpression ClassExpression class ClassBody MethodDeclaration Decorator @ MemberExpression PrivatePropertyName CallExpression TypeArgList CompareOp < declare Privacy static abstract override PrivatePropertyDefinition PropertyDeclaration readonly accessor Optional TypeAnnotation Equals StaticBlock FunctionExpression ArrowFunction ParamList ParamList ArrayPattern ObjectPattern PatternProperty Privacy readonly Arrow MemberExpression BinaryExpression ArithOp ArithOp ArithOp ArithOp BitOp CompareOp instanceof satisfies CompareOp BitOp BitOp BitOp LogicOp LogicOp ConditionalExpression LogicOp LogicOp AssignmentExpression UpdateOp PostfixExpression CallExpression InstantiationExpression TaggedTemplateExpression DynamicImport import ImportMeta JSXElement JSXSelfCloseEndTag JSXSelfClosingTag JSXIdentifier JSXBuiltin JSXIdentifier JSXNamespacedName JSXMemberExpression JSXSpreadAttribute JSXAttribute JSXAttributeValue JSXEscape JSXEndTag JSXOpenTag JSXFragmentTag JSXText JSXEscape JSXStartCloseTag JSXCloseTag PrefixCast < ArrowFunction TypeParamList SequenceExpression InstantiationExpression KeyofType keyof UniqueType unique ImportType InferredType infer TypeName ParenthesizedType FunctionSignature ParamList NewSignature IndexedType TupleType Label ArrayType ReadonlyType ObjectType MethodType PropertyType IndexSignature PropertyDefinition CallSignature TypePredicate asserts is NewSignature new UnionType LogicOp IntersectionType LogicOp ConditionalType ParameterizedType ClassDeclaration abstract implements type VariableDeclaration let var using TypeAliasDeclaration InterfaceDeclaration interface EnumDeclaration enum EnumBody NamespaceDeclaration namespace module AmbientDeclaration declare GlobalDeclaration global ClassDeclaration ClassBody AmbientFunctionDeclaration ExportGroup VariableName VariableName ImportDeclaration defer ImportGroup ForStatement for ForSpec ForInSpec ForOfSpec of WhileStatement while WithStatement with DoStatement do IfStatement if else SwitchStatement switch SwitchBody CaseLabel case DefaultLabel TryStatement try CatchClause catch FinallyClause finally ReturnStatement return ThrowStatement throw BreakStatement break ContinueStatement continue DebuggerStatement debugger LabeledStatement ExpressionStatement SingleExpression SingleClassItem",maxTerm:380,context:Ej,nodeProps:[["isolate",-8,5,6,14,37,39,51,53,55,""],["group",-26,9,17,19,68,207,211,215,216,218,221,224,234,237,243,245,247,249,252,258,264,266,268,270,272,274,275,"Statement",-34,13,14,32,35,36,42,51,54,55,57,62,70,72,76,80,82,84,85,110,111,120,121,136,139,141,142,143,144,145,147,148,167,169,171,"Expression",-23,31,33,37,41,43,45,173,175,177,178,180,181,182,184,185,186,188,189,190,201,203,205,206,"Type",-3,88,103,109,"ClassItem"],["openedBy",23,"<",38,"InterpolationStart",56,"[",60,"{",73,"(",160,"JSXStartCloseTag"],["closedBy",-2,24,168,">",40,"InterpolationEnd",50,"]",61,"}",74,")",165,"JSXEndTag"]],propSources:[Ij],skippedNodes:[0,5,6,278],repeatNodeCount:37,tokenData:"$Fq07[R!bOX%ZXY+gYZ-yZ[+g[]%Z]^.c^p%Zpq+gqr/mrs3cst:_tuEruvJSvwLkwx! Yxy!'iyz!(sz{!)}{|!,q|}!.O}!O!,q!O!P!/Y!P!Q!9j!Q!R#:O!R![#<_![!]#I_!]!^#Jk!^!_#Ku!_!`$![!`!a$$v!a!b$*T!b!c$,r!c!}Er!}#O$-|#O#P$/W#P#Q$4o#Q#R$5y#R#SEr#S#T$7W#T#o$8b#o#p$x#r#s$@U#s$f%Z$f$g+g$g#BYEr#BY#BZ$A`#BZ$ISEr$IS$I_$A`$I_$I|Er$I|$I}$Dk$I}$JO$Dk$JO$JTEr$JT$JU$A`$JU$KVEr$KV$KW$A`$KW&FUEr&FU&FV$A`&FV;'SEr;'S;=`I|<%l?HTEr?HT?HU$A`?HUOEr(n%d_$i&j(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z&j&hT$i&jO!^&c!_#o&c#p;'S&c;'S;=`&w<%lO&c&j&zP;=`<%l&c'|'U]$i&j(Z!bOY&}YZ&cZw&}wx&cx!^&}!^!_'}!_#O&}#O#P&c#P#o&}#o#p'}#p;'S&};'S;=`(l<%lO&}!b(SU(Z!bOY'}Zw'}x#O'}#P;'S'};'S;=`(f<%lO'}!b(iP;=`<%l'}'|(oP;=`<%l&}'[(y]$i&j(WpOY(rYZ&cZr(rrs&cs!^(r!^!_)r!_#O(r#O#P&c#P#o(r#o#p)r#p;'S(r;'S;=`*a<%lO(rp)wU(WpOY)rZr)rs#O)r#P;'S)r;'S;=`*Z<%lO)rp*^P;=`<%l)r'[*dP;=`<%l(r#S*nX(Wp(Z!bOY*gZr*grs'}sw*gwx)rx#O*g#P;'S*g;'S;=`+Z<%lO*g#S+^P;=`<%l*g(n+dP;=`<%l%Z07[+rq$i&j(Wp(Z!b'|0/lOX%ZXY+gYZ&cZ[+g[p%Zpq+gqr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p$f%Z$f$g+g$g#BY%Z#BY#BZ+g#BZ$IS%Z$IS$I_+g$I_$JT%Z$JT$JU+g$JU$KV%Z$KV$KW+g$KW&FU%Z&FU&FV+g&FV;'S%Z;'S;=`+a<%l?HT%Z?HT?HU+g?HUO%Z07[.ST(X#S$i&j'}0/lO!^&c!_#o&c#p;'S&c;'S;=`&w<%lO&c07[.n_$i&j(Wp(Z!b'}0/lOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z)3p/x`$i&j!p),Q(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_!`0z!`#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z(KW1V`#v(Ch$i&j(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_!`2X!`#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z(KW2d_#v(Ch$i&j(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'At3l_(V':f$i&j(Z!bOY4kYZ5qZr4krs7nsw4kwx5qx!^4k!^!_8p!_#O4k#O#P5q#P#o4k#o#p8p#p;'S4k;'S;=`:X<%lO4k(^4r_$i&j(Z!bOY4kYZ5qZr4krs7nsw4kwx5qx!^4k!^!_8p!_#O4k#O#P5q#P#o4k#o#p8p#p;'S4k;'S;=`:X<%lO4k&z5vX$i&jOr5qrs6cs!^5q!^!_6y!_#o5q#o#p6y#p;'S5q;'S;=`7h<%lO5q&z6jT$d`$i&jO!^&c!_#o&c#p;'S&c;'S;=`&w<%lO&c`6|TOr6yrs7]s;'S6y;'S;=`7b<%lO6y`7bO$d``7eP;=`<%l6y&z7kP;=`<%l5q(^7w]$d`$i&j(Z!bOY&}YZ&cZw&}wx&cx!^&}!^!_'}!_#O&}#O#P&c#P#o&}#o#p'}#p;'S&};'S;=`(l<%lO&}!r8uZ(Z!bOY8pYZ6yZr8prs9hsw8pwx6yx#O8p#O#P6y#P;'S8p;'S;=`:R<%lO8p!r9oU$d`(Z!bOY'}Zw'}x#O'}#P;'S'};'S;=`(f<%lO'}!r:UP;=`<%l8p(^:[P;=`<%l4k%9[:hh$i&j(Wp(Z!bOY%ZYZ&cZq%Zqr`#P#o`x!^=^!^!_?q!_#O=^#O#P>`#P#o=^#o#p?q#p;'S=^;'S;=`@h<%lO=^&n>gXWS$i&jOY>`YZ&cZ!^>`!^!_?S!_#o>`#o#p?S#p;'S>`;'S;=`?k<%lO>`S?XSWSOY?SZ;'S?S;'S;=`?e<%lO?SS?hP;=`<%l?S&n?nP;=`<%l>`!f?xWWS(Z!bOY?qZw?qwx?Sx#O?q#O#P?S#P;'S?q;'S;=`@b<%lO?q!f@eP;=`<%l?q(Q@kP;=`<%l=^'`@w]WS$i&j(WpOY@nYZ&cZr@nrs>`s!^@n!^!_Ap!_#O@n#O#P>`#P#o@n#o#pAp#p;'S@n;'S;=`Bg<%lO@ntAwWWS(WpOYApZrAprs?Ss#OAp#O#P?S#P;'SAp;'S;=`Ba<%lOAptBdP;=`<%lAp'`BjP;=`<%l@n#WBvYWS(Wp(Z!bOYBmZrBmrs?qswBmwxApx#OBm#O#P?S#P;'SBm;'S;=`Cf<%lOBm#WCiP;=`<%lBm(rCoP;=`<%l^!Q^$i&j!X7`OY!=yYZ&cZ!P!=y!P!Q!>|!Q!^!=y!^!_!@c!_!}!=y!}#O!CW#O#P!Dy#P#o!=y#o#p!@c#p;'S!=y;'S;=`!Ek<%lO!=y|#X#Z&c#Z#[!>|#[#]&c#]#^!>|#^#a&c#a#b!>|#b#g&c#g#h!>|#h#i&c#i#j!>|#j#k!>|#k#m&c#m#n!>|#n#o&c#p;'S&c;'S;=`&w<%lO&c7`!@hX!X7`OY!@cZ!P!@c!P!Q!AT!Q!}!@c!}#O!Ar#O#P!Bq#P;'S!@c;'S;=`!CQ<%lO!@c7`!AYW!X7`#W#X!AT#Z#[!AT#]#^!AT#a#b!AT#g#h!AT#i#j!AT#j#k!AT#m#n!AT7`!AuVOY!ArZ#O!Ar#O#P!B[#P#Q!@c#Q;'S!Ar;'S;=`!Bk<%lO!Ar7`!B_SOY!ArZ;'S!Ar;'S;=`!Bk<%lO!Ar7`!BnP;=`<%l!Ar7`!BtSOY!@cZ;'S!@c;'S;=`!CQ<%lO!@c7`!CTP;=`<%l!@c^!Ezl$i&j(Z!b!X7`OY&}YZ&cZw&}wx&cx!^&}!^!_'}!_#O&}#O#P&c#P#W&}#W#X!Eq#X#Z&}#Z#[!Eq#[#]&}#]#^!Eq#^#a&}#a#b!Eq#b#g&}#g#h!Eq#h#i&}#i#j!Eq#j#k!Eq#k#m&}#m#n!Eq#n#o&}#o#p'}#p;'S&};'S;=`(l<%lO&}8r!GyZ(Z!b!X7`OY!GrZw!Grwx!@cx!P!Gr!P!Q!Hl!Q!}!Gr!}#O!JU#O#P!Bq#P;'S!Gr;'S;=`!J|<%lO!Gr8r!Hse(Z!b!X7`OY'}Zw'}x#O'}#P#W'}#W#X!Hl#X#Z'}#Z#[!Hl#[#]'}#]#^!Hl#^#a'}#a#b!Hl#b#g'}#g#h!Hl#h#i'}#i#j!Hl#j#k!Hl#k#m'}#m#n!Hl#n;'S'};'S;=`(f<%lO'}8r!JZX(Z!bOY!JUZw!JUwx!Arx#O!JU#O#P!B[#P#Q!Gr#Q;'S!JU;'S;=`!Jv<%lO!JU8r!JyP;=`<%l!JU8r!KPP;=`<%l!Gr>^!KZ^$i&j(Z!bOY!KSYZ&cZw!KSwx!CWx!^!KS!^!_!JU!_#O!KS#O#P!DR#P#Q!^!LYP;=`<%l!KS>^!L`P;=`<%l!_#c#d#Bq#d#l%Z#l#m#Es#m#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#_#c#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#>j_$i&j(Wp(Z!bs'9tOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#?rd$i&j(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q!R#AQ!R!S#AQ!S!^%Z!^!_*g!_#O%Z#O#P&c#P#R%Z#R#S#AQ#S#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#A]f$i&j(Wp(Z!bs'9tOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q!R#AQ!R!S#AQ!S!^%Z!^!_*g!_#O%Z#O#P&c#P#R%Z#R#S#AQ#S#b%Z#b#c#>_#c#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#Bzc$i&j(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q!Y#DV!Y!^%Z!^!_*g!_#O%Z#O#P&c#P#R%Z#R#S#DV#S#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#Dbe$i&j(Wp(Z!bs'9tOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q!Y#DV!Y!^%Z!^!_*g!_#O%Z#O#P&c#P#R%Z#R#S#DV#S#b%Z#b#c#>_#c#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#E|g$i&j(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q![#Ge![!^%Z!^!_*g!_!c%Z!c!i#Ge!i#O%Z#O#P&c#P#R%Z#R#S#Ge#S#T%Z#T#Z#Ge#Z#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#Gpi$i&j(Wp(Z!bs'9tOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q![#Ge![!^%Z!^!_*g!_!c%Z!c!i#Ge!i#O%Z#O#P&c#P#R%Z#R#S#Ge#S#T%Z#T#Z#Ge#Z#b%Z#b#c#>_#c#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z*)x#Il_!g$b$i&j$O)Lv(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z)[#Jv_al$i&j(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z04f#LS^h#)`#R-v$?V_!^(CdvBr$i&j(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z?O$@a_!q7`$i&j(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z07[$Aq|$i&j(Wp(Z!b'|0/l$]#t(T,2j(e$I[OX%ZXY+gYZ&cZ[+g[p%Zpq+gqr%Zrs&}st%ZtuEruw%Zwx(rx}%Z}!OGv!O!Q%Z!Q![Er![!^%Z!^!_*g!_!c%Z!c!}Er!}#O%Z#O#P&c#P#R%Z#R#SEr#S#T%Z#T#oEr#o#p*g#p$f%Z$f$g+g$g#BYEr#BY#BZ$A`#BZ$ISEr$IS$I_$A`$I_$JTEr$JT$JU$A`$JU$KVEr$KV$KW$A`$KW&FUEr&FU&FV$A`&FV;'SEr;'S;=`I|<%l?HTEr?HT?HU$A`?HUOEr07[$D|k$i&j(Wp(Z!b'}0/l$]#t(T,2j(e$I[OY%ZYZ&cZr%Zrs&}st%ZtuEruw%Zwx(rx}%Z}!OGv!O!Q%Z!Q![Er![!^%Z!^!_*g!_!c%Z!c!}Er!}#O%Z#O#P&c#P#R%Z#R#SEr#S#T%Z#T#oEr#o#p*g#p$g%Z$g;'SEr;'S;=`I|<%lOEr",tokenizers:[Dj,zj,jj,Zj,2,3,4,5,6,7,8,9,10,11,12,13,14,Lj,new _O("$S~RRtu[#O#Pg#S#T#|~_P#o#pb~gOx~~jVO#i!P#i#j!U#j#l!P#l#m!q#m;'S!P;'S;=`#v<%lO!P~!UO!U~~!XS!Q![!e!c!i!e#T#Z!e#o#p#Z~!hR!Q![!q!c!i!q#T#Z!q~!tR!Q![!}!c!i!}#T#Z!}~#QR!Q![!P!c!i!P#T#Z!P~#^R!Q![#g!c!i#g#T#Z#g~#jS!Q![#g!c!i#g#T#Z#g#q#r!P~#yP;=`<%l!P~$RO(c~~",141,340),new _O("j~RQYZXz{^~^O(Q~~aP!P!Qd~iO(R~~",25,323)],topRules:{Script:[0,7],SingleExpression:[1,276],SingleClassItem:[2,277]},dialects:{jsx:0,ts:15175},dynamicPrecedences:{80:1,82:1,94:1,169:1,199:1},specialized:[{term:327,get:n=>Nj[n]||-1},{term:343,get:n=>Bj[n]||-1},{term:95,get:n=>Xj[n]||-1}],tokenPrec:15201}),xC=[On("function ${name}(${params}) {\n ${}\n}",{label:"function",detail:"definition",type:"keyword"}),On("for (let ${index} = 0; ${index} < ${bound}; ${index}++) {\n ${}\n}",{label:"for",detail:"loop",type:"keyword"}),On("for (let ${name} of ${collection}) {\n ${}\n}",{label:"for",detail:"of loop",type:"keyword"}),On("do {\n ${}\n} while (${})",{label:"do",detail:"loop",type:"keyword"}),On("while (${}) {\n ${}\n}",{label:"while",detail:"loop",type:"keyword"}),On(`try { + \${} +} catch (\${error}) { + \${} +}`,{label:"try",detail:"/ catch block",type:"keyword"}),On("if (${}) {\n ${}\n}",{label:"if",detail:"block",type:"keyword"}),On(`if (\${}) { + \${} +} else { + \${} +}`,{label:"if",detail:"/ else block",type:"keyword"}),On(`class \${name} { + constructor(\${params}) { + \${} + } +}`,{label:"class",detail:"definition",type:"keyword"}),On('import {${names}} from "${module}"\n${}',{label:"import",detail:"named",type:"keyword"}),On('import ${name} from "${module}"\n${}',{label:"import",detail:"default",type:"keyword"})],Vj=xC.concat([On("interface ${name} {\n ${}\n}",{label:"interface",detail:"definition",type:"keyword"}),On("type ${name} = ${type}",{label:"type",detail:"definition",type:"keyword"}),On("enum ${name} {\n ${}\n}",{label:"enum",detail:"definition",type:"keyword"})]),zS=new SL,vC=new Set(["Script","Block","FunctionExpression","FunctionDeclaration","ArrowFunction","MethodDeclaration","ForStatement"]);function sa(n){return(e,t)=>{let i=e.node.getChild("VariableDefinition");return i&&t(i,n),!0}}const Fj=["FunctionDeclaration"],Yj={FunctionDeclaration:sa("function"),ClassDeclaration:sa("class"),ClassExpression:()=>!0,EnumDeclaration:sa("constant"),TypeAliasDeclaration:sa("type"),NamespaceDeclaration:sa("namespace"),VariableDefinition(n,e){n.matchContext(Fj)||e(n,"variable")},TypeDefinition(n,e){e(n,"type")},__proto__:null};function bC(n,e){let t=zS.get(e);if(t)return t;let i=[],r=!0;function s(o,a){let c=n.sliceString(o.from,o.to);i.push({label:c,type:a})}return e.cursor(Tt.IncludeAnonymous).iterate(o=>{if(r)r=!1;else if(o.name){let a=Yj[o.name];if(a&&a(o,s)||vC.has(o.name))return!1}else if(o.to-o.from>8192){for(let a of bC(n,o.node))i.push(a);return!1}}),zS.set(e,i),i}const jS=/^[\w$\xa1-\uffff][\w$\d\xa1-\uffff]*$/,SC=["TemplateString","String","RegExp","LineComment","BlockComment","VariableDefinition","TypeDefinition","Label","PropertyDefinition","PropertyName","PrivatePropertyDefinition","PrivatePropertyName","JSXText","JSXAttributeValue","JSXOpenTag","JSXCloseTag","JSXSelfClosingTag",".","?."];function qj(n){let e=Pt(n.state).resolveInner(n.pos,-1);if(SC.indexOf(e.name)>-1)return null;let t=e.name=="VariableName"||e.to-e.from<20&&jS.test(n.state.sliceDoc(e.from,e.to));if(!t&&!n.explicit)return null;let i=[];for(let r=e;r;r=r.parent)vC.has(r.name)&&(i=i.concat(bC(n.state.doc,r)));return{options:i,from:t?e.from:n.pos,validFor:jS}}const Zs=Ha.define({name:"javascript",parser:Wj.configure({props:[C0.add({IfStatement:Zh({except:/^\s*({|else\b)/}),TryStatement:Zh({except:/^\s*({|catch\b|finally\b)/}),LabeledStatement:BL,SwitchBody:n=>{let e=n.textAfter,t=/^\s*\}/.test(e),i=/^\s*(case|default)\b/.test(e);return n.baseIndent+(t?0:i?1:2)*n.unit},Block:NL({closing:"}"}),ArrowFunction:n=>n.baseIndent+n.unit,"TemplateString BlockComment":()=>null,"Statement Property":Zh({except:/^\s*{/}),JSXElement(n){let e=/^\s*<\//.test(n.textAfter);return n.lineIndent(n.node.from)+(e?0:n.unit)},JSXEscape(n){let e=/\s*\}/.test(n.textAfter);return n.lineIndent(n.node.from)+(e?0:n.unit)},"JSXOpenTag JSXSelfClosingTag"(n){return n.column(n.node.from)+n.unit}}),$0.add({"Block ClassBody SwitchBody EnumBody ObjectExpression ArrayExpression ObjectType":FL,BlockComment(n){return{from:n.from+2,to:n.to-2}}})]}),languageData:{closeBrackets:{brackets:["(","[","{","'",'"',"`"]},commentTokens:{line:"//",block:{open:"/*",close:"*/"}},indentOnInput:/^\s*(?:case |default:|\{|\}|<\/)$/,wordChars:"$"}}),wC={test:n=>/^JSX/.test(n.name),facet:W_({commentTokens:{block:{open:"{/*",close:"*/}"}}})},Uj=Zs.configure({dialect:"ts"},"typescript"),Hj=Zs.configure({dialect:"jsx",props:[_0.add(n=>n.isTop?[wC]:void 0)]}),Gj=Zs.configure({dialect:"jsx ts",props:[_0.add(n=>n.isTop?[wC]:void 0)]},"typescript");let kC=n=>({label:n,type:"keyword"});const PC="break case const continue default delete export extends false finally in instanceof let new return static super switch this throw true typeof var yield".split(" ").map(kC),Kj=PC.concat(["declare","implements","private","protected","public"].map(kC));function Ug(n={}){let e=n.jsx?n.typescript?Gj:Hj:n.typescript?Uj:Zs,t=n.typescript?Vj.concat(Kj):xC.concat(PC);return new F_(e,[Zs.data.of({autocomplete:HQ(SC,j0(t))}),Zs.data.of({autocomplete:qj}),n.jsx?tZ:[]])}function Jj(n){for(;;){if(n.name=="JSXOpenTag"||n.name=="JSXSelfClosingTag"||n.name=="JSXFragmentTag")return n;if(n.name=="JSXEscape"||!n.parent)return null;n=n.parent}}function ZS(n,e,t=n.length){for(let i=e==null?void 0:e.firstChild;i;i=i.nextSibling)if(i.name=="JSXIdentifier"||i.name=="JSXBuiltin"||i.name=="JSXNamespacedName"||i.name=="JSXMemberExpression")return n.sliceString(i.from,Math.min(i.to,t));return""}const eZ=typeof navigator=="object"&&/Android\b/.test(navigator.userAgent),tZ=fe.inputHandler.of((n,e,t,i,r)=>{if((eZ?n.composing:n.compositionStarted)||n.state.readOnly||e!=t||i!=">"&&i!="/"||!Zs.isActiveAt(n.state,e,-1))return!1;let s=r(),{state:o}=s,a=o.changeByRange(c=>{var h;let{head:f}=c,p=Pt(o).resolveInner(f-1,-1),m;if(p.name=="JSXStartTag"&&(p=p.parent),!(o.doc.sliceString(f-1,f)!=i||p.name=="JSXAttributeValue"&&p.to>f)){if(i==">"&&p.name=="JSXFragmentTag")return{range:c,changes:{from:f,insert:""}};if(i=="/"&&p.name=="JSXStartCloseTag"){let O=p.parent,v=O.parent;if(v&&O.from==f-2&&((m=ZS(o.doc,v.firstChild,f))||((h=v.firstChild)===null||h===void 0?void 0:h.name)=="JSXFragmentTag")){let b=`${m}>`;return{range:V.cursor(f+b.length,-1),changes:{from:f,insert:b}}}}else if(i==">"){let O=Jj(p);if(O&&O.name=="JSXOpenTag"&&!/^\/?>|^<\//.test(o.doc.sliceString(f,f+2))&&(m=ZS(o.doc,O,f)))return{range:c,changes:{from:f,insert:``}}}}return{range:c}});return a.changes.empty?!1:(n.dispatch([s,o.update(a,{userEvent:"input.complete",scrollIntoView:!0})]),!0)}),nZ="#e5c07b",IS="#e06c75",iZ="#56b6c2",rZ="#ffffff",Nh="#abb2bf",AO="#7d8799",sZ="#61afef",oZ="#98c379",NS="#d19a66",lZ="#c678dd",aZ="#21252b",BS="#2c313a",XS="#282c34",Hg="#353a42",cZ="#3E4451",WS="#528bff",uZ=fe.theme({"&":{color:Nh,backgroundColor:XS},".cm-content":{caretColor:WS},".cm-cursor, .cm-dropCursor":{borderLeftColor:WS},"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":{backgroundColor:cZ},".cm-panels":{backgroundColor:aZ,color:Nh},".cm-panels.cm-panels-top":{borderBottom:"2px solid black"},".cm-panels.cm-panels-bottom":{borderTop:"2px solid black"},".cm-searchMatch":{backgroundColor:"#72a1ff59",outline:"1px solid #457dff"},".cm-searchMatch.cm-searchMatch-selected":{backgroundColor:"#6199ff2f"},".cm-activeLine":{backgroundColor:"#6699ff0b"},".cm-selectionMatch":{backgroundColor:"#aafe661a"},"&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket":{backgroundColor:"#bad0f847"},".cm-gutters":{backgroundColor:XS,color:AO,border:"none"},".cm-activeLineGutter":{backgroundColor:BS},".cm-foldPlaceholder":{backgroundColor:"transparent",border:"none",color:"#ddd"},".cm-tooltip":{border:"none",backgroundColor:Hg},".cm-tooltip .cm-tooltip-arrow:before":{borderTopColor:"transparent",borderBottomColor:"transparent"},".cm-tooltip .cm-tooltip-arrow:after":{borderTopColor:Hg,borderBottomColor:Hg},".cm-tooltip-autocomplete":{"& > ul > li[aria-selected]":{backgroundColor:BS,color:Nh}}},{dark:!0}),hZ=vc.define([{tag:A.keyword,color:lZ},{tag:[A.name,A.deleted,A.character,A.propertyName,A.macroName],color:IS},{tag:[A.function(A.variableName),A.labelName],color:sZ},{tag:[A.color,A.constant(A.name),A.standard(A.name)],color:NS},{tag:[A.definition(A.name),A.separator],color:Nh},{tag:[A.typeName,A.className,A.number,A.changed,A.annotation,A.modifier,A.self,A.namespace],color:nZ},{tag:[A.operator,A.operatorKeyword,A.url,A.escape,A.regexp,A.link,A.special(A.string)],color:iZ},{tag:[A.meta,A.comment],color:AO},{tag:A.strong,fontWeight:"bold"},{tag:A.emphasis,fontStyle:"italic"},{tag:A.strikethrough,textDecoration:"line-through"},{tag:A.link,color:AO,textDecoration:"underline"},{tag:A.heading,fontWeight:"bold",color:IS},{tag:[A.atom,A.bool,A.special(A.variableName)],color:NS},{tag:[A.processingInstruction,A.string,A.inserted],color:oZ},{tag:A.invalid,color:rZ}]),fZ=[uZ,iQ(hZ)];var Ar=(n=>(n.PostgreSQL="PostgreSQL",n.MySQL="MySQL",n.Redis="Redis",n.CQL="CQL",n.OrbitQL="OrbitQL",n.Cypher="Cypher",n.AQL="AQL",n.FlightSQL="FlightSQL",n.OrbitWire="OrbitWire",n))(Ar||{});const $a=n=>n==="Connected",_C=n=>typeof n=="object"&&n!==null&&"Error"in n?n.Error:null;var ve=(n=>(n.SQL="SQL",n.OrbitQL="OrbitQL",n.Redis="Redis",n.MySQL="MySQL",n.CQL="CQL",n.Cypher="Cypher",n.AQL="AQL",n))(ve||{});const EO=n=>{switch(n.kind){case"returned":return`${n.rows} row${n.rows===1?"":"s"} returned`;case"affected":return`${n.rows} row${n.rows===1?"":"s"} affected`;case"completed":return"Completed"}};var Oa=(n=>(n.Training="Training",n.Ready="Ready",n.Error="Error",n.Deleted="Deleted",n))(Oa||{});const VS=1024*100,dZ=5e3,pZ=["SELECT","FROM","WHERE","JOIN","GROUP BY","HAVING","ORDER BY","LIMIT"],gZ=(n,e=dZ)=>{if(n.length>VS)throw new Error(`Query too large for formatting (${n.length} chars, max: ${VS})`);const t=Date.now(),i=()=>{if(Date.now()-t>e)throw new Error("Query formatting timeout - potential ReDoS detected")};let r=n;i(),r=r.replace(/[ \t\r\n]+/g," "),i(),r=r.replace(/[ \t]*,[ \t]*/g,`, + `);for(const s of pZ)i(),r=r.replace(new RegExp(`\\b${s}\\b`,"gi"),` +${s}`);return i(),r=r.replace(/^[ \t]+/gm," "),r.trim()},mZ=n=>n.split(/\s+/).filter(e=>e.length>0).join(" ").trim();function LO(){return LO=Object.assign?Object.assign.bind():function(n){for(var e=1;e'),!0):e?n.some(function(t){return e.includes(t)})||n.includes("*"):!0}var PZ=function(e,t,i){i===void 0&&(i=!1);var r=t.alt,s=t.meta,o=t.mod,a=t.shift,c=t.ctrl,h=t.keys,f=e.key,p=e.code,m=e.ctrlKey,O=e.metaKey,v=e.shiftKey,b=e.altKey,S=Ir(p),w=f.toLowerCase();if(!(h!=null&&h.includes(S))&&!(h!=null&&h.includes(w))&&!["ctrl","control","unknown","meta","alt","shift","os"].includes(S))return!1;if(!i){if(r===!b&&w!=="alt"||a===!v&&w!=="shift")return!1;if(o){if(!O&&!m)return!1}else if(s===!O&&w!=="meta"&&w!=="os"||c===!m&&w!=="ctrl"&&w!=="control")return!1}return h&&h.length===1&&(h.includes(w)||h.includes(S))?!0:h?xZ(h):!h},_Z=ne.createContext(void 0),QZ=function(){return ne.useContext(_Z)};function CZ(n,e){return n===e}var TZ=ne.createContext({hotkeys:[],enabledScopes:[],toggleScope:function(){},enableScope:function(){},disableScope:function(){}}),$Z=function(){return ne.useContext(TZ)};function MZ(n){var e=ne.useRef(void 0);return CZ(e.current,n)||(e.current=n),e.current}var FS=function(e){e.stopPropagation(),e.preventDefault(),e.stopImmediatePropagation()},RZ=typeof window<"u"?ne.useLayoutEffect:ne.useEffect;function DO(n,e,t,i){var r=ne.useState(null),s=r[0],o=r[1],a=ne.useRef(!1),c=t instanceof Array?i instanceof Array?void 0:i:t,h=H0(n)?n.join(void 0):n,f=ne.useCallback(e,[]),p=ne.useRef(f);p.current=e;var m=MZ(c),O=$Z(),v=O.enabledScopes,b=QZ();return RZ(function(){if(!((m==null?void 0:m.enabled)===!1||!kZ(v,m==null?void 0:m.scopes))){var S=function(C,M){var R;if(M===void 0&&(M=!1),!(SZ(C)&&!$C(C,m==null?void 0:m.enableOnFormTags))){if(s!==null){var L=s.getRootNode();if((L instanceof Document||L instanceof ShadowRoot)&&L.activeElement!==s&&!s.contains(L.activeElement)){FS(C);return}}(R=C.target)!=null&&R.isContentEditable&&!(m!=null&&m.enableOnContentEditable)||Gg(h,m==null?void 0:m.splitKey).forEach(function(X){var ie,Y=Kg(X,m==null?void 0:m.combinationKey);if(PZ(C,Y,m==null?void 0:m.ignoreModifiers)||(ie=Y.keys)!=null&&ie.includes("*")){if(m!=null&&m.ignoreEventWhen!=null&&m.ignoreEventWhen(C)||M&&a.current)return;if(vZ(C,Y,m==null?void 0:m.preventDefault),!bZ(C,Y,m==null?void 0:m.enabled)){FS(C);return}p.current(C,Y),M||(a.current=!0)}})}},w=function(C){C.key!==void 0&&(CC(Ir(C.code)),((m==null?void 0:m.keydown)===void 0&&(m==null?void 0:m.keyup)!==!0||m!=null&&m.keydown)&&S(C))},T=function(C){C.key!==void 0&&(TC(Ir(C.code)),a.current=!1,m!=null&&m.keyup&&S(C,!0))},k=s||void 0||document;return k.addEventListener("keyup",T,void 0),k.addEventListener("keydown",w,void 0),b&&Gg(h,m==null?void 0:m.splitKey).forEach(function(_){return b.addHotkey(Kg(_,m==null?void 0:m.combinationKey,m==null?void 0:m.description))}),function(){k.removeEventListener("keyup",T,void 0),k.removeEventListener("keydown",w,void 0),b&&Gg(h,m==null?void 0:m.splitKey).forEach(function(_){return b.removeHotkey(Kg(_,m==null?void 0:m.combinationKey,m==null?void 0:m.description))})}}},[s,h,m,v]),o}const MC=["select","from","where","join","inner","left","right","full","cross","group","by","having","order","limit","offset","insert","into","values","update","set","delete","create","drop","table","index","view","with","recursive","traverse","outbound","inbound","steps","on","relate","node","edge","path","connected","live","diff","ml_train_model","ml_predict","ml_evaluate_model","ml_drop_model","ml_list_models","ml_model_info","ml_update_model","ml_xgboost","ml_lightgbm","ml_catboost","ml_adaboost","ml_gradient_boosting","ml_linear_regression","ml_logistic_regression","ml_correlation","ml_covariance","ml_zscore","ml_normalize","ml_encode_categorical","ml_polynomial_features","ml_pca","ml_feature_selection","ml_embed_text","ml_embed_image","ml_similarity_search","ml_vector_cluster","ml_dimensionality_reduction","ml_forecast","ml_seasonality_decompose","ml_anomaly_detection","ml_sentiment_analysis","ml_extract_entities","ml_summarize_text","model","train","predict","using","algorithm","features","target","evaluate","score","fit","transform"],AZ=["get","set","del","exists","expire","ttl","keys","scan","hget","hset","hdel","hgetall","hkeys","hvals","hmget","hmset","llen","lpush","rpush","lpop","rpop","lrange","lindex","lset","sadd","srem","smembers","scard","sismember","sunion","sinter","zadd","zrem","zrange","zrank","zscore","zcard","zcount","ping","echo","info","dbsize","flushdb","flushall","select","auth","quit","shutdown","lastsave","save","bgsave"],EZ=[...MC,"engine","charset","collate","auto_increment","unsigned","zerofill","binary","varbinary","tinyint","smallint","mediumint","bigint","decimal","float","double","bit","year","enum","set","show","describe","explain","use","lock","unlock","grant","revoke"],LZ=["select","from","where","insert","into","values","update","set","delete","create","drop","alter","table","keyspace","index","primary","key","partition","clustering","order","by","asc","desc","allow","filtering","using","ttl","timestamp","batch","apply","truncate","grant","revoke","use","describe","copy","consistency","level","one","quorum","all","any","local_quorum","each_quorum","serial","local_serial","local_one"],DZ=["match","where","return","create","merge","delete","detach","remove","set","with","unwind","union","call","yield","order","by","skip","limit","distinct","optional","as","and","or","not","xor","case","when","then","else","end","is","null","exists","all","any","none","single","start","end","node","relationship","rel","path","shortestpath","allshortestpaths","count","collect","sum","avg","min","max","head","last","tail","size","keys","labels","type","id","properties","toInteger","toFloat","toString","toBoolean","coalesce","timestamp","datetime","date","time"],zZ=["for","in","return","let","filter","sort","limit","collect","with","into","keep","count","aggregate","group","distinct","insert","update","replace","upsert","remove","let","with","into","options","new","old","outbound","inbound","any","all","shortest_path","k_shortest_paths","k_paths","p_paths","traversal","graph","shortest_path","k_shortest_paths","document","collection","edge","vertex","prune","search","analyzer","boost","min_match","prefix","fuzzy","wildcard","phrase","near","within","fulltext","geo_distance","geo_contains"],jZ=I.div` + flex: 1; + display: flex; + flex-direction: column; + height: 100%; + + .cm-editor { + height: 100%; + font-size: 14px; + border: 1px solid #3c3c3c; + border-radius: 4px; + } + + .cm-focused { + outline: none; + border-color: #0078d4; + } + + .cm-content { + padding: 12px; + min-height: 200px; + } + + .cm-line { + line-height: 1.6; + } + + .cm-cursor { + border-left: 2px solid #ffffff; + } + + .cm-selectionBackground { + background: #264f78 !important; + } + + .cm-activeLine { + background-color: rgba(255, 255, 255, 0.05); + } + + .cm-activeLineGutter { + background-color: rgba(255, 255, 255, 0.05); + } +`,ZZ=I.div` + display: flex; + gap: 8px; + padding: 8px 12px; + background: #2d2d2d; + border-bottom: 1px solid #3c3c3c; + align-items: center; +`,Jg=I.button` + padding: 6px 12px; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + transition: all 0.2s; + + ${n=>n.variant==="primary"?` + background: #0078d4; + color: white; + + &:hover:not(:disabled) { + background: #106ebe; + } + `:` + background: #3c3c3c; + color: #ffffff; + + &:hover:not(:disabled) { + background: #484848; + } + `} + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:active { + transform: translateY(1px); + } +`,IZ=I.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 12px; + background: #2d2d2d; + border-top: 1px solid #3c3c3c; + font-size: 12px; + color: #cccccc; +`,NZ=I.div` + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + + ${n=>{switch(n.type){case ve.SQL:return"background: #0078d4; color: white;";case ve.OrbitQL:return"background: #107c10; color: white;";case ve.Redis:return"background: #d83b01; color: white;";case ve.MySQL:return"background: #00758f; color: white;";case ve.CQL:return"background: #1287b1; color: white;";case ve.Cypher:return"background: #008cc1; color: white;";case ve.AQL:return"background: #dd5324; color: white;";default:return"background: #5a5a5a; color: white;"}}} +`,BZ=({value:n,onChange:e,queryType:t,onExecute:i,onExplain:r,isExecuting:s=!1,connection:o,className:a})=>{const c=ne.useRef(null),h=ne.useRef(null),[f,p]=ne.useState({line:1,column:1});DO("ctrl+enter,cmd+enter",()=>{!s&&n.trim()&&v()}),DO("ctrl+shift+enter,cmd+shift+enter",()=>{!s&&n.trim()&&r&&b()});const m=()=>{let T=[];switch(t){case ve.SQL:case ve.OrbitQL:T=MC;break;case ve.Redis:T=AZ;break;case ve.MySQL:T=EZ;break;case ve.CQL:T=LZ;break;case ve.Cypher:T=DZ;break;case ve.AQL:T=zZ;break}return oC({override:[k=>{const _=k.matchBefore(/\w*/);if(!_||_.from===_.to&&!k.explicit)return null;const C=T.filter(M=>M.toLowerCase().includes(_.text.toLowerCase())).map(M=>({label:M,type:"keyword",boost:M.startsWith(_.text.toLowerCase())?1:0}));return{from:_.from,options:C}}]})},O=()=>{const T=[O3,fZ,Oc.of([...F0,ED]),m(),fe.updateListener.of(k=>{if(k.docChanged&&e(k.state.doc.toString()),k.selectionSet){const _=k.state.selection.main.head,C=k.state.doc.lineAt(_);p({line:C.number,column:_-C.from+1})}})];switch(t){case ve.SQL:case ve.OrbitQL:case ve.MySQL:T.push(LS());break;case ve.Redis:T.push(Ug());break;case ve.CQL:T.push(LS());break;case ve.Cypher:T.push(Ug());break;case ve.AQL:T.push(Ug());break}return T};ne.useEffect(()=>{if(!c.current)return;h.current&&h.current.destroy();const T=Ze.create({doc:n,extensions:O()});return h.current=new fe({state:T,parent:c.current}),()=>{h.current&&h.current.destroy()}},[t]),ne.useEffect(()=>{h.current&&h.current.state.doc.toString()!==n&&h.current.dispatch({changes:{from:0,to:h.current.state.doc.length,insert:n}})},[n]);const v=()=>{n.trim()&&i(n.trim())},b=()=>{n.trim()&&r&&r(n.trim())},S=()=>{if(t===ve.SQL||t===ve.OrbitQL||t===ve.MySQL||t===ve.CQL)try{e(gZ(n))}catch(T){console.error("Query formatting failed:",T),e(mZ(n))}},w=()=>{switch(t){case ve.SQL:return"Standard PostgreSQL syntax";case ve.OrbitQL:return"OrbitQL with ML functions - try ML_XGBOOST(), ML_TRAIN_MODEL()";case ve.Redis:return"Redis commands - GET, SET, HGET, etc.";case ve.MySQL:return"MySQL syntax - similar to PostgreSQL with MySQL extensions";case ve.CQL:return"Cassandra Query Language - SELECT, INSERT, UPDATE, DELETE";case ve.Cypher:return"Neo4j Cypher - MATCH, CREATE, RETURN for graph queries";case ve.AQL:return"ArangoDB AQL - FOR, FILTER, RETURN for document and graph queries";default:return""}};return Q.jsxs(jZ,{className:a,children:[Q.jsxs(ZZ,{children:[Q.jsx(Jg,{variant:"primary",onClick:v,disabled:s||!n.trim(),children:s?Q.jsxs(Q.Fragment,{children:[Q.jsx("span",{children:"⟳"})," Executing..."]}):Q.jsxs(Q.Fragment,{children:[Q.jsx("span",{children:"▶"})," Execute (Ctrl+Enter)"]})}),(t===ve.SQL||t===ve.OrbitQL||t===ve.MySQL)&&Q.jsxs(Jg,{onClick:b,disabled:s||!n.trim()||!r,children:[Q.jsx("span",{children:"📊"})," Explain"]}),(t===ve.SQL||t===ve.OrbitQL||t===ve.MySQL||t===ve.CQL)&&Q.jsxs(Jg,{onClick:S,children:[Q.jsx("span",{children:"📝"})," Format"]}),Q.jsx("div",{style:{flex:1}}),Q.jsx(NZ,{type:t,children:t})]}),Q.jsx("div",{ref:c,style:{flex:1}}),Q.jsxs(IZ,{children:[Q.jsxs("div",{children:["Line ",f.line,", Column ",f.column]}),Q.jsxs("div",{children:[o?`Connected to ${o.info.name}`:"No connection"," • ",w()]})]})]})};function XZ(){return window.crypto.getRandomValues(new Uint32Array(1))[0]}function YS(n,e=!1){const t=XZ(),i=`_${t}`;return Object.defineProperty(window,i,{value:r=>(e&&Reflect.deleteProperty(window,i),n==null?void 0:n(r)),writable:!1,configurable:!0}),t}async function RC(n,e={}){return new Promise((t,i)=>{const r=YS(o=>{t(o),Reflect.deleteProperty(window,`_${s}`)},!0),s=YS(o=>{i(o),Reflect.deleteProperty(window,`_${r}`)},!0);window.__TAURI_IPC__({cmd:n,callback:r,error:s,...e})})}const G0=()=>{try{return typeof globalThis.window<"u"&&typeof globalThis.window.__TAURI_IPC__=="function"}catch{return!1}},WZ="This action needs the Orbit Desktop application. The page is running in a plain browser, which has no connection to the database. Run `npm run dev` (which starts Tauri) or launch the built app.",ft=async(n,e)=>{if(!G0())throw new Error(WZ);const t=await RC(n,e);if(!t.success)throw new Error(t.error||`${n} failed`);return t.data};class $t{static createConnection(e){return ft("create_connection",{connectionInfo:e})}static testConnection(e){return ft("test_connection",{connectionInfo:e})}static getConnections(){return ft("get_connections")}static connect(e){return ft("connect",{connectionId:e})}static disconnect(e){return ft("disconnect",{connectionId:e}).then(()=>{})}static deleteConnection(e){return ft("delete_connection",{connectionId:e}).then(()=>{})}static listConnectionTypes(){return ft("list_connection_types")}static executeQuery(e){return ft("execute_query",{request:e})}static explainQuery(e,t=!1){return ft("explain_query",{request:e,analyze:t})}static getQueryHistory(e,t){return ft("get_query_history",{connectionId:e,limit:t})}static getClusterStatus(){return ft("get_cluster_status")}static setClusterRoot(e){return ft("set_cluster_root",{path:e})}static startCluster(e){return ft("start_cluster",{size:e}).then(()=>{})}static stopCluster(){return ft("stop_cluster").then(()=>{})}static getClusterLog(e,t){return ft("get_cluster_log",{nodeId:e,lines:t})}static listMlFunctions(){return ft("list_ml_functions")}static listModels(e){return ft("list_models",{connectionId:e})}static getModelInfo(e,t){return ft("get_model_info",{connectionId:e,modelName:t})}static deleteModel(e,t){return ft("delete_model",{connectionId:e,modelName:t}).then(()=>{})}static getSystemInfo(){return ft("get_system_info")}static saveSettings(e){return ft("save_settings",{settings:e}).then(()=>{})}static loadSettings(){return ft("load_settings")}static async showAboutDialog(){G0()&&await RC("show_about_dialog")}}const Di=n=>{if(typeof n=="string")return n;if(n instanceof Error)return n.message;if(n&&typeof n=="object"){const e=n;if(typeof e.message=="string")return e.message;if(typeof e.error=="string")return e.error}return"An unexpected error occurred"},VZ=I.div` + display: flex; + flex-direction: column; + height: 100%; + background: #1e1e1e; +`,FZ=I.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid #3c3c3c; +`,YZ=I.h2` + margin: 0; + color: #ffffff; + font-size: 18px; + font-weight: 600; +`,qZ=I.button` + padding: 6px 12px; + background: #0078d4; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + + &:hover { + background: #106ebe; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`,UZ=I.div` + display: flex; + border-bottom: 1px solid #3c3c3c; +`,qS=I.button` + padding: 12px 20px; + background: none; + border: none; + color: ${n=>n.active?"#0078d4":"#cccccc"}; + cursor: pointer; + font-size: 14px; + border-bottom: ${n=>n.active?"2px solid #0078d4":"2px solid transparent"}; + transition: all 0.2s; + + &:hover { + color: ${n=>n.active?"#0078d4":"#ffffff"}; + } +`,HZ=I.div` + flex: 1; + overflow: auto; + padding: 20px; +`,GZ=I.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; + margin-bottom: 20px; +`,KZ=I.div` + background: #2d2d2d; + border: 1px solid #3c3c3c; + border-radius: 8px; + padding: 16px; + transition: all 0.2s; + + &:hover { + border-color: #0078d4; + box-shadow: 0 2px 8px rgba(0, 120, 212, 0.1); + } +`,JZ=I.h3` + margin: 0 0 8px 0; + color: #ffffff; + font-size: 16px; + font-weight: 600; +`,e4=I.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +`,t4=I.div` + background: #107c10; + color: white; + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; +`,n4=I.div` + width: 8px; + height: 8px; + border-radius: 50%; + background: ${n=>{switch(n.status){case Oa.Ready:return"#107c10";case Oa.Training:return"#ff8c00";case Oa.Error:return"#d13438";case Oa.Deleted:return"#5a5a5a";default:return"#5a5a5a"}}}; +`,i4=I.div` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 12px; +`,ah=I.div` + color: #cccccc; + font-size: 12px; + + .label { + color: #888888; + display: block; + } + + .value { + color: #ffffff; + font-weight: 600; + font-size: 14px; + } +`,r4=I.div` + display: flex; + gap: 8px; + margin-top: 12px; +`,US=I.button` + flex: 1; + padding: 6px 12px; + border: none; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; + + ${n=>n.variant==="danger"?` + background: #d13438; + color: white; + + &:hover { + background: #b71c1c; + } + `:` + background: #3c3c3c; + color: #ffffff; + + &:hover { + background: #484848; + } + `} + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`,s4=I.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; +`,o4=I.div` + background: #2d2d2d; + border: 1px solid #3c3c3c; + border-radius: 8px; + overflow: hidden; +`,l4={Boosting:"#0078d4",ModelManagement:"#107c10",Statistical:"#d83b01",FeatureEngineering:"#5c2d91",VectorOperations:"#e81123"},a4=I.div` + padding: 12px 16px; + background: ${n=>{var e;return(e=l4[n.category])!=null?e:"#5a5a5a"}}; + color: white; + font-weight: 600; + font-size: 14px; +`,c4=I.div` + padding: 16px; +`,u4=I.div` + margin-bottom: 12px; + padding: 8px; + border-radius: 4px; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: #3c3c3c; + } + + .name { + color: #0078d4; + font-weight: 600; + font-size: 13px; + margin-bottom: 4px; + } + + .description { + color: #cccccc; + font-size: 12px; + line-height: 1.4; + } +`,em=I.div` + text-align: center; + padding: 40px 20px; + color: #888888; + + .icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; + } + + .message { + font-size: 16px; + margin-bottom: 8px; + } + + .submessage { + font-size: 14px; + opacity: 0.7; + } +`,HS=({connection:n,className:e})=>{const[t,i]=ne.useState("models"),[r,s]=ne.useState([]),[o,a]=ne.useState([]),[c,h]=ne.useState(!1),[f,p]=ne.useState(null);ne.useEffect(()=>{t==="models"&&n?m():t==="functions"&&O()},[t,n]);const m=async()=>{if(n){h(!0),p(null);try{const k=await $t.listModels(n.id);s(k)}catch(k){p(k instanceof Error?k.message:"Failed to load models")}finally{h(!1)}}},O=async()=>{h(!0),p(null);try{const k=await $t.listMlFunctions();a(k)}catch(k){p(k instanceof Error?k.message:"Failed to load ML functions")}finally{h(!1)}},v=async k=>{if(n&&confirm(`Are you sure you want to delete the model "${k}"?`))try{await $t.deleteModel(n.id,k),await m()}catch(_){alert(`Failed to delete model: ${_ instanceof Error?_.message:"Unknown error"}`)}},b=k=>`${(k*100).toFixed(1)}%`,S=()=>o.reduce((k,_)=>{var C,M;return((M=k[C=_.category])!=null?M:k[C]=[]).push(_),k},{}),w={Boosting:"Boosting Algorithms",ModelManagement:"Model Management",Statistical:"Statistical Functions",FeatureEngineering:"Feature Engineering",VectorOperations:"Vector Operations"},T=k=>{var _;return(_=w[k])!=null?_:k};return Q.jsxs(VZ,{className:e,children:[Q.jsxs(FZ,{children:[Q.jsx(YZ,{children:"ML Models & Functions"}),Q.jsx(qZ,{onClick:()=>t==="models"?m():O(),disabled:c,children:c?"⟳ Loading...":"🔄 Refresh"})]}),Q.jsxs(UZ,{children:[Q.jsxs(qS,{active:t==="models",onClick:()=>i("models"),children:["📊 Models (",r.length,")"]}),Q.jsxs(qS,{active:t==="functions",onClick:()=>i("functions"),children:["🧠 ML Functions (",o.length,")"]})]}),Q.jsxs(HZ,{children:[f&&Q.jsx("div",{style:{color:"#d13438",background:"rgba(209, 52, 56, 0.1)",padding:"12px",borderRadius:"4px",marginBottom:"16px"},children:f}),t==="models"&&Q.jsx(Q.Fragment,{children:n?r.length===0&&!c?Q.jsxs(em,{children:[Q.jsx("div",{className:"icon",children:"🤖"}),Q.jsx("div",{className:"message",children:"No ML Models Found"}),Q.jsxs("div",{className:"submessage",children:["Train your first model using OrbitQL:",Q.jsx("br",{}),Q.jsx("code",{children:"SELECT ML_TRAIN_MODEL('my_model', 'XGBOOST', features, target) FROM data"})]})]}):Q.jsx(GZ,{children:r.map(k=>{var _;return Q.jsxs(KZ,{children:[Q.jsx(JZ,{children:k.name}),Q.jsxs(e4,{children:[Q.jsx(t4,{children:k.model_type}),Q.jsx(n4,{status:k.status,title:k.status})]}),Q.jsxs(i4,{children:[Q.jsxs(ah,{children:[Q.jsx("span",{className:"label",children:"Accuracy"}),Q.jsx("span",{className:"value",children:k.accuracy===null||k.accuracy===void 0?"—":b(k.accuracy)})]}),Q.jsxs(ah,{children:[Q.jsx("span",{className:"label",children:"Features"}),Q.jsx("span",{className:"value",children:k.features.length})]}),Q.jsxs(ah,{children:[Q.jsx("span",{className:"label",children:"Target"}),Q.jsx("span",{className:"value",children:(_=k.target)!=null?_:"—"})]}),Q.jsxs(ah,{children:[Q.jsx("span",{className:"label",children:"Last trained"}),Q.jsx("span",{className:"value",children:k.last_trained?new Date(k.last_trained).toLocaleDateString():"never"})]})]}),Q.jsxs(r4,{children:[Q.jsx(US,{children:"📊 View Details"}),Q.jsx(US,{variant:"danger",onClick:()=>v(k.name),children:"🗑️ Delete"})]})]},k.id||k.name)})}):Q.jsxs(em,{children:[Q.jsx("div",{className:"icon",children:"🔌"}),Q.jsx("div",{className:"message",children:"No Connection Selected"}),Q.jsx("div",{className:"submessage",children:"Please connect to a database to view ML models"})]})}),t==="functions"&&Q.jsx(Q.Fragment,{children:o.length===0&&!c?Q.jsxs(em,{children:[Q.jsx("div",{className:"icon",children:"🔍"}),Q.jsx("div",{className:"message",children:"No ML Functions Available"}),Q.jsx("div",{className:"submessage",children:"Check your connection to load available ML functions"})]}):Q.jsx(s4,{children:Object.entries(S()).map(([k,_])=>_.length===0?null:Q.jsxs(o4,{children:[Q.jsxs(a4,{category:k,children:[T(k)," (",_.length,")"]}),Q.jsx(c4,{children:_.map(C=>Q.jsxs(u4,{children:[Q.jsx("div",{className:"name",children:C.name}),Q.jsx("div",{className:"description",children:C.description})]},C.name))})]},k))})})]})]})};function kc(n){return n+.5|0}const Br=(n,e,t)=>Math.max(Math.min(n,t),e);function ya(n){return Br(kc(n*2.55),0,255)}function Yr(n){return Br(kc(n*255),0,255)}function tr(n){return Br(kc(n/2.55)/100,0,1)}function GS(n){return Br(kc(n*100),0,100)}const Nn={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},zO=[..."0123456789ABCDEF"],h4=n=>zO[n&15],f4=n=>zO[(n&240)>>4]+zO[n&15],ch=n=>(n&240)>>4===(n&15),d4=n=>ch(n.r)&&ch(n.g)&&ch(n.b)&&ch(n.a);function p4(n){var e=n.length,t;return n[0]==="#"&&(e===4||e===5?t={r:255&Nn[n[1]]*17,g:255&Nn[n[2]]*17,b:255&Nn[n[3]]*17,a:e===5?Nn[n[4]]*17:255}:(e===7||e===9)&&(t={r:Nn[n[1]]<<4|Nn[n[2]],g:Nn[n[3]]<<4|Nn[n[4]],b:Nn[n[5]]<<4|Nn[n[6]],a:e===9?Nn[n[7]]<<4|Nn[n[8]]:255})),t}const g4=(n,e)=>n<255?e(n):"";function m4(n){var e=d4(n)?h4:f4;return n?"#"+e(n.r)+e(n.g)+e(n.b)+g4(n.a,e):void 0}const O4=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function AC(n,e,t){const i=e*Math.min(t,1-t),r=(s,o=(s+n/30)%12)=>t-i*Math.max(Math.min(o-3,9-o,1),-1);return[r(0),r(8),r(4)]}function y4(n,e,t){const i=(r,s=(r+n/60)%6)=>t-t*e*Math.max(Math.min(s,4-s,1),0);return[i(5),i(3),i(1)]}function x4(n,e,t){const i=AC(n,1,.5);let r;for(e+t>1&&(r=1/(e+t),e*=r,t*=r),r=0;r<3;r++)i[r]*=1-e-t,i[r]+=e;return i}function v4(n,e,t,i,r){return n===r?(e-t)/i+(e.5?f/(2-s-o):f/(s+o),c=v4(t,i,r,f,s),c=c*60+.5),[c|0,h||0,a]}function J0(n,e,t,i){return(Array.isArray(e)?n(e[0],e[1],e[2]):n(e,t,i)).map(Yr)}function ey(n,e,t){return J0(AC,n,e,t)}function b4(n,e,t){return J0(x4,n,e,t)}function S4(n,e,t){return J0(y4,n,e,t)}function EC(n){return(n%360+360)%360}function w4(n){const e=O4.exec(n);let t=255,i;if(!e)return;e[5]!==i&&(t=e[6]?ya(+e[5]):Yr(+e[5]));const r=EC(+e[2]),s=+e[3]/100,o=+e[4]/100;return e[1]==="hwb"?i=b4(r,s,o):e[1]==="hsv"?i=S4(r,s,o):i=ey(r,s,o),{r:i[0],g:i[1],b:i[2],a:t}}function k4(n,e){var t=K0(n);t[0]=EC(t[0]+e),t=ey(t),n.r=t[0],n.g=t[1],n.b=t[2]}function P4(n){if(!n)return;const e=K0(n),t=e[0],i=GS(e[1]),r=GS(e[2]);return n.a<255?`hsla(${t}, ${i}%, ${r}%, ${tr(n.a)})`:`hsl(${t}, ${i}%, ${r}%)`}const KS={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},JS={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};function _4(){const n={},e=Object.keys(JS),t=Object.keys(KS);let i,r,s,o,a;for(i=0;i>16&255,s>>8&255,s&255]}return n}let uh;function Q4(n){uh||(uh=_4(),uh.transparent=[0,0,0,0]);const e=uh[n.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:e.length===4?e[3]:255}}const C4=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;function T4(n){const e=C4.exec(n);let t=255,i,r,s;if(e){if(e[7]!==i){const o=+e[7];t=e[8]?ya(o):Br(o*255,0,255)}return i=+e[1],r=+e[3],s=+e[5],i=255&(e[2]?ya(i):Br(i,0,255)),r=255&(e[4]?ya(r):Br(r,0,255)),s=255&(e[6]?ya(s):Br(s,0,255)),{r:i,g:r,b:s,a:t}}}function $4(n){return n&&(n.a<255?`rgba(${n.r}, ${n.g}, ${n.b}, ${tr(n.a)})`:`rgb(${n.r}, ${n.g}, ${n.b})`)}const tm=n=>n<=.0031308?n*12.92:Math.pow(n,1/2.4)*1.055-.055,Po=n=>n<=.04045?n/12.92:Math.pow((n+.055)/1.055,2.4);function M4(n,e,t){const i=Po(tr(n.r)),r=Po(tr(n.g)),s=Po(tr(n.b));return{r:Yr(tm(i+t*(Po(tr(e.r))-i))),g:Yr(tm(r+t*(Po(tr(e.g))-r))),b:Yr(tm(s+t*(Po(tr(e.b))-s))),a:n.a+t*(e.a-n.a)}}function hh(n,e,t){if(n){let i=K0(n);i[e]=Math.max(0,Math.min(i[e]+i[e]*t,e===0?360:1)),i=ey(i),n.r=i[0],n.g=i[1],n.b=i[2]}}function LC(n,e){return n&&Object.assign(e||{},n)}function ew(n){var e={r:0,g:0,b:0,a:255};return Array.isArray(n)?n.length>=3&&(e={r:n[0],g:n[1],b:n[2],a:255},n.length>3&&(e.a=Yr(n[3]))):(e=LC(n,{r:0,g:0,b:0,a:1}),e.a=Yr(e.a)),e}function R4(n){return n.charAt(0)==="r"?T4(n):w4(n)}class oc{constructor(e){if(e instanceof oc)return e;const t=typeof e;let i;t==="object"?i=ew(e):t==="string"&&(i=p4(e)||Q4(e)||R4(e)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var e=LC(this._rgb);return e&&(e.a=tr(e.a)),e}set rgb(e){this._rgb=ew(e)}rgbString(){return this._valid?$4(this._rgb):void 0}hexString(){return this._valid?m4(this._rgb):void 0}hslString(){return this._valid?P4(this._rgb):void 0}mix(e,t){if(e){const i=this.rgb,r=e.rgb;let s;const o=t===s?.5:t,a=2*o-1,c=i.a-r.a,h=((a*c===-1?a:(a+c)/(1+a*c))+1)/2;s=1-h,i.r=255&h*i.r+s*r.r+.5,i.g=255&h*i.g+s*r.g+.5,i.b=255&h*i.b+s*r.b+.5,i.a=o*i.a+(1-o)*r.a,this.rgb=i}return this}interpolate(e,t){return e&&(this._rgb=M4(this._rgb,e._rgb,t)),this}clone(){return new oc(this.rgb)}alpha(e){return this._rgb.a=Yr(e),this}clearer(e){const t=this._rgb;return t.a*=1-e,this}greyscale(){const e=this._rgb,t=kc(e.r*.3+e.g*.59+e.b*.11);return e.r=e.g=e.b=t,this}opaquer(e){const t=this._rgb;return t.a*=1+e,this}negate(){const e=this._rgb;return e.r=255-e.r,e.g=255-e.g,e.b=255-e.b,this}lighten(e){return hh(this._rgb,2,e),this}darken(e){return hh(this._rgb,2,-e),this}saturate(e){return hh(this._rgb,1,e),this}desaturate(e){return hh(this._rgb,1,-e),this}rotate(e){return k4(this._rgb,e),this}}function Ui(){}const A4=(()=>{let n=0;return()=>n++})();function Be(n){return n==null}function bt(n){if(Array.isArray&&Array.isArray(n))return!0;const e=Object.prototype.toString.call(n);return e.slice(0,7)==="[object"&&e.slice(-6)==="Array]"}function De(n){return n!==null&&Object.prototype.toString.call(n)==="[object Object]"}function nn(n){return(typeof n=="number"||n instanceof Number)&&isFinite(+n)}function ki(n,e){return nn(n)?n:e}function Re(n,e){return typeof n>"u"?e:n}const E4=(n,e)=>typeof n=="string"&&n.endsWith("%")?parseFloat(n)/100:+n/e,DC=(n,e)=>typeof n=="string"&&n.endsWith("%")?parseFloat(n)/100*e:+n;function it(n,e,t){if(n&&typeof n.call=="function")return n.apply(t,e)}function Ye(n,e,t,i){let r,s,o;if(bt(n))for(s=n.length,r=0;rn,x:n=>n.x,y:n=>n.y};function z4(n){const e=n.split("."),t=[];let i="";for(const r of e)i+=r,i.endsWith("\\")?i=i.slice(0,-1)+".":(t.push(i),i="");return t}function j4(n){const e=z4(n);return t=>{for(const i of e){if(i==="")break;t=t&&t[i]}return t}}function qs(n,e){return(tw[e]||(tw[e]=j4(e)))(n)}function ty(n){return n.charAt(0).toUpperCase()+n.slice(1)}const ac=n=>typeof n<"u",es=n=>typeof n=="function",nw=(n,e)=>{if(n.size!==e.size)return!1;for(const t of n)if(!e.has(t))return!1;return!0};function Z4(n){return n.type==="mouseup"||n.type==="click"||n.type==="contextmenu"}const qe=Math.PI,ut=2*qe,I4=ut+qe,Mf=Number.POSITIVE_INFINITY,N4=qe/180,Mt=qe/2,vs=qe/4,iw=qe*2/3,jC=Math.log10,zi=Math.sign;function Ra(n,e,t){return Math.abs(n-e)r-s).pop(),e}function X4(n){return typeof n=="symbol"||typeof n=="object"&&n!==null&&!(Symbol.toPrimitive in n||"toString"in n||"valueOf"in n)}function nl(n){return!X4(n)&&!isNaN(parseFloat(n))&&isFinite(n)}function W4(n,e){const t=Math.round(n);return t-e<=n&&t+e>=n}function V4(n,e,t){let i,r,s;for(i=0,r=n.length;ic&&h=Math.min(e,t)-i&&n<=Math.max(e,t)+i}function ny(n,e,t){t=t||(o=>n[o]1;)s=r+i>>1,t(s)?r=s:i=s;return{lo:r,hi:i}}const Ls=(n,e,t,i)=>ny(n,t,i?r=>{const s=n[r][e];return sn[r][e]ny(n,t,i=>n[i][e]>=t);function H4(n,e,t){let i=0,r=n.length;for(;ii&&n[r-1]>t;)r--;return i>0||r{const i="_onData"+ty(t),r=n[t];Object.defineProperty(n,t,{configurable:!0,enumerable:!1,value(...s){const o=r.apply(this,s);return n._chartjs.listeners.forEach(a=>{typeof a[i]=="function"&&a[i](...s)}),o}})})}function ow(n,e){const t=n._chartjs;if(!t)return;const i=t.listeners,r=i.indexOf(e);r!==-1&&i.splice(r,1),!(i.length>0)&&(IC.forEach(s=>{delete n[s]}),delete n._chartjs)}function NC(n){const e=new Set(n);return e.size===n.length?n:Array.from(e)}const BC=(function(){return typeof window>"u"?function(n){return n()}:window.requestAnimationFrame})();function XC(n,e){let t=[],i=!1;return function(...r){t=r,i||(i=!0,BC.call(window,()=>{i=!1,n.apply(e,t)}))}}function K4(n,e){let t;return function(...i){return e?(clearTimeout(t),t=setTimeout(n,e,i)):n.apply(this,i),e}}const iy=n=>n==="start"?"left":n==="end"?"right":"center",Gt=(n,e,t)=>n==="start"?e:n==="end"?t:(e+t)/2,J4=(n,e,t,i)=>n===(i?"left":"right")?t:n==="center"?(e+t)/2:e;function WC(n,e,t){const i=e.length;let r=0,s=i;if(n._sorted){const{iScale:o,vScale:a,_parsed:c}=n,h=n.dataset&&n.dataset.options?n.dataset.options.spanGaps:null,f=o.axis,{min:p,max:m,minDefined:O,maxDefined:v}=o.getUserBounds();if(O){if(r=Math.min(Ls(c,f,p).lo,t?i:Ls(e,f,o.getPixelForValue(p)).lo),h){const b=c.slice(0,r+1).reverse().findIndex(S=>!Be(S[a.axis]));r-=Math.max(0,b)}r=en(r,0,i-1)}if(v){let b=Math.max(Ls(c,o.axis,m,!0).hi+1,t?0:Ls(e,f,o.getPixelForValue(m),!0).hi+1);if(h){const S=c.slice(b-1).findIndex(w=>!Be(w[a.axis]));b+=Math.max(0,S)}s=en(b,r,i)-r}else s=i-r}return{start:r,count:s}}function VC(n){const{xScale:e,yScale:t,_scaleRanges:i}=n,r={xmin:e.min,xmax:e.max,ymin:t.min,ymax:t.max};if(!i)return n._scaleRanges=r,!0;const s=i.xmin!==e.min||i.xmax!==e.max||i.ymin!==t.min||i.ymax!==t.max;return Object.assign(i,r),s}const fh=n=>n===0||n===1,lw=(n,e,t)=>-(Math.pow(2,10*(n-=1))*Math.sin((n-e)*ut/t)),aw=(n,e,t)=>Math.pow(2,-10*n)*Math.sin((n-e)*ut/t)+1,Aa={linear:n=>n,easeInQuad:n=>n*n,easeOutQuad:n=>-n*(n-2),easeInOutQuad:n=>(n/=.5)<1?.5*n*n:-.5*(--n*(n-2)-1),easeInCubic:n=>n*n*n,easeOutCubic:n=>(n-=1)*n*n+1,easeInOutCubic:n=>(n/=.5)<1?.5*n*n*n:.5*((n-=2)*n*n+2),easeInQuart:n=>n*n*n*n,easeOutQuart:n=>-((n-=1)*n*n*n-1),easeInOutQuart:n=>(n/=.5)<1?.5*n*n*n*n:-.5*((n-=2)*n*n*n-2),easeInQuint:n=>n*n*n*n*n,easeOutQuint:n=>(n-=1)*n*n*n*n+1,easeInOutQuint:n=>(n/=.5)<1?.5*n*n*n*n*n:.5*((n-=2)*n*n*n*n+2),easeInSine:n=>-Math.cos(n*Mt)+1,easeOutSine:n=>Math.sin(n*Mt),easeInOutSine:n=>-.5*(Math.cos(qe*n)-1),easeInExpo:n=>n===0?0:Math.pow(2,10*(n-1)),easeOutExpo:n=>n===1?1:-Math.pow(2,-10*n)+1,easeInOutExpo:n=>fh(n)?n:n<.5?.5*Math.pow(2,10*(n*2-1)):.5*(-Math.pow(2,-10*(n*2-1))+2),easeInCirc:n=>n>=1?n:-(Math.sqrt(1-n*n)-1),easeOutCirc:n=>Math.sqrt(1-(n-=1)*n),easeInOutCirc:n=>(n/=.5)<1?-.5*(Math.sqrt(1-n*n)-1):.5*(Math.sqrt(1-(n-=2)*n)+1),easeInElastic:n=>fh(n)?n:lw(n,.075,.3),easeOutElastic:n=>fh(n)?n:aw(n,.075,.3),easeInOutElastic(n){return fh(n)?n:n<.5?.5*lw(n*2,.1125,.45):.5+.5*aw(n*2-1,.1125,.45)},easeInBack(n){return n*n*((1.70158+1)*n-1.70158)},easeOutBack(n){return(n-=1)*n*((1.70158+1)*n+1.70158)+1},easeInOutBack(n){let e=1.70158;return(n/=.5)<1?.5*(n*n*(((e*=1.525)+1)*n-e)):.5*((n-=2)*n*(((e*=1.525)+1)*n+e)+2)},easeInBounce:n=>1-Aa.easeOutBounce(1-n),easeOutBounce(n){return n<1/2.75?7.5625*n*n:n<2/2.75?7.5625*(n-=1.5/2.75)*n+.75:n<2.5/2.75?7.5625*(n-=2.25/2.75)*n+.9375:7.5625*(n-=2.625/2.75)*n+.984375},easeInOutBounce:n=>n<.5?Aa.easeInBounce(n*2)*.5:Aa.easeOutBounce(n*2-1)*.5+.5};function ry(n){if(n&&typeof n=="object"){const e=n.toString();return e==="[object CanvasPattern]"||e==="[object CanvasGradient]"}return!1}function cw(n){return ry(n)?n:new oc(n)}function nm(n){return ry(n)?n:new oc(n).saturate(.5).darken(.1).hexString()}const eI=["x","y","borderWidth","radius","tension"],tI=["color","borderColor","backgroundColor"];function nI(n){n.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),n.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:e=>e!=="onProgress"&&e!=="onComplete"&&e!=="fn"}),n.set("animations",{colors:{type:"color",properties:tI},numbers:{type:"number",properties:eI}}),n.describe("animations",{_fallback:"animation"}),n.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:e=>e|0}}}})}function iI(n){n.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})}const uw=new Map;function rI(n,e){e=e||{};const t=n+JSON.stringify(e);let i=uw.get(t);return i||(i=new Intl.NumberFormat(n,e),uw.set(t,i)),i}function sy(n,e,t){return rI(e,t).format(n)}const sI={values(n){return bt(n)?n:""+n},numeric(n,e,t){if(n===0)return"0";const i=this.chart.options.locale;let r,s=n;if(t.length>1){const h=Math.max(Math.abs(t[0].value),Math.abs(t[t.length-1].value));(h<1e-4||h>1e15)&&(r="scientific"),s=oI(n,t)}const o=jC(Math.abs(s)),a=isNaN(o)?1:Math.max(Math.min(-1*Math.floor(o),20),0),c={notation:r,minimumFractionDigits:a,maximumFractionDigits:a};return Object.assign(c,this.options.ticks.format),sy(n,i,c)}};function oI(n,e){let t=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;return Math.abs(t)>=1&&n!==Math.floor(n)&&(t=n-Math.floor(n)),t}var FC={formatters:sI};function lI(n){n.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(e,t)=>t.lineWidth,tickColor:(e,t)=>t.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:FC.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),n.route("scale.ticks","color","","color"),n.route("scale.grid","color","","borderColor"),n.route("scale.border","color","","borderColor"),n.route("scale.title","color","","color"),n.describe("scale",{_fallback:!1,_scriptable:e=>!e.startsWith("before")&&!e.startsWith("after")&&e!=="callback"&&e!=="parser",_indexable:e=>e!=="borderDash"&&e!=="tickBorderDash"&&e!=="dash"}),n.describe("scales",{_fallback:"scale"}),n.describe("scale.ticks",{_scriptable:e=>e!=="backdropPadding"&&e!=="callback",_indexable:e=>e!=="backdropPadding"})}const Us=Object.create(null),ZO=Object.create(null);function Ea(n,e){if(!e)return n;const t=e.split(".");for(let i=0,r=t.length;ii.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(i,r)=>nm(r.backgroundColor),this.hoverBorderColor=(i,r)=>nm(r.borderColor),this.hoverColor=(i,r)=>nm(r.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(e),this.apply(t)}set(e,t){return im(this,e,t)}get(e){return Ea(this,e)}describe(e,t){return im(ZO,e,t)}override(e,t){return im(Us,e,t)}route(e,t,i,r){const s=Ea(this,e),o=Ea(this,i),a="_"+t;Object.defineProperties(s,{[a]:{value:s[t],writable:!0},[t]:{enumerable:!0,get(){const c=this[a],h=o[r];return De(c)?Object.assign({},h,c):Re(c,h)},set(c){this[a]=c}}})}apply(e){e.forEach(t=>t(this))}}var Ot=new aI({_scriptable:n=>!n.startsWith("on"),_indexable:n=>n!=="events",hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[nI,iI,lI]);function cI(n){return!n||Be(n.size)||Be(n.family)?null:(n.style?n.style+" ":"")+(n.weight?n.weight+" ":"")+n.size+"px "+n.family}function hw(n,e,t,i,r){let s=e[r];return s||(s=e[r]=n.measureText(r).width,t.push(r)),s>i&&(i=s),i}function bs(n,e,t){const i=n.currentDevicePixelRatio,r=t!==0?Math.max(t/2,.5):0;return Math.round((e-r)*i)/i+r}function fw(n,e){!e&&!n||(e=e||n.getContext("2d"),e.save(),e.resetTransform(),e.clearRect(0,0,n.width,n.height),e.restore())}function IO(n,e,t,i){YC(n,e,t,i,null)}function YC(n,e,t,i,r){let s,o,a,c,h,f,p,m;const O=e.pointStyle,v=e.rotation,b=e.radius;let S=(v||0)*N4;if(O&&typeof O=="object"&&(s=O.toString(),s==="[object HTMLImageElement]"||s==="[object HTMLCanvasElement]")){n.save(),n.translate(t,i),n.rotate(S),n.drawImage(O,-O.width/2,-O.height/2,O.width,O.height),n.restore();return}if(!(isNaN(b)||b<=0)){switch(n.beginPath(),O){default:r?n.ellipse(t,i,r/2,b,0,0,ut):n.arc(t,i,b,0,ut),n.closePath();break;case"triangle":f=r?r/2:b,n.moveTo(t+Math.sin(S)*f,i-Math.cos(S)*b),S+=iw,n.lineTo(t+Math.sin(S)*f,i-Math.cos(S)*b),S+=iw,n.lineTo(t+Math.sin(S)*f,i-Math.cos(S)*b),n.closePath();break;case"rectRounded":h=b*.516,c=b-h,o=Math.cos(S+vs)*c,p=Math.cos(S+vs)*(r?r/2-h:c),a=Math.sin(S+vs)*c,m=Math.sin(S+vs)*(r?r/2-h:c),n.arc(t-p,i-a,h,S-qe,S-Mt),n.arc(t+m,i-o,h,S-Mt,S),n.arc(t+p,i+a,h,S,S+Mt),n.arc(t-m,i+o,h,S+Mt,S+qe),n.closePath();break;case"rect":if(!v){c=Math.SQRT1_2*b,f=r?r/2:c,n.rect(t-f,i-c,2*f,2*c);break}S+=vs;case"rectRot":p=Math.cos(S)*(r?r/2:b),o=Math.cos(S)*b,a=Math.sin(S)*b,m=Math.sin(S)*(r?r/2:b),n.moveTo(t-p,i-a),n.lineTo(t+m,i-o),n.lineTo(t+p,i+a),n.lineTo(t-m,i+o),n.closePath();break;case"crossRot":S+=vs;case"cross":p=Math.cos(S)*(r?r/2:b),o=Math.cos(S)*b,a=Math.sin(S)*b,m=Math.sin(S)*(r?r/2:b),n.moveTo(t-p,i-a),n.lineTo(t+p,i+a),n.moveTo(t+m,i-o),n.lineTo(t-m,i+o);break;case"star":p=Math.cos(S)*(r?r/2:b),o=Math.cos(S)*b,a=Math.sin(S)*b,m=Math.sin(S)*(r?r/2:b),n.moveTo(t-p,i-a),n.lineTo(t+p,i+a),n.moveTo(t+m,i-o),n.lineTo(t-m,i+o),S+=vs,p=Math.cos(S)*(r?r/2:b),o=Math.cos(S)*b,a=Math.sin(S)*b,m=Math.sin(S)*(r?r/2:b),n.moveTo(t-p,i-a),n.lineTo(t+p,i+a),n.moveTo(t+m,i-o),n.lineTo(t-m,i+o);break;case"line":o=r?r/2:Math.cos(S)*b,a=Math.sin(S)*b,n.moveTo(t-o,i-a),n.lineTo(t+o,i+a);break;case"dash":n.moveTo(t,i),n.lineTo(t+Math.cos(S)*(r?r/2:b),i+Math.sin(S)*b);break;case!1:n.closePath();break}n.fill(),e.borderWidth>0&&n.stroke()}}function uc(n,e,t){return t=t||.5,!e||n&&n.x>e.left-t&&n.xe.top-t&&n.y0&&s.strokeColor!=="";let c,h;for(n.save(),n.font=r.string,fI(n,s),c=0;c+n||0;function oy(n,e){const t={},i=De(e),r=i?Object.keys(e):e,s=De(n)?i?o=>Re(n[o],n[e[o]]):o=>n[o]:()=>n;for(const o of r)t[o]=yI(s(o));return t}function qC(n){return oy(n,{top:"y",right:"x",bottom:"y",left:"x"})}function Xo(n){return oy(n,["topLeft","topRight","bottomLeft","bottomRight"])}function Un(n){const e=qC(n);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function tn(n,e){n=n||{},e=e||Ot.font;let t=Re(n.size,e.size);typeof t=="string"&&(t=parseInt(t,10));let i=Re(n.style,e.style);i&&!(""+i).match(mI)&&(console.warn('Invalid font style specified: "'+i+'"'),i=void 0);const r={family:Re(n.family,e.family),lineHeight:OI(Re(n.lineHeight,e.lineHeight),t),size:t,style:i,weight:Re(n.weight,e.weight),string:""};return r.string=cI(r),r}function dh(n,e,t,i){let r,s,o;for(r=0,s=n.length;rt&&a===0?0:a+c;return{min:o(i,-Math.abs(s)),max:o(r,s)}}function Hs(n,e){return Object.assign(Object.create(n),e)}function ly(n,e=[""],t,i,r=()=>n[0]){const s=t||n;typeof i>"u"&&(i=KC("_fallback",n));const o={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:n,_rootScopes:s,_fallback:i,_getTarget:r,override:a=>ly([a,...n],e,s,i)};return new Proxy(o,{deleteProperty(a,c){return delete a[c],delete a._keys,delete n[0][c],!0},get(a,c){return HC(a,c,()=>QI(c,e,n,a))},getOwnPropertyDescriptor(a,c){return Reflect.getOwnPropertyDescriptor(a._scopes[0],c)},getPrototypeOf(){return Reflect.getPrototypeOf(n[0])},has(a,c){return pw(a).includes(c)},ownKeys(a){return pw(a)},set(a,c,h){const f=a._storage||(a._storage=r());return a[c]=f[c]=h,delete a._keys,!0}})}function il(n,e,t,i){const r={_cacheable:!1,_proxy:n,_context:e,_subProxy:t,_stack:new Set,_descriptors:UC(n,i),setContext:s=>il(n,s,t,i),override:s=>il(n.override(s),e,t,i)};return new Proxy(r,{deleteProperty(s,o){return delete s[o],delete n[o],!0},get(s,o,a){return HC(s,o,()=>bI(s,o,a))},getOwnPropertyDescriptor(s,o){return s._descriptors.allKeys?Reflect.has(n,o)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(n,o)},getPrototypeOf(){return Reflect.getPrototypeOf(n)},has(s,o){return Reflect.has(n,o)},ownKeys(){return Reflect.ownKeys(n)},set(s,o,a){return n[o]=a,delete s[o],!0}})}function UC(n,e={scriptable:!0,indexable:!0}){const{_scriptable:t=e.scriptable,_indexable:i=e.indexable,_allKeys:r=e.allKeys}=n;return{allKeys:r,scriptable:t,indexable:i,isScriptable:es(t)?t:()=>t,isIndexable:es(i)?i:()=>i}}const vI=(n,e)=>n?n+ty(e):e,ay=(n,e)=>De(e)&&n!=="adapters"&&(Object.getPrototypeOf(e)===null||e.constructor===Object);function HC(n,e,t){if(Object.prototype.hasOwnProperty.call(n,e)||e==="constructor")return n[e];const i=t();return n[e]=i,i}function bI(n,e,t){const{_proxy:i,_context:r,_subProxy:s,_descriptors:o}=n;let a=i[e];return es(a)&&o.isScriptable(e)&&(a=SI(e,a,n,t)),bt(a)&&a.length&&(a=wI(e,a,n,o.isIndexable)),ay(e,a)&&(a=il(a,r,s&&s[e],o)),a}function SI(n,e,t,i){const{_proxy:r,_context:s,_subProxy:o,_stack:a}=t;if(a.has(n))throw new Error("Recursion detected: "+Array.from(a).join("->")+"->"+n);a.add(n);let c=e(s,o||i);return a.delete(n),ay(n,c)&&(c=cy(r._scopes,r,n,c)),c}function wI(n,e,t,i){const{_proxy:r,_context:s,_subProxy:o,_descriptors:a}=t;if(typeof s.index<"u"&&i(n))return e[s.index%e.length];if(De(e[0])){const c=e,h=r._scopes.filter(f=>f!==c);e=[];for(const f of c){const p=cy(h,r,n,f);e.push(il(p,s,o&&o[n],a))}}return e}function GC(n,e,t){return es(n)?n(e,t):n}const kI=(n,e)=>n===!0?e:typeof n=="string"?qs(e,n):void 0;function PI(n,e,t,i,r){for(const s of e){const o=kI(t,s);if(o){n.add(o);const a=GC(o._fallback,t,r);if(typeof a<"u"&&a!==t&&a!==i)return a}else if(o===!1&&typeof i<"u"&&t!==i)return null}return!1}function cy(n,e,t,i){const r=e._rootScopes,s=GC(e._fallback,t,i),o=[...n,...r],a=new Set;a.add(i);let c=dw(a,o,t,s||t,i);return c===null||typeof s<"u"&&s!==t&&(c=dw(a,o,s,c,i),c===null)?!1:ly(Array.from(a),[""],r,s,()=>_I(e,t,i))}function dw(n,e,t,i,r){for(;t;)t=PI(n,e,t,i,r);return t}function _I(n,e,t){const i=n._getTarget();e in i||(i[e]={});const r=i[e];return bt(r)&&De(t)?t:r||{}}function QI(n,e,t,i){let r;for(const s of e)if(r=KC(vI(s,n),t),typeof r<"u")return ay(n,r)?cy(t,i,n,r):r}function KC(n,e){for(const t of e){if(!t)continue;const i=t[n];if(typeof i<"u")return i}}function pw(n){let e=n._keys;return e||(e=n._keys=CI(n._scopes)),e}function CI(n){const e=new Set;for(const t of n)for(const i of Object.keys(t).filter(r=>!r.startsWith("_")))e.add(i);return Array.from(e)}const TI=Number.EPSILON||1e-14,rl=(n,e)=>en==="x"?"y":"x";function $I(n,e,t,i){const r=n.skip?e:n,s=e,o=t.skip?e:t,a=jO(s,r),c=jO(o,s);let h=a/(a+c),f=c/(a+c);h=isNaN(h)?0:h,f=isNaN(f)?0:f;const p=i*h,m=i*f;return{previous:{x:s.x-p*(o.x-r.x),y:s.y-p*(o.y-r.y)},next:{x:s.x+m*(o.x-r.x),y:s.y+m*(o.y-r.y)}}}function MI(n,e,t){const i=n.length;let r,s,o,a,c,h=rl(n,0);for(let f=0;f!h.skip)),e.cubicInterpolationMode==="monotone")AI(n,r);else{let h=i?n[n.length-1]:n[0];for(s=0,o=n.length;sn.ownerDocument.defaultView.getComputedStyle(n,null);function DI(n,e){return ld(n).getPropertyValue(e)}const zI=["top","right","bottom","left"];function Is(n,e,t){const i={};t=t?"-"+t:"";for(let r=0;r<4;r++){const s=zI[r];i[s]=parseFloat(n[e+"-"+s+t])||0}return i.width=i.left+i.right,i.height=i.top+i.bottom,i}const jI=(n,e,t)=>(n>0||e>0)&&(!t||!t.shadowRoot);function ZI(n,e){const t=n.touches,i=t&&t.length?t[0]:n,{offsetX:r,offsetY:s}=i;let o=!1,a,c;if(jI(r,s,n.target))a=r,c=s;else{const h=e.getBoundingClientRect();a=i.clientX-h.left,c=i.clientY-h.top,o=!0}return{x:a,y:c,box:o}}function Cs(n,e){if("native"in n)return n;const{canvas:t,currentDevicePixelRatio:i}=e,r=ld(t),s=r.boxSizing==="border-box",o=Is(r,"padding"),a=Is(r,"border","width"),{x:c,y:h,box:f}=ZI(n,t),p=o.left+(f&&a.left),m=o.top+(f&&a.top);let{width:O,height:v}=e;return s&&(O-=o.width+a.width,v-=o.height+a.height),{x:Math.round((c-p)/O*t.width/i),y:Math.round((h-m)/v*t.height/i)}}function II(n,e,t){let i,r;if(e===void 0||t===void 0){const s=n&&hy(n);if(!s)e=n.clientWidth,t=n.clientHeight;else{const o=s.getBoundingClientRect(),a=ld(s),c=Is(a,"border","width"),h=Is(a,"padding");e=o.width-h.width-c.width,t=o.height-h.height-c.height,i=Af(a.maxWidth,s,"clientWidth"),r=Af(a.maxHeight,s,"clientHeight")}}return{width:e,height:t,maxWidth:i||Mf,maxHeight:r||Mf}}const gh=n=>Math.round(n*10)/10;function NI(n,e,t,i){const r=ld(n),s=Is(r,"margin"),o=Af(r.maxWidth,n,"clientWidth")||Mf,a=Af(r.maxHeight,n,"clientHeight")||Mf,c=II(n,e,t);let{width:h,height:f}=c;if(r.boxSizing==="content-box"){const m=Is(r,"border","width"),O=Is(r,"padding");h-=O.width+m.width,f-=O.height+m.height}return h=Math.max(0,h-s.width),f=Math.max(0,i?h/i:f-s.height),h=gh(Math.min(h,o,c.maxWidth)),f=gh(Math.min(f,a,c.maxHeight)),h&&!f&&(f=gh(h/2)),(e!==void 0||t!==void 0)&&i&&c.height&&f>c.height&&(f=c.height,h=gh(Math.floor(f*i))),{width:h,height:f}}function gw(n,e,t){const i=e||1,r=Math.floor(n.height*i),s=Math.floor(n.width*i);n.height=Math.floor(n.height),n.width=Math.floor(n.width);const o=n.canvas;return o.style&&(t||!o.style.height&&!o.style.width)&&(o.style.height=`${n.height}px`,o.style.width=`${n.width}px`),n.currentDevicePixelRatio!==i||o.height!==r||o.width!==s?(n.currentDevicePixelRatio=i,o.height=r,o.width=s,n.ctx.setTransform(i,0,0,i,0,0),!0):!1}const BI=(function(){let n=!1;try{const e={get passive(){return n=!0,!1}};uy()&&(window.addEventListener("test",null,e),window.removeEventListener("test",null,e))}catch{}return n})();function mw(n,e){const t=DI(n,e),i=t&&t.match(/^(\d+)(\.\d+)?px$/);return i?+i[1]:void 0}function Ts(n,e,t,i){return{x:n.x+t*(e.x-n.x),y:n.y+t*(e.y-n.y)}}function XI(n,e,t,i){return{x:n.x+t*(e.x-n.x),y:i==="middle"?t<.5?n.y:e.y:i==="after"?t<1?n.y:e.y:t>0?e.y:n.y}}function WI(n,e,t,i){const r={x:n.cp2x,y:n.cp2y},s={x:e.cp1x,y:e.cp1y},o=Ts(n,r,t),a=Ts(r,s,t),c=Ts(s,e,t),h=Ts(o,a,t),f=Ts(a,c,t);return Ts(h,f,t)}const VI=function(n,e){return{x(t){return n+n+e-t},setWidth(t){e=t},textAlign(t){return t==="center"?t:t==="right"?"left":"right"},xPlus(t,i){return t-i},leftForLtr(t,i){return t-i}}},FI=function(){return{x(n){return n},setWidth(n){},textAlign(n){return n},xPlus(n,e){return n+e},leftForLtr(n,e){return n}}};function Wo(n,e,t){return n?VI(e,t):FI()}function eT(n,e){let t,i;(e==="ltr"||e==="rtl")&&(t=n.canvas.style,i=[t.getPropertyValue("direction"),t.getPropertyPriority("direction")],t.setProperty("direction",e,"important"),n.prevTextDirection=i)}function tT(n,e){e!==void 0&&(delete n.prevTextDirection,n.canvas.style.setProperty("direction",e[0],e[1]))}function nT(n){return n==="angle"?{between:cc,compare:Y4,normalize:An}:{between:rr,compare:(e,t)=>e-t,normalize:e=>e}}function Ow({start:n,end:e,count:t,loop:i,style:r}){return{start:n%t,end:e%t,loop:i&&(e-n+1)%t===0,style:r}}function YI(n,e,t){const{property:i,start:r,end:s}=t,{between:o,normalize:a}=nT(i),c=e.length;let{start:h,end:f,loop:p}=n,m,O;if(p){for(h+=c,f+=c,m=0,O=c;mc(r,k,w)&&a(r,k)!==0,C=()=>a(s,w)===0||c(s,k,w),M=()=>b||_(),R=()=>!b||C();for(let L=f,X=f;L<=p;++L)T=e[L%o],!T.skip&&(w=h(T[i]),w!==k&&(b=c(w,r,s),S===null&&M()&&(S=a(w,r)===0?L:X),S!==null&&R()&&(v.push(Ow({start:S,end:L,loop:m,count:o,style:O})),S=null),X=L,k=w));return S!==null&&v.push(Ow({start:S,end:p,loop:m,count:o,style:O})),v}function rT(n,e){const t=[],i=n.segments;for(let r=0;rr&&n[s%e].skip;)s--;return s%=e,{start:r,end:s}}function UI(n,e,t,i){const r=n.length,s=[];let o=e,a=n[e],c;for(c=e+1;c<=t;++c){const h=n[c%r];h.skip||h.stop?a.skip||(i=!1,s.push({start:e%r,end:(c-1)%r,loop:i}),e=o=h.stop?c:null):(o=c,a.skip&&(e=c)),a=h}return o!==null&&s.push({start:e%r,end:o%r,loop:i}),s}function HI(n,e){const t=n.points,i=n.options.spanGaps,r=t.length;if(!r)return[];const s=!!n._loop,{start:o,end:a}=qI(t,r,s,i);if(i===!0)return yw(n,[{start:o,end:a,loop:s}],t,e);const c=aa({chart:e,initial:t.initial,numSteps:o,currentStep:Math.min(i-t.start,o)}))}_refresh(){this._request||(this._running=!0,this._request=BC.call(window,()=>{this._update(),this._request=null,this._running&&this._refresh()}))}_update(e=Date.now()){let t=0;this._charts.forEach((i,r)=>{if(!i.running||!i.items.length)return;const s=i.items;let o=s.length-1,a=!1,c;for(;o>=0;--o)c=s[o],c._active?(c._total>i.duration&&(i.duration=c._total),c.tick(e),a=!0):(s[o]=s[s.length-1],s.pop());a&&(r.draw(),this._notify(r,i,e,"progress")),s.length||(i.running=!1,this._notify(r,i,e,"complete"),i.initial=!1),t+=s.length}),this._lastDate=e,t===0&&(this._running=!1)}_getAnims(e){const t=this._charts;let i=t.get(e);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},t.set(e,i)),i}listen(e,t,i){this._getAnims(e).listeners[t].push(i)}add(e,t){!t||!t.length||this._getAnims(e).items.push(...t)}has(e){return this._getAnims(e).items.length>0}start(e){const t=this._charts.get(e);t&&(t.running=!0,t.start=Date.now(),t.duration=t.items.reduce((i,r)=>Math.max(i,r._duration),0),this._refresh())}running(e){if(!this._running)return!1;const t=this._charts.get(e);return!(!t||!t.running||!t.items.length)}stop(e){const t=this._charts.get(e);if(!t||!t.items.length)return;const i=t.items;let r=i.length-1;for(;r>=0;--r)i[r].cancel();t.items=[],this._notify(e,t,Date.now(),"complete")}remove(e){return this._charts.delete(e)}}var Hi=new eN;const vw="transparent",tN={boolean(n,e,t){return t>.5?e:n},color(n,e,t){const i=cw(n||vw),r=i.valid&&cw(e||vw);return r&&r.valid?r.mix(i,t).hexString():e},number(n,e,t){return n+(e-n)*t}};class nN{constructor(e,t,i,r){const s=t[i];r=dh([e.to,r,s,e.from]);const o=dh([e.from,s,r]);this._active=!0,this._fn=e.fn||tN[e.type||typeof o],this._easing=Aa[e.easing]||Aa.linear,this._start=Math.floor(Date.now()+(e.delay||0)),this._duration=this._total=Math.floor(e.duration),this._loop=!!e.loop,this._target=t,this._prop=i,this._from=o,this._to=r,this._promises=void 0}active(){return this._active}update(e,t,i){if(this._active){this._notify(!1);const r=this._target[this._prop],s=i-this._start,o=this._duration-s;this._start=i,this._duration=Math.floor(Math.max(o,e.duration)),this._total+=s,this._loop=!!e.loop,this._to=dh([e.to,t,r,e.from]),this._from=dh([e.from,r,t])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(e){const t=e-this._start,i=this._duration,r=this._prop,s=this._from,o=this._loop,a=this._to;let c;if(this._active=s!==a&&(o||t1?2-c:c,c=this._easing(Math.min(1,Math.max(0,c))),this._target[r]=this._fn(s,a,c)}wait(){const e=this._promises||(this._promises=[]);return new Promise((t,i)=>{e.push({res:t,rej:i})})}_notify(e){const t=e?"res":"rej",i=this._promises||[];for(let r=0;r{const s=e[r];if(!De(s))return;const o={};for(const a of t)o[a]=s[a];(bt(s.properties)&&s.properties||[r]).forEach(a=>{(a===r||!i.has(a))&&i.set(a,o)})})}_animateOptions(e,t){const i=t.options,r=rN(e,i);if(!r)return[];const s=this._createAnimations(r,i);return i.$shared&&iN(e.options.$animations,i).then(()=>{e.options=i},()=>{}),s}_createAnimations(e,t){const i=this._properties,r=[],s=e.$animations||(e.$animations={}),o=Object.keys(t),a=Date.now();let c;for(c=o.length-1;c>=0;--c){const h=o[c];if(h.charAt(0)==="$")continue;if(h==="options"){r.push(...this._animateOptions(e,t));continue}const f=t[h];let p=s[h];const m=i.get(h);if(p)if(m&&p.active()){p.update(m,f,a);continue}else p.cancel();if(!m||!m.duration){e[h]=f;continue}s[h]=p=new nN(m,e,h,f),r.push(p)}return r}update(e,t){if(this._properties.size===0){Object.assign(e,t);return}const i=this._createAnimations(e,t);if(i.length)return Hi.add(this._chart,i),!0}}function iN(n,e){const t=[],i=Object.keys(e);for(let r=0;r0||!t&&s<0)return r.index}return null}function kw(n,e){const{chart:t,_cachedMeta:i}=n,r=t._stacks||(t._stacks={}),{iScale:s,vScale:o,index:a}=i,c=s.axis,h=o.axis,f=aN(s,o,i),p=e.length;let m;for(let O=0;Ot[i].axis===e).shift()}function hN(n,e){return Hs(n,{active:!1,dataset:void 0,datasetIndex:e,index:e,mode:"default",type:"dataset"})}function fN(n,e,t){return Hs(n,{active:!1,dataIndex:e,parsed:void 0,raw:void 0,element:t,index:e,mode:"default",type:"data"})}function oa(n,e){const t=n.controller.index,i=n.vScale&&n.vScale.axis;if(i){e=e||n._parsed;for(const r of e){const s=r._stacks;if(!s||s[i]===void 0||s[i][t]===void 0)return;delete s[i][t],s[i]._visualValues!==void 0&&s[i]._visualValues[t]!==void 0&&delete s[i]._visualValues[t]}}}const om=n=>n==="reset"||n==="none",Pw=(n,e)=>e?n:Object.assign({},n),dN=(n,e,t)=>n&&!e.hidden&&e._stacked&&{keys:lT(t,!0),values:null};class qr{constructor(e,t){this.chart=e,this._ctx=e.ctx,this.index=t,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const e=this._cachedMeta;this.configure(),this.linkScales(),e._stacked=rm(e.vScale,e),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(e){this.index!==e&&oa(this._cachedMeta),this.index=e}linkScales(){const e=this.chart,t=this._cachedMeta,i=this.getDataset(),r=(p,m,O,v)=>p==="x"?m:p==="r"?v:O,s=t.xAxisID=Re(i.xAxisID,sm(e,"x")),o=t.yAxisID=Re(i.yAxisID,sm(e,"y")),a=t.rAxisID=Re(i.rAxisID,sm(e,"r")),c=t.indexAxis,h=t.iAxisID=r(c,s,o,a),f=t.vAxisID=r(c,o,s,a);t.xScale=this.getScaleForId(s),t.yScale=this.getScaleForId(o),t.rScale=this.getScaleForId(a),t.iScale=this.getScaleForId(h),t.vScale=this.getScaleForId(f)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(e){return this.chart.scales[e]}_getOtherScale(e){const t=this._cachedMeta;return e===t.iScale?t.vScale:t.iScale}reset(){this._update("reset")}_destroy(){const e=this._cachedMeta;this._data&&ow(this._data,this),e._stacked&&oa(e)}_dataCheck(){const e=this.getDataset(),t=e.data||(e.data=[]),i=this._data;if(De(t)){const r=this._cachedMeta;this._data=lN(t,r)}else if(i!==t){if(i){ow(i,this);const r=this._cachedMeta;oa(r),r._parsed=[]}t&&Object.isExtensible(t)&&G4(t,this),this._syncList=[],this._data=t}}addElements(){const e=this._cachedMeta;this._dataCheck(),this.datasetElementType&&(e.dataset=new this.datasetElementType)}buildOrUpdateElements(e){const t=this._cachedMeta,i=this.getDataset();let r=!1;this._dataCheck();const s=t._stacked;t._stacked=rm(t.vScale,t),t.stack!==i.stack&&(r=!0,oa(t),t.stack=i.stack),this._resyncElements(e),(r||s!==t._stacked)&&(kw(this,t._parsed),t._stacked=rm(t.vScale,t))}configure(){const e=this.chart.config,t=e.datasetScopeKeys(this._type),i=e.getOptionScopes(this.getDataset(),t,!0);this.options=e.createResolver(i,this.getContext()),this._parsing=this.options.parsing,this._cachedDataOpts={}}parse(e,t){const{_cachedMeta:i,_data:r}=this,{iScale:s,_stacked:o}=i,a=s.axis;let c=e===0&&t===r.length?!0:i._sorted,h=e>0&&i._parsed[e-1],f,p,m;if(this._parsing===!1)i._parsed=r,i._sorted=!0,m=r;else{bt(r[e])?m=this.parseArrayData(i,r,e,t):De(r[e])?m=this.parseObjectData(i,r,e,t):m=this.parsePrimitiveData(i,r,e,t);const O=()=>p[a]===null||h&&p[a]b||p=0;--m)if(!v()){this.updateRangeFromParsed(h,e,O,c);break}}return h}getAllParsedValues(e){const t=this._cachedMeta._parsed,i=[];let r,s,o;for(r=0,s=t.length;r=0&&ethis.getContext(i,r,t),b=h.resolveNamedOptions(m,O,v,p);return b.$shared&&(b.$shared=c,s[o]=Object.freeze(Pw(b,c))),b}_resolveAnimations(e,t,i){const r=this.chart,s=this._cachedDataOpts,o=`animation-${t}`,a=s[o];if(a)return a;let c;if(r.options.animation!==!1){const f=this.chart.config,p=f.datasetAnimationScopeKeys(this._type,t),m=f.getOptionScopes(this.getDataset(),p);c=f.createResolver(m,this.getContext(e,i,t))}const h=new oT(r,c&&c.animations);return c&&c._cacheable&&(s[o]=Object.freeze(h)),h}getSharedOptions(e){if(e.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},e))}includeOptions(e,t){return!t||om(e)||this.chart._animationsDisabled}_getSharedOptions(e,t){const i=this.resolveDataElementOptions(e,t),r=this._sharedOptions,s=this.getSharedOptions(i),o=this.includeOptions(t,s)||s!==r;return this.updateSharedOptions(s,t,i),{sharedOptions:s,includeOptions:o}}updateElement(e,t,i,r){om(r)?Object.assign(e,i):this._resolveAnimations(t,r).update(e,i)}updateSharedOptions(e,t,i){e&&!om(t)&&this._resolveAnimations(void 0,t).update(e,i)}_setStyle(e,t,i,r){e.active=r;const s=this.getStyle(t,r);this._resolveAnimations(t,i,r).update(e,{options:!r&&this.getSharedOptions(s)||s})}removeHoverStyle(e,t,i){this._setStyle(e,i,"active",!1)}setHoverStyle(e,t,i){this._setStyle(e,i,"active",!0)}_removeDatasetHoverStyle(){const e=this._cachedMeta.dataset;e&&this._setStyle(e,void 0,"active",!1)}_setDatasetHoverStyle(){const e=this._cachedMeta.dataset;e&&this._setStyle(e,void 0,"active",!0)}_resyncElements(e){const t=this._data,i=this._cachedMeta.data;for(const[a,c,h]of this._syncList)this[a](c,h);this._syncList=[];const r=i.length,s=t.length,o=Math.min(s,r);o&&this.parse(0,o),s>r?this._insertElements(r,s-r,e):s{for(h.length+=t,a=h.length-1;a>=o;a--)h[a]=h[a-t]};for(c(s),a=e;ar-s))}return n._cache.$bar}function gN(n){const e=n.iScale,t=pN(e,n.type);let i=e._length,r,s,o,a;const c=()=>{o===32767||o===-32768||(ac(a)&&(i=Math.min(i,Math.abs(o-a)||i)),a=o)};for(r=0,s=t.length;r0?r[n-1]:null,a=nMath.abs(a)&&(c=a,h=o),e[t.axis]=h,e._custom={barStart:c,barEnd:h,start:r,end:s,min:o,max:a}}function aT(n,e,t,i){return bt(n)?yN(n,e,t,i):e[t.axis]=t.parse(n,i),e}function _w(n,e,t,i){const r=n.iScale,s=n.vScale,o=r.getLabels(),a=r===s,c=[];let h,f,p,m;for(h=t,f=t+i;h=t?1:-1)}function vN(n){let e,t,i,r,s;return n.horizontal?(e=n.base>n.x,t="left",i="right"):(e=n.basef.controller.options.grouped),s=i.options.stacked,o=[],a=this._cachedMeta.controller.getParsed(t),c=a&&a[i.axis],h=f=>{const p=f._parsed.find(O=>O[i.axis]===c),m=p&&p[f.vScale.axis];if(Be(m)||isNaN(m))return!0};for(const f of r)if(!(t!==void 0&&h(f))&&((s===!1||o.indexOf(f.stack)===-1||s===void 0&&f.stack===void 0)&&o.push(f.stack),f.index===e))break;return o.length||o.push(void 0),o}_getStackCount(e){return this._getStacks(void 0,e).length}_getAxisCount(){return this._getAxis().length}getFirstScaleIdForIndexAxis(){const e=this.chart.scales,t=this.chart.options.indexAxis;return Object.keys(e).filter(i=>e[i].axis===t).shift()}_getAxis(){const e={},t=this.getFirstScaleIdForIndexAxis();for(const i of this.chart.data.datasets)e[Re(this.chart.options.indexAxis==="x"?i.xAxisID:i.yAxisID,t)]=!0;return Object.keys(e)}_getStackIndex(e,t,i){const r=this._getStacks(e,i),s=t!==void 0?r.indexOf(t):-1;return s===-1?r.length-1:s}_getRuler(){const e=this.options,t=this._cachedMeta,i=t.iScale,r=[];let s,o;for(s=0,o=t.data.length;scc(k,a,c,!0)?1:Math.max(_,_*t,C,C*t),v=(k,_,C)=>cc(k,a,c,!0)?-1:Math.min(_,_*t,C,C*t),b=O(0,h,p),S=O(Mt,f,m),w=v(qe,h,p),T=v(qe+Mt,f,m);i=(b-w)/2,r=(S-T)/2,s=-(b+w)/2,o=-(S+T)/2}return{ratioX:i,ratioY:r,offsetX:s,offsetY:o}}class Do extends qr{constructor(e,t){super(e,t),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(e,t){const i=this.getDataset().data,r=this._cachedMeta;if(this._parsing===!1)r._parsed=i;else{let s=c=>+i[c];if(De(i[e])){const{key:c="value"}=this._parsing;s=h=>+qs(i[h],c)}let o,a;for(o=e,a=e+t;o0&&!isNaN(e)?ut*(Math.abs(e)/t):0}getLabelAndValue(e){const t=this._cachedMeta,i=this.chart,r=i.data.labels||[],s=sy(t._parsed[e],i.options.locale);return{label:r[e]||"",value:s}}getMaxBorderWidth(e){let t=0;const i=this.chart;let r,s,o,a,c;if(!e){for(r=0,s=i.data.datasets.length;re!=="spacing",_indexable:e=>e!=="spacing"&&!e.startsWith("borderDash")&&!e.startsWith("hoverBorderDash")}),me(Do,"overrides",{aspectRatio:1,plugins:{legend:{labels:{generateLabels(e){const t=e.data;if(t.labels.length&&t.datasets.length){const{labels:{pointStyle:i,color:r}}=e.legend.options;return t.labels.map((s,o)=>{const c=e.getDatasetMeta(0).controller.getStyle(o);return{text:s,fillStyle:c.backgroundColor,strokeStyle:c.borderColor,fontColor:r,lineWidth:c.borderWidth,pointStyle:i,hidden:!e.getDataVisibility(o),index:o}})}return[]}},onClick(e,t,i){i.chart.toggleDataVisibility(t.index),i.chart.update()}}}});class Xh extends qr{initialize(){this.enableOptionSharing=!0,this.supportsDecimation=!0,super.initialize()}update(e){const t=this._cachedMeta,{dataset:i,data:r=[],_dataset:s}=t,o=this.chart._animationsDisabled;let{start:a,count:c}=WC(t,r,o);this._drawStart=a,this._drawCount=c,VC(t)&&(a=0,c=r.length),i._chart=this.chart,i._datasetIndex=this.index,i._decimated=!!s._decimated,i.points=r;const h=this.resolveDatasetElementOptions(e);this.options.showLine||(h.borderWidth=0),h.segment=this.options.segment,this.updateElement(i,void 0,{animated:!o,options:h},e),this.updateElements(r,a,c,e)}updateElements(e,t,i,r){const s=r==="reset",{iScale:o,vScale:a,_stacked:c,_dataset:h}=this._cachedMeta,{sharedOptions:f,includeOptions:p}=this._getSharedOptions(t,r),m=o.axis,O=a.axis,{spanGaps:v,segment:b}=this.options,S=nl(v)?v:Number.POSITIVE_INFINITY,w=this.chart._animationsDisabled||s||r==="none",T=t+i,k=e.length;let _=t>0&&this.getParsed(t-1);for(let C=0;C=T){R.skip=!0;continue}const L=this.getParsed(C),X=Be(L[O]),ie=R[m]=o.getPixelForValue(L[m],C),Y=R[O]=s||X?a.getBasePixel():a.getPixelForValue(c?this.applyStack(a,L,c):L[O],C);R.skip=isNaN(ie)||isNaN(Y)||X,R.stop=C>0&&Math.abs(L[m]-_[m])>S,b&&(R.parsed=L,R.raw=h.data[C]),p&&(R.options=f||this.resolveDataElementOptions(C,M.active?"active":r)),w||this.updateElement(M,C,R,r),_=L}}getMaxOverflow(){const e=this._cachedMeta,t=e.dataset,i=t.options&&t.options.borderWidth||0,r=e.data||[];if(!r.length)return i;const s=r[0].size(this.resolveDataElementOptions(0)),o=r[r.length-1].size(this.resolveDataElementOptions(r.length-1));return Math.max(i,s,o)/2}draw(){const e=this._cachedMeta;e.dataset.updateControlPoints(this.chart.chartArea,e.iScale.axis),super.draw()}}me(Xh,"id","line"),me(Xh,"defaults",{datasetElementType:"line",dataElementType:"point",showLine:!0,spanGaps:!1}),me(Xh,"overrides",{scales:{_index_:{type:"category"},_value_:{type:"linear"}}});class NO extends Do{}me(NO,"id","pie"),me(NO,"defaults",{cutout:0,rotation:0,circumference:360,radius:"100%"});class La extends qr{getLabelAndValue(e){const t=this._cachedMeta,i=this.chart.data.labels||[],{xScale:r,yScale:s}=t,o=this.getParsed(e),a=r.getLabelForValue(o.x),c=s.getLabelForValue(o.y);return{label:i[e]||"",value:"("+a+", "+c+")"}}update(e){const t=this._cachedMeta,{data:i=[]}=t,r=this.chart._animationsDisabled;let{start:s,count:o}=WC(t,i,r);if(this._drawStart=s,this._drawCount=o,VC(t)&&(s=0,o=i.length),this.options.showLine){this.datasetElementType||this.addElements();const{dataset:a,_dataset:c}=t;a._chart=this.chart,a._datasetIndex=this.index,a._decimated=!!c._decimated,a.points=i;const h=this.resolveDatasetElementOptions(e);h.segment=this.options.segment,this.updateElement(a,void 0,{animated:!r,options:h},e)}else this.datasetElementType&&(delete t.dataset,this.datasetElementType=!1);this.updateElements(i,s,o,e)}addElements(){const{showLine:e}=this.options;!this.datasetElementType&&e&&(this.datasetElementType=this.chart.registry.getElement("line")),super.addElements()}updateElements(e,t,i,r){const s=r==="reset",{iScale:o,vScale:a,_stacked:c,_dataset:h}=this._cachedMeta,f=this.resolveDataElementOptions(t,r),p=this.getSharedOptions(f),m=this.includeOptions(r,p),O=o.axis,v=a.axis,{spanGaps:b,segment:S}=this.options,w=nl(b)?b:Number.POSITIVE_INFINITY,T=this.chart._animationsDisabled||s||r==="none";let k=t>0&&this.getParsed(t-1);for(let _=t;_0&&Math.abs(M[O]-k[O])>w,S&&(R.parsed=M,R.raw=h.data[_]),m&&(R.options=p||this.resolveDataElementOptions(_,C.active?"active":r)),T||this.updateElement(C,_,R,r),k=M}this.updateSharedOptions(p,r,f)}getMaxOverflow(){const e=this._cachedMeta,t=e.data||[];if(!this.options.showLine){let a=0;for(let c=t.length-1;c>=0;--c)a=Math.max(a,t[c].size(this.resolveDataElementOptions(c))/2);return a>0&&a}const i=e.dataset,r=i.options&&i.options.borderWidth||0;if(!t.length)return r;const s=t[0].size(this.resolveDataElementOptions(0)),o=t[t.length-1].size(this.resolveDataElementOptions(t.length-1));return Math.max(r,s,o)/2}}me(La,"id","scatter"),me(La,"defaults",{datasetElementType:!1,dataElementType:"point",showLine:!1,fill:!1}),me(La,"overrides",{interaction:{mode:"point"},scales:{x:{type:"linear"},y:{type:"linear"}}});function Ss(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class fy{constructor(e){me(this,"options");this.options=e||{}}static override(e){Object.assign(fy.prototype,e)}init(){}formats(){return Ss()}parse(){return Ss()}format(){return Ss()}add(){return Ss()}diff(){return Ss()}startOf(){return Ss()}endOf(){return Ss()}}var PN={_date:fy};function _N(n,e,t,i){const{controller:r,data:s,_sorted:o}=n,a=r._cachedMeta.iScale,c=n.dataset&&n.dataset.options?n.dataset.options.spanGaps:null;if(a&&e===a.axis&&e!=="r"&&o&&s.length){const h=a._reversePixels?U4:Ls;if(i){if(r._sharedOptions){const f=s[0],p=typeof f.getRange=="function"&&f.getRange(e);if(p){const m=h(s,e,t-p),O=h(s,e,t+p);return{lo:m.lo,hi:O.hi}}}}else{const f=h(s,e,t);if(c){const{vScale:p}=r._cachedMeta,{_parsed:m}=n,O=m.slice(0,f.lo+1).reverse().findIndex(b=>!Be(b[p.axis]));f.lo-=Math.max(0,O);const v=m.slice(f.hi).findIndex(b=>!Be(b[p.axis]));f.hi+=Math.max(0,v)}return f}}return{lo:0,hi:s.length-1}}function ad(n,e,t,i,r){const s=n.getSortedVisibleDatasetMetas(),o=t[e];for(let a=0,c=s.length;a{c[o]&&c[o](e[t],r)&&(s.push({element:c,datasetIndex:h,index:f}),a=a||c.inRange(e.x,e.y,r))}),i&&!a?[]:s}var $N={modes:{index(n,e,t,i){const r=Cs(e,n),s=t.axis||"x",o=t.includeInvisible||!1,a=t.intersect?am(n,r,s,i,o):cm(n,r,s,!1,i,o),c=[];return a.length?(n.getSortedVisibleDatasetMetas().forEach(h=>{const f=a[0].index,p=h.data[f];p&&!p.skip&&c.push({element:p,datasetIndex:h.index,index:f})}),c):[]},dataset(n,e,t,i){const r=Cs(e,n),s=t.axis||"xy",o=t.includeInvisible||!1;let a=t.intersect?am(n,r,s,i,o):cm(n,r,s,!1,i,o);if(a.length>0){const c=a[0].datasetIndex,h=n.getDatasetMeta(c).data;a=[];for(let f=0;ft.pos===e)}function $w(n,e){return n.filter(t=>cT.indexOf(t.pos)===-1&&t.box.axis===e)}function aa(n,e){return n.sort((t,i)=>{const r=e?i:t,s=e?t:i;return r.weight===s.weight?r.index-s.index:r.weight-s.weight})}function MN(n){const e=[];let t,i,r,s,o,a;for(t=0,i=(n||[]).length;th.box.fullSize),!0),i=aa(la(e,"left"),!0),r=aa(la(e,"right")),s=aa(la(e,"top"),!0),o=aa(la(e,"bottom")),a=$w(e,"x"),c=$w(e,"y");return{fullSize:t,leftAndTop:i.concat(s),rightAndBottom:r.concat(c).concat(o).concat(a),chartArea:la(e,"chartArea"),vertical:i.concat(r).concat(c),horizontal:s.concat(o).concat(a)}}function Mw(n,e,t,i){return Math.max(n[t],e[t])+Math.max(n[i],e[i])}function uT(n,e){n.top=Math.max(n.top,e.top),n.left=Math.max(n.left,e.left),n.bottom=Math.max(n.bottom,e.bottom),n.right=Math.max(n.right,e.right)}function LN(n,e,t,i){const{pos:r,box:s}=t,o=n.maxPadding;if(!De(r)){t.size&&(n[r]-=t.size);const p=i[t.stack]||{size:0,count:1};p.size=Math.max(p.size,t.horizontal?s.height:s.width),t.size=p.size/p.count,n[r]+=t.size}s.getPadding&&uT(o,s.getPadding());const a=Math.max(0,e.outerWidth-Mw(o,n,"left","right")),c=Math.max(0,e.outerHeight-Mw(o,n,"top","bottom")),h=a!==n.w,f=c!==n.h;return n.w=a,n.h=c,t.horizontal?{same:h,other:f}:{same:f,other:h}}function DN(n){const e=n.maxPadding;function t(i){const r=Math.max(e[i]-n[i],0);return n[i]+=r,r}n.y+=t("top"),n.x+=t("left"),t("right"),t("bottom")}function zN(n,e){const t=e.maxPadding;function i(r){const s={left:0,top:0,right:0,bottom:0};return r.forEach(o=>{s[o]=Math.max(e[o],t[o])}),s}return i(n?["left","right"]:["top","bottom"])}function xa(n,e,t,i){const r=[];let s,o,a,c,h,f;for(s=0,o=n.length,h=0;s{typeof b.beforeLayout=="function"&&b.beforeLayout()});const f=c.reduce((b,S)=>S.box.options&&S.box.options.display===!1?b:b+1,0)||1,p=Object.freeze({outerWidth:e,outerHeight:t,padding:r,availableWidth:s,availableHeight:o,vBoxMaxWidth:s/2/f,hBoxMaxHeight:o/2}),m=Object.assign({},r);uT(m,Un(i));const O=Object.assign({maxPadding:m,w:s,h:o,x:r.left,y:r.top},r),v=AN(c.concat(h),p);xa(a.fullSize,O,p,v),xa(c,O,p,v),xa(h,O,p,v)&&xa(c,O,p,v),DN(O),Rw(a.leftAndTop,O,p,v),O.x+=O.w,O.y+=O.h,Rw(a.rightAndBottom,O,p,v),n.chartArea={left:O.left,top:O.top,right:O.left+O.w,bottom:O.top+O.h,height:O.h,width:O.w},Ye(a.chartArea,b=>{const S=b.box;Object.assign(S,n.chartArea),S.update(O.w,O.h,{left:0,top:0,right:0,bottom:0})})}};class hT{acquireContext(e,t){}releaseContext(e){return!1}addEventListener(e,t,i){}removeEventListener(e,t,i){}getDevicePixelRatio(){return 1}getMaximumSize(e,t,i,r){return t=Math.max(0,t||e.width),i=i||e.height,{width:t,height:Math.max(0,r?Math.floor(t/r):i)}}isAttached(e){return!0}updateConfig(e){}}class jN extends hT{acquireContext(e){return e&&e.getContext&&e.getContext("2d")||null}updateConfig(e){e.options.animation=!1}}const Wh="$chartjs",ZN={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},Aw=n=>n===null||n==="";function IN(n,e){const t=n.style,i=n.getAttribute("height"),r=n.getAttribute("width");if(n[Wh]={initial:{height:i,width:r,style:{display:t.display,height:t.height,width:t.width}}},t.display=t.display||"block",t.boxSizing=t.boxSizing||"border-box",Aw(r)){const s=mw(n,"width");s!==void 0&&(n.width=s)}if(Aw(i))if(n.style.height==="")n.height=n.width/(e||2);else{const s=mw(n,"height");s!==void 0&&(n.height=s)}return n}const fT=BI?{passive:!0}:!1;function NN(n,e,t){n&&n.addEventListener(e,t,fT)}function BN(n,e,t){n&&n.canvas&&n.canvas.removeEventListener(e,t,fT)}function XN(n,e){const t=ZN[n.type]||n.type,{x:i,y:r}=Cs(n,e);return{type:t,chart:e,native:n,x:i!==void 0?i:null,y:r!==void 0?r:null}}function Ef(n,e){for(const t of n)if(t===e||t.contains(e))return!0}function WN(n,e,t){const i=n.canvas,r=new MutationObserver(s=>{let o=!1;for(const a of s)o=o||Ef(a.addedNodes,i),o=o&&!Ef(a.removedNodes,i);o&&t()});return r.observe(document,{childList:!0,subtree:!0}),r}function VN(n,e,t){const i=n.canvas,r=new MutationObserver(s=>{let o=!1;for(const a of s)o=o||Ef(a.removedNodes,i),o=o&&!Ef(a.addedNodes,i);o&&t()});return r.observe(document,{childList:!0,subtree:!0}),r}const fc=new Map;let Ew=0;function dT(){const n=window.devicePixelRatio;n!==Ew&&(Ew=n,fc.forEach((e,t)=>{t.currentDevicePixelRatio!==n&&e()}))}function FN(n,e){fc.size||window.addEventListener("resize",dT),fc.set(n,e)}function YN(n){fc.delete(n),fc.size||window.removeEventListener("resize",dT)}function qN(n,e,t){const i=n.canvas,r=i&&hy(i);if(!r)return;const s=XC((a,c)=>{const h=r.clientWidth;t(a,c),h{const c=a[0],h=c.contentRect.width,f=c.contentRect.height;h===0&&f===0||s(h,f)});return o.observe(r),FN(n,s),o}function um(n,e,t){t&&t.disconnect(),e==="resize"&&YN(n)}function UN(n,e,t){const i=n.canvas,r=XC(s=>{n.ctx!==null&&t(XN(s,n))},n);return NN(i,e,r),r}class HN extends hT{acquireContext(e,t){const i=e&&e.getContext&&e.getContext("2d");return i&&i.canvas===e?(IN(e,t),i):null}releaseContext(e){const t=e.canvas;if(!t[Wh])return!1;const i=t[Wh].initial;["height","width"].forEach(s=>{const o=i[s];Be(o)?t.removeAttribute(s):t.setAttribute(s,o)});const r=i.style||{};return Object.keys(r).forEach(s=>{t.style[s]=r[s]}),t.width=t.width,delete t[Wh],!0}addEventListener(e,t,i){this.removeEventListener(e,t);const r=e.$proxies||(e.$proxies={}),o={attach:WN,detach:VN,resize:qN}[t]||UN;r[t]=o(e,t,i)}removeEventListener(e,t){const i=e.$proxies||(e.$proxies={}),r=i[t];if(!r)return;({attach:um,detach:um,resize:um}[t]||BN)(e,t,r),i[t]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(e,t,i,r){return NI(e,t,i,r)}isAttached(e){const t=e&&hy(e);return!!(t&&t.isConnected)}}function GN(n){return!uy()||typeof OffscreenCanvas<"u"&&n instanceof OffscreenCanvas?jN:HN}class fi{constructor(){me(this,"x");me(this,"y");me(this,"active",!1);me(this,"options");me(this,"$animations")}tooltipPosition(e){const{x:t,y:i}=this.getProps(["x","y"],e);return{x:t,y:i}}hasValue(){return nl(this.x)&&nl(this.y)}getProps(e,t){const i=this.$animations;if(!t||!i)return this;const r={};return e.forEach(s=>{r[s]=i[s]&&i[s].active()?i[s]._to:this[s]}),r}}me(fi,"defaults",{}),me(fi,"defaultRoutes");function KN(n,e){const t=n.options.ticks,i=JN(n),r=Math.min(t.maxTicksLimit||i,i),s=t.major.enabled?tB(e):[],o=s.length,a=s[0],c=s[o-1],h=[];if(o>r)return nB(e,h,s,o/r),h;const f=eB(s,e,r);if(o>0){let p,m;const O=o>1?Math.round((c-a)/(o-1)):null;for(yh(e,h,f,Be(O)?0:a-O,a),p=0,m=o-1;pr)return c}return Math.max(r,1)}function tB(n){const e=[];let t,i;for(t=0,i=n.length;tn==="left"?"right":n==="right"?"left":n,Lw=(n,e,t)=>e==="top"||e==="left"?n[e]+t:n[e]-t,Dw=(n,e)=>Math.min(e||n,n);function zw(n,e){const t=[],i=n.length/e,r=n.length;let s=0;for(;so+a)))return c}function oB(n,e){Ye(n,t=>{const i=t.gc,r=i.length/2;let s;if(r>e){for(s=0;si?i:t,i=r&&t>i?t:i,{min:ki(t,ki(i,t)),max:ki(i,ki(t,i))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const e=this.chart.data;return this.options.labels||(this.isHorizontal()?e.xLabels:e.yLabels)||e.labels||[]}getLabelItems(e=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(e))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){it(this.options.beforeUpdate,[this])}update(e,t,i){const{beginAtZero:r,grace:s,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=e,this.maxHeight=t,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=xI(this,s,r),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const c=a=s||i<=1||!this.isHorizontal()){this.labelRotation=r;return}const f=this._getLabelSizes(),p=f.widest.width,m=f.highest.height,O=en(this.chart.width-p,0,this.maxWidth);a=e.offset?this.maxWidth/i:O/(i-1),p+6>a&&(a=O/(i-(e.offset?.5:1)),c=this.maxHeight-ca(e.grid)-t.padding-jw(e.title,this.chart.options.font),h=Math.sqrt(p*p+m*m),o=F4(Math.min(Math.asin(en((f.highest.height+6)/a,-1,1)),Math.asin(en(c/h,-1,1))-Math.asin(en(m/h,-1,1)))),o=Math.max(r,Math.min(s,o))),this.labelRotation=o}afterCalculateLabelRotation(){it(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){it(this.options.beforeFit,[this])}fit(){const e={width:0,height:0},{chart:t,options:{ticks:i,title:r,grid:s}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const c=jw(r,t.options.font);if(a?(e.width=this.maxWidth,e.height=ca(s)+c):(e.height=this.maxHeight,e.width=ca(s)+c),i.display&&this.ticks.length){const{first:h,last:f,widest:p,highest:m}=this._getLabelSizes(),O=i.padding*2,v=ir(this.labelRotation),b=Math.cos(v),S=Math.sin(v);if(a){const w=i.mirror?0:S*p.width+b*m.height;e.height=Math.min(this.maxHeight,e.height+w+O)}else{const w=i.mirror?0:b*p.width+S*m.height;e.width=Math.min(this.maxWidth,e.width+w+O)}this._calculatePadding(h,f,S,b)}}this._handleMargins(),a?(this.width=this._length=t.width-this._margins.left-this._margins.right,this.height=e.height):(this.width=e.width,this.height=this._length=t.height-this._margins.top-this._margins.bottom)}_calculatePadding(e,t,i,r){const{ticks:{align:s,padding:o},position:a}=this.options,c=this.labelRotation!==0,h=a!=="top"&&this.axis==="x";if(this.isHorizontal()){const f=this.getPixelForTick(0)-this.left,p=this.right-this.getPixelForTick(this.ticks.length-1);let m=0,O=0;c?h?(m=r*e.width,O=i*t.height):(m=i*e.height,O=r*t.width):s==="start"?O=t.width:s==="end"?m=e.width:s!=="inner"&&(m=e.width/2,O=t.width/2),this.paddingLeft=Math.max((m-f+o)*this.width/(this.width-f),0),this.paddingRight=Math.max((O-p+o)*this.width/(this.width-p),0)}else{let f=t.height/2,p=e.height/2;s==="start"?(f=0,p=e.height):s==="end"&&(f=t.height,p=0),this.paddingTop=f+o,this.paddingBottom=p+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){it(this.options.afterFit,[this])}isHorizontal(){const{axis:e,position:t}=this.options;return t==="top"||t==="bottom"||e==="x"}isFullSize(){return this.options.fullSize}_convertTicksToLabels(e){this.beforeTickToLabelConversion(),this.generateTickLabels(e);let t,i;for(t=0,i=e.length;t({width:o[X]||0,height:a[X]||0});return{first:L(0),last:L(t-1),widest:L(M),highest:L(R),widths:o,heights:a}}getLabelForValue(e){return e}getPixelForValue(e,t){return NaN}getValueForPixel(e){}getPixelForTick(e){const t=this.ticks;return e<0||e>t.length-1?null:this.getPixelForValue(t[e].value)}getPixelForDecimal(e){this._reversePixels&&(e=1-e);const t=this._startPixel+e*this._length;return q4(this._alignToPixels?bs(this.chart,t,0):t)}getDecimalForPixel(e){const t=(e-this._startPixel)/this._length;return this._reversePixels?1-t:t}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:e,max:t}=this;return e<0&&t<0?t:e>0&&t>0?e:0}getContext(e){const t=this.ticks||[];if(e>=0&&ea*r?a/i:c/r:c*r0}_computeGridLineItems(e){const t=this.axis,i=this.chart,r=this.options,{grid:s,position:o,border:a}=r,c=s.offset,h=this.isHorizontal(),p=this.ticks.length+(c?1:0),m=ca(s),O=[],v=a.setContext(this.getContext()),b=v.display?v.width:0,S=b/2,w=function(ce){return bs(i,ce,b)};let T,k,_,C,M,R,L,X,ie,Y,H,F;if(o==="top")T=w(this.bottom),R=this.bottom-m,X=T-S,Y=w(e.top)+S,F=e.bottom;else if(o==="bottom")T=w(this.top),Y=e.top,F=w(e.bottom)-S,R=T+S,X=this.top+m;else if(o==="left")T=w(this.right),M=this.right-m,L=T-S,ie=w(e.left)+S,H=e.right;else if(o==="right")T=w(this.left),ie=e.left,H=w(e.right)-S,M=T+S,L=this.left+m;else if(t==="x"){if(o==="center")T=w((e.top+e.bottom)/2+.5);else if(De(o)){const ce=Object.keys(o)[0],ue=o[ce];T=w(this.chart.scales[ce].getPixelForValue(ue))}Y=e.top,F=e.bottom,R=T+S,X=R+m}else if(t==="y"){if(o==="center")T=w((e.left+e.right)/2);else if(De(o)){const ce=Object.keys(o)[0],ue=o[ce];T=w(this.chart.scales[ce].getPixelForValue(ue))}M=T-S,L=M-m,ie=e.left,H=e.right}const re=Re(r.ticks.maxTicksLimit,p),oe=Math.max(1,Math.ceil(p/re));for(k=0;k0&&(Pe-=Oe/2);break}D={left:Pe,top:ke,width:Oe+B.width,height:xe+B.height,color:oe.backdropColor}}S.push({label:_,font:X,textOffset:H,options:{rotation:b,color:ue,strokeColor:q,strokeWidth:U,textAlign:J,textBaseline:F,translation:[C,M],backdrop:D}})}return S}_getXAxisLabelAlignment(){const{position:e,ticks:t}=this.options;if(-ir(this.labelRotation))return e==="top"?"left":"right";let r="center";return t.align==="start"?r="left":t.align==="end"?r="right":t.align==="inner"&&(r="inner"),r}_getYAxisLabelAlignment(e){const{position:t,ticks:{crossAlign:i,mirror:r,padding:s}}=this.options,o=this._getLabelSizes(),a=e+s,c=o.widest.width;let h,f;return t==="left"?r?(f=this.right+s,i==="near"?h="left":i==="center"?(h="center",f+=c/2):(h="right",f+=c)):(f=this.right-a,i==="near"?h="right":i==="center"?(h="center",f-=c/2):(h="left",f=this.left)):t==="right"?r?(f=this.left+s,i==="near"?h="right":i==="center"?(h="center",f-=c/2):(h="left",f-=c)):(f=this.left+a,i==="near"?h="left":i==="center"?(h="center",f+=c/2):(h="right",f=this.right)):h="right",{textAlign:h,x:f}}_computeLabelArea(){if(this.options.ticks.mirror)return;const e=this.chart,t=this.options.position;if(t==="left"||t==="right")return{top:0,left:this.left,bottom:e.height,right:this.right};if(t==="top"||t==="bottom")return{top:this.top,left:0,bottom:this.bottom,right:e.width}}drawBackground(){const{ctx:e,options:{backgroundColor:t},left:i,top:r,width:s,height:o}=this;t&&(e.save(),e.fillStyle=t,e.fillRect(i,r,s,o),e.restore())}getLineWidthForValue(e){const t=this.options.grid;if(!this._isVisible()||!t.display)return 0;const r=this.ticks.findIndex(s=>s.value===e);return r>=0?t.setContext(this.getContext(r)).lineWidth:0}drawGrid(e){const t=this.options.grid,i=this.ctx,r=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(e));let s,o;const a=(c,h,f)=>{!f.width||!f.color||(i.save(),i.lineWidth=f.width,i.strokeStyle=f.color,i.setLineDash(f.borderDash||[]),i.lineDashOffset=f.borderDashOffset,i.beginPath(),i.moveTo(c.x,c.y),i.lineTo(h.x,h.y),i.stroke(),i.restore())};if(t.display)for(s=0,o=r.length;s{this.draw(s)}}]:[{z:i,draw:s=>{this.drawBackground(),this.drawGrid(s),this.drawTitle()}},{z:r,draw:()=>{this.drawBorder()}},{z:t,draw:s=>{this.drawLabels(s)}}]}getMatchingVisibleMetas(e){const t=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",r=[];let s,o;for(s=0,o=t.length;s{const i=t.split("."),r=i.pop(),s=[n].concat(i).join("."),o=e[t].split("."),a=o.pop(),c=o.join(".");Ot.route(s,r,c,a)})}function dB(n){return"id"in n&&"defaults"in n}class pB{constructor(){this.controllers=new xh(qr,"datasets",!0),this.elements=new xh(fi,"elements"),this.plugins=new xh(Object,"plugins"),this.scales=new xh(hl,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...e){this._each("register",e)}remove(...e){this._each("unregister",e)}addControllers(...e){this._each("register",e,this.controllers)}addElements(...e){this._each("register",e,this.elements)}addPlugins(...e){this._each("register",e,this.plugins)}addScales(...e){this._each("register",e,this.scales)}getController(e){return this._get(e,this.controllers,"controller")}getElement(e){return this._get(e,this.elements,"element")}getPlugin(e){return this._get(e,this.plugins,"plugin")}getScale(e){return this._get(e,this.scales,"scale")}removeControllers(...e){this._each("unregister",e,this.controllers)}removeElements(...e){this._each("unregister",e,this.elements)}removePlugins(...e){this._each("unregister",e,this.plugins)}removeScales(...e){this._each("unregister",e,this.scales)}_each(e,t,i){[...t].forEach(r=>{const s=i||this._getRegistryForType(r);i||s.isForType(r)||s===this.plugins&&r.id?this._exec(e,s,r):Ye(r,o=>{const a=i||this._getRegistryForType(o);this._exec(e,a,o)})})}_exec(e,t,i){const r=ty(e);it(i["before"+r],[],i),t[e](i),it(i["after"+r],[],i)}_getRegistryForType(e){for(let t=0;ts.filter(a=>!o.some(c=>a.plugin.id===c.plugin.id));this._notify(r(t,i),e,"stop"),this._notify(r(i,t),e,"start")}}function mB(n){const e={},t=[],i=Object.keys(Qi.plugins.items);for(let s=0;s1&&Zw(n[0].toLowerCase());if(i)return i}throw new Error(`Cannot determine type of '${n}' axis. Please provide 'axis' or 'position' option.`)}function Iw(n,e,t){if(t[e+"AxisID"]===n)return{axis:e}}function wB(n,e){if(e.data&&e.data.datasets){const t=e.data.datasets.filter(i=>i.xAxisID===n||i.yAxisID===n);if(t.length)return Iw(n,"x",t[0])||Iw(n,"y",t[0])}return{}}function kB(n,e){const t=Us[n.type]||{scales:{}},i=e.scales||{},r=BO(n.type,e),s=Object.create(null);return Object.keys(i).forEach(o=>{const a=i[o];if(!De(a))return console.error(`Invalid scale configuration for scale: ${o}`);if(a._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${o}`);const c=XO(o,a,wB(o,n),Ot.scales[a.type]),h=bB(c,r),f=t.scales||{};s[o]=Ma(Object.create(null),[{axis:c},a,f[c],f[h]])}),n.data.datasets.forEach(o=>{const a=o.type||n.type,c=o.indexAxis||BO(a,e),f=(Us[a]||{}).scales||{};Object.keys(f).forEach(p=>{const m=vB(p,c),O=o[m+"AxisID"]||m;s[O]=s[O]||Object.create(null),Ma(s[O],[{axis:m},i[O],f[p]])})}),Object.keys(s).forEach(o=>{const a=s[o];Ma(a,[Ot.scales[a.type],Ot.scale])}),s}function pT(n){const e=n.options||(n.options={});e.plugins=Re(e.plugins,{}),e.scales=kB(n,e)}function gT(n){return n=n||{},n.datasets=n.datasets||[],n.labels=n.labels||[],n}function PB(n){return n=n||{},n.data=gT(n.data),pT(n),n}const Nw=new Map,mT=new Set;function vh(n,e){let t=Nw.get(n);return t||(t=e(),Nw.set(n,t),mT.add(t)),t}const ua=(n,e,t)=>{const i=qs(e,t);i!==void 0&&n.add(i)};class _B{constructor(e){this._config=PB(e),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(e){this._config.type=e}get data(){return this._config.data}set data(e){this._config.data=gT(e)}get options(){return this._config.options}set options(e){this._config.options=e}get plugins(){return this._config.plugins}update(){const e=this._config;this.clearCache(),pT(e)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(e){return vh(e,()=>[[`datasets.${e}`,""]])}datasetAnimationScopeKeys(e,t){return vh(`${e}.transition.${t}`,()=>[[`datasets.${e}.transitions.${t}`,`transitions.${t}`],[`datasets.${e}`,""]])}datasetElementScopeKeys(e,t){return vh(`${e}-${t}`,()=>[[`datasets.${e}.elements.${t}`,`datasets.${e}`,`elements.${t}`,""]])}pluginScopeKeys(e){const t=e.id,i=this.type;return vh(`${i}-plugin-${t}`,()=>[[`plugins.${t}`,...e.additionalOptionScopes||[]]])}_cachedScopes(e,t){const i=this._scopeCache;let r=i.get(e);return(!r||t)&&(r=new Map,i.set(e,r)),r}getOptionScopes(e,t,i){const{options:r,type:s}=this,o=this._cachedScopes(e,i),a=o.get(t);if(a)return a;const c=new Set;t.forEach(f=>{e&&(c.add(e),f.forEach(p=>ua(c,e,p))),f.forEach(p=>ua(c,r,p)),f.forEach(p=>ua(c,Us[s]||{},p)),f.forEach(p=>ua(c,Ot,p)),f.forEach(p=>ua(c,ZO,p))});const h=Array.from(c);return h.length===0&&h.push(Object.create(null)),mT.has(t)&&o.set(t,h),h}chartOptionScopes(){const{options:e,type:t}=this;return[e,Us[t]||{},Ot.datasets[t]||{},{type:t},Ot,ZO]}resolveNamedOptions(e,t,i,r=[""]){const s={$shared:!0},{resolver:o,subPrefixes:a}=Bw(this._resolverCache,e,r);let c=o;if(CB(o,t)){s.$shared=!1,i=es(i)?i():i;const h=this.createResolver(e,i,a);c=il(o,i,h)}for(const h of t)s[h]=c[h];return s}createResolver(e,t,i=[""],r){const{resolver:s}=Bw(this._resolverCache,e,i);return De(t)?il(s,t,void 0,r):s}}function Bw(n,e,t){let i=n.get(e);i||(i=new Map,n.set(e,i));const r=t.join();let s=i.get(r);return s||(s={resolver:ly(e,t),subPrefixes:t.filter(a=>!a.toLowerCase().includes("hover"))},i.set(r,s)),s}const QB=n=>De(n)&&Object.getOwnPropertyNames(n).some(e=>es(n[e]));function CB(n,e){const{isScriptable:t,isIndexable:i}=UC(n);for(const r of e){const s=t(r),o=i(r),a=(o||s)&&n[r];if(s&&(es(a)||QB(a))||o&&bt(a))return!0}return!1}var TB="4.5.0";const $B=["top","bottom","left","right","chartArea"];function Xw(n,e){return n==="top"||n==="bottom"||$B.indexOf(n)===-1&&e==="x"}function Ww(n,e){return function(t,i){return t[n]===i[n]?t[e]-i[e]:t[n]-i[n]}}function Vw(n){const e=n.chart,t=e.options.animation;e.notifyPlugins("afterRender"),it(t&&t.onComplete,[n],e)}function MB(n){const e=n.chart,t=e.options.animation;it(t&&t.onProgress,[n],e)}function OT(n){return uy()&&typeof n=="string"?n=document.getElementById(n):n&&n.length&&(n=n[0]),n&&n.canvas&&(n=n.canvas),n}const Vh={},Fw=n=>{const e=OT(n);return Object.values(Vh).filter(t=>t.canvas===e).pop()};function RB(n,e,t){const i=Object.keys(n);for(const r of i){const s=+r;if(s>=e){const o=n[r];delete n[r],(t>0||s>e)&&(n[s+t]=o)}}}function AB(n,e,t,i){return!t||n.type==="mouseout"?null:i?e:n}var Mr;let cd=(Mr=class{static register(...e){Qi.add(...e),Yw()}static unregister(...e){Qi.remove(...e),Yw()}constructor(e,t){const i=this.config=new _B(t),r=OT(e),s=Fw(r);if(s)throw new Error("Canvas is already in use. Chart with ID '"+s.id+"' must be destroyed before the canvas with ID '"+s.canvas.id+"' can be reused.");const o=i.createResolver(i.chartOptionScopes(),this.getContext());this.platform=new(i.platform||GN(r)),this.platform.updateConfig(i);const a=this.platform.acquireContext(r,o.aspectRatio),c=a&&a.canvas,h=c&&c.height,f=c&&c.width;if(this.id=A4(),this.ctx=a,this.canvas=c,this.width=f,this.height=h,this._options=o,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new gB,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=K4(p=>this.update(p),o.resizeDelay||0),this._dataChanges=[],Vh[this.id]=this,!a||!c){console.error("Failed to create chart: can't acquire context from the given item");return}Hi.listen(this,"complete",Vw),Hi.listen(this,"progress",MB),this._initialize(),this.attached&&this.update()}get aspectRatio(){const{options:{aspectRatio:e,maintainAspectRatio:t},width:i,height:r,_aspectRatio:s}=this;return Be(e)?t&&s?s:r?i/r:null:e}get data(){return this.config.data}set data(e){this.config.data=e}get options(){return this._options}set options(e){this.config.options=e}get registry(){return Qi}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():gw(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return fw(this.canvas,this.ctx),this}stop(){return Hi.stop(this),this}resize(e,t){Hi.running(this)?this._resizeBeforeDraw={width:e,height:t}:this._resize(e,t)}_resize(e,t){const i=this.options,r=this.canvas,s=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(r,e,t,s),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),c=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,gw(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),it(i.onResize,[this,o],this),this.attached&&this._doResize(c)&&this.render())}ensureScalesHaveIDs(){const t=this.options.scales||{};Ye(t,(i,r)=>{i.id=r})}buildOrUpdateScales(){const e=this.options,t=e.scales,i=this.scales,r=Object.keys(i).reduce((o,a)=>(o[a]=!1,o),{});let s=[];t&&(s=s.concat(Object.keys(t).map(o=>{const a=t[o],c=XO(o,a),h=c==="r",f=c==="x";return{options:a,dposition:h?"chartArea":f?"bottom":"left",dtype:h?"radialLinear":f?"category":"linear"}}))),Ye(s,o=>{const a=o.options,c=a.id,h=XO(c,a),f=Re(a.type,o.dtype);(a.position===void 0||Xw(a.position,h)!==Xw(o.dposition))&&(a.position=o.dposition),r[c]=!0;let p=null;if(c in i&&i[c].type===f)p=i[c];else{const m=Qi.getScale(f);p=new m({id:c,type:f,ctx:this.ctx,chart:this}),i[p.id]=p}p.init(a,e)}),Ye(r,(o,a)=>{o||delete i[a]}),Ye(i,o=>{Vn.configure(this,o,o.options),Vn.addBox(this,o)})}_updateMetasets(){const e=this._metasets,t=this.data.datasets.length,i=e.length;if(e.sort((r,s)=>r.index-s.index),i>t){for(let r=t;rt.length&&delete this._stacks,e.forEach((i,r)=>{t.filter(s=>s===i._dataset).length===0&&this._destroyDatasetMeta(r)})}buildOrUpdateControllers(){const e=[],t=this.data.datasets;let i,r;for(this._removeUnreferencedMetasets(),i=0,r=t.length;i{this.getDatasetMeta(t).controller.reset()},this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(e){const t=this.config;t.update();const i=this._options=t.createResolver(t.chartOptionScopes(),this.getContext()),r=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),this.notifyPlugins("beforeUpdate",{mode:e,cancelable:!0})===!1)return;const s=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let h=0,f=this.data.datasets.length;h{h.reset()}),this._updateDatasets(e),this.notifyPlugins("afterUpdate",{mode:e}),this._layers.sort(Ww("z","_idx"));const{_active:a,_lastEvent:c}=this;c?this._eventHandler(c,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){Ye(this.scales,e=>{Vn.removeBox(this,e)}),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const e=this.options,t=new Set(Object.keys(this._listeners)),i=new Set(e.events);(!nw(t,i)||!!this._responsiveListeners!==e.responsive)&&(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:e}=this,t=this._getUniformDataChanges()||[];for(const{method:i,start:r,count:s}of t){const o=i==="_removeElements"?-s:s;RB(e,r,o)}}_getUniformDataChanges(){const e=this._dataChanges;if(!e||!e.length)return;this._dataChanges=[];const t=this.data.datasets.length,i=s=>new Set(e.filter(o=>o[0]===s).map((o,a)=>a+","+o.splice(1).join(","))),r=i(0);for(let s=1;ss.split(",")).map(s=>({method:s[1],start:+s[2],count:+s[3]}))}_updateLayout(e){if(this.notifyPlugins("beforeLayout",{cancelable:!0})===!1)return;Vn.update(this,this.width,this.height,e);const t=this.chartArea,i=t.width<=0||t.height<=0;this._layers=[],Ye(this.boxes,r=>{i&&r.position==="chartArea"||(r.configure&&r.configure(),this._layers.push(...r._layers()))},this),this._layers.forEach((r,s)=>{r._idx=s}),this.notifyPlugins("afterLayout")}_updateDatasets(e){if(this.notifyPlugins("beforeDatasetsUpdate",{mode:e,cancelable:!0})!==!1){for(let t=0,i=this.data.datasets.length;t=0;--t)this._drawDataset(e[t]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(e){const t=this.ctx,i={meta:e,index:e.index,cancelable:!0},r=sT(this,e);this.notifyPlugins("beforeDatasetDraw",i)!==!1&&(r&&sd(t,r),e.controller.draw(),r&&od(t),i.cancelable=!1,this.notifyPlugins("afterDatasetDraw",i))}isPointInArea(e){return uc(e,this.chartArea,this._minPadding)}getElementsAtEventForMode(e,t,i,r){const s=$N.modes[t];return typeof s=="function"?s(this,e,i,r):[]}getDatasetMeta(e){const t=this.data.datasets[e],i=this._metasets;let r=i.filter(s=>s&&s._dataset===t).pop();return r||(r={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:t&&t.order||0,index:e,_dataset:t,_parsed:[],_sorted:!1},i.push(r)),r}getContext(){return this.$context||(this.$context=Hs(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(e){const t=this.data.datasets[e];if(!t)return!1;const i=this.getDatasetMeta(e);return typeof i.hidden=="boolean"?!i.hidden:!t.hidden}setDatasetVisibility(e,t){const i=this.getDatasetMeta(e);i.hidden=!t}toggleDataVisibility(e){this._hiddenIndices[e]=!this._hiddenIndices[e]}getDataVisibility(e){return!this._hiddenIndices[e]}_updateVisibility(e,t,i){const r=i?"show":"hide",s=this.getDatasetMeta(e),o=s.controller._resolveAnimations(void 0,r);ac(t)?(s.data[t].hidden=!i,this.update()):(this.setDatasetVisibility(e,i),o.update(s,{visible:i}),this.update(a=>a.datasetIndex===e?r:void 0))}hide(e,t){this._updateVisibility(e,t,!1)}show(e,t){this._updateVisibility(e,t,!0)}_destroyDatasetMeta(e){const t=this._metasets[e];t&&t.controller&&t.controller._destroy(),delete this._metasets[e]}_stop(){let e,t;for(this.stop(),Hi.remove(this),e=0,t=this.data.datasets.length;e{t.addEventListener(this,s,o),e[s]=o},r=(s,o,a)=>{s.offsetX=o,s.offsetY=a,this._eventHandler(s)};Ye(this.options.events,s=>i(s,r))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const e=this._responsiveListeners,t=this.platform,i=(c,h)=>{t.addEventListener(this,c,h),e[c]=h},r=(c,h)=>{e[c]&&(t.removeEventListener(this,c,h),delete e[c])},s=(c,h)=>{this.canvas&&this.resize(c,h)};let o;const a=()=>{r("attach",a),this.attached=!0,this.resize(),i("resize",s),i("detach",o)};o=()=>{this.attached=!1,r("resize",s),this._stop(),this._resize(0,0),i("attach",a)},t.isAttached(this.canvas)?a():o()}unbindEvents(){Ye(this._listeners,(e,t)=>{this.platform.removeEventListener(this,t,e)}),this._listeners={},Ye(this._responsiveListeners,(e,t)=>{this.platform.removeEventListener(this,t,e)}),this._responsiveListeners=void 0}updateHoverStyle(e,t,i){const r=i?"set":"remove";let s,o,a,c;for(t==="dataset"&&(s=this.getDatasetMeta(e[0].datasetIndex),s.controller["_"+r+"DatasetHoverStyle"]()),a=0,c=e.length;a{const a=this.getDatasetMeta(s);if(!a)throw new Error("No dataset found at index "+s);return{datasetIndex:s,element:a.data[o],index:o}});!Tf(i,t)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,t))}notifyPlugins(e,t,i){return this._plugins.notify(this,e,t,i)}isPluginEnabled(e){return this._plugins._cache.filter(t=>t.plugin.id===e).length===1}_updateHoverStyles(e,t,i){const r=this.options.hover,s=(c,h)=>c.filter(f=>!h.some(p=>f.datasetIndex===p.datasetIndex&&f.index===p.index)),o=s(t,e),a=i?e:s(e,t);o.length&&this.updateHoverStyle(o,r.mode,!1),a.length&&r.mode&&this.updateHoverStyle(a,r.mode,!0)}_eventHandler(e,t){const i={event:e,replay:t,cancelable:!0,inChartArea:this.isPointInArea(e)},r=o=>(o.options.events||this.options.events).includes(e.native.type);if(this.notifyPlugins("beforeEvent",i,r)===!1)return;const s=this._handleEvent(e,t,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,r),(s||i.changed)&&this.render(),this}_handleEvent(e,t,i){const{_active:r=[],options:s}=this,o=t,a=this._getActiveElements(e,r,i,o),c=Z4(e),h=AB(e,this._lastEvent,i,c);i&&(this._lastEvent=null,it(s.onHover,[e,a,this],this),c&&it(s.onClick,[e,a,this],this));const f=!Tf(a,r);return(f||t)&&(this._active=a,this._updateHoverStyles(a,r,t)),this._lastEvent=h,f}_getActiveElements(e,t,i,r){if(e.type==="mouseout")return[];if(!i)return t;const s=this.options.hover;return this.getElementsAtEventForMode(e,s.mode,s,r)}},me(Mr,"defaults",Ot),me(Mr,"instances",Vh),me(Mr,"overrides",Us),me(Mr,"registry",Qi),me(Mr,"version",TB),me(Mr,"getChart",Fw),Mr);function Yw(){return Ye(cd.instances,n=>n._plugins.invalidate())}function EB(n,e,t){const{startAngle:i,x:r,y:s,outerRadius:o,innerRadius:a,options:c}=e,{borderWidth:h,borderJoinStyle:f}=c,p=Math.min(h/o,An(i-t));if(n.beginPath(),n.arc(r,s,o-h/2,i+p/2,t-p/2),a>0){const m=Math.min(h/a,An(i-t));n.arc(r,s,a+h/2,t-m/2,i+m/2,!0)}else{const m=Math.min(h/2,o*An(i-t));if(f==="round")n.arc(r,s,m,t-qe/2,i+qe/2,!0);else if(f==="bevel"){const O=2*m*m,v=-O*Math.cos(t+qe/2)+r,b=-O*Math.sin(t+qe/2)+s,S=O*Math.cos(i+qe/2)+r,w=O*Math.sin(i+qe/2)+s;n.lineTo(v,b),n.lineTo(S,w)}}n.closePath(),n.moveTo(0,0),n.rect(0,0,n.canvas.width,n.canvas.height),n.clip("evenodd")}function LB(n,e,t){const{startAngle:i,pixelMargin:r,x:s,y:o,outerRadius:a,innerRadius:c}=e;let h=r/a;n.beginPath(),n.arc(s,o,a,i-h,t+h),c>r?(h=r/c,n.arc(s,o,c,t+h,i-h,!0)):n.arc(s,o,r,t+Mt,i-Mt),n.closePath(),n.clip()}function DB(n){return oy(n,["outerStart","outerEnd","innerStart","innerEnd"])}function zB(n,e,t,i){const r=DB(n.options.borderRadius),s=(t-e)/2,o=Math.min(s,i*e/2),a=c=>{const h=(t-Math.min(s,c))*i/2;return en(c,0,Math.min(s,h))};return{outerStart:a(r.outerStart),outerEnd:a(r.outerEnd),innerStart:en(r.innerStart,0,o),innerEnd:en(r.innerEnd,0,o)}}function _o(n,e,t,i){return{x:t+n*Math.cos(e),y:i+n*Math.sin(e)}}function Lf(n,e,t,i,r,s){const{x:o,y:a,startAngle:c,pixelMargin:h,innerRadius:f}=e,p=Math.max(e.outerRadius+i+t-h,0),m=f>0?f+i+t+h:0;let O=0;const v=r-c;if(i){const oe=f>0?f-i:0,ce=p>0?p-i:0,ue=(oe+ce)/2,q=ue!==0?v*ue/(ue+i):v;O=(v-q)/2}const b=Math.max(.001,v*p-t/qe)/p,S=(v-b)/2,w=c+S+O,T=r-S-O,{outerStart:k,outerEnd:_,innerStart:C,innerEnd:M}=zB(e,m,p,T-w),R=p-k,L=p-_,X=w+k/R,ie=T-_/L,Y=m+C,H=m+M,F=w+C/Y,re=T-M/H;if(n.beginPath(),s){const oe=(X+ie)/2;if(n.arc(o,a,p,X,oe),n.arc(o,a,p,oe,ie),_>0){const U=_o(L,ie,o,a);n.arc(U.x,U.y,_,ie,T+Mt)}const ce=_o(H,T,o,a);if(n.lineTo(ce.x,ce.y),M>0){const U=_o(H,re,o,a);n.arc(U.x,U.y,M,T+Mt,re+Math.PI)}const ue=(T-M/m+(w+C/m))/2;if(n.arc(o,a,m,T-M/m,ue,!0),n.arc(o,a,m,ue,w+C/m,!0),C>0){const U=_o(Y,F,o,a);n.arc(U.x,U.y,C,F+Math.PI,w-Mt)}const q=_o(R,w,o,a);if(n.lineTo(q.x,q.y),k>0){const U=_o(R,X,o,a);n.arc(U.x,U.y,k,w-Mt,X)}}else{n.moveTo(o,a);const oe=Math.cos(X)*p+o,ce=Math.sin(X)*p+a;n.lineTo(oe,ce);const ue=Math.cos(ie)*p+o,q=Math.sin(ie)*p+a;n.lineTo(ue,q)}n.closePath()}function jB(n,e,t,i,r){const{fullCircles:s,startAngle:o,circumference:a}=e;let c=e.endAngle;if(s){Lf(n,e,t,i,c,r);for(let h=0;h=qe&&O===0&&f!=="miter"&&EB(n,e,b),s||(Lf(n,e,t,i,b,r),n.stroke())}class va extends fi{constructor(t){super();me(this,"circumference");me(this,"endAngle");me(this,"fullCircles");me(this,"innerRadius");me(this,"outerRadius");me(this,"pixelMargin");me(this,"startAngle");this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,i,r){const s=this.getProps(["x","y"],r),{angle:o,distance:a}=ZC(s,{x:t,y:i}),{startAngle:c,endAngle:h,innerRadius:f,outerRadius:p,circumference:m}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],r),O=(this.options.spacing+this.options.borderWidth)/2,v=Re(m,h-c),b=cc(o,c,h)&&c!==h,S=v>=ut||b,w=rr(a,f+O,p+O);return S&&w}getCenterPoint(t){const{x:i,y:r,startAngle:s,endAngle:o,innerRadius:a,outerRadius:c}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:h,spacing:f}=this.options,p=(s+o)/2,m=(a+c+f+h)/2;return{x:i+Math.cos(p)*m,y:r+Math.sin(p)*m}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:i,circumference:r}=this,s=(i.offset||0)/4,o=(i.spacing||0)/2,a=i.circular;if(this.pixelMargin=i.borderAlign==="inner"?.33:0,this.fullCircles=r>ut?Math.floor(r/ut):0,r===0||this.innerRadius<0||this.outerRadius<0)return;t.save();const c=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(c)*s,Math.sin(c)*s);const h=1-Math.sin(Math.min(qe,r||0)),f=s*h;t.fillStyle=i.backgroundColor,t.strokeStyle=i.borderColor,jB(t,this,f,o,a),ZB(t,this,f,o,a),t.restore()}}me(va,"id","arc"),me(va,"defaults",{borderAlign:"center",borderColor:"#fff",borderDash:[],borderDashOffset:0,borderJoinStyle:void 0,borderRadius:0,borderWidth:2,offset:0,spacing:0,angle:void 0,circular:!0,selfJoin:!1}),me(va,"defaultRoutes",{backgroundColor:"backgroundColor"}),me(va,"descriptors",{_scriptable:!0,_indexable:t=>t!=="borderDash"});function yT(n,e,t=e){n.lineCap=Re(t.borderCapStyle,e.borderCapStyle),n.setLineDash(Re(t.borderDash,e.borderDash)),n.lineDashOffset=Re(t.borderDashOffset,e.borderDashOffset),n.lineJoin=Re(t.borderJoinStyle,e.borderJoinStyle),n.lineWidth=Re(t.borderWidth,e.borderWidth),n.strokeStyle=Re(t.borderColor,e.borderColor)}function IB(n,e,t){n.lineTo(t.x,t.y)}function NB(n){return n.stepped?uI:n.tension||n.cubicInterpolationMode==="monotone"?hI:IB}function xT(n,e,t={}){const i=n.length,{start:r=0,end:s=i-1}=t,{start:o,end:a}=e,c=Math.max(r,o),h=Math.min(s,a),f=ra&&s>a;return{count:i,start:c,loop:e.loop,ilen:h(o+(h?a-_:_))%s,k=()=>{b!==S&&(n.lineTo(f,S),n.lineTo(f,b),n.lineTo(f,w))};for(c&&(O=r[T(0)],n.moveTo(O.x,O.y)),m=0;m<=a;++m){if(O=r[T(m)],O.skip)continue;const _=O.x,C=O.y,M=_|0;M===v?(CS&&(S=C),f=(p*f+_)/++p):(k(),n.lineTo(_,C),v=M,p=0,b=S=C),w=C}k()}function WO(n){const e=n.options,t=e.borderDash&&e.borderDash.length;return!n._decimated&&!n._loop&&!e.tension&&e.cubicInterpolationMode!=="monotone"&&!e.stepped&&!t?XB:BB}function WB(n){return n.stepped?XI:n.tension||n.cubicInterpolationMode==="monotone"?WI:Ts}function VB(n,e,t,i){let r=e._path;r||(r=e._path=new Path2D,e.path(r,t,i)&&r.closePath()),yT(n,e.options),n.stroke(r)}function FB(n,e,t,i){const{segments:r,options:s}=e,o=WO(e);for(const a of r)yT(n,s,a.style),n.beginPath(),o(n,e,a,{start:t,end:t+i-1})&&n.closePath(),n.stroke()}const YB=typeof Path2D=="function";function qB(n,e,t,i){YB&&!e.options.segment?VB(n,e,t,i):FB(n,e,t,i)}class Xr extends fi{constructor(e){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,e&&Object.assign(this,e)}updateControlPoints(e,t){const i=this.options;if((i.tension||i.cubicInterpolationMode==="monotone")&&!i.stepped&&!this._pointsUpdated){const r=i.spanGaps?this._loop:this._fullLoop;LI(this._points,i,e,r,t),this._pointsUpdated=!0}}set points(e){this._points=e,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=HI(this,this.options.segment))}first(){const e=this.segments,t=this.points;return e.length&&t[e[0].start]}last(){const e=this.segments,t=this.points,i=e.length;return i&&t[e[i-1].end]}interpolate(e,t){const i=this.options,r=e[t],s=this.points,o=rT(this,{property:t,start:r,end:r});if(!o.length)return;const a=[],c=WB(i);let h,f;for(h=0,f=o.length;he!=="borderDash"&&e!=="fill"});function qw(n,e,t,i){const r=n.options,{[t]:s}=n.getProps([t],i);return Math.abs(e-s){a=ud(o,a,r);const c=r[o],h=r[a];i!==null?(s.push({x:c.x,y:i}),s.push({x:h.x,y:i})):t!==null&&(s.push({x:t,y:c.y}),s.push({x:t,y:h.y}))}),s}function ud(n,e,t){for(;e>n;e--){const i=t[e];if(!isNaN(i.x)&&!isNaN(i.y))break}return e}function Uw(n,e,t,i){return n&&e?i(n[t],e[t]):n?n[t]:e?e[t]:0}function bT(n,e){let t=[],i=!1;return bt(n)?(i=!0,t=n):t=tX(n,e),t.length?new Xr({points:t,options:{tension:0},_loop:i,_fullLoop:i}):null}function Hw(n){return n&&n.fill!==!1}function nX(n,e,t){let r=n[e].fill;const s=[e];let o;if(!t)return r;for(;r!==!1&&s.indexOf(r)===-1;){if(!nn(r))return r;if(o=n[r],!o)return!1;if(o.visible)return r;s.push(r),r=o.fill}return!1}function iX(n,e,t){const i=lX(n);if(De(i))return isNaN(i.value)?!1:i;let r=parseFloat(i);return nn(r)&&Math.floor(r)===r?rX(i[0],e,r,t):["origin","start","end","stack","shape"].indexOf(i)>=0&&i}function rX(n,e,t,i){return(n==="-"||n==="+")&&(t=e+t),t===e||t<0||t>=i?!1:t}function sX(n,e){let t=null;return n==="start"?t=e.bottom:n==="end"?t=e.top:De(n)?t=e.getPixelForValue(n.value):e.getBasePixel&&(t=e.getBasePixel()),t}function oX(n,e,t){let i;return n==="start"?i=t:n==="end"?i=e.options.reverse?e.min:e.max:De(n)?i=n.value:i=e.getBaseValue(),i}function lX(n){const e=n.options,t=e.fill;let i=Re(t&&t.target,t);return i===void 0&&(i=!!e.backgroundColor),i===!1||i===null?!1:i===!0?"origin":i}function aX(n){const{scale:e,index:t,line:i}=n,r=[],s=i.segments,o=i.points,a=cX(e,t);a.push(bT({x:null,y:e.bottom},i));for(let c=0;c=0;--o){const a=r[o].$filler;a&&(a.line.updateControlPoints(s,a.axis),i&&a.fill&&dm(n.ctx,a,s))}},beforeDatasetsDraw(n,e,t){if(t.drawTime!=="beforeDatasetsDraw")return;const i=n.getSortedVisibleDatasetMetas();for(let r=i.length-1;r>=0;--r){const s=i[r].$filler;Hw(s)&&dm(n.ctx,s,n.chartArea)}},beforeDatasetDraw(n,e,t){const i=e.meta.$filler;!Hw(i)||t.drawTime!=="beforeDatasetDraw"||dm(n.ctx,i,n.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const ek=(n,e)=>{let{boxHeight:t=e,boxWidth:i=e}=n;return n.usePointStyle&&(t=Math.min(t,e),i=n.pointStyleWidth||Math.min(i,e)),{boxWidth:i,boxHeight:t,itemHeight:Math.max(e,t)}},vX=(n,e)=>n!==null&&e!==null&&n.datasetIndex===e.datasetIndex&&n.index===e.index;class tk extends fi{constructor(e){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=e.chart,this.options=e.options,this.ctx=e.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(e,t,i){this.maxWidth=e,this.maxHeight=t,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const e=this.options.labels||{};let t=it(e.generateLabels,[this.chart],this)||[];e.filter&&(t=t.filter(i=>e.filter(i,this.chart.data))),e.sort&&(t=t.sort((i,r)=>e.sort(i,r,this.chart.data))),this.options.reverse&&t.reverse(),this.legendItems=t}fit(){const{options:e,ctx:t}=this;if(!e.display){this.width=this.height=0;return}const i=e.labels,r=tn(i.font),s=r.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:c}=ek(i,s);let h,f;t.font=r.string,this.isHorizontal()?(h=this.maxWidth,f=this._fitRows(o,s,a,c)+10):(f=this.maxHeight,h=this._fitCols(o,r,a,c)+10),this.width=Math.min(h,e.maxWidth||this.maxWidth),this.height=Math.min(f,e.maxHeight||this.maxHeight)}_fitRows(e,t,i,r){const{ctx:s,maxWidth:o,options:{labels:{padding:a}}}=this,c=this.legendHitBoxes=[],h=this.lineWidths=[0],f=r+a;let p=e;s.textAlign="left",s.textBaseline="middle";let m=-1,O=-f;return this.legendItems.forEach((v,b)=>{const S=i+t/2+s.measureText(v.text).width;(b===0||h[h.length-1]+S+2*a>o)&&(p+=f,h[h.length-(b>0?0:1)]=0,O+=f,m++),c[b]={left:0,top:O,row:m,width:S,height:r},h[h.length-1]+=S+a}),p}_fitCols(e,t,i,r){const{ctx:s,maxHeight:o,options:{labels:{padding:a}}}=this,c=this.legendHitBoxes=[],h=this.columnSizes=[],f=o-e;let p=a,m=0,O=0,v=0,b=0;return this.legendItems.forEach((S,w)=>{const{itemWidth:T,itemHeight:k}=bX(i,t,s,S,r);w>0&&O+k+2*a>f&&(p+=m+a,h.push({width:m,height:O}),v+=m+a,b++,m=O=0),c[w]={left:v,top:O,col:b,width:T,height:k},m=Math.max(m,T),O+=k+a}),p+=m,h.push({width:m,height:O}),p}adjustHitBoxes(){if(!this.options.display)return;const e=this._computeTitleHeight(),{legendHitBoxes:t,options:{align:i,labels:{padding:r},rtl:s}}=this,o=Wo(s,this.left,this.width);if(this.isHorizontal()){let a=0,c=Gt(i,this.left+r,this.right-this.lineWidths[a]);for(const h of t)a!==h.row&&(a=h.row,c=Gt(i,this.left+r,this.right-this.lineWidths[a])),h.top+=this.top+e+r,h.left=o.leftForLtr(o.x(c),h.width),c+=h.width+r}else{let a=0,c=Gt(i,this.top+e+r,this.bottom-this.columnSizes[a].height);for(const h of t)h.col!==a&&(a=h.col,c=Gt(i,this.top+e+r,this.bottom-this.columnSizes[a].height)),h.top=c,h.left+=this.left+r,h.left=o.leftForLtr(o.x(h.left),h.width),c+=h.height+r}}isHorizontal(){return this.options.position==="top"||this.options.position==="bottom"}draw(){if(this.options.display){const e=this.ctx;sd(e,this),this._draw(),od(e)}}_draw(){const{options:e,columnSizes:t,lineWidths:i,ctx:r}=this,{align:s,labels:o}=e,a=Ot.color,c=Wo(e.rtl,this.left,this.width),h=tn(o.font),{padding:f}=o,p=h.size,m=p/2;let O;this.drawTitle(),r.textAlign=c.textAlign("left"),r.textBaseline="middle",r.lineWidth=.5,r.font=h.string;const{boxWidth:v,boxHeight:b,itemHeight:S}=ek(o,p),w=function(M,R,L){if(isNaN(v)||v<=0||isNaN(b)||b<0)return;r.save();const X=Re(L.lineWidth,1);if(r.fillStyle=Re(L.fillStyle,a),r.lineCap=Re(L.lineCap,"butt"),r.lineDashOffset=Re(L.lineDashOffset,0),r.lineJoin=Re(L.lineJoin,"miter"),r.lineWidth=X,r.strokeStyle=Re(L.strokeStyle,a),r.setLineDash(Re(L.lineDash,[])),o.usePointStyle){const ie={radius:b*Math.SQRT2/2,pointStyle:L.pointStyle,rotation:L.rotation,borderWidth:X},Y=c.xPlus(M,v/2),H=R+m;YC(r,ie,Y,H,o.pointStyleWidth&&v)}else{const ie=R+Math.max((p-b)/2,0),Y=c.leftForLtr(M,v),H=Xo(L.borderRadius);r.beginPath(),Object.values(H).some(F=>F!==0)?Rf(r,{x:Y,y:ie,w:v,h:b,radius:H}):r.rect(Y,ie,v,b),r.fill(),X!==0&&r.stroke()}r.restore()},T=function(M,R,L){hc(r,L.text,M,R+S/2,h,{strikethrough:L.hidden,textAlign:c.textAlign(L.textAlign)})},k=this.isHorizontal(),_=this._computeTitleHeight();k?O={x:Gt(s,this.left+f,this.right-i[0]),y:this.top+f+_,line:0}:O={x:this.left+f,y:Gt(s,this.top+_+f,this.bottom-t[0].height),line:0},eT(this.ctx,e.textDirection);const C=S+f;this.legendItems.forEach((M,R)=>{r.strokeStyle=M.fontColor,r.fillStyle=M.fontColor;const L=r.measureText(M.text).width,X=c.textAlign(M.textAlign||(M.textAlign=o.textAlign)),ie=v+m+L;let Y=O.x,H=O.y;c.setWidth(this.width),k?R>0&&Y+ie+f>this.right&&(H=O.y+=C,O.line++,Y=O.x=Gt(s,this.left+f,this.right-i[O.line])):R>0&&H+C>this.bottom&&(Y=O.x=Y+t[O.line].width+f,O.line++,H=O.y=Gt(s,this.top+_+f,this.bottom-t[O.line].height));const F=c.x(Y);if(w(F,H,M),Y=J4(X,Y+v+m,k?Y+ie:this.right,e.rtl),T(c.x(Y),H,M),k)O.x+=ie+f;else if(typeof M.text!="string"){const re=h.lineHeight;O.y+=wT(M,re)+f}else O.y+=C}),tT(this.ctx,e.textDirection)}drawTitle(){const e=this.options,t=e.title,i=tn(t.font),r=Un(t.padding);if(!t.display)return;const s=Wo(e.rtl,this.left,this.width),o=this.ctx,a=t.position,c=i.size/2,h=r.top+c;let f,p=this.left,m=this.width;if(this.isHorizontal())m=Math.max(...this.lineWidths),f=this.top+h,p=Gt(e.align,p,this.right-m);else{const v=this.columnSizes.reduce((b,S)=>Math.max(b,S.height),0);f=h+Gt(e.align,this.top,this.bottom-v-e.labels.padding-this._computeTitleHeight())}const O=Gt(a,p,p+m);o.textAlign=s.textAlign(iy(a)),o.textBaseline="middle",o.strokeStyle=t.color,o.fillStyle=t.color,o.font=i.string,hc(o,t.text,O,f,i)}_computeTitleHeight(){const e=this.options.title,t=tn(e.font),i=Un(e.padding);return e.display?t.lineHeight+i.height:0}_getLegendItemAt(e,t){let i,r,s;if(rr(e,this.left,this.right)&&rr(t,this.top,this.bottom)){for(s=this.legendHitBoxes,i=0;is.length>o.length?s:o)),e+t.size/2+i.measureText(r).width}function wX(n,e,t){let i=n;return typeof e.text!="string"&&(i=wT(e,t)),i}function wT(n,e){const t=n.text?n.text.length:0;return e*t}function kX(n,e){return!!((n==="mousemove"||n==="mouseout")&&(e.onHover||e.onLeave)||e.onClick&&(n==="click"||n==="mouseup"))}var PX={id:"legend",_element:tk,start(n,e,t){const i=n.legend=new tk({ctx:n.ctx,options:t,chart:n});Vn.configure(n,i,t),Vn.addBox(n,i)},stop(n){Vn.removeBox(n,n.legend),delete n.legend},beforeUpdate(n,e,t){const i=n.legend;Vn.configure(n,i,t),i.options=t},afterUpdate(n){const e=n.legend;e.buildLabels(),e.adjustHitBoxes()},afterEvent(n,e){e.replay||n.legend.handleEvent(e.event)},defaults:{display:!0,position:"top",align:"center",fullSize:!0,reverse:!1,weight:1e3,onClick(n,e,t){const i=e.datasetIndex,r=t.chart;r.isDatasetVisible(i)?(r.hide(i),e.hidden=!0):(r.show(i),e.hidden=!1)},onHover:null,onLeave:null,labels:{color:n=>n.chart.options.color,boxWidth:40,padding:10,generateLabels(n){const e=n.data.datasets,{labels:{usePointStyle:t,pointStyle:i,textAlign:r,color:s,useBorderRadius:o,borderRadius:a}}=n.legend.options;return n._getSortedDatasetMetas().map(c=>{const h=c.controller.getStyle(t?0:void 0),f=Un(h.borderWidth);return{text:e[c.index].label,fillStyle:h.backgroundColor,fontColor:s,hidden:!c.visible,lineCap:h.borderCapStyle,lineDash:h.borderDash,lineDashOffset:h.borderDashOffset,lineJoin:h.borderJoinStyle,lineWidth:(f.width+f.height)/4,strokeStyle:h.borderColor,pointStyle:i||h.pointStyle,rotation:h.rotation,textAlign:r||h.textAlign,borderRadius:o&&(a||h.borderRadius),datasetIndex:c.index}},this)}},title:{color:n=>n.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:n=>!n.startsWith("on"),labels:{_scriptable:n=>!["generateLabels","filter","sort"].includes(n)}}};let kT=class extends fi{constructor(e){super(),this.chart=e.chart,this.options=e.options,this.ctx=e.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(e,t){const i=this.options;if(this.left=0,this.top=0,!i.display){this.width=this.height=this.right=this.bottom=0;return}this.width=this.right=e,this.height=this.bottom=t;const r=bt(i.text)?i.text.length:1;this._padding=Un(i.padding);const s=r*tn(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=s:this.width=s}isHorizontal(){const e=this.options.position;return e==="top"||e==="bottom"}_drawArgs(e){const{top:t,left:i,bottom:r,right:s,options:o}=this,a=o.align;let c=0,h,f,p;return this.isHorizontal()?(f=Gt(a,i,s),p=t+e,h=s-i):(o.position==="left"?(f=i+e,p=Gt(a,r,t),c=qe*-.5):(f=s-e,p=Gt(a,t,r),c=qe*.5),h=r-t),{titleX:f,titleY:p,maxWidth:h,rotation:c}}draw(){const e=this.ctx,t=this.options;if(!t.display)return;const i=tn(t.font),s=i.lineHeight/2+this._padding.top,{titleX:o,titleY:a,maxWidth:c,rotation:h}=this._drawArgs(s);hc(e,t.text,0,0,i,{color:t.color,maxWidth:c,rotation:h,textAlign:iy(t.align),textBaseline:"middle",translation:[o,a]})}};function _X(n,e){const t=new kT({ctx:n.ctx,options:e,chart:n});Vn.configure(n,t,e),Vn.addBox(n,t),n.titleBlock=t}var QX={id:"title",_element:kT,start(n,e,t){_X(n,t)},stop(n){const e=n.titleBlock;Vn.removeBox(n,e),delete n.titleBlock},beforeUpdate(n,e,t){const i=n.titleBlock;Vn.configure(n,i,t),i.options=t},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const ba={average(n){if(!n.length)return!1;let e,t,i=new Set,r=0,s=0;for(e=0,t=n.length;ea+c)/i.size,y:r/s}},nearest(n,e){if(!n.length)return!1;let t=e.x,i=e.y,r=Number.POSITIVE_INFINITY,s,o,a;for(s=0,o=n.length;s-1?n.split(` +`):n}function CX(n,e){const{element:t,datasetIndex:i,index:r}=e,s=n.getDatasetMeta(i).controller,{label:o,value:a}=s.getLabelAndValue(r);return{chart:n,label:o,parsed:s.getParsed(r),raw:n.data.datasets[i].data[r],formattedValue:a,dataset:s.getDataset(),dataIndex:r,datasetIndex:i,element:t}}function nk(n,e){const t=n.chart.ctx,{body:i,footer:r,title:s}=n,{boxWidth:o,boxHeight:a}=e,c=tn(e.bodyFont),h=tn(e.titleFont),f=tn(e.footerFont),p=s.length,m=r.length,O=i.length,v=Un(e.padding);let b=v.height,S=0,w=i.reduce((_,C)=>_+C.before.length+C.lines.length+C.after.length,0);if(w+=n.beforeBody.length+n.afterBody.length,p&&(b+=p*h.lineHeight+(p-1)*e.titleSpacing+e.titleMarginBottom),w){const _=e.displayColors?Math.max(a,c.lineHeight):c.lineHeight;b+=O*_+(w-O)*c.lineHeight+(w-1)*e.bodySpacing}m&&(b+=e.footerMarginTop+m*f.lineHeight+(m-1)*e.footerSpacing);let T=0;const k=function(_){S=Math.max(S,t.measureText(_).width+T)};return t.save(),t.font=h.string,Ye(n.title,k),t.font=c.string,Ye(n.beforeBody.concat(n.afterBody),k),T=e.displayColors?o+2+e.boxPadding:0,Ye(i,_=>{Ye(_.before,k),Ye(_.lines,k),Ye(_.after,k)}),T=0,t.font=f.string,Ye(n.footer,k),t.restore(),S+=v.width,{width:S,height:b}}function TX(n,e){const{y:t,height:i}=e;return tn.height-i/2?"bottom":"center"}function $X(n,e,t,i){const{x:r,width:s}=i,o=t.caretSize+t.caretPadding;if(n==="left"&&r+s+o>e.width||n==="right"&&r-s-o<0)return!0}function MX(n,e,t,i){const{x:r,width:s}=t,{width:o,chartArea:{left:a,right:c}}=n;let h="center";return i==="center"?h=r<=(a+c)/2?"left":"right":r<=s/2?h="left":r>=o-s/2&&(h="right"),$X(h,n,e,t)&&(h="center"),h}function ik(n,e,t){const i=t.yAlign||e.yAlign||TX(n,t);return{xAlign:t.xAlign||e.xAlign||MX(n,e,t,i),yAlign:i}}function RX(n,e){let{x:t,width:i}=n;return e==="right"?t-=i:e==="center"&&(t-=i/2),t}function AX(n,e,t){let{y:i,height:r}=n;return e==="top"?i+=t:e==="bottom"?i-=r+t:i-=r/2,i}function rk(n,e,t,i){const{caretSize:r,caretPadding:s,cornerRadius:o}=n,{xAlign:a,yAlign:c}=t,h=r+s,{topLeft:f,topRight:p,bottomLeft:m,bottomRight:O}=Xo(o);let v=RX(e,a);const b=AX(e,c,h);return c==="center"?a==="left"?v+=h:a==="right"&&(v-=h):a==="left"?v-=Math.max(f,m)+r:a==="right"&&(v+=Math.max(p,O)+r),{x:en(v,0,i.width-e.width),y:en(b,0,i.height-e.height)}}function bh(n,e,t){const i=Un(t.padding);return e==="center"?n.x+n.width/2:e==="right"?n.x+n.width-i.right:n.x+i.left}function sk(n){return _i([],Gi(n))}function EX(n,e,t){return Hs(n,{tooltip:e,tooltipItems:t,type:"tooltip"})}function ok(n,e){const t=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return t?n.override(t):n}const PT={beforeTitle:Ui,title(n){if(n.length>0){const e=n[0],t=e.chart.data.labels,i=t?t.length:0;if(this&&this.options&&this.options.mode==="dataset")return e.dataset.label||"";if(e.label)return e.label;if(i>0&&e.dataIndex"u"?PT[e].call(t,i):r}class FO extends fi{constructor(e){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=e.chart,this.options=e.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(e){this.options=e,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){const e=this._cachedAnimations;if(e)return e;const t=this.chart,i=this.options.setContext(this.getContext()),r=i.enabled&&t.options.animation&&i.animations,s=new oT(this.chart,r);return r._cacheable&&(this._cachedAnimations=Object.freeze(s)),s}getContext(){return this.$context||(this.$context=EX(this.chart.getContext(),this,this._tooltipItems))}getTitle(e,t){const{callbacks:i}=t,r=mn(i,"beforeTitle",this,e),s=mn(i,"title",this,e),o=mn(i,"afterTitle",this,e);let a=[];return a=_i(a,Gi(r)),a=_i(a,Gi(s)),a=_i(a,Gi(o)),a}getBeforeBody(e,t){return sk(mn(t.callbacks,"beforeBody",this,e))}getBody(e,t){const{callbacks:i}=t,r=[];return Ye(e,s=>{const o={before:[],lines:[],after:[]},a=ok(i,s);_i(o.before,Gi(mn(a,"beforeLabel",this,s))),_i(o.lines,mn(a,"label",this,s)),_i(o.after,Gi(mn(a,"afterLabel",this,s))),r.push(o)}),r}getAfterBody(e,t){return sk(mn(t.callbacks,"afterBody",this,e))}getFooter(e,t){const{callbacks:i}=t,r=mn(i,"beforeFooter",this,e),s=mn(i,"footer",this,e),o=mn(i,"afterFooter",this,e);let a=[];return a=_i(a,Gi(r)),a=_i(a,Gi(s)),a=_i(a,Gi(o)),a}_createItems(e){const t=this._active,i=this.chart.data,r=[],s=[],o=[];let a=[],c,h;for(c=0,h=t.length;ce.filter(f,p,m,i))),e.itemSort&&(a=a.sort((f,p)=>e.itemSort(f,p,i))),Ye(a,f=>{const p=ok(e.callbacks,f);r.push(mn(p,"labelColor",this,f)),s.push(mn(p,"labelPointStyle",this,f)),o.push(mn(p,"labelTextColor",this,f))}),this.labelColors=r,this.labelPointStyles=s,this.labelTextColors=o,this.dataPoints=a,a}update(e,t){const i=this.options.setContext(this.getContext()),r=this._active;let s,o=[];if(!r.length)this.opacity!==0&&(s={opacity:0});else{const a=ba[i.position].call(this,r,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const c=this._size=nk(this,i),h=Object.assign({},a,c),f=ik(this.chart,i,h),p=rk(i,h,f,this.chart);this.xAlign=f.xAlign,this.yAlign=f.yAlign,s={opacity:1,x:p.x,y:p.y,width:c.width,height:c.height,caretX:a.x,caretY:a.y}}this._tooltipItems=o,this.$context=void 0,s&&this._resolveAnimations().update(this,s),e&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:t})}drawCaret(e,t,i,r){const s=this.getCaretPosition(e,i,r);t.lineTo(s.x1,s.y1),t.lineTo(s.x2,s.y2),t.lineTo(s.x3,s.y3)}getCaretPosition(e,t,i){const{xAlign:r,yAlign:s}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:c,topRight:h,bottomLeft:f,bottomRight:p}=Xo(a),{x:m,y:O}=e,{width:v,height:b}=t;let S,w,T,k,_,C;return s==="center"?(_=O+b/2,r==="left"?(S=m,w=S-o,k=_+o,C=_-o):(S=m+v,w=S+o,k=_-o,C=_+o),T=S):(r==="left"?w=m+Math.max(c,f)+o:r==="right"?w=m+v-Math.max(h,p)-o:w=this.caretX,s==="top"?(k=O,_=k-o,S=w-o,T=w+o):(k=O+b,_=k+o,S=w+o,T=w-o),C=k),{x1:S,x2:w,x3:T,y1:k,y2:_,y3:C}}drawTitle(e,t,i){const r=this.title,s=r.length;let o,a,c;if(s){const h=Wo(i.rtl,this.x,this.width);for(e.x=bh(this,i.titleAlign,i),t.textAlign=h.textAlign(i.titleAlign),t.textBaseline="middle",o=tn(i.titleFont),a=i.titleSpacing,t.fillStyle=i.titleColor,t.font=o.string,c=0;cT!==0)?(e.beginPath(),e.fillStyle=s.multiKeyBackground,Rf(e,{x:b,y:v,w:h,h:c,radius:w}),e.fill(),e.stroke(),e.fillStyle=o.backgroundColor,e.beginPath(),Rf(e,{x:S,y:v+1,w:h-2,h:c-2,radius:w}),e.fill()):(e.fillStyle=s.multiKeyBackground,e.fillRect(b,v,h,c),e.strokeRect(b,v,h,c),e.fillStyle=o.backgroundColor,e.fillRect(S,v+1,h-2,c-2))}e.fillStyle=this.labelTextColors[i]}drawBody(e,t,i){const{body:r}=this,{bodySpacing:s,bodyAlign:o,displayColors:a,boxHeight:c,boxWidth:h,boxPadding:f}=i,p=tn(i.bodyFont);let m=p.lineHeight,O=0;const v=Wo(i.rtl,this.x,this.width),b=function(L){t.fillText(L,v.x(e.x+O),e.y+m/2),e.y+=m+s},S=v.textAlign(o);let w,T,k,_,C,M,R;for(t.textAlign=o,t.textBaseline="middle",t.font=p.string,e.x=bh(this,S,i),t.fillStyle=i.bodyColor,Ye(this.beforeBody,b),O=a&&S!=="right"?o==="center"?h/2+f:h+2+f:0,_=0,M=r.length;_0&&t.stroke()}_updateAnimationTarget(e){const t=this.chart,i=this.$animations,r=i&&i.x,s=i&&i.y;if(r||s){const o=ba[e.position].call(this,this._active,this._eventPosition);if(!o)return;const a=this._size=nk(this,e),c=Object.assign({},o,this._size),h=ik(t,e,c),f=rk(e,c,h,t);(r._to!==f.x||s._to!==f.y)&&(this.xAlign=h.xAlign,this.yAlign=h.yAlign,this.width=a.width,this.height=a.height,this.caretX=o.x,this.caretY=o.y,this._resolveAnimations().update(this,f))}}_willRender(){return!!this.opacity}draw(e){const t=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(t);const r={width:this.width,height:this.height},s={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=Un(t.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;t.enabled&&a&&(e.save(),e.globalAlpha=i,this.drawBackground(s,e,r,t),eT(e,t.textDirection),s.y+=o.top,this.drawTitle(s,e,t),this.drawBody(s,e,t),this.drawFooter(s,e,t),tT(e,t.textDirection),e.restore())}getActiveElements(){return this._active||[]}setActiveElements(e,t){const i=this._active,r=e.map(({datasetIndex:a,index:c})=>{const h=this.chart.getDatasetMeta(a);if(!h)throw new Error("Cannot find a dataset at index "+a);return{datasetIndex:a,element:h.data[c],index:c}}),s=!Tf(i,r),o=this._positionChanged(r,t);(s||o)&&(this._active=r,this._eventPosition=t,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(e,t,i=!0){if(t&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const r=this.options,s=this._active||[],o=this._getActiveElements(e,s,t,i),a=this._positionChanged(o,e),c=t||!Tf(o,s)||a;return c&&(this._active=o,(r.enabled||r.external)&&(this._eventPosition={x:e.x,y:e.y},this.update(!0,t))),c}_getActiveElements(e,t,i,r){const s=this.options;if(e.type==="mouseout")return[];if(!r)return t.filter(a=>this.chart.data.datasets[a.datasetIndex]&&this.chart.getDatasetMeta(a.datasetIndex).controller.getParsed(a.index)!==void 0);const o=this.chart.getElementsAtEventForMode(e,s.mode,s,i);return s.reverse&&o.reverse(),o}_positionChanged(e,t){const{caretX:i,caretY:r,options:s}=this,o=ba[s.position].call(this,e,t);return o!==!1&&(i!==o.x||r!==o.y)}}me(FO,"positioners",ba);var LX={id:"tooltip",_element:FO,positioners:ba,afterInit(n,e,t){t&&(n.tooltip=new FO({chart:n,options:t}))},beforeUpdate(n,e,t){n.tooltip&&n.tooltip.initialize(t)},reset(n,e,t){n.tooltip&&n.tooltip.initialize(t)},afterDraw(n){const e=n.tooltip;if(e&&e._willRender()){const t={tooltip:e};if(n.notifyPlugins("beforeTooltipDraw",{...t,cancelable:!0})===!1)return;e.draw(n.ctx),n.notifyPlugins("afterTooltipDraw",t)}},afterEvent(n,e){if(n.tooltip){const t=e.replay;n.tooltip.handleEvent(e.event,t,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(n,e)=>e.bodyFont.size,boxWidth:(n,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:PT},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:n=>n!=="filter"&&n!=="itemSort"&&n!=="external",_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};const DX=(n,e,t,i)=>(typeof e=="string"?(t=n.push(e)-1,i.unshift({index:t,label:e})):isNaN(e)&&(t=null),t);function zX(n,e,t,i){const r=n.indexOf(e);if(r===-1)return DX(n,e,t,i);const s=n.lastIndexOf(e);return r!==s?t:r}const jX=(n,e)=>n===null?null:en(Math.round(n),0,e);function lk(n){const e=this.getLabels();return n>=0&&nt.length-1?null:this.getPixelForValue(t[e].value)}getValueForPixel(e){return Math.round(this._startValue+this.getDecimalForPixel(e)*this._valueRange)}getBasePixel(){return this.bottom}}me(YO,"id","category"),me(YO,"defaults",{ticks:{callback:lk}});function ZX(n,e){const t=[],{bounds:r,step:s,min:o,max:a,precision:c,count:h,maxTicks:f,maxDigits:p,includeBounds:m}=n,O=s||1,v=f-1,{min:b,max:S}=e,w=!Be(o),T=!Be(a),k=!Be(h),_=(S-b)/(p+1);let C=rw((S-b)/v/O)*O,M,R,L,X;if(C<1e-14&&!w&&!T)return[{value:b},{value:S}];X=Math.ceil(S/C)-Math.floor(b/C),X>v&&(C=rw(X*C/v/O)*O),Be(c)||(M=Math.pow(10,c),C=Math.ceil(C*M)/M),r==="ticks"?(R=Math.floor(b/C)*C,L=Math.ceil(S/C)*C):(R=b,L=S),w&&T&&s&&W4((a-o)/s,C/1e3)?(X=Math.round(Math.min((a-o)/C,f)),C=(a-o)/X,R=o,L=a):k?(R=w?o:R,L=T?a:L,X=h-1,C=(L-R)/X):(X=(L-R)/C,Ra(X,Math.round(X),C/1e3)?X=Math.round(X):X=Math.ceil(X));const ie=Math.max(sw(C),sw(R));M=Math.pow(10,Be(c)?ie:c),R=Math.round(R*M)/M,L=Math.round(L*M)/M;let Y=0;for(w&&(m&&R!==o?(t.push({value:o}),Ra)break;t.push({value:H})}return T&&m&&L!==a?t.length&&Ra(t[t.length-1].value,a,ak(a,_,n))?t[t.length-1].value=a:t.push({value:a}):(!T||L===a)&&t.push({value:L}),t}function ak(n,e,{horizontal:t,minRotation:i}){const r=ir(i),s=(t?Math.sin(r):Math.cos(r))||.001,o=.75*e*(""+n).length;return Math.min(e/s,o)}class IX extends hl{constructor(e){super(e),this.start=void 0,this.end=void 0,this._startValue=void 0,this._endValue=void 0,this._valueRange=0}parse(e,t){return Be(e)||(typeof e=="number"||e instanceof Number)&&!isFinite(+e)?null:+e}handleTickRangeOptions(){const{beginAtZero:e}=this.options,{minDefined:t,maxDefined:i}=this.getUserBounds();let{min:r,max:s}=this;const o=c=>r=t?r:c,a=c=>s=i?s:c;if(e){const c=zi(r),h=zi(s);c<0&&h<0?a(0):c>0&&h>0&&o(0)}if(r===s){let c=s===0?1:Math.abs(s*.05);a(s+c),e||o(r-c)}this.min=r,this.max=s}getTickLimit(){const e=this.options.ticks;let{maxTicksLimit:t,stepSize:i}=e,r;return i?(r=Math.ceil(this.max/i)-Math.floor(this.min/i)+1,r>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${i} would result generating up to ${r} ticks. Limiting to 1000.`),r=1e3)):(r=this.computeTickLimit(),t=t||11),t&&(r=Math.min(t,r)),r}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const e=this.options,t=e.ticks;let i=this.getTickLimit();i=Math.max(2,i);const r={maxTicks:i,bounds:e.bounds,min:e.min,max:e.max,precision:t.precision,step:t.stepSize,count:t.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:t.minRotation||0,includeBounds:t.includeBounds!==!1},s=this._range||this,o=ZX(r,s);return e.bounds==="ticks"&&V4(o,this,"value"),e.reverse?(o.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),o}configure(){const e=this.ticks;let t=this.min,i=this.max;if(super.configure(),this.options.offset&&e.length){const r=(i-t)/Math.max(e.length-1,1)/2;t-=r,i+=r}this._startValue=t,this._endValue=i,this._valueRange=i-t}getLabelForValue(e){return sy(e,this.chart.options.locale,this.options.ticks.format)}}class qO extends IX{determineDataLimits(){const{min:e,max:t}=this.getMinMax(!0);this.min=nn(e)?e:0,this.max=nn(t)?t:1,this.handleTickRangeOptions()}computeTickLimit(){const e=this.isHorizontal(),t=e?this.width:this.height,i=ir(this.options.ticks.minRotation),r=(e?Math.sin(i):Math.cos(i))||.001,s=this._resolveTickFontOptions(0);return Math.ceil(t/Math.min(40,s.lineHeight/r))}getPixelForValue(e){return e===null?NaN:this.getPixelForDecimal((e-this._startValue)/this._valueRange)}getValueForPixel(e){return this._startValue+this.getDecimalForPixel(e)*this._valueRange}}me(qO,"id","linear"),me(qO,"defaults",{ticks:{callback:FC.formatters.numeric}});const hd={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},vn=Object.keys(hd);function ck(n,e){return n-e}function uk(n,e){if(Be(e))return null;const t=n._adapter,{parser:i,round:r,isoWeekday:s}=n._parseOpts;let o=e;return typeof i=="function"&&(o=i(o)),nn(o)||(o=typeof i=="string"?t.parse(o,i):t.parse(o)),o===null?null:(r&&(o=r==="week"&&(nl(s)||s===!0)?t.startOf(o,"isoWeek",s):t.startOf(o,r)),+o)}function hk(n,e,t,i){const r=vn.length;for(let s=vn.indexOf(n);s=vn.indexOf(t);s--){const o=vn[s];if(hd[o].common&&n._adapter.diff(r,i,o)>=e-1)return o}return vn[t?vn.indexOf(t):0]}function BX(n){for(let e=vn.indexOf(n)+1,t=vn.length;e=e?t[i]:t[r];n[s]=!0}}function XX(n,e,t,i){const r=n._adapter,s=+r.startOf(e[0].value,i),o=e[e.length-1].value;let a,c;for(a=s;a<=o;a=+r.add(a,1,i))c=t[a],c>=0&&(e[c].major=!0);return e}function dk(n,e,t){const i=[],r={},s=e.length;let o,a;for(o=0;o+e.value))}initOffsets(e=[]){let t=0,i=0,r,s;this.options.offset&&e.length&&(r=this.getDecimalForValue(e[0]),e.length===1?t=1-r:t=(this.getDecimalForValue(e[1])-r)/2,s=this.getDecimalForValue(e[e.length-1]),e.length===1?i=s:i=(s-this.getDecimalForValue(e[e.length-2]))/2);const o=e.length<3?.5:.25;t=en(t,0,o),i=en(i,0,o),this._offsets={start:t,end:i,factor:1/(t+1+i)}}_generate(){const e=this._adapter,t=this.min,i=this.max,r=this.options,s=r.time,o=s.unit||hk(s.minUnit,t,i,this._getLabelCapacity(t)),a=Re(r.ticks.stepSize,1),c=o==="week"?s.isoWeekday:!1,h=nl(c)||c===!0,f={};let p=t,m,O;if(h&&(p=+e.startOf(p,"isoWeek",c)),p=+e.startOf(p,h?"day":o),e.diff(i,t,o)>1e5*a)throw new Error(t+" and "+i+" are too far apart with stepSize of "+a+" "+o);const v=r.ticks.source==="data"&&this.getDataTimestamps();for(m=p,O=0;m+b)}getLabelForValue(e){const t=this._adapter,i=this.options.time;return i.tooltipFormat?t.format(e,i.tooltipFormat):t.format(e,i.displayFormats.datetime)}format(e,t){const r=this.options.time.displayFormats,s=this._unit,o=t||r[s];return this._adapter.format(e,o)}_tickFormatFunction(e,t,i,r){const s=this.options,o=s.ticks.callback;if(o)return it(o,[e,t,i],this);const a=s.time.displayFormats,c=this._unit,h=this._majorUnit,f=c&&a[c],p=h&&a[h],m=i[t],O=h&&p&&m&&m.major;return this._adapter.format(e,r||(O?p:f))}generateTickLabels(e){let t,i,r;for(t=0,i=e.length;t0?a:1}getDataTimestamps(){let e=this._cache.data||[],t,i;if(e.length)return e;const r=this.getMatchingVisibleMetas();if(this._normalized&&r.length)return this._cache.data=r[0].controller.getAllParsedValues(this);for(t=0,i=r.length;t=n[i].pos&&e<=n[r].pos&&({lo:i,hi:r}=Ls(n,"pos",e)),{pos:s,time:a}=n[i],{pos:o,time:c}=n[r]):(e>=n[i].time&&e<=n[r].time&&({lo:i,hi:r}=Ls(n,"time",e)),{time:s,pos:a}=n[i],{time:o,pos:c}=n[r]);const h=o-s;return h?a+(c-a)*(e-s)/h:a}class pk extends Df{constructor(e){super(e),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const e=this._getTimestampsForTable(),t=this._table=this.buildLookupTable(e);this._minPos=Sh(t,this.min),this._tableRange=Sh(t,this.max)-this._minPos,super.initOffsets(e)}buildLookupTable(e){const{min:t,max:i}=this,r=[],s=[];let o,a,c,h,f;for(o=0,a=e.length;o=t&&h<=i&&r.push(h);if(r.length<2)return[{time:t,pos:0},{time:i,pos:1}];for(o=0,a=r.length;or-s)}_getTimestampsForTable(){let e=this._cache.all||[];if(e.length)return e;const t=this.getDataTimestamps(),i=this.getLabelTimestamps();return t.length&&i.length?e=this.normalize(t.concat(i)):e=t.length?t:i,e=this._cache.all=e,e}getDecimalForValue(e){return(Sh(this._table,e)-this._minPos)/this._tableRange}getValueForPixel(e){const t=this._offsets,i=this.getDecimalForPixel(e)/t.factor-t.end;return Sh(this._table,i*this._tableRange+this._minPos,!0)}}me(pk,"id","timeseries"),me(pk,"defaults",Df.defaults);const _T="label";function gk(n,e){typeof n=="function"?n(e):n&&(n.current=e)}function WX(n,e){const t=n.options;t&&e&&Object.assign(t,e)}function QT(n,e){n.labels=e}function CT(n,e){let t=arguments.length>2&&arguments[2]!==void 0?arguments[2]:_T;const i=[];n.datasets=e.map(r=>{const s=n.datasets.find(o=>o[t]===r[t]);return!s||!r.data||i.includes(s)?{...r}:(i.push(s),Object.assign(s,r),s)})}function VX(n){let e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:_T;const t={labels:[],datasets:[]};return QT(t,n.labels),CT(t,n.datasets,e),t}function FX(n,e){const{height:t=150,width:i=300,redraw:r=!1,datasetIdKey:s,type:o,data:a,options:c,plugins:h=[],fallbackContent:f,updateMode:p,...m}=n,O=ne.useRef(null),v=ne.useRef(null),b=()=>{O.current&&(v.current=new cd(O.current,{type:o,data:VX(a,s),options:c&&{...c},plugins:h}),gk(e,v.current))},S=()=>{gk(e,null),v.current&&(v.current.destroy(),v.current=null)};return ne.useEffect(()=>{!r&&v.current&&c&&WX(v.current,c)},[r,c]),ne.useEffect(()=>{!r&&v.current&&QT(v.current.config.data,a.labels)},[r,a.labels]),ne.useEffect(()=>{!r&&v.current&&a.datasets&&CT(v.current.config.data,a.datasets,s)},[r,a.datasets]),ne.useEffect(()=>{v.current&&(r?(S(),setTimeout(b)):v.current.update(p))},[r,c,a.labels,a.datasets,p]),ne.useEffect(()=>{v.current&&(S(),setTimeout(b))},[o]),ne.useEffect(()=>(b(),()=>S()),[]),dt.createElement("canvas",{ref:O,role:"img",height:t,width:i,...m},f)}const YX=ne.forwardRef(FX);function Pc(n,e){return cd.register(e),ne.forwardRef((t,i)=>dt.createElement(YX,{...t,ref:i,type:n}))}const qX=Pc("line",Xh),UX=Pc("bar",Bh),HX=Pc("doughnut",Do),GX=Pc("pie",NO),KX=Pc("scatter",La);cd.register(YO,qO,Fh,Xr,Yh,va,La,QX,LX,PX,xX);const mk=I.div` + display: flex; + flex-direction: column; + height: 100%; + background: #1e1e1e; + border: 1px solid #3c3c3c; + border-radius: 8px; + overflow: hidden; +`,JX=I.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid #3c3c3c; + background: #2d2d2d; +`,e8=I.h3` + margin: 0; + color: #ffffff; + font-size: 16px; + font-weight: 600; +`,t8=I.div` + display: flex; + gap: 8px; + align-items: center; +`,Ok=I.select` + padding: 4px 8px; + background: #3c3c3c; + border: 1px solid #5a5a5a; + border-radius: 4px; + color: #ffffff; + font-size: 12px; + outline: none; + + &:focus { + border-color: #0078d4; + } + + option { + background: #3c3c3c; + color: #ffffff; + } +`,Qo=I.button` + padding: 4px 8px; + background: ${n=>n.active?"#0078d4":"#3c3c3c"}; + color: white; + border: none; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: ${n=>n.active?"#106ebe":"#484848"}; + } +`,n8=I.div` + flex: 1; + padding: 16px; + position: relative; + min-height: 300px; + + canvas { + max-height: 100% !important; + } +`,i8=I.div` + position: absolute; + top: 0; + right: 0; + width: 250px; + height: 100%; + background: #2d2d2d; + border-left: 1px solid #3c3c3c; + padding: 16px; + transform: ${n=>n.show?"translateX(0)":"translateX(100%)"}; + transition: transform 0.3s ease; + z-index: 10; + overflow-y: auto; + + h4 { + margin: 0 0 12px 0; + color: #ffffff; + font-size: 14px; + font-weight: 600; + } + + .config-group { + margin-bottom: 16px; + + label { + display: block; + margin-bottom: 4px; + color: #cccccc; + font-size: 12px; + } + } +`,r8=I.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: #888888; + + .icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; + } + + .message { + font-size: 16px; + margin-bottom: 8px; + } + + .submessage { + font-size: 14px; + opacity: 0.7; + text-align: center; + } +`,s8=["#0078d4","#107c10","#d83b01","#5c2d91","#e81123","#00bcf2","#bad80a","#ff8c00","#c239b3","#00b7c3"],o8=({data:n,className:e})=>{var b,S;const[t,i]=ne.useState("bar"),[r,s]=ne.useState({type:"bar",title:"Data Visualization",x_axis:((b=n.columns[0])==null?void 0:b.name)||"",y_axis:((S=n.columns[1])==null?void 0:S.name)||"",color_scheme:s8,show_legend:!0,show_grid:!0}),[o,a]=ne.useState(!1);ne.useEffect(()=>{if(n.columns.length>=2){const w=n.columns.filter(C=>C.type.includes("int")||C.type.includes("float")||C.type.includes("decimal")||C.type.includes("numeric")),T=n.columns.filter(C=>C.type.includes("date")||C.type.includes("time")),k=n.columns.filter(C=>C.type.includes("text")||C.type.includes("varchar")||C.type.includes("char"));let _={...r};T.length>0&&w.length>0?(i("line"),_.type="line",_.x_axis=T[0].name,_.y_axis=w[0].name):k.length>0&&w.length>0?(i("bar"),_.type="bar",_.x_axis=k[0].name,_.y_axis=w[0].name):w.length>=2&&(i("scatter"),_.type="scatter",_.x_axis=w[0].name,_.y_axis=w[1].name),s(_)}},[n]);const c=w=>{const T=n.columns.find(k=>k.name===w);return T&&(T.type.includes("int")||T.type.includes("float")||T.type.includes("decimal")||T.type.includes("numeric"))},h=()=>{if(!n.rows.length)return null;const w=n.rows.map(k=>k[r.x_axis]),T=n.rows.map(k=>k[r.y_axis]);switch(t){case"line":case"bar":return{labels:w,datasets:[{label:r.y_axis,data:T,backgroundColor:t==="bar"?r.color_scheme[0]+"80":"transparent",borderColor:r.color_scheme[0],borderWidth:2,fill:t!=="line",tension:t==="line"?.4:0,pointBackgroundColor:r.color_scheme[0],pointBorderColor:r.color_scheme[0],pointRadius:t==="line"?4:0}]};case"scatter":return{datasets:[{label:`${r.x_axis} vs ${r.y_axis}`,data:n.rows.map(k=>({x:k[r.x_axis],y:k[r.y_axis]})),backgroundColor:r.color_scheme[0]+"80",borderColor:r.color_scheme[0],borderWidth:2,pointRadius:5}]};case"pie":case"doughnut":{const k=n.rows.reduce((M,R)=>{const L=R[r.x_axis];return M[L]||(M[L]=0),M[L]+=Number.parseFloat(R[r.y_axis])||0,M},{}),_=Object.keys(k),C=Object.values(k);return{labels:_,datasets:[{data:C,backgroundColor:r.color_scheme.slice(0,_.length),borderColor:"#1e1e1e",borderWidth:2}]}}default:return null}},f=()=>({responsive:!0,maintainAspectRatio:!1,plugins:{title:{display:!!r.title,text:r.title,color:"#ffffff",font:{size:16,weight:"bold"}},legend:{display:r.show_legend,labels:{color:"#ffffff"}},tooltip:{backgroundColor:"#2d2d2d",titleColor:"#ffffff",bodyColor:"#cccccc",borderColor:"#3c3c3c",borderWidth:1}},scales:t!=="pie"&&t!=="doughnut"?{x:{title:{display:!0,text:r.x_axis,color:"#ffffff"},ticks:{color:"#cccccc",maxRotation:45},grid:{display:r.show_grid,color:"#3c3c3c"}},y:{title:{display:!0,text:r.y_axis,color:"#ffffff"},ticks:{color:"#cccccc"},grid:{display:r.show_grid,color:"#3c3c3c"}}}:void 0}),p=h(),m=()=>{if(!p)return null;const w=f();switch(t){case"line":return Q.jsx(qX,{data:p,options:w});case"bar":return Q.jsx(UX,{data:p,options:w});case"pie":return Q.jsx(GX,{data:p,options:w});case"scatter":return Q.jsx(KX,{data:p,options:w});case"doughnut":return Q.jsx(HX,{data:p,options:w});default:return null}};if(n.rows.length===0)return Q.jsx(mk,{className:e,children:Q.jsxs(r8,{children:[Q.jsx("div",{className:"icon",children:"📊"}),Q.jsx("div",{className:"message",children:"No Data to Visualize"}),Q.jsx("div",{className:"submessage",children:"Execute a query that returns data to create visualizations"})]})});const O=n.columns.filter(w=>c(w.name)),v=n.columns.filter(w=>!c(w.name));return Q.jsxs(mk,{className:e,children:[Q.jsxs(JX,{children:[Q.jsx(e8,{children:"Data Visualization"}),Q.jsxs(t8,{children:[Q.jsx(Qo,{active:t==="line",onClick:()=>i("line"),disabled:O.length===0,children:"📈 Line"}),Q.jsx(Qo,{active:t==="bar",onClick:()=>i("bar"),children:"📊 Bar"}),Q.jsx(Qo,{active:t==="pie",onClick:()=>i("pie"),children:"🥧 Pie"}),Q.jsx(Qo,{active:t==="scatter",onClick:()=>i("scatter"),disabled:O.length<2,children:"⚫ Scatter"}),Q.jsx(Qo,{active:t==="doughnut",onClick:()=>i("doughnut"),children:"🍩 Doughnut"}),Q.jsx(Qo,{onClick:()=>a(!o),children:"⚙️ Config"})]})]}),Q.jsxs(n8,{children:[m(),Q.jsxs(i8,{show:o,children:[Q.jsx("h4",{children:"Chart Configuration"}),Q.jsxs("div",{className:"config-group",children:[Q.jsx("label",{htmlFor:"chart-title",children:"Title"}),Q.jsx("input",{id:"chart-title",type:"text",value:r.title,onChange:w=>s({...r,title:w.target.value}),style:{width:"100%",padding:"6px 8px",background:"#3c3c3c",border:"1px solid #5a5a5a",borderRadius:"4px",color:"#ffffff",fontSize:"12px"}})]}),Q.jsxs("div",{className:"config-group",children:[Q.jsx("label",{htmlFor:"chart-x-axis",children:"X Axis"}),Q.jsx(Ok,{id:"chart-x-axis",value:r.x_axis,onChange:w=>s({...r,x_axis:w.target.value}),children:n.columns.map(w=>Q.jsxs("option",{value:w.name,children:[w.name," (",w.type,")"]},w.name))})]}),Q.jsxs("div",{className:"config-group",children:[Q.jsx("label",{htmlFor:"chart-y-axis",children:"Y Axis"}),Q.jsx(Ok,{id:"chart-y-axis",value:r.y_axis,onChange:w=>s({...r,y_axis:w.target.value}),children:n.columns.map(w=>Q.jsxs("option",{value:w.name,children:[w.name," (",w.type,")"]},w.name))})]}),Q.jsx("div",{className:"config-group",children:Q.jsxs("label",{children:[Q.jsx("input",{type:"checkbox",checked:r.show_legend,onChange:w=>s({...r,show_legend:w.target.checked}),style:{marginRight:"8px"}}),Q.jsx("span",{children:"Show Legend"})]})}),Q.jsx("div",{className:"config-group",children:Q.jsxs("label",{children:[Q.jsx("input",{type:"checkbox",checked:r.show_grid,onChange:w=>s({...r,show_grid:w.target.checked}),style:{marginRight:"8px"}}),Q.jsx("span",{children:"Show Grid"})]})}),Q.jsxs("div",{className:"config-group",children:[Q.jsx("h5",{style:{margin:"0 0 8px 0",fontSize:"12px",color:"#ffffff"},children:"Data Summary"}),Q.jsxs("div",{style:{fontSize:"11px",color:"#888888"},children:["Rows: ",n.rows.length,Q.jsx("br",{}),"Columns: ",n.columns.length,Q.jsx("br",{}),"Numeric: ",O.length,Q.jsx("br",{}),"Categorical: ",v.length]})]})]})]})]})},l8=I.div` + display: flex; + flex-direction: column; + height: 100%; + background: #1e1e1e; + border: 1px solid #3c3c3c; + border-radius: 8px; + overflow: hidden; +`,a8=I.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid #3c3c3c; + background: #2d2d2d; +`,c8=I.h3` + margin: 0; + color: #ffffff; + font-size: 16px; + font-weight: 600; +`,u8=I.div` + display: flex; + gap: 8px; + align-items: center; +`,h8=I.button` + padding: 4px 12px; + background: ${n=>n.active?"#0078d4":"transparent"}; + border: 1px solid ${n=>n.active?"#0078d4":"#5a5a5a"}; + border-radius: 4px; + color: ${n=>n.active?"white":"#cccccc"}; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: ${n=>n.active?"#106ebe":"#3c3c3c"}; + color: white; + } +`,f8=I.div` + flex: 1; + overflow-y: auto; + padding: 16px; +`,d8=I.div` + margin-bottom: 24px; + + &:last-child { + margin-bottom: 0; + } +`,p8=I.h4` + margin: 0 0 12px 0; + color: #0078d4; + font-size: 14px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +`,g8=I.div` + background: #2d2d2d; + border: 1px solid #3c3c3c; + border-radius: 6px; + padding: 16px; + margin-bottom: 12px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #333333; + border-color: #0078d4; + transform: translateY(-1px); + } + + &:last-child { + margin-bottom: 0; + } +`,m8=I.div` + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 8px; +`,O8=I.h5` + margin: 0; + color: #ffffff; + font-size: 14px; + font-weight: 600; +`,y8=I.span` + padding: 2px 8px; + border-radius: 12px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + background: ${n=>{switch(n.type){case"OrbitQL":return"#107c10";case"SQL":return"#0078d4";case"Redis":return"#d83b01";default:return"#5a5a5a"}}}; + color: white; +`,x8=I.p` + margin: 0 0 12px 0; + color: #cccccc; + font-size: 13px; + line-height: 1.4; +`,v8=I.code` + display: block; + background: #1e1e1e; + border: 1px solid #3c3c3c; + border-radius: 4px; + padding: 8px; + color: #e6e6e6; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 11px; + line-height: 1.4; + overflow-x: auto; + white-space: pre; +`,b8=I.div` + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 8px; +`,S8=I.span` + padding: 2px 6px; + background: #3c3c3c; + border-radius: 10px; + color: #cccccc; + font-size: 10px; +`,w8=[{id:"pg-basic-select",name:"Basic SELECT Query",description:"Simple data retrieval from a table",category:"PostgreSQL - Basic",queryType:ve.SQL,query:`-- Basic SELECT query +SELECT id, name, email, created_at +FROM users +WHERE active = true +ORDER BY created_at DESC +LIMIT 10;`,tags:["select","basic","postgresql"]},{id:"pg-joins",name:"JOIN Operations",description:"Complex query with multiple table joins",category:"PostgreSQL - Basic",queryType:ve.SQL,query:`-- JOIN query with multiple tables +SELECT + u.name, + p.title as product_name, + o.order_date, + o.total_amount +FROM users u +JOIN orders o ON u.id = o.user_id +JOIN products p ON o.product_id = p.id +WHERE o.order_date >= CURRENT_DATE - INTERVAL '30 days' +ORDER BY o.order_date DESC;`,tags:["join","complex","postgresql"]},{id:"pg-aggregations",name:"Aggregation Functions",description:"Using GROUP BY with aggregate functions",category:"PostgreSQL - Basic",queryType:ve.SQL,query:`-- Aggregation and grouping +SELECT + EXTRACT(YEAR FROM order_date) as year, + EXTRACT(MONTH FROM order_date) as month, + COUNT(*) as total_orders, + SUM(total_amount) as revenue, + AVG(total_amount) as avg_order_value +FROM orders +WHERE order_date >= '2024-01-01' +GROUP BY year, month +ORDER BY year DESC, month DESC;`,tags:["aggregation","group-by","postgresql"]},{id:"orbitql-xgboost",name:"XGBoost Classification",description:"Train and use XGBoost model for binary classification",category:"OrbitQL - ML Boosting",queryType:ve.OrbitQL,query:`-- XGBoost for loan approval prediction +SELECT + ML_XGBOOST( + ARRAY[age, income, credit_score, debt_ratio], + loan_approved, + '{"max_depth": 6, "learning_rate": 0.1, "n_estimators": 100}' + ) as model_performance +FROM loan_applications +WHERE training_set = true;`,tags:["xgboost","classification","ml","orbitql"]},{id:"orbitql-lightgbm",name:"LightGBM Regression",description:"House price prediction using LightGBM",category:"OrbitQL - ML Boosting",queryType:ve.OrbitQL,query:`-- LightGBM for house price prediction +SELECT + ML_LIGHTGBM( + ARRAY[bedrooms, bathrooms, sqft, lot_size, year_built], + price, + '{"objective": "regression", "metric": "rmse", "boosting_type": "gbdt"}' + ) as price_model +FROM real_estate_data +WHERE split_type = 'train';`,tags:["lightgbm","regression","ml","orbitql"]},{id:"orbitql-catboost",name:"CatBoost with Categorical Features",description:"Customer churn prediction with categorical data",category:"OrbitQL - ML Boosting",queryType:ve.OrbitQL,query:`-- CatBoost for customer churn prediction +SELECT + ML_CATBOOST( + ARRAY[tenure, monthly_charges, contract_type, payment_method, tech_support], + churned, + '{"iterations": 1000, "depth": 6, "cat_features": [2, 3, 4]}' + ) as churn_model +FROM customer_data +WHERE dataset_split = 'training';`,tags:["catboost","categorical","churn","orbitql"]},{id:"orbitql-adaboost",name:"AdaBoost Ensemble",description:"Fraud detection using AdaBoost algorithm",category:"OrbitQL - ML Boosting",queryType:ve.OrbitQL,query:`-- AdaBoost for fraud detection +SELECT + ML_ADABOOST( + ARRAY[transaction_amount, merchant_category, hour_of_day, day_of_week], + is_fraud, + '{"n_estimators": 50, "learning_rate": 1, "algorithm": "SAMME.R"}' + ) as fraud_model +FROM transaction_history +WHERE labeled = true;`,tags:["adaboost","fraud-detection","ml","orbitql"]},{id:"orbitql-train-model",name:"Model Training & Management",description:"Train and save a named ML model",category:"OrbitQL - ML Management",queryType:ve.OrbitQL,query:`-- Train and save a named model +SELECT + ML_TRAIN_MODEL( + 'customer_lifetime_value_v1', + 'XGBOOST', + ARRAY[age, total_purchases, avg_order_value, days_since_last_order], + lifetime_value, + '{"max_depth": 8, "learning_rate": 0.05, "n_estimators": 200}' + ) as training_result +FROM customer_analytics +WHERE data_quality_score > 0.8;`,tags:["model-training","ml-ops","orbitql"]},{id:"orbitql-predict",name:"Model Prediction",description:"Make predictions using a trained model",category:"OrbitQL - ML Management",queryType:ve.OrbitQL,query:`-- Make predictions with trained model +SELECT + customer_id, + ML_PREDICT( + 'customer_lifetime_value_v1', + ARRAY[age, total_purchases, avg_order_value, days_since_last_order] + ) as predicted_clv, + ML_PREDICT_PROBA( + 'customer_lifetime_value_v1', + ARRAY[age, total_purchases, avg_order_value, days_since_last_order] + ) as prediction_confidence +FROM customers +WHERE prediction_needed = true;`,tags:["prediction","ml-inference","orbitql"]},{id:"orbitql-evaluate",name:"Model Evaluation",description:"Evaluate model performance on test data",category:"OrbitQL - ML Management",queryType:ve.OrbitQL,query:`-- Evaluate model performance +SELECT + ML_EVALUATE_MODEL( + 'customer_churn_v2', + ARRAY[tenure, monthly_charges, contract_type], + actual_churn, + '{"metrics": ["accuracy", "precision", "recall", "f1", "auc"]}' + ) as model_metrics +FROM customer_test_data +WHERE evaluation_set = true;`,tags:["evaluation","metrics","ml-ops","orbitql"]},{id:"orbitql-feature-importance",name:"Feature Importance Analysis",description:"Analyze which features matter most in your model",category:"OrbitQL - ML Analysis",queryType:ve.OrbitQL,query:`-- Get feature importance from trained model +SELECT + ML_FEATURE_IMPORTANCE('loan_approval_model_v3') as feature_analysis, + ML_MODEL_INFO('loan_approval_model_v3') as model_metadata;`,tags:["feature-importance","analysis","explainability","orbitql"]},{id:"redis-basic-ops",name:"Basic Key-Value Operations",description:"Fundamental Redis operations - SET, GET, DEL",category:"Redis - Basic Operations",queryType:ve.Redis,query:`SET user:1000:name "John Doe" +SET user:1000:email "john@example.com" +SET user:1000:last_login "2024-01-15T10:30:00Z" +GET user:1000:name +EXISTS user:1000:email +DEL user:1000:temp`,tags:["set","get","basic","redis"]},{id:"redis-lists",name:"List Operations",description:"Working with Redis lists - queues and stacks",category:"Redis - Data Structures",queryType:ve.Redis,query:`LPUSH recent_orders "order:5001" "order:5002" +RPUSH pending_tasks "process_payment" "send_email" +LRANGE recent_orders 0 10 +LPOP pending_tasks +LLEN recent_orders +LTRIM recent_orders 0 99`,tags:["lists","queue","stack","redis"]},{id:"redis-sets",name:"Set Operations",description:"Unique collections and set operations",category:"Redis - Data Structures",queryType:ve.Redis,query:`SADD active_users "user:123" "user:456" "user:789" +SADD premium_users "user:123" "user:999" +SISMEMBER active_users "user:123" +SINTER active_users premium_users +SUNION active_users premium_users +SCARD active_users`,tags:["sets","intersection","union","redis"]},{id:"redis-hashes",name:"Hash Operations",description:"Object-like data structures in Redis",category:"Redis - Data Structures",queryType:ve.Redis,query:`HSET product:1001 name "Gaming Laptop" price 1299.99 category "Electronics" +HGET product:1001 name +HMGET product:1001 name price +HGETALL product:1001 +HINCRBY product:1001 views 1 +HDEL product:1001 temp_field`,tags:["hash","object","increment","redis"]},{id:"redis-sorted-sets",name:"Sorted Set Operations",description:"Ranked data structures and leaderboards",category:"Redis - Data Structures",queryType:ve.Redis,query:`ZADD leaderboard 1500 "player:alice" 1200 "player:bob" 1800 "player:carol" +ZRANGE leaderboard 0 2 WITHSCORES +ZREVRANGE leaderboard 0 2 WITHSCORES +ZRANK leaderboard "player:bob" +ZINCRBY leaderboard 50 "player:bob" +ZCOUNT leaderboard 1000 2000`,tags:["sorted-sets","leaderboard","ranking","redis"]},{id:"redis-expiration",name:"Key Expiration and TTL",description:"Managing key lifetimes and cache expiration",category:"Redis - Advanced",queryType:ve.Redis,query:`SET session:abc123 "user_data" EX 3600 +SETEX cache:api_response 300 "cached_json_data" +TTL session:abc123 +EXPIRE user:temp 1800 +PERSIST important_key +PTTL cache:api_response`,tags:["expiration","ttl","cache","redis"]},{id:"redis-pub-sub",name:"Pub/Sub Messaging",description:"Real-time messaging and event publishing",category:"Redis - Advanced",queryType:ve.Redis,query:`SUBSCRIBE notifications +SUBSCRIBE user:*:updates +PUBLISH notifications "Server maintenance in 10 minutes" +PUBLISH user:123:updates "New message received" +PSUBSCRIBE order:* +UNSUBSCRIBE notifications`,tags:["pubsub","messaging","events","redis"]},{id:"redis-transactions",name:"Transactions and Atomicity",description:"Atomic operations using MULTI/EXEC",category:"Redis - Advanced",queryType:ve.Redis,query:`MULTI +INCR counter:page_views +SADD unique_visitors "192.168.1.100" +ZADD hourly_stats 1 "2024-01-15:14" +EXEC + +WATCH important_counter +MULTI +GET important_counter +INCR important_counter +EXEC`,tags:["transactions","atomic","multi-exec","redis"]}],k8=({onSelectQuery:n,className:e})=>{const[t,i]=ne.useState("all"),r=["all","PostgreSQL","OrbitQL","Redis"],o=w8.filter(c=>t==="all"?!0:c.category.includes(t)).reduce((c,h)=>(c[h.category]||(c[h.category]=[]),c[h.category].push(h),c),{}),a=c=>{n(c.query,c.queryType)};return Q.jsxs(l8,{className:e,children:[Q.jsxs(a8,{children:[Q.jsx(c8,{children:"📚 Query Examples"}),Q.jsx(u8,{children:r.map(c=>Q.jsx(h8,{active:t===c,onClick:()=>i(c),children:c},c))})]}),Q.jsx(f8,{children:Object.entries(o).map(([c,h])=>Q.jsxs(d8,{children:[Q.jsx(p8,{children:c}),h.map(f=>Q.jsxs(g8,{onClick:()=>a(f),children:[Q.jsxs(m8,{children:[Q.jsx(O8,{children:f.name}),Q.jsx(y8,{type:f.queryType,children:f.queryType})]}),Q.jsx(x8,{children:f.description}),Q.jsxs(v8,{children:[f.query.split(` +`).slice(0,3).join(` +`),f.query.split(` +`).length>3?` +...`:""]}),Q.jsx(b8,{children:f.tags.map(p=>Q.jsx(S8,{children:p},p))})]},f.id))]},c))})]})},yk=I.button` + padding: 6px 12px; + background: #0078d4; + color: white; + border: none; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + margin-left: 12px; + transition: all 0.2s; + + &:hover { + background: #106ebe; + } +`,P8=I.div` + display: flex; + align-items: center; + margin-bottom: 12px; +`,gm=I.div` + padding: 16px; +`,_8=I.div` + margin-bottom: 12px; + color: #cccccc; + font-size: 13px; +`,Q8=I.div` + overflow: auto; + max-height: 400px; +`,C8=I.table` + width: 100%; + border-collapse: collapse; + font-size: 13px; +`,T8=I.th` + padding: 8px 12px; + text-align: left; + border-bottom: 1px solid #3c3c3c; + font-weight: 600; + background: #2d2d2d; +`,$8=I.div` + font-size: 10px; + color: #888888; + font-weight: normal; +`,M8=I.td` + padding: 8px 12px; + border-bottom: 1px solid #3c3c3c; +`,R8=I.tr` + background: ${n=>n.isEven?"#1e1e1e":"#252525"}; +`,A8=I.div` + color: #d13438; +`,xk=I.div` + color: #cccccc; +`,E8=(n,e)=>`${n.id||n.name||Object.values(n)[0]||e}-${e}`,L8=n=>{if(!n||!n.columns.length||!n.rows.length)return;const e=n.columns.map(a=>a.name).join(","),t=n.rows.map(a=>n.columns.map(c=>{const h=a[c.name];if(h==null)return"";const f=String(h);return f.includes(",")||f.includes('"')||f.includes(` +`)?`"${f.replace(/"/g,'""')}"`:f}).join(",")).join(` +`),i=`${e} +${t}`,r=new Blob([i],{type:"text/csv"}),s=URL.createObjectURL(r),o=document.createElement("a");o.href=s,o.download=`query-results-${new Date().toISOString().slice(0,10)}.csv`,o.click(),URL.revokeObjectURL(s)},D8=n=>{if(!n)return;const e=JSON.stringify(n,null,2),t=new Blob([e],{type:"application/json"}),i=URL.createObjectURL(t),r=document.createElement("a");r.href=i,r.download=`query-results-${new Date().toISOString().slice(0,10)}.json`,r.click(),URL.revokeObjectURL(i)},z8=({result:n})=>{if(!n.success)return Q.jsx(gm,{children:Q.jsxs(A8,{children:[Q.jsx("strong",{children:"Error:"})," ",n.error||"Unknown error occurred"]})});if(!n.data)return Q.jsx(gm,{children:Q.jsx(xk,{children:"Query executed successfully"})});const e=n.data,t=e.rows.length>0;return Q.jsxs(gm,{children:[Q.jsxs(P8,{children:[Q.jsxs(_8,{children:["Execution time: ",n.execution_time_ms.toFixed(2),"ms"," • ",EO(e.outcome)]}),t&&Q.jsxs(Q.Fragment,{children:[Q.jsx(yk,{onClick:()=>L8(e),children:"📥 Export CSV"}),Q.jsx(yk,{onClick:()=>D8(e),children:"📥 Export JSON"})]})]}),t?Q.jsx(Q8,{children:Q.jsxs(C8,{children:[Q.jsx("thead",{children:Q.jsx("tr",{children:e.columns.map(i=>Q.jsxs(T8,{children:[i.name,Q.jsx($8,{children:i.type})]},i.name))})}),Q.jsx("tbody",{children:e.rows.map((i,r)=>Q.jsx(R8,{isEven:r%2===0,children:e.columns.map(s=>{const o=i[s.name],a=o==null?Q.jsx("span",{style:{color:"#666666",fontStyle:"italic"},children:"NULL"}):typeof o=="object"?JSON.stringify(o):String(o);return Q.jsx(M8,{title:typeof o=="object"?JSON.stringify(o,null,2):String(o),children:a},s.name)})},E8(i,r)))})]})}):Q.jsx(xk,{style:{padding:"40px",textAlign:"center"},children:e.outcome.kind==="affected"?`Statement completed: ${EO(e.outcome)}.`:"Statement completed and returned no rows."})]})},j8=I.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: ${n=>n.isOpen?"flex":"none"}; + align-items: center; + justify-content: center; + z-index: 1000; +`,Z8=I.div` + background: #2d2d2d; + border-radius: 8px; + padding: 24px; + width: 600px; + max-width: 90vw; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +`,I8=I.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 1px solid #3c3c3c; +`,N8=I.h2` + color: #ffffff; + font-size: 20px; + font-weight: 600; + margin: 0; +`,B8=I.button` + background: none; + border: none; + color: #cccccc; + font-size: 24px; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; + + &:hover { + background: #3c3c3c; + color: #ffffff; + } +`,ws=I.div` + margin-bottom: 20px; +`,ks=I.label` + display: block; + color: #cccccc; + font-size: 13px; + font-weight: 500; + margin-bottom: 8px; +`,Co=I.input` + width: 100%; + padding: 10px 12px; + background: #1e1e1e; + border: 1px solid #3c3c3c; + border-radius: 4px; + color: #ffffff; + font-size: 14px; + outline: none; + transition: all 0.2s; + + &:focus { + border-color: #0078d4; + box-shadow: 0 0 0 2px rgba(0, 120, 212, 0.2); + } + + &::placeholder { + color: #666666; + } +`,X8=I.select` + width: 100%; + padding: 10px 12px; + background: #1e1e1e; + border: 1px solid #3c3c3c; + border-radius: 4px; + color: #ffffff; + font-size: 14px; + outline: none; + cursor: pointer; + transition: all 0.2s; + + &:focus { + border-color: #0078d4; + box-shadow: 0 0 0 2px rgba(0, 120, 212, 0.2); + } + + option { + background: #1e1e1e; + color: #ffffff; + } +`,W8=I.div` + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid #3c3c3c; +`,UO=I.button` + padding: 10px 20px; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + + ${n=>n.variant==="primary"?` + background: #0078d4; + color: white; + &:hover:not(:disabled) { + background: #106ebe; + } + `:n.variant==="danger"?` + background: #d13438; + color: white; + &:hover:not(:disabled) { + background: #a4262c; + } + `:` + background: #3c3c3c; + color: #ffffff; + &:hover:not(:disabled) { + background: #484848; + } + `} + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`,V8=I(UO)` + background: #107c10; + color: white; + + &:hover:not(:disabled) { + background: #0e6e0e; + } +`,vk=I.div` + background: rgba(209, 52, 56, 0.1); + border: 1px solid rgba(209, 52, 56, 0.3); + color: #d13438; + padding: 12px; + border-radius: 4px; + margin-bottom: 16px; + font-size: 13px; +`,F8=I.div` + background: rgba(16, 124, 16, 0.1); + border: 1px solid rgba(16, 124, 16, 0.3); + color: #107c10; + padding: 12px; + border-radius: 4px; + margin-bottom: 16px; + font-size: 13px; +`,Y8=I.div` + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: #ffffff; + border-radius: 50%; + animation: spin 0.6s linear infinite; + margin-right: 8px; + + @keyframes spin { + to { transform: rotate(360deg); } + } +`,q8=({isOpen:n,onClose:e,onSave:t,connection:i,mode:r})=>{const[s,o]=ne.useState({name:"",connection_type:Ar.PostgreSQL,host:"localhost",port:5432,database:"",username:"",password:"",ssl_mode:void 0,connection_timeout:30,additional_params:{}}),[a,c]=ne.useState(!1),[h,f]=ne.useState(null),[p,m]=ne.useState(!1),[O,v]=ne.useState(null),[b,S]=ne.useState([]);ne.useEffect(()=>{n&&$t.listConnectionTypes().then(S).catch(L=>v(Di(L)))},[n]),ne.useEffect(()=>{n&&(o(r==="edit"&&i?i:{name:"",connection_type:Ar.PostgreSQL,host:"localhost",port:5432,database:"",username:"",password:"",ssl_mode:void 0,connection_timeout:30,additional_params:{}}),f(null),v(null))},[n,r,i]);const w=(L,X)=>{o(ie=>({...ie,[L]:X})),f(null),v(null)},T=L=>{var X;return(X=b.find(ie=>ie.id===L))==null?void 0:X.default_port},k=L=>{w("connection_type",L);const X=T(L);X!==void 0&&w("port",X)},_=async()=>{var L;c(!0),f(null),v(null);try{const X=await $t.testConnection(s);$a(X)?f({success:!0,message:"Connection successful."}):f({success:!1,message:(L=_C(X))!=null?L:"Connection could not be opened."})}catch(X){f({success:!1,message:Di(X)})}finally{c(!1)}},C=async()=>{if(!s.name.trim()){v("Connection name is required");return}if(!s.host.trim()){v("Host is required");return}m(!0),v(null);try{await $t.createConnection(s),t(),e()}catch(L){v(L.message||"Failed to save connection")}finally{m(!1)}},M=L=>[Ar.PostgreSQL,Ar.MySQL,Ar.AQL].includes(L),R=L=>L!==Ar.Redis&&L!==Ar.OrbitQL;return n?Q.jsx(j8,{isOpen:n,onClick:e,children:Q.jsxs(Z8,{onClick:L=>L.stopPropagation(),children:[Q.jsxs(I8,{children:[Q.jsx(N8,{children:r==="create"?"New Connection":"Edit Connection"}),Q.jsx(B8,{onClick:e,children:"×"})]}),O&&Q.jsx(vk,{children:O}),h&&(h.success?Q.jsx(F8,{children:h.message}):Q.jsx(vk,{children:h.message})),Q.jsxs(ws,{children:[Q.jsx(ks,{children:"Connection Name *"}),Q.jsx(Co,{type:"text",value:s.name,onChange:L=>w("name",L.target.value),placeholder:"My Database Connection"})]}),Q.jsxs(ws,{children:[Q.jsx(ks,{children:"Connection Type *"}),Q.jsx(X8,{value:s.connection_type,onChange:L=>k(L.target.value),children:b.map(L=>Q.jsxs("option",{value:L.id,children:[L.id," (",L.default_port,")"]},L.id))}),b.filter(L=>L.id===s.connection_type&&!L.native_wire_protocol).map(L=>Q.jsxs("div",{style:{marginTop:6,fontSize:12,color:"#ffb454",lineHeight:1.5},children:["⚠️ ",L.id," goes through orbit-server's REST API, whose SQL and catalog handlers still return fixed example rows. Queries will succeed but the results are not data from the database."]},L.id))]}),Q.jsxs(ws,{children:[Q.jsx(ks,{children:"Host *"}),Q.jsx(Co,{type:"text",value:s.host,onChange:L=>w("host",L.target.value),placeholder:"localhost"})]}),Q.jsxs(ws,{children:[Q.jsx(ks,{children:"Port *"}),Q.jsx(Co,{type:"number",value:s.port,onChange:L=>w("port",parseInt(L.target.value,10)||T(s.connection_type)||s.port),min:"1",max:"65535"})]}),M(s.connection_type)&&Q.jsxs(ws,{children:[Q.jsx(ks,{children:"Database"}),Q.jsx(Co,{type:"text",value:s.database||"",onChange:L=>w("database",L.target.value),placeholder:"database_name"})]}),R(s.connection_type)&&Q.jsxs(Q.Fragment,{children:[Q.jsxs(ws,{children:[Q.jsx(ks,{children:"Username"}),Q.jsx(Co,{type:"text",value:s.username||"",onChange:L=>w("username",L.target.value),placeholder:"username"})]}),Q.jsxs(ws,{children:[Q.jsx(ks,{children:"Password"}),Q.jsx(Co,{type:"password",value:s.password||"",onChange:L=>w("password",L.target.value),placeholder:"password"})]})]}),Q.jsxs(W8,{children:[Q.jsxs(V8,{onClick:_,disabled:a||!s.name.trim()||!s.host.trim(),children:[a&&Q.jsx(Y8,{}),a?"Testing...":"Test Connection"]}),Q.jsx(UO,{variant:"secondary",onClick:e,children:"Cancel"}),Q.jsx(UO,{variant:"primary",onClick:C,disabled:p||!s.name.trim()||!s.host.trim(),children:p?"Saving...":r==="create"?"Create":"Save"})]})]})}):null},U8=I.div` + padding: 16px; +`,H8=I.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +`,G8=I.h3` + color: #ffffff; + font-size: 16px; + font-weight: 600; + margin: 0; +`,K8=I.button` + padding: 8px 16px; + background: #0078d4; + color: white; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #106ebe; + } +`,J8=I.div` + display: flex; + flex-direction: column; + gap: 8px; +`,eW=I.div` + background: ${n=>n.active?"#3c3c3c":"#2d2d2d"}; + border: 1px solid ${n=>n.active?"#0078d4":"#3c3c3c"}; + border-radius: 4px; + padding: 12px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #3c3c3c; + border-color: #5a5a5a; + } +`,tW=I.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +`,nW=I.div` + color: #ffffff; + font-size: 14px; + font-weight: 500; +`,iW=I.span` + color: #cccccc; + font-size: 12px; + background: #3c3c3c; + padding: 2px 8px; + border-radius: 12px; +`,rW=I.div` + color: #999999; + font-size: 12px; + margin-top: 4px; +`,sW=I.div` + display: flex; + gap: 8px; +`,wh=I.button` + padding: 4px 8px; + background: ${n=>n.variant==="danger"?"#d13438":"#3c3c3c"}; + color: ${n=>n.variant==="danger"?"#ffffff":"#cccccc"}; + border: none; + border-radius: 4px; + font-size: 11px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: ${n=>n.variant==="danger"?"#a4262c":"#484848"}; + color: #ffffff; + } +`,oW=I.span` + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: ${n=>n.tone==="connected"?"#107c10":n.tone==="error"?"#d13438":"#666666"}; + margin-right: 6px; +`,lW=I.div` + color: #f2a3a5; + font-size: 12px; + margin-top: 4px; + line-height: 1.4; +`,aW=I.div` + text-align: center; + padding: 40px 20px; + color: #999999; + font-size: 14px; +`,cW=({connections:n,onConnectionsChange:e})=>{const[t,i]=ne.useState(!1),[r,s]=ne.useState(null),[o,a]=ne.useState(null),[c,h]=ne.useState(null),[f,p]=ne.useState({}),m=(_,C)=>p(M=>{const R={...M};return C===null?delete R[_]:R[_]=C,R}),O=async _=>{h(_),m(_,null);try{await $t.connect(_),e()}catch(C){m(_,Di(C))}finally{h(null)}},v=async _=>{h(_);try{await $t.disconnect(_),e()}catch(C){m(_,Di(C))}finally{h(null)}},b=()=>{s(null),i(!0)},S=_=>{s(_),i(!0)},w=async _=>{if(confirm("Are you sure you want to delete this connection?")){a(_);try{await $t.deleteConnection(_),e()}catch(C){console.error("Failed to delete connection:",C),alert("Failed to delete connection")}finally{a(null)}}},T=()=>{i(!1),s(null)},k=()=>{e()};return Q.jsxs(U8,{children:[Q.jsxs(H8,{children:[Q.jsx(G8,{children:"Connections"}),Q.jsx(K8,{onClick:b,children:"+ New Connection"})]}),n.length===0?Q.jsxs(aW,{children:[Q.jsx("div",{children:"No connections yet"}),Q.jsx("div",{style:{marginTop:"8px",fontSize:"12px"},children:'Click "New Connection" to add one'})]}):Q.jsx(J8,{children:n.map(_=>{var L;const C=$a(_.status),M=(L=f[_.id])!=null?L:_C(_.status),R=C?"connected":M?"error":"idle";return Q.jsxs(eW,{children:[Q.jsxs(tW,{children:[Q.jsxs("div",{style:{display:"flex",alignItems:"center",flex:1},children:[Q.jsx(oW,{tone:R,title:C?"Session open":M!=null?M:"No session open"}),Q.jsx(nW,{children:_.info.name})]}),Q.jsx(iW,{children:_.info.connection_type}),Q.jsxs(sW,{children:[C?Q.jsx(wh,{onClick:()=>v(_.id),disabled:c===_.id,children:"Disconnect"}):Q.jsx(wh,{onClick:()=>O(_.id),disabled:c===_.id,children:c===_.id?"Connecting…":"Connect"}),Q.jsx(wh,{onClick:()=>S(_),children:"Edit"}),Q.jsx(wh,{variant:"danger",onClick:()=>w(_.id),disabled:o===_.id,children:o===_.id?"Deleting...":"Delete"})]})]}),Q.jsxs(rW,{children:[_.info.host,":",_.info.port,_.info.database&&` • ${_.info.database}`,_.query_count>0&&` • ${_.query_count} queries`]}),!C&&M&&Q.jsx(lW,{children:M})]},_.id)})}),Q.jsx(q8,{isOpen:t,onClose:T,onSave:k,connection:(r==null?void 0:r.info)||null,mode:r?"edit":"create"})]})},uW=4e3,bk=I.div` + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + font-size: 13px; +`,Sk=I.div` + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid #3c3c3c; + background: #252525; + flex-wrap: wrap; +`,Ps=I.button` + padding: 5px 10px; + border: none; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + color: #ffffff; + background: ${n=>n.variant==="primary"?"#0078d4":n.variant==="danger"?"#a4262c":"#3c3c3c"}; + transition: filter 0.15s; + + &:hover:not(:disabled) { + filter: brightness(1.2); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } +`,wk=I.input` + width: 46px; + padding: 4px 6px; + background: #3c3c3c; + border: 1px solid #5a5a5a; + border-radius: 4px; + color: #ffffff; + font-size: 12px; +`,hW=I.div` + flex: 1; + overflow: auto; + padding: 12px; +`,fW=I.div` + color: #cccccc; + margin-bottom: 10px; + line-height: 1.6; +`,dW=I.code` + color: #9cdcfe; + word-break: break-all; +`,pW=I.div` + border: 1px solid #3c3c3c; + border-radius: 6px; + padding: 10px 12px; + margin-bottom: 10px; + background: #252525; +`,kk=I.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +`,Pk=I.span` + font-weight: 600; +`,gW=I.span` + padding: 2px 7px; + border-radius: 10px; + font-size: 11px; + color: #ffffff; + background: ${n=>n.tone==="good"?"#107c10":n.tone==="warn"?"#8a6d00":"#a4262c"}; +`,mW=I.div` + display: flex; + flex-wrap: wrap; + gap: 6px; +`,OW=I.span` + padding: 2px 7px; + border-radius: 4px; + font-size: 11px; + border: 1px solid ${n=>n.reachable?"#107c10":"#5a5a5a"}; + color: ${n=>n.reachable?"#8fd18f":"#999999"}; +`,mm=I.div` + color: #999999; + font-size: 12px; + margin-top: 6px; +`,_k=I.div` + margin: 12px; + padding: 10px 12px; + border-radius: 4px; + font-size: 12px; + line-height: 1.5; + color: ${n=>n.tone==="error"?"#f2a3a5":"#cccccc"}; + background: ${n=>n.tone==="error"?"rgba(209, 52, 56, 0.12)":"rgba(255, 255, 255, 0.04)"}; + border: 1px solid + ${n=>n.tone==="error"?"rgba(209, 52, 56, 0.35)":"#3c3c3c"}; +`,yW=I.pre` + margin: 0; + padding: 10px; + background: #1a1a1a; + border: 1px solid #3c3c3c; + border-radius: 4px; + max-height: 240px; + overflow: auto; + font-size: 11px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + color: #cccccc; +`,xW=n=>{const e=Math.floor(n/86400),t=Math.floor(n%86400/3600),i=Math.floor(n%3600/60);return e>0?`${e}d ${t}h`:t>0?`${t}h ${i}m`:i>0?`${i}m ${n%60}s`:`${n}s`},TT=n=>n.state==="running",vW=n=>{if(!TT(n.process))return{tone:"bad",label:`exited (was pid ${n.process.pid})`};const e=n.endpoints.filter(t=>t.reachable).length;return n.endpoints.length===0?{tone:"warn",label:"running, no port flags found"}:e===0?{tone:"warn",label:"running, no ports answering"}:{tone:"good",label:`serving ${e}/${n.endpoints.length} ports`}},bW=()=>{const[n,e]=ne.useState(null),[t,i]=ne.useState(null),[r,s]=ne.useState(!1),[o,a]=ne.useState(3),[c,h]=ne.useState(null),[f,p]=ne.useState(""),[m,O]=ne.useState(""),v=ne.useRef(!0);ne.useEffect(()=>(v.current=!0,()=>{v.current=!1}),[]);const b=ne.useCallback(async()=>{try{const k=await $t.getClusterStatus();if(!v.current)return;e(k),i(null)}catch(k){if(!v.current)return;i(Di(k))}},[]);ne.useEffect(()=>{b();const k=setInterval(()=>void b(),uW);return()=>clearInterval(k)},[b]);const S=async k=>{s(!0),i(null);try{await k(),await b()}catch(_){v.current&&i(Di(_))}finally{v.current&&s(!1)}},w=k=>S(async()=>{const _=await $t.getClusterLog(k,200);v.current&&(h(_),p(k?`${k}.log`:"cluster-control.log"))}),T=()=>S(async()=>{const k=await $t.setClusterRoot(m.trim());v.current&&(e(k),await $t.saveSettings({cluster_root:k.root}))});return t&&!n?Q.jsxs(bk,{children:[Q.jsx(_k,{tone:"error",children:t}),Q.jsxs(Sk,{children:[Q.jsx(wk,{as:"input",style:{width:"100%",minWidth:180},placeholder:"/path/to/orbit-rs",value:m,onChange:k=>O(k.target.value)}),Q.jsx(Ps,{variant:"primary",disabled:r||!m.trim(),onClick:T,children:"Use this checkout"})]})]}):Q.jsxs(bk,{children:[Q.jsxs(Sk,{children:[Q.jsx("label",{htmlFor:"cluster-size",style:{color:"#cccccc"},children:"Nodes"}),Q.jsx(wk,{id:"cluster-size",type:"number",min:1,max:9,value:o,onChange:k=>a(Math.max(1,Math.min(9,Number(k.target.value)||1)))}),Q.jsx(Ps,{variant:"primary",disabled:r,onClick:()=>S(()=>$t.startCluster(o)),title:"Runs scripts/start-cluster.sh; it builds orbit-server in release mode first",children:"▶ Start"}),Q.jsx(Ps,{variant:"danger",disabled:r||!(n!=null&&n.running_nodes),onClick:()=>S(()=>$t.stopCluster()),children:"■ Stop"}),Q.jsx(Ps,{disabled:r,onClick:()=>void b(),children:"⟳ Refresh"}),Q.jsx(Ps,{disabled:r,onClick:()=>w(),children:"Control log"})]}),t&&Q.jsx(_k,{tone:"error",children:t}),Q.jsxs(hW,{children:[n&&Q.jsxs(fW,{children:[Q.jsxs("div",{children:["Checkout: ",Q.jsx(dW,{children:n.root})]}),n.initialized?Q.jsxs("div",{children:[n.running_nodes," of ",n.nodes.length," node",n.nodes.length===1?"":"s"," running · ",n.serving_nodes," serving · checked ",new Date(n.checked_at).toLocaleTimeString()]}):Q.jsx("div",{children:"No cluster has been started in this checkout yet."})]}),n==null?void 0:n.nodes.map(k=>{const _=vW(k);return Q.jsxs(pW,{children:[Q.jsxs(kk,{children:[Q.jsx(Pk,{children:k.node_id}),Q.jsx(gW,{tone:_.tone,children:_.label})]}),TT(k.process)?Q.jsxs(Q.Fragment,{children:[Q.jsxs(mm,{style:{marginTop:0},children:["pid ",k.process.pid," · up ",xW(k.process.uptime_seconds)]}),k.endpoints.length>0?Q.jsx(mW,{style:{marginTop:8},children:k.endpoints.map(C=>Q.jsxs(OW,{reachable:C.reachable,title:C.reachable?"Accepted a TCP connection":"Did not accept a connection",children:[C.protocol," ",C.port]},`${C.protocol}-${C.port}`))}):Q.jsxs(mm,{children:["No ",Q.jsx("code",{children:"--*-port"})," flags on this process's command line, so its ports are unknown."]})]}):Q.jsx(mm,{style:{marginTop:0},children:"A pid file remains but the process is gone. Ports are not shown: the ones it would have used are a guess, not an observation."}),Q.jsx(Ps,{style:{marginTop:10},disabled:r,onClick:()=>w(k.node_id),children:"View log"})]},k.node_id)}),c!==null&&Q.jsxs("div",{style:{marginTop:12},children:[Q.jsxs(kk,{children:[Q.jsx(Pk,{children:f}),Q.jsx(Ps,{onClick:()=>h(null),children:"Close"})]}),Q.jsx(yW,{children:c||"(empty)"})]})]})]})},Om=I.div` + padding: 16px; + height: 100%; + overflow-y: auto; +`,SW=I.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +`,wW=I.h3` + color: #ffffff; + font-size: 16px; + font-weight: 600; + margin: 0; +`,kW=I.button` + padding: 6px 12px; + background: #3c3c3c; + color: #cccccc; + border: none; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #484848; + color: #ffffff; + } +`,PW=I.div` + display: flex; + flex-direction: column; + gap: 8px; +`,_W=I.div` + background: #2d2d2d; + border: 1px solid #3c3c3c; + border-radius: 4px; + padding: 12px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #3c3c3c; + border-color: #5a5a5a; + } +`,QW=I.div` + color: #cccccc; + font-size: 12px; + font-family: 'Courier New', monospace; + margin-bottom: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`,CW=I.div` + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + color: #999999; +`,TW=I.span` + padding: 2px 6px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + background: ${n=>n.ok?"#107c10":"#a4262c"}; + color: white; +`,$W=I.div` + color: #f2a3a5; + font-size: 11px; + margin-top: 4px; + line-height: 1.4; +`,ym=I.div` + text-align: center; + padding: 40px 20px; + color: #999999; + font-size: 14px; +`,MW=I.div` + text-align: center; + padding: 40px 20px; + color: #999999; + font-size: 14px; +`,RW=({connectionId:n,onSelectQuery:e})=>{const[t,i]=ne.useState([]),[r,s]=ne.useState(!1),[o,a]=ne.useState(null);ne.useEffect(()=>{n?c():i([])},[n]);const c=async()=>{if(n){s(!0),a(null);try{const p=await $t.getQueryHistory(n,50);i(p)}catch(p){a(Di(p))}finally{s(!1)}}},h=p=>{e==null||e(p.query,ve.SQL)},f=p=>{const m=new Date(p),v=new Date().getTime()-m.getTime(),b=Math.floor(v/6e4),S=Math.floor(v/36e5),w=Math.floor(v/864e5);return b<1?"Just now":b<60?`${b}m ago`:S<24?`${S}h ago`:w<7?`${w}d ago`:m.toLocaleDateString()};return n?r?Q.jsx(Om,{children:Q.jsx(MW,{children:"Loading history..."})}):Q.jsxs(Om,{children:[Q.jsxs(SW,{children:[Q.jsx(wW,{children:"Query History"}),t.length>0&&Q.jsx(kW,{onClick:c,children:"Refresh"})]}),o&&Q.jsx(ym,{style:{color:"#f2a3a5",padding:"16px 0"},children:o}),t.length===0?Q.jsxs(ym,{children:[Q.jsx("div",{children:"No query history yet"}),Q.jsx("div",{style:{marginTop:"8px",fontSize:"12px"},children:"Execute queries to see them here"})]}):Q.jsx(PW,{children:t.map(p=>Q.jsxs(_W,{onClick:()=>h(p),children:[Q.jsx(QW,{children:p.query}),Q.jsxs(CW,{children:[Q.jsx(TW,{ok:p.success,children:p.success?p.outcome?EO(p.outcome):"OK":"Failed"}),Q.jsxs("span",{children:[p.execution_time_ms.toFixed(1),"ms"]}),Q.jsx("span",{title:new Date(p.executed_at).toLocaleString(),children:f(p.executed_at)})]}),!p.success&&p.error&&Q.jsx($W,{children:p.error})]},p.id))})]}):Q.jsx(Om,{children:Q.jsx(ym,{children:Q.jsx("div",{children:"Select a connection to view query history"})})})},AW=I.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: ${n=>n.isOpen?"flex":"none"}; + align-items: center; + justify-content: center; + z-index: 1000; +`,EW=I.div` + background: #2d2d2d; + border-radius: 8px; + padding: 24px; + width: 500px; + max-width: 90vw; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +`,LW=I.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 1px solid #3c3c3c; +`,DW=I.h2` + color: #ffffff; + font-size: 20px; + font-weight: 600; + margin: 0; +`,zW=I.button` + background: none; + border: none; + color: #cccccc; + font-size: 24px; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; + + &:hover { + background: #3c3c3c; + color: #ffffff; + } +`,jW=I.div` + display: flex; + flex-direction: column; + gap: 16px; +`,ZW=I.div` + display: flex; + flex-direction: column; + gap: 8px; +`,IW=I.h3` + color: #cccccc; + font-size: 14px; + font-weight: 600; + margin: 0; + text-transform: uppercase; + letter-spacing: 0.5px; +`,NW=I.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; +`,BW=I.span` + color: #cccccc; + font-size: 13px; +`,XW=I.div` + display: flex; + gap: 4px; +`,WW=I.kbd` + background: #1e1e1e; + border: 1px solid #3c3c3c; + border-radius: 4px; + padding: 4px 8px; + font-size: 11px; + font-family: 'Courier New', monospace; + color: #ffffff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +`,VW=({isOpen:n,onClose:e})=>{if(!n)return null;const t=[{group:"Query Execution",items:[{description:"Execute Query",keys:["Ctrl","Enter"]},{description:"Execute Query (Mac)",keys:["Cmd","Enter"]},{description:"Explain Query",keys:["Ctrl","Shift","Enter"]},{description:"Explain Query (Mac)",keys:["Cmd","Shift","Enter"]}]},{group:"Editor",items:[{description:"Format Query",keys:["Ctrl","Shift","F"]},{description:"Format Query (Mac)",keys:["Cmd","Shift","F"]},{description:"Indent with Tab",keys:["Tab"]}]},{group:"Navigation",items:[{description:"New Query Tab",keys:["Ctrl","T"]},{description:"New Query Tab (Mac)",keys:["Cmd","T"]},{description:"Close Tab",keys:["Ctrl","W"]},{description:"Close Tab (Mac)",keys:["Cmd","W"]},{description:"Next Tab",keys:["Ctrl","Tab"]},{description:"Previous Tab",keys:["Ctrl","Shift","Tab"]}]},{group:"General",items:[{description:"Show Keyboard Shortcuts",keys:["Ctrl","/"]},{description:"Show Keyboard Shortcuts (Mac)",keys:["Cmd","/"]}]}];return Q.jsx(AW,{isOpen:n,onClick:e,children:Q.jsxs(EW,{onClick:i=>i.stopPropagation(),children:[Q.jsxs(LW,{children:[Q.jsx(DW,{children:"Keyboard Shortcuts"}),Q.jsx(zW,{onClick:e,children:"×"})]}),Q.jsx(jW,{children:t.map((i,r)=>Q.jsxs(ZW,{children:[Q.jsx(IW,{children:i.group}),i.items.map((s,o)=>Q.jsxs(NW,{children:[Q.jsx(BW,{children:s.description}),Q.jsx(XW,{children:s.keys.map((a,c)=>Q.jsx(WW,{children:a},c))})]},o))]},r))})]})})},FW=`-- Welcome to Orbit Desktop! +-- Try some OrbitQL with ML functions: + +SELECT ML_XGBOOST( + ARRAY[age, income, credit_score], + loan_approved +) as model_accuracy +FROM loan_applications;`,YW=()=>{const[n,e]=ne.useState([{id:"1",name:"Query 1",query:FW,query_type:ve.OrbitQL,unsaved_changes:!1,is_executing:!1}]),[t,i]=ne.useState(0),r=(p,m,O)=>{e(v=>(i(v.length),[...v,{id:`${Date.now()}-${v.length}`,name:O!=null?O:`Query ${v.length+1}`,query:p,query_type:m,unsaved_changes:!1,is_executing:!1}]))};return{queryTabs:n,activeTabIndex:t,setActiveTabIndex:i,createNewTab:(p=ve.OrbitQL)=>{r(p===ve.Redis?"PING":"SELECT 1;",p)},openTab:(p,m,O)=>{r(p,m,O)},closeTab:p=>{if(n.length<=1)return;const m=n.filter((O,v)=>v!==p);e(m),t>=m.length?i(m.length-1):t>p&&i(t-1)},updateTabQuery:(p,m)=>{e(O=>{const v=[...O];return v[p]={...v[p],query:m,unsaved_changes:!0},v})},updateTabState:(p,m)=>{e(O=>{const v=[...O];return v[p]={...v[p],...m},v})},getCurrentTab:()=>n[t]}},qW=ZM` + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: #1e1e1e; + color: #ffffff; + overflow: hidden; + height: 100vh; + } + + #root { + width: 100vw; + height: 100vh; + display: flex; + flex-direction: column; + } + + /* React Tabs Styling */ + .react-tabs { + height: 100%; + display: flex; + flex-direction: column; + } + + .react-tabs__tab-list { + margin: 0; + padding: 0; + border-bottom: 1px solid #3c3c3c; + background: #2d2d2d; + display: flex; + } + + .react-tabs__tab { + display: flex; + align-items: center; + padding: 8px 12px; + background: none; + border: none; + color: #cccccc; + cursor: pointer; + font-size: 13px; + border-bottom: 2px solid transparent; + transition: all 0.2s; + gap: 6px; + } + + .react-tabs__tab:hover { + color: #ffffff; + background: #3c3c3c; + } + + .react-tabs__tab--selected { + color: #0078d4; + border-bottom-color: #0078d4; + background: #2d2d2d; + } + + .react-tabs__tab-panel { + flex: 1; + display: flex; + flex-direction: column; + } + + .react-tabs__tab-panel--selected { + display: flex; + } + + /* Split Pane Styling */ + .split { + display: flex; + height: 100%; + } + + .split.split-horizontal { + flex-direction: row; + } + + .split.split-vertical { + flex-direction: column; + } + + .gutter { + background: #3c3c3c; + background-repeat: no-repeat; + background-position: 50%; + } + + .gutter.gutter-horizontal { + cursor: ew-resize; + width: 4px; + } + + .gutter.gutter-vertical { + cursor: ns-resize; + height: 4px; + } + + /* Scrollbar styling */ + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + ::-webkit-scrollbar-track { + background: #2d2d2d; + } + + ::-webkit-scrollbar-thumb { + background: #5a5a5a; + border-radius: 4px; + } + + ::-webkit-scrollbar-thumb:hover { + background: #6a6a6a; + } +`,UW={name:"dark",primary:"#0078d4",secondary:"#107c10",background:"#1e1e1e",surface:"#2d2d2d",text:"#ffffff",textSecondary:"#cccccc",border:"#3c3c3c",error:"#d13438",warning:"#ff8c00",success:"#107c10",info:"#0078d4"},HW=I.div` + height: 100vh; + display: flex; + flex-direction: column; + background: ${n=>n.theme.background}; + color: ${n=>n.theme.text}; +`,GW=I.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: ${n=>n.theme.surface}; + border-bottom: 1px solid ${n=>n.theme.border}; + min-height: 48px; +`,KW=I.div` + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 16px; + + .icon { + width: 24px; + height: 24px; + background: linear-gradient(45deg, #0078d4, #107c10); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 12px; + } +`,JW=I.div` + display: flex; + align-items: center; + gap: 12px; + font-size: 13px; +`,e6=I.div` + width: 8px; + height: 8px; + border-radius: 50%; + background: ${n=>n.connected?"#107c10":"#d13438"}; +`,t6=I.select` + padding: 4px 8px; + background: #3c3c3c; + border: 1px solid #5a5a5a; + border-radius: 4px; + color: #ffffff; + font-size: 13px; + outline: none; + min-width: 200px; + + &:focus { + border-color: #0078d4; + } + + option { + background: #3c3c3c; + color: #ffffff; + } +`,Qk=I.button` + padding: 6px 12px; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + transition: all 0.2s; + + ${n=>n.variant==="primary"?` + background: ${e=>e.theme.primary}; + color: white; + + &:hover:not(:disabled) { + background: #106ebe; + } + `:` + background: #3c3c3c; + color: #ffffff; + + &:hover:not(:disabled) { + background: #484848; + } + `} + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`,n6=I.div` + flex: 1; + display: flex; + overflow: hidden; +`,i6=I.button` + background: none; + border: none; + color: #888888; + cursor: pointer; + padding: 2px; + margin-left: 4px; + border-radius: 2px; + font-size: 12px; + transition: all 0.2s; + + &:hover { + background: #d13438; + color: white; + } +`,r6=I.div` + display: flex; + flex-direction: column; + height: 100%; +`,s6=I.div` + display: flex; + border-bottom: 1px solid #3c3c3c; + background: #2d2d2d; +`,xm=I.button` + padding: 8px 16px; + background: none; + border: none; + color: ${n=>n.active?"#0078d4":"#cccccc"}; + cursor: pointer; + font-size: 13px; + border-bottom: ${n=>n.active?"2px solid #0078d4":"2px solid transparent"}; + transition: all 0.2s; + + &:hover { + color: ${n=>n.active?"#0078d4":"#ffffff"}; + } +`,o6=I.div` + flex: 1; + overflow: auto; +`,l6=[{id:"samples",label:"📚 Samples"},{id:"connections",label:"🔌 Connections"},{id:"cluster",label:"🖥️ Cluster"},{id:"history",label:"📜 History"},{id:"models",label:"🤖 Models"}],a6=()=>{const[n,e]=ne.useState([]),[t,i]=ne.useState(null),[r,s]=ne.useState("table"),[o,a]=ne.useState("samples"),[c,h]=ne.useState(null),[f,p]=ne.useState(!1),{queryTabs:m,activeTabIndex:O,setActiveTabIndex:v,createNewTab:b,openTab:S,closeTab:w,updateTabQuery:T,updateTabState:k,getCurrentTab:_}=YW();DO("ctrl+/,cmd+/",()=>{p(!0)});const C=ne.useCallback(async()=>{try{const F=await $t.getConnections();e(F),h(null),i(re=>{var oe,ce,ue;return re&&F.some(q=>q.id===re.id)?(oe=F.find(q=>q.id===re.id))!=null?oe:re:(ue=(ce=F.find(q=>$a(q.status)))!=null?ce:F[0])!=null?ue:null})}catch(F){h(Di(F))}},[]);ne.useEffect(()=>{C()},[C]);const M=async F=>{var re,oe,ce,ue;if(!t){h("Please select a connection first");return}k(O,{is_executing:!0,unsaved_changes:!1}),h(null);try{const q={connection_id:t.id,query:F,timeout_ms:3e4},U=await $t.executeQuery(q);k(O,{result:U,is_executing:!1}),!U.success&&U.error&&h(U.error);const J=(oe=(re=U.data)==null?void 0:re.rows)!=null?oe:[],D=((ue=(ce=U.data)==null?void 0:ce.columns)!=null?ue:[]).filter(B=>/int|float|double|decimal|numeric|real|serial/i.test(B.type));s(D.length>0&&J.length>1?"chart":"table"),C()}catch(q){h(Di(q)),k(O,{is_executing:!1})}},R=async F=>{if(!t){h("Please select a connection first");return}h(null);try{const re=await $t.explainQuery({connection_id:t.id,query:F,timeout_ms:3e4});k(O,{result:re}),s("table"),!re.success&&re.error&&h(re.error)}catch(re){h(Di(re))}},L=F=>{var re;i((re=n.find(oe=>oe.id===F))!=null?re:null),h(null)},X=(F,re)=>{S(F,re,`Sample ${m.length+1}`)},ie=_(),Y=ie==null?void 0:ie.result,H=!!(Y!=null&&Y.success&&Y.data);return Q.jsxs(DM,{theme:UW,children:[Q.jsx(qW,{}),Q.jsxs(HW,{children:[Q.jsxs(GW,{children:[Q.jsxs(KW,{children:[Q.jsx("div",{className:"icon",children:"🌌"}),"Orbit Desktop",Q.jsx("button",{onClick:()=>p(!0),style:{marginLeft:"12px",background:"none",border:"none",color:"#999999",cursor:"pointer",fontSize:"12px",padding:"4px 8px",borderRadius:"4px",transition:"all 0.2s"},onMouseEnter:F=>{F.currentTarget.style.background="#3c3c3c",F.currentTarget.style.color="#ffffff"},onMouseLeave:F=>{F.currentTarget.style.background="none",F.currentTarget.style.color="#999999"},title:"Keyboard Shortcuts (Ctrl+/)",children:"⌨️ Shortcuts"})]}),Q.jsxs(JW,{children:[Q.jsx(e6,{connected:!!(t&&$a(t.status)),title:t?$a(t.status)?"Session open":"Saved, but no session open yet":"No connection selected"}),Q.jsxs(t6,{value:(t==null?void 0:t.id)||"",onChange:F=>L(F.target.value),children:[Q.jsx("option",{value:"",children:"Select Connection..."}),n.map(F=>Q.jsxs("option",{value:F.id,children:[F.info.name," (",F.info.connection_type,")"]},F.id))]}),Q.jsx(Qk,{onClick:()=>a("connections"),title:"Manage Connections",children:"⚙️ Manage"}),Q.jsx(Qk,{onClick:()=>b(),children:"+ New Query"})]})]}),!G0()&&Q.jsx("div",{style:{padding:"8px 16px",background:"rgba(255, 140, 0, 0.12)",borderBottom:"1px solid rgba(255, 140, 0, 0.3)",color:"#ffb454",fontSize:"12px"},children:"Running in a plain browser: there is no IPC bridge to the database, so every action will report an error. Launch the desktop app for a working session."}),Q.jsx(n6,{children:Q.jsxs(Kh,{sizes:[60,40],direction:"horizontal",className:"split",children:[Q.jsx("div",{style:{display:"flex",flexDirection:"column"},children:Q.jsxs(sP,{selectedIndex:O,onSelect:v,children:[Q.jsx(oP,{children:m.map((F,re)=>Q.jsxs(lP,{children:[Q.jsx("span",{children:F.name}),F.unsaved_changes&&Q.jsx("span",{style:{color:"#ff8c00"},children:"●"}),m.length>1&&Q.jsx(i6,{onClick:oe=>{oe.stopPropagation(),w(re)},children:"×"})]},F.id))}),m.map((F,re)=>Q.jsx(aP,{children:Q.jsxs(Kh,{sizes:[50,50],direction:"vertical",className:"split",children:[Q.jsx(BZ,{value:F.query,onChange:oe=>T(re,oe),queryType:F.query_type,onExecute:M,onExplain:R,isExecuting:F.is_executing,connection:t}),Q.jsxs(r6,{children:[Q.jsxs(s6,{children:[Q.jsx(xm,{active:r==="table",onClick:()=>s("table"),children:"📋 Results"}),H&&Q.jsx(xm,{active:r==="chart",onClick:()=>s("chart"),children:"📊 Chart"}),Q.jsx(xm,{active:r==="models",onClick:()=>s("models"),children:"🤖 Models"})]}),Q.jsxs(o6,{children:[c&&Q.jsx("div",{style:{padding:"16px",background:"rgba(209, 52, 56, 0.1)",color:"#d13438",border:"1px solid rgba(209, 52, 56, 0.3)",margin:"16px",borderRadius:"4px",whiteSpace:"pre-wrap"},children:c}),(Y==null?void 0:Y.notice)&&Q.jsxs("div",{style:{padding:"12px 16px",background:"rgba(255, 140, 0, 0.1)",color:"#ffb454",border:"1px solid rgba(255, 140, 0, 0.3)",margin:"16px",borderRadius:"4px",fontSize:"12px",lineHeight:1.5},children:["⚠️ ",Y.notice]}),r==="table"&&Y&&Q.jsx(z8,{result:Y}),r==="chart"&&(Y==null?void 0:Y.data)&&Q.jsx(o8,{data:Y.data}),r==="models"&&Q.jsx(HS,{connection:t})]})]})]})},F.id))]})}),Q.jsxs("div",{style:{background:"#1e1e1e",borderLeft:"1px solid #3c3c3c",display:"flex",flexDirection:"column"},children:[Q.jsx("div",{style:{display:"flex",borderBottom:"1px solid #3c3c3c",background:"#2d2d2d",flexWrap:"wrap"},children:l6.map(F=>Q.jsx("button",{style:{padding:"8px 12px",background:o===F.id?"#0078d4":"transparent",border:"none",color:o===F.id?"white":"#cccccc",cursor:"pointer",fontSize:"12px",borderBottom:o===F.id?"2px solid #0078d4":"2px solid transparent"},onClick:()=>a(F.id),children:F.label},F.id))}),Q.jsxs("div",{style:{flex:1,overflow:"hidden"},children:[o==="samples"&&Q.jsx(k8,{onSelectQuery:X}),o==="connections"&&Q.jsx(cW,{connections:n,onConnectionsChange:C}),o==="cluster"&&Q.jsx(bW,{}),o==="history"&&Q.jsx(RW,{connectionId:t==null?void 0:t.id,onSelectQuery:X}),o==="models"&&Q.jsx(HS,{connection:t})]})]})]})})]}),Q.jsx(VW,{isOpen:f,onClose:()=>p(!1)})]})};document.addEventListener("contextmenu",n=>n.preventDefault());document.addEventListener("dragover",n=>n.preventDefault());document.addEventListener("drop",n=>n.preventDefault());z$.createRoot(document.getElementById("root")).render(Q.jsx(dt.StrictMode,{children:Q.jsx(a6,{})})); diff --git a/orbit/desktop/dist/assets/index-DQVIKfVi.js b/orbit/desktop/dist/assets/index-DQVIKfVi.js deleted file mode 100644 index 5ea37f07..00000000 --- a/orbit/desktop/dist/assets/index-DQVIKfVi.js +++ /dev/null @@ -1,1032 +0,0 @@ -var K$=Object.defineProperty;var J$=(n,e,t)=>e in n?K$(n,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):n[e]=t;var ge=(n,e,t)=>J$(n,typeof e!="symbol"?e+"":e,t);(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))i(r);new MutationObserver(r=>{for(const s of r)if(s.type==="childList")for(const o of s.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function t(r){const s={};return r.integrity&&(s.integrity=r.integrity),r.referrerPolicy&&(s.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?s.credentials="include":r.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function i(r){if(r.ep)return;r.ep=!0;const s=t(r);fetch(r.href,s)}})();function DO(n){return n&&n.__esModule&&Object.prototype.hasOwnProperty.call(n,"default")?n.default:n}var Hp={exports:{}},Fl={},Gp={exports:{}},Ae={};/** - * @license React - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var X1;function e2(){if(X1)return Ae;X1=1;var n=Symbol.for("react.element"),e=Symbol.for("react.portal"),t=Symbol.for("react.fragment"),i=Symbol.for("react.strict_mode"),r=Symbol.for("react.profiler"),s=Symbol.for("react.provider"),o=Symbol.for("react.context"),a=Symbol.for("react.forward_ref"),c=Symbol.for("react.suspense"),h=Symbol.for("react.memo"),f=Symbol.for("react.lazy"),p=Symbol.iterator;function m(L){return L===null||typeof L!="object"?null:(L=p&&L[p]||L["@@iterator"],typeof L=="function"?L:null)}var y={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},v=Object.assign,b={};function S(L,N,xe){this.props=L,this.context=N,this.refs=b,this.updater=xe||y}S.prototype.isReactComponent={},S.prototype.setState=function(L,N){if(typeof L!="object"&&typeof L!="function"&&L!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,L,N,"setState")},S.prototype.forceUpdate=function(L){this.updater.enqueueForceUpdate(this,L,"forceUpdate")};function w(){}w.prototype=S.prototype;function C(L,N,xe){this.props=L,this.context=N,this.refs=b,this.updater=xe||y}var _=C.prototype=new w;_.constructor=C,v(_,S.prototype),_.isPureReactComponent=!0;var P=Array.isArray,Q=Object.prototype.hasOwnProperty,$={current:null},M={key:!0,ref:!0,__self:!0,__source:!0};function Z(L,N,xe){var Oe,we={},ke=null,Le=null;if(N!=null)for(Oe in N.ref!==void 0&&(Le=N.ref),N.key!==void 0&&(ke=""+N.key),N)Q.call(N,Oe)&&!M.hasOwnProperty(Oe)&&(we[Oe]=N[Oe]);var K=arguments.length-2;if(K===1)we.children=xe;else if(1>>1,N=q[L];if(0>>1;Lr(we,J))ker(Le,we)?(q[L]=Le,q[ke]=J,L=ke):(q[L]=we,q[Oe]=J,L=Oe);else if(ker(Le,J))q[L]=Le,q[ke]=J,L=ke;else break e}}return U}function r(q,U){var J=q.sortIndex-U.sortIndex;return J!==0?J:q.id-U.id}if(typeof performance=="object"&&typeof performance.now=="function"){var s=performance;n.unstable_now=function(){return s.now()}}else{var o=Date,a=o.now();n.unstable_now=function(){return o.now()-a}}var c=[],h=[],f=1,p=null,m=3,y=!1,v=!1,b=!1,S=typeof setTimeout=="function"?setTimeout:null,w=typeof clearTimeout=="function"?clearTimeout:null,C=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function _(q){for(var U=t(h);U!==null;){if(U.callback===null)i(h);else if(U.startTime<=q)i(h),U.sortIndex=U.expirationTime,e(c,U);else break;U=t(h)}}function P(q){if(b=!1,_(q),!v)if(t(c)!==null)v=!0,se(Q);else{var U=t(h);U!==null&&ue(P,U.startTime-q)}}function Q(q,U){v=!1,b&&(b=!1,w(Z),Z=-1),y=!0;var J=m;try{for(_(U),p=t(c);p!==null&&(!(p.expirationTime>U)||q&&!W());){var L=p.callback;if(typeof L=="function"){p.callback=null,m=p.priorityLevel;var N=L(p.expirationTime<=U);U=n.unstable_now(),typeof N=="function"?p.callback=N:p===t(c)&&i(c),_(U)}else i(c);p=t(c)}if(p!==null)var xe=!0;else{var Oe=t(h);Oe!==null&&ue(P,Oe.startTime-U),xe=!1}return xe}finally{p=null,m=J,y=!1}}var $=!1,M=null,Z=-1,j=5,Y=-1;function W(){return!(n.unstable_now()-Yq||125L?(q.sortIndex=J,e(h,q),t(c)===null&&q===t(h)&&(b?(w(Z),Z=-1):b=!0,ue(P,J-L))):(q.sortIndex=N,e(c,q),v||y||(v=!0,se(Q))),q},n.unstable_shouldYield=W,n.unstable_wrapCallback=function(q){var U=m;return function(){var J=m;m=U;try{return q.apply(this,arguments)}finally{m=J}}}})(eg)),eg}var q1;function r2(){return q1||(q1=1,Jp.exports=i2()),Jp.exports}/** - * @license React - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var U1;function s2(){if(U1)return gn;U1=1;var n=zO(),e=r2();function t(l){for(var u="https://reactjs.org/docs/error-decoder.html?invariant="+l,d=1;d"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),c=Object.prototype.hasOwnProperty,h=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,f={},p={};function m(l){return c.call(p,l)?!0:c.call(f,l)?!1:h.test(l)?p[l]=!0:(f[l]=!0,!1)}function y(l,u,d,g){if(d!==null&&d.type===0)return!1;switch(typeof u){case"function":case"symbol":return!0;case"boolean":return g?!1:d!==null?!d.acceptsBooleans:(l=l.toLowerCase().slice(0,5),l!=="data-"&&l!=="aria-");default:return!1}}function v(l,u,d,g){if(u===null||typeof u>"u"||y(l,u,d,g))return!0;if(g)return!1;if(d!==null)switch(d.type){case 3:return!u;case 4:return u===!1;case 5:return isNaN(u);case 6:return isNaN(u)||1>u}return!1}function b(l,u,d,g,O,x,k){this.acceptsBooleans=u===2||u===3||u===4,this.attributeName=g,this.attributeNamespace=O,this.mustUseProperty=d,this.propertyName=l,this.type=u,this.sanitizeURL=x,this.removeEmptyString=k}var S={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(l){S[l]=new b(l,0,!1,l,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(l){var u=l[0];S[u]=new b(u,1,!1,l[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(l){S[l]=new b(l,2,!1,l.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(l){S[l]=new b(l,2,!1,l,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(l){S[l]=new b(l,3,!1,l.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(l){S[l]=new b(l,3,!0,l,null,!1,!1)}),["capture","download"].forEach(function(l){S[l]=new b(l,4,!1,l,null,!1,!1)}),["cols","rows","size","span"].forEach(function(l){S[l]=new b(l,6,!1,l,null,!1,!1)}),["rowSpan","start"].forEach(function(l){S[l]=new b(l,5,!1,l.toLowerCase(),null,!1,!1)});var w=/[\-:]([a-z])/g;function C(l){return l[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(l){var u=l.replace(w,C);S[u]=new b(u,1,!1,l,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(l){var u=l.replace(w,C);S[u]=new b(u,1,!1,l,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(l){var u=l.replace(w,C);S[u]=new b(u,1,!1,l,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(l){S[l]=new b(l,1,!1,l.toLowerCase(),null,!1,!1)}),S.xlinkHref=new b("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(l){S[l]=new b(l,1,!1,l.toLowerCase(),null,!0,!0)});function _(l,u,d,g){var O=S.hasOwnProperty(u)?S[u]:null;(O!==null?O.type!==0:g||!(2T||O[k]!==x[T]){var A=` -`+O[k].replace(" at new "," at ");return l.displayName&&A.includes("")&&(A=A.replace("",l.displayName)),A}while(1<=k&&0<=T);break}}}finally{xe=!1,Error.prepareStackTrace=d}return(l=l?l.displayName||l.name:"")?N(l):""}function we(l){switch(l.tag){case 5:return N(l.type);case 16:return N("Lazy");case 13:return N("Suspense");case 19:return N("SuspenseList");case 0:case 2:case 15:return l=Oe(l.type,!1),l;case 11:return l=Oe(l.type.render,!1),l;case 1:return l=Oe(l.type,!0),l;default:return""}}function ke(l){if(l==null)return null;if(typeof l=="function")return l.displayName||l.name||null;if(typeof l=="string")return l;switch(l){case M:return"Fragment";case $:return"Portal";case j:return"Profiler";case Z:return"StrictMode";case ie:return"Suspense";case oe:return"SuspenseList"}if(typeof l=="object")switch(l.$$typeof){case W:return(l.displayName||"Context")+".Consumer";case Y:return(l._context.displayName||"Context")+".Provider";case F:var u=l.render;return l=l.displayName,l||(l=u.displayName||u.name||"",l=l!==""?"ForwardRef("+l+")":"ForwardRef"),l;case re:return u=l.displayName||null,u!==null?u:ke(l.type)||"Memo";case se:u=l._payload,l=l._init;try{return ke(l(u))}catch{}}return null}function Le(l){var u=l.type;switch(l.tag){case 24:return"Cache";case 9:return(u.displayName||"Context")+".Consumer";case 10:return(u._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return l=u.render,l=l.displayName||l.name||"",u.displayName||(l!==""?"ForwardRef("+l+")":"ForwardRef");case 7:return"Fragment";case 5:return u;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return ke(u);case 8:return u===Z?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof u=="function")return u.displayName||u.name||null;if(typeof u=="string")return u}return null}function K(l){switch(typeof l){case"boolean":case"number":case"string":case"undefined":return l;case"object":return l;default:return""}}function X(l){var u=l.type;return(l=l.nodeName)&&l.toLowerCase()==="input"&&(u==="checkbox"||u==="radio")}function te(l){var u=X(l)?"checked":"value",d=Object.getOwnPropertyDescriptor(l.constructor.prototype,u),g=""+l[u];if(!l.hasOwnProperty(u)&&typeof d<"u"&&typeof d.get=="function"&&typeof d.set=="function"){var O=d.get,x=d.set;return Object.defineProperty(l,u,{configurable:!0,get:function(){return O.call(this)},set:function(k){g=""+k,x.call(this,k)}}),Object.defineProperty(l,u,{enumerable:d.enumerable}),{getValue:function(){return g},setValue:function(k){g=""+k},stopTracking:function(){l._valueTracker=null,delete l[u]}}}}function be(l){l._valueTracker||(l._valueTracker=te(l))}function We(l){if(!l)return!1;var u=l._valueTracker;if(!u)return!0;var d=u.getValue(),g="";return l&&(g=X(l)?l.checked?"true":"false":l.value),l=g,l!==d?(u.setValue(l),!0):!1}function Je(l){if(l=l||(typeof document<"u"?document:void 0),typeof l>"u")return null;try{return l.activeElement||l.body}catch{return l.body}}function Zt(l,u){var d=u.checked;return J({},u,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:d!=null?d:l._wrapperState.initialChecked})}function gi(l,u){var d=u.defaultValue==null?"":u.defaultValue,g=u.checked!=null?u.checked:u.defaultChecked;d=K(u.value!=null?u.value:d),l._wrapperState={initialChecked:g,initialValue:d,controlled:u.type==="checkbox"||u.type==="radio"?u.checked!=null:u.value!=null}}function ts(l,u){u=u.checked,u!=null&&_(l,"checked",u,!1)}function ur(l,u){ts(l,u);var d=K(u.value),g=u.type;if(d!=null)g==="number"?(d===0&&l.value===""||l.value!=d)&&(l.value=""+d):l.value!==""+d&&(l.value=""+d);else if(g==="submit"||g==="reset"){l.removeAttribute("value");return}u.hasOwnProperty("value")?sd(l,u.type,d):u.hasOwnProperty("defaultValue")&&sd(l,u.type,K(u.defaultValue)),u.checked==null&&u.defaultChecked!=null&&(l.defaultChecked=!!u.defaultChecked)}function K0(l,u,d){if(u.hasOwnProperty("value")||u.hasOwnProperty("defaultValue")){var g=u.type;if(!(g!=="submit"&&g!=="reset"||u.value!==void 0&&u.value!==null))return;u=""+l._wrapperState.initialValue,d||u===l.value||(l.value=u),l.defaultValue=u}d=l.name,d!==""&&(l.name=""),l.defaultChecked=!!l._wrapperState.initialChecked,d!==""&&(l.name=d)}function sd(l,u,d){(u!=="number"||Je(l.ownerDocument)!==l)&&(d==null?l.defaultValue=""+l._wrapperState.initialValue:l.defaultValue!==""+d&&(l.defaultValue=""+d))}var ll=Array.isArray;function Fs(l,u,d,g){if(l=l.options,u){u={};for(var O=0;O"+u.valueOf().toString()+"",u=vc.firstChild;l.firstChild;)l.removeChild(l.firstChild);for(;u.firstChild;)l.appendChild(u.firstChild)}});function al(l,u){if(u){var d=l.firstChild;if(d&&d===l.lastChild&&d.nodeType===3){d.nodeValue=u;return}}l.textContent=u}var cl={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},nT=["Webkit","ms","Moz","O"];Object.keys(cl).forEach(function(l){nT.forEach(function(u){u=u+l.charAt(0).toUpperCase()+l.substring(1),cl[u]=cl[l]})});function ry(l,u,d){return u==null||typeof u=="boolean"||u===""?"":d||typeof u!="number"||u===0||cl.hasOwnProperty(l)&&cl[l]?(""+u).trim():u+"px"}function sy(l,u){l=l.style;for(var d in u)if(u.hasOwnProperty(d)){var g=d.indexOf("--")===0,O=ry(d,u[d],g);d==="float"&&(d="cssFloat"),g?l.setProperty(d,O):l[d]=O}}var iT=J({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function ad(l,u){if(u){if(iT[l]&&(u.children!=null||u.dangerouslySetInnerHTML!=null))throw Error(t(137,l));if(u.dangerouslySetInnerHTML!=null){if(u.children!=null)throw Error(t(60));if(typeof u.dangerouslySetInnerHTML!="object"||!("__html"in u.dangerouslySetInnerHTML))throw Error(t(61))}if(u.style!=null&&typeof u.style!="object")throw Error(t(62))}}function cd(l,u){if(l.indexOf("-")===-1)return typeof u.is=="string";switch(l){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var ud=null;function hd(l){return l=l.target||l.srcElement||window,l.correspondingUseElement&&(l=l.correspondingUseElement),l.nodeType===3?l.parentNode:l}var fd=null,Ys=null,qs=null;function oy(l){if(l=Ml(l)){if(typeof fd!="function")throw Error(t(280));var u=l.stateNode;u&&(u=Xc(u),fd(l.stateNode,l.type,u))}}function ly(l){Ys?qs?qs.push(l):qs=[l]:Ys=l}function ay(){if(Ys){var l=Ys,u=qs;if(qs=Ys=null,oy(l),u)for(l=0;l>>=0,l===0?32:31-(pT(l)/gT|0)|0}var Pc=64,_c=4194304;function dl(l){switch(l&-l){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return l&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return l&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return l}}function Qc(l,u){var d=l.pendingLanes;if(d===0)return 0;var g=0,O=l.suspendedLanes,x=l.pingedLanes,k=d&268435455;if(k!==0){var T=k&~O;T!==0?g=dl(T):(x&=k,x!==0&&(g=dl(x)))}else k=d&~O,k!==0?g=dl(k):x!==0&&(g=dl(x));if(g===0)return 0;if(u!==0&&u!==g&&(u&O)===0&&(O=g&-g,x=u&-u,O>=x||O===16&&(x&4194240)!==0))return u;if((g&4)!==0&&(g|=d&16),u=l.entangledLanes,u!==0)for(l=l.entanglements,u&=g;0d;d++)u.push(l);return u}function pl(l,u,d){l.pendingLanes|=u,u!==536870912&&(l.suspendedLanes=0,l.pingedLanes=0),l=l.eventTimes,u=31-Gn(u),l[u]=d}function xT(l,u){var d=l.pendingLanes&~u;l.pendingLanes=u,l.suspendedLanes=0,l.pingedLanes=0,l.expiredLanes&=u,l.mutableReadLanes&=u,l.entangledLanes&=u,u=l.entanglements;var g=l.eventTimes;for(l=l.expirationTimes;0=Sl),Dy=" ",zy=!1;function Zy(l,u){switch(l){case"keyup":return YT.indexOf(u.keyCode)!==-1;case"keydown":return u.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Iy(l){return l=l.detail,typeof l=="object"&&"data"in l?l.data:null}var Gs=!1;function UT(l,u){switch(l){case"compositionend":return Iy(u);case"keypress":return u.which!==32?null:(zy=!0,Dy);case"textInput":return l=u.data,l===Dy&&zy?null:l;default:return null}}function HT(l,u){if(Gs)return l==="compositionend"||!$d&&Zy(l,u)?(l=$y(),Rc=kd=gr=null,Gs=!1,l):null;switch(l){case"paste":return null;case"keypress":if(!(u.ctrlKey||u.altKey||u.metaKey)||u.ctrlKey&&u.altKey){if(u.char&&1=u)return{node:d,offset:u-l};l=g}e:{for(;d;){if(d.nextSibling){d=d.nextSibling;break e}d=d.parentNode}d=void 0}d=Fy(d)}}function qy(l,u){return l&&u?l===u?!0:l&&l.nodeType===3?!1:u&&u.nodeType===3?qy(l,u.parentNode):"contains"in l?l.contains(u):l.compareDocumentPosition?!!(l.compareDocumentPosition(u)&16):!1:!1}function Uy(){for(var l=window,u=Je();u instanceof l.HTMLIFrameElement;){try{var d=typeof u.contentWindow.location.href=="string"}catch{d=!1}if(d)l=u.contentWindow;else break;u=Je(l.document)}return u}function Ad(l){var u=l&&l.nodeName&&l.nodeName.toLowerCase();return u&&(u==="input"&&(l.type==="text"||l.type==="search"||l.type==="tel"||l.type==="url"||l.type==="password")||u==="textarea"||l.contentEditable==="true")}function s$(l){var u=Uy(),d=l.focusedElem,g=l.selectionRange;if(u!==d&&d&&d.ownerDocument&&qy(d.ownerDocument.documentElement,d)){if(g!==null&&Ad(d)){if(u=g.start,l=g.end,l===void 0&&(l=u),"selectionStart"in d)d.selectionStart=u,d.selectionEnd=Math.min(l,d.value.length);else if(l=(u=d.ownerDocument||document)&&u.defaultView||window,l.getSelection){l=l.getSelection();var O=d.textContent.length,x=Math.min(g.start,O);g=g.end===void 0?x:Math.min(g.end,O),!l.extend&&x>g&&(O=g,g=x,x=O),O=Yy(d,x);var k=Yy(d,g);O&&k&&(l.rangeCount!==1||l.anchorNode!==O.node||l.anchorOffset!==O.offset||l.focusNode!==k.node||l.focusOffset!==k.offset)&&(u=u.createRange(),u.setStart(O.node,O.offset),l.removeAllRanges(),x>g?(l.addRange(u),l.extend(k.node,k.offset)):(u.setEnd(k.node,k.offset),l.addRange(u)))}}for(u=[],l=d;l=l.parentNode;)l.nodeType===1&&u.push({element:l,left:l.scrollLeft,top:l.scrollTop});for(typeof d.focus=="function"&&d.focus(),d=0;d=document.documentMode,Ks=null,Ed=null,_l=null,Ld=!1;function Hy(l,u,d){var g=d.window===d?d.document:d.nodeType===9?d:d.ownerDocument;Ld||Ks==null||Ks!==Je(g)||(g=Ks,"selectionStart"in g&&Ad(g)?g={start:g.selectionStart,end:g.selectionEnd}:(g=(g.ownerDocument&&g.ownerDocument.defaultView||window).getSelection(),g={anchorNode:g.anchorNode,anchorOffset:g.anchorOffset,focusNode:g.focusNode,focusOffset:g.focusOffset}),_l&&Pl(_l,g)||(_l=g,g=jc(Ed,"onSelect"),0io||(l.current=Yd[io],Yd[io]=null,io--)}function Ke(l,u){io++,Yd[io]=l.current,l.current=u}var xr={},Vt=yr(xr),un=yr(!1),rs=xr;function ro(l,u){var d=l.type.contextTypes;if(!d)return xr;var g=l.stateNode;if(g&&g.__reactInternalMemoizedUnmaskedChildContext===u)return g.__reactInternalMemoizedMaskedChildContext;var O={},x;for(x in d)O[x]=u[x];return g&&(l=l.stateNode,l.__reactInternalMemoizedUnmaskedChildContext=u,l.__reactInternalMemoizedMaskedChildContext=O),O}function hn(l){return l=l.childContextTypes,l!=null}function Wc(){tt(un),tt(Vt)}function hx(l,u,d){if(Vt.current!==xr)throw Error(t(168));Ke(Vt,u),Ke(un,d)}function fx(l,u,d){var g=l.stateNode;if(u=u.childContextTypes,typeof g.getChildContext!="function")return d;g=g.getChildContext();for(var O in g)if(!(O in u))throw Error(t(108,Le(l)||"Unknown",O));return J({},d,g)}function Vc(l){return l=(l=l.stateNode)&&l.__reactInternalMemoizedMergedChildContext||xr,rs=Vt.current,Ke(Vt,l),Ke(un,un.current),!0}function dx(l,u,d){var g=l.stateNode;if(!g)throw Error(t(169));d?(l=fx(l,u,rs),g.__reactInternalMemoizedMergedChildContext=l,tt(un),tt(Vt),Ke(Vt,l)):tt(un),Ke(un,d)}var ji=null,Fc=!1,qd=!1;function px(l){ji===null?ji=[l]:ji.push(l)}function O$(l){Fc=!0,px(l)}function vr(){if(!qd&&ji!==null){qd=!0;var l=0,u=Ve;try{var d=ji;for(Ve=1;l>=k,O-=k,Bi=1<<32-Gn(u)+O|d<Ce?(At=Qe,Qe=null):At=Qe.sibling;var Be=H(z,Qe,I[Ce],ne);if(Be===null){Qe===null&&(Qe=At);break}l&&Qe&&Be.alternate===null&&u(z,Qe),D=x(Be,D,Ce),_e===null?Se=Be:_e.sibling=Be,_e=Be,Qe=At}if(Ce===I.length)return d(z,Qe),ot&&os(z,Ce),Se;if(Qe===null){for(;CeCe?(At=Qe,Qe=null):At=Qe.sibling;var Tr=H(z,Qe,Be.value,ne);if(Tr===null){Qe===null&&(Qe=At);break}l&&Qe&&Tr.alternate===null&&u(z,Qe),D=x(Tr,D,Ce),_e===null?Se=Tr:_e.sibling=Tr,_e=Tr,Qe=At}if(Be.done)return d(z,Qe),ot&&os(z,Ce),Se;if(Qe===null){for(;!Be.done;Ce++,Be=I.next())Be=ee(z,Be.value,ne),Be!==null&&(D=x(Be,D,Ce),_e===null?Se=Be:_e.sibling=Be,_e=Be);return ot&&os(z,Ce),Se}for(Qe=g(z,Qe);!Be.done;Ce++,Be=I.next())Be=he(Qe,z,Ce,Be.value,ne),Be!==null&&(l&&Be.alternate!==null&&Qe.delete(Be.key===null?Ce:Be.key),D=x(Be,D,Ce),_e===null?Se=Be:_e.sibling=Be,_e=Be);return l&&Qe.forEach(function(G$){return u(z,G$)}),ot&&os(z,Ce),Se}function gt(z,D,I,ne){if(typeof I=="object"&&I!==null&&I.type===M&&I.key===null&&(I=I.props.children),typeof I=="object"&&I!==null){switch(I.$$typeof){case Q:e:{for(var Se=I.key,_e=D;_e!==null;){if(_e.key===Se){if(Se=I.type,Se===M){if(_e.tag===7){d(z,_e.sibling),D=O(_e,I.props.children),D.return=z,z=D;break e}}else if(_e.elementType===Se||typeof Se=="object"&&Se!==null&&Se.$$typeof===se&&vx(Se)===_e.type){d(z,_e.sibling),D=O(_e,I.props),D.ref=Rl(z,_e,I),D.return=z,z=D;break e}d(z,_e);break}else u(z,_e);_e=_e.sibling}I.type===M?(D=ps(I.props.children,z.mode,ne,I.key),D.return=z,z=D):(ne=vu(I.type,I.key,I.props,null,z.mode,ne),ne.ref=Rl(z,D,I),ne.return=z,z=ne)}return k(z);case $:e:{for(_e=I.key;D!==null;){if(D.key===_e)if(D.tag===4&&D.stateNode.containerInfo===I.containerInfo&&D.stateNode.implementation===I.implementation){d(z,D.sibling),D=O(D,I.children||[]),D.return=z,z=D;break e}else{d(z,D);break}else u(z,D);D=D.sibling}D=Vp(I,z.mode,ne),D.return=z,z=D}return k(z);case se:return _e=I._init,gt(z,D,_e(I._payload),ne)}if(ll(I))return ye(z,D,I,ne);if(U(I))return ve(z,D,I,ne);Hc(z,I)}return typeof I=="string"&&I!==""||typeof I=="number"?(I=""+I,D!==null&&D.tag===6?(d(z,D.sibling),D=O(D,I),D.return=z,z=D):(d(z,D),D=Wp(I,z.mode,ne),D.return=z,z=D),k(z)):d(z,D)}return gt}var ao=bx(!0),Sx=bx(!1),Gc=yr(null),Kc=null,co=null,ep=null;function tp(){ep=co=Kc=null}function np(l){var u=Gc.current;tt(Gc),l._currentValue=u}function ip(l,u,d){for(;l!==null;){var g=l.alternate;if((l.childLanes&u)!==u?(l.childLanes|=u,g!==null&&(g.childLanes|=u)):g!==null&&(g.childLanes&u)!==u&&(g.childLanes|=u),l===d)break;l=l.return}}function uo(l,u){Kc=l,ep=co=null,l=l.dependencies,l!==null&&l.firstContext!==null&&((l.lanes&u)!==0&&(fn=!0),l.firstContext=null)}function zn(l){var u=l._currentValue;if(ep!==l)if(l={context:l,memoizedValue:u,next:null},co===null){if(Kc===null)throw Error(t(308));co=l,Kc.dependencies={lanes:0,firstContext:l}}else co=co.next=l;return u}var ls=null;function rp(l){ls===null?ls=[l]:ls.push(l)}function wx(l,u,d,g){var O=u.interleaved;return O===null?(d.next=d,rp(u)):(d.next=O.next,O.next=d),u.interleaved=d,Xi(l,g)}function Xi(l,u){l.lanes|=u;var d=l.alternate;for(d!==null&&(d.lanes|=u),d=l,l=l.return;l!==null;)l.childLanes|=u,d=l.alternate,d!==null&&(d.childLanes|=u),d=l,l=l.return;return d.tag===3?d.stateNode:null}var br=!1;function sp(l){l.updateQueue={baseState:l.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function kx(l,u){l=l.updateQueue,u.updateQueue===l&&(u.updateQueue={baseState:l.baseState,firstBaseUpdate:l.firstBaseUpdate,lastBaseUpdate:l.lastBaseUpdate,shared:l.shared,effects:l.effects})}function Wi(l,u){return{eventTime:l,lane:u,tag:0,payload:null,callback:null,next:null}}function Sr(l,u,d){var g=l.updateQueue;if(g===null)return null;if(g=g.shared,(Ze&2)!==0){var O=g.pending;return O===null?u.next=u:(u.next=O.next,O.next=u),g.pending=u,Xi(l,d)}return O=g.interleaved,O===null?(u.next=u,rp(g)):(u.next=O.next,O.next=u),g.interleaved=u,Xi(l,d)}function Jc(l,u,d){if(u=u.updateQueue,u!==null&&(u=u.shared,(d&4194240)!==0)){var g=u.lanes;g&=l.pendingLanes,d|=g,u.lanes=d,xd(l,d)}}function Px(l,u){var d=l.updateQueue,g=l.alternate;if(g!==null&&(g=g.updateQueue,d===g)){var O=null,x=null;if(d=d.firstBaseUpdate,d!==null){do{var k={eventTime:d.eventTime,lane:d.lane,tag:d.tag,payload:d.payload,callback:d.callback,next:null};x===null?O=x=k:x=x.next=k,d=d.next}while(d!==null);x===null?O=x=u:x=x.next=u}else O=x=u;d={baseState:g.baseState,firstBaseUpdate:O,lastBaseUpdate:x,shared:g.shared,effects:g.effects},l.updateQueue=d;return}l=d.lastBaseUpdate,l===null?d.firstBaseUpdate=u:l.next=u,d.lastBaseUpdate=u}function eu(l,u,d,g){var O=l.updateQueue;br=!1;var x=O.firstBaseUpdate,k=O.lastBaseUpdate,T=O.shared.pending;if(T!==null){O.shared.pending=null;var A=T,B=A.next;A.next=null,k===null?x=B:k.next=B,k=A;var G=l.alternate;G!==null&&(G=G.updateQueue,T=G.lastBaseUpdate,T!==k&&(T===null?G.firstBaseUpdate=B:T.next=B,G.lastBaseUpdate=A))}if(x!==null){var ee=O.baseState;k=0,G=B=A=null,T=x;do{var H=T.lane,he=T.eventTime;if((g&H)===H){G!==null&&(G=G.next={eventTime:he,lane:0,tag:T.tag,payload:T.payload,callback:T.callback,next:null});e:{var ye=l,ve=T;switch(H=u,he=d,ve.tag){case 1:if(ye=ve.payload,typeof ye=="function"){ee=ye.call(he,ee,H);break e}ee=ye;break e;case 3:ye.flags=ye.flags&-65537|128;case 0:if(ye=ve.payload,H=typeof ye=="function"?ye.call(he,ee,H):ye,H==null)break e;ee=J({},ee,H);break e;case 2:br=!0}}T.callback!==null&&T.lane!==0&&(l.flags|=64,H=O.effects,H===null?O.effects=[T]:H.push(T))}else he={eventTime:he,lane:H,tag:T.tag,payload:T.payload,callback:T.callback,next:null},G===null?(B=G=he,A=ee):G=G.next=he,k|=H;if(T=T.next,T===null){if(T=O.shared.pending,T===null)break;H=T,T=H.next,H.next=null,O.lastBaseUpdate=H,O.shared.pending=null}}while(!0);if(G===null&&(A=ee),O.baseState=A,O.firstBaseUpdate=B,O.lastBaseUpdate=G,u=O.shared.interleaved,u!==null){O=u;do k|=O.lane,O=O.next;while(O!==u)}else x===null&&(O.shared.lanes=0);us|=k,l.lanes=k,l.memoizedState=ee}}function _x(l,u,d){if(l=u.effects,u.effects=null,l!==null)for(u=0;ud?d:4,l(!0);var g=up.transition;up.transition={};try{l(!1),u()}finally{Ve=d,up.transition=g}}function Vx(){return Zn().memoizedState}function b$(l,u,d){var g=_r(l);if(d={lane:g,action:d,hasEagerState:!1,eagerState:null,next:null},Fx(l))Yx(u,d);else if(d=wx(l,u,d,g),d!==null){var O=on();ii(d,l,g,O),qx(d,u,g)}}function S$(l,u,d){var g=_r(l),O={lane:g,action:d,hasEagerState:!1,eagerState:null,next:null};if(Fx(l))Yx(u,O);else{var x=l.alternate;if(l.lanes===0&&(x===null||x.lanes===0)&&(x=u.lastRenderedReducer,x!==null))try{var k=u.lastRenderedState,T=x(k,d);if(O.hasEagerState=!0,O.eagerState=T,Kn(T,k)){var A=u.interleaved;A===null?(O.next=O,rp(u)):(O.next=A.next,A.next=O),u.interleaved=O;return}}catch{}finally{}d=wx(l,u,O,g),d!==null&&(O=on(),ii(d,l,g,O),qx(d,u,g))}}function Fx(l){var u=l.alternate;return l===ut||u!==null&&u===ut}function Yx(l,u){Dl=iu=!0;var d=l.pending;d===null?u.next=u:(u.next=d.next,d.next=u),l.pending=u}function qx(l,u,d){if((d&4194240)!==0){var g=u.lanes;g&=l.pendingLanes,d|=g,u.lanes=d,xd(l,d)}}var ou={readContext:zn,useCallback:Ft,useContext:Ft,useEffect:Ft,useImperativeHandle:Ft,useInsertionEffect:Ft,useLayoutEffect:Ft,useMemo:Ft,useReducer:Ft,useRef:Ft,useState:Ft,useDebugValue:Ft,useDeferredValue:Ft,useTransition:Ft,useMutableSource:Ft,useSyncExternalStore:Ft,useId:Ft,unstable_isNewReconciler:!1},w$={readContext:zn,useCallback:function(l,u){return xi().memoizedState=[l,u===void 0?null:u],l},useContext:zn,useEffect:zx,useImperativeHandle:function(l,u,d){return d=d!=null?d.concat([l]):null,ru(4194308,4,jx.bind(null,u,l),d)},useLayoutEffect:function(l,u){return ru(4194308,4,l,u)},useInsertionEffect:function(l,u){return ru(4,2,l,u)},useMemo:function(l,u){var d=xi();return u=u===void 0?null:u,l=l(),d.memoizedState=[l,u],l},useReducer:function(l,u,d){var g=xi();return u=d!==void 0?d(u):u,g.memoizedState=g.baseState=u,l={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:l,lastRenderedState:u},g.queue=l,l=l.dispatch=b$.bind(null,ut,l),[g.memoizedState,l]},useRef:function(l){var u=xi();return l={current:l},u.memoizedState=l},useState:Lx,useDebugValue:Op,useDeferredValue:function(l){return xi().memoizedState=l},useTransition:function(){var l=Lx(!1),u=l[0];return l=v$.bind(null,l[1]),xi().memoizedState=l,[u,l]},useMutableSource:function(){},useSyncExternalStore:function(l,u,d){var g=ut,O=xi();if(ot){if(d===void 0)throw Error(t(407));d=d()}else{if(d=u(),Rt===null)throw Error(t(349));(cs&30)!==0||$x(g,u,d)}O.memoizedState=d;var x={value:d,getSnapshot:u};return O.queue=x,zx(Rx.bind(null,g,x,l),[l]),g.flags|=2048,Il(9,Mx.bind(null,g,x,d,u),void 0,null),d},useId:function(){var l=xi(),u=Rt.identifierPrefix;if(ot){var d=Ni,g=Bi;d=(g&~(1<<32-Gn(g)-1)).toString(32)+d,u=":"+u+"R"+d,d=zl++,0<\/script>",l=l.removeChild(l.firstChild)):typeof g.is=="string"?l=k.createElement(d,{is:g.is}):(l=k.createElement(d),d==="select"&&(k=l,g.multiple?k.multiple=!0:g.size&&(k.size=g.size))):l=k.createElementNS(l,d),l[Oi]=u,l[$l]=g,p1(l,u,!1,!1),u.stateNode=l;e:{switch(k=cd(d,g),d){case"dialog":et("cancel",l),et("close",l),O=g;break;case"iframe":case"object":case"embed":et("load",l),O=g;break;case"video":case"audio":for(O=0;Omo&&(u.flags|=128,g=!0,jl(x,!1),u.lanes=4194304)}else{if(!g)if(l=tu(k),l!==null){if(u.flags|=128,g=!0,d=l.updateQueue,d!==null&&(u.updateQueue=d,u.flags|=4),jl(x,!0),x.tail===null&&x.tailMode==="hidden"&&!k.alternate&&!ot)return Yt(u),null}else 2*pt()-x.renderingStartTime>mo&&d!==1073741824&&(u.flags|=128,g=!0,jl(x,!1),u.lanes=4194304);x.isBackwards?(k.sibling=u.child,u.child=k):(d=x.last,d!==null?d.sibling=k:u.child=k,x.last=k)}return x.tail!==null?(u=x.tail,x.rendering=u,x.tail=u.sibling,x.renderingStartTime=pt(),u.sibling=null,d=ct.current,Ke(ct,g?d&1|2:d&1),u):(Yt(u),null);case 22:case 23:return Bp(),g=u.memoizedState!==null,l!==null&&l.memoizedState!==null!==g&&(u.flags|=8192),g&&(u.mode&1)!==0?(Cn&1073741824)!==0&&(Yt(u),u.subtreeFlags&6&&(u.flags|=8192)):Yt(u),null;case 24:return null;case 25:return null}throw Error(t(156,u.tag))}function M$(l,u){switch(Hd(u),u.tag){case 1:return hn(u.type)&&Wc(),l=u.flags,l&65536?(u.flags=l&-65537|128,u):null;case 3:return ho(),tt(un),tt(Vt),cp(),l=u.flags,(l&65536)!==0&&(l&128)===0?(u.flags=l&-65537|128,u):null;case 5:return lp(u),null;case 13:if(tt(ct),l=u.memoizedState,l!==null&&l.dehydrated!==null){if(u.alternate===null)throw Error(t(340));lo()}return l=u.flags,l&65536?(u.flags=l&-65537|128,u):null;case 19:return tt(ct),null;case 4:return ho(),null;case 10:return np(u.type._context),null;case 22:case 23:return Bp(),null;case 24:return null;default:return null}}var uu=!1,qt=!1,R$=typeof WeakSet=="function"?WeakSet:Set,pe=null;function po(l,u){var d=l.ref;if(d!==null)if(typeof d=="function")try{d(null)}catch(g){ft(l,u,g)}else d.current=null}function Tp(l,u,d){try{d()}catch(g){ft(l,u,g)}}var O1=!1;function A$(l,u){if(Bd=$c,l=Uy(),Ad(l)){if("selectionStart"in l)var d={start:l.selectionStart,end:l.selectionEnd};else e:{d=(d=l.ownerDocument)&&d.defaultView||window;var g=d.getSelection&&d.getSelection();if(g&&g.rangeCount!==0){d=g.anchorNode;var O=g.anchorOffset,x=g.focusNode;g=g.focusOffset;try{d.nodeType,x.nodeType}catch{d=null;break e}var k=0,T=-1,A=-1,B=0,G=0,ee=l,H=null;t:for(;;){for(var he;ee!==d||O!==0&&ee.nodeType!==3||(T=k+O),ee!==x||g!==0&&ee.nodeType!==3||(A=k+g),ee.nodeType===3&&(k+=ee.nodeValue.length),(he=ee.firstChild)!==null;)H=ee,ee=he;for(;;){if(ee===l)break t;if(H===d&&++B===O&&(T=k),H===x&&++G===g&&(A=k),(he=ee.nextSibling)!==null)break;ee=H,H=ee.parentNode}ee=he}d=T===-1||A===-1?null:{start:T,end:A}}else d=null}d=d||{start:0,end:0}}else d=null;for(Nd={focusedElem:l,selectionRange:d},$c=!1,pe=u;pe!==null;)if(u=pe,l=u.child,(u.subtreeFlags&1028)!==0&&l!==null)l.return=u,pe=l;else for(;pe!==null;){u=pe;try{var ye=u.alternate;if((u.flags&1024)!==0)switch(u.tag){case 0:case 11:case 15:break;case 1:if(ye!==null){var ve=ye.memoizedProps,gt=ye.memoizedState,z=u.stateNode,D=z.getSnapshotBeforeUpdate(u.elementType===u.type?ve:ei(u.type,ve),gt);z.__reactInternalSnapshotBeforeUpdate=D}break;case 3:var I=u.stateNode.containerInfo;I.nodeType===1?I.textContent="":I.nodeType===9&&I.documentElement&&I.removeChild(I.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(t(163))}}catch(ne){ft(u,u.return,ne)}if(l=u.sibling,l!==null){l.return=u.return,pe=l;break}pe=u.return}return ye=O1,O1=!1,ye}function Bl(l,u,d){var g=u.updateQueue;if(g=g!==null?g.lastEffect:null,g!==null){var O=g=g.next;do{if((O.tag&l)===l){var x=O.destroy;O.destroy=void 0,x!==void 0&&Tp(u,d,x)}O=O.next}while(O!==g)}}function hu(l,u){if(u=u.updateQueue,u=u!==null?u.lastEffect:null,u!==null){var d=u=u.next;do{if((d.tag&l)===l){var g=d.create;d.destroy=g()}d=d.next}while(d!==u)}}function $p(l){var u=l.ref;if(u!==null){var d=l.stateNode;switch(l.tag){case 5:l=d;break;default:l=d}typeof u=="function"?u(l):u.current=l}}function y1(l){var u=l.alternate;u!==null&&(l.alternate=null,y1(u)),l.child=null,l.deletions=null,l.sibling=null,l.tag===5&&(u=l.stateNode,u!==null&&(delete u[Oi],delete u[$l],delete u[Fd],delete u[g$],delete u[m$])),l.stateNode=null,l.return=null,l.dependencies=null,l.memoizedProps=null,l.memoizedState=null,l.pendingProps=null,l.stateNode=null,l.updateQueue=null}function x1(l){return l.tag===5||l.tag===3||l.tag===4}function v1(l){e:for(;;){for(;l.sibling===null;){if(l.return===null||x1(l.return))return null;l=l.return}for(l.sibling.return=l.return,l=l.sibling;l.tag!==5&&l.tag!==6&&l.tag!==18;){if(l.flags&2||l.child===null||l.tag===4)continue e;l.child.return=l,l=l.child}if(!(l.flags&2))return l.stateNode}}function Mp(l,u,d){var g=l.tag;if(g===5||g===6)l=l.stateNode,u?d.nodeType===8?d.parentNode.insertBefore(l,u):d.insertBefore(l,u):(d.nodeType===8?(u=d.parentNode,u.insertBefore(l,d)):(u=d,u.appendChild(l)),d=d._reactRootContainer,d!=null||u.onclick!==null||(u.onclick=Nc));else if(g!==4&&(l=l.child,l!==null))for(Mp(l,u,d),l=l.sibling;l!==null;)Mp(l,u,d),l=l.sibling}function Rp(l,u,d){var g=l.tag;if(g===5||g===6)l=l.stateNode,u?d.insertBefore(l,u):d.appendChild(l);else if(g!==4&&(l=l.child,l!==null))for(Rp(l,u,d),l=l.sibling;l!==null;)Rp(l,u,d),l=l.sibling}var It=null,ti=!1;function wr(l,u,d){for(d=d.child;d!==null;)b1(l,u,d),d=d.sibling}function b1(l,u,d){if(mi&&typeof mi.onCommitFiberUnmount=="function")try{mi.onCommitFiberUnmount(kc,d)}catch{}switch(d.tag){case 5:qt||po(d,u);case 6:var g=It,O=ti;It=null,wr(l,u,d),It=g,ti=O,It!==null&&(ti?(l=It,d=d.stateNode,l.nodeType===8?l.parentNode.removeChild(d):l.removeChild(d)):It.removeChild(d.stateNode));break;case 18:It!==null&&(ti?(l=It,d=d.stateNode,l.nodeType===8?Vd(l.parentNode,d):l.nodeType===1&&Vd(l,d),xl(l)):Vd(It,d.stateNode));break;case 4:g=It,O=ti,It=d.stateNode.containerInfo,ti=!0,wr(l,u,d),It=g,ti=O;break;case 0:case 11:case 14:case 15:if(!qt&&(g=d.updateQueue,g!==null&&(g=g.lastEffect,g!==null))){O=g=g.next;do{var x=O,k=x.destroy;x=x.tag,k!==void 0&&((x&2)!==0||(x&4)!==0)&&Tp(d,u,k),O=O.next}while(O!==g)}wr(l,u,d);break;case 1:if(!qt&&(po(d,u),g=d.stateNode,typeof g.componentWillUnmount=="function"))try{g.props=d.memoizedProps,g.state=d.memoizedState,g.componentWillUnmount()}catch(T){ft(d,u,T)}wr(l,u,d);break;case 21:wr(l,u,d);break;case 22:d.mode&1?(qt=(g=qt)||d.memoizedState!==null,wr(l,u,d),qt=g):wr(l,u,d);break;default:wr(l,u,d)}}function S1(l){var u=l.updateQueue;if(u!==null){l.updateQueue=null;var d=l.stateNode;d===null&&(d=l.stateNode=new R$),u.forEach(function(g){var O=N$.bind(null,l,g);d.has(g)||(d.add(g),g.then(O,O))})}}function ni(l,u){var d=u.deletions;if(d!==null)for(var g=0;gO&&(O=k),g&=~x}if(g=O,g=pt()-g,g=(120>g?120:480>g?480:1080>g?1080:1920>g?1920:3e3>g?3e3:4320>g?4320:1960*L$(g/1960))-g,10l?16:l,Pr===null)var g=!1;else{if(l=Pr,Pr=null,mu=0,(Ze&6)!==0)throw Error(t(331));var O=Ze;for(Ze|=4,pe=l.current;pe!==null;){var x=pe,k=x.child;if((pe.flags&16)!==0){var T=x.deletions;if(T!==null){for(var A=0;Apt()-Lp?fs(l,0):Ep|=d),pn(l,u)}function L1(l,u){u===0&&((l.mode&1)===0?u=1:(u=_c,_c<<=1,(_c&130023424)===0&&(_c=4194304)));var d=on();l=Xi(l,u),l!==null&&(pl(l,u,d),pn(l,d))}function B$(l){var u=l.memoizedState,d=0;u!==null&&(d=u.retryLane),L1(l,d)}function N$(l,u){var d=0;switch(l.tag){case 13:var g=l.stateNode,O=l.memoizedState;O!==null&&(d=O.retryLane);break;case 19:g=l.stateNode;break;default:throw Error(t(314))}g!==null&&g.delete(u),L1(l,d)}var D1;D1=function(l,u,d){if(l!==null)if(l.memoizedProps!==u.pendingProps||un.current)fn=!0;else{if((l.lanes&d)===0&&(u.flags&128)===0)return fn=!1,T$(l,u,d);fn=(l.flags&131072)!==0}else fn=!1,ot&&(u.flags&1048576)!==0&&gx(u,qc,u.index);switch(u.lanes=0,u.tag){case 2:var g=u.type;cu(l,u),l=u.pendingProps;var O=ro(u,Vt.current);uo(u,d),O=fp(null,u,g,l,O,d);var x=dp();return u.flags|=1,typeof O=="object"&&O!==null&&typeof O.render=="function"&&O.$$typeof===void 0?(u.tag=1,u.memoizedState=null,u.updateQueue=null,hn(g)?(x=!0,Vc(u)):x=!1,u.memoizedState=O.state!==null&&O.state!==void 0?O.state:null,sp(u),O.updater=lu,u.stateNode=O,O._reactInternals=u,xp(u,g,l,d),u=wp(null,u,g,!0,x,d)):(u.tag=0,ot&&x&&Ud(u),sn(null,u,O,d),u=u.child),u;case 16:g=u.elementType;e:{switch(cu(l,u),l=u.pendingProps,O=g._init,g=O(g._payload),u.type=g,O=u.tag=W$(g),l=ei(g,l),O){case 0:u=Sp(null,u,g,l,d);break e;case 1:u=a1(null,u,g,l,d);break e;case 11:u=i1(null,u,g,l,d);break e;case 14:u=r1(null,u,g,ei(g.type,l),d);break e}throw Error(t(306,g,""))}return u;case 0:return g=u.type,O=u.pendingProps,O=u.elementType===g?O:ei(g,O),Sp(l,u,g,O,d);case 1:return g=u.type,O=u.pendingProps,O=u.elementType===g?O:ei(g,O),a1(l,u,g,O,d);case 3:e:{if(c1(u),l===null)throw Error(t(387));g=u.pendingProps,x=u.memoizedState,O=x.element,kx(l,u),eu(u,g,null,d);var k=u.memoizedState;if(g=k.element,x.isDehydrated)if(x={element:g,isDehydrated:!1,cache:k.cache,pendingSuspenseBoundaries:k.pendingSuspenseBoundaries,transitions:k.transitions},u.updateQueue.baseState=x,u.memoizedState=x,u.flags&256){O=fo(Error(t(423)),u),u=u1(l,u,g,d,O);break e}else if(g!==O){O=fo(Error(t(424)),u),u=u1(l,u,g,d,O);break e}else for(Qn=Or(u.stateNode.containerInfo.firstChild),_n=u,ot=!0,Jn=null,d=Sx(u,null,g,d),u.child=d;d;)d.flags=d.flags&-3|4096,d=d.sibling;else{if(lo(),g===O){u=Vi(l,u,d);break e}sn(l,u,g,d)}u=u.child}return u;case 5:return Qx(u),l===null&&Kd(u),g=u.type,O=u.pendingProps,x=l!==null?l.memoizedProps:null,k=O.children,Xd(g,O)?k=null:x!==null&&Xd(g,x)&&(u.flags|=32),l1(l,u),sn(l,u,k,d),u.child;case 6:return l===null&&Kd(u),null;case 13:return h1(l,u,d);case 4:return op(u,u.stateNode.containerInfo),g=u.pendingProps,l===null?u.child=ao(u,null,g,d):sn(l,u,g,d),u.child;case 11:return g=u.type,O=u.pendingProps,O=u.elementType===g?O:ei(g,O),i1(l,u,g,O,d);case 7:return sn(l,u,u.pendingProps,d),u.child;case 8:return sn(l,u,u.pendingProps.children,d),u.child;case 12:return sn(l,u,u.pendingProps.children,d),u.child;case 10:e:{if(g=u.type._context,O=u.pendingProps,x=u.memoizedProps,k=O.value,Ke(Gc,g._currentValue),g._currentValue=k,x!==null)if(Kn(x.value,k)){if(x.children===O.children&&!un.current){u=Vi(l,u,d);break e}}else for(x=u.child,x!==null&&(x.return=u);x!==null;){var T=x.dependencies;if(T!==null){k=x.child;for(var A=T.firstContext;A!==null;){if(A.context===g){if(x.tag===1){A=Wi(-1,d&-d),A.tag=2;var B=x.updateQueue;if(B!==null){B=B.shared;var G=B.pending;G===null?A.next=A:(A.next=G.next,G.next=A),B.pending=A}}x.lanes|=d,A=x.alternate,A!==null&&(A.lanes|=d),ip(x.return,d,u),T.lanes|=d;break}A=A.next}}else if(x.tag===10)k=x.type===u.type?null:x.child;else if(x.tag===18){if(k=x.return,k===null)throw Error(t(341));k.lanes|=d,T=k.alternate,T!==null&&(T.lanes|=d),ip(k,d,u),k=x.sibling}else k=x.child;if(k!==null)k.return=x;else for(k=x;k!==null;){if(k===u){k=null;break}if(x=k.sibling,x!==null){x.return=k.return,k=x;break}k=k.return}x=k}sn(l,u,O.children,d),u=u.child}return u;case 9:return O=u.type,g=u.pendingProps.children,uo(u,d),O=zn(O),g=g(O),u.flags|=1,sn(l,u,g,d),u.child;case 14:return g=u.type,O=ei(g,u.pendingProps),O=ei(g.type,O),r1(l,u,g,O,d);case 15:return s1(l,u,u.type,u.pendingProps,d);case 17:return g=u.type,O=u.pendingProps,O=u.elementType===g?O:ei(g,O),cu(l,u),u.tag=1,hn(g)?(l=!0,Vc(u)):l=!1,uo(u,d),Hx(u,g,O),xp(u,g,O,d),wp(null,u,g,!0,l,d);case 19:return d1(l,u,d);case 22:return o1(l,u,d)}throw Error(t(156,u.tag))};function z1(l,u){return my(l,u)}function X$(l,u,d,g){this.tag=l,this.key=d,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=u,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=g,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function jn(l,u,d,g){return new X$(l,u,d,g)}function Xp(l){return l=l.prototype,!(!l||!l.isReactComponent)}function W$(l){if(typeof l=="function")return Xp(l)?1:0;if(l!=null){if(l=l.$$typeof,l===F)return 11;if(l===re)return 14}return 2}function Cr(l,u){var d=l.alternate;return d===null?(d=jn(l.tag,u,l.key,l.mode),d.elementType=l.elementType,d.type=l.type,d.stateNode=l.stateNode,d.alternate=l,l.alternate=d):(d.pendingProps=u,d.type=l.type,d.flags=0,d.subtreeFlags=0,d.deletions=null),d.flags=l.flags&14680064,d.childLanes=l.childLanes,d.lanes=l.lanes,d.child=l.child,d.memoizedProps=l.memoizedProps,d.memoizedState=l.memoizedState,d.updateQueue=l.updateQueue,u=l.dependencies,d.dependencies=u===null?null:{lanes:u.lanes,firstContext:u.firstContext},d.sibling=l.sibling,d.index=l.index,d.ref=l.ref,d}function vu(l,u,d,g,O,x){var k=2;if(g=l,typeof l=="function")Xp(l)&&(k=1);else if(typeof l=="string")k=5;else e:switch(l){case M:return ps(d.children,O,x,u);case Z:k=8,O|=8;break;case j:return l=jn(12,d,u,O|2),l.elementType=j,l.lanes=x,l;case ie:return l=jn(13,d,u,O),l.elementType=ie,l.lanes=x,l;case oe:return l=jn(19,d,u,O),l.elementType=oe,l.lanes=x,l;case ue:return bu(d,O,x,u);default:if(typeof l=="object"&&l!==null)switch(l.$$typeof){case Y:k=10;break e;case W:k=9;break e;case F:k=11;break e;case re:k=14;break e;case se:k=16,g=null;break e}throw Error(t(130,l==null?l:typeof l,""))}return u=jn(k,d,u,O),u.elementType=l,u.type=g,u.lanes=x,u}function ps(l,u,d,g){return l=jn(7,l,g,u),l.lanes=d,l}function bu(l,u,d,g){return l=jn(22,l,g,u),l.elementType=ue,l.lanes=d,l.stateNode={isHidden:!1},l}function Wp(l,u,d){return l=jn(6,l,null,u),l.lanes=d,l}function Vp(l,u,d){return u=jn(4,l.children!==null?l.children:[],l.key,u),u.lanes=d,u.stateNode={containerInfo:l.containerInfo,pendingChildren:null,implementation:l.implementation},u}function V$(l,u,d,g,O){this.tag=u,this.containerInfo=l,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=yd(0),this.expirationTimes=yd(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=yd(0),this.identifierPrefix=g,this.onRecoverableError=O,this.mutableSourceEagerHydrationData=null}function Fp(l,u,d,g,O,x,k,T,A){return l=new V$(l,u,d,T,A),u===1?(u=1,x===!0&&(u|=8)):u=0,x=jn(3,null,null,u),l.current=x,x.stateNode=l,x.memoizedState={element:g,isDehydrated:d,cache:null,transitions:null,pendingSuspenseBoundaries:null},sp(x),l}function F$(l,u,d){var g=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}return n(),Kp.exports=s2(),Kp.exports}var G1;function l2(){if(G1)return Cu;G1=1;var n=o2();return Cu.createRoot=n.createRoot,Cu.hydrateRoot=n.hydrateRoot,Cu}var a2=l2();const c2=DO(a2);var Dt=function(){return Dt=Object.assign||function(e){for(var t,i=1,r=arguments.length;i0?Et(el,--Yn):0,Bo--,vt===10&&(Bo=1,Mf--),vt}function ci(){return vt=Yn2||um(vt)>3?"":" "}function x2(n,e){for(;--e&&ci()&&!(vt<48||vt>102||vt>57&&vt<65||vt>70&&vt<97););return Af(n,yh()+(e<6&&Ms()==32&&ci()==32))}function hm(n){for(;ci();)switch(vt){case n:return Yn;case 34:case 39:n!==34&&n!==39&&hm(vt);break;case 40:n===41&&hm(n);break;case 92:ci();break}return Yn}function v2(n,e){for(;ci()&&n+vt!==57;)if(n+vt===84&&Ms()===47)break;return"/*"+Af(e,Yn-1)+"*"+IO(n===47?n:ci())}function b2(n){for(;!um(Ms());)ci();return Af(n,Yn)}function S2(n){return O2(xh("",null,null,null,[""],n=m2(n),0,[0],n))}function xh(n,e,t,i,r,s,o,a,c){for(var h=0,f=0,p=o,m=0,y=0,v=0,b=1,S=1,w=1,C=0,_="",P=r,Q=s,$=i,M=_;S;)switch(v=C,C=ci()){case 40:if(v!=108&&Et(M,p-1)==58){Oh(M+=Me(tg(C),"&","&\f"),"&\f",ok(h?a[h-1]:0))!=-1&&(w=-1);break}case 34:case 39:case 91:M+=tg(C);break;case 9:case 10:case 13:case 32:M+=y2(v);break;case 92:M+=x2(yh()-1,7);continue;case 47:switch(Ms()){case 42:case 47:oa(w2(v2(ci(),yh()),e,t,c),c);break;default:M+="/"}break;case 123*b:a[h++]=Ci(M)*w;case 125*b:case 59:case 0:switch(C){case 0:case 125:S=0;case 59+f:w==-1&&(M=Me(M,/\f/g,"")),y>0&&Ci(M)-p&&oa(y>32?ev(M+";",i,t,p-1,c):ev(Me(M," ","")+";",i,t,p-2,c),c);break;case 59:M+=";";default:if(oa($=J1(M,e,t,h,f,r,a,_,P=[],Q=[],p,s),s),C===123)if(f===0)xh(M,e,$,$,P,s,p,a,Q);else switch(m===99&&Et(M,3)===110?100:m){case 100:case 108:case 109:case 115:xh(n,$,$,i&&oa(J1(n,$,$,0,0,r,a,_,r,P=[],p,Q),Q),r,Q,p,a,i?P:Q);break;default:xh(M,$,$,$,[""],Q,0,a,Q)}}h=f=y=0,b=w=1,_=M="",p=o;break;case 58:p=1+Ci(M),y=v;default:if(b<1){if(C==123)--b;else if(C==125&&b++==0&&g2()==125)continue}switch(M+=IO(C),C*b){case 38:w=f>0?1:(M+="\f",-1);break;case 44:a[h++]=(Ci(M)-1)*w,w=1;break;case 64:Ms()===45&&(M+=tg(ci())),m=Ms(),f=p=Ci(_=M+=b2(yh())),C++;break;case 45:v===45&&Ci(M)==2&&(b=0)}}return s}function J1(n,e,t,i,r,s,o,a,c,h,f,p){for(var m=r-1,y=r===0?s:[""],v=ak(y),b=0,S=0,w=0;b0?y[C]+" "+_:Me(_,/&\f/g,y[C])))&&(c[w++]=P);return Rf(n,e,t,r===0?$f:a,c,h,f,p)}function w2(n,e,t,i){return Rf(n,e,t,rk,IO(p2()),jo(n,2,-2),0,i)}function ev(n,e,t,i,r){return Rf(n,e,t,ZO,jo(n,0,i),jo(n,i+1,-1),i,r)}function uk(n,e,t){switch(f2(n,e)){case 5103:return Xe+"print-"+n+n;case 5737:case 4201:case 3177:case 3433:case 1641:case 4457:case 2921:case 5572:case 6356:case 5844:case 3191:case 6645:case 3005:case 6391:case 5879:case 5623:case 6135:case 4599:case 4855:case 4215:case 6389:case 5109:case 5365:case 5621:case 3829:return Xe+n+n;case 4789:return Oa+n+n;case 5349:case 4246:case 4810:case 6968:case 2756:return Xe+n+Oa+n+nt+n+n;case 5936:switch(Et(n,e+11)){case 114:return Xe+n+nt+Me(n,/[svh]\w+-[tblr]{2}/,"tb")+n;case 108:return Xe+n+nt+Me(n,/[svh]\w+-[tblr]{2}/,"tb-rl")+n;case 45:return Xe+n+nt+Me(n,/[svh]\w+-[tblr]{2}/,"lr")+n}case 6828:case 4268:case 2903:return Xe+n+nt+n+n;case 6165:return Xe+n+nt+"flex-"+n+n;case 5187:return Xe+n+Me(n,/(\w+).+(:[^]+)/,Xe+"box-$1$2"+nt+"flex-$1$2")+n;case 5443:return Xe+n+nt+"flex-item-"+Me(n,/flex-|-self/g,"")+(Gi(n,/flex-|baseline/)?"":nt+"grid-row-"+Me(n,/flex-|-self/g,""))+n;case 4675:return Xe+n+nt+"flex-line-pack"+Me(n,/align-content|flex-|-self/g,"")+n;case 5548:return Xe+n+nt+Me(n,"shrink","negative")+n;case 5292:return Xe+n+nt+Me(n,"basis","preferred-size")+n;case 6060:return Xe+"box-"+Me(n,"-grow","")+Xe+n+nt+Me(n,"grow","positive")+n;case 4554:return Xe+Me(n,/([^-])(transform)/g,"$1"+Xe+"$2")+n;case 6187:return Me(Me(Me(n,/(zoom-|grab)/,Xe+"$1"),/(image-set)/,Xe+"$1"),n,"")+n;case 5495:case 3959:return Me(n,/(image-set\([^]*)/,Xe+"$1$`$1");case 4968:return Me(Me(n,/(.+:)(flex-)?(.*)/,Xe+"box-pack:$3"+nt+"flex-pack:$3"),/s.+-b[^;]+/,"justify")+Xe+n+n;case 4200:if(!Gi(n,/flex-|baseline/))return nt+"grid-column-align"+jo(n,e)+n;break;case 2592:case 3360:return nt+Me(n,"template-","")+n;case 4384:case 3616:return t&&t.some(function(i,r){return e=r,Gi(i.props,/grid-\w+-end/)})?~Oh(n+(t=t[e].value),"span",0)?n:nt+Me(n,"-start","")+n+nt+"grid-row-span:"+(~Oh(t,"span",0)?Gi(t,/\d+/):+Gi(t,/\d+/)-+Gi(n,/\d+/))+";":nt+Me(n,"-start","")+n;case 4896:case 4128:return t&&t.some(function(i){return Gi(i.props,/grid-\w+-start/)})?n:nt+Me(Me(n,"-end","-span"),"span ","")+n;case 4095:case 3583:case 4068:case 2532:return Me(n,/(.+)-inline(.+)/,Xe+"$1$2")+n;case 8116:case 7059:case 5753:case 5535:case 5445:case 5701:case 4933:case 4677:case 5533:case 5789:case 5021:case 4765:if(Ci(n)-1-e>6)switch(Et(n,e+1)){case 109:if(Et(n,e+4)!==45)break;case 102:return Me(n,/(.+:)(.+)-([^]+)/,"$1"+Xe+"$2-$3$1"+Oa+(Et(n,e+3)==108?"$3":"$2-$3"))+n;case 115:return~Oh(n,"stretch",0)?uk(Me(n,"stretch","fill-available"),e,t)+n:n}break;case 5152:case 5920:return Me(n,/(.+?):(\d+)(\s*\/\s*(span)?\s*(\d+))?(.*)/,function(i,r,s,o,a,c,h){return nt+r+":"+s+h+(o?nt+r+"-span:"+(a?c:+c-+s)+h:"")+n});case 4949:if(Et(n,e+6)===121)return Me(n,":",":"+Xe)+n;break;case 6444:switch(Et(n,Et(n,14)===45?18:11)){case 120:return Me(n,/(.+:)([^;\s!]+)(;|(\s+)?!.+)?/,"$1"+Xe+(Et(n,14)===45?"inline-":"")+"box$3$1"+Xe+"$2$3$1"+nt+"$2box$3")+n;case 100:return Me(n,":",":"+nt)+n}break;case 5719:case 2647:case 2135:case 3927:case 2391:return Me(n,"scroll-","scroll-snap-")+n}return n}function Bh(n,e){for(var t="",i=0;i-1&&!n.return)switch(n.type){case ZO:n.return=uk(n.value,n.length,t);return;case sk:return Bh([Mr(n,{value:Me(n.value,"@","@"+Xe)})],i);case $f:if(n.length)return d2(t=n.props,function(r){switch(Gi(r,i=/(::plac\w+|:read-\w+)/)){case":read-only":case":read-write":yo(Mr(n,{props:[Me(r,/:(read-\w+)/,":"+Oa+"$1")]})),yo(Mr(n,{props:[r]})),cm(n,{props:K1(t,i)});break;case"::placeholder":yo(Mr(n,{props:[Me(r,/:(plac\w+)/,":"+Xe+"input-$1")]})),yo(Mr(n,{props:[Me(r,/:(plac\w+)/,":"+Oa+"$1")]})),yo(Mr(n,{props:[Me(r,/:(plac\w+)/,nt+"input-$1")]})),yo(Mr(n,{props:[r]})),cm(n,{props:K1(t,i)});break}return""})}}var C2={animationIterationCount:1,aspectRatio:1,borderImageOutset:1,borderImageSlice:1,borderImageWidth:1,boxFlex:1,boxFlexGroup:1,boxOrdinalGroup:1,columnCount:1,columns:1,flex:1,flexGrow:1,flexPositive:1,flexShrink:1,flexNegative:1,flexOrder:1,gridRow:1,gridRowEnd:1,gridRowSpan:1,gridRowStart:1,gridColumn:1,gridColumnEnd:1,gridColumnSpan:1,gridColumnStart:1,msGridRow:1,msGridRowSpan:1,msGridColumn:1,msGridColumnSpan:1,fontWeight:1,lineHeight:1,opacity:1,order:1,orphans:1,tabSize:1,widows:1,zIndex:1,zoom:1,WebkitLineClamp:1,fillOpacity:1,floodOpacity:1,stopOpacity:1,strokeDasharray:1,strokeDashoffset:1,strokeMiterlimit:1,strokeOpacity:1,strokeWidth:1},Mn={},No=typeof process<"u"&&Mn!==void 0&&(Mn.REACT_APP_SC_ATTR||Mn.SC_ATTR)||"data-styled",hk="active",fk="data-styled-version",Ef="6.1.19",jO=`/*!sc*/ -`,Nh=typeof window<"u"&&typeof document<"u",T2=!!(typeof SC_DISABLE_SPEEDY=="boolean"?SC_DISABLE_SPEEDY:typeof process<"u"&&Mn!==void 0&&Mn.REACT_APP_SC_DISABLE_SPEEDY!==void 0&&Mn.REACT_APP_SC_DISABLE_SPEEDY!==""?Mn.REACT_APP_SC_DISABLE_SPEEDY!=="false"&&Mn.REACT_APP_SC_DISABLE_SPEEDY:typeof process<"u"&&Mn!==void 0&&Mn.SC_DISABLE_SPEEDY!==void 0&&Mn.SC_DISABLE_SPEEDY!==""&&Mn.SC_DISABLE_SPEEDY!=="false"&&Mn.SC_DISABLE_SPEEDY),$2={},Lf=Object.freeze([]),Xo=Object.freeze({});function dk(n,e,t){return t===void 0&&(t=Xo),n.theme!==t.theme&&n.theme||e||t.theme}var pk=new Set(["a","abbr","address","area","article","aside","audio","b","base","bdi","bdo","big","blockquote","body","br","button","canvas","caption","cite","code","col","colgroup","data","datalist","dd","del","details","dfn","dialog","div","dl","dt","em","embed","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","hr","html","i","iframe","img","input","ins","kbd","keygen","label","legend","li","link","main","map","mark","menu","menuitem","meta","meter","nav","noscript","object","ol","optgroup","option","output","p","param","picture","pre","progress","q","rp","rt","ruby","s","samp","script","section","select","small","source","span","strong","style","sub","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","track","u","ul","use","var","video","wbr","circle","clipPath","defs","ellipse","foreignObject","g","image","line","linearGradient","marker","mask","path","pattern","polygon","polyline","radialGradient","rect","stop","svg","text","tspan"]),M2=/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~-]+/g,R2=/(^-|-$)/g;function tv(n){return n.replace(M2,"-").replace(R2,"")}var A2=/(a)(d)/gi,Tu=52,nv=function(n){return String.fromCharCode(n+(n>25?39:97))};function fm(n){var e,t="";for(e=Math.abs(n);e>Tu;e=e/Tu|0)t=nv(e%Tu)+t;return(nv(e%Tu)+t).replace(A2,"$1-$2")}var ng,gk=5381,Po=function(n,e){for(var t=e.length;t;)n=33*n^e.charCodeAt(--t);return n},mk=function(n){return Po(gk,n)};function Ok(n){return fm(mk(n)>>>0)}function E2(n){return n.displayName||n.name||"Component"}function ig(n){return typeof n=="string"&&!0}var yk=typeof Symbol=="function"&&Symbol.for,xk=yk?Symbol.for("react.memo"):60115,L2=yk?Symbol.for("react.forward_ref"):60112,D2={childContextTypes:!0,contextType:!0,contextTypes:!0,defaultProps:!0,displayName:!0,getDefaultProps:!0,getDerivedStateFromError:!0,getDerivedStateFromProps:!0,mixins:!0,propTypes:!0,type:!0},z2={name:!0,length:!0,prototype:!0,caller:!0,callee:!0,arguments:!0,arity:!0},vk={$$typeof:!0,compare:!0,defaultProps:!0,displayName:!0,propTypes:!0,type:!0},Z2=((ng={})[L2]={$$typeof:!0,render:!0,defaultProps:!0,displayName:!0,propTypes:!0},ng[xk]=vk,ng);function iv(n){return("type"in(e=n)&&e.type.$$typeof)===xk?vk:"$$typeof"in n?Z2[n.$$typeof]:D2;var e}var I2=Object.defineProperty,j2=Object.getOwnPropertyNames,rv=Object.getOwnPropertySymbols,B2=Object.getOwnPropertyDescriptor,N2=Object.getPrototypeOf,sv=Object.prototype;function bk(n,e,t){if(typeof e!="string"){if(sv){var i=N2(e);i&&i!==sv&&bk(n,i,t)}var r=j2(e);rv&&(r=r.concat(rv(e)));for(var s=iv(n),o=iv(e),a=0;a0?" Args: ".concat(e.join(", ")):""))}var X2=(function(){function n(e){this.groupSizes=new Uint32Array(512),this.length=512,this.tag=e}return n.prototype.indexOfGroup=function(e){for(var t=0,i=0;i=this.groupSizes.length){for(var i=this.groupSizes,r=i.length,s=r;e>=s;)if((s<<=1)<0)throw zs(16,"".concat(e));this.groupSizes=new Uint32Array(s),this.groupSizes.set(i),this.length=s;for(var o=r;o=this.length||this.groupSizes[e]===0)return t;for(var i=this.groupSizes[e],r=this.indexOfGroup(e),s=r+i,o=r;o=0){var i=document.createTextNode(t);return this.element.insertBefore(i,this.nodes[e]||null),this.length++,!0}return!1},n.prototype.deleteRule=function(e){this.element.removeChild(this.nodes[e]),this.length--},n.prototype.getRule=function(e){return e0&&(S+="".concat(w,","))}),c+="".concat(v).concat(b,'{content:"').concat(S,'"}').concat(jO)},f=0;f0?".".concat(e):m},f=c.slice();f.push(function(m){m.type===$f&&m.value.includes("&")&&(m.props[0]=m.props[0].replace(eM,t).replace(i,h))}),o.prefix&&f.push(Q2),f.push(k2);var p=function(m,y,v,b){y===void 0&&(y=""),v===void 0&&(v=""),b===void 0&&(b="&"),e=b,t=y,i=new RegExp("\\".concat(t,"\\b"),"g");var S=m.replace(tM,""),w=S2(v||y?"".concat(v," ").concat(y," { ").concat(S," }"):S);o.namespace&&(w=wk(w,o.namespace));var C=[];return Bh(w,P2(f.concat(_2(function(_){return C.push(_)})))),C};return p.hash=c.length?c.reduce(function(m,y){return y.name||zs(15),Po(m,y.name)},gk).toString():"",p}var iM=new Wh,gm=nM(),kk=dt.createContext({shouldForwardProp:void 0,styleSheet:iM,stylis:gm});kk.Consumer;dt.createContext(void 0);function mm(){return me.useContext(kk)}var rM=(function(){function n(e,t){var i=this;this.inject=function(r,s){s===void 0&&(s=gm);var o=i.name+s.hash;r.hasNameForId(i.id,o)||r.insertRules(i.id,o,s(i.rules,o,"@keyframes"))},this.name=e,this.id="sc-keyframes-".concat(e),this.rules=t,NO(this,function(){throw zs(12,String(i.name))})}return n.prototype.getName=function(e){return e===void 0&&(e=gm),this.name+e.hash},n})(),sM=function(n){return n>="A"&&n<="Z"};function av(n){for(var e="",t=0;t>>0);if(!t.hasNameForId(this.componentId,o)){var a=i(s,".".concat(o),void 0,this.componentId);t.insertRules(this.componentId,o,a)}r=Qs(r,o),this.staticRulesId=o}else{for(var c=Po(this.baseHash,i.hash),h="",f=0;f>>0);t.hasNameForId(this.componentId,y)||t.insertRules(this.componentId,y,i(h,".".concat(y),void 0,this.componentId)),r=Qs(r,y)}}return r},n})(),Ra=dt.createContext(void 0);Ra.Consumer;function aM(n){var e=dt.useContext(Ra),t=me.useMemo(function(){return(function(i,r){if(!i)throw zs(14);if(Ds(i)){var s=i(r);return s}if(Array.isArray(i)||typeof i!="object")throw zs(8);return r?Dt(Dt({},r),i):i})(n.theme,e)},[n.theme,e]);return n.children?dt.createElement(Ra.Provider,{value:t},n.children):null}var rg={};function cM(n,e,t){var i=BO(n),r=n,s=!ig(n),o=e.attrs,a=o===void 0?Lf:o,c=e.componentId,h=c===void 0?(function(P,Q){var $=typeof P!="string"?"sc":tv(P);rg[$]=(rg[$]||0)+1;var M="".concat($,"-").concat(Ok(Ef+$+rg[$]));return Q?"".concat(Q,"-").concat(M):M})(e.displayName,e.parentComponentId):c,f=e.displayName,p=f===void 0?(function(P){return ig(P)?"styled.".concat(P):"Styled(".concat(E2(P),")")})(n):f,m=e.displayName&&e.componentId?"".concat(tv(e.displayName),"-").concat(e.componentId):e.componentId||h,y=i&&r.attrs?r.attrs.concat(a).filter(Boolean):a,v=e.shouldForwardProp;if(i&&r.shouldForwardProp){var b=r.shouldForwardProp;if(e.shouldForwardProp){var S=e.shouldForwardProp;v=function(P,Q){return b(P,Q)&&S(P,Q)}}else v=b}var w=new lM(t,m,i?r.componentStyle:void 0);function C(P,Q){return(function($,M,Z){var j=$.attrs,Y=$.componentStyle,W=$.defaultProps,F=$.foldedComponentIds,ie=$.styledComponentId,oe=$.target,re=dt.useContext(Ra),se=mm(),ue=$.shouldForwardProp||se.shouldForwardProp,q=dk(M,re,W)||Xo,U=(function(we,ke,Le){for(var K,X=Dt(Dt({},ke),{className:void 0,theme:Le}),te=0;te2&&Wh.registerId(this.componentId+e),this.removeStyles(e,i),this.createStyles(e,t,i,r)},n})();function hM(n){for(var e=[],t=1;t1&&(X=Math.round(X/M)*M),X<=te.minSize+te.snapOffset+this[oi]?X=te.minSize+this[oi]:X>=this.size-(be.minSize+be.snapOffset+this[Yi])&&(X=this.size-(be.minSize+this[Yi])),X>=te.maxSize-te.snapOffset+this[oi]?X=te.maxSize+this[oi]:X<=this.size-(be.maxSize-be.snapOffset+this[Yi])&&(X=this.size-(be.maxSize+this[Yi])),ue.call(this,X),Ut(e,"onDrag",li)(re()))}function U(){var K=c[this.a].element,X=c[this.b].element,te=K[ag](),be=X[ag]();this.size=te[i]+be[i]+this[oi]+this[Yi],this.start=te[s],this.end=te[o]}function J(K){if(!getComputedStyle)return null;var X=getComputedStyle(K);if(!X)return null;var te=K[a];return te===0?null:(Z===Mu?te-=parseFloat(X.paddingLeft)+parseFloat(X.paddingRight):te-=parseFloat(X.paddingTop)+parseFloat(X.paddingBottom),te)}function L(K){var X=J(f);if(X===null||b.reduce(function(Je,Zt){return Je+Zt},0)>X)return K;var te=0,be=[],We=K.map(function(Je,Zt){var gi=X*Je/100,ts=Ru(_,Zt===0,Zt===K.length-1,P),ur=b[Zt]+ts;return gi0&&be[Zt]-te>0){var ts=Math.min(te,be[Zt]-te);te-=ts,gi=Je-ts}return gi/X*100})}function N(){var K=this,X=c[K.a].element,te=c[K.b].element;K.dragging&&Ut(e,"onDragEnd",li)(re()),K.dragging=!1,Nn[si]("mouseup",K.stop),Nn[si]("touchend",K.stop),Nn[si]("touchcancel",K.stop),Nn[si]("mousemove",K.move),Nn[si]("touchmove",K.move),K.stop=null,K.move=null,X[si]("selectstart",li),X[si]("dragstart",li),te[si]("selectstart",li),te[si]("dragstart",li),X.style.userSelect="",X.style.webkitUserSelect="",X.style.MozUserSelect="",X.style.pointerEvents="",te.style.userSelect="",te.style.webkitUserSelect="",te.style.MozUserSelect="",te.style.pointerEvents="",K.gutter.style.cursor="",K.parent.style.cursor="",Aa.body.style.cursor=""}function xe(K){if(!("button"in K&&K.button!==0)){var X=this,te=c[X.a].element,be=c[X.b].element;X.dragging||Ut(e,"onDragStart",li)(re()),K.preventDefault(),X.dragging=!0,X.move=q.bind(X),X.stop=N.bind(X),Nn[ri]("mouseup",X.stop),Nn[ri]("touchend",X.stop),Nn[ri]("touchcancel",X.stop),Nn[ri]("mousemove",X.move),Nn[ri]("touchmove",X.move),te[ri]("selectstart",li),te[ri]("dragstart",li),be[ri]("selectstart",li),be[ri]("dragstart",li),te.style.userSelect="none",te.style.webkitUserSelect="none",te.style.MozUserSelect="none",te.style.pointerEvents="none",be.style.userSelect="none",be.style.webkitUserSelect="none",be.style.MozUserSelect="none",be.style.pointerEvents="none",X.gutter.style.cursor=j,X.parent.style.cursor=j,Aa.body.style.cursor=j,U.call(X),X.dragOffset=se(K)-X.end}}y=L(y);var Oe=[];c=t.map(function(K,X){var te={element:pv(K),size:y[X],minSize:b[X],maxSize:w[X],snapOffset:$[X],i:X},be;if(X>0&&(be={a:X-1,b:X,dragging:!1,direction:Z,parent:f},be[oi]=Ru(_,X-1===0,!1,P),be[Yi]=Ru(_,!1,X===t.length-1,P),m==="row-reverse"||m==="column-reverse")){var We=be.a;be.a=be.b,be.b=We}if(X>0){var Je=Y(X,Z,te.element);oe(Je,_,X),be[Yl]=xe.bind(be),Je[ri]("mousedown",be[Yl]),Je[ri]("touchstart",be[Yl]),f.insertBefore(Je,te.element),be.gutter=Je}return ie(te.element,te.size,Ru(_,X===0,X===t.length-1,P),X),X>0&&Oe.push(be),te});function we(K){var X=K.i===Oe.length,te=X?Oe[K.i-1]:Oe[K.i];U.call(te);var be=X?te.size-K.minSize-te[Yi]:K.minSize+te[oi];ue.call(te,be)}c.forEach(function(K){var X=K.element[ag]()[i];X0){var We=Oe[be-1],Je=c[We.a],Zt=c[We.b];Je.size=X[be-1],Zt.size=te,ie(Je.element,Je.size,We[oi],Je.i),ie(Zt.element,Zt.size,We[Yi],Zt.i)}})}function Le(K,X){Oe.forEach(function(te){if(X!==!0?te.parent.removeChild(te.gutter):(te.gutter[si]("mousedown",te[Yl]),te.gutter[si]("touchstart",te[Yl])),K!==!0){var be=W(i,te.a.size,te[oi]);Object.keys(be).forEach(function(We){c[te.a].element.style[We]="",c[te.b].element.style[We]=""})}})}return{setSizes:ke,getSizes:re,collapse:function(X){we(c[X])},destroy:Le,parent:f,pairs:Oe}};function cg(n,e){var t={};for(var i in n)Object.prototype.hasOwnProperty.call(n,i)&&e.indexOf(i)===-1&&(t[i]=n[i]);return t}var Vh=(function(n){function e(){n.apply(this,arguments)}return n&&(e.__proto__=n),e.prototype=Object.create(n&&n.prototype),e.prototype.constructor=e,e.prototype.componentDidMount=function(){var i=this.props;i.children;var r=i.gutter,s=cg(i,["children","gutter"]),o=s;o.gutter=function(a,c){var h;return r?h=r(a,c):(h=document.createElement("div"),h.className="gutter gutter-"+c),h.__isSplitGutter=!0,h},this.split=gv(this.parent.children,o)},e.prototype.componentDidUpdate=function(i){var r=this,s=this.props;s.children;var o=s.minSize,a=s.sizes,c=s.collapsed,h=cg(s,["children","minSize","sizes","collapsed"]),f=h,p=i.minSize,m=i.sizes,y=i.collapsed,v=["maxSize","expandToMin","gutterSize","gutterAlign","snapOffset","dragInterval","direction","cursor"],b=v.map(function(C){return r.props[C]!==i[C]}).reduce(function(C,_){return C||_},!1);if(Array.isArray(o)&&Array.isArray(p)){var S=!1;o.forEach(function(C,_){S=S||C!==p[_]}),b=b||S}else Array.isArray(o)||Array.isArray(p)?b=!0:b=b||o!==p;if(b)f.minSize=o,f.sizes=a||this.split.getSizes(),this.split.destroy(!0,!0),f.gutter=function(C,_,P){return P.previousSibling},this.split=gv(Array.from(this.parent.children).filter(function(C){return!C.__isSplitGutter}),f);else if(a){var w=!1;a.forEach(function(C,_){w=w||C!==m[_]}),w&&this.split.setSizes(this.props.sizes)}Number.isInteger(c)&&(c!==y||b)&&this.split.collapse(c)},e.prototype.componentWillUnmount=function(){this.split.destroy(),delete this.split},e.prototype.render=function(){var i=this,r=this.props;r.sizes,r.minSize,r.maxSize,r.expandToMin,r.gutterSize,r.gutterAlign,r.snapOffset,r.dragInterval,r.direction,r.cursor,r.gutter,r.elementStyle,r.gutterStyle,r.onDrag,r.onDragStart,r.onDragEnd,r.collapsed;var s=r.children,o=cg(r,["sizes","minSize","maxSize","expandToMin","gutterSize","gutterAlign","snapOffset","dragInterval","direction","cursor","gutter","elementStyle","gutterStyle","onDrag","onDragStart","onDragEnd","collapsed","children"]),a=o;return dt.createElement("div",Object.assign({},{ref:function(c){i.parent=c}},a),s)},e})(dt.Component);Vh.propTypes={sizes:Fe.arrayOf(Fe.number),minSize:Fe.oneOfType([Fe.number,Fe.arrayOf(Fe.number)]),maxSize:Fe.oneOfType([Fe.number,Fe.arrayOf(Fe.number)]),expandToMin:Fe.bool,gutterSize:Fe.number,gutterAlign:Fe.string,snapOffset:Fe.oneOfType([Fe.number,Fe.arrayOf(Fe.number)]),dragInterval:Fe.number,direction:Fe.string,cursor:Fe.string,gutter:Fe.func,elementStyle:Fe.func,gutterStyle:Fe.func,onDrag:Fe.func,onDragStart:Fe.func,onDragEnd:Fe.func,collapsed:Fe.number,children:Fe.arrayOf(Fe.element)};Vh.defaultProps={sizes:void 0,minSize:void 0,maxSize:void 0,expandToMin:void 0,gutterSize:void 0,gutterAlign:void 0,snapOffset:void 0,dragInterval:void 0,direction:void 0,cursor:void 0,gutter:void 0,elementStyle:void 0,gutterStyle:void 0,onDrag:void 0,onDragStart:void 0,onDragEnd:void 0,collapsed:void 0,children:void 0};function VO(n){return e=>!!e.type&&e.type.tabsRole===n}const lc=VO("Tab"),Df=VO("TabList"),zf=VO("TabPanel");function xM(n){return lc(n)||Df(n)||zf(n)}function ym(n,e){return me.Children.map(n,t=>t===null?null:xM(t)?e(t):t.props&&t.props.children&&typeof t.props.children=="object"?me.cloneElement(t,Object.assign({},t.props,{children:ym(t.props.children,e)})):t)}function Fh(n,e){return me.Children.forEach(n,t=>{t!==null&&(lc(t)||zf(t)?e(t):t.props&&t.props.children&&typeof t.props.children=="object"&&(Df(t)&&e(t),Fh(t.props.children,e)))})}function Mk(n,e,t){let i,r=0,s=0,o=!1;const a=[],c=n[e];return Fh(c,h=>{Df(h)&&(h.props&&h.props.children&&typeof h.props.children=="object"&&Fh(h.props.children,f=>a.push(f)),o&&(i=new Error("Found multiple 'TabList' components inside 'Tabs'. Only one is allowed.")),o=!0),lc(h)?((!o||a.indexOf(h)===-1)&&(i=new Error("Found a 'Tab' component outside of the 'TabList' component. 'Tab' components have to be inside the 'TabList' component.")),r++):zf(h)&&s++}),!i&&r!==s&&(i=new Error(`There should be an equal number of 'Tab' and 'TabPanel' in \`${t}\`. Received ${r} 'Tab' and ${s} 'TabPanel'.`)),i}function vM(n,e,t,i,r){const s=n[e],o=r||e;let a=null;return s&&typeof s!="function"?a=new Error(`Invalid ${i} \`${o}\` of type \`${typeof s}\` supplied to \`${t}\`, expected \`function\`.`):n.selectedIndex!=null&&s==null&&(a=new Error(`The ${i} \`${o}\` is marked as required in \`${t}\`, but its value is \`undefined\` or \`null\`. -\`onSelect\` is required when \`selectedIndex\` is also set. Not doing so will make the tabs not do anything, as \`selectedIndex\` indicates that you want to handle the selected tab yourself. -If you only want to set the inital tab replace \`selectedIndex\` with \`defaultIndex\`.`)),a}function bM(n,e,t,i,r){const s=n[e],o=r||e;let a=null;if(s!=null&&typeof s!="number")a=new Error(`Invalid ${i} \`${o}\` of type \`${typeof s}\` supplied to \`${t}\`, expected \`number\`.`);else if(n.defaultIndex!=null&&s!=null)return new Error(`The ${i} \`${o}\` cannot be used together with \`defaultIndex\` in \`${t}\`. -Either remove \`${o}\` to let \`${t}\` handle the selected tab internally or remove \`defaultIndex\` to handle it yourself.`);return a}function Rk(n){var e,t,i="";if(typeof n=="string"||typeof n=="number")i+=n;else if(typeof n=="object")if(Array.isArray(n)){var r=n.length;for(e=0;e{lc(t)&&e++}),e}const SM=["children","className","disabledTabClassName","domRef","focus","forceRenderTabPanel","onSelect","selectedIndex","selectedTabClassName","selectedTabPanelClassName","environment","disableUpDownKeys","disableLeftRightKeys"];function wM(n,e){if(n==null)return{};var t={};for(var i in n)if({}.hasOwnProperty.call(n,i)){if(e.includes(i))continue;t[i]=n[i]}return t}function Ek(n){return n&&"getAttribute"in n}function mv(n){return Ek(n)&&n.getAttribute("data-rttab")}function gs(n){return Ek(n)&&n.getAttribute("aria-disabled")==="true"}let Yh;function kM(n){const e=n||(typeof window<"u"?window:void 0);try{Yh=!!(typeof e<"u"&&e.document&&e.document.activeElement)}catch{Yh=!1}}const PM={className:"react-tabs",focus:!1},_M={children:Mk},QM=n=>{XO.checkPropTypes(_M,n,"prop","UncontrolledTabs");let e=me.useRef([]),t=me.useRef([]);const i=me.useRef();function r(_,P){if(_<0||_>=h())return;const{onSelect:Q,selectedIndex:$}=n;Q(_,$,P)}function s(_){const P=h();for(let Q=_+1;Q_;)if(!gs(f(P)))return P;return _}function a(){const _=h();for(let P=0;P<_;P++)if(!gs(f(P)))return P;return null}function c(){let _=h();for(;_--;)if(!gs(f(_)))return _;return null}function h(){const{children:_}=n;return Ak(_)}function f(_){return e.current[`tabs-${_}`]}function p(){let _=0;const{children:P,disabledTabClassName:Q,focus:$,forceRenderTabPanel:M,selectedIndex:Z,selectedTabClassName:j,selectedTabPanelClassName:Y,environment:W}=n;t.current=t.current||[];let F=t.current.length-h();const ie=me.useId();for(;F++<0;)t.current.push(`${ie}${t.current.length}`);return ym(P,oe=>{let re=oe;if(Df(oe)){let se=0,ue=!1;Yh==null&&kM(W);const q=W||(typeof window<"u"?window:void 0);Yh&&q&&(ue=dt.Children.toArray(oe.props.children).filter(lc).some((U,J)=>q.document.activeElement===f(J))),re=me.cloneElement(oe,{children:ym(oe.props.children,U=>{const J=`tabs-${se}`,L=Z===se,N={tabRef:xe=>{e.current[J]=xe},id:t.current[se],selected:L,focus:L&&($||ue)};return j&&(N.selectedClassName=j),Q&&(N.disabledClassName=Q),se++,me.cloneElement(U,N)})})}else if(zf(oe)){const se={id:t.current[_],selected:Z===_};M&&(se.forceRender=M),Y&&(se.selectedClassName=Y),_++,re=me.cloneElement(oe,se)}return re})}function m(_){const{direction:P,disableUpDownKeys:Q,disableLeftRightKeys:$}=n;if(v(_.target)){let{selectedIndex:M}=n,Z=!1,j=!1;(_.code==="Space"||_.keyCode===32||_.code==="Enter"||_.keyCode===13)&&(Z=!0,j=!1,y(_)),!$&&(_.keyCode===37||_.code==="ArrowLeft")||!Q&&(_.keyCode===38||_.code==="ArrowUp")?(P==="rtl"?M=s(M):M=o(M),Z=!0,j=!0):!$&&(_.keyCode===39||_.code==="ArrowRight")||!Q&&(_.keyCode===40||_.code==="ArrowDown")?(P==="rtl"?M=o(M):M=s(M),Z=!0,j=!0):_.keyCode===35||_.code==="End"?(M=c(),Z=!0,j=!0):(_.keyCode===36||_.code==="Home")&&(M=a(),Z=!0,j=!0),Z&&_.preventDefault(),j&&r(M,_)}}function y(_){let P=_.target;do if(v(P)){if(gs(P))return;const Q=[].slice.call(P.parentNode.children).filter(mv).indexOf(P);r(Q,_);return}while((P=P.parentNode)!=null)}function v(_){if(!mv(_))return!1;let P=_.parentElement;do{if(P===i.current)return!0;if(P.getAttribute("data-rttabs"))break;P=P.parentElement}while(P);return!1}const b=Object.assign({},PM,n),{className:S,domRef:w}=b,C=wM(b,SM);return dt.createElement("div",Object.assign({},C,{className:Zf(S),onClick:y,onKeyDown:m,ref:_=>{i.current=_,w&&w(_)},"data-rttabs":!0}),p())},CM=["children","defaultFocus","defaultIndex","focusTabOnClick","onSelect"];function TM(n,e){if(n==null)return{};var t={};for(var i in n)if({}.hasOwnProperty.call(n,i)){if(e.includes(i))continue;t[i]=n[i]}return t}const $M=0,Sh=1,MM={children:Mk,onSelect:vM,selectedIndex:bM},RM={defaultFocus:!1,focusTabOnClick:!0,forceRenderTabPanel:!1,selectedIndex:null,defaultIndex:null,environment:null,disableUpDownKeys:!1,disableLeftRightKeys:!1},AM=n=>n.selectedIndex===null?Sh:$M,Lk=n=>{XO.checkPropTypes(MM,n,"prop","Tabs");const e=Object.assign({},RM,n),{children:t,defaultFocus:i,defaultIndex:r,focusTabOnClick:s,onSelect:o}=e,a=TM(e,CM),[c,h]=me.useState(i),[f]=me.useState(AM(a)),[p,m]=me.useState(f===Sh?r||0:null);if(me.useEffect(()=>{h(!1)},[]),f===Sh){const b=Ak(t);me.useEffect(()=>{if(p!=null){const S=Math.max(0,b-1);m(Math.min(p,S))}},[b])}const y=(b,S,w)=>{typeof o=="function"&&o(b,S,w)===!1||(s&&h(!0),f===Sh&&m(b))};let v=Object.assign({},n,a);return v.focus=c,v.onSelect=y,p!=null&&(v.selectedIndex=p),delete v.defaultFocus,delete v.defaultIndex,delete v.focusTabOnClick,dt.createElement(QM,v,t)};Lk.tabsRole="Tabs";const EM=["children","className"];function LM(n,e){if(n==null)return{};var t={};for(var i in n)if({}.hasOwnProperty.call(n,i)){if(e.includes(i))continue;t[i]=n[i]}return t}const DM={className:"react-tabs__tab-list"},Dk=n=>{const e=Object.assign({},DM,n),{children:t,className:i}=e,r=LM(e,EM);return dt.createElement("ul",Object.assign({},r,{className:Zf(i),role:"tablist"}),t)};Dk.tabsRole="TabList";const zM=["children","className","disabled","disabledClassName","focus","id","selected","selectedClassName","tabIndex","tabRef"];function ZM(n,e){if(n==null)return{};var t={};for(var i in n)if({}.hasOwnProperty.call(n,i)){if(e.includes(i))continue;t[i]=n[i]}return t}const ug="react-tabs__tab",IM={className:ug,disabledClassName:`${ug}--disabled`,focus:!1,id:null,selected:!1,selectedClassName:`${ug}--selected`},zk=n=>{let e=me.useRef();const t=Object.assign({},IM,n),{children:i,className:r,disabled:s,disabledClassName:o,focus:a,id:c,selected:h,selectedClassName:f,tabIndex:p,tabRef:m}=t,y=ZM(t,zM);return me.useEffect(()=>{h&&a&&e.current.focus()},[h,a]),dt.createElement("li",Object.assign({},y,{className:Zf(r,{[f]:h,[o]:s}),ref:v=>{e.current=v,m&&m(v)},role:"tab",id:`tab${c}`,"aria-selected":h?"true":"false","aria-disabled":s?"true":"false","aria-controls":`panel${c}`,tabIndex:p||(h?"0":null),"data-rttab":!0}),i)};zk.tabsRole="Tab";const jM=["children","className","forceRender","id","selected","selectedClassName"];function BM(n,e){if(n==null)return{};var t={};for(var i in n)if({}.hasOwnProperty.call(n,i)){if(e.includes(i))continue;t[i]=n[i]}return t}const Ov="react-tabs__tab-panel",NM={className:Ov,forceRender:!1,selectedClassName:`${Ov}--selected`},Zk=n=>{const e=Object.assign({},NM,n),{children:t,className:i,forceRender:r,id:s,selected:o,selectedClassName:a}=e,c=BM(e,jM);return dt.createElement("div",Object.assign({},c,{className:Zf(i,{[a]:o}),role:"tabpanel",id:`panel${s}`,"aria-labelledby":`tab${s}`}),r||o?t:null)};Zk.tabsRole="TabPanel";let xm=[],Ik=[];(()=>{let n="lc,34,7n,7,7b,19,,,,2,,2,,,20,b,1c,l,g,,2t,7,2,6,2,2,,4,z,,u,r,2j,b,1m,9,9,,o,4,,9,,3,,5,17,3,3b,f,,w,1j,,,,4,8,4,,3,7,a,2,t,,1m,,,,2,4,8,,9,,a,2,q,,2,2,1l,,4,2,4,2,2,3,3,,u,2,3,,b,2,1l,,4,5,,2,4,,k,2,m,6,,,1m,,,2,,4,8,,7,3,a,2,u,,1n,,,,c,,9,,14,,3,,1l,3,5,3,,4,7,2,b,2,t,,1m,,2,,2,,3,,5,2,7,2,b,2,s,2,1l,2,,,2,4,8,,9,,a,2,t,,20,,4,,2,3,,,8,,29,,2,7,c,8,2q,,2,9,b,6,22,2,r,,,,,,1j,e,,5,,2,5,b,,10,9,,2u,4,,6,,2,2,2,p,2,4,3,g,4,d,,2,2,6,,f,,jj,3,qa,3,t,3,t,2,u,2,1s,2,,7,8,,2,b,9,,19,3,3b,2,y,,3a,3,4,2,9,,6,3,63,2,2,,1m,,,7,,,,,2,8,6,a,2,,1c,h,1r,4,1c,7,,,5,,14,9,c,2,w,4,2,2,,3,1k,,,2,3,,,3,1m,8,2,2,48,3,,d,,7,4,,6,,3,2,5i,1m,,5,ek,,5f,x,2da,3,3x,,2o,w,fe,6,2x,2,n9w,4,,a,w,2,28,2,7k,,3,,4,,p,2,5,,47,2,q,i,d,,12,8,p,b,1a,3,1c,,2,4,2,2,13,,1v,6,2,2,2,2,c,,8,,1b,,1f,,,3,2,2,5,2,,,16,2,8,,6m,,2,,4,,fn4,,kh,g,g,g,a6,2,gt,,6a,,45,5,1ae,3,,2,5,4,14,3,4,,4l,2,fx,4,ar,2,49,b,4w,,1i,f,1k,3,1d,4,2,2,1x,3,10,5,,8,1q,,c,2,1g,9,a,4,2,,2n,3,2,,,2,6,,4g,,3,8,l,2,1l,2,,,,,m,,e,7,3,5,5f,8,2,3,,,n,,29,,2,6,,,2,,,2,,2,6j,,2,4,6,2,,2,r,2,2d,8,2,,,2,2y,,,,2,6,,,2t,3,2,4,,5,77,9,,2,6t,,a,2,,,4,,40,4,2,2,4,,w,a,14,6,2,4,8,,9,6,2,3,1a,d,,2,ba,7,,6,,,2a,m,2,7,,2,,2,3e,6,3,,,2,,7,,,20,2,3,,,,9n,2,f0b,5,1n,7,t4,,1r,4,29,,f5k,2,43q,,,3,4,5,8,8,2,7,u,4,44,3,1iz,1j,4,1e,8,,e,,m,5,,f,11s,7,,h,2,7,,2,,5,79,7,c5,4,15s,7,31,7,240,5,gx7k,2o,3k,6o".split(",").map(e=>e?parseInt(e,36):1);for(let e=0,t=0;e>1;if(n=Ik[i])e=i+1;else return!0;if(e==t)return!1}}function yv(n){return n>=127462&&n<=127487}const xv=8205;function WM(n,e,t=!0,i=!0){return(t?jk:VM)(n,e,i)}function jk(n,e,t){if(e==n.length)return e;e&&Bk(n.charCodeAt(e))&&Nk(n.charCodeAt(e-1))&&e--;let i=hg(n,e);for(e+=vv(i);e=0&&yv(hg(n,o));)s++,o-=2;if(s%2==0)break;e+=2}else break}return e}function VM(n,e,t){for(;e>0;){let i=jk(n,e-2,t);if(i=56320&&n<57344}function Nk(n){return n>=55296&&n<56320}function vv(n){return n<65536?1:2}class ze{lineAt(e){if(e<0||e>this.length)throw new RangeError(`Invalid position ${e} in document of length ${this.length}`);return this.lineInner(e,!1,1,0)}line(e){if(e<1||e>this.lines)throw new RangeError(`Invalid line number ${e} in ${this.lines}-line document`);return this.lineInner(e,!0,1,0)}replace(e,t,i){[e,t]=Wo(this,e,t);let r=[];return this.decompose(0,e,r,2),i.length&&i.decompose(0,i.length,r,3),this.decompose(t,this.length,r,1),Ti.from(r,this.length-(t-e)+i.length)}append(e){return this.replace(this.length,this.length,e)}slice(e,t=this.length){[e,t]=Wo(this,e,t);let i=[];return this.decompose(e,t,i,0),Ti.from(i,t-e)}eq(e){if(e==this)return!0;if(e.length!=this.length||e.lines!=this.lines)return!1;let t=this.scanIdentical(e,1),i=this.length-this.scanIdentical(e,-1),r=new ya(this),s=new ya(e);for(let o=t,a=t;;){if(r.next(o),s.next(o),o=0,r.lineBreak!=s.lineBreak||r.done!=s.done||r.value!=s.value)return!1;if(a+=r.value.length,r.done||a>=i)return!0}}iter(e=1){return new ya(this,e)}iterRange(e,t=this.length){return new Xk(this,e,t)}iterLines(e,t){let i;if(e==null)i=this.iter();else{t==null&&(t=this.lines+1);let r=this.line(e).from;i=this.iterRange(r,Math.max(r,t==this.lines+1?this.length:t<=1?0:this.line(t-1).to))}return new Wk(i)}toString(){return this.sliceString(0)}toJSON(){let e=[];return this.flatten(e),e}constructor(){}static of(e){if(e.length==0)throw new RangeError("A document must have at least one line");return e.length==1&&!e[0]?ze.empty:e.length<=32?new mt(e):Ti.from(mt.split(e,[]))}}class mt extends ze{constructor(e,t=FM(e)){super(),this.text=e,this.length=t}get lines(){return this.text.length}get children(){return null}lineInner(e,t,i,r){for(let s=0;;s++){let o=this.text[s],a=r+o.length;if((t?i:a)>=e)return new YM(r,a,i,o);r=a+1,i++}}decompose(e,t,i,r){let s=e<=0&&t>=this.length?this:new mt(bv(this.text,e,t),Math.min(t,this.length)-Math.max(0,e));if(r&1){let o=i.pop(),a=wh(s.text,o.text.slice(),0,s.length);if(a.length<=32)i.push(new mt(a,o.length+s.length));else{let c=a.length>>1;i.push(new mt(a.slice(0,c)),new mt(a.slice(c)))}}else i.push(s)}replace(e,t,i){if(!(i instanceof mt))return super.replace(e,t,i);[e,t]=Wo(this,e,t);let r=wh(this.text,wh(i.text,bv(this.text,0,e)),t),s=this.length+i.length-(t-e);return r.length<=32?new mt(r,s):Ti.from(mt.split(r,[]),s)}sliceString(e,t=this.length,i=` -`){[e,t]=Wo(this,e,t);let r="";for(let s=0,o=0;s<=t&&oe&&o&&(r+=i),es&&(r+=a.slice(Math.max(0,e-s),t-s)),s=c+1}return r}flatten(e){for(let t of this.text)e.push(t)}scanIdentical(){return 0}static split(e,t){let i=[],r=-1;for(let s of e)i.push(s),r+=s.length+1,i.length==32&&(t.push(new mt(i,r)),i=[],r=-1);return r>-1&&t.push(new mt(i,r)),t}}class Ti extends ze{constructor(e,t){super(),this.children=e,this.length=t,this.lines=0;for(let i of e)this.lines+=i.lines}lineInner(e,t,i,r){for(let s=0;;s++){let o=this.children[s],a=r+o.length,c=i+o.lines-1;if((t?c:a)>=e)return o.lineInner(e,t,i,r);r=a+1,i=c+1}}decompose(e,t,i,r){for(let s=0,o=0;o<=t&&s=o){let h=r&((o<=e?1:0)|(c>=t?2:0));o>=e&&c<=t&&!h?i.push(a):a.decompose(e-o,t-o,i,h)}o=c+1}}replace(e,t,i){if([e,t]=Wo(this,e,t),i.lines=s&&t<=a){let c=o.replace(e-s,t-s,i),h=this.lines-o.lines+c.lines;if(c.lines>4&&c.lines>h>>6){let f=this.children.slice();return f[r]=c,new Ti(f,this.length-(t-e)+i.length)}return super.replace(s,a,c)}s=a+1}return super.replace(e,t,i)}sliceString(e,t=this.length,i=` -`){[e,t]=Wo(this,e,t);let r="";for(let s=0,o=0;se&&s&&(r+=i),eo&&(r+=a.sliceString(e-o,t-o,i)),o=c+1}return r}flatten(e){for(let t of this.children)t.flatten(e)}scanIdentical(e,t){if(!(e instanceof Ti))return 0;let i=0,[r,s,o,a]=t>0?[0,0,this.children.length,e.children.length]:[this.children.length-1,e.children.length-1,-1,-1];for(;;r+=t,s+=t){if(r==o||s==a)return i;let c=this.children[r],h=e.children[s];if(c!=h)return i+c.scanIdentical(h,t);i+=c.length+1}}static from(e,t=e.reduce((i,r)=>i+r.length+1,-1)){let i=0;for(let y of e)i+=y.lines;if(i<32){let y=[];for(let v of e)v.flatten(y);return new mt(y,t)}let r=Math.max(32,i>>5),s=r<<1,o=r>>1,a=[],c=0,h=-1,f=[];function p(y){let v;if(y.lines>s&&y instanceof Ti)for(let b of y.children)p(b);else y.lines>o&&(c>o||!c)?(m(),a.push(y)):y instanceof mt&&c&&(v=f[f.length-1])instanceof mt&&y.lines+v.lines<=32?(c+=y.lines,h+=y.length+1,f[f.length-1]=new mt(v.text.concat(y.text),v.length+1+y.length)):(c+y.lines>r&&m(),c+=y.lines,h+=y.length+1,f.push(y))}function m(){c!=0&&(a.push(f.length==1?f[0]:Ti.from(f,h)),h=-1,c=f.length=0)}for(let y of e)p(y);return m(),a.length==1?a[0]:new Ti(a,t)}}ze.empty=new mt([""],0);function FM(n){let e=-1;for(let t of n)e+=t.length+1;return e}function wh(n,e,t=0,i=1e9){for(let r=0,s=0,o=!0;s=t&&(c>i&&(a=a.slice(0,i-r)),r0?1:(e instanceof mt?e.text.length:e.children.length)<<1]}nextInner(e,t){for(this.done=this.lineBreak=!1;;){let i=this.nodes.length-1,r=this.nodes[i],s=this.offsets[i],o=s>>1,a=r instanceof mt?r.text.length:r.children.length;if(o==(t>0?a:0)){if(i==0)return this.done=!0,this.value="",this;t>0&&this.offsets[i-1]++,this.nodes.pop(),this.offsets.pop()}else if((s&1)==(t>0?0:1)){if(this.offsets[i]+=t,e==0)return this.lineBreak=!0,this.value=` -`,this;e--}else if(r instanceof mt){let c=r.text[o+(t<0?-1:0)];if(this.offsets[i]+=t,c.length>Math.max(0,e))return this.value=e==0?c:t>0?c.slice(e):c.slice(0,c.length-e),this;e-=c.length}else{let c=r.children[o+(t<0?-1:0)];e>c.length?(e-=c.length,this.offsets[i]+=t):(t<0&&this.offsets[i]--,this.nodes.push(c),this.offsets.push(t>0?1:(c instanceof mt?c.text.length:c.children.length)<<1))}}}next(e=0){return e<0&&(this.nextInner(-e,-this.dir),e=this.value.length),this.nextInner(e,this.dir)}}class Xk{constructor(e,t,i){this.value="",this.done=!1,this.cursor=new ya(e,t>i?-1:1),this.pos=t>i?e.length:0,this.from=Math.min(t,i),this.to=Math.max(t,i)}nextInner(e,t){if(t<0?this.pos<=this.from:this.pos>=this.to)return this.value="",this.done=!0,this;e+=Math.max(0,t<0?this.pos-this.to:this.from-this.pos);let i=t<0?this.pos-this.from:this.to-this.pos;e>i&&(e=i),i-=e;let{value:r}=this.cursor.next(e);return this.pos+=(r.length+e)*t,this.value=r.length<=i?r:t<0?r.slice(r.length-i):r.slice(0,i),this.done=!this.value,this}next(e=0){return e<0?e=Math.max(e,this.from-this.pos):e>0&&(e=Math.min(e,this.to-this.pos)),this.nextInner(e,this.cursor.dir)}get lineBreak(){return this.cursor.lineBreak&&this.value!=""}}class Wk{constructor(e){this.inner=e,this.afterBreak=!0,this.value="",this.done=!1}next(e=0){let{done:t,lineBreak:i,value:r}=this.inner.next(e);return t&&this.afterBreak?(this.value="",this.afterBreak=!1):t?(this.done=!0,this.value=""):i?this.afterBreak?this.value="":(this.afterBreak=!0,this.next()):(this.value=r,this.afterBreak=!1),this}get lineBreak(){return!1}}typeof Symbol<"u"&&(ze.prototype[Symbol.iterator]=function(){return this.iter()},ya.prototype[Symbol.iterator]=Xk.prototype[Symbol.iterator]=Wk.prototype[Symbol.iterator]=function(){return this});let YM=class{constructor(e,t,i,r){this.from=e,this.to=t,this.number=i,this.text=r}get length(){return this.to-this.from}};function Wo(n,e,t){return e=Math.max(0,Math.min(n.length,e)),[e,Math.max(e,Math.min(n.length,t))]}function Wt(n,e,t=!0,i=!0){return WM(n,e,t,i)}function qM(n){return n>=56320&&n<57344}function UM(n){return n>=55296&&n<56320}function yn(n,e){let t=n.charCodeAt(e);if(!UM(t)||e+1==n.length)return t;let i=n.charCodeAt(e+1);return qM(i)?(t-55296<<10)+(i-56320)+65536:t}function FO(n){return n<=65535?String.fromCharCode(n):(n-=65536,String.fromCharCode((n>>10)+55296,(n&1023)+56320))}function $i(n){return n<65536?1:2}const vm=/\r\n?|\n/;var Xt=(function(n){return n[n.Simple=0]="Simple",n[n.TrackDel=1]="TrackDel",n[n.TrackBefore=2]="TrackBefore",n[n.TrackAfter=3]="TrackAfter",n})(Xt||(Xt={}));class Li{constructor(e){this.sections=e}get length(){let e=0;for(let t=0;te)return s+(e-r);s+=a}else{if(i!=Xt.Simple&&h>=e&&(i==Xt.TrackDel&&re||i==Xt.TrackBefore&&re))return null;if(h>e||h==e&&t<0&&!a)return e==r||t<0?s:s+c;s+=c}r=h}if(e>r)throw new RangeError(`Position ${e} is out of range for changeset of length ${r}`);return s}touchesRange(e,t=e){for(let i=0,r=0;i=0&&r<=t&&a>=e)return rt?"cover":!0;r=a}return!1}toString(){let e="";for(let t=0;t=0?":"+r:"")}return e}toJSON(){return this.sections}static fromJSON(e){if(!Array.isArray(e)||e.length%2||e.some(t=>typeof t!="number"))throw new RangeError("Invalid JSON representation of ChangeDesc");return new Li(e)}static create(e){return new Li(e)}}class Ct extends Li{constructor(e,t){super(e),this.inserted=t}apply(e){if(this.length!=e.length)throw new RangeError("Applying change set to a document with the wrong length");return bm(this,(t,i,r,s,o)=>e=e.replace(r,r+(i-t),o),!1),e}mapDesc(e,t=!1){return Sm(this,e,t,!0)}invert(e){let t=this.sections.slice(),i=[];for(let r=0,s=0;r=0){t[r]=a,t[r+1]=o;let c=r>>1;for(;i.length0&&Lr(i,t,s.text),s.forward(f),a+=f}let h=e[o++];for(;a>1].toJSON()))}return e}static of(e,t,i){let r=[],s=[],o=0,a=null;function c(f=!1){if(!f&&!r.length)return;om||p<0||m>t)throw new RangeError(`Invalid change range ${p} to ${m} (in doc of length ${t})`);let v=y?typeof y=="string"?ze.of(y.split(i||vm)):y:ze.empty,b=v.length;if(p==m&&b==0)return;po&&Kt(r,p-o,-1),Kt(r,m-p,b),Lr(s,r,v),o=m}}return h(e),c(!a),a}static empty(e){return new Ct(e?[e,-1]:[],[])}static fromJSON(e){if(!Array.isArray(e))throw new RangeError("Invalid JSON representation of ChangeSet");let t=[],i=[];for(let r=0;ra&&typeof o!="string"))throw new RangeError("Invalid JSON representation of ChangeSet");if(s.length==1)t.push(s[0],0);else{for(;i.length=0&&t<=0&&t==n[r+1]?n[r]+=e:r>=0&&e==0&&n[r]==0?n[r+1]+=t:i?(n[r]+=e,n[r+1]+=t):n.push(e,t)}function Lr(n,e,t){if(t.length==0)return;let i=e.length-2>>1;if(i>1])),!(t||o==n.sections.length||n.sections[o+1]<0);)a=n.sections[o++],c=n.sections[o++];e(r,h,s,f,p),r=h,s=f}}}function Sm(n,e,t,i=!1){let r=[],s=i?[]:null,o=new Ea(n),a=new Ea(e);for(let c=-1;;){if(o.done&&a.len||a.done&&o.len)throw new Error("Mismatched change set lengths");if(o.ins==-1&&a.ins==-1){let h=Math.min(o.len,a.len);Kt(r,h,-1),o.forward(h),a.forward(h)}else if(a.ins>=0&&(o.ins<0||c==o.i||o.off==0&&(a.len=0&&c=0){let h=0,f=o.len;for(;f;)if(a.ins==-1){let p=Math.min(f,a.len);h+=p,f-=p,a.forward(p)}else if(a.ins==0&&a.lenc||o.ins>=0&&o.len>c)&&(a||i.length>h),s.forward2(c),o.forward(c)}}}}class Ea{constructor(e){this.set=e,this.i=0,this.next()}next(){let{sections:e}=this.set;this.i>1;return t>=e.length?ze.empty:e[t]}textBit(e){let{inserted:t}=this.set,i=this.i-2>>1;return i>=t.length&&!e?ze.empty:t[i].slice(this.off,e==null?void 0:this.off+e)}forward(e){e==this.len?this.next():(this.len-=e,this.off+=e)}forward2(e){this.ins==-1?this.forward(e):e==this.ins?this.next():(this.ins-=e,this.off+=e)}}class Cs{constructor(e,t,i){this.from=e,this.to=t,this.flags=i}get anchor(){return this.flags&32?this.to:this.from}get head(){return this.flags&32?this.from:this.to}get empty(){return this.from==this.to}get assoc(){return this.flags&8?-1:this.flags&16?1:0}get bidiLevel(){let e=this.flags&7;return e==7?null:e}get goalColumn(){let e=this.flags>>6;return e==16777215?void 0:e}map(e,t=-1){let i,r;return this.empty?i=r=e.mapPos(this.from,t):(i=e.mapPos(this.from,1),r=e.mapPos(this.to,-1)),i==this.from&&r==this.to?this:new Cs(i,r,this.flags)}extend(e,t=e){if(e<=this.anchor&&t>=this.anchor)return V.range(e,t);let i=Math.abs(e-this.anchor)>Math.abs(t-this.anchor)?e:t;return V.range(this.anchor,i)}eq(e,t=!1){return this.anchor==e.anchor&&this.head==e.head&&(!t||!this.empty||this.assoc==e.assoc)}toJSON(){return{anchor:this.anchor,head:this.head}}static fromJSON(e){if(!e||typeof e.anchor!="number"||typeof e.head!="number")throw new RangeError("Invalid JSON representation for SelectionRange");return V.range(e.anchor,e.head)}static create(e,t,i){return new Cs(e,t,i)}}class V{constructor(e,t){this.ranges=e,this.mainIndex=t}map(e,t=-1){return e.empty?this:V.create(this.ranges.map(i=>i.map(e,t)),this.mainIndex)}eq(e,t=!1){if(this.ranges.length!=e.ranges.length||this.mainIndex!=e.mainIndex)return!1;for(let i=0;ie.toJSON()),main:this.mainIndex}}static fromJSON(e){if(!e||!Array.isArray(e.ranges)||typeof e.main!="number"||e.main>=e.ranges.length)throw new RangeError("Invalid JSON representation for EditorSelection");return new V(e.ranges.map(t=>Cs.fromJSON(t)),e.main)}static single(e,t=e){return new V([V.range(e,t)],0)}static create(e,t=0){if(e.length==0)throw new RangeError("A selection needs at least one range");for(let i=0,r=0;re?8:0)|s)}static normalized(e,t=0){let i=e[t];e.sort((r,s)=>r.from-s.from),t=e.indexOf(i);for(let r=1;rs.head?V.range(c,a):V.range(a,c))}}return new V(e,t)}}function Fk(n,e){for(let t of n.ranges)if(t.to>e)throw new RangeError("Selection points outside of document")}let YO=0;class fe{constructor(e,t,i,r,s){this.combine=e,this.compareInput=t,this.compare=i,this.isStatic=r,this.id=YO++,this.default=e([]),this.extensions=typeof s=="function"?s(this):s}get reader(){return this}static define(e={}){return new fe(e.combine||(t=>t),e.compareInput||((t,i)=>t===i),e.compare||(e.combine?(t,i)=>t===i:qO),!!e.static,e.enables)}of(e){return new kh([],this,0,e)}compute(e,t){if(this.isStatic)throw new Error("Can't compute a static facet");return new kh(e,this,1,t)}computeN(e,t){if(this.isStatic)throw new Error("Can't compute a static facet");return new kh(e,this,2,t)}from(e,t){return t||(t=i=>i),this.compute([e],i=>t(i.field(e)))}}function qO(n,e){return n==e||n.length==e.length&&n.every((t,i)=>t===e[i])}class kh{constructor(e,t,i,r){this.dependencies=e,this.facet=t,this.type=i,this.value=r,this.id=YO++}dynamicSlot(e){var t;let i=this.value,r=this.facet.compareInput,s=this.id,o=e[s]>>1,a=this.type==2,c=!1,h=!1,f=[];for(let p of this.dependencies)p=="doc"?c=!0:p=="selection"?h=!0:(((t=e[p.id])!==null&&t!==void 0?t:1)&1)==0&&f.push(e[p.id]);return{create(p){return p.values[o]=i(p),1},update(p,m){if(c&&m.docChanged||h&&(m.docChanged||m.selection)||wm(p,f)){let y=i(p);if(a?!Sv(y,p.values[o],r):!r(y,p.values[o]))return p.values[o]=y,1}return 0},reconfigure:(p,m)=>{let y,v=m.config.address[s];if(v!=null){let b=Uh(m,v);if(this.dependencies.every(S=>S instanceof fe?m.facet(S)===p.facet(S):S instanceof zt?m.field(S,!1)==p.field(S,!1):!0)||(a?Sv(y=i(p),b,r):r(y=i(p),b)))return p.values[o]=b,0}else y=i(p);return p.values[o]=y,1}}}}function Sv(n,e,t){if(n.length!=e.length)return!1;for(let i=0;in[c.id]),r=t.map(c=>c.type),s=i.filter(c=>!(c&1)),o=n[e.id]>>1;function a(c){let h=[];for(let f=0;fi===r),e);return e.provide&&(t.provides=e.provide(t)),t}create(e){let t=e.facet(Au).find(i=>i.field==this);return((t==null?void 0:t.create)||this.createF)(e)}slot(e){let t=e[this.id]>>1;return{create:i=>(i.values[t]=this.create(i),1),update:(i,r)=>{let s=i.values[t],o=this.updateF(s,r);return this.compareF(s,o)?0:(i.values[t]=o,1)},reconfigure:(i,r)=>{let s=i.facet(Au),o=r.facet(Au),a;return(a=s.find(c=>c.field==this))&&a!=o.find(c=>c.field==this)?(i.values[t]=a.create(i),1):r.config.address[this.id]!=null?(i.values[t]=r.field(this),0):(i.values[t]=this.create(i),1)}}}init(e){return[this,Au.of({field:this,create:e})]}get extension(){return this}}const Ps={lowest:4,low:3,default:2,high:1,highest:0};function ql(n){return e=>new Yk(e,n)}const Jr={highest:ql(Ps.highest),high:ql(Ps.high),default:ql(Ps.default),low:ql(Ps.low),lowest:ql(Ps.lowest)};class Yk{constructor(e,t){this.inner=e,this.prec=t}}class If{of(e){return new km(this,e)}reconfigure(e){return If.reconfigure.of({compartment:this,extension:e})}get(e){return e.config.compartments.get(this)}}class km{constructor(e,t){this.compartment=e,this.inner=t}}class qh{constructor(e,t,i,r,s,o){for(this.base=e,this.compartments=t,this.dynamicSlots=i,this.address=r,this.staticValues=s,this.facets=o,this.statusTemplate=[];this.statusTemplate.length>1]}static resolve(e,t,i){let r=[],s=Object.create(null),o=new Map;for(let m of GM(e,t,o))m instanceof zt?r.push(m):(s[m.facet.id]||(s[m.facet.id]=[])).push(m);let a=Object.create(null),c=[],h=[];for(let m of r)a[m.id]=h.length<<1,h.push(y=>m.slot(y));let f=i==null?void 0:i.config.facets;for(let m in s){let y=s[m],v=y[0].facet,b=f&&f[m]||[];if(y.every(S=>S.type==0))if(a[v.id]=c.length<<1|1,qO(b,y))c.push(i.facet(v));else{let S=v.combine(y.map(w=>w.value));c.push(i&&v.compare(S,i.facet(v))?i.facet(v):S)}else{for(let S of y)S.type==0?(a[S.id]=c.length<<1|1,c.push(S.value)):(a[S.id]=h.length<<1,h.push(w=>S.dynamicSlot(w)));a[v.id]=h.length<<1,h.push(S=>HM(S,v,y))}}let p=h.map(m=>m(a));return new qh(e,o,p,a,c,s)}}function GM(n,e,t){let i=[[],[],[],[],[]],r=new Map;function s(o,a){let c=r.get(o);if(c!=null){if(c<=a)return;let h=i[c].indexOf(o);h>-1&&i[c].splice(h,1),o instanceof km&&t.delete(o.compartment)}if(r.set(o,a),Array.isArray(o))for(let h of o)s(h,a);else if(o instanceof km){if(t.has(o.compartment))throw new RangeError("Duplicate use of compartment in extensions");let h=e.get(o.compartment)||o.inner;t.set(o.compartment,h),s(h,a)}else if(o instanceof Yk)s(o.inner,o.prec);else if(o instanceof zt)i[a].push(o),o.provides&&s(o.provides,a);else if(o instanceof kh)i[a].push(o),o.facet.extensions&&s(o.facet.extensions,Ps.default);else{let h=o.extension;if(!h)throw new Error(`Unrecognized extension value in extension set (${o}). This sometimes happens because multiple instances of @codemirror/state are loaded, breaking instanceof checks.`);s(h,a)}}return s(n,Ps.default),i.reduce((o,a)=>o.concat(a))}function xa(n,e){if(e&1)return 2;let t=e>>1,i=n.status[t];if(i==4)throw new Error("Cyclic dependency between fields and/or facets");if(i&2)return i;n.status[t]=4;let r=n.computeSlot(n,n.config.dynamicSlots[t]);return n.status[t]=2|r}function Uh(n,e){return e&1?n.config.staticValues[e>>1]:n.values[e>>1]}const qk=fe.define(),Pm=fe.define({combine:n=>n.some(e=>e),static:!0}),Uk=fe.define({combine:n=>n.length?n[0]:void 0,static:!0}),Hk=fe.define(),Gk=fe.define(),Kk=fe.define(),Jk=fe.define({combine:n=>n.length?n[0]:!1});class ar{constructor(e,t){this.type=e,this.value=t}static define(){return new KM}}class KM{of(e){return new ar(this,e)}}class JM{constructor(e){this.map=e}of(e){return new Te(this,e)}}class Te{constructor(e,t){this.type=e,this.value=t}map(e){let t=this.type.map(this.value,e);return t===void 0?void 0:t==this.value?this:new Te(this.type,t)}is(e){return this.type==e}static define(e={}){return new JM(e.map||(t=>t))}static mapEffects(e,t){if(!e.length)return e;let i=[];for(let r of e){let s=r.map(t);s&&i.push(s)}return i}}Te.reconfigure=Te.define();Te.appendConfig=Te.define();class St{constructor(e,t,i,r,s,o){this.startState=e,this.changes=t,this.selection=i,this.effects=r,this.annotations=s,this.scrollIntoView=o,this._doc=null,this._state=null,i&&Fk(i,t.newLength),s.some(a=>a.type==St.time)||(this.annotations=s.concat(St.time.of(Date.now())))}static create(e,t,i,r,s,o){return new St(e,t,i,r,s,o)}get newDoc(){return this._doc||(this._doc=this.changes.apply(this.startState.doc))}get newSelection(){return this.selection||this.startState.selection.map(this.changes)}get state(){return this._state||this.startState.applyTransaction(this),this._state}annotation(e){for(let t of this.annotations)if(t.type==e)return t.value}get docChanged(){return!this.changes.empty}get reconfigured(){return this.startState.config!=this.state.config}isUserEvent(e){let t=this.annotation(St.userEvent);return!!(t&&(t==e||t.length>e.length&&t.slice(0,e.length)==e&&t[e.length]=="."))}}St.time=ar.define();St.userEvent=ar.define();St.addToHistory=ar.define();St.remote=ar.define();function eR(n,e){let t=[];for(let i=0,r=0;;){let s,o;if(i=n[i]))s=n[i++],o=n[i++];else if(r=0;r--){let s=i[r](n);s instanceof St?n=s:Array.isArray(s)&&s.length==1&&s[0]instanceof St?n=s[0]:n=tP(e,Mo(s),!1)}return n}function nR(n){let e=n.startState,t=e.facet(Kk),i=n;for(let r=t.length-1;r>=0;r--){let s=t[r](n);s&&Object.keys(s).length&&(i=eP(i,_m(e,s,n.changes.newLength),!0))}return i==n?n:St.create(e,n.changes,n.selection,i.effects,i.annotations,i.scrollIntoView)}const iR=[];function Mo(n){return n==null?iR:Array.isArray(n)?n:[n]}var at=(function(n){return n[n.Word=0]="Word",n[n.Space=1]="Space",n[n.Other=2]="Other",n})(at||(at={}));const rR=/[\u00df\u0587\u0590-\u05f4\u0600-\u06ff\u3040-\u309f\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcc\uac00-\ud7af]/;let Qm;try{Qm=new RegExp("[\\p{Alphabetic}\\p{Number}_]","u")}catch{}function sR(n){if(Qm)return Qm.test(n);for(let e=0;e"€"&&(t.toUpperCase()!=t.toLowerCase()||rR.test(t)))return!0}return!1}function oR(n){return e=>{if(!/\S/.test(e))return at.Space;if(sR(e))return at.Word;for(let t=0;t-1)return at.Word;return at.Other}}class Ie{constructor(e,t,i,r,s,o){this.config=e,this.doc=t,this.selection=i,this.values=r,this.status=e.statusTemplate.slice(),this.computeSlot=s,o&&(o._state=this);for(let a=0;ar.set(h,c)),t=null),r.set(a.value.compartment,a.value.extension)):a.is(Te.reconfigure)?(t=null,i=a.value):a.is(Te.appendConfig)&&(t=null,i=Mo(i).concat(a.value));let s;t?s=e.startState.values.slice():(t=qh.resolve(i,r,this),s=new Ie(t,this.doc,this.selection,t.dynamicSlots.map(()=>null),(c,h)=>h.reconfigure(c,this),null).values);let o=e.startState.facet(Pm)?e.newSelection:e.newSelection.asSingle();new Ie(t,e.newDoc,o,s,(a,c)=>c.update(a,e),e)}replaceSelection(e){return typeof e=="string"&&(e=this.toText(e)),this.changeByRange(t=>({changes:{from:t.from,to:t.to,insert:e},range:V.cursor(t.from+e.length)}))}changeByRange(e){let t=this.selection,i=e(t.ranges[0]),r=this.changes(i.changes),s=[i.range],o=Mo(i.effects);for(let a=1;ao.spec.fromJSON(a,c)))}}return Ie.create({doc:e.doc,selection:V.fromJSON(e.selection),extensions:t.extensions?r.concat([t.extensions]):r})}static create(e={}){let t=qh.resolve(e.extensions||[],new Map),i=e.doc instanceof ze?e.doc:ze.of((e.doc||"").split(t.staticFacet(Ie.lineSeparator)||vm)),r=e.selection?e.selection instanceof V?e.selection:V.single(e.selection.anchor,e.selection.head):V.single(0);return Fk(r,i.length),t.staticFacet(Pm)||(r=r.asSingle()),new Ie(t,i,r,t.dynamicSlots.map(()=>null),(s,o)=>o.create(s),null)}get tabSize(){return this.facet(Ie.tabSize)}get lineBreak(){return this.facet(Ie.lineSeparator)||` -`}get readOnly(){return this.facet(Jk)}phrase(e,...t){for(let i of this.facet(Ie.phrases))if(Object.prototype.hasOwnProperty.call(i,e)){e=i[e];break}return t.length&&(e=e.replace(/\$(\$|\d*)/g,(i,r)=>{if(r=="$")return"$";let s=+(r||1);return!s||s>t.length?i:t[s-1]})),e}languageDataAt(e,t,i=-1){let r=[];for(let s of this.facet(qk))for(let o of s(this,t,i))Object.prototype.hasOwnProperty.call(o,e)&&r.push(o[e]);return r}charCategorizer(e){return oR(this.languageDataAt("wordChars",e).join(""))}wordAt(e){let{text:t,from:i,length:r}=this.doc.lineAt(e),s=this.charCategorizer(e),o=e-i,a=e-i;for(;o>0;){let c=Wt(t,o,!1);if(s(t.slice(c,o))!=at.Word)break;o=c}for(;an.length?n[0]:4});Ie.lineSeparator=Uk;Ie.readOnly=Jk;Ie.phrases=fe.define({compare(n,e){let t=Object.keys(n),i=Object.keys(e);return t.length==i.length&&t.every(r=>n[r]==e[r])}});Ie.languageData=qk;Ie.changeFilter=Hk;Ie.transactionFilter=Gk;Ie.transactionExtender=Kk;If.reconfigure=Te.define();function Zi(n,e,t={}){let i={};for(let r of n)for(let s of Object.keys(r)){let o=r[s],a=i[s];if(a===void 0)i[s]=o;else if(!(a===o||o===void 0))if(Object.hasOwnProperty.call(t,s))i[s]=t[s](a,o);else throw new Error("Config merge conflict for field "+s)}for(let r in e)i[r]===void 0&&(i[r]=e[r]);return i}class Zs{eq(e){return this==e}range(e,t=e){return Cm.create(e,t,this)}}Zs.prototype.startSide=Zs.prototype.endSide=0;Zs.prototype.point=!1;Zs.prototype.mapMode=Xt.TrackDel;let Cm=class nP{constructor(e,t,i){this.from=e,this.to=t,this.value=i}static create(e,t,i){return new nP(e,t,i)}};function Tm(n,e){return n.from-e.from||n.value.startSide-e.value.startSide}class UO{constructor(e,t,i,r){this.from=e,this.to=t,this.value=i,this.maxPoint=r}get length(){return this.to[this.to.length-1]}findIndex(e,t,i,r=0){let s=i?this.to:this.from;for(let o=r,a=s.length;;){if(o==a)return o;let c=o+a>>1,h=s[c]-e||(i?this.value[c].endSide:this.value[c].startSide)-t;if(c==o)return h>=0?o:a;h>=0?a=c:o=c+1}}between(e,t,i,r){for(let s=this.findIndex(t,-1e9,!0),o=this.findIndex(i,1e9,!1,s);sy||m==y&&h.startSide>0&&h.endSide<=0)continue;(y-m||h.endSide-h.startSide)<0||(o<0&&(o=m),h.point&&(a=Math.max(a,y-m)),i.push(h),r.push(m-o),s.push(y-o))}return{mapped:i.length?new UO(r,s,i,a):null,pos:o}}}class je{constructor(e,t,i,r){this.chunkPos=e,this.chunk=t,this.nextLayer=i,this.maxPoint=r}static create(e,t,i,r){return new je(e,t,i,r)}get length(){let e=this.chunk.length-1;return e<0?0:Math.max(this.chunkEnd(e),this.nextLayer.length)}get size(){if(this.isEmpty)return 0;let e=this.nextLayer.size;for(let t of this.chunk)e+=t.value.length;return e}chunkEnd(e){return this.chunkPos[e]+this.chunk[e].length}update(e){let{add:t=[],sort:i=!1,filterFrom:r=0,filterTo:s=this.length}=e,o=e.filter;if(t.length==0&&!o)return this;if(i&&(t=t.slice().sort(Tm)),this.isEmpty)return t.length?je.of(t):this;let a=new iP(this,null,-1).goto(0),c=0,h=[],f=new sr;for(;a.value||c=0){let p=t[c++];f.addInner(p.from,p.to,p.value)||h.push(p)}else a.rangeIndex==1&&a.chunkIndexthis.chunkEnd(a.chunkIndex)||sa.to||s=s&&e<=s+o.length&&o.between(s,e-s,t-s,i)===!1)return}this.nextLayer.between(e,t,i)}}iter(e=0){return La.from([this]).goto(e)}get isEmpty(){return this.nextLayer==this}static iter(e,t=0){return La.from(e).goto(t)}static compare(e,t,i,r,s=-1){let o=e.filter(p=>p.maxPoint>0||!p.isEmpty&&p.maxPoint>=s),a=t.filter(p=>p.maxPoint>0||!p.isEmpty&&p.maxPoint>=s),c=wv(o,a,i),h=new Ul(o,c,s),f=new Ul(a,c,s);i.iterGaps((p,m,y)=>kv(h,p,f,m,y,r)),i.empty&&i.length==0&&kv(h,0,f,0,0,r)}static eq(e,t,i=0,r){r==null&&(r=999999999);let s=e.filter(f=>!f.isEmpty&&t.indexOf(f)<0),o=t.filter(f=>!f.isEmpty&&e.indexOf(f)<0);if(s.length!=o.length)return!1;if(!s.length)return!0;let a=wv(s,o),c=new Ul(s,a,0).goto(i),h=new Ul(o,a,0).goto(i);for(;;){if(c.to!=h.to||!$m(c.active,h.active)||c.point&&(!h.point||!c.point.eq(h.point)))return!1;if(c.to>r)return!0;c.next(),h.next()}}static spans(e,t,i,r,s=-1){let o=new Ul(e,null,s).goto(t),a=t,c=o.openStart;for(;;){let h=Math.min(o.to,i);if(o.point){let f=o.activeForPoint(o.to),p=o.pointFroma&&(r.span(a,h,o.active,c),c=o.openEnd(h));if(o.to>i)return c+(o.point&&o.to>i?1:0);a=o.to,o.next()}}static of(e,t=!1){let i=new sr;for(let r of e instanceof Cm?[e]:t?lR(e):e)i.add(r.from,r.to,r.value);return i.finish()}static join(e){if(!e.length)return je.empty;let t=e[e.length-1];for(let i=e.length-2;i>=0;i--)for(let r=e[i];r!=je.empty;r=r.nextLayer)t=new je(r.chunkPos,r.chunk,t,Math.max(r.maxPoint,t.maxPoint));return t}}je.empty=new je([],[],null,-1);function lR(n){if(n.length>1)for(let e=n[0],t=1;t0)return n.slice().sort(Tm);e=i}return n}je.empty.nextLayer=je.empty;class sr{finishChunk(e){this.chunks.push(new UO(this.from,this.to,this.value,this.maxPoint)),this.chunkPos.push(this.chunkStart),this.chunkStart=-1,this.setMaxPoint=Math.max(this.setMaxPoint,this.maxPoint),this.maxPoint=-1,e&&(this.from=[],this.to=[],this.value=[])}constructor(){this.chunks=[],this.chunkPos=[],this.chunkStart=-1,this.last=null,this.lastFrom=-1e9,this.lastTo=-1e9,this.from=[],this.to=[],this.value=[],this.maxPoint=-1,this.setMaxPoint=-1,this.nextLayer=null}add(e,t,i){this.addInner(e,t,i)||(this.nextLayer||(this.nextLayer=new sr)).add(e,t,i)}addInner(e,t,i){let r=e-this.lastTo||i.startSide-this.last.endSide;if(r<=0&&(e-this.lastFrom||i.startSide-this.last.startSide)<0)throw new Error("Ranges must be added sorted by `from` position and `startSide`");return r<0?!1:(this.from.length==250&&this.finishChunk(!0),this.chunkStart<0&&(this.chunkStart=e),this.from.push(e-this.chunkStart),this.to.push(t-this.chunkStart),this.last=i,this.lastFrom=e,this.lastTo=t,this.value.push(i),i.point&&(this.maxPoint=Math.max(this.maxPoint,t-e)),!0)}addChunk(e,t){if((e-this.lastTo||t.value[0].startSide-this.last.endSide)<0)return!1;this.from.length&&this.finishChunk(!0),this.setMaxPoint=Math.max(this.setMaxPoint,t.maxPoint),this.chunks.push(t),this.chunkPos.push(e);let i=t.value.length-1;return this.last=t.value[i],this.lastFrom=t.from[i]+e,this.lastTo=t.to[i]+e,!0}finish(){return this.finishInner(je.empty)}finishInner(e){if(this.from.length&&this.finishChunk(!1),this.chunks.length==0)return e;let t=je.create(this.chunkPos,this.chunks,this.nextLayer?this.nextLayer.finishInner(e):e,this.setMaxPoint);return this.from=null,t}}function wv(n,e,t){let i=new Map;for(let s of n)for(let o=0;o=this.minPoint)break}}setRangeIndex(e){if(e==this.layer.chunk[this.chunkIndex].value.length){if(this.chunkIndex++,this.skip)for(;this.chunkIndex=i&&r.push(new iP(o,t,i,s));return r.length==1?r[0]:new La(r)}get startSide(){return this.value?this.value.startSide:0}goto(e,t=-1e9){for(let i of this.heap)i.goto(e,t);for(let i=this.heap.length>>1;i>=0;i--)fg(this.heap,i);return this.next(),this}forward(e,t){for(let i of this.heap)i.forward(e,t);for(let i=this.heap.length>>1;i>=0;i--)fg(this.heap,i);(this.to-e||this.value.endSide-t)<0&&this.next()}next(){if(this.heap.length==0)this.from=this.to=1e9,this.value=null,this.rank=-1;else{let e=this.heap[0];this.from=e.from,this.to=e.to,this.value=e.value,this.rank=e.rank,e.value&&e.next(),fg(this.heap,0)}}}function fg(n,e){for(let t=n[e];;){let i=(e<<1)+1;if(i>=n.length)break;let r=n[i];if(i+1=0&&(r=n[i+1],i++),t.compare(r)<0)break;n[i]=t,n[e]=r,e=i}}class Ul{constructor(e,t,i){this.minPoint=i,this.active=[],this.activeTo=[],this.activeRank=[],this.minActive=-1,this.point=null,this.pointFrom=0,this.pointRank=0,this.to=-1e9,this.endSide=0,this.openStart=-1,this.cursor=La.from(e,t,i)}goto(e,t=-1e9){return this.cursor.goto(e,t),this.active.length=this.activeTo.length=this.activeRank.length=0,this.minActive=-1,this.to=e,this.endSide=t,this.openStart=-1,this.next(),this}forward(e,t){for(;this.minActive>-1&&(this.activeTo[this.minActive]-e||this.active[this.minActive].endSide-t)<0;)this.removeActive(this.minActive);this.cursor.forward(e,t)}removeActive(e){Eu(this.active,e),Eu(this.activeTo,e),Eu(this.activeRank,e),this.minActive=Pv(this.active,this.activeTo)}addActive(e){let t=0,{value:i,to:r,rank:s}=this.cursor;for(;t0;)t++;Lu(this.active,t,i),Lu(this.activeTo,t,r),Lu(this.activeRank,t,s),e&&Lu(e,t,this.cursor.from),this.minActive=Pv(this.active,this.activeTo)}next(){let e=this.to,t=this.point;this.point=null;let i=this.openStart<0?[]:null;for(;;){let r=this.minActive;if(r>-1&&(this.activeTo[r]-this.cursor.from||this.active[r].endSide-this.cursor.startSide)<0){if(this.activeTo[r]>e){this.to=this.activeTo[r],this.endSide=this.active[r].endSide;break}this.removeActive(r),i&&Eu(i,r)}else if(this.cursor.value)if(this.cursor.from>e){this.to=this.cursor.from,this.endSide=this.cursor.startSide;break}else{let s=this.cursor.value;if(!s.point)this.addActive(i),this.cursor.next();else if(t&&this.cursor.to==this.to&&this.cursor.from=0&&i[r]=0&&!(this.activeRank[i]e||this.activeTo[i]==e&&this.active[i].endSide>=this.point.endSide)&&t.push(this.active[i]);return t.reverse()}openEnd(e){let t=0;for(let i=this.activeTo.length-1;i>=0&&this.activeTo[i]>e;i--)t++;return t}}function kv(n,e,t,i,r,s){n.goto(e),t.goto(i);let o=i+r,a=i,c=i-e;for(;;){let h=n.to+c-t.to,f=h||n.endSide-t.endSide,p=f<0?n.to+c:t.to,m=Math.min(p,o);if(n.point||t.point?n.point&&t.point&&(n.point==t.point||n.point.eq(t.point))&&$m(n.activeForPoint(n.to),t.activeForPoint(t.to))||s.comparePoint(a,m,n.point,t.point):m>a&&!$m(n.active,t.active)&&s.compareRange(a,m,n.active,t.active),p>o)break;(h||n.openEnd!=t.openEnd)&&s.boundChange&&s.boundChange(p),a=p,f<=0&&n.next(),f>=0&&t.next()}}function $m(n,e){if(n.length!=e.length)return!1;for(let t=0;t=e;i--)n[i+1]=n[i];n[e]=t}function Pv(n,e){let t=-1,i=1e9;for(let r=0;r=e)return r;if(r==n.length)break;s+=n.charCodeAt(r)==9?t-s%t:1,r=Wt(n,r)}return i===!0?-1:n.length}const Rm="ͼ",_v=typeof Symbol>"u"?"__"+Rm:Symbol.for(Rm),Am=typeof Symbol>"u"?"__styleSet"+Math.floor(Math.random()*1e8):Symbol("styleSet"),Qv=typeof globalThis<"u"?globalThis:typeof window<"u"?window:{};class Yr{constructor(e,t){this.rules=[];let{finish:i}=t||{};function r(o){return/^@/.test(o)?[o]:o.split(/,\s*/)}function s(o,a,c,h){let f=[],p=/^@(\w+)\b/.exec(o[0]),m=p&&p[1]=="keyframes";if(p&&a==null)return c.push(o[0]+";");for(let y in a){let v=a[y];if(/&/.test(y))s(y.split(/,\s*/).map(b=>o.map(S=>b.replace(/&/,S))).reduce((b,S)=>b.concat(S)),v,c);else if(v&&typeof v=="object"){if(!p)throw new RangeError("The value of a property ("+y+") should be a primitive value.");s(r(y),v,f,m)}else v!=null&&f.push(y.replace(/_.*/,"").replace(/[A-Z]/g,b=>"-"+b.toLowerCase())+": "+v+";")}(f.length||m)&&c.push((i&&!p&&!h?o.map(i):o).join(", ")+" {"+f.join(" ")+"}")}for(let o in e)s(r(o),e[o],this.rules)}getRules(){return this.rules.join(` -`)}static newName(){let e=Qv[_v]||1;return Qv[_v]=e+1,Rm+e.toString(36)}static mount(e,t,i){let r=e[Am],s=i&&i.nonce;r?s&&r.setNonce(s):r=new aR(e,s),r.mount(Array.isArray(t)?t:[t],e)}}let Cv=new Map;class aR{constructor(e,t){let i=e.ownerDocument||e,r=i.defaultView;if(!e.head&&e.adoptedStyleSheets&&r.CSSStyleSheet){let s=Cv.get(i);if(s)return e[Am]=s;this.sheet=new r.CSSStyleSheet,Cv.set(i,this)}else this.styleTag=i.createElement("style"),t&&this.styleTag.setAttribute("nonce",t);this.modules=[],e[Am]=this}mount(e,t){let i=this.sheet,r=0,s=0;for(let o=0;o-1&&(this.modules.splice(c,1),s--,c=-1),c==-1){if(this.modules.splice(s++,0,a),i)for(let h=0;h",191:"?",192:"~",219:"{",220:"|",221:"}",222:'"'},cR=typeof navigator<"u"&&/Mac/.test(navigator.platform),uR=typeof navigator<"u"&&/MSIE \d|Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(navigator.userAgent);for(var Nt=0;Nt<10;Nt++)qr[48+Nt]=qr[96+Nt]=String(Nt);for(var Nt=1;Nt<=24;Nt++)qr[Nt+111]="F"+Nt;for(var Nt=65;Nt<=90;Nt++)qr[Nt]=String.fromCharCode(Nt+32),Da[Nt]=String.fromCharCode(Nt);for(var dg in qr)Da.hasOwnProperty(dg)||(Da[dg]=qr[dg]);function hR(n){var e=cR&&n.metaKey&&n.shiftKey&&!n.ctrlKey&&!n.altKey||uR&&n.shiftKey&&n.key&&n.key.length==1||n.key=="Unidentified",t=!e&&n.key||(n.shiftKey?Da:qr)[n.keyCode]||n.key||"Unidentified";return t=="Esc"&&(t="Escape"),t=="Del"&&(t="Delete"),t=="Left"&&(t="ArrowLeft"),t=="Up"&&(t="ArrowUp"),t=="Right"&&(t="ArrowRight"),t=="Down"&&(t="ArrowDown"),t}function He(){var n=arguments[0];typeof n=="string"&&(n=document.createElement(n));var e=1,t=arguments[1];if(t&&typeof t=="object"&&t.nodeType==null&&!Array.isArray(t)){for(var i in t)if(Object.prototype.hasOwnProperty.call(t,i)){var r=t[i];typeof r=="string"?n.setAttribute(i,r):r!=null&&(n[i]=r)}e++}for(;e2);var ae={mac:$v||/Mac/.test(ln.platform),windows:/Win/.test(ln.platform),linux:/Linux|X11/.test(ln.platform),ie:jf,ie_version:sP?Em.documentMode||6:Dm?+Dm[1]:Lm?+Lm[1]:0,gecko:Tv,gecko_version:Tv?+(/Firefox\/(\d+)/.exec(ln.userAgent)||[0,0])[1]:0,chrome:!!pg,chrome_version:pg?+pg[1]:0,ios:$v,android:/Android\b/.test(ln.userAgent),webkit_version:fR?+(/\bAppleWebKit\/(\d+)/.exec(ln.userAgent)||[0,0])[1]:0,safari:zm,safari_version:zm?+(/\bVersion\/(\d+(\.\d+)?)/.exec(ln.userAgent)||[0,0])[1]:0,tabSize:Em.documentElement.style.tabSize!=null?"tab-size":"-moz-tab-size"};function za(n){let e;return n.nodeType==11?e=n.getSelection?n:n.ownerDocument:e=n,e.getSelection()}function Zm(n,e){return e?n==e||n.contains(e.nodeType!=1?e.parentNode:e):!1}function Ph(n,e){if(!e.anchorNode)return!1;try{return Zm(n,e.anchorNode)}catch{return!1}}function Za(n){return n.nodeType==3?js(n,0,n.nodeValue.length).getClientRects():n.nodeType==1?n.getClientRects():[]}function va(n,e,t,i){return t?Mv(n,e,t,i,-1)||Mv(n,e,t,i,1):!1}function Is(n){for(var e=0;;e++)if(n=n.previousSibling,!n)return e}function Hh(n){return n.nodeType==1&&/^(DIV|P|LI|UL|OL|BLOCKQUOTE|DD|DT|H\d|SECTION|PRE)$/.test(n.nodeName)}function Mv(n,e,t,i,r){for(;;){if(n==t&&e==i)return!0;if(e==(r<0?0:zi(n))){if(n.nodeName=="DIV")return!1;let s=n.parentNode;if(!s||s.nodeType!=1)return!1;e=Is(n)+(r<0?0:1),n=s}else if(n.nodeType==1){if(n=n.childNodes[e+(r<0?-1:0)],n.nodeType==1&&n.contentEditable=="false")return!1;e=r<0?zi(n):0}else return!1}}function zi(n){return n.nodeType==3?n.nodeValue.length:n.childNodes.length}function Bf(n,e){let t=e?n.left:n.right;return{left:t,right:t,top:n.top,bottom:n.bottom}}function dR(n){let e=n.visualViewport;return e?{left:0,right:e.width,top:0,bottom:e.height}:{left:0,right:n.innerWidth,top:0,bottom:n.innerHeight}}function oP(n,e){let t=e.width/n.offsetWidth,i=e.height/n.offsetHeight;return(t>.995&&t<1.005||!isFinite(t)||Math.abs(e.width-n.offsetWidth)<1)&&(t=1),(i>.995&&i<1.005||!isFinite(i)||Math.abs(e.height-n.offsetHeight)<1)&&(i=1),{scaleX:t,scaleY:i}}function pR(n,e,t,i,r,s,o,a){let c=n.ownerDocument,h=c.defaultView||window;for(let f=n,p=!1;f&&!p;)if(f.nodeType==1){let m,y=f==c.body,v=1,b=1;if(y)m=dR(h);else{if(/^(fixed|sticky)$/.test(getComputedStyle(f).position)&&(p=!0),f.scrollHeight<=f.clientHeight&&f.scrollWidth<=f.clientWidth){f=f.assignedSlot||f.parentNode;continue}let C=f.getBoundingClientRect();({scaleX:v,scaleY:b}=oP(f,C)),m={left:C.left,right:C.left+f.clientWidth*v,top:C.top,bottom:C.top+f.clientHeight*b}}let S=0,w=0;if(r=="nearest")e.top0&&e.bottom>m.bottom+w&&(w=e.bottom-m.bottom+o)):e.bottom>m.bottom&&(w=e.bottom-m.bottom+o,t<0&&e.top-w0&&e.right>m.right+S&&(S=e.right-m.right+s)):e.right>m.right&&(S=e.right-m.right+s,t<0&&e.leftm.bottom||e.leftm.right)&&(e={left:Math.max(e.left,m.left),right:Math.min(e.right,m.right),top:Math.max(e.top,m.top),bottom:Math.min(e.bottom,m.bottom)}),f=f.assignedSlot||f.parentNode}else if(f.nodeType==11)f=f.host;else break}function gR(n){let e=n.ownerDocument,t,i;for(let r=n.parentNode;r&&!(r==e.body||t&&i);)if(r.nodeType==1)!i&&r.scrollHeight>r.clientHeight&&(i=r),!t&&r.scrollWidth>r.clientWidth&&(t=r),r=r.assignedSlot||r.parentNode;else if(r.nodeType==11)r=r.host;else break;return{x:t,y:i}}class mR{constructor(){this.anchorNode=null,this.anchorOffset=0,this.focusNode=null,this.focusOffset=0}eq(e){return this.anchorNode==e.anchorNode&&this.anchorOffset==e.anchorOffset&&this.focusNode==e.focusNode&&this.focusOffset==e.focusOffset}setRange(e){let{anchorNode:t,focusNode:i}=e;this.set(t,Math.min(e.anchorOffset,t?zi(t):0),i,Math.min(e.focusOffset,i?zi(i):0))}set(e,t,i,r){this.anchorNode=e,this.anchorOffset=t,this.focusNode=i,this.focusOffset=r}}let bs=null;ae.safari&&ae.safari_version>=26&&(bs=!1);function lP(n){if(n.setActive)return n.setActive();if(bs)return n.focus(bs);let e=[];for(let t=n;t&&(e.push(t,t.scrollTop,t.scrollLeft),t!=t.ownerDocument);t=t.parentNode);if(n.focus(bs==null?{get preventScroll(){return bs={preventScroll:!0},!0}}:void 0),!bs){bs=!1;for(let t=0;tMath.max(1,n.scrollHeight-n.clientHeight-4)}function uP(n,e){for(let t=n,i=e;;){if(t.nodeType==3&&i>0)return{node:t,offset:i};if(t.nodeType==1&&i>0){if(t.contentEditable=="false")return null;t=t.childNodes[i-1],i=zi(t)}else if(t.parentNode&&!Hh(t))i=Is(t),t=t.parentNode;else return null}}function hP(n,e){for(let t=n,i=e;;){if(t.nodeType==3&&it)return p.domBoundsAround(e,t,h);if(m>=e&&r==-1&&(r=c,s=h),h>t&&p.dom.parentNode==this.dom){o=c,a=f;break}f=m,h=m+p.breakAfter}return{from:s,to:a<0?i+this.length:a,startDOM:(r?this.children[r-1].dom.nextSibling:null)||this.dom.firstChild,endDOM:o=0?this.children[o].dom:null}}markDirty(e=!1){this.flags|=2,this.markParentsDirty(e)}markParentsDirty(e){for(let t=this.parent;t;t=t.parent){if(e&&(t.flags|=2),t.flags&1)return;t.flags|=1,e=!1}}setParent(e){this.parent!=e&&(this.parent=e,this.flags&7&&this.markParentsDirty(!0))}setDOM(e){this.dom!=e&&(this.dom&&(this.dom.cmView=null),this.dom=e,e.cmView=this)}get rootView(){for(let e=this;;){let t=e.parent;if(!t)return e;e=t}}replaceChildren(e,t,i=HO){this.markDirty();for(let r=e;rthis.pos||e==this.pos&&(t>0||this.i==0||this.children[this.i-1].breakAfter))return this.off=e-this.pos,this;let i=this.children[--this.i];this.pos-=i.length+i.breakAfter}}}function dP(n,e,t,i,r,s,o,a,c){let{children:h}=n,f=h.length?h[e]:null,p=s.length?s[s.length-1]:null,m=p?p.breakAfter:o;if(!(e==i&&f&&!o&&!m&&s.length<2&&f.merge(t,r,s.length?p:null,t==0,a,c))){if(i0&&(!o&&s.length&&f.merge(t,f.length,s[0],!1,a,0)?f.breakAfter=s.shift().breakAfter:(txR||i.flags&8)?!1:(this.text=this.text.slice(0,e)+(i?i.text:"")+this.text.slice(t),this.markDirty(),!0)}split(e){let t=new ui(this.text.slice(e));return this.text=this.text.slice(0,e),this.markDirty(),t.flags|=this.flags&8,t}localPosFromDOM(e,t){return e==this.dom?t:t?this.text.length:0}domAtPos(e){return new Jt(this.dom,e)}domBoundsAround(e,t,i){return{from:i,to:i+this.length,startDOM:this.dom,endDOM:this.dom.nextSibling}}coordsAt(e,t){return vR(this.dom,e,t)}}class or extends Ue{constructor(e,t=[],i=0){super(),this.mark=e,this.children=t,this.length=i;for(let r of t)r.setParent(this)}setAttrs(e){if(aP(e),this.mark.class&&(e.className=this.mark.class),this.mark.attrs)for(let t in this.mark.attrs)e.setAttribute(t,this.mark.attrs[t]);return e}canReuseDOM(e){return super.canReuseDOM(e)&&!((this.flags|e.flags)&8)}reuseDOM(e){e.nodeName==this.mark.tagName.toUpperCase()&&(this.setDOM(e),this.flags|=6)}sync(e,t){this.dom?this.flags&4&&this.setAttrs(this.dom):this.setDOM(this.setAttrs(document.createElement(this.mark.tagName))),super.sync(e,t)}merge(e,t,i,r,s,o){return i&&(!(i instanceof or&&i.mark.eq(this.mark))||e&&s<=0||te&&t.push(i=e&&(r=s),i=c,s++}let o=this.length-e;return this.length=e,r>-1&&(this.children.length=r,this.markDirty()),new or(this.mark,t,o)}domAtPos(e){return gP(this,e)}coordsAt(e,t){return OP(this,e,t)}}function vR(n,e,t){let i=n.nodeValue.length;e>i&&(e=i);let r=e,s=e,o=0;e==0&&t<0||e==i&&t>=0?ae.chrome||ae.gecko||(e?(r--,o=1):s=0)?0:a.length-1];return ae.safari&&!o&&c.width==0&&(c=Array.prototype.find.call(a,h=>h.width)||c),o?Bf(c,o<0):c||null}class Dr extends Ue{static create(e,t,i){return new Dr(e,t,i)}constructor(e,t,i){super(),this.widget=e,this.length=t,this.side=i,this.prevWidget=null}split(e){let t=Dr.create(this.widget,this.length-e,this.side);return this.length-=e,t}sync(e){(!this.dom||!this.widget.updateDOM(this.dom,e))&&(this.dom&&this.prevWidget&&this.prevWidget.destroy(this.dom),this.prevWidget=null,this.setDOM(this.widget.toDOM(e)),this.widget.editable||(this.dom.contentEditable="false"))}getSide(){return this.side}merge(e,t,i,r,s,o){return i&&(!(i instanceof Dr)||!this.widget.compare(i.widget)||e>0&&s<=0||t0)?Jt.before(this.dom):Jt.after(this.dom,e==this.length)}domBoundsAround(){return null}coordsAt(e,t){let i=this.widget.coordsAt(this.dom,e,t);if(i)return i;let r=this.dom.getClientRects(),s=null;if(!r.length)return null;let o=this.side?this.side<0:e>0;for(let a=o?r.length-1:0;s=r[a],!(e>0?a==0:a==r.length-1||s.top0?Jt.before(this.dom):Jt.after(this.dom)}localPosFromDOM(){return 0}domBoundsAround(){return null}coordsAt(e){return this.dom.getBoundingClientRect()}get overrideDOMText(){return ze.empty}get isHidden(){return!0}}ui.prototype.children=Dr.prototype.children=Vo.prototype.children=HO;function gP(n,e){let t=n.dom,{children:i}=n,r=0;for(let s=0;rs&&e0;s--){let o=i[s-1];if(o.dom.parentNode==t)return o.domAtPos(o.length)}for(let s=r;s0&&e instanceof or&&r.length&&(i=r[r.length-1])instanceof or&&i.mark.eq(e.mark)?mP(i,e.children[0],t-1):(r.push(e),e.setParent(n)),n.length+=e.length}function OP(n,e,t){let i=null,r=-1,s=null,o=-1;function a(h,f){for(let p=0,m=0;p=f&&(y.children.length?a(y,f-m):(!s||s.isHidden&&(t>0||SR(s,y)))&&(v>f||m==v&&y.getSide()>0)?(s=y,o=f-m):(m-1?1:0)!=r.length-(t&&r.indexOf(t)>-1?1:0))return!1;for(let s of i)if(s!=t&&(r.indexOf(s)==-1||n[s]!==e[s]))return!1;return!0}function jm(n,e,t){let i=!1;if(e)for(let r in e)t&&r in t||(i=!0,r=="style"?n.style.cssText="":n.removeAttribute(r));if(t)for(let r in t)e&&e[r]==t[r]||(i=!0,r=="style"?n.style.cssText=t[r]:n.setAttribute(r,t[r]));return i}function wR(n){let e=Object.create(null);for(let t=0;t0?3e8:-4e8:t>0?1e8:-1e8,new Ur(e,t,t,i,e.widget||null,!1)}static replace(e){let t=!!e.block,i,r;if(e.isBlockGap)i=-5e8,r=4e8;else{let{start:s,end:o}=yP(e,t);i=(s?t?-3e8:-1:5e8)-1,r=(o?t?2e8:1:-6e8)+1}return new Ur(e,i,r,t,e.widget||null,!0)}static line(e){return new cc(e)}static set(e,t=!1){return je.of(e,t)}hasHeight(){return this.widget?this.widget.estimatedHeight>-1:!1}}Pe.none=je.empty;class ac extends Pe{constructor(e){let{start:t,end:i}=yP(e);super(t?-1:5e8,i?1:-6e8,null,e),this.tagName=e.tagName||"span",this.class=e.class||"",this.attrs=e.attributes||null}eq(e){var t,i;return this==e||e instanceof ac&&this.tagName==e.tagName&&(this.class||((t=this.attrs)===null||t===void 0?void 0:t.class))==(e.class||((i=e.attrs)===null||i===void 0?void 0:i.class))&&Gh(this.attrs,e.attrs,"class")}range(e,t=e){if(e>=t)throw new RangeError("Mark decorations may not be empty");return super.range(e,t)}}ac.prototype.point=!1;class cc extends Pe{constructor(e){super(-2e8,-2e8,null,e)}eq(e){return e instanceof cc&&this.spec.class==e.spec.class&&Gh(this.spec.attributes,e.spec.attributes)}range(e,t=e){if(t!=e)throw new RangeError("Line decoration ranges must be zero-length");return super.range(e,t)}}cc.prototype.mapMode=Xt.TrackBefore;cc.prototype.point=!0;class Ur extends Pe{constructor(e,t,i,r,s,o){super(t,i,s,e),this.block=r,this.isReplace=o,this.mapMode=r?t<=0?Xt.TrackBefore:Xt.TrackAfter:Xt.TrackDel}get type(){return this.startSide!=this.endSide?an.WidgetRange:this.startSide<=0?an.WidgetBefore:an.WidgetAfter}get heightRelevant(){return this.block||!!this.widget&&(this.widget.estimatedHeight>=5||this.widget.lineBreaks>0)}eq(e){return e instanceof Ur&&kR(this.widget,e.widget)&&this.block==e.block&&this.startSide==e.startSide&&this.endSide==e.endSide}range(e,t=e){if(this.isReplace&&(e>t||e==t&&this.startSide>0&&this.endSide<=0))throw new RangeError("Invalid range for replacement decoration");if(!this.isReplace&&t!=e)throw new RangeError("Widget decorations can only have zero-length ranges");return super.range(e,t)}}Ur.prototype.point=!0;function yP(n,e=!1){let{inclusiveStart:t,inclusiveEnd:i}=n;return t==null&&(t=n.inclusive),i==null&&(i=n.inclusive),{start:t!=null?t:e,end:i!=null?i:e}}function kR(n,e){return n==e||!!(n&&e&&n.compare(e))}function _h(n,e,t,i=0){let r=t.length-1;r>=0&&t[r]+i>=n?t[r]=Math.max(t[r],e):t.push(n,e)}class xt extends Ue{constructor(){super(...arguments),this.children=[],this.length=0,this.prevAttrs=void 0,this.attrs=null,this.breakAfter=0}merge(e,t,i,r,s,o){if(i){if(!(i instanceof xt))return!1;this.dom||i.transferDOM(this)}return r&&this.setDeco(i?i.attrs:null),pP(this,e,t,i?i.children.slice():[],s,o),!0}split(e){let t=new xt;if(t.breakAfter=this.breakAfter,this.length==0)return t;let{i,off:r}=this.childPos(e);r&&(t.append(this.children[i].split(r),0),this.children[i].merge(r,this.children[i].length,null,!1,0,0),i++);for(let s=i;s0&&this.children[i-1].length==0;)this.children[--i].destroy();return this.children.length=i,this.markDirty(),this.length=e,t}transferDOM(e){this.dom&&(this.markDirty(),e.setDOM(this.dom),e.prevAttrs=this.prevAttrs===void 0?this.attrs:this.prevAttrs,this.prevAttrs=void 0,this.dom=null)}setDeco(e){Gh(this.attrs,e)||(this.dom&&(this.prevAttrs=this.attrs,this.markDirty()),this.attrs=e)}append(e,t){mP(this,e,t)}addLineDeco(e){let t=e.spec.attributes,i=e.spec.class;t&&(this.attrs=Im(t,this.attrs||{})),i&&(this.attrs=Im({class:i},this.attrs||{}))}domAtPos(e){return gP(this,e)}reuseDOM(e){e.nodeName=="DIV"&&(this.setDOM(e),this.flags|=6)}sync(e,t){var i;this.dom?this.flags&4&&(aP(this.dom),this.dom.className="cm-line",this.prevAttrs=this.attrs?null:void 0):(this.setDOM(document.createElement("div")),this.dom.className="cm-line",this.prevAttrs=this.attrs?null:void 0),this.prevAttrs!==void 0&&(jm(this.dom,this.prevAttrs,this.attrs),this.dom.classList.add("cm-line"),this.prevAttrs=void 0),super.sync(e,t);let r=this.dom.lastChild;for(;r&&Ue.get(r)instanceof or;)r=r.lastChild;if(!r||!this.length||r.nodeName!="BR"&&((i=Ue.get(r))===null||i===void 0?void 0:i.isEditable)==!1&&(!ae.ios||!this.children.some(s=>s instanceof ui))){let s=document.createElement("BR");s.cmIgnore=!0,this.dom.appendChild(s)}}measureTextSize(){if(this.children.length==0||this.length>20)return null;let e=0,t;for(let i of this.children){if(!(i instanceof ui)||/[^ -~]/.test(i.text))return null;let r=Za(i.dom);if(r.length!=1)return null;e+=r[0].width,t=r[0].height}return e?{lineHeight:this.dom.getBoundingClientRect().height,charWidth:e/this.length,textHeight:t}:null}coordsAt(e,t){let i=OP(this,e,t);if(!this.children.length&&i&&this.parent){let{heightOracle:r}=this.parent.view.viewState,s=i.bottom-i.top;if(Math.abs(s-r.lineHeight)<2&&r.textHeight=t){if(s instanceof xt)return s;if(o>t)break}r=o+s.breakAfter}return null}}class rr extends Ue{constructor(e,t,i){super(),this.widget=e,this.length=t,this.deco=i,this.breakAfter=0,this.prevWidget=null}merge(e,t,i,r,s,o){return i&&(!(i instanceof rr)||!this.widget.compare(i.widget)||e>0&&s<=0||t0}}class Bm extends cr{constructor(e){super(),this.height=e}toDOM(){let e=document.createElement("div");return e.className="cm-gap",this.updateDOM(e),e}eq(e){return e.height==this.height}updateDOM(e){return e.style.height=this.height+"px",!0}get editable(){return!0}get estimatedHeight(){return this.height}ignoreEvent(){return!1}}class ba{constructor(e,t,i,r){this.doc=e,this.pos=t,this.end=i,this.disallowBlockEffectsFor=r,this.content=[],this.curLine=null,this.breakAtStart=0,this.pendingBuffer=0,this.bufferMarks=[],this.atCursorPos=!0,this.openStart=-1,this.openEnd=-1,this.text="",this.textOff=0,this.cursor=e.iter(),this.skip=t}posCovered(){if(this.content.length==0)return!this.breakAtStart&&this.doc.lineAt(this.pos).from!=this.pos;let e=this.content[this.content.length-1];return!(e.breakAfter||e instanceof rr&&e.deco.endSide<0)}getLine(){return this.curLine||(this.content.push(this.curLine=new xt),this.atCursorPos=!0),this.curLine}flushBuffer(e=this.bufferMarks){this.pendingBuffer&&(this.curLine.append(Du(new Vo(-1),e),e.length),this.pendingBuffer=0)}addBlockWidget(e){this.flushBuffer(),this.curLine=null,this.content.push(e)}finish(e){this.pendingBuffer&&e<=this.bufferMarks.length?this.flushBuffer():this.pendingBuffer=0,!this.posCovered()&&!(e&&this.content.length&&this.content[this.content.length-1]instanceof rr)&&this.getLine()}buildText(e,t,i){for(;e>0;){if(this.textOff==this.text.length){let{value:o,lineBreak:a,done:c}=this.cursor.next(this.skip);if(this.skip=0,c)throw new Error("Ran out of text content when drawing inline views");if(a){this.posCovered()||this.getLine(),this.content.length?this.content[this.content.length-1].breakAfter=1:this.breakAtStart=1,this.flushBuffer(),this.curLine=null,this.atCursorPos=!0,e--;continue}else this.text=o,this.textOff=0}let r=Math.min(this.text.length-this.textOff,e),s=Math.min(r,512);this.flushBuffer(t.slice(t.length-i)),this.getLine().append(Du(new ui(this.text.slice(this.textOff,this.textOff+s)),t),i),this.atCursorPos=!0,this.textOff+=s,e-=s,i=r<=s?0:t.length}}span(e,t,i,r){this.buildText(t-e,i,r),this.pos=t,this.openStart<0&&(this.openStart=r)}point(e,t,i,r,s,o){if(this.disallowBlockEffectsFor[o]&&i instanceof Ur){if(i.block)throw new RangeError("Block decorations may not be specified via plugins");if(t>this.doc.lineAt(this.pos).to)throw new RangeError("Decorations that replace line breaks may not be specified via plugins")}let a=t-e;if(i instanceof Ur)if(i.block)i.startSide>0&&!this.posCovered()&&this.getLine(),this.addBlockWidget(new rr(i.widget||Fo.block,a,i));else{let c=Dr.create(i.widget||Fo.inline,a,a?0:i.startSide),h=this.atCursorPos&&!c.isEditable&&s<=r.length&&(e0),f=!c.isEditable&&(er.length||i.startSide<=0),p=this.getLine();this.pendingBuffer==2&&!h&&!c.isEditable&&(this.pendingBuffer=0),this.flushBuffer(r),h&&(p.append(Du(new Vo(1),r),s),s=r.length+Math.max(0,s-r.length)),p.append(Du(c,r),s),this.atCursorPos=f,this.pendingBuffer=f?er.length?1:2:0,this.pendingBuffer&&(this.bufferMarks=r.slice())}else this.doc.lineAt(this.pos).from==this.pos&&this.getLine().addLineDeco(i);a&&(this.textOff+a<=this.text.length?this.textOff+=a:(this.skip+=a-(this.text.length-this.textOff),this.text="",this.textOff=0),this.pos=t),this.openStart<0&&(this.openStart=s)}static build(e,t,i,r,s){let o=new ba(e,t,i,s);return o.openEnd=je.spans(r,t,i,o),o.openStart<0&&(o.openStart=o.openEnd),o.finish(o.openEnd),o}}function Du(n,e){for(let t of e)n=new or(t,[n],n.length);return n}class Fo extends cr{constructor(e){super(),this.tag=e}eq(e){return e.tag==this.tag}toDOM(){return document.createElement(this.tag)}updateDOM(e){return e.nodeName.toLowerCase()==this.tag}get isHidden(){return!0}}Fo.inline=new Fo("span");Fo.block=new Fo("div");var st=(function(n){return n[n.LTR=0]="LTR",n[n.RTL=1]="RTL",n})(st||(st={}));const Bs=st.LTR,GO=st.RTL;function xP(n){let e=[];for(let t=0;t=t){if(a.level==i)return o;(s<0||(r!=0?r<0?a.fromt:e[s].level>a.level))&&(s=o)}}if(s<0)throw new RangeError("Index out of range");return s}}function bP(n,e){if(n.length!=e.length)return!1;for(let t=0;t=0;b-=3)if(bi[b+1]==-y){let S=bi[b+2],w=S&2?r:S&4?S&1?s:r:0;w&&(Ge[p]=Ge[bi[b]]=w),a=b;break}}else{if(bi.length==189)break;bi[a++]=p,bi[a++]=m,bi[a++]=c}else if((v=Ge[p])==2||v==1){let b=v==r;c=b?0:1;for(let S=a-3;S>=0;S-=3){let w=bi[S+2];if(w&2)break;if(b)bi[S+2]|=2;else{if(w&4)break;bi[S+2]|=4}}}}}function $R(n,e,t,i){for(let r=0,s=i;r<=t.length;r++){let o=r?t[r-1].to:n,a=rc;)v==S&&(v=t[--b].from,S=b?t[b-1].to:n),Ge[--v]=y;c=f}else s=h,c++}}}function Xm(n,e,t,i,r,s,o){let a=i%2?2:1;if(i%2==r%2)for(let c=e,h=0;cc&&o.push(new zr(c,b.from,y));let S=b.direction==Bs!=!(y%2);Wm(n,S?i+1:i,r,b.inner,b.from,b.to,o),c=b.to}v=b.to}else{if(v==t||(f?Ge[v]!=a:Ge[v]==a))break;v++}m?Xm(n,c,v,i+1,r,m,o):ce;){let f=!0,p=!1;if(!h||c>s[h-1].to){let b=Ge[c-1];b!=a&&(f=!1,p=b==16)}let m=!f&&a==1?[]:null,y=f?i:i+1,v=c;e:for(;;)if(h&&v==s[h-1].to){if(p)break e;let b=s[--h];if(!f)for(let S=b.from,w=h;;){if(S==e)break e;if(w&&s[w-1].to==S)S=s[--w].from;else{if(Ge[S-1]==a)break e;break}}if(m)m.push(b);else{b.toGe.length;)Ge[Ge.length]=256;let i=[],r=e==Bs?0:1;return Wm(n,r,r,t,0,n.length,i),i}function SP(n){return[new zr(0,n,0)]}let wP="";function RR(n,e,t,i,r){var s;let o=i.head-n.from,a=zr.find(e,o,(s=i.bidiLevel)!==null&&s!==void 0?s:-1,i.assoc),c=e[a],h=c.side(r,t);if(o==h){let m=a+=r?1:-1;if(m<0||m>=e.length)return null;c=e[a=m],o=c.side(!r,t),h=c.side(r,t)}let f=Wt(n.text,o,c.forward(r,t));(fc.to)&&(f=h),wP=n.text.slice(Math.min(o,f),Math.max(o,f));let p=a==(r?e.length-1:0)?null:e[a+(r?1:-1)];return p&&f==h&&p.level+(r?0:1)n.some(e=>e)}),MP=fe.define({combine:n=>n.some(e=>e)}),RP=fe.define();class Ao{constructor(e,t="nearest",i="nearest",r=5,s=5,o=!1){this.range=e,this.y=t,this.x=i,this.yMargin=r,this.xMargin=s,this.isSnapshot=o}map(e){return e.empty?this:new Ao(this.range.map(e),this.y,this.x,this.yMargin,this.xMargin,this.isSnapshot)}clip(e){return this.range.to<=e.doc.length?this:new Ao(V.cursor(e.doc.length),this.y,this.x,this.yMargin,this.xMargin,this.isSnapshot)}}const zu=Te.define({map:(n,e)=>n.map(e)}),AP=Te.define();function bn(n,e,t){let i=n.facet(QP);i.length?i[0](e):window.onerror&&window.onerror(String(e),t,void 0,void 0,e)||(t?console.error(t+":",e):console.error(e))}const tr=fe.define({combine:n=>n.length?n[0]:!0});let ER=0;const _o=fe.define({combine(n){return n.filter((e,t)=>{for(let i=0;i{let c=[];return o&&c.push(Ia.of(h=>{let f=h.plugin(a);return f?o(f):Pe.none})),s&&c.push(s(a)),c})}static fromClass(e,t){return kt.define((i,r)=>new e(i,r),t)}}class gg{constructor(e){this.spec=e,this.mustUpdate=null,this.value=null}get plugin(){return this.spec&&this.spec.plugin}update(e){if(this.value){if(this.mustUpdate){let t=this.mustUpdate;if(this.mustUpdate=null,this.value.update)try{this.value.update(t)}catch(i){if(bn(t.state,i,"CodeMirror plugin crashed"),this.value.destroy)try{this.value.destroy()}catch{}this.deactivate()}}}else if(this.spec)try{this.value=this.spec.plugin.create(e,this.spec.arg)}catch(t){bn(e.state,t,"CodeMirror plugin crashed"),this.deactivate()}return this}destroy(e){var t;if(!((t=this.value)===null||t===void 0)&&t.destroy)try{this.value.destroy()}catch(i){bn(e.state,i,"CodeMirror plugin crashed")}}deactivate(){this.spec=this.value=null}}const EP=fe.define(),e0=fe.define(),Ia=fe.define(),LP=fe.define(),uc=fe.define(),DP=fe.define();function Lv(n,e){let t=n.state.facet(DP);if(!t.length)return t;let i=t.map(s=>s instanceof Function?s(n):s),r=[];return je.spans(i,e.from,e.to,{point(){},span(s,o,a,c){let h=s-e.from,f=o-e.from,p=r;for(let m=a.length-1;m>=0;m--,c--){let y=a[m].spec.bidiIsolate,v;if(y==null&&(y=AR(e.text,h,f)),c>0&&p.length&&(v=p[p.length-1]).to==h&&v.direction==y)v.to=f,p=v.inner;else{let b={from:h,to:f,direction:y,inner:[]};p.push(b),p=b.inner}}}}),r}const zP=fe.define();function t0(n){let e=0,t=0,i=0,r=0;for(let s of n.state.facet(zP)){let o=s(n);o&&(o.left!=null&&(e=Math.max(e,o.left)),o.right!=null&&(t=Math.max(t,o.right)),o.top!=null&&(i=Math.max(i,o.top)),o.bottom!=null&&(r=Math.max(r,o.bottom)))}return{left:e,right:t,top:i,bottom:r}}const la=fe.define();class Fn{constructor(e,t,i,r){this.fromA=e,this.toA=t,this.fromB=i,this.toB=r}join(e){return new Fn(Math.min(this.fromA,e.fromA),Math.max(this.toA,e.toA),Math.min(this.fromB,e.fromB),Math.max(this.toB,e.toB))}addToSet(e){let t=e.length,i=this;for(;t>0;t--){let r=e[t-1];if(!(r.fromA>i.toA)){if(r.toAf)break;s+=2}if(!c)return i;new Fn(c.fromA,c.toA,c.fromB,c.toB).addToSet(i),o=c.toA,a=c.toB}}}class Kh{constructor(e,t,i){this.view=e,this.state=t,this.transactions=i,this.flags=0,this.startState=e.state,this.changes=Ct.empty(this.startState.doc.length);for(let s of i)this.changes=this.changes.compose(s.changes);let r=[];this.changes.iterChangedRanges((s,o,a,c)=>r.push(new Fn(s,o,a,c))),this.changedRanges=r}static create(e,t,i){return new Kh(e,t,i)}get viewportChanged(){return(this.flags&4)>0}get viewportMoved(){return(this.flags&8)>0}get heightChanged(){return(this.flags&2)>0}get geometryChanged(){return this.docChanged||(this.flags&18)>0}get focusChanged(){return(this.flags&1)>0}get docChanged(){return!this.changes.empty}get selectionSet(){return this.transactions.some(e=>e.selection)}get empty(){return this.flags==0&&this.transactions.length==0}}class Dv extends Ue{get length(){return this.view.state.doc.length}constructor(e){super(),this.view=e,this.decorations=[],this.dynamicDecorationMap=[!1],this.domChanged=null,this.hasComposition=null,this.markedForComposition=new Set,this.editContextFormatting=Pe.none,this.lastCompositionAfterCursor=!1,this.minWidth=0,this.minWidthFrom=0,this.minWidthTo=0,this.impreciseAnchor=null,this.impreciseHead=null,this.forceSelection=!1,this.lastUpdate=Date.now(),this.setDOM(e.contentDOM),this.children=[new xt],this.children[0].setParent(this),this.updateDeco(),this.updateInner([new Fn(0,0,0,e.state.doc.length)],0,null)}update(e){var t;let i=e.changedRanges;this.minWidth>0&&i.length&&(i.every(({fromA:h,toA:f})=>fthis.minWidthTo)?(this.minWidthFrom=e.changes.mapPos(this.minWidthFrom,1),this.minWidthTo=e.changes.mapPos(this.minWidthTo,1)):this.minWidth=this.minWidthFrom=this.minWidthTo=0),this.updateEditContextFormatting(e);let r=-1;this.view.inputState.composing>=0&&!this.view.observer.editContext&&(!((t=this.domChanged)===null||t===void 0)&&t.newSel?r=this.domChanged.newSel.head:!BR(e.changes,this.hasComposition)&&!e.selectionSet&&(r=e.state.selection.main.head));let s=r>-1?DR(this.view,e.changes,r):null;if(this.domChanged=null,this.hasComposition){this.markedForComposition.clear();let{from:h,to:f}=this.hasComposition;i=new Fn(h,f,e.changes.mapPos(h,-1),e.changes.mapPos(f,1)).addToSet(i.slice())}this.hasComposition=s?{from:s.range.fromB,to:s.range.toB}:null,(ae.ie||ae.chrome)&&!s&&e&&e.state.doc.lines!=e.startState.doc.lines&&(this.forceSelection=!0);let o=this.decorations,a=this.updateDeco(),c=IR(o,a,e.changes);return i=Fn.extendWithRanges(i,c),!(this.flags&7)&&i.length==0?!1:(this.updateInner(i,e.startState.doc.length,s),e.transactions.length&&(this.lastUpdate=Date.now()),!0)}updateInner(e,t,i){this.view.viewState.mustMeasureContent=!0,this.updateChildren(e,t,i);let{observer:r}=this.view;r.ignore(()=>{this.dom.style.height=this.view.viewState.contentHeight/this.view.scaleY+"px",this.dom.style.flexBasis=this.minWidth?this.minWidth+"px":"";let o=ae.chrome||ae.ios?{node:r.selectionRange.focusNode,written:!1}:void 0;this.sync(this.view,o),this.flags&=-8,o&&(o.written||r.selectionRange.focusNode!=o.node)&&(this.forceSelection=!0),this.dom.style.height=""}),this.markedForComposition.forEach(o=>o.flags&=-9);let s=[];if(this.view.viewport.from||this.view.viewport.to=0?r[o]:null;if(!a)break;let{fromA:c,toA:h,fromB:f,toB:p}=a,m,y,v,b;if(i&&i.range.fromBf){let P=ba.build(this.view.state.doc,f,i.range.fromB,this.decorations,this.dynamicDecorationMap),Q=ba.build(this.view.state.doc,i.range.toB,p,this.decorations,this.dynamicDecorationMap);y=P.breakAtStart,v=P.openStart,b=Q.openEnd;let $=this.compositionView(i);Q.breakAtStart?$.breakAfter=1:Q.content.length&&$.merge($.length,$.length,Q.content[0],!1,Q.openStart,0)&&($.breakAfter=Q.content[0].breakAfter,Q.content.shift()),P.content.length&&$.merge(0,0,P.content[P.content.length-1],!0,0,P.openEnd)&&P.content.pop(),m=P.content.concat($).concat(Q.content)}else({content:m,breakAtStart:y,openStart:v,openEnd:b}=ba.build(this.view.state.doc,f,p,this.decorations,this.dynamicDecorationMap));let{i:S,off:w}=s.findPos(h,1),{i:C,off:_}=s.findPos(c,-1);dP(this,C,_,S,w,m,y,v,b)}i&&this.fixCompositionDOM(i)}updateEditContextFormatting(e){this.editContextFormatting=this.editContextFormatting.map(e.changes);for(let t of e.transactions)for(let i of t.effects)i.is(AP)&&(this.editContextFormatting=i.value)}compositionView(e){let t=new ui(e.text.nodeValue);t.flags|=8;for(let{deco:r}of e.marks)t=new or(r,[t],t.length);let i=new xt;return i.append(t,0),i}fixCompositionDOM(e){let t=(s,o)=>{o.flags|=8|(o.children.some(c=>c.flags&7)?1:0),this.markedForComposition.add(o);let a=Ue.get(s);a&&a!=o&&(a.dom=null),o.setDOM(s)},i=this.childPos(e.range.fromB,1),r=this.children[i.i];t(e.line,r);for(let s=e.marks.length-1;s>=-1;s--)i=r.childPos(i.off,1),r=r.children[i.i],t(s>=0?e.marks[s].node:e.text,r)}updateSelection(e=!1,t=!1){(e||!this.view.observer.selectionRange.focusNode)&&this.view.observer.readSelectionRange();let i=this.view.root.activeElement,r=i==this.dom,s=!r&&!(this.view.state.facet(tr)||this.dom.tabIndex>-1)&&Ph(this.dom,this.view.observer.selectionRange)&&!(i&&this.dom.contains(i));if(!(r||t||s))return;let o=this.forceSelection;this.forceSelection=!1;let a=this.view.state.selection.main,c=this.moveToLine(this.domAtPos(a.anchor)),h=a.empty?c:this.moveToLine(this.domAtPos(a.head));if(ae.gecko&&a.empty&&!this.hasComposition&&LR(c)){let p=document.createTextNode("");this.view.observer.ignore(()=>c.node.insertBefore(p,c.node.childNodes[c.offset]||null)),c=h=new Jt(p,0),o=!0}let f=this.view.observer.selectionRange;(o||!f.focusNode||(!va(c.node,c.offset,f.anchorNode,f.anchorOffset)||!va(h.node,h.offset,f.focusNode,f.focusOffset))&&!this.suppressWidgetCursorChange(f,a))&&(this.view.observer.ignore(()=>{ae.android&&ae.chrome&&this.dom.contains(f.focusNode)&&jR(f.focusNode,this.dom)&&(this.dom.blur(),this.dom.focus({preventScroll:!0}));let p=za(this.view.root);if(p)if(a.empty){if(ae.gecko){let m=zR(c.node,c.offset);if(m&&m!=3){let y=(m==1?uP:hP)(c.node,c.offset);y&&(c=new Jt(y.node,y.offset))}}p.collapse(c.node,c.offset),a.bidiLevel!=null&&p.caretBidiLevel!==void 0&&(p.caretBidiLevel=a.bidiLevel)}else if(p.extend){p.collapse(c.node,c.offset);try{p.extend(h.node,h.offset)}catch{}}else{let m=document.createRange();a.anchor>a.head&&([c,h]=[h,c]),m.setEnd(h.node,h.offset),m.setStart(c.node,c.offset),p.removeAllRanges(),p.addRange(m)}s&&this.view.root.activeElement==this.dom&&(this.dom.blur(),i&&i.focus())}),this.view.observer.setSelectionRange(c,h)),this.impreciseAnchor=c.precise?null:new Jt(f.anchorNode,f.anchorOffset),this.impreciseHead=h.precise?null:new Jt(f.focusNode,f.focusOffset)}suppressWidgetCursorChange(e,t){return this.hasComposition&&t.empty&&va(e.focusNode,e.focusOffset,e.anchorNode,e.anchorOffset)&&this.posFromDOM(e.focusNode,e.focusOffset)==t.head}enforceCursorAssoc(){if(this.hasComposition)return;let{view:e}=this,t=e.state.selection.main,i=za(e.root),{anchorNode:r,anchorOffset:s}=e.observer.selectionRange;if(!i||!t.empty||!t.assoc||!i.modify)return;let o=xt.find(this,t.head);if(!o)return;let a=o.posAtStart;if(t.head==a||t.head==a+o.length)return;let c=this.coordsAt(t.head,-1),h=this.coordsAt(t.head,1);if(!c||!h||c.bottom>h.top)return;let f=this.domAtPos(t.head+t.assoc);i.collapse(f.node,f.offset),i.modify("move",t.assoc<0?"forward":"backward","lineboundary"),e.observer.readSelectionRange();let p=e.observer.selectionRange;e.docView.posFromDOM(p.anchorNode,p.anchorOffset)!=t.from&&i.collapse(r,s)}moveToLine(e){let t=this.dom,i;if(e.node!=t)return e;for(let r=e.offset;!i&&r=0;r--){let s=Ue.get(t.childNodes[r]);s instanceof xt&&(i=s.domAtPos(s.length))}return i?new Jt(i.node,i.offset,!0):e}nearest(e){for(let t=e;t;){let i=Ue.get(t);if(i&&i.rootView==this)return i;t=t.parentNode}return null}posFromDOM(e,t){let i=this.nearest(e);if(!i)throw new RangeError("Trying to find position for a DOM position outside of the document");return i.localPosFromDOM(e,t)+i.posAtStart}domAtPos(e){let{i:t,off:i}=this.childCursor().findPos(e,-1);for(;t=0;o--){let a=this.children[o],c=s-a.breakAfter,h=c-a.length;if(ce||a.covers(1))&&(!i||a instanceof xt&&!(i instanceof xt&&t>=0)))i=a,r=h;else if(i&&h==e&&c==e&&a instanceof rr&&Math.abs(t)<2){if(a.deco.startSide<0)break;o&&(i=null)}s=h}return i?i.coordsAt(e-r,t):null}coordsForChar(e){let{i:t,off:i}=this.childPos(e,1),r=this.children[t];if(!(r instanceof xt))return null;for(;r.children.length;){let{i:a,off:c}=r.childPos(i,1);for(;;a++){if(a==r.children.length)return null;if((r=r.children[a]).length)break}i=c}if(!(r instanceof ui))return null;let s=Wt(r.text,i);if(s==i)return null;let o=js(r.dom,i,s).getClientRects();for(let a=0;aMath.max(this.view.scrollDOM.clientWidth,this.minWidth)+1,a=-1,c=this.view.textDirection==st.LTR;for(let h=0,f=0;fr)break;if(h>=i){let y=p.dom.getBoundingClientRect();if(t.push(y.height),o){let v=p.dom.lastChild,b=v?Za(v):[];if(b.length){let S=b[b.length-1],w=c?S.right-y.left:y.right-S.left;w>a&&(a=w,this.minWidth=s,this.minWidthFrom=h,this.minWidthTo=m)}}}h=m+p.breakAfter}return t}textDirectionAt(e){let{i:t}=this.childPos(e,1);return getComputedStyle(this.children[t].dom).direction=="rtl"?st.RTL:st.LTR}measureTextSize(){for(let s of this.children)if(s instanceof xt){let o=s.measureTextSize();if(o)return o}let e=document.createElement("div"),t,i,r;return e.className="cm-line",e.style.width="99999px",e.style.position="absolute",e.textContent="abc def ghi jkl mno pqr stu",this.view.observer.ignore(()=>{this.dom.appendChild(e);let s=Za(e.firstChild)[0];t=e.getBoundingClientRect().height,i=s?s.width/27:7,r=s?s.height:t,e.remove()}),{lineHeight:t,charWidth:i,textHeight:r}}childCursor(e=this.length){let t=this.children.length;return t&&(e-=this.children[--t].length),new fP(this.children,e,t)}computeBlockGapDeco(){let e=[],t=this.view.viewState;for(let i=0,r=0;;r++){let s=r==t.viewports.length?null:t.viewports[r],o=s?s.from-1:this.length;if(o>i){let a=(t.lineBlockAt(o).bottom-t.lineBlockAt(i).top)/this.view.scaleY;e.push(Pe.replace({widget:new Bm(a),block:!0,inclusive:!0,isBlockGap:!0}).range(i,o))}if(!s)break;i=s.to+1}return Pe.set(e)}updateDeco(){let e=1,t=this.view.state.facet(Ia).map(s=>(this.dynamicDecorationMap[e++]=typeof s=="function")?s(this.view):s),i=!1,r=this.view.state.facet(LP).map((s,o)=>{let a=typeof s=="function";return a&&(i=!0),a?s(this.view):s});for(r.length&&(this.dynamicDecorationMap[e++]=i,t.push(je.join(r))),this.decorations=[this.editContextFormatting,...t,this.computeBlockGapDeco(),this.view.viewState.lineGapDeco];et.anchor?-1:1),r;if(!i)return;!t.empty&&(r=this.coordsAt(t.anchor,t.anchor>t.head?-1:1))&&(i={left:Math.min(i.left,r.left),top:Math.min(i.top,r.top),right:Math.max(i.right,r.right),bottom:Math.max(i.bottom,r.bottom)});let s=t0(this.view),o={left:i.left-s.left,top:i.top-s.top,right:i.right+s.right,bottom:i.bottom+s.bottom},{offsetWidth:a,offsetHeight:c}=this.view.scrollDOM;pR(this.view.scrollDOM,o,t.head{ie.from&&(t=!0)}),t}function NR(n,e,t=1){let i=n.charCategorizer(e),r=n.doc.lineAt(e),s=e-r.from;if(r.length==0)return V.cursor(e);s==0?t=1:s==r.length&&(t=-1);let o=s,a=s;t<0?o=Wt(r.text,s,!1):a=Wt(r.text,s);let c=i(r.text.slice(o,a));for(;o>0;){let h=Wt(r.text,o,!1);if(i(r.text.slice(h,o))!=c)break;o=h}for(;an?e.left-n:Math.max(0,n-e.right)}function WR(n,e){return e.top>n?e.top-n:Math.max(0,n-e.bottom)}function mg(n,e){return n.tope.top+1}function zv(n,e){return en.bottom?{top:n.top,left:n.left,right:n.right,bottom:e}:n}function Fm(n,e,t){let i,r,s,o,a=!1,c,h,f,p;for(let v=n.firstChild;v;v=v.nextSibling){let b=Za(v);for(let S=0;S_||o==_&&s>C)&&(i=v,r=w,s=C,o=_,a=C?e0:Sw.bottom&&(!f||f.bottomw.top)&&(h=v,p=w):f&&mg(f,w)?f=Zv(f,w.bottom):p&&mg(p,w)&&(p=zv(p,w.top))}}if(f&&f.bottom>=t?(i=c,r=f):p&&p.top<=t&&(i=h,r=p),!i)return{node:n,offset:0};let m=Math.max(r.left,Math.min(r.right,e));if(i.nodeType==3)return Iv(i,m,t);if(a&&i.contentEditable!="false")return Fm(i,m,t);let y=Array.prototype.indexOf.call(n.childNodes,i)+(e>=(r.left+r.right)/2?1:0);return{node:n,offset:y}}function Iv(n,e,t){let i=n.nodeValue.length,r=-1,s=1e9,o=0;for(let a=0;at?f.top-t:t-f.bottom)-1;if(f.left-1<=e&&f.right+1>=e&&p=(f.left+f.right)/2,y=m;if((ae.chrome||ae.gecko)&&js(n,a).getBoundingClientRect().left==f.right&&(y=!m),p<=0)return{node:n,offset:a+(y?1:0)};r=a+(y?1:0),s=p}}}return{node:n,offset:r>-1?r:o>0?n.nodeValue.length:0}}function IP(n,e,t,i=-1){var r,s;let o=n.contentDOM.getBoundingClientRect(),a=o.top+n.viewState.paddingTop,c,{docHeight:h}=n.viewState,{x:f,y:p}=e,m=p-a;if(m<0)return 0;if(m>h)return n.state.doc.length;for(let P=n.viewState.heightOracle.textHeight/2,Q=!1;c=n.elementAtHeight(m),c.type!=an.Text;)for(;m=i>0?c.bottom+P:c.top-P,!(m>=0&&m<=h);){if(Q)return t?null:0;Q=!0,i=-i}p=a+m;let y=c.from;if(yn.viewport.to)return n.viewport.to==n.state.doc.length?n.state.doc.length:t?null:jv(n,o,c,f,p);let v=n.dom.ownerDocument,b=n.root.elementFromPoint?n.root:v,S=b.elementFromPoint(f,p);S&&!n.contentDOM.contains(S)&&(S=null),S||(f=Math.max(o.left+1,Math.min(o.right-1,f)),S=b.elementFromPoint(f,p),S&&!n.contentDOM.contains(S)&&(S=null));let w,C=-1;if(S&&((r=n.docView.nearest(S))===null||r===void 0?void 0:r.isEditable)!=!1){if(v.caretPositionFromPoint){let P=v.caretPositionFromPoint(f,p);P&&({offsetNode:w,offset:C}=P)}else if(v.caretRangeFromPoint){let P=v.caretRangeFromPoint(f,p);P&&({startContainer:w,startOffset:C}=P)}w&&(!n.contentDOM.contains(w)||ae.safari&&VR(w,C,f)||ae.chrome&&FR(w,C,f))&&(w=void 0),w&&(C=Math.min(zi(w),C))}if(!w||!n.docView.dom.contains(w)){let P=xt.find(n.docView,y);if(!P)return m>c.top+c.height/2?c.to:c.from;({node:w,offset:C}=Fm(P.dom,f,p))}let _=n.docView.nearest(w);if(!_)return null;if(_.isWidget&&((s=_.dom)===null||s===void 0?void 0:s.nodeType)==1){let P=_.dom.getBoundingClientRect();return e.yn.defaultLineHeight*1.5){let a=n.viewState.heightOracle.textHeight,c=Math.floor((r-t.top-(n.defaultLineHeight-a)*.5)/a);s+=c*n.viewState.heightOracle.lineLength}let o=n.state.sliceDoc(t.from,t.to);return t.from+Mm(o,s,n.state.tabSize)}function jP(n,e,t){let i,r=n;if(n.nodeType!=3||e!=(i=n.nodeValue.length))return!1;for(;;){let s=r.nextSibling;if(s){if(s.nodeName=="BR")break;return!1}else{let o=r.parentNode;if(!o||o.nodeName=="DIV")break;r=o}}return js(n,i-1,i).getBoundingClientRect().right>t}function VR(n,e,t){return jP(n,e,t)}function FR(n,e,t){if(e!=0)return jP(n,e,t);for(let r=n;;){let s=r.parentNode;if(!s||s.nodeType!=1||s.firstChild!=r)return!1;if(s.classList.contains("cm-line"))break;r=s}let i=n.nodeType==1?n.getBoundingClientRect():js(n,0,Math.max(n.nodeValue.length,1)).getBoundingClientRect();return t-i.left>5}function Ym(n,e,t){let i=n.lineBlockAt(e);if(Array.isArray(i.type)){let r;for(let s of i.type){if(s.from>e)break;if(!(s.toe)return s;(!r||s.type==an.Text&&(r.type!=s.type||(t<0?s.frome)))&&(r=s)}}return r||i}return i}function YR(n,e,t,i){let r=Ym(n,e.head,e.assoc||-1),s=!i||r.type!=an.Text||!(n.lineWrapping||r.widgetLineBreaks)?null:n.coordsAtPos(e.assoc<0&&e.head>r.from?e.head-1:e.head);if(s){let o=n.dom.getBoundingClientRect(),a=n.textDirectionAt(r.from),c=n.posAtCoords({x:t==(a==st.LTR)?o.right-1:o.left+1,y:(s.top+s.bottom)/2});if(c!=null)return V.cursor(c,t?-1:1)}return V.cursor(t?r.to:r.from,t?-1:1)}function Bv(n,e,t,i){let r=n.state.doc.lineAt(e.head),s=n.bidiSpans(r),o=n.textDirectionAt(r.from);for(let a=e,c=null;;){let h=RR(r,s,o,a,t),f=wP;if(!h){if(r.number==(t?n.state.doc.lines:1))return a;f=` -`,r=n.state.doc.line(r.number+(t?1:-1)),s=n.bidiSpans(r),h=n.visualLineSide(r,!t)}if(c){if(!c(f))return a}else{if(!i)return h;c=i(f)}a=h}}function qR(n,e,t){let i=n.state.charCategorizer(e),r=i(t);return s=>{let o=i(s);return r==at.Space&&(r=o),r==o}}function UR(n,e,t,i){let r=e.head,s=t?1:-1;if(r==(t?n.state.doc.length:0))return V.cursor(r,e.assoc);let o=e.goalColumn,a,c=n.contentDOM.getBoundingClientRect(),h=n.coordsAtPos(r,e.assoc||-1),f=n.documentTop;if(h)o==null&&(o=h.left-c.left),a=s<0?h.top:h.bottom;else{let y=n.viewState.lineBlockAt(r);o==null&&(o=Math.min(c.right-c.left,n.defaultCharacterWidth*(r-y.from))),a=(s<0?y.top:y.bottom)+f}let p=c.left+o,m=i!=null?i:n.viewState.heightOracle.textHeight>>1;for(let y=0;;y+=10){let v=a+(m+y)*s,b=IP(n,{x:p,y:v},!1,s);if(vc.bottom||(s<0?br)){let S=n.docView.coordsForChar(b),w=!S||v{if(e>s&&er(n)),t.from,e.head>t.from?-1:1);return i==t.from?t:V.cursor(i,is)&&!KR(o,t)&&this.lineBreak(),r=o}return this.findPointBefore(i,t),this}readTextNode(e){let t=e.nodeValue;for(let i of this.points)i.node==e&&(i.pos=this.text.length+Math.min(i.offset,t.length));for(let i=0,r=this.lineSeparator?null:/\r\n?|\n/g;;){let s=-1,o=1,a;if(this.lineSeparator?(s=t.indexOf(this.lineSeparator,i),o=this.lineSeparator.length):(a=r.exec(t))&&(s=a.index,o=a[0].length),this.append(t.slice(i,s<0?t.length:s)),s<0)break;if(this.lineBreak(),o>1)for(let c of this.points)c.node==e&&c.pos>this.text.length&&(c.pos-=o-1);i=s+o}}readNode(e){if(e.cmIgnore)return;let t=Ue.get(e),i=t&&t.overrideDOMText;if(i!=null){this.findPointInside(e,i.length);for(let r=i.iter();!r.next().done;)r.lineBreak?this.lineBreak():this.append(r.value)}else e.nodeType==3?this.readTextNode(e):e.nodeName=="BR"?e.nextSibling&&this.lineBreak():e.nodeType==1&&this.readRange(e.firstChild,null)}findPointBefore(e,t){for(let i of this.points)i.node==e&&e.childNodes[i.offset]==t&&(i.pos=this.text.length)}findPointInside(e,t){for(let i of this.points)(e.nodeType==3?i.node==e:e.contains(i.node))&&(i.pos=this.text.length+(GR(e,i.node,i.offset)?t:0))}}function GR(n,e,t){for(;;){if(!e||t-1;let{impreciseHead:s,impreciseAnchor:o}=e.docView;if(e.state.readOnly&&t>-1)this.newSel=null;else if(t>-1&&(this.bounds=e.docView.domBoundsAround(t,i,0))){let a=s||o?[]:tA(e),c=new HR(a,e.state);c.readRange(this.bounds.startDOM,this.bounds.endDOM),this.text=c.text,this.newSel=nA(a,this.bounds.from)}else{let a=e.observer.selectionRange,c=s&&s.node==a.focusNode&&s.offset==a.focusOffset||!Zm(e.contentDOM,a.focusNode)?e.state.selection.main.head:e.docView.posFromDOM(a.focusNode,a.focusOffset),h=o&&o.node==a.anchorNode&&o.offset==a.anchorOffset||!Zm(e.contentDOM,a.anchorNode)?e.state.selection.main.anchor:e.docView.posFromDOM(a.anchorNode,a.anchorOffset),f=e.viewport;if((ae.ios||ae.chrome)&&e.state.selection.main.empty&&c!=h&&(f.from>0||f.toDate.now()-100?n.inputState.lastKeyCode:-1;if(e.bounds){let{from:o,to:a}=e.bounds,c=r.from,h=null;(s===8||ae.android&&e.text.length=r.from&&t.to<=r.to&&(t.from!=r.from||t.to!=r.to)&&r.to-r.from-(t.to-t.from)<=4?t={from:r.from,to:r.to,insert:n.state.doc.slice(r.from,t.from).append(t.insert).append(n.state.doc.slice(t.to,r.to))}:ae.chrome&&t&&t.from==t.to&&t.from==r.head&&t.insert.toString()==` - `&&n.lineWrapping&&(i&&(i=V.single(i.main.anchor-1,i.main.head-1)),t={from:r.from,to:r.to,insert:ze.of([" "])}),t)return n0(n,t,i,s);if(i&&!i.main.eq(r)){let o=!1,a="select";return n.inputState.lastSelectionTime>Date.now()-50&&(n.inputState.lastSelectionOrigin=="select"&&(o=!0),a=n.inputState.lastSelectionOrigin,a=="select.pointer"&&(i=BP(n.state.facet(uc).map(c=>c(n)),i))),n.dispatch({selection:i,scrollIntoView:o,userEvent:a}),!0}else return!1}function n0(n,e,t,i=-1){if(ae.ios&&n.inputState.flushIOSKey(e))return!0;let r=n.state.selection.main;if(ae.android&&(e.to==r.to&&(e.from==r.from||e.from==r.from-1&&n.state.sliceDoc(e.from,r.from)==" ")&&e.insert.length==1&&e.insert.lines==2&&Ro(n.contentDOM,"Enter",13)||(e.from==r.from-1&&e.to==r.to&&e.insert.length==0||i==8&&e.insert.lengthr.head)&&Ro(n.contentDOM,"Backspace",8)||e.from==r.from&&e.to==r.to+1&&e.insert.length==0&&Ro(n.contentDOM,"Delete",46)))return!0;let s=e.insert.toString();n.inputState.composing>=0&&n.inputState.composing++;let o,a=()=>o||(o=eA(n,e,t));return n.state.facet(CP).some(c=>c(n,e.from,e.to,s,a))||n.dispatch(a()),!0}function eA(n,e,t){let i,r=n.state,s=r.selection.main,o=-1;if(e.from==e.to&&e.froms.to){let c=e.fromp(n)),h,c);e.from==f&&(o=f)}if(o>-1)i={changes:e,selection:V.cursor(e.from+e.insert.length,-1)};else if(e.from>=s.from&&e.to<=s.to&&e.to-e.from>=(s.to-s.from)/3&&(!t||t.main.empty&&t.main.from==e.from+e.insert.length)&&n.inputState.composing<0){let c=s.frome.to?r.sliceDoc(e.to,s.to):"";i=r.replaceSelection(n.state.toText(c+e.insert.sliceString(0,void 0,n.state.lineBreak)+h))}else{let c=r.changes(e),h=t&&t.main.to<=c.newLength?t.main:void 0;if(r.selection.ranges.length>1&&n.inputState.composing>=0&&e.to<=s.to&&e.to>=s.to-10){let f=n.state.sliceDoc(e.from,e.to),p,m=t&&ZP(n,t.main.head);if(m){let b=e.insert.length-(e.to-e.from);p={from:m.from,to:m.to-b}}else p=n.state.doc.lineAt(s.head);let y=s.to-e.to,v=s.to-s.from;i=r.changeByRange(b=>{if(b.from==s.from&&b.to==s.to)return{changes:c,range:h||b.map(c)};let S=b.to-y,w=S-f.length;if(b.to-b.from!=v||n.state.sliceDoc(w,S)!=f||b.to>=p.from&&b.from<=p.to)return{range:b};let C=r.changes({from:w,to:S,insert:e.insert}),_=b.to-s.to;return{changes:C,range:h?V.range(Math.max(0,h.anchor+_),Math.max(0,h.head+_)):b.map(C)}})}else i={changes:c,selection:h&&r.selection.replaceRange(h)}}let a="input.type";return(n.composing||n.inputState.compositionPendingChange&&n.inputState.compositionEndedAt>Date.now()-50)&&(n.inputState.compositionPendingChange=!1,a+=".compose",n.inputState.compositionFirstChange&&(a+=".start",n.inputState.compositionFirstChange=!1)),r.update(i,{userEvent:a,scrollIntoView:!0})}function XP(n,e,t,i){let r=Math.min(n.length,e.length),s=0;for(;s0&&a>0&&n.charCodeAt(o-1)==e.charCodeAt(a-1);)o--,a--;if(i=="end"){let c=Math.max(0,s-Math.min(o,a));t-=o+c-s}if(o=o?s-t:0;s-=c,a=s+(a-o),o=s}else if(a=a?s-t:0;s-=c,o=s+(o-a),a=s}return{from:s,toA:o,toB:a}}function tA(n){let e=[];if(n.root.activeElement!=n.contentDOM)return e;let{anchorNode:t,anchorOffset:i,focusNode:r,focusOffset:s}=n.observer.selectionRange;return t&&(e.push(new Nv(t,i)),(r!=t||s!=i)&&e.push(new Nv(r,s))),e}function nA(n,e){if(n.length==0)return null;let t=n[0].pos,i=n.length==2?n[1].pos:t;return t>-1&&i>-1?V.single(t+e,i+e):null}class iA{setSelectionOrigin(e){this.lastSelectionOrigin=e,this.lastSelectionTime=Date.now()}constructor(e){this.view=e,this.lastKeyCode=0,this.lastKeyTime=0,this.lastTouchTime=0,this.lastFocusTime=0,this.lastScrollTop=0,this.lastScrollLeft=0,this.pendingIOSKey=void 0,this.tabFocusMode=-1,this.lastSelectionOrigin=null,this.lastSelectionTime=0,this.lastContextMenu=0,this.scrollHandlers=[],this.handlers=Object.create(null),this.composing=-1,this.compositionFirstChange=null,this.compositionEndedAt=0,this.compositionPendingKey=!1,this.compositionPendingChange=!1,this.mouseSelection=null,this.draggedContent=null,this.handleEvent=this.handleEvent.bind(this),this.notifiedFocused=e.hasFocus,ae.safari&&e.contentDOM.addEventListener("input",()=>null),ae.gecko&&xA(e.contentDOM.ownerDocument)}handleEvent(e){!hA(this.view,e)||this.ignoreDuringComposition(e)||e.type=="keydown"&&this.keydown(e)||(this.view.updateState!=0?Promise.resolve().then(()=>this.runHandlers(e.type,e)):this.runHandlers(e.type,e))}runHandlers(e,t){let i=this.handlers[e];if(i){for(let r of i.observers)r(this.view,t);for(let r of i.handlers){if(t.defaultPrevented)break;if(r(this.view,t)){t.preventDefault();break}}}}ensureHandlers(e){let t=rA(e),i=this.handlers,r=this.view.contentDOM;for(let s in t)if(s!="scroll"){let o=!t[s].handlers.length,a=i[s];a&&o!=!a.handlers.length&&(r.removeEventListener(s,this.handleEvent),a=null),a||r.addEventListener(s,this.handleEvent,{passive:o})}for(let s in i)s!="scroll"&&!t[s]&&r.removeEventListener(s,this.handleEvent);this.handlers=t}keydown(e){if(this.lastKeyCode=e.keyCode,this.lastKeyTime=Date.now(),e.keyCode==9&&this.tabFocusMode>-1&&(!this.tabFocusMode||Date.now()<=this.tabFocusMode))return!0;if(this.tabFocusMode>0&&e.keyCode!=27&&VP.indexOf(e.keyCode)<0&&(this.tabFocusMode=-1),ae.android&&ae.chrome&&!e.synthetic&&(e.keyCode==13||e.keyCode==8))return this.view.observer.delayAndroidKey(e.key,e.keyCode),!0;let t;return ae.ios&&!e.synthetic&&!e.altKey&&!e.metaKey&&((t=WP.find(i=>i.keyCode==e.keyCode))&&!e.ctrlKey||sA.indexOf(e.key)>-1&&e.ctrlKey&&!e.shiftKey)?(this.pendingIOSKey=t||e,setTimeout(()=>this.flushIOSKey(),250),!0):(e.keyCode!=229&&this.view.observer.forceFlush(),!1)}flushIOSKey(e){let t=this.pendingIOSKey;return!t||t.key=="Enter"&&e&&e.from0?!0:ae.safari&&!ae.ios&&this.compositionPendingKey&&Date.now()-this.compositionEndedAt<100?(this.compositionPendingKey=!1,!0):!1}startMouseSelection(e){this.mouseSelection&&this.mouseSelection.destroy(),this.mouseSelection=e}update(e){this.view.observer.update(e),this.mouseSelection&&this.mouseSelection.update(e),this.draggedContent&&e.docChanged&&(this.draggedContent=this.draggedContent.map(e.changes)),e.transactions.length&&(this.lastKeyCode=this.lastSelectionTime=0)}destroy(){this.mouseSelection&&this.mouseSelection.destroy()}}function Xv(n,e){return(t,i)=>{try{return e.call(n,i,t)}catch(r){bn(t.state,r)}}}function rA(n){let e=Object.create(null);function t(i){return e[i]||(e[i]={observers:[],handlers:[]})}for(let i of n){let r=i.spec,s=r&&r.plugin.domEventHandlers,o=r&&r.plugin.domEventObservers;if(s)for(let a in s){let c=s[a];c&&t(a).handlers.push(Xv(i.value,c))}if(o)for(let a in o){let c=o[a];c&&t(a).observers.push(Xv(i.value,c))}}for(let i in hi)t(i).handlers.push(hi[i]);for(let i in qn)t(i).observers.push(qn[i]);return e}const WP=[{key:"Backspace",keyCode:8,inputType:"deleteContentBackward"},{key:"Enter",keyCode:13,inputType:"insertParagraph"},{key:"Enter",keyCode:13,inputType:"insertLineBreak"},{key:"Delete",keyCode:46,inputType:"deleteContentForward"}],sA="dthko",VP=[16,17,18,20,91,92,224,225],Zu=6;function Iu(n){return Math.max(0,n)*.7+8}function oA(n,e){return Math.max(Math.abs(n.clientX-e.clientX),Math.abs(n.clientY-e.clientY))}class lA{constructor(e,t,i,r){this.view=e,this.startEvent=t,this.style=i,this.mustSelect=r,this.scrollSpeed={x:0,y:0},this.scrolling=-1,this.lastEvent=t,this.scrollParents=gR(e.contentDOM),this.atoms=e.state.facet(uc).map(o=>o(e));let s=e.contentDOM.ownerDocument;s.addEventListener("mousemove",this.move=this.move.bind(this)),s.addEventListener("mouseup",this.up=this.up.bind(this)),this.extend=t.shiftKey,this.multiple=e.state.facet(Ie.allowMultipleSelections)&&aA(e,t),this.dragging=uA(e,t)&&qP(t)==1?null:!1}start(e){this.dragging===!1&&this.select(e)}move(e){if(e.buttons==0)return this.destroy();if(this.dragging||this.dragging==null&&oA(this.startEvent,e)<10)return;this.select(this.lastEvent=e);let t=0,i=0,r=0,s=0,o=this.view.win.innerWidth,a=this.view.win.innerHeight;this.scrollParents.x&&({left:r,right:o}=this.scrollParents.x.getBoundingClientRect()),this.scrollParents.y&&({top:s,bottom:a}=this.scrollParents.y.getBoundingClientRect());let c=t0(this.view);e.clientX-c.left<=r+Zu?t=-Iu(r-e.clientX):e.clientX+c.right>=o-Zu&&(t=Iu(e.clientX-o)),e.clientY-c.top<=s+Zu?i=-Iu(s-e.clientY):e.clientY+c.bottom>=a-Zu&&(i=Iu(e.clientY-a)),this.setScrollSpeed(t,i)}up(e){this.dragging==null&&this.select(this.lastEvent),this.dragging||e.preventDefault(),this.destroy()}destroy(){this.setScrollSpeed(0,0);let e=this.view.contentDOM.ownerDocument;e.removeEventListener("mousemove",this.move),e.removeEventListener("mouseup",this.up),this.view.inputState.mouseSelection=this.view.inputState.draggedContent=null}setScrollSpeed(e,t){this.scrollSpeed={x:e,y:t},e||t?this.scrolling<0&&(this.scrolling=setInterval(()=>this.scroll(),50)):this.scrolling>-1&&(clearInterval(this.scrolling),this.scrolling=-1)}scroll(){let{x:e,y:t}=this.scrollSpeed;e&&this.scrollParents.x&&(this.scrollParents.x.scrollLeft+=e,e=0),t&&this.scrollParents.y&&(this.scrollParents.y.scrollTop+=t,t=0),(e||t)&&this.view.win.scrollBy(e,t),this.dragging===!1&&this.select(this.lastEvent)}select(e){let{view:t}=this,i=BP(this.atoms,this.style.get(e,this.extend,this.multiple));(this.mustSelect||!i.eq(t.state.selection,this.dragging===!1))&&this.view.dispatch({selection:i,userEvent:"select.pointer"}),this.mustSelect=!1}update(e){e.transactions.some(t=>t.isUserEvent("input.type"))?this.destroy():this.style.update(e)&&setTimeout(()=>this.select(this.lastEvent),20)}}function aA(n,e){let t=n.state.facet(kP);return t.length?t[0](e):ae.mac?e.metaKey:e.ctrlKey}function cA(n,e){let t=n.state.facet(PP);return t.length?t[0](e):ae.mac?!e.altKey:!e.ctrlKey}function uA(n,e){let{main:t}=n.state.selection;if(t.empty)return!1;let i=za(n.root);if(!i||i.rangeCount==0)return!0;let r=i.getRangeAt(0).getClientRects();for(let s=0;s=e.clientX&&o.top<=e.clientY&&o.bottom>=e.clientY)return!0}return!1}function hA(n,e){if(!e.bubbles)return!0;if(e.defaultPrevented)return!1;for(let t=e.target,i;t!=n.contentDOM;t=t.parentNode)if(!t||t.nodeType==11||(i=Ue.get(t))&&i.ignoreEvent(e))return!1;return!0}const hi=Object.create(null),qn=Object.create(null),FP=ae.ie&&ae.ie_version<15||ae.ios&&ae.webkit_version<604;function fA(n){let e=n.dom.parentNode;if(!e)return;let t=e.appendChild(document.createElement("textarea"));t.style.cssText="position: fixed; left: -10000px; top: 10px",t.focus(),setTimeout(()=>{n.focus(),t.remove(),YP(n,t.value)},50)}function Nf(n,e,t){for(let i of n.facet(e))t=i(t,n);return t}function YP(n,e){e=Nf(n.state,KO,e);let{state:t}=n,i,r=1,s=t.toText(e),o=s.lines==t.selection.ranges.length;if(qm!=null&&t.selection.ranges.every(c=>c.empty)&&qm==s.toString()){let c=-1;i=t.changeByRange(h=>{let f=t.doc.lineAt(h.from);if(f.from==c)return{range:h};c=f.from;let p=t.toText((o?s.line(r++).text:e)+t.lineBreak);return{changes:{from:f.from,insert:p},range:V.cursor(h.from+p.length)}})}else o?i=t.changeByRange(c=>{let h=s.line(r++);return{changes:{from:c.from,to:c.to,insert:h.text},range:V.cursor(c.from+h.length)}}):i=t.replaceSelection(s);n.dispatch(i,{userEvent:"input.paste",scrollIntoView:!0})}qn.scroll=n=>{n.inputState.lastScrollTop=n.scrollDOM.scrollTop,n.inputState.lastScrollLeft=n.scrollDOM.scrollLeft};hi.keydown=(n,e)=>(n.inputState.setSelectionOrigin("select"),e.keyCode==27&&n.inputState.tabFocusMode!=0&&(n.inputState.tabFocusMode=Date.now()+2e3),!1);qn.touchstart=(n,e)=>{n.inputState.lastTouchTime=Date.now(),n.inputState.setSelectionOrigin("select.pointer")};qn.touchmove=n=>{n.inputState.setSelectionOrigin("select.pointer")};hi.mousedown=(n,e)=>{if(n.observer.flush(),n.inputState.lastTouchTime>Date.now()-2e3)return!1;let t=null;for(let i of n.state.facet(_P))if(t=i(n,e),t)break;if(!t&&e.button==0&&(t=gA(n,e)),t){let i=!n.hasFocus;n.inputState.startMouseSelection(new lA(n,e,t,i)),i&&n.observer.ignore(()=>{lP(n.contentDOM);let s=n.root.activeElement;s&&!s.contains(n.contentDOM)&&s.blur()});let r=n.inputState.mouseSelection;if(r)return r.start(e),r.dragging===!1}else n.inputState.setSelectionOrigin("select.pointer");return!1};function Wv(n,e,t,i){if(i==1)return V.cursor(e,t);if(i==2)return NR(n.state,e,t);{let r=xt.find(n.docView,e),s=n.state.doc.lineAt(r?r.posAtEnd:e),o=r?r.posAtStart:s.from,a=r?r.posAtEnd:s.to;return ae>=t.top&&e<=t.bottom&&n>=t.left&&n<=t.right;function dA(n,e,t,i){let r=xt.find(n.docView,e);if(!r)return 1;let s=e-r.posAtStart;if(s==0)return 1;if(s==r.length)return-1;let o=r.coordsAt(s,-1);if(o&&Vv(t,i,o))return-1;let a=r.coordsAt(s,1);return a&&Vv(t,i,a)?1:o&&o.bottom>=i?-1:1}function Fv(n,e){let t=n.posAtCoords({x:e.clientX,y:e.clientY},!1);return{pos:t,bias:dA(n,t,e.clientX,e.clientY)}}const pA=ae.ie&&ae.ie_version<=11;let Yv=null,qv=0,Uv=0;function qP(n){if(!pA)return n.detail;let e=Yv,t=Uv;return Yv=n,Uv=Date.now(),qv=!e||t>Date.now()-400&&Math.abs(e.clientX-n.clientX)<2&&Math.abs(e.clientY-n.clientY)<2?(qv+1)%3:1}function gA(n,e){let t=Fv(n,e),i=qP(e),r=n.state.selection;return{update(s){s.docChanged&&(t.pos=s.changes.mapPos(t.pos),r=r.map(s.changes))},get(s,o,a){let c=Fv(n,s),h,f=Wv(n,c.pos,c.bias,i);if(t.pos!=c.pos&&!o){let p=Wv(n,t.pos,t.bias,i),m=Math.min(p.from,f.from),y=Math.max(p.to,f.to);f=m1&&(h=mA(r,c.pos))?h:a?r.addRange(f):V.create([f])}}}function mA(n,e){for(let t=0;t=e)return V.create(n.ranges.slice(0,t).concat(n.ranges.slice(t+1)),n.mainIndex==t?0:n.mainIndex-(n.mainIndex>t?1:0))}return null}hi.dragstart=(n,e)=>{let{selection:{main:t}}=n.state;if(e.target.draggable){let r=n.docView.nearest(e.target);if(r&&r.isWidget){let s=r.posAtStart,o=s+r.length;(s>=t.to||o<=t.from)&&(t=V.range(s,o))}}let{inputState:i}=n;return i.mouseSelection&&(i.mouseSelection.dragging=!0),i.draggedContent=t,e.dataTransfer&&(e.dataTransfer.setData("Text",Nf(n.state,JO,n.state.sliceDoc(t.from,t.to))),e.dataTransfer.effectAllowed="copyMove"),!1};hi.dragend=n=>(n.inputState.draggedContent=null,!1);function Hv(n,e,t,i){if(t=Nf(n.state,KO,t),!t)return;let r=n.posAtCoords({x:e.clientX,y:e.clientY},!1),{draggedContent:s}=n.inputState,o=i&&s&&cA(n,e)?{from:s.from,to:s.to}:null,a={from:r,insert:t},c=n.state.changes(o?[o,a]:a);n.focus(),n.dispatch({changes:c,selection:{anchor:c.mapPos(r,-1),head:c.mapPos(r,1)},userEvent:o?"move.drop":"input.drop"}),n.inputState.draggedContent=null}hi.drop=(n,e)=>{if(!e.dataTransfer)return!1;if(n.state.readOnly)return!0;let t=e.dataTransfer.files;if(t&&t.length){let i=Array(t.length),r=0,s=()=>{++r==t.length&&Hv(n,e,i.filter(o=>o!=null).join(n.state.lineBreak),!1)};for(let o=0;o{/[\x00-\x08\x0e-\x1f]{2}/.test(a.result)||(i[o]=a.result),s()},a.readAsText(t[o])}return!0}else{let i=e.dataTransfer.getData("Text");if(i)return Hv(n,e,i,!0),!0}return!1};hi.paste=(n,e)=>{if(n.state.readOnly)return!0;n.observer.flush();let t=FP?null:e.clipboardData;return t?(YP(n,t.getData("text/plain")||t.getData("text/uri-list")),!0):(fA(n),!1)};function OA(n,e){let t=n.dom.parentNode;if(!t)return;let i=t.appendChild(document.createElement("textarea"));i.style.cssText="position: fixed; left: -10000px; top: 10px",i.value=e,i.focus(),i.selectionEnd=e.length,i.selectionStart=0,setTimeout(()=>{i.remove(),n.focus()},50)}function yA(n){let e=[],t=[],i=!1;for(let r of n.selection.ranges)r.empty||(e.push(n.sliceDoc(r.from,r.to)),t.push(r));if(!e.length){let r=-1;for(let{from:s}of n.selection.ranges){let o=n.doc.lineAt(s);o.number>r&&(e.push(o.text),t.push({from:o.from,to:Math.min(n.doc.length,o.to+1)})),r=o.number}i=!0}return{text:Nf(n,JO,e.join(n.lineBreak)),ranges:t,linewise:i}}let qm=null;hi.copy=hi.cut=(n,e)=>{let{text:t,ranges:i,linewise:r}=yA(n.state);if(!t&&!r)return!1;qm=r?t:null,e.type=="cut"&&!n.state.readOnly&&n.dispatch({changes:i,scrollIntoView:!0,userEvent:"delete.cut"});let s=FP?null:e.clipboardData;return s?(s.clearData(),s.setData("text/plain",t),!0):(OA(n,t),!1)};const UP=ar.define();function HP(n,e){let t=[];for(let i of n.facet(TP)){let r=i(n,e);r&&t.push(r)}return t.length?n.update({effects:t,annotations:UP.of(!0)}):null}function GP(n){setTimeout(()=>{let e=n.hasFocus;if(e!=n.inputState.notifiedFocused){let t=HP(n.state,e);t?n.dispatch(t):n.update([])}},10)}qn.focus=n=>{n.inputState.lastFocusTime=Date.now(),!n.scrollDOM.scrollTop&&(n.inputState.lastScrollTop||n.inputState.lastScrollLeft)&&(n.scrollDOM.scrollTop=n.inputState.lastScrollTop,n.scrollDOM.scrollLeft=n.inputState.lastScrollLeft),GP(n)};qn.blur=n=>{n.observer.clearSelectionRange(),GP(n)};qn.compositionstart=qn.compositionupdate=n=>{n.observer.editContext||(n.inputState.compositionFirstChange==null&&(n.inputState.compositionFirstChange=!0),n.inputState.composing<0&&(n.inputState.composing=0))};qn.compositionend=n=>{n.observer.editContext||(n.inputState.composing=-1,n.inputState.compositionEndedAt=Date.now(),n.inputState.compositionPendingKey=!0,n.inputState.compositionPendingChange=n.observer.pendingRecords().length>0,n.inputState.compositionFirstChange=null,ae.chrome&&ae.android?n.observer.flushSoon():n.inputState.compositionPendingChange?Promise.resolve().then(()=>n.observer.flush()):setTimeout(()=>{n.inputState.composing<0&&n.docView.hasComposition&&n.update([])},50))};qn.contextmenu=n=>{n.inputState.lastContextMenu=Date.now()};hi.beforeinput=(n,e)=>{var t,i;if(e.inputType=="insertReplacementText"&&n.observer.editContext){let s=(t=e.dataTransfer)===null||t===void 0?void 0:t.getData("text/plain"),o=e.getTargetRanges();if(s&&o.length){let a=o[0],c=n.posAtDOM(a.startContainer,a.startOffset),h=n.posAtDOM(a.endContainer,a.endOffset);return n0(n,{from:c,to:h,insert:n.state.toText(s)},null),!0}}let r;if(ae.chrome&&ae.android&&(r=WP.find(s=>s.inputType==e.inputType))&&(n.observer.delayAndroidKey(r.key,r.keyCode),r.key=="Backspace"||r.key=="Delete")){let s=((i=window.visualViewport)===null||i===void 0?void 0:i.height)||0;setTimeout(()=>{var o;(((o=window.visualViewport)===null||o===void 0?void 0:o.height)||0)>s+10&&n.hasFocus&&(n.contentDOM.blur(),n.focus())},100)}return ae.ios&&e.inputType=="deleteContentForward"&&n.observer.flushSoon(),ae.safari&&e.inputType=="insertText"&&n.inputState.composing>=0&&setTimeout(()=>qn.compositionend(n,e),20),!1};const Gv=new Set;function xA(n){Gv.has(n)||(Gv.add(n),n.addEventListener("copy",()=>{}),n.addEventListener("cut",()=>{}))}const Kv=["pre-wrap","normal","pre-line","break-spaces"];let Yo=!1;function Jv(){Yo=!1}class vA{constructor(e){this.lineWrapping=e,this.doc=ze.empty,this.heightSamples={},this.lineHeight=14,this.charWidth=7,this.textHeight=14,this.lineLength=30}heightForGap(e,t){let i=this.doc.lineAt(t).number-this.doc.lineAt(e).number+1;return this.lineWrapping&&(i+=Math.max(0,Math.ceil((t-e-i*this.lineLength*.5)/this.lineLength))),this.lineHeight*i}heightForLine(e){return this.lineWrapping?(1+Math.max(0,Math.ceil((e-this.lineLength)/Math.max(1,this.lineLength-5))))*this.lineHeight:this.lineHeight}setDoc(e){return this.doc=e,this}mustRefreshForWrapping(e){return Kv.indexOf(e)>-1!=this.lineWrapping}mustRefreshForHeights(e){let t=!1;for(let i=0;i-1,c=Math.round(t)!=Math.round(this.lineHeight)||this.lineWrapping!=a;if(this.lineWrapping=a,this.lineHeight=t,this.charWidth=i,this.textHeight=r,this.lineLength=s,c){this.heightSamples={};for(let h=0;h0}set outdated(e){this.flags=(e?2:0)|this.flags&-3}setHeight(e){this.height!=e&&(Math.abs(this.height-e)>Qh&&(Yo=!0),this.height=e)}replace(e,t,i){return cn.of(i)}decomposeLeft(e,t){t.push(this)}decomposeRight(e,t){t.push(this)}applyChanges(e,t,i,r){let s=this,o=i.doc;for(let a=r.length-1;a>=0;a--){let{fromA:c,toA:h,fromB:f,toB:p}=r[a],m=s.lineAt(c,rt.ByPosNoHeight,i.setDoc(t),0,0),y=m.to>=h?m:s.lineAt(h,rt.ByPosNoHeight,i,0,0);for(p+=y.to-h,h=y.to;a>0&&m.from<=r[a-1].toA;)c=r[a-1].fromA,f=r[a-1].fromB,a--,cs*2){let a=e[t-1];a.break?e.splice(--t,1,a.left,null,a.right):e.splice(--t,1,a.left,a.right),i+=1+a.break,r-=a.size}else if(s>r*2){let a=e[i];a.break?e.splice(i,1,a.left,null,a.right):e.splice(i,1,a.left,a.right),i+=2+a.break,s-=a.size}else break;else if(r=s&&o(this.blockAt(0,i,r,s))}updateHeight(e,t=0,i=!1,r){return r&&r.from<=t&&r.more&&this.setHeight(r.heights[r.index++]),this.outdated=!1,this}toString(){return`block(${this.length})`}}class Rn extends KP{constructor(e,t){super(e,t,null),this.collapsed=0,this.widgetHeight=0,this.breaks=0}blockAt(e,t,i,r){return new Mi(r,this.length,i,this.height,this.breaks)}replace(e,t,i){let r=i[0];return i.length==1&&(r instanceof Rn||r instanceof Bt&&r.flags&4)&&Math.abs(this.length-r.length)<10?(r instanceof Bt?r=new Rn(r.length,this.height):r.height=this.height,this.outdated||(r.outdated=!1),r):cn.of(i)}updateHeight(e,t=0,i=!1,r){return r&&r.from<=t&&r.more?this.setHeight(r.heights[r.index++]):(i||this.outdated)&&this.setHeight(Math.max(this.widgetHeight,e.heightForLine(this.length-this.collapsed))+this.breaks*e.lineHeight),this.outdated=!1,this}toString(){return`line(${this.length}${this.collapsed?-this.collapsed:""}${this.widgetHeight?":"+this.widgetHeight:""})`}}class Bt extends cn{constructor(e){super(e,0)}heightMetrics(e,t){let i=e.doc.lineAt(t).number,r=e.doc.lineAt(t+this.length).number,s=r-i+1,o,a=0;if(e.lineWrapping){let c=Math.min(this.height,e.lineHeight*s);o=c/s,this.length>s+1&&(a=(this.height-c)/(this.length-s-1))}else o=this.height/s;return{firstLine:i,lastLine:r,perLine:o,perChar:a}}blockAt(e,t,i,r){let{firstLine:s,lastLine:o,perLine:a,perChar:c}=this.heightMetrics(t,r);if(t.lineWrapping){let h=r+(e0){let s=i[i.length-1];s instanceof Bt?i[i.length-1]=new Bt(s.length+r):i.push(null,new Bt(r-1))}if(e>0){let s=i[0];s instanceof Bt?i[0]=new Bt(e+s.length):i.unshift(new Bt(e-1),null)}return cn.of(i)}decomposeLeft(e,t){t.push(new Bt(e-1),null)}decomposeRight(e,t){t.push(null,new Bt(this.length-e-1))}updateHeight(e,t=0,i=!1,r){let s=t+this.length;if(r&&r.from<=t+this.length&&r.more){let o=[],a=Math.max(t,r.from),c=-1;for(r.from>t&&o.push(new Bt(r.from-t-1).updateHeight(e,t));a<=s&&r.more;){let f=e.doc.lineAt(a).length;o.length&&o.push(null);let p=r.heights[r.index++];c==-1?c=p:Math.abs(p-c)>=Qh&&(c=-2);let m=new Rn(f,p);m.outdated=!1,o.push(m),a+=f+1}a<=s&&o.push(null,new Bt(s-a).updateHeight(e,a));let h=cn.of(o);return(c<0||Math.abs(h.height-this.height)>=Qh||Math.abs(c-this.heightMetrics(e,t).perLine)>=Qh)&&(Yo=!0),Jh(this,h)}else(i||this.outdated)&&(this.setHeight(e.heightForGap(t,t+this.length)),this.outdated=!1);return this}toString(){return`gap(${this.length})`}}class SA extends cn{constructor(e,t,i){super(e.length+t+i.length,e.height+i.height,t|(e.outdated||i.outdated?2:0)),this.left=e,this.right=i,this.size=e.size+i.size}get break(){return this.flags&1}blockAt(e,t,i,r){let s=i+this.left.height;return ea))return h;let f=t==rt.ByPosNoHeight?rt.ByPosNoHeight:rt.ByPos;return c?h.join(this.right.lineAt(a,f,i,o,a)):this.left.lineAt(a,f,i,r,s).join(h)}forEachLine(e,t,i,r,s,o){let a=r+this.left.height,c=s+this.left.length+this.break;if(this.break)e=c&&this.right.forEachLine(e,t,i,a,c,o);else{let h=this.lineAt(c,rt.ByPos,i,r,s);e=e&&h.from<=t&&o(h),t>h.to&&this.right.forEachLine(h.to+1,t,i,a,c,o)}}replace(e,t,i){let r=this.left.length+this.break;if(tthis.left.length)return this.balanced(this.left,this.right.replace(e-r,t-r,i));let s=[];e>0&&this.decomposeLeft(e,s);let o=s.length;for(let a of i)s.push(a);if(e>0&&eb(s,o-1),t=i&&t.push(null)),e>i&&this.right.decomposeLeft(e-i,t)}decomposeRight(e,t){let i=this.left.length,r=i+this.break;if(e>=r)return this.right.decomposeRight(e-r,t);e2*t.size||t.size>2*e.size?cn.of(this.break?[e,null,t]:[e,t]):(this.left=Jh(this.left,e),this.right=Jh(this.right,t),this.setHeight(e.height+t.height),this.outdated=e.outdated||t.outdated,this.size=e.size+t.size,this.length=e.length+this.break+t.length,this)}updateHeight(e,t=0,i=!1,r){let{left:s,right:o}=this,a=t+s.length+this.break,c=null;return r&&r.from<=t+s.length&&r.more?c=s=s.updateHeight(e,t,i,r):s.updateHeight(e,t,i),r&&r.from<=a+o.length&&r.more?c=o=o.updateHeight(e,a,i,r):o.updateHeight(e,a,i),c?this.balanced(s,o):(this.height=this.left.height+this.right.height,this.outdated=!1,this)}toString(){return this.left+(this.break?" ":"-")+this.right}}function eb(n,e){let t,i;n[e]==null&&(t=n[e-1])instanceof Bt&&(i=n[e+1])instanceof Bt&&n.splice(e-1,3,new Bt(t.length+1+i.length))}const wA=5;class i0{constructor(e,t){this.pos=e,this.oracle=t,this.nodes=[],this.lineStart=-1,this.lineEnd=-1,this.covering=null,this.writtenTo=e}get isCovered(){return this.covering&&this.nodes[this.nodes.length-1]==this.covering}span(e,t){if(this.lineStart>-1){let i=Math.min(t,this.lineEnd),r=this.nodes[this.nodes.length-1];r instanceof Rn?r.length+=i-this.pos:(i>this.pos||!this.isCovered)&&this.nodes.push(new Rn(i-this.pos,-1)),this.writtenTo=i,t>i&&(this.nodes.push(null),this.writtenTo++,this.lineStart=-1)}this.pos=t}point(e,t,i){if(e=wA)&&this.addLineDeco(r,s,o)}else t>e&&this.span(e,t);this.lineEnd>-1&&this.lineEnd-1)return;let{from:e,to:t}=this.oracle.doc.lineAt(this.pos);this.lineStart=e,this.lineEnd=t,this.writtenToe&&this.nodes.push(new Rn(this.pos-e,-1)),this.writtenTo=this.pos}blankContent(e,t){let i=new Bt(t-e);return this.oracle.doc.lineAt(e).to==t&&(i.flags|=4),i}ensureLine(){this.enterLine();let e=this.nodes.length?this.nodes[this.nodes.length-1]:null;if(e instanceof Rn)return e;let t=new Rn(0,-1);return this.nodes.push(t),t}addBlock(e){this.enterLine();let t=e.deco;t&&t.startSide>0&&!this.isCovered&&this.ensureLine(),this.nodes.push(e),this.writtenTo=this.pos=this.pos+e.length,t&&t.endSide>0&&(this.covering=e)}addLineDeco(e,t,i){let r=this.ensureLine();r.length+=i,r.collapsed+=i,r.widgetHeight=Math.max(r.widgetHeight,e),r.breaks+=t,this.writtenTo=this.pos=this.pos+i}finish(e){let t=this.nodes.length==0?null:this.nodes[this.nodes.length-1];this.lineStart>-1&&!(t instanceof Rn)&&!this.isCovered?this.nodes.push(new Rn(0,-1)):(this.writtenTof.clientHeight||f.scrollWidth>f.clientWidth)&&p.overflow!="visible"){let m=f.getBoundingClientRect();s=Math.max(s,m.left),o=Math.min(o,m.right),a=Math.max(a,m.top),c=Math.min(h==n.parentNode?r.innerHeight:c,m.bottom)}h=p.position=="absolute"||p.position=="fixed"?f.offsetParent:f.parentNode}else if(h.nodeType==11)h=h.host;else break;return{left:s-t.left,right:Math.max(s,o)-t.left,top:a-(t.top+e),bottom:Math.max(a,c)-(t.top+e)}}function QA(n){let e=n.getBoundingClientRect(),t=n.ownerDocument.defaultView||window;return e.left0&&e.top0}function CA(n,e){let t=n.getBoundingClientRect();return{left:0,right:t.right-t.left,top:e,bottom:t.bottom-(t.top+e)}}class yg{constructor(e,t,i,r){this.from=e,this.to=t,this.size=i,this.displaySize=r}static same(e,t){if(e.length!=t.length)return!1;for(let i=0;itypeof i!="function"&&i.class=="cm-lineWrapping");this.heightOracle=new vA(t),this.stateDeco=e.facet(Ia).filter(i=>typeof i!="function"),this.heightMap=cn.empty().applyChanges(this.stateDeco,ze.empty,this.heightOracle.setDoc(e.doc),[new Fn(0,0,0,e.doc.length)]);for(let i=0;i<2&&(this.viewport=this.getViewport(0,null),!!this.updateForViewport());i++);this.updateViewportLines(),this.lineGaps=this.ensureLineGaps([]),this.lineGapDeco=Pe.set(this.lineGaps.map(i=>i.draw(this,!1))),this.computeVisibleRanges()}updateForViewport(){let e=[this.viewport],{main:t}=this.state.selection;for(let i=0;i<=1;i++){let r=i?t.head:t.anchor;if(!e.some(({from:s,to:o})=>r>=s&&r<=o)){let{from:s,to:o}=this.lineBlockAt(r);e.push(new ju(s,o))}}return this.viewports=e.sort((i,r)=>i.from-r.from),this.updateScaler()}updateScaler(){let e=this.scaler;return this.scaler=this.heightMap.height<=7e6?nb:new r0(this.heightOracle,this.heightMap,this.viewports),e.eq(this.scaler)?0:2}updateViewportLines(){this.viewportLines=[],this.heightMap.forEachLine(this.viewport.from,this.viewport.to,this.heightOracle.setDoc(this.state.doc),0,0,e=>{this.viewportLines.push(ca(e,this.scaler))})}update(e,t=null){this.state=e.state;let i=this.stateDeco;this.stateDeco=this.state.facet(Ia).filter(f=>typeof f!="function");let r=e.changedRanges,s=Fn.extendWithRanges(r,kA(i,this.stateDeco,e?e.changes:Ct.empty(this.state.doc.length))),o=this.heightMap.height,a=this.scrolledToBottom?null:this.scrollAnchorAt(this.scrollTop);Jv(),this.heightMap=this.heightMap.applyChanges(this.stateDeco,e.startState.doc,this.heightOracle.setDoc(this.state.doc),s),(this.heightMap.height!=o||Yo)&&(e.flags|=2),a?(this.scrollAnchorPos=e.changes.mapPos(a.from,-1),this.scrollAnchorHeight=a.top):(this.scrollAnchorPos=-1,this.scrollAnchorHeight=o);let c=s.length?this.mapViewport(this.viewport,e.changes):this.viewport;(t&&(t.range.headc.to)||!this.viewportIsAppropriate(c))&&(c=this.getViewport(0,t));let h=c.from!=this.viewport.from||c.to!=this.viewport.to;this.viewport=c,e.flags|=this.updateForViewport(),(h||!e.changes.empty||e.flags&2)&&this.updateViewportLines(),(this.lineGaps.length||this.viewport.to-this.viewport.from>4e3)&&this.updateLineGaps(this.ensureLineGaps(this.mapLineGaps(this.lineGaps,e.changes))),e.flags|=this.computeVisibleRanges(e.changes),t&&(this.scrollTarget=t),!this.mustEnforceCursorAssoc&&e.selectionSet&&e.view.lineWrapping&&e.state.selection.main.empty&&e.state.selection.main.assoc&&!e.state.facet(MP)&&(this.mustEnforceCursorAssoc=!0)}measure(e){let t=e.contentDOM,i=window.getComputedStyle(t),r=this.heightOracle,s=i.whiteSpace;this.defaultTextDirection=i.direction=="rtl"?st.RTL:st.LTR;let o=this.heightOracle.mustRefreshForWrapping(s),a=t.getBoundingClientRect(),c=o||this.mustMeasureContent||this.contentDOMHeight!=a.height;this.contentDOMHeight=a.height,this.mustMeasureContent=!1;let h=0,f=0;if(a.width&&a.height){let{scaleX:P,scaleY:Q}=oP(t,a);(P>.005&&Math.abs(this.scaleX-P)>.005||Q>.005&&Math.abs(this.scaleY-Q)>.005)&&(this.scaleX=P,this.scaleY=Q,h|=16,o=c=!0)}let p=(parseInt(i.paddingTop)||0)*this.scaleY,m=(parseInt(i.paddingBottom)||0)*this.scaleY;(this.paddingTop!=p||this.paddingBottom!=m)&&(this.paddingTop=p,this.paddingBottom=m,h|=18),this.editorWidth!=e.scrollDOM.clientWidth&&(r.lineWrapping&&(c=!0),this.editorWidth=e.scrollDOM.clientWidth,h|=16);let y=e.scrollDOM.scrollTop*this.scaleY;this.scrollTop!=y&&(this.scrollAnchorHeight=-1,this.scrollTop=y),this.scrolledToBottom=cP(e.scrollDOM);let v=(this.printing?CA:_A)(t,this.paddingTop),b=v.top-this.pixelViewport.top,S=v.bottom-this.pixelViewport.bottom;this.pixelViewport=v;let w=this.pixelViewport.bottom>this.pixelViewport.top&&this.pixelViewport.right>this.pixelViewport.left;if(w!=this.inView&&(this.inView=w,w&&(c=!0)),!this.inView&&!this.scrollTarget&&!QA(e.dom))return 0;let C=a.width;if((this.contentDOMWidth!=C||this.editorHeight!=e.scrollDOM.clientHeight)&&(this.contentDOMWidth=a.width,this.editorHeight=e.scrollDOM.clientHeight,h|=16),c){let P=e.docView.measureVisibleLineHeights(this.viewport);if(r.mustRefreshForHeights(P)&&(o=!0),o||r.lineWrapping&&Math.abs(C-this.contentDOMWidth)>r.charWidth){let{lineHeight:Q,charWidth:$,textHeight:M}=e.docView.measureTextSize();o=Q>0&&r.refresh(s,Q,$,M,Math.max(5,C/$),P),o&&(e.docView.minWidth=0,h|=16)}b>0&&S>0?f=Math.max(b,S):b<0&&S<0&&(f=Math.min(b,S)),Jv();for(let Q of this.viewports){let $=Q.from==this.viewport.from?P:e.docView.measureVisibleLineHeights(Q);this.heightMap=(o?cn.empty().applyChanges(this.stateDeco,ze.empty,this.heightOracle,[new Fn(0,0,0,e.state.doc.length)]):this.heightMap).updateHeight(r,0,o,new bA(Q.from,$))}Yo&&(h|=2)}let _=!this.viewportIsAppropriate(this.viewport,f)||this.scrollTarget&&(this.scrollTarget.range.headthis.viewport.to);return _&&(h&2&&(h|=this.updateScaler()),this.viewport=this.getViewport(f,this.scrollTarget),h|=this.updateForViewport()),(h&2||_)&&this.updateViewportLines(),(this.lineGaps.length||this.viewport.to-this.viewport.from>4e3)&&this.updateLineGaps(this.ensureLineGaps(o?[]:this.lineGaps,e)),h|=this.computeVisibleRanges(),this.mustEnforceCursorAssoc&&(this.mustEnforceCursorAssoc=!1,e.docView.enforceCursorAssoc()),h}get visibleTop(){return this.scaler.fromDOM(this.pixelViewport.top)}get visibleBottom(){return this.scaler.fromDOM(this.pixelViewport.bottom)}getViewport(e,t){let i=.5-Math.max(-.5,Math.min(.5,e/1e3/2)),r=this.heightMap,s=this.heightOracle,{visibleTop:o,visibleBottom:a}=this,c=new ju(r.lineAt(o-i*1e3,rt.ByHeight,s,0,0).from,r.lineAt(a+(1-i)*1e3,rt.ByHeight,s,0,0).to);if(t){let{head:h}=t.range;if(hc.to){let f=Math.min(this.editorHeight,this.pixelViewport.bottom-this.pixelViewport.top),p=r.lineAt(h,rt.ByPos,s,0,0),m;t.y=="center"?m=(p.top+p.bottom)/2-f/2:t.y=="start"||t.y=="nearest"&&h=a+Math.max(10,Math.min(i,250)))&&r>o-2*1e3&&s>1,o=r<<1;if(this.defaultTextDirection!=st.LTR&&!i)return[];let a=[],c=(f,p,m,y)=>{if(p-ff&&ww.from>=m.from&&w.to<=m.to&&Math.abs(w.from-f)w.fromC));if(!S){if(p_.from<=p&&_.to>=p)){let _=t.moveToLineBoundary(V.cursor(p),!1,!0).head;_>f&&(p=_)}let w=this.gapSize(m,f,p,y),C=i||w<2e6?w:2e6;S=new yg(f,p,w,C)}a.push(S)},h=f=>{if(f.length2e6)for(let $ of e)$.from>=f.from&&$.fromf.from&&c(f.from,y,f,p),vt.draw(this,this.heightOracle.lineWrapping))))}computeVisibleRanges(e){let t=this.stateDeco;this.lineGaps.length&&(t=t.concat(this.lineGapDeco));let i=[];je.spans(t,this.viewport.from,this.viewport.to,{span(s,o){i.push({from:s,to:o})},point(){}},20);let r=0;if(i.length!=this.visibleRanges.length)r=12;else for(let s=0;s=this.viewport.from&&e<=this.viewport.to&&this.viewportLines.find(t=>t.from<=e&&t.to>=e)||ca(this.heightMap.lineAt(e,rt.ByPos,this.heightOracle,0,0),this.scaler)}lineBlockAtHeight(e){return e>=this.viewportLines[0].top&&e<=this.viewportLines[this.viewportLines.length-1].bottom&&this.viewportLines.find(t=>t.top<=e&&t.bottom>=e)||ca(this.heightMap.lineAt(this.scaler.fromDOM(e),rt.ByHeight,this.heightOracle,0,0),this.scaler)}scrollAnchorAt(e){let t=this.lineBlockAtHeight(e+8);return t.from>=this.viewport.from||this.viewportLines[0].top-e>200?t:this.viewportLines[0]}elementAtHeight(e){return ca(this.heightMap.blockAt(this.scaler.fromDOM(e),this.heightOracle,0,0),this.scaler)}get docHeight(){return this.scaler.toDOM(this.heightMap.height)}get contentHeight(){return this.docHeight+this.paddingTop+this.paddingBottom}}class ju{constructor(e,t){this.from=e,this.to=t}}function $A(n,e,t){let i=[],r=n,s=0;return je.spans(t,n,e,{span(){},point(o,a){o>r&&(i.push({from:r,to:o}),s+=o-r),r=a}},20),r=1)return e[e.length-1].to;let i=Math.floor(n*t);for(let r=0;;r++){let{from:s,to:o}=e[r],a=o-s;if(i<=a)return s+i;i-=a}}function Nu(n,e){let t=0;for(let{from:i,to:r}of n.ranges){if(e<=r){t+=e-i;break}t+=r-i}return t/n.total}function MA(n,e){for(let t of n)if(e(t))return t}const nb={toDOM(n){return n},fromDOM(n){return n},scale:1,eq(n){return n==this}};class r0{constructor(e,t,i){let r=0,s=0,o=0;this.viewports=i.map(({from:a,to:c})=>{let h=t.lineAt(a,rt.ByPos,e,0,0).top,f=t.lineAt(c,rt.ByPos,e,0,0).bottom;return r+=f-h,{from:a,to:c,top:h,bottom:f,domTop:0,domBottom:0}}),this.scale=(7e6-r)/(t.height-r);for(let a of this.viewports)a.domTop=o+(a.top-s)*this.scale,o=a.domBottom=a.domTop+(a.bottom-a.top),s=a.bottom}toDOM(e){for(let t=0,i=0,r=0;;t++){let s=tt.from==e.viewports[i].from&&t.to==e.viewports[i].to):!1}}function ca(n,e){if(e.scale==1)return n;let t=e.toDOM(n.top),i=e.toDOM(n.bottom);return new Mi(n.from,n.length,t,i-t,Array.isArray(n._content)?n._content.map(r=>ca(r,e)):n._content)}const Xu=fe.define({combine:n=>n.join(" ")}),Um=fe.define({combine:n=>n.indexOf(!0)>-1}),Hm=Yr.newName(),JP=Yr.newName(),e_=Yr.newName(),t_={"&light":"."+JP,"&dark":"."+e_};function Gm(n,e,t){return new Yr(e,{finish(i){return/&/.test(i)?i.replace(/&\w*/,r=>{if(r=="&")return n;if(!t||!t[r])throw new RangeError(`Unsupported selector: ${r}`);return t[r]}):n+" "+i}})}const RA=Gm("."+Hm,{"&":{position:"relative !important",boxSizing:"border-box","&.cm-focused":{outline:"1px dotted #212121"},display:"flex !important",flexDirection:"column"},".cm-scroller":{display:"flex !important",alignItems:"flex-start !important",fontFamily:"monospace",lineHeight:1.4,height:"100%",overflowX:"auto",position:"relative",zIndex:0,overflowAnchor:"none"},".cm-content":{margin:0,flexGrow:2,flexShrink:0,display:"block",whiteSpace:"pre",wordWrap:"normal",boxSizing:"border-box",minHeight:"100%",padding:"4px 0",outline:"none","&[contenteditable=true]":{WebkitUserModify:"read-write-plaintext-only"}},".cm-lineWrapping":{whiteSpace_fallback:"pre-wrap",whiteSpace:"break-spaces",wordBreak:"break-word",overflowWrap:"anywhere",flexShrink:1},"&light .cm-content":{caretColor:"black"},"&dark .cm-content":{caretColor:"white"},".cm-line":{display:"block",padding:"0 2px 0 6px"},".cm-layer":{position:"absolute",left:0,top:0,contain:"size style","& > *":{position:"absolute"}},"&light .cm-selectionBackground":{background:"#d9d9d9"},"&dark .cm-selectionBackground":{background:"#222"},"&light.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground":{background:"#d7d4f0"},"&dark.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground":{background:"#233"},".cm-cursorLayer":{pointerEvents:"none"},"&.cm-focused > .cm-scroller > .cm-cursorLayer":{animation:"steps(1) cm-blink 1.2s infinite"},"@keyframes cm-blink":{"0%":{},"50%":{opacity:0},"100%":{}},"@keyframes cm-blink2":{"0%":{},"50%":{opacity:0},"100%":{}},".cm-cursor, .cm-dropCursor":{borderLeft:"1.2px solid black",marginLeft:"-0.6px",pointerEvents:"none"},".cm-cursor":{display:"none"},"&dark .cm-cursor":{borderLeftColor:"#ddd"},".cm-dropCursor":{position:"absolute"},"&.cm-focused > .cm-scroller > .cm-cursorLayer .cm-cursor":{display:"block"},".cm-iso":{unicodeBidi:"isolate"},".cm-announced":{position:"fixed",top:"-10000px"},"@media print":{".cm-announced":{display:"none"}},"&light .cm-activeLine":{backgroundColor:"#cceeff44"},"&dark .cm-activeLine":{backgroundColor:"#99eeff33"},"&light .cm-specialChar":{color:"red"},"&dark .cm-specialChar":{color:"#f78"},".cm-gutters":{flexShrink:0,display:"flex",height:"100%",boxSizing:"border-box",zIndex:200},".cm-gutters-before":{insetInlineStart:0},".cm-gutters-after":{insetInlineEnd:0},"&light .cm-gutters":{backgroundColor:"#f5f5f5",color:"#6c6c6c",border:"0px solid #ddd","&.cm-gutters-before":{borderRightWidth:"1px"},"&.cm-gutters-after":{borderLeftWidth:"1px"}},"&dark .cm-gutters":{backgroundColor:"#333338",color:"#ccc"},".cm-gutter":{display:"flex !important",flexDirection:"column",flexShrink:0,boxSizing:"border-box",minHeight:"100%",overflow:"hidden"},".cm-gutterElement":{boxSizing:"border-box"},".cm-lineNumbers .cm-gutterElement":{padding:"0 3px 0 5px",minWidth:"20px",textAlign:"right",whiteSpace:"nowrap"},"&light .cm-activeLineGutter":{backgroundColor:"#e2f2ff"},"&dark .cm-activeLineGutter":{backgroundColor:"#222227"},".cm-panels":{boxSizing:"border-box",position:"sticky",left:0,right:0,zIndex:300},"&light .cm-panels":{backgroundColor:"#f5f5f5",color:"black"},"&light .cm-panels-top":{borderBottom:"1px solid #ddd"},"&light .cm-panels-bottom":{borderTop:"1px solid #ddd"},"&dark .cm-panels":{backgroundColor:"#333338",color:"white"},".cm-dialog":{padding:"2px 19px 4px 6px",position:"relative","& label":{fontSize:"80%"}},".cm-dialog-close":{position:"absolute",top:"3px",right:"4px",backgroundColor:"inherit",border:"none",font:"inherit",fontSize:"14px",padding:"0"},".cm-tab":{display:"inline-block",overflow:"hidden",verticalAlign:"bottom"},".cm-widgetBuffer":{verticalAlign:"text-top",height:"1em",width:0,display:"inline"},".cm-placeholder":{color:"#888",display:"inline-block",verticalAlign:"top",userSelect:"none"},".cm-highlightSpace":{backgroundImage:"radial-gradient(circle at 50% 55%, #aaa 20%, transparent 5%)",backgroundPosition:"center"},".cm-highlightTab":{backgroundImage:`url('data:image/svg+xml,')`,backgroundSize:"auto 100%",backgroundPosition:"right 90%",backgroundRepeat:"no-repeat"},".cm-trailingSpace":{backgroundColor:"#ff332255"},".cm-button":{verticalAlign:"middle",color:"inherit",fontSize:"70%",padding:".2em 1em",borderRadius:"1px"},"&light .cm-button":{backgroundImage:"linear-gradient(#eff1f5, #d9d9df)",border:"1px solid #888","&:active":{backgroundImage:"linear-gradient(#b4b4b4, #d0d3d6)"}},"&dark .cm-button":{backgroundImage:"linear-gradient(#393939, #111)",border:"1px solid #888","&:active":{backgroundImage:"linear-gradient(#111, #333)"}},".cm-textfield":{verticalAlign:"middle",color:"inherit",fontSize:"70%",border:"1px solid silver",padding:".2em .5em"},"&light .cm-textfield":{backgroundColor:"white"},"&dark .cm-textfield":{border:"1px solid #555",backgroundColor:"inherit"}},t_),AA={childList:!0,characterData:!0,subtree:!0,attributes:!0,characterDataOldValue:!0},xg=ae.ie&&ae.ie_version<=11;class EA{constructor(e){this.view=e,this.active=!1,this.editContext=null,this.selectionRange=new mR,this.selectionChanged=!1,this.delayedFlush=-1,this.resizeTimeout=-1,this.queue=[],this.delayedAndroidKey=null,this.flushingAndroidKey=-1,this.lastChange=0,this.scrollTargets=[],this.intersection=null,this.resizeScroll=null,this.intersecting=!1,this.gapIntersection=null,this.gaps=[],this.printQuery=null,this.parentCheck=-1,this.dom=e.contentDOM,this.observer=new MutationObserver(t=>{for(let i of t)this.queue.push(i);(ae.ie&&ae.ie_version<=11||ae.ios&&e.composing)&&t.some(i=>i.type=="childList"&&i.removedNodes.length||i.type=="characterData"&&i.oldValue.length>i.target.nodeValue.length)?this.flushSoon():this.flush()}),window.EditContext&&ae.android&&e.constructor.EDIT_CONTEXT!==!1&&!(ae.chrome&&ae.chrome_version<126)&&(this.editContext=new DA(e),e.state.facet(tr)&&(e.contentDOM.editContext=this.editContext.editContext)),xg&&(this.onCharData=t=>{this.queue.push({target:t.target,type:"characterData",oldValue:t.prevValue}),this.flushSoon()}),this.onSelectionChange=this.onSelectionChange.bind(this),this.onResize=this.onResize.bind(this),this.onPrint=this.onPrint.bind(this),this.onScroll=this.onScroll.bind(this),window.matchMedia&&(this.printQuery=window.matchMedia("print")),typeof ResizeObserver=="function"&&(this.resizeScroll=new ResizeObserver(()=>{var t;((t=this.view.docView)===null||t===void 0?void 0:t.lastUpdate){this.parentCheck<0&&(this.parentCheck=setTimeout(this.listenForScroll.bind(this),1e3)),t.length>0&&t[t.length-1].intersectionRatio>0!=this.intersecting&&(this.intersecting=!this.intersecting,this.intersecting!=this.view.inView&&this.onScrollChanged(document.createEvent("Event")))},{threshold:[0,.001]}),this.intersection.observe(this.dom),this.gapIntersection=new IntersectionObserver(t=>{t.length>0&&t[t.length-1].intersectionRatio>0&&this.onScrollChanged(document.createEvent("Event"))},{})),this.listenForScroll(),this.readSelectionRange()}onScrollChanged(e){this.view.inputState.runHandlers("scroll",e),this.intersecting&&this.view.measure()}onScroll(e){this.intersecting&&this.flush(!1),this.editContext&&this.view.requestMeasure(this.editContext.measureReq),this.onScrollChanged(e)}onResize(){this.resizeTimeout<0&&(this.resizeTimeout=setTimeout(()=>{this.resizeTimeout=-1,this.view.requestMeasure()},50))}onPrint(e){(e.type=="change"||!e.type)&&!e.matches||(this.view.viewState.printing=!0,this.view.measure(),setTimeout(()=>{this.view.viewState.printing=!1,this.view.requestMeasure()},500))}updateGaps(e){if(this.gapIntersection&&(e.length!=this.gaps.length||this.gaps.some((t,i)=>t!=e[i]))){this.gapIntersection.disconnect();for(let t of e)this.gapIntersection.observe(t);this.gaps=e}}onSelectionChange(e){let t=this.selectionChanged;if(!this.readSelectionRange()||this.delayedAndroidKey)return;let{view:i}=this,r=this.selectionRange;if(i.state.facet(tr)?i.root.activeElement!=this.dom:!Ph(this.dom,r))return;let s=r.anchorNode&&i.docView.nearest(r.anchorNode);if(s&&s.ignoreEvent(e)){t||(this.selectionChanged=!1);return}(ae.ie&&ae.ie_version<=11||ae.android&&ae.chrome)&&!i.state.selection.main.empty&&r.focusNode&&va(r.focusNode,r.focusOffset,r.anchorNode,r.anchorOffset)?this.flushSoon():this.flush(!1)}readSelectionRange(){let{view:e}=this,t=za(e.root);if(!t)return!1;let i=ae.safari&&e.root.nodeType==11&&e.root.activeElement==this.dom&&LA(this.view,t)||t;if(!i||this.selectionRange.eq(i))return!1;let r=Ph(this.dom,i);return r&&!this.selectionChanged&&e.inputState.lastFocusTime>Date.now()-200&&e.inputState.lastTouchTime{let s=this.delayedAndroidKey;s&&(this.clearDelayedAndroidKey(),this.view.inputState.lastKeyCode=s.keyCode,this.view.inputState.lastKeyTime=Date.now(),!this.flush()&&s.force&&Ro(this.dom,s.key,s.keyCode))};this.flushingAndroidKey=this.view.win.requestAnimationFrame(r)}(!this.delayedAndroidKey||e=="Enter")&&(this.delayedAndroidKey={key:e,keyCode:t,force:this.lastChange{this.delayedFlush=-1,this.flush()}))}forceFlush(){this.delayedFlush>=0&&(this.view.win.cancelAnimationFrame(this.delayedFlush),this.delayedFlush=-1),this.flush()}pendingRecords(){for(let e of this.observer.takeRecords())this.queue.push(e);return this.queue}processRecords(){let e=this.pendingRecords();e.length&&(this.queue=[]);let t=-1,i=-1,r=!1;for(let s of e){let o=this.readMutation(s);o&&(o.typeOver&&(r=!0),t==-1?{from:t,to:i}=o:(t=Math.min(o.from,t),i=Math.max(o.to,i)))}return{from:t,to:i,typeOver:r}}readChange(){let{from:e,to:t,typeOver:i}=this.processRecords(),r=this.selectionChanged&&Ph(this.dom,this.selectionRange);if(e<0&&!r)return null;e>-1&&(this.lastChange=Date.now()),this.view.inputState.lastFocusTime=0,this.selectionChanged=!1;let s=new JR(this.view,e,t,i);return this.view.docView.domChanged={newSel:s.newSel?s.newSel.main:null},s}flush(e=!0){if(this.delayedFlush>=0||this.delayedAndroidKey)return!1;e&&this.readSelectionRange();let t=this.readChange();if(!t)return this.view.requestMeasure(),!1;let i=this.view.state,r=NP(this.view,t);return this.view.state==i&&(t.domChanged||t.newSel&&!t.newSel.main.eq(this.view.state.selection.main))&&this.view.update([]),r}readMutation(e){let t=this.view.docView.nearest(e.target);if(!t||t.ignoreMutation(e))return null;if(t.markDirty(e.type=="attributes"),e.type=="attributes"&&(t.flags|=4),e.type=="childList"){let i=ib(t,e.previousSibling||e.target.previousSibling,-1),r=ib(t,e.nextSibling||e.target.nextSibling,1);return{from:i?t.posAfter(i):t.posAtStart,to:r?t.posBefore(r):t.posAtEnd,typeOver:!1}}else return e.type=="characterData"?{from:t.posAtStart,to:t.posAtEnd,typeOver:e.target.nodeValue==e.oldValue}:null}setWindow(e){e!=this.win&&(this.removeWindowListeners(this.win),this.win=e,this.addWindowListeners(this.win))}addWindowListeners(e){e.addEventListener("resize",this.onResize),this.printQuery?this.printQuery.addEventListener?this.printQuery.addEventListener("change",this.onPrint):this.printQuery.addListener(this.onPrint):e.addEventListener("beforeprint",this.onPrint),e.addEventListener("scroll",this.onScroll),e.document.addEventListener("selectionchange",this.onSelectionChange)}removeWindowListeners(e){e.removeEventListener("scroll",this.onScroll),e.removeEventListener("resize",this.onResize),this.printQuery?this.printQuery.removeEventListener?this.printQuery.removeEventListener("change",this.onPrint):this.printQuery.removeListener(this.onPrint):e.removeEventListener("beforeprint",this.onPrint),e.document.removeEventListener("selectionchange",this.onSelectionChange)}update(e){this.editContext&&(this.editContext.update(e),e.startState.facet(tr)!=e.state.facet(tr)&&(e.view.contentDOM.editContext=e.state.facet(tr)?this.editContext.editContext:null))}destroy(){var e,t,i;this.stop(),(e=this.intersection)===null||e===void 0||e.disconnect(),(t=this.gapIntersection)===null||t===void 0||t.disconnect(),(i=this.resizeScroll)===null||i===void 0||i.disconnect();for(let r of this.scrollTargets)r.removeEventListener("scroll",this.onScroll);this.removeWindowListeners(this.win),clearTimeout(this.parentCheck),clearTimeout(this.resizeTimeout),this.win.cancelAnimationFrame(this.delayedFlush),this.win.cancelAnimationFrame(this.flushingAndroidKey),this.editContext&&(this.view.contentDOM.editContext=null,this.editContext.destroy())}}function ib(n,e,t){for(;e;){let i=Ue.get(e);if(i&&i.parent==n)return i;let r=e.parentNode;e=r!=n.dom?r:t>0?e.nextSibling:e.previousSibling}return null}function rb(n,e){let t=e.startContainer,i=e.startOffset,r=e.endContainer,s=e.endOffset,o=n.docView.domAtPos(n.state.selection.main.anchor);return va(o.node,o.offset,r,s)&&([t,i,r,s]=[r,s,t,i]),{anchorNode:t,anchorOffset:i,focusNode:r,focusOffset:s}}function LA(n,e){if(e.getComposedRanges){let r=e.getComposedRanges(n.root)[0];if(r)return rb(n,r)}let t=null;function i(r){r.preventDefault(),r.stopImmediatePropagation(),t=r.getTargetRanges()[0]}return n.contentDOM.addEventListener("beforeinput",i,!0),n.dom.ownerDocument.execCommand("indent"),n.contentDOM.removeEventListener("beforeinput",i,!0),t?rb(n,t):null}class DA{constructor(e){this.from=0,this.to=0,this.pendingContextChange=null,this.handlers=Object.create(null),this.composing=null,this.resetRange(e.state);let t=this.editContext=new window.EditContext({text:e.state.doc.sliceString(this.from,this.to),selectionStart:this.toContextPos(Math.max(this.from,Math.min(this.to,e.state.selection.main.anchor))),selectionEnd:this.toContextPos(e.state.selection.main.head)});this.handlers.textupdate=i=>{let r=e.state.selection.main,{anchor:s,head:o}=r,a=this.toEditorPos(i.updateRangeStart),c=this.toEditorPos(i.updateRangeEnd);e.inputState.composing>=0&&!this.composing&&(this.composing={contextBase:i.updateRangeStart,editorBase:a,drifted:!1});let h=c-a>i.text.length;a==this.from&&sthis.to&&(c=s);let f=XP(e.state.sliceDoc(a,c),i.text,(h?r.from:r.to)-a,h?"end":null);if(!f){let m=V.single(this.toEditorPos(i.selectionStart),this.toEditorPos(i.selectionEnd));m.main.eq(r)||e.dispatch({selection:m,userEvent:"select"});return}let p={from:f.from+a,to:f.toA+a,insert:ze.of(i.text.slice(f.from,f.toB).split(` -`))};if((ae.mac||ae.android)&&p.from==o-1&&/^\. ?$/.test(i.text)&&e.contentDOM.getAttribute("autocorrect")=="off"&&(p={from:a,to:c,insert:ze.of([i.text.replace("."," ")])}),this.pendingContextChange=p,!e.state.readOnly){let m=this.to-this.from+(p.to-p.from+p.insert.length);n0(e,p,V.single(this.toEditorPos(i.selectionStart,m),this.toEditorPos(i.selectionEnd,m)))}this.pendingContextChange&&(this.revertPending(e.state),this.setSelection(e.state)),p.from=0&&!/[\\p{Alphabetic}\\p{Number}_]/.test(t.text.slice(Math.max(0,i.updateRangeStart-1),Math.min(t.text.length,i.updateRangeStart+1)))&&this.handlers.compositionend(i)},this.handlers.characterboundsupdate=i=>{let r=[],s=null;for(let o=this.toEditorPos(i.rangeStart),a=this.toEditorPos(i.rangeEnd);o{let r=[];for(let s of i.getTextFormats()){let o=s.underlineStyle,a=s.underlineThickness;if(!/none/i.test(o)&&!/none/i.test(a)){let c=this.toEditorPos(s.rangeStart),h=this.toEditorPos(s.rangeEnd);if(c{e.inputState.composing<0&&(e.inputState.composing=0,e.inputState.compositionFirstChange=!0)},this.handlers.compositionend=()=>{if(e.inputState.composing=-1,e.inputState.compositionFirstChange=null,this.composing){let{drifted:i}=this.composing;this.composing=null,i&&this.reset(e.state)}};for(let i in this.handlers)t.addEventListener(i,this.handlers[i]);this.measureReq={read:i=>{this.editContext.updateControlBounds(i.contentDOM.getBoundingClientRect());let r=za(i.root);r&&r.rangeCount&&this.editContext.updateSelectionBounds(r.getRangeAt(0).getBoundingClientRect())}}}applyEdits(e){let t=0,i=!1,r=this.pendingContextChange;return e.changes.iterChanges((s,o,a,c,h)=>{if(i)return;let f=h.length-(o-s);if(r&&o>=r.to)if(r.from==s&&r.to==o&&r.insert.eq(h)){r=this.pendingContextChange=null,t+=f,this.to+=f;return}else r=null,this.revertPending(e.state);if(s+=t,o+=t,o<=this.from)this.from+=f,this.to+=f;else if(sthis.to||this.to-this.from+h.length>3e4){i=!0;return}this.editContext.updateText(this.toContextPos(s),this.toContextPos(o),h.toString()),this.to+=f}t+=f}),r&&!i&&this.revertPending(e.state),!i}update(e){let t=this.pendingContextChange,i=e.startState.selection.main;this.composing&&(this.composing.drifted||!e.changes.touchesRange(i.from,i.to)&&e.transactions.some(r=>!r.isUserEvent("input.type")&&r.changes.touchesRange(this.from,this.to)))?(this.composing.drifted=!0,this.composing.editorBase=e.changes.mapPos(this.composing.editorBase)):!this.applyEdits(e)||!this.rangeIsValid(e.state)?(this.pendingContextChange=null,this.reset(e.state)):(e.docChanged||e.selectionSet||t)&&this.setSelection(e.state),(e.geometryChanged||e.docChanged||e.selectionSet)&&e.view.requestMeasure(this.measureReq)}resetRange(e){let{head:t}=e.selection.main;this.from=Math.max(0,t-1e4),this.to=Math.min(e.doc.length,t+1e4)}reset(e){this.resetRange(e),this.editContext.updateText(0,this.editContext.text.length,e.doc.sliceString(this.from,this.to)),this.setSelection(e)}revertPending(e){let t=this.pendingContextChange;this.pendingContextChange=null,this.editContext.updateText(this.toContextPos(t.from),this.toContextPos(t.from+t.insert.length),e.doc.sliceString(t.from,t.to))}setSelection(e){let{main:t}=e.selection,i=this.toContextPos(Math.max(this.from,Math.min(this.to,t.anchor))),r=this.toContextPos(t.head);(this.editContext.selectionStart!=i||this.editContext.selectionEnd!=r)&&this.editContext.updateSelection(i,r)}rangeIsValid(e){let{head:t}=e.selection.main;return!(this.from>0&&t-this.from<500||this.to1e4*3)}toEditorPos(e,t=this.to-this.from){e=Math.min(e,t);let i=this.composing;return i&&i.drifted?i.editorBase+(e-i.contextBase):e+this.from}toContextPos(e){let t=this.composing;return t&&t.drifted?t.contextBase+(e-t.editorBase):e-this.from}destroy(){for(let e in this.handlers)this.editContext.removeEventListener(e,this.handlers[e])}}class ce{get state(){return this.viewState.state}get viewport(){return this.viewState.viewport}get visibleRanges(){return this.viewState.visibleRanges}get inView(){return this.viewState.inView}get composing(){return!!this.inputState&&this.inputState.composing>0}get compositionStarted(){return!!this.inputState&&this.inputState.composing>=0}get root(){return this._root}get win(){return this.dom.ownerDocument.defaultView||window}constructor(e={}){var t;this.plugins=[],this.pluginMap=new Map,this.editorAttrs={},this.contentAttrs={},this.bidiCache=[],this.destroyed=!1,this.updateState=2,this.measureScheduled=-1,this.measureRequests=[],this.contentDOM=document.createElement("div"),this.scrollDOM=document.createElement("div"),this.scrollDOM.tabIndex=-1,this.scrollDOM.className="cm-scroller",this.scrollDOM.appendChild(this.contentDOM),this.announceDOM=document.createElement("div"),this.announceDOM.className="cm-announced",this.announceDOM.setAttribute("aria-live","polite"),this.dom=document.createElement("div"),this.dom.appendChild(this.announceDOM),this.dom.appendChild(this.scrollDOM),e.parent&&e.parent.appendChild(this.dom);let{dispatch:i}=e;this.dispatchTransactions=e.dispatchTransactions||i&&(r=>r.forEach(s=>i(s,this)))||(r=>this.update(r)),this.dispatch=this.dispatch.bind(this),this._root=e.root||OR(e.parent)||document,this.viewState=new tb(e.state||Ie.create(e)),e.scrollTo&&e.scrollTo.is(zu)&&(this.viewState.scrollTarget=e.scrollTo.value.clip(this.viewState.state)),this.plugins=this.state.facet(_o).map(r=>new gg(r));for(let r of this.plugins)r.update(this);this.observer=new EA(this),this.inputState=new iA(this),this.inputState.ensureHandlers(this.plugins),this.docView=new Dv(this),this.mountStyles(),this.updateAttrs(),this.updateState=0,this.requestMeasure(),!((t=document.fonts)===null||t===void 0)&&t.ready&&document.fonts.ready.then(()=>this.requestMeasure())}dispatch(...e){let t=e.length==1&&e[0]instanceof St?e:e.length==1&&Array.isArray(e[0])?e[0]:[this.state.update(...e)];this.dispatchTransactions(t,this)}update(e){if(this.updateState!=0)throw new Error("Calls to EditorView.update are not allowed while an update is in progress");let t=!1,i=!1,r,s=this.state;for(let m of e){if(m.startState!=s)throw new RangeError("Trying to update state with a transaction that doesn't start from the previous state.");s=m.state}if(this.destroyed){this.viewState.state=s;return}let o=this.hasFocus,a=0,c=null;e.some(m=>m.annotation(UP))?(this.inputState.notifiedFocused=o,a=1):o!=this.inputState.notifiedFocused&&(this.inputState.notifiedFocused=o,c=HP(s,o),c||(a=1));let h=this.observer.delayedAndroidKey,f=null;if(h?(this.observer.clearDelayedAndroidKey(),f=this.observer.readChange(),(f&&!this.state.doc.eq(s.doc)||!this.state.selection.eq(s.selection))&&(f=null)):this.observer.clear(),s.facet(Ie.phrases)!=this.state.facet(Ie.phrases))return this.setState(s);r=Kh.create(this,s,e),r.flags|=a;let p=this.viewState.scrollTarget;try{this.updateState=2;for(let m of e){if(p&&(p=p.map(m.changes)),m.scrollIntoView){let{main:y}=m.state.selection;p=new Ao(y.empty?y:V.cursor(y.head,y.head>y.anchor?-1:1))}for(let y of m.effects)y.is(zu)&&(p=y.value.clip(this.state))}this.viewState.update(r,p),this.bidiCache=ef.update(this.bidiCache,r.changes),r.empty||(this.updatePlugins(r),this.inputState.update(r)),t=this.docView.update(r),this.state.facet(la)!=this.styleModules&&this.mountStyles(),i=this.updateAttrs(),this.showAnnouncements(e),this.docView.updateSelection(t,e.some(m=>m.isUserEvent("select.pointer")))}finally{this.updateState=0}if(r.startState.facet(Xu)!=r.state.facet(Xu)&&(this.viewState.mustMeasureContent=!0),(t||i||p||this.viewState.mustEnforceCursorAssoc||this.viewState.mustMeasureContent)&&this.requestMeasure(),t&&this.docViewUpdate(),!r.empty)for(let m of this.state.facet(Vm))try{m(r)}catch(y){bn(this.state,y,"update listener")}(c||f)&&Promise.resolve().then(()=>{c&&this.state==c.startState&&this.dispatch(c),f&&!NP(this,f)&&h.force&&Ro(this.contentDOM,h.key,h.keyCode)})}setState(e){if(this.updateState!=0)throw new Error("Calls to EditorView.setState are not allowed while an update is in progress");if(this.destroyed){this.viewState.state=e;return}this.updateState=2;let t=this.hasFocus;try{for(let i of this.plugins)i.destroy(this);this.viewState=new tb(e),this.plugins=e.facet(_o).map(i=>new gg(i)),this.pluginMap.clear();for(let i of this.plugins)i.update(this);this.docView.destroy(),this.docView=new Dv(this),this.inputState.ensureHandlers(this.plugins),this.mountStyles(),this.updateAttrs(),this.bidiCache=[]}finally{this.updateState=0}t&&this.focus(),this.requestMeasure()}updatePlugins(e){let t=e.startState.facet(_o),i=e.state.facet(_o);if(t!=i){let r=[];for(let s of i){let o=t.indexOf(s);if(o<0)r.push(new gg(s));else{let a=this.plugins[o];a.mustUpdate=e,r.push(a)}}for(let s of this.plugins)s.mustUpdate!=e&&s.destroy(this);this.plugins=r,this.pluginMap.clear()}else for(let r of this.plugins)r.mustUpdate=e;for(let r=0;r-1&&this.win.cancelAnimationFrame(this.measureScheduled),this.observer.delayedAndroidKey){this.measureScheduled=-1,this.requestMeasure();return}this.measureScheduled=0,e&&this.observer.forceFlush();let t=null,i=this.scrollDOM,r=i.scrollTop*this.scaleY,{scrollAnchorPos:s,scrollAnchorHeight:o}=this.viewState;Math.abs(r-this.viewState.scrollTop)>1&&(o=-1),this.viewState.scrollAnchorHeight=-1;try{for(let a=0;;a++){if(o<0)if(cP(i))s=-1,o=this.viewState.heightMap.height;else{let y=this.viewState.scrollAnchorAt(r);s=y.from,o=y.top}this.updateState=1;let c=this.viewState.measure(this);if(!c&&!this.measureRequests.length&&this.viewState.scrollTarget==null)break;if(a>5){console.warn(this.measureRequests.length?"Measure loop restarted more than 5 times":"Viewport failed to stabilize");break}let h=[];c&4||([this.measureRequests,h]=[h,this.measureRequests]);let f=h.map(y=>{try{return y.read(this)}catch(v){return bn(this.state,v),sb}}),p=Kh.create(this,this.state,[]),m=!1;p.flags|=c,t?t.flags|=c:t=p,this.updateState=2,p.empty||(this.updatePlugins(p),this.inputState.update(p),this.updateAttrs(),m=this.docView.update(p),m&&this.docViewUpdate());for(let y=0;y1||v<-1){r=r+v,i.scrollTop=r/this.scaleY,o=-1;continue}}break}}}finally{this.updateState=0,this.measureScheduled=-1}if(t&&!t.empty)for(let a of this.state.facet(Vm))a(t)}get themeClasses(){return Hm+" "+(this.state.facet(Um)?e_:JP)+" "+this.state.facet(Xu)}updateAttrs(){let e=ob(this,EP,{class:"cm-editor"+(this.hasFocus?" cm-focused ":" ")+this.themeClasses}),t={spellcheck:"false",autocorrect:"off",autocapitalize:"off",writingsuggestions:"false",translate:"no",contenteditable:this.state.facet(tr)?"true":"false",class:"cm-content",style:`${ae.tabSize}: ${this.state.tabSize}`,role:"textbox","aria-multiline":"true"};this.state.readOnly&&(t["aria-readonly"]="true"),ob(this,e0,t);let i=this.observer.ignore(()=>{let r=jm(this.contentDOM,this.contentAttrs,t),s=jm(this.dom,this.editorAttrs,e);return r||s});return this.editorAttrs=e,this.contentAttrs=t,i}showAnnouncements(e){let t=!0;for(let i of e)for(let r of i.effects)if(r.is(ce.announce)){t&&(this.announceDOM.textContent=""),t=!1;let s=this.announceDOM.appendChild(document.createElement("div"));s.textContent=r.value}}mountStyles(){this.styleModules=this.state.facet(la);let e=this.state.facet(ce.cspNonce);Yr.mount(this.root,this.styleModules.concat(RA).reverse(),e?{nonce:e}:void 0)}readMeasured(){if(this.updateState==2)throw new Error("Reading the editor layout isn't allowed during an update");this.updateState==0&&this.measureScheduled>-1&&this.measure(!1)}requestMeasure(e){if(this.measureScheduled<0&&(this.measureScheduled=this.win.requestAnimationFrame(()=>this.measure())),e){if(this.measureRequests.indexOf(e)>-1)return;if(e.key!=null){for(let t=0;ti.plugin==e)||null),t&&t.update(this).value}get documentTop(){return this.contentDOM.getBoundingClientRect().top+this.viewState.paddingTop}get documentPadding(){return{top:this.viewState.paddingTop,bottom:this.viewState.paddingBottom}}get scaleX(){return this.viewState.scaleX}get scaleY(){return this.viewState.scaleY}elementAtHeight(e){return this.readMeasured(),this.viewState.elementAtHeight(e)}lineBlockAtHeight(e){return this.readMeasured(),this.viewState.lineBlockAtHeight(e)}get viewportLineBlocks(){return this.viewState.viewportLines}lineBlockAt(e){return this.viewState.lineBlockAt(e)}get contentHeight(){return this.viewState.contentHeight}moveByChar(e,t,i){return Og(this,e,Bv(this,e,t,i))}moveByGroup(e,t){return Og(this,e,Bv(this,e,t,i=>qR(this,e.head,i)))}visualLineSide(e,t){let i=this.bidiSpans(e),r=this.textDirectionAt(e.from),s=i[t?i.length-1:0];return V.cursor(s.side(t,r)+e.from,s.forward(!t,r)?1:-1)}moveToLineBoundary(e,t,i=!0){return YR(this,e,t,i)}moveVertically(e,t,i){return Og(this,e,UR(this,e,t,i))}domAtPos(e){return this.docView.domAtPos(e)}posAtDOM(e,t=0){return this.docView.posFromDOM(e,t)}posAtCoords(e,t=!0){return this.readMeasured(),IP(this,e,t)}coordsAtPos(e,t=1){this.readMeasured();let i=this.docView.coordsAt(e,t);if(!i||i.left==i.right)return i;let r=this.state.doc.lineAt(e),s=this.bidiSpans(r),o=s[zr.find(s,e-r.from,-1,t)];return Bf(i,o.dir==st.LTR==t>0)}coordsForChar(e){return this.readMeasured(),this.docView.coordsForChar(e)}get defaultCharacterWidth(){return this.viewState.heightOracle.charWidth}get defaultLineHeight(){return this.viewState.heightOracle.lineHeight}get textDirection(){return this.viewState.defaultTextDirection}textDirectionAt(e){return!this.state.facet($P)||ethis.viewport.to?this.textDirection:(this.readMeasured(),this.docView.textDirectionAt(e))}get lineWrapping(){return this.viewState.heightOracle.lineWrapping}bidiSpans(e){if(e.length>zA)return SP(e.length);let t=this.textDirectionAt(e.from),i;for(let s of this.bidiCache)if(s.from==e.from&&s.dir==t&&(s.fresh||bP(s.isolates,i=Lv(this,e))))return s.order;i||(i=Lv(this,e));let r=MR(e.text,t,i);return this.bidiCache.push(new ef(e.from,e.to,t,i,!0,r)),r}get hasFocus(){var e;return(this.dom.ownerDocument.hasFocus()||ae.safari&&((e=this.inputState)===null||e===void 0?void 0:e.lastContextMenu)>Date.now()-3e4)&&this.root.activeElement==this.contentDOM}focus(){this.observer.ignore(()=>{lP(this.contentDOM),this.docView.updateSelection()})}setRoot(e){this._root!=e&&(this._root=e,this.observer.setWindow((e.nodeType==9?e:e.ownerDocument).defaultView||window),this.mountStyles())}destroy(){this.root.activeElement==this.contentDOM&&this.contentDOM.blur();for(let e of this.plugins)e.destroy(this);this.plugins=[],this.inputState.destroy(),this.docView.destroy(),this.dom.remove(),this.observer.destroy(),this.measureScheduled>-1&&this.win.cancelAnimationFrame(this.measureScheduled),this.destroyed=!0}static scrollIntoView(e,t={}){return zu.of(new Ao(typeof e=="number"?V.cursor(e):e,t.y,t.x,t.yMargin,t.xMargin))}scrollSnapshot(){let{scrollTop:e,scrollLeft:t}=this.scrollDOM,i=this.viewState.scrollAnchorAt(e);return zu.of(new Ao(V.cursor(i.from),"start","start",i.top-e,t,!0))}setTabFocusMode(e){e==null?this.inputState.tabFocusMode=this.inputState.tabFocusMode<0?0:-1:typeof e=="boolean"?this.inputState.tabFocusMode=e?0:-1:this.inputState.tabFocusMode!=0&&(this.inputState.tabFocusMode=Date.now()+e)}static domEventHandlers(e){return kt.define(()=>({}),{eventHandlers:e})}static domEventObservers(e){return kt.define(()=>({}),{eventObservers:e})}static theme(e,t){let i=Yr.newName(),r=[Xu.of(i),la.of(Gm(`.${i}`,e))];return t&&t.dark&&r.push(Um.of(!0)),r}static baseTheme(e){return Jr.lowest(la.of(Gm("."+Hm,e,t_)))}static findFromDOM(e){var t;let i=e.querySelector(".cm-content"),r=i&&Ue.get(i)||Ue.get(e);return((t=r==null?void 0:r.rootView)===null||t===void 0?void 0:t.view)||null}}ce.styleModule=la;ce.inputHandler=CP;ce.clipboardInputFilter=KO;ce.clipboardOutputFilter=JO;ce.scrollHandler=RP;ce.focusChangeEffect=TP;ce.perLineTextDirection=$P;ce.exceptionSink=QP;ce.updateListener=Vm;ce.editable=tr;ce.mouseSelectionStyle=_P;ce.dragMovesSelection=PP;ce.clickAddsSelectionRange=kP;ce.decorations=Ia;ce.outerDecorations=LP;ce.atomicRanges=uc;ce.bidiIsolatedRanges=DP;ce.scrollMargins=zP;ce.darkTheme=Um;ce.cspNonce=fe.define({combine:n=>n.length?n[0]:""});ce.contentAttributes=e0;ce.editorAttributes=EP;ce.lineWrapping=ce.contentAttributes.of({class:"cm-lineWrapping"});ce.announce=Te.define();const zA=4096,sb={};class ef{constructor(e,t,i,r,s,o){this.from=e,this.to=t,this.dir=i,this.isolates=r,this.fresh=s,this.order=o}static update(e,t){if(t.empty&&!e.some(s=>s.fresh))return e;let i=[],r=e.length?e[e.length-1].dir:st.LTR;for(let s=Math.max(0,e.length-10);s=0;r--){let s=i[r],o=typeof s=="function"?s(n):s;o&&Im(o,t)}return t}const ZA=ae.mac?"mac":ae.windows?"win":ae.linux?"linux":"key";function IA(n,e){const t=n.split(/-(?!$)/);let i=t[t.length-1];i=="Space"&&(i=" ");let r,s,o,a;for(let c=0;ci.concat(r),[]))),t}function BA(n,e,t){return i_(n_(n.state),e,n,t)}let Er=null;const NA=4e3;function XA(n,e=ZA){let t=Object.create(null),i=Object.create(null),r=(o,a)=>{let c=i[o];if(c==null)i[o]=a;else if(c!=a)throw new Error("Key binding "+o+" is used both as a regular binding and as a multi-stroke prefix")},s=(o,a,c,h,f)=>{var p,m;let y=t[o]||(t[o]=Object.create(null)),v=a.split(/ (?!$)/).map(w=>IA(w,e));for(let w=1;w{let P=Er={view:_,prefix:C,scope:o};return setTimeout(()=>{Er==P&&(Er=null)},NA),!0}]})}let b=v.join(" ");r(b,!1);let S=y[b]||(y[b]={preventDefault:!1,stopPropagation:!1,run:((m=(p=y._any)===null||p===void 0?void 0:p.run)===null||m===void 0?void 0:m.slice())||[]});c&&S.run.push(c),h&&(S.preventDefault=!0),f&&(S.stopPropagation=!0)};for(let o of n){let a=o.scope?o.scope.split(" "):["editor"];if(o.any)for(let h of a){let f=t[h]||(t[h]=Object.create(null));f._any||(f._any={preventDefault:!1,stopPropagation:!1,run:[]});let{any:p}=o;for(let m in f)f[m].run.push(y=>p(y,Km))}let c=o[e]||o.key;if(c)for(let h of a)s(h,c,o.run,o.preventDefault,o.stopPropagation),o.shift&&s(h,"Shift-"+c,o.shift,o.preventDefault,o.stopPropagation)}return t}let Km=null;function i_(n,e,t,i){Km=e;let r=hR(e),s=yn(r,0),o=$i(s)==r.length&&r!=" ",a="",c=!1,h=!1,f=!1;Er&&Er.view==t&&Er.scope==i&&(a=Er.prefix+" ",VP.indexOf(e.keyCode)<0&&(h=!0,Er=null));let p=new Set,m=S=>{if(S){for(let w of S.run)if(!p.has(w)&&(p.add(w),w(t)))return S.stopPropagation&&(f=!0),!0;S.preventDefault&&(S.stopPropagation&&(f=!0),h=!0)}return!1},y=n[i],v,b;return y&&(m(y[a+Wu(r,e,!o)])?c=!0:o&&(e.altKey||e.metaKey||e.ctrlKey)&&!(ae.windows&&e.ctrlKey&&e.altKey)&&!(ae.mac&&e.altKey&&!(e.ctrlKey||e.metaKey))&&(v=qr[e.keyCode])&&v!=r?(m(y[a+Wu(v,e,!0)])||e.shiftKey&&(b=Da[e.keyCode])!=r&&b!=v&&m(y[a+Wu(b,e,!1)]))&&(c=!0):o&&e.shiftKey&&m(y[a+Wu(r,e,!0)])&&(c=!0),!c&&m(y._any)&&(c=!0)),h&&(c=!0),c&&f&&e.stopPropagation(),Km=null,c}class fc{constructor(e,t,i,r,s){this.className=e,this.left=t,this.top=i,this.width=r,this.height=s}draw(){let e=document.createElement("div");return e.className=this.className,this.adjust(e),e}update(e,t){return t.className!=this.className?!1:(this.adjust(e),!0)}adjust(e){e.style.left=this.left+"px",e.style.top=this.top+"px",this.width!=null&&(e.style.width=this.width+"px"),e.style.height=this.height+"px"}eq(e){return this.left==e.left&&this.top==e.top&&this.width==e.width&&this.height==e.height&&this.className==e.className}static forRange(e,t,i){if(i.empty){let r=e.coordsAtPos(i.head,i.assoc||1);if(!r)return[];let s=r_(e);return[new fc(t,r.left-s.left,r.top-s.top,null,r.bottom-r.top)]}else return WA(e,t,i)}}function r_(n){let e=n.scrollDOM.getBoundingClientRect();return{left:(n.textDirection==st.LTR?e.left:e.right-n.scrollDOM.clientWidth*n.scaleX)-n.scrollDOM.scrollLeft*n.scaleX,top:e.top-n.scrollDOM.scrollTop*n.scaleY}}function ab(n,e,t,i){let r=n.coordsAtPos(e,t*2);if(!r)return i;let s=n.dom.getBoundingClientRect(),o=(r.top+r.bottom)/2,a=n.posAtCoords({x:s.left+1,y:o}),c=n.posAtCoords({x:s.right-1,y:o});return a==null||c==null?i:{from:Math.max(i.from,Math.min(a,c)),to:Math.min(i.to,Math.max(a,c))}}function WA(n,e,t){if(t.to<=n.viewport.from||t.from>=n.viewport.to)return[];let i=Math.max(t.from,n.viewport.from),r=Math.min(t.to,n.viewport.to),s=n.textDirection==st.LTR,o=n.contentDOM,a=o.getBoundingClientRect(),c=r_(n),h=o.querySelector(".cm-line"),f=h&&window.getComputedStyle(h),p=a.left+(f?parseInt(f.paddingLeft)+Math.min(0,parseInt(f.textIndent)):0),m=a.right-(f?parseInt(f.paddingRight):0),y=Ym(n,i,1),v=Ym(n,r,-1),b=y.type==an.Text?y:null,S=v.type==an.Text?v:null;if(b&&(n.lineWrapping||y.widgetLineBreaks)&&(b=ab(n,i,1,b)),S&&(n.lineWrapping||v.widgetLineBreaks)&&(S=ab(n,r,-1,S)),b&&S&&b.from==S.from&&b.to==S.to)return C(_(t.from,t.to,b));{let Q=b?_(t.from,null,b):P(y,!1),$=S?_(null,t.to,S):P(v,!0),M=[];return(b||y).to<(S||v).from-(b&&S?1:0)||y.widgetLineBreaks>1&&Q.bottom+n.defaultLineHeight/2<$.top?M.push(w(p,Q.bottom,m,$.top)):Q.bottom<$.top&&n.elementAtHeight((Q.bottom+$.top)/2).type==an.Text&&(Q.bottom=$.top=(Q.bottom+$.top)/2),C(Q).concat(M).concat(C($))}function w(Q,$,M,Z){return new fc(e,Q-c.left,$-c.top,M-Q,Z-$)}function C({top:Q,bottom:$,horizontal:M}){let Z=[];for(let j=0;jF&&oe.from=se)break;J>re&&W(Math.max(U,re),Q==null&&U<=F,Math.min(J,se),$==null&&J>=ie,q.dir)}if(re=ue.to+1,re>=se)break}return Y.length==0&&W(F,Q==null,ie,$==null,n.textDirection),{top:Z,bottom:j,horizontal:Y}}function P(Q,$){let M=a.top+($?Q.top:Q.bottom);return{top:M,bottom:M,horizontal:[]}}}function VA(n,e){return n.constructor==e.constructor&&n.eq(e)}class FA{constructor(e,t){this.view=e,this.layer=t,this.drawn=[],this.scaleX=1,this.scaleY=1,this.measureReq={read:this.measure.bind(this),write:this.draw.bind(this)},this.dom=e.scrollDOM.appendChild(document.createElement("div")),this.dom.classList.add("cm-layer"),t.above&&this.dom.classList.add("cm-layer-above"),t.class&&this.dom.classList.add(t.class),this.scale(),this.dom.setAttribute("aria-hidden","true"),this.setOrder(e.state),e.requestMeasure(this.measureReq),t.mount&&t.mount(this.dom,e)}update(e){e.startState.facet(Ch)!=e.state.facet(Ch)&&this.setOrder(e.state),(this.layer.update(e,this.dom)||e.geometryChanged)&&(this.scale(),e.view.requestMeasure(this.measureReq))}docViewUpdate(e){this.layer.updateOnDocViewUpdate!==!1&&e.requestMeasure(this.measureReq)}setOrder(e){let t=0,i=e.facet(Ch);for(;t!VA(t,this.drawn[i]))){let t=this.dom.firstChild,i=0;for(let r of e)r.update&&t&&r.constructor&&this.drawn[i].constructor&&r.update(t,this.drawn[i])?(t=t.nextSibling,i++):this.dom.insertBefore(r.draw(),t);for(;t;){let r=t.nextSibling;t.remove(),t=r}this.drawn=e,ae.ios&&(this.dom.style.display=this.dom.firstChild?"":"none")}}destroy(){this.layer.destroy&&this.layer.destroy(this.dom,this.view),this.dom.remove()}}const Ch=fe.define();function s_(n){return[kt.define(e=>new FA(e,n)),Ch.of(n)]}const ja=fe.define({combine(n){return Zi(n,{cursorBlinkRate:1200,drawRangeCursor:!0},{cursorBlinkRate:(e,t)=>Math.min(e,t),drawRangeCursor:(e,t)=>e||t})}});function YA(n={}){return[ja.of(n),qA,UA,HA,MP.of(!0)]}function o_(n){return n.startState.facet(ja)!=n.state.facet(ja)}const qA=s_({above:!0,markers(n){let{state:e}=n,t=e.facet(ja),i=[];for(let r of e.selection.ranges){let s=r==e.selection.main;if(r.empty||t.drawRangeCursor){let o=s?"cm-cursor cm-cursor-primary":"cm-cursor cm-cursor-secondary",a=r.empty?r:V.cursor(r.head,r.head>r.anchor?-1:1);for(let c of fc.forRange(n,o,a))i.push(c)}}return i},update(n,e){n.transactions.some(i=>i.selection)&&(e.style.animationName=e.style.animationName=="cm-blink"?"cm-blink2":"cm-blink");let t=o_(n);return t&&cb(n.state,e),n.docChanged||n.selectionSet||t},mount(n,e){cb(e.state,n)},class:"cm-cursorLayer"});function cb(n,e){e.style.animationDuration=n.facet(ja).cursorBlinkRate+"ms"}const UA=s_({above:!1,markers(n){return n.state.selection.ranges.map(e=>e.empty?[]:fc.forRange(n,"cm-selectionBackground",e)).reduce((e,t)=>e.concat(t))},update(n,e){return n.docChanged||n.selectionSet||n.viewportChanged||o_(n)},class:"cm-selectionLayer"}),HA=Jr.highest(ce.theme({".cm-line":{"& ::selection, &::selection":{backgroundColor:"transparent !important"},caretColor:"transparent !important"},".cm-content":{caretColor:"transparent !important","& :focus":{caretColor:"initial !important","&::selection, & ::selection":{backgroundColor:"Highlight !important"}}}})),l_=Te.define({map(n,e){return n==null?null:e.mapPos(n)}}),ua=zt.define({create(){return null},update(n,e){return n!=null&&(n=e.changes.mapPos(n)),e.effects.reduce((t,i)=>i.is(l_)?i.value:t,n)}}),GA=kt.fromClass(class{constructor(n){this.view=n,this.cursor=null,this.measureReq={read:this.readPos.bind(this),write:this.drawCursor.bind(this)}}update(n){var e;let t=n.state.field(ua);t==null?this.cursor!=null&&((e=this.cursor)===null||e===void 0||e.remove(),this.cursor=null):(this.cursor||(this.cursor=this.view.scrollDOM.appendChild(document.createElement("div")),this.cursor.className="cm-dropCursor"),(n.startState.field(ua)!=t||n.docChanged||n.geometryChanged)&&this.view.requestMeasure(this.measureReq))}readPos(){let{view:n}=this,e=n.state.field(ua),t=e!=null&&n.coordsAtPos(e);if(!t)return null;let i=n.scrollDOM.getBoundingClientRect();return{left:t.left-i.left+n.scrollDOM.scrollLeft*n.scaleX,top:t.top-i.top+n.scrollDOM.scrollTop*n.scaleY,height:t.bottom-t.top}}drawCursor(n){if(this.cursor){let{scaleX:e,scaleY:t}=this.view;n?(this.cursor.style.left=n.left/e+"px",this.cursor.style.top=n.top/t+"px",this.cursor.style.height=n.height/t+"px"):this.cursor.style.left="-100000px"}}destroy(){this.cursor&&this.cursor.remove()}setDropPos(n){this.view.state.field(ua)!=n&&this.view.dispatch({effects:l_.of(n)})}},{eventObservers:{dragover(n){this.setDropPos(this.view.posAtCoords({x:n.clientX,y:n.clientY}))},dragleave(n){(n.target==this.view.contentDOM||!this.view.contentDOM.contains(n.relatedTarget))&&this.setDropPos(null)},dragend(){this.setDropPos(null)},drop(){this.setDropPos(null)}}});function KA(){return[ua,GA]}function ub(n,e,t,i,r){e.lastIndex=0;for(let s=n.iterRange(t,i),o=t,a;!s.next().done;o+=s.value.length)if(!s.lineBreak)for(;a=e.exec(s.value);)r(o+a.index,a)}function JA(n,e){let t=n.visibleRanges;if(t.length==1&&t[0].from==n.viewport.from&&t[0].to==n.viewport.to)return t;let i=[];for(let{from:r,to:s}of t)r=Math.max(n.state.doc.lineAt(r).from,r-e),s=Math.min(n.state.doc.lineAt(s).to,s+e),i.length&&i[i.length-1].to>=r?i[i.length-1].to=s:i.push({from:r,to:s});return i}class eE{constructor(e){const{regexp:t,decoration:i,decorate:r,boundary:s,maxLength:o=1e3}=e;if(!t.global)throw new RangeError("The regular expression given to MatchDecorator should have its 'g' flag set");if(this.regexp=t,r)this.addMatch=(a,c,h,f)=>r(f,h,h+a[0].length,a,c);else if(typeof i=="function")this.addMatch=(a,c,h,f)=>{let p=i(a,c,h);p&&f(h,h+a[0].length,p)};else if(i)this.addMatch=(a,c,h,f)=>f(h,h+a[0].length,i);else throw new RangeError("Either 'decorate' or 'decoration' should be provided to MatchDecorator");this.boundary=s,this.maxLength=o}createDeco(e){let t=new sr,i=t.add.bind(t);for(let{from:r,to:s}of JA(e,this.maxLength))ub(e.state.doc,this.regexp,r,s,(o,a)=>this.addMatch(a,e,o,i));return t.finish()}updateDeco(e,t){let i=1e9,r=-1;return e.docChanged&&e.changes.iterChanges((s,o,a,c)=>{c>=e.view.viewport.from&&a<=e.view.viewport.to&&(i=Math.min(a,i),r=Math.max(c,r))}),e.viewportMoved||r-i>1e3?this.createDeco(e.view):r>-1?this.updateRange(e.view,t.map(e.changes),i,r):t}updateRange(e,t,i,r){for(let s of e.visibleRanges){let o=Math.max(s.from,i),a=Math.min(s.to,r);if(a>=o){let c=e.state.doc.lineAt(o),h=c.toc.from;o--)if(this.boundary.test(c.text[o-1-c.from])){f=o;break}for(;am.push(w.range(b,S));if(c==h)for(this.regexp.lastIndex=f-c.from;(y=this.regexp.exec(c.text))&&y.indexthis.addMatch(S,e,b,v));t=t.update({filterFrom:f,filterTo:p,filter:(b,S)=>bp,add:m})}}return t}}const Jm=/x/.unicode!=null?"gu":"g",tE=new RegExp(`[\0-\b ---Ÿ­؜​‎‏\u2028\u2029‭‮⁦⁧⁩\uFEFF-]`,Jm),nE={0:"null",7:"bell",8:"backspace",10:"newline",11:"vertical tab",13:"carriage return",27:"escape",8203:"zero width space",8204:"zero width non-joiner",8205:"zero width joiner",8206:"left-to-right mark",8207:"right-to-left mark",8232:"line separator",8237:"left-to-right override",8238:"right-to-left override",8294:"left-to-right isolate",8295:"right-to-left isolate",8297:"pop directional isolate",8233:"paragraph separator",65279:"zero width no-break space",65532:"object replacement"};let vg=null;function iE(){var n;if(vg==null&&typeof document<"u"&&document.body){let e=document.body.style;vg=((n=e.tabSize)!==null&&n!==void 0?n:e.MozTabSize)!=null}return vg||!1}const Th=fe.define({combine(n){let e=Zi(n,{render:null,specialChars:tE,addSpecialChars:null});return(e.replaceTabs=!iE())&&(e.specialChars=new RegExp(" |"+e.specialChars.source,Jm)),e.addSpecialChars&&(e.specialChars=new RegExp(e.specialChars.source+"|"+e.addSpecialChars.source,Jm)),e}});function rE(n={}){return[Th.of(n),sE()]}let hb=null;function sE(){return hb||(hb=kt.fromClass(class{constructor(n){this.view=n,this.decorations=Pe.none,this.decorationCache=Object.create(null),this.decorator=this.makeDecorator(n.state.facet(Th)),this.decorations=this.decorator.createDeco(n)}makeDecorator(n){return new eE({regexp:n.specialChars,decoration:(e,t,i)=>{let{doc:r}=t.state,s=yn(e[0],0);if(s==9){let o=r.lineAt(i),a=t.state.tabSize,c=tl(o.text,a,i-o.from);return Pe.replace({widget:new cE((a-c%a)*this.view.defaultCharacterWidth/this.view.scaleX)})}return this.decorationCache[s]||(this.decorationCache[s]=Pe.replace({widget:new aE(n,s)}))},boundary:n.replaceTabs?void 0:/[^]/})}update(n){let e=n.state.facet(Th);n.startState.facet(Th)!=e?(this.decorator=this.makeDecorator(e),this.decorations=this.decorator.createDeco(n.view)):this.decorations=this.decorator.updateDeco(n,this.decorations)}},{decorations:n=>n.decorations}))}const oE="•";function lE(n){return n>=32?oE:n==10?"␤":String.fromCharCode(9216+n)}class aE extends cr{constructor(e,t){super(),this.options=e,this.code=t}eq(e){return e.code==this.code}toDOM(e){let t=lE(this.code),i=e.state.phrase("Control character")+" "+(nE[this.code]||"0x"+this.code.toString(16)),r=this.options.render&&this.options.render(this.code,i,t);if(r)return r;let s=document.createElement("span");return s.textContent=t,s.title=i,s.setAttribute("aria-label",i),s.className="cm-specialChar",s}ignoreEvent(){return!1}}class cE extends cr{constructor(e){super(),this.width=e}eq(e){return e.width==this.width}toDOM(){let e=document.createElement("span");return e.textContent=" ",e.className="cm-tab",e.style.width=this.width+"px",e}ignoreEvent(){return!1}}function uE(){return fE}const hE=Pe.line({class:"cm-activeLine"}),fE=kt.fromClass(class{constructor(n){this.decorations=this.getDeco(n)}update(n){(n.docChanged||n.selectionSet)&&(this.decorations=this.getDeco(n.view))}getDeco(n){let e=-1,t=[];for(let i of n.state.selection.ranges){let r=n.lineBlockAt(i.head);r.from>e&&(t.push(hE.range(r.from)),e=r.from)}return Pe.set(t)}},{decorations:n=>n.decorations}),eO=2e3;function dE(n,e,t){let i=Math.min(e.line,t.line),r=Math.max(e.line,t.line),s=[];if(e.off>eO||t.off>eO||e.col<0||t.col<0){let o=Math.min(e.off,t.off),a=Math.max(e.off,t.off);for(let c=i;c<=r;c++){let h=n.doc.line(c);h.length<=a&&s.push(V.range(h.from+o,h.to+a))}}else{let o=Math.min(e.col,t.col),a=Math.max(e.col,t.col);for(let c=i;c<=r;c++){let h=n.doc.line(c),f=Mm(h.text,o,n.tabSize,!0);if(f<0)s.push(V.cursor(h.to));else{let p=Mm(h.text,a,n.tabSize);s.push(V.range(h.from+f,h.from+p))}}}return s}function pE(n,e){let t=n.coordsAtPos(n.viewport.from);return t?Math.round(Math.abs((t.left-e)/n.defaultCharacterWidth)):-1}function fb(n,e){let t=n.posAtCoords({x:e.clientX,y:e.clientY},!1),i=n.state.doc.lineAt(t),r=t-i.from,s=r>eO?-1:r==i.length?pE(n,e.clientX):tl(i.text,n.state.tabSize,t-i.from);return{line:i.number,col:s,off:r}}function gE(n,e){let t=fb(n,e),i=n.state.selection;return t?{update(r){if(r.docChanged){let s=r.changes.mapPos(r.startState.doc.line(t.line).from),o=r.state.doc.lineAt(s);t={line:o.number,col:t.col,off:Math.min(t.off,o.length)},i=i.map(r.changes)}},get(r,s,o){let a=fb(n,r);if(!a)return i;let c=dE(n.state,t,a);return c.length?o?V.create(c.concat(i.ranges)):V.create(c):i}}:null}function mE(n){let e=(t=>t.altKey&&t.button==0);return ce.mouseSelectionStyle.of((t,i)=>e(i)?gE(t,i):null)}const OE={Alt:[18,n=>!!n.altKey],Control:[17,n=>!!n.ctrlKey],Shift:[16,n=>!!n.shiftKey],Meta:[91,n=>!!n.metaKey]},yE={style:"cursor: crosshair"};function xE(n={}){let[e,t]=OE[n.key||"Alt"],i=kt.fromClass(class{constructor(r){this.view=r,this.isDown=!1}set(r){this.isDown!=r&&(this.isDown=r,this.view.update([]))}},{eventObservers:{keydown(r){this.set(r.keyCode==e||t(r))},keyup(r){(r.keyCode==e||!t(r))&&this.set(!1)},mousemove(r){this.set(t(r))}}});return[i,ce.contentAttributes.of(r=>{var s;return!((s=r.plugin(i))===null||s===void 0)&&s.isDown?yE:null})]}const Hl="-10000px";class a_{constructor(e,t,i,r){this.facet=t,this.createTooltipView=i,this.removeTooltipView=r,this.input=e.state.facet(t),this.tooltips=this.input.filter(o=>o);let s=null;this.tooltipViews=this.tooltips.map(o=>s=i(o,s))}update(e,t){var i;let r=e.state.facet(this.facet),s=r.filter(c=>c);if(r===this.input){for(let c of this.tooltipViews)c.update&&c.update(e);return!1}let o=[],a=t?[]:null;for(let c=0;ct[h]=c),t.length=a.length),this.input=r,this.tooltips=s,this.tooltipViews=o,!0}}function vE(n){let e=n.dom.ownerDocument.documentElement;return{top:0,left:0,bottom:e.clientHeight,right:e.clientWidth}}const bg=fe.define({combine:n=>{var e,t,i;return{position:ae.ios?"absolute":((e=n.find(r=>r.position))===null||e===void 0?void 0:e.position)||"fixed",parent:((t=n.find(r=>r.parent))===null||t===void 0?void 0:t.parent)||null,tooltipSpace:((i=n.find(r=>r.tooltipSpace))===null||i===void 0?void 0:i.tooltipSpace)||vE}}}),db=new WeakMap,s0=kt.fromClass(class{constructor(n){this.view=n,this.above=[],this.inView=!0,this.madeAbsolute=!1,this.lastTransaction=0,this.measureTimeout=-1;let e=n.state.facet(bg);this.position=e.position,this.parent=e.parent,this.classes=n.themeClasses,this.createContainer(),this.measureReq={read:this.readMeasure.bind(this),write:this.writeMeasure.bind(this),key:this},this.resizeObserver=typeof ResizeObserver=="function"?new ResizeObserver(()=>this.measureSoon()):null,this.manager=new a_(n,o0,(t,i)=>this.createTooltip(t,i),t=>{this.resizeObserver&&this.resizeObserver.unobserve(t.dom),t.dom.remove()}),this.above=this.manager.tooltips.map(t=>!!t.above),this.intersectionObserver=typeof IntersectionObserver=="function"?new IntersectionObserver(t=>{Date.now()>this.lastTransaction-50&&t.length>0&&t[t.length-1].intersectionRatio<1&&this.measureSoon()},{threshold:[1]}):null,this.observeIntersection(),n.win.addEventListener("resize",this.measureSoon=this.measureSoon.bind(this)),this.maybeMeasure()}createContainer(){this.parent?(this.container=document.createElement("div"),this.container.style.position="relative",this.container.className=this.view.themeClasses,this.parent.appendChild(this.container)):this.container=this.view.dom}observeIntersection(){if(this.intersectionObserver){this.intersectionObserver.disconnect();for(let n of this.manager.tooltipViews)this.intersectionObserver.observe(n.dom)}}measureSoon(){this.measureTimeout<0&&(this.measureTimeout=setTimeout(()=>{this.measureTimeout=-1,this.maybeMeasure()},50))}update(n){n.transactions.length&&(this.lastTransaction=Date.now());let e=this.manager.update(n,this.above);e&&this.observeIntersection();let t=e||n.geometryChanged,i=n.state.facet(bg);if(i.position!=this.position&&!this.madeAbsolute){this.position=i.position;for(let r of this.manager.tooltipViews)r.dom.style.position=this.position;t=!0}if(i.parent!=this.parent){this.parent&&this.container.remove(),this.parent=i.parent,this.createContainer();for(let r of this.manager.tooltipViews)this.container.appendChild(r.dom);t=!0}else this.parent&&this.view.themeClasses!=this.classes&&(this.classes=this.container.className=this.view.themeClasses);t&&this.maybeMeasure()}createTooltip(n,e){let t=n.create(this.view),i=e?e.dom:null;if(t.dom.classList.add("cm-tooltip"),n.arrow&&!t.dom.querySelector(".cm-tooltip > .cm-tooltip-arrow")){let r=document.createElement("div");r.className="cm-tooltip-arrow",t.dom.appendChild(r)}return t.dom.style.position=this.position,t.dom.style.top=Hl,t.dom.style.left="0px",this.container.insertBefore(t.dom,i),t.mount&&t.mount(this.view),this.resizeObserver&&this.resizeObserver.observe(t.dom),t}destroy(){var n,e,t;this.view.win.removeEventListener("resize",this.measureSoon);for(let i of this.manager.tooltipViews)i.dom.remove(),(n=i.destroy)===null||n===void 0||n.call(i);this.parent&&this.container.remove(),(e=this.resizeObserver)===null||e===void 0||e.disconnect(),(t=this.intersectionObserver)===null||t===void 0||t.disconnect(),clearTimeout(this.measureTimeout)}readMeasure(){let n=1,e=1,t=!1;if(this.position=="fixed"&&this.manager.tooltipViews.length){let{dom:s}=this.manager.tooltipViews[0];if(ae.gecko)t=s.offsetParent!=this.container.ownerDocument.body;else if(s.style.top==Hl&&s.style.left=="0px"){let o=s.getBoundingClientRect();t=Math.abs(o.top+1e4)>1||Math.abs(o.left)>1}}if(t||this.position=="absolute")if(this.parent){let s=this.parent.getBoundingClientRect();s.width&&s.height&&(n=s.width/this.parent.offsetWidth,e=s.height/this.parent.offsetHeight)}else({scaleX:n,scaleY:e}=this.view.viewState);let i=this.view.scrollDOM.getBoundingClientRect(),r=t0(this.view);return{visible:{left:i.left+r.left,top:i.top+r.top,right:i.right-r.right,bottom:i.bottom-r.bottom},parent:this.parent?this.container.getBoundingClientRect():this.view.dom.getBoundingClientRect(),pos:this.manager.tooltips.map((s,o)=>{let a=this.manager.tooltipViews[o];return a.getCoords?a.getCoords(s.pos):this.view.coordsAtPos(s.pos)}),size:this.manager.tooltipViews.map(({dom:s})=>s.getBoundingClientRect()),space:this.view.state.facet(bg).tooltipSpace(this.view),scaleX:n,scaleY:e,makeAbsolute:t}}writeMeasure(n){var e;if(n.makeAbsolute){this.madeAbsolute=!0,this.position="absolute";for(let a of this.manager.tooltipViews)a.dom.style.position="absolute"}let{visible:t,space:i,scaleX:r,scaleY:s}=n,o=[];for(let a=0;a=Math.min(t.bottom,i.bottom)||p.rightMath.min(t.right,i.right)+.1)){f.style.top=Hl;continue}let y=c.arrow?h.dom.querySelector(".cm-tooltip-arrow"):null,v=y?7:0,b=m.right-m.left,S=(e=db.get(h))!==null&&e!==void 0?e:m.bottom-m.top,w=h.offset||SE,C=this.view.textDirection==st.LTR,_=m.width>i.right-i.left?C?i.left:i.right-m.width:C?Math.max(i.left,Math.min(p.left-(y?14:0)+w.x,i.right-b)):Math.min(Math.max(i.left,p.left-b+(y?14:0)-w.x),i.right-b),P=this.above[a];!c.strictSide&&(P?p.top-S-v-w.yi.bottom)&&P==i.bottom-p.bottom>p.top-i.top&&(P=this.above[a]=!P);let Q=(P?p.top-i.top:i.bottom-p.bottom)-v;if(Q_&&Z.top<$+S&&Z.bottom>$&&($=P?Z.top-S-2-v:Z.bottom+v+2);if(this.position=="absolute"?(f.style.top=($-n.parent.top)/s+"px",pb(f,(_-n.parent.left)/r)):(f.style.top=$/s+"px",pb(f,_/r)),y){let Z=p.left+(C?w.x:-w.x)-(_+14-7);y.style.left=Z/r+"px"}h.overlap!==!0&&o.push({left:_,top:$,right:M,bottom:$+S}),f.classList.toggle("cm-tooltip-above",P),f.classList.toggle("cm-tooltip-below",!P),h.positioned&&h.positioned(n.space)}}maybeMeasure(){if(this.manager.tooltips.length&&(this.view.inView&&this.view.requestMeasure(this.measureReq),this.inView!=this.view.inView&&(this.inView=this.view.inView,!this.inView)))for(let n of this.manager.tooltipViews)n.dom.style.top=Hl}},{eventObservers:{scroll(){this.maybeMeasure()}}});function pb(n,e){let t=parseInt(n.style.left,10);(isNaN(t)||Math.abs(e-t)>1)&&(n.style.left=e+"px")}const bE=ce.baseTheme({".cm-tooltip":{zIndex:500,boxSizing:"border-box"},"&light .cm-tooltip":{border:"1px solid #bbb",backgroundColor:"#f5f5f5"},"&light .cm-tooltip-section:not(:first-child)":{borderTop:"1px solid #bbb"},"&dark .cm-tooltip":{backgroundColor:"#333338",color:"white"},".cm-tooltip-arrow":{height:"7px",width:"14px",position:"absolute",zIndex:-1,overflow:"hidden","&:before, &:after":{content:"''",position:"absolute",width:0,height:0,borderLeft:"7px solid transparent",borderRight:"7px solid transparent"},".cm-tooltip-above &":{bottom:"-7px","&:before":{borderTop:"7px solid #bbb"},"&:after":{borderTop:"7px solid #f5f5f5",bottom:"1px"}},".cm-tooltip-below &":{top:"-7px","&:before":{borderBottom:"7px solid #bbb"},"&:after":{borderBottom:"7px solid #f5f5f5",top:"1px"}}},"&dark .cm-tooltip .cm-tooltip-arrow":{"&:before":{borderTopColor:"#333338",borderBottomColor:"#333338"},"&:after":{borderTopColor:"transparent",borderBottomColor:"transparent"}}}),SE={x:0,y:0},o0=fe.define({enables:[s0,bE]}),tf=fe.define({combine:n=>n.reduce((e,t)=>e.concat(t),[])});class Xf{static create(e){return new Xf(e)}constructor(e){this.view=e,this.mounted=!1,this.dom=document.createElement("div"),this.dom.classList.add("cm-tooltip-hover"),this.manager=new a_(e,tf,(t,i)=>this.createHostedView(t,i),t=>t.dom.remove())}createHostedView(e,t){let i=e.create(this.view);return i.dom.classList.add("cm-tooltip-section"),this.dom.insertBefore(i.dom,t?t.dom.nextSibling:this.dom.firstChild),this.mounted&&i.mount&&i.mount(this.view),i}mount(e){for(let t of this.manager.tooltipViews)t.mount&&t.mount(e);this.mounted=!0}positioned(e){for(let t of this.manager.tooltipViews)t.positioned&&t.positioned(e)}update(e){this.manager.update(e)}destroy(){var e;for(let t of this.manager.tooltipViews)(e=t.destroy)===null||e===void 0||e.call(t)}passProp(e){let t;for(let i of this.manager.tooltipViews){let r=i[e];if(r!==void 0){if(t===void 0)t=r;else if(t!==r)return}}return t}get offset(){return this.passProp("offset")}get getCoords(){return this.passProp("getCoords")}get overlap(){return this.passProp("overlap")}get resize(){return this.passProp("resize")}}const wE=o0.compute([tf],n=>{let e=n.facet(tf);return e.length===0?null:{pos:Math.min(...e.map(t=>t.pos)),end:Math.max(...e.map(t=>{var i;return(i=t.end)!==null&&i!==void 0?i:t.pos})),create:Xf.create,above:e[0].above,arrow:e.some(t=>t.arrow)}});class kE{constructor(e,t,i,r,s){this.view=e,this.source=t,this.field=i,this.setHover=r,this.hoverTime=s,this.hoverTimeout=-1,this.restartTimeout=-1,this.pending=null,this.lastMove={x:0,y:0,target:e.dom,time:0},this.checkHover=this.checkHover.bind(this),e.dom.addEventListener("mouseleave",this.mouseleave=this.mouseleave.bind(this)),e.dom.addEventListener("mousemove",this.mousemove=this.mousemove.bind(this))}update(){this.pending&&(this.pending=null,clearTimeout(this.restartTimeout),this.restartTimeout=setTimeout(()=>this.startHover(),20))}get active(){return this.view.state.field(this.field)}checkHover(){if(this.hoverTimeout=-1,this.active.length)return;let e=Date.now()-this.lastMove.time;ea.bottom||t.xa.right+e.defaultCharacterWidth)return;let c=e.bidiSpans(e.state.doc.lineAt(r)).find(f=>f.from<=r&&f.to>=r),h=c&&c.dir==st.RTL?-1:1;s=t.x{this.pending==a&&(this.pending=null,c&&!(Array.isArray(c)&&!c.length)&&e.dispatch({effects:this.setHover.of(Array.isArray(c)?c:[c])}))},c=>bn(e.state,c,"hover tooltip"))}else o&&!(Array.isArray(o)&&!o.length)&&e.dispatch({effects:this.setHover.of(Array.isArray(o)?o:[o])})}get tooltip(){let e=this.view.plugin(s0),t=e?e.manager.tooltips.findIndex(i=>i.create==Xf.create):-1;return t>-1?e.manager.tooltipViews[t]:null}mousemove(e){var t,i;this.lastMove={x:e.clientX,y:e.clientY,target:e.target,time:Date.now()},this.hoverTimeout<0&&(this.hoverTimeout=setTimeout(this.checkHover,this.hoverTime));let{active:r,tooltip:s}=this;if(r.length&&s&&!PE(s.dom,e)||this.pending){let{pos:o}=r[0]||this.pending,a=(i=(t=r[0])===null||t===void 0?void 0:t.end)!==null&&i!==void 0?i:o;(o==a?this.view.posAtCoords(this.lastMove)!=o:!_E(this.view,o,a,e.clientX,e.clientY))&&(this.view.dispatch({effects:this.setHover.of([])}),this.pending=null)}}mouseleave(e){clearTimeout(this.hoverTimeout),this.hoverTimeout=-1;let{active:t}=this;if(t.length){let{tooltip:i}=this;i&&i.dom.contains(e.relatedTarget)?this.watchTooltipLeave(i.dom):this.view.dispatch({effects:this.setHover.of([])})}}watchTooltipLeave(e){let t=i=>{e.removeEventListener("mouseleave",t),this.active.length&&!this.view.dom.contains(i.relatedTarget)&&this.view.dispatch({effects:this.setHover.of([])})};e.addEventListener("mouseleave",t)}destroy(){clearTimeout(this.hoverTimeout),this.view.dom.removeEventListener("mouseleave",this.mouseleave),this.view.dom.removeEventListener("mousemove",this.mousemove)}}const Vu=4;function PE(n,e){let{left:t,right:i,top:r,bottom:s}=n.getBoundingClientRect(),o;if(o=n.querySelector(".cm-tooltip-arrow")){let a=o.getBoundingClientRect();r=Math.min(a.top,r),s=Math.max(a.bottom,s)}return e.clientX>=t-Vu&&e.clientX<=i+Vu&&e.clientY>=r-Vu&&e.clientY<=s+Vu}function _E(n,e,t,i,r,s){let o=n.scrollDOM.getBoundingClientRect(),a=n.documentTop+n.documentPadding.top+n.contentHeight;if(o.left>i||o.rightr||Math.min(o.bottom,a)=e&&c<=t}function QE(n,e={}){let t=Te.define(),i=zt.define({create(){return[]},update(r,s){if(r.length&&(e.hideOnChange&&(s.docChanged||s.selection)?r=[]:e.hideOn&&(r=r.filter(o=>!e.hideOn(s,o))),s.docChanged)){let o=[];for(let a of r){let c=s.changes.mapPos(a.pos,-1,Xt.TrackDel);if(c!=null){let h=Object.assign(Object.create(null),a);h.pos=c,h.end!=null&&(h.end=s.changes.mapPos(h.end)),o.push(h)}}r=o}for(let o of s.effects)o.is(t)&&(r=o.value),o.is(CE)&&(r=[]);return r},provide:r=>tf.from(r)});return{active:i,extension:[i,kt.define(r=>new kE(r,n,i,t,e.hoverTime||300)),wE]}}function c_(n,e){let t=n.plugin(s0);if(!t)return null;let i=t.manager.tooltips.indexOf(e);return i<0?null:t.manager.tooltipViews[i]}const CE=Te.define(),gb=fe.define({combine(n){let e,t;for(let i of n)e=e||i.topContainer,t=t||i.bottomContainer;return{topContainer:e,bottomContainer:t}}});function Ba(n,e){let t=n.plugin(u_),i=t?t.specs.indexOf(e):-1;return i>-1?t.panels[i]:null}const u_=kt.fromClass(class{constructor(n){this.input=n.state.facet(Na),this.specs=this.input.filter(t=>t),this.panels=this.specs.map(t=>t(n));let e=n.state.facet(gb);this.top=new Fu(n,!0,e.topContainer),this.bottom=new Fu(n,!1,e.bottomContainer),this.top.sync(this.panels.filter(t=>t.top)),this.bottom.sync(this.panels.filter(t=>!t.top));for(let t of this.panels)t.dom.classList.add("cm-panel"),t.mount&&t.mount()}update(n){let e=n.state.facet(gb);this.top.container!=e.topContainer&&(this.top.sync([]),this.top=new Fu(n.view,!0,e.topContainer)),this.bottom.container!=e.bottomContainer&&(this.bottom.sync([]),this.bottom=new Fu(n.view,!1,e.bottomContainer)),this.top.syncClasses(),this.bottom.syncClasses();let t=n.state.facet(Na);if(t!=this.input){let i=t.filter(c=>c),r=[],s=[],o=[],a=[];for(let c of i){let h=this.specs.indexOf(c),f;h<0?(f=c(n.view),a.push(f)):(f=this.panels[h],f.update&&f.update(n)),r.push(f),(f.top?s:o).push(f)}this.specs=i,this.panels=r,this.top.sync(s),this.bottom.sync(o);for(let c of a)c.dom.classList.add("cm-panel"),c.mount&&c.mount()}else for(let i of this.panels)i.update&&i.update(n)}destroy(){this.top.sync([]),this.bottom.sync([])}},{provide:n=>ce.scrollMargins.of(e=>{let t=e.plugin(n);return t&&{top:t.top.scrollMargin(),bottom:t.bottom.scrollMargin()}})});class Fu{constructor(e,t,i){this.view=e,this.top=t,this.container=i,this.dom=void 0,this.classes="",this.panels=[],this.syncClasses()}sync(e){for(let t of this.panels)t.destroy&&e.indexOf(t)<0&&t.destroy();this.panels=e,this.syncDOM()}syncDOM(){if(this.panels.length==0){this.dom&&(this.dom.remove(),this.dom=void 0);return}if(!this.dom){this.dom=document.createElement("div"),this.dom.className=this.top?"cm-panels cm-panels-top":"cm-panels cm-panels-bottom",this.dom.style[this.top?"top":"bottom"]="0";let t=this.container||this.view.dom;t.insertBefore(this.dom,this.top?t.firstChild:null)}let e=this.dom.firstChild;for(let t of this.panels)if(t.dom.parentNode==this.dom){for(;e!=t.dom;)e=mb(e);e=e.nextSibling}else this.dom.insertBefore(t.dom,e);for(;e;)e=mb(e)}scrollMargin(){return!this.dom||this.container?0:Math.max(0,this.top?this.dom.getBoundingClientRect().bottom-Math.max(0,this.view.scrollDOM.getBoundingClientRect().top):Math.min(innerHeight,this.view.scrollDOM.getBoundingClientRect().bottom)-this.dom.getBoundingClientRect().top)}syncClasses(){if(!(!this.container||this.classes==this.view.themeClasses)){for(let e of this.classes.split(" "))e&&this.container.classList.remove(e);for(let e of(this.classes=this.view.themeClasses).split(" "))e&&this.container.classList.add(e)}}}function mb(n){let e=n.nextSibling;return n.remove(),e}const Na=fe.define({enables:u_});class lr extends Zs{compare(e){return this==e||this.constructor==e.constructor&&this.eq(e)}eq(e){return!1}destroy(e){}}lr.prototype.elementClass="";lr.prototype.toDOM=void 0;lr.prototype.mapMode=Xt.TrackBefore;lr.prototype.startSide=lr.prototype.endSide=-1;lr.prototype.point=!0;const $h=fe.define(),TE=fe.define(),$E={class:"",renderEmptyElements:!1,elementStyle:"",markers:()=>je.empty,lineMarker:()=>null,widgetMarker:()=>null,lineMarkerChange:null,initialSpacer:null,updateSpacer:null,domEventHandlers:{},side:"before"},wa=fe.define();function ME(n){return[h_(),wa.of({...$E,...n})]}const Ob=fe.define({combine:n=>n.some(e=>e)});function h_(n){return[RE]}const RE=kt.fromClass(class{constructor(n){this.view=n,this.domAfter=null,this.prevViewport=n.viewport,this.dom=document.createElement("div"),this.dom.className="cm-gutters cm-gutters-before",this.dom.setAttribute("aria-hidden","true"),this.dom.style.minHeight=this.view.contentHeight/this.view.scaleY+"px",this.gutters=n.state.facet(wa).map(e=>new xb(n,e)),this.fixed=!n.state.facet(Ob);for(let e of this.gutters)e.config.side=="after"?this.getDOMAfter().appendChild(e.dom):this.dom.appendChild(e.dom);this.fixed&&(this.dom.style.position="sticky"),this.syncGutters(!1),n.scrollDOM.insertBefore(this.dom,n.contentDOM)}getDOMAfter(){return this.domAfter||(this.domAfter=document.createElement("div"),this.domAfter.className="cm-gutters cm-gutters-after",this.domAfter.setAttribute("aria-hidden","true"),this.domAfter.style.minHeight=this.view.contentHeight/this.view.scaleY+"px",this.domAfter.style.position=this.fixed?"sticky":"",this.view.scrollDOM.appendChild(this.domAfter)),this.domAfter}update(n){if(this.updateGutters(n)){let e=this.prevViewport,t=n.view.viewport,i=Math.min(e.to,t.to)-Math.max(e.from,t.from);this.syncGutters(i<(t.to-t.from)*.8)}if(n.geometryChanged){let e=this.view.contentHeight/this.view.scaleY+"px";this.dom.style.minHeight=e,this.domAfter&&(this.domAfter.style.minHeight=e)}this.view.state.facet(Ob)!=!this.fixed&&(this.fixed=!this.fixed,this.dom.style.position=this.fixed?"sticky":"",this.domAfter&&(this.domAfter.style.position=this.fixed?"sticky":"")),this.prevViewport=n.view.viewport}syncGutters(n){let e=this.dom.nextSibling;n&&(this.dom.remove(),this.domAfter&&this.domAfter.remove());let t=je.iter(this.view.state.facet($h),this.view.viewport.from),i=[],r=this.gutters.map(s=>new AE(s,this.view.viewport,-this.view.documentPadding.top));for(let s of this.view.viewportLineBlocks)if(i.length&&(i=[]),Array.isArray(s.type)){let o=!0;for(let a of s.type)if(a.type==an.Text&&o){tO(t,i,a.from);for(let c of r)c.line(this.view,a,i);o=!1}else if(a.widget)for(let c of r)c.widget(this.view,a)}else if(s.type==an.Text){tO(t,i,s.from);for(let o of r)o.line(this.view,s,i)}else if(s.widget)for(let o of r)o.widget(this.view,s);for(let s of r)s.finish();n&&(this.view.scrollDOM.insertBefore(this.dom,e),this.domAfter&&this.view.scrollDOM.appendChild(this.domAfter))}updateGutters(n){let e=n.startState.facet(wa),t=n.state.facet(wa),i=n.docChanged||n.heightChanged||n.viewportChanged||!je.eq(n.startState.facet($h),n.state.facet($h),n.view.viewport.from,n.view.viewport.to);if(e==t)for(let r of this.gutters)r.update(n)&&(i=!0);else{i=!0;let r=[];for(let s of t){let o=e.indexOf(s);o<0?r.push(new xb(this.view,s)):(this.gutters[o].update(n),r.push(this.gutters[o]))}for(let s of this.gutters)s.dom.remove(),r.indexOf(s)<0&&s.destroy();for(let s of r)s.config.side=="after"?this.getDOMAfter().appendChild(s.dom):this.dom.appendChild(s.dom);this.gutters=r}return i}destroy(){for(let n of this.gutters)n.destroy();this.dom.remove(),this.domAfter&&this.domAfter.remove()}},{provide:n=>ce.scrollMargins.of(e=>{let t=e.plugin(n);if(!t||t.gutters.length==0||!t.fixed)return null;let i=t.dom.offsetWidth*e.scaleX,r=t.domAfter?t.domAfter.offsetWidth*e.scaleX:0;return e.textDirection==st.LTR?{left:i,right:r}:{right:i,left:r}})});function yb(n){return Array.isArray(n)?n:[n]}function tO(n,e,t){for(;n.value&&n.from<=t;)n.from==t&&e.push(n.value),n.next()}class AE{constructor(e,t,i){this.gutter=e,this.height=i,this.i=0,this.cursor=je.iter(e.markers,t.from)}addElement(e,t,i){let{gutter:r}=this,s=(t.top-this.height)/e.scaleY,o=t.height/e.scaleY;if(this.i==r.elements.length){let a=new f_(e,o,s,i);r.elements.push(a),r.dom.appendChild(a.dom)}else r.elements[this.i].update(e,o,s,i);this.height=t.bottom,this.i++}line(e,t,i){let r=[];tO(this.cursor,r,t.from),i.length&&(r=r.concat(i));let s=this.gutter.config.lineMarker(e,t,r);s&&r.unshift(s);let o=this.gutter;r.length==0&&!o.config.renderEmptyElements||this.addElement(e,t,r)}widget(e,t){let i=this.gutter.config.widgetMarker(e,t.widget,t),r=i?[i]:null;for(let s of e.state.facet(TE)){let o=s(e,t.widget,t);o&&(r||(r=[])).push(o)}r&&this.addElement(e,t,r)}finish(){let e=this.gutter;for(;e.elements.length>this.i;){let t=e.elements.pop();e.dom.removeChild(t.dom),t.destroy()}}}class xb{constructor(e,t){this.view=e,this.config=t,this.elements=[],this.spacer=null,this.dom=document.createElement("div"),this.dom.className="cm-gutter"+(this.config.class?" "+this.config.class:"");for(let i in t.domEventHandlers)this.dom.addEventListener(i,r=>{let s=r.target,o;if(s!=this.dom&&this.dom.contains(s)){for(;s.parentNode!=this.dom;)s=s.parentNode;let c=s.getBoundingClientRect();o=(c.top+c.bottom)/2}else o=r.clientY;let a=e.lineBlockAtHeight(o-e.documentTop);t.domEventHandlers[i](e,a,r)&&r.preventDefault()});this.markers=yb(t.markers(e)),t.initialSpacer&&(this.spacer=new f_(e,0,0,[t.initialSpacer(e)]),this.dom.appendChild(this.spacer.dom),this.spacer.dom.style.cssText+="visibility: hidden; pointer-events: none")}update(e){let t=this.markers;if(this.markers=yb(this.config.markers(e.view)),this.spacer&&this.config.updateSpacer){let r=this.config.updateSpacer(this.spacer.markers[0],e);r!=this.spacer.markers[0]&&this.spacer.update(e.view,0,0,[r])}let i=e.view.viewport;return!je.eq(this.markers,t,i.from,i.to)||(this.config.lineMarkerChange?this.config.lineMarkerChange(e):!1)}destroy(){for(let e of this.elements)e.destroy()}}class f_{constructor(e,t,i,r){this.height=-1,this.above=0,this.markers=[],this.dom=document.createElement("div"),this.dom.className="cm-gutterElement",this.update(e,t,i,r)}update(e,t,i,r){this.height!=t&&(this.height=t,this.dom.style.height=t+"px"),this.above!=i&&(this.dom.style.marginTop=(this.above=i)?i+"px":""),EE(this.markers,r)||this.setMarkers(e,r)}setMarkers(e,t){let i="cm-gutterElement",r=this.dom.firstChild;for(let s=0,o=0;;){let a=o,c=ss(a,c,h)||o(a,c,h):o}return i}})}});class Sg extends lr{constructor(e){super(),this.number=e}eq(e){return this.number==e.number}toDOM(){return document.createTextNode(this.number)}}function wg(n,e){return n.state.facet(Qo).formatNumber(e,n.state)}const zE=wa.compute([Qo],n=>({class:"cm-lineNumbers",renderEmptyElements:!1,markers(e){return e.state.facet(LE)},lineMarker(e,t,i){return i.some(r=>r.toDOM)?null:new Sg(wg(e,e.state.doc.lineAt(t.from).number))},widgetMarker:(e,t,i)=>{for(let r of e.state.facet(DE)){let s=r(e,t,i);if(s)return s}return null},lineMarkerChange:e=>e.startState.facet(Qo)!=e.state.facet(Qo),initialSpacer(e){return new Sg(wg(e,vb(e.state.doc.lines)))},updateSpacer(e,t){let i=wg(t.view,vb(t.view.state.doc.lines));return i==e.number?e:new Sg(i)},domEventHandlers:n.facet(Qo).domEventHandlers,side:"before"}));function ZE(n={}){return[Qo.of(n),h_(),zE]}function vb(n){let e=9;for(;e{let e=[],t=-1;for(let i of n.selection.ranges){let r=n.doc.lineAt(i.head).from;r>t&&(t=r,e.push(IE.range(r)))}return je.of(e)});function BE(){return jE}const d_=1024;let NE=0;class kg{constructor(e,t){this.from=e,this.to=t}}class Ee{constructor(e={}){this.id=NE++,this.perNode=!!e.perNode,this.deserialize=e.deserialize||(()=>{throw new Error("This node type doesn't define a deserialize function")})}add(e){if(this.perNode)throw new RangeError("Can't add per-node props to node types");return typeof e!="function"&&(e=kn.match(e)),t=>{let i=e(t);return i===void 0?null:[this,i]}}}Ee.closedBy=new Ee({deserialize:n=>n.split(" ")});Ee.openedBy=new Ee({deserialize:n=>n.split(" ")});Ee.group=new Ee({deserialize:n=>n.split(" ")});Ee.isolate=new Ee({deserialize:n=>{if(n&&n!="rtl"&&n!="ltr"&&n!="auto")throw new RangeError("Invalid value for isolate: "+n);return n||"auto"}});Ee.contextHash=new Ee({perNode:!0});Ee.lookAhead=new Ee({perNode:!0});Ee.mounted=new Ee({perNode:!0});class nf{constructor(e,t,i){this.tree=e,this.overlay=t,this.parser=i}static get(e){return e&&e.props&&e.props[Ee.mounted.id]}}const XE=Object.create(null);class kn{constructor(e,t,i,r=0){this.name=e,this.props=t,this.id=i,this.flags=r}static define(e){let t=e.props&&e.props.length?Object.create(null):XE,i=(e.top?1:0)|(e.skipped?2:0)|(e.error?4:0)|(e.name==null?8:0),r=new kn(e.name||"",t,e.id,i);if(e.props){for(let s of e.props)if(Array.isArray(s)||(s=s(r)),s){if(s[0].perNode)throw new RangeError("Can't store a per-node prop on a node type");t[s[0].id]=s[1]}}return r}prop(e){return this.props[e.id]}get isTop(){return(this.flags&1)>0}get isSkipped(){return(this.flags&2)>0}get isError(){return(this.flags&4)>0}get isAnonymous(){return(this.flags&8)>0}is(e){if(typeof e=="string"){if(this.name==e)return!0;let t=this.prop(Ee.group);return t?t.indexOf(e)>-1:!1}return this.id==e}static match(e){let t=Object.create(null);for(let i in e)for(let r of i.split(" "))t[r]=e[i];return i=>{for(let r=i.prop(Ee.group),s=-1;s<(r?r.length:0);s++){let o=t[s<0?i.name:r[s]];if(o)return o}}}}kn.none=new kn("",Object.create(null),0,8);class l0{constructor(e){this.types=e;for(let t=0;t0;for(let c=this.cursor(o|Tt.IncludeAnonymous);;){let h=!1;if(c.from<=s&&c.to>=r&&(!a&&c.type.isAnonymous||t(c)!==!1)){if(c.firstChild())continue;h=!0}for(;h&&i&&(a||!c.type.isAnonymous)&&i(c),!c.nextSibling();){if(!c.parent())return;h=!0}}}prop(e){return e.perNode?this.props?this.props[e.id]:void 0:this.type.prop(e)}get propValues(){let e=[];if(this.props)for(let t in this.props)e.push([+t,this.props[t]]);return e}balance(e={}){return this.children.length<=8?this:u0(kn.none,this.children,this.positions,0,this.children.length,0,this.length,(t,i,r)=>new wt(this.type,t,i,r,this.propValues),e.makeTree||((t,i,r)=>new wt(kn.none,t,i,r)))}static build(e){return YE(e)}}wt.empty=new wt(kn.none,[],[],0);class a0{constructor(e,t){this.buffer=e,this.index=t}get id(){return this.buffer[this.index-4]}get start(){return this.buffer[this.index-3]}get end(){return this.buffer[this.index-2]}get size(){return this.buffer[this.index-1]}get pos(){return this.index}next(){this.index-=4}fork(){return new a0(this.buffer,this.index)}}class Hr{constructor(e,t,i){this.buffer=e,this.length=t,this.set=i}get type(){return kn.none}toString(){let e=[];for(let t=0;t0));c=o[c+3]);return a}slice(e,t,i){let r=this.buffer,s=new Uint16Array(t-e),o=0;for(let a=e,c=0;a=e&&te;case 1:return t<=e&&i>e;case 2:return i>e;case 4:return!0}}function Xa(n,e,t,i){for(var r;n.from==n.to||(t<1?n.from>=e:n.from>e)||(t>-1?n.to<=e:n.to0?a.length:-1;e!=h;e+=t){let f=a[e],p=c[e]+o.from;if(p_(r,i,p,p+f.length)){if(f instanceof Hr){if(s&Tt.ExcludeBuffers)continue;let m=f.findChild(0,f.buffer.length,t,i-p,r);if(m>-1)return new Ri(new WE(o,f,e,p),null,m)}else if(s&Tt.IncludeAnonymous||!f.type.isAnonymous||c0(f)){let m;if(!(s&Tt.IgnoreMounts)&&(m=nf.get(f))&&!m.overlay)return new wn(m.tree,p,e,o);let y=new wn(f,p,e,o);return s&Tt.IncludeAnonymous||!y.type.isAnonymous?y:y.nextChild(t<0?f.children.length-1:0,t,i,r)}}}if(s&Tt.IncludeAnonymous||!o.type.isAnonymous||(o.index>=0?e=o.index+t:e=t<0?-1:o._parent._tree.children.length,o=o._parent,!o))return null}}get firstChild(){return this.nextChild(0,1,0,4)}get lastChild(){return this.nextChild(this._tree.children.length-1,-1,0,4)}childAfter(e){return this.nextChild(0,1,e,2)}childBefore(e){return this.nextChild(this._tree.children.length-1,-1,e,-2)}enter(e,t,i=0){let r;if(!(i&Tt.IgnoreOverlays)&&(r=nf.get(this._tree))&&r.overlay){let s=e-this.from;for(let{from:o,to:a}of r.overlay)if((t>0?o<=s:o=s:a>s))return new wn(r.tree,r.overlay[0].from+this.from,-1,this)}return this.nextChild(0,1,e,t,i)}nextSignificantParent(){let e=this;for(;e.type.isAnonymous&&e._parent;)e=e._parent;return e}get parent(){return this._parent?this._parent.nextSignificantParent():null}get nextSibling(){return this._parent&&this.index>=0?this._parent.nextChild(this.index+1,1,0,4):null}get prevSibling(){return this._parent&&this.index>=0?this._parent.nextChild(this.index-1,-1,0,4):null}get tree(){return this._tree}toTree(){return this._tree}toString(){return this._tree.toString()}}function Sb(n,e,t,i){let r=n.cursor(),s=[];if(!r.firstChild())return s;if(t!=null){for(let o=!1;!o;)if(o=r.type.is(t),!r.nextSibling())return s}for(;;){if(i!=null&&r.type.is(i))return s;if(r.type.is(e)&&s.push(r.node),!r.nextSibling())return i==null?s:[]}}function nO(n,e,t=e.length-1){for(let i=n;t>=0;i=i.parent){if(!i)return!1;if(!i.type.isAnonymous){if(e[t]&&e[t]!=i.name)return!1;t--}}return!0}class WE{constructor(e,t,i,r){this.parent=e,this.buffer=t,this.index=i,this.start=r}}class Ri extends g_{get name(){return this.type.name}get from(){return this.context.start+this.context.buffer.buffer[this.index+1]}get to(){return this.context.start+this.context.buffer.buffer[this.index+2]}constructor(e,t,i){super(),this.context=e,this._parent=t,this.index=i,this.type=e.buffer.set.types[e.buffer.buffer[i]]}child(e,t,i){let{buffer:r}=this.context,s=r.findChild(this.index+4,r.buffer[this.index+3],e,t-this.context.start,i);return s<0?null:new Ri(this.context,this,s)}get firstChild(){return this.child(1,0,4)}get lastChild(){return this.child(-1,0,4)}childAfter(e){return this.child(1,e,2)}childBefore(e){return this.child(-1,e,-2)}enter(e,t,i=0){if(i&Tt.ExcludeBuffers)return null;let{buffer:r}=this.context,s=r.findChild(this.index+4,r.buffer[this.index+3],t>0?1:-1,e-this.context.start,t);return s<0?null:new Ri(this.context,this,s)}get parent(){return this._parent||this.context.parent.nextSignificantParent()}externalSibling(e){return this._parent?null:this.context.parent.nextChild(this.context.index+e,e,0,4)}get nextSibling(){let{buffer:e}=this.context,t=e.buffer[this.index+3];return t<(this._parent?e.buffer[this._parent.index+3]:e.buffer.length)?new Ri(this.context,this._parent,t):this.externalSibling(1)}get prevSibling(){let{buffer:e}=this.context,t=this._parent?this._parent.index+4:0;return this.index==t?this.externalSibling(-1):new Ri(this.context,this._parent,e.findChild(t,this.index,-1,0,4))}get tree(){return null}toTree(){let e=[],t=[],{buffer:i}=this.context,r=this.index+4,s=i.buffer[this.index+3];if(s>r){let o=i.buffer[this.index+1];e.push(i.slice(r,s,o)),t.push(0)}return new wt(this.type,e,t,this.to-this.from)}toString(){return this.context.buffer.childString(this.index)}}function m_(n){if(!n.length)return null;let e=0,t=n[0];for(let s=1;st.from||o.to=e){let a=new wn(o.tree,o.overlay[0].from+s.from,-1,s);(r||(r=[i])).push(Xa(a,e,t,!1))}}return r?m_(r):i}class iO{get name(){return this.type.name}constructor(e,t=0){if(this.mode=t,this.buffer=null,this.stack=[],this.index=0,this.bufferNode=null,e instanceof wn)this.yieldNode(e);else{this._tree=e.context.parent,this.buffer=e.context;for(let i=e._parent;i;i=i._parent)this.stack.unshift(i.index);this.bufferNode=e,this.yieldBuf(e.index)}}yieldNode(e){return e?(this._tree=e,this.type=e.type,this.from=e.from,this.to=e.to,!0):!1}yieldBuf(e,t){this.index=e;let{start:i,buffer:r}=this.buffer;return this.type=t||r.set.types[r.buffer[e]],this.from=i+r.buffer[e+1],this.to=i+r.buffer[e+2],!0}yield(e){return e?e instanceof wn?(this.buffer=null,this.yieldNode(e)):(this.buffer=e.context,this.yieldBuf(e.index,e.type)):!1}toString(){return this.buffer?this.buffer.buffer.childString(this.index):this._tree.toString()}enterChild(e,t,i){if(!this.buffer)return this.yield(this._tree.nextChild(e<0?this._tree._tree.children.length-1:0,e,t,i,this.mode));let{buffer:r}=this.buffer,s=r.findChild(this.index+4,r.buffer[this.index+3],e,t-this.buffer.start,i);return s<0?!1:(this.stack.push(this.index),this.yieldBuf(s))}firstChild(){return this.enterChild(1,0,4)}lastChild(){return this.enterChild(-1,0,4)}childAfter(e){return this.enterChild(1,e,2)}childBefore(e){return this.enterChild(-1,e,-2)}enter(e,t,i=this.mode){return this.buffer?i&Tt.ExcludeBuffers?!1:this.enterChild(1,e,t):this.yield(this._tree.enter(e,t,i))}parent(){if(!this.buffer)return this.yieldNode(this.mode&Tt.IncludeAnonymous?this._tree._parent:this._tree.parent);if(this.stack.length)return this.yieldBuf(this.stack.pop());let e=this.mode&Tt.IncludeAnonymous?this.buffer.parent:this.buffer.parent.nextSignificantParent();return this.buffer=null,this.yieldNode(e)}sibling(e){if(!this.buffer)return this._tree._parent?this.yield(this._tree.index<0?null:this._tree._parent.nextChild(this._tree.index+e,e,0,4,this.mode)):!1;let{buffer:t}=this.buffer,i=this.stack.length-1;if(e<0){let r=i<0?0:this.stack[i]+4;if(this.index!=r)return this.yieldBuf(t.findChild(r,this.index,-1,0,4))}else{let r=t.buffer[this.index+3];if(r<(i<0?t.buffer.length:t.buffer[this.stack[i]+3]))return this.yieldBuf(r)}return i<0?this.yield(this.buffer.parent.nextChild(this.buffer.index+e,e,0,4,this.mode)):!1}nextSibling(){return this.sibling(1)}prevSibling(){return this.sibling(-1)}atLastNode(e){let t,i,{buffer:r}=this;if(r){if(e>0){if(this.index-1)for(let s=t+e,o=e<0?-1:i._tree.children.length;s!=o;s+=e){let a=i._tree.children[s];if(this.mode&Tt.IncludeAnonymous||a instanceof Hr||!a.type.isAnonymous||c0(a))return!1}return!0}move(e,t){if(t&&this.enterChild(e,0,4))return!0;for(;;){if(this.sibling(e))return!0;if(this.atLastNode(e)||!this.parent())return!1}}next(e=!0){return this.move(1,e)}prev(e=!0){return this.move(-1,e)}moveTo(e,t=0){for(;(this.from==this.to||(t<1?this.from>=e:this.from>e)||(t>-1?this.to<=e:this.to=0;){for(let o=e;o;o=o._parent)if(o.index==r){if(r==this.index)return o;t=o,i=s+1;break e}r=this.stack[--s]}for(let r=i;r=0;s--){if(s<0)return nO(this._tree,e,r);let o=i[t.buffer[this.stack[s]]];if(!o.isAnonymous){if(e[r]&&e[r]!=o.name)return!1;r--}}return!0}}function c0(n){return n.children.some(e=>e instanceof Hr||!e.type.isAnonymous||c0(e))}function YE(n){var e;let{buffer:t,nodeSet:i,maxBufferLength:r=d_,reused:s=[],minRepeatType:o=i.types.length}=n,a=Array.isArray(t)?new a0(t,t.length):t,c=i.types,h=0,f=0;function p(Q,$,M,Z,j,Y){let{id:W,start:F,end:ie,size:oe}=a,re=f,se=h;for(;oe<0;)if(a.next(),oe==-1){let L=s[W];M.push(L),Z.push(F-Q);return}else if(oe==-3){h=W;return}else if(oe==-4){f=W;return}else throw new RangeError(`Unrecognized record size: ${oe}`);let ue=c[W],q,U,J=F-Q;if(ie-F<=r&&(U=S(a.pos-$,j))){let L=new Uint16Array(U.size-U.skip),N=a.pos-U.size,xe=L.length;for(;a.pos>N;)xe=w(U.start,L,xe);q=new Hr(L,ie-U.start,i),J=U.start-Q}else{let L=a.pos-oe;a.next();let N=[],xe=[],Oe=W>=o?W:-1,we=0,ke=ie;for(;a.pos>L;)Oe>=0&&a.id==Oe&&a.size>=0?(a.end<=ke-r&&(v(N,xe,F,we,a.end,ke,Oe,re,se),we=N.length,ke=a.end),a.next()):Y>2500?m(F,L,N,xe):p(F,L,N,xe,Oe,Y+1);if(Oe>=0&&we>0&&we-1&&we>0){let Le=y(ue,se);q=u0(ue,N,xe,0,N.length,0,ie-F,Le,Le)}else q=b(ue,N,xe,ie-F,re-ie,se)}M.push(q),Z.push(J)}function m(Q,$,M,Z){let j=[],Y=0,W=-1;for(;a.pos>$;){let{id:F,start:ie,end:oe,size:re}=a;if(re>4)a.next();else{if(W>-1&&ie=0;oe-=3)F[re++]=j[oe],F[re++]=j[oe+1]-ie,F[re++]=j[oe+2]-ie,F[re++]=re;M.push(new Hr(F,j[2]-ie,i)),Z.push(ie-Q)}}function y(Q,$){return(M,Z,j)=>{let Y=0,W=M.length-1,F,ie;if(W>=0&&(F=M[W])instanceof wt){if(!W&&F.type==Q&&F.length==j)return F;(ie=F.prop(Ee.lookAhead))&&(Y=Z[W]+F.length+ie)}return b(Q,M,Z,j,Y,$)}}function v(Q,$,M,Z,j,Y,W,F,ie){let oe=[],re=[];for(;Q.length>Z;)oe.push(Q.pop()),re.push($.pop()+M-j);Q.push(b(i.types[W],oe,re,Y-j,F-Y,ie)),$.push(j-M)}function b(Q,$,M,Z,j,Y,W){if(Y){let F=[Ee.contextHash,Y];W=W?[F].concat(W):[F]}if(j>25){let F=[Ee.lookAhead,j];W=W?[F].concat(W):[F]}return new wt(Q,$,M,Z,W)}function S(Q,$){let M=a.fork(),Z=0,j=0,Y=0,W=M.end-r,F={size:0,start:0,skip:0};e:for(let ie=M.pos-Q;M.pos>ie;){let oe=M.size;if(M.id==$&&oe>=0){F.size=Z,F.start=j,F.skip=Y,Y+=4,Z+=4,M.next();continue}let re=M.pos-oe;if(oe<0||re=o?4:0,ue=M.start;for(M.next();M.pos>re;){if(M.size<0)if(M.size==-3)se+=4;else break e;else M.id>=o&&(se+=4);M.next()}j=ue,Z+=oe,Y+=se}return($<0||Z==Q)&&(F.size=Z,F.start=j,F.skip=Y),F.size>4?F:void 0}function w(Q,$,M){let{id:Z,start:j,end:Y,size:W}=a;if(a.next(),W>=0&&Z4){let ie=a.pos-(W-4);for(;a.pos>ie;)M=w(Q,$,M)}$[--M]=F,$[--M]=Y-Q,$[--M]=j-Q,$[--M]=Z}else W==-3?h=Z:W==-4&&(f=Z);return M}let C=[],_=[];for(;a.pos>0;)p(n.start||0,n.bufferStart||0,C,_,-1,0);let P=(e=n.length)!==null&&e!==void 0?e:C.length?_[0]+C[0].length:0;return new wt(c[n.topID],C.reverse(),_.reverse(),P)}const wb=new WeakMap;function Mh(n,e){if(!n.isAnonymous||e instanceof Hr||e.type!=n)return 1;let t=wb.get(e);if(t==null){t=1;for(let i of e.children){if(i.type!=n||!(i instanceof wt)){t=1;break}t+=Mh(n,i)}wb.set(e,t)}return t}function u0(n,e,t,i,r,s,o,a,c){let h=0;for(let v=i;v=f)break;$+=M}if(_==P+1){if($>f){let M=v[P];y(M.children,M.positions,0,M.children.length,b[P]+C);continue}p.push(v[P])}else{let M=b[_-1]+v[_-1].length-Q;p.push(u0(n,v,b,P,_,Q,M,null,c))}m.push(Q+C-s)}}return y(e,t,i,r,0),(a||c)(p,m,o)}class qE{constructor(){this.map=new WeakMap}setBuffer(e,t,i){let r=this.map.get(e);r||this.map.set(e,r=new Map),r.set(t,i)}getBuffer(e,t){let i=this.map.get(e);return i&&i.get(t)}set(e,t){e instanceof Ri?this.setBuffer(e.context.buffer,e.index,t):e instanceof wn&&this.map.set(e.tree,t)}get(e){return e instanceof Ri?this.getBuffer(e.context.buffer,e.index):e instanceof wn?this.map.get(e.tree):void 0}cursorSet(e,t){e.buffer?this.setBuffer(e.buffer.buffer,e.index,t):this.map.set(e.tree,t)}cursorGet(e){return e.buffer?this.getBuffer(e.buffer.buffer,e.index):this.map.get(e.tree)}}class Rs{constructor(e,t,i,r,s=!1,o=!1){this.from=e,this.to=t,this.tree=i,this.offset=r,this.open=(s?1:0)|(o?2:0)}get openStart(){return(this.open&1)>0}get openEnd(){return(this.open&2)>0}static addTree(e,t=[],i=!1){let r=[new Rs(0,e.length,e,0,!1,i)];for(let s of t)s.to>e.length&&r.push(s);return r}static applyChanges(e,t,i=128){if(!t.length)return e;let r=[],s=1,o=e.length?e[0]:null;for(let a=0,c=0,h=0;;a++){let f=a=i)for(;o&&o.from=m.from||p<=m.to||h){let y=Math.max(m.from,c)-h,v=Math.min(m.to,p)-h;m=y>=v?null:new Rs(y,v,m.tree,m.offset+h,a>0,!!f)}if(m&&r.push(m),o.to>p)break;o=snew kg(r.from,r.to)):[new kg(0,0)]:[new kg(0,e.length)],this.createParse(e,t||[],i)}parse(e,t,i){let r=this.startParse(e,t,i);for(;;){let s=r.advance();if(s)return s}}}class UE{constructor(e){this.string=e}get length(){return this.string.length}chunk(e){return this.string.slice(e)}get lineChunks(){return!1}read(e,t){return this.string.slice(e,t)}}new Ee({perNode:!0});let HE=0,Ki=class rO{constructor(e,t,i,r){this.name=e,this.set=t,this.base=i,this.modified=r,this.id=HE++}toString(){let{name:e}=this;for(let t of this.modified)t.name&&(e=`${t.name}(${e})`);return e}static define(e,t){let i=typeof e=="string"?e:"?";if(e instanceof rO&&(t=e),t!=null&&t.base)throw new Error("Can not derive from a modified tag");let r=new rO(i,[],null,[]);if(r.set.push(r),t)for(let s of t.set)r.set.push(s);return r}static defineModifier(e){let t=new rf(e);return i=>i.modified.indexOf(t)>-1?i:rf.get(i.base||i,i.modified.concat(t).sort((r,s)=>r.id-s.id))}},GE=0;class rf{constructor(e){this.name=e,this.instances=[],this.id=GE++}static get(e,t){if(!t.length)return e;let i=t[0].instances.find(a=>a.base==e&&KE(t,a.modified));if(i)return i;let r=[],s=new Ki(e.name,r,e,t);for(let a of t)a.instances.push(s);let o=JE(t);for(let a of e.set)if(!a.modified.length)for(let c of o)r.push(rf.get(a,c));return s}}function KE(n,e){return n.length==e.length&&n.every((t,i)=>t==e[i])}function JE(n){let e=[[]];for(let t=0;ti.length-t.length)}function h0(n){let e=Object.create(null);for(let t in n){let i=n[t];Array.isArray(i)||(i=[i]);for(let r of t.split(" "))if(r){let s=[],o=2,a=r;for(let p=0;;){if(a=="..."&&p>0&&p+3==r.length){o=1;break}let m=/^"(?:[^"\\]|\\.)*?"|[^\/!]+/.exec(a);if(!m)throw new RangeError("Invalid path: "+r);if(s.push(m[0]=="*"?"":m[0][0]=='"'?JSON.parse(m[0]):m[0]),p+=m[0].length,p==r.length)break;let y=r[p++];if(p==r.length&&y=="!"){o=0;break}if(y!="/")throw new RangeError("Invalid path: "+r);a=r.slice(p)}let c=s.length-1,h=s[c];if(!h)throw new RangeError("Invalid path: "+r);let f=new sf(i,o,c>0?s.slice(0,c):null);e[h]=f.sort(e[h])}}return y_.add(e)}const y_=new Ee;class sf{constructor(e,t,i,r){this.tags=e,this.mode=t,this.context=i,this.next=r}get opaque(){return this.mode==0}get inherit(){return this.mode==1}sort(e){return!e||e.depth{let o=r;for(let a of s)for(let c of a.set){let h=t[c.id];if(h){o=o?o+" "+h:h;break}}return o},scope:i}}function eL(n,e){let t=null;for(let i of n){let r=i.style(e);r&&(t=t?t+" "+r:r)}return t}function tL(n,e,t,i=0,r=n.length){let s=new nL(i,Array.isArray(e)?e:[e],t);s.highlightRange(n.cursor(),i,r,"",s.highlighters),s.flush(r)}class nL{constructor(e,t,i){this.at=e,this.highlighters=t,this.span=i,this.class=""}startSpan(e,t){t!=this.class&&(this.flush(e),e>this.at&&(this.at=e),this.class=t)}flush(e){e>this.at&&this.class&&this.span(this.at,e,this.class)}highlightRange(e,t,i,r,s){let{type:o,from:a,to:c}=e;if(a>=i||c<=t)return;o.isTop&&(s=this.highlighters.filter(y=>!y.scope||y.scope(o)));let h=r,f=iL(e)||sf.empty,p=eL(s,f.tags);if(p&&(h&&(h+=" "),h+=p,f.mode==1&&(r+=(r?" ":"")+p)),this.startSpan(Math.max(t,a),h),f.opaque)return;let m=e.tree&&e.tree.prop(Ee.mounted);if(m&&m.overlay){let y=e.node.enter(m.overlay[0].from+a,1),v=this.highlighters.filter(S=>!S.scope||S.scope(m.tree.type)),b=e.firstChild();for(let S=0,w=a;;S++){let C=S=_||!e.nextSibling())););if(!C||_>i)break;w=C.to+a,w>t&&(this.highlightRange(y.cursor(),Math.max(t,C.from+a),Math.min(i,w),"",v),this.startSpan(Math.min(i,w),h))}b&&e.parent()}else if(e.firstChild()){m&&(r="");do if(!(e.to<=t)){if(e.from>=i)break;this.highlightRange(e,t,i,r,s),this.startSpan(Math.min(i,e.to),h)}while(e.nextSibling());e.parent()}}}function iL(n){let e=n.type.prop(y_);for(;e&&e.context&&!n.matchContext(e.context);)e=e.next;return e||null}const le=Ki.define,qu=le(),Rr=le(),kb=le(Rr),Pb=le(Rr),Ar=le(),Uu=le(Ar),Pg=le(Ar),Pi=le(),ms=le(Pi),Si=le(),wi=le(),sO=le(),Gl=le(sO),Hu=le(),R={comment:qu,lineComment:le(qu),blockComment:le(qu),docComment:le(qu),name:Rr,variableName:le(Rr),typeName:kb,tagName:le(kb),propertyName:Pb,attributeName:le(Pb),className:le(Rr),labelName:le(Rr),namespace:le(Rr),macroName:le(Rr),literal:Ar,string:Uu,docString:le(Uu),character:le(Uu),attributeValue:le(Uu),number:Pg,integer:le(Pg),float:le(Pg),bool:le(Ar),regexp:le(Ar),escape:le(Ar),color:le(Ar),url:le(Ar),keyword:Si,self:le(Si),null:le(Si),atom:le(Si),unit:le(Si),modifier:le(Si),operatorKeyword:le(Si),controlKeyword:le(Si),definitionKeyword:le(Si),moduleKeyword:le(Si),operator:wi,derefOperator:le(wi),arithmeticOperator:le(wi),logicOperator:le(wi),bitwiseOperator:le(wi),compareOperator:le(wi),updateOperator:le(wi),definitionOperator:le(wi),typeOperator:le(wi),controlOperator:le(wi),punctuation:sO,separator:le(sO),bracket:Gl,angleBracket:le(Gl),squareBracket:le(Gl),paren:le(Gl),brace:le(Gl),content:Pi,heading:ms,heading1:le(ms),heading2:le(ms),heading3:le(ms),heading4:le(ms),heading5:le(ms),heading6:le(ms),contentSeparator:le(Pi),list:le(Pi),quote:le(Pi),emphasis:le(Pi),strong:le(Pi),link:le(Pi),monospace:le(Pi),strikethrough:le(Pi),inserted:le(),deleted:le(),changed:le(),invalid:le(),meta:Hu,documentMeta:le(Hu),annotation:le(Hu),processingInstruction:le(Hu),definition:Ki.defineModifier("definition"),constant:Ki.defineModifier("constant"),function:Ki.defineModifier("function"),standard:Ki.defineModifier("standard"),local:Ki.defineModifier("local"),special:Ki.defineModifier("special")};for(let n in R){let e=R[n];e instanceof Ki&&(e.name=n)}x_([{tag:R.link,class:"tok-link"},{tag:R.heading,class:"tok-heading"},{tag:R.emphasis,class:"tok-emphasis"},{tag:R.strong,class:"tok-strong"},{tag:R.keyword,class:"tok-keyword"},{tag:R.atom,class:"tok-atom"},{tag:R.bool,class:"tok-bool"},{tag:R.url,class:"tok-url"},{tag:R.labelName,class:"tok-labelName"},{tag:R.inserted,class:"tok-inserted"},{tag:R.deleted,class:"tok-deleted"},{tag:R.literal,class:"tok-literal"},{tag:R.string,class:"tok-string"},{tag:R.number,class:"tok-number"},{tag:[R.regexp,R.escape,R.special(R.string)],class:"tok-string2"},{tag:R.variableName,class:"tok-variableName"},{tag:R.local(R.variableName),class:"tok-variableName tok-local"},{tag:R.definition(R.variableName),class:"tok-variableName tok-definition"},{tag:R.special(R.variableName),class:"tok-variableName2"},{tag:R.definition(R.propertyName),class:"tok-propertyName tok-definition"},{tag:R.typeName,class:"tok-typeName"},{tag:R.namespace,class:"tok-namespace"},{tag:R.className,class:"tok-className"},{tag:R.macroName,class:"tok-macroName"},{tag:R.propertyName,class:"tok-propertyName"},{tag:R.operator,class:"tok-operator"},{tag:R.comment,class:"tok-comment"},{tag:R.meta,class:"tok-meta"},{tag:R.invalid,class:"tok-invalid"},{tag:R.punctuation,class:"tok-punctuation"}]);var _g;const Co=new Ee;function v_(n){return fe.define({combine:n?e=>e.concat(n):void 0})}const f0=new Ee;class ai{constructor(e,t,i=[],r=""){this.data=e,this.name=r,Ie.prototype.hasOwnProperty("tree")||Object.defineProperty(Ie.prototype,"tree",{get(){return Pt(this)}}),this.parser=t,this.extension=[Gr.of(this),Ie.languageData.of((s,o,a)=>{let c=_b(s,o,a),h=c.type.prop(Co);if(!h)return[];let f=s.facet(h),p=c.type.prop(f0);if(p){let m=c.resolve(o-c.from,a);for(let y of p)if(y.test(m,s)){let v=s.facet(y.facet);return y.type=="replace"?v:v.concat(f)}}return f})].concat(i)}isActiveAt(e,t,i=-1){return _b(e,t,i).type.prop(Co)==this.data}findRegions(e){let t=e.facet(Gr);if((t==null?void 0:t.data)==this.data)return[{from:0,to:e.doc.length}];if(!t||!t.allowsNesting)return[];let i=[],r=(s,o)=>{if(s.prop(Co)==this.data){i.push({from:o,to:o+s.length});return}let a=s.prop(Ee.mounted);if(a){if(a.tree.prop(Co)==this.data){if(a.overlay)for(let c of a.overlay)i.push({from:c.from+o,to:c.to+o});else i.push({from:o,to:o+s.length});return}else if(a.overlay){let c=i.length;if(r(a.tree,a.overlay[0].from+o),i.length>c)return}}for(let c=0;ci.isTop?t:void 0)]}),e.name)}configure(e,t){return new Wa(this.data,this.parser.configure(e),t||this.name)}get allowsNesting(){return this.parser.hasWrappers()}}function Pt(n){let e=n.field(ai.state,!1);return e?e.tree:wt.empty}class rL{constructor(e){this.doc=e,this.cursorPos=0,this.string="",this.cursor=e.iter()}get length(){return this.doc.length}syncTo(e){return this.string=this.cursor.next(e-this.cursorPos).value,this.cursorPos=e+this.string.length,this.cursorPos-this.string.length}chunk(e){return this.syncTo(e),this.string}get lineChunks(){return!0}read(e,t){let i=this.cursorPos-this.string.length;return e=this.cursorPos?this.doc.sliceString(e,t):this.string.slice(e-i,t-i)}}let Kl=null;class of{constructor(e,t,i=[],r,s,o,a,c){this.parser=e,this.state=t,this.fragments=i,this.tree=r,this.treeLen=s,this.viewport=o,this.skipped=a,this.scheduleOn=c,this.parse=null,this.tempSkipped=[]}static create(e,t,i){return new of(e,t,[],wt.empty,0,i,[],null)}startParse(){return this.parser.startParse(new rL(this.state.doc),this.fragments)}work(e,t){return t!=null&&t>=this.state.doc.length&&(t=void 0),this.tree!=wt.empty&&this.isDone(t!=null?t:this.state.doc.length)?(this.takeTree(),!0):this.withContext(()=>{var i;if(typeof e=="number"){let r=Date.now()+e;e=()=>Date.now()>r}for(this.parse||(this.parse=this.startParse()),t!=null&&(this.parse.stoppedAt==null||this.parse.stoppedAt>t)&&t=this.treeLen&&((this.parse.stoppedAt==null||this.parse.stoppedAt>e)&&this.parse.stopAt(e),this.withContext(()=>{for(;!(t=this.parse.advance()););}),this.treeLen=e,this.tree=t,this.fragments=this.withoutTempSkipped(Rs.addTree(this.tree,this.fragments,!0)),this.parse=null)}withContext(e){let t=Kl;Kl=this;try{return e()}finally{Kl=t}}withoutTempSkipped(e){for(let t;t=this.tempSkipped.pop();)e=Qb(e,t.from,t.to);return e}changes(e,t){let{fragments:i,tree:r,treeLen:s,viewport:o,skipped:a}=this;if(this.takeTree(),!e.empty){let c=[];if(e.iterChangedRanges((h,f,p,m)=>c.push({fromA:h,toA:f,fromB:p,toB:m})),i=Rs.applyChanges(i,c),r=wt.empty,s=0,o={from:e.mapPos(o.from,-1),to:e.mapPos(o.to,1)},this.skipped.length){a=[];for(let h of this.skipped){let f=e.mapPos(h.from,1),p=e.mapPos(h.to,-1);fe.from&&(this.fragments=Qb(this.fragments,r,s),this.skipped.splice(i--,1))}return this.skipped.length>=t?!1:(this.reset(),!0)}reset(){this.parse&&(this.takeTree(),this.parse=null)}skipUntilInView(e,t){this.skipped.push({from:e,to:t})}static getSkippingParser(e){return new class extends O_{createParse(t,i,r){let s=r[0].from,o=r[r.length-1].to;return{parsedPos:s,advance(){let c=Kl;if(c){for(let h of r)c.tempSkipped.push(h);e&&(c.scheduleOn=c.scheduleOn?Promise.all([c.scheduleOn,e]):e)}return this.parsedPos=o,new wt(kn.none,[],[],o-s)},stoppedAt:null,stopAt(){}}}}}isDone(e){e=Math.min(e,this.state.doc.length);let t=this.fragments;return this.treeLen>=e&&t.length&&t[0].from==0&&t[0].to>=e}static get(){return Kl}}function Qb(n,e,t){return Rs.applyChanges(n,[{fromA:e,toA:t,fromB:e,toB:t}])}class qo{constructor(e){this.context=e,this.tree=e.tree}apply(e){if(!e.docChanged&&this.tree==this.context.tree)return this;let t=this.context.changes(e.changes,e.state),i=this.context.treeLen==e.startState.doc.length?void 0:Math.max(e.changes.mapPos(this.context.treeLen),t.viewport.to);return t.work(20,i)||t.takeTree(),new qo(t)}static init(e){let t=Math.min(3e3,e.doc.length),i=of.create(e.facet(Gr).parser,e,{from:0,to:t});return i.work(20,t)||i.takeTree(),new qo(i)}}ai.state=zt.define({create:qo.init,update(n,e){for(let t of e.effects)if(t.is(ai.setState))return t.value;return e.startState.facet(Gr)!=e.state.facet(Gr)?qo.init(e.state):n.apply(e)}});let b_=n=>{let e=setTimeout(()=>n(),500);return()=>clearTimeout(e)};typeof requestIdleCallback<"u"&&(b_=n=>{let e=-1,t=setTimeout(()=>{e=requestIdleCallback(n,{timeout:400})},100);return()=>e<0?clearTimeout(t):cancelIdleCallback(e)});const Qg=typeof navigator<"u"&&(!((_g=navigator.scheduling)===null||_g===void 0)&&_g.isInputPending)?()=>navigator.scheduling.isInputPending():null,sL=kt.fromClass(class{constructor(e){this.view=e,this.working=null,this.workScheduled=0,this.chunkEnd=-1,this.chunkBudget=-1,this.work=this.work.bind(this),this.scheduleWork()}update(e){let t=this.view.state.field(ai.state).context;(t.updateViewport(e.view.viewport)||this.view.viewport.to>t.treeLen)&&this.scheduleWork(),(e.docChanged||e.selectionSet)&&(this.view.hasFocus&&(this.chunkBudget+=50),this.scheduleWork()),this.checkAsyncSchedule(t)}scheduleWork(){if(this.working)return;let{state:e}=this.view,t=e.field(ai.state);(t.tree!=t.context.tree||!t.context.isDone(e.doc.length))&&(this.working=b_(this.work))}work(e){this.working=null;let t=Date.now();if(this.chunkEndr+1e3,c=s.context.work(()=>Qg&&Qg()||Date.now()>o,r+(a?0:1e5));this.chunkBudget-=Date.now()-t,(c||this.chunkBudget<=0)&&(s.context.takeTree(),this.view.dispatch({effects:ai.setState.of(new qo(s.context))})),this.chunkBudget>0&&!(c&&!a)&&this.scheduleWork(),this.checkAsyncSchedule(s.context)}checkAsyncSchedule(e){e.scheduleOn&&(this.workScheduled++,e.scheduleOn.then(()=>this.scheduleWork()).catch(t=>bn(this.view.state,t)).then(()=>this.workScheduled--),e.scheduleOn=null)}destroy(){this.working&&this.working()}isWorking(){return!!(this.working||this.workScheduled>0)}},{eventHandlers:{focus(){this.scheduleWork()}}}),Gr=fe.define({combine(n){return n.length?n[0]:null},enables:n=>[ai.state,sL,ce.contentAttributes.compute([n],e=>{let t=e.facet(n);return t&&t.name?{"data-language":t.name}:{}})]});class S_{constructor(e,t=[]){this.language=e,this.support=t,this.extension=[e,t]}}const oL=fe.define(),Wf=fe.define({combine:n=>{if(!n.length)return" ";let e=n[0];if(!e||/\S/.test(e)||Array.from(e).some(t=>t!=e[0]))throw new Error("Invalid indent unit: "+JSON.stringify(n[0]));return e}});function lf(n){let e=n.facet(Wf);return e.charCodeAt(0)==9?n.tabSize*e.length:e.length}function Va(n,e){let t="",i=n.tabSize,r=n.facet(Wf)[0];if(r==" "){for(;e>=i;)t+=" ",e-=i;r=" "}for(let s=0;s=e?lL(n,t,e):null}class Vf{constructor(e,t={}){this.state=e,this.options=t,this.unit=lf(e)}lineAt(e,t=1){let i=this.state.doc.lineAt(e),{simulateBreak:r,simulateDoubleBreak:s}=this.options;return r!=null&&r>=i.from&&r<=i.to?s&&r==e?{text:"",from:e}:(t<0?r-1&&(s+=o-this.countColumn(i,i.search(/\S|$/))),s}countColumn(e,t=e.length){return tl(e,this.state.tabSize,t)}lineIndent(e,t=1){let{text:i,from:r}=this.lineAt(e,t),s=this.options.overrideIndentation;if(s){let o=s(r);if(o>-1)return o}return this.countColumn(i,i.search(/\S|$/))}get simulatedBreak(){return this.options.simulateBreak||null}}const p0=new Ee;function lL(n,e,t){let i=e.resolveStack(t),r=e.resolveInner(t,-1).resolve(t,0).enterUnfinishedNodesBefore(t);if(r!=i.node){let s=[];for(let o=r;o&&!(o.fromi.node.to||o.from==i.node.from&&o.type==i.node.type);o=o.parent)s.push(o);for(let o=s.length-1;o>=0;o--)i={node:s[o],next:i}}return w_(i,n,t)}function w_(n,e,t){for(let i=n;i;i=i.next){let r=cL(i.node);if(r)return r(g0.create(e,t,i))}return 0}function aL(n){return n.pos==n.options.simulateBreak&&n.options.simulateDoubleBreak}function cL(n){let e=n.type.prop(p0);if(e)return e;let t=n.firstChild,i;if(t&&(i=t.type.prop(Ee.closedBy))){let r=n.lastChild,s=r&&i.indexOf(r.name)>-1;return o=>k_(o,!0,1,void 0,s&&!aL(o)?r.from:void 0)}return n.parent==null?uL:null}function uL(){return 0}class g0 extends Vf{constructor(e,t,i){super(e.state,e.options),this.base=e,this.pos=t,this.context=i}get node(){return this.context.node}static create(e,t,i){return new g0(e,t,i)}get textAfter(){return this.textAfterPos(this.pos)}get baseIndent(){return this.baseIndentFor(this.node)}baseIndentFor(e){let t=this.state.doc.lineAt(e.from);for(;;){let i=e.resolve(t.from);for(;i.parent&&i.parent.from==i.from;)i=i.parent;if(hL(i,e))break;t=this.state.doc.lineAt(i.from)}return this.lineIndent(t.from)}continue(){return w_(this.context.next,this.base,this.pos)}}function hL(n,e){for(let t=e;t;t=t.parent)if(n==t)return!0;return!1}function fL(n){let e=n.node,t=e.childAfter(e.from),i=e.lastChild;if(!t)return null;let r=n.options.simulateBreak,s=n.state.doc.lineAt(t.from),o=r==null||r<=s.from?s.to:Math.min(s.to,r);for(let a=t.to;;){let c=e.childAfter(a);if(!c||c==i)return null;if(!c.type.isSkipped){if(c.from>=o)return null;let h=/^ */.exec(s.text.slice(t.to-s.from))[0].length;return{from:t.from,to:t.to+h}}a=c.to}}function dL({closing:n,align:e=!0,units:t=1}){return i=>k_(i,e,t,n)}function k_(n,e,t,i,r){let s=n.textAfter,o=s.match(/^\s*/)[0].length,a=i&&s.slice(o,o+i.length)==i||r==n.pos+o,c=e?fL(n):null;return c?a?n.column(c.from):n.column(c.to):n.baseIndent+(a?0:n.unit*t)}const pL=n=>n.baseIndent;function Rh({except:n,units:e=1}={}){return t=>{let i=n&&n.test(t.textAfter);return t.baseIndent+(i?0:e*t.unit)}}const gL=200;function mL(){return Ie.transactionFilter.of(n=>{if(!n.docChanged||!n.isUserEvent("input.type")&&!n.isUserEvent("input.complete"))return n;let e=n.startState.languageDataAt("indentOnInput",n.startState.selection.main.head);if(!e.length)return n;let t=n.newDoc,{head:i}=n.newSelection.main,r=t.lineAt(i);if(i>r.from+gL)return n;let s=t.sliceString(r.from,i);if(!e.some(h=>h.test(s)))return n;let{state:o}=n,a=-1,c=[];for(let{head:h}of o.selection.ranges){let f=o.doc.lineAt(h);if(f.from==a)continue;a=f.from;let p=d0(o,f.from);if(p==null)continue;let m=/^\s*/.exec(f.text)[0],y=Va(o,p);m!=y&&c.push({from:f.from,to:f.from+m.length,insert:y})}return c.length?[n,{changes:c,sequential:!0}]:n})}const OL=fe.define(),m0=new Ee;function yL(n){let e=n.firstChild,t=n.lastChild;return e&&e.tot)continue;if(s&&a.from=e&&h.to>t&&(s=h)}}return s}function vL(n){let e=n.lastChild;return e&&e.to==n.to&&e.type.isError}function af(n,e,t){for(let i of n.facet(OL)){let r=i(n,e,t);if(r)return r}return xL(n,e,t)}function P_(n,e){let t=e.mapPos(n.from,1),i=e.mapPos(n.to,-1);return t>=i?void 0:{from:t,to:i}}const Ff=Te.define({map:P_}),dc=Te.define({map:P_});function __(n){let e=[];for(let{head:t}of n.state.selection.ranges)e.some(i=>i.from<=t&&i.to>=t)||e.push(n.lineBlockAt(t));return e}const Ns=zt.define({create(){return Pe.none},update(n,e){e.isUserEvent("delete")&&e.changes.iterChangedRanges((t,i)=>n=Cb(n,t,i)),n=n.map(e.changes);for(let t of e.effects)if(t.is(Ff)&&!bL(n,t.value.from,t.value.to)){let{preparePlaceholder:i}=e.state.facet(T_),r=i?Pe.replace({widget:new CL(i(e.state,t.value))}):Tb;n=n.update({add:[r.range(t.value.from,t.value.to)]})}else t.is(dc)&&(n=n.update({filter:(i,r)=>t.value.from!=i||t.value.to!=r,filterFrom:t.value.from,filterTo:t.value.to}));return e.selection&&(n=Cb(n,e.selection.main.head)),n},provide:n=>ce.decorations.from(n),toJSON(n,e){let t=[];return n.between(0,e.doc.length,(i,r)=>{t.push(i,r)}),t},fromJSON(n){if(!Array.isArray(n)||n.length%2)throw new RangeError("Invalid JSON for fold state");let e=[];for(let t=0;t{re&&(i=!0)}),i?n.update({filterFrom:e,filterTo:t,filter:(r,s)=>r>=t||s<=e}):n}function cf(n,e,t){var i;let r=null;return(i=n.field(Ns,!1))===null||i===void 0||i.between(e,t,(s,o)=>{(!r||r.from>s)&&(r={from:s,to:o})}),r}function bL(n,e,t){let i=!1;return n.between(e,e,(r,s)=>{r==e&&s==t&&(i=!0)}),i}function Q_(n,e){return n.field(Ns,!1)?e:e.concat(Te.appendConfig.of($_()))}const SL=n=>{for(let e of __(n)){let t=af(n.state,e.from,e.to);if(t)return n.dispatch({effects:Q_(n.state,[Ff.of(t),C_(n,t)])}),!0}return!1},wL=n=>{if(!n.state.field(Ns,!1))return!1;let e=[];for(let t of __(n)){let i=cf(n.state,t.from,t.to);i&&e.push(dc.of(i),C_(n,i,!1))}return e.length&&n.dispatch({effects:e}),e.length>0};function C_(n,e,t=!0){let i=n.state.doc.lineAt(e.from).number,r=n.state.doc.lineAt(e.to).number;return ce.announce.of(`${n.state.phrase(t?"Folded lines":"Unfolded lines")} ${i} ${n.state.phrase("to")} ${r}.`)}const kL=n=>{let{state:e}=n,t=[];for(let i=0;i{let e=n.state.field(Ns,!1);if(!e||!e.size)return!1;let t=[];return e.between(0,n.state.doc.length,(i,r)=>{t.push(dc.of({from:i,to:r}))}),n.dispatch({effects:t}),!0},_L=[{key:"Ctrl-Shift-[",mac:"Cmd-Alt-[",run:SL},{key:"Ctrl-Shift-]",mac:"Cmd-Alt-]",run:wL},{key:"Ctrl-Alt-[",run:kL},{key:"Ctrl-Alt-]",run:PL}],QL={placeholderDOM:null,preparePlaceholder:null,placeholderText:"…"},T_=fe.define({combine(n){return Zi(n,QL)}});function $_(n){return[Ns,ML]}function M_(n,e){let{state:t}=n,i=t.facet(T_),r=o=>{let a=n.lineBlockAt(n.posAtDOM(o.target)),c=cf(n.state,a.from,a.to);c&&n.dispatch({effects:dc.of(c)}),o.preventDefault()};if(i.placeholderDOM)return i.placeholderDOM(n,r,e);let s=document.createElement("span");return s.textContent=i.placeholderText,s.setAttribute("aria-label",t.phrase("folded code")),s.title=t.phrase("unfold"),s.className="cm-foldPlaceholder",s.onclick=r,s}const Tb=Pe.replace({widget:new class extends cr{toDOM(n){return M_(n,null)}}});class CL extends cr{constructor(e){super(),this.value=e}eq(e){return this.value==e.value}toDOM(e){return M_(e,this.value)}}const TL={openText:"⌄",closedText:"›",markerDOM:null,domEventHandlers:{},foldingChanged:()=>!1};class Cg extends lr{constructor(e,t){super(),this.config=e,this.open=t}eq(e){return this.config==e.config&&this.open==e.open}toDOM(e){if(this.config.markerDOM)return this.config.markerDOM(this.open);let t=document.createElement("span");return t.textContent=this.open?this.config.openText:this.config.closedText,t.title=e.state.phrase(this.open?"Fold line":"Unfold line"),t}}function $L(n={}){let e={...TL,...n},t=new Cg(e,!0),i=new Cg(e,!1),r=kt.fromClass(class{constructor(o){this.from=o.viewport.from,this.markers=this.buildMarkers(o)}update(o){(o.docChanged||o.viewportChanged||o.startState.facet(Gr)!=o.state.facet(Gr)||o.startState.field(Ns,!1)!=o.state.field(Ns,!1)||Pt(o.startState)!=Pt(o.state)||e.foldingChanged(o))&&(this.markers=this.buildMarkers(o.view))}buildMarkers(o){let a=new sr;for(let c of o.viewportLineBlocks){let h=cf(o.state,c.from,c.to)?i:af(o.state,c.from,c.to)?t:null;h&&a.add(c.from,c.from,h)}return a.finish()}}),{domEventHandlers:s}=e;return[r,ME({class:"cm-foldGutter",markers(o){var a;return((a=o.plugin(r))===null||a===void 0?void 0:a.markers)||je.empty},initialSpacer(){return new Cg(e,!1)},domEventHandlers:{...s,click:(o,a,c)=>{if(s.click&&s.click(o,a,c))return!0;let h=cf(o.state,a.from,a.to);if(h)return o.dispatch({effects:dc.of(h)}),!0;let f=af(o.state,a.from,a.to);return f?(o.dispatch({effects:Ff.of(f)}),!0):!1}}}),$_()]}const ML=ce.baseTheme({".cm-foldPlaceholder":{backgroundColor:"#eee",border:"1px solid #ddd",color:"#888",borderRadius:".2em",margin:"0 1px",padding:"0 1px",cursor:"pointer"},".cm-foldGutter span":{padding:"0 1px",cursor:"pointer"}});class pc{constructor(e,t){this.specs=e;let i;function r(a){let c=Yr.newName();return(i||(i=Object.create(null)))["."+c]=a,c}const s=typeof t.all=="string"?t.all:t.all?r(t.all):void 0,o=t.scope;this.scope=o instanceof ai?a=>a.prop(Co)==o.data:o?a=>a==o:void 0,this.style=x_(e.map(a=>({tag:a.tag,class:a.class||r(Object.assign({},a,{tag:null}))})),{all:s}).style,this.module=i?new Yr(i):null,this.themeType=t.themeType}static define(e,t){return new pc(e,t||{})}}const oO=fe.define(),R_=fe.define({combine(n){return n.length?[n[0]]:null}});function Tg(n){let e=n.facet(oO);return e.length?e:n.facet(R_)}function A_(n,e){let t=[AL],i;return n instanceof pc&&(n.module&&t.push(ce.styleModule.of(n.module)),i=n.themeType),e!=null&&e.fallback?t.push(R_.of(n)):i?t.push(oO.computeN([ce.darkTheme],r=>r.facet(ce.darkTheme)==(i=="dark")?[n]:[])):t.push(oO.of(n)),t}class RL{constructor(e){this.markCache=Object.create(null),this.tree=Pt(e.state),this.decorations=this.buildDeco(e,Tg(e.state)),this.decoratedTo=e.viewport.to}update(e){let t=Pt(e.state),i=Tg(e.state),r=i!=Tg(e.startState),{viewport:s}=e.view,o=e.changes.mapPos(this.decoratedTo,1);t.length=s.to?(this.decorations=this.decorations.map(e.changes),this.decoratedTo=o):(t!=this.tree||e.viewportChanged||r)&&(this.tree=t,this.decorations=this.buildDeco(e.view,i),this.decoratedTo=s.to)}buildDeco(e,t){if(!t||!this.tree.length)return Pe.none;let i=new sr;for(let{from:r,to:s}of e.visibleRanges)tL(this.tree,t,(o,a,c)=>{i.add(o,a,this.markCache[c]||(this.markCache[c]=Pe.mark({class:c})))},r,s);return i.finish()}}const AL=Jr.high(kt.fromClass(RL,{decorations:n=>n.decorations})),EL=pc.define([{tag:R.meta,color:"#404740"},{tag:R.link,textDecoration:"underline"},{tag:R.heading,textDecoration:"underline",fontWeight:"bold"},{tag:R.emphasis,fontStyle:"italic"},{tag:R.strong,fontWeight:"bold"},{tag:R.strikethrough,textDecoration:"line-through"},{tag:R.keyword,color:"#708"},{tag:[R.atom,R.bool,R.url,R.contentSeparator,R.labelName],color:"#219"},{tag:[R.literal,R.inserted],color:"#164"},{tag:[R.string,R.deleted],color:"#a11"},{tag:[R.regexp,R.escape,R.special(R.string)],color:"#e40"},{tag:R.definition(R.variableName),color:"#00f"},{tag:R.local(R.variableName),color:"#30a"},{tag:[R.typeName,R.namespace],color:"#085"},{tag:R.className,color:"#167"},{tag:[R.special(R.variableName),R.macroName],color:"#256"},{tag:R.definition(R.propertyName),color:"#00c"},{tag:R.comment,color:"#940"},{tag:R.invalid,color:"#f00"}]),LL=ce.baseTheme({"&.cm-focused .cm-matchingBracket":{backgroundColor:"#328c8252"},"&.cm-focused .cm-nonmatchingBracket":{backgroundColor:"#bb555544"}}),E_=1e4,L_="()[]{}",D_=fe.define({combine(n){return Zi(n,{afterCursor:!0,brackets:L_,maxScanDistance:E_,renderMatch:ZL})}}),DL=Pe.mark({class:"cm-matchingBracket"}),zL=Pe.mark({class:"cm-nonmatchingBracket"});function ZL(n){let e=[],t=n.matched?DL:zL;return e.push(t.range(n.start.from,n.start.to)),n.end&&e.push(t.range(n.end.from,n.end.to)),e}const IL=zt.define({create(){return Pe.none},update(n,e){if(!e.docChanged&&!e.selection)return n;let t=[],i=e.state.facet(D_);for(let r of e.state.selection.ranges){if(!r.empty)continue;let s=Ai(e.state,r.head,-1,i)||r.head>0&&Ai(e.state,r.head-1,1,i)||i.afterCursor&&(Ai(e.state,r.head,1,i)||r.headce.decorations.from(n)}),jL=[IL,LL];function BL(n={}){return[D_.of(n),jL]}const NL=new Ee;function lO(n,e,t){let i=n.prop(e<0?Ee.openedBy:Ee.closedBy);if(i)return i;if(n.name.length==1){let r=t.indexOf(n.name);if(r>-1&&r%2==(e<0?1:0))return[t[r+e]]}return null}function aO(n){let e=n.type.prop(NL);return e?e(n.node):n}function Ai(n,e,t,i={}){let r=i.maxScanDistance||E_,s=i.brackets||L_,o=Pt(n),a=o.resolveInner(e,t);for(let c=a;c;c=c.parent){let h=lO(c.type,t,s);if(h&&c.from0?e>=f.from&&ef.from&&e<=f.to))return XL(n,e,t,c,f,h,s)}}return WL(n,e,t,o,a.type,r,s)}function XL(n,e,t,i,r,s,o){let a=i.parent,c={from:r.from,to:r.to},h=0,f=a==null?void 0:a.cursor();if(f&&(t<0?f.childBefore(i.from):f.childAfter(i.to)))do if(t<0?f.to<=i.from:f.from>=i.to){if(h==0&&s.indexOf(f.type.name)>-1&&f.from0)return null;let h={from:t<0?e-1:e,to:t>0?e+1:e},f=n.doc.iterRange(e,t>0?n.doc.length:0),p=0;for(let m=0;!f.next().done&&m<=s;){let y=f.value;t<0&&(m+=y.length);let v=e+m*t;for(let b=t>0?0:y.length-1,S=t>0?y.length:-1;b!=S;b+=t){let w=o.indexOf(y[b]);if(!(w<0||i.resolveInner(v+b,1).type!=r))if(w%2==0==t>0)p++;else{if(p==1)return{start:h,end:{from:v+b,to:v+b+1},matched:w>>1==c>>1};p--}}t>0&&(m+=y.length)}return f.done?{start:h,matched:!1}:null}const VL=Object.create(null),$b=[kn.none],Mb=[],Rb=Object.create(null),FL=Object.create(null);for(let[n,e]of[["variable","variableName"],["variable-2","variableName.special"],["string-2","string.special"],["def","variableName.definition"],["tag","tagName"],["attribute","attributeName"],["type","typeName"],["builtin","variableName.standard"],["qualifier","modifier"],["error","invalid"],["header","heading"],["property","propertyName"]])FL[n]=YL(VL,e);function $g(n,e){Mb.indexOf(n)>-1||(Mb.push(n),console.warn(e))}function YL(n,e){let t=[];for(let a of e.split(" ")){let c=[];for(let h of a.split(".")){let f=n[h]||R[h];f?typeof f=="function"?c.length?c=c.map(f):$g(h,`Modifier ${h} used at start of tag`):c.length?$g(h,`Tag ${h} used as modifier`):c=Array.isArray(f)?f:[f]:$g(h,`Unknown highlighting tag ${h}`)}for(let h of c)t.push(h)}if(!t.length)return 0;let i=e.replace(/ /g,"_"),r=i+" "+t.map(a=>a.id),s=Rb[r];if(s)return s.id;let o=Rb[r]=kn.define({id:$b.length,name:i,props:[h0({[i]:t})]});return $b.push(o),o.id}st.RTL,st.LTR;const qL=n=>{let{state:e}=n,t=e.doc.lineAt(e.selection.main.from),i=y0(n.state,t.from);return i.line?UL(n):i.block?GL(n):!1};function O0(n,e){return({state:t,dispatch:i})=>{if(t.readOnly)return!1;let r=n(e,t);return r?(i(t.update(r)),!0):!1}}const UL=O0(eD,0),HL=O0(z_,0),GL=O0((n,e)=>z_(n,e,JL(e)),0);function y0(n,e){let t=n.languageDataAt("commentTokens",e,1);return t.length?t[0]:{}}const Jl=50;function KL(n,{open:e,close:t},i,r){let s=n.sliceDoc(i-Jl,i),o=n.sliceDoc(r,r+Jl),a=/\s*$/.exec(s)[0].length,c=/^\s*/.exec(o)[0].length,h=s.length-a;if(s.slice(h-e.length,h)==e&&o.slice(c,c+t.length)==t)return{open:{pos:i-a,margin:a&&1},close:{pos:r+c,margin:c&&1}};let f,p;r-i<=2*Jl?f=p=n.sliceDoc(i,r):(f=n.sliceDoc(i,i+Jl),p=n.sliceDoc(r-Jl,r));let m=/^\s*/.exec(f)[0].length,y=/\s*$/.exec(p)[0].length,v=p.length-y-t.length;return f.slice(m,m+e.length)==e&&p.slice(v,v+t.length)==t?{open:{pos:i+m+e.length,margin:/\s/.test(f.charAt(m+e.length))?1:0},close:{pos:r-y-t.length,margin:/\s/.test(p.charAt(v-1))?1:0}}:null}function JL(n){let e=[];for(let t of n.selection.ranges){let i=n.doc.lineAt(t.from),r=t.to<=i.to?i:n.doc.lineAt(t.to);r.from>i.from&&r.from==t.to&&(r=t.to==i.to+1?i:n.doc.lineAt(t.to-1));let s=e.length-1;s>=0&&e[s].to>i.from?e[s].to=r.to:e.push({from:i.from+/^\s*/.exec(i.text)[0].length,to:r.to})}return e}function z_(n,e,t=e.selection.ranges){let i=t.map(s=>y0(e,s.from).block);if(!i.every(s=>s))return null;let r=t.map((s,o)=>KL(e,i[o],s.from,s.to));if(n!=2&&!r.every(s=>s))return{changes:e.changes(t.map((s,o)=>r[o]?[]:[{from:s.from,insert:i[o].open+" "},{from:s.to,insert:" "+i[o].close}]))};if(n!=1&&r.some(s=>s)){let s=[];for(let o=0,a;or&&(s==o||o>p.from)){r=p.from;let m=/^\s*/.exec(p.text)[0].length,y=m==p.length,v=p.text.slice(m,m+h.length)==h?m:-1;ms.comment<0&&(!s.empty||s.single))){let s=[];for(let{line:a,token:c,indent:h,empty:f,single:p}of i)(p||!f)&&s.push({from:a.from+h,insert:c+" "});let o=e.changes(s);return{changes:o,selection:e.selection.map(o,1)}}else if(n!=1&&i.some(s=>s.comment>=0)){let s=[];for(let{line:o,comment:a,token:c}of i)if(a>=0){let h=o.from+a,f=h+c.length;o.text[f-o.from]==" "&&f++,s.push({from:h,to:f})}return{changes:s}}return null}const cO=ar.define(),tD=ar.define(),nD=fe.define(),Z_=fe.define({combine(n){return Zi(n,{minDepth:100,newGroupDelay:500,joinToEvent:(e,t)=>t},{minDepth:Math.max,newGroupDelay:Math.min,joinToEvent:(e,t)=>(i,r)=>e(i,r)||t(i,r)})}}),I_=zt.define({create(){return Ei.empty},update(n,e){let t=e.state.facet(Z_),i=e.annotation(cO);if(i){let c=Sn.fromTransaction(e,i.selection),h=i.side,f=h==0?n.undone:n.done;return c?f=uf(f,f.length,t.minDepth,c):f=N_(f,e.startState.selection),new Ei(h==0?i.rest:f,h==0?f:i.rest)}let r=e.annotation(tD);if((r=="full"||r=="before")&&(n=n.isolate()),e.annotation(St.addToHistory)===!1)return e.changes.empty?n:n.addMapping(e.changes.desc);let s=Sn.fromTransaction(e),o=e.annotation(St.time),a=e.annotation(St.userEvent);return s?n=n.addChanges(s,o,a,t,e):e.selection&&(n=n.addSelection(e.startState.selection,o,a,t.newGroupDelay)),(r=="full"||r=="after")&&(n=n.isolate()),n},toJSON(n){return{done:n.done.map(e=>e.toJSON()),undone:n.undone.map(e=>e.toJSON())}},fromJSON(n){return new Ei(n.done.map(Sn.fromJSON),n.undone.map(Sn.fromJSON))}});function iD(n={}){return[I_,Z_.of(n),ce.domEventHandlers({beforeinput(e,t){let i=e.inputType=="historyUndo"?j_:e.inputType=="historyRedo"?uO:null;return i?(e.preventDefault(),i(t)):!1}})]}function Yf(n,e){return function({state:t,dispatch:i}){if(!e&&t.readOnly)return!1;let r=t.field(I_,!1);if(!r)return!1;let s=r.pop(n,t,e);return s?(i(s),!0):!1}}const j_=Yf(0,!1),uO=Yf(1,!1),rD=Yf(0,!0),sD=Yf(1,!0);class Sn{constructor(e,t,i,r,s){this.changes=e,this.effects=t,this.mapped=i,this.startSelection=r,this.selectionsAfter=s}setSelAfter(e){return new Sn(this.changes,this.effects,this.mapped,this.startSelection,e)}toJSON(){var e,t,i;return{changes:(e=this.changes)===null||e===void 0?void 0:e.toJSON(),mapped:(t=this.mapped)===null||t===void 0?void 0:t.toJSON(),startSelection:(i=this.startSelection)===null||i===void 0?void 0:i.toJSON(),selectionsAfter:this.selectionsAfter.map(r=>r.toJSON())}}static fromJSON(e){return new Sn(e.changes&&Ct.fromJSON(e.changes),[],e.mapped&&Li.fromJSON(e.mapped),e.startSelection&&V.fromJSON(e.startSelection),e.selectionsAfter.map(V.fromJSON))}static fromTransaction(e,t){let i=Xn;for(let r of e.startState.facet(nD)){let s=r(e);s.length&&(i=i.concat(s))}return!i.length&&e.changes.empty?null:new Sn(e.changes.invert(e.startState.doc),i,void 0,t||e.startState.selection,Xn)}static selection(e){return new Sn(void 0,Xn,void 0,void 0,e)}}function uf(n,e,t,i){let r=e+1>t+20?e-t-1:0,s=n.slice(r,e);return s.push(i),s}function oD(n,e){let t=[],i=!1;return n.iterChangedRanges((r,s)=>t.push(r,s)),e.iterChangedRanges((r,s,o,a)=>{for(let c=0;c=h&&o<=f&&(i=!0)}}),i}function lD(n,e){return n.ranges.length==e.ranges.length&&n.ranges.filter((t,i)=>t.empty!=e.ranges[i].empty).length===0}function B_(n,e){return n.length?e.length?n.concat(e):n:e}const Xn=[],aD=200;function N_(n,e){if(n.length){let t=n[n.length-1],i=t.selectionsAfter.slice(Math.max(0,t.selectionsAfter.length-aD));return i.length&&i[i.length-1].eq(e)?n:(i.push(e),uf(n,n.length-1,1e9,t.setSelAfter(i)))}else return[Sn.selection([e])]}function cD(n){let e=n[n.length-1],t=n.slice();return t[n.length-1]=e.setSelAfter(e.selectionsAfter.slice(0,e.selectionsAfter.length-1)),t}function Mg(n,e){if(!n.length)return n;let t=n.length,i=Xn;for(;t;){let r=uD(n[t-1],e,i);if(r.changes&&!r.changes.empty||r.effects.length){let s=n.slice(0,t);return s[t-1]=r,s}else e=r.mapped,t--,i=r.selectionsAfter}return i.length?[Sn.selection(i)]:Xn}function uD(n,e,t){let i=B_(n.selectionsAfter.length?n.selectionsAfter.map(a=>a.map(e)):Xn,t);if(!n.changes)return Sn.selection(i);let r=n.changes.map(e),s=e.mapDesc(n.changes,!0),o=n.mapped?n.mapped.composeDesc(s):s;return new Sn(r,Te.mapEffects(n.effects,e),o,n.startSelection.map(s),i)}const hD=/^(input\.type|delete)($|\.)/;class Ei{constructor(e,t,i=0,r=void 0){this.done=e,this.undone=t,this.prevTime=i,this.prevUserEvent=r}isolate(){return this.prevTime?new Ei(this.done,this.undone):this}addChanges(e,t,i,r,s){let o=this.done,a=o[o.length-1];return a&&a.changes&&!a.changes.empty&&e.changes&&(!i||hD.test(i))&&(!a.selectionsAfter.length&&t-this.prevTime0&&t-this.prevTimet.empty?n.moveByChar(t,e):qf(t,e))}function rn(n){return n.textDirectionAt(n.state.selection.main.head)==st.LTR}const W_=n=>X_(n,!rn(n)),V_=n=>X_(n,rn(n));function F_(n,e){return pi(n,t=>t.empty?n.moveByGroup(t,e):qf(t,e))}const dD=n=>F_(n,!rn(n)),pD=n=>F_(n,rn(n));function gD(n,e,t){if(e.type.prop(t))return!0;let i=e.to-e.from;return i&&(i>2||/[^\s,.;:]/.test(n.sliceDoc(e.from,e.to)))||e.firstChild}function Uf(n,e,t){let i=Pt(n).resolveInner(e.head),r=t?Ee.closedBy:Ee.openedBy;for(let c=e.head;;){let h=t?i.childAfter(c):i.childBefore(c);if(!h)break;gD(n,h,r)?i=h:c=t?h.to:h.from}let s=i.type.prop(r),o,a;return s&&(o=t?Ai(n,i.from,1):Ai(n,i.to,-1))&&o.matched?a=t?o.end.to:o.end.from:a=t?i.to:i.from,V.cursor(a,t?-1:1)}const mD=n=>pi(n,e=>Uf(n.state,e,!rn(n))),OD=n=>pi(n,e=>Uf(n.state,e,rn(n)));function Y_(n,e){return pi(n,t=>{if(!t.empty)return qf(t,e);let i=n.moveVertically(t,e);return i.head!=t.head?i:n.moveToLineBoundary(t,e)})}const q_=n=>Y_(n,!1),U_=n=>Y_(n,!0);function H_(n){let e=n.scrollDOM.clientHeighto.empty?n.moveVertically(o,e,t.height):qf(o,e));if(r.eq(i.selection))return!1;let s;if(t.selfScroll){let o=n.coordsAtPos(i.selection.main.head),a=n.scrollDOM.getBoundingClientRect(),c=a.top+t.marginTop,h=a.bottom-t.marginBottom;o&&o.top>c&&o.bottomG_(n,!1),hO=n=>G_(n,!0);function es(n,e,t){let i=n.lineBlockAt(e.head),r=n.moveToLineBoundary(e,t);if(r.head==e.head&&r.head!=(t?i.to:i.from)&&(r=n.moveToLineBoundary(e,t,!1)),!t&&r.head==i.from&&i.length){let s=/^\s*/.exec(n.state.sliceDoc(i.from,Math.min(i.from+100,i.to)))[0].length;s&&e.head!=i.from+s&&(r=V.cursor(i.from+s))}return r}const yD=n=>pi(n,e=>es(n,e,!0)),xD=n=>pi(n,e=>es(n,e,!1)),vD=n=>pi(n,e=>es(n,e,!rn(n))),bD=n=>pi(n,e=>es(n,e,rn(n))),SD=n=>pi(n,e=>V.cursor(n.lineBlockAt(e.head).from,1)),wD=n=>pi(n,e=>V.cursor(n.lineBlockAt(e.head).to,-1));function kD(n,e,t){let i=!1,r=nl(n.selection,s=>{let o=Ai(n,s.head,-1)||Ai(n,s.head,1)||s.head>0&&Ai(n,s.head-1,1)||s.headkD(n,e);function Hn(n,e){let t=nl(n.state.selection,i=>{let r=e(i);return V.range(i.anchor,r.head,r.goalColumn,r.bidiLevel||void 0)});return t.eq(n.state.selection)?!1:(n.dispatch(di(n.state,t)),!0)}function K_(n,e){return Hn(n,t=>n.moveByChar(t,e))}const J_=n=>K_(n,!rn(n)),eQ=n=>K_(n,rn(n));function tQ(n,e){return Hn(n,t=>n.moveByGroup(t,e))}const _D=n=>tQ(n,!rn(n)),QD=n=>tQ(n,rn(n)),CD=n=>Hn(n,e=>Uf(n.state,e,!rn(n))),TD=n=>Hn(n,e=>Uf(n.state,e,rn(n)));function nQ(n,e){return Hn(n,t=>n.moveVertically(t,e))}const iQ=n=>nQ(n,!1),rQ=n=>nQ(n,!0);function sQ(n,e){return Hn(n,t=>n.moveVertically(t,e,H_(n).height))}const Eb=n=>sQ(n,!1),Lb=n=>sQ(n,!0),$D=n=>Hn(n,e=>es(n,e,!0)),MD=n=>Hn(n,e=>es(n,e,!1)),RD=n=>Hn(n,e=>es(n,e,!rn(n))),AD=n=>Hn(n,e=>es(n,e,rn(n))),ED=n=>Hn(n,e=>V.cursor(n.lineBlockAt(e.head).from)),LD=n=>Hn(n,e=>V.cursor(n.lineBlockAt(e.head).to)),Db=({state:n,dispatch:e})=>(e(di(n,{anchor:0})),!0),zb=({state:n,dispatch:e})=>(e(di(n,{anchor:n.doc.length})),!0),Zb=({state:n,dispatch:e})=>(e(di(n,{anchor:n.selection.main.anchor,head:0})),!0),Ib=({state:n,dispatch:e})=>(e(di(n,{anchor:n.selection.main.anchor,head:n.doc.length})),!0),DD=({state:n,dispatch:e})=>(e(n.update({selection:{anchor:0,head:n.doc.length},userEvent:"select"})),!0),zD=({state:n,dispatch:e})=>{let t=Hf(n).map(({from:i,to:r})=>V.range(i,Math.min(r+1,n.doc.length)));return e(n.update({selection:V.create(t),userEvent:"select"})),!0},ZD=({state:n,dispatch:e})=>{let t=nl(n.selection,i=>{let r=Pt(n),s=r.resolveStack(i.from,1);if(i.empty){let o=r.resolveStack(i.from,-1);o.node.from>=s.node.from&&o.node.to<=s.node.to&&(s=o)}for(let o=s;o;o=o.next){let{node:a}=o;if((a.from=i.to||a.to>i.to&&a.from<=i.from)&&o.next)return V.range(a.to,a.from)}return i});return t.eq(n.selection)?!1:(e(di(n,t)),!0)};function oQ(n,e){let{state:t}=n,i=t.selection,r=t.selection.ranges.slice();for(let s of t.selection.ranges){let o=t.doc.lineAt(s.head);if(e?o.to0)for(let a=s;;){let c=n.moveVertically(a,e);if(c.heado.to){r.some(h=>h.head==c.head)||r.push(c);break}else{if(c.head==a.head)break;a=c}}}return r.length==i.ranges.length?!1:(n.dispatch(di(t,V.create(r,r.length-1))),!0)}const ID=n=>oQ(n,!1),jD=n=>oQ(n,!0),BD=({state:n,dispatch:e})=>{let t=n.selection,i=null;return t.ranges.length>1?i=V.create([t.main]):t.main.empty||(i=V.create([V.cursor(t.main.head)])),i?(e(di(n,i)),!0):!1};function gc(n,e){if(n.state.readOnly)return!1;let t="delete.selection",{state:i}=n,r=i.changeByRange(s=>{let{from:o,to:a}=s;if(o==a){let c=e(s);co&&(t="delete.forward",c=Gu(n,c,!0)),o=Math.min(o,c),a=Math.max(a,c)}else o=Gu(n,o,!1),a=Gu(n,a,!0);return o==a?{range:s}:{changes:{from:o,to:a},range:V.cursor(o,or(n)))i.between(e,e,(r,s)=>{re&&(e=t?s:r)});return e}const lQ=(n,e,t)=>gc(n,i=>{let r=i.from,{state:s}=n,o=s.doc.lineAt(r),a,c;if(t&&!e&&r>o.from&&rlQ(n,!1,!0),aQ=n=>lQ(n,!0,!1),cQ=(n,e)=>gc(n,t=>{let i=t.head,{state:r}=n,s=r.doc.lineAt(i),o=r.charCategorizer(i);for(let a=null;;){if(i==(e?s.to:s.from)){i==t.head&&s.number!=(e?r.doc.lines:1)&&(i+=e?1:-1);break}let c=Wt(s.text,i-s.from,e)+s.from,h=s.text.slice(Math.min(i,c)-s.from,Math.max(i,c)-s.from),f=o(h);if(a!=null&&f!=a)break;(h!=" "||i!=t.head)&&(a=f),i=c}return i}),uQ=n=>cQ(n,!1),ND=n=>cQ(n,!0),XD=n=>gc(n,e=>{let t=n.lineBlockAt(e.head).to;return e.headgc(n,e=>{let t=n.moveToLineBoundary(e,!1).head;return e.head>t?t:Math.max(0,e.head-1)}),VD=n=>gc(n,e=>{let t=n.moveToLineBoundary(e,!0).head;return e.head{if(n.readOnly)return!1;let t=n.changeByRange(i=>({changes:{from:i.from,to:i.to,insert:ze.of(["",""])},range:V.cursor(i.from)}));return e(n.update(t,{scrollIntoView:!0,userEvent:"input"})),!0},YD=({state:n,dispatch:e})=>{if(n.readOnly)return!1;let t=n.changeByRange(i=>{if(!i.empty||i.from==0||i.from==n.doc.length)return{range:i};let r=i.from,s=n.doc.lineAt(r),o=r==s.from?r-1:Wt(s.text,r-s.from,!1)+s.from,a=r==s.to?r+1:Wt(s.text,r-s.from,!0)+s.from;return{changes:{from:o,to:a,insert:n.doc.slice(r,a).append(n.doc.slice(o,r))},range:V.cursor(a)}});return t.changes.empty?!1:(e(n.update(t,{scrollIntoView:!0,userEvent:"move.character"})),!0)};function Hf(n){let e=[],t=-1;for(let i of n.selection.ranges){let r=n.doc.lineAt(i.from),s=n.doc.lineAt(i.to);if(!i.empty&&i.to==s.from&&(s=n.doc.lineAt(i.to-1)),t>=r.number){let o=e[e.length-1];o.to=s.to,o.ranges.push(i)}else e.push({from:r.from,to:s.to,ranges:[i]});t=s.number+1}return e}function hQ(n,e,t){if(n.readOnly)return!1;let i=[],r=[];for(let s of Hf(n)){if(t?s.to==n.doc.length:s.from==0)continue;let o=n.doc.lineAt(t?s.to+1:s.from-1),a=o.length+1;if(t){i.push({from:s.to,to:o.to},{from:s.from,insert:o.text+n.lineBreak});for(let c of s.ranges)r.push(V.range(Math.min(n.doc.length,c.anchor+a),Math.min(n.doc.length,c.head+a)))}else{i.push({from:o.from,to:s.from},{from:s.to,insert:n.lineBreak+o.text});for(let c of s.ranges)r.push(V.range(c.anchor-a,c.head-a))}}return i.length?(e(n.update({changes:i,scrollIntoView:!0,selection:V.create(r,n.selection.mainIndex),userEvent:"move.line"})),!0):!1}const qD=({state:n,dispatch:e})=>hQ(n,e,!1),UD=({state:n,dispatch:e})=>hQ(n,e,!0);function fQ(n,e,t){if(n.readOnly)return!1;let i=[];for(let r of Hf(n))t?i.push({from:r.from,insert:n.doc.slice(r.from,r.to)+n.lineBreak}):i.push({from:r.to,insert:n.lineBreak+n.doc.slice(r.from,r.to)});return e(n.update({changes:i,scrollIntoView:!0,userEvent:"input.copyline"})),!0}const HD=({state:n,dispatch:e})=>fQ(n,e,!1),GD=({state:n,dispatch:e})=>fQ(n,e,!0),KD=n=>{if(n.state.readOnly)return!1;let{state:e}=n,t=e.changes(Hf(e).map(({from:r,to:s})=>(r>0?r--:s{let s;if(n.lineWrapping){let o=n.lineBlockAt(r.head),a=n.coordsAtPos(r.head,r.assoc||1);a&&(s=o.bottom+n.documentTop-a.bottom+n.defaultLineHeight/2)}return n.moveVertically(r,!0,s)}).map(t);return n.dispatch({changes:t,selection:i,scrollIntoView:!0,userEvent:"delete.line"}),!0};function JD(n,e){if(/\(\)|\[\]|\{\}/.test(n.sliceDoc(e-1,e+1)))return{from:e,to:e};let t=Pt(n).resolveInner(e),i=t.childBefore(e),r=t.childAfter(e),s;return i&&r&&i.to<=e&&r.from>=e&&(s=i.type.prop(Ee.closedBy))&&s.indexOf(r.name)>-1&&n.doc.lineAt(i.to).from==n.doc.lineAt(r.from).from&&!/\S/.test(n.sliceDoc(i.to,r.from))?{from:i.to,to:r.from}:null}const jb=dQ(!1),e5=dQ(!0);function dQ(n){return({state:e,dispatch:t})=>{if(e.readOnly)return!1;let i=e.changeByRange(r=>{let{from:s,to:o}=r,a=e.doc.lineAt(s),c=!n&&s==o&&JD(e,s);n&&(s=o=(o<=a.to?a:e.doc.lineAt(o)).to);let h=new Vf(e,{simulateBreak:s,simulateDoubleBreak:!!c}),f=d0(h,s);for(f==null&&(f=tl(/^\s*/.exec(e.doc.lineAt(s).text)[0],e.tabSize));oa.from&&s{let r=[];for(let o=i.from;o<=i.to;){let a=n.doc.lineAt(o);a.number>t&&(i.empty||i.to>a.from)&&(e(a,r,i),t=a.number),o=a.to+1}let s=n.changes(r);return{changes:r,range:V.range(s.mapPos(i.anchor,1),s.mapPos(i.head,1))}})}const t5=({state:n,dispatch:e})=>{if(n.readOnly)return!1;let t=Object.create(null),i=new Vf(n,{overrideIndentation:s=>{let o=t[s];return o==null?-1:o}}),r=x0(n,(s,o,a)=>{let c=d0(i,s.from);if(c==null)return;/\S/.test(s.text)||(c=0);let h=/^\s*/.exec(s.text)[0],f=Va(n,c);(h!=f||a.fromn.readOnly?!1:(e(n.update(x0(n,(t,i)=>{i.push({from:t.from,insert:n.facet(Wf)})}),{userEvent:"input.indent"})),!0),gQ=({state:n,dispatch:e})=>n.readOnly?!1:(e(n.update(x0(n,(t,i)=>{let r=/^\s*/.exec(t.text)[0];if(!r)return;let s=tl(r,n.tabSize),o=0,a=Va(n,Math.max(0,s-lf(n)));for(;o(n.setTabFocusMode(),!0),i5=[{key:"Ctrl-b",run:W_,shift:J_,preventDefault:!0},{key:"Ctrl-f",run:V_,shift:eQ},{key:"Ctrl-p",run:q_,shift:iQ},{key:"Ctrl-n",run:U_,shift:rQ},{key:"Ctrl-a",run:SD,shift:ED},{key:"Ctrl-e",run:wD,shift:LD},{key:"Ctrl-d",run:aQ},{key:"Ctrl-h",run:fO},{key:"Ctrl-k",run:XD},{key:"Ctrl-Alt-h",run:uQ},{key:"Ctrl-o",run:FD},{key:"Ctrl-t",run:YD},{key:"Ctrl-v",run:hO}],r5=[{key:"ArrowLeft",run:W_,shift:J_,preventDefault:!0},{key:"Mod-ArrowLeft",mac:"Alt-ArrowLeft",run:dD,shift:_D,preventDefault:!0},{mac:"Cmd-ArrowLeft",run:vD,shift:RD,preventDefault:!0},{key:"ArrowRight",run:V_,shift:eQ,preventDefault:!0},{key:"Mod-ArrowRight",mac:"Alt-ArrowRight",run:pD,shift:QD,preventDefault:!0},{mac:"Cmd-ArrowRight",run:bD,shift:AD,preventDefault:!0},{key:"ArrowUp",run:q_,shift:iQ,preventDefault:!0},{mac:"Cmd-ArrowUp",run:Db,shift:Zb},{mac:"Ctrl-ArrowUp",run:Ab,shift:Eb},{key:"ArrowDown",run:U_,shift:rQ,preventDefault:!0},{mac:"Cmd-ArrowDown",run:zb,shift:Ib},{mac:"Ctrl-ArrowDown",run:hO,shift:Lb},{key:"PageUp",run:Ab,shift:Eb},{key:"PageDown",run:hO,shift:Lb},{key:"Home",run:xD,shift:MD,preventDefault:!0},{key:"Mod-Home",run:Db,shift:Zb},{key:"End",run:yD,shift:$D,preventDefault:!0},{key:"Mod-End",run:zb,shift:Ib},{key:"Enter",run:jb,shift:jb},{key:"Mod-a",run:DD},{key:"Backspace",run:fO,shift:fO,preventDefault:!0},{key:"Delete",run:aQ,preventDefault:!0},{key:"Mod-Backspace",mac:"Alt-Backspace",run:uQ,preventDefault:!0},{key:"Mod-Delete",mac:"Alt-Delete",run:ND,preventDefault:!0},{mac:"Mod-Backspace",run:WD,preventDefault:!0},{mac:"Mod-Delete",run:VD,preventDefault:!0}].concat(i5.map(n=>({mac:n.key,run:n.run,shift:n.shift}))),s5=[{key:"Alt-ArrowLeft",mac:"Ctrl-ArrowLeft",run:mD,shift:CD},{key:"Alt-ArrowRight",mac:"Ctrl-ArrowRight",run:OD,shift:TD},{key:"Alt-ArrowUp",run:qD},{key:"Shift-Alt-ArrowUp",run:HD},{key:"Alt-ArrowDown",run:UD},{key:"Shift-Alt-ArrowDown",run:GD},{key:"Mod-Alt-ArrowUp",run:ID},{key:"Mod-Alt-ArrowDown",run:jD},{key:"Escape",run:BD},{key:"Mod-Enter",run:e5},{key:"Alt-l",mac:"Ctrl-l",run:zD},{key:"Mod-i",run:ZD,preventDefault:!0},{key:"Mod-[",run:gQ},{key:"Mod-]",run:pQ},{key:"Mod-Alt-\\",run:t5},{key:"Shift-Mod-k",run:KD},{key:"Shift-Mod-\\",run:PD},{key:"Mod-/",run:qL},{key:"Alt-A",run:HL},{key:"Ctrl-m",mac:"Shift-Alt-m",run:n5}].concat(r5),o5={key:"Tab",run:pQ,shift:gQ},Bb=typeof String.prototype.normalize=="function"?n=>n.normalize("NFKD"):n=>n;class Uo{constructor(e,t,i=0,r=e.length,s,o){this.test=o,this.value={from:0,to:0},this.done=!1,this.matches=[],this.buffer="",this.bufferPos=0,this.iter=e.iterRange(i,r),this.bufferStart=i,this.normalize=s?a=>s(Bb(a)):Bb,this.query=this.normalize(t)}peek(){if(this.bufferPos==this.buffer.length){if(this.bufferStart+=this.buffer.length,this.iter.next(),this.iter.done)return-1;this.bufferPos=0,this.buffer=this.iter.value}return yn(this.buffer,this.bufferPos)}next(){for(;this.matches.length;)this.matches.pop();return this.nextOverlapping()}nextOverlapping(){for(;;){let e=this.peek();if(e<0)return this.done=!0,this;let t=FO(e),i=this.bufferStart+this.bufferPos;this.bufferPos+=$i(e);let r=this.normalize(t);if(r.length)for(let s=0,o=i;;s++){let a=r.charCodeAt(s),c=this.match(a,o,this.bufferPos+this.bufferStart);if(s==r.length-1){if(c)return this.value=c,this;break}o==i&&sthis.to&&(this.curLine=this.curLine.slice(0,this.to-this.curLineStart)),this.iter.next())}nextLine(){this.curLineStart=this.curLineStart+this.curLine.length+1,this.curLineStart>this.to?this.curLine="":this.getLine(0)}next(){for(let e=this.matchPos-this.curLineStart;;){this.re.lastIndex=e;let t=this.matchPos<=this.to&&this.re.exec(this.curLine);if(t){let i=this.curLineStart+t.index,r=i+t[0].length;if(this.matchPos=hf(this.text,r+(i==r?1:0)),i==this.curLineStart+this.curLine.length&&this.nextLine(),(ithis.value.to)&&(!this.test||this.test(i,r,t)))return this.value={from:i,to:r,match:t},this;e=this.matchPos-this.curLineStart}else if(this.curLineStart+this.curLine.length=i||r.to<=t){let a=new Eo(t,e.sliceString(t,i));return Rg.set(e,a),a}if(r.from==t&&r.to==i)return r;let{text:s,from:o}=r;return o>t&&(s=e.sliceString(t,o)+s,o=t),r.to=this.to?this.to:this.text.lineAt(e).to}next(){for(;;){let e=this.re.lastIndex=this.matchPos-this.flat.from,t=this.re.exec(this.flat.text);if(t&&!t[0]&&t.index==e&&(this.re.lastIndex=e+1,t=this.re.exec(this.flat.text)),t){let i=this.flat.from+t.index,r=i+t[0].length;if((this.flat.to>=this.to||t.index+t[0].length<=this.flat.text.length-10)&&(!this.test||this.test(i,r,t)))return this.value={from:i,to:r,match:t},this.matchPos=hf(this.text,r+(i==r?1:0)),this}if(this.flat.to==this.to)return this.done=!0,this;this.flat=Eo.get(this.text,this.flat.from,this.chunkEnd(this.flat.from+this.flat.text.length*2))}}}typeof Symbol<"u"&&(OQ.prototype[Symbol.iterator]=yQ.prototype[Symbol.iterator]=function(){return this});function l5(n){try{return new RegExp(n,v0),!0}catch{return!1}}function hf(n,e){if(e>=n.length)return e;let t=n.lineAt(e),i;for(;e=56320&&i<57344;)e++;return e}function dO(n){let e=String(n.state.doc.lineAt(n.state.selection.main.head).number),t=He("input",{class:"cm-textfield",name:"line",value:e}),i=He("form",{class:"cm-gotoLine",onkeydown:s=>{s.keyCode==27?(s.preventDefault(),n.dispatch({effects:ka.of(!1)}),n.focus()):s.keyCode==13&&(s.preventDefault(),r())},onsubmit:s=>{s.preventDefault(),r()}},He("label",n.state.phrase("Go to line"),": ",t)," ",He("button",{class:"cm-button",type:"submit"},n.state.phrase("go")),He("button",{name:"close",onclick:()=>{n.dispatch({effects:ka.of(!1)}),n.focus()},"aria-label":n.state.phrase("close"),type:"button"},["×"]));function r(){let s=/^([+-])?(\d+)?(:\d+)?(%)?$/.exec(t.value);if(!s)return;let{state:o}=n,a=o.doc.lineAt(o.selection.main.head),[,c,h,f,p]=s,m=f?+f.slice(1):0,y=h?+h:a.number;if(h&&p){let S=y/100;c&&(S=S*(c=="-"?-1:1)+a.number/o.doc.lines),y=Math.round(o.doc.lines*S)}else h&&c&&(y=y*(c=="-"?-1:1)+a.number);let v=o.doc.line(Math.max(1,Math.min(o.doc.lines,y))),b=V.cursor(v.from+Math.max(0,Math.min(m,v.length)));n.dispatch({effects:[ka.of(!1),ce.scrollIntoView(b.from,{y:"center"})],selection:b}),n.focus()}return{dom:i}}const ka=Te.define(),Nb=zt.define({create(){return!0},update(n,e){for(let t of e.effects)t.is(ka)&&(n=t.value);return n},provide:n=>Na.from(n,e=>e?dO:null)}),a5=n=>{let e=Ba(n,dO);if(!e){let t=[ka.of(!0)];n.state.field(Nb,!1)==null&&t.push(Te.appendConfig.of([Nb,c5])),n.dispatch({effects:t}),e=Ba(n,dO)}return e&&e.dom.querySelector("input").select(),!0},c5=ce.baseTheme({".cm-panel.cm-gotoLine":{padding:"2px 6px 4px",position:"relative","& label":{fontSize:"80%"},"& [name=close]":{position:"absolute",top:"0",bottom:"0",right:"4px",backgroundColor:"inherit",border:"none",font:"inherit",padding:"0"}}}),u5={highlightWordAroundCursor:!1,minSelectionLength:1,maxMatches:100,wholeWords:!1},h5=fe.define({combine(n){return Zi(n,u5,{highlightWordAroundCursor:(e,t)=>e||t,minSelectionLength:Math.min,maxMatches:Math.min})}});function f5(n){return[O5,m5]}const d5=Pe.mark({class:"cm-selectionMatch"}),p5=Pe.mark({class:"cm-selectionMatch cm-selectionMatch-main"});function Xb(n,e,t,i){return(t==0||n(e.sliceDoc(t-1,t))!=at.Word)&&(i==e.doc.length||n(e.sliceDoc(i,i+1))!=at.Word)}function g5(n,e,t,i){return n(e.sliceDoc(t,t+1))==at.Word&&n(e.sliceDoc(i-1,i))==at.Word}const m5=kt.fromClass(class{constructor(n){this.decorations=this.getDeco(n)}update(n){(n.selectionSet||n.docChanged||n.viewportChanged)&&(this.decorations=this.getDeco(n.view))}getDeco(n){let e=n.state.facet(h5),{state:t}=n,i=t.selection;if(i.ranges.length>1)return Pe.none;let r=i.main,s,o=null;if(r.empty){if(!e.highlightWordAroundCursor)return Pe.none;let c=t.wordAt(r.head);if(!c)return Pe.none;o=t.charCategorizer(r.head),s=t.sliceDoc(c.from,c.to)}else{let c=r.to-r.from;if(c200)return Pe.none;if(e.wholeWords){if(s=t.sliceDoc(r.from,r.to),o=t.charCategorizer(r.head),!(Xb(o,t,r.from,r.to)&&g5(o,t,r.from,r.to)))return Pe.none}else if(s=t.sliceDoc(r.from,r.to),!s)return Pe.none}let a=[];for(let c of n.visibleRanges){let h=new Uo(t.doc,s,c.from,c.to);for(;!h.next().done;){let{from:f,to:p}=h.value;if((!o||Xb(o,t,f,p))&&(r.empty&&f<=r.from&&p>=r.to?a.push(p5.range(f,p)):(f>=r.to||p<=r.from)&&a.push(d5.range(f,p)),a.length>e.maxMatches))return Pe.none}}return Pe.set(a)}},{decorations:n=>n.decorations}),O5=ce.baseTheme({".cm-selectionMatch":{backgroundColor:"#99ff7780"},".cm-searchMatch .cm-selectionMatch":{backgroundColor:"transparent"}}),y5=({state:n,dispatch:e})=>{let{selection:t}=n,i=V.create(t.ranges.map(r=>n.wordAt(r.head)||V.cursor(r.head)),t.mainIndex);return i.eq(t)?!1:(e(n.update({selection:i})),!0)};function x5(n,e){let{main:t,ranges:i}=n.selection,r=n.wordAt(t.head),s=r&&r.from==t.from&&r.to==t.to;for(let o=!1,a=new Uo(n.doc,e,i[i.length-1].to);;)if(a.next(),a.done){if(o)return null;a=new Uo(n.doc,e,0,Math.max(0,i[i.length-1].from-1)),o=!0}else{if(o&&i.some(c=>c.from==a.value.from))continue;if(s){let c=n.wordAt(a.value.from);if(!c||c.from!=a.value.from||c.to!=a.value.to)continue}return a.value}}const v5=({state:n,dispatch:e})=>{let{ranges:t}=n.selection;if(t.some(s=>s.from===s.to))return y5({state:n,dispatch:e});let i=n.sliceDoc(t[0].from,t[0].to);if(n.selection.ranges.some(s=>n.sliceDoc(s.from,s.to)!=i))return!1;let r=x5(n,i);return r?(e(n.update({selection:n.selection.addRange(V.range(r.from,r.to),!1),effects:ce.scrollIntoView(r.to)})),!0):!1},il=fe.define({combine(n){return Zi(n,{top:!1,caseSensitive:!1,literal:!1,regexp:!1,wholeWord:!1,createPanel:e=>new R5(e),scrollToMatch:e=>ce.scrollIntoView(e)})}});class xQ{constructor(e){this.search=e.search,this.caseSensitive=!!e.caseSensitive,this.literal=!!e.literal,this.regexp=!!e.regexp,this.replace=e.replace||"",this.valid=!!this.search&&(!this.regexp||l5(this.search)),this.unquoted=this.unquote(this.search),this.wholeWord=!!e.wholeWord}unquote(e){return this.literal?e:e.replace(/\\([nrt\\])/g,(t,i)=>i=="n"?` -`:i=="r"?"\r":i=="t"?" ":"\\")}eq(e){return this.search==e.search&&this.replace==e.replace&&this.caseSensitive==e.caseSensitive&&this.regexp==e.regexp&&this.wholeWord==e.wholeWord}create(){return this.regexp?new k5(this):new S5(this)}getCursor(e,t=0,i){let r=e.doc?e:Ie.create({doc:e});return i==null&&(i=r.doc.length),this.regexp?ko(this,r,t,i):wo(this,r,t,i)}}let vQ=class{constructor(e){this.spec=e}};function wo(n,e,t,i){return new Uo(e.doc,n.unquoted,t,i,n.caseSensitive?void 0:r=>r.toLowerCase(),n.wholeWord?b5(e.doc,e.charCategorizer(e.selection.main.head)):void 0)}function b5(n,e){return(t,i,r,s)=>((s>t||s+r.length=t)return null;r.push(i.value)}return r}highlight(e,t,i,r){let s=wo(this.spec,e,Math.max(0,t-this.spec.unquoted.length),Math.min(i+this.spec.unquoted.length,e.doc.length));for(;!s.next().done;)r(s.value.from,s.value.to)}}function ko(n,e,t,i){return new OQ(e.doc,n.search,{ignoreCase:!n.caseSensitive,test:n.wholeWord?w5(e.charCategorizer(e.selection.main.head)):void 0},t,i)}function ff(n,e){return n.slice(Wt(n,e,!1),e)}function df(n,e){return n.slice(e,Wt(n,e))}function w5(n){return(e,t,i)=>!i[0].length||(n(ff(i.input,i.index))!=at.Word||n(df(i.input,i.index))!=at.Word)&&(n(df(i.input,i.index+i[0].length))!=at.Word||n(ff(i.input,i.index+i[0].length))!=at.Word)}class k5 extends vQ{nextMatch(e,t,i){let r=ko(this.spec,e,i,e.doc.length).next();return r.done&&(r=ko(this.spec,e,0,t).next()),r.done?null:r.value}prevMatchInRange(e,t,i){for(let r=1;;r++){let s=Math.max(t,i-r*1e4),o=ko(this.spec,e,s,i),a=null;for(;!o.next().done;)a=o.value;if(a&&(s==t||a.from>s+10))return a;if(s==t)return null}}prevMatch(e,t,i){return this.prevMatchInRange(e,0,t)||this.prevMatchInRange(e,i,e.doc.length)}getReplacement(e){return this.spec.unquote(this.spec.replace).replace(/\$([$&]|\d+)/g,(t,i)=>{if(i=="&")return e.match[0];if(i=="$")return"$";for(let r=i.length;r>0;r--){let s=+i.slice(0,r);if(s>0&&s=t)return null;r.push(i.value)}return r}highlight(e,t,i,r){let s=ko(this.spec,e,Math.max(0,t-250),Math.min(i+250,e.doc.length));for(;!s.next().done;)r(s.value.from,s.value.to)}}const Fa=Te.define(),b0=Te.define(),Wr=zt.define({create(n){return new Ag(pO(n).create(),null)},update(n,e){for(let t of e.effects)t.is(Fa)?n=new Ag(t.value.create(),n.panel):t.is(b0)&&(n=new Ag(n.query,t.value?S0:null));return n},provide:n=>Na.from(n,e=>e.panel)});class Ag{constructor(e,t){this.query=e,this.panel=t}}const P5=Pe.mark({class:"cm-searchMatch"}),_5=Pe.mark({class:"cm-searchMatch cm-searchMatch-selected"}),Q5=kt.fromClass(class{constructor(n){this.view=n,this.decorations=this.highlight(n.state.field(Wr))}update(n){let e=n.state.field(Wr);(e!=n.startState.field(Wr)||n.docChanged||n.selectionSet||n.viewportChanged)&&(this.decorations=this.highlight(e))}highlight({query:n,panel:e}){if(!e||!n.spec.valid)return Pe.none;let{view:t}=this,i=new sr;for(let r=0,s=t.visibleRanges,o=s.length;rs[r+1].from-500;)c=s[++r].to;n.highlight(t.state,a,c,(h,f)=>{let p=t.state.selection.ranges.some(m=>m.from==h&&m.to==f);i.add(h,f,p?_5:P5)})}return i.finish()}},{decorations:n=>n.decorations});function mc(n){return e=>{let t=e.state.field(Wr,!1);return t&&t.query.spec.valid?n(e,t):wQ(e)}}const pf=mc((n,{query:e})=>{let{to:t}=n.state.selection.main,i=e.nextMatch(n.state,t,t);if(!i)return!1;let r=V.single(i.from,i.to),s=n.state.facet(il);return n.dispatch({selection:r,effects:[w0(n,i),s.scrollToMatch(r.main,n)],userEvent:"select.search"}),SQ(n),!0}),gf=mc((n,{query:e})=>{let{state:t}=n,{from:i}=t.selection.main,r=e.prevMatch(t,i,i);if(!r)return!1;let s=V.single(r.from,r.to),o=n.state.facet(il);return n.dispatch({selection:s,effects:[w0(n,r),o.scrollToMatch(s.main,n)],userEvent:"select.search"}),SQ(n),!0}),C5=mc((n,{query:e})=>{let t=e.matchAll(n.state,1e3);return!t||!t.length?!1:(n.dispatch({selection:V.create(t.map(i=>V.range(i.from,i.to))),userEvent:"select.search.matches"}),!0)}),T5=({state:n,dispatch:e})=>{let t=n.selection;if(t.ranges.length>1||t.main.empty)return!1;let{from:i,to:r}=t.main,s=[],o=0;for(let a=new Uo(n.doc,n.sliceDoc(i,r));!a.next().done;){if(s.length>1e3)return!1;a.value.from==i&&(o=s.length),s.push(V.range(a.value.from,a.value.to))}return e(n.update({selection:V.create(s,o),userEvent:"select.search.matches"})),!0},Wb=mc((n,{query:e})=>{let{state:t}=n,{from:i,to:r}=t.selection.main;if(t.readOnly)return!1;let s=e.nextMatch(t,i,i);if(!s)return!1;let o=s,a=[],c,h,f=[];o.from==i&&o.to==r&&(h=t.toText(e.getReplacement(o)),a.push({from:o.from,to:o.to,insert:h}),o=e.nextMatch(t,o.from,o.to),f.push(ce.announce.of(t.phrase("replaced match on line $",t.doc.lineAt(i).number)+".")));let p=n.state.changes(a);return o&&(c=V.single(o.from,o.to).map(p),f.push(w0(n,o)),f.push(t.facet(il).scrollToMatch(c.main,n))),n.dispatch({changes:p,selection:c,effects:f,userEvent:"input.replace"}),!0}),$5=mc((n,{query:e})=>{if(n.state.readOnly)return!1;let t=e.matchAll(n.state,1e9).map(r=>{let{from:s,to:o}=r;return{from:s,to:o,insert:e.getReplacement(r)}});if(!t.length)return!1;let i=n.state.phrase("replaced $ matches",t.length)+".";return n.dispatch({changes:t,effects:ce.announce.of(i),userEvent:"input.replace.all"}),!0});function S0(n){return n.state.facet(il).createPanel(n)}function pO(n,e){var t,i,r,s,o;let a=n.selection.main,c=a.empty||a.to>a.from+100?"":n.sliceDoc(a.from,a.to);if(e&&!c)return e;let h=n.facet(il);return new xQ({search:((t=e==null?void 0:e.literal)!==null&&t!==void 0?t:h.literal)?c:c.replace(/\n/g,"\\n"),caseSensitive:(i=e==null?void 0:e.caseSensitive)!==null&&i!==void 0?i:h.caseSensitive,literal:(r=e==null?void 0:e.literal)!==null&&r!==void 0?r:h.literal,regexp:(s=e==null?void 0:e.regexp)!==null&&s!==void 0?s:h.regexp,wholeWord:(o=e==null?void 0:e.wholeWord)!==null&&o!==void 0?o:h.wholeWord})}function bQ(n){let e=Ba(n,S0);return e&&e.dom.querySelector("[main-field]")}function SQ(n){let e=bQ(n);e&&e==n.root.activeElement&&e.select()}const wQ=n=>{let e=n.state.field(Wr,!1);if(e&&e.panel){let t=bQ(n);if(t&&t!=n.root.activeElement){let i=pO(n.state,e.query.spec);i.valid&&n.dispatch({effects:Fa.of(i)}),t.focus(),t.select()}}else n.dispatch({effects:[b0.of(!0),e?Fa.of(pO(n.state,e.query.spec)):Te.appendConfig.of(E5)]});return!0},kQ=n=>{let e=n.state.field(Wr,!1);if(!e||!e.panel)return!1;let t=Ba(n,S0);return t&&t.dom.contains(n.root.activeElement)&&n.focus(),n.dispatch({effects:b0.of(!1)}),!0},M5=[{key:"Mod-f",run:wQ,scope:"editor search-panel"},{key:"F3",run:pf,shift:gf,scope:"editor search-panel",preventDefault:!0},{key:"Mod-g",run:pf,shift:gf,scope:"editor search-panel",preventDefault:!0},{key:"Escape",run:kQ,scope:"editor search-panel"},{key:"Mod-Shift-l",run:T5},{key:"Mod-Alt-g",run:a5},{key:"Mod-d",run:v5,preventDefault:!0}];class R5{constructor(e){this.view=e;let t=this.query=e.state.field(Wr).query.spec;this.commit=this.commit.bind(this),this.searchField=He("input",{value:t.search,placeholder:Tn(e,"Find"),"aria-label":Tn(e,"Find"),class:"cm-textfield",name:"search",form:"","main-field":"true",onchange:this.commit,onkeyup:this.commit}),this.replaceField=He("input",{value:t.replace,placeholder:Tn(e,"Replace"),"aria-label":Tn(e,"Replace"),class:"cm-textfield",name:"replace",form:"",onchange:this.commit,onkeyup:this.commit}),this.caseField=He("input",{type:"checkbox",name:"case",form:"",checked:t.caseSensitive,onchange:this.commit}),this.reField=He("input",{type:"checkbox",name:"re",form:"",checked:t.regexp,onchange:this.commit}),this.wordField=He("input",{type:"checkbox",name:"word",form:"",checked:t.wholeWord,onchange:this.commit});function i(r,s,o){return He("button",{class:"cm-button",name:r,onclick:s,type:"button"},o)}this.dom=He("div",{onkeydown:r=>this.keydown(r),class:"cm-search"},[this.searchField,i("next",()=>pf(e),[Tn(e,"next")]),i("prev",()=>gf(e),[Tn(e,"previous")]),i("select",()=>C5(e),[Tn(e,"all")]),He("label",null,[this.caseField,Tn(e,"match case")]),He("label",null,[this.reField,Tn(e,"regexp")]),He("label",null,[this.wordField,Tn(e,"by word")]),...e.state.readOnly?[]:[He("br"),this.replaceField,i("replace",()=>Wb(e),[Tn(e,"replace")]),i("replaceAll",()=>$5(e),[Tn(e,"replace all")])],He("button",{name:"close",onclick:()=>kQ(e),"aria-label":Tn(e,"close"),type:"button"},["×"])])}commit(){let e=new xQ({search:this.searchField.value,caseSensitive:this.caseField.checked,regexp:this.reField.checked,wholeWord:this.wordField.checked,replace:this.replaceField.value});e.eq(this.query)||(this.query=e,this.view.dispatch({effects:Fa.of(e)}))}keydown(e){BA(this.view,e,"search-panel")?e.preventDefault():e.keyCode==13&&e.target==this.searchField?(e.preventDefault(),(e.shiftKey?gf:pf)(this.view)):e.keyCode==13&&e.target==this.replaceField&&(e.preventDefault(),Wb(this.view))}update(e){for(let t of e.transactions)for(let i of t.effects)i.is(Fa)&&!i.value.eq(this.query)&&this.setQuery(i.value)}setQuery(e){this.query=e,this.searchField.value=e.search,this.replaceField.value=e.replace,this.caseField.checked=e.caseSensitive,this.reField.checked=e.regexp,this.wordField.checked=e.wholeWord}mount(){this.searchField.select()}get pos(){return 80}get top(){return this.view.state.facet(il).top}}function Tn(n,e){return n.state.phrase(e)}const Ku=30,Ju=/[\s\.,:;?!]/;function w0(n,{from:e,to:t}){let i=n.state.doc.lineAt(e),r=n.state.doc.lineAt(t).to,s=Math.max(i.from,e-Ku),o=Math.min(r,t+Ku),a=n.state.sliceDoc(s,o);if(s!=i.from){for(let c=0;ca.length-Ku;c--)if(!Ju.test(a[c-1])&&Ju.test(a[c])){a=a.slice(0,c);break}}return ce.announce.of(`${n.state.phrase("current match")}. ${a} ${n.state.phrase("on line")} ${i.number}.`)}const A5=ce.baseTheme({".cm-panel.cm-search":{padding:"2px 6px 4px",position:"relative","& [name=close]":{position:"absolute",top:"0",right:"4px",backgroundColor:"inherit",border:"none",font:"inherit",padding:0,margin:0},"& input, & button, & label":{margin:".2em .6em .2em 0"},"& input[type=checkbox]":{marginRight:".2em"},"& label":{fontSize:"80%",whiteSpace:"pre"}},"&light .cm-searchMatch":{backgroundColor:"#ffff0054"},"&dark .cm-searchMatch":{backgroundColor:"#00ffff8a"},"&light .cm-searchMatch-selected":{backgroundColor:"#ff6a0054"},"&dark .cm-searchMatch-selected":{backgroundColor:"#ff00ff8a"}}),E5=[Wr,Jr.low(Q5),A5];class PQ{constructor(e,t,i,r){this.state=e,this.pos=t,this.explicit=i,this.view=r,this.abortListeners=[],this.abortOnDocChange=!1}tokenBefore(e){let t=Pt(this.state).resolveInner(this.pos,-1);for(;t&&e.indexOf(t.name)<0;)t=t.parent;return t?{from:t.from,to:this.pos,text:this.state.sliceDoc(t.from,this.pos),type:t.type}:null}matchBefore(e){let t=this.state.doc.lineAt(this.pos),i=Math.max(t.from,this.pos-250),r=t.text.slice(i-t.from,this.pos-t.from),s=r.search(QQ(e,!1));return s<0?null:{from:i+s,to:this.pos,text:r.slice(s)}}get aborted(){return this.abortListeners==null}addEventListener(e,t,i){e=="abort"&&this.abortListeners&&(this.abortListeners.push(t),i&&i.onDocChange&&(this.abortOnDocChange=!0))}}function Vb(n){let e=Object.keys(n).join(""),t=/\w/.test(e);return t&&(e=e.replace(/\w/g,"")),`[${t?"\\w":""}${e.replace(/[^\w\s]/g,"\\$&")}]`}function L5(n){let e=Object.create(null),t=Object.create(null);for(let{label:r}of n){e[r[0]]=!0;for(let s=1;stypeof r=="string"?{label:r}:r),[t,i]=e.every(r=>/^\w+$/.test(r.label))?[/\w*$/,/\w+$/]:L5(e);return r=>{let s=r.matchBefore(i);return s||r.explicit?{from:s?s.from:r.pos,options:e,validFor:t}:null}}function _Q(n,e){return t=>{for(let i=Pt(t.state).resolveInner(t.pos,-1);i;i=i.parent){if(n.indexOf(i.name)>-1)return null;if(i.type.isTop)break}return e(t)}}class Fb{constructor(e,t,i,r){this.completion=e,this.source=t,this.match=i,this.score=r}}function As(n){return n.selection.main.from}function QQ(n,e){var t;let{source:i}=n,r=e&&i[0]!="^",s=i[i.length-1]!="$";return!r&&!s?n:new RegExp(`${r?"^":""}(?:${i})${s?"$":""}`,(t=n.flags)!==null&&t!==void 0?t:n.ignoreCase?"i":"")}const P0=ar.define();function D5(n,e,t,i){let{main:r}=n.selection,s=t-r.from,o=i-r.from;return{...n.changeByRange(a=>{if(a!=r&&t!=i&&n.sliceDoc(a.from+s,a.from+o)!=n.sliceDoc(t,i))return{range:a};let c=n.toText(e);return{changes:{from:a.from+s,to:i==r.from?a.to:a.from+o,insert:c},range:V.cursor(a.from+s+c.length)}}),scrollIntoView:!0,userEvent:"input.complete"}}const Yb=new WeakMap;function z5(n){if(!Array.isArray(n))return n;let e=Yb.get(n);return e||Yb.set(n,e=k0(n)),e}const mf=Te.define(),Ya=Te.define();class Z5{constructor(e){this.pattern=e,this.chars=[],this.folded=[],this.any=[],this.precise=[],this.byWord=[],this.score=0,this.matched=[];for(let t=0;t=48&&Q<=57||Q>=97&&Q<=122?2:Q>=65&&Q<=90?1:0:($=FO(Q))!=$.toLowerCase()?1:$!=$.toUpperCase()?2:0;(!C||M==1&&S||P==0&&M!=0)&&(t[p]==Q||i[p]==Q&&(m=!0)?o[p++]=C:o.length&&(w=!1)),P=M,C+=$i(Q)}return p==c&&o[0]==0&&w?this.result(-100+(m?-200:0),o,e):y==c&&v==0?this.ret(-200-e.length+(b==e.length?0:-100),[0,b]):a>-1?this.ret(-700-e.length,[a,a+this.pattern.length]):y==c?this.ret(-900-e.length,[v,b]):p==c?this.result(-100+(m?-200:0)+-700+(w?0:-1100),o,e):t.length==2?null:this.result((r[0]?-700:0)+-200+-1100,r,e)}result(e,t,i){let r=[],s=0;for(let o of t){let a=o+(this.astral?$i(yn(i,o)):1);s&&r[s-1]==o?r[s-1]=a:(r[s++]=o,r[s++]=a)}return this.ret(e-i.length,r)}}class I5{constructor(e){this.pattern=e,this.matched=[],this.score=0,this.folded=e.toLowerCase()}match(e){if(e.length!1,activateOnTypingDelay:100,selectOnOpen:!0,override:null,closeOnBlur:!0,maxRenderedOptions:100,defaultKeymap:!0,tooltipClass:()=>"",optionClass:()=>"",aboveCursor:!1,icons:!0,addToOptions:[],positionInfo:j5,filterStrict:!1,compareCompletions:(e,t)=>e.label.localeCompare(t.label),interactionDelay:75,updateSyncTime:100},{defaultKeymap:(e,t)=>e&&t,closeOnBlur:(e,t)=>e&&t,icons:(e,t)=>e&&t,tooltipClass:(e,t)=>i=>qb(e(i),t(i)),optionClass:(e,t)=>i=>qb(e(i),t(i)),addToOptions:(e,t)=>e.concat(t),filterStrict:(e,t)=>e||t})}});function qb(n,e){return n?e?n+" "+e:n:e}function j5(n,e,t,i,r,s){let o=n.textDirection==st.RTL,a=o,c=!1,h="top",f,p,m=e.left-r.left,y=r.right-e.right,v=i.right-i.left,b=i.bottom-i.top;if(a&&m=b||C>e.top?f=t.bottom-e.top:(h="bottom",f=e.bottom-t.top)}let S=(e.bottom-e.top)/s.offsetHeight,w=(e.right-e.left)/s.offsetWidth;return{style:`${h}: ${f/S}px; max-width: ${p/w}px`,class:"cm-completionInfo-"+(c?o?"left-narrow":"right-narrow":a?"left":"right")}}function B5(n){let e=n.addToOptions.slice();return n.icons&&e.push({render(t){let i=document.createElement("div");return i.classList.add("cm-completionIcon"),t.type&&i.classList.add(...t.type.split(/\s+/g).map(r=>"cm-completionIcon-"+r)),i.setAttribute("aria-hidden","true"),i},position:20}),e.push({render(t,i,r,s){let o=document.createElement("span");o.className="cm-completionLabel";let a=t.displayLabel||t.label,c=0;for(let h=0;hc&&o.appendChild(document.createTextNode(a.slice(c,f)));let m=o.appendChild(document.createElement("span"));m.appendChild(document.createTextNode(a.slice(f,p))),m.className="cm-completionMatchedText",c=p}return ct.position-i.position).map(t=>t.render)}function Eg(n,e,t){if(n<=t)return{from:0,to:n};if(e<0&&(e=0),e<=n>>1){let r=Math.floor(e/t);return{from:r*t,to:(r+1)*t}}let i=Math.floor((n-e)/t);return{from:n-(i+1)*t,to:n-i*t}}class N5{constructor(e,t,i){this.view=e,this.stateField=t,this.applyCompletion=i,this.info=null,this.infoDestroy=null,this.placeInfoReq={read:()=>this.measureInfo(),write:c=>this.placeInfo(c),key:this},this.space=null,this.currentClass="";let r=e.state.field(t),{options:s,selected:o}=r.open,a=e.state.facet(Lt);this.optionContent=B5(a),this.optionClass=a.optionClass,this.tooltipClass=a.tooltipClass,this.range=Eg(s.length,o,a.maxRenderedOptions),this.dom=document.createElement("div"),this.dom.className="cm-tooltip-autocomplete",this.updateTooltipClass(e.state),this.dom.addEventListener("mousedown",c=>{let{options:h}=e.state.field(t).open;for(let f=c.target,p;f&&f!=this.dom;f=f.parentNode)if(f.nodeName=="LI"&&(p=/-(\d+)$/.exec(f.id))&&+p[1]{let h=e.state.field(this.stateField,!1);h&&h.tooltip&&e.state.facet(Lt).closeOnBlur&&c.relatedTarget!=e.contentDOM&&e.dispatch({effects:Ya.of(null)})}),this.showOptions(s,r.id)}mount(){this.updateSel()}showOptions(e,t){this.list&&this.list.remove(),this.list=this.dom.appendChild(this.createListBox(e,t,this.range)),this.list.addEventListener("scroll",()=>{this.info&&this.view.requestMeasure(this.placeInfoReq)})}update(e){var t;let i=e.state.field(this.stateField),r=e.startState.field(this.stateField);if(this.updateTooltipClass(e.state),i!=r){let{options:s,selected:o,disabled:a}=i.open;(!r.open||r.open.options!=s)&&(this.range=Eg(s.length,o,e.state.facet(Lt).maxRenderedOptions),this.showOptions(s,i.id)),this.updateSel(),a!=((t=r.open)===null||t===void 0?void 0:t.disabled)&&this.dom.classList.toggle("cm-tooltip-autocomplete-disabled",!!a)}}updateTooltipClass(e){let t=this.tooltipClass(e);if(t!=this.currentClass){for(let i of this.currentClass.split(" "))i&&this.dom.classList.remove(i);for(let i of t.split(" "))i&&this.dom.classList.add(i);this.currentClass=t}}positioned(e){this.space=e,this.info&&this.view.requestMeasure(this.placeInfoReq)}updateSel(){let e=this.view.state.field(this.stateField),t=e.open;if((t.selected>-1&&t.selected=this.range.to)&&(this.range=Eg(t.options.length,t.selected,this.view.state.facet(Lt).maxRenderedOptions),this.showOptions(t.options,e.id)),this.updateSelectedOption(t.selected)){this.destroyInfo();let{completion:i}=t.options[t.selected],{info:r}=i;if(!r)return;let s=typeof r=="string"?document.createTextNode(r):r(i);if(!s)return;"then"in s?s.then(o=>{o&&this.view.state.field(this.stateField,!1)==e&&this.addInfoPane(o,i)}).catch(o=>bn(this.view.state,o,"completion info")):this.addInfoPane(s,i)}}addInfoPane(e,t){this.destroyInfo();let i=this.info=document.createElement("div");if(i.className="cm-tooltip cm-completionInfo",e.nodeType!=null)i.appendChild(e),this.infoDestroy=null;else{let{dom:r,destroy:s}=e;i.appendChild(r),this.infoDestroy=s||null}this.dom.appendChild(i),this.view.requestMeasure(this.placeInfoReq)}updateSelectedOption(e){let t=null;for(let i=this.list.firstChild,r=this.range.from;i;i=i.nextSibling,r++)i.nodeName!="LI"||!i.id?r--:r==e?i.hasAttribute("aria-selected")||(i.setAttribute("aria-selected","true"),t=i):i.hasAttribute("aria-selected")&&i.removeAttribute("aria-selected");return t&&W5(this.list,t),t}measureInfo(){let e=this.dom.querySelector("[aria-selected]");if(!e||!this.info)return null;let t=this.dom.getBoundingClientRect(),i=this.info.getBoundingClientRect(),r=e.getBoundingClientRect(),s=this.space;if(!s){let o=this.dom.ownerDocument.documentElement;s={left:0,top:0,right:o.clientWidth,bottom:o.clientHeight}}return r.top>Math.min(s.bottom,t.bottom)-10||r.bottom{o.target==r&&o.preventDefault()});let s=null;for(let o=i.from;oi.from||i.from==0))if(s=m,typeof h!="string"&&h.header)r.appendChild(h.header(h));else{let y=r.appendChild(document.createElement("completion-section"));y.textContent=m}}const f=r.appendChild(document.createElement("li"));f.id=t+"-"+o,f.setAttribute("role","option");let p=this.optionClass(a);p&&(f.className=p);for(let m of this.optionContent){let y=m(a,this.view.state,this.view,c);y&&f.appendChild(y)}}return i.from&&r.classList.add("cm-completionListIncompleteTop"),i.tonew N5(t,n,e)}function W5(n,e){let t=n.getBoundingClientRect(),i=e.getBoundingClientRect(),r=t.height/n.offsetHeight;i.topt.bottom&&(n.scrollTop+=(i.bottom-t.bottom)/r)}function Ub(n){return(n.boost||0)*100+(n.apply?10:0)+(n.info?5:0)+(n.type?1:0)}function V5(n,e){let t=[],i=null,r=null,s=f=>{t.push(f);let{section:p}=f.completion;if(p){i||(i=[]);let m=typeof p=="string"?p:p.name;i.some(y=>y.name==m)||i.push(typeof p=="string"?{name:m}:p)}},o=e.facet(Lt);for(let f of n)if(f.hasResult()){let p=f.result.getMatch;if(f.result.filter===!1)for(let m of f.result.options)s(new Fb(m,f.source,p?p(m):[],1e9-t.length));else{let m=e.sliceDoc(f.from,f.to),y,v=o.filterStrict?new I5(m):new Z5(m);for(let b of f.result.options)if(y=v.match(b.label)){let S=b.displayLabel?p?p(b,y.matched):[]:y.matched,w=y.score+(b.boost||0);if(s(new Fb(b,f.source,S,w)),typeof b.section=="object"&&b.section.rank==="dynamic"){let{name:C}=b.section;r||(r=Object.create(null)),r[C]=Math.max(w,r[C]||-1e9)}}}}if(i){let f=Object.create(null),p=0,m=(y,v)=>(y.rank==="dynamic"&&v.rank==="dynamic"?r[v.name]-r[y.name]:0)||(typeof y.rank=="number"?y.rank:1e9)-(typeof v.rank=="number"?v.rank:1e9)||(y.namem.score-p.score||h(p.completion,m.completion))){let p=f.completion;!c||c.label!=p.label||c.detail!=p.detail||c.type!=null&&p.type!=null&&c.type!=p.type||c.apply!=p.apply||c.boost!=p.boost?a.push(f):Ub(f.completion)>Ub(c)&&(a[a.length-1]=f),c=f.completion}return a}class To{constructor(e,t,i,r,s,o){this.options=e,this.attrs=t,this.tooltip=i,this.timestamp=r,this.selected=s,this.disabled=o}setSelected(e,t){return e==this.selected||e>=this.options.length?this:new To(this.options,Hb(t,e),this.tooltip,this.timestamp,e,this.disabled)}static build(e,t,i,r,s,o){if(r&&!o&&e.some(h=>h.isPending))return r.setDisabled();let a=V5(e,t);if(!a.length)return r&&e.some(h=>h.isPending)?r.setDisabled():null;let c=t.facet(Lt).selectOnOpen?0:-1;if(r&&r.selected!=c&&r.selected!=-1){let h=r.options[r.selected].completion;for(let f=0;ff.hasResult()?Math.min(h,f.from):h,1e8),create:G5,above:s.aboveCursor},r?r.timestamp:Date.now(),c,!1)}map(e){return new To(this.options,this.attrs,{...this.tooltip,pos:e.mapPos(this.tooltip.pos)},this.timestamp,this.selected,this.disabled)}setDisabled(){return new To(this.options,this.attrs,this.tooltip,this.timestamp,this.selected,!0)}}class Of{constructor(e,t,i){this.active=e,this.id=t,this.open=i}static start(){return new Of(U5,"cm-ac-"+Math.floor(Math.random()*2e6).toString(36),null)}update(e){let{state:t}=e,i=t.facet(Lt),s=(i.override||t.languageDataAt("autocomplete",As(t)).map(z5)).map(c=>(this.active.find(f=>f.source==c)||new Wn(c,this.active.some(f=>f.state!=0)?1:0)).update(e,i));s.length==this.active.length&&s.every((c,h)=>c==this.active[h])&&(s=this.active);let o=this.open,a=e.effects.some(c=>c.is(_0));o&&e.docChanged&&(o=o.map(e.changes)),e.selection||s.some(c=>c.hasResult()&&e.changes.touchesRange(c.from,c.to))||!F5(s,this.active)||a?o=To.build(s,t,this.id,o,i,a):o&&o.disabled&&!s.some(c=>c.isPending)&&(o=null),!o&&s.every(c=>!c.isPending)&&s.some(c=>c.hasResult())&&(s=s.map(c=>c.hasResult()?new Wn(c.source,0):c));for(let c of e.effects)c.is(TQ)&&(o=o&&o.setSelected(c.value,this.id));return s==this.active&&o==this.open?this:new Of(s,this.id,o)}get tooltip(){return this.open?this.open.tooltip:null}get attrs(){return this.open?this.open.attrs:this.active.length?Y5:q5}}function F5(n,e){if(n==e)return!0;for(let t=0,i=0;;){for(;t-1&&(t["aria-activedescendant"]=n+"-"+e),t}const U5=[];function CQ(n,e){if(n.isUserEvent("input.complete")){let i=n.annotation(P0);if(i&&e.activateOnCompletion(i))return 12}let t=n.isUserEvent("input.type");return t&&e.activateOnTyping?5:t?1:n.isUserEvent("delete.backward")?2:n.selection?8:n.docChanged?16:0}class Wn{constructor(e,t,i=!1){this.source=e,this.state=t,this.explicit=i}hasResult(){return!1}get isPending(){return this.state==1}update(e,t){let i=CQ(e,t),r=this;(i&8||i&16&&this.touches(e))&&(r=new Wn(r.source,0)),i&4&&r.state==0&&(r=new Wn(this.source,1)),r=r.updateFor(e,i);for(let s of e.effects)if(s.is(mf))r=new Wn(r.source,1,s.value);else if(s.is(Ya))r=new Wn(r.source,0);else if(s.is(_0))for(let o of s.value)o.source==r.source&&(r=o);return r}updateFor(e,t){return this.map(e.changes)}map(e){return this}touches(e){return e.changes.touchesRange(As(e.state))}}class Lo extends Wn{constructor(e,t,i,r,s,o){super(e,3,t),this.limit=i,this.result=r,this.from=s,this.to=o}hasResult(){return!0}updateFor(e,t){var i;if(!(t&3))return this.map(e.changes);let r=this.result;r.map&&!e.changes.empty&&(r=r.map(r,e.changes));let s=e.changes.mapPos(this.from),o=e.changes.mapPos(this.to,1),a=As(e.state);if(a>o||!r||t&2&&(As(e.startState)==this.from||at.map(e))}}),TQ=Te.define(),xn=zt.define({create(){return Of.start()},update(n,e){return n.update(e)},provide:n=>[o0.from(n,e=>e.tooltip),ce.contentAttributes.from(n,e=>e.attrs)]});function Q0(n,e){const t=e.completion.apply||e.completion.label;let i=n.state.field(xn).active.find(r=>r.source==e.source);return i instanceof Lo?(typeof t=="string"?n.dispatch({...D5(n.state,t,i.from,i.to),annotations:P0.of(e.completion)}):t(n,e.completion,i.from,i.to),!0):!1}const G5=X5(xn,Q0);function eh(n,e="option"){return t=>{let i=t.state.field(xn,!1);if(!i||!i.open||i.open.disabled||Date.now()-i.open.timestamp-1?i.open.selected+r*(n?1:-1):n?0:o-1;return a<0?a=e=="page"?0:o-1:a>=o&&(a=e=="page"?o-1:0),t.dispatch({effects:TQ.of(a)}),!0}}const K5=n=>{let e=n.state.field(xn,!1);return n.state.readOnly||!e||!e.open||e.open.selected<0||e.open.disabled||Date.now()-e.open.timestampn.state.field(xn,!1)?(n.dispatch({effects:mf.of(!0)}),!0):!1,J5=n=>{let e=n.state.field(xn,!1);return!e||!e.active.some(t=>t.state!=0)?!1:(n.dispatch({effects:Ya.of(null)}),!0)};class ez{constructor(e,t){this.active=e,this.context=t,this.time=Date.now(),this.updates=[],this.done=void 0}}const tz=50,nz=1e3,iz=kt.fromClass(class{constructor(n){this.view=n,this.debounceUpdate=-1,this.running=[],this.debounceAccept=-1,this.pendingStart=!1,this.composing=0;for(let e of n.state.field(xn).active)e.isPending&&this.startQuery(e)}update(n){let e=n.state.field(xn),t=n.state.facet(Lt);if(!n.selectionSet&&!n.docChanged&&n.startState.field(xn)==e)return;let i=n.transactions.some(s=>{let o=CQ(s,t);return o&8||(s.selection||s.docChanged)&&!(o&3)});for(let s=0;stz&&Date.now()-o.time>nz){for(let a of o.context.abortListeners)try{a()}catch(c){bn(this.view.state,c)}o.context.abortListeners=null,this.running.splice(s--,1)}else o.updates.push(...n.transactions)}this.debounceUpdate>-1&&clearTimeout(this.debounceUpdate),n.transactions.some(s=>s.effects.some(o=>o.is(mf)))&&(this.pendingStart=!0);let r=this.pendingStart?50:t.activateOnTypingDelay;if(this.debounceUpdate=e.active.some(s=>s.isPending&&!this.running.some(o=>o.active.source==s.source))?setTimeout(()=>this.startUpdate(),r):-1,this.composing!=0)for(let s of n.transactions)s.isUserEvent("input.type")?this.composing=2:this.composing==2&&s.selection&&(this.composing=3)}startUpdate(){this.debounceUpdate=-1,this.pendingStart=!1;let{state:n}=this.view,e=n.field(xn);for(let t of e.active)t.isPending&&!this.running.some(i=>i.active.source==t.source)&&this.startQuery(t);this.running.length&&e.open&&e.open.disabled&&(this.debounceAccept=setTimeout(()=>this.accept(),this.view.state.facet(Lt).updateSyncTime))}startQuery(n){let{state:e}=this.view,t=As(e),i=new PQ(e,t,n.explicit,this.view),r=new ez(n,i);this.running.push(r),Promise.resolve(n.source(i)).then(s=>{r.context.aborted||(r.done=s||null,this.scheduleAccept())},s=>{this.view.dispatch({effects:Ya.of(null)}),bn(this.view.state,s)})}scheduleAccept(){this.running.every(n=>n.done!==void 0)?this.accept():this.debounceAccept<0&&(this.debounceAccept=setTimeout(()=>this.accept(),this.view.state.facet(Lt).updateSyncTime))}accept(){var n;this.debounceAccept>-1&&clearTimeout(this.debounceAccept),this.debounceAccept=-1;let e=[],t=this.view.state.facet(Lt),i=this.view.state.field(xn);for(let r=0;ra.source==s.active.source);if(o&&o.isPending)if(s.done==null){let a=new Wn(s.active.source,0);for(let c of s.updates)a=a.update(c,t);a.isPending||e.push(a)}else this.startQuery(o)}(e.length||i.open&&i.open.disabled)&&this.view.dispatch({effects:_0.of(e)})}},{eventHandlers:{blur(n){let e=this.view.state.field(xn,!1);if(e&&e.tooltip&&this.view.state.facet(Lt).closeOnBlur){let t=e.open&&c_(this.view,e.open.tooltip);(!t||!t.dom.contains(n.relatedTarget))&&setTimeout(()=>this.view.dispatch({effects:Ya.of(null)}),10)}},compositionstart(){this.composing=1},compositionend(){this.composing==3&&setTimeout(()=>this.view.dispatch({effects:mf.of(!1)}),20),this.composing=0}}}),rz=typeof navigator=="object"&&/Win/.test(navigator.platform),sz=Jr.highest(ce.domEventHandlers({keydown(n,e){let t=e.state.field(xn,!1);if(!t||!t.open||t.open.disabled||t.open.selected<0||n.key.length>1||n.ctrlKey&&!(rz&&n.altKey)||n.metaKey)return!1;let i=t.open.options[t.open.selected],r=t.active.find(o=>o.source==i.source),s=i.completion.commitCharacters||r.result.commitCharacters;return s&&s.indexOf(n.key)>-1&&Q0(e,i),!1}})),$Q=ce.baseTheme({".cm-tooltip.cm-tooltip-autocomplete":{"& > ul":{fontFamily:"monospace",whiteSpace:"nowrap",overflow:"hidden auto",maxWidth_fallback:"700px",maxWidth:"min(700px, 95vw)",minWidth:"250px",maxHeight:"10em",height:"100%",listStyle:"none",margin:0,padding:0,"& > li, & > completion-section":{padding:"1px 3px",lineHeight:1.2},"& > li":{overflowX:"hidden",textOverflow:"ellipsis",cursor:"pointer"},"& > completion-section":{display:"list-item",borderBottom:"1px solid silver",paddingLeft:"0.5em",opacity:.7}}},"&light .cm-tooltip-autocomplete ul li[aria-selected]":{background:"#17c",color:"white"},"&light .cm-tooltip-autocomplete-disabled ul li[aria-selected]":{background:"#777"},"&dark .cm-tooltip-autocomplete ul li[aria-selected]":{background:"#347",color:"white"},"&dark .cm-tooltip-autocomplete-disabled ul li[aria-selected]":{background:"#444"},".cm-completionListIncompleteTop:before, .cm-completionListIncompleteBottom:after":{content:'"···"',opacity:.5,display:"block",textAlign:"center"},".cm-tooltip.cm-completionInfo":{position:"absolute",padding:"3px 9px",width:"max-content",maxWidth:"400px",boxSizing:"border-box",whiteSpace:"pre-line"},".cm-completionInfo.cm-completionInfo-left":{right:"100%"},".cm-completionInfo.cm-completionInfo-right":{left:"100%"},".cm-completionInfo.cm-completionInfo-left-narrow":{right:"30px"},".cm-completionInfo.cm-completionInfo-right-narrow":{left:"30px"},"&light .cm-snippetField":{backgroundColor:"#00000022"},"&dark .cm-snippetField":{backgroundColor:"#ffffff22"},".cm-snippetFieldPosition":{verticalAlign:"text-top",width:0,height:"1.15em",display:"inline-block",margin:"0 -0.7px -.7em",borderLeft:"1.4px dotted #888"},".cm-completionMatchedText":{textDecoration:"underline"},".cm-completionDetail":{marginLeft:"0.5em",fontStyle:"italic"},".cm-completionIcon":{fontSize:"90%",width:".8em",display:"inline-block",textAlign:"center",paddingRight:".6em",opacity:"0.6",boxSizing:"content-box"},".cm-completionIcon-function, .cm-completionIcon-method":{"&:after":{content:"'ƒ'"}},".cm-completionIcon-class":{"&:after":{content:"'○'"}},".cm-completionIcon-interface":{"&:after":{content:"'◌'"}},".cm-completionIcon-variable":{"&:after":{content:"'𝑥'"}},".cm-completionIcon-constant":{"&:after":{content:"'𝐶'"}},".cm-completionIcon-type":{"&:after":{content:"'𝑡'"}},".cm-completionIcon-enum":{"&:after":{content:"'∪'"}},".cm-completionIcon-property":{"&:after":{content:"'□'"}},".cm-completionIcon-keyword":{"&:after":{content:"'🔑︎'"}},".cm-completionIcon-namespace":{"&:after":{content:"'▢'"}},".cm-completionIcon-text":{"&:after":{content:"'abc'",fontSize:"50%",verticalAlign:"middle"}}});class oz{constructor(e,t,i,r){this.field=e,this.line=t,this.from=i,this.to=r}}class C0{constructor(e,t,i){this.field=e,this.from=t,this.to=i}map(e){let t=e.mapPos(this.from,-1,Xt.TrackDel),i=e.mapPos(this.to,1,Xt.TrackDel);return t==null||i==null?null:new C0(this.field,t,i)}}class T0{constructor(e,t){this.lines=e,this.fieldPositions=t}instantiate(e,t){let i=[],r=[t],s=e.doc.lineAt(t),o=/^\s*/.exec(s.text)[0];for(let c of this.lines){if(i.length){let h=o,f=/^\t*/.exec(c)[0].length;for(let p=0;pnew C0(c.field,r[c.line]+c.from,r[c.line]+c.to));return{text:i,ranges:a}}static parse(e){let t=[],i=[],r=[],s;for(let o of e.split(/\r\n?|\n/)){for(;s=/[#$]\{(?:(\d+)(?::([^{}]*))?|((?:\\[{}]|[^{}])*))\}/.exec(o);){let a=s[1]?+s[1]:null,c=s[2]||s[3]||"",h=-1,f=c.replace(/\\[{}]/g,p=>p[1]);for(let p=0;p=h&&m.field++}for(let p of r)if(p.line==i.length&&p.from>s.index){let m=s[2]?3+(s[1]||"").length:2;p.from-=m,p.to-=m}r.push(new oz(h,i.length,s.index,s.index+f.length)),o=o.slice(0,s.index)+c+o.slice(s.index+s[0].length)}o=o.replace(/\\([{}])/g,(a,c,h)=>{for(let f of r)f.line==i.length&&f.from>h&&(f.from--,f.to--);return c}),i.push(o)}return new T0(i,r)}}let lz=Pe.widget({widget:new class extends cr{toDOM(){let n=document.createElement("span");return n.className="cm-snippetFieldPosition",n}ignoreEvent(){return!1}}}),az=Pe.mark({class:"cm-snippetField"});class rl{constructor(e,t){this.ranges=e,this.active=t,this.deco=Pe.set(e.map(i=>(i.from==i.to?lz:az).range(i.from,i.to)),!0)}map(e){let t=[];for(let i of this.ranges){let r=i.map(e);if(!r)return null;t.push(r)}return new rl(t,this.active)}selectionInsideField(e){return e.ranges.every(t=>this.ranges.some(i=>i.field==this.active&&i.from<=t.from&&i.to>=t.to))}}const Oc=Te.define({map(n,e){return n&&n.map(e)}}),cz=Te.define(),qa=zt.define({create(){return null},update(n,e){for(let t of e.effects){if(t.is(Oc))return t.value;if(t.is(cz)&&n)return new rl(n.ranges,t.value)}return n&&e.docChanged&&(n=n.map(e.changes)),n&&e.selection&&!n.selectionInsideField(e.selection)&&(n=null),n},provide:n=>ce.decorations.from(n,e=>e?e.deco:Pe.none)});function $0(n,e){return V.create(n.filter(t=>t.field==e).map(t=>V.range(t.from,t.to)))}function uz(n){let e=T0.parse(n);return(t,i,r,s)=>{let{text:o,ranges:a}=e.instantiate(t.state,r),{main:c}=t.state.selection,h={changes:{from:r,to:s==c.from?c.to:s,insert:ze.of(o)},scrollIntoView:!0,annotations:i?[P0.of(i),St.userEvent.of("input.complete")]:void 0};if(a.length&&(h.selection=$0(a,0)),a.some(f=>f.field>0)){let f=new rl(a,0),p=h.effects=[Oc.of(f)];t.state.field(qa,!1)===void 0&&p.push(Te.appendConfig.of([qa,gz,mz,$Q]))}t.dispatch(t.state.update(h))}}function MQ(n){return({state:e,dispatch:t})=>{let i=e.field(qa,!1);if(!i||n<0&&i.active==0)return!1;let r=i.active+n,s=n>0&&!i.ranges.some(o=>o.field==r+n);return t(e.update({selection:$0(i.ranges,r),effects:Oc.of(s?null:new rl(i.ranges,r)),scrollIntoView:!0})),!0}}const hz=({state:n,dispatch:e})=>n.field(qa,!1)?(e(n.update({effects:Oc.of(null)})),!0):!1,fz=MQ(1),dz=MQ(-1),pz=[{key:"Tab",run:fz,shift:dz},{key:"Escape",run:hz}],Gb=fe.define({combine(n){return n.length?n[0]:pz}}),gz=Jr.highest(hc.compute([Gb],n=>n.facet(Gb)));function On(n,e){return{...e,apply:uz(n)}}const mz=ce.domEventHandlers({mousedown(n,e){let t=e.state.field(qa,!1),i;if(!t||(i=e.posAtCoords({x:n.clientX,y:n.clientY}))==null)return!1;let r=t.ranges.find(s=>s.from<=i&&s.to>=i);return!r||r.field==t.active?!1:(e.dispatch({selection:$0(t.ranges,r.field),effects:Oc.of(t.ranges.some(s=>s.field>r.field)?new rl(t.ranges,r.field):null),scrollIntoView:!0}),!0)}}),Ua={brackets:["(","[","{","'",'"'],before:")]}:;>",stringPrefixes:[]},Ts=Te.define({map(n,e){let t=e.mapPos(n,-1,Xt.TrackAfter);return t==null?void 0:t}}),M0=new class extends Zs{};M0.startSide=1;M0.endSide=-1;const RQ=zt.define({create(){return je.empty},update(n,e){if(n=n.map(e.changes),e.selection){let t=e.state.doc.lineAt(e.selection.main.head);n=n.update({filter:i=>i>=t.from&&i<=t.to})}for(let t of e.effects)t.is(Ts)&&(n=n.update({add:[M0.range(t.value,t.value+1)]}));return n}});function Oz(){return[xz,RQ]}const Dg="()[]{}<>«»»«[]{}";function AQ(n){for(let e=0;e{if((yz?n.composing:n.compositionStarted)||n.state.readOnly)return!1;let r=n.state.selection.main;if(i.length>2||i.length==2&&$i(yn(i,0))==1||e!=r.from||t!=r.to)return!1;let s=Sz(n.state,i);return s?(n.dispatch(s),!0):!1}),vz=({state:n,dispatch:e})=>{if(n.readOnly)return!1;let i=EQ(n,n.selection.main.head).brackets||Ua.brackets,r=null,s=n.changeByRange(o=>{if(o.empty){let a=wz(n.doc,o.head);for(let c of i)if(c==a&&Gf(n.doc,o.head)==AQ(yn(c,0)))return{changes:{from:o.head-c.length,to:o.head+c.length},range:V.cursor(o.head-c.length)}}return{range:r=o}});return r||e(n.update(s,{scrollIntoView:!0,userEvent:"delete.backward"})),!r},bz=[{key:"Backspace",run:vz}];function Sz(n,e){let t=EQ(n,n.selection.main.head),i=t.brackets||Ua.brackets;for(let r of i){let s=AQ(yn(r,0));if(e==r)return s==r?_z(n,r,i.indexOf(r+r+r)>-1,t):kz(n,r,s,t.before||Ua.before);if(e==s&&LQ(n,n.selection.main.from))return Pz(n,r,s)}return null}function LQ(n,e){let t=!1;return n.field(RQ).between(0,n.doc.length,i=>{i==e&&(t=!0)}),t}function Gf(n,e){let t=n.sliceString(e,e+2);return t.slice(0,$i(yn(t,0)))}function wz(n,e){let t=n.sliceString(e-2,e);return $i(yn(t,0))==t.length?t:t.slice(1)}function kz(n,e,t,i){let r=null,s=n.changeByRange(o=>{if(!o.empty)return{changes:[{insert:e,from:o.from},{insert:t,from:o.to}],effects:Ts.of(o.to+e.length),range:V.range(o.anchor+e.length,o.head+e.length)};let a=Gf(n.doc,o.head);return!a||/\s/.test(a)||i.indexOf(a)>-1?{changes:{insert:e+t,from:o.head},effects:Ts.of(o.head+e.length),range:V.cursor(o.head+e.length)}:{range:r=o}});return r?null:n.update(s,{scrollIntoView:!0,userEvent:"input.type"})}function Pz(n,e,t){let i=null,r=n.changeByRange(s=>s.empty&&Gf(n.doc,s.head)==t?{changes:{from:s.head,to:s.head+t.length,insert:t},range:V.cursor(s.head+t.length)}:i={range:s});return i?null:n.update(r,{scrollIntoView:!0,userEvent:"input.type"})}function _z(n,e,t,i){let r=i.stringPrefixes||Ua.stringPrefixes,s=null,o=n.changeByRange(a=>{if(!a.empty)return{changes:[{insert:e,from:a.from},{insert:e,from:a.to}],effects:Ts.of(a.to+e.length),range:V.range(a.anchor+e.length,a.head+e.length)};let c=a.head,h=Gf(n.doc,c),f;if(h==e){if(Kb(n,c))return{changes:{insert:e+e,from:c},effects:Ts.of(c+e.length),range:V.cursor(c+e.length)};if(LQ(n,c)){let m=t&&n.sliceDoc(c,c+e.length*3)==e+e+e?e+e+e:e;return{changes:{from:c,to:c+m.length,insert:m},range:V.cursor(c+m.length)}}}else{if(t&&n.sliceDoc(c-2*e.length,c)==e+e&&(f=Jb(n,c-2*e.length,r))>-1&&Kb(n,f))return{changes:{insert:e+e+e+e,from:c},effects:Ts.of(c+e.length),range:V.cursor(c+e.length)};if(n.charCategorizer(c)(h)!=at.Word&&Jb(n,c,r)>-1&&!Qz(n,c,e,r))return{changes:{insert:e+e,from:c},effects:Ts.of(c+e.length),range:V.cursor(c+e.length)}}return{range:s=a}});return s?null:n.update(o,{scrollIntoView:!0,userEvent:"input.type"})}function Kb(n,e){let t=Pt(n).resolveInner(e+1);return t.parent&&t.from==e}function Qz(n,e,t,i){let r=Pt(n).resolveInner(e,-1),s=i.reduce((o,a)=>Math.max(o,a.length),0);for(let o=0;o<5;o++){let a=n.sliceDoc(r.from,Math.min(r.to,r.from+t.length+s)),c=a.indexOf(t);if(!c||c>-1&&i.indexOf(a.slice(0,c))>-1){let f=r.firstChild;for(;f&&f.from==r.from&&f.to-f.from>t.length+c;){if(n.sliceDoc(f.to-t.length,f.to)==t)return!1;f=f.firstChild}return!0}let h=r.to==e&&r.parent;if(!h)break;r=h}return!1}function Jb(n,e,t){let i=n.charCategorizer(e);if(i(n.sliceDoc(e-1,e))!=at.Word)return e;for(let r of t){let s=e-r.length;if(n.sliceDoc(s,e)==r&&i(n.sliceDoc(s-1,s))!=at.Word)return s}return-1}function DQ(n={}){return[sz,xn,Lt.of(n),iz,Cz,$Q]}const R0=[{key:"Ctrl-Space",run:Lg},{mac:"Alt-`",run:Lg},{mac:"Alt-i",run:Lg},{key:"Escape",run:J5},{key:"ArrowDown",run:eh(!0)},{key:"ArrowUp",run:eh(!1)},{key:"PageDown",run:eh(!0,"page")},{key:"PageUp",run:eh(!1,"page")},{key:"Enter",run:K5}],Cz=Jr.highest(hc.computeN([Lt],n=>n.facet(Lt).defaultKeymap?[R0]:[]));class eS{constructor(e,t,i){this.from=e,this.to=t,this.diagnostic=i}}class _s{constructor(e,t,i){this.diagnostics=e,this.panel=t,this.selected=i}static init(e,t,i){let r=i.facet(Ha).markerFilter;r&&(e=r(e,i));let s=e.slice().sort((f,p)=>f.from-p.from||f.to-p.to),o=new sr,a=[],c=0;for(let f=0;;){let p=f==s.length?null:s[f];if(!p&&!a.length)break;let m,y;for(a.length?(m=c,y=a.reduce((b,S)=>Math.min(b,S.to),p&&p.from>m?p.from:1e8)):(m=p.from,y=p.to,a.push(p),f++);fb.from||b.to==m))a.push(b),f++,y=Math.min(b.to,y);else{y=Math.min(b.from,y);break}}let v=Bz(a);if(a.some(b=>b.from==b.to||b.from==b.to-1&&i.doc.lineAt(b.from).to==b.from))o.add(m,m,Pe.widget({widget:new zz(v),diagnostics:a.slice()}));else{let b=a.reduce((S,w)=>w.markClass?S+" "+w.markClass:S,"");o.add(m,y,Pe.mark({class:"cm-lintRange cm-lintRange-"+v+b,diagnostics:a.slice(),inclusiveEnd:a.some(S=>S.to>y)}))}c=y;for(let b=0;b{if(!(e&&o.diagnostics.indexOf(e)<0))if(!i)i=new eS(r,s,e||o.diagnostics[0]);else{if(o.diagnostics.indexOf(i.diagnostic)<0)return!1;i=new eS(i.from,s,i.diagnostic)}}),i}function Tz(n,e){let t=e.pos,i=e.end||t,r=n.state.facet(Ha).hideOn(n,t,i);if(r!=null)return r;let s=n.startState.doc.lineAt(e.pos);return!!(n.effects.some(o=>o.is(zQ))||n.changes.touchesRange(s.from,Math.max(s.to,i)))}function $z(n,e){return n.field(En,!1)?e:e.concat(Te.appendConfig.of(Nz))}const zQ=Te.define(),A0=Te.define(),ZQ=Te.define(),En=zt.define({create(){return new _s(Pe.none,null,null)},update(n,e){if(e.docChanged&&n.diagnostics.size){let t=n.diagnostics.map(e.changes),i=null,r=n.panel;if(n.selected){let s=e.changes.mapPos(n.selected.from,1);i=Ho(t,n.selected.diagnostic,s)||Ho(t,null,s)}!t.size&&r&&e.state.facet(Ha).autoPanel&&(r=null),n=new _s(t,r,i)}for(let t of e.effects)if(t.is(zQ)){let i=e.state.facet(Ha).autoPanel?t.value.length?Ga.open:null:n.panel;n=_s.init(t.value,i,e.state)}else t.is(A0)?n=new _s(n.diagnostics,t.value?Ga.open:null,n.selected):t.is(ZQ)&&(n=new _s(n.diagnostics,n.panel,t.value));return n},provide:n=>[Na.from(n,e=>e.panel),ce.decorations.from(n,e=>e.diagnostics)]}),Mz=Pe.mark({class:"cm-lintRange cm-lintRange-active"});function Rz(n,e,t){let{diagnostics:i}=n.state.field(En),r,s=-1,o=-1;i.between(e-(t<0?1:0),e+(t>0?1:0),(c,h,{spec:f})=>{if(e>=c&&e<=h&&(c==h||(e>c||t>0)&&(ejQ(n,t,!1)))}const Ez=n=>{let e=n.state.field(En,!1);(!e||!e.panel)&&n.dispatch({effects:$z(n.state,[A0.of(!0)])});let t=Ba(n,Ga.open);return t&&t.dom.querySelector(".cm-panel-lint ul").focus(),!0},tS=n=>{let e=n.state.field(En,!1);return!e||!e.panel?!1:(n.dispatch({effects:A0.of(!1)}),!0)},Lz=n=>{let e=n.state.field(En,!1);if(!e)return!1;let t=n.state.selection.main,i=e.diagnostics.iter(t.to+1);return!i.value&&(i=e.diagnostics.iter(0),!i.value||i.from==t.from&&i.to==t.to)?!1:(n.dispatch({selection:{anchor:i.from,head:i.to},scrollIntoView:!0}),!0)},Dz=[{key:"Mod-Shift-m",run:Ez,preventDefault:!0},{key:"F8",run:Lz}],Ha=fe.define({combine(n){return{sources:n.map(e=>e.source).filter(e=>e!=null),...Zi(n.map(e=>e.config),{delay:750,markerFilter:null,tooltipFilter:null,needsRefresh:null,hideOn:()=>null},{delay:Math.max,markerFilter:nS,tooltipFilter:nS,needsRefresh:(e,t)=>e?t?i=>e(i)||t(i):e:t,hideOn:(e,t)=>e?t?(i,r,s)=>e(i,r,s)||t(i,r,s):e:t,autoPanel:(e,t)=>e||t})}}});function nS(n,e){return n?e?(t,i)=>e(n(t,i),i):n:e}function IQ(n){let e=[];if(n)e:for(let{name:t}of n){for(let i=0;is.toLowerCase()==r.toLowerCase())){e.push(r);continue e}}e.push("")}return e}function jQ(n,e,t){var i;let r=t?IQ(e.actions):[];return He("li",{class:"cm-diagnostic cm-diagnostic-"+e.severity},He("span",{class:"cm-diagnosticText"},e.renderMessage?e.renderMessage(n):e.message),(i=e.actions)===null||i===void 0?void 0:i.map((s,o)=>{let a=!1,c=y=>{if(y.preventDefault(),a)return;a=!0;let v=Ho(n.state.field(En).diagnostics,e);v&&s.apply(n,v.from,v.to)},{name:h}=s,f=r[o]?h.indexOf(r[o]):-1,p=f<0?h:[h.slice(0,f),He("u",h.slice(f,f+1)),h.slice(f+1)],m=s.markClass?" "+s.markClass:"";return He("button",{type:"button",class:"cm-diagnosticAction"+m,onclick:c,onmousedown:c,"aria-label":` Action: ${h}${f<0?"":` (access key "${r[o]})"`}.`},p)}),e.source&&He("div",{class:"cm-diagnosticSource"},e.source))}class zz extends cr{constructor(e){super(),this.sev=e}eq(e){return e.sev==this.sev}toDOM(){return He("span",{class:"cm-lintPoint cm-lintPoint-"+this.sev})}}class iS{constructor(e,t){this.diagnostic=t,this.id="item_"+Math.floor(Math.random()*4294967295).toString(16),this.dom=jQ(e,t,!0),this.dom.id=this.id,this.dom.setAttribute("role","option")}}class Ga{constructor(e){this.view=e,this.items=[];let t=r=>{if(r.keyCode==27)tS(this.view),this.view.focus();else if(r.keyCode==38||r.keyCode==33)this.moveSelection((this.selectedIndex-1+this.items.length)%this.items.length);else if(r.keyCode==40||r.keyCode==34)this.moveSelection((this.selectedIndex+1)%this.items.length);else if(r.keyCode==36)this.moveSelection(0);else if(r.keyCode==35)this.moveSelection(this.items.length-1);else if(r.keyCode==13)this.view.focus();else if(r.keyCode>=65&&r.keyCode<=90&&this.selectedIndex>=0){let{diagnostic:s}=this.items[this.selectedIndex],o=IQ(s.actions);for(let a=0;a{for(let s=0;stS(this.view)},"×")),this.update()}get selectedIndex(){let e=this.view.state.field(En).selected;if(!e)return-1;for(let t=0;t{for(let f of h.diagnostics){if(o.has(f))continue;o.add(f);let p=-1,m;for(let y=i;yi&&(this.items.splice(i,p-i),r=!0)),t&&m.diagnostic==t.diagnostic?m.dom.hasAttribute("aria-selected")||(m.dom.setAttribute("aria-selected","true"),s=m):m.dom.hasAttribute("aria-selected")&&m.dom.removeAttribute("aria-selected"),i++}});i({sel:s.dom.getBoundingClientRect(),panel:this.list.getBoundingClientRect()}),write:({sel:a,panel:c})=>{let h=c.height/this.list.offsetHeight;a.topc.bottom&&(this.list.scrollTop+=(a.bottom-c.bottom)/h)}})):this.selectedIndex<0&&this.list.removeAttribute("aria-activedescendant"),r&&this.sync()}sync(){let e=this.list.firstChild;function t(){let i=e;e=i.nextSibling,i.remove()}for(let i of this.items)if(i.dom.parentNode==this.list){for(;e!=i.dom;)t();e=i.dom.nextSibling}else this.list.insertBefore(i.dom,e);for(;e;)t()}moveSelection(e){if(this.selectedIndex<0)return;let t=this.view.state.field(En),i=Ho(t.diagnostics,this.items[e].diagnostic);i&&this.view.dispatch({selection:{anchor:i.from,head:i.to},scrollIntoView:!0,effects:ZQ.of(i)})}static open(e){return new Ga(e)}}function Zz(n,e='viewBox="0 0 40 40"'){return`url('data:image/svg+xml,${encodeURIComponent(n)}')`}function th(n){return Zz(``,'width="6" height="3"')}const Iz=ce.baseTheme({".cm-diagnostic":{padding:"3px 6px 3px 8px",marginLeft:"-1px",display:"block",whiteSpace:"pre-wrap"},".cm-diagnostic-error":{borderLeft:"5px solid #d11"},".cm-diagnostic-warning":{borderLeft:"5px solid orange"},".cm-diagnostic-info":{borderLeft:"5px solid #999"},".cm-diagnostic-hint":{borderLeft:"5px solid #66d"},".cm-diagnosticAction":{font:"inherit",border:"none",padding:"2px 4px",backgroundColor:"#444",color:"white",borderRadius:"3px",marginLeft:"8px",cursor:"pointer"},".cm-diagnosticSource":{fontSize:"70%",opacity:.7},".cm-lintRange":{backgroundPosition:"left bottom",backgroundRepeat:"repeat-x",paddingBottom:"0.7px"},".cm-lintRange-error":{backgroundImage:th("#d11")},".cm-lintRange-warning":{backgroundImage:th("orange")},".cm-lintRange-info":{backgroundImage:th("#999")},".cm-lintRange-hint":{backgroundImage:th("#66d")},".cm-lintRange-active":{backgroundColor:"#ffdd9980"},".cm-tooltip-lint":{padding:0,margin:0},".cm-lintPoint":{position:"relative","&:after":{content:'""',position:"absolute",bottom:0,left:"-2px",borderLeft:"3px solid transparent",borderRight:"3px solid transparent",borderBottom:"4px solid #d11"}},".cm-lintPoint-warning":{"&:after":{borderBottomColor:"orange"}},".cm-lintPoint-info":{"&:after":{borderBottomColor:"#999"}},".cm-lintPoint-hint":{"&:after":{borderBottomColor:"#66d"}},".cm-panel.cm-panel-lint":{position:"relative","& ul":{maxHeight:"100px",overflowY:"auto","& [aria-selected]":{backgroundColor:"#ddd","& u":{textDecoration:"underline"}},"&:focus [aria-selected]":{background_fallback:"#bdf",backgroundColor:"Highlight",color_fallback:"white",color:"HighlightText"},"& u":{textDecoration:"none"},padding:0,margin:0},"& [name=close]":{position:"absolute",top:"0",right:"2px",background:"inherit",border:"none",font:"inherit",padding:0,margin:0}}});function jz(n){return n=="error"?4:n=="warning"?3:n=="info"?2:1}function Bz(n){let e="hint",t=1;for(let i of n){let r=jz(i.severity);r>t&&(t=r,e=i.severity)}return e}const Nz=[En,ce.decorations.compute([En],n=>{let{selected:e,panel:t}=n.field(En);return!e||!t||e.from==e.to?Pe.none:Pe.set([Mz.range(e.from,e.to)])}),QE(Rz,{hideOn:Tz}),Iz],Xz=[ZE(),BE(),rE(),iD(),$L(),YA(),KA(),Ie.allowMultipleSelections.of(!0),mL(),A_(EL,{fallback:!0}),BL(),Oz(),DQ(),mE(),xE(),uE(),f5(),hc.of([...bz,...s5,...M5,...fD,..._L,...R0,...Dz])];var rS={};class yf{constructor(e,t,i,r,s,o,a,c,h,f=0,p){this.p=e,this.stack=t,this.state=i,this.reducePos=r,this.pos=s,this.score=o,this.buffer=a,this.bufferBase=c,this.curContext=h,this.lookAhead=f,this.parent=p}toString(){return`[${this.stack.filter((e,t)=>t%3==0).concat(this.state)}]@${this.pos}${this.score?"!"+this.score:""}`}static start(e,t,i=0){let r=e.parser.context;return new yf(e,[],t,i,i,0,[],0,r?new sS(r,r.start):null,0,null)}get context(){return this.curContext?this.curContext.context:null}pushState(e,t){this.stack.push(this.state,t,this.bufferBase+this.buffer.length),this.state=e}reduce(e){var t;let i=e>>19,r=e&65535,{parser:s}=this.p,o=this.reducePos=2e3&&!(!((t=this.p.parser.nodeSet.types[r])===null||t===void 0)&&t.isAnonymous)&&(h==this.p.lastBigReductionStart?(this.p.bigReductionCount++,this.p.lastBigReductionSize=f):this.p.lastBigReductionSizec;)this.stack.pop();this.reduceContext(r,h)}storeNode(e,t,i,r=4,s=!1){if(e==0&&(!this.stack.length||this.stack[this.stack.length-1]0&&o.buffer[a-4]==0&&o.buffer[a-1]>-1){if(t==i)return;if(o.buffer[a-2]>=t){o.buffer[a-2]=i;return}}}if(!s||this.pos==i)this.buffer.push(e,t,i,r);else{let o=this.buffer.length;if(o>0&&this.buffer[o-4]!=0){let a=!1;for(let c=o;c>0&&this.buffer[c-2]>i;c-=4)if(this.buffer[c-1]>=0){a=!0;break}if(a)for(;o>0&&this.buffer[o-2]>i;)this.buffer[o]=this.buffer[o-4],this.buffer[o+1]=this.buffer[o-3],this.buffer[o+2]=this.buffer[o-2],this.buffer[o+3]=this.buffer[o-1],o-=4,r>4&&(r-=4)}this.buffer[o]=e,this.buffer[o+1]=t,this.buffer[o+2]=i,this.buffer[o+3]=r}}shift(e,t,i,r){if(e&131072)this.pushState(e&65535,this.pos);else if((e&262144)==0){let s=e,{parser:o}=this.p;(r>this.pos||t<=o.maxNode)&&(this.pos=r,o.stateFlag(s,1)||(this.reducePos=r)),this.pushState(s,i),this.shiftContext(t,i),t<=o.maxNode&&this.buffer.push(t,i,r,4)}else this.pos=r,this.shiftContext(t,i),t<=this.p.parser.maxNode&&this.buffer.push(t,i,r,4)}apply(e,t,i,r){e&65536?this.reduce(e):this.shift(e,t,i,r)}useNode(e,t){let i=this.p.reused.length-1;(i<0||this.p.reused[i]!=e)&&(this.p.reused.push(e),i++);let r=this.pos;this.reducePos=this.pos=r+e.length,this.pushState(t,r),this.buffer.push(i,r,this.reducePos,-1),this.curContext&&this.updateContext(this.curContext.tracker.reuse(this.curContext.context,e,this,this.p.stream.reset(this.pos-e.length)))}split(){let e=this,t=e.buffer.length;for(;t>0&&e.buffer[t-2]>e.reducePos;)t-=4;let i=e.buffer.slice(t),r=e.bufferBase+t;for(;e&&r==e.bufferBase;)e=e.parent;return new yf(this.p,this.stack.slice(),this.state,this.reducePos,this.pos,this.score,i,r,this.curContext,this.lookAhead,e)}recoverByDelete(e,t){let i=e<=this.p.parser.maxNode;i&&this.storeNode(e,this.pos,t,4),this.storeNode(0,this.pos,t,i?8:4),this.pos=this.reducePos=t,this.score-=190}canShift(e){for(let t=new Wz(this);;){let i=this.p.parser.stateSlot(t.state,4)||this.p.parser.hasAction(t.state,e);if(i==0)return!1;if((i&65536)==0)return!0;t.reduce(i)}}recoverByInsert(e){if(this.stack.length>=300)return[];let t=this.p.parser.nextStates(this.state);if(t.length>8||this.stack.length>=120){let r=[];for(let s=0,o;sc&1&&a==o)||r.push(t[s],o)}t=r}let i=[];for(let r=0;r>19,r=t&65535,s=this.stack.length-i*3;if(s<0||e.getGoto(this.stack[s],r,!1)<0){let o=this.findForcedReduction();if(o==null)return!1;t=o}this.storeNode(0,this.pos,this.pos,4,!0),this.score-=100}return this.reducePos=this.pos,this.reduce(t),!0}findForcedReduction(){let{parser:e}=this.p,t=[],i=(r,s)=>{if(!t.includes(r))return t.push(r),e.allActions(r,o=>{if(!(o&393216))if(o&65536){let a=(o>>19)-s;if(a>1){let c=o&65535,h=this.stack.length-a*3;if(h>=0&&e.getGoto(this.stack[h],c,!1)>=0)return a<<19|65536|c}}else{let a=i(o,s+1);if(a!=null)return a}})};return i(this.state,0)}forceAll(){for(;!this.p.parser.stateFlag(this.state,2);)if(!this.forceReduce()){this.storeNode(0,this.pos,this.pos,4,!0);break}return this}get deadEnd(){if(this.stack.length!=3)return!1;let{parser:e}=this.p;return e.data[e.stateSlot(this.state,1)]==65535&&!e.stateSlot(this.state,4)}restart(){this.storeNode(0,this.pos,this.pos,4,!0),this.state=this.stack[0],this.stack.length=0}sameState(e){if(this.state!=e.state||this.stack.length!=e.stack.length)return!1;for(let t=0;tthis.lookAhead&&(this.emitLookAhead(),this.lookAhead=e)}close(){this.curContext&&this.curContext.tracker.strict&&this.emitContext(),this.lookAhead>0&&this.emitLookAhead()}}class sS{constructor(e,t){this.tracker=e,this.context=t,this.hash=e.strict?e.hash(t):0}}class Wz{constructor(e){this.start=e,this.state=e.state,this.stack=e.stack,this.base=this.stack.length}reduce(e){let t=e&65535,i=e>>19;i==0?(this.stack==this.start.stack&&(this.stack=this.stack.slice()),this.stack.push(this.state,0,0),this.base+=3):this.base-=(i-1)*3;let r=this.start.p.parser.getGoto(this.stack[this.base-3],t,!0);this.state=r}}class xf{constructor(e,t,i){this.stack=e,this.pos=t,this.index=i,this.buffer=e.buffer,this.index==0&&this.maybeNext()}static create(e,t=e.bufferBase+e.buffer.length){return new xf(e,t,t-e.bufferBase)}maybeNext(){let e=this.stack.parent;e!=null&&(this.index=this.stack.bufferBase-e.bufferBase,this.stack=e,this.buffer=e.buffer)}get id(){return this.buffer[this.index-4]}get start(){return this.buffer[this.index-3]}get end(){return this.buffer[this.index-2]}get size(){return this.buffer[this.index-1]}next(){this.index-=4,this.pos-=4,this.index==0&&this.maybeNext()}fork(){return new xf(this.stack,this.pos,this.index)}}function ha(n,e=Uint16Array){if(typeof n!="string")return n;let t=null;for(let i=0,r=0;i=92&&o--,o>=34&&o--;let c=o-32;if(c>=46&&(c-=46,a=!0),s+=c,a)break;s*=46}t?t[r++]=s:t=new e(s)}return t}class Ah{constructor(){this.start=-1,this.value=-1,this.end=-1,this.extended=-1,this.lookAhead=0,this.mask=0,this.context=0}}const oS=new Ah;class Vz{constructor(e,t){this.input=e,this.ranges=t,this.chunk="",this.chunkOff=0,this.chunk2="",this.chunk2Pos=0,this.next=-1,this.token=oS,this.rangeIndex=0,this.pos=this.chunkPos=t[0].from,this.range=t[0],this.end=t[t.length-1].to,this.readNext()}resolveOffset(e,t){let i=this.range,r=this.rangeIndex,s=this.pos+e;for(;si.to:s>=i.to;){if(r==this.ranges.length-1)return null;let o=this.ranges[++r];s+=o.from-i.to,i=o}return s}clipPos(e){if(e>=this.range.from&&ee)return Math.max(e,t.from);return this.end}peek(e){let t=this.chunkOff+e,i,r;if(t>=0&&t=this.chunk2Pos&&ia.to&&(this.chunk2=this.chunk2.slice(0,a.to-i)),r=this.chunk2.charCodeAt(0)}}return i>=this.token.lookAhead&&(this.token.lookAhead=i+1),r}acceptToken(e,t=0){let i=t?this.resolveOffset(t,-1):this.pos;if(i==null||i=this.chunk2Pos&&this.posthis.range.to?e.slice(0,this.range.to-this.pos):e,this.chunkPos=this.pos,this.chunkOff=0}}readNext(){return this.chunkOff>=this.chunk.length&&(this.getChunk(),this.chunkOff==this.chunk.length)?this.next=-1:this.next=this.chunk.charCodeAt(this.chunkOff)}advance(e=1){for(this.chunkOff+=e;this.pos+e>=this.range.to;){if(this.rangeIndex==this.ranges.length-1)return this.setDone();e-=this.range.to-this.pos,this.range=this.ranges[++this.rangeIndex],this.pos=this.range.from}return this.pos+=e,this.pos>=this.token.lookAhead&&(this.token.lookAhead=this.pos+1),this.readNext()}setDone(){return this.pos=this.chunkPos=this.end,this.range=this.ranges[this.rangeIndex=this.ranges.length-1],this.chunk="",this.next=-1}reset(e,t){if(t?(this.token=t,t.start=e,t.lookAhead=e+1,t.value=t.extended=-1):this.token=oS,this.pos!=e){if(this.pos=e,e==this.end)return this.setDone(),this;for(;e=this.range.to;)this.range=this.ranges[++this.rangeIndex];e>=this.chunkPos&&e=this.chunkPos&&t<=this.chunkPos+this.chunk.length)return this.chunk.slice(e-this.chunkPos,t-this.chunkPos);if(e>=this.chunk2Pos&&t<=this.chunk2Pos+this.chunk2.length)return this.chunk2.slice(e-this.chunk2Pos,t-this.chunk2Pos);if(e>=this.range.from&&t<=this.range.to)return this.input.read(e,t);let i="";for(let r of this.ranges){if(r.from>=t)break;r.to>e&&(i+=this.input.read(Math.max(r.from,e),Math.min(r.to,t)))}return i}}class Do{constructor(e,t){this.data=e,this.id=t}token(e,t){let{parser:i}=t.p;BQ(this.data,e,t,this.id,i.data,i.tokenPrecTable)}}Do.prototype.contextual=Do.prototype.fallback=Do.prototype.extend=!1;class gO{constructor(e,t,i){this.precTable=t,this.elseToken=i,this.data=typeof e=="string"?ha(e):e}token(e,t){let i=e.pos,r=0;for(;;){let s=e.next<0,o=e.resolveOffset(1,1);if(BQ(this.data,e,t,0,this.data,this.precTable),e.token.value>-1)break;if(this.elseToken==null)return;if(s||r++,o==null)break;e.reset(o,e.token)}r&&(e.reset(i,e.token),e.acceptToken(this.elseToken,r))}}gO.prototype.contextual=Do.prototype.fallback=Do.prototype.extend=!1;class sl{constructor(e,t={}){this.token=e,this.contextual=!!t.contextual,this.fallback=!!t.fallback,this.extend=!!t.extend}}function BQ(n,e,t,i,r,s){let o=0,a=1<0){let v=n[y];if(c.allows(v)&&(e.token.value==-1||e.token.value==v||Fz(v,e.token.value,r,s))){e.acceptToken(v);break}}let f=e.next,p=0,m=n[o+2];if(e.next<0&&m>p&&n[h+m*3-3]==65535){o=n[h+m*3-1];continue e}for(;p>1,v=h+y+(y<<1),b=n[v],S=n[v+1]||65536;if(f=S)p=y+1;else{o=n[v+2],e.advance();continue e}}break}}function lS(n,e,t){for(let i=e,r;(r=n[i])!=65535;i++)if(r==t)return i-e;return-1}function Fz(n,e,t,i){let r=lS(t,i,e);return r<0||lS(t,i,n)e)&&!i.type.isError)return t<0?Math.max(0,Math.min(i.to-1,e-25)):Math.min(n.length,Math.max(i.from+1,e+25));if(t<0?i.prevSibling():i.nextSibling())break;if(!i.parent())return t<0?0:n.length}}class Yz{constructor(e,t){this.fragments=e,this.nodeSet=t,this.i=0,this.fragment=null,this.safeFrom=-1,this.safeTo=-1,this.trees=[],this.start=[],this.index=[],this.nextFragment()}nextFragment(){let e=this.fragment=this.i==this.fragments.length?null:this.fragments[this.i++];if(e){for(this.safeFrom=e.openStart?aS(e.tree,e.from+e.offset,1)-e.offset:e.from,this.safeTo=e.openEnd?aS(e.tree,e.to+e.offset,-1)-e.offset:e.to;this.trees.length;)this.trees.pop(),this.start.pop(),this.index.pop();this.trees.push(e.tree),this.start.push(-e.offset),this.index.push(0),this.nextStart=this.safeFrom}else this.nextStart=1e9}nodeAt(e){if(ee)return this.nextStart=o,null;if(s instanceof wt){if(o==e){if(o=Math.max(this.safeFrom,e)&&(this.trees.push(s),this.start.push(o),this.index.push(0))}else this.index[t]++,this.nextStart=o+s.length}}}class qz{constructor(e,t){this.stream=t,this.tokens=[],this.mainToken=null,this.actions=[],this.tokens=e.tokenizers.map(i=>new Ah)}getActions(e){let t=0,i=null,{parser:r}=e.p,{tokenizers:s}=r,o=r.stateSlot(e.state,3),a=e.curContext?e.curContext.hash:0,c=0;for(let h=0;hp.end+25&&(c=Math.max(p.lookAhead,c)),p.value!=0)){let m=t;if(p.extended>-1&&(t=this.addActions(e,p.extended,p.end,t)),t=this.addActions(e,p.value,p.end,t),!f.extend&&(i=p,t>m))break}}for(;this.actions.length>t;)this.actions.pop();return c&&e.setLookAhead(c),!i&&e.pos==this.stream.end&&(i=new Ah,i.value=e.p.parser.eofTerm,i.start=i.end=e.pos,t=this.addActions(e,i.value,i.end,t)),this.mainToken=i,this.actions}getMainToken(e){if(this.mainToken)return this.mainToken;let t=new Ah,{pos:i,p:r}=e;return t.start=i,t.end=Math.min(i+1,r.stream.end),t.value=i==r.stream.end?r.parser.eofTerm:0,t}updateCachedToken(e,t,i){let r=this.stream.clipPos(i.pos);if(t.token(this.stream.reset(r,e),i),e.value>-1){let{parser:s}=i.p;for(let o=0;o=0&&i.p.parser.dialect.allows(a>>1)){(a&1)==0?e.value=a>>1:e.extended=a>>1;break}}}else e.value=0,e.end=this.stream.clipPos(r+1)}putAction(e,t,i,r){for(let s=0;se.bufferLength*4?new Yz(i,e.nodeSet):null}get parsedPos(){return this.minStackPos}advance(){let e=this.stacks,t=this.minStackPos,i=this.stacks=[],r,s;if(this.bigReductionCount>300&&e.length==1){let[o]=e;for(;o.forceReduce()&&o.stack.length&&o.stack[o.stack.length-2]>=this.lastBigReductionStart;);this.bigReductionCount=this.lastBigReductionSize=0}for(let o=0;ot)i.push(a);else{if(this.advanceStack(a,i,e))continue;{r||(r=[],s=[]),r.push(a);let c=this.tokens.getMainToken(a);s.push(c.value,c.end)}}break}}if(!i.length){let o=r&&Kz(r);if(o)return $n&&console.log("Finish with "+this.stackID(o)),this.stackToTree(o);if(this.parser.strict)throw $n&&r&&console.log("Stuck with token "+(this.tokens.mainToken?this.parser.getName(this.tokens.mainToken.value):"none")),new SyntaxError("No parse at "+t);this.recovering||(this.recovering=5)}if(this.recovering&&r){let o=this.stoppedAt!=null&&r[0].pos>this.stoppedAt?r[0]:this.runRecovery(r,s,i);if(o)return $n&&console.log("Force-finish "+this.stackID(o)),this.stackToTree(o.forceAll())}if(this.recovering){let o=this.recovering==1?1:this.recovering*3;if(i.length>o)for(i.sort((a,c)=>c.score-a.score);i.length>o;)i.pop();i.some(a=>a.reducePos>t)&&this.recovering--}else if(i.length>1){e:for(let o=0;o500&&h.buffer.length>500)if((a.score-h.score||a.buffer.length-h.buffer.length)>0)i.splice(c--,1);else{i.splice(o--,1);continue e}}}i.length>12&&i.splice(12,i.length-12)}this.minStackPos=i[0].pos;for(let o=1;o ":"";if(this.stoppedAt!=null&&r>this.stoppedAt)return e.forceReduce()?e:null;if(this.fragments){let h=e.curContext&&e.curContext.tracker.strict,f=h?e.curContext.hash:0;for(let p=this.fragments.nodeAt(r);p;){let m=this.parser.nodeSet.types[p.type.id]==p.type?s.getGoto(e.state,p.type.id):-1;if(m>-1&&p.length&&(!h||(p.prop(Ee.contextHash)||0)==f))return e.useNode(p,m),$n&&console.log(o+this.stackID(e)+` (via reuse of ${s.getName(p.type.id)})`),!0;if(!(p instanceof wt)||p.children.length==0||p.positions[0]>0)break;let y=p.children[0];if(y instanceof wt&&p.positions[0]==0)p=y;else break}}let a=s.stateSlot(e.state,4);if(a>0)return e.reduce(a),$n&&console.log(o+this.stackID(e)+` (via always-reduce ${s.getName(a&65535)})`),!0;if(e.stack.length>=8400)for(;e.stack.length>6e3&&e.forceReduce(););let c=this.tokens.getActions(e);for(let h=0;hr?t.push(v):i.push(v)}return!1}advanceFully(e,t){let i=e.pos;for(;;){if(!this.advanceStack(e,null,null))return!1;if(e.pos>i)return cS(e,t),!0}}runRecovery(e,t,i){let r=null,s=!1;for(let o=0;o ":"";if(a.deadEnd&&(s||(s=!0,a.restart(),$n&&console.log(f+this.stackID(a)+" (restarted)"),this.advanceFully(a,i))))continue;let p=a.split(),m=f;for(let y=0;p.forceReduce()&&y<10&&($n&&console.log(m+this.stackID(p)+" (via force-reduce)"),!this.advanceFully(p,i));y++)$n&&(m=this.stackID(p)+" -> ");for(let y of a.recoverByInsert(c))$n&&console.log(f+this.stackID(y)+" (via recover-insert)"),this.advanceFully(y,i);this.stream.end>a.pos?(h==a.pos&&(h++,c=0),a.recoverByDelete(c,h),$n&&console.log(f+this.stackID(a)+` (via recover-delete ${this.parser.getName(c)})`),cS(a,i)):(!r||r.scoren;class Gz{constructor(e){this.start=e.start,this.shift=e.shift||Zg,this.reduce=e.reduce||Zg,this.reuse=e.reuse||Zg,this.hash=e.hash||(()=>0),this.strict=e.strict!==!1}}class Ka extends O_{constructor(e){if(super(),this.wrappers=[],e.version!=14)throw new RangeError(`Parser version (${e.version}) doesn't match runtime version (14)`);let t=e.nodeNames.split(" ");this.minRepeatTerm=t.length;for(let a=0;ae.topRules[a][1]),r=[];for(let a=0;a=0)s(f,c,a[h++]);else{let p=a[h+-f];for(let m=-f;m>0;m--)s(a[h++],c,p);h++}}}this.nodeSet=new l0(t.map((a,c)=>kn.define({name:c>=this.minRepeatTerm?void 0:a,id:c,props:r[c],top:i.indexOf(c)>-1,error:c==0,skipped:e.skippedNodes&&e.skippedNodes.indexOf(c)>-1}))),e.propSources&&(this.nodeSet=this.nodeSet.extend(...e.propSources)),this.strict=!1,this.bufferLength=d_;let o=ha(e.tokenData);this.context=e.context,this.specializerSpecs=e.specialized||[],this.specialized=new Uint16Array(this.specializerSpecs.length);for(let a=0;atypeof a=="number"?new Do(o,a):a),this.topRules=e.topRules,this.dialects=e.dialects||{},this.dynamicPrecedences=e.dynamicPrecedences||null,this.tokenPrecTable=e.tokenPrec,this.termNames=e.termNames||null,this.maxNode=this.nodeSet.types.length-1,this.dialect=this.parseDialect(),this.top=this.topRules[Object.keys(this.topRules)[0]]}createParse(e,t,i){let r=new Uz(this,e,t,i);for(let s of this.wrappers)r=s(r,e,t,i);return r}getGoto(e,t,i=!1){let r=this.goto;if(t>=r[0])return-1;for(let s=r[t+1];;){let o=r[s++],a=o&1,c=r[s++];if(a&&i)return c;for(let h=s+(o>>1);s0}validAction(e,t){return!!this.allActions(e,i=>i==t?!0:null)}allActions(e,t){let i=this.stateSlot(e,4),r=i?t(i):void 0;for(let s=this.stateSlot(e,1);r==null;s+=3){if(this.data[s]==65535)if(this.data[s+1]==1)s=Ji(this.data,s+2);else break;r=t(Ji(this.data,s+1))}return r}nextStates(e){let t=[];for(let i=this.stateSlot(e,1);;i+=3){if(this.data[i]==65535)if(this.data[i+1]==1)i=Ji(this.data,i+2);else break;if((this.data[i+2]&1)==0){let r=this.data[i+1];t.some((s,o)=>o&1&&s==r)||t.push(this.data[i],r)}}return t}configure(e){let t=Object.assign(Object.create(Ka.prototype),this);if(e.props&&(t.nodeSet=this.nodeSet.extend(...e.props)),e.top){let i=this.topRules[e.top];if(!i)throw new RangeError(`Invalid top rule name ${e.top}`);t.top=i}return e.tokenizers&&(t.tokenizers=this.tokenizers.map(i=>{let r=e.tokenizers.find(s=>s.from==i);return r?r.to:i})),e.specializers&&(t.specializers=this.specializers.slice(),t.specializerSpecs=this.specializerSpecs.map((i,r)=>{let s=e.specializers.find(a=>a.from==i.external);if(!s)return i;let o=Object.assign(Object.assign({},i),{external:s.to});return t.specializers[r]=uS(o),o})),e.contextTracker&&(t.context=e.contextTracker),e.dialect&&(t.dialect=this.parseDialect(e.dialect)),e.strict!=null&&(t.strict=e.strict),e.wrap&&(t.wrappers=t.wrappers.concat(e.wrap)),e.bufferLength!=null&&(t.bufferLength=e.bufferLength),t}hasWrappers(){return this.wrappers.length>0}getName(e){return this.termNames?this.termNames[e]:String(e<=this.maxNode&&this.nodeSet.types[e].name||e)}get eofTerm(){return this.maxNode+1}get topNode(){return this.nodeSet.types[this.top[1]]}dynamicPrecedence(e){let t=this.dynamicPrecedences;return t==null?0:t[e]||0}parseDialect(e){let t=Object.keys(this.dialects),i=t.map(()=>!1);if(e)for(let s of e.split(" ")){let o=t.indexOf(s);o>=0&&(i[o]=!0)}let r=null;for(let s=0;si)&&t.p.parser.stateFlag(t.state,2)&&(!e||e.scoren.external(t,i)<<1|e}return n.get}const Jz=36,hS=1,eZ=2,xo=3,Ig=4,tZ=5,nZ=6,iZ=7,rZ=8,sZ=9,oZ=10,lZ=11,aZ=12,cZ=13,uZ=14,hZ=15,fZ=16,dZ=17,fS=18,pZ=19,NQ=20,XQ=21,dS=22,gZ=23,mZ=24;function mO(n){return n>=65&&n<=90||n>=97&&n<=122||n>=48&&n<=57}function OZ(n){return n>=48&&n<=57||n>=97&&n<=102||n>=65&&n<=70}function Ss(n,e,t){for(let i=!1;;){if(n.next<0)return;if(n.next==e&&!i){n.advance();return}i=t&&!i&&n.next==92,n.advance()}}function yZ(n,e){e:for(;;){if(n.next<0)return;if(n.next==36){n.advance();for(let t=0;t)".charCodeAt(t);for(;;){if(n.next<0)return;if(n.next==i&&n.peek(1)==39){n.advance(2);return}n.advance()}}function OO(n,e){for(;!(n.next!=95&&!mO(n.next));)e!=null&&(e+=String.fromCharCode(n.next)),n.advance();return e}function vZ(n){if(n.next==39||n.next==34||n.next==96){let e=n.next;n.advance(),Ss(n,e,!1)}else OO(n)}function pS(n,e){for(;n.next==48||n.next==49;)n.advance();e&&n.next==e&&n.advance()}function gS(n,e){for(;;){if(n.next==46){if(e)break;e=!0}else if(n.next<48||n.next>57)break;n.advance()}if(n.next==69||n.next==101)for(n.advance(),(n.next==43||n.next==45)&&n.advance();n.next>=48&&n.next<=57;)n.advance()}function mS(n){for(;!(n.next<0||n.next==10);)n.advance()}function Os(n,e){for(let t=0;t!=&|~^/",specialVar:"?",identifierQuotes:'"',caseInsensitiveIdentifiers:!1,words:WQ(SZ,bZ)};function wZ(n,e,t,i){let r={};for(let s in yO)r[s]=(n.hasOwnProperty(s)?n:yO)[s];return e&&(r.words=WQ(e,t||"",i)),r}function VQ(n){return new sl(e=>{var t;let{next:i}=e;if(e.advance(),Os(i,jg)){for(;Os(e.next,jg);)e.advance();e.acceptToken(Jz)}else if(i==36&&n.doubleDollarQuotedStrings){let r=OO(e,"");e.next==36&&(e.advance(),yZ(e,r),e.acceptToken(xo))}else if(i==39||i==34&&n.doubleQuotedStrings)Ss(e,i,n.backslashEscapes),e.acceptToken(xo);else if(i==35&&n.hashComments||i==47&&e.next==47&&n.slashComments)mS(e),e.acceptToken(hS);else if(i==45&&e.next==45&&(!n.spaceAfterDashes||e.peek(1)==32))mS(e),e.acceptToken(hS);else if(i==47&&e.next==42){e.advance();for(let r=1;;){let s=e.next;if(e.next<0)break;if(e.advance(),s==42&&e.next==47){if(r--,e.advance(),!r)break}else s==47&&e.next==42&&(r++,e.advance())}e.acceptToken(eZ)}else if((i==101||i==69)&&e.next==39)e.advance(),Ss(e,39,!0),e.acceptToken(xo);else if((i==110||i==78)&&e.next==39&&n.charSetCasts)e.advance(),Ss(e,39,n.backslashEscapes),e.acceptToken(xo);else if(i==95&&n.charSetCasts)for(let r=0;;r++){if(e.next==39&&r>1){e.advance(),Ss(e,39,n.backslashEscapes),e.acceptToken(xo);break}if(!mO(e.next))break;e.advance()}else if(n.plsqlQuotingMechanism&&(i==113||i==81)&&e.next==39&&e.peek(1)>0&&!Os(e.peek(1),jg)){let r=e.peek(1);e.advance(2),xZ(e,r),e.acceptToken(xo)}else if(Os(i,n.identifierQuotes)){const r=i==91?93:i;Ss(e,r,!1),e.acceptToken(pZ)}else if(i==40)e.acceptToken(iZ);else if(i==41)e.acceptToken(rZ);else if(i==123)e.acceptToken(sZ);else if(i==125)e.acceptToken(oZ);else if(i==91)e.acceptToken(lZ);else if(i==93)e.acceptToken(aZ);else if(i==59)e.acceptToken(cZ);else if(n.unquotedBitLiterals&&i==48&&e.next==98)e.advance(),pS(e),e.acceptToken(dS);else if((i==98||i==66)&&(e.next==39||e.next==34)){const r=e.next;e.advance(),n.treatBitsAsBytes?(Ss(e,r,n.backslashEscapes),e.acceptToken(gZ)):(pS(e,r),e.acceptToken(dS))}else if(i==48&&(e.next==120||e.next==88)||(i==120||i==88)&&e.next==39){let r=e.next==39;for(e.advance();OZ(e.next);)e.advance();r&&e.next==39&&e.advance(),e.acceptToken(Ig)}else if(i==46&&e.next>=48&&e.next<=57)gS(e,!0),e.acceptToken(Ig);else if(i==46)e.acceptToken(uZ);else if(i>=48&&i<=57)gS(e,!1),e.acceptToken(Ig);else if(Os(i,n.operatorChars)){for(;Os(e.next,n.operatorChars);)e.advance();e.acceptToken(hZ)}else if(Os(i,n.specialVar))e.next==i&&e.advance(),vZ(e),e.acceptToken(dZ);else if(i==58||i==44)e.acceptToken(fZ);else if(mO(i)){let r=OO(e,String.fromCharCode(i));e.acceptToken(e.next==46||e.peek(-r.length-1)==46?fS:(t=n.words[r.toLowerCase()])!==null&&t!==void 0?t:fS)}})}const FQ=VQ(yO),kZ=Ka.deserialize({version:14,states:"%vQ]QQOOO#wQRO'#DSO$OQQO'#CwO%eQQO'#CxO%lQQO'#CyO%sQQO'#CzOOQQ'#DS'#DSOOQQ'#C}'#C}O'UQRO'#C{OOQQ'#Cv'#CvOOQQ'#C|'#C|Q]QQOOQOQQOOO'`QQO'#DOO(xQRO,59cO)PQQO,59cO)UQQO'#DSOOQQ,59d,59dO)cQQO,59dOOQQ,59e,59eO)jQQO,59eOOQQ,59f,59fO)qQQO,59fOOQQ-E6{-E6{OOQQ,59b,59bOOQQ-E6z-E6zOOQQ,59j,59jOOQQ-E6|-E6|O+VQRO1G.}O+^QQO,59cOOQQ1G/O1G/OOOQQ1G/P1G/POOQQ1G/Q1G/QP+kQQO'#C}O+rQQO1G.}O)PQQO,59cO,PQQO'#Cw",stateData:",[~OtOSPOSQOS~ORUOSUOTUOUUOVROXSOZTO]XO^QO_UO`UOaPObPOcPOdUOeUOfUOgUOhUO~O^]ORvXSvXTvXUvXVvXXvXZvX]vX_vX`vXavXbvXcvXdvXevXfvXgvXhvX~OsvX~P!jOa_Ob_Oc_O~ORUOSUOTUOUUOVROXSOZTO^tO_UO`UOa`Ob`Oc`OdUOeUOfUOgUOhUO~OWaO~P$ZOYcO~P$ZO[eO~P$ZORUOSUOTUOUUOVROXSOZTO^QO_UO`UOaPObPOcPOdUOeUOfUOgUOhUO~O]hOsoX~P%zOajObjOcjO~O^]ORkaSkaTkaUkaVkaXkaZka]ka_ka`kaakabkackadkaekafkagkahka~Oska~P'kO^]O~OWvXYvX[vX~P!jOWnO~P$ZOYoO~P$ZO[pO~P$ZO^]ORkiSkiTkiUkiVkiXkiZki]ki_ki`kiakibkickidkiekifkigkihki~Oski~P)xOWkaYka[ka~P'kO]hO~P$ZOWkiYki[ki~P)xOasObsOcsO~O",goto:"#hwPPPPPPPPPPPPPPPPPPPPPPPPPPx||||!Y!^!d!xPPP#[TYOZeUORSTWZbdfqT[OZQZORiZSWOZQbRQdSQfTZgWbdfqQ^PWk^lmrQl_Qm`RrseVORSTWZbdfq",nodeNames:"⚠ LineComment BlockComment String Number Bool Null ( ) { } [ ] ; . Operator Punctuation SpecialVar Identifier QuotedIdentifier Keyword Type Bits Bytes Builtin Script Statement CompositeIdentifier Parens Braces Brackets Statement",maxTerm:38,nodeProps:[["isolate",-4,1,2,3,19,""]],skippedNodes:[0,1,2],repeatNodeCount:3,tokenData:"RORO",tokenizers:[0,FQ],topRules:{Script:[0,25]},tokenPrec:0});function xO(n){let e=n.cursor().moveTo(n.from,-1);for(;/Comment/.test(e.name);)e.moveTo(e.from,-1);return e.node}function Ja(n,e){let t=n.sliceString(e.from,e.to),i=/^([`'"\[])(.*)([`'"\]])$/.exec(t);return i?i[2]:t}function vf(n){return n&&(n.name=="Identifier"||n.name=="QuotedIdentifier")}function PZ(n,e){if(e.name=="CompositeIdentifier"){let t=[];for(let i=e.firstChild;i;i=i.nextSibling)vf(i)&&t.push(Ja(n,i));return t}return[Ja(n,e)]}function OS(n,e){for(let t=[];;){if(!e||e.name!=".")return t;let i=xO(e);if(!vf(i))return t;t.unshift(Ja(n,i)),e=xO(i)}}function _Z(n,e){let t=Pt(n).resolveInner(e,-1),i=CZ(n.doc,t);return t.name=="Identifier"||t.name=="QuotedIdentifier"||t.name=="Keyword"?{from:t.from,quoted:t.name=="QuotedIdentifier"?n.doc.sliceString(t.from,t.from+1):null,parents:OS(n.doc,xO(t)),aliases:i}:t.name=="."?{from:e,quoted:null,parents:OS(n.doc,t),aliases:i}:{from:e,quoted:null,parents:[],empty:!0,aliases:i}}const QZ=new Set("where group having order union intersect except all distinct limit offset fetch for".split(" "));function CZ(n,e){let t;for(let r=e;!t;r=r.parent){if(!r)return null;r.name=="Statement"&&(t=r)}let i=null;for(let r=t.firstChild,s=!1,o=null;r;r=r.nextSibling){let a=r.name=="Keyword"?n.sliceString(r.from,r.to).toLowerCase():null,c=null;if(!s)s=a=="from";else if(a=="as"&&o&&vf(r.nextSibling))c=Ja(n,r.nextSibling);else{if(a&&QZ.has(a))break;o&&vf(r)&&(c=Ja(n,r))}c&&(i||(i=Object.create(null)),i[c]=PZ(n,o)),o=/Identifier$/.test(r.name)?r:null}return i}function TZ(n,e,t){return t.map(i=>({...i,label:i.label[0]==n?i.label:n+i.label+e,apply:void 0}))}const $Z=/^\w*$/,MZ=/^[`'"\[]?\w*[`'"\]]?$/;function yS(n){return n.self&&typeof n.self.label=="string"}class E0{constructor(e,t){this.idQuote=e,this.idCaseInsensitive=t,this.list=[],this.children=void 0}child(e){let t=this.children||(this.children=Object.create(null)),i=t[e];return i||(e&&!this.list.some(r=>r.label==e)&&this.list.push(xS(e,"type",this.idQuote,this.idCaseInsensitive)),t[e]=new E0(this.idQuote,this.idCaseInsensitive))}maybeChild(e){return this.children?this.children[e]:null}addCompletion(e){let t=this.list.findIndex(i=>i.label==e.label);t>-1?this.list[t]=e:this.list.push(e)}addCompletions(e){for(let t of e)this.addCompletion(typeof t=="string"?xS(t,"property",this.idQuote,this.idCaseInsensitive):t)}addNamespace(e){Array.isArray(e)?this.addCompletions(e):yS(e)?this.addNamespace(e.children):this.addNamespaceObject(e)}addNamespaceObject(e){for(let t of Object.keys(e)){let i=e[t],r=null,s=t.replace(/\\?\./g,a=>a=="."?"\0":a).split("\0"),o=this;yS(i)&&(r=i.self,i=i.children);for(let a=0;a{let{parents:p,from:m,quoted:y,empty:v,aliases:b}=_Z(f.state,f.pos);if(v&&!f.explicit)return null;b&&p.length==1&&(p=b[p[0]]||p);let S=c;for(let C of p){for(;!S.children||!S.children[C];)if(S==c&&h)S=h;else if(S==h&&i)S=S.child(i);else return null;let _=S.maybeChild(C);if(!_)return null;S=_}let w=S.list;if(S==c&&b&&(w=w.concat(Object.keys(b).map(C=>({label:C,type:"constant"})))),y){let C=y[0],_=YQ(C),P=f.state.sliceDoc(f.pos,f.pos+1)==_;return{from:m,to:P?f.pos+1:void 0,options:TZ(C,_,w),validFor:MZ}}else return{from:m,options:w,validFor:$Z}}}function AZ(n){return n==XQ?"type":n==NQ?"keyword":"variable"}function EZ(n,e,t){let i=Object.keys(n).map(r=>t(e?r.toUpperCase():r,AZ(n[r])));return _Q(["QuotedIdentifier","String","LineComment","BlockComment","."],k0(i))}let LZ=kZ.configure({props:[p0.add({Statement:Rh()}),m0.add({Statement(n,e){return{from:Math.min(n.from+100,e.doc.lineAt(n.from).to),to:n.to}},BlockComment(n){return{from:n.from+2,to:n.to-2}}}),h0({Keyword:R.keyword,Type:R.typeName,Builtin:R.standard(R.name),Bits:R.number,Bytes:R.string,Bool:R.bool,Null:R.null,Number:R.number,String:R.string,Identifier:R.name,QuotedIdentifier:R.special(R.string),SpecialVar:R.special(R.name),LineComment:R.lineComment,BlockComment:R.blockComment,Operator:R.operator,"Semi Punctuation":R.punctuation,"( )":R.paren,"{ }":R.brace,"[ ]":R.squareBracket})]});class bf{constructor(e,t,i){this.dialect=e,this.language=t,this.spec=i}get extension(){return this.language.extension}configureLanguage(e,t){return new bf(this.dialect,this.language.configure(e,t),this.spec)}static define(e){let t=wZ(e,e.keywords,e.types,e.builtin),i=Wa.define({name:"sql",parser:LZ.configure({tokenizers:[{from:FQ,to:VQ(t)}]}),languageData:{commentTokens:{line:"--",block:{open:"/*",close:"*/"}},closeBrackets:{brackets:["(","[","{","'",'"',"`"]}}});return new bf(t,i,e)}}function DZ(n,e){return{label:n,type:e,boost:-1}}function zZ(n,e=!1,t){return EZ(n.dialect.words,e,t||DZ)}function ZZ(n){return n.schema?RZ(n.schema,n.tables,n.schemas,n.defaultTable,n.defaultSchema,n.dialect||L0):()=>null}function IZ(n){return n.schema?(n.dialect||L0).language.data.of({autocomplete:ZZ(n)}):[]}function jZ(n={}){let e=n.dialect||L0;return new S_(e.language,[IZ(n),e.language.data.of({autocomplete:zZ(e,n.upperCaseKeywords,n.keywordCompletion)})])}const L0=bf.define({}),BZ=316,NZ=317,vS=1,XZ=2,WZ=3,VZ=4,FZ=318,YZ=320,qZ=321,UZ=5,HZ=6,GZ=0,vO=[9,10,11,12,13,32,133,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288],qQ=125,KZ=59,bO=47,JZ=42,e3=43,t3=45,n3=60,i3=44,r3=63,s3=46,o3=91,l3=new Gz({start:!1,shift(n,e){return e==UZ||e==HZ||e==YZ?n:e==qZ},strict:!1}),a3=new sl((n,e)=>{let{next:t}=n;(t==qQ||t==-1||e.context)&&n.acceptToken(FZ)},{contextual:!0,fallback:!0}),c3=new sl((n,e)=>{let{next:t}=n,i;vO.indexOf(t)>-1||t==bO&&((i=n.peek(1))==bO||i==JZ)||t!=qQ&&t!=KZ&&t!=-1&&!e.context&&n.acceptToken(BZ)},{contextual:!0}),u3=new sl((n,e)=>{n.next==o3&&!e.context&&n.acceptToken(NZ)},{contextual:!0}),h3=new sl((n,e)=>{let{next:t}=n;if(t==e3||t==t3){if(n.advance(),t==n.next){n.advance();let i=!e.context&&e.canShift(vS);n.acceptToken(i?vS:XZ)}}else t==r3&&n.peek(1)==s3&&(n.advance(),n.advance(),(n.next<48||n.next>57)&&n.acceptToken(WZ))},{contextual:!0});function Bg(n,e){return n>=65&&n<=90||n>=97&&n<=122||n==95||n>=192||!e&&n>=48&&n<=57}const f3=new sl((n,e)=>{if(n.next!=n3||!e.dialectEnabled(GZ)||(n.advance(),n.next==bO))return;let t=0;for(;vO.indexOf(n.next)>-1;)n.advance(),t++;if(Bg(n.next,!0)){for(n.advance(),t++;Bg(n.next,!1);)n.advance(),t++;for(;vO.indexOf(n.next)>-1;)n.advance(),t++;if(n.next==i3)return;for(let i=0;;i++){if(i==7){if(!Bg(n.next,!0))return;break}if(n.next!="extends".charCodeAt(i))break;n.advance(),t++}}n.acceptToken(VZ,-t)}),d3=h0({"get set async static":R.modifier,"for while do if else switch try catch finally return throw break continue default case defer":R.controlKeyword,"in of await yield void typeof delete instanceof as satisfies":R.operatorKeyword,"let var const using function class extends":R.definitionKeyword,"import export from":R.moduleKeyword,"with debugger new":R.keyword,TemplateString:R.special(R.string),super:R.atom,BooleanLiteral:R.bool,this:R.self,null:R.null,Star:R.modifier,VariableName:R.variableName,"CallExpression/VariableName TaggedTemplateExpression/VariableName":R.function(R.variableName),VariableDefinition:R.definition(R.variableName),Label:R.labelName,PropertyName:R.propertyName,PrivatePropertyName:R.special(R.propertyName),"CallExpression/MemberExpression/PropertyName":R.function(R.propertyName),"FunctionDeclaration/VariableDefinition":R.function(R.definition(R.variableName)),"ClassDeclaration/VariableDefinition":R.definition(R.className),"NewExpression/VariableName":R.className,PropertyDefinition:R.definition(R.propertyName),PrivatePropertyDefinition:R.definition(R.special(R.propertyName)),UpdateOp:R.updateOperator,"LineComment Hashbang":R.lineComment,BlockComment:R.blockComment,Number:R.number,String:R.string,Escape:R.escape,ArithOp:R.arithmeticOperator,LogicOp:R.logicOperator,BitOp:R.bitwiseOperator,CompareOp:R.compareOperator,RegExp:R.regexp,Equals:R.definitionOperator,Arrow:R.function(R.punctuation),": Spread":R.punctuation,"( )":R.paren,"[ ]":R.squareBracket,"{ }":R.brace,"InterpolationStart InterpolationEnd":R.special(R.brace),".":R.derefOperator,", ;":R.separator,"@":R.meta,TypeName:R.typeName,TypeDefinition:R.definition(R.typeName),"type enum interface implements namespace module declare":R.definitionKeyword,"abstract global Privacy readonly override":R.modifier,"is keyof unique infer asserts":R.operatorKeyword,JSXAttributeValue:R.attributeValue,JSXText:R.content,"JSXStartTag JSXStartCloseTag JSXSelfCloseEndTag JSXEndTag":R.angleBracket,"JSXIdentifier JSXNameSpacedName":R.tagName,"JSXAttribute/JSXIdentifier JSXAttribute/JSXNameSpacedName":R.attributeName,"JSXBuiltin/JSXIdentifier":R.standard(R.tagName)}),p3={__proto__:null,export:20,as:25,from:33,default:36,async:41,function:42,in:52,out:55,const:56,extends:60,this:64,true:72,false:72,null:84,void:88,typeof:92,super:108,new:142,delete:154,yield:163,await:167,class:172,public:235,private:235,protected:235,readonly:237,instanceof:256,satisfies:259,import:292,keyof:349,unique:353,infer:359,asserts:395,is:397,abstract:417,implements:419,type:421,let:424,var:426,using:429,interface:435,enum:439,namespace:445,module:447,declare:451,global:455,defer:471,for:476,of:485,while:488,with:492,do:496,if:500,else:502,switch:506,case:512,try:518,catch:522,finally:526,return:530,throw:534,break:538,continue:542,debugger:546},g3={__proto__:null,async:129,get:131,set:133,declare:195,public:197,private:197,protected:197,static:199,abstract:201,override:203,readonly:209,accessor:211,new:401},m3={__proto__:null,"<":193},O3=Ka.deserialize({version:14,states:"$F|Q%TQlOOO%[QlOOO'_QpOOP(lO`OOO*zQ!0MxO'#CiO+RO#tO'#CjO+aO&jO'#CjO+oO#@ItO'#DaO.QQlO'#DgO.bQlO'#DrO%[QlO'#DzO0fQlO'#ESOOQ!0Lf'#E['#E[O1PQ`O'#EXOOQO'#Ep'#EpOOQO'#Il'#IlO1XQ`O'#GsO1dQ`O'#EoO1iQ`O'#EoO3hQ!0MxO'#JrO6[Q!0MxO'#JsO6uQ`O'#F]O6zQ,UO'#FtOOQ!0Lf'#Ff'#FfO7VO7dO'#FfO9XQMhO'#F|O9`Q`O'#F{OOQ!0Lf'#Js'#JsOOQ!0Lb'#Jr'#JrO9eQ`O'#GwOOQ['#K_'#K_O9pQ`O'#IYO9uQ!0LrO'#IZOOQ['#J`'#J`OOQ['#I_'#I_Q`QlOOQ`QlOOO9}Q!L^O'#DvO:UQlO'#EOO:]QlO'#EQO9kQ`O'#GsO:dQMhO'#CoO:rQ`O'#EnO:}Q`O'#EyO;hQMhO'#FeO;xQ`O'#GsOOQO'#K`'#K`O;}Q`O'#K`O<]Q`O'#G{O<]Q`O'#G|O<]Q`O'#HOO9kQ`O'#HRO=SQ`O'#HUO>kQ`O'#CeO>{Q`O'#HcO?TQ`O'#HiO?TQ`O'#HkO`QlO'#HmO?TQ`O'#HoO?TQ`O'#HrO?YQ`O'#HxO?_Q!0LsO'#IOO%[QlO'#IQO?jQ!0LsO'#ISO?uQ!0LsO'#IUO9uQ!0LrO'#IWO@QQ!0MxO'#CiOASQpO'#DlQOQ`OOO%[QlO'#EQOAjQ`O'#ETO:dQMhO'#EnOAuQ`O'#EnOBQQ!bO'#FeOOQ['#Cg'#CgOOQ!0Lb'#Dq'#DqOOQ!0Lb'#Jv'#JvO%[QlO'#JvOOQO'#Jy'#JyOOQO'#Ih'#IhOCQQpO'#EgOOQ!0Lb'#Ef'#EfOOQ!0Lb'#J}'#J}OC|Q!0MSO'#EgODWQpO'#EWOOQO'#Jx'#JxODlQpO'#JyOEyQpO'#EWODWQpO'#EgPFWO&2DjO'#CbPOOO)CD})CD}OOOO'#I`'#I`OFcO#tO,59UOOQ!0Lh,59U,59UOOOO'#Ia'#IaOFqO&jO,59UOGPQ!L^O'#DcOOOO'#Ic'#IcOGWO#@ItO,59{OOQ!0Lf,59{,59{OGfQlO'#IdOGyQ`O'#JtOIxQ!fO'#JtO+}QlO'#JtOJPQ`O,5:ROJgQ`O'#EpOJtQ`O'#KTOKPQ`O'#KSOKPQ`O'#KSOKXQ`O,5;^OK^Q`O'#KROOQ!0Ln,5:^,5:^OKeQlO,5:^OMcQ!0MxO,5:fONSQ`O,5:nONmQ!0LrO'#KQONtQ`O'#KPO9eQ`O'#KPO! YQ`O'#KPO! bQ`O,5;]O! gQ`O'#KPO!#lQ!fO'#JsOOQ!0Lh'#Ci'#CiO%[QlO'#ESO!$[Q!fO,5:sOOQS'#Jz'#JzOOQO-EtOOQ['#Jh'#JhOOQ[,5>u,5>uOOQ[-E<]-E<]O!TO`QlO,5>VO!LOQ`O,5>XO`QlO,5>ZO!LTQ`O,5>^O!LYQlO,5>dOOQ[,5>j,5>jO%[QlO,5>jO9uQ!0LrO,5>lOOQ[,5>n,5>nO#!dQ`O,5>nOOQ[,5>p,5>pO#!dQ`O,5>pOOQ[,5>r,5>rO##QQpO'#D_O%[QlO'#JvO##sQpO'#JvO##}QpO'#DmO#$`QpO'#DmO#&qQlO'#DmO#&xQ`O'#JuO#'QQ`O,5:WO#'VQ`O'#EtO#'eQ`O'#KUO#'mQ`O,5;_O#'rQpO'#DmO#(PQpO'#EVOOQ!0Lf,5:o,5:oO%[QlO,5:oO#(WQ`O,5:oO?YQ`O,5;YO!CUQpO,5;YO!C^QMhO,5;YO:dQMhO,5;YO#(`Q`O,5@bO#(eQ07dO,5:sOOQO-EPO$6^Q`O,5>POOQ[1G3i1G3iO`QlO1G3iOOQ[1G3o1G3oOOQ[1G3q1G3qO?TQ`O1G3sO$6cQlO1G3uO$:gQlO'#HtOOQ[1G3x1G3xO$:tQ`O'#HzO?YQ`O'#H|OOQ[1G4O1G4OO$:|QlO1G4OO9uQ!0LrO1G4UOOQ[1G4W1G4WOOQ!0Lb'#G_'#G_O9uQ!0LrO1G4YO9uQ!0LrO1G4[O$?TQ`O,5@bO!)[QlO,5;`O9eQ`O,5;`O?YQ`O,5:XO!)[QlO,5:XO!CUQpO,5:XO$?YQ?MtO,5:XOOQO,5;`,5;`O$?dQpO'#IeO$?zQ`O,5@aOOQ!0Lf1G/r1G/rO$@SQpO'#IkO$@^Q`O,5@pOOQ!0Lb1G0y1G0yO#$`QpO,5:XOOQO'#Ig'#IgO$@fQpO,5:qOOQ!0Ln,5:q,5:qO#(ZQ`O1G0ZOOQ!0Lf1G0Z1G0ZO%[QlO1G0ZOOQ!0Lf1G0t1G0tO?YQ`O1G0tO!CUQpO1G0tO!C^QMhO1G0tOOQ!0Lb1G5|1G5|O!ByQ!0LrO1G0^OOQO1G0m1G0mO%[QlO1G0mO$@mQ!0LrO1G0mO$@xQ!0LrO1G0mO!CUQpO1G0^ODWQpO1G0^O$AWQ!0LrO1G0mOOQO1G0^1G0^O$AlQ!0MxO1G0mPOOO-E<[-E<[POOO1G.h1G.hOOOO1G/i1G/iO$AvQ!bO,5QQpO,5@}OOQ!0Lb1G3c1G3cOOQ[7+$V7+$VO@zQ`O7+$VO9uQ!0LrO7+$VO%>]Q`O7+$VO%[QlO1G6lO%[QlO1G6mO%>bQ!0LrO1G6lO%>lQlO1G3kO%>sQ`O1G3kO%>xQlO1G3kOOQ[7+)T7+)TO9uQ!0LrO7+)_O`QlO7+)aOOQ['#Kh'#KhOOQ['#JS'#JSO%?PQlO,5>`OOQ[,5>`,5>`O%[QlO'#HuO%?^Q`O'#HwOOQ[,5>f,5>fO9eQ`O,5>fOOQ[,5>h,5>hOOQ[7+)j7+)jOOQ[7+)p7+)pOOQ[7+)t7+)tOOQ[7+)v7+)vO%?cQpO1G5|O%?}Q?MtO1G0zO%@XQ`O1G0zOOQO1G/s1G/sO%@dQ?MtO1G/sO?YQ`O1G/sO!)[QlO'#DmOOQO,5?P,5?POOQO-ERQ`O7+,WO&>WQ`O7+,XO%[QlO7+,WO%[QlO7+,XOOQ[7+)V7+)VO&>]Q`O7+)VO&>bQlO7+)VO&>iQ`O7+)VOOQ[<nQ`O,5>aOOQ[,5>c,5>cO&>sQ`O1G4QO9eQ`O7+&fO!)[QlO7+&fOOQO7+%_7+%_O&>xQ?MtO1G6ZO?YQ`O7+%_OOQ!0Lf<yQ?MvO,5?aO'@|Q?MvO,5?cO'CPQ?MvO7+'|O'DuQMjOG27TOOQO<VO!l$xO#jROe!iOpkOrPO(T)]O(VTO(YUO(aVO(o[O~O!]$_Oa$qa'z$qa'w$qa!k$qa!Y$qa!_$qa%i$qa!g$qa~Ol)dO~P!&zOh%VOp%WOr%XOs$tOt$tOz%YO|%ZO!O%]O!S${O!_$|O!i%bO!l$xO#j%cO$W%`O$t%^O$v%_O$y%aO(T(vO(VTO(YUO(a$uO(y$}O(z%PO~Og(pP~P!,TO!Q)iO!g)hO!_$^X$Z$^X$]$^X$_$^X$f$^X~O!g)hO!_({X$Z({X$]({X$_({X$f({X~O!Q)iO~P!.^O!Q)iO!_({X$Z({X$]({X$_({X$f({X~O!_)kO$Z)oO$])jO$_)jO$f)pO~O![)sO~P!)[O$]$hO$_$gO$f)wO~On$zX!Q$zX#S$zX'y$zX(y$zX(z$zX~OgmXg$zXnmX!]mX#`mX~P!0SOx)yO(b)zO(c)|O~On*VO!Q*OO'y*PO(y$}O(z%PO~Og)}O~P!1WOg*WO~Oh%VOr%XOs$tOt$tOz%YO|%ZO!OVO!l$xO#jVO!l$xO#jROe!iOpkOrPO(VTO(YUO(aVO(o[O~O(T=QO~P#$qO!]-]O!^(iX~O!^-_O~O!g-VO#`-UO!]#hX!^#hX~O!]-`O!^(xX~O!^-bO~O!c-cO!d-cO(U!lO~P#$`O!^-fO~P'_On-iO!_'`O~O!Y-nO~Os!{a!b!{a!c!{a!d!{a#T!{a#U!{a#V!{a#W!{a#X!{a#[!{a#]!{a(U!{a(V!{a(Y!{a(e!{a(o!{a~P!#vO!p-sO#`-qO~PChO!c-uO!d-uO(U!lO~PDWOa%nO#`-qO'z%nO~Oa%nO!g#vO#`-qO'z%nO~Oa%nO!g#vO!p-sO#`-qO'z%nO(r'pO~O(P'xO(Q'xO(R-zO~Ov-{O~O!Y'Wa!]'Wa~P!:tO![.PO!Y'WX!]'WX~P%[O!](VO!Y(ha~O!Y(ha~PHRO!](^O!Y(va~O!S%hO![.TO!_%iO(T%gO!Y'^X!]'^X~O#`.VO!](ta!k(taa(ta'z(ta~O!g#vO~P#,wO!](jO!k(sa~O!S%hO!_%iO#j.ZO(T%gO~Op.`O!S%hO![.]O!_%iO!|]O#i._O#j.]O(T%gO!]'aX!k'aX~OR.dO!l#xO~Oh%VOn.gO!_'`O%i.fO~Oa#ci!]#ci'z#ci'w#ci!Y#ci!k#civ#ci!_#ci%i#ci!g#ci~P!:tOn>]O!Q*OO'y*PO(y$}O(z%PO~O#k#_aa#_a#`#_a'z#_a!]#_a!k#_a!_#_a!Y#_a~P#/sO#k(`XP(`XR(`X[(`Xa(`Xj(`Xr(`X!S(`X!l(`X!p(`X#R(`X#n(`X#o(`X#p(`X#q(`X#r(`X#s(`X#t(`X#u(`X#v(`X#x(`X#z(`X#{(`X'z(`X(a(`X(r(`X!k(`X!Y(`X'w(`Xv(`X!_(`X%i(`X!g(`X~P!6kO!].tO!k(kX~P!:tO!k.wO~O!Y.yO~OP$[OR#zO!Q#yO!S#{O!l#xO!p$[O(aVO[#mia#mij#mir#mi!]#mi#R#mi#o#mi#p#mi#q#mi#r#mi#s#mi#t#mi#u#mi#v#mi#x#mi#z#mi#{#mi'z#mi(r#mi(y#mi(z#mi'w#mi!Y#mi!k#miv#mi!_#mi%i#mi!g#mi~O#n#mi~P#3cO#n$OO~P#3cOP$[OR#zOr$aO!Q#yO!S#{O!l#xO!p$[O#n$OO#o$PO#p$PO#q$PO(aVO[#mia#mij#mi!]#mi#R#mi#s#mi#t#mi#u#mi#v#mi#x#mi#z#mi#{#mi'z#mi(r#mi(y#mi(z#mi'w#mi!Y#mi!k#miv#mi!_#mi%i#mi!g#mi~O#r#mi~P#6QO#r$QO~P#6QOP$[OR#zO[$cOj$ROr$aO!Q#yO!S#{O!l#xO!p$[O#R$RO#n$OO#o$PO#p$PO#q$PO#r$QO#s$RO#t$RO#u$bO(aVOa#mi!]#mi#x#mi#z#mi#{#mi'z#mi(r#mi(y#mi(z#mi'w#mi!Y#mi!k#miv#mi!_#mi%i#mi!g#mi~O#v#mi~P#8oOP$[OR#zO[$cOj$ROr$aO!Q#yO!S#{O!l#xO!p$[O#R$RO#n$OO#o$PO#p$PO#q$PO#r$QO#s$RO#t$RO#u$bO#v$SO(aVO(z#}Oa#mi!]#mi#z#mi#{#mi'z#mi(r#mi(y#mi'w#mi!Y#mi!k#miv#mi!_#mi%i#mi!g#mi~O#x$UO~P#;VO#x#mi~P#;VO#v$SO~P#8oOP$[OR#zO[$cOj$ROr$aO!Q#yO!S#{O!l#xO!p$[O#R$RO#n$OO#o$PO#p$PO#q$PO#r$QO#s$RO#t$RO#u$bO#v$SO#x$UO(aVO(y#|O(z#}Oa#mi!]#mi#{#mi'z#mi(r#mi'w#mi!Y#mi!k#miv#mi!_#mi%i#mi!g#mi~O#z#mi~P#={O#z$WO~P#={OP]XR]X[]Xj]Xr]X!Q]X!S]X!l]X!p]X#R]X#S]X#`]X#kfX#n]X#o]X#p]X#q]X#r]X#s]X#t]X#u]X#v]X#x]X#z]X#{]X$Q]X(a]X(r]X(y]X(z]X!]]X!^]X~O$O]X~P#@jOP$[OR#zO[]O!Q*OO'y*PO(y$}O(z%POP#miR#mi!S#mi!l#mi!p#mi#n#mi#o#mi#p#mi#q#mi(a#mi~P#EyO!]/POg(pX~P!1WOg/RO~Oa$Pi!]$Pi'z$Pi'w$Pi!Y$Pi!k$Piv$Pi!_$Pi%i$Pi!g$Pi~P!:tO$]/SO$_/SO~O$]/TO$_/TO~O!g)hO#`/UO!_$cX$Z$cX$]$cX$_$cX$f$cX~O![/VO~O!_)kO$Z/XO$])jO$_)jO$f/YO~O!]VO!l$xO#j^O!Q*OO'y*PO(y$}O(z%POP#miR#mi!S#mi!l#mi!p#mi#n#mi#o#mi#p#mi#q#mi(a#mi~P&,QO#S$dOP(`XR(`X[(`Xj(`Xn(`Xr(`X!Q(`X!S(`X!l(`X!p(`X#R(`X#n(`X#o(`X#p(`X#q(`X#r(`X#s(`X#t(`X#u(`X#v(`X#x(`X#z(`X#{(`X$O(`X'y(`X(a(`X(r(`X(y(`X(z(`X!](`X!^(`X~O$O$Pi!]$Pi!^$Pi~P#BwO$O!ri!^!ri~P$+oOg']a!]']a~P!1WO!^7nO~O!]'da!^'da~P#BwO!Y7oO~P#/sO!g#vO(r'pO!]'ea!k'ea~O!]/pO!k)Oi~O!]/pO!g#vO!k)Oi~Og$|q!]$|q#`$|q$O$|q~P!1WO!Y'ga!]'ga~P#/sO!g7vO~O!]/yO!Y)Pi~P#/sO!]/yO!Y)Pi~O!Y7yO~Oh%VOr8OO!l%eO(r'pO~Oj8QO!g#vO~Or8TO!g#vO(r'pO~O!Q*OO'y*PO(z%POn'ja(y'ja!]'ja#`'ja~Og'ja$O'ja~P&5RO!Q*OO'y*POn'la(y'la(z'la!]'la#`'la~Og'la$O'la~P&5tOg(_q!](_q~P!1WO#`8VOg(_q!](_q~P!1WO!Y8WO~Og%Oq!]%Oq#`%Oq$O%Oq~P!1WOa$oy!]$oy'z$oy'w$oy!Y$oy!k$oyv$oy!_$oy%i$oy!g$oy~P!:tO!g6rO~O!]5[O!_)Qa~O!_'`OP$TaR$Ta[$Taj$Tar$Ta!Q$Ta!S$Ta!]$Ta!l$Ta!p$Ta#R$Ta#n$Ta#o$Ta#p$Ta#q$Ta#r$Ta#s$Ta#t$Ta#u$Ta#v$Ta#x$Ta#z$Ta#{$Ta(a$Ta(r$Ta(y$Ta(z$Ta~O%i7WO~P&8fO%^8[Oa%[i!_%[i'z%[i!]%[i~Oa#cy!]#cy'z#cy'w#cy!Y#cy!k#cyv#cy!_#cy%i#cy!g#cy~P!:tO[8^O~Ob8`O(T+qO(VTO(YUO~O!]1TO!^)Xi~O`8dO~O(e(|O!]'pX!^'pX~O!]5uO!^)Ua~O!^8nO~P%;eO(o!sO~P$&YO#[8oO~O!_1oO~O!_1oO%i8qO~On8tO!_1oO%i8qO~O[8yO!]'sa!^'sa~O!]1zO!^)Vi~O!k8}O~O!k9OO~O!k9RO~O!k9RO~P%[Oa9TO~O!g9UO~O!k9VO~O!](wi!^(wi~P#BwOa%nO#`9_O'z%nO~O!](ty!k(tya(ty'z(ty~P!:tO!](jO!k(sy~O%i9bO~P&8fO!_'`O%i9bO~O#k$|qP$|qR$|q[$|qa$|qj$|qr$|q!S$|q!]$|q!l$|q!p$|q#R$|q#n$|q#o$|q#p$|q#q$|q#r$|q#s$|q#t$|q#u$|q#v$|q#x$|q#z$|q#{$|q'z$|q(a$|q(r$|q!k$|q!Y$|q'w$|q#`$|qv$|q!_$|q%i$|q!g$|q~P#/sO#k'jaP'jaR'ja['jaa'jaj'jar'ja!S'ja!l'ja!p'ja#R'ja#n'ja#o'ja#p'ja#q'ja#r'ja#s'ja#t'ja#u'ja#v'ja#x'ja#z'ja#{'ja'z'ja(a'ja(r'ja!k'ja!Y'ja'w'jav'ja!_'ja%i'ja!g'ja~P&5RO#k'laP'laR'la['laa'laj'lar'la!S'la!l'la!p'la#R'la#n'la#o'la#p'la#q'la#r'la#s'la#t'la#u'la#v'la#x'la#z'la#{'la'z'la(a'la(r'la!k'la!Y'la'w'lav'la!_'la%i'la!g'la~P&5tO#k%OqP%OqR%Oq[%Oqa%Oqj%Oqr%Oq!S%Oq!]%Oq!l%Oq!p%Oq#R%Oq#n%Oq#o%Oq#p%Oq#q%Oq#r%Oq#s%Oq#t%Oq#u%Oq#v%Oq#x%Oq#z%Oq#{%Oq'z%Oq(a%Oq(r%Oq!k%Oq!Y%Oq'w%Oq#`%Oqv%Oq!_%Oq%i%Oq!g%Oq~P#/sO!]'Yi!k'Yi~P!:tO$O#cq!]#cq!^#cq~P#BwO(y$}OP%aaR%aa[%aaj%aar%aa!S%aa!l%aa!p%aa#R%aa#n%aa#o%aa#p%aa#q%aa#r%aa#s%aa#t%aa#u%aa#v%aa#x%aa#z%aa#{%aa$O%aa(a%aa(r%aa!]%aa!^%aa~On%aa!Q%aa'y%aa(z%aa~P&IyO(z%POP%caR%ca[%caj%car%ca!S%ca!l%ca!p%ca#R%ca#n%ca#o%ca#p%ca#q%ca#r%ca#s%ca#t%ca#u%ca#v%ca#x%ca#z%ca#{%ca$O%ca(a%ca(r%ca!]%ca!^%ca~On%ca!Q%ca'y%ca(y%ca~P&LQOn>^O!Q*OO'y*PO(z%PO~P&IyOn>^O!Q*OO'y*PO(y$}O~P&LQOR0kO!Q0kO!S0lO#S$dOP}a[}aj}an}ar}a!l}a!p}a#R}a#n}a#o}a#p}a#q}a#r}a#s}a#t}a#u}a#v}a#x}a#z}a#{}a$O}a'y}a(a}a(r}a(y}a(z}a!]}a!^}a~O!Q*OO'y*POP$saR$sa[$saj$san$sar$sa!S$sa!l$sa!p$sa#R$sa#n$sa#o$sa#p$sa#q$sa#r$sa#s$sa#t$sa#u$sa#v$sa#x$sa#z$sa#{$sa$O$sa(a$sa(r$sa(y$sa(z$sa!]$sa!^$sa~O!Q*OO'y*POP$uaR$ua[$uaj$uan$uar$ua!S$ua!l$ua!p$ua#R$ua#n$ua#o$ua#p$ua#q$ua#r$ua#s$ua#t$ua#u$ua#v$ua#x$ua#z$ua#{$ua$O$ua(a$ua(r$ua(y$ua(z$ua!]$ua!^$ua~On>^O!Q*OO'y*PO(y$}O(z%PO~OP%TaR%Ta[%Taj%Tar%Ta!S%Ta!l%Ta!p%Ta#R%Ta#n%Ta#o%Ta#p%Ta#q%Ta#r%Ta#s%Ta#t%Ta#u%Ta#v%Ta#x%Ta#z%Ta#{%Ta$O%Ta(a%Ta(r%Ta!]%Ta!^%Ta~P''VO$O$mq!]$mq!^$mq~P#BwO$O$oq!]$oq!^$oq~P#BwO!^9oO~O$O9pO~P!1WO!g#vO!]'ei!k'ei~O!g#vO(r'pO!]'ei!k'ei~O!]/pO!k)Oq~O!Y'gi!]'gi~P#/sO!]/yO!Y)Pq~Or9wO!g#vO(r'pO~O[9yO!Y9xO~P#/sO!Y9xO~Oj:PO!g#vO~Og(_y!](_y~P!1WO!]'na!_'na~P#/sOa%[q!_%[q'z%[q!]%[q~P#/sO[:UO~O!]1TO!^)Xq~O`:YO~O#`:ZO!]'pa!^'pa~O!]5uO!^)Ui~P#BwO!S:]O~O!_1oO%i:`O~O(VTO(YUO(e:eO~O!]1zO!^)Vq~O!k:hO~O!k:iO~O!k:jO~O!k:jO~P%[O#`:mO!]#hy!^#hy~O!]#hy!^#hy~P#BwO%i:rO~P&8fO!_'`O%i:rO~O$O#|y!]#|y!^#|y~P#BwOP$|iR$|i[$|ij$|ir$|i!S$|i!l$|i!p$|i#R$|i#n$|i#o$|i#p$|i#q$|i#r$|i#s$|i#t$|i#u$|i#v$|i#x$|i#z$|i#{$|i$O$|i(a$|i(r$|i!]$|i!^$|i~P''VO!Q*OO'y*PO(z%POP'iaR'ia['iaj'ian'iar'ia!S'ia!l'ia!p'ia#R'ia#n'ia#o'ia#p'ia#q'ia#r'ia#s'ia#t'ia#u'ia#v'ia#x'ia#z'ia#{'ia$O'ia(a'ia(r'ia(y'ia!]'ia!^'ia~O!Q*OO'y*POP'kaR'ka['kaj'kan'kar'ka!S'ka!l'ka!p'ka#R'ka#n'ka#o'ka#p'ka#q'ka#r'ka#s'ka#t'ka#u'ka#v'ka#x'ka#z'ka#{'ka$O'ka(a'ka(r'ka(y'ka(z'ka!]'ka!^'ka~O(y$}OP%aiR%ai[%aij%ain%air%ai!Q%ai!S%ai!l%ai!p%ai#R%ai#n%ai#o%ai#p%ai#q%ai#r%ai#s%ai#t%ai#u%ai#v%ai#x%ai#z%ai#{%ai$O%ai'y%ai(a%ai(r%ai(z%ai!]%ai!^%ai~O(z%POP%ciR%ci[%cij%cin%cir%ci!Q%ci!S%ci!l%ci!p%ci#R%ci#n%ci#o%ci#p%ci#q%ci#r%ci#s%ci#t%ci#u%ci#v%ci#x%ci#z%ci#{%ci$O%ci'y%ci(a%ci(r%ci(y%ci!]%ci!^%ci~O$O$oy!]$oy!^$oy~P#BwO$O#cy!]#cy!^#cy~P#BwO!g#vO!]'eq!k'eq~O!]/pO!k)Oy~O!Y'gq!]'gq~P#/sOr:|O!g#vO(r'pO~O[;QO!Y;PO~P#/sO!Y;PO~Og(_!R!](_!R~P!1WOa%[y!_%[y'z%[y!]%[y~P#/sO!]1TO!^)Xy~O!]5uO!^)Uq~O(T;XO~O!_1oO%i;[O~O!k;_O~O%i;dO~P&8fOP$|qR$|q[$|qj$|qr$|q!S$|q!l$|q!p$|q#R$|q#n$|q#o$|q#p$|q#q$|q#r$|q#s$|q#t$|q#u$|q#v$|q#x$|q#z$|q#{$|q$O$|q(a$|q(r$|q!]$|q!^$|q~P''VO!Q*OO'y*PO(z%POP'jaR'ja['jaj'jan'jar'ja!S'ja!l'ja!p'ja#R'ja#n'ja#o'ja#p'ja#q'ja#r'ja#s'ja#t'ja#u'ja#v'ja#x'ja#z'ja#{'ja$O'ja(a'ja(r'ja(y'ja!]'ja!^'ja~O!Q*OO'y*POP'laR'la['laj'lan'lar'la!S'la!l'la!p'la#R'la#n'la#o'la#p'la#q'la#r'la#s'la#t'la#u'la#v'la#x'la#z'la#{'la$O'la(a'la(r'la(y'la(z'la!]'la!^'la~OP%OqR%Oq[%Oqj%Oqr%Oq!S%Oq!l%Oq!p%Oq#R%Oq#n%Oq#o%Oq#p%Oq#q%Oq#r%Oq#s%Oq#t%Oq#u%Oq#v%Oq#x%Oq#z%Oq#{%Oq$O%Oq(a%Oq(r%Oq!]%Oq!^%Oq~P''VOg%e!Z!]%e!Z#`%e!Z$O%e!Z~P!1WO!Y;hO~P#/sOr;iO!g#vO(r'pO~O[;kO!Y;hO~P#/sO!]'pq!^'pq~P#BwO!]#h!Z!^#h!Z~P#BwO#k%e!ZP%e!ZR%e!Z[%e!Za%e!Zj%e!Zr%e!Z!S%e!Z!]%e!Z!l%e!Z!p%e!Z#R%e!Z#n%e!Z#o%e!Z#p%e!Z#q%e!Z#r%e!Z#s%e!Z#t%e!Z#u%e!Z#v%e!Z#x%e!Z#z%e!Z#{%e!Z'z%e!Z(a%e!Z(r%e!Z!k%e!Z!Y%e!Z'w%e!Z#`%e!Zv%e!Z!_%e!Z%i%e!Z!g%e!Z~P#/sOr;tO!g#vO(r'pO~O!Y;uO~P#/sOr;|O!g#vO(r'pO~O!Y;}O~P#/sOP%e!ZR%e!Z[%e!Zj%e!Zr%e!Z!S%e!Z!l%e!Z!p%e!Z#R%e!Z#n%e!Z#o%e!Z#p%e!Z#q%e!Z#r%e!Z#s%e!Z#t%e!Z#u%e!Z#v%e!Z#x%e!Z#z%e!Z#{%e!Z$O%e!Z(a%e!Z(r%e!Z!]%e!Z!^%e!Z~P''VOrROe!iOpkOrPO(T)]O(VTO(YUO(aVO(o[O~O!]WO!l$xO#jgPPP!>oI[PPPPPPPPP!BOP!C]PPI[!DnPI[PI[I[I[I[I[PI[!FQP!I[P!LbP!Lf!Lp!Lt!LtP!IXP!Lx!LxP#!OP#!SI[PI[#!Y#%_CjA^PA^PA^A^P#&lA^A^#)OA^#+vA^#.SA^A^#.r#1W#1W#1]#1f#1W#1qPP#1WPA^#2ZA^#6YA^A^6mPPP#:_PPP#:x#:xP#:xP#;`#:xPP#;fP#;]P#;]#;y#;]#P#>V#>]#>k#>q#>{#?R#?]#?c#?s#?y#@k#@}#AT#AZ#Ai#BO#Cs#DR#DY#Et#FS#Gt#HS#HY#H`#Hf#Hp#Hv#H|#IW#Ij#IpPPPPPPPPPPP#IvPPPPPPP#Jk#Mx$ b$ i$ qPPP$']P$'f$*_$0x$0{$1O$1}$2Q$2X$2aP$2g$2jP$3W$3[$4S$5b$5g$5}PP$6S$6Y$6^$6a$6e$6i$7e$7|$8e$8i$8l$8o$8y$8|$9Q$9UR!|RoqOXst!Z#d%m&r&t&u&w,s,x2[2_Y!vQ'`-e1o5{Q%tvQ%|yQ&T|Q&j!VS'W!e-]Q'f!iS'l!r!yU*k$|*Z*oQ+o%}S+|&V&WQ,d&dQ-c'_Q-m'gQ-u'mQ0[*qQ1b,OQ1y,eR<{SU+P%]S!S!nQ!r!v!y!z$|'W'_'`'l'm'n*k*o*q*r-]-c-e-u0[0_1o5{5}%[$ti#v$b$c$d$x${%O%Q%^%_%c)y*R*T*V*Y*a*g*w*x+f+i,S,V.f/P/d/m/x/y/{0`0b0i0j0o1f1i1q3c4^4_4j4o5Q5[5_6S7W7v8Q8V8[8q9b9p9y:P:`:r;Q;[;d;kP>X>Y>]>^Q&X|Q'U!eS'[%i-`Q+t&PQ,P&WQ,f&gQ0n+SQ1Y+uQ1_+{Q2Q,jQ2R,kQ5f1TQ5o1aQ6[1zQ6_1|Q6`2PQ8`5gQ8c5lQ8|6bQ:X8dQ:f8yQ;V:YR<}*ZrnOXst!V!Z#d%m&i&r&t&u&w,s,x2[2_R,h&k&z^OPXYstuvwz!Z!`!g!j!o#S#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$Z$_$a$e$n%m%t&R&k&n&o&r&t&u&w&{'T'b'r(V(](d(x(z)O)s)}*i+X+]+g,p,s,x-U-X-i-q.P.V.g.t.{/V/n0]0l0r1S1r2S2T2V2X2[2_2a2p3Q3W3d3l4T4z5w6T6e6f6i6s6|7[8t9T9_:Z:mR>S[#]WZ#W#Z'X(T!b%jm#h#i#l$x%e%h(^(h(i(j*Y*^*b+Z+[+^,o-V.T.Z.[.]._/m/p2d3[3]4a6r7TQ%wxQ%{yW&Q|&V&W,OQ&_!TQ'c!hQ'e!iQ(q#sS+n%|%}Q+r&PQ,_&bQ,c&dS-l'f'gQ.i(rQ1R+oQ1X+uQ1Z+vQ1^+zQ1t,`S1x,d,eQ2|-mQ5e1TQ5i1WQ5n1`Q6Z1yQ8_5gQ8b5kQ8f5pQ:T8^R;T:U!U$zi$d%O%Q%^%_%c*R*T*a*w*x/P/x0`0b0i0j0o4_5Q8V9p>P>X>Y!^%yy!i!u%{%|%}'V'e'f'g'k'u*j+n+o-Y-l-m-t0R0U1R2u2|3T4r4s4v7}9{Q+h%wQ,T&[Q,W&]Q,b&dQ.h(qQ1s,_U1w,c,d,eQ3e.iQ6U1tS6Y1x1yQ8x6Z#f>T#v$b$c$x${)y*V*Y*g+f+i,S,V.f/d/m/y/{1f1i1q3c4^4j4o5[5_6S7W7v8Q8[8q9b9y:P:`:r;Q;[;d;k]>^o>UPS&[!Q&iQ&]!RQ&^!SU*}%[%d=sR,R&Y%]%Si#v$b$c$d$x${%O%Q%^%_%c)y*R*T*V*Y*a*g*w*x+f+i,S,V.f/P/d/m/x/y/{0`0b0i0j0o1f1i1q3c4^4_4j4o5Q5[5_6S7W7v8Q8V8[8q9b9p9y:P:`:r;Q;[;d;kP>X>Y>]>^T)z$u){V+P%]S$i$^c#Y#e%q%s%u(S(Y(t(y)R)S)T)U)V)W)X)Y)Z)[)^)`)b)g)q+d+x-Z-x-}.S.U.s.v.z.|.}/O/b0p2k2n3O3V3k3p3q3r3s3t3u3v3w3x3y3z3{3|4P4Q4X5X5c6u6{7Q7a7b7k7l8k9X9]9g9m9n:o;W;`SQ'Y!eR2q-]!W!nQ!e!r!v!y!z$|'W'_'`'l'm'n*Z*k*o*q*r-]-c-e-u0[0_1o5{5}R1l,ZnqOXst!Z#d%m&r&t&u&w,s,x2[2_Q&y!^Q'v!xS(s#u<^Q+l%zQ,]&_Q,^&aQ-j'dQ-w'oS.r(x=PS0q+X=ZQ1P+mQ1n,[Q2c,zQ2e,{Q2m-WQ2z-kQ2}-oS5Y0r=eQ5a1QS5d1S=fQ6t2oQ6x2{Q6}3SQ8]5bQ9Y6vQ9Z6yQ9^7OR:l9V$d$]c#Y#e%s%u(S(Y(t(y)R)S)T)U)V)W)X)Y)Z)[)^)`)b)g)q+d+x-Z-x-}.S.U.s.v.z.}/O/b0p2k2n3O3V3k3p3q3r3s3t3u3v3w3x3y3z3{3|4P4Q4X5X5c6u6{7Q7a7b7k7l8k9X9]9g9m9n:o;W;`SS#q]SU$fd)_,mS(p#p'iU*v%R(w4OU0m+O.n7gQ5^0xQ7V3`Q9d7YR:s9em!tQ!r!v!y!z'`'l'm'n-e-u1o5{5}Q't!uS(f#g2US-s'k'wQ/s*]Q0R*jQ3U-vQ4f/tQ4r0TQ4s0UQ4x0^Q7r4`S7}4t4vS8R4y4{Q9r7sQ9v7yQ9{8OQ:Q8TS:{9w9xS;g:|;PS;s;h;iS;{;t;uSSR=o>R%^bOPWXYZstuvw!Z!`!g!o#S#W#Z#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$_$a$e%m%t&R&k&n&o&r&t&u&w&{'T'b'r(T(V(](d(x(z)O)}*i+X+]+g,p,s,x-i-q.P.V.g.t.{/n0]0l0r1S1r2S2T2V2X2[2_2a3Q3W3d3l4z6T6e6f6i6|7[8t9T9_Q%fj!^%xy!i!u%{%|%}'V'e'f'g'k'u*j+n+o-Y-l-m-t0R0U1R2u2|3T4r4s4v7}9{S&Oz!jQ+k%yQ,a&dW1v,b,c,d,eU6X1w1x1yS8w6Y6ZQ:d8x!r=j$Z$n'X)s-U-X/V2p4T5w6s:Z:mSQ=t>QR=u>R%QeOPXYstuvw!Z!`!g!o#S#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$_$a$e%m%t&R&k&n&r&t&u&w&{'T'b'r(V(](d(x(z)O)}*i+X+]+g,p,s,x-i-q.P.V.g.t.{/n0]0l0r1S1r2S2T2V2X2[2_2a3Q3W3d3l4z6T6e6f6i6|7[8t9T9_Y#bWZ#W#Z(T!b%jm#h#i#l$x%e%h(^(h(i(j*Y*^*b+Z+[+^,o-V.T.Z.[.]._/m/p2d3[3]4a6r7TQ,n&o!p=k$Z$n)s-U-X/V2p4T5w6s:Z:mSR=n'XU']!e%i*ZR2s-`%SdOPWXYZstuvw!Z!`!g!o#S#W#Z#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$_$a$e%m%t&R&k&n&r&t&u&w&{'T'b'r(T(V(](d(x(z)O)}*i+X+],p,s,x-i-q.P.V.t.{/n0]0l0r1S1r2S2T2V2X2[2_2a3Q3W3l4z6T6e6f6i6|8t9T9_!r)_$Z$n'X)s-U-X/V2p4T5w6s:Z:mSQ,m&oQ0x+gQ3`.gQ7Y3dR9e7[!b$Tc#Y%q(S(Y(t(y)Z)[)`)g+x-x-}.S.U.s.v/b0p3O3V3k3{5X5c6{7Q7a9]:oS)^)q-Z.|2k2n3p4P4X6u7b7k7l8k9X9g9m9n;W;`=vQ>X>ZR>Y>['QkOPWXYZstuvw!Z!`!g!o#S#W#Z#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$Z$_$a$e$n%m%t&R&k&n&o&r&t&u&w&{'T'X'b'r(T(V(](d(x(z)O)s)}*i+X+]+g,p,s,x-U-X-i-q.P.V.g.t.{/V/n0]0l0r1S1r2S2T2V2X2[2_2a2p3Q3W3d3l4T4z5w6T6e6f6i6s6|7[8t9T9_:Z:mSS$oh$pR4U/U'XgOPWXYZhstuvw!Z!`!g!o#S#W#Z#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$Z$_$a$e$n$p%m%t&R&k&n&o&r&t&u&w&{'T'X'b'r(T(V(](d(x(z)O)s)}*i+X+]+g,p,s,x-U-X-i-q.P.V.g.t.{/U/V/n0]0l0r1S1r2S2T2V2X2[2_2a2p3Q3W3d3l4T4z5w6T6e6f6i6s6|7[8t9T9_:Z:mST$kf$qQ$ifS)j$l)nR)v$qT$jf$qT)l$l)n'XhOPWXYZhstuvw!Z!`!g!o#S#W#Z#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$Z$_$a$e$n$p%m%t&R&k&n&o&r&t&u&w&{'T'X'b'r(T(V(](d(x(z)O)s)}*i+X+]+g,p,s,x-U-X-i-q.P.V.g.t.{/U/V/n0]0l0r1S1r2S2T2V2X2[2_2a2p3Q3W3d3l4T4z5w6T6e6f6i6s6|7[8t9T9_:Z:mST$oh$pQ$rhR)u$p%^jOPWXYZstuvw!Z!`!g!o#S#W#Z#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$_$a$e%m%t&R&k&n&o&r&t&u&w&{'T'b'r(T(V(](d(x(z)O)}*i+X+]+g,p,s,x-i-q.P.V.g.t.{/n0]0l0r1S1r2S2T2V2X2[2_2a3Q3W3d3l4z6T6e6f6i6|7[8t9T9_!s>Q$Z$n'X)s-U-X/V2p4T5w6s:Z:mS#glOPXZst!Z!`!o#S#d#o#{$n%m&k&n&o&r&t&u&w&{'T'b)O)s*i+]+g,p,s,x-i.g/V/n0]0l1r2S2T2V2X2[2_2a3d4T4z6T6e6f6i7[8t9T!U%Ri$d%O%Q%^%_%c*R*T*a*w*x/P/x0`0b0i0j0o4_5Q8V9p>P>X>Y#f(w#v$b$c$x${)y*V*Y*g+f+i,S,V.f/d/m/y/{1f1i1q3c4^4j4o5[5_6S7W7v8Q8[8q9b9y:P:`:r;Q;[;d;k]>^Q+T%aQ/c*Oo4OP>X>YQ*c$zU*l$|*Z*oQ+U%bQ0W*m#f=q#v$b$c$x${)y*V*Y*g+f+i,S,V.f/d/m/y/{1f1i1q3c4^4j4o5[5_6S7W7v8Q8[8q9b9y:P:`:r;Q;[;d;k]>^n=rTQ=x>UQ=y>VR=z>W!U%Ri$d%O%Q%^%_%c*R*T*a*w*x/P/x0`0b0i0j0o4_5Q8V9p>P>X>Y#f(w#v$b$c$x${)y*V*Y*g+f+i,S,V.f/d/m/y/{1f1i1q3c4^4j4o5[5_6S7W7v8Q8[8q9b9y:P:`:r;Q;[;d;k]>^o4OP>X>Y>]>^Q,U&]Q1h,WQ5s1gR8h5tV*n$|*Z*oU*n$|*Z*oT5z1o5{S0P*i/nQ4w0]T8S4z:]Q+j%xQ0V*lQ1O+kQ1u,aQ6W1vQ8v6XQ:c8wR;^:d!U%Oi$d%O%Q%^%_%c*R*T*a*w*x/P/x0`0b0i0j0o4_5Q8V9p>P>X>Yx*R$v)e*S*u+V/v0d0e4R4g5R5S5W7p8U:R:x=p=}>OS0`*t0a#f]>^nZ>[`=T3}7c7f7j9h:t:w;yS=_.l3iT=`7e9k!U%Qi$d%O%Q%^%_%c*R*T*a*w*x/P/x0`0b0i0j0o4_5Q8V9p>P>X>Y|*T$v)e*U*t+V/g/v0d0e4R4g4|5R5S5W7p8U:R:x=p=}>OS0b*u0c#f]>^nZ>[d=V3}7d7e7j9h9i:t:u:w;yS=a.m3jT=b7f9lrnOXst!V!Z#d%m&i&r&t&u&w,s,x2[2_Q&f!UR,p&ornOXst!V!Z#d%m&i&r&t&u&w,s,x2[2_R&f!UQ,Y&^R1d,RsnOXst!V!Z#d%m&i&r&t&u&w,s,x2[2_Q1p,_S6R1s1tU8p6P6Q6US:_8r8sS;Y:^:aQ;m;ZR;w;nQ&m!VR,i&iR6_1|R:f8yW&Q|&V&W,OR1Z+vQ&r!WR,s&sR,y&xT2],x2_R,}&yQ,|&yR2f,}Q'y!{R-y'ySsOtQ#dXT%ps#dQ#OTR'{#OQ#RUR'}#RQ){$uR/`){Q#UVR(Q#UQ#XWU(W#X(X.QQ(X#YR.Q(YQ-^'YR2r-^Q.u(yS3m.u3nR3n.vQ-e'`R2v-eY!rQ'`-e1o5{R'j!rQ/Q)eR4S/QU#_W%h*YU(_#_(`.RQ(`#`R.R(ZQ-a']R2t-at`OXst!V!Z#d%m&i&k&r&t&u&w,s,x2[2_S#hZ%eU#r`#h.[R.[(jQ(k#jQ.X(gW.a(k.X3X7RQ3X.YR7R3YQ)n$lR/W)nQ$phR)t$pQ$`cU)a$`-|O>Z>[Q/z*eU4k/z4m7xQ4m/|R7x4lS*o$|*ZR0Y*ox*S$v)e*t*u+V/v0d0e4R4g5R5S5W7p8U:R:x=p=}>O!d.j(u)c*[*e.l.m.q/_/k/|0v1e3h4[4h4l5r7]7`7w7z8X8Z9t9|:S:};R;e;j;v>Z>[U/h*S.j7ca7c3}7e7f7j9h:t:w;yQ0a*tQ3i.lU4}0a3i9kR9k7e|*U$v)e*t*u+V/g/v0d0e4R4g4|5R5S5W7p8U:R:x=p=}>O!h.k(u)c*[*e.l.m.q/_/k/|0v1e3f3h4[4h4l5r7]7^7`7w7z8X8Z9t9|:S:};R;e;j;v>Z>[U/j*U.k7de7d3}7e7f7j9h9i:t:u:w;yQ0c*uQ3j.mU5P0c3j9lR9l7fQ*z%UR0g*zQ5]0vR8Y5]Q+_%kR0u+_Q5v1jS8j5v:[R:[8kQ,[&_R1m,[Q5{1oR8m5{Q1{,fS6]1{8zR8z6_Q1U+rW5h1U5j8a:VQ5j1XQ8a5iR:V8bQ+w&QR1[+wQ2_,xR6m2_YrOXst#dQ&v!ZQ+a%mQ,r&rQ,t&tQ,u&uQ,w&wQ2Y,sS2],x2_R6l2[Q%opQ&z!_Q&}!aQ'P!bQ'R!cQ'q!uQ+`%lQ+l%zQ,Q&XQ,h&mQ-P&|W-p'k's't'wQ-w'oQ0X*nQ1P+mQ1c,PS2O,i,lQ2g-OQ2h-RQ2i-SQ2}-oW3P-r-s-v-xQ5a1QQ5m1_Q5q1eQ6V1uQ6a2QQ6k2ZU6z3O3R3UQ6}3SQ8]5bQ8e5oQ8g5rQ8l5zQ8u6WQ8{6`S9[6{7PQ9^7OQ:W8cQ:b8vQ:g8|Q:n9]Q;U:XQ;]:cQ;a:oQ;l;VR;o;^Q%zyQ'd!iQ'o!uU+m%{%|%}Q-W'VU-k'e'f'gS-o'k'uQ0Q*jS1Q+n+oQ2o-YS2{-l-mQ3S-tS4p0R0UQ5b1RQ6v2uQ6y2|Q7O3TU7{4r4s4vQ9z7}R;O9{S$wi>PR*{%VU%Ui%V>PR0f*yQ$viS(u#v+iS)c$b$cQ)e$dQ*[$xS*e${*YQ*t%OQ*u%QQ+Q%^Q+R%_Q+V%cQ.lPQ=}>XQ>O>YQ>Z>]R>[>^Q+O%]Q.nSR#[WR'Z!el!tQ!r!v!y!z'`'l'm'n-e-u1o5{5}S'V!e-]U*j$|*Z*oS-Y'W'_S0U*k*qQ0^*rQ2u-cQ4v0[R4{0_R({#xQ!fQT-d'`-e]!qQ!r'`-e1o5{Q#p]R'i < TypeParamList in out const TypeDefinition extends ThisType this LiteralType ArithOp Number BooleanLiteral TemplateType InterpolationEnd Interpolation InterpolationStart NullType null VoidType void TypeofType typeof MemberExpression . PropertyName [ TemplateString Escape Interpolation super RegExp ] ArrayExpression Spread , } { ObjectExpression Property async get set PropertyDefinition Block : NewTarget new NewExpression ) ( ArgList UnaryExpression delete LogicOp BitOp YieldExpression yield AwaitExpression await ParenthesizedExpression ClassExpression class ClassBody MethodDeclaration Decorator @ MemberExpression PrivatePropertyName CallExpression TypeArgList CompareOp < declare Privacy static abstract override PrivatePropertyDefinition PropertyDeclaration readonly accessor Optional TypeAnnotation Equals StaticBlock FunctionExpression ArrowFunction ParamList ParamList ArrayPattern ObjectPattern PatternProperty Privacy readonly Arrow MemberExpression BinaryExpression ArithOp ArithOp ArithOp ArithOp BitOp CompareOp instanceof satisfies CompareOp BitOp BitOp BitOp LogicOp LogicOp ConditionalExpression LogicOp LogicOp AssignmentExpression UpdateOp PostfixExpression CallExpression InstantiationExpression TaggedTemplateExpression DynamicImport import ImportMeta JSXElement JSXSelfCloseEndTag JSXSelfClosingTag JSXIdentifier JSXBuiltin JSXIdentifier JSXNamespacedName JSXMemberExpression JSXSpreadAttribute JSXAttribute JSXAttributeValue JSXEscape JSXEndTag JSXOpenTag JSXFragmentTag JSXText JSXEscape JSXStartCloseTag JSXCloseTag PrefixCast < ArrowFunction TypeParamList SequenceExpression InstantiationExpression KeyofType keyof UniqueType unique ImportType InferredType infer TypeName ParenthesizedType FunctionSignature ParamList NewSignature IndexedType TupleType Label ArrayType ReadonlyType ObjectType MethodType PropertyType IndexSignature PropertyDefinition CallSignature TypePredicate asserts is NewSignature new UnionType LogicOp IntersectionType LogicOp ConditionalType ParameterizedType ClassDeclaration abstract implements type VariableDeclaration let var using TypeAliasDeclaration InterfaceDeclaration interface EnumDeclaration enum EnumBody NamespaceDeclaration namespace module AmbientDeclaration declare GlobalDeclaration global ClassDeclaration ClassBody AmbientFunctionDeclaration ExportGroup VariableName VariableName ImportDeclaration defer ImportGroup ForStatement for ForSpec ForInSpec ForOfSpec of WhileStatement while WithStatement with DoStatement do IfStatement if else SwitchStatement switch SwitchBody CaseLabel case DefaultLabel TryStatement try CatchClause catch FinallyClause finally ReturnStatement return ThrowStatement throw BreakStatement break ContinueStatement continue DebuggerStatement debugger LabeledStatement ExpressionStatement SingleExpression SingleClassItem",maxTerm:380,context:l3,nodeProps:[["isolate",-8,5,6,14,37,39,51,53,55,""],["group",-26,9,17,19,68,207,211,215,216,218,221,224,234,237,243,245,247,249,252,258,264,266,268,270,272,274,275,"Statement",-34,13,14,32,35,36,42,51,54,55,57,62,70,72,76,80,82,84,85,110,111,120,121,136,139,141,142,143,144,145,147,148,167,169,171,"Expression",-23,31,33,37,41,43,45,173,175,177,178,180,181,182,184,185,186,188,189,190,201,203,205,206,"Type",-3,88,103,109,"ClassItem"],["openedBy",23,"<",38,"InterpolationStart",56,"[",60,"{",73,"(",160,"JSXStartCloseTag"],["closedBy",-2,24,168,">",40,"InterpolationEnd",50,"]",61,"}",74,")",165,"JSXEndTag"]],propSources:[d3],skippedNodes:[0,5,6,278],repeatNodeCount:37,tokenData:"$Fq07[R!bOX%ZXY+gYZ-yZ[+g[]%Z]^.c^p%Zpq+gqr/mrs3cst:_tuEruvJSvwLkwx! Yxy!'iyz!(sz{!)}{|!,q|}!.O}!O!,q!O!P!/Y!P!Q!9j!Q!R#:O!R![#<_![!]#I_!]!^#Jk!^!_#Ku!_!`$![!`!a$$v!a!b$*T!b!c$,r!c!}Er!}#O$-|#O#P$/W#P#Q$4o#Q#R$5y#R#SEr#S#T$7W#T#o$8b#o#p$x#r#s$@U#s$f%Z$f$g+g$g#BYEr#BY#BZ$A`#BZ$ISEr$IS$I_$A`$I_$I|Er$I|$I}$Dk$I}$JO$Dk$JO$JTEr$JT$JU$A`$JU$KVEr$KV$KW$A`$KW&FUEr&FU&FV$A`&FV;'SEr;'S;=`I|<%l?HTEr?HT?HU$A`?HUOEr(n%d_$i&j(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z&j&hT$i&jO!^&c!_#o&c#p;'S&c;'S;=`&w<%lO&c&j&zP;=`<%l&c'|'U]$i&j(Z!bOY&}YZ&cZw&}wx&cx!^&}!^!_'}!_#O&}#O#P&c#P#o&}#o#p'}#p;'S&};'S;=`(l<%lO&}!b(SU(Z!bOY'}Zw'}x#O'}#P;'S'};'S;=`(f<%lO'}!b(iP;=`<%l'}'|(oP;=`<%l&}'[(y]$i&j(WpOY(rYZ&cZr(rrs&cs!^(r!^!_)r!_#O(r#O#P&c#P#o(r#o#p)r#p;'S(r;'S;=`*a<%lO(rp)wU(WpOY)rZr)rs#O)r#P;'S)r;'S;=`*Z<%lO)rp*^P;=`<%l)r'[*dP;=`<%l(r#S*nX(Wp(Z!bOY*gZr*grs'}sw*gwx)rx#O*g#P;'S*g;'S;=`+Z<%lO*g#S+^P;=`<%l*g(n+dP;=`<%l%Z07[+rq$i&j(Wp(Z!b'|0/lOX%ZXY+gYZ&cZ[+g[p%Zpq+gqr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p$f%Z$f$g+g$g#BY%Z#BY#BZ+g#BZ$IS%Z$IS$I_+g$I_$JT%Z$JT$JU+g$JU$KV%Z$KV$KW+g$KW&FU%Z&FU&FV+g&FV;'S%Z;'S;=`+a<%l?HT%Z?HT?HU+g?HUO%Z07[.ST(X#S$i&j'}0/lO!^&c!_#o&c#p;'S&c;'S;=`&w<%lO&c07[.n_$i&j(Wp(Z!b'}0/lOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z)3p/x`$i&j!p),Q(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_!`0z!`#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z(KW1V`#v(Ch$i&j(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_!`2X!`#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z(KW2d_#v(Ch$i&j(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'At3l_(V':f$i&j(Z!bOY4kYZ5qZr4krs7nsw4kwx5qx!^4k!^!_8p!_#O4k#O#P5q#P#o4k#o#p8p#p;'S4k;'S;=`:X<%lO4k(^4r_$i&j(Z!bOY4kYZ5qZr4krs7nsw4kwx5qx!^4k!^!_8p!_#O4k#O#P5q#P#o4k#o#p8p#p;'S4k;'S;=`:X<%lO4k&z5vX$i&jOr5qrs6cs!^5q!^!_6y!_#o5q#o#p6y#p;'S5q;'S;=`7h<%lO5q&z6jT$d`$i&jO!^&c!_#o&c#p;'S&c;'S;=`&w<%lO&c`6|TOr6yrs7]s;'S6y;'S;=`7b<%lO6y`7bO$d``7eP;=`<%l6y&z7kP;=`<%l5q(^7w]$d`$i&j(Z!bOY&}YZ&cZw&}wx&cx!^&}!^!_'}!_#O&}#O#P&c#P#o&}#o#p'}#p;'S&};'S;=`(l<%lO&}!r8uZ(Z!bOY8pYZ6yZr8prs9hsw8pwx6yx#O8p#O#P6y#P;'S8p;'S;=`:R<%lO8p!r9oU$d`(Z!bOY'}Zw'}x#O'}#P;'S'};'S;=`(f<%lO'}!r:UP;=`<%l8p(^:[P;=`<%l4k%9[:hh$i&j(Wp(Z!bOY%ZYZ&cZq%Zqr`#P#o`x!^=^!^!_?q!_#O=^#O#P>`#P#o=^#o#p?q#p;'S=^;'S;=`@h<%lO=^&n>gXWS$i&jOY>`YZ&cZ!^>`!^!_?S!_#o>`#o#p?S#p;'S>`;'S;=`?k<%lO>`S?XSWSOY?SZ;'S?S;'S;=`?e<%lO?SS?hP;=`<%l?S&n?nP;=`<%l>`!f?xWWS(Z!bOY?qZw?qwx?Sx#O?q#O#P?S#P;'S?q;'S;=`@b<%lO?q!f@eP;=`<%l?q(Q@kP;=`<%l=^'`@w]WS$i&j(WpOY@nYZ&cZr@nrs>`s!^@n!^!_Ap!_#O@n#O#P>`#P#o@n#o#pAp#p;'S@n;'S;=`Bg<%lO@ntAwWWS(WpOYApZrAprs?Ss#OAp#O#P?S#P;'SAp;'S;=`Ba<%lOAptBdP;=`<%lAp'`BjP;=`<%l@n#WBvYWS(Wp(Z!bOYBmZrBmrs?qswBmwxApx#OBm#O#P?S#P;'SBm;'S;=`Cf<%lOBm#WCiP;=`<%lBm(rCoP;=`<%l^!Q^$i&j!X7`OY!=yYZ&cZ!P!=y!P!Q!>|!Q!^!=y!^!_!@c!_!}!=y!}#O!CW#O#P!Dy#P#o!=y#o#p!@c#p;'S!=y;'S;=`!Ek<%lO!=y|#X#Z&c#Z#[!>|#[#]&c#]#^!>|#^#a&c#a#b!>|#b#g&c#g#h!>|#h#i&c#i#j!>|#j#k!>|#k#m&c#m#n!>|#n#o&c#p;'S&c;'S;=`&w<%lO&c7`!@hX!X7`OY!@cZ!P!@c!P!Q!AT!Q!}!@c!}#O!Ar#O#P!Bq#P;'S!@c;'S;=`!CQ<%lO!@c7`!AYW!X7`#W#X!AT#Z#[!AT#]#^!AT#a#b!AT#g#h!AT#i#j!AT#j#k!AT#m#n!AT7`!AuVOY!ArZ#O!Ar#O#P!B[#P#Q!@c#Q;'S!Ar;'S;=`!Bk<%lO!Ar7`!B_SOY!ArZ;'S!Ar;'S;=`!Bk<%lO!Ar7`!BnP;=`<%l!Ar7`!BtSOY!@cZ;'S!@c;'S;=`!CQ<%lO!@c7`!CTP;=`<%l!@c^!Ezl$i&j(Z!b!X7`OY&}YZ&cZw&}wx&cx!^&}!^!_'}!_#O&}#O#P&c#P#W&}#W#X!Eq#X#Z&}#Z#[!Eq#[#]&}#]#^!Eq#^#a&}#a#b!Eq#b#g&}#g#h!Eq#h#i&}#i#j!Eq#j#k!Eq#k#m&}#m#n!Eq#n#o&}#o#p'}#p;'S&};'S;=`(l<%lO&}8r!GyZ(Z!b!X7`OY!GrZw!Grwx!@cx!P!Gr!P!Q!Hl!Q!}!Gr!}#O!JU#O#P!Bq#P;'S!Gr;'S;=`!J|<%lO!Gr8r!Hse(Z!b!X7`OY'}Zw'}x#O'}#P#W'}#W#X!Hl#X#Z'}#Z#[!Hl#[#]'}#]#^!Hl#^#a'}#a#b!Hl#b#g'}#g#h!Hl#h#i'}#i#j!Hl#j#k!Hl#k#m'}#m#n!Hl#n;'S'};'S;=`(f<%lO'}8r!JZX(Z!bOY!JUZw!JUwx!Arx#O!JU#O#P!B[#P#Q!Gr#Q;'S!JU;'S;=`!Jv<%lO!JU8r!JyP;=`<%l!JU8r!KPP;=`<%l!Gr>^!KZ^$i&j(Z!bOY!KSYZ&cZw!KSwx!CWx!^!KS!^!_!JU!_#O!KS#O#P!DR#P#Q!^!LYP;=`<%l!KS>^!L`P;=`<%l!_#c#d#Bq#d#l%Z#l#m#Es#m#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#_#c#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#>j_$i&j(Wp(Z!bs'9tOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#?rd$i&j(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q!R#AQ!R!S#AQ!S!^%Z!^!_*g!_#O%Z#O#P&c#P#R%Z#R#S#AQ#S#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#A]f$i&j(Wp(Z!bs'9tOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q!R#AQ!R!S#AQ!S!^%Z!^!_*g!_#O%Z#O#P&c#P#R%Z#R#S#AQ#S#b%Z#b#c#>_#c#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#Bzc$i&j(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q!Y#DV!Y!^%Z!^!_*g!_#O%Z#O#P&c#P#R%Z#R#S#DV#S#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#Dbe$i&j(Wp(Z!bs'9tOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q!Y#DV!Y!^%Z!^!_*g!_#O%Z#O#P&c#P#R%Z#R#S#DV#S#b%Z#b#c#>_#c#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#E|g$i&j(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q![#Ge![!^%Z!^!_*g!_!c%Z!c!i#Ge!i#O%Z#O#P&c#P#R%Z#R#S#Ge#S#T%Z#T#Z#Ge#Z#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#Gpi$i&j(Wp(Z!bs'9tOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q![#Ge![!^%Z!^!_*g!_!c%Z!c!i#Ge!i#O%Z#O#P&c#P#R%Z#R#S#Ge#S#T%Z#T#Z#Ge#Z#b%Z#b#c#>_#c#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z*)x#Il_!g$b$i&j$O)Lv(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z)[#Jv_al$i&j(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z04f#LS^h#)`#R-v$?V_!^(CdvBr$i&j(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z?O$@a_!q7`$i&j(Wp(Z!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z07[$Aq|$i&j(Wp(Z!b'|0/l$]#t(T,2j(e$I[OX%ZXY+gYZ&cZ[+g[p%Zpq+gqr%Zrs&}st%ZtuEruw%Zwx(rx}%Z}!OGv!O!Q%Z!Q![Er![!^%Z!^!_*g!_!c%Z!c!}Er!}#O%Z#O#P&c#P#R%Z#R#SEr#S#T%Z#T#oEr#o#p*g#p$f%Z$f$g+g$g#BYEr#BY#BZ$A`#BZ$ISEr$IS$I_$A`$I_$JTEr$JT$JU$A`$JU$KVEr$KV$KW$A`$KW&FUEr&FU&FV$A`&FV;'SEr;'S;=`I|<%l?HTEr?HT?HU$A`?HUOEr07[$D|k$i&j(Wp(Z!b'}0/l$]#t(T,2j(e$I[OY%ZYZ&cZr%Zrs&}st%ZtuEruw%Zwx(rx}%Z}!OGv!O!Q%Z!Q![Er![!^%Z!^!_*g!_!c%Z!c!}Er!}#O%Z#O#P&c#P#R%Z#R#SEr#S#T%Z#T#oEr#o#p*g#p$g%Z$g;'SEr;'S;=`I|<%lOEr",tokenizers:[c3,u3,h3,f3,2,3,4,5,6,7,8,9,10,11,12,13,14,a3,new gO("$S~RRtu[#O#Pg#S#T#|~_P#o#pb~gOx~~jVO#i!P#i#j!U#j#l!P#l#m!q#m;'S!P;'S;=`#v<%lO!P~!UO!U~~!XS!Q![!e!c!i!e#T#Z!e#o#p#Z~!hR!Q![!q!c!i!q#T#Z!q~!tR!Q![!}!c!i!}#T#Z!}~#QR!Q![!P!c!i!P#T#Z!P~#^R!Q![#g!c!i#g#T#Z#g~#jS!Q![#g!c!i#g#T#Z#g#q#r!P~#yP;=`<%l!P~$RO(c~~",141,340),new gO("j~RQYZXz{^~^O(Q~~aP!P!Qd~iO(R~~",25,323)],topRules:{Script:[0,7],SingleExpression:[1,276],SingleClassItem:[2,277]},dialects:{jsx:0,ts:15175},dynamicPrecedences:{80:1,82:1,94:1,169:1,199:1},specialized:[{term:327,get:n=>p3[n]||-1},{term:343,get:n=>g3[n]||-1},{term:95,get:n=>m3[n]||-1}],tokenPrec:15201}),UQ=[On("function ${name}(${params}) {\n ${}\n}",{label:"function",detail:"definition",type:"keyword"}),On("for (let ${index} = 0; ${index} < ${bound}; ${index}++) {\n ${}\n}",{label:"for",detail:"loop",type:"keyword"}),On("for (let ${name} of ${collection}) {\n ${}\n}",{label:"for",detail:"of loop",type:"keyword"}),On("do {\n ${}\n} while (${})",{label:"do",detail:"loop",type:"keyword"}),On("while (${}) {\n ${}\n}",{label:"while",detail:"loop",type:"keyword"}),On(`try { - \${} -} catch (\${error}) { - \${} -}`,{label:"try",detail:"/ catch block",type:"keyword"}),On("if (${}) {\n ${}\n}",{label:"if",detail:"block",type:"keyword"}),On(`if (\${}) { - \${} -} else { - \${} -}`,{label:"if",detail:"/ else block",type:"keyword"}),On(`class \${name} { - constructor(\${params}) { - \${} - } -}`,{label:"class",detail:"definition",type:"keyword"}),On('import {${names}} from "${module}"\n${}',{label:"import",detail:"named",type:"keyword"}),On('import ${name} from "${module}"\n${}',{label:"import",detail:"default",type:"keyword"})],y3=UQ.concat([On("interface ${name} {\n ${}\n}",{label:"interface",detail:"definition",type:"keyword"}),On("type ${name} = ${type}",{label:"type",detail:"definition",type:"keyword"}),On("enum ${name} {\n ${}\n}",{label:"enum",detail:"definition",type:"keyword"})]),bS=new qE,HQ=new Set(["Script","Block","FunctionExpression","FunctionDeclaration","ArrowFunction","MethodDeclaration","ForStatement"]);function ea(n){return(e,t)=>{let i=e.node.getChild("VariableDefinition");return i&&t(i,n),!0}}const x3=["FunctionDeclaration"],v3={FunctionDeclaration:ea("function"),ClassDeclaration:ea("class"),ClassExpression:()=>!0,EnumDeclaration:ea("constant"),TypeAliasDeclaration:ea("type"),NamespaceDeclaration:ea("namespace"),VariableDefinition(n,e){n.matchContext(x3)||e(n,"variable")},TypeDefinition(n,e){e(n,"type")},__proto__:null};function GQ(n,e){let t=bS.get(e);if(t)return t;let i=[],r=!0;function s(o,a){let c=n.sliceString(o.from,o.to);i.push({label:c,type:a})}return e.cursor(Tt.IncludeAnonymous).iterate(o=>{if(r)r=!1;else if(o.name){let a=v3[o.name];if(a&&a(o,s)||HQ.has(o.name))return!1}else if(o.to-o.from>8192){for(let a of GQ(n,o.node))i.push(a);return!1}}),bS.set(e,i),i}const SS=/^[\w$\xa1-\uffff][\w$\d\xa1-\uffff]*$/,KQ=["TemplateString","String","RegExp","LineComment","BlockComment","VariableDefinition","TypeDefinition","Label","PropertyDefinition","PropertyName","PrivatePropertyDefinition","PrivatePropertyName","JSXText","JSXAttributeValue","JSXOpenTag","JSXCloseTag","JSXSelfClosingTag",".","?."];function b3(n){let e=Pt(n.state).resolveInner(n.pos,-1);if(KQ.indexOf(e.name)>-1)return null;let t=e.name=="VariableName"||e.to-e.from<20&&SS.test(n.state.sliceDoc(e.from,e.to));if(!t&&!n.explicit)return null;let i=[];for(let r=e;r;r=r.parent)HQ.has(r.name)&&(i=i.concat(GQ(n.state.doc,r)));return{options:i,from:t?e.from:n.pos,validFor:SS}}const Es=Wa.define({name:"javascript",parser:O3.configure({props:[p0.add({IfStatement:Rh({except:/^\s*({|else\b)/}),TryStatement:Rh({except:/^\s*({|catch\b|finally\b)/}),LabeledStatement:pL,SwitchBody:n=>{let e=n.textAfter,t=/^\s*\}/.test(e),i=/^\s*(case|default)\b/.test(e);return n.baseIndent+(t?0:i?1:2)*n.unit},Block:dL({closing:"}"}),ArrowFunction:n=>n.baseIndent+n.unit,"TemplateString BlockComment":()=>null,"Statement Property":Rh({except:/^\s*{/}),JSXElement(n){let e=/^\s*<\//.test(n.textAfter);return n.lineIndent(n.node.from)+(e?0:n.unit)},JSXEscape(n){let e=/\s*\}/.test(n.textAfter);return n.lineIndent(n.node.from)+(e?0:n.unit)},"JSXOpenTag JSXSelfClosingTag"(n){return n.column(n.node.from)+n.unit}}),m0.add({"Block ClassBody SwitchBody EnumBody ObjectExpression ArrayExpression ObjectType":yL,BlockComment(n){return{from:n.from+2,to:n.to-2}}})]}),languageData:{closeBrackets:{brackets:["(","[","{","'",'"',"`"]},commentTokens:{line:"//",block:{open:"/*",close:"*/"}},indentOnInput:/^\s*(?:case |default:|\{|\}|<\/)$/,wordChars:"$"}}),JQ={test:n=>/^JSX/.test(n.name),facet:v_({commentTokens:{block:{open:"{/*",close:"*/}"}}})},S3=Es.configure({dialect:"ts"},"typescript"),w3=Es.configure({dialect:"jsx",props:[f0.add(n=>n.isTop?[JQ]:void 0)]}),k3=Es.configure({dialect:"jsx ts",props:[f0.add(n=>n.isTop?[JQ]:void 0)]},"typescript");let eC=n=>({label:n,type:"keyword"});const tC="break case const continue default delete export extends false finally in instanceof let new return static super switch this throw true typeof var yield".split(" ").map(eC),P3=tC.concat(["declare","implements","private","protected","public"].map(eC));function _3(n={}){let e=n.jsx?n.typescript?k3:w3:n.typescript?S3:Es,t=n.typescript?y3.concat(P3):UQ.concat(tC);return new S_(e,[Es.data.of({autocomplete:_Q(KQ,k0(t))}),Es.data.of({autocomplete:b3}),n.jsx?T3:[]])}function Q3(n){for(;;){if(n.name=="JSXOpenTag"||n.name=="JSXSelfClosingTag"||n.name=="JSXFragmentTag")return n;if(n.name=="JSXEscape"||!n.parent)return null;n=n.parent}}function wS(n,e,t=n.length){for(let i=e==null?void 0:e.firstChild;i;i=i.nextSibling)if(i.name=="JSXIdentifier"||i.name=="JSXBuiltin"||i.name=="JSXNamespacedName"||i.name=="JSXMemberExpression")return n.sliceString(i.from,Math.min(i.to,t));return""}const C3=typeof navigator=="object"&&/Android\b/.test(navigator.userAgent),T3=ce.inputHandler.of((n,e,t,i,r)=>{if((C3?n.composing:n.compositionStarted)||n.state.readOnly||e!=t||i!=">"&&i!="/"||!Es.isActiveAt(n.state,e,-1))return!1;let s=r(),{state:o}=s,a=o.changeByRange(c=>{var h;let{head:f}=c,p=Pt(o).resolveInner(f-1,-1),m;if(p.name=="JSXStartTag"&&(p=p.parent),!(o.doc.sliceString(f-1,f)!=i||p.name=="JSXAttributeValue"&&p.to>f)){if(i==">"&&p.name=="JSXFragmentTag")return{range:c,changes:{from:f,insert:""}};if(i=="/"&&p.name=="JSXStartCloseTag"){let y=p.parent,v=y.parent;if(v&&y.from==f-2&&((m=wS(o.doc,v.firstChild,f))||((h=v.firstChild)===null||h===void 0?void 0:h.name)=="JSXFragmentTag")){let b=`${m}>`;return{range:V.cursor(f+b.length,-1),changes:{from:f,insert:b}}}}else if(i==">"){let y=Q3(p);if(y&&y.name=="JSXOpenTag"&&!/^\/?>|^<\//.test(o.doc.sliceString(f,f+2))&&(m=wS(o.doc,y,f)))return{range:c,changes:{from:f,insert:``}}}}return{range:c}});return a.changes.empty?!1:(n.dispatch([s,o.update(a,{userEvent:"input.complete",scrollIntoView:!0})]),!0)}),$3="#e5c07b",kS="#e06c75",M3="#56b6c2",R3="#ffffff",Eh="#abb2bf",SO="#7d8799",A3="#61afef",E3="#98c379",PS="#d19a66",L3="#c678dd",D3="#21252b",_S="#2c313a",QS="#282c34",Ng="#353a42",z3="#3E4451",CS="#528bff",Z3=ce.theme({"&":{color:Eh,backgroundColor:QS},".cm-content":{caretColor:CS},".cm-cursor, .cm-dropCursor":{borderLeftColor:CS},"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":{backgroundColor:z3},".cm-panels":{backgroundColor:D3,color:Eh},".cm-panels.cm-panels-top":{borderBottom:"2px solid black"},".cm-panels.cm-panels-bottom":{borderTop:"2px solid black"},".cm-searchMatch":{backgroundColor:"#72a1ff59",outline:"1px solid #457dff"},".cm-searchMatch.cm-searchMatch-selected":{backgroundColor:"#6199ff2f"},".cm-activeLine":{backgroundColor:"#6699ff0b"},".cm-selectionMatch":{backgroundColor:"#aafe661a"},"&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket":{backgroundColor:"#bad0f847"},".cm-gutters":{backgroundColor:QS,color:SO,border:"none"},".cm-activeLineGutter":{backgroundColor:_S},".cm-foldPlaceholder":{backgroundColor:"transparent",border:"none",color:"#ddd"},".cm-tooltip":{border:"none",backgroundColor:Ng},".cm-tooltip .cm-tooltip-arrow:before":{borderTopColor:"transparent",borderBottomColor:"transparent"},".cm-tooltip .cm-tooltip-arrow:after":{borderTopColor:Ng,borderBottomColor:Ng},".cm-tooltip-autocomplete":{"& > ul > li[aria-selected]":{backgroundColor:_S,color:Eh}}},{dark:!0}),I3=pc.define([{tag:R.keyword,color:L3},{tag:[R.name,R.deleted,R.character,R.propertyName,R.macroName],color:kS},{tag:[R.function(R.variableName),R.labelName],color:A3},{tag:[R.color,R.constant(R.name),R.standard(R.name)],color:PS},{tag:[R.definition(R.name),R.separator],color:Eh},{tag:[R.typeName,R.className,R.number,R.changed,R.annotation,R.modifier,R.self,R.namespace],color:$3},{tag:[R.operator,R.operatorKeyword,R.url,R.escape,R.regexp,R.link,R.special(R.string)],color:M3},{tag:[R.meta,R.comment],color:SO},{tag:R.strong,fontWeight:"bold"},{tag:R.emphasis,fontStyle:"italic"},{tag:R.strikethrough,textDecoration:"line-through"},{tag:R.link,color:SO,textDecoration:"underline"},{tag:R.heading,fontWeight:"bold",color:kS},{tag:[R.atom,R.bool,R.special(R.variableName)],color:PS},{tag:[R.processingInstruction,R.string,R.inserted],color:E3},{tag:R.invalid,color:R3}]),j3=[Z3,A_(I3)];var $e=(n=>(n.SQL="SQL",n.OrbitQL="OrbitQL",n.Redis="Redis",n))($e||{}),fa=(n=>(n.Training="Training",n.Ready="Ready",n.Error="Error",n.Deprecated="Deprecated",n))(fa||{}),lt=(n=>(n.ModelManagement="ModelManagement",n.Statistical="Statistical",n.SupervisedLearning="SupervisedLearning",n.UnsupervisedLearning="UnsupervisedLearning",n.BoostingAlgorithms="BoostingAlgorithms",n.FeatureEngineering="FeatureEngineering",n.VectorOperations="VectorOperations",n.TimeSeries="TimeSeries",n.NLP="NLP",n))(lt||{});function wO(){return wO=Object.assign?Object.assign.bind():function(n){for(var e=1;e'),!0):e?n.some(function(t){return e.includes(t)})||n.includes("*"):!0}var U3=function(e,t,i){i===void 0&&(i=!1);var r=t.alt,s=t.meta,o=t.mod,a=t.shift,c=t.ctrl,h=t.keys,f=e.key,p=e.code,m=e.ctrlKey,y=e.metaKey,v=e.shiftKey,b=e.altKey,S=Zr(p),w=f.toLowerCase();if(!(h!=null&&h.includes(S))&&!(h!=null&&h.includes(w))&&!["ctrl","control","unknown","meta","alt","shift","os"].includes(S))return!1;if(!i){if(r===!b&&w!=="alt"||a===!v&&w!=="shift")return!1;if(o){if(!y&&!m)return!1}else if(s===!y&&w!=="meta"&&w!=="os"||c===!m&&w!=="ctrl"&&w!=="control")return!1}return h&&h.length===1&&(h.includes(w)||h.includes(S))?!0:h?X3(h):!h},H3=me.createContext(void 0),G3=function(){return me.useContext(H3)};function K3(n,e){return n===e}var J3=me.createContext({hotkeys:[],enabledScopes:[],toggleScope:function(){},enableScope:function(){},disableScope:function(){}}),eI=function(){return me.useContext(J3)};function tI(n){var e=me.useRef(void 0);return K3(e.current,n)||(e.current=n),e.current}var TS=function(e){e.stopPropagation(),e.preventDefault(),e.stopImmediatePropagation()},nI=typeof window<"u"?me.useLayoutEffect:me.useEffect;function $S(n,e,t,i){var r=me.useState(null),s=r[0],o=r[1],a=me.useRef(!1),c=t instanceof Array?i instanceof Array?void 0:i:t,h=D0(n)?n.join(void 0):n,f=me.useCallback(e,[]),p=me.useRef(f);p.current=e;var m=tI(c),y=eI(),v=y.enabledScopes,b=G3();return nI(function(){if(!((m==null?void 0:m.enabled)===!1||!q3(v,m==null?void 0:m.scopes))){var S=function(Q,$){var M;if($===void 0&&($=!1),!(F3(Q)&&!sC(Q,m==null?void 0:m.enableOnFormTags))){if(s!==null){var Z=s.getRootNode();if((Z instanceof Document||Z instanceof ShadowRoot)&&Z.activeElement!==s&&!s.contains(Z.activeElement)){TS(Q);return}}(M=Q.target)!=null&&M.isContentEditable&&!(m!=null&&m.enableOnContentEditable)||Xg(h,m==null?void 0:m.splitKey).forEach(function(j){var Y,W=Wg(j,m==null?void 0:m.combinationKey);if(U3(Q,W,m==null?void 0:m.ignoreModifiers)||(Y=W.keys)!=null&&Y.includes("*")){if(m!=null&&m.ignoreEventWhen!=null&&m.ignoreEventWhen(Q)||$&&a.current)return;if(W3(Q,W,m==null?void 0:m.preventDefault),!V3(Q,W,m==null?void 0:m.enabled)){TS(Q);return}p.current(Q,W),$||(a.current=!0)}})}},w=function(Q){Q.key!==void 0&&(iC(Zr(Q.code)),((m==null?void 0:m.keydown)===void 0&&(m==null?void 0:m.keyup)!==!0||m!=null&&m.keydown)&&S(Q))},C=function(Q){Q.key!==void 0&&(rC(Zr(Q.code)),a.current=!1,m!=null&&m.keyup&&S(Q,!0))},_=s||void 0||document;return _.addEventListener("keyup",C,void 0),_.addEventListener("keydown",w,void 0),b&&Xg(h,m==null?void 0:m.splitKey).forEach(function(P){return b.addHotkey(Wg(P,m==null?void 0:m.combinationKey,m==null?void 0:m.description))}),function(){_.removeEventListener("keyup",C,void 0),_.removeEventListener("keydown",w,void 0),b&&Xg(h,m==null?void 0:m.splitKey).forEach(function(P){return b.removeHotkey(Wg(P,m==null?void 0:m.combinationKey,m==null?void 0:m.description))})}}},[s,h,m,v]),o}const iI=["select","from","where","join","inner","left","right","full","cross","group","by","having","order","limit","offset","insert","into","values","update","set","delete","create","drop","table","index","view","with","recursive","traverse","outbound","inbound","steps","on","relate","node","edge","path","connected","live","diff","ml_train_model","ml_predict","ml_evaluate_model","ml_drop_model","ml_list_models","ml_model_info","ml_update_model","ml_xgboost","ml_lightgbm","ml_catboost","ml_adaboost","ml_gradient_boosting","ml_linear_regression","ml_logistic_regression","ml_correlation","ml_covariance","ml_zscore","ml_normalize","ml_encode_categorical","ml_polynomial_features","ml_pca","ml_feature_selection","ml_embed_text","ml_embed_image","ml_similarity_search","ml_vector_cluster","ml_dimensionality_reduction","ml_forecast","ml_seasonality_decompose","ml_anomaly_detection","ml_sentiment_analysis","ml_extract_entities","ml_summarize_text","model","train","predict","using","algorithm","features","target","evaluate","score","fit","transform"],rI=["get","set","del","exists","expire","ttl","keys","scan","hget","hset","hdel","hgetall","hkeys","hvals","hmget","hmset","llen","lpush","rpush","lpop","rpop","lrange","lindex","lset","sadd","srem","smembers","scard","sismember","sunion","sinter","zadd","zrem","zrange","zrank","zscore","zcard","zcount","ping","echo","info","dbsize","flushdb","flushall","select","auth","quit","shutdown","lastsave","save","bgsave"],sI=de.div` - flex: 1; - display: flex; - flex-direction: column; - height: 100%; - - .cm-editor { - height: 100%; - font-size: 14px; - border: 1px solid #3c3c3c; - border-radius: 4px; - } - - .cm-focused { - outline: none; - border-color: #0078d4; - } - - .cm-content { - padding: 12px; - min-height: 200px; - } - - .cm-line { - line-height: 1.6; - } - - .cm-cursor { - border-left: 2px solid #ffffff; - } - - .cm-selectionBackground { - background: #264f78 !important; - } - - .cm-activeLine { - background-color: rgba(255, 255, 255, 0.05); - } - - .cm-activeLineGutter { - background-color: rgba(255, 255, 255, 0.05); - } -`,oI=de.div` - display: flex; - gap: 8px; - padding: 8px 12px; - background: #2d2d2d; - border-bottom: 1px solid #3c3c3c; - align-items: center; -`,Vg=de.button` - padding: 6px 12px; - border: none; - border-radius: 4px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - display: flex; - align-items: center; - gap: 4px; - transition: all 0.2s; - - ${n=>n.variant==="primary"?` - background: #0078d4; - color: white; - - &:hover:not(:disabled) { - background: #106ebe; - } - `:` - background: #3c3c3c; - color: #ffffff; - - &:hover:not(:disabled) { - background: #484848; - } - `} - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - &:active { - transform: translateY(1px); - } -`,lI=de.div` - display: flex; - justify-content: space-between; - align-items: center; - padding: 4px 12px; - background: #2d2d2d; - border-top: 1px solid #3c3c3c; - font-size: 12px; - color: #cccccc; -`,aI=de.div` - padding: 2px 8px; - border-radius: 12px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - - ${n=>{switch(n.type){case $e.SQL:return"background: #0078d4; color: white;";case $e.OrbitQL:return"background: #107c10; color: white;";case $e.Redis:return"background: #d83b01; color: white;";default:return"background: #5a5a5a; color: white;"}}} -`,cI=({value:n,onChange:e,queryType:t,onExecute:i,onExplain:r,isExecuting:s=!1,connection:o,className:a})=>{const c=me.useRef(null),h=me.useRef(null),[f,p]=me.useState({line:1,column:1});$S("ctrl+enter,cmd+enter",()=>{!s&&n.trim()&&v()}),$S("ctrl+shift+enter,cmd+shift+enter",()=>{!s&&n.trim()&&r&&b()});const m=()=>{let C=[];switch(t){case $e.SQL:case $e.OrbitQL:C=iI;break;case $e.Redis:C=rI;break}return DQ({override:[_=>{const P=_.matchBefore(/\w*/);if(!P||P.from===P.to&&!_.explicit)return null;const Q=C.filter($=>$.toLowerCase().includes(P.text.toLowerCase())).map($=>({label:$,type:"keyword",boost:$.startsWith(P.text.toLowerCase())?1:0}));return{from:P.from,options:Q}}]})},y=()=>{const C=[Xz,j3,hc.of([...R0,o5]),m(),ce.updateListener.of(_=>{if(_.docChanged&&e(_.state.doc.toString()),_.selectionSet){const P=_.state.selection.main.head,Q=_.state.doc.lineAt(P);p({line:Q.number,column:P-Q.from+1})}})];switch(t){case $e.SQL:case $e.OrbitQL:C.push(jZ());break;case $e.Redis:C.push(_3());break}return C};me.useEffect(()=>{if(!c.current)return;h.current&&h.current.destroy();const C=Ie.create({doc:n,extensions:y()});return h.current=new ce({state:C,parent:c.current}),()=>{h.current&&h.current.destroy()}},[t]),me.useEffect(()=>{h.current&&h.current.state.doc.toString()!==n&&h.current.dispatch({changes:{from:0,to:h.current.state.doc.length,insert:n}})},[n]);const v=()=>{n.trim()&&i(n.trim())},b=()=>{n.trim()&&r&&r(n.trim())},S=()=>{if(t===$e.SQL||t===$e.OrbitQL){const C=n.replace(/[ \t\r\n]+/g," ").replace(/[ \t]*,[ \t]*/g,`, - `).replace(/\b(?:SELECT|FROM|WHERE|JOIN|GROUP BY|HAVING|ORDER BY|LIMIT)\b/gi,` -$1`).replace(/^[ \t]+/gm," ").trim();e(C)}},w=()=>{switch(t){case $e.SQL:return"Standard PostgreSQL syntax";case $e.OrbitQL:return"OrbitQL with ML functions - try ML_XGBOOST(), ML_TRAIN_MODEL()";case $e.Redis:return"Redis commands - GET, SET, HGET, etc.";default:return""}};return E.jsxs(sI,{className:a,children:[E.jsxs(oI,{children:[E.jsx(Vg,{variant:"primary",onClick:v,disabled:s||!n.trim(),children:s?E.jsxs(E.Fragment,{children:[E.jsx("span",{children:"⟳"})," Executing..."]}):E.jsxs(E.Fragment,{children:[E.jsx("span",{children:"▶"})," Execute (Ctrl+Enter)"]})}),(t===$e.SQL||t===$e.OrbitQL)&&E.jsxs(Vg,{onClick:b,disabled:s||!n.trim()||!r,children:[E.jsx("span",{children:"📊"})," Explain"]}),(t===$e.SQL||t===$e.OrbitQL)&&E.jsxs(Vg,{onClick:S,children:[E.jsx("span",{children:"📝"})," Format"]}),E.jsx("div",{style:{flex:1}}),E.jsx(aI,{type:t,children:t})]}),E.jsx("div",{ref:c,style:{flex:1}}),E.jsxs(lI,{children:[E.jsxs("div",{children:["Line ",f.line,", Column ",f.column]}),E.jsxs("div",{children:[o?`Connected to ${o.info.name}`:"No connection"," • ",w()]})]})]})};function uI(){return window.crypto.getRandomValues(new Uint32Array(1))[0]}function MS(n,e=!1){const t=uI(),i=`_${t}`;return Object.defineProperty(window,i,{value:r=>(e&&Reflect.deleteProperty(window,i),n==null?void 0:n(r)),writable:!1,configurable:!0}),t}async function Ht(n,e={}){return new Promise((t,i)=>{const r=MS(o=>{t(o),Reflect.deleteProperty(window,`_${s}`)},!0),s=MS(o=>{i(o),Reflect.deleteProperty(window,`_${r}`)},!0);window.__TAURI_IPC__({cmd:n,callback:r,error:s,...e})})}const Fg=()=>{try{return globalThis.window!==void 0&&"window"in globalThis&&"__TAURI_IPC__"in globalThis.window&&typeof globalThis.window.__TAURI_IPC__=="function"}catch{return!1}},hI=()=>[{id:"mock-postgres",info:{name:"Mock PostgreSQL",connection_type:"PostgreSQL",host:"localhost",port:5432,database:"demo",username:"postgres",password:null,ssl_mode:null,connection_timeout:null,additional_params:{}},status:"Connected",created_at:new Date().toISOString(),last_used:new Date().toISOString(),query_count:0},{id:"mock-orbitql",info:{name:"Mock OrbitQL",connection_type:"OrbitQL",host:"localhost",port:8080,database:null,username:null,password:null,ssl_mode:null,connection_timeout:null,additional_params:{}},status:"Connected",created_at:new Date().toISOString(),last_used:new Date().toISOString(),query_count:0}],fI=()=>({success:!0,data:{columns:[{name:"id",column_type:"integer"},{name:"name",column_type:"text"},{name:"value",column_type:"decimal"}],rows:[{id:1,name:"Sample Data",value:100.5},{id:2,name:"Another Row",value:250}]},error:null,execution_time:23.5,rows_affected:2});class zo{static async createConnection(e){const t=await Ht("create_connection",{connectionInfo:e});if(!t.success||!t.data)throw new Error(t.error||"Failed to create connection");return t.data}static async testConnection(e){const t=await Ht("test_connection",{connectionInfo:e});if(!t.success||!t.data)throw new Error(t.error||"Failed to test connection");return t.data}static async getConnections(){if(!Fg())return await new Promise(t=>setTimeout(t,100)),hI();const e=await Ht("get_connections");if(!e.success||!e.data)throw new Error(e.error||"Failed to get connections");return e.data}static async disconnect(e){const t=await Ht("disconnect",{connectionId:e});if(!t.success)throw new Error(t.error||"Failed to disconnect")}static async deleteConnection(e){const t=await Ht("delete_connection",{connectionId:e});if(!t.success)throw new Error(t.error||"Failed to delete connection")}static async executeQuery(e){if(!Fg())return await new Promise(i=>setTimeout(i,200)),fI();const t=await Ht("execute_query",{request:e});if(!t.success||!t.data)throw new Error(t.error||"Failed to execute query");return t.data}static async getQueryHistory(e,t){const i=await Ht("get_query_history",{connectionId:e,limit:t});if(!i.success||!i.data)throw new Error(i.error||"Failed to get query history");return i.data}static async explainQuery(e){const t=await Ht("explain_query",{request:e});if(!t.success||!t.data)throw new Error(t.error||"Failed to explain query");return t.data}static async listMlFunctions(){if(!Fg())return[{name:"ML_XGBOOST",category:"Boosting",description:"XGBoost gradient boosting algorithm",parameters:[],example:"SELECT ML_XGBOOST(features, target) FROM data;"}];const e=await Ht("list_ml_functions");if(!e.success||!e.data)throw new Error(e.error||"Failed to list ML functions");return e.data}static async listModels(e){const t=await Ht("list_models",{connectionId:e});if(!t.success||!t.data)throw new Error(t.error||"Failed to list models");return t.data}static async getModelInfo(e,t){const i=await Ht("get_model_info",{connectionId:e,modelName:t});if(!i.success||!i.data)throw new Error(i.error||"Failed to get model info");return i.data}static async deleteModel(e,t){const i=await Ht("delete_model",{connectionId:e,modelName:t});if(!i.success)throw new Error(i.error||"Failed to delete model")}static async getSystemInfo(){const e=await Ht("get_system_info");if(!e.success||!e.data)throw new Error(e.error||"Failed to get system info");return e.data}static async saveSettings(e){const t=await Ht("save_settings",{settings:e});if(!t.success)throw new Error(t.error||"Failed to save settings")}static async loadSettings(){const e=await Ht("load_settings");if(!e.success||!e.data)throw new Error(e.error||"Failed to load settings");return e.data}static async showAboutDialog(){await Ht("show_about_dialog")}}const RS=n=>typeof n=="string"?n:n!=null&&n.message?n.message:n!=null&&n.error?n.error:"An unexpected error occurred",dI=de.div` - display: flex; - flex-direction: column; - height: 100%; - background: #1e1e1e; -`,pI=de.div` - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - border-bottom: 1px solid #3c3c3c; -`,gI=de.h2` - margin: 0; - color: #ffffff; - font-size: 18px; - font-weight: 600; -`,mI=de.button` - padding: 6px 12px; - background: #0078d4; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 13px; - - &:hover { - background: #106ebe; - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } -`,OI=de.div` - display: flex; - border-bottom: 1px solid #3c3c3c; -`,AS=de.button` - padding: 12px 20px; - background: none; - border: none; - color: ${n=>n.active?"#0078d4":"#cccccc"}; - cursor: pointer; - font-size: 14px; - border-bottom: ${n=>n.active?"2px solid #0078d4":"2px solid transparent"}; - transition: all 0.2s; - - &:hover { - color: ${n=>n.active?"#0078d4":"#ffffff"}; - } -`,yI=de.div` - flex: 1; - overflow: auto; - padding: 20px; -`,xI=de.div` - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 16px; - margin-bottom: 20px; -`,vI=de.div` - background: #2d2d2d; - border: 1px solid #3c3c3c; - border-radius: 8px; - padding: 16px; - transition: all 0.2s; - - &:hover { - border-color: #0078d4; - box-shadow: 0 2px 8px rgba(0, 120, 212, 0.1); - } -`,bI=de.h3` - margin: 0 0 8px 0; - color: #ffffff; - font-size: 16px; - font-weight: 600; -`,SI=de.div` - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 12px; -`,wI=de.div` - background: #107c10; - color: white; - padding: 2px 8px; - border-radius: 12px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; -`,kI=de.div` - width: 8px; - height: 8px; - border-radius: 50%; - background: ${n=>{switch(n.status){case fa.Ready:return"#107c10";case fa.Training:return"#ff8c00";case fa.Error:return"#d13438";case fa.Deprecated:return"#5a5a5a";default:return"#5a5a5a"}}}; -`,PI=de.div` - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; - margin-bottom: 12px; -`,nh=de.div` - color: #cccccc; - font-size: 12px; - - .label { - color: #888888; - display: block; - } - - .value { - color: #ffffff; - font-weight: 600; - font-size: 14px; - } -`,_I=de.div` - display: flex; - gap: 8px; - margin-top: 12px; -`,ES=de.button` - flex: 1; - padding: 6px 12px; - border: none; - border-radius: 4px; - font-size: 12px; - cursor: pointer; - transition: all 0.2s; - - ${n=>n.variant==="danger"?` - background: #d13438; - color: white; - - &:hover { - background: #b71c1c; - } - `:` - background: #3c3c3c; - color: #ffffff; - - &:hover { - background: #484848; - } - `} - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } -`,QI=de.div` - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 20px; -`,CI=de.div` - background: #2d2d2d; - border: 1px solid #3c3c3c; - border-radius: 8px; - overflow: hidden; -`,TI=de.div` - padding: 12px 16px; - background: ${n=>{switch(n.category){case lt.BoostingAlgorithms:return"#0078d4";case lt.ModelManagement:return"#107c10";case lt.Statistical:return"#d83b01";case lt.FeatureEngineering:return"#5c2d91";case lt.VectorOperations:return"#e81123";default:return"#5a5a5a"}}}; - color: white; - font-weight: 600; - font-size: 14px; -`,$I=de.div` - padding: 16px; -`,MI=de.div` - margin-bottom: 12px; - padding: 8px; - border-radius: 4px; - cursor: pointer; - transition: background 0.2s; - - &:hover { - background: #3c3c3c; - } - - .name { - color: #0078d4; - font-weight: 600; - font-size: 13px; - margin-bottom: 4px; - } - - .description { - color: #cccccc; - font-size: 12px; - line-height: 1.4; - } -`,Yg=de.div` - text-align: center; - padding: 40px 20px; - color: #888888; - - .icon { - font-size: 48px; - margin-bottom: 16px; - opacity: 0.5; - } - - .message { - font-size: 16px; - margin-bottom: 8px; - } - - .submessage { - font-size: 14px; - opacity: 0.7; - } -`,LS=({connection:n,className:e})=>{const[t,i]=me.useState("models"),[r,s]=me.useState([]),[o,a]=me.useState([]),[c,h]=me.useState(!1),[f,p]=me.useState(null);me.useEffect(()=>{t==="models"&&n?m():t==="functions"&&y()},[t,n]);const m=async()=>{if(n){h(!0),p(null);try{const P=await zo.listModels(n.id);s(P)}catch(P){p(P instanceof Error?P.message:"Failed to load models")}finally{h(!1)}}},y=async()=>{h(!0),p(null);try{const P=await zo.listMlFunctions();a(P)}catch(P){p(P instanceof Error?P.message:"Failed to load ML functions")}finally{h(!1)}},v=async P=>{if(n&&confirm(`Are you sure you want to delete the model "${P}"?`))try{await zo.deleteModel(n.id,P),await m()}catch(Q){alert(`Failed to delete model: ${Q instanceof Error?Q.message:"Unknown error"}`)}},b=P=>{if(P===0)return"0 B";const Q=1024,$=["B","KB","MB","GB"],M=Math.floor(Math.log(P)/Math.log(Q));return Number.parseFloat((P/Math.pow(Q,M)).toFixed(2))+" "+$[M]},S=P=>(P*100).toFixed(1)+"%",w=()=>{const P={[lt.ModelManagement]:[],[lt.Statistical]:[],[lt.SupervisedLearning]:[],[lt.UnsupervisedLearning]:[],[lt.BoostingAlgorithms]:[],[lt.FeatureEngineering]:[],[lt.VectorOperations]:[],[lt.TimeSeries]:[],[lt.NLP]:[]};for(const Q of o)P[Q.category].push(Q);return P},C={[lt.ModelManagement]:"Model Management",[lt.Statistical]:"Statistical Functions",[lt.SupervisedLearning]:"Supervised Learning",[lt.UnsupervisedLearning]:"Unsupervised Learning",[lt.BoostingAlgorithms]:"Boosting Algorithms",[lt.FeatureEngineering]:"Feature Engineering",[lt.VectorOperations]:"Vector Operations",[lt.TimeSeries]:"Time Series ML",[lt.NLP]:"Natural Language Processing"},_=P=>C[P]||P;return E.jsxs(dI,{className:e,children:[E.jsxs(pI,{children:[E.jsx(gI,{children:"ML Models & Functions"}),E.jsx(mI,{onClick:()=>t==="models"?m():y(),disabled:c,children:c?"⟳ Loading...":"🔄 Refresh"})]}),E.jsxs(OI,{children:[E.jsxs(AS,{active:t==="models",onClick:()=>i("models"),children:["📊 Models (",r.length,")"]}),E.jsxs(AS,{active:t==="functions",onClick:()=>i("functions"),children:["🧠 ML Functions (",o.length,")"]})]}),E.jsxs(yI,{children:[f&&E.jsx("div",{style:{color:"#d13438",background:"rgba(209, 52, 56, 0.1)",padding:"12px",borderRadius:"4px",marginBottom:"16px"},children:f}),t==="models"&&E.jsx(E.Fragment,{children:n?r.length===0&&!c?E.jsxs(Yg,{children:[E.jsx("div",{className:"icon",children:"🤖"}),E.jsx("div",{className:"message",children:"No ML Models Found"}),E.jsxs("div",{className:"submessage",children:["Train your first model using OrbitQL:",E.jsx("br",{}),E.jsx("code",{children:"SELECT ML_TRAIN_MODEL('my_model', 'XGBOOST', features, target) FROM data"})]})]}):E.jsx(xI,{children:r.map(P=>E.jsxs(vI,{children:[E.jsx(bI,{children:P.name}),E.jsxs(SI,{children:[E.jsx(wI,{children:P.algorithm}),E.jsx(kI,{status:P.status,title:P.status})]}),E.jsxs(PI,{children:[E.jsxs(nh,{children:[E.jsx("span",{className:"label",children:"Accuracy"}),E.jsx("span",{className:"value",children:S(P.accuracy)})]}),E.jsxs(nh,{children:[E.jsx("span",{className:"label",children:"Features"}),E.jsx("span",{className:"value",children:P.feature_count})]}),E.jsxs(nh,{children:[E.jsx("span",{className:"label",children:"Training Samples"}),E.jsx("span",{className:"value",children:P.training_samples.toLocaleString()})]}),E.jsxs(nh,{children:[E.jsx("span",{className:"label",children:"Size"}),E.jsx("span",{className:"value",children:b(P.size_bytes)})]})]}),E.jsxs("div",{style:{fontSize:"11px",color:"#888888",marginBottom:"8px"},children:["Updated: ",new Date(P.updated_at).toLocaleDateString()]}),E.jsxs(_I,{children:[E.jsx(ES,{children:"📊 View Details"}),E.jsx(ES,{variant:"danger",onClick:()=>v(P.name),children:"🗑️ Delete"})]})]},P.name))}):E.jsxs(Yg,{children:[E.jsx("div",{className:"icon",children:"🔌"}),E.jsx("div",{className:"message",children:"No Connection Selected"}),E.jsx("div",{className:"submessage",children:"Please connect to a database to view ML models"})]})}),t==="functions"&&E.jsx(E.Fragment,{children:o.length===0&&!c?E.jsxs(Yg,{children:[E.jsx("div",{className:"icon",children:"🔍"}),E.jsx("div",{className:"message",children:"No ML Functions Available"}),E.jsx("div",{className:"submessage",children:"Check your connection to load available ML functions"})]}):E.jsx(QI,{children:Object.entries(w()).map(([P,Q])=>Q.length===0?null:E.jsxs(CI,{children:[E.jsxs(TI,{category:P,children:[_(P)," (",Q.length,")"]}),E.jsx($I,{children:Q.map($=>E.jsxs(MI,{children:[E.jsx("div",{className:"name",children:$.name}),E.jsx("div",{className:"description",children:$.description})]},$.name))})]},P))})})]})]})};/*! - * @kurkle/color v0.3.4 - * https://github.com/kurkle/color#readme - * (c) 2024 Jukka Kurkela - * Released under the MIT License - */function yc(n){return n+.5|0}const jr=(n,e,t)=>Math.max(Math.min(n,t),e);function da(n){return jr(yc(n*2.55),0,255)}function Vr(n){return jr(yc(n*255),0,255)}function er(n){return jr(yc(n/2.55)/100,0,1)}function DS(n){return jr(yc(n*100),0,100)}const Bn={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},kO=[..."0123456789ABCDEF"],RI=n=>kO[n&15],AI=n=>kO[(n&240)>>4]+kO[n&15],ih=n=>(n&240)>>4===(n&15),EI=n=>ih(n.r)&&ih(n.g)&&ih(n.b)&&ih(n.a);function LI(n){var e=n.length,t;return n[0]==="#"&&(e===4||e===5?t={r:255&Bn[n[1]]*17,g:255&Bn[n[2]]*17,b:255&Bn[n[3]]*17,a:e===5?Bn[n[4]]*17:255}:(e===7||e===9)&&(t={r:Bn[n[1]]<<4|Bn[n[2]],g:Bn[n[3]]<<4|Bn[n[4]],b:Bn[n[5]]<<4|Bn[n[6]],a:e===9?Bn[n[7]]<<4|Bn[n[8]]:255})),t}const DI=(n,e)=>n<255?e(n):"";function zI(n){var e=EI(n)?RI:AI;return n?"#"+e(n.r)+e(n.g)+e(n.b)+DI(n.a,e):void 0}const ZI=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function oC(n,e,t){const i=e*Math.min(t,1-t),r=(s,o=(s+n/30)%12)=>t-i*Math.max(Math.min(o-3,9-o,1),-1);return[r(0),r(8),r(4)]}function II(n,e,t){const i=(r,s=(r+n/60)%6)=>t-t*e*Math.max(Math.min(s,4-s,1),0);return[i(5),i(3),i(1)]}function jI(n,e,t){const i=oC(n,1,.5);let r;for(e+t>1&&(r=1/(e+t),e*=r,t*=r),r=0;r<3;r++)i[r]*=1-e-t,i[r]+=e;return i}function BI(n,e,t,i,r){return n===r?(e-t)/i+(e.5?f/(2-s-o):f/(s+o),c=BI(t,i,r,f,s),c=c*60+.5),[c|0,h||0,a]}function Z0(n,e,t,i){return(Array.isArray(e)?n(e[0],e[1],e[2]):n(e,t,i)).map(Vr)}function I0(n,e,t){return Z0(oC,n,e,t)}function NI(n,e,t){return Z0(jI,n,e,t)}function XI(n,e,t){return Z0(II,n,e,t)}function lC(n){return(n%360+360)%360}function WI(n){const e=ZI.exec(n);let t=255,i;if(!e)return;e[5]!==i&&(t=e[6]?da(+e[5]):Vr(+e[5]));const r=lC(+e[2]),s=+e[3]/100,o=+e[4]/100;return e[1]==="hwb"?i=NI(r,s,o):e[1]==="hsv"?i=XI(r,s,o):i=I0(r,s,o),{r:i[0],g:i[1],b:i[2],a:t}}function VI(n,e){var t=z0(n);t[0]=lC(t[0]+e),t=I0(t),n.r=t[0],n.g=t[1],n.b=t[2]}function FI(n){if(!n)return;const e=z0(n),t=e[0],i=DS(e[1]),r=DS(e[2]);return n.a<255?`hsla(${t}, ${i}%, ${r}%, ${er(n.a)})`:`hsl(${t}, ${i}%, ${r}%)`}const zS={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},ZS={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};function YI(){const n={},e=Object.keys(ZS),t=Object.keys(zS);let i,r,s,o,a;for(i=0;i>16&255,s>>8&255,s&255]}return n}let rh;function qI(n){rh||(rh=YI(),rh.transparent=[0,0,0,0]);const e=rh[n.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:e.length===4?e[3]:255}}const UI=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;function HI(n){const e=UI.exec(n);let t=255,i,r,s;if(e){if(e[7]!==i){const o=+e[7];t=e[8]?da(o):jr(o*255,0,255)}return i=+e[1],r=+e[3],s=+e[5],i=255&(e[2]?da(i):jr(i,0,255)),r=255&(e[4]?da(r):jr(r,0,255)),s=255&(e[6]?da(s):jr(s,0,255)),{r:i,g:r,b:s,a:t}}}function GI(n){return n&&(n.a<255?`rgba(${n.r}, ${n.g}, ${n.b}, ${er(n.a)})`:`rgb(${n.r}, ${n.g}, ${n.b})`)}const qg=n=>n<=.0031308?n*12.92:Math.pow(n,1/2.4)*1.055-.055,vo=n=>n<=.04045?n/12.92:Math.pow((n+.055)/1.055,2.4);function KI(n,e,t){const i=vo(er(n.r)),r=vo(er(n.g)),s=vo(er(n.b));return{r:Vr(qg(i+t*(vo(er(e.r))-i))),g:Vr(qg(r+t*(vo(er(e.g))-r))),b:Vr(qg(s+t*(vo(er(e.b))-s))),a:n.a+t*(e.a-n.a)}}function sh(n,e,t){if(n){let i=z0(n);i[e]=Math.max(0,Math.min(i[e]+i[e]*t,e===0?360:1)),i=I0(i),n.r=i[0],n.g=i[1],n.b=i[2]}}function aC(n,e){return n&&Object.assign(e||{},n)}function IS(n){var e={r:0,g:0,b:0,a:255};return Array.isArray(n)?n.length>=3&&(e={r:n[0],g:n[1],b:n[2],a:255},n.length>3&&(e.a=Vr(n[3]))):(e=aC(n,{r:0,g:0,b:0,a:1}),e.a=Vr(e.a)),e}function JI(n){return n.charAt(0)==="r"?HI(n):WI(n)}class ec{constructor(e){if(e instanceof ec)return e;const t=typeof e;let i;t==="object"?i=IS(e):t==="string"&&(i=LI(e)||qI(e)||JI(e)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var e=aC(this._rgb);return e&&(e.a=er(e.a)),e}set rgb(e){this._rgb=IS(e)}rgbString(){return this._valid?GI(this._rgb):void 0}hexString(){return this._valid?zI(this._rgb):void 0}hslString(){return this._valid?FI(this._rgb):void 0}mix(e,t){if(e){const i=this.rgb,r=e.rgb;let s;const o=t===s?.5:t,a=2*o-1,c=i.a-r.a,h=((a*c===-1?a:(a+c)/(1+a*c))+1)/2;s=1-h,i.r=255&h*i.r+s*r.r+.5,i.g=255&h*i.g+s*r.g+.5,i.b=255&h*i.b+s*r.b+.5,i.a=o*i.a+(1-o)*r.a,this.rgb=i}return this}interpolate(e,t){return e&&(this._rgb=KI(this._rgb,e._rgb,t)),this}clone(){return new ec(this.rgb)}alpha(e){return this._rgb.a=Vr(e),this}clearer(e){const t=this._rgb;return t.a*=1-e,this}greyscale(){const e=this._rgb,t=yc(e.r*.3+e.g*.59+e.b*.11);return e.r=e.g=e.b=t,this}opaquer(e){const t=this._rgb;return t.a*=1+e,this}negate(){const e=this._rgb;return e.r=255-e.r,e.g=255-e.g,e.b=255-e.b,this}lighten(e){return sh(this._rgb,2,e),this}darken(e){return sh(this._rgb,2,-e),this}saturate(e){return sh(this._rgb,1,e),this}desaturate(e){return sh(this._rgb,1,-e),this}rotate(e){return VI(this._rgb,e),this}}/*! - * Chart.js v4.5.0 - * https://www.chartjs.org - * (c) 2025 Chart.js Contributors - * Released under the MIT License - */function qi(){}const e4=(()=>{let n=0;return()=>n++})();function Ne(n){return n==null}function bt(n){if(Array.isArray&&Array.isArray(n))return!0;const e=Object.prototype.toString.call(n);return e.slice(0,7)==="[object"&&e.slice(-6)==="Array]"}function De(n){return n!==null&&Object.prototype.toString.call(n)==="[object Object]"}function nn(n){return(typeof n=="number"||n instanceof Number)&&isFinite(+n)}function ki(n,e){return nn(n)?n:e}function Re(n,e){return typeof n>"u"?e:n}const t4=(n,e)=>typeof n=="string"&&n.endsWith("%")?parseFloat(n)/100:+n/e,cC=(n,e)=>typeof n=="string"&&n.endsWith("%")?parseFloat(n)/100*e:+n;function it(n,e,t){if(n&&typeof n.call=="function")return n.apply(t,e)}function Ye(n,e,t,i){let r,s,o;if(bt(n))for(s=n.length,r=0;rn,x:n=>n.x,y:n=>n.y};function r4(n){const e=n.split("."),t=[];let i="";for(const r of e)i+=r,i.endsWith("\\")?i=i.slice(0,-1)+".":(t.push(i),i="");return t}function s4(n){const e=r4(n);return t=>{for(const i of e){if(i==="")break;t=t&&t[i]}return t}}function Xs(n,e){return(jS[e]||(jS[e]=s4(e)))(n)}function j0(n){return n.charAt(0).toUpperCase()+n.slice(1)}const nc=n=>typeof n<"u",Kr=n=>typeof n=="function",BS=(n,e)=>{if(n.size!==e.size)return!1;for(const t of n)if(!e.has(t))return!1;return!0};function o4(n){return n.type==="mouseup"||n.type==="click"||n.type==="contextmenu"}const qe=Math.PI,ht=2*qe,l4=ht+qe,kf=Number.POSITIVE_INFINITY,a4=qe/180,$t=qe/2,ys=qe/4,NS=qe*2/3,hC=Math.log10,Di=Math.sign;function _a(n,e,t){return Math.abs(n-e)r-s).pop(),e}function u4(n){return typeof n=="symbol"||typeof n=="object"&&n!==null&&!(Symbol.toPrimitive in n||"toString"in n||"valueOf"in n)}function Go(n){return!u4(n)&&!isNaN(parseFloat(n))&&isFinite(n)}function h4(n,e){const t=Math.round(n);return t-e<=n&&t+e>=n}function f4(n,e,t){let i,r,s;for(i=0,r=n.length;ic&&h=Math.min(e,t)-i&&n<=Math.max(e,t)+i}function B0(n,e,t){t=t||(o=>n[o]1;)s=r+i>>1,t(s)?r=s:i=s;return{lo:r,hi:i}}const $s=(n,e,t,i)=>B0(n,t,i?r=>{const s=n[r][e];return sn[r][e]B0(n,t,i=>n[i][e]>=t);function O4(n,e,t){let i=0,r=n.length;for(;ii&&n[r-1]>t;)r--;return i>0||r{const i="_onData"+j0(t),r=n[t];Object.defineProperty(n,t,{configurable:!0,enumerable:!1,value(...s){const o=r.apply(this,s);return n._chartjs.listeners.forEach(a=>{typeof a[i]=="function"&&a[i](...s)}),o}})})}function VS(n,e){const t=n._chartjs;if(!t)return;const i=t.listeners,r=i.indexOf(e);r!==-1&&i.splice(r,1),!(i.length>0)&&(dC.forEach(s=>{delete n[s]}),delete n._chartjs)}function pC(n){const e=new Set(n);return e.size===n.length?n:Array.from(e)}const gC=(function(){return typeof window>"u"?function(n){return n()}:window.requestAnimationFrame})();function mC(n,e){let t=[],i=!1;return function(...r){t=r,i||(i=!0,gC.call(window,()=>{i=!1,n.apply(e,t)}))}}function x4(n,e){let t;return function(...i){return e?(clearTimeout(t),t=setTimeout(n,e,i)):n.apply(this,i),e}}const N0=n=>n==="start"?"left":n==="end"?"right":"center",Gt=(n,e,t)=>n==="start"?e:n==="end"?t:(e+t)/2,v4=(n,e,t,i)=>n===(i?"left":"right")?t:n==="center"?(e+t)/2:e;function OC(n,e,t){const i=e.length;let r=0,s=i;if(n._sorted){const{iScale:o,vScale:a,_parsed:c}=n,h=n.dataset&&n.dataset.options?n.dataset.options.spanGaps:null,f=o.axis,{min:p,max:m,minDefined:y,maxDefined:v}=o.getUserBounds();if(y){if(r=Math.min($s(c,f,p).lo,t?i:$s(e,f,o.getPixelForValue(p)).lo),h){const b=c.slice(0,r+1).reverse().findIndex(S=>!Ne(S[a.axis]));r-=Math.max(0,b)}r=en(r,0,i-1)}if(v){let b=Math.max($s(c,o.axis,m,!0).hi+1,t?0:$s(e,f,o.getPixelForValue(m),!0).hi+1);if(h){const S=c.slice(b-1).findIndex(w=>!Ne(w[a.axis]));b+=Math.max(0,S)}s=en(b,r,i)-r}else s=i-r}return{start:r,count:s}}function yC(n){const{xScale:e,yScale:t,_scaleRanges:i}=n,r={xmin:e.min,xmax:e.max,ymin:t.min,ymax:t.max};if(!i)return n._scaleRanges=r,!0;const s=i.xmin!==e.min||i.xmax!==e.max||i.ymin!==t.min||i.ymax!==t.max;return Object.assign(i,r),s}const oh=n=>n===0||n===1,FS=(n,e,t)=>-(Math.pow(2,10*(n-=1))*Math.sin((n-e)*ht/t)),YS=(n,e,t)=>Math.pow(2,-10*n)*Math.sin((n-e)*ht/t)+1,Qa={linear:n=>n,easeInQuad:n=>n*n,easeOutQuad:n=>-n*(n-2),easeInOutQuad:n=>(n/=.5)<1?.5*n*n:-.5*(--n*(n-2)-1),easeInCubic:n=>n*n*n,easeOutCubic:n=>(n-=1)*n*n+1,easeInOutCubic:n=>(n/=.5)<1?.5*n*n*n:.5*((n-=2)*n*n+2),easeInQuart:n=>n*n*n*n,easeOutQuart:n=>-((n-=1)*n*n*n-1),easeInOutQuart:n=>(n/=.5)<1?.5*n*n*n*n:-.5*((n-=2)*n*n*n-2),easeInQuint:n=>n*n*n*n*n,easeOutQuint:n=>(n-=1)*n*n*n*n+1,easeInOutQuint:n=>(n/=.5)<1?.5*n*n*n*n*n:.5*((n-=2)*n*n*n*n+2),easeInSine:n=>-Math.cos(n*$t)+1,easeOutSine:n=>Math.sin(n*$t),easeInOutSine:n=>-.5*(Math.cos(qe*n)-1),easeInExpo:n=>n===0?0:Math.pow(2,10*(n-1)),easeOutExpo:n=>n===1?1:-Math.pow(2,-10*n)+1,easeInOutExpo:n=>oh(n)?n:n<.5?.5*Math.pow(2,10*(n*2-1)):.5*(-Math.pow(2,-10*(n*2-1))+2),easeInCirc:n=>n>=1?n:-(Math.sqrt(1-n*n)-1),easeOutCirc:n=>Math.sqrt(1-(n-=1)*n),easeInOutCirc:n=>(n/=.5)<1?-.5*(Math.sqrt(1-n*n)-1):.5*(Math.sqrt(1-(n-=2)*n)+1),easeInElastic:n=>oh(n)?n:FS(n,.075,.3),easeOutElastic:n=>oh(n)?n:YS(n,.075,.3),easeInOutElastic(n){return oh(n)?n:n<.5?.5*FS(n*2,.1125,.45):.5+.5*YS(n*2-1,.1125,.45)},easeInBack(n){return n*n*((1.70158+1)*n-1.70158)},easeOutBack(n){return(n-=1)*n*((1.70158+1)*n+1.70158)+1},easeInOutBack(n){let e=1.70158;return(n/=.5)<1?.5*(n*n*(((e*=1.525)+1)*n-e)):.5*((n-=2)*n*(((e*=1.525)+1)*n+e)+2)},easeInBounce:n=>1-Qa.easeOutBounce(1-n),easeOutBounce(n){return n<1/2.75?7.5625*n*n:n<2/2.75?7.5625*(n-=1.5/2.75)*n+.75:n<2.5/2.75?7.5625*(n-=2.25/2.75)*n+.9375:7.5625*(n-=2.625/2.75)*n+.984375},easeInOutBounce:n=>n<.5?Qa.easeInBounce(n*2)*.5:Qa.easeOutBounce(n*2-1)*.5+.5};function X0(n){if(n&&typeof n=="object"){const e=n.toString();return e==="[object CanvasPattern]"||e==="[object CanvasGradient]"}return!1}function qS(n){return X0(n)?n:new ec(n)}function Ug(n){return X0(n)?n:new ec(n).saturate(.5).darken(.1).hexString()}const b4=["x","y","borderWidth","radius","tension"],S4=["color","borderColor","backgroundColor"];function w4(n){n.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),n.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:e=>e!=="onProgress"&&e!=="onComplete"&&e!=="fn"}),n.set("animations",{colors:{type:"color",properties:S4},numbers:{type:"number",properties:b4}}),n.describe("animations",{_fallback:"animation"}),n.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:e=>e|0}}}})}function k4(n){n.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})}const US=new Map;function P4(n,e){e=e||{};const t=n+JSON.stringify(e);let i=US.get(t);return i||(i=new Intl.NumberFormat(n,e),US.set(t,i)),i}function W0(n,e,t){return P4(e,t).format(n)}const _4={values(n){return bt(n)?n:""+n},numeric(n,e,t){if(n===0)return"0";const i=this.chart.options.locale;let r,s=n;if(t.length>1){const h=Math.max(Math.abs(t[0].value),Math.abs(t[t.length-1].value));(h<1e-4||h>1e15)&&(r="scientific"),s=Q4(n,t)}const o=hC(Math.abs(s)),a=isNaN(o)?1:Math.max(Math.min(-1*Math.floor(o),20),0),c={notation:r,minimumFractionDigits:a,maximumFractionDigits:a};return Object.assign(c,this.options.ticks.format),W0(n,i,c)}};function Q4(n,e){let t=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;return Math.abs(t)>=1&&n!==Math.floor(n)&&(t=n-Math.floor(n)),t}var xC={formatters:_4};function C4(n){n.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(e,t)=>t.lineWidth,tickColor:(e,t)=>t.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:xC.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),n.route("scale.ticks","color","","color"),n.route("scale.grid","color","","borderColor"),n.route("scale.border","color","","borderColor"),n.route("scale.title","color","","color"),n.describe("scale",{_fallback:!1,_scriptable:e=>!e.startsWith("before")&&!e.startsWith("after")&&e!=="callback"&&e!=="parser",_indexable:e=>e!=="borderDash"&&e!=="tickBorderDash"&&e!=="dash"}),n.describe("scales",{_fallback:"scale"}),n.describe("scale.ticks",{_scriptable:e=>e!=="backdropPadding"&&e!=="callback",_indexable:e=>e!=="backdropPadding"})}const Ws=Object.create(null),_O=Object.create(null);function Ca(n,e){if(!e)return n;const t=e.split(".");for(let i=0,r=t.length;ii.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(i,r)=>Ug(r.backgroundColor),this.hoverBorderColor=(i,r)=>Ug(r.borderColor),this.hoverColor=(i,r)=>Ug(r.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(e),this.apply(t)}set(e,t){return Hg(this,e,t)}get(e){return Ca(this,e)}describe(e,t){return Hg(_O,e,t)}override(e,t){return Hg(Ws,e,t)}route(e,t,i,r){const s=Ca(this,e),o=Ca(this,i),a="_"+t;Object.defineProperties(s,{[a]:{value:s[t],writable:!0},[t]:{enumerable:!0,get(){const c=this[a],h=o[r];return De(c)?Object.assign({},h,c):Re(c,h)},set(c){this[a]=c}}})}apply(e){e.forEach(t=>t(this))}}var Ot=new T4({_scriptable:n=>!n.startsWith("on"),_indexable:n=>n!=="events",hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[w4,k4,C4]);function $4(n){return!n||Ne(n.size)||Ne(n.family)?null:(n.style?n.style+" ":"")+(n.weight?n.weight+" ":"")+n.size+"px "+n.family}function HS(n,e,t,i,r){let s=e[r];return s||(s=e[r]=n.measureText(r).width,t.push(r)),s>i&&(i=s),i}function xs(n,e,t){const i=n.currentDevicePixelRatio,r=t!==0?Math.max(t/2,.5):0;return Math.round((e-r)*i)/i+r}function GS(n,e){!e&&!n||(e=e||n.getContext("2d"),e.save(),e.resetTransform(),e.clearRect(0,0,n.width,n.height),e.restore())}function QO(n,e,t,i){vC(n,e,t,i,null)}function vC(n,e,t,i,r){let s,o,a,c,h,f,p,m;const y=e.pointStyle,v=e.rotation,b=e.radius;let S=(v||0)*a4;if(y&&typeof y=="object"&&(s=y.toString(),s==="[object HTMLImageElement]"||s==="[object HTMLCanvasElement]")){n.save(),n.translate(t,i),n.rotate(S),n.drawImage(y,-y.width/2,-y.height/2,y.width,y.height),n.restore();return}if(!(isNaN(b)||b<=0)){switch(n.beginPath(),y){default:r?n.ellipse(t,i,r/2,b,0,0,ht):n.arc(t,i,b,0,ht),n.closePath();break;case"triangle":f=r?r/2:b,n.moveTo(t+Math.sin(S)*f,i-Math.cos(S)*b),S+=NS,n.lineTo(t+Math.sin(S)*f,i-Math.cos(S)*b),S+=NS,n.lineTo(t+Math.sin(S)*f,i-Math.cos(S)*b),n.closePath();break;case"rectRounded":h=b*.516,c=b-h,o=Math.cos(S+ys)*c,p=Math.cos(S+ys)*(r?r/2-h:c),a=Math.sin(S+ys)*c,m=Math.sin(S+ys)*(r?r/2-h:c),n.arc(t-p,i-a,h,S-qe,S-$t),n.arc(t+m,i-o,h,S-$t,S),n.arc(t+p,i+a,h,S,S+$t),n.arc(t-m,i+o,h,S+$t,S+qe),n.closePath();break;case"rect":if(!v){c=Math.SQRT1_2*b,f=r?r/2:c,n.rect(t-f,i-c,2*f,2*c);break}S+=ys;case"rectRot":p=Math.cos(S)*(r?r/2:b),o=Math.cos(S)*b,a=Math.sin(S)*b,m=Math.sin(S)*(r?r/2:b),n.moveTo(t-p,i-a),n.lineTo(t+m,i-o),n.lineTo(t+p,i+a),n.lineTo(t-m,i+o),n.closePath();break;case"crossRot":S+=ys;case"cross":p=Math.cos(S)*(r?r/2:b),o=Math.cos(S)*b,a=Math.sin(S)*b,m=Math.sin(S)*(r?r/2:b),n.moveTo(t-p,i-a),n.lineTo(t+p,i+a),n.moveTo(t+m,i-o),n.lineTo(t-m,i+o);break;case"star":p=Math.cos(S)*(r?r/2:b),o=Math.cos(S)*b,a=Math.sin(S)*b,m=Math.sin(S)*(r?r/2:b),n.moveTo(t-p,i-a),n.lineTo(t+p,i+a),n.moveTo(t+m,i-o),n.lineTo(t-m,i+o),S+=ys,p=Math.cos(S)*(r?r/2:b),o=Math.cos(S)*b,a=Math.sin(S)*b,m=Math.sin(S)*(r?r/2:b),n.moveTo(t-p,i-a),n.lineTo(t+p,i+a),n.moveTo(t+m,i-o),n.lineTo(t-m,i+o);break;case"line":o=r?r/2:Math.cos(S)*b,a=Math.sin(S)*b,n.moveTo(t-o,i-a),n.lineTo(t+o,i+a);break;case"dash":n.moveTo(t,i),n.lineTo(t+Math.cos(S)*(r?r/2:b),i+Math.sin(S)*b);break;case!1:n.closePath();break}n.fill(),e.borderWidth>0&&n.stroke()}}function rc(n,e,t){return t=t||.5,!e||n&&n.x>e.left-t&&n.xe.top-t&&n.y0&&s.strokeColor!=="";let c,h;for(n.save(),n.font=r.string,A4(n,s),c=0;c+n||0;function V0(n,e){const t={},i=De(e),r=i?Object.keys(e):e,s=De(n)?i?o=>Re(n[o],n[e[o]]):o=>n[o]:()=>n;for(const o of r)t[o]=I4(s(o));return t}function bC(n){return V0(n,{top:"y",right:"x",bottom:"y",left:"x"})}function Zo(n){return V0(n,["topLeft","topRight","bottomLeft","bottomRight"])}function Un(n){const e=bC(n);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function tn(n,e){n=n||{},e=e||Ot.font;let t=Re(n.size,e.size);typeof t=="string"&&(t=parseInt(t,10));let i=Re(n.style,e.style);i&&!(""+i).match(z4)&&(console.warn('Invalid font style specified: "'+i+'"'),i=void 0);const r={family:Re(n.family,e.family),lineHeight:Z4(Re(n.lineHeight,e.lineHeight),t),size:t,style:i,weight:Re(n.weight,e.weight),string:""};return r.string=$4(r),r}function lh(n,e,t,i){let r,s,o;for(r=0,s=n.length;rt&&a===0?0:a+c;return{min:o(i,-Math.abs(s)),max:o(r,s)}}function Vs(n,e){return Object.assign(Object.create(n),e)}function F0(n,e=[""],t,i,r=()=>n[0]){const s=t||n;typeof i>"u"&&(i=PC("_fallback",n));const o={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:n,_rootScopes:s,_fallback:i,_getTarget:r,override:a=>F0([a,...n],e,s,i)};return new Proxy(o,{deleteProperty(a,c){return delete a[c],delete a._keys,delete n[0][c],!0},get(a,c){return wC(a,c,()=>q4(c,e,n,a))},getOwnPropertyDescriptor(a,c){return Reflect.getOwnPropertyDescriptor(a._scopes[0],c)},getPrototypeOf(){return Reflect.getPrototypeOf(n[0])},has(a,c){return JS(a).includes(c)},ownKeys(a){return JS(a)},set(a,c,h){const f=a._storage||(a._storage=r());return a[c]=f[c]=h,delete a._keys,!0}})}function Ko(n,e,t,i){const r={_cacheable:!1,_proxy:n,_context:e,_subProxy:t,_stack:new Set,_descriptors:SC(n,i),setContext:s=>Ko(n,s,t,i),override:s=>Ko(n.override(s),e,t,i)};return new Proxy(r,{deleteProperty(s,o){return delete s[o],delete n[o],!0},get(s,o,a){return wC(s,o,()=>N4(s,o,a))},getOwnPropertyDescriptor(s,o){return s._descriptors.allKeys?Reflect.has(n,o)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(n,o)},getPrototypeOf(){return Reflect.getPrototypeOf(n)},has(s,o){return Reflect.has(n,o)},ownKeys(){return Reflect.ownKeys(n)},set(s,o,a){return n[o]=a,delete s[o],!0}})}function SC(n,e={scriptable:!0,indexable:!0}){const{_scriptable:t=e.scriptable,_indexable:i=e.indexable,_allKeys:r=e.allKeys}=n;return{allKeys:r,scriptable:t,indexable:i,isScriptable:Kr(t)?t:()=>t,isIndexable:Kr(i)?i:()=>i}}const B4=(n,e)=>n?n+j0(e):e,Y0=(n,e)=>De(e)&&n!=="adapters"&&(Object.getPrototypeOf(e)===null||e.constructor===Object);function wC(n,e,t){if(Object.prototype.hasOwnProperty.call(n,e)||e==="constructor")return n[e];const i=t();return n[e]=i,i}function N4(n,e,t){const{_proxy:i,_context:r,_subProxy:s,_descriptors:o}=n;let a=i[e];return Kr(a)&&o.isScriptable(e)&&(a=X4(e,a,n,t)),bt(a)&&a.length&&(a=W4(e,a,n,o.isIndexable)),Y0(e,a)&&(a=Ko(a,r,s&&s[e],o)),a}function X4(n,e,t,i){const{_proxy:r,_context:s,_subProxy:o,_stack:a}=t;if(a.has(n))throw new Error("Recursion detected: "+Array.from(a).join("->")+"->"+n);a.add(n);let c=e(s,o||i);return a.delete(n),Y0(n,c)&&(c=q0(r._scopes,r,n,c)),c}function W4(n,e,t,i){const{_proxy:r,_context:s,_subProxy:o,_descriptors:a}=t;if(typeof s.index<"u"&&i(n))return e[s.index%e.length];if(De(e[0])){const c=e,h=r._scopes.filter(f=>f!==c);e=[];for(const f of c){const p=q0(h,r,n,f);e.push(Ko(p,s,o&&o[n],a))}}return e}function kC(n,e,t){return Kr(n)?n(e,t):n}const V4=(n,e)=>n===!0?e:typeof n=="string"?Xs(e,n):void 0;function F4(n,e,t,i,r){for(const s of e){const o=V4(t,s);if(o){n.add(o);const a=kC(o._fallback,t,r);if(typeof a<"u"&&a!==t&&a!==i)return a}else if(o===!1&&typeof i<"u"&&t!==i)return null}return!1}function q0(n,e,t,i){const r=e._rootScopes,s=kC(e._fallback,t,i),o=[...n,...r],a=new Set;a.add(i);let c=KS(a,o,t,s||t,i);return c===null||typeof s<"u"&&s!==t&&(c=KS(a,o,s,c,i),c===null)?!1:F0(Array.from(a),[""],r,s,()=>Y4(e,t,i))}function KS(n,e,t,i,r){for(;t;)t=F4(n,e,t,i,r);return t}function Y4(n,e,t){const i=n._getTarget();e in i||(i[e]={});const r=i[e];return bt(r)&&De(t)?t:r||{}}function q4(n,e,t,i){let r;for(const s of e)if(r=PC(B4(s,n),t),typeof r<"u")return Y0(n,r)?q0(t,i,n,r):r}function PC(n,e){for(const t of e){if(!t)continue;const i=t[n];if(typeof i<"u")return i}}function JS(n){let e=n._keys;return e||(e=n._keys=U4(n._scopes)),e}function U4(n){const e=new Set;for(const t of n)for(const i of Object.keys(t).filter(r=>!r.startsWith("_")))e.add(i);return Array.from(e)}const H4=Number.EPSILON||1e-14,Jo=(n,e)=>en==="x"?"y":"x";function G4(n,e,t,i){const r=n.skip?e:n,s=e,o=t.skip?e:t,a=PO(s,r),c=PO(o,s);let h=a/(a+c),f=c/(a+c);h=isNaN(h)?0:h,f=isNaN(f)?0:f;const p=i*h,m=i*f;return{previous:{x:s.x-p*(o.x-r.x),y:s.y-p*(o.y-r.y)},next:{x:s.x+m*(o.x-r.x),y:s.y+m*(o.y-r.y)}}}function K4(n,e,t){const i=n.length;let r,s,o,a,c,h=Jo(n,0);for(let f=0;f!h.skip)),e.cubicInterpolationMode==="monotone")ej(n,r);else{let h=i?n[n.length-1]:n[0];for(s=0,o=n.length;sn.ownerDocument.defaultView.getComputedStyle(n,null);function ij(n,e){return ed(n).getPropertyValue(e)}const rj=["top","right","bottom","left"];function Ls(n,e,t){const i={};t=t?"-"+t:"";for(let r=0;r<4;r++){const s=rj[r];i[s]=parseFloat(n[e+"-"+s+t])||0}return i.width=i.left+i.right,i.height=i.top+i.bottom,i}const sj=(n,e,t)=>(n>0||e>0)&&(!t||!t.shadowRoot);function oj(n,e){const t=n.touches,i=t&&t.length?t[0]:n,{offsetX:r,offsetY:s}=i;let o=!1,a,c;if(sj(r,s,n.target))a=r,c=s;else{const h=e.getBoundingClientRect();a=i.clientX-h.left,c=i.clientY-h.top,o=!0}return{x:a,y:c,box:o}}function ws(n,e){if("native"in n)return n;const{canvas:t,currentDevicePixelRatio:i}=e,r=ed(t),s=r.boxSizing==="border-box",o=Ls(r,"padding"),a=Ls(r,"border","width"),{x:c,y:h,box:f}=oj(n,t),p=o.left+(f&&a.left),m=o.top+(f&&a.top);let{width:y,height:v}=e;return s&&(y-=o.width+a.width,v-=o.height+a.height),{x:Math.round((c-p)/y*t.width/i),y:Math.round((h-m)/v*t.height/i)}}function lj(n,e,t){let i,r;if(e===void 0||t===void 0){const s=n&&H0(n);if(!s)e=n.clientWidth,t=n.clientHeight;else{const o=s.getBoundingClientRect(),a=ed(s),c=Ls(a,"border","width"),h=Ls(a,"padding");e=o.width-h.width-c.width,t=o.height-h.height-c.height,i=_f(a.maxWidth,s,"clientWidth"),r=_f(a.maxHeight,s,"clientHeight")}}return{width:e,height:t,maxWidth:i||kf,maxHeight:r||kf}}const ch=n=>Math.round(n*10)/10;function aj(n,e,t,i){const r=ed(n),s=Ls(r,"margin"),o=_f(r.maxWidth,n,"clientWidth")||kf,a=_f(r.maxHeight,n,"clientHeight")||kf,c=lj(n,e,t);let{width:h,height:f}=c;if(r.boxSizing==="content-box"){const m=Ls(r,"border","width"),y=Ls(r,"padding");h-=y.width+m.width,f-=y.height+m.height}return h=Math.max(0,h-s.width),f=Math.max(0,i?h/i:f-s.height),h=ch(Math.min(h,o,c.maxWidth)),f=ch(Math.min(f,a,c.maxHeight)),h&&!f&&(f=ch(h/2)),(e!==void 0||t!==void 0)&&i&&c.height&&f>c.height&&(f=c.height,h=ch(Math.floor(f*i))),{width:h,height:f}}function ew(n,e,t){const i=e||1,r=Math.floor(n.height*i),s=Math.floor(n.width*i);n.height=Math.floor(n.height),n.width=Math.floor(n.width);const o=n.canvas;return o.style&&(t||!o.style.height&&!o.style.width)&&(o.style.height=`${n.height}px`,o.style.width=`${n.width}px`),n.currentDevicePixelRatio!==i||o.height!==r||o.width!==s?(n.currentDevicePixelRatio=i,o.height=r,o.width=s,n.ctx.setTransform(i,0,0,i,0,0),!0):!1}const cj=(function(){let n=!1;try{const e={get passive(){return n=!0,!1}};U0()&&(window.addEventListener("test",null,e),window.removeEventListener("test",null,e))}catch{}return n})();function tw(n,e){const t=ij(n,e),i=t&&t.match(/^(\d+)(\.\d+)?px$/);return i?+i[1]:void 0}function ks(n,e,t,i){return{x:n.x+t*(e.x-n.x),y:n.y+t*(e.y-n.y)}}function uj(n,e,t,i){return{x:n.x+t*(e.x-n.x),y:i==="middle"?t<.5?n.y:e.y:i==="after"?t<1?n.y:e.y:t>0?e.y:n.y}}function hj(n,e,t,i){const r={x:n.cp2x,y:n.cp2y},s={x:e.cp1x,y:e.cp1y},o=ks(n,r,t),a=ks(r,s,t),c=ks(s,e,t),h=ks(o,a,t),f=ks(a,c,t);return ks(h,f,t)}const fj=function(n,e){return{x(t){return n+n+e-t},setWidth(t){e=t},textAlign(t){return t==="center"?t:t==="right"?"left":"right"},xPlus(t,i){return t-i},leftForLtr(t,i){return t-i}}},dj=function(){return{x(n){return n},setWidth(n){},textAlign(n){return n},xPlus(n,e){return n+e},leftForLtr(n,e){return n}}};function Io(n,e,t){return n?fj(e,t):dj()}function QC(n,e){let t,i;(e==="ltr"||e==="rtl")&&(t=n.canvas.style,i=[t.getPropertyValue("direction"),t.getPropertyPriority("direction")],t.setProperty("direction",e,"important"),n.prevTextDirection=i)}function CC(n,e){e!==void 0&&(delete n.prevTextDirection,n.canvas.style.setProperty("direction",e[0],e[1]))}function TC(n){return n==="angle"?{between:ic,compare:p4,normalize:An}:{between:ir,compare:(e,t)=>e-t,normalize:e=>e}}function nw({start:n,end:e,count:t,loop:i,style:r}){return{start:n%t,end:e%t,loop:i&&(e-n+1)%t===0,style:r}}function pj(n,e,t){const{property:i,start:r,end:s}=t,{between:o,normalize:a}=TC(i),c=e.length;let{start:h,end:f,loop:p}=n,m,y;if(p){for(h+=c,f+=c,m=0,y=c;mc(r,_,w)&&a(r,_)!==0,Q=()=>a(s,w)===0||c(s,_,w),$=()=>b||P(),M=()=>!b||Q();for(let Z=f,j=f;Z<=p;++Z)C=e[Z%o],!C.skip&&(w=h(C[i]),w!==_&&(b=c(w,r,s),S===null&&$()&&(S=a(w,r)===0?Z:j),S!==null&&M()&&(v.push(nw({start:S,end:Z,loop:m,count:o,style:y})),S=null),j=Z,_=w));return S!==null&&v.push(nw({start:S,end:p,loop:m,count:o,style:y})),v}function MC(n,e){const t=[],i=n.segments;for(let r=0;rr&&n[s%e].skip;)s--;return s%=e,{start:r,end:s}}function mj(n,e,t,i){const r=n.length,s=[];let o=e,a=n[e],c;for(c=e+1;c<=t;++c){const h=n[c%r];h.skip||h.stop?a.skip||(i=!1,s.push({start:e%r,end:(c-1)%r,loop:i}),e=o=h.stop?c:null):(o=c,a.skip&&(e=c)),a=h}return o!==null&&s.push({start:e%r,end:o%r,loop:i}),s}function Oj(n,e){const t=n.points,i=n.options.spanGaps,r=t.length;if(!r)return[];const s=!!n._loop,{start:o,end:a}=gj(t,r,s,i);if(i===!0)return iw(n,[{start:o,end:a,loop:s}],t,e);const c=aa({chart:e,initial:t.initial,numSteps:o,currentStep:Math.min(i-t.start,o)}))}_refresh(){this._request||(this._running=!0,this._request=gC.call(window,()=>{this._update(),this._request=null,this._running&&this._refresh()}))}_update(e=Date.now()){let t=0;this._charts.forEach((i,r)=>{if(!i.running||!i.items.length)return;const s=i.items;let o=s.length-1,a=!1,c;for(;o>=0;--o)c=s[o],c._active?(c._total>i.duration&&(i.duration=c._total),c.tick(e),a=!0):(s[o]=s[s.length-1],s.pop());a&&(r.draw(),this._notify(r,i,e,"progress")),s.length||(i.running=!1,this._notify(r,i,e,"complete"),i.initial=!1),t+=s.length}),this._lastDate=e,t===0&&(this._running=!1)}_getAnims(e){const t=this._charts;let i=t.get(e);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},t.set(e,i)),i}listen(e,t,i){this._getAnims(e).listeners[t].push(i)}add(e,t){!t||!t.length||this._getAnims(e).items.push(...t)}has(e){return this._getAnims(e).items.length>0}start(e){const t=this._charts.get(e);t&&(t.running=!0,t.start=Date.now(),t.duration=t.items.reduce((i,r)=>Math.max(i,r._duration),0),this._refresh())}running(e){if(!this._running)return!1;const t=this._charts.get(e);return!(!t||!t.running||!t.items.length)}stop(e){const t=this._charts.get(e);if(!t||!t.items.length)return;const i=t.items;let r=i.length-1;for(;r>=0;--r)i[r].cancel();t.items=[],this._notify(e,t,Date.now(),"complete")}remove(e){return this._charts.delete(e)}}var Ui=new bj;const sw="transparent",Sj={boolean(n,e,t){return t>.5?e:n},color(n,e,t){const i=qS(n||sw),r=i.valid&&qS(e||sw);return r&&r.valid?r.mix(i,t).hexString():e},number(n,e,t){return n+(e-n)*t}};class wj{constructor(e,t,i,r){const s=t[i];r=lh([e.to,r,s,e.from]);const o=lh([e.from,s,r]);this._active=!0,this._fn=e.fn||Sj[e.type||typeof o],this._easing=Qa[e.easing]||Qa.linear,this._start=Math.floor(Date.now()+(e.delay||0)),this._duration=this._total=Math.floor(e.duration),this._loop=!!e.loop,this._target=t,this._prop=i,this._from=o,this._to=r,this._promises=void 0}active(){return this._active}update(e,t,i){if(this._active){this._notify(!1);const r=this._target[this._prop],s=i-this._start,o=this._duration-s;this._start=i,this._duration=Math.floor(Math.max(o,e.duration)),this._total+=s,this._loop=!!e.loop,this._to=lh([e.to,t,r,e.from]),this._from=lh([e.from,r,t])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(e){const t=e-this._start,i=this._duration,r=this._prop,s=this._from,o=this._loop,a=this._to;let c;if(this._active=s!==a&&(o||t1?2-c:c,c=this._easing(Math.min(1,Math.max(0,c))),this._target[r]=this._fn(s,a,c)}wait(){const e=this._promises||(this._promises=[]);return new Promise((t,i)=>{e.push({res:t,rej:i})})}_notify(e){const t=e?"res":"rej",i=this._promises||[];for(let r=0;r{const s=e[r];if(!De(s))return;const o={};for(const a of t)o[a]=s[a];(bt(s.properties)&&s.properties||[r]).forEach(a=>{(a===r||!i.has(a))&&i.set(a,o)})})}_animateOptions(e,t){const i=t.options,r=Pj(e,i);if(!r)return[];const s=this._createAnimations(r,i);return i.$shared&&kj(e.options.$animations,i).then(()=>{e.options=i},()=>{}),s}_createAnimations(e,t){const i=this._properties,r=[],s=e.$animations||(e.$animations={}),o=Object.keys(t),a=Date.now();let c;for(c=o.length-1;c>=0;--c){const h=o[c];if(h.charAt(0)==="$")continue;if(h==="options"){r.push(...this._animateOptions(e,t));continue}const f=t[h];let p=s[h];const m=i.get(h);if(p)if(m&&p.active()){p.update(m,f,a);continue}else p.cancel();if(!m||!m.duration){e[h]=f;continue}s[h]=p=new wj(m,e,h,f),r.push(p)}return r}update(e,t){if(this._properties.size===0){Object.assign(e,t);return}const i=this._createAnimations(e,t);if(i.length)return Ui.add(this._chart,i),!0}}function kj(n,e){const t=[],i=Object.keys(e);for(let r=0;r0||!t&&s<0)return r.index}return null}function cw(n,e){const{chart:t,_cachedMeta:i}=n,r=t._stacks||(t._stacks={}),{iScale:s,vScale:o,index:a}=i,c=s.axis,h=o.axis,f=Tj(s,o,i),p=e.length;let m;for(let y=0;yt[i].axis===e).shift()}function Rj(n,e){return Vs(n,{active:!1,dataset:void 0,datasetIndex:e,index:e,mode:"default",type:"dataset"})}function Aj(n,e,t){return Vs(n,{active:!1,dataIndex:e,parsed:void 0,raw:void 0,element:t,index:e,mode:"default",type:"data"})}function ta(n,e){const t=n.controller.index,i=n.vScale&&n.vScale.axis;if(i){e=e||n._parsed;for(const r of e){const s=r._stacks;if(!s||s[i]===void 0||s[i][t]===void 0)return;delete s[i][t],s[i]._visualValues!==void 0&&s[i]._visualValues[t]!==void 0&&delete s[i]._visualValues[t]}}}const Jg=n=>n==="reset"||n==="none",uw=(n,e)=>e?n:Object.assign({},n),Ej=(n,e,t)=>n&&!e.hidden&&e._stacked&&{keys:EC(t,!0),values:null};class Fr{constructor(e,t){this.chart=e,this._ctx=e.ctx,this.index=t,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const e=this._cachedMeta;this.configure(),this.linkScales(),e._stacked=Gg(e.vScale,e),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(e){this.index!==e&&ta(this._cachedMeta),this.index=e}linkScales(){const e=this.chart,t=this._cachedMeta,i=this.getDataset(),r=(p,m,y,v)=>p==="x"?m:p==="r"?v:y,s=t.xAxisID=Re(i.xAxisID,Kg(e,"x")),o=t.yAxisID=Re(i.yAxisID,Kg(e,"y")),a=t.rAxisID=Re(i.rAxisID,Kg(e,"r")),c=t.indexAxis,h=t.iAxisID=r(c,s,o,a),f=t.vAxisID=r(c,o,s,a);t.xScale=this.getScaleForId(s),t.yScale=this.getScaleForId(o),t.rScale=this.getScaleForId(a),t.iScale=this.getScaleForId(h),t.vScale=this.getScaleForId(f)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(e){return this.chart.scales[e]}_getOtherScale(e){const t=this._cachedMeta;return e===t.iScale?t.vScale:t.iScale}reset(){this._update("reset")}_destroy(){const e=this._cachedMeta;this._data&&VS(this._data,this),e._stacked&&ta(e)}_dataCheck(){const e=this.getDataset(),t=e.data||(e.data=[]),i=this._data;if(De(t)){const r=this._cachedMeta;this._data=Cj(t,r)}else if(i!==t){if(i){VS(i,this);const r=this._cachedMeta;ta(r),r._parsed=[]}t&&Object.isExtensible(t)&&y4(t,this),this._syncList=[],this._data=t}}addElements(){const e=this._cachedMeta;this._dataCheck(),this.datasetElementType&&(e.dataset=new this.datasetElementType)}buildOrUpdateElements(e){const t=this._cachedMeta,i=this.getDataset();let r=!1;this._dataCheck();const s=t._stacked;t._stacked=Gg(t.vScale,t),t.stack!==i.stack&&(r=!0,ta(t),t.stack=i.stack),this._resyncElements(e),(r||s!==t._stacked)&&(cw(this,t._parsed),t._stacked=Gg(t.vScale,t))}configure(){const e=this.chart.config,t=e.datasetScopeKeys(this._type),i=e.getOptionScopes(this.getDataset(),t,!0);this.options=e.createResolver(i,this.getContext()),this._parsing=this.options.parsing,this._cachedDataOpts={}}parse(e,t){const{_cachedMeta:i,_data:r}=this,{iScale:s,_stacked:o}=i,a=s.axis;let c=e===0&&t===r.length?!0:i._sorted,h=e>0&&i._parsed[e-1],f,p,m;if(this._parsing===!1)i._parsed=r,i._sorted=!0,m=r;else{bt(r[e])?m=this.parseArrayData(i,r,e,t):De(r[e])?m=this.parseObjectData(i,r,e,t):m=this.parsePrimitiveData(i,r,e,t);const y=()=>p[a]===null||h&&p[a]b||p=0;--m)if(!v()){this.updateRangeFromParsed(h,e,y,c);break}}return h}getAllParsedValues(e){const t=this._cachedMeta._parsed,i=[];let r,s,o;for(r=0,s=t.length;r=0&&ethis.getContext(i,r,t),b=h.resolveNamedOptions(m,y,v,p);return b.$shared&&(b.$shared=c,s[o]=Object.freeze(uw(b,c))),b}_resolveAnimations(e,t,i){const r=this.chart,s=this._cachedDataOpts,o=`animation-${t}`,a=s[o];if(a)return a;let c;if(r.options.animation!==!1){const f=this.chart.config,p=f.datasetAnimationScopeKeys(this._type,t),m=f.getOptionScopes(this.getDataset(),p);c=f.createResolver(m,this.getContext(e,i,t))}const h=new AC(r,c&&c.animations);return c&&c._cacheable&&(s[o]=Object.freeze(h)),h}getSharedOptions(e){if(e.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},e))}includeOptions(e,t){return!t||Jg(e)||this.chart._animationsDisabled}_getSharedOptions(e,t){const i=this.resolveDataElementOptions(e,t),r=this._sharedOptions,s=this.getSharedOptions(i),o=this.includeOptions(t,s)||s!==r;return this.updateSharedOptions(s,t,i),{sharedOptions:s,includeOptions:o}}updateElement(e,t,i,r){Jg(r)?Object.assign(e,i):this._resolveAnimations(t,r).update(e,i)}updateSharedOptions(e,t,i){e&&!Jg(t)&&this._resolveAnimations(void 0,t).update(e,i)}_setStyle(e,t,i,r){e.active=r;const s=this.getStyle(t,r);this._resolveAnimations(t,i,r).update(e,{options:!r&&this.getSharedOptions(s)||s})}removeHoverStyle(e,t,i){this._setStyle(e,i,"active",!1)}setHoverStyle(e,t,i){this._setStyle(e,i,"active",!0)}_removeDatasetHoverStyle(){const e=this._cachedMeta.dataset;e&&this._setStyle(e,void 0,"active",!1)}_setDatasetHoverStyle(){const e=this._cachedMeta.dataset;e&&this._setStyle(e,void 0,"active",!0)}_resyncElements(e){const t=this._data,i=this._cachedMeta.data;for(const[a,c,h]of this._syncList)this[a](c,h);this._syncList=[];const r=i.length,s=t.length,o=Math.min(s,r);o&&this.parse(0,o),s>r?this._insertElements(r,s-r,e):s{for(h.length+=t,a=h.length-1;a>=o;a--)h[a]=h[a-t]};for(c(s),a=e;ar-s))}return n._cache.$bar}function Dj(n){const e=n.iScale,t=Lj(e,n.type);let i=e._length,r,s,o,a;const c=()=>{o===32767||o===-32768||(nc(a)&&(i=Math.min(i,Math.abs(o-a)||i)),a=o)};for(r=0,s=t.length;r0?r[n-1]:null,a=nMath.abs(a)&&(c=a,h=o),e[t.axis]=h,e._custom={barStart:c,barEnd:h,start:r,end:s,min:o,max:a}}function LC(n,e,t,i){return bt(n)?Ij(n,e,t,i):e[t.axis]=t.parse(n,i),e}function hw(n,e,t,i){const r=n.iScale,s=n.vScale,o=r.getLabels(),a=r===s,c=[];let h,f,p,m;for(h=t,f=t+i;h=t?1:-1)}function Bj(n){let e,t,i,r,s;return n.horizontal?(e=n.base>n.x,t="left",i="right"):(e=n.basef.controller.options.grouped),s=i.options.stacked,o=[],a=this._cachedMeta.controller.getParsed(t),c=a&&a[i.axis],h=f=>{const p=f._parsed.find(y=>y[i.axis]===c),m=p&&p[f.vScale.axis];if(Ne(m)||isNaN(m))return!0};for(const f of r)if(!(t!==void 0&&h(f))&&((s===!1||o.indexOf(f.stack)===-1||s===void 0&&f.stack===void 0)&&o.push(f.stack),f.index===e))break;return o.length||o.push(void 0),o}_getStackCount(e){return this._getStacks(void 0,e).length}_getAxisCount(){return this._getAxis().length}getFirstScaleIdForIndexAxis(){const e=this.chart.scales,t=this.chart.options.indexAxis;return Object.keys(e).filter(i=>e[i].axis===t).shift()}_getAxis(){const e={},t=this.getFirstScaleIdForIndexAxis();for(const i of this.chart.data.datasets)e[Re(this.chart.options.indexAxis==="x"?i.xAxisID:i.yAxisID,t)]=!0;return Object.keys(e)}_getStackIndex(e,t,i){const r=this._getStacks(e,i),s=t!==void 0?r.indexOf(t):-1;return s===-1?r.length-1:s}_getRuler(){const e=this.options,t=this._cachedMeta,i=t.iScale,r=[];let s,o;for(s=0,o=t.data.length;sic(_,a,c,!0)?1:Math.max(P,P*t,Q,Q*t),v=(_,P,Q)=>ic(_,a,c,!0)?-1:Math.min(P,P*t,Q,Q*t),b=y(0,h,p),S=y($t,f,m),w=v(qe,h,p),C=v(qe+$t,f,m);i=(b-w)/2,r=(S-C)/2,s=-(b+w)/2,o=-(S+C)/2}return{ratioX:i,ratioY:r,offsetX:s,offsetY:o}}class $o extends Fr{constructor(e,t){super(e,t),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(e,t){const i=this.getDataset().data,r=this._cachedMeta;if(this._parsing===!1)r._parsed=i;else{let s=c=>+i[c];if(De(i[e])){const{key:c="value"}=this._parsing;s=h=>+Xs(i[h],c)}let o,a;for(o=e,a=e+t;o0&&!isNaN(e)?ht*(Math.abs(e)/t):0}getLabelAndValue(e){const t=this._cachedMeta,i=this.chart,r=i.data.labels||[],s=W0(t._parsed[e],i.options.locale);return{label:r[e]||"",value:s}}getMaxBorderWidth(e){let t=0;const i=this.chart;let r,s,o,a,c;if(!e){for(r=0,s=i.data.datasets.length;re!=="spacing",_indexable:e=>e!=="spacing"&&!e.startsWith("borderDash")&&!e.startsWith("hoverBorderDash")}),ge($o,"overrides",{aspectRatio:1,plugins:{legend:{labels:{generateLabels(e){const t=e.data;if(t.labels.length&&t.datasets.length){const{labels:{pointStyle:i,color:r}}=e.legend.options;return t.labels.map((s,o)=>{const c=e.getDatasetMeta(0).controller.getStyle(o);return{text:s,fillStyle:c.backgroundColor,strokeStyle:c.borderColor,fontColor:r,lineWidth:c.borderWidth,pointStyle:i,hidden:!e.getDataVisibility(o),index:o}})}return[]}},onClick(e,t,i){i.chart.toggleDataVisibility(t.index),i.chart.update()}}}});class Dh extends Fr{initialize(){this.enableOptionSharing=!0,this.supportsDecimation=!0,super.initialize()}update(e){const t=this._cachedMeta,{dataset:i,data:r=[],_dataset:s}=t,o=this.chart._animationsDisabled;let{start:a,count:c}=OC(t,r,o);this._drawStart=a,this._drawCount=c,yC(t)&&(a=0,c=r.length),i._chart=this.chart,i._datasetIndex=this.index,i._decimated=!!s._decimated,i.points=r;const h=this.resolveDatasetElementOptions(e);this.options.showLine||(h.borderWidth=0),h.segment=this.options.segment,this.updateElement(i,void 0,{animated:!o,options:h},e),this.updateElements(r,a,c,e)}updateElements(e,t,i,r){const s=r==="reset",{iScale:o,vScale:a,_stacked:c,_dataset:h}=this._cachedMeta,{sharedOptions:f,includeOptions:p}=this._getSharedOptions(t,r),m=o.axis,y=a.axis,{spanGaps:v,segment:b}=this.options,S=Go(v)?v:Number.POSITIVE_INFINITY,w=this.chart._animationsDisabled||s||r==="none",C=t+i,_=e.length;let P=t>0&&this.getParsed(t-1);for(let Q=0;Q<_;++Q){const $=e[Q],M=w?$:{};if(Q=C){M.skip=!0;continue}const Z=this.getParsed(Q),j=Ne(Z[y]),Y=M[m]=o.getPixelForValue(Z[m],Q),W=M[y]=s||j?a.getBasePixel():a.getPixelForValue(c?this.applyStack(a,Z,c):Z[y],Q);M.skip=isNaN(Y)||isNaN(W)||j,M.stop=Q>0&&Math.abs(Z[m]-P[m])>S,b&&(M.parsed=Z,M.raw=h.data[Q]),p&&(M.options=f||this.resolveDataElementOptions(Q,$.active?"active":r)),w||this.updateElement($,Q,M,r),P=Z}}getMaxOverflow(){const e=this._cachedMeta,t=e.dataset,i=t.options&&t.options.borderWidth||0,r=e.data||[];if(!r.length)return i;const s=r[0].size(this.resolveDataElementOptions(0)),o=r[r.length-1].size(this.resolveDataElementOptions(r.length-1));return Math.max(i,s,o)/2}draw(){const e=this._cachedMeta;e.dataset.updateControlPoints(this.chart.chartArea,e.iScale.axis),super.draw()}}ge(Dh,"id","line"),ge(Dh,"defaults",{datasetElementType:"line",dataElementType:"point",showLine:!0,spanGaps:!1}),ge(Dh,"overrides",{scales:{_index_:{type:"category"},_value_:{type:"linear"}}});class CO extends $o{}ge(CO,"id","pie"),ge(CO,"defaults",{cutout:0,rotation:0,circumference:360,radius:"100%"});class Ta extends Fr{getLabelAndValue(e){const t=this._cachedMeta,i=this.chart.data.labels||[],{xScale:r,yScale:s}=t,o=this.getParsed(e),a=r.getLabelForValue(o.x),c=s.getLabelForValue(o.y);return{label:i[e]||"",value:"("+a+", "+c+")"}}update(e){const t=this._cachedMeta,{data:i=[]}=t,r=this.chart._animationsDisabled;let{start:s,count:o}=OC(t,i,r);if(this._drawStart=s,this._drawCount=o,yC(t)&&(s=0,o=i.length),this.options.showLine){this.datasetElementType||this.addElements();const{dataset:a,_dataset:c}=t;a._chart=this.chart,a._datasetIndex=this.index,a._decimated=!!c._decimated,a.points=i;const h=this.resolveDatasetElementOptions(e);h.segment=this.options.segment,this.updateElement(a,void 0,{animated:!r,options:h},e)}else this.datasetElementType&&(delete t.dataset,this.datasetElementType=!1);this.updateElements(i,s,o,e)}addElements(){const{showLine:e}=this.options;!this.datasetElementType&&e&&(this.datasetElementType=this.chart.registry.getElement("line")),super.addElements()}updateElements(e,t,i,r){const s=r==="reset",{iScale:o,vScale:a,_stacked:c,_dataset:h}=this._cachedMeta,f=this.resolveDataElementOptions(t,r),p=this.getSharedOptions(f),m=this.includeOptions(r,p),y=o.axis,v=a.axis,{spanGaps:b,segment:S}=this.options,w=Go(b)?b:Number.POSITIVE_INFINITY,C=this.chart._animationsDisabled||s||r==="none";let _=t>0&&this.getParsed(t-1);for(let P=t;P0&&Math.abs($[y]-_[y])>w,S&&(M.parsed=$,M.raw=h.data[P]),m&&(M.options=p||this.resolveDataElementOptions(P,Q.active?"active":r)),C||this.updateElement(Q,P,M,r),_=$}this.updateSharedOptions(p,r,f)}getMaxOverflow(){const e=this._cachedMeta,t=e.data||[];if(!this.options.showLine){let a=0;for(let c=t.length-1;c>=0;--c)a=Math.max(a,t[c].size(this.resolveDataElementOptions(c))/2);return a>0&&a}const i=e.dataset,r=i.options&&i.options.borderWidth||0;if(!t.length)return r;const s=t[0].size(this.resolveDataElementOptions(0)),o=t[t.length-1].size(this.resolveDataElementOptions(t.length-1));return Math.max(r,s,o)/2}}ge(Ta,"id","scatter"),ge(Ta,"defaults",{datasetElementType:!1,dataElementType:"point",showLine:!1,fill:!1}),ge(Ta,"overrides",{interaction:{mode:"point"},scales:{x:{type:"linear"},y:{type:"linear"}}});function vs(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class G0{constructor(e){ge(this,"options");this.options=e||{}}static override(e){Object.assign(G0.prototype,e)}init(){}formats(){return vs()}parse(){return vs()}format(){return vs()}add(){return vs()}diff(){return vs()}startOf(){return vs()}endOf(){return vs()}}var Fj={_date:G0};function Yj(n,e,t,i){const{controller:r,data:s,_sorted:o}=n,a=r._cachedMeta.iScale,c=n.dataset&&n.dataset.options?n.dataset.options.spanGaps:null;if(a&&e===a.axis&&e!=="r"&&o&&s.length){const h=a._reversePixels?m4:$s;if(i){if(r._sharedOptions){const f=s[0],p=typeof f.getRange=="function"&&f.getRange(e);if(p){const m=h(s,e,t-p),y=h(s,e,t+p);return{lo:m.lo,hi:y.hi}}}}else{const f=h(s,e,t);if(c){const{vScale:p}=r._cachedMeta,{_parsed:m}=n,y=m.slice(0,f.lo+1).reverse().findIndex(b=>!Ne(b[p.axis]));f.lo-=Math.max(0,y);const v=m.slice(f.hi).findIndex(b=>!Ne(b[p.axis]));f.hi+=Math.max(0,v)}return f}}return{lo:0,hi:s.length-1}}function td(n,e,t,i,r){const s=n.getSortedVisibleDatasetMetas(),o=t[e];for(let a=0,c=s.length;a{c[o]&&c[o](e[t],r)&&(s.push({element:c,datasetIndex:h,index:f}),a=a||c.inRange(e.x,e.y,r))}),i&&!a?[]:s}var Gj={modes:{index(n,e,t,i){const r=ws(e,n),s=t.axis||"x",o=t.includeInvisible||!1,a=t.intersect?tm(n,r,s,i,o):nm(n,r,s,!1,i,o),c=[];return a.length?(n.getSortedVisibleDatasetMetas().forEach(h=>{const f=a[0].index,p=h.data[f];p&&!p.skip&&c.push({element:p,datasetIndex:h.index,index:f})}),c):[]},dataset(n,e,t,i){const r=ws(e,n),s=t.axis||"xy",o=t.includeInvisible||!1;let a=t.intersect?tm(n,r,s,i,o):nm(n,r,s,!1,i,o);if(a.length>0){const c=a[0].datasetIndex,h=n.getDatasetMeta(c).data;a=[];for(let f=0;ft.pos===e)}function gw(n,e){return n.filter(t=>DC.indexOf(t.pos)===-1&&t.box.axis===e)}function ia(n,e){return n.sort((t,i)=>{const r=e?i:t,s=e?t:i;return r.weight===s.weight?r.index-s.index:r.weight-s.weight})}function Kj(n){const e=[];let t,i,r,s,o,a;for(t=0,i=(n||[]).length;th.box.fullSize),!0),i=ia(na(e,"left"),!0),r=ia(na(e,"right")),s=ia(na(e,"top"),!0),o=ia(na(e,"bottom")),a=gw(e,"x"),c=gw(e,"y");return{fullSize:t,leftAndTop:i.concat(s),rightAndBottom:r.concat(c).concat(o).concat(a),chartArea:na(e,"chartArea"),vertical:i.concat(r).concat(c),horizontal:s.concat(o).concat(a)}}function mw(n,e,t,i){return Math.max(n[t],e[t])+Math.max(n[i],e[i])}function zC(n,e){n.top=Math.max(n.top,e.top),n.left=Math.max(n.left,e.left),n.bottom=Math.max(n.bottom,e.bottom),n.right=Math.max(n.right,e.right)}function nB(n,e,t,i){const{pos:r,box:s}=t,o=n.maxPadding;if(!De(r)){t.size&&(n[r]-=t.size);const p=i[t.stack]||{size:0,count:1};p.size=Math.max(p.size,t.horizontal?s.height:s.width),t.size=p.size/p.count,n[r]+=t.size}s.getPadding&&zC(o,s.getPadding());const a=Math.max(0,e.outerWidth-mw(o,n,"left","right")),c=Math.max(0,e.outerHeight-mw(o,n,"top","bottom")),h=a!==n.w,f=c!==n.h;return n.w=a,n.h=c,t.horizontal?{same:h,other:f}:{same:f,other:h}}function iB(n){const e=n.maxPadding;function t(i){const r=Math.max(e[i]-n[i],0);return n[i]+=r,r}n.y+=t("top"),n.x+=t("left"),t("right"),t("bottom")}function rB(n,e){const t=e.maxPadding;function i(r){const s={left:0,top:0,right:0,bottom:0};return r.forEach(o=>{s[o]=Math.max(e[o],t[o])}),s}return i(n?["left","right"]:["top","bottom"])}function pa(n,e,t,i){const r=[];let s,o,a,c,h,f;for(s=0,o=n.length,h=0;s{typeof b.beforeLayout=="function"&&b.beforeLayout()});const f=c.reduce((b,S)=>S.box.options&&S.box.options.display===!1?b:b+1,0)||1,p=Object.freeze({outerWidth:e,outerHeight:t,padding:r,availableWidth:s,availableHeight:o,vBoxMaxWidth:s/2/f,hBoxMaxHeight:o/2}),m=Object.assign({},r);zC(m,Un(i));const y=Object.assign({maxPadding:m,w:s,h:o,x:r.left,y:r.top},r),v=eB(c.concat(h),p);pa(a.fullSize,y,p,v),pa(c,y,p,v),pa(h,y,p,v)&&pa(c,y,p,v),iB(y),Ow(a.leftAndTop,y,p,v),y.x+=y.w,y.y+=y.h,Ow(a.rightAndBottom,y,p,v),n.chartArea={left:y.left,top:y.top,right:y.left+y.w,bottom:y.top+y.h,height:y.h,width:y.w},Ye(a.chartArea,b=>{const S=b.box;Object.assign(S,n.chartArea),S.update(y.w,y.h,{left:0,top:0,right:0,bottom:0})})}};class ZC{acquireContext(e,t){}releaseContext(e){return!1}addEventListener(e,t,i){}removeEventListener(e,t,i){}getDevicePixelRatio(){return 1}getMaximumSize(e,t,i,r){return t=Math.max(0,t||e.width),i=i||e.height,{width:t,height:Math.max(0,r?Math.floor(t/r):i)}}isAttached(e){return!0}updateConfig(e){}}class sB extends ZC{acquireContext(e){return e&&e.getContext&&e.getContext("2d")||null}updateConfig(e){e.options.animation=!1}}const zh="$chartjs",oB={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},yw=n=>n===null||n==="";function lB(n,e){const t=n.style,i=n.getAttribute("height"),r=n.getAttribute("width");if(n[zh]={initial:{height:i,width:r,style:{display:t.display,height:t.height,width:t.width}}},t.display=t.display||"block",t.boxSizing=t.boxSizing||"border-box",yw(r)){const s=tw(n,"width");s!==void 0&&(n.width=s)}if(yw(i))if(n.style.height==="")n.height=n.width/(e||2);else{const s=tw(n,"height");s!==void 0&&(n.height=s)}return n}const IC=cj?{passive:!0}:!1;function aB(n,e,t){n&&n.addEventListener(e,t,IC)}function cB(n,e,t){n&&n.canvas&&n.canvas.removeEventListener(e,t,IC)}function uB(n,e){const t=oB[n.type]||n.type,{x:i,y:r}=ws(n,e);return{type:t,chart:e,native:n,x:i!==void 0?i:null,y:r!==void 0?r:null}}function Qf(n,e){for(const t of n)if(t===e||t.contains(e))return!0}function hB(n,e,t){const i=n.canvas,r=new MutationObserver(s=>{let o=!1;for(const a of s)o=o||Qf(a.addedNodes,i),o=o&&!Qf(a.removedNodes,i);o&&t()});return r.observe(document,{childList:!0,subtree:!0}),r}function fB(n,e,t){const i=n.canvas,r=new MutationObserver(s=>{let o=!1;for(const a of s)o=o||Qf(a.removedNodes,i),o=o&&!Qf(a.addedNodes,i);o&&t()});return r.observe(document,{childList:!0,subtree:!0}),r}const oc=new Map;let xw=0;function jC(){const n=window.devicePixelRatio;n!==xw&&(xw=n,oc.forEach((e,t)=>{t.currentDevicePixelRatio!==n&&e()}))}function dB(n,e){oc.size||window.addEventListener("resize",jC),oc.set(n,e)}function pB(n){oc.delete(n),oc.size||window.removeEventListener("resize",jC)}function gB(n,e,t){const i=n.canvas,r=i&&H0(i);if(!r)return;const s=mC((a,c)=>{const h=r.clientWidth;t(a,c),h{const c=a[0],h=c.contentRect.width,f=c.contentRect.height;h===0&&f===0||s(h,f)});return o.observe(r),dB(n,s),o}function im(n,e,t){t&&t.disconnect(),e==="resize"&&pB(n)}function mB(n,e,t){const i=n.canvas,r=mC(s=>{n.ctx!==null&&t(uB(s,n))},n);return aB(i,e,r),r}class OB extends ZC{acquireContext(e,t){const i=e&&e.getContext&&e.getContext("2d");return i&&i.canvas===e?(lB(e,t),i):null}releaseContext(e){const t=e.canvas;if(!t[zh])return!1;const i=t[zh].initial;["height","width"].forEach(s=>{const o=i[s];Ne(o)?t.removeAttribute(s):t.setAttribute(s,o)});const r=i.style||{};return Object.keys(r).forEach(s=>{t.style[s]=r[s]}),t.width=t.width,delete t[zh],!0}addEventListener(e,t,i){this.removeEventListener(e,t);const r=e.$proxies||(e.$proxies={}),o={attach:hB,detach:fB,resize:gB}[t]||mB;r[t]=o(e,t,i)}removeEventListener(e,t){const i=e.$proxies||(e.$proxies={}),r=i[t];if(!r)return;({attach:im,detach:im,resize:im}[t]||cB)(e,t,r),i[t]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(e,t,i,r){return aj(e,t,i,r)}isAttached(e){const t=e&&H0(e);return!!(t&&t.isConnected)}}function yB(n){return!U0()||typeof OffscreenCanvas<"u"&&n instanceof OffscreenCanvas?sB:OB}class fi{constructor(){ge(this,"x");ge(this,"y");ge(this,"active",!1);ge(this,"options");ge(this,"$animations")}tooltipPosition(e){const{x:t,y:i}=this.getProps(["x","y"],e);return{x:t,y:i}}hasValue(){return Go(this.x)&&Go(this.y)}getProps(e,t){const i=this.$animations;if(!t||!i)return this;const r={};return e.forEach(s=>{r[s]=i[s]&&i[s].active()?i[s]._to:this[s]}),r}}ge(fi,"defaults",{}),ge(fi,"defaultRoutes");function xB(n,e){const t=n.options.ticks,i=vB(n),r=Math.min(t.maxTicksLimit||i,i),s=t.major.enabled?SB(e):[],o=s.length,a=s[0],c=s[o-1],h=[];if(o>r)return wB(e,h,s,o/r),h;const f=bB(s,e,r);if(o>0){let p,m;const y=o>1?Math.round((c-a)/(o-1)):null;for(fh(e,h,f,Ne(y)?0:a-y,a),p=0,m=o-1;pr)return c}return Math.max(r,1)}function SB(n){const e=[];let t,i;for(t=0,i=n.length;tn==="left"?"right":n==="right"?"left":n,vw=(n,e,t)=>e==="top"||e==="left"?n[e]+t:n[e]-t,bw=(n,e)=>Math.min(e||n,n);function Sw(n,e){const t=[],i=n.length/e,r=n.length;let s=0;for(;so+a)))return c}function QB(n,e){Ye(n,t=>{const i=t.gc,r=i.length/2;let s;if(r>e){for(s=0;si?i:t,i=r&&t>i?t:i,{min:ki(t,ki(i,t)),max:ki(i,ki(t,i))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const e=this.chart.data;return this.options.labels||(this.isHorizontal()?e.xLabels:e.yLabels)||e.labels||[]}getLabelItems(e=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(e))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){it(this.options.beforeUpdate,[this])}update(e,t,i){const{beginAtZero:r,grace:s,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=e,this.maxHeight=t,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=j4(this,s,r),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const c=a=s||i<=1||!this.isHorizontal()){this.labelRotation=r;return}const f=this._getLabelSizes(),p=f.widest.width,m=f.highest.height,y=en(this.chart.width-p,0,this.maxWidth);a=e.offset?this.maxWidth/i:y/(i-1),p+6>a&&(a=y/(i-(e.offset?.5:1)),c=this.maxHeight-ra(e.grid)-t.padding-ww(e.title,this.chart.options.font),h=Math.sqrt(p*p+m*m),o=d4(Math.min(Math.asin(en((f.highest.height+6)/a,-1,1)),Math.asin(en(c/h,-1,1))-Math.asin(en(m/h,-1,1)))),o=Math.max(r,Math.min(s,o))),this.labelRotation=o}afterCalculateLabelRotation(){it(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){it(this.options.beforeFit,[this])}fit(){const e={width:0,height:0},{chart:t,options:{ticks:i,title:r,grid:s}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const c=ww(r,t.options.font);if(a?(e.width=this.maxWidth,e.height=ra(s)+c):(e.height=this.maxHeight,e.width=ra(s)+c),i.display&&this.ticks.length){const{first:h,last:f,widest:p,highest:m}=this._getLabelSizes(),y=i.padding*2,v=nr(this.labelRotation),b=Math.cos(v),S=Math.sin(v);if(a){const w=i.mirror?0:S*p.width+b*m.height;e.height=Math.min(this.maxHeight,e.height+w+y)}else{const w=i.mirror?0:b*p.width+S*m.height;e.width=Math.min(this.maxWidth,e.width+w+y)}this._calculatePadding(h,f,S,b)}}this._handleMargins(),a?(this.width=this._length=t.width-this._margins.left-this._margins.right,this.height=e.height):(this.width=e.width,this.height=this._length=t.height-this._margins.top-this._margins.bottom)}_calculatePadding(e,t,i,r){const{ticks:{align:s,padding:o},position:a}=this.options,c=this.labelRotation!==0,h=a!=="top"&&this.axis==="x";if(this.isHorizontal()){const f=this.getPixelForTick(0)-this.left,p=this.right-this.getPixelForTick(this.ticks.length-1);let m=0,y=0;c?h?(m=r*e.width,y=i*t.height):(m=i*e.height,y=r*t.width):s==="start"?y=t.width:s==="end"?m=e.width:s!=="inner"&&(m=e.width/2,y=t.width/2),this.paddingLeft=Math.max((m-f+o)*this.width/(this.width-f),0),this.paddingRight=Math.max((y-p+o)*this.width/(this.width-p),0)}else{let f=t.height/2,p=e.height/2;s==="start"?(f=0,p=e.height):s==="end"&&(f=t.height,p=0),this.paddingTop=f+o,this.paddingBottom=p+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){it(this.options.afterFit,[this])}isHorizontal(){const{axis:e,position:t}=this.options;return t==="top"||t==="bottom"||e==="x"}isFullSize(){return this.options.fullSize}_convertTicksToLabels(e){this.beforeTickToLabelConversion(),this.generateTickLabels(e);let t,i;for(t=0,i=e.length;t({width:o[j]||0,height:a[j]||0});return{first:Z(0),last:Z(t-1),widest:Z($),highest:Z(M),widths:o,heights:a}}getLabelForValue(e){return e}getPixelForValue(e,t){return NaN}getValueForPixel(e){}getPixelForTick(e){const t=this.ticks;return e<0||e>t.length-1?null:this.getPixelForValue(t[e].value)}getPixelForDecimal(e){this._reversePixels&&(e=1-e);const t=this._startPixel+e*this._length;return g4(this._alignToPixels?xs(this.chart,t,0):t)}getDecimalForPixel(e){const t=(e-this._startPixel)/this._length;return this._reversePixels?1-t:t}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:e,max:t}=this;return e<0&&t<0?t:e>0&&t>0?e:0}getContext(e){const t=this.ticks||[];if(e>=0&&ea*r?a/i:c/r:c*r0}_computeGridLineItems(e){const t=this.axis,i=this.chart,r=this.options,{grid:s,position:o,border:a}=r,c=s.offset,h=this.isHorizontal(),p=this.ticks.length+(c?1:0),m=ra(s),y=[],v=a.setContext(this.getContext()),b=v.display?v.width:0,S=b/2,w=function(se){return xs(i,se,b)};let C,_,P,Q,$,M,Z,j,Y,W,F,ie;if(o==="top")C=w(this.bottom),M=this.bottom-m,j=C-S,W=w(e.top)+S,ie=e.bottom;else if(o==="bottom")C=w(this.top),W=e.top,ie=w(e.bottom)-S,M=C+S,j=this.top+m;else if(o==="left")C=w(this.right),$=this.right-m,Z=C-S,Y=w(e.left)+S,F=e.right;else if(o==="right")C=w(this.left),Y=e.left,F=w(e.right)-S,$=C+S,Z=this.left+m;else if(t==="x"){if(o==="center")C=w((e.top+e.bottom)/2+.5);else if(De(o)){const se=Object.keys(o)[0],ue=o[se];C=w(this.chart.scales[se].getPixelForValue(ue))}W=e.top,ie=e.bottom,M=C+S,j=M+m}else if(t==="y"){if(o==="center")C=w((e.left+e.right)/2);else if(De(o)){const se=Object.keys(o)[0],ue=o[se];C=w(this.chart.scales[se].getPixelForValue(ue))}$=C-S,Z=$-m,Y=e.left,F=e.right}const oe=Re(r.ticks.maxTicksLimit,p),re=Math.max(1,Math.ceil(p/oe));for(_=0;_0&&(ke-=Oe/2);break}L={left:ke,top:we,width:Oe+N.width,height:xe+N.height,color:re.backdropColor}}S.push({label:P,font:j,textOffset:F,options:{rotation:b,color:ue,strokeColor:q,strokeWidth:U,textAlign:J,textBaseline:ie,translation:[Q,$],backdrop:L}})}return S}_getXAxisLabelAlignment(){const{position:e,ticks:t}=this.options;if(-nr(this.labelRotation))return e==="top"?"left":"right";let r="center";return t.align==="start"?r="left":t.align==="end"?r="right":t.align==="inner"&&(r="inner"),r}_getYAxisLabelAlignment(e){const{position:t,ticks:{crossAlign:i,mirror:r,padding:s}}=this.options,o=this._getLabelSizes(),a=e+s,c=o.widest.width;let h,f;return t==="left"?r?(f=this.right+s,i==="near"?h="left":i==="center"?(h="center",f+=c/2):(h="right",f+=c)):(f=this.right-a,i==="near"?h="right":i==="center"?(h="center",f-=c/2):(h="left",f=this.left)):t==="right"?r?(f=this.left+s,i==="near"?h="right":i==="center"?(h="center",f-=c/2):(h="left",f-=c)):(f=this.left+a,i==="near"?h="left":i==="center"?(h="center",f+=c/2):(h="right",f=this.right)):h="right",{textAlign:h,x:f}}_computeLabelArea(){if(this.options.ticks.mirror)return;const e=this.chart,t=this.options.position;if(t==="left"||t==="right")return{top:0,left:this.left,bottom:e.height,right:this.right};if(t==="top"||t==="bottom")return{top:this.top,left:0,bottom:this.bottom,right:e.width}}drawBackground(){const{ctx:e,options:{backgroundColor:t},left:i,top:r,width:s,height:o}=this;t&&(e.save(),e.fillStyle=t,e.fillRect(i,r,s,o),e.restore())}getLineWidthForValue(e){const t=this.options.grid;if(!this._isVisible()||!t.display)return 0;const r=this.ticks.findIndex(s=>s.value===e);return r>=0?t.setContext(this.getContext(r)).lineWidth:0}drawGrid(e){const t=this.options.grid,i=this.ctx,r=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(e));let s,o;const a=(c,h,f)=>{!f.width||!f.color||(i.save(),i.lineWidth=f.width,i.strokeStyle=f.color,i.setLineDash(f.borderDash||[]),i.lineDashOffset=f.borderDashOffset,i.beginPath(),i.moveTo(c.x,c.y),i.lineTo(h.x,h.y),i.stroke(),i.restore())};if(t.display)for(s=0,o=r.length;s{this.draw(s)}}]:[{z:i,draw:s=>{this.drawBackground(),this.drawGrid(s),this.drawTitle()}},{z:r,draw:()=>{this.drawBorder()}},{z:t,draw:s=>{this.drawLabels(s)}}]}getMatchingVisibleMetas(e){const t=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",r=[];let s,o;for(s=0,o=t.length;s{const i=t.split("."),r=i.pop(),s=[n].concat(i).join("."),o=e[t].split("."),a=o.pop(),c=o.join(".");Ot.route(s,r,c,a)})}function EB(n){return"id"in n&&"defaults"in n}class LB{constructor(){this.controllers=new dh(Fr,"datasets",!0),this.elements=new dh(fi,"elements"),this.plugins=new dh(Object,"plugins"),this.scales=new dh(ol,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...e){this._each("register",e)}remove(...e){this._each("unregister",e)}addControllers(...e){this._each("register",e,this.controllers)}addElements(...e){this._each("register",e,this.elements)}addPlugins(...e){this._each("register",e,this.plugins)}addScales(...e){this._each("register",e,this.scales)}getController(e){return this._get(e,this.controllers,"controller")}getElement(e){return this._get(e,this.elements,"element")}getPlugin(e){return this._get(e,this.plugins,"plugin")}getScale(e){return this._get(e,this.scales,"scale")}removeControllers(...e){this._each("unregister",e,this.controllers)}removeElements(...e){this._each("unregister",e,this.elements)}removePlugins(...e){this._each("unregister",e,this.plugins)}removeScales(...e){this._each("unregister",e,this.scales)}_each(e,t,i){[...t].forEach(r=>{const s=i||this._getRegistryForType(r);i||s.isForType(r)||s===this.plugins&&r.id?this._exec(e,s,r):Ye(r,o=>{const a=i||this._getRegistryForType(o);this._exec(e,a,o)})})}_exec(e,t,i){const r=j0(e);it(i["before"+r],[],i),t[e](i),it(i["after"+r],[],i)}_getRegistryForType(e){for(let t=0;ts.filter(a=>!o.some(c=>a.plugin.id===c.plugin.id));this._notify(r(t,i),e,"stop"),this._notify(r(i,t),e,"start")}}function zB(n){const e={},t=[],i=Object.keys(Qi.plugins.items);for(let s=0;s1&&kw(n[0].toLowerCase());if(i)return i}throw new Error(`Cannot determine type of '${n}' axis. Please provide 'axis' or 'position' option.`)}function Pw(n,e,t){if(t[e+"AxisID"]===n)return{axis:e}}function WB(n,e){if(e.data&&e.data.datasets){const t=e.data.datasets.filter(i=>i.xAxisID===n||i.yAxisID===n);if(t.length)return Pw(n,"x",t[0])||Pw(n,"y",t[0])}return{}}function VB(n,e){const t=Ws[n.type]||{scales:{}},i=e.scales||{},r=TO(n.type,e),s=Object.create(null);return Object.keys(i).forEach(o=>{const a=i[o];if(!De(a))return console.error(`Invalid scale configuration for scale: ${o}`);if(a._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${o}`);const c=$O(o,a,WB(o,n),Ot.scales[a.type]),h=NB(c,r),f=t.scales||{};s[o]=Pa(Object.create(null),[{axis:c},a,f[c],f[h]])}),n.data.datasets.forEach(o=>{const a=o.type||n.type,c=o.indexAxis||TO(a,e),f=(Ws[a]||{}).scales||{};Object.keys(f).forEach(p=>{const m=BB(p,c),y=o[m+"AxisID"]||m;s[y]=s[y]||Object.create(null),Pa(s[y],[{axis:m},i[y],f[p]])})}),Object.keys(s).forEach(o=>{const a=s[o];Pa(a,[Ot.scales[a.type],Ot.scale])}),s}function BC(n){const e=n.options||(n.options={});e.plugins=Re(e.plugins,{}),e.scales=VB(n,e)}function NC(n){return n=n||{},n.datasets=n.datasets||[],n.labels=n.labels||[],n}function FB(n){return n=n||{},n.data=NC(n.data),BC(n),n}const _w=new Map,XC=new Set;function ph(n,e){let t=_w.get(n);return t||(t=e(),_w.set(n,t),XC.add(t)),t}const sa=(n,e,t)=>{const i=Xs(e,t);i!==void 0&&n.add(i)};class YB{constructor(e){this._config=FB(e),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(e){this._config.type=e}get data(){return this._config.data}set data(e){this._config.data=NC(e)}get options(){return this._config.options}set options(e){this._config.options=e}get plugins(){return this._config.plugins}update(){const e=this._config;this.clearCache(),BC(e)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(e){return ph(e,()=>[[`datasets.${e}`,""]])}datasetAnimationScopeKeys(e,t){return ph(`${e}.transition.${t}`,()=>[[`datasets.${e}.transitions.${t}`,`transitions.${t}`],[`datasets.${e}`,""]])}datasetElementScopeKeys(e,t){return ph(`${e}-${t}`,()=>[[`datasets.${e}.elements.${t}`,`datasets.${e}`,`elements.${t}`,""]])}pluginScopeKeys(e){const t=e.id,i=this.type;return ph(`${i}-plugin-${t}`,()=>[[`plugins.${t}`,...e.additionalOptionScopes||[]]])}_cachedScopes(e,t){const i=this._scopeCache;let r=i.get(e);return(!r||t)&&(r=new Map,i.set(e,r)),r}getOptionScopes(e,t,i){const{options:r,type:s}=this,o=this._cachedScopes(e,i),a=o.get(t);if(a)return a;const c=new Set;t.forEach(f=>{e&&(c.add(e),f.forEach(p=>sa(c,e,p))),f.forEach(p=>sa(c,r,p)),f.forEach(p=>sa(c,Ws[s]||{},p)),f.forEach(p=>sa(c,Ot,p)),f.forEach(p=>sa(c,_O,p))});const h=Array.from(c);return h.length===0&&h.push(Object.create(null)),XC.has(t)&&o.set(t,h),h}chartOptionScopes(){const{options:e,type:t}=this;return[e,Ws[t]||{},Ot.datasets[t]||{},{type:t},Ot,_O]}resolveNamedOptions(e,t,i,r=[""]){const s={$shared:!0},{resolver:o,subPrefixes:a}=Qw(this._resolverCache,e,r);let c=o;if(UB(o,t)){s.$shared=!1,i=Kr(i)?i():i;const h=this.createResolver(e,i,a);c=Ko(o,i,h)}for(const h of t)s[h]=c[h];return s}createResolver(e,t,i=[""],r){const{resolver:s}=Qw(this._resolverCache,e,i);return De(t)?Ko(s,t,void 0,r):s}}function Qw(n,e,t){let i=n.get(e);i||(i=new Map,n.set(e,i));const r=t.join();let s=i.get(r);return s||(s={resolver:F0(e,t),subPrefixes:t.filter(a=>!a.toLowerCase().includes("hover"))},i.set(r,s)),s}const qB=n=>De(n)&&Object.getOwnPropertyNames(n).some(e=>Kr(n[e]));function UB(n,e){const{isScriptable:t,isIndexable:i}=SC(n);for(const r of e){const s=t(r),o=i(r),a=(o||s)&&n[r];if(s&&(Kr(a)||qB(a))||o&&bt(a))return!0}return!1}var HB="4.5.0";const GB=["top","bottom","left","right","chartArea"];function Cw(n,e){return n==="top"||n==="bottom"||GB.indexOf(n)===-1&&e==="x"}function Tw(n,e){return function(t,i){return t[n]===i[n]?t[e]-i[e]:t[n]-i[n]}}function $w(n){const e=n.chart,t=e.options.animation;e.notifyPlugins("afterRender"),it(t&&t.onComplete,[n],e)}function KB(n){const e=n.chart,t=e.options.animation;it(t&&t.onProgress,[n],e)}function WC(n){return U0()&&typeof n=="string"?n=document.getElementById(n):n&&n.length&&(n=n[0]),n&&n.canvas&&(n=n.canvas),n}const Zh={},Mw=n=>{const e=WC(n);return Object.values(Zh).filter(t=>t.canvas===e).pop()};function JB(n,e,t){const i=Object.keys(n);for(const r of i){const s=+r;if(s>=e){const o=n[r];delete n[r],(t>0||s>e)&&(n[s+t]=o)}}}function eN(n,e,t,i){return!t||n.type==="mouseout"?null:i?e:n}var $r;let nd=($r=class{static register(...e){Qi.add(...e),Rw()}static unregister(...e){Qi.remove(...e),Rw()}constructor(e,t){const i=this.config=new YB(t),r=WC(e),s=Mw(r);if(s)throw new Error("Canvas is already in use. Chart with ID '"+s.id+"' must be destroyed before the canvas with ID '"+s.canvas.id+"' can be reused.");const o=i.createResolver(i.chartOptionScopes(),this.getContext());this.platform=new(i.platform||yB(r)),this.platform.updateConfig(i);const a=this.platform.acquireContext(r,o.aspectRatio),c=a&&a.canvas,h=c&&c.height,f=c&&c.width;if(this.id=e4(),this.ctx=a,this.canvas=c,this.width=f,this.height=h,this._options=o,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new DB,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=x4(p=>this.update(p),o.resizeDelay||0),this._dataChanges=[],Zh[this.id]=this,!a||!c){console.error("Failed to create chart: can't acquire context from the given item");return}Ui.listen(this,"complete",$w),Ui.listen(this,"progress",KB),this._initialize(),this.attached&&this.update()}get aspectRatio(){const{options:{aspectRatio:e,maintainAspectRatio:t},width:i,height:r,_aspectRatio:s}=this;return Ne(e)?t&&s?s:r?i/r:null:e}get data(){return this.config.data}set data(e){this.config.data=e}get options(){return this._options}set options(e){this.config.options=e}get registry(){return Qi}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ew(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return GS(this.canvas,this.ctx),this}stop(){return Ui.stop(this),this}resize(e,t){Ui.running(this)?this._resizeBeforeDraw={width:e,height:t}:this._resize(e,t)}_resize(e,t){const i=this.options,r=this.canvas,s=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(r,e,t,s),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),c=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ew(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),it(i.onResize,[this,o],this),this.attached&&this._doResize(c)&&this.render())}ensureScalesHaveIDs(){const t=this.options.scales||{};Ye(t,(i,r)=>{i.id=r})}buildOrUpdateScales(){const e=this.options,t=e.scales,i=this.scales,r=Object.keys(i).reduce((o,a)=>(o[a]=!1,o),{});let s=[];t&&(s=s.concat(Object.keys(t).map(o=>{const a=t[o],c=$O(o,a),h=c==="r",f=c==="x";return{options:a,dposition:h?"chartArea":f?"bottom":"left",dtype:h?"radialLinear":f?"category":"linear"}}))),Ye(s,o=>{const a=o.options,c=a.id,h=$O(c,a),f=Re(a.type,o.dtype);(a.position===void 0||Cw(a.position,h)!==Cw(o.dposition))&&(a.position=o.dposition),r[c]=!0;let p=null;if(c in i&&i[c].type===f)p=i[c];else{const m=Qi.getScale(f);p=new m({id:c,type:f,ctx:this.ctx,chart:this}),i[p.id]=p}p.init(a,e)}),Ye(r,(o,a)=>{o||delete i[a]}),Ye(i,o=>{Vn.configure(this,o,o.options),Vn.addBox(this,o)})}_updateMetasets(){const e=this._metasets,t=this.data.datasets.length,i=e.length;if(e.sort((r,s)=>r.index-s.index),i>t){for(let r=t;rt.length&&delete this._stacks,e.forEach((i,r)=>{t.filter(s=>s===i._dataset).length===0&&this._destroyDatasetMeta(r)})}buildOrUpdateControllers(){const e=[],t=this.data.datasets;let i,r;for(this._removeUnreferencedMetasets(),i=0,r=t.length;i{this.getDatasetMeta(t).controller.reset()},this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(e){const t=this.config;t.update();const i=this._options=t.createResolver(t.chartOptionScopes(),this.getContext()),r=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),this.notifyPlugins("beforeUpdate",{mode:e,cancelable:!0})===!1)return;const s=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let h=0,f=this.data.datasets.length;h{h.reset()}),this._updateDatasets(e),this.notifyPlugins("afterUpdate",{mode:e}),this._layers.sort(Tw("z","_idx"));const{_active:a,_lastEvent:c}=this;c?this._eventHandler(c,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){Ye(this.scales,e=>{Vn.removeBox(this,e)}),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const e=this.options,t=new Set(Object.keys(this._listeners)),i=new Set(e.events);(!BS(t,i)||!!this._responsiveListeners!==e.responsive)&&(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:e}=this,t=this._getUniformDataChanges()||[];for(const{method:i,start:r,count:s}of t){const o=i==="_removeElements"?-s:s;JB(e,r,o)}}_getUniformDataChanges(){const e=this._dataChanges;if(!e||!e.length)return;this._dataChanges=[];const t=this.data.datasets.length,i=s=>new Set(e.filter(o=>o[0]===s).map((o,a)=>a+","+o.splice(1).join(","))),r=i(0);for(let s=1;ss.split(",")).map(s=>({method:s[1],start:+s[2],count:+s[3]}))}_updateLayout(e){if(this.notifyPlugins("beforeLayout",{cancelable:!0})===!1)return;Vn.update(this,this.width,this.height,e);const t=this.chartArea,i=t.width<=0||t.height<=0;this._layers=[],Ye(this.boxes,r=>{i&&r.position==="chartArea"||(r.configure&&r.configure(),this._layers.push(...r._layers()))},this),this._layers.forEach((r,s)=>{r._idx=s}),this.notifyPlugins("afterLayout")}_updateDatasets(e){if(this.notifyPlugins("beforeDatasetsUpdate",{mode:e,cancelable:!0})!==!1){for(let t=0,i=this.data.datasets.length;t=0;--t)this._drawDataset(e[t]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(e){const t=this.ctx,i={meta:e,index:e.index,cancelable:!0},r=RC(this,e);this.notifyPlugins("beforeDatasetDraw",i)!==!1&&(r&&Kf(t,r),e.controller.draw(),r&&Jf(t),i.cancelable=!1,this.notifyPlugins("afterDatasetDraw",i))}isPointInArea(e){return rc(e,this.chartArea,this._minPadding)}getElementsAtEventForMode(e,t,i,r){const s=Gj.modes[t];return typeof s=="function"?s(this,e,i,r):[]}getDatasetMeta(e){const t=this.data.datasets[e],i=this._metasets;let r=i.filter(s=>s&&s._dataset===t).pop();return r||(r={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:t&&t.order||0,index:e,_dataset:t,_parsed:[],_sorted:!1},i.push(r)),r}getContext(){return this.$context||(this.$context=Vs(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(e){const t=this.data.datasets[e];if(!t)return!1;const i=this.getDatasetMeta(e);return typeof i.hidden=="boolean"?!i.hidden:!t.hidden}setDatasetVisibility(e,t){const i=this.getDatasetMeta(e);i.hidden=!t}toggleDataVisibility(e){this._hiddenIndices[e]=!this._hiddenIndices[e]}getDataVisibility(e){return!this._hiddenIndices[e]}_updateVisibility(e,t,i){const r=i?"show":"hide",s=this.getDatasetMeta(e),o=s.controller._resolveAnimations(void 0,r);nc(t)?(s.data[t].hidden=!i,this.update()):(this.setDatasetVisibility(e,i),o.update(s,{visible:i}),this.update(a=>a.datasetIndex===e?r:void 0))}hide(e,t){this._updateVisibility(e,t,!1)}show(e,t){this._updateVisibility(e,t,!0)}_destroyDatasetMeta(e){const t=this._metasets[e];t&&t.controller&&t.controller._destroy(),delete this._metasets[e]}_stop(){let e,t;for(this.stop(),Ui.remove(this),e=0,t=this.data.datasets.length;e{t.addEventListener(this,s,o),e[s]=o},r=(s,o,a)=>{s.offsetX=o,s.offsetY=a,this._eventHandler(s)};Ye(this.options.events,s=>i(s,r))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const e=this._responsiveListeners,t=this.platform,i=(c,h)=>{t.addEventListener(this,c,h),e[c]=h},r=(c,h)=>{e[c]&&(t.removeEventListener(this,c,h),delete e[c])},s=(c,h)=>{this.canvas&&this.resize(c,h)};let o;const a=()=>{r("attach",a),this.attached=!0,this.resize(),i("resize",s),i("detach",o)};o=()=>{this.attached=!1,r("resize",s),this._stop(),this._resize(0,0),i("attach",a)},t.isAttached(this.canvas)?a():o()}unbindEvents(){Ye(this._listeners,(e,t)=>{this.platform.removeEventListener(this,t,e)}),this._listeners={},Ye(this._responsiveListeners,(e,t)=>{this.platform.removeEventListener(this,t,e)}),this._responsiveListeners=void 0}updateHoverStyle(e,t,i){const r=i?"set":"remove";let s,o,a,c;for(t==="dataset"&&(s=this.getDatasetMeta(e[0].datasetIndex),s.controller["_"+r+"DatasetHoverStyle"]()),a=0,c=e.length;a{const a=this.getDatasetMeta(s);if(!a)throw new Error("No dataset found at index "+s);return{datasetIndex:s,element:a.data[o],index:o}});!Sf(i,t)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,t))}notifyPlugins(e,t,i){return this._plugins.notify(this,e,t,i)}isPluginEnabled(e){return this._plugins._cache.filter(t=>t.plugin.id===e).length===1}_updateHoverStyles(e,t,i){const r=this.options.hover,s=(c,h)=>c.filter(f=>!h.some(p=>f.datasetIndex===p.datasetIndex&&f.index===p.index)),o=s(t,e),a=i?e:s(e,t);o.length&&this.updateHoverStyle(o,r.mode,!1),a.length&&r.mode&&this.updateHoverStyle(a,r.mode,!0)}_eventHandler(e,t){const i={event:e,replay:t,cancelable:!0,inChartArea:this.isPointInArea(e)},r=o=>(o.options.events||this.options.events).includes(e.native.type);if(this.notifyPlugins("beforeEvent",i,r)===!1)return;const s=this._handleEvent(e,t,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,r),(s||i.changed)&&this.render(),this}_handleEvent(e,t,i){const{_active:r=[],options:s}=this,o=t,a=this._getActiveElements(e,r,i,o),c=o4(e),h=eN(e,this._lastEvent,i,c);i&&(this._lastEvent=null,it(s.onHover,[e,a,this],this),c&&it(s.onClick,[e,a,this],this));const f=!Sf(a,r);return(f||t)&&(this._active=a,this._updateHoverStyles(a,r,t)),this._lastEvent=h,f}_getActiveElements(e,t,i,r){if(e.type==="mouseout")return[];if(!i)return t;const s=this.options.hover;return this.getElementsAtEventForMode(e,s.mode,s,r)}},ge($r,"defaults",Ot),ge($r,"instances",Zh),ge($r,"overrides",Ws),ge($r,"registry",Qi),ge($r,"version",HB),ge($r,"getChart",Mw),$r);function Rw(){return Ye(nd.instances,n=>n._plugins.invalidate())}function tN(n,e,t){const{startAngle:i,x:r,y:s,outerRadius:o,innerRadius:a,options:c}=e,{borderWidth:h,borderJoinStyle:f}=c,p=Math.min(h/o,An(i-t));if(n.beginPath(),n.arc(r,s,o-h/2,i+p/2,t-p/2),a>0){const m=Math.min(h/a,An(i-t));n.arc(r,s,a+h/2,t-m/2,i+m/2,!0)}else{const m=Math.min(h/2,o*An(i-t));if(f==="round")n.arc(r,s,m,t-qe/2,i+qe/2,!0);else if(f==="bevel"){const y=2*m*m,v=-y*Math.cos(t+qe/2)+r,b=-y*Math.sin(t+qe/2)+s,S=y*Math.cos(i+qe/2)+r,w=y*Math.sin(i+qe/2)+s;n.lineTo(v,b),n.lineTo(S,w)}}n.closePath(),n.moveTo(0,0),n.rect(0,0,n.canvas.width,n.canvas.height),n.clip("evenodd")}function nN(n,e,t){const{startAngle:i,pixelMargin:r,x:s,y:o,outerRadius:a,innerRadius:c}=e;let h=r/a;n.beginPath(),n.arc(s,o,a,i-h,t+h),c>r?(h=r/c,n.arc(s,o,c,t+h,i-h,!0)):n.arc(s,o,r,t+$t,i-$t),n.closePath(),n.clip()}function iN(n){return V0(n,["outerStart","outerEnd","innerStart","innerEnd"])}function rN(n,e,t,i){const r=iN(n.options.borderRadius),s=(t-e)/2,o=Math.min(s,i*e/2),a=c=>{const h=(t-Math.min(s,c))*i/2;return en(c,0,Math.min(s,h))};return{outerStart:a(r.outerStart),outerEnd:a(r.outerEnd),innerStart:en(r.innerStart,0,o),innerEnd:en(r.innerEnd,0,o)}}function bo(n,e,t,i){return{x:t+n*Math.cos(e),y:i+n*Math.sin(e)}}function Cf(n,e,t,i,r,s){const{x:o,y:a,startAngle:c,pixelMargin:h,innerRadius:f}=e,p=Math.max(e.outerRadius+i+t-h,0),m=f>0?f+i+t+h:0;let y=0;const v=r-c;if(i){const re=f>0?f-i:0,se=p>0?p-i:0,ue=(re+se)/2,q=ue!==0?v*ue/(ue+i):v;y=(v-q)/2}const b=Math.max(.001,v*p-t/qe)/p,S=(v-b)/2,w=c+S+y,C=r-S-y,{outerStart:_,outerEnd:P,innerStart:Q,innerEnd:$}=rN(e,m,p,C-w),M=p-_,Z=p-P,j=w+_/M,Y=C-P/Z,W=m+Q,F=m+$,ie=w+Q/W,oe=C-$/F;if(n.beginPath(),s){const re=(j+Y)/2;if(n.arc(o,a,p,j,re),n.arc(o,a,p,re,Y),P>0){const U=bo(Z,Y,o,a);n.arc(U.x,U.y,P,Y,C+$t)}const se=bo(F,C,o,a);if(n.lineTo(se.x,se.y),$>0){const U=bo(F,oe,o,a);n.arc(U.x,U.y,$,C+$t,oe+Math.PI)}const ue=(C-$/m+(w+Q/m))/2;if(n.arc(o,a,m,C-$/m,ue,!0),n.arc(o,a,m,ue,w+Q/m,!0),Q>0){const U=bo(W,ie,o,a);n.arc(U.x,U.y,Q,ie+Math.PI,w-$t)}const q=bo(M,w,o,a);if(n.lineTo(q.x,q.y),_>0){const U=bo(M,j,o,a);n.arc(U.x,U.y,_,w-$t,j)}}else{n.moveTo(o,a);const re=Math.cos(j)*p+o,se=Math.sin(j)*p+a;n.lineTo(re,se);const ue=Math.cos(Y)*p+o,q=Math.sin(Y)*p+a;n.lineTo(ue,q)}n.closePath()}function sN(n,e,t,i,r){const{fullCircles:s,startAngle:o,circumference:a}=e;let c=e.endAngle;if(s){Cf(n,e,t,i,c,r);for(let h=0;h=qe&&y===0&&f!=="miter"&&tN(n,e,b),s||(Cf(n,e,t,i,b,r),n.stroke())}class ga extends fi{constructor(t){super();ge(this,"circumference");ge(this,"endAngle");ge(this,"fullCircles");ge(this,"innerRadius");ge(this,"outerRadius");ge(this,"pixelMargin");ge(this,"startAngle");this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,i,r){const s=this.getProps(["x","y"],r),{angle:o,distance:a}=fC(s,{x:t,y:i}),{startAngle:c,endAngle:h,innerRadius:f,outerRadius:p,circumference:m}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],r),y=(this.options.spacing+this.options.borderWidth)/2,v=Re(m,h-c),b=ic(o,c,h)&&c!==h,S=v>=ht||b,w=ir(a,f+y,p+y);return S&&w}getCenterPoint(t){const{x:i,y:r,startAngle:s,endAngle:o,innerRadius:a,outerRadius:c}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:h,spacing:f}=this.options,p=(s+o)/2,m=(a+c+f+h)/2;return{x:i+Math.cos(p)*m,y:r+Math.sin(p)*m}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:i,circumference:r}=this,s=(i.offset||0)/4,o=(i.spacing||0)/2,a=i.circular;if(this.pixelMargin=i.borderAlign==="inner"?.33:0,this.fullCircles=r>ht?Math.floor(r/ht):0,r===0||this.innerRadius<0||this.outerRadius<0)return;t.save();const c=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(c)*s,Math.sin(c)*s);const h=1-Math.sin(Math.min(qe,r||0)),f=s*h;t.fillStyle=i.backgroundColor,t.strokeStyle=i.borderColor,sN(t,this,f,o,a),oN(t,this,f,o,a),t.restore()}}ge(ga,"id","arc"),ge(ga,"defaults",{borderAlign:"center",borderColor:"#fff",borderDash:[],borderDashOffset:0,borderJoinStyle:void 0,borderRadius:0,borderWidth:2,offset:0,spacing:0,angle:void 0,circular:!0,selfJoin:!1}),ge(ga,"defaultRoutes",{backgroundColor:"backgroundColor"}),ge(ga,"descriptors",{_scriptable:!0,_indexable:t=>t!=="borderDash"});function VC(n,e,t=e){n.lineCap=Re(t.borderCapStyle,e.borderCapStyle),n.setLineDash(Re(t.borderDash,e.borderDash)),n.lineDashOffset=Re(t.borderDashOffset,e.borderDashOffset),n.lineJoin=Re(t.borderJoinStyle,e.borderJoinStyle),n.lineWidth=Re(t.borderWidth,e.borderWidth),n.strokeStyle=Re(t.borderColor,e.borderColor)}function lN(n,e,t){n.lineTo(t.x,t.y)}function aN(n){return n.stepped?M4:n.tension||n.cubicInterpolationMode==="monotone"?R4:lN}function FC(n,e,t={}){const i=n.length,{start:r=0,end:s=i-1}=t,{start:o,end:a}=e,c=Math.max(r,o),h=Math.min(s,a),f=ra&&s>a;return{count:i,start:c,loop:e.loop,ilen:h(o+(h?a-P:P))%s,_=()=>{b!==S&&(n.lineTo(f,S),n.lineTo(f,b),n.lineTo(f,w))};for(c&&(y=r[C(0)],n.moveTo(y.x,y.y)),m=0;m<=a;++m){if(y=r[C(m)],y.skip)continue;const P=y.x,Q=y.y,$=P|0;$===v?(QS&&(S=Q),f=(p*f+P)/++p):(_(),n.lineTo(P,Q),v=$,p=0,b=S=Q),w=Q}_()}function MO(n){const e=n.options,t=e.borderDash&&e.borderDash.length;return!n._decimated&&!n._loop&&!e.tension&&e.cubicInterpolationMode!=="monotone"&&!e.stepped&&!t?uN:cN}function hN(n){return n.stepped?uj:n.tension||n.cubicInterpolationMode==="monotone"?hj:ks}function fN(n,e,t,i){let r=e._path;r||(r=e._path=new Path2D,e.path(r,t,i)&&r.closePath()),VC(n,e.options),n.stroke(r)}function dN(n,e,t,i){const{segments:r,options:s}=e,o=MO(e);for(const a of r)VC(n,s,a.style),n.beginPath(),o(n,e,a,{start:t,end:t+i-1})&&n.closePath(),n.stroke()}const pN=typeof Path2D=="function";function gN(n,e,t,i){pN&&!e.options.segment?fN(n,e,t,i):dN(n,e,t,i)}class Br extends fi{constructor(e){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,e&&Object.assign(this,e)}updateControlPoints(e,t){const i=this.options;if((i.tension||i.cubicInterpolationMode==="monotone")&&!i.stepped&&!this._pointsUpdated){const r=i.spanGaps?this._loop:this._fullLoop;nj(this._points,i,e,r,t),this._pointsUpdated=!0}}set points(e){this._points=e,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=Oj(this,this.options.segment))}first(){const e=this.segments,t=this.points;return e.length&&t[e[0].start]}last(){const e=this.segments,t=this.points,i=e.length;return i&&t[e[i-1].end]}interpolate(e,t){const i=this.options,r=e[t],s=this.points,o=MC(this,{property:t,start:r,end:r});if(!o.length)return;const a=[],c=hN(i);let h,f;for(h=0,f=o.length;he!=="borderDash"&&e!=="fill"});function Aw(n,e,t,i){const r=n.options,{[t]:s}=n.getProps([t],i);return Math.abs(e-s){a=id(o,a,r);const c=r[o],h=r[a];i!==null?(s.push({x:c.x,y:i}),s.push({x:h.x,y:i})):t!==null&&(s.push({x:t,y:c.y}),s.push({x:t,y:h.y}))}),s}function id(n,e,t){for(;e>n;e--){const i=t[e];if(!isNaN(i.x)&&!isNaN(i.y))break}return e}function Ew(n,e,t,i){return n&&e?i(n[t],e[t]):n?n[t]:e?e[t]:0}function qC(n,e){let t=[],i=!1;return bt(n)?(i=!0,t=n):t=SN(n,e),t.length?new Br({points:t,options:{tension:0},_loop:i,_fullLoop:i}):null}function Lw(n){return n&&n.fill!==!1}function wN(n,e,t){let r=n[e].fill;const s=[e];let o;if(!t)return r;for(;r!==!1&&s.indexOf(r)===-1;){if(!nn(r))return r;if(o=n[r],!o)return!1;if(o.visible)return r;s.push(r),r=o.fill}return!1}function kN(n,e,t){const i=CN(n);if(De(i))return isNaN(i.value)?!1:i;let r=parseFloat(i);return nn(r)&&Math.floor(r)===r?PN(i[0],e,r,t):["origin","start","end","stack","shape"].indexOf(i)>=0&&i}function PN(n,e,t,i){return(n==="-"||n==="+")&&(t=e+t),t===e||t<0||t>=i?!1:t}function _N(n,e){let t=null;return n==="start"?t=e.bottom:n==="end"?t=e.top:De(n)?t=e.getPixelForValue(n.value):e.getBasePixel&&(t=e.getBasePixel()),t}function QN(n,e,t){let i;return n==="start"?i=t:n==="end"?i=e.options.reverse?e.min:e.max:De(n)?i=n.value:i=e.getBaseValue(),i}function CN(n){const e=n.options,t=e.fill;let i=Re(t&&t.target,t);return i===void 0&&(i=!!e.backgroundColor),i===!1||i===null?!1:i===!0?"origin":i}function TN(n){const{scale:e,index:t,line:i}=n,r=[],s=i.segments,o=i.points,a=$N(e,t);a.push(qC({x:null,y:e.bottom},i));for(let c=0;c=0;--o){const a=r[o].$filler;a&&(a.line.updateControlPoints(s,a.axis),i&&a.fill&&om(n.ctx,a,s))}},beforeDatasetsDraw(n,e,t){if(t.drawTime!=="beforeDatasetsDraw")return;const i=n.getSortedVisibleDatasetMetas();for(let r=i.length-1;r>=0;--r){const s=i[r].$filler;Lw(s)&&om(n.ctx,s,n.chartArea)}},beforeDatasetDraw(n,e,t){const i=e.meta.$filler;!Lw(i)||t.drawTime!=="beforeDatasetDraw"||om(n.ctx,i,n.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const Iw=(n,e)=>{let{boxHeight:t=e,boxWidth:i=e}=n;return n.usePointStyle&&(t=Math.min(t,e),i=n.pointStyleWidth||Math.min(i,e)),{boxWidth:i,boxHeight:t,itemHeight:Math.max(e,t)}},BN=(n,e)=>n!==null&&e!==null&&n.datasetIndex===e.datasetIndex&&n.index===e.index;class jw extends fi{constructor(e){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=e.chart,this.options=e.options,this.ctx=e.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(e,t,i){this.maxWidth=e,this.maxHeight=t,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const e=this.options.labels||{};let t=it(e.generateLabels,[this.chart],this)||[];e.filter&&(t=t.filter(i=>e.filter(i,this.chart.data))),e.sort&&(t=t.sort((i,r)=>e.sort(i,r,this.chart.data))),this.options.reverse&&t.reverse(),this.legendItems=t}fit(){const{options:e,ctx:t}=this;if(!e.display){this.width=this.height=0;return}const i=e.labels,r=tn(i.font),s=r.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:c}=Iw(i,s);let h,f;t.font=r.string,this.isHorizontal()?(h=this.maxWidth,f=this._fitRows(o,s,a,c)+10):(f=this.maxHeight,h=this._fitCols(o,r,a,c)+10),this.width=Math.min(h,e.maxWidth||this.maxWidth),this.height=Math.min(f,e.maxHeight||this.maxHeight)}_fitRows(e,t,i,r){const{ctx:s,maxWidth:o,options:{labels:{padding:a}}}=this,c=this.legendHitBoxes=[],h=this.lineWidths=[0],f=r+a;let p=e;s.textAlign="left",s.textBaseline="middle";let m=-1,y=-f;return this.legendItems.forEach((v,b)=>{const S=i+t/2+s.measureText(v.text).width;(b===0||h[h.length-1]+S+2*a>o)&&(p+=f,h[h.length-(b>0?0:1)]=0,y+=f,m++),c[b]={left:0,top:y,row:m,width:S,height:r},h[h.length-1]+=S+a}),p}_fitCols(e,t,i,r){const{ctx:s,maxHeight:o,options:{labels:{padding:a}}}=this,c=this.legendHitBoxes=[],h=this.columnSizes=[],f=o-e;let p=a,m=0,y=0,v=0,b=0;return this.legendItems.forEach((S,w)=>{const{itemWidth:C,itemHeight:_}=NN(i,t,s,S,r);w>0&&y+_+2*a>f&&(p+=m+a,h.push({width:m,height:y}),v+=m+a,b++,m=y=0),c[w]={left:v,top:y,col:b,width:C,height:_},m=Math.max(m,C),y+=_+a}),p+=m,h.push({width:m,height:y}),p}adjustHitBoxes(){if(!this.options.display)return;const e=this._computeTitleHeight(),{legendHitBoxes:t,options:{align:i,labels:{padding:r},rtl:s}}=this,o=Io(s,this.left,this.width);if(this.isHorizontal()){let a=0,c=Gt(i,this.left+r,this.right-this.lineWidths[a]);for(const h of t)a!==h.row&&(a=h.row,c=Gt(i,this.left+r,this.right-this.lineWidths[a])),h.top+=this.top+e+r,h.left=o.leftForLtr(o.x(c),h.width),c+=h.width+r}else{let a=0,c=Gt(i,this.top+e+r,this.bottom-this.columnSizes[a].height);for(const h of t)h.col!==a&&(a=h.col,c=Gt(i,this.top+e+r,this.bottom-this.columnSizes[a].height)),h.top=c,h.left+=this.left+r,h.left=o.leftForLtr(o.x(h.left),h.width),c+=h.height+r}}isHorizontal(){return this.options.position==="top"||this.options.position==="bottom"}draw(){if(this.options.display){const e=this.ctx;Kf(e,this),this._draw(),Jf(e)}}_draw(){const{options:e,columnSizes:t,lineWidths:i,ctx:r}=this,{align:s,labels:o}=e,a=Ot.color,c=Io(e.rtl,this.left,this.width),h=tn(o.font),{padding:f}=o,p=h.size,m=p/2;let y;this.drawTitle(),r.textAlign=c.textAlign("left"),r.textBaseline="middle",r.lineWidth=.5,r.font=h.string;const{boxWidth:v,boxHeight:b,itemHeight:S}=Iw(o,p),w=function($,M,Z){if(isNaN(v)||v<=0||isNaN(b)||b<0)return;r.save();const j=Re(Z.lineWidth,1);if(r.fillStyle=Re(Z.fillStyle,a),r.lineCap=Re(Z.lineCap,"butt"),r.lineDashOffset=Re(Z.lineDashOffset,0),r.lineJoin=Re(Z.lineJoin,"miter"),r.lineWidth=j,r.strokeStyle=Re(Z.strokeStyle,a),r.setLineDash(Re(Z.lineDash,[])),o.usePointStyle){const Y={radius:b*Math.SQRT2/2,pointStyle:Z.pointStyle,rotation:Z.rotation,borderWidth:j},W=c.xPlus($,v/2),F=M+m;vC(r,Y,W,F,o.pointStyleWidth&&v)}else{const Y=M+Math.max((p-b)/2,0),W=c.leftForLtr($,v),F=Zo(Z.borderRadius);r.beginPath(),Object.values(F).some(ie=>ie!==0)?Pf(r,{x:W,y:Y,w:v,h:b,radius:F}):r.rect(W,Y,v,b),r.fill(),j!==0&&r.stroke()}r.restore()},C=function($,M,Z){sc(r,Z.text,$,M+S/2,h,{strikethrough:Z.hidden,textAlign:c.textAlign(Z.textAlign)})},_=this.isHorizontal(),P=this._computeTitleHeight();_?y={x:Gt(s,this.left+f,this.right-i[0]),y:this.top+f+P,line:0}:y={x:this.left+f,y:Gt(s,this.top+P+f,this.bottom-t[0].height),line:0},QC(this.ctx,e.textDirection);const Q=S+f;this.legendItems.forEach(($,M)=>{r.strokeStyle=$.fontColor,r.fillStyle=$.fontColor;const Z=r.measureText($.text).width,j=c.textAlign($.textAlign||($.textAlign=o.textAlign)),Y=v+m+Z;let W=y.x,F=y.y;c.setWidth(this.width),_?M>0&&W+Y+f>this.right&&(F=y.y+=Q,y.line++,W=y.x=Gt(s,this.left+f,this.right-i[y.line])):M>0&&F+Q>this.bottom&&(W=y.x=W+t[y.line].width+f,y.line++,F=y.y=Gt(s,this.top+P+f,this.bottom-t[y.line].height));const ie=c.x(W);if(w(ie,F,$),W=v4(j,W+v+m,_?W+Y:this.right,e.rtl),C(c.x(W),F,$),_)y.x+=Y+f;else if(typeof $.text!="string"){const oe=h.lineHeight;y.y+=HC($,oe)+f}else y.y+=Q}),CC(this.ctx,e.textDirection)}drawTitle(){const e=this.options,t=e.title,i=tn(t.font),r=Un(t.padding);if(!t.display)return;const s=Io(e.rtl,this.left,this.width),o=this.ctx,a=t.position,c=i.size/2,h=r.top+c;let f,p=this.left,m=this.width;if(this.isHorizontal())m=Math.max(...this.lineWidths),f=this.top+h,p=Gt(e.align,p,this.right-m);else{const v=this.columnSizes.reduce((b,S)=>Math.max(b,S.height),0);f=h+Gt(e.align,this.top,this.bottom-v-e.labels.padding-this._computeTitleHeight())}const y=Gt(a,p,p+m);o.textAlign=s.textAlign(N0(a)),o.textBaseline="middle",o.strokeStyle=t.color,o.fillStyle=t.color,o.font=i.string,sc(o,t.text,y,f,i)}_computeTitleHeight(){const e=this.options.title,t=tn(e.font),i=Un(e.padding);return e.display?t.lineHeight+i.height:0}_getLegendItemAt(e,t){let i,r,s;if(ir(e,this.left,this.right)&&ir(t,this.top,this.bottom)){for(s=this.legendHitBoxes,i=0;is.length>o.length?s:o)),e+t.size/2+i.measureText(r).width}function WN(n,e,t){let i=n;return typeof e.text!="string"&&(i=HC(e,t)),i}function HC(n,e){const t=n.text?n.text.length:0;return e*t}function VN(n,e){return!!((n==="mousemove"||n==="mouseout")&&(e.onHover||e.onLeave)||e.onClick&&(n==="click"||n==="mouseup"))}var FN={id:"legend",_element:jw,start(n,e,t){const i=n.legend=new jw({ctx:n.ctx,options:t,chart:n});Vn.configure(n,i,t),Vn.addBox(n,i)},stop(n){Vn.removeBox(n,n.legend),delete n.legend},beforeUpdate(n,e,t){const i=n.legend;Vn.configure(n,i,t),i.options=t},afterUpdate(n){const e=n.legend;e.buildLabels(),e.adjustHitBoxes()},afterEvent(n,e){e.replay||n.legend.handleEvent(e.event)},defaults:{display:!0,position:"top",align:"center",fullSize:!0,reverse:!1,weight:1e3,onClick(n,e,t){const i=e.datasetIndex,r=t.chart;r.isDatasetVisible(i)?(r.hide(i),e.hidden=!0):(r.show(i),e.hidden=!1)},onHover:null,onLeave:null,labels:{color:n=>n.chart.options.color,boxWidth:40,padding:10,generateLabels(n){const e=n.data.datasets,{labels:{usePointStyle:t,pointStyle:i,textAlign:r,color:s,useBorderRadius:o,borderRadius:a}}=n.legend.options;return n._getSortedDatasetMetas().map(c=>{const h=c.controller.getStyle(t?0:void 0),f=Un(h.borderWidth);return{text:e[c.index].label,fillStyle:h.backgroundColor,fontColor:s,hidden:!c.visible,lineCap:h.borderCapStyle,lineDash:h.borderDash,lineDashOffset:h.borderDashOffset,lineJoin:h.borderJoinStyle,lineWidth:(f.width+f.height)/4,strokeStyle:h.borderColor,pointStyle:i||h.pointStyle,rotation:h.rotation,textAlign:r||h.textAlign,borderRadius:o&&(a||h.borderRadius),datasetIndex:c.index}},this)}},title:{color:n=>n.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:n=>!n.startsWith("on"),labels:{_scriptable:n=>!["generateLabels","filter","sort"].includes(n)}}};let GC=class extends fi{constructor(e){super(),this.chart=e.chart,this.options=e.options,this.ctx=e.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(e,t){const i=this.options;if(this.left=0,this.top=0,!i.display){this.width=this.height=this.right=this.bottom=0;return}this.width=this.right=e,this.height=this.bottom=t;const r=bt(i.text)?i.text.length:1;this._padding=Un(i.padding);const s=r*tn(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=s:this.width=s}isHorizontal(){const e=this.options.position;return e==="top"||e==="bottom"}_drawArgs(e){const{top:t,left:i,bottom:r,right:s,options:o}=this,a=o.align;let c=0,h,f,p;return this.isHorizontal()?(f=Gt(a,i,s),p=t+e,h=s-i):(o.position==="left"?(f=i+e,p=Gt(a,r,t),c=qe*-.5):(f=s-e,p=Gt(a,t,r),c=qe*.5),h=r-t),{titleX:f,titleY:p,maxWidth:h,rotation:c}}draw(){const e=this.ctx,t=this.options;if(!t.display)return;const i=tn(t.font),s=i.lineHeight/2+this._padding.top,{titleX:o,titleY:a,maxWidth:c,rotation:h}=this._drawArgs(s);sc(e,t.text,0,0,i,{color:t.color,maxWidth:c,rotation:h,textAlign:N0(t.align),textBaseline:"middle",translation:[o,a]})}};function YN(n,e){const t=new GC({ctx:n.ctx,options:e,chart:n});Vn.configure(n,t,e),Vn.addBox(n,t),n.titleBlock=t}var qN={id:"title",_element:GC,start(n,e,t){YN(n,t)},stop(n){const e=n.titleBlock;Vn.removeBox(n,e),delete n.titleBlock},beforeUpdate(n,e,t){const i=n.titleBlock;Vn.configure(n,i,t),i.options=t},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const ma={average(n){if(!n.length)return!1;let e,t,i=new Set,r=0,s=0;for(e=0,t=n.length;ea+c)/i.size,y:r/s}},nearest(n,e){if(!n.length)return!1;let t=e.x,i=e.y,r=Number.POSITIVE_INFINITY,s,o,a;for(s=0,o=n.length;s-1?n.split(` -`):n}function UN(n,e){const{element:t,datasetIndex:i,index:r}=e,s=n.getDatasetMeta(i).controller,{label:o,value:a}=s.getLabelAndValue(r);return{chart:n,label:o,parsed:s.getParsed(r),raw:n.data.datasets[i].data[r],formattedValue:a,dataset:s.getDataset(),dataIndex:r,datasetIndex:i,element:t}}function Bw(n,e){const t=n.chart.ctx,{body:i,footer:r,title:s}=n,{boxWidth:o,boxHeight:a}=e,c=tn(e.bodyFont),h=tn(e.titleFont),f=tn(e.footerFont),p=s.length,m=r.length,y=i.length,v=Un(e.padding);let b=v.height,S=0,w=i.reduce((P,Q)=>P+Q.before.length+Q.lines.length+Q.after.length,0);if(w+=n.beforeBody.length+n.afterBody.length,p&&(b+=p*h.lineHeight+(p-1)*e.titleSpacing+e.titleMarginBottom),w){const P=e.displayColors?Math.max(a,c.lineHeight):c.lineHeight;b+=y*P+(w-y)*c.lineHeight+(w-1)*e.bodySpacing}m&&(b+=e.footerMarginTop+m*f.lineHeight+(m-1)*e.footerSpacing);let C=0;const _=function(P){S=Math.max(S,t.measureText(P).width+C)};return t.save(),t.font=h.string,Ye(n.title,_),t.font=c.string,Ye(n.beforeBody.concat(n.afterBody),_),C=e.displayColors?o+2+e.boxPadding:0,Ye(i,P=>{Ye(P.before,_),Ye(P.lines,_),Ye(P.after,_)}),C=0,t.font=f.string,Ye(n.footer,_),t.restore(),S+=v.width,{width:S,height:b}}function HN(n,e){const{y:t,height:i}=e;return tn.height-i/2?"bottom":"center"}function GN(n,e,t,i){const{x:r,width:s}=i,o=t.caretSize+t.caretPadding;if(n==="left"&&r+s+o>e.width||n==="right"&&r-s-o<0)return!0}function KN(n,e,t,i){const{x:r,width:s}=t,{width:o,chartArea:{left:a,right:c}}=n;let h="center";return i==="center"?h=r<=(a+c)/2?"left":"right":r<=s/2?h="left":r>=o-s/2&&(h="right"),GN(h,n,e,t)&&(h="center"),h}function Nw(n,e,t){const i=t.yAlign||e.yAlign||HN(n,t);return{xAlign:t.xAlign||e.xAlign||KN(n,e,t,i),yAlign:i}}function JN(n,e){let{x:t,width:i}=n;return e==="right"?t-=i:e==="center"&&(t-=i/2),t}function eX(n,e,t){let{y:i,height:r}=n;return e==="top"?i+=t:e==="bottom"?i-=r+t:i-=r/2,i}function Xw(n,e,t,i){const{caretSize:r,caretPadding:s,cornerRadius:o}=n,{xAlign:a,yAlign:c}=t,h=r+s,{topLeft:f,topRight:p,bottomLeft:m,bottomRight:y}=Zo(o);let v=JN(e,a);const b=eX(e,c,h);return c==="center"?a==="left"?v+=h:a==="right"&&(v-=h):a==="left"?v-=Math.max(f,m)+r:a==="right"&&(v+=Math.max(p,y)+r),{x:en(v,0,i.width-e.width),y:en(b,0,i.height-e.height)}}function gh(n,e,t){const i=Un(t.padding);return e==="center"?n.x+n.width/2:e==="right"?n.x+n.width-i.right:n.x+i.left}function Ww(n){return _i([],Hi(n))}function tX(n,e,t){return Vs(n,{tooltip:e,tooltipItems:t,type:"tooltip"})}function Vw(n,e){const t=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return t?n.override(t):n}const KC={beforeTitle:qi,title(n){if(n.length>0){const e=n[0],t=e.chart.data.labels,i=t?t.length:0;if(this&&this.options&&this.options.mode==="dataset")return e.dataset.label||"";if(e.label)return e.label;if(i>0&&e.dataIndex"u"?KC[e].call(t,i):r}class AO extends fi{constructor(e){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=e.chart,this.options=e.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(e){this.options=e,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){const e=this._cachedAnimations;if(e)return e;const t=this.chart,i=this.options.setContext(this.getContext()),r=i.enabled&&t.options.animation&&i.animations,s=new AC(this.chart,r);return r._cacheable&&(this._cachedAnimations=Object.freeze(s)),s}getContext(){return this.$context||(this.$context=tX(this.chart.getContext(),this,this._tooltipItems))}getTitle(e,t){const{callbacks:i}=t,r=mn(i,"beforeTitle",this,e),s=mn(i,"title",this,e),o=mn(i,"afterTitle",this,e);let a=[];return a=_i(a,Hi(r)),a=_i(a,Hi(s)),a=_i(a,Hi(o)),a}getBeforeBody(e,t){return Ww(mn(t.callbacks,"beforeBody",this,e))}getBody(e,t){const{callbacks:i}=t,r=[];return Ye(e,s=>{const o={before:[],lines:[],after:[]},a=Vw(i,s);_i(o.before,Hi(mn(a,"beforeLabel",this,s))),_i(o.lines,mn(a,"label",this,s)),_i(o.after,Hi(mn(a,"afterLabel",this,s))),r.push(o)}),r}getAfterBody(e,t){return Ww(mn(t.callbacks,"afterBody",this,e))}getFooter(e,t){const{callbacks:i}=t,r=mn(i,"beforeFooter",this,e),s=mn(i,"footer",this,e),o=mn(i,"afterFooter",this,e);let a=[];return a=_i(a,Hi(r)),a=_i(a,Hi(s)),a=_i(a,Hi(o)),a}_createItems(e){const t=this._active,i=this.chart.data,r=[],s=[],o=[];let a=[],c,h;for(c=0,h=t.length;ce.filter(f,p,m,i))),e.itemSort&&(a=a.sort((f,p)=>e.itemSort(f,p,i))),Ye(a,f=>{const p=Vw(e.callbacks,f);r.push(mn(p,"labelColor",this,f)),s.push(mn(p,"labelPointStyle",this,f)),o.push(mn(p,"labelTextColor",this,f))}),this.labelColors=r,this.labelPointStyles=s,this.labelTextColors=o,this.dataPoints=a,a}update(e,t){const i=this.options.setContext(this.getContext()),r=this._active;let s,o=[];if(!r.length)this.opacity!==0&&(s={opacity:0});else{const a=ma[i.position].call(this,r,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const c=this._size=Bw(this,i),h=Object.assign({},a,c),f=Nw(this.chart,i,h),p=Xw(i,h,f,this.chart);this.xAlign=f.xAlign,this.yAlign=f.yAlign,s={opacity:1,x:p.x,y:p.y,width:c.width,height:c.height,caretX:a.x,caretY:a.y}}this._tooltipItems=o,this.$context=void 0,s&&this._resolveAnimations().update(this,s),e&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:t})}drawCaret(e,t,i,r){const s=this.getCaretPosition(e,i,r);t.lineTo(s.x1,s.y1),t.lineTo(s.x2,s.y2),t.lineTo(s.x3,s.y3)}getCaretPosition(e,t,i){const{xAlign:r,yAlign:s}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:c,topRight:h,bottomLeft:f,bottomRight:p}=Zo(a),{x:m,y}=e,{width:v,height:b}=t;let S,w,C,_,P,Q;return s==="center"?(P=y+b/2,r==="left"?(S=m,w=S-o,_=P+o,Q=P-o):(S=m+v,w=S+o,_=P-o,Q=P+o),C=S):(r==="left"?w=m+Math.max(c,f)+o:r==="right"?w=m+v-Math.max(h,p)-o:w=this.caretX,s==="top"?(_=y,P=_-o,S=w-o,C=w+o):(_=y+b,P=_+o,S=w+o,C=w-o),Q=_),{x1:S,x2:w,x3:C,y1:_,y2:P,y3:Q}}drawTitle(e,t,i){const r=this.title,s=r.length;let o,a,c;if(s){const h=Io(i.rtl,this.x,this.width);for(e.x=gh(this,i.titleAlign,i),t.textAlign=h.textAlign(i.titleAlign),t.textBaseline="middle",o=tn(i.titleFont),a=i.titleSpacing,t.fillStyle=i.titleColor,t.font=o.string,c=0;cC!==0)?(e.beginPath(),e.fillStyle=s.multiKeyBackground,Pf(e,{x:b,y:v,w:h,h:c,radius:w}),e.fill(),e.stroke(),e.fillStyle=o.backgroundColor,e.beginPath(),Pf(e,{x:S,y:v+1,w:h-2,h:c-2,radius:w}),e.fill()):(e.fillStyle=s.multiKeyBackground,e.fillRect(b,v,h,c),e.strokeRect(b,v,h,c),e.fillStyle=o.backgroundColor,e.fillRect(S,v+1,h-2,c-2))}e.fillStyle=this.labelTextColors[i]}drawBody(e,t,i){const{body:r}=this,{bodySpacing:s,bodyAlign:o,displayColors:a,boxHeight:c,boxWidth:h,boxPadding:f}=i,p=tn(i.bodyFont);let m=p.lineHeight,y=0;const v=Io(i.rtl,this.x,this.width),b=function(Z){t.fillText(Z,v.x(e.x+y),e.y+m/2),e.y+=m+s},S=v.textAlign(o);let w,C,_,P,Q,$,M;for(t.textAlign=o,t.textBaseline="middle",t.font=p.string,e.x=gh(this,S,i),t.fillStyle=i.bodyColor,Ye(this.beforeBody,b),y=a&&S!=="right"?o==="center"?h/2+f:h+2+f:0,P=0,$=r.length;P<$;++P){for(w=r[P],C=this.labelTextColors[P],t.fillStyle=C,Ye(w.before,b),_=w.lines,a&&_.length&&(this._drawColorBox(t,e,P,v,i),m=Math.max(p.lineHeight,c)),Q=0,M=_.length;Q0&&t.stroke()}_updateAnimationTarget(e){const t=this.chart,i=this.$animations,r=i&&i.x,s=i&&i.y;if(r||s){const o=ma[e.position].call(this,this._active,this._eventPosition);if(!o)return;const a=this._size=Bw(this,e),c=Object.assign({},o,this._size),h=Nw(t,e,c),f=Xw(e,c,h,t);(r._to!==f.x||s._to!==f.y)&&(this.xAlign=h.xAlign,this.yAlign=h.yAlign,this.width=a.width,this.height=a.height,this.caretX=o.x,this.caretY=o.y,this._resolveAnimations().update(this,f))}}_willRender(){return!!this.opacity}draw(e){const t=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(t);const r={width:this.width,height:this.height},s={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=Un(t.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;t.enabled&&a&&(e.save(),e.globalAlpha=i,this.drawBackground(s,e,r,t),QC(e,t.textDirection),s.y+=o.top,this.drawTitle(s,e,t),this.drawBody(s,e,t),this.drawFooter(s,e,t),CC(e,t.textDirection),e.restore())}getActiveElements(){return this._active||[]}setActiveElements(e,t){const i=this._active,r=e.map(({datasetIndex:a,index:c})=>{const h=this.chart.getDatasetMeta(a);if(!h)throw new Error("Cannot find a dataset at index "+a);return{datasetIndex:a,element:h.data[c],index:c}}),s=!Sf(i,r),o=this._positionChanged(r,t);(s||o)&&(this._active=r,this._eventPosition=t,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(e,t,i=!0){if(t&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const r=this.options,s=this._active||[],o=this._getActiveElements(e,s,t,i),a=this._positionChanged(o,e),c=t||!Sf(o,s)||a;return c&&(this._active=o,(r.enabled||r.external)&&(this._eventPosition={x:e.x,y:e.y},this.update(!0,t))),c}_getActiveElements(e,t,i,r){const s=this.options;if(e.type==="mouseout")return[];if(!r)return t.filter(a=>this.chart.data.datasets[a.datasetIndex]&&this.chart.getDatasetMeta(a.datasetIndex).controller.getParsed(a.index)!==void 0);const o=this.chart.getElementsAtEventForMode(e,s.mode,s,i);return s.reverse&&o.reverse(),o}_positionChanged(e,t){const{caretX:i,caretY:r,options:s}=this,o=ma[s.position].call(this,e,t);return o!==!1&&(i!==o.x||r!==o.y)}}ge(AO,"positioners",ma);var nX={id:"tooltip",_element:AO,positioners:ma,afterInit(n,e,t){t&&(n.tooltip=new AO({chart:n,options:t}))},beforeUpdate(n,e,t){n.tooltip&&n.tooltip.initialize(t)},reset(n,e,t){n.tooltip&&n.tooltip.initialize(t)},afterDraw(n){const e=n.tooltip;if(e&&e._willRender()){const t={tooltip:e};if(n.notifyPlugins("beforeTooltipDraw",{...t,cancelable:!0})===!1)return;e.draw(n.ctx),n.notifyPlugins("afterTooltipDraw",t)}},afterEvent(n,e){if(n.tooltip){const t=e.replay;n.tooltip.handleEvent(e.event,t,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(n,e)=>e.bodyFont.size,boxWidth:(n,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:KC},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:n=>n!=="filter"&&n!=="itemSort"&&n!=="external",_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};const iX=(n,e,t,i)=>(typeof e=="string"?(t=n.push(e)-1,i.unshift({index:t,label:e})):isNaN(e)&&(t=null),t);function rX(n,e,t,i){const r=n.indexOf(e);if(r===-1)return iX(n,e,t,i);const s=n.lastIndexOf(e);return r!==s?t:r}const sX=(n,e)=>n===null?null:en(Math.round(n),0,e);function Fw(n){const e=this.getLabels();return n>=0&&nt.length-1?null:this.getPixelForValue(t[e].value)}getValueForPixel(e){return Math.round(this._startValue+this.getDecimalForPixel(e)*this._valueRange)}getBasePixel(){return this.bottom}}ge(EO,"id","category"),ge(EO,"defaults",{ticks:{callback:Fw}});function oX(n,e){const t=[],{bounds:r,step:s,min:o,max:a,precision:c,count:h,maxTicks:f,maxDigits:p,includeBounds:m}=n,y=s||1,v=f-1,{min:b,max:S}=e,w=!Ne(o),C=!Ne(a),_=!Ne(h),P=(S-b)/(p+1);let Q=XS((S-b)/v/y)*y,$,M,Z,j;if(Q<1e-14&&!w&&!C)return[{value:b},{value:S}];j=Math.ceil(S/Q)-Math.floor(b/Q),j>v&&(Q=XS(j*Q/v/y)*y),Ne(c)||($=Math.pow(10,c),Q=Math.ceil(Q*$)/$),r==="ticks"?(M=Math.floor(b/Q)*Q,Z=Math.ceil(S/Q)*Q):(M=b,Z=S),w&&C&&s&&h4((a-o)/s,Q/1e3)?(j=Math.round(Math.min((a-o)/Q,f)),Q=(a-o)/j,M=o,Z=a):_?(M=w?o:M,Z=C?a:Z,j=h-1,Q=(Z-M)/j):(j=(Z-M)/Q,_a(j,Math.round(j),Q/1e3)?j=Math.round(j):j=Math.ceil(j));const Y=Math.max(WS(Q),WS(M));$=Math.pow(10,Ne(c)?Y:c),M=Math.round(M*$)/$,Z=Math.round(Z*$)/$;let W=0;for(w&&(m&&M!==o?(t.push({value:o}),Ma)break;t.push({value:F})}return C&&m&&Z!==a?t.length&&_a(t[t.length-1].value,a,Yw(a,P,n))?t[t.length-1].value=a:t.push({value:a}):(!C||Z===a)&&t.push({value:Z}),t}function Yw(n,e,{horizontal:t,minRotation:i}){const r=nr(i),s=(t?Math.sin(r):Math.cos(r))||.001,o=.75*e*(""+n).length;return Math.min(e/s,o)}class lX extends ol{constructor(e){super(e),this.start=void 0,this.end=void 0,this._startValue=void 0,this._endValue=void 0,this._valueRange=0}parse(e,t){return Ne(e)||(typeof e=="number"||e instanceof Number)&&!isFinite(+e)?null:+e}handleTickRangeOptions(){const{beginAtZero:e}=this.options,{minDefined:t,maxDefined:i}=this.getUserBounds();let{min:r,max:s}=this;const o=c=>r=t?r:c,a=c=>s=i?s:c;if(e){const c=Di(r),h=Di(s);c<0&&h<0?a(0):c>0&&h>0&&o(0)}if(r===s){let c=s===0?1:Math.abs(s*.05);a(s+c),e||o(r-c)}this.min=r,this.max=s}getTickLimit(){const e=this.options.ticks;let{maxTicksLimit:t,stepSize:i}=e,r;return i?(r=Math.ceil(this.max/i)-Math.floor(this.min/i)+1,r>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${i} would result generating up to ${r} ticks. Limiting to 1000.`),r=1e3)):(r=this.computeTickLimit(),t=t||11),t&&(r=Math.min(t,r)),r}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const e=this.options,t=e.ticks;let i=this.getTickLimit();i=Math.max(2,i);const r={maxTicks:i,bounds:e.bounds,min:e.min,max:e.max,precision:t.precision,step:t.stepSize,count:t.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:t.minRotation||0,includeBounds:t.includeBounds!==!1},s=this._range||this,o=oX(r,s);return e.bounds==="ticks"&&f4(o,this,"value"),e.reverse?(o.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),o}configure(){const e=this.ticks;let t=this.min,i=this.max;if(super.configure(),this.options.offset&&e.length){const r=(i-t)/Math.max(e.length-1,1)/2;t-=r,i+=r}this._startValue=t,this._endValue=i,this._valueRange=i-t}getLabelForValue(e){return W0(e,this.chart.options.locale,this.options.ticks.format)}}class LO extends lX{determineDataLimits(){const{min:e,max:t}=this.getMinMax(!0);this.min=nn(e)?e:0,this.max=nn(t)?t:1,this.handleTickRangeOptions()}computeTickLimit(){const e=this.isHorizontal(),t=e?this.width:this.height,i=nr(this.options.ticks.minRotation),r=(e?Math.sin(i):Math.cos(i))||.001,s=this._resolveTickFontOptions(0);return Math.ceil(t/Math.min(40,s.lineHeight/r))}getPixelForValue(e){return e===null?NaN:this.getPixelForDecimal((e-this._startValue)/this._valueRange)}getValueForPixel(e){return this._startValue+this.getDecimalForPixel(e)*this._valueRange}}ge(LO,"id","linear"),ge(LO,"defaults",{ticks:{callback:xC.formatters.numeric}});const rd={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},vn=Object.keys(rd);function qw(n,e){return n-e}function Uw(n,e){if(Ne(e))return null;const t=n._adapter,{parser:i,round:r,isoWeekday:s}=n._parseOpts;let o=e;return typeof i=="function"&&(o=i(o)),nn(o)||(o=typeof i=="string"?t.parse(o,i):t.parse(o)),o===null?null:(r&&(o=r==="week"&&(Go(s)||s===!0)?t.startOf(o,"isoWeek",s):t.startOf(o,r)),+o)}function Hw(n,e,t,i){const r=vn.length;for(let s=vn.indexOf(n);s=vn.indexOf(t);s--){const o=vn[s];if(rd[o].common&&n._adapter.diff(r,i,o)>=e-1)return o}return vn[t?vn.indexOf(t):0]}function cX(n){for(let e=vn.indexOf(n)+1,t=vn.length;e=e?t[i]:t[r];n[s]=!0}}function uX(n,e,t,i){const r=n._adapter,s=+r.startOf(e[0].value,i),o=e[e.length-1].value;let a,c;for(a=s;a<=o;a=+r.add(a,1,i))c=t[a],c>=0&&(e[c].major=!0);return e}function Kw(n,e,t){const i=[],r={},s=e.length;let o,a;for(o=0;o+e.value))}initOffsets(e=[]){let t=0,i=0,r,s;this.options.offset&&e.length&&(r=this.getDecimalForValue(e[0]),e.length===1?t=1-r:t=(this.getDecimalForValue(e[1])-r)/2,s=this.getDecimalForValue(e[e.length-1]),e.length===1?i=s:i=(s-this.getDecimalForValue(e[e.length-2]))/2);const o=e.length<3?.5:.25;t=en(t,0,o),i=en(i,0,o),this._offsets={start:t,end:i,factor:1/(t+1+i)}}_generate(){const e=this._adapter,t=this.min,i=this.max,r=this.options,s=r.time,o=s.unit||Hw(s.minUnit,t,i,this._getLabelCapacity(t)),a=Re(r.ticks.stepSize,1),c=o==="week"?s.isoWeekday:!1,h=Go(c)||c===!0,f={};let p=t,m,y;if(h&&(p=+e.startOf(p,"isoWeek",c)),p=+e.startOf(p,h?"day":o),e.diff(i,t,o)>1e5*a)throw new Error(t+" and "+i+" are too far apart with stepSize of "+a+" "+o);const v=r.ticks.source==="data"&&this.getDataTimestamps();for(m=p,y=0;m+b)}getLabelForValue(e){const t=this._adapter,i=this.options.time;return i.tooltipFormat?t.format(e,i.tooltipFormat):t.format(e,i.displayFormats.datetime)}format(e,t){const r=this.options.time.displayFormats,s=this._unit,o=t||r[s];return this._adapter.format(e,o)}_tickFormatFunction(e,t,i,r){const s=this.options,o=s.ticks.callback;if(o)return it(o,[e,t,i],this);const a=s.time.displayFormats,c=this._unit,h=this._majorUnit,f=c&&a[c],p=h&&a[h],m=i[t],y=h&&p&&m&&m.major;return this._adapter.format(e,r||(y?p:f))}generateTickLabels(e){let t,i,r;for(t=0,i=e.length;t0?a:1}getDataTimestamps(){let e=this._cache.data||[],t,i;if(e.length)return e;const r=this.getMatchingVisibleMetas();if(this._normalized&&r.length)return this._cache.data=r[0].controller.getAllParsedValues(this);for(t=0,i=r.length;t=n[i].pos&&e<=n[r].pos&&({lo:i,hi:r}=$s(n,"pos",e)),{pos:s,time:a}=n[i],{pos:o,time:c}=n[r]):(e>=n[i].time&&e<=n[r].time&&({lo:i,hi:r}=$s(n,"time",e)),{time:s,pos:a}=n[i],{time:o,pos:c}=n[r]);const h=o-s;return h?a+(c-a)*(e-s)/h:a}class Jw extends Tf{constructor(e){super(e),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const e=this._getTimestampsForTable(),t=this._table=this.buildLookupTable(e);this._minPos=mh(t,this.min),this._tableRange=mh(t,this.max)-this._minPos,super.initOffsets(e)}buildLookupTable(e){const{min:t,max:i}=this,r=[],s=[];let o,a,c,h,f;for(o=0,a=e.length;o=t&&h<=i&&r.push(h);if(r.length<2)return[{time:t,pos:0},{time:i,pos:1}];for(o=0,a=r.length;or-s)}_getTimestampsForTable(){let e=this._cache.all||[];if(e.length)return e;const t=this.getDataTimestamps(),i=this.getLabelTimestamps();return t.length&&i.length?e=this.normalize(t.concat(i)):e=t.length?t:i,e=this._cache.all=e,e}getDecimalForValue(e){return(mh(this._table,e)-this._minPos)/this._tableRange}getValueForPixel(e){const t=this._offsets,i=this.getDecimalForPixel(e)/t.factor-t.end;return mh(this._table,i*this._tableRange+this._minPos,!0)}}ge(Jw,"id","timeseries"),ge(Jw,"defaults",Tf.defaults);const JC="label";function ek(n,e){typeof n=="function"?n(e):n&&(n.current=e)}function hX(n,e){const t=n.options;t&&e&&Object.assign(t,e)}function eT(n,e){n.labels=e}function tT(n,e){let t=arguments.length>2&&arguments[2]!==void 0?arguments[2]:JC;const i=[];n.datasets=e.map(r=>{const s=n.datasets.find(o=>o[t]===r[t]);return!s||!r.data||i.includes(s)?{...r}:(i.push(s),Object.assign(s,r),s)})}function fX(n){let e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:JC;const t={labels:[],datasets:[]};return eT(t,n.labels),tT(t,n.datasets,e),t}function dX(n,e){const{height:t=150,width:i=300,redraw:r=!1,datasetIdKey:s,type:o,data:a,options:c,plugins:h=[],fallbackContent:f,updateMode:p,...m}=n,y=me.useRef(null),v=me.useRef(null),b=()=>{y.current&&(v.current=new nd(y.current,{type:o,data:fX(a,s),options:c&&{...c},plugins:h}),ek(e,v.current))},S=()=>{ek(e,null),v.current&&(v.current.destroy(),v.current=null)};return me.useEffect(()=>{!r&&v.current&&c&&hX(v.current,c)},[r,c]),me.useEffect(()=>{!r&&v.current&&eT(v.current.config.data,a.labels)},[r,a.labels]),me.useEffect(()=>{!r&&v.current&&a.datasets&&tT(v.current.config.data,a.datasets,s)},[r,a.datasets]),me.useEffect(()=>{v.current&&(r?(S(),setTimeout(b)):v.current.update(p))},[r,c,a.labels,a.datasets,p]),me.useEffect(()=>{v.current&&(S(),setTimeout(b))},[o]),me.useEffect(()=>(b(),()=>S()),[]),dt.createElement("canvas",{ref:y,role:"img",height:t,width:i,...m},f)}const pX=me.forwardRef(dX);function xc(n,e){return nd.register(e),me.forwardRef((t,i)=>dt.createElement(pX,{...t,ref:i,type:n}))}const gX=xc("line",Dh),mX=xc("bar",Lh),OX=xc("doughnut",$o),yX=xc("pie",CO),xX=xc("scatter",Ta);nd.register(EO,LO,Ih,Br,jh,ga,Ta,qN,nX,FN,jN);const tk=de.div` - display: flex; - flex-direction: column; - height: 100%; - background: #1e1e1e; - border: 1px solid #3c3c3c; - border-radius: 8px; - overflow: hidden; -`,vX=de.div` - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - border-bottom: 1px solid #3c3c3c; - background: #2d2d2d; -`,bX=de.h3` - margin: 0; - color: #ffffff; - font-size: 16px; - font-weight: 600; -`,SX=de.div` - display: flex; - gap: 8px; - align-items: center; -`,nk=de.select` - padding: 4px 8px; - background: #3c3c3c; - border: 1px solid #5a5a5a; - border-radius: 4px; - color: #ffffff; - font-size: 12px; - outline: none; - - &:focus { - border-color: #0078d4; - } - - option { - background: #3c3c3c; - color: #ffffff; - } -`,So=de.button` - padding: 4px 8px; - background: ${n=>n.active?"#0078d4":"#3c3c3c"}; - color: white; - border: none; - border-radius: 4px; - font-size: 12px; - cursor: pointer; - transition: background 0.2s; - - &:hover { - background: ${n=>n.active?"#106ebe":"#484848"}; - } -`,wX=de.div` - flex: 1; - padding: 16px; - position: relative; - min-height: 300px; - - canvas { - max-height: 100% !important; - } -`,kX=de.div` - position: absolute; - top: 0; - right: 0; - width: 250px; - height: 100%; - background: #2d2d2d; - border-left: 1px solid #3c3c3c; - padding: 16px; - transform: ${n=>n.show?"translateX(0)":"translateX(100%)"}; - transition: transform 0.3s ease; - z-index: 10; - overflow-y: auto; - - h4 { - margin: 0 0 12px 0; - color: #ffffff; - font-size: 14px; - font-weight: 600; - } - - .config-group { - margin-bottom: 16px; - - label { - display: block; - margin-bottom: 4px; - color: #cccccc; - font-size: 12px; - } - } -`,PX=de.div` - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - color: #888888; - - .icon { - font-size: 48px; - margin-bottom: 16px; - opacity: 0.5; - } - - .message { - font-size: 16px; - margin-bottom: 8px; - } - - .submessage { - font-size: 14px; - opacity: 0.7; - text-align: center; - } -`,_X=["#0078d4","#107c10","#d83b01","#5c2d91","#e81123","#00bcf2","#bad80a","#ff8c00","#c239b3","#00b7c3"],QX=({data:n,className:e})=>{var b,S;const[t,i]=me.useState("bar"),[r,s]=me.useState({type:"bar",title:"Data Visualization",x_axis:((b=n.columns[0])==null?void 0:b.name)||"",y_axis:((S=n.columns[1])==null?void 0:S.name)||"",color_scheme:_X,show_legend:!0,show_grid:!0}),[o,a]=me.useState(!1);me.useEffect(()=>{if(n.columns.length>=2){const w=n.columns.filter(Q=>Q.type.includes("int")||Q.type.includes("float")||Q.type.includes("decimal")||Q.type.includes("numeric")),C=n.columns.filter(Q=>Q.type.includes("date")||Q.type.includes("time")),_=n.columns.filter(Q=>Q.type.includes("text")||Q.type.includes("varchar")||Q.type.includes("char"));let P={...r};C.length>0&&w.length>0?(i("line"),P.type="line",P.x_axis=C[0].name,P.y_axis=w[0].name):_.length>0&&w.length>0?(i("bar"),P.type="bar",P.x_axis=_[0].name,P.y_axis=w[0].name):w.length>=2&&(i("scatter"),P.type="scatter",P.x_axis=w[0].name,P.y_axis=w[1].name),s(P)}},[n]);const c=w=>{const C=n.columns.find(_=>_.name===w);return C&&(C.type.includes("int")||C.type.includes("float")||C.type.includes("decimal")||C.type.includes("numeric"))},h=()=>{if(!n.rows.length)return null;const w=n.rows.map(_=>_[r.x_axis]),C=n.rows.map(_=>_[r.y_axis]);switch(t){case"line":case"bar":return{labels:w,datasets:[{label:r.y_axis,data:C,backgroundColor:t==="bar"?r.color_scheme[0]+"80":"transparent",borderColor:r.color_scheme[0],borderWidth:2,fill:t!=="line",tension:t==="line"?.4:0,pointBackgroundColor:r.color_scheme[0],pointBorderColor:r.color_scheme[0],pointRadius:t==="line"?4:0}]};case"scatter":return{datasets:[{label:`${r.x_axis} vs ${r.y_axis}`,data:n.rows.map($=>({x:$[r.x_axis],y:$[r.y_axis]})),backgroundColor:r.color_scheme[0]+"80",borderColor:r.color_scheme[0],borderWidth:2,pointRadius:5}]};case"pie":case"doughnut":const _=n.rows.reduce(($,M)=>{const Z=M[r.x_axis];return $[Z]||($[Z]=0),$[Z]+=Number.parseFloat(M[r.y_axis])||0,$},{}),P=Object.keys(_),Q=Object.values(_);return{labels:P,datasets:[{data:Q,backgroundColor:r.color_scheme.slice(0,P.length),borderColor:"#1e1e1e",borderWidth:2}]};default:return null}},f=()=>({responsive:!0,maintainAspectRatio:!1,plugins:{title:{display:!!r.title,text:r.title,color:"#ffffff",font:{size:16,weight:"bold"}},legend:{display:r.show_legend,labels:{color:"#ffffff"}},tooltip:{backgroundColor:"#2d2d2d",titleColor:"#ffffff",bodyColor:"#cccccc",borderColor:"#3c3c3c",borderWidth:1}},scales:t!=="pie"&&t!=="doughnut"?{x:{title:{display:!0,text:r.x_axis,color:"#ffffff"},ticks:{color:"#cccccc",maxRotation:45},grid:{display:r.show_grid,color:"#3c3c3c"}},y:{title:{display:!0,text:r.y_axis,color:"#ffffff"},ticks:{color:"#cccccc"},grid:{display:r.show_grid,color:"#3c3c3c"}}}:void 0}),p=h(),m=()=>{if(!p)return null;const w=f();switch(t){case"line":return E.jsx(gX,{data:p,options:w});case"bar":return E.jsx(mX,{data:p,options:w});case"pie":return E.jsx(yX,{data:p,options:w});case"scatter":return E.jsx(xX,{data:p,options:w});case"doughnut":return E.jsx(OX,{data:p,options:w});default:return null}};if(n.rows.length===0)return E.jsx(tk,{className:e,children:E.jsxs(PX,{children:[E.jsx("div",{className:"icon",children:"📊"}),E.jsx("div",{className:"message",children:"No Data to Visualize"}),E.jsx("div",{className:"submessage",children:"Execute a query that returns data to create visualizations"})]})});const y=n.columns.filter(w=>c(w.name)),v=n.columns.filter(w=>!c(w.name));return E.jsxs(tk,{className:e,children:[E.jsxs(vX,{children:[E.jsx(bX,{children:"Data Visualization"}),E.jsxs(SX,{children:[E.jsx(So,{active:t==="line",onClick:()=>i("line"),disabled:y.length===0,children:"📈 Line"}),E.jsx(So,{active:t==="bar",onClick:()=>i("bar"),children:"📊 Bar"}),E.jsx(So,{active:t==="pie",onClick:()=>i("pie"),children:"🥧 Pie"}),E.jsx(So,{active:t==="scatter",onClick:()=>i("scatter"),disabled:y.length<2,children:"⚫ Scatter"}),E.jsx(So,{active:t==="doughnut",onClick:()=>i("doughnut"),children:"🍩 Doughnut"}),E.jsx(So,{onClick:()=>a(!o),children:"⚙️ Config"})]})]}),E.jsxs(wX,{children:[m(),E.jsxs(kX,{show:o,children:[E.jsx("h4",{children:"Chart Configuration"}),E.jsxs("div",{className:"config-group",children:[E.jsx("label",{htmlFor:"chart-title",children:"Title"}),E.jsx("input",{id:"chart-title",type:"text",value:r.title,onChange:w=>s({...r,title:w.target.value}),style:{width:"100%",padding:"6px 8px",background:"#3c3c3c",border:"1px solid #5a5a5a",borderRadius:"4px",color:"#ffffff",fontSize:"12px"}})]}),E.jsxs("div",{className:"config-group",children:[E.jsx("label",{htmlFor:"chart-x-axis",children:"X Axis"}),E.jsx(nk,{id:"chart-x-axis",value:r.x_axis,onChange:w=>s({...r,x_axis:w.target.value}),children:n.columns.map(w=>E.jsxs("option",{value:w.name,children:[w.name," (",w.type,")"]},w.name))})]}),E.jsxs("div",{className:"config-group",children:[E.jsx("label",{htmlFor:"chart-y-axis",children:"Y Axis"}),E.jsx(nk,{id:"chart-y-axis",value:r.y_axis,onChange:w=>s({...r,y_axis:w.target.value}),children:n.columns.map(w=>E.jsxs("option",{value:w.name,children:[w.name," (",w.type,")"]},w.name))})]}),E.jsx("div",{className:"config-group",children:E.jsxs("label",{children:[E.jsx("input",{type:"checkbox",checked:r.show_legend,onChange:w=>s({...r,show_legend:w.target.checked}),style:{marginRight:"8px"}}),E.jsx("span",{children:"Show Legend"})]})}),E.jsx("div",{className:"config-group",children:E.jsxs("label",{children:[E.jsx("input",{type:"checkbox",checked:r.show_grid,onChange:w=>s({...r,show_grid:w.target.checked}),style:{marginRight:"8px"}}),E.jsx("span",{children:"Show Grid"})]})}),E.jsxs("div",{className:"config-group",children:[E.jsx("h5",{style:{margin:"0 0 8px 0",fontSize:"12px",color:"#ffffff"},children:"Data Summary"}),E.jsxs("div",{style:{fontSize:"11px",color:"#888888"},children:["Rows: ",n.rows.length,E.jsx("br",{}),"Columns: ",n.columns.length,E.jsx("br",{}),"Numeric: ",y.length,E.jsx("br",{}),"Categorical: ",v.length]})]})]})]})]})},CX=de.div` - display: flex; - flex-direction: column; - height: 100%; - background: #1e1e1e; - border: 1px solid #3c3c3c; - border-radius: 8px; - overflow: hidden; -`,TX=de.div` - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - border-bottom: 1px solid #3c3c3c; - background: #2d2d2d; -`,$X=de.h3` - margin: 0; - color: #ffffff; - font-size: 16px; - font-weight: 600; -`,MX=de.div` - display: flex; - gap: 8px; - align-items: center; -`,RX=de.button` - padding: 4px 12px; - background: ${n=>n.active?"#0078d4":"transparent"}; - border: 1px solid ${n=>n.active?"#0078d4":"#5a5a5a"}; - border-radius: 4px; - color: ${n=>n.active?"white":"#cccccc"}; - font-size: 12px; - cursor: pointer; - transition: all 0.2s; - - &:hover { - background: ${n=>n.active?"#106ebe":"#3c3c3c"}; - color: white; - } -`,AX=de.div` - flex: 1; - overflow-y: auto; - padding: 16px; -`,EX=de.div` - margin-bottom: 24px; - - &:last-child { - margin-bottom: 0; - } -`,LX=de.h4` - margin: 0 0 12px 0; - color: #0078d4; - font-size: 14px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; -`,DX=de.div` - background: #2d2d2d; - border: 1px solid #3c3c3c; - border-radius: 6px; - padding: 16px; - margin-bottom: 12px; - cursor: pointer; - transition: all 0.2s; - - &:hover { - background: #333333; - border-color: #0078d4; - transform: translateY(-1px); - } - - &:last-child { - margin-bottom: 0; - } -`,zX=de.div` - display: flex; - align-items: flex-start; - justify-content: space-between; - margin-bottom: 8px; -`,ZX=de.h5` - margin: 0; - color: #ffffff; - font-size: 14px; - font-weight: 600; -`,IX=de.span` - padding: 2px 8px; - border-radius: 12px; - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - background: ${n=>{switch(n.type){case"OrbitQL":return"#107c10";case"SQL":return"#0078d4";case"Redis":return"#d83b01";default:return"#5a5a5a"}}}; - color: white; -`,jX=de.p` - margin: 0 0 12px 0; - color: #cccccc; - font-size: 13px; - line-height: 1.4; -`,BX=de.code` - display: block; - background: #1e1e1e; - border: 1px solid #3c3c3c; - border-radius: 4px; - padding: 8px; - color: #e6e6e6; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 11px; - line-height: 1.4; - overflow-x: auto; - white-space: pre; -`,NX=de.div` - display: flex; - flex-wrap: wrap; - gap: 4px; - margin-top: 8px; -`,XX=de.span` - padding: 2px 6px; - background: #3c3c3c; - border-radius: 10px; - color: #cccccc; - font-size: 10px; -`,WX=[{id:"pg-basic-select",name:"Basic SELECT Query",description:"Simple data retrieval from a table",category:"PostgreSQL - Basic",queryType:$e.SQL,query:`-- Basic SELECT query -SELECT id, name, email, created_at -FROM users -WHERE active = true -ORDER BY created_at DESC -LIMIT 10;`,tags:["select","basic","postgresql"]},{id:"pg-joins",name:"JOIN Operations",description:"Complex query with multiple table joins",category:"PostgreSQL - Basic",queryType:$e.SQL,query:`-- JOIN query with multiple tables -SELECT - u.name, - p.title as product_name, - o.order_date, - o.total_amount -FROM users u -JOIN orders o ON u.id = o.user_id -JOIN products p ON o.product_id = p.id -WHERE o.order_date >= CURRENT_DATE - INTERVAL '30 days' -ORDER BY o.order_date DESC;`,tags:["join","complex","postgresql"]},{id:"pg-aggregations",name:"Aggregation Functions",description:"Using GROUP BY with aggregate functions",category:"PostgreSQL - Basic",queryType:$e.SQL,query:`-- Aggregation and grouping -SELECT - EXTRACT(YEAR FROM order_date) as year, - EXTRACT(MONTH FROM order_date) as month, - COUNT(*) as total_orders, - SUM(total_amount) as revenue, - AVG(total_amount) as avg_order_value -FROM orders -WHERE order_date >= '2024-01-01' -GROUP BY year, month -ORDER BY year DESC, month DESC;`,tags:["aggregation","group-by","postgresql"]},{id:"orbitql-xgboost",name:"XGBoost Classification",description:"Train and use XGBoost model for binary classification",category:"OrbitQL - ML Boosting",queryType:$e.OrbitQL,query:`-- XGBoost for loan approval prediction -SELECT - ML_XGBOOST( - ARRAY[age, income, credit_score, debt_ratio], - loan_approved, - '{"max_depth": 6, "learning_rate": 0.1, "n_estimators": 100}' - ) as model_performance -FROM loan_applications -WHERE training_set = true;`,tags:["xgboost","classification","ml","orbitql"]},{id:"orbitql-lightgbm",name:"LightGBM Regression",description:"House price prediction using LightGBM",category:"OrbitQL - ML Boosting",queryType:$e.OrbitQL,query:`-- LightGBM for house price prediction -SELECT - ML_LIGHTGBM( - ARRAY[bedrooms, bathrooms, sqft, lot_size, year_built], - price, - '{"objective": "regression", "metric": "rmse", "boosting_type": "gbdt"}' - ) as price_model -FROM real_estate_data -WHERE split_type = 'train';`,tags:["lightgbm","regression","ml","orbitql"]},{id:"orbitql-catboost",name:"CatBoost with Categorical Features",description:"Customer churn prediction with categorical data",category:"OrbitQL - ML Boosting",queryType:$e.OrbitQL,query:`-- CatBoost for customer churn prediction -SELECT - ML_CATBOOST( - ARRAY[tenure, monthly_charges, contract_type, payment_method, tech_support], - churned, - '{"iterations": 1000, "depth": 6, "cat_features": [2, 3, 4]}' - ) as churn_model -FROM customer_data -WHERE dataset_split = 'training';`,tags:["catboost","categorical","churn","orbitql"]},{id:"orbitql-adaboost",name:"AdaBoost Ensemble",description:"Fraud detection using AdaBoost algorithm",category:"OrbitQL - ML Boosting",queryType:$e.OrbitQL,query:`-- AdaBoost for fraud detection -SELECT - ML_ADABOOST( - ARRAY[transaction_amount, merchant_category, hour_of_day, day_of_week], - is_fraud, - '{"n_estimators": 50, "learning_rate": 1, "algorithm": "SAMME.R"}' - ) as fraud_model -FROM transaction_history -WHERE labeled = true;`,tags:["adaboost","fraud-detection","ml","orbitql"]},{id:"orbitql-train-model",name:"Model Training & Management",description:"Train and save a named ML model",category:"OrbitQL - ML Management",queryType:$e.OrbitQL,query:`-- Train and save a named model -SELECT - ML_TRAIN_MODEL( - 'customer_lifetime_value_v1', - 'XGBOOST', - ARRAY[age, total_purchases, avg_order_value, days_since_last_order], - lifetime_value, - '{"max_depth": 8, "learning_rate": 0.05, "n_estimators": 200}' - ) as training_result -FROM customer_analytics -WHERE data_quality_score > 0.8;`,tags:["model-training","ml-ops","orbitql"]},{id:"orbitql-predict",name:"Model Prediction",description:"Make predictions using a trained model",category:"OrbitQL - ML Management",queryType:$e.OrbitQL,query:`-- Make predictions with trained model -SELECT - customer_id, - ML_PREDICT( - 'customer_lifetime_value_v1', - ARRAY[age, total_purchases, avg_order_value, days_since_last_order] - ) as predicted_clv, - ML_PREDICT_PROBA( - 'customer_lifetime_value_v1', - ARRAY[age, total_purchases, avg_order_value, days_since_last_order] - ) as prediction_confidence -FROM customers -WHERE prediction_needed = true;`,tags:["prediction","ml-inference","orbitql"]},{id:"orbitql-evaluate",name:"Model Evaluation",description:"Evaluate model performance on test data",category:"OrbitQL - ML Management",queryType:$e.OrbitQL,query:`-- Evaluate model performance -SELECT - ML_EVALUATE_MODEL( - 'customer_churn_v2', - ARRAY[tenure, monthly_charges, contract_type], - actual_churn, - '{"metrics": ["accuracy", "precision", "recall", "f1", "auc"]}' - ) as model_metrics -FROM customer_test_data -WHERE evaluation_set = true;`,tags:["evaluation","metrics","ml-ops","orbitql"]},{id:"orbitql-feature-importance",name:"Feature Importance Analysis",description:"Analyze which features matter most in your model",category:"OrbitQL - ML Analysis",queryType:$e.OrbitQL,query:`-- Get feature importance from trained model -SELECT - ML_FEATURE_IMPORTANCE('loan_approval_model_v3') as feature_analysis, - ML_MODEL_INFO('loan_approval_model_v3') as model_metadata;`,tags:["feature-importance","analysis","explainability","orbitql"]},{id:"redis-basic-ops",name:"Basic Key-Value Operations",description:"Fundamental Redis operations - SET, GET, DEL",category:"Redis - Basic Operations",queryType:$e.Redis,query:`SET user:1000:name "John Doe" -SET user:1000:email "john@example.com" -SET user:1000:last_login "2024-01-15T10:30:00Z" -GET user:1000:name -EXISTS user:1000:email -DEL user:1000:temp`,tags:["set","get","basic","redis"]},{id:"redis-lists",name:"List Operations",description:"Working with Redis lists - queues and stacks",category:"Redis - Data Structures",queryType:$e.Redis,query:`LPUSH recent_orders "order:5001" "order:5002" -RPUSH pending_tasks "process_payment" "send_email" -LRANGE recent_orders 0 10 -LPOP pending_tasks -LLEN recent_orders -LTRIM recent_orders 0 99`,tags:["lists","queue","stack","redis"]},{id:"redis-sets",name:"Set Operations",description:"Unique collections and set operations",category:"Redis - Data Structures",queryType:$e.Redis,query:`SADD active_users "user:123" "user:456" "user:789" -SADD premium_users "user:123" "user:999" -SISMEMBER active_users "user:123" -SINTER active_users premium_users -SUNION active_users premium_users -SCARD active_users`,tags:["sets","intersection","union","redis"]},{id:"redis-hashes",name:"Hash Operations",description:"Object-like data structures in Redis",category:"Redis - Data Structures",queryType:$e.Redis,query:`HSET product:1001 name "Gaming Laptop" price 1299.99 category "Electronics" -HGET product:1001 name -HMGET product:1001 name price -HGETALL product:1001 -HINCRBY product:1001 views 1 -HDEL product:1001 temp_field`,tags:["hash","object","increment","redis"]},{id:"redis-sorted-sets",name:"Sorted Set Operations",description:"Ranked data structures and leaderboards",category:"Redis - Data Structures",queryType:$e.Redis,query:`ZADD leaderboard 1500 "player:alice" 1200 "player:bob" 1800 "player:carol" -ZRANGE leaderboard 0 2 WITHSCORES -ZREVRANGE leaderboard 0 2 WITHSCORES -ZRANK leaderboard "player:bob" -ZINCRBY leaderboard 50 "player:bob" -ZCOUNT leaderboard 1000 2000`,tags:["sorted-sets","leaderboard","ranking","redis"]},{id:"redis-expiration",name:"Key Expiration and TTL",description:"Managing key lifetimes and cache expiration",category:"Redis - Advanced",queryType:$e.Redis,query:`SET session:abc123 "user_data" EX 3600 -SETEX cache:api_response 300 "cached_json_data" -TTL session:abc123 -EXPIRE user:temp 1800 -PERSIST important_key -PTTL cache:api_response`,tags:["expiration","ttl","cache","redis"]},{id:"redis-pub-sub",name:"Pub/Sub Messaging",description:"Real-time messaging and event publishing",category:"Redis - Advanced",queryType:$e.Redis,query:`SUBSCRIBE notifications -SUBSCRIBE user:*:updates -PUBLISH notifications "Server maintenance in 10 minutes" -PUBLISH user:123:updates "New message received" -PSUBSCRIBE order:* -UNSUBSCRIBE notifications`,tags:["pubsub","messaging","events","redis"]},{id:"redis-transactions",name:"Transactions and Atomicity",description:"Atomic operations using MULTI/EXEC",category:"Redis - Advanced",queryType:$e.Redis,query:`MULTI -INCR counter:page_views -SADD unique_visitors "192.168.1.100" -ZADD hourly_stats 1 "2024-01-15:14" -EXEC - -WATCH important_counter -MULTI -GET important_counter -INCR important_counter -EXEC`,tags:["transactions","atomic","multi-exec","redis"]}],VX=({onSelectQuery:n,className:e})=>{const[t,i]=me.useState("all"),r=["all","PostgreSQL","OrbitQL","Redis"],o=WX.filter(c=>t==="all"?!0:c.category.includes(t)).reduce((c,h)=>(c[h.category]||(c[h.category]=[]),c[h.category].push(h),c),{}),a=c=>{n(c.query,c.queryType)};return E.jsxs(CX,{className:e,children:[E.jsxs(TX,{children:[E.jsx($X,{children:"📚 Query Examples"}),E.jsx(MX,{children:r.map(c=>E.jsx(RX,{active:t===c,onClick:()=>i(c),children:c},c))})]}),E.jsx(AX,{children:Object.entries(o).map(([c,h])=>E.jsxs(EX,{children:[E.jsx(LX,{children:c}),h.map(f=>E.jsxs(DX,{onClick:()=>a(f),children:[E.jsxs(zX,{children:[E.jsx(ZX,{children:f.name}),E.jsx(IX,{type:f.queryType,children:f.queryType})]}),E.jsx(jX,{children:f.description}),E.jsxs(BX,{children:[f.query.split(` -`).slice(0,3).join(` -`),f.query.split(` -`).length>3?` -...`:""]}),E.jsx(NX,{children:f.tags.map(p=>E.jsx(XX,{children:p},p))})]},f.id))]},c))})]})},FX=hM` - * { - margin: 0; - padding: 0; - box-sizing: border-box; - } - - body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; - background: #1e1e1e; - color: #ffffff; - overflow: hidden; - height: 100vh; - } - - #root { - width: 100vw; - height: 100vh; - display: flex; - flex-direction: column; - } - - /* React Tabs Styling */ - .react-tabs { - height: 100%; - display: flex; - flex-direction: column; - } - - .react-tabs__tab-list { - margin: 0; - padding: 0; - border-bottom: 1px solid #3c3c3c; - background: #2d2d2d; - display: flex; - } - - .react-tabs__tab { - display: flex; - align-items: center; - padding: 8px 12px; - background: none; - border: none; - color: #cccccc; - cursor: pointer; - font-size: 13px; - border-bottom: 2px solid transparent; - transition: all 0.2s; - gap: 6px; - } - - .react-tabs__tab:hover { - color: #ffffff; - background: #3c3c3c; - } - - .react-tabs__tab--selected { - color: #0078d4; - border-bottom-color: #0078d4; - background: #2d2d2d; - } - - .react-tabs__tab-panel { - flex: 1; - display: flex; - flex-direction: column; - } - - .react-tabs__tab-panel--selected { - display: flex; - } - - /* Split Pane Styling */ - .split { - display: flex; - height: 100%; - } - - .split.split-horizontal { - flex-direction: row; - } - - .split.split-vertical { - flex-direction: column; - } - - .gutter { - background: #3c3c3c; - background-repeat: no-repeat; - background-position: 50%; - } - - .gutter.gutter-horizontal { - cursor: ew-resize; - width: 4px; - } - - .gutter.gutter-vertical { - cursor: ns-resize; - height: 4px; - } - - /* Scrollbar styling */ - ::-webkit-scrollbar { - width: 8px; - height: 8px; - } - - ::-webkit-scrollbar-track { - background: #2d2d2d; - } - - ::-webkit-scrollbar-thumb { - background: #5a5a5a; - border-radius: 4px; - } - - ::-webkit-scrollbar-thumb:hover { - background: #6a6a6a; - } -`,YX={name:"dark",primary:"#0078d4",secondary:"#107c10",background:"#1e1e1e",surface:"#2d2d2d",text:"#ffffff",textSecondary:"#cccccc",border:"#3c3c3c",error:"#d13438",warning:"#ff8c00",success:"#107c10",info:"#0078d4"},qX=de.div` - height: 100vh; - display: flex; - flex-direction: column; - background: ${n=>n.theme.background}; - color: ${n=>n.theme.text}; -`,UX=de.div` - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 16px; - background: ${n=>n.theme.surface}; - border-bottom: 1px solid ${n=>n.theme.border}; - min-height: 48px; -`,HX=de.div` - display: flex; - align-items: center; - gap: 8px; - font-weight: 600; - font-size: 16px; - - .icon { - width: 24px; - height: 24px; - background: linear-gradient(45deg, #0078d4, #107c10); - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - color: white; - font-size: 12px; - } -`,GX=de.div` - display: flex; - align-items: center; - gap: 12px; - font-size: 13px; -`,KX=de.div` - width: 8px; - height: 8px; - border-radius: 50%; - background: ${n=>n.connected?"#107c10":"#d13438"}; -`,JX=de.select` - padding: 4px 8px; - background: #3c3c3c; - border: 1px solid #5a5a5a; - border-radius: 4px; - color: #ffffff; - font-size: 13px; - outline: none; - min-width: 200px; - - &:focus { - border-color: #0078d4; - } - - option { - background: #3c3c3c; - color: #ffffff; - } -`,ik=de.button` - padding: 6px 12px; - border: none; - border-radius: 4px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - display: flex; - align-items: center; - gap: 4px; - transition: all 0.2s; - - ${n=>n.variant==="primary"?` - background: ${e=>e.theme.primary}; - color: white; - - &:hover:not(:disabled) { - background: #106ebe; - } - `:` - background: #3c3c3c; - color: #ffffff; - - &:hover:not(:disabled) { - background: #484848; - } - `} - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } -`,eW=de.div` - flex: 1; - display: flex; - overflow: hidden; -`,tW=de.button` - background: none; - border: none; - color: #888888; - cursor: pointer; - padding: 2px; - margin-left: 4px; - border-radius: 2px; - font-size: 12px; - transition: all 0.2s; - - &:hover { - background: #d13438; - color: white; - } -`,nW=de.div` - display: flex; - flex-direction: column; - height: 100%; -`,iW=de.div` - display: flex; - border-bottom: 1px solid #3c3c3c; - background: #2d2d2d; -`,am=de.button` - padding: 8px 16px; - background: none; - border: none; - color: ${n=>n.active?"#0078d4":"#cccccc"}; - cursor: pointer; - font-size: 13px; - border-bottom: ${n=>n.active?"2px solid #0078d4":"2px solid transparent"}; - transition: all 0.2s; - - &:hover { - color: ${n=>n.active?"#0078d4":"#ffffff"}; - } -`,rW=de.div` - flex: 1; - overflow: auto; -`,sW=()=>{var Z;const[n,e]=me.useState([]),[t,i]=me.useState(null),[r,s]=me.useState([{id:"1",name:"Query 1",query:`-- Welcome to Orbit Desktop! --- Try some OrbitQL with ML functions: - -SELECT ML_XGBOOST( - ARRAY[age, income, credit_score], - loan_approved -) as model_accuracy -FROM loan_applications;`,query_type:$e.OrbitQL,unsaved_changes:!1,is_executing:!1}]),[o,a]=me.useState(0),[c,h]=me.useState("table"),[f,p]=me.useState("samples"),[m,y]=me.useState(null);me.useEffect(()=>{v(),typeof globalThis.window<"u"&&(!globalThis.window.__TAURI_IPC__||typeof globalThis.window.__TAURI_IPC__!="function")&&console.log("🌐 Running in browser mode with mock data. For full functionality, run as Tauri desktop app.")},[]);const v=async()=>{try{const j=await zo.getConnections();e(j);const Y=j.find(W=>W.status==="Connected");Y&&!t&&i(Y)}catch(j){console.error("Failed to load connections:",j)}},b=(j=$e.OrbitQL)=>{const Y={id:Date.now().toString(),name:`Query ${r.length+1}`,query:j===$e.Redis?"PING":"SELECT 1;",query_type:j,unsaved_changes:!1,is_executing:!1};s([...r,Y]),a(r.length)},S=j=>{if(r.length<=1)return;const Y=r.filter((W,F)=>F!==j);s(Y),o>=Y.length?a(Y.length-1):o>j&&a(o-1)},w=(j,Y)=>{const W=[...r];W[j]={...W[j],query:Y,unsaved_changes:!0},s(W)},C=async j=>{if(!t){y("Please select a connection first");return}const Y=r[o];if(!Y)return;const W=[...r];W[o]={...W[o],is_executing:!0,unsaved_changes:!1},s(W),y(null);try{const F={connection_id:t.id,query:j,query_type:Y.query_type,timeout:3e4},ie=await zo.executeQuery(F),oe=[...r];oe[o]={...oe[o],result:ie,is_executing:!1},s(oe),ie.data&&ie.data.rows.length>0&&(ie.data.columns.filter(se=>se.type.includes("int")||se.type.includes("float")||se.type.includes("decimal")).length>0&&ie.data.rows.length>1?h("chart"):h("table"))}catch(F){const ie=RS(F);y(ie);const oe=[...r];oe[o]={...oe[o],is_executing:!1},s(oe)}},_=async j=>{if(!t){y("Please select a connection first");return}try{const Y={connection_id:t.id,query:`EXPLAIN ANALYZE ${j}`,query_type:$e.SQL},W=await zo.explainQuery(Y),F=[...r];F[o]={...F[o],result:W},s(F),h("table")}catch(Y){y(RS(Y))}},P=j=>{const Y=n.find(W=>W.id===j);i(Y||null)},Q=(j,Y)=>{const W={id:Date.now().toString(),name:`Sample ${r.length+1}`,query:j,query_type:Y,unsaved_changes:!1,is_executing:!1};s([...r,W]),a(r.length)},$=r[o],M=((Z=$==null?void 0:$.result)==null?void 0:Z.success)&&$.result.data;return E.jsxs(aM,{theme:YX,children:[E.jsx(FX,{}),E.jsxs(qX,{children:[E.jsxs(UX,{children:[E.jsxs(HX,{children:[E.jsx("div",{className:"icon",children:"🌌"}),"Orbit Desktop"]}),E.jsxs(GX,{children:[E.jsx(KX,{connected:!!t}),E.jsxs(JX,{value:(t==null?void 0:t.id)||"",onChange:j=>P(j.target.value),children:[E.jsx("option",{value:"",children:"Select Connection..."}),n.map(j=>E.jsxs("option",{value:j.id,children:[j.info.name," (",j.info.connection_type,")"]},j.id))]}),E.jsx(ik,{onClick:()=>b(),children:"+ New Query"}),E.jsx(ik,{onClick:()=>b($e.Redis),children:"+ Redis"})]})]}),E.jsx(eW,{children:E.jsxs(Vh,{sizes:[60,40],direction:"horizontal",className:"split",children:[E.jsx("div",{style:{display:"flex",flexDirection:"column"},children:E.jsxs(Lk,{selectedIndex:o,onSelect:a,children:[E.jsx(Dk,{children:r.map((j,Y)=>E.jsxs(zk,{children:[E.jsx("span",{children:j.name}),j.unsaved_changes&&E.jsx("span",{style:{color:"#ff8c00"},children:"●"}),r.length>1&&E.jsx(tW,{onClick:W=>{W.stopPropagation(),S(Y)},children:"×"})]},j.id))}),r.map((j,Y)=>{var W,F,ie,oe,re;return E.jsx(Zk,{children:E.jsxs(Vh,{sizes:[50,50],direction:"vertical",className:"split",children:[E.jsx(cI,{value:j.query,onChange:se=>w(Y,se),queryType:j.query_type,onExecute:C,onExplain:_,isExecuting:j.is_executing,connection:t}),E.jsxs(nW,{children:[E.jsxs(iW,{children:[E.jsx(am,{active:c==="table",onClick:()=>h("table"),children:"📋 Results"}),M&&E.jsx(am,{active:c==="chart",onClick:()=>h("chart"),children:"📊 Chart"}),E.jsx(am,{active:c==="models",onClick:()=>h("models"),children:"🤖 Models"})]}),E.jsxs(rW,{children:[m&&E.jsx("div",{style:{padding:"16px",background:"rgba(209, 52, 56, 0.1)",color:"#d13438",border:"1px solid rgba(209, 52, 56, 0.3)",margin:"16px",borderRadius:"4px"},children:m}),c==="table"&&($==null?void 0:$.result)&&E.jsx("div",{style:{padding:"16px"},children:$.result.success?$.result.data?E.jsxs("div",{children:[E.jsxs("div",{style:{marginBottom:"12px",color:"#cccccc",fontSize:"13px"},children:["Execution time: ",((F=(W=$.result)==null?void 0:W.execution_time)==null?void 0:F.toFixed(2))||0,"ms",((ie=$.result)==null?void 0:ie.rows_affected)&&E.jsxs(E.Fragment,{children:[" • Rows affected: ",$.result.rows_affected]})]}),E.jsx("div",{style:{overflow:"auto",maxHeight:"400px"},children:E.jsxs("table",{style:{width:"100%",borderCollapse:"collapse",fontSize:"13px"},children:[E.jsx("thead",{children:E.jsx("tr",{style:{background:"#2d2d2d"},children:(re=(oe=$.result)==null?void 0:oe.data)==null?void 0:re.columns.map(se=>E.jsxs("th",{style:{padding:"8px 12px",textAlign:"left",borderBottom:"1px solid #3c3c3c",fontWeight:"600"},children:[se.name,E.jsx("div",{style:{fontSize:"10px",color:"#888888",fontWeight:"normal"},children:se.type})]},se.name))})}),E.jsx("tbody",{children:$.result.data.rows.map((se,ue)=>E.jsx("tr",{style:{background:ue%2===0?"#1e1e1e":"#252525"},children:$.result.data.columns.map(q=>{var U;return E.jsx("td",{style:{padding:"8px 12px",borderBottom:"1px solid #3c3c3c"},children:String((U=se[q.name])!=null?U:"NULL")},q.name)})},ue))})]})})]}):E.jsx("div",{style:{color:"#cccccc"},children:"Query executed successfully"}):E.jsxs("div",{style:{color:"#d13438"},children:["Error: ",$.result.error]})}),c==="chart"&&M&&E.jsx(QX,{data:$.result.data}),c==="models"&&E.jsx(LS,{connection:t})]})]})]})},j.id)})]})}),E.jsxs("div",{style:{background:"#1e1e1e",borderLeft:"1px solid #3c3c3c",display:"flex",flexDirection:"column"},children:[E.jsxs("div",{style:{display:"flex",borderBottom:"1px solid #3c3c3c",background:"#2d2d2d"},children:[E.jsx("button",{style:{padding:"8px 16px",background:f==="samples"?"#0078d4":"transparent",border:"none",color:f==="samples"?"white":"#cccccc",cursor:"pointer",fontSize:"13px",borderBottom:f==="samples"?"2px solid #0078d4":"2px solid transparent"},onClick:()=>p("samples"),children:"📚 Sample Queries"}),E.jsx("button",{style:{padding:"8px 16px",background:f==="models"?"#0078d4":"transparent",border:"none",color:f==="models"?"white":"#cccccc",cursor:"pointer",fontSize:"13px",borderBottom:f==="models"?"2px solid #0078d4":"2px solid transparent"},onClick:()=>p("models"),children:"🤖 ML Models"})]}),E.jsxs("div",{style:{flex:1,overflow:"hidden"},children:[f==="samples"&&E.jsx(VX,{onSelectQuery:Q}),f==="models"&&E.jsx(LS,{connection:t})]})]})]})})]})]})};document.addEventListener("contextmenu",n=>n.preventDefault());document.addEventListener("dragover",n=>n.preventDefault());document.addEventListener("drop",n=>n.preventDefault());c2.createRoot(document.getElementById("root")).render(E.jsx(dt.StrictMode,{children:E.jsx(sW,{})})); diff --git a/orbit/desktop/dist/index.html b/orbit/desktop/dist/index.html index 658b7744..0111236f 100644 --- a/orbit/desktop/dist/index.html +++ b/orbit/desktop/dist/index.html @@ -50,7 +50,7 @@ 100% { transform: rotate(360deg); } } - + diff --git a/orbit/desktop/package-lock.json b/orbit/desktop/package-lock.json index f15fe516..6325c8f3 100644 --- a/orbit/desktop/package-lock.json +++ b/orbit/desktop/package-lock.json @@ -44,7 +44,8 @@ "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "typescript": "^5.2.2", - "vite": "^7.2.2" + "vite": "^7.2.2", + "vitest": "^4.1.10" }, "engines": { "node": ">=18.0.0" @@ -81,7 +82,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1601,6 +1601,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tauri-apps/api": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-1.6.0.tgz", @@ -1863,6 +1870,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1890,7 +1915,6 @@ "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1971,7 +1995,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -2156,13 +2179,125 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2378,6 +2513,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -2464,7 +2609,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -2569,6 +2713,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2604,7 +2758,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -2835,7 +2988,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.21.0" }, @@ -3080,6 +3232,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3212,7 +3371,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3464,6 +3622,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3474,6 +3642,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4666,6 +4844,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4862,6 +5050,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -5000,6 +5202,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5120,7 +5329,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5143,7 +5351,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5693,6 +5900,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -5724,6 +5938,20 @@ "integrity": "sha512-mPTnGCiS/RiuTNsVhCm9De9cCAUsrNFFviRbADdKiiV+Kk8HKp/0fWu7Kr8pi3/yBmsqLFHuXGT9UUZ+CNLwFw==", "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -5959,6 +6187,23 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6000,7 +6245,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6008,6 +6252,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6161,7 +6415,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6236,7 +6489,6 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -6330,7 +6582,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6367,6 +6618,109 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -6478,6 +6832,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/orbit/desktop/package.json b/orbit/desktop/package.json index 234802ce..1d983171 100644 --- a/orbit/desktop/package.json +++ b/orbit/desktop/package.json @@ -11,6 +11,8 @@ "build": "vite build && tauri build", "preview": "vite preview", "lint": "eslint src --ext js,jsx,ts,tsx", + "typecheck": "tsc --noEmit", + "test": "vitest run", "tauri": "tauri" }, "dependencies": { @@ -50,9 +52,10 @@ "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "typescript": "^5.2.2", - "vite": "^7.2.2" + "vite": "^7.2.2", + "vitest": "^4.1.10" }, "engines": { "node": ">=18.0.0" } -} \ No newline at end of file +} diff --git a/orbit/desktop/src-tauri/Cargo.lock b/orbit/desktop/src-tauri/Cargo.lock index 742b5411..0a954196 100644 --- a/orbit/desktop/src-tauri/Cargo.lock +++ b/orbit/desktop/src-tauri/Cargo.lock @@ -3054,12 +3054,14 @@ version = "0.1.0" dependencies = [ "aes-gcm", "anyhow", + "async-trait", "base64 0.22.1", "chrono", "mysql_async", "rand 0.8.5", "redis", "reqwest 0.12.28", + "rust_decimal", "serde", "serde_json", "sqlparser", @@ -4079,6 +4081,7 @@ dependencies = [ "borsh", "bytes", "num-traits", + "postgres-types", "rand 0.8.5", "rkyv", "serde", diff --git a/orbit/desktop/src-tauri/src/cluster.rs b/orbit/desktop/src-tauri/src/cluster.rs index 6ccc2144..64783707 100644 --- a/orbit/desktop/src-tauri/src/cluster.rs +++ b/orbit/desktop/src-tauri/src/cluster.rs @@ -114,6 +114,7 @@ pub struct ClusterStatus { } /// Locates the repository and drives `start-cluster.sh`. +#[derive(Debug)] pub struct ClusterManager { root: PathBuf, } @@ -552,3 +553,159 @@ mod tests { assert!(matches!(error, ClusterError::NotARepository(..))); } } + +/// Tests that need the real repository and real processes. +/// +/// These verify the parts that cannot be checked from a unit test: that a pid +/// file written on disk is discovered, that `ps` on this machine reports the +/// process, and that a port with a real listener behind it probes as reachable +/// while a port without one does not. +/// +/// ```text +/// cargo test --manifest-path orbit/desktop/src-tauri/Cargo.toml -- --ignored --test-threads=1 +/// ``` +#[cfg(test)] +mod live_tests { + use super::*; + + /// A port pair well outside the ranges `start-cluster.sh` uses, so a real + /// cluster running alongside this test cannot be mistaken for the fixture. + const LISTENING_PORT: u16 = 47731; + const SILENT_PORT: u16 = 47732; + + /// Removes the fixture's pid file even if an assertion panics, so a failed + /// run cannot leave a stale node in the user's cluster panel. + struct Fixture { + pid_file: PathBuf, + child: std::process::Child, + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = std::fs::remove_file(&self.pid_file); + } + } + + fn repo_root() -> ClusterManager { + // CARGO_MANIFEST_DIR is /orbit/desktop/src-tauri. + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + ClusterManager::discover(&manifest).expect("tests run inside the orbit-rs checkout") + } + + /// Spawn a process that holds one port open and leaves the cluster's port + /// flags on its own command line, which is where `status` reads them from. + fn spawn_fixture(manager: &ClusterManager, node_id: &str) -> Fixture { + let script = format!( + "import socket, time, sys\n\ + s = socket.socket()\n\ + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n\ + s.bind(('127.0.0.1', {LISTENING_PORT}))\n\ + s.listen(8)\n\ + time.sleep(600)\n" + ); + + let child = std::process::Command::new("python3") + .arg("-c") + .arg(script) + .arg("--postgres-port") + .arg(LISTENING_PORT.to_string()) + .arg("--redis-port") + .arg(SILENT_PORT.to_string()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("python3 is needed to stand up the fixture listener"); + + let pid_dir = manager.pid_dir(); + std::fs::create_dir_all(&pid_dir).expect("create pid dir"); + let pid_file = pid_dir.join(format!("{node_id}.pid")); + std::fs::write(&pid_file, child.id().to_string()).expect("write pid file"); + + Fixture { pid_file, child } + } + + #[tokio::test] + #[ignore = "spawns a real process and binds a real port"] + async fn status_reports_a_live_process_and_distinguishes_open_from_closed_ports() { + let manager = repo_root(); + let node_id = "node-test-fixture"; + let fixture = spawn_fixture(&manager, node_id); + + // Give the listener a moment to reach listen(2). + tokio::time::sleep(Duration::from_millis(500)).await; + + let status = manager.status().await.expect("status should read the pid dir"); + assert!(status.initialized, "the pid directory exists"); + + let node = status + .nodes + .iter() + .find(|n| n.node_id == node_id) + .expect("the fixture's pid file should be discovered"); + + let ProcessState::Running { pid, .. } = node.process else { + panic!("the fixture process is alive, so it must report as running"); + }; + assert_eq!(pid, fixture.child.id(), "the reported pid is the fixture's"); + + let by_protocol: HashMap<&str, &Endpoint> = node + .endpoints + .iter() + .map(|e| (e.protocol.as_str(), e)) + .collect(); + + // Both flags were on the command line, so both are reported... + assert_eq!(by_protocol.len(), 2, "two --*-port flags were passed"); + // ...but only the bound one answers. This is the distinction the panel + // relies on to show "running but not serving". + assert!( + by_protocol["PostgreSQL"].reachable, + "port {LISTENING_PORT} has a live listener" + ); + assert!( + !by_protocol["Redis"].reachable, + "port {SILENT_PORT} has nothing bound to it" + ); + assert!(node.is_serving(), "one reachable port counts as serving"); + } + + #[tokio::test] + #[ignore = "spawns a real process and binds a real port"] + async fn a_pid_file_whose_process_has_gone_reports_as_exited() { + let manager = repo_root(); + let node_id = "node-test-exited"; + + let pid_dir = manager.pid_dir(); + std::fs::create_dir_all(&pid_dir).expect("create pid dir"); + let pid_file = pid_dir.join(format!("{node_id}.pid")); + + // Start a process, record it, then let it finish: the pid file now + // points at something that no longer exists, exactly as it would after + // a node crashed. + let mut child = std::process::Command::new("true") + .spawn() + .expect("spawn a process that exits immediately"); + std::fs::write(&pid_file, child.id().to_string()).expect("write pid file"); + let _ = child.wait(); + tokio::time::sleep(Duration::from_millis(300)).await; + + let status = manager.status().await.expect("status"); + let node = status + .nodes + .iter() + .find(|n| n.node_id == node_id) + .expect("the pid file should still be discovered"); + + assert!( + matches!(node.process, ProcessState::Exited { .. }), + "a dead pid must not be reported as running, got {:?}", + node.process + ); + assert!(node.endpoints.is_empty(), "no ports are claimed for a dead node"); + assert!(!node.is_serving()); + + let _ = std::fs::remove_file(&pid_file); + } +} diff --git a/orbit/desktop/src-tauri/src/connections.rs b/orbit/desktop/src-tauri/src/connections.rs index 5a7e7979..c207cebe 100644 --- a/orbit/desktop/src-tauri/src/connections.rs +++ b/orbit/desktop/src-tauri/src/connections.rs @@ -25,12 +25,18 @@ use std::time::Duration; use tokio::sync::{Mutex, RwLock}; use uuid::Uuid; -use crate::queries::{ColumnInfo, QueryPayload, StatementOutcome}; +use crate::queries::{ColumnInfo, QueryPayload}; /// How long to wait for a TCP connect / handshake when the user has not said. const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); /// Types of database endpoints the desktop app can talk to. +/// +/// The acronym spellings are deliberate: these names are the serialized +/// contract. They appear verbatim in `storage.json`, in the `ConnectionType` +/// enum in `src/types/index.ts`, and on the IPC boundary between them. Renaming +/// `CQL` to `Cql` would orphan every saved connection of that type. +#[allow(clippy::upper_case_acronyms)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ConnectionType { PostgreSQL, @@ -205,6 +211,27 @@ pub struct Connection { pub query_count: u64, } +/// Render an error together with everything that caused it. +/// +/// Drivers routinely put the useful part in the source chain: `tokio_postgres` +/// reports a missing password as the top-level string "invalid configuration", +/// which tells the user nothing about what to change. The chain says +/// "invalid configuration: password missing". +fn describe(error: &dyn std::error::Error) -> String { + let mut message = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + let text = cause.to_string(); + // Drivers often repeat the outer message in the first source. + if !message.contains(&text) { + message.push_str(": "); + message.push_str(&text); + } + source = cause.source(); + } + message +} + /// Failures opening or using a connection. #[derive(Debug, thiserror::Error)] pub enum ConnectionError { @@ -445,10 +472,40 @@ async fn open_session( /// A PostgreSQL session held open across statements. pub struct PostgresSession { client: tokio_postgres::Client, + /// Whether the peer answers `Describe` well enough for `prepare()`. + /// + /// Real PostgreSQL does. `orbit-server`'s wire implementation replies to + /// every `Describe` with `NoData` instead of a `ParameterDescription`, so + /// `prepare()` fails there with "unexpected message from server" and the + /// simple query protocol has to be used instead. Which one applies is + /// decided once per session rather than per statement. + extended_protocol: bool, } impl PostgresSession { async fn connect(info: &ConnectionInfo) -> Result { + let client = Self::open_client(info).await?; + + // Probe with a statement that cannot fail for any reason except an + // unsupported extended protocol. The client stays usable either way — + // a failed `prepare` does not disturb subsequent simple queries — so + // this costs one round trip and no extra connection. + let extended_protocol = client.prepare("SELECT 1").await.is_ok(); + if !extended_protocol { + tracing::info!( + host = %info.host, + port = info.port, + "peer does not support the extended query protocol; using simple queries" + ); + } + + Ok(Self { + client, + extended_protocol, + }) + } + + async fn open_client(info: &ConnectionInfo) -> Result { // Built through `Config` rather than a connection string so that // passwords containing spaces, quotes or backslashes cannot corrupt // (or inject into) the parameter list. @@ -469,7 +526,7 @@ impl PostgresSession { let (client, connection) = config .connect(tokio_postgres::NoTls) .await - .map_err(|e| ConnectionError::ConnectionFailed(e.to_string()))?; + .map_err(|e| ConnectionError::ConnectionFailed(describe(&e)))?; // The connection future drives the socket; it ends when the client is // dropped, which is what closes the session. @@ -479,31 +536,25 @@ impl PostgresSession { } }); - Ok(Self { client }) + Ok(client) } -} -#[async_trait] -impl DatabaseSession for PostgresSession { - fn connection_type(&self) -> ConnectionType { - ConnectionType::PostgreSQL - } - - async fn execute(&mut self, statement: &str) -> Result { + /// Run a statement over the extended protocol, with server-declared types. + async fn execute_extended(&self, statement: &str) -> Result { // Preparing first tells us whether the statement returns a result set, // and gives real column types instead of guessing from a Debug string. let prepared = self .client .prepare(statement) .await - .map_err(|e| ConnectionError::QueryFailed(e.to_string()))?; + .map_err(|e| ConnectionError::QueryFailed(describe(&e)))?; if prepared.columns().is_empty() { let affected = self .client .execute(&prepared, &[]) .await - .map_err(|e| ConnectionError::QueryFailed(e.to_string()))?; + .map_err(|e| ConnectionError::QueryFailed(describe(&e)))?; return Ok(QueryPayload::affected(affected)); } @@ -517,7 +568,7 @@ impl DatabaseSession for PostgresSession { .client .query(&prepared, &[]) .await - .map_err(|e| ConnectionError::QueryFailed(e.to_string()))?; + .map_err(|e| ConnectionError::QueryFailed(describe(&e)))?; let shaped = rows .iter() @@ -535,6 +586,88 @@ impl DatabaseSession for PostgresSession { Ok(QueryPayload::returned(columns, shaped)) } + /// Run a statement over the simple query protocol. + /// + /// Every value arrives as text and the protocol carries no type OIDs to the + /// client, so columns are reported as `text`. That is what the wire actually + /// said; guessing a richer type from the characters in a value would put an + /// unverified claim in the type column. + async fn execute_simple(&self, statement: &str) -> Result { + use tokio_postgres::SimpleQueryMessage; + + let messages = self + .client + .simple_query(statement) + .await + .map_err(|e| ConnectionError::QueryFailed(describe(&e)))?; + + let mut columns: Vec = Vec::new(); + let mut rows: Vec> = Vec::new(); + let mut affected: Option = None; + + for message in messages { + match message { + SimpleQueryMessage::Row(row) => { + if columns.is_empty() { + columns = row + .columns() + .iter() + .map(|column| ColumnInfo::new(column.name(), "text")) + .collect(); + } + rows.push( + columns + .iter() + .enumerate() + .map(|(index, column)| { + let value = row + .get(index) + .map(|text| serde_json::Value::String(text.to_string())) + // A missing field here is SQL NULL: the + // simple protocol sends those as absent. + .unwrap_or(serde_json::Value::Null); + (column.name.clone(), value) + }) + .collect(), + ); + } + SimpleQueryMessage::CommandComplete(count) => { + affected = Some(affected.unwrap_or(0) + count); + } + // The enum is non_exhaustive; anything new carries no rows. + _ => {} + } + } + + if !columns.is_empty() { + return Ok(QueryPayload::returned(columns, rows)); + } + + match affected { + Some(count) => Ok(QueryPayload::affected(count)), + None => Ok(QueryPayload { + columns: Vec::new(), + rows: Vec::new(), + outcome: crate::queries::StatementOutcome::Completed, + }), + } + } +} + +#[async_trait] +impl DatabaseSession for PostgresSession { + fn connection_type(&self) -> ConnectionType { + ConnectionType::PostgreSQL + } + + async fn execute(&mut self, statement: &str) -> Result { + if self.extended_protocol { + self.execute_extended(statement).await + } else { + self.execute_simple(statement).await + } + } + async fn ping(&mut self) -> Result<(), ConnectionError> { if self.client.is_closed() { return Err(ConnectionError::NetworkError("session closed".to_string())); @@ -665,7 +798,7 @@ impl DatabaseSession for MySqlSession { .map(|column| { ColumnInfo::new( column.name_str().as_ref(), - &format!("{:?}", column.column_type()).to_lowercase(), + format!("{:?}", column.column_type()).to_lowercase(), ) }) .collect(); @@ -705,6 +838,8 @@ impl DatabaseSession for MySqlSession { } async fn ping(&mut self) -> Result<(), ConnectionError> { + use mysql_async::prelude::Queryable; + self.conn .ping() .await @@ -1271,3 +1406,165 @@ mod tests { assert_eq!(hex_encode(&[0x00, 0x0f, 0xff]), "000fff"); } } + +/// Tests that need a running `orbit-server`. +/// +/// A green unit-test run says nothing about whether the app can actually reach +/// Orbit, so these drive the real session types against real listeners. They +/// are `#[ignore]`d because they need a server: +/// +/// ```text +/// ./target/debug/orbit-server --dev-mode --data-dir /tmp/orbit-verify & +/// cargo test --manifest-path orbit/desktop/src-tauri/Cargo.toml -- --ignored --test-threads=1 +/// ``` +#[cfg(test)] +mod live_tests { + use super::*; + + /// `orbit-server` auto-registers an unknown user with the password set to + /// the username, so these credentials work against a fresh dev server. + const USER: &str = "orbit"; + + fn info(connection_type: ConnectionType, port: u16) -> ConnectionInfo { + ConnectionInfo { + name: format!("live-{connection_type}"), + connection_type, + host: "127.0.0.1".to_string(), + port, + database: None, + username: Some(USER.to_string()), + password: Some(USER.to_string()), + ssl_mode: None, + connection_timeout: Some(5_000), + additional_params: HashMap::new(), + } + } + + #[tokio::test] + #[ignore = "requires a running orbit-server on 5432"] + async fn a_connection_failure_reports_the_underlying_cause() { + // The driver's own message here is the useless "invalid configuration"; + // the reason lives one level down in the source chain. + let mut without_password = info(ConnectionType::PostgreSQL, 5432); + without_password.password = None; + + let message = match PostgresSession::connect(&without_password).await { + Ok(_) => panic!("the server asks for a password, so this must fail"), + Err(e) => e.to_string(), + }; + assert!( + message.contains("password"), + "the message must name the cause, got: {message}" + ); + } + + #[tokio::test] + #[ignore = "requires a running orbit-server on 5432"] + async fn postgres_session_runs_a_statement_and_shapes_the_rows() { + let mut session = PostgresSession::connect(&info(ConnectionType::PostgreSQL, 5432)) + .await + .expect("orbit-server should accept a PostgreSQL connection"); + + session.ping().await.expect("ping should succeed"); + + let payload = session + .execute("SELECT 1 AS one") + .await + .expect("SELECT 1 should execute"); + + assert_eq!(payload.columns.len(), 1, "one column expected"); + assert_eq!(payload.columns[0].name, "one"); + assert_eq!( + payload.outcome, + crate::queries::StatementOutcome::Returned { rows: 1 } + ); + + // Both protocol paths must surface the value. The extended path decodes + // int4 to a JSON number; the simple path can only report the text the + // wire carried, and says so in the column type rather than guessing. + let value = payload.rows[0].get("one").expect("the column must be present"); + assert!( + *value == serde_json::json!(1) || *value == serde_json::json!("1"), + "expected the value 1 in either form, got {value}" + ); + } + + #[tokio::test] + #[ignore = "requires a running orbit-server on 5432"] + async fn a_session_survives_across_statements() { + let mut session = PostgresSession::connect(&info(ConnectionType::PostgreSQL, 5432)) + .await + .expect("connect"); + + // Three statements on one session: if the manager were reconnecting per + // query this would still pass, but the session would be a new one each + // time and any SET or temp table would vanish. + for expected in 1..=3 { + let payload = session + .execute(&format!("SELECT {expected} AS n")) + .await + .expect("statement should execute on the reused session"); + let value = payload.rows[0].get("n").expect("column n"); + assert!( + *value == serde_json::json!(expected) + || *value == serde_json::json!(expected.to_string()), + "expected {expected} in either form, got {value}" + ); + } + } + + #[tokio::test] + #[ignore = "requires a running orbit-server on 6379"] + async fn redis_session_runs_commands_and_decodes_replies() { + let mut session = RedisSession::connect(&info(ConnectionType::Redis, 6379)) + .await + .expect("orbit-server should accept a Redis connection"); + + session.ping().await.expect("PING should succeed"); + + let payload = session.execute("PING").await.expect("PING should execute"); + assert_eq!(payload.columns.len(), 1); + assert!( + !payload.rows.is_empty(), + "a Redis reply should produce one row" + ); + } + + #[tokio::test] + #[ignore = "requires a running orbit-server on 5432"] + async fn the_manager_reopens_a_session_for_a_restored_connection() { + let manager = ConnectionManager::new(); + + // Exactly the startup path: register a description with no live session, + // as `restore_connections` does for every connection loaded from disk. + let connection = Connection { + id: "restored".to_string(), + info: info(ConnectionType::PostgreSQL, 5432), + status: ConnectionStatus::Disconnected, + created_at: None, + last_used: None, + query_count: 0, + }; + manager.register(connection).await; + + let session = manager + .session("restored") + .await + .expect("a restored connection must be usable without being recreated"); + + let payload = session + .lock() + .await + .execute("SELECT 1 AS one") + .await + .expect("query on the lazily opened session"); + let value = payload.rows[0].get("one").expect("column one"); + assert!( + *value == serde_json::json!(1) || *value == serde_json::json!("1"), + "expected 1 in either form, got {value}" + ); + + let listed = manager.list_connections().await; + assert_eq!(listed[0].status, ConnectionStatus::Connected); + } +} diff --git a/orbit/desktop/src-tauri/src/encryption.rs b/orbit/desktop/src-tauri/src/encryption.rs index 364b5787..d48a8873 100644 --- a/orbit/desktop/src-tauri/src/encryption.rs +++ b/orbit/desktop/src-tauri/src/encryption.rs @@ -9,15 +9,18 @@ use aes_gcm::{ }; use base64::{engine::general_purpose, Engine as _}; use std::fs; -use std::path::PathBuf; +use std::path::Path; use tauri::api::path::app_data_dir; use tauri::Config; -use tracing::{error, info, warn}; +use tracing::{info, warn}; -/// Encryption key manager +/// Encryption key manager. +/// +/// Cloning shares the same key material, so every clone can read what any +/// other wrote. +#[derive(Clone)] pub struct EncryptionManager { key: Aes256Gcm, - key_file: PathBuf, } impl EncryptionManager { @@ -26,18 +29,17 @@ impl EncryptionManager { let app_name = config .package .product_name - .as_ref() - .map(|s| s.as_str()) + .as_deref() .unwrap_or("orbit-desktop"); let app_dir = app_data_dir(config) .ok_or_else(|| { - EncryptionError::ConfigError("Could not determine app data directory".to_string()) + EncryptionError::Config("Could not determine app data directory".to_string()) })? .join(app_name); std::fs::create_dir_all(&app_dir).map_err(|e| { - EncryptionError::IoError(format!("Failed to create app directory: {}", e)) + EncryptionError::Io(format!("Failed to create app directory: {}", e)) })?; let key_file = app_dir.join(".encryption_key"); @@ -45,14 +47,14 @@ impl EncryptionManager { let key = if key_file.exists() { // Load existing key let key_bytes = fs::read(&key_file) - .map_err(|e| EncryptionError::IoError(format!("Failed to read key file: {}", e)))?; + .map_err(|e| EncryptionError::Io(format!("Failed to read key file: {}", e)))?; if key_bytes.len() != 32 { warn!("Key file has invalid length, generating new key"); Self::generate_and_save_key(&key_file)? } else { Aes256Gcm::new_from_slice(&key_bytes) - .map_err(|e| EncryptionError::KeyError(format!("Invalid key: {}", e)))? + .map_err(|e| EncryptionError::Key(format!("Invalid key: {}", e)))? } } else { // Generate new key @@ -60,39 +62,45 @@ impl EncryptionManager { Self::generate_and_save_key(&key_file)? }; - Ok(Self { key, key_file }) + Ok(Self { key }) } - fn generate_and_save_key(key_file: &PathBuf) -> Result { + fn generate_and_save_key(key_file: &Path) -> Result { let key = Aes256Gcm::generate_key(&mut OsRng); // Save key to file with restricted permissions (Unix only) #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(key_file.parent().unwrap()) - .map_err(|e| EncryptionError::IoError(format!("Failed to get metadata: {}", e)))? + let parent = key_file.parent().ok_or_else(|| { + EncryptionError::Config(format!( + "key path {} has no parent directory", + key_file.display() + )) + })?; + let mut perms = fs::metadata(parent) + .map_err(|e| EncryptionError::Io(format!("Failed to get metadata: {}", e)))? .permissions(); perms.set_mode(0o700); // rwx------ - fs::set_permissions(key_file.parent().unwrap(), perms).map_err(|e| { - EncryptionError::IoError(format!("Failed to set permissions: {}", e)) + fs::set_permissions(parent, perms).map_err(|e| { + EncryptionError::Io(format!("Failed to set permissions: {}", e)) })?; } - fs::write(key_file, key.as_slice()) - .map_err(|e| EncryptionError::IoError(format!("Failed to write key file: {}", e)))?; + fs::write(key_file, AsRef::<[u8]>::as_ref(&key)) + .map_err(|e| EncryptionError::Io(format!("Failed to write key file: {}", e)))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(key_file) .map_err(|e| { - EncryptionError::IoError(format!("Failed to get key file metadata: {}", e)) + EncryptionError::Io(format!("Failed to get key file metadata: {}", e)) })? .permissions(); perms.set_mode(0o600); // rw------- fs::set_permissions(key_file, perms).map_err(|e| { - EncryptionError::IoError(format!("Failed to set key file permissions: {}", e)) + EncryptionError::Io(format!("Failed to set key file permissions: {}", e)) })?; } @@ -105,7 +113,7 @@ impl EncryptionManager { let ciphertext = self .key .encrypt(&nonce, plaintext.as_bytes()) - .map_err(|e| EncryptionError::EncryptionError(e.to_string()))?; + .map_err(|e| EncryptionError::Encrypt(e.to_string()))?; // Combine nonce and ciphertext let mut combined = nonce.to_vec(); @@ -120,26 +128,30 @@ impl EncryptionManager { // Decode from base64 let combined = general_purpose::STANDARD .decode(ciphertext) - .map_err(|e| EncryptionError::DecryptionError(format!("Invalid base64: {}", e)))?; + .map_err(|e| EncryptionError::Decrypt(format!("Invalid base64: {}", e)))?; if combined.len() < 12 { - return Err(EncryptionError::DecryptionError( + return Err(EncryptionError::Decrypt( "Ciphertext too short".to_string(), )); } - // Extract nonce (first 12 bytes) and ciphertext - let nonce = Nonce::from_slice(&combined[..12]); + // Extract nonce (first 12 bytes) and ciphertext. The length check above + // guarantees the slice is exactly nonce-sized, so the conversion holds. + let nonce_bytes: [u8; 12] = combined[..12] + .try_into() + .map_err(|_| EncryptionError::Decrypt("Malformed nonce".to_string()))?; + let nonce = Nonce::from(nonce_bytes); let ciphertext = &combined[12..]; // Decrypt let plaintext = self .key - .decrypt(nonce, ciphertext) - .map_err(|e| EncryptionError::DecryptionError(e.to_string()))?; + .decrypt(&nonce, ciphertext) + .map_err(|e| EncryptionError::Decrypt(e.to_string()))?; String::from_utf8(plaintext) - .map_err(|e| EncryptionError::DecryptionError(format!("Invalid UTF-8: {}", e))) + .map_err(|e| EncryptionError::Decrypt(format!("Invalid UTF-8: {}", e))) } } @@ -147,13 +159,13 @@ impl EncryptionManager { #[derive(Debug, thiserror::Error)] pub enum EncryptionError { #[error("IO error: {0}")] - IoError(String), + Io(String), #[error("Key error: {0}")] - KeyError(String), + Key(String), #[error("Encryption error: {0}")] - EncryptionError(String), + Encrypt(String), #[error("Decryption error: {0}")] - DecryptionError(String), + Decrypt(String), #[error("Config error: {0}")] - ConfigError(String), + Config(String), } diff --git a/orbit/desktop/src-tauri/src/main.rs b/orbit/desktop/src-tauri/src/main.rs index c71d6293..9829c3e5 100644 --- a/orbit/desktop/src-tauri/src/main.rs +++ b/orbit/desktop/src-tauri/src/main.rs @@ -239,22 +239,27 @@ async fn get_query_history( // ============================ Cluster ============================ -/// Resolve the cluster manager, or explain why there is none. -async fn with_cluster(state: &AppState, action: F) -> Result, String> -where - F: FnOnce(&ClusterManager) -> Fut, - Fut: std::future::Future>, -{ - let guard = state.cluster_root.read().await; - let Some(manager) = guard.as_ref() else { - return Ok(ApiResponse::error(cluster::ClusterError::RootNotSet)); - }; - respond!(action(manager).await) +/// Run `$body` against the configured [`ClusterManager`], or report that no +/// Orbit-RS checkout has been located. +/// +/// A macro rather than a generic helper: the body borrows the manager out of a +/// lock guard, which a `FnOnce -> Future` bound cannot express without naming +/// the guard's lifetime. +macro_rules! with_cluster { + ($state:expr, |$manager:ident| $body:expr) => {{ + let guard = $state.cluster_root.read().await; + match guard.as_ref() { + None => Ok(ApiResponse::error(cluster::ClusterError::RootNotSet)), + Some($manager) => respond!($body), + } + }}; } #[tauri::command] -async fn get_cluster_status(state: State<'_, AppState>) -> Result, String> { - with_cluster(&state, |manager| async move { manager.status().await }).await +async fn get_cluster_status( + state: State<'_, AppState>, +) -> Result, String> { + with_cluster!(state, |manager| manager.status().await) } /// Point the cluster panel at an Orbit-RS checkout. @@ -278,22 +283,13 @@ async fn set_cluster_root( } #[tauri::command] -async fn start_cluster( - size: u8, - state: State<'_, AppState>, -) -> Result, String> { - with_cluster(&state, |manager| async move { - manager.start(size).await.map(|()| true) - }) - .await +async fn start_cluster(size: u8, state: State<'_, AppState>) -> Result, String> { + with_cluster!(state, |manager| manager.start(size).await.map(|()| true)) } #[tauri::command] async fn stop_cluster(state: State<'_, AppState>) -> Result, String> { - with_cluster(&state, |manager| async move { - manager.stop().await.map(|()| true) - }) - .await + with_cluster!(state, |manager| manager.stop().await.map(|()| true)) } #[tauri::command] @@ -303,13 +299,10 @@ async fn get_cluster_log( state: State<'_, AppState>, ) -> Result, String> { let lines = lines.unwrap_or(200); - with_cluster(&state, |manager| async move { - match node_id { - Some(node_id) => manager.node_log(&node_id, lines), - None => manager.control_log(lines), - } + with_cluster!(state, |manager| match node_id.as_deref() { + Some(node_id) => manager.node_log(node_id, lines), + None => manager.control_log(lines), }) - .await } // ============================ ML models ============================ diff --git a/orbit/desktop/src-tauri/src/models.rs b/orbit/desktop/src-tauri/src/models.rs index 660ad3ec..b4a073f6 100644 --- a/orbit/desktop/src-tauri/src/models.rs +++ b/orbit/desktop/src-tauri/src/models.rs @@ -1,6 +1,21 @@ +//! ML model catalogue. +//! +//! [`ModelManager::get_ml_functions`] returns a static reference list of the ML +//! SQL functions Orbit-RS exposes — documentation, not measurements. +//! +//! The per-connection operations are not implemented: nothing here queries a +//! server for the models it actually holds. They report that plainly instead of +//! returning invented models, because a fabricated accuracy figure or a delete +//! that reports success without deleting anything is worse than a visible gap. + use serde::{Deserialize, Serialize}; use std::collections::HashMap; +/// Shared message for the operations that have no implementation behind them. +const NOT_IMPLEMENTED: &str = + "Model management is not implemented in this build: the desktop app has no client for \ + Orbit's model catalogue yet. Use ML SQL functions from the query editor instead."; + #[derive(Debug, Serialize, Deserialize)] pub struct MLModel { pub id: String, @@ -40,48 +55,6 @@ pub struct FunctionParameter { pub description: String, } -#[derive(Debug, Serialize, Deserialize)] -pub struct TrainModelRequest { - pub connection_id: String, - pub model_name: String, - pub algorithm: String, - pub features: Vec, - pub target: String, - pub training_data: String, // SQL query or table name - pub parameters: HashMap, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct PredictionRequest { - pub connection_id: String, - pub model_id: String, - pub features: HashMap, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct PredictionResult { - pub prediction: serde_json::Value, - pub confidence: Option, - pub model_id: String, -} - -impl Default for MLModel { - fn default() -> Self { - MLModel { - id: String::new(), - name: String::new(), - model_type: String::new(), - status: ModelStatus::Training, - accuracy: None, - created_at: String::new(), - last_trained: None, - features: vec![], - target: None, - metadata: HashMap::new(), - } - } -} - // Type alias for compatibility pub type ModelInfo = MLModel; pub type MLFunctionInfo = MLFunction; @@ -95,48 +68,17 @@ impl ModelManager { ModelManager } - pub async fn get_models(&self, connection_id: &str) -> Result, String> { - // Return sample ML models - let models = vec![ - MLModel { - id: "model_001".to_string(), - name: "Loan Approval Model".to_string(), - model_type: "XGBoost".to_string(), - status: ModelStatus::Ready, - accuracy: Some(0.92), - created_at: "2024-01-15T10:30:00Z".to_string(), - last_trained: Some("2024-01-20T15:45:00Z".to_string()), - features: vec![ - "age".to_string(), - "income".to_string(), - "credit_score".to_string(), - ], - target: Some("approved".to_string()), - metadata: std::collections::HashMap::new(), - }, - MLModel { - id: "model_002".to_string(), - name: "Customer Churn Prediction".to_string(), - model_type: "LightGBM".to_string(), - status: ModelStatus::Training, - accuracy: None, - created_at: "2024-01-22T08:15:00Z".to_string(), - last_trained: None, - features: vec![ - "tenure".to_string(), - "monthly_charges".to_string(), - "contract_type".to_string(), - ], - target: Some("churn".to_string()), - metadata: std::collections::HashMap::new(), - }, - ]; - - Ok(models) + /// Models registered on a connection. + /// + /// # Errors + /// Always: there is no catalogue client behind this yet. Returning an error + /// keeps invented models off the screen. + pub async fn get_models(&self, _connection_id: &str) -> Result, String> { + Err(NOT_IMPLEMENTED.to_string()) } + /// The ML SQL functions Orbit-RS exposes, as reference documentation. pub async fn get_ml_functions(&self) -> Result, String> { - // Return sample ML functions let functions = vec![ MLFunction { name: "ML_XGBOOST".to_string(), @@ -209,31 +151,24 @@ impl ModelManager { Ok(functions) } - pub async fn train_model(&self, _request: TrainModelRequest) -> Result { - // Placeholder implementation - Ok(MLModel::default()) - } - + /// Delete a model. + /// + /// # Errors + /// Always. Reporting success for a deletion that never happened would tell + /// the user a model is gone while it is still there. pub async fn delete_model(&self, _connection_id: &str, _model_id: &str) -> Result<(), String> { - // Placeholder implementation - Ok(()) - } - - pub async fn predict(&self, _request: PredictionRequest) -> Result { - // Placeholder implementation - Ok(PredictionResult { - prediction: serde_json::Value::Null, - confidence: None, - model_id: String::new(), - }) + Err(NOT_IMPLEMENTED.to_string()) } + /// Details for one model. + /// + /// # Errors + /// Always; see [`ModelManager::get_models`]. pub async fn get_model_info( &self, _connection_id: &str, _model_name: &str, ) -> Result { - // Placeholder implementation - Ok(MLModel::default()) + Err(NOT_IMPLEMENTED.to_string()) } } diff --git a/orbit/desktop/src-tauri/src/storage.rs b/orbit/desktop/src-tauri/src/storage.rs index b13956f9..e3aa11cd 100644 --- a/orbit/desktop/src-tauri/src/storage.rs +++ b/orbit/desktop/src-tauri/src/storage.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use tauri::api::path::app_data_dir; use tauri::Config; use tracing::info; @@ -116,7 +116,6 @@ impl Default for AppStorage { /// Storage manager for persisting application data #[derive(Clone)] pub struct StorageManager { - storage_dir: PathBuf, storage_file: PathBuf, } @@ -126,19 +125,18 @@ impl StorageManager { let app_name = config .package .product_name - .as_ref() - .map(|s| s.as_str()) + .as_deref() .unwrap_or("orbit-desktop"); let storage_dir = app_data_dir(config) .ok_or_else(|| { - StorageError::ConfigError("Could not determine app data directory".to_string()) + StorageError::Config("Could not determine app data directory".to_string()) })? .join(app_name); // Create directory if it doesn't exist std::fs::create_dir_all(&storage_dir).map_err(|e| { - StorageError::IoError(format!("Failed to create storage directory: {}", e)) + StorageError::Io(format!("Failed to create storage directory: {}", e)) })?; let storage_file = storage_dir.join("storage.json"); @@ -146,10 +144,7 @@ impl StorageManager { info!("Storage directory: {:?}", storage_dir); info!("Storage file: {:?}", storage_file); - Ok(Self { - storage_dir, - storage_file, - }) + Ok(Self { storage_file }) } /// Load application storage from disk @@ -162,10 +157,10 @@ impl StorageManager { } let content = std::fs::read_to_string(&self.storage_file) - .map_err(|e| StorageError::IoError(format!("Failed to read storage file: {}", e)))?; + .map_err(|e| StorageError::Io(format!("Failed to read storage file: {}", e)))?; let storage: AppStorage = serde_json::from_str(&content).map_err(|e| { - StorageError::ParseError(format!("Failed to parse storage file: {}", e)) + StorageError::Parse(format!("Failed to parse storage file: {}", e)) })?; info!( @@ -180,16 +175,16 @@ impl StorageManager { /// Save application storage to disk pub fn save(&self, storage: &AppStorage) -> Result<(), StorageError> { let content = serde_json::to_string_pretty(storage).map_err(|e| { - StorageError::SerializeError(format!("Failed to serialize storage: {}", e)) + StorageError::Serialize(format!("Failed to serialize storage: {}", e)) })?; // Write to temporary file first, then rename (atomic write) let temp_file = self.storage_file.with_extension("tmp"); std::fs::write(&temp_file, content) - .map_err(|e| StorageError::IoError(format!("Failed to write storage file: {}", e)))?; + .map_err(|e| StorageError::Io(format!("Failed to write storage file: {}", e)))?; std::fs::rename(&temp_file, &self.storage_file) - .map_err(|e| StorageError::IoError(format!("Failed to rename storage file: {}", e)))?; + .map_err(|e| StorageError::Io(format!("Failed to rename storage file: {}", e)))?; info!( "Saved storage with {} connections and {} query history entries", @@ -199,37 +194,27 @@ impl StorageManager { Ok(()) } - - /// Get storage directory path - pub fn storage_dir(&self) -> &Path { - &self.storage_dir - } - - /// Get storage file path - pub fn storage_file(&self) -> &Path { - &self.storage_file - } } /// Storage errors #[derive(Debug, thiserror::Error)] pub enum StorageError { #[error("IO error: {0}")] - IoError(String), + Io(String), #[error("Parse error: {0}")] - ParseError(String), + Parse(String), #[error("Serialize error: {0}")] - SerializeError(String), + Serialize(String), #[error("Config error: {0}")] - ConfigError(String), + Config(String), } -/// Helper functions for converting between storage and runtime types +// Conversions between the persisted and runtime representations. impl StoredConnection { /// Convert to Connection with password decryption /// # Errors - /// Returns [`StorageError::ParseError`] when the stored protocol name is + /// Returns [`StorageError::Parse`] when the stored protocol name is /// not one this build knows. pub fn to_connection( &self, @@ -251,7 +236,7 @@ impl StoredConnection { .info .connection_type .parse() - .map_err(|e| StorageError::ParseError(format!("{e}")))?; + .map_err(|e| StorageError::Parse(format!("{e}")))?; /// Parse an RFC 3339 stamp, reporting `None` instead of substituting /// the current time for one that will not parse. @@ -301,7 +286,7 @@ impl Connection { // Encrypt password if present let password_encrypted = if let Some(password) = &self.info.password { Some(enc_manager.encrypt(password).map_err(|e| { - StorageError::SerializeError(format!("Failed to encrypt password: {}", e)) + StorageError::Serialize(format!("Failed to encrypt password: {}", e)) })?) } else { None diff --git a/orbit/desktop/src/App.tsx b/orbit/desktop/src/App.tsx index 113d37d7..e087e20a 100644 --- a/orbit/desktop/src/App.tsx +++ b/orbit/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useState, useEffect } from 'react'; import styled, { ThemeProvider, createGlobalStyle } from 'styled-components'; import Split from 'react-split'; import { Tabs, TabList, Tab, TabPanel } from 'react-tabs'; @@ -10,17 +10,18 @@ import { DataVisualization } from '@/components/DataVisualization'; import { SampleQueries } from '@/components/SampleQueries'; import QueryResultsTable from '@/components/QueryResultsTable'; import { ConnectionManager } from '@/components/ConnectionManager'; +import { ClusterPanel } from '@/components/ClusterPanel'; import { QueryHistoryPanel } from '@/components/QueryHistoryPanel'; import { KeyboardShortcuts } from '@/components/KeyboardShortcuts'; -import { TauriService, handleTauriError } from '@/services/tauri'; +import { TauriService, handleTauriError, isTauri } from '@/services/tauri'; import { useQueryTabs } from '@/hooks/useQueryTabs'; import { useHotkeys } from 'react-hotkeys-hook'; -import { - Connection, - QueryType, +import { + Connection, + QueryType, QueryRequest, - QueryTab, - Theme + Theme, + isConnected, } from '@/types'; // Global styles @@ -322,20 +323,30 @@ const ResultsContent = styled.div` overflow: auto; `; +type RightPanelView = 'samples' | 'connections' | 'cluster' | 'history' | 'models'; + +const RIGHT_PANEL_TABS: ReadonlyArray<{ id: RightPanelView; label: string }> = [ + { id: 'samples', label: '📚 Samples' }, + { id: 'connections', label: '🔌 Connections' }, + { id: 'cluster', label: '🖥️ Cluster' }, + { id: 'history', label: '📜 History' }, + { id: 'models', label: '🤖 Models' }, +]; + const App: React.FC = () => { const [connections, setConnections] = useState([]); const [currentConnection, setCurrentConnection] = useState(null); const [resultsView, setResultsView] = useState<'table' | 'chart' | 'models'>('table'); - const [rightPanelView, setRightPanelView] = useState<'models' | 'samples' | 'connections' | 'history'>('samples'); + const [rightPanelView, setRightPanelView] = useState('samples'); const [error, setError] = useState(null); - const [showConnectionManager, setShowConnectionManager] = useState(false); const [showShortcuts, setShowShortcuts] = useState(false); - + const { queryTabs, activeTabIndex, setActiveTabIndex, createNewTab, + openTab, closeTab, updateTabQuery, updateTabState, @@ -347,30 +358,28 @@ const App: React.FC = () => { setShowShortcuts(true); }); - useEffect(() => { - loadConnections(); - - // Check if running in browser mode and show notification - if (globalThis.window !== undefined && (!globalThis.window.__TAURI_IPC__ || typeof globalThis.window.__TAURI_IPC__ !== 'function')) { - console.log('🌐 Running in browser mode with mock data. For full functionality, run as Tauri desktop app.'); - } - }, []); - - const loadConnections = async () => { + const loadConnections = useCallback(async () => { try { const connectionList = await TauriService.getConnections(); setConnections(connectionList); - - // Auto-select first connected connection - const connected = connectionList.find(c => c.status === 'Connected'); - if (connected && !currentConnection) { - setCurrentConnection(connected); - } + setError(null); + + // Prefer a connection with a live session; fall back to the first saved + // one so a restored connection is selectable before it has been opened. + setCurrentConnection(previous => { + if (previous && connectionList.some(c => c.id === previous.id)) { + return connectionList.find(c => c.id === previous.id) ?? previous; + } + return connectionList.find(c => isConnected(c.status)) ?? connectionList[0] ?? null; + }); } catch (err) { - console.error('Failed to load connections:', err); + setError(handleTauriError(err)); } - }; + }, []); + useEffect(() => { + void loadConnections(); + }, [loadConnections]); const executeQuery = async (query: string) => { if (!currentConnection) { @@ -378,10 +387,6 @@ const App: React.FC = () => { return; } - const currentTab = getCurrentTab(); - if (!currentTab) return; - - // Update tab state to executing updateTabState(activeTabIndex, { is_executing: true, unsaved_changes: false, @@ -392,36 +397,34 @@ const App: React.FC = () => { const request: QueryRequest = { connection_id: currentConnection.id, query, - query_type: currentTab.query_type, - timeout: 30000, + timeout_ms: 30000, }; const result = await TauriService.executeQuery(request); - - // Update tab with result + updateTabState(activeTabIndex, { result, is_executing: false, }); - // Switch to appropriate results view - if (result.data && result.data.rows.length > 0) { - const numericColumns = result.data.columns.filter(col => - col.type.includes('int') || col.type.includes('float') || col.type.includes('decimal') - ); - - if (numericColumns.length > 0 && result.data.rows.length > 1) { - setResultsView('chart'); - } else { - setResultsView('table'); - } + // A failed statement's message lives in the results grid, but surface it + // in the banner too so it is visible without switching views. + if (!result.success && result.error) { + setError(result.error); } + // Only offer the chart view when there is something plottable; never + // switch away from the grid on the user's behalf otherwise. + const rows = result.data?.rows ?? []; + const numericColumns = (result.data?.columns ?? []).filter(col => + /int|float|double|decimal|numeric|real|serial/i.test(col.type) + ); + setResultsView(numericColumns.length > 0 && rows.length > 1 ? 'chart' : 'table'); + + // Usage counters and session state changed; refresh the connection list. + void loadConnections(); } catch (err) { - const errorMessage = handleTauriError(err); - setError(errorMessage); - - // Clear executing state + setError(handleTauriError(err)); updateTabState(activeTabIndex, { is_executing: false }); } }; @@ -432,45 +435,38 @@ const App: React.FC = () => { return; } + setError(null); try { - const request: QueryRequest = { + // The backend prefixes EXPLAIN itself and defaults to the non-executing + // form, so a plan request cannot modify data. + const result = await TauriService.explainQuery({ connection_id: currentConnection.id, - query: `EXPLAIN ANALYZE ${query}`, - query_type: QueryType.SQL, - }; + query, + timeout_ms: 30000, + }); - const result = await TauriService.explainQuery(request); - - // Update tab with explain result updateTabState(activeTabIndex, { result }); setResultsView('table'); - + if (!result.success && result.error) { + setError(result.error); + } } catch (err) { setError(handleTauriError(err)); } }; const handleConnectionChange = (connectionId: string) => { - const connection = connections.find(c => c.id === connectionId); - setCurrentConnection(connection || null); + setCurrentConnection(connections.find(c => c.id === connectionId) ?? null); + setError(null); }; const handleSampleQuerySelect = (query: string, queryType: QueryType) => { - const newTab: QueryTab = { - id: Date.now().toString(), - name: `Sample ${queryTabs.length + 1}`, - query: query, - query_type: queryType, - unsaved_changes: false, - is_executing: false, - }; - - setQueryTabs([...queryTabs, newTab]); - setActiveTabIndex(queryTabs.length); + openTab(query, queryType, `Sample ${queryTabs.length + 1}`); }; const currentTab = getCurrentTab(); - const hasResults = currentTab?.result?.success && currentTab.result.data; + const currentResult = currentTab?.result; + const hasResults = Boolean(currentResult?.success && currentResult.data); return ( @@ -508,7 +504,16 @@ const App: React.FC = () => { - + handleConnectionChange(e.target.value)} @@ -520,18 +525,31 @@ const App: React.FC = () => { ))} - - - + + {!isTauri() && ( +
+ Running in a plain browser: there is no IPC bridge to the database, so every + action will report an error. Launch the desktop app for a working session. +
+ )} + { {error && ( -
{error}
)} - - {resultsView === 'table' && currentTab?.result && ( - + + {currentResult?.notice && ( +
+ ⚠️ {currentResult.notice} +
+ )} + + {resultsView === 'table' && currentResult && ( + )} - - {resultsView === 'chart' && hasResults && ( - + + {resultsView === 'chart' && currentResult?.data && ( + )} - + {resultsView === 'models' && ( )} @@ -639,75 +673,38 @@ const App: React.FC = () => { {/* Right Panel - Additional Tools */}
- - - - + {RIGHT_PANEL_TABS.map(tab => ( + + ))}
{rightPanelView === 'samples' && ( )} {rightPanelView === 'connections' && ( - )} + {rightPanelView === 'cluster' && } {rightPanelView === 'history' && ( - diff --git a/orbit/desktop/src/components/ClusterPanel.tsx b/orbit/desktop/src/components/ClusterPanel.tsx new file mode 100644 index 00000000..351e66a9 --- /dev/null +++ b/orbit/desktop/src/components/ClusterPanel.tsx @@ -0,0 +1,397 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { TauriService, handleTauriError } from '@/services/tauri'; +import { ClusterNode, ClusterStatus, Endpoint, ProcessState } from '@/types'; + +/** How often to re-poll while the panel is visible. */ +const REFRESH_INTERVAL_MS = 4000; + +const Container = styled.div` + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + font-size: 13px; +`; + +const Toolbar = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid #3c3c3c; + background: #252525; + flex-wrap: wrap; +`; + +const Button = styled.button<{ variant?: 'primary' | 'danger' }>` + padding: 5px 10px; + border: none; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + color: #ffffff; + background: ${props => + props.variant === 'primary' ? '#0078d4' : props.variant === 'danger' ? '#a4262c' : '#3c3c3c'}; + transition: filter 0.15s; + + &:hover:not(:disabled) { + filter: brightness(1.2); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } +`; + +const SizeInput = styled.input` + width: 46px; + padding: 4px 6px; + background: #3c3c3c; + border: 1px solid #5a5a5a; + border-radius: 4px; + color: #ffffff; + font-size: 12px; +`; + +const Scroll = styled.div` + flex: 1; + overflow: auto; + padding: 12px; +`; + +const Summary = styled.div` + color: #cccccc; + margin-bottom: 10px; + line-height: 1.6; +`; + +const RootPath = styled.code` + color: #9cdcfe; + word-break: break-all; +`; + +const NodeCard = styled.div` + border: 1px solid #3c3c3c; + border-radius: 6px; + padding: 10px 12px; + margin-bottom: 10px; + background: #252525; +`; + +const NodeHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +`; + +const NodeName = styled.span` + font-weight: 600; +`; + +const Badge = styled.span<{ tone: 'good' | 'bad' | 'warn' }>` + padding: 2px 7px; + border-radius: 10px; + font-size: 11px; + color: #ffffff; + background: ${props => + props.tone === 'good' ? '#107c10' : props.tone === 'warn' ? '#8a6d00' : '#a4262c'}; +`; + +const PortGrid = styled.div` + display: flex; + flex-wrap: wrap; + gap: 6px; +`; + +const Port = styled.span<{ reachable: boolean }>` + padding: 2px 7px; + border-radius: 4px; + font-size: 11px; + border: 1px solid ${props => (props.reachable ? '#107c10' : '#5a5a5a')}; + color: ${props => (props.reachable ? '#8fd18f' : '#999999')}; +`; + +const Muted = styled.div` + color: #999999; + font-size: 12px; + margin-top: 6px; +`; + +const Banner = styled.div<{ tone: 'error' | 'info' }>` + margin: 12px; + padding: 10px 12px; + border-radius: 4px; + font-size: 12px; + line-height: 1.5; + color: ${props => (props.tone === 'error' ? '#f2a3a5' : '#cccccc')}; + background: ${props => + props.tone === 'error' ? 'rgba(209, 52, 56, 0.12)' : 'rgba(255, 255, 255, 0.04)'}; + border: 1px solid + ${props => (props.tone === 'error' ? 'rgba(209, 52, 56, 0.35)' : '#3c3c3c')}; +`; + +const LogView = styled.pre` + margin: 0; + padding: 10px; + background: #1a1a1a; + border: 1px solid #3c3c3c; + border-radius: 4px; + max-height: 240px; + overflow: auto; + font-size: 11px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + color: #cccccc; +`; + +/** Render a duration in whole units, largest first. */ +const formatUptime = (seconds: number): string => { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m ${seconds % 60}s`; + return `${seconds}s`; +}; + +const isRunning = (process: ProcessState): process is Extract => + process.state === 'running'; + +/** + * Alive and answering, alive but answering nothing, or gone. Kept as three + * states because a process that is up with dead listeners is exactly the case + * worth spotting, and collapsing it into "running" would hide it. + */ +const nodeHealth = (node: ClusterNode): { tone: 'good' | 'warn' | 'bad'; label: string } => { + if (!isRunning(node.process)) { + return { tone: 'bad', label: `exited (was pid ${node.process.pid})` }; + } + const reachable = node.endpoints.filter((e: Endpoint) => e.reachable).length; + if (node.endpoints.length === 0) { + return { tone: 'warn', label: 'running, no port flags found' }; + } + if (reachable === 0) { + return { tone: 'warn', label: 'running, no ports answering' }; + } + return { tone: 'good', label: `serving ${reachable}/${node.endpoints.length} ports` }; +}; + +export const ClusterPanel: React.FC = () => { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [size, setSize] = useState(3); + const [log, setLog] = useState(null); + const [logTitle, setLogTitle] = useState(''); + const [rootInput, setRootInput] = useState(''); + + // Avoids a state update after unmount when a poll is still in flight. + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const refresh = useCallback(async (): Promise => { + try { + const next = await TauriService.getClusterStatus(); + if (!mounted.current) return; + setStatus(next); + setError(null); + } catch (err) { + if (!mounted.current) return; + setError(handleTauriError(err)); + } + }, []); + + useEffect(() => { + void refresh(); + const timer = setInterval(() => void refresh(), REFRESH_INTERVAL_MS); + return () => clearInterval(timer); + }, [refresh]); + + const run = async (action: () => Promise): Promise => { + setBusy(true); + setError(null); + try { + await action(); + await refresh(); + } catch (err) { + if (mounted.current) setError(handleTauriError(err)); + } finally { + if (mounted.current) setBusy(false); + } + }; + + const showLog = (nodeId?: string) => + run(async () => { + const content = await TauriService.getClusterLog(nodeId, 200); + if (!mounted.current) return; + setLog(content); + setLogTitle(nodeId ? `${nodeId}.log` : 'cluster-control.log'); + }); + + const applyRoot = () => + run(async () => { + const next = await TauriService.setClusterRoot(rootInput.trim()); + if (!mounted.current) return; + setStatus(next); + await TauriService.saveSettings({ cluster_root: next.root }); + }); + + // No checkout located: the only useful action is to name one. + if (error && !status) { + return ( + + {error} + + setRootInput(e.target.value)} + /> + + + + ); + } + + return ( + + + + setSize(Math.max(1, Math.min(9, Number(e.target.value) || 1)))} + /> + + + + + + + {error && {error}} + + + {status && ( + +
+ Checkout: {status.root} +
+ {status.initialized ? ( +
+ {status.running_nodes} of {status.nodes.length} node + {status.nodes.length === 1 ? '' : 's'} running · {status.serving_nodes} serving · + checked {new Date(status.checked_at).toLocaleTimeString()} +
+ ) : ( +
No cluster has been started in this checkout yet.
+ )} +
+ )} + + {status?.nodes.map(node => { + const health = nodeHealth(node); + return ( + + + {node.node_id} + {health.label} + + + {isRunning(node.process) ? ( + <> + + pid {node.process.pid} · up {formatUptime(node.process.uptime_seconds)} + + {node.endpoints.length > 0 ? ( + + {node.endpoints.map(endpoint => ( + + {endpoint.protocol} {endpoint.port} + + ))} + + ) : ( + + No --*-port flags on this process's command line, so its ports + are unknown. + + )} + + ) : ( + + A pid file remains but the process is gone. Ports are not shown: the ones it + would have used are a guess, not an observation. + + )} + + + + ); + })} + + {log !== null && ( +
+ + {logTitle} + + + {log || '(empty)'} +
+ )} +
+
+ ); +}; + +export default ClusterPanel; diff --git a/orbit/desktop/src/components/ConnectionDialog.tsx b/orbit/desktop/src/components/ConnectionDialog.tsx index a374303c..83b92c26 100644 --- a/orbit/desktop/src/components/ConnectionDialog.tsx +++ b/orbit/desktop/src/components/ConnectionDialog.tsx @@ -1,7 +1,13 @@ import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; -import { ConnectionInfo, ConnectionType, ConnectionStatus } from '@/types'; -import { TauriService } from '@/services/tauri'; +import { + ConnectionInfo, + ConnectionType, + ConnectionTypeInfo, + connectionStatusError, + isConnected, +} from '@/types'; +import { TauriService, handleTauriError } from '@/services/tauri'; interface ConnectionDialogProps { isOpen: boolean; @@ -248,6 +254,16 @@ export const ConnectionDialog: React.FC = ({ const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const [connectionTypes, setConnectionTypes] = useState([]); + + // The backend owns the protocol table: which types exist, their default + // ports, and which are served by a native wire implementation. + useEffect(() => { + if (!isOpen) return; + TauriService.listConnectionTypes() + .then(setConnectionTypes) + .catch(err => setError(handleTauriError(err))); + }, [isOpen]); useEffect(() => { if (isOpen) { @@ -279,22 +295,17 @@ export const ConnectionDialog: React.FC = ({ setError(null); }; - const getDefaultPort = (type: ConnectionType): number => { - switch (type) { - case ConnectionType.PostgreSQL: return 5432; - case ConnectionType.MySQL: return 3306; - case ConnectionType.OrbitQL: return 8081; - case ConnectionType.Redis: return 6379; - case ConnectionType.CQL: return 9042; - case ConnectionType.Cypher: return 7687; - case ConnectionType.AQL: return 8529; - default: return 5432; - } - }; + // Default ports come from the backend, which owns the protocol table. A copy + // here had already drifted (OrbitQL 8081 against the server's 8080). + const defaultPortFor = (type: ConnectionType): number | undefined => + connectionTypes.find(candidate => candidate.id === type)?.default_port; const handleConnectionTypeChange = (type: ConnectionType) => { updateFormData('connection_type', type); - updateFormData('port', getDefaultPort(type)); + const port = defaultPortFor(type); + if (port !== undefined) { + updateFormData('port', port); + } }; const handleTest = async () => { @@ -304,15 +315,17 @@ export const ConnectionDialog: React.FC = ({ try { const status = await TauriService.testConnection(formData); - if (status === ConnectionStatus.Connected) { - setTestResult({ success: true, message: 'Connection successful!' }); - } else if (status === ConnectionStatus.Error) { - setTestResult({ success: false, message: 'Connection failed. Please check your settings.' }); + if (isConnected(status)) { + setTestResult({ success: true, message: 'Connection successful.' }); } else { - setTestResult({ success: false, message: 'Connection test returned unexpected status.' }); + // Show what the server actually said, not a generic sentence. + setTestResult({ + success: false, + message: connectionStatusError(status) ?? 'Connection could not be opened.', + }); } - } catch (err: any) { - setTestResult({ success: false, message: err.message || 'Connection test failed' }); + } catch (err) { + setTestResult({ success: false, message: handleTauriError(err) }); } finally { setTesting(false); } @@ -388,14 +401,24 @@ export const ConnectionDialog: React.FC = ({ value={formData.connection_type} onChange={(e) => handleConnectionTypeChange(e.target.value as ConnectionType)} > - - - - - - - + {connectionTypes.map(type => ( + + ))} + {connectionTypes + .filter(t => t.id === formData.connection_type && !t.native_wire_protocol) + .map(t => ( +
+ ⚠️ {t.id} goes through orbit-server's REST API, whose SQL and catalog + handlers still return fixed example rows. Queries will succeed but the + results are not data from the database. +
+ ))} @@ -413,7 +436,14 @@ export const ConnectionDialog: React.FC = ({ updateFormData('port', parseInt(e.target.value) || getDefaultPort(formData.connection_type))} + onChange={(e) => + updateFormData( + 'port', + parseInt(e.target.value, 10) || + defaultPortFor(formData.connection_type) || + formData.port + ) + } min="1" max="65535" /> diff --git a/orbit/desktop/src/components/ConnectionManager.tsx b/orbit/desktop/src/components/ConnectionManager.tsx index b8fbb423..2932aa79 100644 --- a/orbit/desktop/src/components/ConnectionManager.tsx +++ b/orbit/desktop/src/components/ConnectionManager.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import styled from 'styled-components'; -import { Connection, ConnectionInfo } from '@/types'; -import { TauriService } from '@/services/tauri'; +import { Connection, connectionStatusError, isConnected } from '@/types'; +import { TauriService, handleTauriError } from '@/services/tauri'; import { ConnectionDialog } from './ConnectionDialog'; interface ConnectionManagerProps { @@ -111,20 +111,25 @@ const ActionButton = styled.button<{ variant?: 'danger' }>` } `; -const StatusIndicator = styled.span<{ status: string }>` +type StatusTone = 'connected' | 'error' | 'idle'; + +const StatusIndicator = styled.span<{ tone: StatusTone }>` display: inline-block; width: 8px; height: 8px; border-radius: 50%; - background: ${props => - props.status === 'Connected' ? '#107c10' : - props.status === 'Connecting' ? '#ff8c00' : - props.status === 'Error' ? '#d13438' : - '#666666' - }; + background: ${props => + props.tone === 'connected' ? '#107c10' : props.tone === 'error' ? '#d13438' : '#666666'}; margin-right: 6px; `; +const ErrorNote = styled.div` + color: #f2a3a5; + font-size: 12px; + margin-top: 4px; + line-height: 1.4; +`; + const EmptyState = styled.div` text-align: center; padding: 40px 20px; @@ -139,6 +144,46 @@ export const ConnectionManager: React.FC = ({ const [dialogOpen, setDialogOpen] = useState(false); const [editingConnection, setEditingConnection] = useState(null); const [deleting, setDeleting] = useState(null); + const [busyId, setBusyId] = useState(null); + /** Per-connection failure from the most recent connect attempt. */ + const [attemptErrors, setAttemptErrors] = useState>({}); + + const setAttemptError = (id: string, message: string | null) => + setAttemptErrors(prev => { + const next = { ...prev }; + if (message === null) { + delete next[id]; + } else { + next[id] = message; + } + return next; + }); + + /** Open a session now so the user finds out here, not on their first query. */ + const handleConnect = async (connectionId: string) => { + setBusyId(connectionId); + setAttemptError(connectionId, null); + try { + await TauriService.connect(connectionId); + onConnectionsChange(); + } catch (err) { + setAttemptError(connectionId, handleTauriError(err)); + } finally { + setBusyId(null); + } + }; + + const handleDisconnect = async (connectionId: string) => { + setBusyId(connectionId); + try { + await TauriService.disconnect(connectionId); + onConnectionsChange(); + } catch (err) { + setAttemptError(connectionId, handleTauriError(err)); + } finally { + setBusyId(null); + } + }; const handleCreate = () => { setEditingConnection(null); @@ -192,34 +237,61 @@ export const ConnectionManager: React.FC = ({ ) : ( - {connections.map(conn => ( - - -
- - {conn.info.name} -
- {conn.info.connection_type} - - handleEdit(conn)}> - Edit - - handleDelete(conn.id)} - disabled={deleting === conn.id} - > - {deleting === conn.id ? 'Deleting...' : 'Delete'} - - -
- - {conn.info.host}:{conn.info.port} - {conn.info.database && ` • ${conn.info.database}`} - {conn.query_count > 0 && ` • ${conn.query_count} queries`} - -
- ))} + {connections.map(conn => { + const connected = isConnected(conn.status); + // A stored status error and a failed click are different events; + // show whichever is more recent, preferring the click. + const failure = attemptErrors[conn.id] ?? connectionStatusError(conn.status); + const tone: StatusTone = connected ? 'connected' : failure ? 'error' : 'idle'; + + return ( + + +
+ + {conn.info.name} +
+ {conn.info.connection_type} + + {connected ? ( + handleDisconnect(conn.id)} + disabled={busyId === conn.id} + > + Disconnect + + ) : ( + handleConnect(conn.id)} + disabled={busyId === conn.id} + > + {busyId === conn.id ? 'Connecting…' : 'Connect'} + + )} + handleEdit(conn)}> + Edit + + handleDelete(conn.id)} + disabled={deleting === conn.id} + > + {deleting === conn.id ? 'Deleting...' : 'Delete'} + + +
+ + {conn.info.host}:{conn.info.port} + {conn.info.database && ` • ${conn.info.database}`} + {conn.query_count > 0 && ` • ${conn.query_count} queries`} + + {!connected && failure && {failure}} +
+ ); + })}
)} diff --git a/orbit/desktop/src/components/DataVisualization.tsx b/orbit/desktop/src/components/DataVisualization.tsx index c306a4bf..0fe805a2 100644 --- a/orbit/desktop/src/components/DataVisualization.tsx +++ b/orbit/desktop/src/components/DataVisualization.tsx @@ -246,11 +246,6 @@ export const DataVisualization: React.FC = ({ ); }; - const isDateColumn = (columnName: string) => { - const column = data.columns.find(col => col.name === columnName); - return column && (column.type.includes('date') || column.type.includes('time')); - }; - const processChartData = () => { if (!data.rows.length) return null; diff --git a/orbit/desktop/src/components/MLModelManager.tsx b/orbit/desktop/src/components/MLModelManager.tsx index e25ad572..3c23b080 100644 --- a/orbit/desktop/src/components/MLModelManager.tsx +++ b/orbit/desktop/src/components/MLModelManager.tsx @@ -1,11 +1,10 @@ import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; -import { - ModelInfo, - MLFunctionInfo, - Connection, - ModelStatus, - MLFunctionCategory +import { + ModelInfo, + MLFunctionInfo, + Connection, + ModelStatus, } from '@/types'; import { TauriService } from '@/services/tauri'; @@ -135,7 +134,7 @@ const StatusIndicator = styled.div<{ status: ModelStatus }>` case ModelStatus.Ready: return '#107c10'; case ModelStatus.Training: return '#ff8c00'; case ModelStatus.Error: return '#d13438'; - case ModelStatus.Deprecated: return '#5a5a5a'; + case ModelStatus.Deleted: return '#5a5a5a'; default: return '#5a5a5a'; } }}; @@ -215,18 +214,22 @@ const CategoryCard = styled.div` overflow: hidden; `; -const CategoryHeader = styled.div<{ category: MLFunctionCategory }>` +/** + * Colours for the categories the backend currently emits. The category is a + * free-form string there, so anything unrecognised falls back to grey rather + * than being dropped. + */ +const CATEGORY_COLORS: Record = { + Boosting: '#0078d4', + ModelManagement: '#107c10', + Statistical: '#d83b01', + FeatureEngineering: '#5c2d91', + VectorOperations: '#e81123', +}; + +const CategoryHeader = styled.div<{ category: string }>` padding: 12px 16px; - background: ${props => { - switch (props.category) { - case MLFunctionCategory.BoostingAlgorithms: return '#0078d4'; - case MLFunctionCategory.ModelManagement: return '#107c10'; - case MLFunctionCategory.Statistical: return '#d83b01'; - case MLFunctionCategory.FeatureEngineering: return '#5c2d91'; - case MLFunctionCategory.VectorOperations: return '#e81123'; - default: return '#5a5a5a'; - } - }}; + background: ${props => CATEGORY_COLORS[props.category] ?? '#5a5a5a'}; color: white; font-weight: 600; font-size: 14px; @@ -346,54 +349,31 @@ export const MLModelManager: React.FC = ({ } }; - const formatBytes = (bytes: number) => { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + const formatAccuracy = (accuracy: number) => `${(accuracy * 100).toFixed(1)}%`; + + /** + * Bucket functions by whatever category the backend reported. + * + * Built from the data rather than from a fixed list: a hardcoded set of + * categories silently dropped every function whose category was not in it. + */ + const groupFunctionsByCategory = (): Record => + mlFunctions.reduce>((groups, func) => { + (groups[func.category] ??= []).push(func); + return groups; + }, {}); + + /** Prettier labels for the categories we know; others show as sent. */ + const CATEGORY_LABELS: Record = { + Boosting: 'Boosting Algorithms', + ModelManagement: 'Model Management', + Statistical: 'Statistical Functions', + FeatureEngineering: 'Feature Engineering', + VectorOperations: 'Vector Operations', }; - const formatAccuracy = (accuracy: number) => { - return (accuracy * 100).toFixed(1) + '%'; - }; - - const groupFunctionsByCategory = () => { - const groups: Record = { - [MLFunctionCategory.ModelManagement]: [], - [MLFunctionCategory.Statistical]: [], - [MLFunctionCategory.SupervisedLearning]: [], - [MLFunctionCategory.UnsupervisedLearning]: [], - [MLFunctionCategory.BoostingAlgorithms]: [], - [MLFunctionCategory.FeatureEngineering]: [], - [MLFunctionCategory.VectorOperations]: [], - [MLFunctionCategory.TimeSeries]: [], - [MLFunctionCategory.NLP]: [], - }; - - for (const func of mlFunctions) { - groups[func.category].push(func); - } - - return groups; - }; - - // Category display name mapping to reduce cognitive complexity - const categoryDisplayNames: Record = { - [MLFunctionCategory.ModelManagement]: 'Model Management', - [MLFunctionCategory.Statistical]: 'Statistical Functions', - [MLFunctionCategory.SupervisedLearning]: 'Supervised Learning', - [MLFunctionCategory.UnsupervisedLearning]: 'Unsupervised Learning', - [MLFunctionCategory.BoostingAlgorithms]: 'Boosting Algorithms', - [MLFunctionCategory.FeatureEngineering]: 'Feature Engineering', - [MLFunctionCategory.VectorOperations]: 'Vector Operations', - [MLFunctionCategory.TimeSeries]: 'Time Series ML', - [MLFunctionCategory.NLP]: 'Natural Language Processing', - }; - - const getCategoryDisplayName = (category: MLFunctionCategory) => { - return categoryDisplayNames[category] || category; - }; + const getCategoryDisplayName = (category: string) => + CATEGORY_LABELS[category] ?? category; return ( @@ -456,37 +436,42 @@ export const MLModelManager: React.FC = ({ ) : ( {models.map((model) => ( - + {model.name} - + - {model.algorithm} + {model.model_type} Accuracy - {formatAccuracy(model.accuracy)} + {/* An untrained model has no accuracy; "unknown" beats 0%. */} + + {model.accuracy === null || model.accuracy === undefined + ? '—' + : formatAccuracy(model.accuracy)} + Features - {model.feature_count} + {model.features.length} - Training Samples - {model.training_samples.toLocaleString()} + Target + {model.target ?? '—'} - Size - {formatBytes(model.size_bytes)} + Last trained + + {model.last_trained + ? new Date(model.last_trained).toLocaleDateString() + : 'never'} + -
- Updated: {new Date(model.updated_at).toLocaleDateString()} -
- 📊 View Details handleDeleteModel(model.name)}> @@ -515,8 +500,8 @@ export const MLModelManager: React.FC = ({ return ( - - {getCategoryDisplayName(category as MLFunctionCategory)} ({functions.length}) + + {getCategoryDisplayName(category)} ({functions.length}) {functions.map((func) => ( diff --git a/orbit/desktop/src/components/QueryEditor.tsx b/orbit/desktop/src/components/QueryEditor.tsx index 18ea5c01..f4413eaf 100644 --- a/orbit/desktop/src/components/QueryEditor.tsx +++ b/orbit/desktop/src/components/QueryEditor.tsx @@ -8,8 +8,8 @@ import { keymap } from '@codemirror/view'; import { indentWithTab } from '@codemirror/commands'; import { autocompletion, completionKeymap } from '@codemirror/autocomplete'; import styled from 'styled-components'; -import { QueryType, QueryRequest, QueryResult, Connection } from '@/types'; -import { TauriService } from '@/services/tauri'; +import { QueryType, Connection } from '@/types'; +import { collapseWhitespace, safeFormatQuery } from '@/utils/queryFormatter'; import { useHotkeys } from 'react-hotkeys-hook'; import { orbitqlKeywords, redisCommands, mysqlKeywords, cqlKeywords, cypherKeywords, aqlKeywords } from '@/constants/queryKeywords'; @@ -327,69 +327,13 @@ export const QueryEditor: React.FC = ({ } }; - // ReDoS-safe formatter utility with input size limits and timeout protection - const safeFormatQuery = (input: string): string => { - // Prevent DoS by limiting input size (typical large queries are < 100KB) - const MAX_QUERY_SIZE = 1024 * 100; // 100KB limit - if (input.length > MAX_QUERY_SIZE) { - throw new Error(`Query too large for formatting (${input.length} chars, max: ${MAX_QUERY_SIZE})`); - } - - // Use timeout wrapper to prevent infinite regex execution - const formatWithTimeout = (text: string, timeoutMs: number = 5000): string => { - const start = Date.now(); - - // Check timeout before each regex operation - const checkTimeout = () => { - if (Date.now() - start > timeoutMs) { - throw new Error('Query formatting timeout - potential ReDoS detected'); - } - }; - - // ReDoS-safe regex patterns with linear time complexity O(n) - let result = text; - - checkTimeout(); - // Safe: atomic group prevents backtracking on whitespace sequences - result = result.replace(/(?:[ \t\r\n])+/g, ' '); - - checkTimeout(); - // Safe: limited quantifiers with character classes - result = result.replace(/[ \t]*,[ \t]*/g, ',\n '); - - // Safe: individual keyword replacements avoid alternation backtracking - const keywords = ['SELECT', 'FROM', 'WHERE', 'JOIN', 'GROUP BY', 'HAVING', 'ORDER BY', 'LIMIT']; - for (const keyword of keywords) { - checkTimeout(); - // Safe: exact word boundary matches, no nested quantifiers - const regex = new RegExp(`\\b${keyword}\\b`, 'gi'); - result = result.replace(regex, `\n${keyword}`); - } - - checkTimeout(); - // Safe: anchored pattern with character class, no backtracking - result = result.replace(/^[ \t]+/gm, ' '); - - return result.trim(); - }; - - return formatWithTimeout(input); - }; - const handleFormat = () => { if (queryType === QueryType.SQL || queryType === QueryType.OrbitQL || queryType === QueryType.MySQL || queryType === QueryType.CQL) { try { - const formatted = safeFormatQuery(value); - onChange(formatted); + onChange(safeFormatQuery(value)); } catch (error) { console.error('Query formatting failed:', error); - // Graceful fallback - just clean up basic whitespace without regex - const basicFormatted = value - .split(/\s+/) - .filter(word => word.length > 0) - .join(' ') - .trim(); - onChange(basicFormatted); + onChange(collapseWhitespace(value)); } } }; diff --git a/orbit/desktop/src/components/QueryHistoryPanel.tsx b/orbit/desktop/src/components/QueryHistoryPanel.tsx index 010d0e68..de066396 100644 --- a/orbit/desktop/src/components/QueryHistoryPanel.tsx +++ b/orbit/desktop/src/components/QueryHistoryPanel.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; -import { QueryRequest, QueryType } from '@/types'; -import { TauriService } from '@/services/tauri'; +import { QueryHistoryEntry, QueryType, describeOutcome } from '@/types'; +import { TauriService, handleTauriError } from '@/services/tauri'; interface QueryHistoryPanelProps { connectionId?: string; @@ -82,26 +82,22 @@ const QueryMeta = styled.div` color: #999999; `; -const QueryTypeBadge = styled.span<{ type: QueryType }>` +const OutcomeBadge = styled.span<{ ok: boolean }>` padding: 2px 6px; border-radius: 10px; font-size: 10px; font-weight: 600; - background: ${props => { - switch (props.type) { - case QueryType.SQL: return '#0078d4'; - case QueryType.MySQL: return '#00758f'; - case QueryType.OrbitQL: return '#107c10'; - case QueryType.Redis: return '#d83b01'; - case QueryType.CQL: return '#1287b1'; - case QueryType.Cypher: return '#008cc1'; - case QueryType.AQL: return '#dd5324'; - default: return '#5a5a5a'; - } - }}; + background: ${props => (props.ok ? '#107c10' : '#a4262c')}; color: white; `; +const FailureNote = styled.div` + color: #f2a3a5; + font-size: 11px; + margin-top: 4px; + line-height: 1.4; +`; + const EmptyState = styled.div` text-align: center; padding: 40px 20px; @@ -120,8 +116,9 @@ export const QueryHistoryPanel: React.FC = ({ connectionId, onSelectQuery }) => { - const [history, setHistory] = useState([]); + const [history, setHistory] = useState([]); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); useEffect(() => { if (connectionId) { @@ -135,20 +132,21 @@ export const QueryHistoryPanel: React.FC = ({ if (!connectionId) return; setLoading(true); + setError(null); try { const queryHistory = await TauriService.getQueryHistory(connectionId, 50); setHistory(queryHistory); } catch (err) { - console.error('Failed to load query history:', err); + setError(handleTauriError(err)); } finally { setLoading(false); } }; - const handleQueryClick = (query: QueryRequest) => { - if (onSelectQuery) { - onSelectQuery(query.query, query.query_type); - } + const handleQueryClick = (entry: QueryHistoryEntry) => { + // History records the statement, not which editor mode it was typed in; + // the new tab opens in SQL, which the user can change. + onSelectQuery?.(entry.query, QueryType.SQL); }; const formatTime = (timestamp: string) => { @@ -193,6 +191,10 @@ export const QueryHistoryPanel: React.FC = ({ )} + {error && ( + {error} + )} + {history.length === 0 ? (
No query history yet
@@ -202,15 +204,24 @@ export const QueryHistoryPanel: React.FC = ({
) : ( - {history.map((query, index) => ( - handleQueryClick(query)}> - {query.query} + {history.map(entry => ( + handleQueryClick(entry)}> + {entry.query} - - {query.query_type} - - {formatTime(new Date().toISOString())} + + {entry.success + ? entry.outcome + ? describeOutcome(entry.outcome) + : 'OK' + : 'Failed'} + + {entry.execution_time_ms.toFixed(1)}ms + {/* The real execution time, not the moment this list rendered. */} + + {formatTime(entry.executed_at)} + + {!entry.success && entry.error && {entry.error}} ))} diff --git a/orbit/desktop/src/components/QueryResultsTable.tsx b/orbit/desktop/src/components/QueryResultsTable.tsx index 92bc83fa..f7e41d45 100644 --- a/orbit/desktop/src/components/QueryResultsTable.tsx +++ b/orbit/desktop/src/components/QueryResultsTable.tsx @@ -1,6 +1,6 @@ import React from 'react'; import styled from 'styled-components'; -import { QueryResult } from '@/types'; +import { QueryResult, QueryResultData, describeOutcome } from '@/types'; const ExportButton = styled.button` padding: 6px 12px; @@ -85,7 +85,7 @@ const createUniqueRowKey = (row: any, rowIndex: number): string => { return `${keyValue}-${rowIndex}`; }; -const exportToCSV = (data: QueryResult['data']) => { +const exportToCSV = (data: QueryResultData | null | undefined) => { if (!data || !data.columns.length || !data.rows.length) return; const headers = data.columns.map(col => col.name).join(','); @@ -112,7 +112,7 @@ const exportToCSV = (data: QueryResult['data']) => { URL.revokeObjectURL(url); }; -const exportToJSON = (data: QueryResult['data']) => { +const exportToJSON = (data: QueryResultData | null | undefined) => { if (!data) return; const json = JSON.stringify(data, null, 2); @@ -144,38 +144,37 @@ const QueryResultsTable: React.FC = ({ result }) => { ); } - const hasData = result.data.rows.length > 0; + // Bound to a local so TypeScript can narrow it inside the callbacks below. + const data = result.data; + const hasData = data.rows.length > 0; return ( - Execution time: {result.execution_time?.toFixed(2) || 0}ms - {result.rows_affected !== undefined && ( - <> • Rows: {result.rows_affected} - )} - {hasData && ( - <> • Showing {result.data.rows.length} row{result.data.rows.length !== 1 ? 's' : ''} - )} + Execution time: {result.execution_time_ms.toFixed(2)}ms + {/* "returned" and "affected" are reported as the server distinguished + them, rather than collapsed into one ambiguous row count. */} + {' • '}{describeOutcome(data.outcome)} {hasData && ( <> - exportToCSV(result.data)}> + exportToCSV(data)}> 📥 Export CSV - exportToJSON(result.data)}> + exportToJSON(data)}> 📥 Export JSON )} - + {hasData ? ( - {result.data.columns.map(col => ( + {data.columns.map(col => ( {col.name} {col.type} @@ -184,16 +183,16 @@ const QueryResultsTable: React.FC = ({ result }) => { - {result.data.rows.map((row, rowIndex) => ( + {data.rows.map((row, rowIndex) => ( - {result.data.columns.map(col => { + {data.columns.map(col => { const value = row[col.name]; - const displayValue = value === null || value === undefined + const displayValue = value === null || value === undefined ? NULL : typeof value === 'object' ? JSON.stringify(value) : String(value); - + return ( {displayValue} @@ -207,7 +206,9 @@ const QueryResultsTable: React.FC = ({ result }) => { ) : ( - Query executed successfully but returned no rows + {data.outcome.kind === 'affected' + ? `Statement completed: ${describeOutcome(data.outcome)}.` + : 'Statement completed and returned no rows.'} )} diff --git a/orbit/desktop/src/hooks/useQueryTabs.ts b/orbit/desktop/src/hooks/useQueryTabs.ts index fe68fe5b..a008559c 100644 --- a/orbit/desktop/src/hooks/useQueryTabs.ts +++ b/orbit/desktop/src/hooks/useQueryTabs.ts @@ -15,6 +15,8 @@ interface UseQueryTabsReturn { activeTabIndex: number; setActiveTabIndex: (index: number) => void; createNewTab: (queryType?: QueryType) => void; + /** Open a new tab pre-filled with `query` and focus it. */ + openTab: (query: string, queryType: QueryType, name?: string) => void; closeTab: (index: number) => void; updateTabQuery: (index: number, query: string) => void; updateTabState: (index: number, updates: Partial) => void; @@ -34,18 +36,30 @@ export const useQueryTabs = (): UseQueryTabsReturn => { ]); const [activeTabIndex, setActiveTabIndex] = useState(0); + /** Append a tab and focus it, deriving both from the list's current length. */ + const appendTab = (query: string, queryType: QueryType, name?: string) => { + setQueryTabs(prev => { + setActiveTabIndex(prev.length); + return [ + ...prev, + { + id: `${Date.now()}-${prev.length}`, + name: name ?? `Query ${prev.length + 1}`, + query, + query_type: queryType, + unsaved_changes: false, + is_executing: false, + }, + ]; + }); + }; + const createNewTab = (queryType: QueryType = QueryType.OrbitQL) => { - const newTab: QueryTab = { - id: Date.now().toString(), - name: `Query ${queryTabs.length + 1}`, - query: queryType === QueryType.Redis ? 'PING' : 'SELECT 1;', - query_type: queryType, - unsaved_changes: false, - is_executing: false, - }; + appendTab(queryType === QueryType.Redis ? 'PING' : 'SELECT 1;', queryType); + }; - setQueryTabs(prev => [...prev, newTab]); - setActiveTabIndex(queryTabs.length); + const openTab = (query: string, queryType: QueryType, name?: string) => { + appendTab(query, queryType, name); }; const closeTab = (index: number) => { @@ -91,6 +105,7 @@ export const useQueryTabs = (): UseQueryTabsReturn => { activeTabIndex, setActiveTabIndex, createNewTab, + openTab, closeTab, updateTabQuery, updateTabState, diff --git a/orbit/desktop/src/services/tauri.ts b/orbit/desktop/src/services/tauri.ts index 518009b2..01526307 100644 --- a/orbit/desktop/src/services/tauri.ts +++ b/orbit/desktop/src/services/tauri.ts @@ -1,268 +1,177 @@ import { invoke } from '@tauri-apps/api/tauri'; -import { - Connection, - ConnectionInfo, - ConnectionStatus, - QueryRequest, - QueryResult, - ModelInfo, - MLFunctionInfo, +import { ApiResponse, - QueryType, - QueryResultData + ClusterStatus, + Connection, + ConnectionInfo, + ConnectionStatus, + ConnectionTypeInfo, + MLFunctionInfo, + ModelInfo, + QueryHistoryEntry, + QueryRequest, + QueryResult, } from '@/types'; -// Check if running in Tauri environment -const isTauri = (): boolean => { +/** + * Whether the Tauri IPC bridge is present. + * + * Outside it (a plain `vite dev` in a browser) no command can run. Rather than + * substituting sample connections and rows — which look exactly like real ones + * and have historically been mistaken for them — every call fails with a clear + * message saying the desktop shell is required. + */ +export const isTauri = (): boolean => { try { - return globalThis.window !== undefined && - 'window' in globalThis && - '__TAURI_IPC__' in globalThis.window && - typeof globalThis.window.__TAURI_IPC__ === 'function'; + return ( + typeof globalThis.window !== 'undefined' && + typeof (globalThis.window as any).__TAURI_IPC__ === 'function' + ); } catch { return false; } }; -// Mock data for development/browser environment -const createMockConnections = (): Connection[] => [ - { - id: 'mock-postgres', - info: { - name: 'Mock PostgreSQL', - connection_type: 'PostgreSQL', - host: 'localhost', - port: 5432, - database: 'demo', - username: 'postgres', - password: null, - ssl_mode: null, - connection_timeout: null, - additional_params: {}, - }, - status: 'Connected', - created_at: new Date().toISOString(), - last_used: new Date().toISOString(), - query_count: 0, - }, - { - id: 'mock-orbitql', - info: { - name: 'Mock OrbitQL', - connection_type: 'OrbitQL', - host: 'localhost', - port: 8080, - database: null, - username: null, - password: null, - ssl_mode: null, - connection_timeout: null, - additional_params: {}, - }, - status: 'Connected', - created_at: new Date().toISOString(), - last_used: new Date().toISOString(), - query_count: 0, - } -]; - -const createMockQueryResult = (): QueryResult => ({ - success: true, - data: { - columns: [ - { name: 'id', column_type: 'integer' }, - { name: 'name', column_type: 'text' }, - { name: 'value', column_type: 'decimal' } - ], - rows: [ - { id: 1, name: 'Sample Data', value: 100.5 }, - { id: 2, name: 'Another Row', value: 250 } - ] - } as QueryResultData, - error: null, - execution_time: 23.5, - rows_affected: 2 -}); +const BROWSER_MODE_MESSAGE = + 'This action needs the Orbit Desktop application. The page is running in a ' + + 'plain browser, which has no connection to the database. Run `npm run dev` ' + + '(which starts Tauri) or launch the built app.'; -/** - * Helper function to handle Tauri API responses and throw errors if needed - */ -const handleApiResponse = (response: ApiResponse, fallbackError: string): T => { - if (!response.success || !response.data) { - throw new Error(response.error || fallbackError); +/** Invoke a Tauri command, unwrapping the `ApiResponse` envelope. */ +const call = async (command: string, args?: Record): Promise => { + if (!isTauri()) { + throw new Error(BROWSER_MODE_MESSAGE); } - return response.data; -}; -/** - * Helper function for boolean responses that don't return data - */ -const handleBooleanResponse = (response: ApiResponse, fallbackError: string): void => { + const response = await invoke>(command, args); if (!response.success) { - throw new Error(response.error || fallbackError); + throw new Error(response.error || `${command} failed`); } + // `data` is absent only for commands whose payload is genuinely empty. + return response.data as T; }; -/** - * Service for communicating with Tauri backend - */ +/** Service for communicating with the Tauri backend. */ export class TauriService { - // Connection Management - - static async createConnection(connectionInfo: ConnectionInfo): Promise { - const response: ApiResponse = await invoke('create_connection', { connectionInfo }); - return handleApiResponse(response, 'Failed to create connection'); + // ----------------------------- Connections ----------------------------- + + static createConnection(connectionInfo: ConnectionInfo): Promise { + return call('create_connection', { connectionInfo }); } - static async testConnection(connectionInfo: ConnectionInfo): Promise { - const response: ApiResponse = await invoke('test_connection', { connectionInfo }); - return handleApiResponse(response, 'Failed to test connection'); + static testConnection(connectionInfo: ConnectionInfo): Promise { + return call('test_connection', { connectionInfo }); } - static async getConnections(): Promise { - if (!isTauri()) { - // Return mock data for browser/dev environment - await new Promise(resolve => setTimeout(resolve, 100)); // Simulate delay - return createMockConnections(); - } - - const response: ApiResponse = await invoke('get_connections'); - return handleApiResponse(response, 'Failed to get connections'); + static getConnections(): Promise { + return call('get_connections'); } - static async disconnect(connectionId: string): Promise { - const response: ApiResponse = await invoke('disconnect', { connectionId }); - handleBooleanResponse(response, 'Failed to disconnect'); + /** Open a session now, so the UI can report whether a saved connection works. */ + static connect(connectionId: string): Promise { + return call('connect', { connectionId }); } - static async deleteConnection(connectionId: string): Promise { - const response: ApiResponse = await invoke('delete_connection', { connectionId }); - handleBooleanResponse(response, 'Failed to delete connection'); + static disconnect(connectionId: string): Promise { + return call('disconnect', { connectionId }).then(() => undefined); } - // Query Execution - - static async executeQuery(request: QueryRequest): Promise { - if (!isTauri()) { - // Return mock data for browser/dev environment - await new Promise(resolve => setTimeout(resolve, 200)); // Simulate execution time - return createMockQueryResult(); - } - - const response: ApiResponse = await invoke('execute_query', { request }); - return handleApiResponse(response, 'Failed to execute query'); + static deleteConnection(connectionId: string): Promise { + return call('delete_connection', { connectionId }).then(() => undefined); } - static async getQueryHistory(connectionId: string, limit?: number): Promise { - const response: ApiResponse = await invoke('get_query_history', { - connectionId, - limit - }); - return handleApiResponse(response, 'Failed to get query history'); + static listConnectionTypes(): Promise { + return call('list_connection_types'); } - static async explainQuery(request: QueryRequest): Promise { - const response: ApiResponse = await invoke('explain_query', { request }); - return handleApiResponse(response, 'Failed to explain query'); + // ------------------------------- Queries ------------------------------- + + static executeQuery(request: QueryRequest): Promise { + return call('execute_query', { request }); } - // ML Model Management - - static async listMlFunctions(): Promise { - if (!isTauri()) { - // Return mock ML functions for browser/dev environment - return [ - { - name: 'ML_XGBOOST', - category: 'Boosting', - description: 'XGBoost gradient boosting algorithm', - parameters: [], - example: 'SELECT ML_XGBOOST(features, target) FROM data;' - } - ]; - } - - const response: ApiResponse = await invoke('list_ml_functions'); - if (!response.success || !response.data) { - throw new Error(response.error || 'Failed to list ML functions'); - } - return response.data; + /** + * Ask for a plan. `analyze` runs the statement to collect real timings, so it + * is off unless the caller asks: EXPLAIN ANALYZE on a DELETE deletes. + */ + static explainQuery(request: QueryRequest, analyze = false): Promise { + return call('explain_query', { request, analyze }); } - static async listModels(connectionId: string): Promise { - const response: ApiResponse = await invoke('list_models', { connectionId }); - if (!response.success || !response.data) { - throw new Error(response.error || 'Failed to list models'); - } - return response.data; + static getQueryHistory(connectionId: string, limit?: number): Promise { + return call('get_query_history', { connectionId, limit }); } - static async getModelInfo(connectionId: string, modelName: string): Promise { - const response: ApiResponse = await invoke('get_model_info', { - connectionId, - modelName - }); - if (!response.success || !response.data) { - throw new Error(response.error || 'Failed to get model info'); - } - return response.data; + // ------------------------------- Cluster ------------------------------- + + static getClusterStatus(): Promise { + return call('get_cluster_status'); } - static async deleteModel(connectionId: string, modelName: string): Promise { - const response: ApiResponse = await invoke('delete_model', { - connectionId, - modelName - }); - if (!response.success) { - throw new Error(response.error || 'Failed to delete model'); - } + static setClusterRoot(path: string): Promise { + return call('set_cluster_root', { path }); } - // System Operations + /** Starts the cluster script and returns immediately; poll status for progress. */ + static startCluster(size: number): Promise { + return call('start_cluster', { size }).then(() => undefined); + } - static async getSystemInfo(): Promise> { - const response: ApiResponse> = await invoke('get_system_info'); - if (!response.success || !response.data) { - throw new Error(response.error || 'Failed to get system info'); - } - return response.data; + static stopCluster(): Promise { + return call('stop_cluster').then(() => undefined); } - static async saveSettings(settings: Record): Promise { - const response: ApiResponse = await invoke('save_settings', { settings }); - if (!response.success) { - throw new Error(response.error || 'Failed to save settings'); - } + /** Tail a node's log, or the start/stop control log when `nodeId` is omitted. */ + static getClusterLog(nodeId?: string, lines?: number): Promise { + return call('get_cluster_log', { nodeId, lines }); } - static async loadSettings(): Promise> { - const response: ApiResponse> = await invoke('load_settings'); - if (!response.success || !response.data) { - throw new Error(response.error || 'Failed to load settings'); - } - return response.data; + // ------------------------------ ML models ------------------------------ + + static listMlFunctions(): Promise { + return call('list_ml_functions'); + } + + static listModels(connectionId: string): Promise { + return call('list_models', { connectionId }); + } + + static getModelInfo(connectionId: string, modelName: string): Promise { + return call('get_model_info', { connectionId, modelName }); + } + + static deleteModel(connectionId: string, modelName: string): Promise { + return call('delete_model', { connectionId, modelName }).then(() => undefined); + } + + // ------------------------------- System -------------------------------- + + static getSystemInfo(): Promise> { + return call>('get_system_info'); + } + + static saveSettings(settings: Record): Promise { + return call('save_settings', { settings }).then(() => undefined); + } + + static loadSettings(): Promise> { + return call>('load_settings'); } static async showAboutDialog(): Promise { + if (!isTauri()) return; await invoke('show_about_dialog'); } } -/** - * Error handler for Tauri API calls - */ -export const handleTauriError = (error: any): string => { - if (typeof error === 'string') { - return error; - } - - if (error?.message) { - return error.message; +/** Reduce anything thrown by a command to a message worth displaying. */ +export const handleTauriError = (error: unknown): string => { + if (typeof error === 'string') return error; + if (error instanceof Error) return error.message; + if (error && typeof error === 'object') { + const candidate = error as { message?: unknown; error?: unknown }; + if (typeof candidate.message === 'string') return candidate.message; + if (typeof candidate.error === 'string') return candidate.error; } - - if (error?.error) { - return error.error; - } - return 'An unexpected error occurred'; -}; \ No newline at end of file +}; diff --git a/orbit/desktop/src/types/index.ts b/orbit/desktop/src/types/index.ts index 28a9ca1d..1d796bb8 100644 --- a/orbit/desktop/src/types/index.ts +++ b/orbit/desktop/src/types/index.ts @@ -1,10 +1,15 @@ -// Type definitions for Orbit Desktop +// Type definitions for Orbit Desktop. +// +// These mirror the Tauri command payloads in `src-tauri/src`. Keep them in step +// with `connections.rs`, `queries.rs` and `cluster.rs` — there is no codegen +// between the two, so a rename on one side is a silent break on the other. export interface Connection { id: string; info: ConnectionInfo; status: ConnectionStatus; - created_at: string; + /** Null when the persisted record carried no readable timestamp. */ + created_at: string | null; last_used: string | null; query_count: number; } @@ -14,39 +19,64 @@ export interface ConnectionInfo { connection_type: ConnectionType; host: string; port: number; - database?: string; - username?: string; - password?: string; - ssl_mode?: string; - connection_timeout?: number; + database?: string | null; + username?: string | null; + password?: string | null; + ssl_mode?: string | null; + /** Connect/handshake timeout in milliseconds. */ + connection_timeout?: number | null; additional_params: Record; } export enum ConnectionType { PostgreSQL = 'PostgreSQL', - OrbitQL = 'OrbitQL', - Redis = 'Redis', MySQL = 'MySQL', + Redis = 'Redis', CQL = 'CQL', + OrbitQL = 'OrbitQL', Cypher = 'Cypher', AQL = 'AQL', + FlightSQL = 'FlightSQL', + OrbitWire = 'OrbitWire', } -export enum ConnectionStatus { - Connected = 'Connected', - Disconnected = 'Disconnected', - Connecting = 'Connecting', - Error = 'Error', +/** + * Serde's external tagging: unit variants arrive as bare strings, the error + * variant as `{ Error: "..." }`. + */ +export type ConnectionStatus = + | 'Connected' + | 'Disconnected' + | { Error: string }; + +export const isConnected = (status: ConnectionStatus): boolean => + status === 'Connected'; + +export const connectionStatusError = (status: ConnectionStatus): string | null => + typeof status === 'object' && status !== null && 'Error' in status + ? status.Error + : null; + +/** A connection type as offered by the backend, with its default port. */ +export interface ConnectionTypeInfo { + id: ConnectionType; + default_port: number; + /** + * False for the HTTP-backed protocols. Their handlers in orbit-server still + * return fixed example rows, so results are a protocol check, not data. + */ + native_wire_protocol: boolean; } export interface QueryRequest { connection_id: string; query: string; - query_type: QueryType; - parameters?: Record; - timeout?: number; + /** Statement timeout in milliseconds; the backend defaults to 30s. */ + timeout_ms?: number | null; } +/** Which language the editor highlights. Presentation only — execution + * dispatches on the connection's protocol, not on this. */ export enum QueryType { SQL = 'SQL', OrbitQL = 'OrbitQL', @@ -57,70 +87,127 @@ export enum QueryType { AQL = 'AQL', } +/** + * What a statement did. `returned` and `affected` are different facts and are + * kept apart deliberately. + */ +export type StatementOutcome = + | { kind: 'returned'; rows: number } + | { kind: 'affected'; rows: number } + | { kind: 'completed' }; + +export const describeOutcome = (outcome: StatementOutcome): string => { + switch (outcome.kind) { + case 'returned': + return `${outcome.rows} row${outcome.rows === 1 ? '' : 's'} returned`; + case 'affected': + return `${outcome.rows} row${outcome.rows === 1 ? '' : 's'} affected`; + case 'completed': + return 'Completed'; + } +}; + export interface QueryResult { success: boolean; - data?: QueryResultData; - error?: string; - execution_time: number; - rows_affected?: number; - timestamp?: string; + data?: QueryResultData | null; + error?: string | null; + execution_time_ms: number; + /** A caveat the grid alone cannot convey, e.g. a mocked server endpoint. */ + notice?: string | null; } export interface QueryResultData { columns: Column[]; rows: Row[]; - metadata?: Record; + outcome: StatementOutcome; } export interface Column { name: string; + /** The server's own type name. */ type: string; - nullable: boolean; } export type Row = Record; -// ML Model types +export interface QueryHistoryEntry { + id: string; + connection_id: string; + query: string; + executed_at: string; + execution_time_ms: number; + success: boolean; + error?: string | null; + outcome?: StatementOutcome | null; +} + +// --------------------------------------------------------------------------- +// Cluster lifecycle +// --------------------------------------------------------------------------- + +/** + * Whether a node's process is alive. `Exited` means a pid file exists but that + * process is gone — a crash or an unclean stop. + */ +export type ProcessState = + | { state: 'running'; pid: number; uptime_seconds: number } + | { state: 'exited'; pid: number }; + +export interface Endpoint { + protocol: string; + port: number; + /** Whether the port accepted a TCP connection at `checked_at`. */ + reachable: boolean; +} + +export interface ClusterNode { + node_id: string; + process: ProcessState; + /** Read from the live process's command line. Empty when it is not running: + * the ports it would use are a guess, not an observation. */ + endpoints: Endpoint[]; + log_file: string; +} + +export interface ClusterStatus { + root: string; + /** False when no cluster has ever been started in this checkout. */ + initialized: boolean; + nodes: ClusterNode[]; + running_nodes: number; + /** Nodes that are both alive and answering on at least one port. */ + serving_nodes: number; + checked_at: string; +} + +// ML types — these mirror `models.rs`. + export interface ModelInfo { + id: string; name: string; - algorithm: string; - accuracy: number; - training_samples: number; - feature_count: number; - size_bytes: number; + model_type: string; status: ModelStatus; + accuracy?: number | null; created_at: string; - updated_at: string; - version: string; - description?: string; + last_trained?: string | null; + features: string[]; + target?: string | null; + metadata: Record; } export enum ModelStatus { Training = 'Training', Ready = 'Ready', Error = 'Error', - Deprecated = 'Deprecated', + Deleted = 'Deleted', } export interface MLFunctionInfo { name: string; + category: string; description: string; - category: MLFunctionCategory; parameters: MLParameter[]; - return_type: string; - examples: string[]; -} - -export enum MLFunctionCategory { - ModelManagement = 'ModelManagement', - Statistical = 'Statistical', - SupervisedLearning = 'SupervisedLearning', - UnsupervisedLearning = 'UnsupervisedLearning', - BoostingAlgorithms = 'BoostingAlgorithms', - FeatureEngineering = 'FeatureEngineering', - VectorOperations = 'VectorOperations', - TimeSeries = 'TimeSeries', - NLP = 'NLP', + example: string; } export interface MLParameter { @@ -128,7 +215,6 @@ export interface MLParameter { param_type: string; required: boolean; description: string; - default_value?: any; } // API Response wrapper @@ -189,7 +275,9 @@ export interface ChartConfig { type: 'line' | 'bar' | 'pie' | 'scatter' | 'area'; title: string; x_axis: string; - y_axis: string | string[]; + /** A single column. Multi-series charts are not built here, and the union + * that allowed for them only ever produced invalid row-index expressions. */ + y_axis: string; color_scheme: string[]; show_legend: boolean; show_grid: boolean; diff --git a/orbit/desktop/src/utils/queryFormatter.ts b/orbit/desktop/src/utils/queryFormatter.ts new file mode 100644 index 00000000..efce8801 --- /dev/null +++ b/orbit/desktop/src/utils/queryFormatter.ts @@ -0,0 +1,89 @@ +/** + * Query formatting, hardened against regex denial-of-service. + * + * Extracted from `QueryEditor` so the safety tests exercise the code the editor + * actually runs. Previously the test file carried its own copy of this logic, + * which meant a change here could not fail a test. + */ + +/** Inputs above this are rejected rather than formatted. */ +export const MAX_QUERY_SIZE = 1024 * 100; + +/** Wall-clock budget for one format call. */ +export const DEFAULT_FORMAT_TIMEOUT_MS = 5000; + +/** Keywords placed on their own line, applied one at a time so the pattern + * never contains alternation that could backtrack. */ +const LINE_BREAK_KEYWORDS = [ + 'SELECT', + 'FROM', + 'WHERE', + 'JOIN', + 'GROUP BY', + 'HAVING', + 'ORDER BY', + 'LIMIT', +] as const; + +/** + * Format a SQL-like statement. + * + * Every pattern here is linear-time: no nested quantifiers and no alternation + * over user input. The size cap and the elapsed-time check between passes are + * belt-and-braces in case a future edit introduces one. + * + * Note that clause keywords are matched case-insensitively and replaced with + * their canonical spelling, so `select` comes back as `SELECT`. + * + * @throws If `input` exceeds {@link MAX_QUERY_SIZE}, or formatting runs past + * `timeoutMs`. + */ +export const safeFormatQuery = ( + input: string, + timeoutMs: number = DEFAULT_FORMAT_TIMEOUT_MS +): string => { + if (input.length > MAX_QUERY_SIZE) { + throw new Error( + `Query too large for formatting (${input.length} chars, max: ${MAX_QUERY_SIZE})` + ); + } + + const start = Date.now(); + const checkTimeout = () => { + if (Date.now() - start > timeoutMs) { + throw new Error('Query formatting timeout - potential ReDoS detected'); + } + }; + + let result = input; + + checkTimeout(); + // Collapse runs of whitespace. Single character class, no backtracking. + result = result.replace(/[ \t\r\n]+/g, ' '); + + checkTimeout(); + result = result.replace(/[ \t]*,[ \t]*/g, ',\n '); + + for (const keyword of LINE_BREAK_KEYWORDS) { + checkTimeout(); + result = result.replace(new RegExp(`\\b${keyword}\\b`, 'gi'), `\n${keyword}`); + } + + checkTimeout(); + result = result.replace(/^[ \t]+/gm, ' '); + + return result.trim(); +}; + +/** + * Collapse whitespace without regex backtracking risk. + * + * Used as the fallback when {@link safeFormatQuery} refuses an input, so the + * button still does something predictable on a very large query. + */ +export const collapseWhitespace = (input: string): string => + input + .split(/\s+/) + .filter(word => word.length > 0) + .join(' ') + .trim(); diff --git a/orbit/desktop/src/utils/queryFormatterSafety.test.ts b/orbit/desktop/src/utils/queryFormatterSafety.test.ts index d81ac71a..bb5dce77 100644 --- a/orbit/desktop/src/utils/queryFormatterSafety.test.ts +++ b/orbit/desktop/src/utils/queryFormatterSafety.test.ts @@ -1,213 +1,128 @@ /** - * ReDoS Safety Tests for Query Formatter - * - * These tests verify that the regex patterns used in query formatting - * are safe from Regular Expression Denial of Service (ReDoS) attacks. + * ReDoS safety tests for the query formatter. + * + * These import the formatter the editor actually calls. An earlier version of + * this file re-declared the logic inline, so it could not fail when the real + * implementation changed. */ -import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; - -// Mock the formatter function from QueryEditor.tsx -const safeFormatQuery = (input: string): string => { - const MAX_QUERY_SIZE = 1024 * 100; // 100KB limit - if (input.length > MAX_QUERY_SIZE) { - throw new Error(`Query too large for formatting (${input.length} chars, max: ${MAX_QUERY_SIZE})`); - } - - const formatWithTimeout = (text: string, timeoutMs: number = 5000): string => { - const start = Date.now(); - - const checkTimeout = () => { - if (Date.now() - start > timeoutMs) { - throw new Error('Query formatting timeout - potential ReDoS detected'); - } - }; - - let result = text; - - checkTimeout(); - // Safe: atomic group prevents backtracking on whitespace sequences - result = result.replace(/(?:[ \t\r\n])+/g, ' '); - - checkTimeout(); - // Safe: limited quantifiers with character classes - result = result.replace(/[ \t]*,[ \t]*/g, ',\n '); - - // Safe: individual keyword replacements avoid alternation backtracking - const keywords = ['SELECT', 'FROM', 'WHERE', 'JOIN', 'GROUP BY', 'HAVING', 'ORDER BY', 'LIMIT']; - for (const keyword of keywords) { - checkTimeout(); - const regex = new RegExp(`\\b${keyword}\\b`, 'gi'); - result = result.replace(regex, `\n${keyword}`); - } - - checkTimeout(); - // Safe: anchored pattern with character class, no backtracking - result = result.replace(/^[ \t]+/gm, ' '); - - return result.trim(); - }; - - return formatWithTimeout(input); -}; - -describe('Query Formatter ReDoS Safety Tests', () => { - let consoleErrorSpy: jest.SpyInstance; - - beforeEach(() => { - consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - }); +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + MAX_QUERY_SIZE, + collapseWhitespace, + safeFormatQuery, +} from './queryFormatter'; +describe('safeFormatQuery', () => { afterEach(() => { - consoleErrorSpy.mockRestore(); + vi.restoreAllMocks(); }); - describe('Input Size Limits', () => { - it('should reject queries larger than 100KB', () => { - const largeQuery = 'SELECT * FROM table WHERE ' + 'a'.repeat(1024 * 101); - - expect(() => safeFormatQuery(largeQuery)).toThrow( - /Query too large for formatting/ - ); + describe('input size limits', () => { + it('rejects queries larger than the cap', () => { + const large = `SELECT * FROM t WHERE ${'a'.repeat(MAX_QUERY_SIZE + 1)}`; + expect(() => safeFormatQuery(large)).toThrow(/Query too large for formatting/); }); - it('should accept queries within size limits', () => { - const normalQuery = 'SELECT * FROM table WHERE col = 1'; - - expect(() => safeFormatQuery(normalQuery)).not.toThrow(); + it('accepts queries within the cap', () => { + expect(() => safeFormatQuery('SELECT * FROM t WHERE col = 1')).not.toThrow(); }); }); - describe('ReDoS Attack Patterns', () => { - it('should handle catastrophic backtracking patterns safely', () => { - // Classic ReDoS pattern that would cause exponential backtracking in vulnerable regex - const maliciousInput = 'SELECT' + ' '.repeat(10000) + 'FROM' + '\t'.repeat(10000) + 'WHERE'; - + describe('pathological inputs complete in linear time', () => { + it('handles long whitespace runs', () => { + const input = `SELECT${' '.repeat(10000)}FROM${'\t'.repeat(10000)}WHERE`; + const start = Date.now(); - const result = safeFormatQuery(maliciousInput); - const executionTime = Date.now() - start; - - // Should complete in reasonable time (< 1 second) - expect(executionTime).toBeLessThan(1000); + const result = safeFormatQuery(input); + expect(Date.now() - start).toBeLessThan(1000); + expect(result).toContain('SELECT'); expect(result).toContain('FROM'); expect(result).toContain('WHERE'); }); - it('should handle nested quantifier patterns without exponential time', () => { - // Pattern that could cause ReDoS: repeated whitespace with alternation - const nestedPattern = 'SELECT' + ' \t \n '.repeat(1000) + 'FROM table'; - + it('handles repeated mixed whitespace without blowing up', () => { + const input = `SELECT${' \t \n '.repeat(1000)}FROM table`; + const start = Date.now(); - const result = safeFormatQuery(nestedPattern); - const executionTime = Date.now() - start; - - // Should complete quickly - expect(executionTime).toBeLessThan(500); + const result = safeFormatQuery(input); + expect(Date.now() - start).toBeLessThan(500); + expect(result).toMatch(/SELECT\s+FROM/); }); - it('should timeout on extremely long processing', () => { - // Create a pattern that would take very long if not protected - const extremeInput = 'SELECT ' + '/* ' + 'a'.repeat(50000) + ' */ FROM table'; - - // Mock Date.now to simulate timeout condition - const originalDateNow = Date.now; - let callCount = 0; - Date.now = jest.fn(() => { - callCount++; - // Simulate timeout after several calls - return callCount > 5 ? originalDateNow() + 10000 : originalDateNow(); - }); + it('handles a very long comment body', () => { + const input = `SELECT /* ${'a'.repeat(50000)} */ FROM table`; - try { - expect(() => safeFormatQuery(extremeInput)).toThrow( - /Query formatting timeout/ - ); - } finally { - Date.now = originalDateNow; - } + const start = Date.now(); + expect(() => safeFormatQuery(input)).not.toThrow(); + expect(Date.now() - start).toBeLessThan(1000); }); }); - describe('Regex Pattern Safety', () => { - it('should use linear time complexity for whitespace normalization', () => { - const inputs = [ - ' SELECT FROM table ', - '\t\t\tSELECT\n\n\nFROM\r\r\rtable', - ' '.repeat(1000) + 'SELECT' + '\n'.repeat(1000) + 'FROM' - ]; - - inputs.forEach(input => { - const start = Date.now(); - const result = safeFormatQuery(input); - const executionTime = Date.now() - start; - - expect(executionTime).toBeLessThan(100); - expect(result).not.toMatch(/\s{2,}/); // Should not have multiple consecutive spaces + describe('timeout guard', () => { + it('aborts once the clock passes the budget mid-format', () => { + // The guard is what stops a pathological pattern running unbounded, so + // drive the clock rather than trusting a real format to be slow. + const real = Date.now(); + let call = 0; + vi.spyOn(Date, 'now').mockImplementation(() => { + call += 1; + // First call sets the start; later calls appear far in the future. + return call === 1 ? real : real + 60_000; }); - }); - it('should handle comma formatting without backtracking', () => { - const commaHeavyQuery = 'SELECT col1,col2, col3 ,col4, col5 FROM table'; - - const start = Date.now(); - const result = safeFormatQuery(commaHeavyQuery); - const executionTime = Date.now() - start; - - expect(executionTime).toBeLessThan(50); - expect(result).toMatch(/,\s*\n/g); // Commas should be followed by newlines + expect(() => safeFormatQuery('SELECT a, b FROM t')).toThrow( + /Query formatting timeout/ + ); }); - it('should format SQL keywords without alternation backtracking', () => { - const keywordQuery = 'select col from table where id = 1 group by col having count > 0 order by col limit 10'; - - const start = Date.now(); - const result = safeFormatQuery(keywordQuery); - const executionTime = Date.now() - start; - - expect(executionTime).toBeLessThan(100); - // Each keyword should be on its own line - expect(result).toMatch(/\nSELECT/); - expect(result).toMatch(/\nFROM/); - expect(result).toMatch(/\nWHERE/); - expect(result).toMatch(/\nGROUP BY/); + it('does not abort a normal query under the default budget', () => { + expect(() => safeFormatQuery('SELECT a, b FROM t')).not.toThrow(); }); }); - describe('Edge Cases', () => { - it('should handle empty input safely', () => { - expect(() => safeFormatQuery('')).not.toThrow(); - expect(safeFormatQuery('')).toBe(''); + describe('formatting behaviour', () => { + it('puts clause keywords on their own lines and normalises their case', () => { + const result = safeFormatQuery('select a from t where a = 1 order by a'); + const lines = result.split('\n').map(line => line.trim()); + + // Keyword replacement substitutes the canonical spelling, so lowercase + // input comes back uppercased. + expect(lines).toContain('SELECT a'); + expect(lines.some(line => line.startsWith('FROM'))).toBe(true); + expect(lines.some(line => line.startsWith('WHERE'))).toBe(true); + expect(lines.some(line => line.startsWith('ORDER BY'))).toBe(true); }); - it('should handle input with only whitespace', () => { - const whitespaceOnly = ' \t\t\n\n '; - const result = safeFormatQuery(whitespaceOnly); - expect(result).toBe(''); + it('breaks select lists on commas', () => { + expect(safeFormatQuery('SELECT a,b FROM t')).toContain(',\n'); }); - it('should handle unicode and special characters safely', () => { - const unicodeQuery = 'SELECT 你好, прив世ет FROM tåble_ñame WHERE çøl = "spéçiål"'; - - expect(() => safeFormatQuery(unicodeQuery)).not.toThrow(); - const result = safeFormatQuery(unicodeQuery); - expect(result).toContain('你好'); - expect(result).toContain('прив世ет'); + it('is idempotent: formatting twice matches formatting once', () => { + const once = safeFormatQuery('SELECT a, b FROM t WHERE a = 1'); + expect(safeFormatQuery(once)).toBe(once); }); - it('should preserve query semantics while formatting', () => { - const originalQuery = 'SELECT id,name FROM users WHERE active=1 ORDER BY created_at LIMIT 100'; - const formatted = safeFormatQuery(originalQuery); - - // Should contain all original elements - expect(formatted).toContain('SELECT'); - expect(formatted).toContain('id'); - expect(formatted).toContain('name'); - expect(formatted).toContain('FROM users'); - expect(formatted).toContain('WHERE active=1'); - expect(formatted).toContain('ORDER BY'); - expect(formatted).toContain('LIMIT 100'); + it('leaves an empty input empty', () => { + expect(safeFormatQuery('')).toBe(''); + expect(safeFormatQuery(' \n\t ')).toBe(''); }); }); -}); \ No newline at end of file +}); + +describe('collapseWhitespace', () => { + it('reduces every whitespace run to a single space', () => { + expect(collapseWhitespace('SELECT \t\n a FROM t ')).toBe('SELECT a FROM t'); + }); + + it('handles input that is only whitespace', () => { + expect(collapseWhitespace(' \t\n ')).toBe(''); + }); + + it('accepts input far larger than the formatter cap', () => { + const huge = `SELECT ${'a '.repeat(MAX_QUERY_SIZE)}`; + expect(() => collapseWhitespace(huge)).not.toThrow(); + }); +}); From d228ec343f78a7bae21dd15dda7130bd1c223dcc Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Wed, 5 Aug 2026 21:48:15 -0700 Subject: [PATCH 25/36] feat(llm): complete the orbit-llm provider abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the crate whose first half landed in cae34bcb (that snapshot had no lib.rs and could not build). Adds the registry, router, providers, usage accounting, HTTP layer, and legacy compat shim. A model gateway inside the database process: unified API over four wire shapes covering ~15 named services, named model profiles switchable at runtime, fallback chains, retries, circuit breaking, timeouts, and cost accounting — without a separate deployment, and with retrieval, vector index, and generation sharing one process and one security boundary. Layout: - provider.rs / providers/ LlmProvider + EmbeddingProvider traits and the OpenAI, Anthropic, Ollama, and generic OpenAI-compatible implementations - registry.rs named, hot-swappable ModelProfiles - router.rs timeout -> retry -> breaker -> fallback -> accounting; providers do HTTP shaping only, so resilience is identical across backends - usage.rs token/cost/latency counters - http.rs one pooled reqwest client - compat.rs graphrag::LLMProvider -> ModelProfile Fixes carried over from the hand-rolled GraphRAG clients this replaces: - Anthropic worked at all. It previously returned Err("Anthropic client not yet implemented") for a provider the config enum advertised. - temperature/max_tokens reach the wire. The old factory accepted them, bound them to `_`, and discarded them; the caller compensated by matching the enum a second time. - One pooled HTTP client instead of Client::new() per request, which rebuilt the connection pool and TLS session on every call. - Bounded per-attempt timeouts. A hung provider could previously pin a database query open indefinitely. - Credentials are SecretString; Debug, Display, and Serialize redact. Modelling honesty, enforced by the types: - Token counts are Option. Most local servers report nothing, and unwrap_or(0) would assert a request used no tokens. - Cost is Option, computed only from configured prices. No price table is bundled: one baked into a database binary goes stale silently and then reports confident wrong costs. - A fallback that fires is logged, counted, and named in the response. 160 tests, including request-body shaping asserted against each vendor's documented format and an in-process stub that exercises the router's retry, breaker, and failover paths deterministically. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 20 + Cargo.toml | 1 + orbit/llm/src/breaker.rs | 3 +- orbit/llm/src/compat.rs | 301 +++++++++ orbit/llm/src/config.rs | 108 ++- orbit/llm/src/http.rs | 185 +++++ orbit/llm/src/lib.rs | 104 +++ orbit/llm/src/provider.rs | 1 + orbit/llm/src/providers/anthropic.rs | 361 ++++++++++ orbit/llm/src/providers/compatible.rs | 249 +++++++ orbit/llm/src/providers/mod.rs | 210 ++++++ orbit/llm/src/providers/ollama.rs | 327 +++++++++ orbit/llm/src/providers/openai.rs | 165 +++++ orbit/llm/src/providers/openai_shape.rs | 381 +++++++++++ orbit/llm/src/registry.rs | 680 +++++++++++++++++++ orbit/llm/src/router.rs | 865 ++++++++++++++++++++++++ orbit/llm/src/secret.rs | 5 + orbit/llm/src/testing.rs | 158 +++++ orbit/llm/src/types.rs | 21 + orbit/llm/src/usage.rs | 278 ++++++++ 20 files changed, 4419 insertions(+), 4 deletions(-) create mode 100644 orbit/llm/src/compat.rs create mode 100644 orbit/llm/src/http.rs create mode 100644 orbit/llm/src/lib.rs create mode 100644 orbit/llm/src/providers/anthropic.rs create mode 100644 orbit/llm/src/providers/compatible.rs create mode 100644 orbit/llm/src/providers/mod.rs create mode 100644 orbit/llm/src/providers/ollama.rs create mode 100644 orbit/llm/src/providers/openai.rs create mode 100644 orbit/llm/src/providers/openai_shape.rs create mode 100644 orbit/llm/src/registry.rs create mode 100644 orbit/llm/src/router.rs create mode 100644 orbit/llm/src/testing.rs create mode 100644 orbit/llm/src/usage.rs diff --git a/Cargo.lock b/Cargo.lock index 356c6336..45b7a420 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7579,6 +7579,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "orbit-llm" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "fastrand", + "futures", + "orbit-shared", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-test", + "toml 0.9.12+spec-1.1.0", + "tracing", +] + [[package]] name = "orbit-ml" version = "0.1.0" @@ -7715,6 +7734,7 @@ dependencies = [ "orbit-client", "orbit-compute", "orbit-engine", + "orbit-llm", "orbit-ml", "orbit-proto", "orbit-shared", diff --git a/Cargo.toml b/Cargo.toml index f0291fc1..1ee6eb7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "orbit/operator", "orbit/compute", "orbit/ml", + "orbit/llm", "orbit/cli", # Tests diff --git a/orbit/llm/src/breaker.rs b/orbit/llm/src/breaker.rs index 7f0523a1..4ace4d98 100644 --- a/orbit/llm/src/breaker.rs +++ b/orbit/llm/src/breaker.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; -use std::time::{Duration, Instant}; +use std::time::Instant; /// Breaker tuning. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -226,6 +226,7 @@ impl CircuitBreaker { #[cfg(test)] mod tests { use super::*; + use std::time::Duration; fn breaker() -> CircuitBreaker { CircuitBreaker::new(BreakerConfig { diff --git a/orbit/llm/src/compat.rs b/orbit/llm/src/compat.rs new file mode 100644 index 00000000..f1fefcb6 --- /dev/null +++ b/orbit/llm/src/compat.rs @@ -0,0 +1,301 @@ +//! Conversion from the pre-existing `orbit_shared::graphrag::LLMProvider` into a +//! [`ModelProfile`]. +//! +//! `LLMProvider` is public API, re-exported from `orbit_shared`'s crate root and referenced by the +//! RESP, Cypher, AQL, and PostgreSQL GraphRAG engines. Breaking it would ripple across four +//! protocol surfaces for no user-visible benefit, so it stays as the on-the-wire shape and converts +//! into a profile at the boundary. +//! +//! The conversion is where a long-standing defect is fixed. `create_llm_client` used to accept +//! `temperature` and `max_tokens` and immediately bind them to `_`, leaving the caller to re-derive +//! them with a second `match` over the same enum. Here they land in +//! [`crate::GenerationParams`] and are carried all the way to the request body — verified by the +//! provider tests, which assert every configured parameter appears on the wire. + +use crate::config::{ModelProfile, ProviderConfig}; +use crate::error::{LlmError, LlmResult}; +use crate::secret::SecretString; +use crate::types::GenerationParams; +use orbit_shared::graphrag::LLMProvider; + +/// Token cap applied to an Anthropic profile that arrives without one. +/// +/// Anthropic's Messages API requires `max_tokens` and offers no server-side default, so a request +/// without one cannot be sent at all. The legacy enum makes it `Option`, so a value is needed here. +/// It is a documented constant rather than an invented per-model guess, and any caller that cares +/// sets `max_tokens` explicitly. +pub const ANTHROPIC_REQUIRED_MAX_TOKENS: u32 = 4_096; + +/// Build a named [`ModelProfile`] from a legacy provider description. +/// +/// # Errors +/// +/// Returns [`LlmError::Configuration`] if the resulting profile is not usable — most often a +/// missing API key. +pub fn profile_from_legacy( + name: impl Into, + provider: &LLMProvider, +) -> LlmResult { + let name = name.into(); + + let profile = match provider { + LLMProvider::OpenAI { + api_key, + model, + temperature, + max_tokens, + } => ModelProfile::new( + name, + ProviderConfig::OpenAi { + api_key: SecretString::new(api_key.clone()), + base_url: "https://api.openai.com/v1".to_string(), + organization: None, + project: None, + }, + model.clone(), + ) + .with_params(GenerationParams { + temperature: *temperature, + max_tokens: *max_tokens, + ..Default::default() + }), + + LLMProvider::Anthropic { + api_key, + model, + temperature, + max_tokens, + } => ModelProfile::new( + name, + ProviderConfig::Anthropic { + api_key: SecretString::new(api_key.clone()), + base_url: "https://api.anthropic.com/v1".to_string(), + version: "2023-06-01".to_string(), + }, + model.clone(), + ) + .with_params(GenerationParams { + temperature: *temperature, + max_tokens: Some(max_tokens.unwrap_or(ANTHROPIC_REQUIRED_MAX_TOKENS)), + ..Default::default() + }), + + LLMProvider::Ollama { model, temperature } => ModelProfile::new( + name, + ProviderConfig::Ollama { + base_url: "http://localhost:11434".to_string(), + }, + model.clone(), + ) + .with_params(GenerationParams { + temperature: *temperature, + ..Default::default() + }), + + LLMProvider::Local { + endpoint, + model, + temperature, + max_tokens, + } => ModelProfile::new( + name, + ProviderConfig::Compatible { + flavor: crate::config::CompatibleFlavor::Generic, + api_key: None, + base_url: strip_chat_completions(endpoint), + api_version: None, + }, + model.clone(), + ) + .with_params(GenerationParams { + temperature: *temperature, + max_tokens: *max_tokens, + ..Default::default() + }), + }; + + profile.validate()?; + Ok(profile) +} + +impl TryFrom<&LLMProvider> for ModelProfile { + type Error = LlmError; + + /// Convert using the provider's own name as the profile name. + fn try_from(provider: &LLMProvider) -> Result { + profile_from_legacy(legacy_provider_name(provider), provider) + } +} + +/// The conventional profile name for a legacy provider variant. +#[must_use] +pub fn legacy_provider_name(provider: &LLMProvider) -> &'static str { + match provider { + LLMProvider::OpenAI { .. } => "openai", + LLMProvider::Anthropic { .. } => "anthropic", + LLMProvider::Ollama { .. } => "ollama", + LLMProvider::Local { .. } => "local", + } +} + +/// Normalize a legacy `Local` endpoint into a base URL. +/// +/// The old `LocalLLMClient` POSTed to the configured endpoint verbatim, so existing configurations +/// carry a full `.../v1/chat/completions` URL. The compatible provider appends the route itself, so +/// the suffix is trimmed to avoid producing `/v1/chat/completions/chat/completions`. +fn strip_chat_completions(endpoint: &str) -> String { + let trimmed = endpoint.trim_end_matches('/'); + trimmed + .strip_suffix("/chat/completions") + .unwrap_or(trimmed) + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::provider::ProviderKind; + + #[test] + fn openai_parameters_survive_the_conversion() { + let legacy = LLMProvider::OpenAI { + api_key: "sk-legacy".into(), + model: "gpt-4o-mini".into(), + temperature: Some(0.35), + max_tokens: Some(1500), + }; + let profile = profile_from_legacy("openai", &legacy).expect("converts"); + + assert_eq!(profile.provider.kind(), ProviderKind::OpenAi); + assert_eq!(profile.model, "gpt-4o-mini"); + assert_eq!( + profile.params.temperature, + Some(0.35), + "the old factory bound temperature to `_` and dropped it" + ); + assert_eq!(profile.params.max_tokens, Some(1500)); + } + + #[test] + fn anthropic_converts_instead_of_erroring() { + // The pre-existing create_llm_client returned Err("Anthropic client not yet implemented") + // for this exact input. + let legacy = LLMProvider::Anthropic { + api_key: "sk-ant-legacy".into(), + model: "claude-sonnet-4-5".into(), + temperature: Some(0.2), + max_tokens: Some(2048), + }; + let profile = profile_from_legacy("anthropic", &legacy).expect("converts"); + + assert_eq!(profile.provider.kind(), ProviderKind::Anthropic); + assert_eq!(profile.params.max_tokens, Some(2048)); + assert_eq!(profile.params.temperature, Some(0.2)); + } + + #[test] + fn anthropic_without_max_tokens_gets_the_documented_minimum() { + let legacy = LLMProvider::Anthropic { + api_key: "sk-ant".into(), + model: "claude-sonnet-4-5".into(), + temperature: None, + max_tokens: None, + }; + let profile = profile_from_legacy("anthropic", &legacy).expect("converts"); + assert_eq!( + profile.params.max_tokens, + Some(ANTHROPIC_REQUIRED_MAX_TOKENS), + "the Messages API rejects a request without one" + ); + } + + #[test] + fn ollama_keeps_its_temperature_and_needs_no_credential() { + let legacy = LLMProvider::Ollama { + model: "llama3.2".into(), + temperature: Some(0.7), + }; + let profile = profile_from_legacy("ollama", &legacy).expect("converts"); + assert_eq!(profile.provider.kind(), ProviderKind::Ollama); + assert_eq!(profile.params.temperature, Some(0.7)); + } + + #[test] + fn a_legacy_local_endpoint_is_normalized_to_a_base_url() { + let legacy = LLMProvider::Local { + endpoint: "http://localhost:8000/v1/chat/completions".into(), + model: "Qwen3-8B".into(), + temperature: Some(0.1), + max_tokens: Some(256), + }; + let profile = profile_from_legacy("local", &legacy).expect("converts"); + assert_eq!( + profile.provider.base_url(), + "http://localhost:8000/v1", + "the compatible provider appends the route itself" + ); + assert_eq!(profile.params.max_tokens, Some(256)); + } + + #[test] + fn a_local_endpoint_that_is_already_a_base_url_is_left_alone() { + let legacy = LLMProvider::Local { + endpoint: "http://localhost:8000/v1".into(), + model: "m".into(), + temperature: None, + max_tokens: None, + }; + let profile = profile_from_legacy("local", &legacy).expect("converts"); + assert_eq!(profile.provider.base_url(), "http://localhost:8000/v1"); + } + + #[test] + fn a_credentialless_openai_provider_is_rejected_at_conversion() { + let legacy = LLMProvider::OpenAI { + api_key: String::new(), + model: "gpt-4o".into(), + temperature: None, + max_tokens: None, + }; + let err = profile_from_legacy("openai", &legacy).expect_err("no credential"); + assert!(err.to_string().contains("api_key")); + } + + #[test] + fn every_legacy_variant_has_a_conventional_name_and_converts() { + // Guards the affordance audit: a variant with no arm here would be a configuration the + // GraphRAG engines accept and the router cannot serve. + let variants = [ + LLMProvider::OpenAI { + api_key: "k".into(), + model: "m".into(), + temperature: None, + max_tokens: None, + }, + LLMProvider::Anthropic { + api_key: "k".into(), + model: "m".into(), + temperature: None, + max_tokens: None, + }, + LLMProvider::Ollama { + model: "m".into(), + temperature: None, + }, + LLMProvider::Local { + endpoint: "http://localhost:8000/v1".into(), + model: "m".into(), + temperature: None, + max_tokens: None, + }, + ]; + + let names: Vec<_> = variants.iter().map(legacy_provider_name).collect(); + assert_eq!(names, vec!["openai", "anthropic", "ollama", "local"]); + + for variant in &variants { + let profile = ModelProfile::try_from(variant).expect("every variant converts"); + assert!(!profile.name.is_empty()); + } + } +} diff --git a/orbit/llm/src/config.rs b/orbit/llm/src/config.rs index 96fbde92..0c7b562d 100644 --- a/orbit/llm/src/config.rs +++ b/orbit/llm/src/config.rs @@ -65,6 +65,7 @@ impl CompatibleFlavor { #[non_exhaustive] pub enum ProviderConfig { /// OpenAI's own API. + #[serde(rename = "openai")] OpenAi { /// API credential. #[serde(default)] @@ -102,6 +103,22 @@ pub enum ProviderConfig { }, /// Any OpenAI-compatible endpoint. + /// + /// The aliases let a config say what it *is* (`provider = "groq"`) rather than what shape it + /// speaks, which is friendlier to read and identical to parse. + #[serde( + alias = "local", + alias = "openai_compatible", + alias = "vllm", + alias = "groq", + alias = "together", + alias = "openrouter", + alias = "lmstudio", + alias = "deepseek", + alias = "fireworks", + alias = "azure", + alias = "azure_openai" + )] Compatible { /// Header and URL convention to use. #[serde(default)] @@ -237,6 +254,82 @@ impl ProviderConfig { } } +/// Provider settings gathered from an untyped source — a `LLM.REGISTER` command, a form, an +/// environment map — before they are resolved into a [`ProviderConfig`]. +/// +/// This exists so [`ProviderConfig::from_settings`] can own the `match` over [`ProviderKind`]. +/// The enum is `#[non_exhaustive]`, so a caller outside this crate would need a wildcard arm, and a +/// wildcard silently absorbs a newly added provider instead of failing to compile. Keeping the +/// match here means adding a variant breaks the build at the one place that must change. +#[derive(Debug, Clone, Default)] +pub struct ProviderSettings { + /// Credential, where the provider needs one. + pub api_key: Option, + /// Endpoint root override. + pub base_url: Option, + /// `api-version` (Azure) or `anthropic-version`. + pub api_version: Option, + /// `OpenAI-Organization` header. + pub organization: Option, + /// `OpenAI-Project` header. + pub project: Option, + /// Header/URL convention for the compatible shape. + pub flavor: Option, +} + +impl ProviderConfig { + /// Resolve settings into a provider configuration for `kind`. + /// + /// Well-known defaults fill in for OpenAI, Anthropic, and Ollama. The compatible shape has no + /// defensible default endpoint — an OpenAI-compatible server could be anywhere — so a missing + /// `base_url` is an error rather than a guess. + /// + /// # Errors + /// + /// Returns [`LlmError::Configuration`] when a required field is absent. + pub fn from_settings(kind: ProviderKind, settings: &ProviderSettings) -> LlmResult { + let config = match kind { + ProviderKind::OpenAi => ProviderConfig::OpenAi { + api_key: settings.api_key.clone().unwrap_or_default(), + base_url: settings + .base_url + .clone() + .unwrap_or_else(default_openai_base), + organization: settings.organization.clone(), + project: settings.project.clone(), + }, + ProviderKind::Anthropic => ProviderConfig::Anthropic { + api_key: settings.api_key.clone().unwrap_or_default(), + base_url: settings + .base_url + .clone() + .unwrap_or_else(default_anthropic_base), + version: settings + .api_version + .clone() + .unwrap_or_else(default_anthropic_version), + }, + ProviderKind::Ollama => ProviderConfig::Ollama { + base_url: settings + .base_url + .clone() + .unwrap_or_else(default_ollama_base), + }, + ProviderKind::Compatible => ProviderConfig::Compatible { + flavor: settings.flavor.unwrap_or_default(), + api_key: settings.api_key.clone(), + base_url: settings.base_url.clone().ok_or_else(|| { + LlmError::configuration( + "an OpenAI-compatible provider requires an explicit base_url", + ) + })?, + api_version: settings.api_version.clone(), + }, + }; + Ok(config) + } +} + /// Per-token prices for a model, in USD per million tokens. /// /// Only present when an operator configured it. Orbit-RS ships no built-in price table: a table @@ -276,6 +369,10 @@ impl ModelPricing { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ModelProfile { /// Profile name, unique within a registry. + /// + /// Optional in TOML: `[llm.profiles.fast]` names the profile `fast` from the map key, and + /// [`LlmConfig::from_toml_str`] backfills it. Repeating it in the body would let the two drift. + #[serde(default)] pub name: String, /// How to reach the provider. #[serde(flatten)] @@ -376,12 +473,14 @@ impl ModelProfile { } if self.model.trim().is_empty() { return Err(LlmError::configuration(format!( - "profile '{}' requires a model", self.name + "profile '{}' requires a model", + self.name ))); } if self.fallbacks.iter().any(|f| f == &self.name) { return Err(LlmError::configuration(format!( - "profile '{}' lists itself as a fallback, which would loop", self.name + "profile '{}' lists itself as a fallback, which would loop", + self.name ))); } self.provider.validate() @@ -670,7 +769,10 @@ model = "gpt-4o-mini" _ => None, }); - assert_eq!(cfg.profiles["fast"].provider.base_url(), "http://gpu-box:11434"); + assert_eq!( + cfg.profiles["fast"].provider.base_url(), + "http://gpu-box:11434" + ); assert_eq!(cfg.profiles["fast"].model, "qwen3"); assert_eq!(cfg.default_profile.as_deref(), Some("smart")); } diff --git a/orbit/llm/src/http.rs b/orbit/llm/src/http.rs new file mode 100644 index 00000000..c92943b0 --- /dev/null +++ b/orbit/llm/src/http.rs @@ -0,0 +1,185 @@ +//! Shared HTTP client and response handling. +//! +//! The pre-existing GraphRAG clients called `reqwest::Client::new()` *inside* each `generate()`. +//! That builds a fresh connection pool and TLS session per LLM call — the connection is established +//! and thrown away every time. One process-wide client, cloned by handle, fixes it: `reqwest::Client` +//! is already `Arc`-backed, so cloning shares the pool. + +use crate::error::{LlmError, LlmResult}; +use reqwest::{Client, Response}; +use serde::de::DeserializeOwned; +use std::sync::OnceLock; +use std::time::Duration; + +/// How much of an error body to keep. Enough to identify the failure, bounded so a provider +/// returning an HTML error page does not put a megabyte into a log line. +const MAX_ERROR_BODY: usize = 2_048; + +/// Idle connections kept per host. LLM traffic is bursty and long-lived; keeping a few warm +/// removes a TLS handshake from the critical path without pinning many sockets. +const POOL_IDLE_PER_HOST: usize = 8; + +/// How long an idle connection is retained. +const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90); + +/// Ceiling on connection establishment, distinct from the per-request deadline. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +static SHARED: OnceLock = OnceLock::new(); + +/// The process-wide HTTP client. +/// +/// Per-request deadlines are applied by the router with `tokio::time::timeout` rather than +/// `reqwest`'s own timeout, so the deadline covers the whole attempt — including body read — and is +/// configurable per profile rather than baked into the client. +/// +/// # Panics +/// +/// Never in practice: the builder only fails on TLS backend initialization, and a process that +/// cannot build a TLS client cannot serve any provider. If it does fail, a default client is used +/// so the failure surfaces as a connection error naming the host rather than as a panic at startup. +#[must_use] +pub fn shared_client() -> &'static Client { + SHARED.get_or_init(|| { + Client::builder() + .pool_max_idle_per_host(POOL_IDLE_PER_HOST) + .pool_idle_timeout(POOL_IDLE_TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + .user_agent(concat!("orbit-rs/", env!("CARGO_PKG_VERSION"))) + .build() + .unwrap_or_else(|e| { + tracing::error!( + error = %e, + "failed to build the shared HTTP client; falling back to defaults" + ); + Client::new() + }) + }) +} + +/// Convert a `reqwest` failure into a transport error naming the provider. +pub fn transport_error(provider: &str, err: reqwest::Error) -> LlmError { + LlmError::Transport { + provider: provider.to_owned(), + message: err.to_string(), + } +} + +/// Turn a non-success response into an [`LlmError::Api`], preserving `Retry-After`. +/// +/// `Retry-After` is read as seconds; the HTTP-date form is not parsed, and an unparseable value +/// yields `None` rather than a guessed delay — the router then falls back to its own backoff. +async fn api_error(provider: &str, response: Response) -> LlmError { + let status = response.status().as_u16(); + let retry_after = response + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse::().ok()) + .map(Duration::from_secs); + + let body = response + .text() + .await + .unwrap_or_else(|e| format!("")); + let body = truncate(&body, MAX_ERROR_BODY); + + LlmError::Api { + provider: provider.to_owned(), + status, + body, + retry_after, + } +} + +fn truncate(text: &str, limit: usize) -> String { + if text.len() <= limit { + return text.to_owned(); + } + // Cut on a character boundary so the truncated body is still valid UTF-8. + let boundary = (0..=limit) + .rev() + .find(|i| text.is_char_boundary(*i)) + .unwrap_or(0); + format!( + "{}… <{} bytes truncated>", + &text[..boundary], + text.len() - boundary + ) +} + +/// Check the status and deserialize the body. +/// +/// # Errors +/// +/// Returns [`LlmError::Api`] for a non-success status and [`LlmError::MalformedResponse`] if the +/// body does not deserialize into `T`. +pub async fn parse_json(provider: &str, response: Response) -> LlmResult { + if !response.status().is_success() { + return Err(api_error(provider, response).await); + } + // Read as text first: a provider that returns HTML on success (a captive portal, a + // misconfigured proxy) produces a clear message instead of an opaque decode error. + let body = response + .text() + .await + .map_err(|e| transport_error(provider, e))?; + + serde_json::from_str(&body).map_err(|e| LlmError::MalformedResponse { + provider: provider.to_owned(), + detail: format!("{e}; body was: {}", truncate(&body, 512)), + }) +} + +/// Report a field the provider's documented response shape should have contained. +pub fn missing_field(provider: &str, field: &str) -> LlmError { + LlmError::MalformedResponse { + provider: provider.to_owned(), + detail: format!("response did not contain '{field}'"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shared_client_is_actually_shared() { + let a = shared_client(); + let b = shared_client(); + assert!( + std::ptr::eq(a, b), + "a new client per call would rebuild the connection pool every request" + ); + } + + #[test] + fn truncate_leaves_short_text_alone() { + assert_eq!(truncate("short", 100), "short"); + } + + #[test] + fn truncate_bounds_long_text() { + let long = "x".repeat(5_000); + let out = truncate(&long, MAX_ERROR_BODY); + assert!(out.len() < long.len()); + assert!(out.contains("bytes truncated")); + } + + #[test] + fn truncate_respects_utf8_boundaries() { + // Multi-byte characters straddling the limit must not produce invalid UTF-8. + let text = "é".repeat(2_000); + let out = truncate(&text, MAX_ERROR_BODY); + assert!(out.contains("bytes truncated")); + assert!(out.is_char_boundary(0)); + } + + #[test] + fn missing_field_names_the_field_and_provider() { + let err = missing_field("anthropic", "content[0].text"); + assert!(err.to_string().contains("anthropic")); + assert!(err.to_string().contains("content[0].text")); + assert!(!err.is_retryable(), "a shape mismatch will not fix itself"); + } +} diff --git a/orbit/llm/src/lib.rs b/orbit/llm/src/lib.rs new file mode 100644 index 00000000..bd4d2dee --- /dev/null +++ b/orbit/llm/src/lib.rs @@ -0,0 +1,104 @@ +//! Provider-agnostic LLM and embedding layer for Orbit-RS. +//! +//! # What this is +//! +//! A model *gateway* that lives inside the database process. It gives Orbit-RS the capability set +//! an external LLM proxy would provide — a unified API over several providers, fallback chains, +//! retries, circuit breaking, timeouts, and cost accounting — without a separate deployment, and +//! with the retrieval layer, vector index, and generation call sharing one process and one security +//! boundary. +//! +//! # Design +//! +//! ```text +//! caller ──▶ LlmRegistry ──▶ Router ──▶ provider +//! (named, (timeout, (HTTP shaping +//! hot-swappable retry, only) +//! profiles) breaker, +//! fallback, +//! accounting) +//! ``` +//! +//! Providers do HTTP shaping and nothing else, so every resilience behavior is identical across +//! backends instead of reimplemented per backend. Four wire shapes — OpenAI, Anthropic, Ollama, and +//! generic OpenAI-compatible — cover roughly fifteen named services, because Azure OpenAI, vLLM, +//! Groq, Together, OpenRouter, LM Studio, DeepSeek, and Fireworks all speak `/chat/completions`. +//! +//! # Modelling honesty +//! +//! Three rules are enforced by the types rather than by convention, and each one exists because the +//! comfortable alternative reports a number nobody measured: +//! +//! * **Token counts are `Option`.** Most local servers do not report them. `unwrap_or(0)` would +//! assert "this request used no tokens". +//! * **Cost is `Option`, computed only from configured prices.** No price table is bundled: one +//! baked into a database binary goes stale silently and then reports confident wrong costs. +//! * **Credentials are [`SecretString`].** `Debug`, `Display`, and `Serialize` all redact. +//! +//! # Example +//! +//! ```no_run +//! use orbit_llm::{ChatRequest, LlmConfig, LlmRegistry, Router}; +//! use std::sync::Arc; +//! +//! # async fn example() -> Result<(), Box> { +//! let mut config = LlmConfig::from_toml_str(r#" +//! enabled = true +//! default_profile = "local" +//! +//! [profiles.local] +//! provider = "ollama" +//! model = "llama3.2" +//! "#)?; +//! config.apply_env_overrides(); +//! +//! let router = Router::new(Arc::new(LlmRegistry::from_config(&config)?)); +//! let answer = router.generate(None, ChatRequest::prompt("Why is the sky blue?", None)).await?; +//! println!("{} (via {})", answer.text, answer.profile); +//! # Ok(()) +//! # } +//! ``` +//! +//! Switching models at runtime is a registry call — [`LlmRegistry::register`] to add one and +//! [`LlmRegistry::set_default`] to switch — and takes effect on the next request with no restart. +//! Over the wire that is `LLM.REGISTER` and `LLM.USE`. + +#![deny(missing_docs)] + +pub mod breaker; +pub mod compat; +pub mod config; +pub mod error; +pub mod http; +pub mod provider; +pub mod providers; +pub mod registry; +pub mod retry; +pub mod router; +pub mod secret; +pub mod types; +pub mod usage; + +#[cfg(test)] +mod testing; + +pub use breaker::{BreakerConfig, BreakerState, CircuitBreaker}; +pub use compat::{legacy_provider_name, profile_from_legacy}; +pub use config::{ + CompatibleFlavor, LlmConfig, ModelPricing, ModelProfile, ProviderConfig, ProviderSettings, + DEFAULT_TIMEOUT_MS, +}; +pub use error::{LlmError, LlmResult}; +pub use provider::{ + EmbeddingProvider, LlmProvider, ProviderChatOutput, ProviderEmbeddingOutput, ProviderKind, +}; +pub use providers::{build_provider, BuiltProvider}; +pub use registry::{LlmRegistry, ModelSummary, RegisteredModel}; +pub use retry::RetryPolicy; +pub use router::Router; +pub use secret::{SecretString, REDACTED}; +pub use types::{ + ChatRequest, ChatResponse, Cost, EmbeddingRequest, EmbeddingResponse, FinishReason, + GenerationParams, Message, Role, TokenUsage, +}; +pub use usage::{ProfileCounters, UsageSnapshot}; diff --git a/orbit/llm/src/provider.rs b/orbit/llm/src/provider.rs index 5141503f..04017dd6 100644 --- a/orbit/llm/src/provider.rs +++ b/orbit/llm/src/provider.rs @@ -21,6 +21,7 @@ use std::fmt; #[non_exhaustive] pub enum ProviderKind { /// OpenAI's own API. + #[serde(rename = "openai")] OpenAi, /// Anthropic's Messages API. Anthropic, diff --git a/orbit/llm/src/providers/anthropic.rs b/orbit/llm/src/providers/anthropic.rs new file mode 100644 index 00000000..29130fe1 --- /dev/null +++ b/orbit/llm/src/providers/anthropic.rs @@ -0,0 +1,361 @@ +//! Anthropic's Messages API. +//! +//! This closes the pre-existing defect where `create_llm_client` returned +//! `Err("Anthropic client not yet implemented")` for a provider the configuration enum already +//! advertised. +//! +//! Three details differ from the OpenAI shape and each one is a hard failure if got wrong: +//! +//! 1. The system prompt is a **top-level `system` field**, not a message with `role: "system"`. +//! Sending it as a message is rejected. +//! 2. `max_tokens` is **required**. There is no server-side default to fall back on, which is why +//! [`crate::config::ModelProfile::validate`] insists an Anthropic profile carries one. +//! 3. Authentication is the `x-api-key` header plus a pinned `anthropic-version`, not bearer auth. + +use crate::error::{LlmError, LlmResult}; +use crate::http::{missing_field, parse_json, shared_client, transport_error}; +use crate::provider::{ + EmbeddingProvider, LlmProvider, ProviderChatOutput, ProviderEmbeddingOutput, ProviderKind, +}; +use crate::secret::SecretString; +use crate::types::{ChatRequest, EmbeddingRequest, FinishReason, TokenUsage}; +use serde_json::{json, Map, Value}; + +const PROVIDER: &str = "anthropic"; + +/// Client for Anthropic's Messages API. +#[derive(Debug, Clone)] +pub struct AnthropicProvider { + api_key: SecretString, + base_url: String, + version: String, + model: String, +} + +impl AnthropicProvider { + /// Build a client. + pub fn new(api_key: SecretString, base_url: String, version: String, model: String) -> Self { + Self { + api_key, + base_url: base_url.trim_end_matches('/').to_owned(), + version, + model, + } + } +} + +/// Build a `/messages` request body. +/// +/// # Errors +/// +/// Returns [`LlmError::Configuration`] when `max_tokens` is absent, and [`LlmError::Unsupported`] +/// when the caller asked for a deterministic seed — the Messages API has no seed parameter, and +/// dropping the request silently would let a caller believe their run is reproducible when it is +/// not. +pub fn messages_body(model: &str, request: &ChatRequest) -> LlmResult { + let max_tokens = request.params.max_tokens.ok_or_else(|| { + LlmError::configuration( + "Anthropic requires max_tokens; set it on the profile or the request", + ) + })?; + + if request.params.seed.is_some() { + return Err(LlmError::Unsupported { + provider: PROVIDER.to_owned(), + capability: "deterministic seeding", + }); + } + + let messages: Vec = request + .conversation() + .map(|m| json!({ "role": m.role.as_str(), "content": m.content })) + .collect(); + + let mut body = Map::new(); + body.insert("model".into(), json!(model)); + body.insert("max_tokens".into(), json!(max_tokens)); + body.insert("messages".into(), json!(messages)); + + if let Some(system) = request.system_message() { + body.insert("system".into(), json!(system)); + } + if let Some(temperature) = request.params.temperature { + body.insert("temperature".into(), json!(temperature)); + } + if let Some(top_p) = request.params.top_p { + body.insert("top_p".into(), json!(top_p)); + } + if !request.params.stop.is_empty() { + body.insert("stop_sequences".into(), json!(request.params.stop)); + } + + Ok(Value::Object(body)) +} + +/// Parse a `/messages` response. +/// +/// The body carries `content` as an array of typed blocks; text blocks are concatenated in order +/// and non-text blocks (tool use, thinking) are skipped rather than stringified. +/// +/// # Errors +/// +/// Returns [`LlmError::MalformedResponse`] when `content` is absent or contains no text block. +pub fn parse_messages(fallback_model: &str, body: &Value) -> LlmResult { + let blocks = body + .get("content") + .and_then(Value::as_array) + .ok_or_else(|| missing_field(PROVIDER, "content"))?; + + let text = blocks + .iter() + .filter(|block| block.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|block| block.get("text").and_then(Value::as_str)) + .collect::>() + .join(""); + + if text.is_empty() && !blocks.is_empty() { + return Err(missing_field(PROVIDER, "content[].text")); + } + + Ok(ProviderChatOutput { + text, + model: body + .get("model") + .and_then(Value::as_str) + .unwrap_or(fallback_model) + .to_owned(), + usage: TokenUsage { + prompt_tokens: body + .pointer("/usage/input_tokens") + .and_then(Value::as_u64) + .map(|v| v as u32), + completion_tokens: body + .pointer("/usage/output_tokens") + .and_then(Value::as_u64) + .map(|v| v as u32), + }, + finish_reason: body + .get("stop_reason") + .and_then(Value::as_str) + .map(FinishReason::from_wire), + }) +} + +#[async_trait::async_trait] +impl LlmProvider for AnthropicProvider { + fn kind(&self) -> ProviderKind { + ProviderKind::Anthropic + } + + fn model(&self) -> &str { + &self.model + } + + async fn generate(&self, request: &ChatRequest) -> LlmResult { + let body = messages_body(&self.model, request)?; + let response = shared_client() + .post(format!("{}/messages", self.base_url)) + .header("x-api-key", self.api_key.expose()) + .header("anthropic-version", &self.version) + .json(&body) + .send() + .await + .map_err(|e| transport_error(PROVIDER, e))?; + + let json: Value = parse_json(PROVIDER, response).await?; + parse_messages(&self.model, &json) + } +} + +#[async_trait::async_trait] +impl EmbeddingProvider for AnthropicProvider { + fn kind(&self) -> ProviderKind { + ProviderKind::Anthropic + } + + fn embedding_model(&self) -> &str { + // Anthropic ships no embeddings endpoint; there is no model name to report. + "" + } + + async fn embed(&self, _request: &EmbeddingRequest) -> LlmResult { + // Returning zero vectors here would be worse than failing: a zero vector indexes cleanly + // and then silently ruins every similarity search that touches it. + Err(LlmError::Unsupported { + provider: PROVIDER.to_owned(), + capability: "embeddings", + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{GenerationParams, Message}; + + fn request_with(params: GenerationParams) -> ChatRequest { + ChatRequest { + messages: vec![ + Message::system("be terse"), + Message::user("hello"), + Message::assistant("hi"), + Message::user("again"), + ], + params, + } + } + + fn basic_params() -> GenerationParams { + GenerationParams { + max_tokens: Some(1024), + temperature: Some(0.4), + ..Default::default() + } + } + + #[test] + fn system_prompt_is_a_top_level_field_not_a_message() { + let body = + messages_body("claude-sonnet-4-5", &request_with(basic_params())).expect("body builds"); + + assert_eq!(body["system"], "be terse"); + let messages = body["messages"].as_array().expect("messages array"); + assert_eq!(messages.len(), 3, "the system turn is not a message"); + assert!( + messages.iter().all(|m| m["role"] != "system"), + "Anthropic rejects a system-role message" + ); + assert_eq!(messages[0]["role"], "user"); + assert_eq!(messages[1]["role"], "assistant"); + assert_eq!(messages[2]["role"], "user"); + } + + #[test] + fn system_field_is_omitted_when_absent() { + let req = ChatRequest::prompt("hi", None).with_params(basic_params()); + let body = messages_body("claude-sonnet-4-5", &req).expect("body builds"); + assert!(body.get("system").is_none()); + } + + #[test] + fn max_tokens_is_required_by_the_api_and_by_us() { + let req = ChatRequest::prompt("hi", None); + let err = messages_body("claude-sonnet-4-5", &req).expect_err("max_tokens required"); + assert!(err.to_string().contains("max_tokens")); + } + + #[test] + fn stop_sequences_use_the_anthropic_field_name() { + let params = GenerationParams { + stop: vec!["STOP".into()], + ..basic_params() + }; + let body = messages_body("m", &request_with(params)).expect("body builds"); + assert_eq!(body["stop_sequences"][0], "STOP"); + assert!( + body.get("stop").is_none(), + "'stop' is the OpenAI field name" + ); + } + + #[test] + fn seed_is_refused_rather_than_silently_dropped() { + let params = GenerationParams { + seed: Some(7), + ..basic_params() + }; + let err = messages_body("m", &request_with(params)).expect_err("seed unsupported"); + assert!(err.to_string().contains("deterministic seeding")); + assert!(!err.is_retryable()); + } + + #[test] + fn parse_reads_the_documented_response_shape() { + let body = json!({ + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5-20250929", + "content": [{ "type": "text", "text": "the answer" }], + "stop_reason": "end_turn", + "usage": { "input_tokens": 20, "output_tokens": 7 } + }); + let out = parse_messages("claude-sonnet-4-5", &body).expect("parses"); + + assert_eq!(out.text, "the answer"); + assert_eq!(out.model, "claude-sonnet-4-5-20250929"); + assert_eq!(out.usage.prompt_tokens, Some(20)); + assert_eq!(out.usage.completion_tokens, Some(7)); + assert_eq!(out.finish_reason, Some(FinishReason::Stop)); + } + + #[test] + fn end_turn_and_max_tokens_map_onto_the_common_reasons() { + let truncated = json!({ + "content": [{ "type": "text", "text": "cut" }], + "stop_reason": "max_tokens" + }); + assert_eq!( + parse_messages("m", &truncated) + .expect("parses") + .finish_reason, + Some(FinishReason::Length) + ); + } + + #[test] + fn multiple_text_blocks_are_concatenated_in_order() { + let body = json!({ + "content": [ + { "type": "text", "text": "part one " }, + { "type": "thinking", "thinking": "ignored" }, + { "type": "text", "text": "part two" } + ] + }); + let out = parse_messages("m", &body).expect("parses"); + assert_eq!(out.text, "part one part two"); + } + + #[test] + fn a_response_with_no_text_block_is_an_error_not_an_empty_answer() { + let body = json!({ + "content": [{ "type": "tool_use", "name": "x" }] + }); + let err = parse_messages("m", &body).expect_err("no text block"); + assert!(err.to_string().contains("content[].text")); + } + + #[test] + fn an_empty_content_array_yields_empty_text() { + // Distinct from the case above: the model genuinely returned nothing, which is a valid + // (if unhelpful) response rather than a shape mismatch. + let out = parse_messages("m", &json!({ "content": [] })).expect("parses"); + assert!(out.text.is_empty()); + } + + #[tokio::test] + async fn embeddings_are_refused_not_faked() { + let provider = AnthropicProvider::new( + SecretString::new("k"), + "https://api.anthropic.com/v1".into(), + "2023-06-01".into(), + "claude-sonnet-4-5".into(), + ); + let err = provider + .embed(&EmbeddingRequest::new(["x"])) + .await + .expect_err("no embeddings endpoint exists"); + assert!(err.to_string().contains("embeddings")); + } + + #[test] + fn debug_output_does_not_leak_the_key() { + let provider = AnthropicProvider::new( + SecretString::new("sk-ant-secret"), + "https://api.anthropic.com/v1".into(), + "2023-06-01".into(), + "claude-sonnet-4-5".into(), + ); + assert!(!format!("{provider:?}").contains("sk-ant-secret")); + } +} diff --git a/orbit/llm/src/providers/compatible.rs b/orbit/llm/src/providers/compatible.rs new file mode 100644 index 00000000..8e46e929 --- /dev/null +++ b/orbit/llm/src/providers/compatible.rs @@ -0,0 +1,249 @@ +//! Any endpoint speaking OpenAI's `/chat/completions`. +//! +//! One implementation covers Azure OpenAI, vLLM, Groq, Together, OpenRouter, LM Studio, DeepSeek, +//! Fireworks, and any local server that implements the same route. The differences are entirely in +//! authentication and URL construction, which is what [`CompatibleFlavor`] selects. +//! +//! Note the `max_tokens` choice: third-party servers implement the original field name, not +//! OpenAI's newer `max_completion_tokens`. See [`super::openai_shape::MaxTokensField`]. + +use super::openai_shape::{ + chat_body, embedding_body, parse_chat, parse_embeddings, MaxTokensField, +}; +use crate::config::CompatibleFlavor; +use crate::error::LlmResult; +use crate::http::{parse_json, shared_client, transport_error}; +use crate::provider::{ + EmbeddingProvider, LlmProvider, ProviderChatOutput, ProviderEmbeddingOutput, ProviderKind, +}; +use crate::secret::SecretString; +use crate::types::{ChatRequest, EmbeddingRequest}; +use serde_json::Value; + +const PROVIDER: &str = "compatible"; + +/// Client for an OpenAI-compatible endpoint. +#[derive(Debug, Clone)] +pub struct CompatibleProvider { + flavor: CompatibleFlavor, + api_key: Option, + base_url: String, + api_version: Option, + model: String, + embedding_model: String, +} + +impl CompatibleProvider { + /// Build a client. + /// + /// When no embedding model is named the chat model is reused: many compatible servers host a + /// single model and serve both routes from it. Reporting the model back in the response means + /// this substitution is visible rather than assumed. + pub fn new( + flavor: CompatibleFlavor, + api_key: Option, + base_url: String, + api_version: Option, + model: String, + embedding_model: Option, + ) -> Self { + let embedding_model = embedding_model.unwrap_or_else(|| model.clone()); + Self { + flavor, + api_key, + base_url: base_url.trim_end_matches('/').to_owned(), + api_version, + model, + embedding_model, + } + } + + /// Full URL for a route. + /// + /// Azure puts the deployment name in the path and the API version in the query string; every + /// other flavor appends the route to the base URL. + #[must_use] + pub fn url_for(&self, route: Route, model: &str) -> String { + match self.flavor { + CompatibleFlavor::Generic => format!("{}{}", self.base_url, route.generic_path()), + CompatibleFlavor::AzureOpenAi => { + let version = self.api_version.as_deref().unwrap_or_default(); + format!( + "{}/openai/deployments/{model}{}?api-version={version}", + self.base_url, + route.generic_path() + ) + } + } + } + + fn request(&self, url: String) -> reqwest::RequestBuilder { + let builder = shared_client().post(url); + match (&self.api_key, self.flavor) { + (Some(key), CompatibleFlavor::AzureOpenAi) => builder.header("api-key", key.expose()), + (Some(key), CompatibleFlavor::Generic) => builder.bearer_auth(key.expose()), + // Unauthenticated local servers are the common case for vLLM and LM Studio. + (None, _) => builder, + } + } +} + +/// Routes this provider speaks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Route { + /// Text generation. + Chat, + /// Embeddings. + Embeddings, +} + +impl Route { + const fn generic_path(self) -> &'static str { + match self { + Route::Chat => "/chat/completions", + Route::Embeddings => "/embeddings", + } + } +} + +#[async_trait::async_trait] +impl LlmProvider for CompatibleProvider { + fn kind(&self) -> ProviderKind { + ProviderKind::Compatible + } + + fn model(&self) -> &str { + &self.model + } + + async fn generate(&self, request: &ChatRequest) -> LlmResult { + let body = chat_body(&self.model, request, MaxTokensField::Legacy); + let response = self + .request(self.url_for(Route::Chat, &self.model)) + .json(&body) + .send() + .await + .map_err(|e| transport_error(PROVIDER, e))?; + + let json: Value = parse_json(PROVIDER, response).await?; + parse_chat(PROVIDER, &self.model, &json) + } +} + +#[async_trait::async_trait] +impl EmbeddingProvider for CompatibleProvider { + fn kind(&self) -> ProviderKind { + ProviderKind::Compatible + } + + fn embedding_model(&self) -> &str { + &self.embedding_model + } + + async fn embed(&self, request: &EmbeddingRequest) -> LlmResult { + let body = embedding_body(&self.embedding_model, request); + let response = self + .request(self.url_for(Route::Embeddings, &self.embedding_model)) + .json(&body) + .send() + .await + .map_err(|e| transport_error(PROVIDER, e))?; + + let json: Value = parse_json(PROVIDER, response).await?; + parse_embeddings(PROVIDER, &self.embedding_model, &json) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn generic() -> CompatibleProvider { + CompatibleProvider::new( + CompatibleFlavor::Generic, + Some(SecretString::new("gsk-test")), + "https://api.groq.com/openai/v1/".into(), + None, + "llama-3.3-70b".into(), + None, + ) + } + + fn azure() -> CompatibleProvider { + CompatibleProvider::new( + CompatibleFlavor::AzureOpenAi, + Some(SecretString::new("azure-key")), + "https://contoso.openai.azure.com".into(), + Some("2024-10-21".into()), + "gpt-4o-deployment".into(), + Some("embed-deployment".into()), + ) + } + + #[test] + fn generic_url_appends_the_route() { + assert_eq!( + generic().url_for(Route::Chat, "llama-3.3-70b"), + "https://api.groq.com/openai/v1/chat/completions" + ); + assert_eq!( + generic().url_for(Route::Embeddings, "m"), + "https://api.groq.com/openai/v1/embeddings" + ); + } + + #[test] + fn azure_url_carries_the_deployment_and_api_version() { + assert_eq!( + azure().url_for(Route::Chat, "gpt-4o-deployment"), + "https://contoso.openai.azure.com/openai/deployments/gpt-4o-deployment/chat/completions?api-version=2024-10-21" + ); + assert_eq!( + azure().url_for(Route::Embeddings, "embed-deployment"), + "https://contoso.openai.azure.com/openai/deployments/embed-deployment/embeddings?api-version=2024-10-21" + ); + } + + #[test] + fn compatible_servers_get_the_legacy_max_tokens_field() { + let request = ChatRequest::prompt("hi", None).with_params(crate::types::GenerationParams { + max_tokens: Some(128), + ..Default::default() + }); + let body = chat_body("llama-3.3-70b", &request, MaxTokensField::Legacy); + assert_eq!(body["max_tokens"], 128); + assert!( + body.get("max_completion_tokens").is_none(), + "third-party servers implement the original field name" + ); + } + + #[test] + fn embedding_model_defaults_to_the_chat_model() { + assert_eq!(generic().embedding_model(), "llama-3.3-70b"); + assert_eq!(azure().embedding_model(), "embed-deployment"); + } + + #[test] + fn unauthenticated_local_servers_are_supported() { + let local = CompatibleProvider::new( + CompatibleFlavor::Generic, + None, + "http://localhost:8000/v1".into(), + None, + "Qwen3-8B".into(), + None, + ); + assert_eq!( + local.url_for(Route::Chat, "Qwen3-8B"), + "http://localhost:8000/v1/chat/completions" + ); + assert_eq!(LlmProvider::kind(&local), ProviderKind::Compatible); + } + + #[test] + fn debug_output_does_not_leak_the_key() { + assert!(!format!("{:?}", generic()).contains("gsk-test")); + assert!(!format!("{:?}", azure()).contains("azure-key")); + } +} diff --git a/orbit/llm/src/providers/mod.rs b/orbit/llm/src/providers/mod.rs new file mode 100644 index 00000000..e60bad79 --- /dev/null +++ b/orbit/llm/src/providers/mod.rs @@ -0,0 +1,210 @@ +//! Provider implementations and the dispatch that builds them from configuration. + +pub mod anthropic; +pub mod compatible; +pub mod ollama; +pub mod openai; +pub mod openai_shape; + +use crate::config::{ModelProfile, ProviderConfig}; +use crate::error::LlmResult; +use crate::provider::{EmbeddingProvider, LlmProvider}; +use std::sync::Arc; + +/// A provider that can both generate and embed, type-erased for storage in the registry. +pub struct BuiltProvider { + /// Text generation. + pub llm: Arc, + /// Embeddings. Present for every kind; Anthropic's reports `Unsupported` when called. + pub embedding: Arc, +} + +impl std::fmt::Debug for BuiltProvider { + /// Reports the wire shape only. The concrete providers hold credentials, and a derived `Debug` + /// would be one `{:?}` away from printing them. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BuiltProvider") + .field("kind", &self.llm.kind()) + .field("model", &self.llm.model()) + .finish() + } +} + +/// Construct the provider a profile describes. +/// +/// Every [`crate::ProviderKind`] variant is reachable from here. A variant with no arm would be a +/// configuration option that parses and then does nothing. +/// +/// # Errors +/// +/// Returns [`crate::LlmError::Configuration`] when the profile does not validate. +pub fn build_provider(profile: &ModelProfile) -> LlmResult { + profile.validate()?; + + let built = match &profile.provider { + ProviderConfig::OpenAi { + api_key, + base_url, + organization, + project, + } => { + let provider = Arc::new(openai::OpenAiProvider::new( + api_key.clone(), + base_url.clone(), + organization.clone(), + project.clone(), + profile.model.clone(), + profile.embedding_model.clone(), + )); + BuiltProvider { + llm: provider.clone(), + embedding: provider, + } + } + + ProviderConfig::Anthropic { + api_key, + base_url, + version, + } => { + let provider = Arc::new(anthropic::AnthropicProvider::new( + api_key.clone(), + base_url.clone(), + version.clone(), + profile.model.clone(), + )); + BuiltProvider { + llm: provider.clone(), + embedding: provider, + } + } + + ProviderConfig::Ollama { base_url } => { + let provider = Arc::new(ollama::OllamaProvider::new( + base_url.clone(), + profile.model.clone(), + profile.embedding_model.clone(), + )); + BuiltProvider { + llm: provider.clone(), + embedding: provider, + } + } + + ProviderConfig::Compatible { + flavor, + api_key, + base_url, + api_version, + } => { + let provider = Arc::new(compatible::CompatibleProvider::new( + *flavor, + api_key.clone(), + base_url.clone(), + api_version.clone(), + profile.model.clone(), + profile.embedding_model.clone(), + )); + BuiltProvider { + llm: provider.clone(), + embedding: provider, + } + } + }; + + Ok(built) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::CompatibleFlavor; + use crate::provider::ProviderKind; + use crate::secret::SecretString; + use crate::types::GenerationParams; + + fn profile_for(provider: ProviderConfig, model: &str) -> ModelProfile { + ModelProfile::new("p", provider, model).with_params(GenerationParams { + // Anthropic requires this; harmless for the rest. + max_tokens: Some(256), + ..Default::default() + }) + } + + #[test] + fn every_provider_kind_is_constructible() { + let cases = vec![ + ( + ProviderConfig::OpenAi { + api_key: SecretString::new("k"), + base_url: "https://api.openai.com/v1".into(), + organization: None, + project: None, + }, + ProviderKind::OpenAi, + ), + ( + ProviderConfig::Anthropic { + api_key: SecretString::new("k"), + base_url: "https://api.anthropic.com/v1".into(), + version: "2023-06-01".into(), + }, + ProviderKind::Anthropic, + ), + ( + ProviderConfig::Ollama { + base_url: "http://localhost:11434".into(), + }, + ProviderKind::Ollama, + ), + ( + ProviderConfig::Compatible { + flavor: CompatibleFlavor::Generic, + api_key: None, + base_url: "http://localhost:8000/v1".into(), + api_version: None, + }, + ProviderKind::Compatible, + ), + ]; + + assert_eq!( + cases.len(), + ProviderKind::all().len(), + "a kind with no construction site is a config option that does nothing" + ); + + for (config, expected) in cases { + let built = build_provider(&profile_for(config, "m")).expect("provider builds"); + assert_eq!(built.llm.kind(), expected); + assert_eq!(built.embedding.kind(), expected); + } + } + + #[test] + fn an_invalid_profile_is_rejected_before_a_provider_is_built() { + let profile = profile_for( + ProviderConfig::OpenAi { + api_key: SecretString::default(), + base_url: "https://api.openai.com/v1".into(), + organization: None, + project: None, + }, + "gpt-4o", + ); + let err = build_provider(&profile).expect_err("missing credential rejected"); + assert!(err.to_string().contains("api_key")); + } + + #[test] + fn the_profiles_model_reaches_the_provider() { + let profile = profile_for( + ProviderConfig::Ollama { + base_url: "http://localhost:11434".into(), + }, + "qwen3:8b", + ); + let built = build_provider(&profile).expect("builds"); + assert_eq!(built.llm.model(), "qwen3:8b"); + } +} diff --git a/orbit/llm/src/providers/ollama.rs b/orbit/llm/src/providers/ollama.rs new file mode 100644 index 00000000..7d63f0bc --- /dev/null +++ b/orbit/llm/src/providers/ollama.rs @@ -0,0 +1,327 @@ +//! Ollama's native API. +//! +//! Ollama uses `/api/chat` with generation knobs nested under `options`, and names the token cap +//! `num_predict`. It *does* report token counts (`prompt_eval_count` / `eval_count`), so usage is +//! genuinely available here — unlike most local OpenAI-compatible servers. + +use crate::error::LlmResult; +use crate::http::{missing_field, parse_json, shared_client, transport_error}; +use crate::provider::{ + EmbeddingProvider, LlmProvider, ProviderChatOutput, ProviderEmbeddingOutput, ProviderKind, +}; +use crate::types::{ChatRequest, EmbeddingRequest, FinishReason, TokenUsage}; +use serde_json::{json, Map, Value}; + +const PROVIDER: &str = "ollama"; + +/// Embedding model used when a profile names none. +const DEFAULT_EMBEDDING_MODEL: &str = "nomic-embed-text"; + +/// Client for a local or remote Ollama daemon. +#[derive(Debug, Clone)] +pub struct OllamaProvider { + base_url: String, + model: String, + embedding_model: String, +} + +impl OllamaProvider { + /// Build a client. + pub fn new(base_url: String, model: String, embedding_model: Option) -> Self { + Self { + base_url: base_url.trim_end_matches('/').to_owned(), + model, + embedding_model: embedding_model.unwrap_or_else(|| DEFAULT_EMBEDDING_MODEL.to_owned()), + } + } +} + +/// Build an `/api/chat` request body. +/// +/// `stream` is explicitly `false`: Ollama streams by default, and an unset flag would produce a +/// newline-delimited body that the single-object parser cannot read. +#[must_use] +pub fn chat_body(model: &str, request: &ChatRequest) -> Value { + let messages: Vec = request + .messages + .iter() + .map(|m| json!({ "role": m.role.as_str(), "content": m.content })) + .collect(); + + let params = &request.params; + let mut options = Map::new(); + if let Some(temperature) = params.temperature { + options.insert("temperature".into(), json!(temperature)); + } + if let Some(max_tokens) = params.max_tokens { + options.insert("num_predict".into(), json!(max_tokens)); + } + if let Some(top_p) = params.top_p { + options.insert("top_p".into(), json!(top_p)); + } + if let Some(seed) = params.seed { + options.insert("seed".into(), json!(seed)); + } + if !params.stop.is_empty() { + options.insert("stop".into(), json!(params.stop)); + } + + let mut body = Map::new(); + body.insert("model".into(), json!(model)); + body.insert("messages".into(), json!(messages)); + body.insert("stream".into(), json!(false)); + if !options.is_empty() { + body.insert("options".into(), Value::Object(options)); + } + + Value::Object(body) +} + +/// Parse an `/api/chat` response. +/// +/// # Errors +/// +/// Returns [`crate::LlmError::MalformedResponse`] when `message.content` is absent. +pub fn parse_chat(fallback_model: &str, body: &Value) -> LlmResult { + let text = body + .pointer("/message/content") + .and_then(Value::as_str) + .ok_or_else(|| missing_field(PROVIDER, "message.content"))? + .to_owned(); + + Ok(ProviderChatOutput { + text, + model: body + .get("model") + .and_then(Value::as_str) + .unwrap_or(fallback_model) + .to_owned(), + usage: TokenUsage { + prompt_tokens: body + .get("prompt_eval_count") + .and_then(Value::as_u64) + .map(|v| v as u32), + completion_tokens: body + .get("eval_count") + .and_then(Value::as_u64) + .map(|v| v as u32), + }, + finish_reason: body + .get("done_reason") + .and_then(Value::as_str) + .map(FinishReason::from_wire), + }) +} + +/// Build an `/api/embed` request body. +#[must_use] +pub fn embedding_body(model: &str, request: &EmbeddingRequest) -> Value { + json!({ "model": model, "input": request.inputs }) +} + +/// Parse an `/api/embed` response. +/// +/// # Errors +/// +/// Returns [`crate::LlmError::MalformedResponse`] when `embeddings` is absent. +pub fn parse_embeddings(fallback_model: &str, body: &Value) -> LlmResult { + let embeddings = body + .get("embeddings") + .and_then(Value::as_array) + .ok_or_else(|| missing_field(PROVIDER, "embeddings"))? + .iter() + .map(|vector| { + vector + .as_array() + .map(|values| { + values + .iter() + .map(|v| v.as_f64().unwrap_or_default() as f32) + .collect::>() + }) + .ok_or_else(|| missing_field(PROVIDER, "embeddings[]")) + }) + .collect::>>()?; + + Ok(ProviderEmbeddingOutput { + embeddings, + model: body + .get("model") + .and_then(Value::as_str) + .unwrap_or(fallback_model) + .to_owned(), + usage: TokenUsage { + prompt_tokens: body + .get("prompt_eval_count") + .and_then(Value::as_u64) + .map(|v| v as u32), + completion_tokens: Some(0), + }, + }) +} + +#[async_trait::async_trait] +impl LlmProvider for OllamaProvider { + fn kind(&self) -> ProviderKind { + ProviderKind::Ollama + } + + fn model(&self) -> &str { + &self.model + } + + async fn generate(&self, request: &ChatRequest) -> LlmResult { + let body = chat_body(&self.model, request); + let response = shared_client() + .post(format!("{}/api/chat", self.base_url)) + .json(&body) + .send() + .await + .map_err(|e| transport_error(PROVIDER, e))?; + + let json: Value = parse_json(PROVIDER, response).await?; + parse_chat(&self.model, &json) + } +} + +#[async_trait::async_trait] +impl EmbeddingProvider for OllamaProvider { + fn kind(&self) -> ProviderKind { + ProviderKind::Ollama + } + + fn embedding_model(&self) -> &str { + &self.embedding_model + } + + async fn embed(&self, request: &EmbeddingRequest) -> LlmResult { + let body = embedding_body(&self.embedding_model, request); + let response = shared_client() + .post(format!("{}/api/embed", self.base_url)) + .json(&body) + .send() + .await + .map_err(|e| transport_error(PROVIDER, e))?; + + let json: Value = parse_json(PROVIDER, response).await?; + parse_embeddings(&self.embedding_model, &json) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{GenerationParams, Message}; + + #[test] + fn generation_knobs_go_under_options_with_ollama_names() { + let request = ChatRequest { + messages: vec![Message::system("sys"), Message::user("hi")], + params: GenerationParams { + temperature: Some(0.5), + max_tokens: Some(256), + top_p: Some(0.9), + stop: vec!["END".into()], + seed: Some(3), + }, + }; + let body = chat_body("llama3.2", &request); + + assert_eq!(body["model"], "llama3.2"); + assert_eq!(body["stream"], false, "the single-object parser needs this"); + assert_eq!(body["options"]["temperature"], 0.5); + assert_eq!( + body["options"]["num_predict"], 256, + "Ollama's name for the token cap" + ); + // `top_p` is an `f32`; serializing widens it to the nearest `f64`, so 0.9 is not exact. + let top_p = body["options"]["top_p"].as_f64().expect("number"); + assert!((top_p - 0.9).abs() < 1e-6, "got {top_p}"); + assert_eq!(body["options"]["seed"], 3); + assert_eq!(body["options"]["stop"][0], "END"); + assert!( + body.get("max_tokens").is_none(), + "top-level max_tokens is ignored by Ollama" + ); + } + + #[test] + fn the_system_turn_stays_a_message() { + let request = ChatRequest::prompt("hi", Some("sys".into())); + let body = chat_body("llama3.2", &request); + assert_eq!(body["messages"][0]["role"], "system"); + assert_eq!(body["messages"][0]["content"], "sys"); + } + + #[test] + fn options_is_omitted_when_nothing_is_configured() { + let body = chat_body("llama3.2", &ChatRequest::prompt("hi", None)); + assert!(body.get("options").is_none()); + } + + #[test] + fn parse_reads_ollamas_token_counters() { + let body = json!({ + "model": "llama3.2", + "message": { "role": "assistant", "content": "answer" }, + "done": true, + "done_reason": "stop", + "prompt_eval_count": 31, + "eval_count": 12 + }); + let out = parse_chat("llama3.2", &body).expect("parses"); + + assert_eq!(out.text, "answer"); + assert_eq!(out.usage.prompt_tokens, Some(31)); + assert_eq!(out.usage.completion_tokens, Some(12)); + assert_eq!(out.finish_reason, Some(FinishReason::Stop)); + } + + #[test] + fn a_length_stop_is_reported_as_truncation() { + let body = json!({ + "message": { "content": "cut" }, + "done_reason": "length" + }); + assert_eq!( + parse_chat("m", &body).expect("parses").finish_reason, + Some(FinishReason::Length) + ); + } + + #[test] + fn a_missing_message_is_an_error() { + let err = parse_chat("m", &json!({ "done": true })).expect_err("no message"); + assert!(err.to_string().contains("message.content")); + } + + #[test] + fn embeddings_round_trip() { + let body = embedding_body("nomic-embed-text", &EmbeddingRequest::new(["a", "b"])); + assert_eq!(body["input"][1], "b"); + + let response = json!({ + "model": "nomic-embed-text", + "embeddings": [[0.1, 0.2], [0.3, 0.4]], + "prompt_eval_count": 4 + }); + let out = parse_embeddings("nomic-embed-text", &response).expect("parses"); + assert_eq!(out.embeddings.len(), 2); + assert_eq!(out.embeddings[1], vec![0.3, 0.4]); + assert_eq!(out.usage.prompt_tokens, Some(4)); + } + + #[test] + fn a_missing_embeddings_array_is_an_error() { + let err = parse_embeddings("m", &json!({})).expect_err("no embeddings"); + assert!(err.to_string().contains("embeddings")); + } + + #[test] + fn defaults_are_named_not_guessed() { + let p = OllamaProvider::new("http://localhost:11434/".into(), "llama3.2".into(), None); + assert_eq!(p.base_url, "http://localhost:11434"); + assert_eq!(p.embedding_model(), DEFAULT_EMBEDDING_MODEL); + assert_eq!(LlmProvider::kind(&p), ProviderKind::Ollama); + } +} diff --git a/orbit/llm/src/providers/openai.rs b/orbit/llm/src/providers/openai.rs new file mode 100644 index 00000000..e7f98896 --- /dev/null +++ b/orbit/llm/src/providers/openai.rs @@ -0,0 +1,165 @@ +//! OpenAI's own API. + +use super::openai_shape::{ + chat_body, embedding_body, parse_chat, parse_embeddings, MaxTokensField, +}; +use crate::error::LlmResult; +use crate::http::{parse_json, shared_client, transport_error}; +use crate::provider::{ + EmbeddingProvider, LlmProvider, ProviderChatOutput, ProviderEmbeddingOutput, ProviderKind, +}; +use crate::secret::SecretString; +use crate::types::{ChatRequest, EmbeddingRequest}; +use serde_json::Value; + +const PROVIDER: &str = "openai"; + +/// Model used when a profile requests embeddings without naming an embedding model. +/// +/// Unlike a fabricated default *value*, this is a documented fallback identifier the caller can +/// override; it is reported back in the response so nobody has to guess what ran. +const DEFAULT_EMBEDDING_MODEL: &str = "text-embedding-3-small"; + +/// Client for `api.openai.com` and API-identical proxies. +#[derive(Debug, Clone)] +pub struct OpenAiProvider { + api_key: SecretString, + base_url: String, + organization: Option, + project: Option, + model: String, + embedding_model: String, +} + +impl OpenAiProvider { + /// Build a client. + pub fn new( + api_key: SecretString, + base_url: String, + organization: Option, + project: Option, + model: String, + embedding_model: Option, + ) -> Self { + Self { + api_key, + base_url: base_url.trim_end_matches('/').to_owned(), + organization, + project, + model, + embedding_model: embedding_model.unwrap_or_else(|| DEFAULT_EMBEDDING_MODEL.to_owned()), + } + } + + fn request(&self, path: &str) -> reqwest::RequestBuilder { + let builder = shared_client() + .post(format!("{}{path}", self.base_url)) + .bearer_auth(self.api_key.expose()); + + let builder = match &self.organization { + Some(org) => builder.header("OpenAI-Organization", org), + None => builder, + }; + match &self.project { + Some(project) => builder.header("OpenAI-Project", project), + None => builder, + } + } +} + +#[async_trait::async_trait] +impl LlmProvider for OpenAiProvider { + fn kind(&self) -> ProviderKind { + ProviderKind::OpenAi + } + + fn model(&self) -> &str { + &self.model + } + + async fn generate(&self, request: &ChatRequest) -> LlmResult { + let body = chat_body(&self.model, request, MaxTokensField::Completion); + let response = self + .request("/chat/completions") + .json(&body) + .send() + .await + .map_err(|e| transport_error(PROVIDER, e))?; + + let json: Value = parse_json(PROVIDER, response).await?; + parse_chat(PROVIDER, &self.model, &json) + } +} + +#[async_trait::async_trait] +impl EmbeddingProvider for OpenAiProvider { + fn kind(&self) -> ProviderKind { + ProviderKind::OpenAi + } + + fn embedding_model(&self) -> &str { + &self.embedding_model + } + + async fn embed(&self, request: &EmbeddingRequest) -> LlmResult { + let body = embedding_body(&self.embedding_model, request); + let response = self + .request("/embeddings") + .json(&body) + .send() + .await + .map_err(|e| transport_error(PROVIDER, e))?; + + let json: Value = parse_json(PROVIDER, response).await?; + parse_embeddings(PROVIDER, &self.embedding_model, &json) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn provider() -> OpenAiProvider { + OpenAiProvider::new( + SecretString::new("sk-test"), + "https://api.openai.com/v1/".into(), + Some("org-1".into()), + None, + "gpt-4o-mini".into(), + None, + ) + } + + #[test] + fn trailing_slash_in_base_url_does_not_double_up() { + let p = provider(); + assert_eq!(p.base_url, "https://api.openai.com/v1"); + } + + #[test] + fn embedding_model_falls_back_to_a_named_default() { + assert_eq!(provider().embedding_model(), DEFAULT_EMBEDDING_MODEL); + + let explicit = OpenAiProvider::new( + SecretString::new("k"), + "https://api.openai.com/v1".into(), + None, + None, + "gpt-4o".into(), + Some("text-embedding-3-large".into()), + ); + assert_eq!(explicit.embedding_model(), "text-embedding-3-large"); + } + + #[test] + fn declares_its_kind_and_model() { + let p = provider(); + assert_eq!(LlmProvider::kind(&p), ProviderKind::OpenAi); + assert_eq!(p.model(), "gpt-4o-mini"); + } + + #[test] + fn debug_output_does_not_leak_the_key() { + assert!(!format!("{:?}", provider()).contains("sk-test")); + } +} diff --git a/orbit/llm/src/providers/openai_shape.rs b/orbit/llm/src/providers/openai_shape.rs new file mode 100644 index 00000000..0d8e533c --- /dev/null +++ b/orbit/llm/src/providers/openai_shape.rs @@ -0,0 +1,381 @@ +//! The OpenAI `/chat/completions` and `/embeddings` wire shape. +//! +//! Shared by [`super::openai`] and [`super::compatible`], because Azure OpenAI, vLLM, Groq, +//! Together, OpenRouter, LM Studio, DeepSeek, and Fireworks all speak it. The two callers differ +//! only in authentication and URL construction. +//! +//! Body construction and response parsing are pure functions taking and returning values, so the +//! emitted wire format is asserted by unit tests rather than by reading the code. + +use crate::error::LlmResult; +use crate::http::missing_field; +use crate::provider::{ProviderChatOutput, ProviderEmbeddingOutput}; +use crate::types::{ChatRequest, EmbeddingRequest, FinishReason, TokenUsage}; +use serde_json::{json, Map, Value}; + +/// Which field name to use for the output-token cap. +/// +/// OpenAI's own API renamed `max_tokens` to `max_completion_tokens` and rejects the old name for +/// reasoning models (o-series, GPT-5). Third-party OpenAI-compatible servers overwhelmingly +/// implement only the original `max_tokens`. Sending the wrong one is a hard 400, so the choice is +/// made per caller rather than guessed from the model name — a name-prefix heuristic would break +/// the first time a vendor ships a model that does not match the pattern. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MaxTokensField { + /// `max_tokens` — third-party compatible servers. + Legacy, + /// `max_completion_tokens` — OpenAI's current API. + Completion, +} + +impl MaxTokensField { + const fn as_str(self) -> &'static str { + match self { + MaxTokensField::Legacy => "max_tokens", + MaxTokensField::Completion => "max_completion_tokens", + } + } +} + +/// Build a `/chat/completions` request body. +/// +/// Every parameter present in `request.params` appears in the output. There is no path by which a +/// configured temperature is accepted and then dropped. +#[must_use] +pub fn chat_body(model: &str, request: &ChatRequest, max_tokens_field: MaxTokensField) -> Value { + let messages: Vec = request + .messages + .iter() + .map(|m| json!({ "role": m.role.as_str(), "content": m.content })) + .collect(); + + let mut body = Map::new(); + body.insert("model".into(), json!(model)); + body.insert("messages".into(), json!(messages)); + + let params = &request.params; + if let Some(temperature) = params.temperature { + body.insert("temperature".into(), json!(temperature)); + } + if let Some(max_tokens) = params.max_tokens { + body.insert(max_tokens_field.as_str().into(), json!(max_tokens)); + } + if let Some(top_p) = params.top_p { + body.insert("top_p".into(), json!(top_p)); + } + if !params.stop.is_empty() { + body.insert("stop".into(), json!(params.stop)); + } + if let Some(seed) = params.seed { + body.insert("seed".into(), json!(seed)); + } + + Value::Object(body) +} + +/// Parse a `/chat/completions` response. +/// +/// # Errors +/// +/// Returns [`crate::LlmError::MalformedResponse`] when the documented fields are absent. +pub fn parse_chat( + provider: &str, + fallback_model: &str, + body: &Value, +) -> LlmResult { + let choice = body + .get("choices") + .and_then(Value::as_array) + .and_then(|c| c.first()) + .ok_or_else(|| missing_field(provider, "choices[0]"))?; + + let text = choice + .pointer("/message/content") + .and_then(Value::as_str) + .ok_or_else(|| missing_field(provider, "choices[0].message.content"))? + .to_owned(); + + Ok(ProviderChatOutput { + text, + model: body + .get("model") + .and_then(Value::as_str) + .unwrap_or(fallback_model) + .to_owned(), + usage: parse_usage(body), + finish_reason: choice + .get("finish_reason") + .and_then(Value::as_str) + .map(FinishReason::from_wire), + }) +} + +/// Read the `usage` object, leaving unreported counts absent. +fn parse_usage(body: &Value) -> TokenUsage { + let field = |name: &str| { + body.pointer(&format!("/usage/{name}")) + .and_then(Value::as_u64) + .map(|v| v as u32) + }; + TokenUsage { + prompt_tokens: field("prompt_tokens"), + completion_tokens: field("completion_tokens"), + } +} + +/// Build an `/embeddings` request body. +#[must_use] +pub fn embedding_body(model: &str, request: &EmbeddingRequest) -> Value { + let mut body = Map::new(); + body.insert("model".into(), json!(model)); + body.insert("input".into(), json!(request.inputs)); + if let Some(dimensions) = request.dimensions { + body.insert("dimensions".into(), json!(dimensions)); + } + Value::Object(body) +} + +/// Parse an `/embeddings` response. +/// +/// Vectors are reordered by the response's `index` field. OpenAI documents that the `data` array +/// may not be in request order, and returning vectors misaligned with their inputs is a silent +/// data-corruption bug that no error surfaces. +/// +/// # Errors +/// +/// Returns [`crate::LlmError::MalformedResponse`] when `data` is absent or an entry lacks an +/// embedding. +pub fn parse_embeddings( + provider: &str, + fallback_model: &str, + body: &Value, +) -> LlmResult { + let data = body + .get("data") + .and_then(Value::as_array) + .ok_or_else(|| missing_field(provider, "data"))?; + + let mut indexed: Vec<(usize, Vec)> = data + .iter() + .enumerate() + .map(|(position, entry)| { + let vector = entry + .get("embedding") + .and_then(Value::as_array) + .ok_or_else(|| missing_field(provider, "data[].embedding"))? + .iter() + .map(|v| v.as_f64().unwrap_or_default() as f32) + .collect(); + let index = entry + .get("index") + .and_then(Value::as_u64) + .map_or(position, |i| i as usize); + Ok((index, vector)) + }) + .collect::>>()?; + + indexed.sort_by_key(|(index, _)| *index); + + Ok(ProviderEmbeddingOutput { + embeddings: indexed.into_iter().map(|(_, vector)| vector).collect(), + model: body + .get("model") + .and_then(Value::as_str) + .unwrap_or(fallback_model) + .to_owned(), + usage: TokenUsage { + prompt_tokens: body + .pointer("/usage/prompt_tokens") + .and_then(Value::as_u64) + .map(|v| v as u32), + // An embeddings call generates no completion tokens; that is a fact about the + // operation, not an unreported measurement, so zero is the honest value. + completion_tokens: Some(0), + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{GenerationParams, Message}; + + /// Assert a JSON number matches an `f32`-sourced value after widening to `f64`. + fn assert_close(actual: &Value, expected: f32) { + let actual = actual + .as_f64() + .unwrap_or_else(|| panic!("not a number: {actual}")); + assert!( + (actual - f64::from(expected)).abs() < 1e-6, + "expected ~{expected}, got {actual}" + ); + } + + fn request() -> ChatRequest { + ChatRequest { + messages: vec![Message::system("be terse"), Message::user("hello")], + params: GenerationParams { + temperature: Some(0.25), + max_tokens: Some(512), + top_p: Some(0.8), + stop: vec!["END".into()], + seed: Some(42), + }, + } + } + + #[test] + fn chat_body_carries_every_configured_parameter() { + let body = chat_body("gpt-4o-mini", &request(), MaxTokensField::Legacy); + + assert_eq!(body["model"], "gpt-4o-mini"); + assert_eq!(body["messages"][0]["role"], "system"); + assert_eq!(body["messages"][0]["content"], "be terse"); + assert_eq!(body["messages"][1]["role"], "user"); + assert_eq!(body["max_tokens"], 512); + // Compared with a tolerance: `GenerationParams` holds `f32`, and serializing widens to the + // nearest f64, so 0.25 is exact but 0.8 is not. + assert_close(&body["temperature"], 0.25); + assert_close(&body["top_p"], 0.8); + assert_eq!(body["stop"][0], "END"); + assert_eq!(body["seed"], 42); + } + + #[test] + fn openai_native_uses_max_completion_tokens() { + let body = chat_body("gpt-5", &request(), MaxTokensField::Completion); + assert_eq!(body["max_completion_tokens"], 512); + assert!( + body.get("max_tokens").is_none(), + "sending both names is a 400 on OpenAI's reasoning models" + ); + } + + #[test] + fn unset_parameters_are_omitted_not_defaulted() { + let bare = ChatRequest::prompt("hi", None); + let body = chat_body("m", &bare, MaxTokensField::Legacy); + + for absent in ["temperature", "max_tokens", "top_p", "stop", "seed"] { + assert!( + body.get(absent).is_none(), + "{absent} must be absent, not defaulted — a default temperature is a claim we did not make" + ); + } + assert_eq!(body["messages"].as_array().map(Vec::len), Some(1)); + } + + #[test] + fn parse_chat_reads_the_documented_shape() { + let body = json!({ + "model": "gpt-4o-mini-2024-07-18", + "choices": [{ + "message": { "role": "assistant", "content": "hi there" }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 12, "completion_tokens": 3, "total_tokens": 15 } + }); + let out = parse_chat("openai", "gpt-4o-mini", &body).expect("parses"); + + assert_eq!(out.text, "hi there"); + assert_eq!(out.model, "gpt-4o-mini-2024-07-18"); + assert_eq!(out.usage.prompt_tokens, Some(12)); + assert_eq!(out.usage.completion_tokens, Some(3)); + assert_eq!(out.finish_reason, Some(FinishReason::Stop)); + } + + #[test] + fn parse_chat_leaves_unreported_usage_absent() { + let body = json!({ + "choices": [{ "message": { "content": "x" } }] + }); + let out = parse_chat("compatible", "local-model", &body).expect("parses"); + + assert_eq!( + out.model, "local-model", + "falls back to the configured name" + ); + assert_eq!(out.usage.prompt_tokens, None); + assert_eq!(out.usage.completion_tokens, None); + assert!(!out.usage.is_reported()); + assert_eq!(out.finish_reason, None); + } + + #[test] + fn parse_chat_reports_a_missing_body() { + let err = parse_chat("openai", "m", &json!({ "choices": [] })).expect_err("no choices"); + assert!(err.to_string().contains("choices[0]")); + + let err = parse_chat("openai", "m", &json!({ "choices": [{}] })).expect_err("no content"); + assert!(err.to_string().contains("message.content")); + } + + #[test] + fn parse_chat_preserves_a_length_truncation() { + let body = json!({ + "choices": [{ "message": { "content": "trunc" }, "finish_reason": "length" }] + }); + let out = parse_chat("openai", "m", &body).expect("parses"); + assert_eq!( + out.finish_reason, + Some(FinishReason::Length), + "a truncated answer must not read as a complete one" + ); + } + + #[test] + fn embedding_body_includes_dimensions_only_when_asked() { + let plain = EmbeddingRequest::new(["a", "b"]); + let body = embedding_body("text-embedding-3-small", &plain); + assert_eq!(body["input"][0], "a"); + assert_eq!(body["input"][1], "b"); + assert!(body.get("dimensions").is_none()); + + let truncated = EmbeddingRequest { + inputs: vec!["a".into()], + dimensions: Some(256), + }; + assert_eq!(embedding_body("m", &truncated)["dimensions"], 256); + } + + #[test] + fn embeddings_are_realigned_with_their_inputs() { + // OpenAI documents that `data` need not arrive in request order. + let body = json!({ + "model": "text-embedding-3-small", + "data": [ + { "index": 2, "embedding": [3.0] }, + { "index": 0, "embedding": [1.0] }, + { "index": 1, "embedding": [2.0] } + ], + "usage": { "prompt_tokens": 9 } + }); + let out = parse_embeddings("openai", "m", &body).expect("parses"); + + assert_eq!( + out.embeddings, + vec![vec![1.0], vec![2.0], vec![3.0]], + "vectors misaligned with their inputs is silent data corruption" + ); + assert_eq!(out.usage.prompt_tokens, Some(9)); + assert_eq!(out.usage.completion_tokens, Some(0)); + } + + #[test] + fn embeddings_without_index_keep_response_order() { + let body = json!({ + "data": [ + { "embedding": [1.0] }, + { "embedding": [2.0] } + ] + }); + let out = parse_embeddings("compatible", "m", &body).expect("parses"); + assert_eq!(out.embeddings, vec![vec![1.0], vec![2.0]]); + } + + #[test] + fn embeddings_report_a_missing_data_array() { + let err = parse_embeddings("openai", "m", &json!({})).expect_err("no data"); + assert!(err.to_string().contains("'data'")); + } +} diff --git a/orbit/llm/src/registry.rs b/orbit/llm/src/registry.rs new file mode 100644 index 00000000..4f785483 --- /dev/null +++ b/orbit/llm/src/registry.rs @@ -0,0 +1,680 @@ +//! The registry of named, switchable model profiles. +//! +//! This is what makes "switch the model without restarting the server" true. A profile is +//! registered, replaced, or removed at runtime; the default can be reassigned by name; and every +//! read is a lock-free-enough `RwLock` read that clones an `Arc`. +//! +//! Reads happen on every request and writes only on a configuration change, so the lock is held +//! for the duration of a map lookup and never across an `await`. + +use crate::breaker::{BreakerState, CircuitBreaker}; +use crate::config::{LlmConfig, ModelProfile}; +use crate::error::{LlmError, LlmResult}; +use crate::providers::{build_provider, BuiltProvider}; +use crate::usage::{ProfileCounters, UsageSnapshot}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::sync::{Arc, RwLock}; + +/// A profile that has been resolved into a live provider. +/// +/// The breaker and counters live here rather than in the router so they survive across requests and +/// are discarded together with the profile they describe. +pub struct RegisteredModel { + /// The configuration this was built from. + pub profile: ModelProfile, + /// The live provider. + pub provider: BuiltProvider, + /// This profile's circuit breaker. + pub breaker: CircuitBreaker, + /// This profile's counters. + pub counters: ProfileCounters, +} + +impl std::fmt::Debug for RegisteredModel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RegisteredModel") + .field("profile", &self.profile) + .field("breaker", &self.breaker.state()) + .finish_non_exhaustive() + } +} + +/// A profile as reported by `LLM.MODELS` / `LLM.INFO`. +/// +/// Derived from the profile rather than holding it, so there is no path by which a credential +/// reaches a command response. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ModelSummary { + /// Profile name. + pub name: String, + /// Provider shape. + pub provider: String, + /// Model identifier. + pub model: String, + /// Embedding model, when configured. + pub embedding_model: Option, + /// Endpoint root. + pub base_url: String, + /// Whether this is the registry's default. + pub is_default: bool, + /// Fallback chain. + pub fallbacks: Vec, + /// Attempt deadline. + pub timeout_ms: u64, + /// Whether prices are configured; `false` means cost will be reported as unknown. + pub has_pricing: bool, + /// Current breaker state. + pub breaker_state: String, + /// Counters for this profile. + pub usage: UsageSnapshot, +} + +#[derive(Default)] +struct RegistryInner { + models: BTreeMap>, + default_profile: Option, +} + +/// Named model profiles, mutable at runtime. +#[derive(Default)] +pub struct LlmRegistry { + inner: RwLock, +} + +impl std::fmt::Debug for LlmRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LlmRegistry") + .field("profiles", &self.profile_names()) + .field("default", &self.default_profile()) + .finish() + } +} + +impl LlmRegistry { + /// An empty registry. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Build a registry from a validated configuration. + /// + /// # Errors + /// + /// Returns [`LlmError::Configuration`] if the configuration does not validate or a profile + /// cannot be turned into a provider. + pub fn from_config(config: &LlmConfig) -> LlmResult { + config.validate()?; + let registry = Self::new(); + for profile in config.profiles.values() { + registry.register(profile.clone())?; + } + if let Some(default) = &config.default_profile { + registry.set_default(default)?; + } + Ok(registry) + } + + /// Add or replace a profile. + /// + /// Replacing resets the breaker and counters: the new configuration has not failed yet, and + /// inheriting an open circuit would reject requests to a provider that was never called. + /// + /// # Errors + /// + /// Returns [`LlmError::Configuration`] if the profile is invalid. + pub fn register(&self, profile: ModelProfile) -> LlmResult<()> { + let provider = build_provider(&profile)?; + let breaker = CircuitBreaker::new(profile.breaker.clone()); + let name = profile.name.clone(); + + let model = Arc::new(RegisteredModel { + profile, + provider, + breaker, + counters: ProfileCounters::default(), + }); + + let mut inner = self.write(); + inner.models.insert(name.clone(), model); + // First profile registered becomes the default, so a single-profile deployment needs no + // separate default_profile setting. + if inner.default_profile.is_none() { + inner.default_profile = Some(name); + } + Ok(()) + } + + /// Register a profile with a caller-supplied provider. + /// + /// The seam used by tests and by any future in-process provider that is not built from HTTP + /// configuration. The profile is still validated for name and fallback sanity. + /// + /// # Errors + /// + /// Returns [`LlmError::Configuration`] for an unnamed profile or a self-referential fallback. + pub fn register_with_provider( + &self, + profile: ModelProfile, + provider: BuiltProvider, + ) -> LlmResult<()> { + if profile.name.trim().is_empty() { + return Err(LlmError::configuration("model profile requires a name")); + } + if profile.fallbacks.iter().any(|f| f == &profile.name) { + return Err(LlmError::configuration(format!( + "profile '{}' lists itself as a fallback, which would loop", + profile.name + ))); + } + + let breaker = CircuitBreaker::new(profile.breaker.clone()); + let name = profile.name.clone(); + let model = Arc::new(RegisteredModel { + profile, + provider, + breaker, + counters: ProfileCounters::default(), + }); + + let mut inner = self.write(); + inner.models.insert(name.clone(), model); + if inner.default_profile.is_none() { + inner.default_profile = Some(name); + } + Ok(()) + } + + /// Remove a profile. + /// + /// # Errors + /// + /// Returns [`LlmError::UnknownProfile`] if it is not registered, or + /// [`LlmError::Configuration`] if another profile falls back to it — removing it would turn a + /// configured failover into one that cannot fire. + pub fn unregister(&self, name: &str) -> LlmResult<()> { + let mut inner = self.write(); + if !inner.models.contains_key(name) { + return Err(LlmError::UnknownProfile { + name: name.to_owned(), + }); + } + if let Some(dependent) = inner + .models + .values() + .find(|m| m.profile.fallbacks.iter().any(|f| f == name)) + { + return Err(LlmError::configuration(format!( + "cannot remove '{name}': profile '{}' falls back to it", + dependent.profile.name + ))); + } + + inner.models.remove(name); + if inner.default_profile.as_deref() == Some(name) { + // Promote deterministically rather than leaving the registry with no default: a + // silently defaultless registry fails every unnamed request afterwards. + inner.default_profile = inner.models.keys().next().cloned(); + } + Ok(()) + } + + /// Make `name` the default profile. + /// + /// This is `LLM.USE`: the switch takes effect for the next request, with no restart. + /// + /// # Errors + /// + /// Returns [`LlmError::UnknownProfile`] if it is not registered. + pub fn set_default(&self, name: &str) -> LlmResult<()> { + let mut inner = self.write(); + if !inner.models.contains_key(name) { + return Err(LlmError::UnknownProfile { + name: name.to_owned(), + }); + } + inner.default_profile = Some(name.to_owned()); + Ok(()) + } + + /// The default profile's name, if one is set. + #[must_use] + pub fn default_profile(&self) -> Option { + self.read().default_profile.clone() + } + + /// Resolve a profile by name, or the default when `name` is `None`. + /// + /// # Errors + /// + /// Returns [`LlmError::UnknownProfile`] or [`LlmError::NoDefaultProfile`]. + pub fn resolve(&self, name: Option<&str>) -> LlmResult> { + let inner = self.read(); + let name = match name { + Some(name) => name.to_owned(), + None => inner + .default_profile + .clone() + .ok_or(LlmError::NoDefaultProfile)?, + }; + inner + .models + .get(&name) + .cloned() + .ok_or(LlmError::UnknownProfile { name }) + } + + /// Registered profile names, in stable order. + #[must_use] + pub fn profile_names(&self) -> Vec { + self.read().models.keys().cloned().collect() + } + + /// Whether a profile is registered. + #[must_use] + pub fn contains(&self, name: &str) -> bool { + self.read().models.contains_key(name) + } + + /// Number of registered profiles. + #[must_use] + pub fn len(&self) -> usize { + self.read().models.len() + } + + /// Whether the registry holds no profiles. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Summaries of every profile, in stable order. + #[must_use] + pub fn summaries(&self) -> Vec { + let inner = self.read(); + let default = inner.default_profile.clone(); + inner + .models + .values() + .map(|model| summarize(model, default.as_deref())) + .collect() + } + + /// Summary of one profile. + /// + /// # Errors + /// + /// Returns [`LlmError::UnknownProfile`] if it is not registered. + pub fn summary(&self, name: &str) -> LlmResult { + let inner = self.read(); + let default = inner.default_profile.clone(); + inner + .models + .get(name) + .map(|model| summarize(model, default.as_deref())) + .ok_or_else(|| LlmError::UnknownProfile { + name: name.to_owned(), + }) + } + + /// Resolve `name`'s attempt order: the profile itself followed by its fallback chain. + /// + /// Chains are followed transitively and de-duplicated, so a cycle terminates instead of + /// looping. Fallbacks naming an unregistered profile are skipped — the chain is best-effort at + /// call time, while [`LlmConfig::validate`] rejects dangling references at load time. + #[must_use] + pub fn attempt_chain(&self, name: &str) -> Vec> { + let inner = self.read(); + let mut chain = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut queue = vec![name.to_owned()]; + + while let Some(current) = queue.pop() { + if !seen.insert(current.clone()) { + continue; + } + let Some(model) = inner.models.get(¤t) else { + continue; + }; + chain.push(Arc::clone(model)); + // Reversed so the queue (a stack) pops them in declaration order. + queue.extend(model.profile.fallbacks.iter().rev().cloned()); + } + + chain + } + + fn read(&self) -> std::sync::RwLockReadGuard<'_, RegistryInner> { + // A poisoned lock means a writer panicked mid-update. The registry holds no invariant that + // a panic could half-break — every mutation is a single map operation — so recovering is + // strictly better than propagating a panic into every subsequent request. + self.inner.read().unwrap_or_else(|e| e.into_inner()) + } + + fn write(&self) -> std::sync::RwLockWriteGuard<'_, RegistryInner> { + self.inner.write().unwrap_or_else(|e| e.into_inner()) + } +} + +fn summarize(model: &RegisteredModel, default: Option<&str>) -> ModelSummary { + let profile = &model.profile; + ModelSummary { + name: profile.name.clone(), + provider: profile.provider.kind().to_string(), + model: profile.model.clone(), + embedding_model: profile.embedding_model.clone(), + base_url: profile.provider.base_url().to_owned(), + is_default: default == Some(profile.name.as_str()), + fallbacks: profile.fallbacks.clone(), + timeout_ms: profile.timeout_ms, + has_pricing: profile.pricing.is_some(), + breaker_state: model.breaker.state().as_str().to_owned(), + usage: model.counters.snapshot(), + } +} + +/// Breaker state for a profile, for `LLM.STATS`. +#[must_use] +pub fn breaker_state_of(model: &RegisteredModel) -> BreakerState { + model.breaker.state() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ProviderConfig; + use crate::testing::stub_provider; + + fn profile(name: &str) -> ModelProfile { + ModelProfile::new( + name, + ProviderConfig::Ollama { + base_url: "http://localhost:11434".into(), + }, + "llama3.2", + ) + } + + fn registry_with(names: &[&str]) -> LlmRegistry { + let registry = LlmRegistry::new(); + for name in names { + registry.register(profile(name)).expect("registers"); + } + registry + } + + #[test] + fn first_registration_becomes_the_default() { + let registry = registry_with(&["a", "b"]); + assert_eq!(registry.default_profile().as_deref(), Some("a")); + assert_eq!(registry.len(), 2); + assert!(!registry.is_empty()); + } + + #[test] + fn switching_the_default_takes_effect_immediately() { + let registry = registry_with(&["a", "b"]); + registry.set_default("b").expect("b is registered"); + + let resolved = registry.resolve(None).expect("default resolves"); + assert_eq!( + resolved.profile.name, "b", + "LLM.USE must change the next request's model without a restart" + ); + } + + #[test] + fn switching_to_an_unregistered_profile_is_rejected() { + let registry = registry_with(&["a"]); + let err = registry.set_default("ghost").expect_err("not registered"); + assert!(matches!(err, LlmError::UnknownProfile { .. })); + assert_eq!( + registry.default_profile().as_deref(), + Some("a"), + "a rejected switch must not clear the working default" + ); + } + + #[test] + fn re_registering_replaces_the_profile_in_place() { + let registry = registry_with(&["a"]); + let mut updated = profile("a"); + updated.model = "qwen3".into(); + registry.register(updated).expect("replaces"); + + assert_eq!(registry.len(), 1); + assert_eq!( + registry.resolve(Some("a")).expect("resolves").profile.model, + "qwen3" + ); + } + + #[test] + fn re_registering_clears_an_open_circuit() { + let registry = registry_with(&["a"]); + let before = registry.resolve(Some("a")).expect("resolves"); + for _ in 0..10 { + before.breaker.record_failure(); + } + assert_eq!(before.breaker.state(), BreakerState::Open); + + registry.register(profile("a")).expect("replaces"); + let after = registry.resolve(Some("a")).expect("resolves"); + assert_eq!( + after.breaker.state(), + BreakerState::Closed, + "a reconfigured provider has not failed yet" + ); + } + + #[test] + fn resolve_reports_an_unknown_profile_by_name() { + let registry = registry_with(&["a"]); + let err = registry.resolve(Some("ghost")).expect_err("unknown"); + assert!(err.to_string().contains("'ghost'")); + } + + #[test] + fn an_empty_registry_has_no_default() { + let registry = LlmRegistry::new(); + assert!(registry.is_empty()); + assert!(matches!( + registry.resolve(None).expect_err("no default"), + LlmError::NoDefaultProfile + )); + } + + #[test] + fn removing_the_default_promotes_another_profile() { + let registry = registry_with(&["a", "b"]); + registry.unregister("a").expect("removes"); + + assert_eq!( + registry.default_profile().as_deref(), + Some("b"), + "leaving the registry defaultless would fail every unnamed request" + ); + } + + #[test] + fn removing_the_last_profile_leaves_no_default() { + let registry = registry_with(&["a"]); + registry.unregister("a").expect("removes"); + assert_eq!(registry.default_profile(), None); + assert!(registry.is_empty()); + } + + #[test] + fn a_profile_another_falls_back_to_cannot_be_removed() { + let registry = LlmRegistry::new(); + registry.register(profile("backup")).expect("registers"); + registry + .register(profile("primary").with_fallbacks(vec!["backup".into()])) + .expect("registers"); + + let err = registry.unregister("backup").expect_err("still referenced"); + assert!(err.to_string().contains("'primary' falls back to it")); + assert!(registry.contains("backup")); + } + + #[test] + fn attempt_chain_follows_fallbacks_in_declaration_order() { + let registry = LlmRegistry::new(); + registry.register(profile("c")).expect("registers"); + registry.register(profile("b")).expect("registers"); + registry + .register(profile("a").with_fallbacks(vec!["b".into(), "c".into()])) + .expect("registers"); + + let chain: Vec<_> = registry + .attempt_chain("a") + .iter() + .map(|m| m.profile.name.clone()) + .collect(); + assert_eq!(chain, vec!["a", "b", "c"]); + } + + #[test] + fn attempt_chain_is_transitive() { + let registry = LlmRegistry::new(); + registry.register(profile("c")).expect("registers"); + registry + .register(profile("b").with_fallbacks(vec!["c".into()])) + .expect("registers"); + registry + .register(profile("a").with_fallbacks(vec!["b".into()])) + .expect("registers"); + + let chain: Vec<_> = registry + .attempt_chain("a") + .iter() + .map(|m| m.profile.name.clone()) + .collect(); + assert_eq!(chain, vec!["a", "b", "c"]); + } + + #[test] + fn a_fallback_cycle_terminates() { + let registry = LlmRegistry::new(); + // Registered without cross-validation, then wired into a cycle — exactly the state a pair + // of runtime LLM.REGISTER calls can produce. + registry.register(profile("b")).expect("registers"); + registry + .register(profile("a").with_fallbacks(vec!["b".into()])) + .expect("registers"); + registry + .register(profile("b").with_fallbacks(vec!["a".into()])) + .expect("registers"); + + let chain: Vec<_> = registry + .attempt_chain("a") + .iter() + .map(|m| m.profile.name.clone()) + .collect(); + assert_eq!(chain, vec!["a", "b"], "each profile is attempted once"); + } + + #[test] + fn attempt_chain_skips_unregistered_fallbacks() { + let registry = LlmRegistry::new(); + registry + .register_with_provider( + profile("a").with_fallbacks(vec!["ghost".into()]), + stub_provider(|_| Ok("ok".into())), + ) + .expect("registers"); + + let chain = registry.attempt_chain("a"); + assert_eq!(chain.len(), 1); + } + + #[test] + fn summaries_are_ordered_and_flag_the_default() { + let registry = registry_with(&["zeta", "alpha"]); + registry.set_default("zeta").expect("registered"); + + let summaries = registry.summaries(); + assert_eq!( + summaries + .iter() + .map(|s| s.name.as_str()) + .collect::>(), + vec!["alpha", "zeta"], + "stable order makes LLM.MODELS output diffable" + ); + assert!(summaries.iter().any(|s| s.is_default && s.name == "zeta")); + assert_eq!(summaries.iter().filter(|s| s.is_default).count(), 1); + } + + #[test] + fn a_summary_carries_no_credential() { + let registry = LlmRegistry::new(); + registry + .register(ModelProfile::new( + "openai", + ProviderConfig::OpenAi { + api_key: crate::SecretString::new("sk-must-not-escape"), + base_url: "https://api.openai.com/v1".into(), + organization: None, + project: None, + }, + "gpt-4o-mini", + )) + .expect("registers"); + + let summary = registry.summary("openai").expect("exists"); + let rendered = serde_json::to_string(&summary).expect("serializes"); + assert!(!rendered.contains("sk-must-not-escape"), "got: {rendered}"); + } + + #[test] + fn a_summary_reports_whether_cost_can_be_computed() { + let registry = LlmRegistry::new(); + registry.register(profile("unpriced")).expect("registers"); + registry + .register(profile("priced").with_pricing(crate::config::ModelPricing { + prompt_usd_per_million: 1.0, + completion_usd_per_million: 2.0, + })) + .expect("registers"); + + assert!(!registry.summary("unpriced").expect("exists").has_pricing); + assert!(registry.summary("priced").expect("exists").has_pricing); + } + + #[test] + fn from_config_registers_every_profile_and_the_default() { + let config = LlmConfig::from_toml_str( + r#" +enabled = true +default_profile = "second" + +[profiles.first] +provider = "ollama" +model = "llama3.2" + +[profiles.second] +provider = "ollama" +model = "qwen3" +"#, + ) + .expect("parses"); + + let registry = LlmRegistry::from_config(&config).expect("builds"); + assert_eq!(registry.profile_names(), vec!["first", "second"]); + assert_eq!(registry.default_profile().as_deref(), Some("second")); + } + + #[test] + fn a_self_referential_fallback_is_refused_even_via_the_test_seam() { + let registry = LlmRegistry::new(); + let err = registry + .register_with_provider( + profile("loop").with_fallbacks(vec!["loop".into()]), + stub_provider(|_| Ok("ok".into())), + ) + .expect_err("self-fallback rejected"); + assert!(err.to_string().contains("itself as a fallback")); + } +} diff --git a/orbit/llm/src/router.rs b/orbit/llm/src/router.rs new file mode 100644 index 00000000..86c766f3 --- /dev/null +++ b/orbit/llm/src/router.rs @@ -0,0 +1,865 @@ +//! The request path: resolve → breaker → timeout → retry → fallback → account. +//! +//! Providers do HTTP shaping and nothing else. Every resilience behavior lives here, so it is +//! identical across backends rather than reimplemented per backend — and testable against an +//! in-process stub rather than against a live API. +//! +//! Ordering matters and is deliberate: +//! +//! 1. **Breaker before attempt** — a known-dead provider costs zero, not one full timeout. +//! 2. **Timeout inside retry** — each attempt gets the full deadline; a slow first attempt does not +//! consume the second attempt's budget. +//! 3. **Retry inside fallback** — transient faults are absorbed at the primary before paying the +//! latency of switching providers. +//! 4. **Accounting after everything** — including which fallbacks fired, because a failover nobody +//! can see is an outage nobody can see. + +use crate::error::{LlmError, LlmResult}; +use crate::registry::{LlmRegistry, RegisteredModel}; +use crate::types::{ChatRequest, ChatResponse, EmbeddingRequest, EmbeddingResponse}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tracing::{debug, warn}; + +/// Routes requests through the registry, applying resilience and accounting. +#[derive(Debug, Clone)] +pub struct Router { + registry: Arc, +} + +impl Router { + /// Build a router over a registry. + #[must_use] + pub fn new(registry: Arc) -> Self { + Self { registry } + } + + /// The registry this router reads. + #[must_use] + pub fn registry(&self) -> &Arc { + &self.registry + } + + /// Generate text, falling back through the chain if needed. + /// + /// `profile` names a registered profile; `None` uses the registry default. Parameters on + /// `request` override the profile's defaults field by field. + /// + /// # Errors + /// + /// Returns [`LlmError::UnknownProfile`] or [`LlmError::NoDefaultProfile`] if resolution fails, + /// and [`LlmError::AllProvidersFailed`] if every profile in the chain failed. + pub async fn generate( + &self, + profile: Option<&str>, + request: ChatRequest, + ) -> LlmResult { + let primary = self.registry.resolve(profile)?; + let chain = self.registry.attempt_chain(&primary.profile.name); + let mut attempted = Vec::with_capacity(chain.len()); + let mut last_error: Option = None; + + for model in &chain { + let is_fallback = !attempted.is_empty(); + let name = model.profile.name.as_str(); + + if !model.breaker.allows_request() { + let failures = model.breaker.consecutive_failures(); + debug!(profile = name, failures, "skipping profile: circuit open"); + attempted.push(name.to_owned()); + last_error = Some(LlmError::CircuitOpen { + profile: name.to_owned(), + failures, + }); + continue; + } + + // The profile's defaults, overlaid with anything the caller specified. + let effective = ChatRequest { + messages: request.messages.clone(), + params: model.profile.params.merged_with(&request.params), + }; + + let started = Instant::now(); + let llm = Arc::clone(&model.provider.llm); + match attempt_with_retry(model, || { + let effective = effective.clone(); + let llm = Arc::clone(&llm); + async move { llm.generate(&effective).await } + }) + .await + { + Ok(output) => { + let latency = started.elapsed(); + let cost = model + .profile + .pricing + .and_then(|pricing| pricing.cost_of(&output.usage)); + + model + .counters + .record_success(&output.usage, cost, latency.as_millis() as u64); + if is_fallback { + model.counters.record_fallback_use(); + // Attribute the failover to where the request started, so an operator + // reading the primary's stats sees that it is shedding traffic. + if let Some(first) = chain.first() { + first.counters.record_fallback_fired(); + } + warn!( + requested = %primary.profile.name, + served_by = name, + skipped = ?attempted, + "LLM request served by a fallback profile" + ); + } + + return Ok(ChatResponse { + text: output.text, + model: output.model, + profile: name.to_owned(), + usage: output.usage, + cost, + finish_reason: output.finish_reason, + latency, + fallbacks_used: attempted, + }); + } + Err(err) => { + model.counters.record_failure(); + warn!(profile = name, error = %err, "LLM attempt failed"); + attempted.push(name.to_owned()); + last_error = Some(err); + } + } + } + + Err(finish_failed(attempted, last_error, &primary.profile.name)) + } + + /// Produce embeddings, falling back through the chain if needed. + /// + /// # Errors + /// + /// As [`Router::generate`], plus [`LlmError::Unsupported`] surfacing from a provider with no + /// embeddings endpoint. + pub async fn embed( + &self, + profile: Option<&str>, + request: EmbeddingRequest, + ) -> LlmResult { + let primary = self.registry.resolve(profile)?; + let chain = self.registry.attempt_chain(&primary.profile.name); + let mut attempted = Vec::with_capacity(chain.len()); + let mut last_error: Option = None; + + for model in &chain { + let name = model.profile.name.as_str(); + + if !model.breaker.allows_request() { + attempted.push(name.to_owned()); + last_error = Some(LlmError::CircuitOpen { + profile: name.to_owned(), + failures: model.breaker.consecutive_failures(), + }); + continue; + } + + let started = Instant::now(); + let embedder = Arc::clone(&model.provider.embedding); + match attempt_with_retry(model, || { + let request = request.clone(); + let embedder = Arc::clone(&embedder); + async move { embedder.embed(&request).await } + }) + .await + { + Ok(output) => { + let latency = started.elapsed(); + model + .counters + .record_success(&output.usage, None, latency.as_millis() as u64); + if !attempted.is_empty() { + model.counters.record_fallback_use(); + warn!( + requested = %primary.profile.name, + served_by = name, + "embedding request served by a fallback profile" + ); + } + return Ok(EmbeddingResponse { + embeddings: output.embeddings, + model: output.model, + profile: name.to_owned(), + usage: output.usage, + latency, + }); + } + Err(err) => { + model.counters.record_failure(); + attempted.push(name.to_owned()); + last_error = Some(err); + } + } + } + + Err(finish_failed(attempted, last_error, &primary.profile.name)) + } +} + +/// Collapse an exhausted chain into one error. +/// +/// A single-profile failure reports its own error unwrapped — wrapping it in "all 1 profiles +/// failed" adds noise without adding information. +fn finish_failed(attempted: Vec, last_error: Option, primary: &str) -> LlmError { + let last = last_error.unwrap_or_else(|| LlmError::UnknownProfile { + name: primary.to_owned(), + }); + if attempted.len() <= 1 { + return last; + } + LlmError::AllProvidersFailed { + attempted, + last: Box::new(last), + } +} + +/// Run one profile's attempts: timeout per attempt, retry per policy, breaker updated throughout. +async fn attempt_with_retry(model: &RegisteredModel, mut call: F) -> LlmResult +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + let policy = &model.profile.retry; + let timeout = model.profile.timeout(); + let provider_name = model.profile.provider.kind().to_string(); + let mut attempts = 0_u32; + + loop { + attempts += 1; + let outcome = run_once(&provider_name, timeout, call()).await; + + match outcome { + Ok(value) => { + model.breaker.record_success(); + return Ok(value); + } + Err(err) => { + if err.indicates_provider_unhealthy() { + model.breaker.record_failure(); + } + if !err.is_retryable() || !policy.should_retry(attempts) { + return Err(err); + } + let delay = policy.delay_for_attempt(attempts, err.retry_after()); + debug!( + profile = %model.profile.name, + attempt = attempts, + delay_ms = delay.as_millis() as u64, + error = %err, + "retrying LLM request" + ); + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + } + } + } +} + +/// Apply the per-attempt deadline. +async fn run_once( + provider: &str, + timeout: Duration, + future: impl std::future::Future>, +) -> LlmResult { + let started = Instant::now(); + match tokio::time::timeout(timeout, future).await { + Ok(result) => result, + Err(_) => Err(LlmError::Timeout { + provider: provider.to_owned(), + elapsed: started.elapsed(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::breaker::BreakerConfig; + use crate::config::{ModelPricing, ModelProfile, ProviderConfig}; + use crate::retry::RetryPolicy; + use crate::testing::{api_error, stub_with_handle, StubProvider}; + use crate::types::{GenerationParams, TokenUsage}; + + fn base_profile(name: &str) -> ModelProfile { + ModelProfile { + retry: RetryPolicy::none(), + timeout_ms: 500, + ..ModelProfile::new( + name, + ProviderConfig::Ollama { + base_url: "http://localhost:11434".into(), + }, + "stub", + ) + } + } + + fn router_with(entries: Vec<(ModelProfile, StubProvider)>) -> (Router, Vec>) { + let registry = Arc::new(LlmRegistry::new()); + let handles = entries + .into_iter() + .map(|(profile, stub)| { + let (built, handle) = stub_with_handle(stub); + registry + .register_with_provider(profile, built) + .expect("registers"); + handle + }) + .collect(); + (Router::new(Arc::clone(®istry)), handles) + } + + fn ask() -> ChatRequest { + ChatRequest::prompt("question", None) + } + + #[tokio::test] + async fn a_healthy_primary_answers_without_failover() { + let (router, _) = router_with(vec![( + base_profile("main"), + StubProvider::new(|_| Ok("answer".into())), + )]); + + let response = router.generate(None, ask()).await.expect("succeeds"); + assert_eq!(response.text, "answer"); + assert_eq!(response.profile, "main"); + assert!(response.fallbacks_used.is_empty()); + } + + #[tokio::test] + async fn a_failing_primary_fails_over_and_the_failover_is_visible() { + let (router, handles) = router_with(vec![ + ( + base_profile("primary").with_fallbacks(vec!["backup".into()]), + StubProvider::new(|_| Err(api_error(503))), + ), + ( + base_profile("backup"), + StubProvider::new(|_| Ok("from backup".into())), + ), + ]); + + let response = router + .generate(Some("primary"), ask()) + .await + .expect("succeeds"); + + assert_eq!(response.text, "from backup"); + assert_eq!(response.profile, "backup"); + assert_eq!( + response.fallbacks_used, + vec!["primary".to_string()], + "a silent failover is an outage nobody can see" + ); + assert_eq!(handles[0].calls(), 1); + assert_eq!(handles[1].calls(), 1); + + let primary = router.registry().summary("primary").expect("exists"); + let backup = router.registry().summary("backup").expect("exists"); + assert_eq!(primary.usage.failures, 1); + assert_eq!(primary.usage.fallbacks_fired, 1); + assert_eq!(backup.usage.fallback_uses, 1); + } + + #[tokio::test] + async fn a_whole_failed_chain_reports_every_profile_tried() { + let (router, _) = router_with(vec![ + ( + base_profile("primary").with_fallbacks(vec!["backup".into()]), + StubProvider::new(|_| Err(api_error(503))), + ), + ( + base_profile("backup"), + StubProvider::new(|_| Err(api_error(500))), + ), + ]); + + let err = router + .generate(Some("primary"), ask()) + .await + .expect_err("both failed"); + let LlmError::AllProvidersFailed { attempted, .. } = &err else { + panic!("expected AllProvidersFailed, got {err:?}"); + }; + assert_eq!(attempted, &["primary".to_string(), "backup".to_string()]); + } + + #[tokio::test] + async fn a_lone_profile_failure_is_not_wrapped() { + let (router, _) = router_with(vec![( + base_profile("only"), + StubProvider::new(|_| Err(api_error(401))), + )]); + + let err = router.generate(None, ask()).await.expect_err("fails"); + assert!( + matches!(err, LlmError::Api { status: 401, .. }), + "wrapping a single failure adds noise, not information; got {err:?}" + ); + } + + #[tokio::test] + async fn a_retryable_failure_is_retried_within_the_budget() { + let profile = ModelProfile { + retry: RetryPolicy { + max_retries: 2, + initial_backoff_ms: 1, + max_backoff_ms: 2, + backoff_multiplier_pct: 200, + }, + ..base_profile("flaky") + }; + let (router, handles) = router_with(vec![( + profile, + StubProvider::new(|call| { + if call < 2 { + Err(api_error(503)) + } else { + Ok("recovered".into()) + } + }), + )]); + + let response = router.generate(None, ask()).await.expect("recovers"); + assert_eq!(response.text, "recovered"); + assert_eq!(handles[0].calls(), 3, "two failures then a success"); + } + + #[tokio::test] + async fn a_non_retryable_failure_is_not_retried() { + let profile = ModelProfile { + retry: RetryPolicy { + max_retries: 5, + initial_backoff_ms: 1, + max_backoff_ms: 2, + backoff_multiplier_pct: 200, + }, + ..base_profile("authfail") + }; + let (router, handles) = + router_with(vec![(profile, StubProvider::new(|_| Err(api_error(401))))]); + + router.generate(None, ask()).await.expect_err("401"); + assert_eq!( + handles[0].calls(), + 1, + "retrying a 401 burns quota to reach the same answer" + ); + } + + #[tokio::test] + async fn the_retry_budget_is_finite() { + let profile = ModelProfile { + retry: RetryPolicy { + max_retries: 3, + initial_backoff_ms: 1, + max_backoff_ms: 2, + backoff_multiplier_pct: 200, + }, + ..base_profile("down") + }; + let (router, handles) = + router_with(vec![(profile, StubProvider::new(|_| Err(api_error(503))))]); + + router.generate(None, ask()).await.expect_err("always down"); + assert_eq!(handles[0].calls(), 4, "the initial attempt plus 3 retries"); + } + + #[tokio::test] + async fn a_hung_provider_hits_the_deadline_instead_of_hanging_the_query() { + let profile = ModelProfile { + timeout_ms: 50, + ..base_profile("slow") + }; + let (router, _) = router_with(vec![( + profile, + StubProvider::new(|_| Ok("eventually".into())).with_delay(Duration::from_secs(30)), + )]); + + let started = Instant::now(); + let err = router.generate(None, ask()).await.expect_err("times out"); + assert!(matches!(err, LlmError::Timeout { .. }), "got {err:?}"); + assert!( + started.elapsed() < Duration::from_secs(5), + "the deadline must bound the request, not the provider's patience" + ); + } + + #[tokio::test] + async fn a_timeout_on_the_primary_fails_over() { + let (router, _) = router_with(vec![ + ( + ModelProfile { + timeout_ms: 30, + ..base_profile("slow").with_fallbacks(vec!["quick".into()]) + }, + StubProvider::new(|_| Ok("never".into())).with_delay(Duration::from_secs(30)), + ), + ( + base_profile("quick"), + StubProvider::new(|_| Ok("fast answer".into())), + ), + ]); + + let response = router + .generate(Some("slow"), ask()) + .await + .expect("fails over"); + assert_eq!(response.text, "fast answer"); + assert_eq!(response.fallbacks_used, vec!["slow".to_string()]); + } + + #[tokio::test] + async fn an_open_breaker_short_circuits_instead_of_calling_the_provider() { + let profile = ModelProfile { + breaker: BreakerConfig { + failure_threshold: 2, + open_duration_ms: 60_000, + success_threshold: 1, + }, + ..base_profile("dead") + }; + let (router, handles) = + router_with(vec![(profile, StubProvider::new(|_| Err(api_error(503))))]); + + for _ in 0..2 { + router.generate(None, ask()).await.expect_err("down"); + } + assert_eq!(handles[0].calls(), 2); + + let err = router + .generate(None, ask()) + .await + .expect_err("circuit open"); + assert!(matches!(err, LlmError::CircuitOpen { .. }), "got {err:?}"); + assert_eq!( + handles[0].calls(), + 2, + "a known-dead provider must cost zero, not one full timeout" + ); + } + + #[tokio::test] + async fn a_client_error_does_not_open_the_breaker() { + let profile = ModelProfile { + breaker: BreakerConfig { + failure_threshold: 2, + open_duration_ms: 60_000, + success_threshold: 1, + }, + ..base_profile("picky") + }; + let (router, handles) = router_with(vec![( + profile, + StubProvider::new(|call| { + if call < 3 { + Err(api_error(400)) + } else { + Ok("fine".into()) + } + }), + )]); + + for _ in 0..3 { + router.generate(None, ask()).await.expect_err("bad request"); + } + let response = router.generate(None, ask()).await.expect("still reachable"); + assert_eq!( + response.text, "fine", + "one caller's malformed prompt must not take a healthy provider out of service" + ); + assert_eq!(handles[0].calls(), 4); + } + + #[tokio::test] + async fn an_open_breaker_falls_through_to_the_next_profile() { + let (router, handles) = router_with(vec![ + ( + ModelProfile { + breaker: BreakerConfig { + failure_threshold: 1, + open_duration_ms: 60_000, + success_threshold: 1, + }, + ..base_profile("dead").with_fallbacks(vec!["alive".into()]) + }, + StubProvider::new(|_| Err(api_error(503))), + ), + ( + base_profile("alive"), + StubProvider::new(|_| Ok("healthy".into())), + ), + ]); + + // First call trips the breaker and fails over. + router + .generate(Some("dead"), ask()) + .await + .expect("fails over"); + let calls_after_first = handles[0].calls(); + + let response = router + .generate(Some("dead"), ask()) + .await + .expect("fails over"); + assert_eq!(response.profile, "alive"); + assert_eq!( + handles[0].calls(), + calls_after_first, + "the open circuit was not dialled again" + ); + } + + #[tokio::test] + async fn request_parameters_override_profile_defaults() { + let profile = base_profile("params").with_params(GenerationParams { + temperature: Some(0.1), + max_tokens: Some(100), + ..Default::default() + }); + let registry = Arc::new(LlmRegistry::new()); + let captured = Arc::new(std::sync::Mutex::new(GenerationParams::default())); + + struct Capturing(Arc>); + + #[async_trait::async_trait] + impl crate::provider::LlmProvider for Capturing { + fn kind(&self) -> crate::ProviderKind { + crate::ProviderKind::Compatible + } + fn model(&self) -> &str { + "capture" + } + async fn generate( + &self, + request: &ChatRequest, + ) -> LlmResult { + *self.0.lock().expect("lock") = request.params.clone(); + Ok(crate::provider::ProviderChatOutput { + text: "ok".into(), + model: "capture".into(), + usage: TokenUsage::default(), + finish_reason: None, + }) + } + } + + #[async_trait::async_trait] + impl crate::provider::EmbeddingProvider for Capturing { + fn kind(&self) -> crate::ProviderKind { + crate::ProviderKind::Compatible + } + fn embedding_model(&self) -> &str { + "capture" + } + async fn embed( + &self, + _: &EmbeddingRequest, + ) -> LlmResult { + unreachable!("not exercised by this test") + } + } + + let capturing = Arc::new(Capturing(Arc::clone(&captured))); + registry + .register_with_provider( + profile, + crate::providers::BuiltProvider { + llm: Arc::clone(&capturing) as Arc, + embedding: capturing as Arc, + }, + ) + .expect("registers"); + + let router = Router::new(registry); + router + .generate( + None, + ask().with_params(GenerationParams { + temperature: Some(0.9), + ..Default::default() + }), + ) + .await + .expect("succeeds"); + + let seen = captured.lock().expect("lock").clone(); + assert_eq!(seen.temperature, Some(0.9), "the request wins"); + assert_eq!(seen.max_tokens, Some(100), "the profile fills the gap"); + } + + #[tokio::test] + async fn cost_is_computed_only_when_the_profile_is_priced() { + let (unpriced_router, _) = router_with(vec![( + base_profile("free"), + StubProvider::new(|_| Ok("x".into())), + )]); + let response = unpriced_router + .generate(None, ask()) + .await + .expect("succeeds"); + assert!( + response.cost.is_none(), + "an unconfigured price must read as unknown, not as zero" + ); + + let (priced_router, _) = router_with(vec![( + base_profile("paid").with_pricing(ModelPricing { + prompt_usd_per_million: 1_000_000.0, + completion_usd_per_million: 1_000_000.0, + }), + StubProvider::new(|_| Ok("x".into())).with_usage(TokenUsage { + prompt_tokens: Some(2), + completion_tokens: Some(3), + }), + )]); + let cost = priced_router + .generate(None, ask()) + .await + .expect("succeeds") + .cost + .expect("priced"); + assert!((cost.total_usd() - 5.0).abs() < 1e-9); + } + + #[tokio::test] + async fn unreported_usage_stays_unreported_through_the_router() { + let (router, _) = router_with(vec![( + base_profile("local"), + StubProvider::new(|_| Ok("x".into())).with_usage(TokenUsage::default()), + )]); + + let response = router.generate(None, ask()).await.expect("succeeds"); + assert_eq!(response.usage.total(), None); + assert!(!router + .registry() + .summary("local") + .expect("exists") + .usage + .tokens_are_complete()); + } + + #[tokio::test] + async fn switching_the_default_changes_which_model_answers() { + let (router, _) = router_with(vec![ + ( + base_profile("a"), + StubProvider::new(|_| Ok("from a".into())), + ), + ( + base_profile("b"), + StubProvider::new(|_| Ok("from b".into())), + ), + ]); + + assert_eq!( + router.generate(None, ask()).await.expect("a").text, + "from a" + ); + router.registry().set_default("b").expect("registered"); + assert_eq!( + router.generate(None, ask()).await.expect("b").text, + "from b", + "LLM.USE must take effect on the next request, with no restart" + ); + } + + #[tokio::test] + async fn embeddings_route_and_account_like_generations() { + let (router, _) = router_with(vec![( + base_profile("embed"), + StubProvider::new(|_| Ok("seed".into())), + )]); + + let response = router + .embed(None, EmbeddingRequest::new(["one", "two-two"])) + .await + .expect("succeeds"); + + assert_eq!(response.embeddings.len(), 2); + assert_eq!(response.dimensions(), Some(2)); + assert_eq!(response.embeddings[0][1], 3.0, "len(\"one\")"); + assert_eq!(response.embeddings[1][1], 7.0, "len(\"two-two\")"); + assert_eq!( + router + .registry() + .summary("embed") + .expect("exists") + .usage + .requests, + 1 + ); + } + + #[tokio::test] + async fn embeddings_fail_over_too() { + let (router, _) = router_with(vec![ + ( + base_profile("primary").with_fallbacks(vec!["backup".into()]), + StubProvider::new(|_| Err(api_error(500))), + ), + ( + base_profile("backup"), + StubProvider::new(|_| Ok("seed".into())), + ), + ]); + + let response = router + .embed(Some("primary"), EmbeddingRequest::new(["x"])) + .await + .expect("fails over"); + assert_eq!(response.profile, "backup"); + } + + #[tokio::test] + async fn the_response_names_the_model_the_provider_reported() { + // The profile is named "alias" but the provider reports a versioned model id; the response + // must carry what actually ran, not what was asked for. + let (router, _) = router_with(vec![( + base_profile("alias"), + StubProvider::new(|_| Ok("x".into())).with_model("llama3.2:3b-instruct-q4"), + )]); + + let response = router.generate(None, ask()).await.expect("succeeds"); + assert_eq!(response.profile, "alias"); + assert_eq!(response.model, "llama3.2:3b-instruct-q4"); + } + + #[tokio::test] + async fn an_unknown_profile_is_reported_not_silently_defaulted() { + let (router, _) = router_with(vec![( + base_profile("only"), + StubProvider::new(|_| Ok("x".into())), + )]); + + let err = router + .generate(Some("ghost"), ask()) + .await + .expect_err("unknown"); + assert!( + matches!(err, LlmError::UnknownProfile { .. }), + "quietly answering from a different model than asked for is worse than failing" + ); + } + + #[tokio::test] + async fn an_empty_registry_reports_no_default() { + let router = Router::new(Arc::new(LlmRegistry::new())); + assert!(matches!( + router.generate(None, ask()).await.expect_err("empty"), + LlmError::NoDefaultProfile + )); + } +} diff --git a/orbit/llm/src/secret.rs b/orbit/llm/src/secret.rs index 7d376c00..e0a2e0d6 100644 --- a/orbit/llm/src/secret.rs +++ b/orbit/llm/src/secret.rs @@ -125,5 +125,10 @@ mod tests { api_key: SecretString::new("sk-nested"), }; assert!(!format!("{cfg:?}").contains("sk-nested")); + assert_eq!( + cfg.api_key.expose(), + "sk-nested", + "redaction must not damage the value itself" + ); } } diff --git a/orbit/llm/src/testing.rs b/orbit/llm/src/testing.rs new file mode 100644 index 00000000..1457bbee --- /dev/null +++ b/orbit/llm/src/testing.rs @@ -0,0 +1,158 @@ +//! In-process stub provider used to test the router without HTTP. +//! +//! The router's job — retry, timeout, breaker, fallback, accounting — is the part most likely to be +//! wrong and the part hardest to exercise against a real provider. A stub makes each of those +//! behaviors assertable deterministically, which is why the registry exposes +//! [`crate::LlmRegistry::register_with_provider`] as a seam. + +use crate::error::LlmResult; +use crate::provider::{ + EmbeddingProvider, LlmProvider, ProviderChatOutput, ProviderEmbeddingOutput, ProviderKind, +}; +use crate::providers::BuiltProvider; +use crate::types::{ChatRequest, EmbeddingRequest, FinishReason, TokenUsage}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +type Responder = Box LlmResult + Send + Sync>; + +/// A provider whose every response is scripted. +pub struct StubProvider { + responder: Responder, + calls: AtomicUsize, + delay: Option, + model: String, + usage: TokenUsage, +} + +impl StubProvider { + /// Build a stub whose responder receives the 0-based call index. + pub fn new(responder: impl Fn(usize) -> LlmResult + Send + Sync + 'static) -> Self { + Self { + responder: Box::new(responder), + calls: AtomicUsize::new(0), + delay: None, + model: "stub-model".to_owned(), + usage: TokenUsage { + prompt_tokens: Some(10), + completion_tokens: Some(5), + }, + } + } + + /// Make every call take `delay`, to exercise the timeout path. + #[must_use] + pub fn with_delay(mut self, delay: Duration) -> Self { + self.delay = Some(delay); + self + } + + /// Report a specific model name. + #[must_use] + pub fn with_model(mut self, model: impl Into) -> Self { + self.model = model.into(); + self + } + + /// Report specific token counts, or none at all. + #[must_use] + pub fn with_usage(mut self, usage: TokenUsage) -> Self { + self.usage = usage; + self + } + + /// How many times this stub has been called. + #[must_use] + pub fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + + async fn next_response(&self) -> LlmResult { + let index = self.calls.fetch_add(1, Ordering::SeqCst); + if let Some(delay) = self.delay { + tokio::time::sleep(delay).await; + } + (self.responder)(index) + } +} + +#[async_trait::async_trait] +impl LlmProvider for StubProvider { + fn kind(&self) -> ProviderKind { + ProviderKind::Compatible + } + + fn model(&self) -> &str { + &self.model + } + + async fn generate(&self, _request: &ChatRequest) -> LlmResult { + let text = self.next_response().await?; + Ok(ProviderChatOutput { + text, + model: self.model.clone(), + usage: self.usage, + finish_reason: Some(FinishReason::Stop), + }) + } +} + +#[async_trait::async_trait] +impl EmbeddingProvider for StubProvider { + fn kind(&self) -> ProviderKind { + ProviderKind::Compatible + } + + fn embedding_model(&self) -> &str { + &self.model + } + + async fn embed(&self, request: &EmbeddingRequest) -> LlmResult { + // Deterministic vectors derived from input length, so alignment is checkable. + let text = self.next_response().await?; + let base = text.len() as f32; + Ok(ProviderEmbeddingOutput { + embeddings: request + .inputs + .iter() + .map(|input| vec![base, input.len() as f32]) + .collect(), + model: self.model.clone(), + usage: self.usage, + }) + } +} + +/// Wrap a stub as a [`BuiltProvider`], discarding the handle. +#[must_use] +pub fn stub_provider( + responder: impl Fn(usize) -> LlmResult + Send + Sync + 'static, +) -> BuiltProvider { + built_from(Arc::new(StubProvider::new(responder))) +} + +/// Wrap a stub as a [`BuiltProvider`], keeping a handle for call-count assertions. +#[must_use] +pub fn stub_with_handle(stub: StubProvider) -> (BuiltProvider, Arc) { + let stub = Arc::new(stub); + (built_from(Arc::clone(&stub)), stub) +} + +fn built_from(stub: Arc) -> BuiltProvider { + BuiltProvider { + llm: Arc::clone(&stub) as Arc, + embedding: stub as Arc, + } +} + +/// An [`crate::LlmError::Api`] with the given status, for scripting failures. +#[must_use] +pub fn api_error(status: u16) -> crate::LlmError { + crate::LlmError::Api { + provider: "stub".to_owned(), + status, + body: format!("scripted {status}"), + retry_after: None, + } +} diff --git a/orbit/llm/src/types.rs b/orbit/llm/src/types.rs index e32684b4..88c9ddaf 100644 --- a/orbit/llm/src/types.rs +++ b/orbit/llm/src/types.rs @@ -193,6 +193,27 @@ impl FinishReason { other => FinishReason::Other(other.to_owned()), } } + + /// The normalized wire value for this reason. + /// + /// Lives here rather than at each call site because the enum is `#[non_exhaustive]`: an + /// external crate matching on it needs a catch-all arm, which would silently absorb a new + /// variant instead of failing to compile. Inside the crate the match stays exhaustive. + #[must_use] + pub fn as_wire(&self) -> &str { + match self { + FinishReason::Stop => "stop", + FinishReason::Length => "length", + FinishReason::ContentFilter => "content_filter", + FinishReason::Other(other) => other, + } + } +} + +impl std::fmt::Display for FinishReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_wire()) + } } /// Token counts for one request. diff --git a/orbit/llm/src/usage.rs b/orbit/llm/src/usage.rs new file mode 100644 index 00000000..028adc5f --- /dev/null +++ b/orbit/llm/src/usage.rs @@ -0,0 +1,278 @@ +//! Per-profile usage, cost, and failure accounting. +//! +//! Counters are atomics updated on every request, so they are sampled here rather than locked. The +//! numbers back `LLM.STATS` and the Prometheus gauges. +//! +//! Cost is accumulated in micro-dollars as an integer. Summing `f64` across millions of requests +//! accumulates representation error into the column an operator is going to reconcile against an +//! invoice; integers do not drift. + +use crate::types::{Cost, TokenUsage}; +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Micro-dollars per dollar. +const MICRO_USD: f64 = 1_000_000.0; + +/// Live counters for one profile. +#[derive(Debug, Default)] +pub struct ProfileCounters { + requests: AtomicU64, + failures: AtomicU64, + /// Times this profile was reached *as a fallback* for another profile. + fallback_uses: AtomicU64, + /// Times a request starting at this profile had to fail over. + fallbacks_fired: AtomicU64, + prompt_tokens: AtomicU64, + completion_tokens: AtomicU64, + /// Requests for which the provider reported no token counts at all. + unreported_usage: AtomicU64, + cost_micro_usd: AtomicU64, + latency_ms_total: AtomicU64, +} + +impl ProfileCounters { + /// Record a completed request. + pub fn record_success(&self, usage: &TokenUsage, cost: Option, latency_ms: u64) { + self.requests.fetch_add(1, Ordering::Relaxed); + self.latency_ms_total + .fetch_add(latency_ms, Ordering::Relaxed); + + match (usage.prompt_tokens, usage.completion_tokens) { + (None, None) => { + // Counted separately rather than added as zero: "this provider does not report + // tokens" and "this request used no tokens" are different facts, and collapsing + // them makes a token total look complete when it is not. + self.unreported_usage.fetch_add(1, Ordering::Relaxed); + } + (prompt, completion) => { + if let Some(p) = prompt { + self.prompt_tokens + .fetch_add(u64::from(p), Ordering::Relaxed); + } + if let Some(c) = completion { + self.completion_tokens + .fetch_add(u64::from(c), Ordering::Relaxed); + } + } + } + + if let Some(cost) = cost { + let micros = (cost.total_usd() * MICRO_USD).round().max(0.0) as u64; + self.cost_micro_usd.fetch_add(micros, Ordering::Relaxed); + } + } + + /// Record a failed request. + pub fn record_failure(&self) { + self.requests.fetch_add(1, Ordering::Relaxed); + self.failures.fetch_add(1, Ordering::Relaxed); + } + + /// Record that this profile served as a fallback for another. + pub fn record_fallback_use(&self) { + self.fallback_uses.fetch_add(1, Ordering::Relaxed); + } + + /// Record that a request starting at this profile had to fail over. + pub fn record_fallback_fired(&self) { + self.fallbacks_fired.fetch_add(1, Ordering::Relaxed); + } + + /// Take a consistent-enough snapshot for reporting. + /// + /// Fields are read independently, so a snapshot taken during heavy traffic may mix values from + /// adjacent instants. That is acceptable for an operational counter and is cheaper than the + /// lock that would avoid it. + #[must_use] + pub fn snapshot(&self) -> UsageSnapshot { + let requests = self.requests.load(Ordering::Relaxed); + let latency_total = self.latency_ms_total.load(Ordering::Relaxed); + let failures = self.failures.load(Ordering::Relaxed); + let successes = requests.saturating_sub(failures); + + UsageSnapshot { + requests, + failures, + fallback_uses: self.fallback_uses.load(Ordering::Relaxed), + fallbacks_fired: self.fallbacks_fired.load(Ordering::Relaxed), + prompt_tokens: self.prompt_tokens.load(Ordering::Relaxed), + completion_tokens: self.completion_tokens.load(Ordering::Relaxed), + unreported_usage: self.unreported_usage.load(Ordering::Relaxed), + cost_usd: self.cost_micro_usd.load(Ordering::Relaxed) as f64 / MICRO_USD, + // Averaged over successes only: a failure contributes no latency sample, so dividing + // by total requests would report a mean that is systematically too low whenever the + // provider is failing. + mean_latency_ms: (successes > 0).then(|| latency_total as f64 / successes as f64), + } + } +} + +/// A point-in-time reading of one profile's counters. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UsageSnapshot { + /// Requests attempted. + pub requests: u64, + /// Requests that ended in an error. + pub failures: u64, + /// Times this profile served as another profile's fallback. + pub fallback_uses: u64, + /// Times a request starting here failed over to a fallback. + pub fallbacks_fired: u64, + /// Input tokens, summed over requests that reported them. + pub prompt_tokens: u64, + /// Output tokens, summed over requests that reported them. + pub completion_tokens: u64, + /// Requests whose provider reported no token counts. + /// + /// Non-zero means the token totals above are a lower bound, not a total. + pub unreported_usage: u64, + /// Spend, summed over requests whose profile carried prices. + pub cost_usd: f64, + /// Mean latency over successful requests, absent when there have been none. + pub mean_latency_ms: Option, +} + +impl UsageSnapshot { + /// Whether the token totals cover every request. + /// + /// When this is false, `prompt_tokens`/`completion_tokens` under-report and should be presented + /// as "at least", not as a total. + #[must_use] + pub fn tokens_are_complete(&self) -> bool { + self.unreported_usage == 0 + } + + /// Observed failure rate, absent when no request has been made. + #[must_use] + pub fn failure_rate(&self) -> Option { + (self.requests > 0).then(|| self.failures as f64 / self.requests as f64) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn successes_accumulate_tokens_cost_and_latency() { + let counters = ProfileCounters::default(); + let usage = TokenUsage { + prompt_tokens: Some(100), + completion_tokens: Some(50), + }; + let cost = Cost { + prompt_usd: 0.001, + completion_usd: 0.002, + }; + + counters.record_success(&usage, Some(cost), 200); + counters.record_success(&usage, Some(cost), 400); + + let snap = counters.snapshot(); + assert_eq!(snap.requests, 2); + assert_eq!(snap.failures, 0); + assert_eq!(snap.prompt_tokens, 200); + assert_eq!(snap.completion_tokens, 100); + assert!((snap.cost_usd - 0.006).abs() < 1e-9); + assert_eq!(snap.mean_latency_ms, Some(300.0)); + assert!(snap.tokens_are_complete()); + } + + #[test] + fn unreported_usage_is_counted_not_zeroed() { + let counters = ProfileCounters::default(); + counters.record_success(&TokenUsage::default(), None, 10); + counters.record_success( + &TokenUsage { + prompt_tokens: Some(7), + completion_tokens: Some(3), + }, + None, + 10, + ); + + let snap = counters.snapshot(); + assert_eq!(snap.prompt_tokens, 7); + assert_eq!(snap.unreported_usage, 1); + assert!( + !snap.tokens_are_complete(), + "one request's tokens are unknown, so 7 is a lower bound" + ); + } + + #[test] + fn partial_usage_records_the_half_that_was_reported() { + let counters = ProfileCounters::default(); + counters.record_success( + &TokenUsage { + prompt_tokens: Some(11), + completion_tokens: None, + }, + None, + 5, + ); + let snap = counters.snapshot(); + assert_eq!(snap.prompt_tokens, 11); + assert_eq!(snap.completion_tokens, 0); + assert_eq!( + snap.unreported_usage, 0, + "a half-reported request is not an unreported one" + ); + } + + #[test] + fn mean_latency_excludes_failures() { + let counters = ProfileCounters::default(); + counters.record_success(&TokenUsage::default(), None, 100); + counters.record_failure(); + counters.record_failure(); + + let snap = counters.snapshot(); + assert_eq!(snap.requests, 3); + assert_eq!(snap.failures, 2); + assert_eq!( + snap.mean_latency_ms, + Some(100.0), + "failures contribute no latency sample" + ); + assert!((snap.failure_rate().expect("requests made") - 2.0 / 3.0).abs() < 1e-9); + } + + #[test] + fn an_untouched_profile_reports_no_mean_or_rate() { + let snap = ProfileCounters::default().snapshot(); + assert_eq!(snap.requests, 0); + assert_eq!(snap.mean_latency_ms, None); + assert_eq!(snap.failure_rate(), None); + } + + #[test] + fn fallback_participation_is_recorded_on_both_sides() { + let primary = ProfileCounters::default(); + let backup = ProfileCounters::default(); + + primary.record_fallback_fired(); + backup.record_fallback_use(); + + assert_eq!(primary.snapshot().fallbacks_fired, 1); + assert_eq!(backup.snapshot().fallback_uses, 1); + } + + #[test] + fn cost_does_not_drift_over_many_small_charges() { + let counters = ProfileCounters::default(); + let cost = Cost { + prompt_usd: 0.000_001, + completion_usd: 0.0, + }; + for _ in 0..1_000_000 { + counters.record_success(&TokenUsage::default(), Some(cost), 1); + } + let total = counters.snapshot().cost_usd; + assert!( + (total - 1.0).abs() < 1e-9, + "integer micro-dollars must not accumulate float error; got {total}" + ); + } +} From 948a355b848fdc7b90412db33a538beb230d0dfd Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Wed, 5 Aug 2026 21:48:28 -0700 Subject: [PATCH 26/36] feat(server): add LLM.* commands, route GraphRAG through the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires orbit-llm into the server behind one shared runtime, so a model registered or switched through any surface is visible to all of them. - server/src/llm/ bootstraps the registry from the [llm] config section, layered under LLM_* env vars, plus well-known credentials (OPENAI_API_KEY, ANTHROPIC_API_KEY, OLLAMA_MODEL) so an existing deployment keeps working with no config file - resp/commands/llm.rs LLM.PROVIDERS, MODELS, INFO, REGISTER, UNREGISTER, USE, GENERATE, EMBED, STATS - graphrag/llm_client.rs now a thin adapter over the router Removes from GraphRAG: - the double `match` over LLMProvider that re-derived temperature and max_tokens because the client factory discarded them - std::env::var("OPENAI_API_KEY") read inline in a RESP handler with a hardcoded "gpt-4" that no deployment could change Also fixes GRAPHRAG.QUERY, which built a fresh actor and discarded the configured one, leaving the query with no model. Verified against a live Ollama daemon over the Redis wire protocol: LLM.REGISTER -> LLM.USE switched the answering model from llama3.2 to granite4.1:3b with no restart; a primary pointed at a dead port failed over and reported fallbacks_used; the breaker opened after six failures and rejected without dialling; GRAPHRAG.QUERY used the switched profile. The Anthropic path was reconciled against the real API, which returned `authentication_error: invalid x-api-key` — confirming URL and both headers against the live service rather than our expectation. The Python SDK gains the nine matching llm_* methods. Note: LLM counters are readable via LLM.STATS but are not yet exported to Prometheus. Co-Authored-By: Claude Opus 5 (1M context) --- config/orbit-server.toml | 111 +++ orbit-python-client/orbit_client/client.py | 225 +++++ orbit/server/Cargo.toml | 1 + orbit/server/src/config.rs | 11 + orbit/server/src/lib.rs | 1 + orbit/server/src/llm/mod.rs | 416 +++++++++ .../protocols/graphrag/entity_extraction.rs | 43 +- .../src/protocols/graphrag/graph_rag_actor.rs | 50 +- .../src/protocols/graphrag/llm_client.rs | 475 ++++------ .../src/protocols/resp/commands/graphrag.rs | 34 +- .../server/src/protocols/resp/commands/llm.rs | 872 ++++++++++++++++++ .../server/src/protocols/resp/commands/mod.rs | 12 +- 12 files changed, 1866 insertions(+), 385 deletions(-) create mode 100644 orbit/server/src/llm/mod.rs create mode 100644 orbit/server/src/protocols/resp/commands/llm.rs diff --git a/config/orbit-server.toml b/config/orbit-server.toml index 56cb223c..2914629e 100644 --- a/config/orbit-server.toml +++ b/config/orbit-server.toml @@ -632,6 +632,111 @@ commit_interval_secs = 5 # Enable merge policy optimization enable_merge_optimization = true +# ================================ +# LLM PROVIDERS AND MODEL PROFILES +# ================================ +# +# Named, switchable model profiles. Every AI surface (GraphRAG, the RESP LLM.* commands) resolves +# models through this registry, so switching one switches all of them. +# +# Runtime switching needs no restart and no edit to this file: +# redis-cli LLM.MODELS +# redis-cli LLM.REGISTER fast ollama llama3.2 TEMPERATURE 0.2 +# redis-cli LLM.USE fast +# redis-cli LLM.STATS +# +# CREDENTIALS DO NOT BELONG IN THIS FILE (12-factor III). Leave api_key unset and supply it from +# the environment; anything set here is redacted when the config is dumped, but it is still a +# secret checked into a config directory. +# +# Environment variables, in increasing precedence: +# OPENAI_API_KEY / ANTHROPIC_API_KEY credential for any profile of that provider lacking one +# OLLAMA_HOST base URL for every Ollama profile +# ORBIT_LLM_DEFAULT_PROFILE overrides default_profile below +# ORBIT_LLM__API_KEY credential for one profile +# ORBIT_LLM__BASE_URL endpoint for one profile +# ORBIT_LLM__MODEL model for one profile +# is the profile name uppercased with '-' and '.' replaced by '_'. +# +# If no [llm] section and no credentials are present, AI features report that no model is +# configured rather than failing inside a query. + +[llm] +enabled = false + +# Profile used when a request names none. LLM.USE changes this at runtime. +default_profile = "local" + +# --- A local Ollama daemon: no credential, no per-token cost --- +[llm.profiles.local] +provider = "ollama" +model = "llama3.2" +embedding_model = "nomic-embed-text" +timeout_ms = 60000 + +[llm.profiles.local.params] +temperature = 0.3 +max_tokens = 2048 + +# --- Anthropic. Supply ANTHROPIC_API_KEY from the environment. --- +# max_tokens is REQUIRED by the Messages API; there is no server-side default. +# [llm.profiles.claude] +# provider = "anthropic" +# model = "claude-sonnet-4-5" +# fallbacks = ["local"] # tried in order if this profile fails +# timeout_ms = 45000 +# +# [llm.profiles.claude.params] +# temperature = 0.2 +# max_tokens = 4096 +# +# Prices are per MILLION tokens and are optional. Orbit-RS ships no built-in price table: one +# baked into the binary goes stale silently and then reports confident wrong costs. Without +# this block, LLM.STATS reports tokens but no cost. +# [llm.profiles.claude.pricing] +# prompt_usd_per_million = 3.0 +# completion_usd_per_million = 15.0 + +# --- OpenAI. Supply OPENAI_API_KEY from the environment. --- +# [llm.profiles.gpt] +# provider = "openai" +# model = "gpt-4o-mini" +# embedding_model = "text-embedding-3-small" +# fallbacks = ["local"] +# +# [llm.profiles.gpt.params] +# temperature = 0.3 +# max_tokens = 2048 + +# --- Any OpenAI-compatible endpoint: vLLM, Groq, Together, OpenRouter, LM Studio, DeepSeek, +# Fireworks. One shape, many services; base_url is required because a compatible server +# could be anywhere. --- +# [llm.profiles.groq] +# provider = "groq" +# model = "llama-3.3-70b-versatile" +# base_url = "https://api.groq.com/openai/v1" + +# --- Azure OpenAI: the deployment name goes in `model`, and api_version is required. --- +# [llm.profiles.azure] +# provider = "azure" +# model = "my-gpt4o-deployment" +# base_url = "https://contoso.openai.azure.com" +# api_version = "2024-10-21" + +# Retry and circuit-breaker tuning are per profile and default to sensible values +# (2 retries with jittered exponential backoff; breaker opens after 5 consecutive +# provider-health failures and probes again after 30s). +# [llm.profiles.local.retry] +# max_retries = 2 +# initial_backoff_ms = 250 +# max_backoff_ms = 8000 +# backoff_multiplier_pct = 200 +# +# [llm.profiles.local.breaker] +# failure_threshold = 5 +# open_duration_ms = 30000 +# success_threshold = 2 + # ================================ # EXAMPLE USAGE INSTRUCTIONS # ================================ @@ -659,3 +764,9 @@ enable_merge_optimization = true # Connect to AQL/ArangoDB: # arangosh --server.endpoint tcp://localhost:8529 + +# Inspect and switch LLM models at runtime: +# redis-cli LLM.PROVIDERS +# redis-cli LLM.MODELS +# redis-cli LLM.USE claude +# redis-cli LLM.GENERATE "why is the sky blue?" diff --git a/orbit-python-client/orbit_client/client.py b/orbit-python-client/orbit_client/client.py index cc457ccd..534f1fce 100644 --- a/orbit-python-client/orbit_client/client.py +++ b/orbit-python-client/orbit_client/client.py @@ -552,6 +552,231 @@ def ts_deleterule(self, source_key: str, dest_key: str) -> bool: raise ValueError("ts_deleterule() is only available for Redis protocol") return self._protocol_client.execute("TS.DELETERULE", source_key, dest_key) + # ------------------------------------------------------------------ + # LLM model management and inference (Redis protocol, LLM.* commands) + # ------------------------------------------------------------------ + + def _require_redis(self, method: str) -> None: + """Raise unless the active protocol is Redis.""" + if self._protocol != Protocol.REDIS: + raise ValueError(f"{method}() is only available for Redis protocol") + + def llm_providers(self) -> List[Any]: + """ + List the LLM provider shapes this server build supports (Redis only). + + Example: + >>> client.llm_providers() + """ + self._require_redis("llm_providers") + return self._protocol_client.execute("LLM.PROVIDERS") + + def llm_models(self) -> List[Any]: + """ + List registered model profiles, marking the default (Redis only). + + Example: + >>> client.llm_models() + """ + self._require_redis("llm_models") + return self._protocol_client.execute("LLM.MODELS") + + def llm_info(self, profile: str) -> List[Any]: + """ + Get full detail for one model profile (Redis only). + + Credentials are never included in the response. + + Args: + profile: Profile name + + Example: + >>> client.llm_info("claude") + """ + self._require_redis("llm_info") + return self._protocol_client.execute("LLM.INFO", profile) + + def llm_register( + self, + profile: str, + provider: str, + model: str, + *, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + api_version: Optional[str] = None, + embedding_model: Optional[str] = None, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + timeout_ms: Optional[int] = None, + fallbacks: Optional[List[str]] = None, + price_prompt: Optional[float] = None, + price_completion: Optional[float] = None, + ) -> str: + """ + Register or replace a model profile at runtime (Redis only). + + Takes effect on the next request; no server restart is required. + + Args: + profile: Name to register the model under + provider: One of openai, anthropic, ollama, or an OpenAI-compatible + alias (azure, vllm, groq, together, openrouter, lmstudio, + deepseek, fireworks, local) + model: Model identifier, or the deployment name for Azure + api_key: Credential, where the provider needs one + base_url: Endpoint root; required for OpenAI-compatible providers + api_version: Required by Azure OpenAI + embedding_model: Model used for llm_embed() + temperature: Default sampling temperature + max_tokens: Default output cap; required for Anthropic + timeout_ms: Per-attempt deadline + fallbacks: Profiles to try, in order, if this one fails + price_prompt: USD per million input tokens; enables cost reporting + price_completion: USD per million output tokens + + Note: + Prices are optional and have no defaults. Without both, llm_stats() + reports tokens but no cost, rather than reporting a guessed figure. + + Example: + >>> client.llm_register( + ... "claude", "anthropic", "claude-sonnet-4-5", + ... max_tokens=4096, fallbacks=["local"], + ... ) + """ + self._require_redis("llm_register") + args = [profile, provider, model] + options = [ + ("APIKEY", api_key), + ("BASEURL", base_url), + ("APIVERSION", api_version), + ("EMBEDDINGMODEL", embedding_model), + ("TEMPERATURE", temperature), + ("MAXTOKENS", max_tokens), + ("TIMEOUTMS", timeout_ms), + ("FALLBACKS", ",".join(fallbacks) if fallbacks else None), + ("PRICEPROMPT", price_prompt), + ("PRICECOMPLETION", price_completion), + ] + for key, value in options: + if value is not None: + args.extend([key, str(value)]) + return self._protocol_client.execute("LLM.REGISTER", *args) + + def llm_unregister(self, profile: str) -> str: + """ + Remove a model profile (Redis only). + + Fails if another profile lists this one as a fallback. + + Args: + profile: Profile name + + Example: + >>> client.llm_unregister("claude") + """ + self._require_redis("llm_unregister") + return self._protocol_client.execute("LLM.UNREGISTER", profile) + + def llm_use(self, profile: str) -> str: + """ + Switch the default model profile (Redis only). + + Takes effect on the next request across every AI surface, including + GraphRAG. No restart required. + + Args: + profile: Profile name to make default + + Example: + >>> client.llm_use("claude") + """ + self._require_redis("llm_use") + return self._protocol_client.execute("LLM.USE", profile) + + def llm_generate( + self, + prompt: str, + *, + model: Optional[str] = None, + system: Optional[str] = None, + max_tokens: Optional[int] = None, + temperature: Optional[float] = None, + ) -> List[Any]: + """ + Generate text through a model profile (Redis only). + + Args: + prompt: The prompt text + model: Profile to use; defaults to the registry default + system: System message framing the exchange + max_tokens: Output cap, overriding the profile default + temperature: Sampling temperature, overriding the profile default + + Returns: + Flat key/value list including text, model, profile, latency_ms, and + — only where the provider reported them — tokens_used, cost_usd, + finish_reason, and fallbacks_used. + + Example: + >>> client.llm_generate("why is the sky blue?", max_tokens=100) + """ + self._require_redis("llm_generate") + args: List[str] = [prompt] + for key, value in ( + ("MODEL", model), + ("SYSTEM", system), + ("MAXTOKENS", max_tokens), + ("TEMPERATURE", temperature), + ): + if value is not None: + args.extend([key, str(value)]) + return self._protocol_client.execute("LLM.GENERATE", *args) + + def llm_embed(self, *inputs: str, model: Optional[str] = None) -> List[Any]: + """ + Produce embeddings for one or more inputs (Redis only). + + Inputs are batched into a single provider call and returned in input + order. + + Args: + inputs: One or more texts to embed + model: Profile to use; defaults to the registry default + + Example: + >>> client.llm_embed("hello world", "second input") + """ + self._require_redis("llm_embed") + if not inputs: + raise ValueError("llm_embed() requires at least one input") + args = list(inputs) + if model is not None: + args.extend(["MODEL", model]) + return self._protocol_client.execute("LLM.EMBED", *args) + + def llm_stats(self, profile: Optional[str] = None) -> List[Any]: + """ + Get request, failure, fallback, token, and cost counters (Redis only). + + Args: + profile: Restrict to one profile; omit for all + + Note: + ``tokens_complete`` reports whether the token totals cover every + request. When false, they are a lower bound — some provider did not + report counts — not a total. ``cost_usd`` appears only for profiles + that carry configured prices. + + Example: + >>> client.llm_stats() + """ + self._require_redis("llm_stats") + if profile is None: + return self._protocol_client.execute("LLM.STATS") + return self._protocol_client.execute("LLM.STATS", profile) + @property def protocol(self) -> Protocol: """Get current protocol.""" diff --git a/orbit/server/Cargo.toml b/orbit/server/Cargo.toml index 7e75c205..61eeef9a 100644 --- a/orbit/server/Cargo.toml +++ b/orbit/server/Cargo.toml @@ -16,6 +16,7 @@ orbit-shared = { path = "../shared" } orbit-proto = { path = "../proto" } orbit-client = { path = "../client" } orbit-ml = { path = "../ml" } +orbit-llm = { path = "../llm" } orbit-compute = { path = "../compute", optional = true } orbit-engine = { path = "../engine" } tokio.workspace = true diff --git a/orbit/server/src/config.rs b/orbit/server/src/config.rs index 54376031..d92111f5 100644 --- a/orbit/server/src/config.rs +++ b/orbit/server/src/config.rs @@ -49,6 +49,14 @@ pub struct OrbitServerConfig { /// Unified cross-protocol storage configuration pub unified_storage: Option, + + /// LLM provider and model-profile configuration. + /// + /// Consumed by [`crate::llm`], which layers `LLM_*` environment variables over whatever is + /// here (12-factor III). Absent means AI features report that no model is configured rather + /// than failing per request. + #[serde(default)] + pub llm: Option, } /// Server identification and basic settings @@ -2020,6 +2028,9 @@ impl Default for OrbitServerConfig { persistence: Some(PersistenceConfig::default()), storage: Some(StorageConfig::default()), unified_storage: Some(UnifiedStorageCfg::default()), + // No default model: a fabricated provider would either fail on first use or silently + // send data somewhere the operator never chose. + llm: None, } } } diff --git a/orbit/server/src/lib.rs b/orbit/server/src/lib.rs index 09797ee6..9f5322c5 100644 --- a/orbit/server/src/lib.rs +++ b/orbit/server/src/lib.rs @@ -25,6 +25,7 @@ pub mod features; pub mod fts; #[cfg(any(feature = "js-boa", feature = "js-quickjs"))] pub mod js; +pub mod llm; pub mod load_balancer; pub mod lua; pub mod mesh; diff --git a/orbit/server/src/llm/mod.rs b/orbit/server/src/llm/mod.rs new file mode 100644 index 00000000..b076e911 --- /dev/null +++ b/orbit/server/src/llm/mod.rs @@ -0,0 +1,416 @@ +//! Server-wide LLM runtime. +//! +//! Holds the [`LlmRegistry`] and [`Router`] that every AI surface — GraphRAG, the RESP `LLM.*` +//! commands, and future SQL/MCP entry points — shares, so a model registered or switched through +//! one surface is visible to all of them. +//! +//! # Why a process-global +//! +//! `GraphRAGActor` is a `Serialize`/`Deserialize` value type that is constructed per request today; +//! it cannot own a router. The pre-existing code worked around this by calling +//! `std::env::var("OPENAI_API_KEY")` inline in a RESP command handler with a hardcoded `"gpt-4"`. +//! A single lazily-initialized runtime replaces those scattered reads with one bootstrap that is +//! configurable, inspectable, and mutable at runtime. +//! +//! # Bootstrap order (12-factor III: config in the environment) +//! +//! 1. The `[llm]` section of the server config file, if one is found. +//! 2. Environment overrides layered on top ([`orbit_llm::LlmConfig::apply_env_overrides`]). +//! 3. Well-known credentials that name no profile of their own — `ANTHROPIC_API_KEY`, +//! `OPENAI_API_KEY`, `OLLAMA_MODEL` — registered as conventional profiles. This preserves the +//! behavior GraphRAG had before this module existed, so an existing deployment keeps working +//! with no config file at all. + +use orbit_llm::{LlmConfig, LlmRegistry, ModelProfile, ProviderConfig, Router, SecretString}; +use std::sync::{Arc, OnceLock}; +use tracing::{debug, info, warn}; + +/// Config-file locations searched when `ORBIT_LLM_CONFIG` is unset. +/// +/// Mirrors the search order documented in `main.rs` so the LLM section is found in the same place +/// as the rest of the server configuration. +const CONFIG_SEARCH_PATHS: &[&str] = &[ + "./config/orbit-server.toml", + "/app/config/orbit-server.toml", + "/etc/orbit/orbit-server.toml", +]; + +/// Default model registered from `OLLAMA_MODEL`'s sibling variable when only a host is set. +const DEFAULT_OLLAMA_MODEL: &str = "llama3.2"; + +/// The shared registry and router. +#[derive(Debug, Clone)] +pub struct LlmRuntime { + registry: Arc, + router: Router, +} + +impl LlmRuntime { + /// Build a runtime over an existing registry. + #[must_use] + pub fn new(registry: Arc) -> Self { + let router = Router::new(Arc::clone(®istry)); + Self { registry, router } + } + + /// The shared registry — the surface `LLM.REGISTER` / `LLM.USE` mutate. + #[must_use] + pub fn registry(&self) -> &Arc { + &self.registry + } + + /// The router — the surface `LLM.GENERATE` / GraphRAG call. + #[must_use] + pub fn router(&self) -> &Router { + &self.router + } + + /// Whether any model is configured. + /// + /// Callers should check this before offering an AI feature: a clear "no model configured" + /// beats a per-request `NoDefaultProfile` error deep inside a query. + #[must_use] + pub fn is_configured(&self) -> bool { + !self.registry.is_empty() + } +} + +static RUNTIME: OnceLock = OnceLock::new(); + +/// The process-wide runtime, bootstrapped on first use. +/// +/// Bootstrapping never fails: an unreadable config file or an invalid profile is logged and skipped +/// so the database still starts. A database that refuses to boot because an optional LLM +/// credential is malformed has turned a degraded feature into an outage. +#[must_use] +pub fn runtime() -> &'static LlmRuntime { + RUNTIME.get_or_init(|| LlmRuntime::new(Arc::new(bootstrap_registry()))) +} + +/// Install a runtime explicitly, before anything calls [`runtime`]. +/// +/// Returns `false` if one is already installed — the first caller wins, and silently replacing a +/// live registry would strand any profile registered against it. +pub fn install(runtime: LlmRuntime) -> bool { + RUNTIME.set(runtime).is_ok() +} + +/// Build a registry from config file, environment, and well-known credentials. +fn bootstrap_registry() -> LlmRegistry { + let mut config = load_config().unwrap_or_default(); + config.apply_env_overrides(); + + let registry = LlmRegistry::new(); + + register_config_profiles(®istry, &config); + + register_env_profiles(®istry, &config); + + // Applied after the env profiles so a configured default can point at one of them. + if let Some(default) = &config.default_profile { + if let Err(e) = registry.set_default(default) { + warn!(profile = %default, error = %e, "configured default LLM profile is unusable"); + } + } + + if registry.is_empty() { + info!( + "no LLM profile configured; AI features will report that no model is available \ + (set OPENAI_API_KEY, ANTHROPIC_API_KEY, or OLLAMA_MODEL, or add an [llm] section)" + ); + } else { + info!( + profiles = ?registry.profile_names(), + default = ?registry.default_profile(), + "LLM runtime ready" + ); + } + + registry +} + +/// Register the profiles a config file declared, honoring its `enabled` flag. +/// +/// `enabled = false` has to actually disable them. A flag that can be flipped without changing any +/// behavior is a decorative parameter, and this one reads as a kill switch. Environment credentials +/// are unaffected: exporting `OPENAI_API_KEY` is a deliberate act by whoever runs the process, not +/// a stale line in a checked-in file. +fn register_config_profiles(registry: &LlmRegistry, config: &LlmConfig) { + if !config.enabled { + if !config.profiles.is_empty() { + info!( + profiles = config.profiles.len(), + "[llm] section has enabled = false; its profiles are not registered" + ); + } + return; + } + + for profile in config.profiles.values() { + match registry.register(profile.clone()) { + Ok(()) => debug!(profile = %profile.name, "registered LLM profile from configuration"), + Err(e) => warn!( + profile = %profile.name, + error = %e, + "skipping LLM profile: it is not usable as configured" + ), + } + } +} + +/// Register conventional profiles for credentials found in the environment. +/// +/// Skips any name the config file already claimed: an explicit profile is a deliberate choice and +/// must not be overwritten by an ambient variable. +fn register_env_profiles(registry: &LlmRegistry, config: &LlmConfig) { + let candidates = [ + env_openai_profile(), + env_anthropic_profile(), + env_ollama_profile(), + ]; + + for profile in candidates.into_iter().flatten() { + if config.profiles.contains_key(&profile.name) { + debug!( + profile = %profile.name, + "environment credential ignored: the config file defines this profile" + ); + continue; + } + let name = profile.name.clone(); + match registry.register(profile) { + Ok(()) => info!(profile = %name, "registered LLM profile from the environment"), + Err(e) => warn!(profile = %name, error = %e, "environment LLM profile is unusable"), + } + } +} + +fn env_openai_profile() -> Option { + let api_key = non_empty_env("OPENAI_API_KEY")?; + Some(ModelProfile::new( + "openai", + ProviderConfig::OpenAi { + api_key: SecretString::new(api_key), + base_url: non_empty_env("OPENAI_BASE_URL") + .unwrap_or_else(|| "https://api.openai.com/v1".to_string()), + organization: non_empty_env("OPENAI_ORG_ID"), + project: non_empty_env("OPENAI_PROJECT_ID"), + }, + // Previously hardcoded to "gpt-4" at the call site, which no deployment could change. + non_empty_env("OPENAI_MODEL").unwrap_or_else(|| "gpt-4o-mini".to_string()), + )) +} + +fn env_anthropic_profile() -> Option { + let api_key = non_empty_env("ANTHROPIC_API_KEY")?; + Some( + ModelProfile::new( + "anthropic", + ProviderConfig::Anthropic { + api_key: SecretString::new(api_key), + base_url: non_empty_env("ANTHROPIC_BASE_URL") + .unwrap_or_else(|| "https://api.anthropic.com/v1".to_string()), + version: non_empty_env("ANTHROPIC_VERSION") + .unwrap_or_else(|| "2023-06-01".to_string()), + }, + non_empty_env("ANTHROPIC_MODEL").unwrap_or_else(|| "claude-sonnet-4-5".to_string()), + ) + .with_params(orbit_llm::GenerationParams { + // The Messages API requires a cap and offers no server-side default. + max_tokens: Some(orbit_llm::compat::ANTHROPIC_REQUIRED_MAX_TOKENS), + ..Default::default() + }), + ) +} + +fn env_ollama_profile() -> Option { + // Either variable is enough: a host with no model named still points at a working daemon. + let model = non_empty_env("OLLAMA_MODEL") + .or_else(|| non_empty_env("OLLAMA_HOST").map(|_| DEFAULT_OLLAMA_MODEL.to_string()))?; + + let mut profile = ModelProfile::new( + "ollama", + ProviderConfig::Ollama { + base_url: non_empty_env("OLLAMA_HOST") + .unwrap_or_else(|| "http://localhost:11434".to_string()), + }, + model, + ); + if let Some(embedding) = non_empty_env("OLLAMA_EMBEDDING_MODEL") { + profile.embedding_model = Some(embedding); + } + Some(profile) +} + +/// Read an environment variable, treating an empty value as absent. +/// +/// An exported-but-empty variable is a misconfiguration, not a credential; accepting it produces a +/// profile that fails on its first request instead of at startup. +fn non_empty_env(key: &str) -> Option { + std::env::var(key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +/// Load the `[llm]` section from the server config file, if one can be found and parsed. +fn load_config() -> Option { + let path = config_path()?; + let content = std::fs::read_to_string(&path) + .inspect_err(|e| debug!(path = %path, error = %e, "no readable server config")) + .ok()?; + + let document: toml::Value = toml::from_str(&content) + .inspect_err(|e| warn!(path = %path, error = %e, "server config is not valid TOML")) + .ok()?; + + let section = document.get("llm")?; + let config: LlmConfig = section + .clone() + .try_into() + .inspect_err(|e| warn!(path = %path, error = %e, "[llm] section is invalid; ignoring it")) + .ok()?; + + info!(path = %path, profiles = config.profiles.len(), "loaded [llm] configuration"); + Some(normalize_profile_names(config)) +} + +/// Backfill each profile's name from its map key. +/// +/// `LlmConfig::from_toml_str` does this for a whole document; the section is extracted by value +/// here, so the same normalization has to be applied. +fn normalize_profile_names(mut config: LlmConfig) -> LlmConfig { + for (key, profile) in config.profiles.iter_mut() { + profile.name.clone_from(key); + } + config +} + +fn config_path() -> Option { + if let Some(explicit) = non_empty_env("ORBIT_LLM_CONFIG") { + return Some(explicit); + } + CONFIG_SEARCH_PATHS + .iter() + .find(|path| std::path::Path::new(path).is_file()) + .map(|path| (*path).to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_runtime_over_an_empty_registry_reports_itself_unconfigured() { + let runtime = LlmRuntime::new(Arc::new(LlmRegistry::new())); + assert!(!runtime.is_configured()); + } + + #[test] + fn a_registered_profile_makes_the_runtime_configured() { + let registry = LlmRegistry::new(); + registry + .register(ModelProfile::new( + "local", + ProviderConfig::Ollama { + base_url: "http://localhost:11434".into(), + }, + "llama3.2", + )) + .expect("registers"); + + let runtime = LlmRuntime::new(Arc::new(registry)); + assert!(runtime.is_configured()); + assert_eq!( + runtime.registry().default_profile().as_deref(), + Some("local") + ); + } + + #[test] + fn the_router_and_registry_share_one_state() { + let runtime = LlmRuntime::new(Arc::new(LlmRegistry::new())); + runtime + .registry() + .register(ModelProfile::new( + "later", + ProviderConfig::Ollama { + base_url: "http://localhost:11434".into(), + }, + "llama3.2", + )) + .expect("registers"); + + assert!( + runtime.router().registry().contains("later"), + "a model registered through one surface must be visible to all of them" + ); + } + + fn config_with(enabled: bool) -> LlmConfig { + let mut config = LlmConfig::from_toml_str( + r#" +[profiles.fast] +provider = "ollama" +model = "llama3.2" +"#, + ) + .expect("parses"); + config.enabled = enabled; + config + } + + #[test] + fn the_enabled_flag_actually_gates_registration() { + let disabled = LlmRegistry::new(); + register_config_profiles(&disabled, &config_with(false)); + assert!( + disabled.is_empty(), + "a kill switch that registers the profiles anyway is a decorative parameter" + ); + + let enabled = LlmRegistry::new(); + register_config_profiles(&enabled, &config_with(true)); + assert_eq!(enabled.profile_names(), vec!["fast"]); + } + + #[test] + fn profile_names_are_backfilled_from_their_keys() { + let section: toml::Value = toml::from_str( + r#" +enabled = true +[profiles.fast] +provider = "ollama" +model = "llama3.2" +"#, + ) + .expect("parses"); + + let config: LlmConfig = section.try_into().expect("converts"); + let normalized = normalize_profile_names(config); + assert_eq!(normalized.profiles["fast"].name, "fast"); + } + + #[test] + fn an_exported_but_empty_variable_reads_as_absent() { + // Uses a name no other test touches, so the process-wide mutation cannot race. + let key = "ORBIT_LLM_TEST_EMPTY_VAR"; + // SAFETY: single-threaded within this test and the key is unique to it. + unsafe { + std::env::set_var(key, " "); + } + assert_eq!( + non_empty_env(key), + None, + "a blank value is not a credential" + ); + unsafe { + std::env::set_var(key, " value "); + } + assert_eq!(non_empty_env(key), Some("value".to_string())); + unsafe { + std::env::remove_var(key); + } + assert_eq!(non_empty_env(key), None); + } +} diff --git a/orbit/server/src/protocols/graphrag/entity_extraction.rs b/orbit/server/src/protocols/graphrag/entity_extraction.rs index b2cfbe5d..0d837b8f 100644 --- a/orbit/server/src/protocols/graphrag/entity_extraction.rs +++ b/orbit/server/src/protocols/graphrag/entity_extraction.rs @@ -622,36 +622,19 @@ impl EntityExtractionActor { entity_types: &[EntityType], request: &DocumentProcessingRequest, ) -> OrbitResult<(Vec, Vec)> { - use crate::protocols::graphrag::llm_client::{create_llm_client, LLMGenerationRequest}; - use orbit_shared::graphrag::LLMProvider; + use crate::protocols::graphrag::llm_client::{self, LLMGenerationRequest}; use std::str::FromStr; - // Get LLM provider from environment or configuration - let llm_provider = if provider_name == "openai" { - if let Ok(api_key) = std::env::var("OPENAI_API_KEY") { - LLMProvider::OpenAI { - api_key, - model: "gpt-4".to_string(), - temperature: Some(0.3), - max_tokens: Some(2048), - } - } else { - warn!("OpenAI API key not found, skipping LLM extraction"); - return Ok((Vec::new(), Vec::new())); - } - } else if provider_name == "ollama" { - let model = std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama2".to_string()); - LLMProvider::Ollama { - model, - temperature: Some(0.3), - } - } else { + // The extractor names a profile in the shared runtime rather than reconstructing a + // provider from environment variables with a hardcoded model, which is what this did + // before: `provider_name == "openai"` meant `gpt-4`, and nothing could change it. + if !llm_client::profile_is_available(provider_name) { warn!( - "Unknown LLM provider: {}, skipping LLM extraction", - provider_name + provider = provider_name, + "no LLM profile registered under this name; skipping LLM extraction" ); return Ok((Vec::new(), Vec::new())); - }; + } // Build prompt with entity types let entity_types_str = entity_types @@ -664,12 +647,11 @@ impl EntityExtractionActor { .replace("{text}", &request.text) .replace("{entity_types}", &entity_types_str); - let llm_client = create_llm_client(&llm_provider) - .map_err(|e| OrbitError::internal(format!("Failed to create LLM client: {}", e)))?; - let generation_request = LLMGenerationRequest { prompt, max_tokens: Some(2048), + // Extraction wants near-deterministic output regardless of what the profile is tuned + // for conversationally, so this override is deliberate rather than inherited. temperature: Some(0.3), system_message: Some( "You are an entity extraction system. Extract entities and relationships from the text. " @@ -679,10 +661,9 @@ impl EntityExtractionActor { ), }; - let response = llm_client - .generate(generation_request) + let response = llm_client::generate(Some(provider_name), generation_request) .await - .map_err(|e| OrbitError::internal(format!("LLM extraction failed: {}", e)))?; + .map_err(|e| OrbitError::internal(format!("LLM extraction failed: {e}")))?; // Parse JSON response let json: serde_json::Value = serde_json::from_str(&response.text) diff --git a/orbit/server/src/protocols/graphrag/graph_rag_actor.rs b/orbit/server/src/protocols/graphrag/graph_rag_actor.rs index 44b2c9bc..21f9624c 100644 --- a/orbit/server/src/protocols/graphrag/graph_rag_actor.rs +++ b/orbit/server/src/protocols/graphrag/graph_rag_actor.rs @@ -12,7 +12,7 @@ use orbit_client::OrbitClient; use orbit_shared::graphrag::{ ContextItem, ContextSourceType, LLMProvider, RAGResponse, SearchStrategy, }; -use orbit_shared::{Addressable, OrbitError, OrbitResult}; +use orbit_shared::{Addressable, OrbitResult}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; @@ -631,17 +631,16 @@ impl GraphRAGActor { query: &GraphRAGQuery, context_items: &[ContextItem], ) -> OrbitResult { - use crate::protocols::graphrag::llm_client::{create_llm_client, LLMGenerationRequest}; + use crate::protocols::graphrag::llm_client::{self, LLMGenerationRequest}; - let llm_provider_name = query + // Prefer the profile the query names, then the actor's default; `None` lets the shared + // runtime pick its own default, so a deployment that configures one model needs no + // per-actor wiring at all. + let requested_profile = query .llm_provider - .as_ref() - .or(self.default_llm_provider.as_ref()) - .ok_or_else(|| OrbitError::internal("No LLM provider configured"))?; - - let llm_provider = self.llm_providers.get(llm_provider_name).ok_or_else(|| { - OrbitError::internal(format!("LLM provider '{llm_provider_name}' not found")) - })?; + .as_deref() + .or(self.default_llm_provider.as_deref()); + let profile = llm_client::resolve_profile(requested_profile, &self.llm_providers)?; // Build context text from context items let context_text = if context_items.is_empty() { @@ -667,31 +666,18 @@ impl GraphRAGActor { context_text, query.query_text ); - // Create LLM client and generate response - let llm_client = create_llm_client(llm_provider)?; - + // Generation parameters are left unset here: they live on the model profile, and the + // router merges them in. The previous code re-derived them by matching the provider enum a + // second time because the client factory discarded the ones it was given. let generation_request = LLMGenerationRequest { prompt, - max_tokens: match llm_provider { - LLMProvider::OpenAI { max_tokens, .. } => *max_tokens, - LLMProvider::Anthropic { max_tokens, .. } => *max_tokens, - LLMProvider::Local { max_tokens, .. } => *max_tokens, - LLMProvider::Ollama { .. } => Some(2048), - }, - temperature: match llm_provider { - LLMProvider::OpenAI { temperature, .. } => *temperature, - LLMProvider::Anthropic { temperature, .. } => *temperature, - LLMProvider::Local { temperature, .. } => *temperature, - LLMProvider::Ollama { temperature, .. } => *temperature, - }, + max_tokens: None, + temperature: None, system_message, }; let start_time = std::time::Instant::now(); - let llm_response = llm_client - .generate(generation_request) - .await - .map_err(|e| OrbitError::internal(format!("LLM generation failed: {}", e)))?; + let llm_response = llm_client::generate(profile.as_deref(), generation_request).await?; let processing_time_ms = start_time.elapsed().as_millis() as u64; let citations = context_items @@ -714,6 +700,12 @@ impl GraphRAGActor { let mut metadata = HashMap::new(); metadata.insert("model".to_string(), serde_json::json!(llm_response.model)); + // The profile that actually served the request, which differs from the one asked for when + // a fallback fired. A failover nobody can see is an outage nobody can see. + metadata.insert( + "llm_profile".to_string(), + serde_json::json!(llm_response.profile), + ); if let Some(tokens) = llm_response.tokens_used { metadata.insert("tokens_used".to_string(), serde_json::json!(tokens)); } diff --git a/orbit/server/src/protocols/graphrag/llm_client.rs b/orbit/server/src/protocols/graphrag/llm_client.rs index 08ea098c..21502ee3 100644 --- a/orbit/server/src/protocols/graphrag/llm_client.rs +++ b/orbit/server/src/protocols/graphrag/llm_client.rs @@ -1,348 +1,227 @@ -//! LLM Client for GraphRAG +//! GraphRAG's adapter onto the shared LLM runtime. //! -//! This module provides LLM client implementations for various providers -//! including OpenAI, Anthropic, Ollama, and local LLM APIs. +//! This module used to carry three hand-rolled HTTP clients (OpenAI, Ollama, and a generic local +//! endpoint), an Anthropic branch that returned `Err("Anthropic client not yet implemented")`, and +//! a factory that accepted `temperature`/`max_tokens` and discarded them. All of that now lives in +//! [`orbit_llm`], which additionally provides the timeouts, retries, circuit breaking, fallback, +//! connection pooling, and cost accounting the hand-rolled clients had none of. +//! +//! What remains here is the translation between GraphRAG's request vocabulary and the router's. +use crate::llm::runtime; +use orbit_llm::{ChatRequest, GenerationParams, Message, ModelProfile, Router}; use orbit_shared::graphrag::LLMProvider; use orbit_shared::{OrbitError, OrbitResult}; use serde::{Deserialize, Serialize}; -/// LLM generation request -#[derive(Debug, Clone)] +/// A GraphRAG generation request. +#[derive(Debug, Clone, Default)] pub struct LLMGenerationRequest { - /// Prompt text + /// Prompt text. pub prompt: String, - /// Maximum tokens to generate + /// Upper bound on generated tokens; falls back to the profile's setting. pub max_tokens: Option, - /// Temperature for generation + /// Sampling temperature; falls back to the profile's setting. pub temperature: Option, - /// System message (optional) + /// System message, when the caller frames the exchange. pub system_message: Option, } -/// LLM generation response +/// A GraphRAG generation response. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LLMGenerationResponse { - /// Generated text + /// Generated text. pub text: String, - /// Tokens used + /// Tokens used, when the provider reported them. + /// + /// Stays `None` for providers that do not report counts. A zero here would assert the request + /// was free. pub tokens_used: Option, - /// Finish reason + /// Why generation stopped, when reported. pub finish_reason: Option, - /// Model used + /// Model that answered, as reported by the provider. pub model: String, + /// Profile that served the request. + /// + /// Differs from the requested profile when a fallback fired, so a caller can tell that its + /// answer came from the backup model. + pub profile: String, } -/// LLM client trait -#[async_trait::async_trait] -pub trait LLMClient: Send + Sync { - /// Generate text from a prompt - async fn generate(&self, request: LLMGenerationRequest) -> OrbitResult; - - /// Get the model name - fn model_name(&self) -> &str; -} - -/// OpenAI LLM client -pub struct OpenAIClient { - api_key: String, - model: String, - base_url: String, - default_temperature: f32, - default_max_tokens: u32, -} - -impl OpenAIClient { - pub fn new(api_key: String, model: String) -> Self { - Self { - api_key, - model, - base_url: "https://api.openai.com/v1".to_string(), - default_temperature: 0.7, - default_max_tokens: 2048, +impl From for ChatRequest { + fn from(request: LLMGenerationRequest) -> Self { + let messages = request + .system_message + .map(Message::system) + .into_iter() + .chain(std::iter::once(Message::user(request.prompt))) + .collect(); + + ChatRequest { + messages, + params: GenerationParams { + temperature: request.temperature, + max_tokens: request.max_tokens, + ..Default::default() + }, } } } -#[async_trait::async_trait] -impl LLMClient for OpenAIClient { - async fn generate(&self, request: LLMGenerationRequest) -> OrbitResult { - use reqwest::Client; - - let client = Client::new(); - let url = format!("{}/chat/completions", self.base_url); - - let mut messages = Vec::new(); - if let Some(system_msg) = request.system_message { - messages.push(serde_json::json!({ - "role": "system", - "content": system_msg - })); - } - messages.push(serde_json::json!({ - "role": "user", - "content": request.prompt - })); - - let body = serde_json::json!({ - "model": self.model, - "messages": messages, - "temperature": request.temperature.unwrap_or(self.default_temperature), - "max_tokens": request.max_tokens.unwrap_or(self.default_max_tokens), - }); - - let response = client - .post(&url) - .header("Authorization", format!("Bearer {}", self.api_key)) - .header("Content-Type", "application/json") - .json(&body) - .send() - .await - .map_err(|e| OrbitError::internal(format!("OpenAI API request failed: {}", e)))?; - - if !response.status().is_success() { - let status = response.status(); - let error_text = response.text().await.unwrap_or_default(); - return Err(OrbitError::internal(format!( - "OpenAI API error ({}): {}", - status, error_text - ))); - } - - let json: serde_json::Value = response - .json() - .await - .map_err(|e| OrbitError::internal(format!("Failed to parse OpenAI response: {}", e)))?; - - let text = json["choices"][0]["message"]["content"] - .as_str() - .ok_or_else(|| OrbitError::internal("Invalid OpenAI response format"))? - .to_string(); - - let tokens_used = json["usage"]["total_tokens"].as_u64().map(|v| v as u32); - - Ok(LLMGenerationResponse { - text, - tokens_used, - finish_reason: json["choices"][0]["finish_reason"] - .as_str() - .map(|s| s.to_string()), - model: self.model.clone(), - }) - } - - fn model_name(&self) -> &str { - &self.model - } +/// Generate through a named profile in the shared runtime. +/// +/// `profile` names a registered model; `None` uses the runtime default. The router applies the +/// profile's timeout, retry policy, circuit breaker, and fallback chain. +/// +/// # Errors +/// +/// Returns [`OrbitError`] when no model is configured, the named profile is unknown, or every +/// profile in the fallback chain failed. +pub async fn generate( + profile: Option<&str>, + request: LLMGenerationRequest, +) -> OrbitResult { + generate_with_router(runtime().router(), profile, request).await } -/// Ollama LLM client -pub struct OllamaClient { - model: String, - base_url: String, - default_temperature: f32, +/// Generate through an explicitly supplied router. +/// +/// The seam that lets GraphRAG be exercised against a test router instead of the process-wide one. +/// +/// # Errors +/// +/// As [`generate`]. +pub async fn generate_with_router( + router: &Router, + profile: Option<&str>, + request: LLMGenerationRequest, +) -> OrbitResult { + let response = router + .generate(profile, request.into()) + .await + .map_err(OrbitError::from)?; + + Ok(LLMGenerationResponse { + text: response.text, + tokens_used: response.usage.total(), + finish_reason: response + .finish_reason + .map(|reason| reason.as_wire().to_string()), + model: response.model, + profile: response.profile, + }) } -impl OllamaClient { - pub fn new(model: String) -> Self { - Self { - model, - base_url: "http://localhost:11434".to_string(), - default_temperature: 0.7, - } - } - - pub fn with_endpoint(model: String, endpoint: String) -> Self { - Self { - model, - base_url: endpoint, - default_temperature: 0.7, - } - } +/// Register a legacy provider description into the shared runtime, returning its profile name. +/// +/// GraphRAG actors carry `LLMProvider` values in their serialized state. Registering one makes it +/// routable without changing that representation. +/// +/// # Errors +/// +/// Returns [`OrbitError`] if the provider is not usable — most often a missing credential. +pub fn register_legacy_provider(name: &str, provider: &LLMProvider) -> OrbitResult { + let profile: ModelProfile = + orbit_llm::profile_from_legacy(name, provider).map_err(OrbitError::from)?; + let profile_name = profile.name.clone(); + runtime() + .registry() + .register(profile) + .map_err(OrbitError::from)?; + Ok(profile_name) } -#[async_trait::async_trait] -impl LLMClient for OllamaClient { - async fn generate(&self, request: LLMGenerationRequest) -> OrbitResult { - use reqwest::Client; - - let client = Client::new(); - let url = format!("{}/api/generate", self.base_url); - - let mut prompt = request.prompt; - if let Some(system_msg) = request.system_message { - prompt = format!("{}\n\n{}", system_msg, prompt); - } - - let body = serde_json::json!({ - "model": self.model, - "prompt": prompt, - "stream": false, - "options": { - "temperature": request.temperature.unwrap_or(self.default_temperature), - "num_predict": request.max_tokens.unwrap_or(2048), - } - }); - - let response = client - .post(&url) - .json(&body) - .send() - .await - .map_err(|e| OrbitError::internal(format!("Ollama API request failed: {}", e)))?; - - if !response.status().is_success() { - let status = response.status(); - let error_text = response.text().await.unwrap_or_default(); - return Err(OrbitError::internal(format!( - "Ollama API error ({}): {}", - status, error_text - ))); - } - - let json: serde_json::Value = response - .json() - .await - .map_err(|e| OrbitError::internal(format!("Failed to parse Ollama response: {}", e)))?; - - let text = json["response"] - .as_str() - .ok_or_else(|| OrbitError::internal("Invalid Ollama response format"))? - .to_string(); +/// Whether a profile is registered in the shared runtime. +/// +/// Lets a caller degrade cleanly — skipping an optional LLM step — instead of issuing a request +/// that is certain to fail. +#[must_use] +pub fn profile_is_available(name: &str) -> bool { + runtime().registry().contains(name) +} - Ok(LLMGenerationResponse { - text, - tokens_used: None, - finish_reason: Some("stop".to_string()), - model: self.model.clone(), - }) +/// Resolve which profile a GraphRAG call should use. +/// +/// Prefers, in order: the profile the query names, a legacy provider the actor carries under that +/// name (registered on demand), and finally the runtime default. +/// +/// # Errors +/// +/// Returns [`OrbitError`] when a named provider cannot be registered. +pub fn resolve_profile( + requested: Option<&str>, + legacy_providers: &std::collections::HashMap, +) -> OrbitResult> { + let Some(name) = requested else { + return Ok(None); + }; + + if runtime().registry().contains(name) { + return Ok(Some(name.to_string())); } - fn model_name(&self) -> &str { - &self.model + match legacy_providers.get(name) { + Some(provider) => register_legacy_provider(name, provider).map(Some), + // Not registered and not carried by the actor: hand the name to the router so the error + // names the profile the caller actually asked for. + None => Ok(Some(name.to_string())), } } -/// Local LLM client (generic HTTP API) -pub struct LocalLLMClient { - endpoint: String, - model: String, - default_temperature: f32, - default_max_tokens: u32, -} - -impl LocalLLMClient { - pub fn new(endpoint: String, model: String) -> Self { - Self { - endpoint, - model, - default_temperature: 0.7, - default_max_tokens: 2048, +#[cfg(test)] +mod tests { + use super::*; + use orbit_llm::{ChatRequest as _ChatRequest, Role}; + + fn request() -> LLMGenerationRequest { + LLMGenerationRequest { + prompt: "what is orbit?".into(), + max_tokens: Some(256), + temperature: Some(0.3), + system_message: Some("answer from the graph".into()), } } -} - -#[async_trait::async_trait] -impl LLMClient for LocalLLMClient { - async fn generate(&self, request: LLMGenerationRequest) -> OrbitResult { - use reqwest::Client; - - let client = Client::new(); - let mut prompt = request.prompt; - if let Some(system_msg) = request.system_message { - prompt = format!("{}\n\n{}", system_msg, prompt); - } + #[test] + fn a_graphrag_request_becomes_a_two_turn_chat() { + let chat: _ChatRequest = request().into(); - // Try OpenAI-compatible format first - let body = serde_json::json!({ - "model": self.model, - "messages": [ - { - "role": "user", - "content": prompt - } - ], - "temperature": request.temperature.unwrap_or(self.default_temperature), - "max_tokens": request.max_tokens.unwrap_or(self.default_max_tokens), - }); + assert_eq!(chat.messages.len(), 2); + assert_eq!(chat.messages[0].role, Role::System); + assert_eq!(chat.messages[0].content, "answer from the graph"); + assert_eq!(chat.messages[1].role, Role::User); + assert_eq!(chat.messages[1].content, "what is orbit?"); + } - let response = client - .post(&self.endpoint) - .json(&body) - .send() - .await - .map_err(|e| OrbitError::internal(format!("Local LLM API request failed: {}", e)))?; + #[test] + fn generation_parameters_reach_the_request_instead_of_being_dropped() { + let chat: _ChatRequest = request().into(); + assert_eq!(chat.params.temperature, Some(0.3)); + assert_eq!(chat.params.max_tokens, Some(256)); + } - if !response.status().is_success() { - let status = response.status(); - let error_text = response.text().await.unwrap_or_default(); - return Err(OrbitError::internal(format!( - "Local LLM API error ({}): {}", - status, error_text - ))); + #[test] + fn an_absent_system_message_produces_a_single_turn() { + let chat: _ChatRequest = LLMGenerationRequest { + prompt: "hi".into(), + system_message: None, + ..Default::default() } + .into(); - let json: serde_json::Value = response.json().await.map_err(|e| { - OrbitError::internal(format!("Failed to parse local LLM response: {}", e)) - })?; - - // Try OpenAI-compatible format - let text = if let Some(text) = json["choices"][0]["message"]["content"].as_str() { - text.to_string() - } else if let Some(text) = json["response"].as_str() { - text.to_string() - } else if let Some(text) = json["text"].as_str() { - text.to_string() - } else { - return Err(OrbitError::internal("Invalid local LLM response format")); - }; - - Ok(LLMGenerationResponse { - text, - tokens_used: json["usage"]["total_tokens"].as_u64().map(|v| v as u32), - finish_reason: json["choices"][0]["finish_reason"] - .as_str() - .map(|s| s.to_string()), - model: self.model.clone(), - }) - } - - fn model_name(&self) -> &str { - &self.model + assert_eq!(chat.messages.len(), 1); + assert_eq!(chat.messages[0].role, Role::User); } -} -/// Create LLM client from provider configuration -pub fn create_llm_client(provider: &LLMProvider) -> OrbitResult> { - match provider { - LLMProvider::OpenAI { - api_key, - model, - temperature: _, - max_tokens: _, - } => Ok(Box::new(OpenAIClient::new(api_key.clone(), model.clone()))), - LLMProvider::Ollama { - model, - temperature: _, - } => Ok(Box::new(OllamaClient::new(model.clone()))), - LLMProvider::Local { - endpoint, - model, - temperature: _, - max_tokens: _, - } => Ok(Box::new(LocalLLMClient::new( - endpoint.clone(), - model.clone(), - ))), - LLMProvider::Anthropic { - api_key: _, - model: _, - temperature: _, - max_tokens: _, - } => { - // TODO: Implement Anthropic client - Err(OrbitError::internal("Anthropic client not yet implemented")) + #[test] + fn unset_parameters_stay_unset_so_the_profile_can_supply_them() { + let chat: _ChatRequest = LLMGenerationRequest { + prompt: "hi".into(), + ..Default::default() } + .into(); + + assert_eq!(chat.params.temperature, None); + assert_eq!(chat.params.max_tokens, None); } } diff --git a/orbit/server/src/protocols/resp/commands/graphrag.rs b/orbit/server/src/protocols/resp/commands/graphrag.rs index 62ad7ec1..c0570b61 100644 --- a/orbit/server/src/protocols/resp/commands/graphrag.rs +++ b/orbit/server/src/protocols/resp/commands/graphrag.rs @@ -16,7 +16,6 @@ use crate::protocols::graphrag::graph_rag_actor::{ use crate::protocols::resp::simple_local::SimpleLocalRegistry; use crate::protocols::resp::types::RespValue; use orbit_client::OrbitClient; -use orbit_shared::graphrag::LLMProvider; use std::sync::Arc; /// GraphRAG command handler @@ -48,28 +47,10 @@ impl GraphRAGCommands { let mut actor = GraphRAGActor::new(kg_name.to_string()); actor.initialize_components(); - // Try to add default LLM provider if available - if let Ok(ollama_model) = std::env::var("OLLAMA_MODEL") { - actor.add_llm_provider( - "ollama".to_string(), - LLMProvider::Ollama { - model: ollama_model, - temperature: Some(0.7), - }, - ); - } - - if let Ok(openai_key) = std::env::var("OPENAI_API_KEY") { - actor.add_llm_provider( - "openai".to_string(), - LLMProvider::OpenAI { - api_key: openai_key, - model: "gpt-4".to_string(), - temperature: Some(0.7), - max_tokens: Some(2048), - }, - ); - } + // LLM providers are no longer reconstructed here from environment variables with a + // hardcoded model name. The shared runtime (`crate::llm`) owns model configuration, so the + // actor simply adopts its default — which `LLM.USE` can change without a restart. + actor.default_llm_provider = crate::llm::runtime().registry().default_profile(); Ok(Arc::new(actor)) } @@ -194,9 +175,10 @@ impl GraphRAGCommands { // In production, this would use the actor system properly let mut actor_mut = GraphRAGActor::new(kg_name.clone()); actor_mut.initialize_components(); - - // Copy LLM providers if any were configured - // (This is a limitation of the current design - actors should be managed by the actor system) + // The actor built above is discarded by this path, so the default has to be applied here + // too — otherwise a RAG query issued over RESP has no model and fails with + // "no default profile" even when one is registered. + actor_mut.default_llm_provider = crate::llm::runtime().registry().default_profile(); let result = actor_mut .query_rag(orbit_client, query) diff --git a/orbit/server/src/protocols/resp/commands/llm.rs b/orbit/server/src/protocols/resp/commands/llm.rs new file mode 100644 index 00000000..e8b2828a --- /dev/null +++ b/orbit/server/src/protocols/resp/commands/llm.rs @@ -0,0 +1,872 @@ +//! `LLM.*` commands: inspect, register, and switch models at runtime. +//! +//! This is the control surface for the capability the roadmap calls *provider and model +//! switchability*. Before it existed, changing which model GraphRAG used meant editing environment +//! variables and restarting the server. Now: +//! +//! ```text +//! LLM.REGISTER fast ollama llama3.2 TEMPERATURE 0.2 +//! LLM.USE fast +//! LLM.GENERATE "why is the sky blue?" +//! LLM.STATS +//! ``` +//! +//! Every command reads or mutates the process-wide registry in [`crate::llm`], so a switch made +//! here is immediately visible to GraphRAG and any other AI surface. +//! +//! Responses never contain a credential: profile detail is rendered from +//! [`orbit_llm::ModelSummary`], which is derived from the profile rather than holding it. + +use super::traits::CommandHandler; +use crate::llm::runtime; +use crate::protocols::error::{ProtocolError, ProtocolResult}; +use crate::protocols::resp::simple_local::SimpleLocalRegistry; +use crate::protocols::resp::types::RespValue; +use async_trait::async_trait; +use bytes::Bytes; +use orbit_client::OrbitClient; +use orbit_llm::{ + ChatRequest, CompatibleFlavor, EmbeddingRequest, GenerationParams, ModelPricing, ModelProfile, + ModelSummary, ProviderConfig, ProviderKind, ProviderSettings, SecretString, +}; +use std::str::FromStr; +use std::sync::Arc; + +/// Commands supported by this handler. +const SUPPORTED: &[&str] = &[ + "LLM.PROVIDERS", + "LLM.MODELS", + "LLM.INFO", + "LLM.REGISTER", + "LLM.UNREGISTER", + "LLM.USE", + "LLM.GENERATE", + "LLM.EMBED", + "LLM.STATS", +]; + +/// Handler for the `LLM.*` command family. +pub struct LlmCommands { + #[allow(dead_code)] + local_registry: Arc, + #[allow(dead_code)] + orbit_client: Arc, +} + +impl LlmCommands { + /// Create a handler. + pub fn new(orbit_client: Arc, local_registry: Arc) -> Self { + Self { + local_registry, + orbit_client, + } + } + + /// `LLM.PROVIDERS` — the wire shapes this build can speak. + fn providers(&self) -> RespValue { + RespValue::Array( + ProviderKind::all() + .iter() + .map(|kind| { + RespValue::Array(vec![ + bulk("name"), + bulk(kind.as_str()), + bulk("embeddings"), + RespValue::Boolean(kind.supports_embeddings()), + ]) + }) + .collect(), + ) + } + + /// `LLM.MODELS` — registered profiles, marking the default. + fn models(&self) -> RespValue { + RespValue::Array( + runtime() + .registry() + .summaries() + .iter() + .map(render_summary_brief) + .collect(), + ) + } + + /// `LLM.INFO ` — full detail for one profile. + fn info(&self, args: &[RespValue]) -> ProtocolResult { + let name = self.get_string_arg(args, 0, "LLM.INFO")?; + let summary = runtime() + .registry() + .summary(&name) + .map_err(|e| ProtocolError::RespError(format!("ERR {e}")))?; + Ok(render_summary_full(&summary)) + } + + /// `LLM.USE ` — switch the default model, no restart. + fn use_profile(&self, args: &[RespValue]) -> ProtocolResult { + let name = self.get_string_arg(args, 0, "LLM.USE")?; + runtime() + .registry() + .set_default(&name) + .map_err(|e| ProtocolError::RespError(format!("ERR {e}")))?; + tracing::info!(profile = %name, "default LLM profile switched at runtime"); + Ok(RespValue::SimpleString("OK".to_string())) + } + + /// `LLM.UNREGISTER ` — remove a profile. + fn unregister(&self, args: &[RespValue]) -> ProtocolResult { + let name = self.get_string_arg(args, 0, "LLM.UNREGISTER")?; + runtime() + .registry() + .unregister(&name) + .map_err(|e| ProtocolError::RespError(format!("ERR {e}")))?; + tracing::info!(profile = %name, "LLM profile removed at runtime"); + Ok(RespValue::SimpleString("OK".to_string())) + } + + /// `LLM.REGISTER [option value]...` + fn register(&self, args: &[RespValue]) -> ProtocolResult { + let profile = build_profile_from_args(args)?; + let name = profile.name.clone(); + runtime() + .registry() + .register(profile) + .map_err(|e| ProtocolError::RespError(format!("ERR {e}")))?; + tracing::info!(profile = %name, "LLM profile registered at runtime"); + Ok(RespValue::SimpleString("OK".to_string())) + } + + /// `LLM.GENERATE [MODEL p] [SYSTEM s] [MAXTOKENS n] [TEMPERATURE t]` + async fn generate(&self, args: &[RespValue]) -> ProtocolResult { + let prompt = self.get_string_arg(args, 0, "LLM.GENERATE")?; + let options = parse_options(&args[1..])?; + + let request = ChatRequest::prompt(prompt, options.system).with_params(GenerationParams { + temperature: options.temperature, + max_tokens: options.max_tokens, + ..Default::default() + }); + + let response = runtime() + .router() + .generate(options.model.as_deref(), request) + .await + .map_err(|e| ProtocolError::RespError(format!("ERR {e}")))?; + + let mut fields = vec![ + bulk("text"), + bulk(&response.text), + bulk("model"), + bulk(&response.model), + bulk("profile"), + bulk(&response.profile), + bulk("latency_ms"), + RespValue::Integer(response.latency.as_millis() as i64), + ]; + + // Token and cost fields appear only when the provider actually reported them. Emitting a + // zero would assert the request was free. + if let Some(total) = response.usage.total() { + fields.push(bulk("tokens_used")); + fields.push(RespValue::Integer(i64::from(total))); + } + if let Some(cost) = response.cost { + fields.push(bulk("cost_usd")); + fields.push(RespValue::Double(cost.total_usd())); + } + if let Some(reason) = &response.finish_reason { + fields.push(bulk("finish_reason")); + fields.push(bulk(reason.as_wire())); + } + if !response.fallbacks_used.is_empty() { + fields.push(bulk("fallbacks_used")); + fields.push(RespValue::Array( + response.fallbacks_used.iter().map(|f| bulk(f)).collect(), + )); + } + + Ok(RespValue::Array(fields)) + } + + /// `LLM.EMBED [text...] [MODEL p]` + async fn embed(&self, args: &[RespValue]) -> ProtocolResult { + // Everything up to an option keyword is an input; the rest are options. Splitting this way + // lets a caller embed a batch in one round trip. + let split = args + .iter() + .position(|arg| { + arg.as_string() + .is_some_and(|s| s.eq_ignore_ascii_case("MODEL")) + }) + .unwrap_or(args.len()); + + let inputs: Vec = args[..split] + .iter() + .filter_map(RespValue::as_string) + .collect(); + if inputs.is_empty() { + return Err(ProtocolError::RespError( + "ERR wrong number of arguments for 'llm.embed' command".to_string(), + )); + } + + let options = parse_options(&args[split..])?; + let response = runtime() + .router() + .embed(options.model.as_deref(), EmbeddingRequest::new(inputs)) + .await + .map_err(|e| ProtocolError::RespError(format!("ERR {e}")))?; + + Ok(RespValue::Array(vec![ + bulk("model"), + bulk(&response.model), + bulk("profile"), + bulk(&response.profile), + bulk("dimensions"), + RespValue::Integer(response.dimensions().unwrap_or(0) as i64), + bulk("embeddings"), + RespValue::Array( + response + .embeddings + .iter() + .map(|vector| { + RespValue::Array( + vector + .iter() + .map(|v| RespValue::Double(f64::from(*v))) + .collect(), + ) + }) + .collect(), + ), + ])) + } + + /// `LLM.STATS [profile]` — counters, breaker state, and cost. + fn stats(&self, args: &[RespValue]) -> ProtocolResult { + let summaries = match args.first().and_then(RespValue::as_string) { + Some(name) => vec![runtime() + .registry() + .summary(&name) + .map_err(|e| ProtocolError::RespError(format!("ERR {e}")))?], + None => runtime().registry().summaries(), + }; + + Ok(RespValue::Array( + summaries.iter().map(render_stats).collect(), + )) + } +} + +#[async_trait] +impl CommandHandler for LlmCommands { + async fn handle(&self, command_name: &str, args: &[RespValue]) -> ProtocolResult { + match command_name { + "LLM.PROVIDERS" => Ok(self.providers()), + "LLM.MODELS" => Ok(self.models()), + "LLM.INFO" => self.info(args), + "LLM.REGISTER" => self.register(args), + "LLM.UNREGISTER" => self.unregister(args), + "LLM.USE" => self.use_profile(args), + "LLM.GENERATE" => self.generate(args).await, + "LLM.EMBED" => self.embed(args).await, + "LLM.STATS" => self.stats(args), + other => Err(ProtocolError::RespError(format!( + "ERR unknown command '{other}'" + ))), + } + } + + fn supported_commands(&self) -> &[&'static str] { + SUPPORTED + } +} + +/// Options accepted by `LLM.GENERATE` and `LLM.EMBED`. +#[derive(Debug, Default, PartialEq)] +struct RequestOptions { + model: Option, + system: Option, + temperature: Option, + max_tokens: Option, +} + +/// Parse trailing `KEY value` pairs. +/// +/// An unrecognized key is an error rather than being skipped: silently ignoring `TEMPRATURE 0.2` +/// would let a caller believe a setting took effect when it did not. +fn parse_options(args: &[RespValue]) -> ProtocolResult { + let mut options = RequestOptions::default(); + let mut index = 0; + + while index < args.len() { + let key = args[index] + .as_string() + .ok_or_else(|| ProtocolError::RespError("ERR invalid option name".to_string()))? + .to_uppercase(); + + let value = args + .get(index + 1) + .and_then(RespValue::as_string) + .ok_or_else(|| { + ProtocolError::RespError(format!("ERR option '{key}' requires a value")) + })?; + + match key.as_str() { + "MODEL" | "PROFILE" => options.model = Some(value), + "SYSTEM" => options.system = Some(value), + "TEMPERATURE" => { + options.temperature = Some(parse_number(&value, "TEMPERATURE")?); + } + "MAXTOKENS" | "MAX_TOKENS" => { + options.max_tokens = Some(parse_number(&value, "MAXTOKENS")?); + } + other => { + return Err(ProtocolError::RespError(format!( + "ERR unknown option '{other}'" + ))) + } + } + index += 2; + } + + Ok(options) +} + +fn parse_number(value: &str, field: &str) -> ProtocolResult { + value + .parse() + .map_err(|_| ProtocolError::RespError(format!("ERR invalid value for '{field}': {value}"))) +} + +/// Build a profile from `LLM.REGISTER` arguments. +fn build_profile_from_args(args: &[RespValue]) -> ProtocolResult { + if args.len() < 3 { + return Err(ProtocolError::RespError( + "ERR wrong number of arguments for 'llm.register' command; \ + expected [option value]..." + .to_string(), + )); + } + + let name = string_at(args, 0, "profile")?; + let provider_arg = string_at(args, 1, "provider")?; + let model = string_at(args, 2, "model")?; + + let kind = ProviderKind::from_str(&provider_arg) + .map_err(|e| ProtocolError::RespError(format!("ERR {e}")))?; + let settings = ProfileSettings::parse(&args[3..])?; + + let provider = build_provider_config(kind, &provider_arg, &settings)?; + let mut profile = ModelProfile::new(name, provider, model).with_params(GenerationParams { + temperature: settings.temperature, + max_tokens: settings.max_tokens, + ..Default::default() + }); + + profile.embedding_model = settings.embedding_model.clone(); + profile.fallbacks = settings.fallbacks.clone(); + if let Some(timeout_ms) = settings.timeout_ms { + profile.timeout_ms = timeout_ms; + } + if let (Some(prompt), Some(completion)) = (settings.price_prompt, settings.price_completion) { + profile.pricing = Some(ModelPricing { + prompt_usd_per_million: prompt, + completion_usd_per_million: completion, + }); + } + + Ok(profile) +} + +fn build_provider_config( + kind: ProviderKind, + provider_arg: &str, + settings: &ProfileSettings, +) -> ProtocolResult { + // The provider argument doubles as the flavor, so `LLM.REGISTER p azure gpt-4o` picks Azure's + // header and URL convention without needing a separate option. + let flavor = (kind == ProviderKind::Compatible) + .then(|| CompatibleFlavor::parse(provider_arg)) + .transpose() + .map_err(|e| ProtocolError::RespError(format!("ERR {e}")))?; + + ProviderConfig::from_settings( + kind, + &ProviderSettings { + api_key: settings.api_key.clone(), + base_url: settings.base_url.clone(), + api_version: settings.api_version.clone(), + organization: settings.organization.clone(), + project: settings.project.clone(), + flavor, + }, + ) + .map_err(|e| { + // Name the option the caller would actually type. "requires an explicit base_url" is + // correct and unactionable at a redis-cli prompt where the option is spelled BASEURL. + ProtocolError::RespError(match kind { + ProviderKind::Compatible if settings.base_url.is_none() => { + "ERR an OpenAI-compatible provider requires BASEURL ".to_string() + } + _ => format!("ERR {e}"), + }) + }) +} + +/// Optional `LLM.REGISTER` settings. +#[derive(Debug, Default)] +struct ProfileSettings { + api_key: Option, + base_url: Option, + api_version: Option, + organization: Option, + project: Option, + embedding_model: Option, + temperature: Option, + max_tokens: Option, + timeout_ms: Option, + fallbacks: Vec, + price_prompt: Option, + price_completion: Option, +} + +impl ProfileSettings { + fn parse(args: &[RespValue]) -> ProtocolResult { + let mut settings = Self::default(); + let mut index = 0; + + while index < args.len() { + let key = args[index] + .as_string() + .ok_or_else(|| ProtocolError::RespError("ERR invalid option name".to_string()))? + .to_uppercase(); + + let value = args + .get(index + 1) + .and_then(RespValue::as_string) + .ok_or_else(|| { + ProtocolError::RespError(format!("ERR option '{key}' requires a value")) + })?; + + match key.as_str() { + "APIKEY" | "API_KEY" => settings.api_key = Some(SecretString::new(value)), + "BASEURL" | "BASE_URL" | "ENDPOINT" => settings.base_url = Some(value), + "APIVERSION" | "API_VERSION" => settings.api_version = Some(value), + "ORGANIZATION" | "ORG" => settings.organization = Some(value), + "PROJECT" => settings.project = Some(value), + "EMBEDDINGMODEL" | "EMBEDDING_MODEL" => settings.embedding_model = Some(value), + "TEMPERATURE" => settings.temperature = Some(parse_number(&value, "TEMPERATURE")?), + "MAXTOKENS" | "MAX_TOKENS" => { + settings.max_tokens = Some(parse_number(&value, "MAXTOKENS")?); + } + "TIMEOUTMS" | "TIMEOUT_MS" => { + settings.timeout_ms = Some(parse_number(&value, "TIMEOUTMS")?); + } + "FALLBACKS" => { + settings.fallbacks = value + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + } + "PRICEPROMPT" | "PRICE_PROMPT" => { + settings.price_prompt = Some(parse_number(&value, "PRICEPROMPT")?); + } + "PRICECOMPLETION" | "PRICE_COMPLETION" => { + settings.price_completion = Some(parse_number(&value, "PRICECOMPLETION")?); + } + other => { + return Err(ProtocolError::RespError(format!( + "ERR unknown option '{other}'" + ))) + } + } + index += 2; + } + + Ok(settings) + } +} + +fn string_at(args: &[RespValue], index: usize, field: &str) -> ProtocolResult { + args.get(index) + .and_then(RespValue::as_string) + .ok_or_else(|| ProtocolError::RespError(format!("ERR invalid {field}"))) +} + +fn bulk(value: &str) -> RespValue { + RespValue::BulkString(Bytes::from(value.to_owned())) +} + +fn render_summary_brief(summary: &ModelSummary) -> RespValue { + RespValue::Array(vec![ + bulk("name"), + bulk(&summary.name), + bulk("provider"), + bulk(&summary.provider), + bulk("model"), + bulk(&summary.model), + bulk("default"), + RespValue::Boolean(summary.is_default), + bulk("breaker"), + bulk(&summary.breaker_state), + ]) +} + +fn render_summary_full(summary: &ModelSummary) -> RespValue { + let mut fields = vec![ + bulk("name"), + bulk(&summary.name), + bulk("provider"), + bulk(&summary.provider), + bulk("model"), + bulk(&summary.model), + bulk("base_url"), + bulk(&summary.base_url), + bulk("default"), + RespValue::Boolean(summary.is_default), + bulk("timeout_ms"), + RespValue::Integer(summary.timeout_ms as i64), + bulk("has_pricing"), + RespValue::Boolean(summary.has_pricing), + bulk("breaker"), + bulk(&summary.breaker_state), + ]; + + if let Some(embedding_model) = &summary.embedding_model { + fields.push(bulk("embedding_model")); + fields.push(bulk(embedding_model)); + } + if !summary.fallbacks.is_empty() { + fields.push(bulk("fallbacks")); + fields.push(RespValue::Array( + summary.fallbacks.iter().map(|f| bulk(f)).collect(), + )); + } + + RespValue::Array(fields) +} + +fn render_stats(summary: &ModelSummary) -> RespValue { + let usage = &summary.usage; + let mut fields = vec![ + bulk("name"), + bulk(&summary.name), + bulk("requests"), + RespValue::Integer(usage.requests as i64), + bulk("failures"), + RespValue::Integer(usage.failures as i64), + bulk("fallbacks_fired"), + RespValue::Integer(usage.fallbacks_fired as i64), + bulk("fallback_uses"), + RespValue::Integer(usage.fallback_uses as i64), + bulk("prompt_tokens"), + RespValue::Integer(usage.prompt_tokens as i64), + bulk("completion_tokens"), + RespValue::Integer(usage.completion_tokens as i64), + // Says whether the two token figures above are totals or lower bounds. Without it a + // reader cannot tell a provider that reports nothing from one that used nothing. + bulk("tokens_complete"), + RespValue::Boolean(usage.tokens_are_complete()), + bulk("breaker"), + bulk(&summary.breaker_state), + ]; + + // Cost is meaningful only for a priced profile; on an unpriced one it would always read 0.00. + if summary.has_pricing { + fields.push(bulk("cost_usd")); + fields.push(RespValue::Double(usage.cost_usd)); + } + if let Some(mean) = usage.mean_latency_ms { + fields.push(bulk("mean_latency_ms")); + fields.push(RespValue::Double(mean)); + } + + RespValue::Array(fields) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|v| bulk(v)).collect() + } + + #[test] + fn register_builds_an_ollama_profile_with_its_parameters() { + let profile = build_profile_from_args(&args(&[ + "fast", + "ollama", + "llama3.2", + "TEMPERATURE", + "0.25", + "MAXTOKENS", + "1024", + "EMBEDDINGMODEL", + "nomic-embed-text", + ])) + .expect("builds"); + + assert_eq!(profile.name, "fast"); + assert_eq!(profile.provider.kind(), ProviderKind::Ollama); + assert_eq!(profile.model, "llama3.2"); + assert_eq!(profile.params.temperature, Some(0.25)); + assert_eq!(profile.params.max_tokens, Some(1024)); + assert_eq!(profile.embedding_model.as_deref(), Some("nomic-embed-text")); + } + + #[test] + fn register_accepts_a_fallback_chain_and_timeout() { + let profile = build_profile_from_args(&args(&[ + "primary", + "ollama", + "llama3.2", + "FALLBACKS", + "backup, spare", + "TIMEOUTMS", + "15000", + ])) + .expect("builds"); + + assert_eq!( + profile.fallbacks, + vec!["backup".to_string(), "spare".to_string()] + ); + assert_eq!(profile.timeout_ms, 15_000); + } + + #[test] + fn register_maps_a_named_service_onto_the_compatible_shape() { + let profile = build_profile_from_args(&args(&[ + "groq", + "groq", + "llama-3.3-70b", + "BASEURL", + "https://api.groq.com/openai/v1", + "APIKEY", + "gsk-test", + ])) + .expect("builds"); + + assert_eq!(profile.provider.kind(), ProviderKind::Compatible); + assert_eq!( + profile.provider.base_url(), + "https://api.groq.com/openai/v1" + ); + } + + #[test] + fn register_selects_the_azure_flavor_from_the_provider_argument() { + let profile = build_profile_from_args(&args(&[ + "azure", + "azure", + "gpt-4o-deployment", + "BASEURL", + "https://contoso.openai.azure.com", + "APIVERSION", + "2024-10-21", + "APIKEY", + "azure-key", + ])) + .expect("builds"); + + let ProviderConfig::Compatible { flavor, .. } = &profile.provider else { + panic!("expected the compatible shape"); + }; + assert_eq!(*flavor, CompatibleFlavor::AzureOpenAi); + } + + #[test] + fn register_requires_a_base_url_for_a_compatible_provider() { + let err = build_profile_from_args(&args(&["local", "vllm", "Qwen3-8B"])) + .expect_err("no base URL"); + assert!(err.to_string().contains("BASEURL")); + } + + #[test] + fn register_rejects_an_unknown_provider() { + let err = build_profile_from_args(&args(&["x", "cohere", "command-r"])) + .expect_err("unknown provider"); + assert!(err.to_string().contains("unknown provider")); + } + + #[test] + fn register_requires_the_three_positional_arguments() { + for short in [vec![], vec!["a"], vec!["a", "ollama"]] { + assert!(build_profile_from_args(&args(&short)).is_err()); + } + } + + #[test] + fn pricing_needs_both_halves_to_be_meaningful() { + let half = build_profile_from_args(&args(&["p", "ollama", "m", "PRICEPROMPT", "3.0"])) + .expect("builds"); + assert!( + half.pricing.is_none(), + "half a price schedule would compute a wrong cost, not a partial one" + ); + + let full = build_profile_from_args(&args(&[ + "p", + "ollama", + "m", + "PRICEPROMPT", + "3.0", + "PRICECOMPLETION", + "15.0", + ])) + .expect("builds"); + let pricing = full.pricing.expect("both halves supplied"); + assert!((pricing.prompt_usd_per_million - 3.0).abs() < f64::EPSILON); + assert!((pricing.completion_usd_per_million - 15.0).abs() < f64::EPSILON); + } + + #[test] + fn options_parse_case_insensitively() { + let parsed = parse_options(&args(&[ + "model", + "fast", + "System", + "be terse", + "TEMPERATURE", + "0.7", + "maxtokens", + "128", + ])) + .expect("parses"); + + assert_eq!(parsed.model.as_deref(), Some("fast")); + assert_eq!(parsed.system.as_deref(), Some("be terse")); + assert_eq!(parsed.temperature, Some(0.7)); + assert_eq!(parsed.max_tokens, Some(128)); + } + + #[test] + fn a_misspelled_option_is_an_error_not_a_silent_no_op() { + let err = parse_options(&args(&["TEMPRATURE", "0.2"])).expect_err("typo rejected"); + assert!( + err.to_string().contains("unknown option"), + "skipping it would let a caller believe the setting took effect" + ); + } + + #[test] + fn an_option_without_a_value_is_an_error() { + let err = parse_options(&args(&["MODEL"])).expect_err("dangling option"); + assert!(err.to_string().contains("requires a value")); + } + + #[test] + fn a_non_numeric_temperature_is_rejected() { + let err = parse_options(&args(&["TEMPERATURE", "warm"])).expect_err("not a number"); + assert!(err.to_string().contains("invalid value for 'TEMPERATURE'")); + } + + #[test] + fn no_options_parses_to_all_defaults() { + assert_eq!( + parse_options(&[]).expect("parses"), + RequestOptions::default() + ); + } + + #[test] + fn every_supported_command_is_reachable_from_dispatch() { + // Affordance audit: a command listed but not dispatched is documentation, not a feature. + // `handle` is exercised indirectly here by checking the match arms cover the list. + let dispatched = [ + "LLM.PROVIDERS", + "LLM.MODELS", + "LLM.INFO", + "LLM.REGISTER", + "LLM.UNREGISTER", + "LLM.USE", + "LLM.GENERATE", + "LLM.EMBED", + "LLM.STATS", + ]; + assert_eq!(SUPPORTED, dispatched); + } + + #[test] + fn stats_rendering_omits_cost_for_an_unpriced_profile() { + let summary = ModelSummary { + name: "free".into(), + provider: "ollama".into(), + model: "llama3.2".into(), + embedding_model: None, + base_url: "http://localhost:11434".into(), + is_default: true, + fallbacks: vec![], + timeout_ms: 60_000, + has_pricing: false, + breaker_state: "closed".into(), + usage: orbit_llm::UsageSnapshot { + requests: 3, + failures: 1, + fallback_uses: 0, + fallbacks_fired: 0, + prompt_tokens: 100, + completion_tokens: 50, + unreported_usage: 1, + cost_usd: 0.0, + mean_latency_ms: Some(120.0), + }, + }; + + let RespValue::Array(fields) = render_stats(&summary) else { + panic!("expected an array"); + }; + let keys: Vec = fields.iter().filter_map(RespValue::as_string).collect(); + + assert!( + !keys.contains(&"cost_usd".to_string()), + "an unpriced profile would always report 0.00, which reads as 'free'" + ); + assert!(keys.contains(&"tokens_complete".to_string())); + assert!(keys.contains(&"mean_latency_ms".to_string())); + } + + #[test] + fn full_info_rendering_carries_no_credential_fields() { + let summary = ModelSummary { + name: "openai".into(), + provider: "openai".into(), + model: "gpt-4o-mini".into(), + embedding_model: Some("text-embedding-3-small".into()), + base_url: "https://api.openai.com/v1".into(), + is_default: false, + fallbacks: vec!["backup".into()], + timeout_ms: 30_000, + has_pricing: true, + breaker_state: "closed".into(), + usage: orbit_llm::UsageSnapshot { + requests: 0, + failures: 0, + fallback_uses: 0, + fallbacks_fired: 0, + prompt_tokens: 0, + completion_tokens: 0, + unreported_usage: 0, + cost_usd: 0.0, + mean_latency_ms: None, + }, + }; + + let RespValue::Array(fields) = render_summary_full(&summary) else { + panic!("expected an array"); + }; + let rendered: Vec = fields.iter().filter_map(RespValue::as_string).collect(); + + for forbidden in ["api_key", "apikey", "secret", "token"] { + assert!( + !rendered.iter().any(|f| f.eq_ignore_ascii_case(forbidden)), + "LLM.INFO must not expose a credential field, found {forbidden}" + ); + } + assert!(rendered.contains(&"embedding_model".to_string())); + assert!(rendered.contains(&"fallbacks".to_string())); + } +} diff --git a/orbit/server/src/protocols/resp/commands/mod.rs b/orbit/server/src/protocols/resp/commands/mod.rs index 0fb3027a..fc06af8b 100644 --- a/orbit/server/src/protocols/resp/commands/mod.rs +++ b/orbit/server/src/protocols/resp/commands/mod.rs @@ -11,6 +11,7 @@ pub mod graphrag; pub mod hash; pub mod hyperloglog; pub mod list; +pub mod llm; pub mod pubsub; pub mod scripting; pub mod server; @@ -36,7 +37,7 @@ mod handler { use super::{ acl::AclCommands, cluster::ClusterCommands, connection::ConnectionCommands, functions::FunctionCommands, graph::GraphCommands, graphrag::GraphRAGCommands, - hash::HashCommands, hyperloglog::HyperLogLogCommands, list::ListCommands, + hash::HashCommands, hyperloglog::HyperLogLogCommands, list::ListCommands, llm::LlmCommands, pubsub::PubSubCommands, scripting::ScriptingCommands, server::ServerCommands, set::SetCommands, sorted_set::SortedSetCommands, stream::StreamCommands, string::StringCommands, time_series::TimeSeriesCommands, transactions::TransactionCommands, @@ -68,6 +69,7 @@ mod handler { TimeSeries, Graph, GraphRAG, + Llm, Server, Transactions, Unknown, @@ -98,6 +100,7 @@ mod handler { time_series: TimeSeriesCommands, graph: GraphCommands, graphrag: GraphRAGCommands, + llm: LlmCommands, server: ServerCommands, transactions: TransactionCommands, } @@ -140,6 +143,7 @@ mod handler { time_series: TimeSeriesCommands::new(orbit_client.clone(), local_registry.clone()), graph: GraphCommands::new(orbit_client.clone(), local_registry.clone()), graphrag: GraphRAGCommands::new(orbit_client.clone(), local_registry.clone()), + llm: LlmCommands::new(orbit_client.clone(), local_registry.clone()), server: ServerCommands::new(orbit_client.clone(), local_registry.clone()), transactions: TransactionCommands::new( orbit_client.clone(), @@ -224,6 +228,9 @@ mod handler { CommandCategory::GraphRAG => { CommandHandlerTrait::handle(&self.graphrag, &command_name, &args).await } + CommandCategory::Llm => { + CommandHandlerTrait::handle(&self.llm, &command_name, &args).await + } CommandCategory::Server => { CommandHandlerTrait::handle(&self.server, &command_name, &args).await } @@ -330,6 +337,9 @@ mod handler { // GraphRAG commands cmd if cmd.starts_with("GRAPHRAG.") => CommandCategory::GraphRAG, + // LLM model-management and inference commands + cmd if cmd.starts_with("LLM.") => CommandCategory::Llm, + // Server commands "INFO" | "DBSIZE" | "FLUSHDB" | "FLUSHALL" | "COMMAND" => CommandCategory::Server, From 008cccc39ac8e5e65e8d3df5811b88e5c315f85a Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Wed, 5 Aug 2026 21:48:36 -0700 Subject: [PATCH 27/36] chore(ml): gate the industry_models scaffolding behind a feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit industry_models is not implemented. It is roughly 470 `// TODO: Implement` method bodies across seven verticals — healthcare, fintech, adtech, defense, logistics, banking, insurance — with no training, no inference, and no tests behind them. Shipping that in a default-on crate advertises capability that does not exist, which is a modelling-honesty failure at the package level: it inflates the apparent surface area, cannot be tested, and any caller who reaches it gets silence or a default. Gates the subtree behind `experimental-industry-models` (default off; each per-vertical feature implies it), corrects the crate-level docs to say it is scaffolding, and adds a test asserting a default build does not advertise it. Reversible, and it stops the overclaim now. Deleting and reintroducing verticals one at a time with tests remains the better end state. Co-Authored-By: Claude Opus 5 (1M context) --- orbit/ml/Cargo.toml | 18 ++++++++++------- orbit/ml/src/lib.rs | 47 ++++++++++++++++++++++++++++++++++++--------- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/orbit/ml/Cargo.toml b/orbit/ml/Cargo.toml index b1e7b0ba..5ec65b0c 100644 --- a/orbit/ml/Cargo.toml +++ b/orbit/ml/Cargo.toml @@ -18,13 +18,17 @@ lua = [] # Dependencies commented out for initial build gpu = ["candle-core/cuda", "candle-nn/cuda"] # GPU acceleration with Candle (CUDA) gpu-metal = ["candle-core/metal", "candle-nn/metal"] # GPU acceleration with Candle (Metal) distributed = [] # Dependencies commented out for initial build -industry-healthcare = [] -industry-fintech = [] -industry-adtech = [] -industry-defense = [] -industry-logistics = [] -industry-banking = [] -industry-insurance = [] +# Experimental industry verticals. The `industry_models` subtree is scaffolding — method bodies +# are `// TODO: Implement` — so it is excluded from the default build. Enabling this umbrella +# feature compiles it; the per-vertical features below additionally re-export their aliases. +experimental-industry-models = [] +industry-healthcare = ["experimental-industry-models"] +industry-fintech = ["experimental-industry-models"] +industry-adtech = ["experimental-industry-models"] +industry-defense = ["experimental-industry-models"] +industry-logistics = ["experimental-industry-models"] +industry-banking = ["experimental-industry-models"] +industry-insurance = ["experimental-industry-models"] [dependencies] # Core workspace dependencies diff --git a/orbit/ml/src/lib.rs b/orbit/ml/src/lib.rs index 93516a52..36167946 100644 --- a/orbit/ml/src/lib.rs +++ b/orbit/ml/src/lib.rs @@ -9,7 +9,9 @@ //! - **Transformers**: BERT, GPT, Vision Transformers with attention mechanisms //! - **Graph Neural Networks**: GCN, GraphSAGE, GAT for graph-based learning //! - **Multi-Language Support**: Python, JavaScript, Lua integration -//! - **Industry Models**: Specialized models for healthcare, fintech, defense, etc. +//! - **Industry Models**: *Experimental scaffolding, off by default.* The healthcare, fintech, +//! defense, logistics, banking, and insurance subtrees are method signatures with unimplemented +//! bodies. Build with `--features experimental-industry-models` to compile them. //! - **SQL Integration**: Native SQL syntax for all ML operations //! - **GPU Acceleration**: CUDA support for training and inference //! - **Distributed Training**: Multi-node, multi-GPU capabilities @@ -70,7 +72,14 @@ pub mod transformers; pub mod multi_language; // Industry-specific models -/// Pre-built models for various industry verticals +// +// EXPERIMENTAL AND OFF BY DEFAULT. This subtree is scaffolding: roughly 470 `// TODO: Implement` +// method bodies across seven verticals, with no training, no inference, and no tests behind them. +// Shipping it in a default-on crate advertises capability that does not exist, so it is gated until +// a vertical is real. Enable with `--features experimental-industry-models` if you are working on +// it. See `specifications/COMPETITIVE_ANALYSIS.md` §2.5 and `AI_LLM_ROADMAP.md` decision D7. +/// Pre-built models for various industry verticals (experimental scaffolding). +#[cfg(feature = "experimental-industry-models")] pub mod industry_models; // SQL extensions @@ -117,25 +126,34 @@ pub use multi_language::javascript::JavaScriptMLEngine; #[cfg(feature = "lua")] pub use multi_language::lua::LuaMLEngine; -#[cfg(feature = "industry-healthcare")] +#[cfg(all( + feature = "experimental-industry-models", + feature = "industry-healthcare" +))] pub use industry_models::healthcare_pharma_lifesciences as healthcare; -#[cfg(feature = "industry-fintech")] +#[cfg(all(feature = "experimental-industry-models", feature = "industry-fintech"))] pub use industry_models::finance_banking_insurance as fintech; -#[cfg(feature = "industry-adtech")] +#[cfg(all(feature = "experimental-industry-models", feature = "industry-adtech"))] pub use industry_models::arts_design_creative as adtech; -#[cfg(feature = "industry-defense")] +#[cfg(all(feature = "experimental-industry-models", feature = "industry-defense"))] pub use industry_models::government_defense_publicsector as defense; -#[cfg(feature = "industry-logistics")] +#[cfg(all( + feature = "experimental-industry-models", + feature = "industry-logistics" +))] pub use industry_models::transportation_logistics_travel as logistics; -#[cfg(feature = "industry-banking")] +#[cfg(all(feature = "experimental-industry-models", feature = "industry-banking"))] pub use industry_models::finance_banking_insurance as banking; -#[cfg(feature = "industry-insurance")] +#[cfg(all( + feature = "experimental-industry-models", + feature = "industry-insurance" +))] pub use industry_models::finance_banking_insurance as insurance; /// Version information @@ -158,6 +176,7 @@ pub fn has_feature(feature: &str) -> bool { "lua" => cfg!(feature = "lua"), "gpu" => cfg!(feature = "gpu"), "distributed" => cfg!(feature = "distributed"), + "experimental-industry-models" => cfg!(feature = "experimental-industry-models"), "industry-healthcare" => cfg!(feature = "industry-healthcare"), "industry-fintech" => cfg!(feature = "industry-fintech"), "industry-adtech" => cfg!(feature = "industry-adtech"), @@ -186,6 +205,16 @@ mod tests { assert!(has_feature("graph-neural-networks")); } + #[test] + fn industry_scaffolding_is_off_by_default() { + // The `industry_models` subtree is unimplemented stubs; a default build must not advertise + // it. See specifications/COMPETITIVE_ANALYSIS.md §2.5. + #[cfg(not(feature = "experimental-industry-models"))] + assert!(!has_feature("experimental-industry-models")); + #[cfg(not(feature = "industry-healthcare"))] + assert!(!has_feature("industry-healthcare")); + } + #[tokio::test] async fn test_engine_creation() { let result = MLEngine::new().await; From 6e6b350e975dfdb5a9db0d259b5082f144e4353f Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Wed, 5 Aug 2026 21:48:36 -0700 Subject: [PATCH 28/36] style(tests): apply rustfmt to the TLS integration tests Fallout from `make format`; no behavior change. Co-Authored-By: Claude Opus 5 (1M context) --- orbit/server/tests/lb_tls_test.rs | 11 ++++++++--- orbit/server/tests/tls_test.rs | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/orbit/server/tests/lb_tls_test.rs b/orbit/server/tests/lb_tls_test.rs index c79fae2c..4a822d32 100644 --- a/orbit/server/tests/lb_tls_test.rs +++ b/orbit/server/tests/lb_tls_test.rs @@ -24,8 +24,7 @@ async fn test_lb_tls_passthrough() { .ok(); // 1. Setup paths (relative to workspace root) - let certs_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../config/certs"); + let certs_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../config/certs"); let ca_cert_path = certs_dir.join("ca_cert.pem"); let server_cert_path = certs_dir.join("server_cert.pem"); let server_key_path = certs_dir.join("server_key.pem"); @@ -33,7 +32,13 @@ async fn test_lb_tls_passthrough() { let client_key_path = certs_dir.join("client_key.pem"); // Verify cert files exist - for path in [&ca_cert_path, &server_cert_path, &server_key_path, &client_cert_path, &client_key_path] { + for path in [ + &ca_cert_path, + &server_cert_path, + &server_key_path, + &client_cert_path, + &client_key_path, + ] { assert!(path.exists(), "Missing cert file: {}", path.display()); } diff --git a/orbit/server/tests/tls_test.rs b/orbit/server/tests/tls_test.rs index 5e46e004..7edbc7c0 100644 --- a/orbit/server/tests/tls_test.rs +++ b/orbit/server/tests/tls_test.rs @@ -23,8 +23,7 @@ async fn test_postgres_server_tls_connection() { .ok(); // 1. Setup paths (relative to workspace root) - let certs_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../config/certs"); + let certs_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../config/certs"); let ca_cert_path = certs_dir.join("ca_cert.pem"); let server_cert_path = certs_dir.join("server_cert.pem"); let server_key_path = certs_dir.join("server_key.pem"); @@ -32,7 +31,13 @@ async fn test_postgres_server_tls_connection() { let client_key_path = certs_dir.join("client_key.pem"); // Verify cert files exist - for path in [&ca_cert_path, &server_cert_path, &server_key_path, &client_cert_path, &client_key_path] { + for path in [ + &ca_cert_path, + &server_cert_path, + &server_key_path, + &client_cert_path, + &client_key_path, + ] { assert!(path.exists(), "Missing cert file: {}", path.display()); } From e2ea04fca69f6ceb25007622a68c95bebc49ae7a Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Wed, 5 Aug 2026 21:48:47 -0700 Subject: [PATCH 29/36] docs: add competitive analysis and AI/LLM roadmap; update PRD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit specifications/COMPETITIVE_ANALYSIS.md — competitor landscape across six tiers and a feature-by-feature fit-gap. Every "Orbit-RS today" claim was checked by reading the module, not the docs about it; capabilities that exist as scaffolding are marked Scaffold, not Yes. Findings that drove the LLM workstream: - SurrealDB 3.0 (GA 2026-02-17) is the direct threat: our architecture with a finished AI story, repositioned as AI agent memory - Inference moved inside the database boundary during 2025-26 - The model gateway became a required component, with a normalized feature list any LLM-calling product is now measured against - orbit/ml carries 470 stub bodies in 31k LOC (addressed in 008cccc3) specifications/AI_LLM_ROADMAP.md — eight decisions recorded with reasoning and reversal cost, milestones M1-M8. M1-M5 are built; M6 (streaming), M7 (semantic cache over the in-process HNSW index, plus per-tenant budgets), and M8 (auto-embedding on write) are specified only, and say so. §4 records what verification actually ran rather than what was planned, including the defect it found that the green build hid: `[llm] enabled = false` was ignored, so the kill switch registered every profile anyway — a decorative parameter shipped by the author of the warning about decorative parameters. PRD.md — adds the orbit-llm module reference and the LLM.* command surface, and corrects the feature matrix: `Machine Learning | Complete` now reads Partial for the core and Scaffolding for the verticals. Co-Authored-By: Claude Opus 5 (1M context) --- specifications/AI_LLM_ROADMAP.md | 101 ++++++++++--- specifications/COMPETITIVE_ANALYSIS.md | 17 +-- specifications/PRD.md | 189 ++++++++++++++++++++++++- 3 files changed, 279 insertions(+), 28 deletions(-) diff --git a/specifications/AI_LLM_ROADMAP.md b/specifications/AI_LLM_ROADMAP.md index 2c129646..f432b4ee 100644 --- a/specifications/AI_LLM_ROADMAP.md +++ b/specifications/AI_LLM_ROADMAP.md @@ -16,10 +16,10 @@ reasoning so it can be overturned deliberately rather than by accident. | D1 | **Build `orbit/llm` as a new workspace crate** rather than depend on `rig` or `genai` | The value is the router (fallback, breaker, cost, hot-swap) and its integration with `OrbitError`/`tracing`/Prometheus — none of which a third-party crate provides. HTTP shaping over `reqwest` (already a dependency) is the cheap part. | Low — the provider trait is the seam; a `genai`-backed provider could be added behind it | | D2 | **Four provider shapes, not twenty** — OpenAI, Anthropic, Ollama, OpenAI-compatible | The compatible shape covers Azure, vLLM, Groq, Together, OpenRouter, LM Studio, DeepSeek, Fireworks, and any local server. Chasing a provider count is vanity; the trait makes each new one ~80 LOC. | None — additive | | D3 | **No bundled model price table** | A price map baked into a database binary goes stale silently and then reports confident wrong costs. Prices are configured per model profile; cost is `None` when unpriced. | None | -| D4 | **`SecretString` for all credentials**, redacting `Debug`/`Display` and refusing `Serialize` | `LLMProvider` today is `Serialize` with a plain `String` api_key. Config dumps and error paths can print it. | None | +| D4 | **`SecretString` for all credentials**, redacting `Debug`/`Display` and emitting a redaction marker from `Serialize` | `LLMProvider` today is `Serialize` with a plain `String` api_key. Config dumps and error paths can print it. | None | | D5 | **Registry is hot-swappable at runtime** via `RwLock`-guarded snapshot, exposed over RESP `LLM.*` | The explicit ask. Read-mostly access pattern; a write is a config change, a read is every request. | None | | D6 | **Keep `graphrag::LLMProvider` as a compatibility shim** that converts into an `orbit-llm` profile | It is public API re-exported from `orbit_shared::lib`. Breaking it would ripple through the RESP/Cypher/AQL/Postgres GraphRAG engines for no user benefit. | n/a | -| D7 | **Gate `orbit/ml::industry_models` behind an `experimental-industry-models` feature**, default off | 470 stub bodies shipping in a default-on crate is package-level overclaiming (§2.5 of the analysis). Feature-gating is reversible and immediately stops the overclaim. | Low | +| D7 | **Gate `orbit/ml::industry_models` behind an `experimental-industry-models` feature**, default off *(done)* | 470 stub bodies shipping in a default-on crate is package-level overclaiming (§2.5 of the analysis). Feature-gating is reversible and immediately stops the overclaim. | Low | | D8 | **Milestones M1–M5 are implemented in this workstream; M6–M8 are specified but not built** | M1–M5 form a coherent shippable unit: provider abstraction → providers → router → integration → control surface. M6+ each depend on M1–M5 landing first. | n/a | --- @@ -30,7 +30,7 @@ reasoning so it can be overturned deliberately rather than by accident. The crate skeleton and everything provider-independent. -- `LlmProvider` / `EmbeddingProvider` traits (`async_trait`, object-safe, sealed-adjacent) +- `LlmProvider` / `EmbeddingProvider` traits (`async_trait`, object-safe, stored as `Arc`) - Request/response types: `ChatRequest`, `ChatResponse`, `Message`, `Role`, `TokenUsage`, `FinishReason`, `EmbeddingRequest`, `EmbeddingResponse` - `SecretString` — redacting `Debug`/`Display`, `Serialize` emits a redaction marker, `Deserialize` @@ -39,7 +39,9 @@ The crate skeleton and everything provider-independent. - Shared `reqwest::Client` with connection pooling and a bounded timeout - `RetryPolicy` — exponential backoff with full jitter, retry-budget capped - `CircuitBreaker` — closed/open/half-open, per-provider -- `UsageAccountant` — token and cost aggregation, `None` when unknown +- `ProfileCounters` / `UsageSnapshot` — token, cost, latency, and failure aggregation in atomics; + cost accumulated in integer micro-dollars so it does not drift; unreported token counts are + counted separately rather than summed as zero **Acceptance:** `cargo test -p orbit-llm` green; no `unwrap`/`expect` outside tests; a `SecretString` round-trips through `Debug` without revealing its contents (test asserts this). @@ -65,7 +67,12 @@ the profile appears in the emitted body — verified by test, not by inspection. - `LlmRegistry` — named profiles, a default, `register`/`remove`/`set_default`, all at runtime - `Router` — the request path: resolve → timeout → retry → breaker → fallback → account - Config layering: `LLM_*` env vars over `[llm]` TOML, per 12-factor III -- Prometheus-shaped metrics: requests, failures, fallbacks fired, breaker state, tokens, latency +- Counters surfaced through `LLM.STATS`: requests, failures, fallbacks fired and used, tokens, + cost, mean latency, breaker state + +> **Not built:** these counters are *not* registered with the `metrics` crate, so they do not +> appear on the Prometheus endpoint yet. `LLM.STATS` is the only way to read them. Wiring them to +> `orbit-server-prometheus` is a small follow-up, listed here rather than claimed as done. **Acceptance:** tests cover — fallback fires on primary failure and is *counted*; breaker opens after threshold and rejects fast; retry respects the budget; a non-retryable error (401) does not @@ -100,9 +107,9 @@ Runtime switchability, exposed over RESP (the protocol with the cleanest command | `LLM.EMBED [MODEL p]` | Embeddings | | `LLM.STATS [profile]` | Requests, failures, fallbacks, tokens, cost, breaker state | -**Acceptance:** `redis-cli` session demonstrates registering a second profile, switching to it with +**Acceptance:** a live session demonstrates registering a second profile, switching to it with `LLM.USE`, and seeing `LLM.STATS` attribute the next generation to the new profile — all without -restarting the server. +restarting the server. **Verified**, see §4.2. ### M6 — Streaming *(specified, not built)* @@ -155,21 +162,77 @@ by D7), and benchmarking `orbit/compute` (§3.7). - **Not** an agent framework. Orbit-RS is the memory and retrieval layer agents call, not the loop. This is the boundary that makes `rig` the wrong dependency (D1). - **Not** a bundled price table (D3). +- **Not** Prometheus-exported LLM metrics yet — see the note under M3. Deliberately deferred, not + overlooked. - **Not** fine-tuning or training orchestration. `orbit/ml` has not earned more surface area (D7). --- -## 4. Verification Plan +## 4. Verification — What Was Actually Run + +Per `CLAUDE.md` → *Verification*: a green build proves almost nothing. Executed in yield order, +2026-08-05. + +### 4.1 Automated + +| Step | Result | +|---|---| +| `cargo test -p orbit-llm` | **160 passed**, 0 failed (+ 2 doc-tests) | +| `cargo test -p orbit-server --lib` | **1706 passed**, 0 failed, 55 ignored | +| `cargo test -p orbit-ml --lib` | **78 passed**, 0 failed | +| `make check` (clippy `-D warnings`) | **clean** — zero findings in any new or modified file | +| `make format` | applied | + +### 4.2 Run it — live session against a real provider + +The server was started on isolated ports with a live Ollama daemon and driven over the Redis wire +protocol. (`redis-cli` is not installed on this machine; a minimal RESP client was used instead.) + +| Claim | How it was verified | Result | +|---|---|---| +| Generation works end to end | `LLM.GENERATE` against `llama3.2` | `"The sky appears blue on a clear day."`, 52 tokens, 10.1s | +| **Model switching needs no restart** | `LLM.REGISTER granite …` → `LLM.USE granite` → `LLM.GENERATE` | answered by `granite4.1:3b`; `LLM.MODELS` showed the default moved | +| Embeddings work | `LLM.EMBED` two inputs via `nomic-embed-text` | 2 vectors, 768 dimensions, batched in one call | +| Fallback fires **and is visible** | primary pointed at a dead port with `FALLBACKS granite` | served by `granite`, response carried `fallbacks_used: ["broken"]` | +| Failover is attributed to both sides | `LLM.STATS` after the above | `broken`: `failures=1, fallbacks_fired=1`; `granite`: `fallback_uses=1` | +| Breaker opens and costs nothing | 6 consecutive failures against a dead endpoint | breaker `open`; subsequent calls rejected instantly without dialling | +| GraphRAG uses the switchable registry | `GRAPHRAG.QUERY` after `LLM.USE granite` | real LLM response, served by the newly selected profile | + +### 4.3 Reconciled against an external reference + +The Anthropic path was probed against the **real** `api.anthropic.com` with an invalid key: + +```text +ERR anthropic returned HTTP 401: +{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"},...} +``` + +An `authentication_error` naming `x-api-key` — rather than a 404, a 400, or a version complaint — +confirms the URL, the `x-api-key` header, and the `anthropic-version` header are all correct +against the live API, not merely against our own expectation of it. The 401 also correctly did +**not** retry and did **not** trip the breaker. + +### 4.4 What verification found that the tests did not + +Running it surfaced a defect the green build hid: + +> **`[llm] enabled = false` was ignored.** The bootstrap registered every profile in the config +> file regardless of the flag. A kill switch that changes nothing is the exact "decorative +> parameter" this document warns about in D3's neighbourhood, shipped by the author of the warning. +> Fixed in `server/src/llm/mod.rs::register_config_profiles`, with a test +> (`the_enabled_flag_actually_gates_registration`) asserting a disabled section registers nothing. + +This is the argument for §4.2 in one bullet: the unit tests were green, clippy was clean, and the +flag did not work. -Per `CLAUDE.md` → *Verification*: a green build proves almost nothing. In yield order: +### 4.5 Affordance audit -1. `cargo test -p orbit-llm` — router, registry, config layering, secret redaction, request shaping -2. `make check` + `make format` — zero warnings -3. **Run it.** `make dev`, then a `redis-cli` session against a local Ollama exercising - `LLM.REGISTER` → `LLM.USE` → `LLM.GENERATE` → `LLM.STATS` -4. **Reconcile against an external reference** — the Anthropic request body is checked against the - documented Messages API shape (system as top-level field, `max_tokens` required), not against - our own expectation of it -5. **Audit affordances** — every `Provider` enum variant must appear at a construction site, and - every registered command must appear in the dispatch table. An unreachable variant is data - pretending to be control flow. +- Every `ProviderKind` variant has a construction site in `providers::build_provider`, asserted by + `every_provider_kind_is_constructible`, which also asserts the case count equals + `ProviderKind::all().len()` — so adding a variant without wiring it fails the test. +- Every `LLMProvider` legacy variant converts, asserted by + `every_legacy_variant_has_a_conventional_name_and_converts`. +- Every command in `SUPPORTED` appears in the `handle` dispatch, asserted by + `every_supported_command_is_reachable_from_dispatch`. +- `orbit-ml`'s industry scaffolding is off by default, asserted by + `industry_scaffolding_is_off_by_default` (D7). diff --git a/specifications/COMPETITIVE_ANALYSIS.md b/specifications/COMPETITIVE_ANALYSIS.md index 259cbe1c..a7a9e1c0 100644 --- a/specifications/COMPETITIVE_ANALYSIS.md +++ b/specifications/COMPETITIVE_ANALYSIS.md @@ -101,7 +101,7 @@ This is where the distance from the market is largest, and it is also the cheape | Feature | Orbit-RS **before** this work | Best-in-class | Gap severity | |---|---|---|---| | LLM provider abstraction | **Partial** — `LLMClient` trait, 348 LOC, GraphRAG-internal | `rig` (20+), `genai` (26+), LiteLLM (100+) | High | -| Anthropic support | **No** — `Err("Anthropic client not yet implemented")` | Universal | **High — a named enum variant that returns an error** | +| Anthropic support | **No** — `Err("Anthropic client not yet implemented")` | Universal | **High — a named enum variant that returns an error** ✅ *closed; verified against the live API* | | Azure OpenAI / Bedrock / Vertex / Gemini | **No** | LiteLLM, Portkey | High (enterprise procurement blocker) | | OpenAI-compatible endpoints (vLLM, Groq, Together, OpenRouter, LM Studio) | **Partial** — one hardcoded `Local` variant | LiteLLM | Medium | | **Runtime model switching (no restart)** | **No** — provider baked into actor construction | LiteLLM, Portkey | **High — the explicit ask** | @@ -128,7 +128,7 @@ This is where the distance from the market is largest, and it is also the cheape | Feature | Orbit-RS | Gap | |---|---|---| | Structured logging (`tracing`) | **Yes** | — | -| Prometheus metrics | **Yes** (`orbit-server-prometheus`) | LLM/AI metrics absent | +| Prometheus metrics | **Yes** (`orbit-server-prometheus`) | LLM counters exist but are not yet exported — readable only via `LLM.STATS` | | K8s operator + Helm | **Yes** | — | | Config from env over TOML | **Partial** | AI subsystem read env ad hoc (`std::env::var("OPENAI_API_KEY")` inline in a RESP handler) | | Backup/PITR | **Partial** | Maturity | @@ -169,11 +169,11 @@ Ranked by (competitive damage if unfixed) ÷ (effort to fix). 4. **Vector quantization + pre-filtered search.** The pure scale/quality gap vs Qdrant. Larger effort, no dependency on items 1–3, can run in parallel. 5. **Managed cloud offering.** Largest adoption blocker, entirely outside this workstream. -6. **Decide `orbit/ml`'s fate.** Either (a) gate `industry_models` behind a `experimental-` - feature flag and remove it from public docs until real, or (b) delete it and reintroduce - verticals one at a time with tests. Leaving 470 stub bodies in a shipped crate is a - modelling-honesty failure at the package level. Recommendation: **(a) now, (b) as capacity - allows** — cheaper, reversible, and immediately stops overclaiming. +6. **Decide `orbit/ml`'s fate.** ✅ **(a) done.** `industry_models` is now gated behind + `experimental-industry-models`, default off, with the crate-level docs corrected to say it is + scaffolding and a test asserting a default build does not advertise it. Leaving 470 stub bodies + in a shipped crate was a modelling-honesty failure at the package level. Step (b) — delete and + reintroduce verticals one at a time with tests — remains open as capacity allows. 7. **Benchmark the SIMD/GPU claim.** `orbit/compute` is 28.7k LOC of differentiator with no published number against ClickHouse or DuckDB. Unmeasured performance work is indistinguishable from no performance work. @@ -208,7 +208,7 @@ Local) and a factory. Reading it against the market list produces eleven concret | Depend on `rig` | Agent framework — brings a vector-store abstraction Orbit-RS *is*, and an agent loop it does not want. Impedance mismatch. | | Depend on `genai` | Closest fit, 26+ providers. But: no fallback/circuit-breaker/cost layer, no `OrbitError` integration, and it owns the retry policy Orbit-RS must own. | | Depend on `async-openai` | OpenAI-shaped only. | -| **Build `orbit-llm`** | **Chosen.** ~1.5k LOC of HTTP shaping over `reqwest` (already a dependency). The value is not the HTTP calls — it is the *router*: fallback, breaker, cost, registry, and hot-swap, all of which need to be Orbit-native to integrate with `OrbitError`, `tracing` spans, Prometheus, and the config layering. A wrapper around `genai` would still need all of that, plus a translation layer. | +| **Build `orbit-llm`** | **Chosen.** ~1.5k LOC of HTTP shaping over `reqwest` (already a dependency). The value is not the HTTP calls — it is the *router*: fallback, breaker, cost, registry, and hot-swap, all of which need to be Orbit-native to integrate with `OrbitError`, `tracing` spans, the config layering, and (pending) Prometheus. A wrapper around `genai` would still need all of that, plus a translation layer. | **Decision recorded:** build `orbit/llm` as a first-class workspace crate, depending only on `reqwest`/`serde`/`tokio`/`orbit-shared`. No new heavyweight dependency. Providers are @@ -261,6 +261,7 @@ implementation for this category: | Capability | LiteLLM | Orbit-RS after M5 | |---|---|---| | Provider count | 100+ | 4 native shapes covering ~15 named services | +| Metrics endpoint | Yes | Counters via `LLM.STATS`; Prometheus export pending | | Unified API | Yes | Yes | | Fallback chains | Yes | Yes | | Retries + backoff | Yes | Yes (+ jitter) | diff --git a/specifications/PRD.md b/specifications/PRD.md index 120a722b..4041c6b6 100644 --- a/specifications/PRD.md +++ b/specifications/PRD.md @@ -64,6 +64,7 @@ orbit-rs/ │ ├── engine/ # Storage engine (OrbitQL, adapters) │ ├── compute/ # Hardware acceleration (SIMD, GPU) │ ├── ml/ # Machine learning inference +│ ├── llm/ # LLM provider abstraction, registry, router │ ├── proto/ # Protocol Buffer definitions │ ├── cli/ # Interactive CLI client │ ├── operator/ # Kubernetes operator @@ -113,6 +114,7 @@ orbit/server/src/ │ │ ├── commands/ # Command handlers │ │ │ ├── mod.rs # Command dispatcher │ │ │ ├── traits.rs # CommandHandler trait +│ │ │ ├── llm.rs # LLM.* model management and inference │ │ │ ├── string_persistent.rs # String commands with RocksDB │ │ │ ├── hash_commands.rs # Hash commands │ │ │ ├── list_commands.rs # List commands @@ -416,6 +418,45 @@ orbit/ml/src/ └── mod.rs ``` +### orbit-llm + +**Path**: `orbit/llm/` +**Purpose**: Provider-agnostic LLM and embedding layer — a model gateway inside the database process + +```text +orbit/llm/src/ +├── lib.rs # Crate exports +├── types.rs # ChatRequest/Response, Message, TokenUsage, Cost +├── provider.rs # LlmProvider / EmbeddingProvider traits, ProviderKind +├── config.rs # ModelProfile, ProviderConfig, LlmConfig, env layering +├── registry.rs # LlmRegistry — named, hot-swappable model profiles +├── router.rs # timeout → retry → breaker → fallback → accounting +├── retry.rs # Exponential backoff with full jitter +├── breaker.rs # Per-profile circuit breaker +├── usage.rs # Token/cost/failure counters +├── secret.rs # SecretString (redacting Debug/Display/Serialize) +├── http.rs # Shared pooled reqwest client +├── compat.rs # orbit_shared::graphrag::LLMProvider → ModelProfile +├── testing.rs # In-process stub provider (test-only) +└── providers/ + ├── openai_shape.rs # Shared /chat/completions + /embeddings wire shape + ├── openai.rs # OpenAI (max_completion_tokens) + ├── anthropic.rs # Anthropic Messages API + ├── ollama.rs # Ollama /api/chat + /api/embed + └── compatible.rs # Azure/vLLM/Groq/Together/OpenRouter/LM Studio/DeepSeek +``` + +**Capabilities**: unified API over 4 wire shapes (~15 named services), named model profiles +switchable at runtime with no restart, fallback chains, retries with jittered backoff, per-profile +circuit breakers, per-attempt timeouts, connection pooling, and token/cost accounting. + +**Consumers**: GraphRAG (`server/src/protocols/graphrag/`), the `LLM.*` RESP commands +(`server/src/protocols/resp/commands/llm.rs`), via the shared runtime in `server/src/llm/`. + +**Design notes**: token counts and cost are `Option` — a provider that reports nothing yields +absent, never zero. No model price table is bundled; cost is computed only from configured prices. +See [`AI_LLM_ROADMAP.md`](AI_LLM_ROADMAP.md) and [`COMPETITIVE_ANALYSIS.md`](COMPETITIVE_ANALYSIS.md). + ### orbit-operator **Path**: `orbit/operator/` @@ -554,6 +595,100 @@ npm run compile # Press F5 in VS Code to launch extension ``` +### orbit-desktop (Desktop GUI) + +**Path**: `orbit/desktop/` +**Language**: Rust (Tauri 1.x backend) + TypeScript/React (frontend) +**Purpose**: Desktop client for connecting to Orbit-RS, running statements, +reading results, and controlling a local development cluster. + +> Note: this crate carries its own `[workspace]` in `src-tauri/Cargo.toml`, so +> it is **not** built by the root workspace. `make check` and `cargo test` at the +> repository root do not cover it — it must be built and tested separately. + +#### Desktop Structure + +```text +orbit/desktop/ +├── src-tauri/ +│ ├── src/ +│ │ ├── main.rs # Tauri commands + application state +│ │ ├── connections.rs # Connection descriptions and live sessions +│ │ ├── queries.rs # Statement execution, timeouts, history +│ │ ├── cluster.rs # Local cluster lifecycle and observation +│ │ ├── models.rs # ML function catalogue +│ │ ├── storage.rs # Persisted connections and settings +│ │ └── encryption.rs # AES-GCM password storage +│ └── Cargo.toml # Separate workspace +├── src/ +│ ├── App.tsx # Shell, editor tabs, results, side panels +│ ├── components/ +│ │ ├── ConnectionDialog.tsx # Create/edit a connection +│ │ ├── ConnectionManager.tsx# Connect, disconnect, delete +│ │ ├── ClusterPanel.tsx # Cluster start/stop/status/logs +│ │ ├── QueryEditor.tsx # CodeMirror editor +│ │ ├── QueryResultsTable.tsx# Result grid and CSV/JSON export +│ │ ├── QueryHistoryPanel.tsx# Past statements with real timings +│ │ ├── DataVisualization.tsx# Chart.js views +│ │ └── MLModelManager.tsx # ML function reference +│ ├── services/tauri.ts # Typed wrapper over the Tauri commands +│ ├── utils/queryFormatter.ts # ReDoS-hardened SQL formatter +│ └── types/index.ts # Mirrors the Rust command payloads +└── package.json +``` + +#### Connection Model + +A **connection** is a saved description (host, port, credentials); a **session** +is a live handle opened from it. Descriptions persist across restarts, sessions +do not. Sessions open lazily on first use and are held open across statements, +so `SET`, temporary tables, open transactions and Redis `SELECT` behave as +expected. A dead session is detected by ping and transparently reopened. + +| Protocol | Client | Statement support | +|----------|--------|-------------------| +| PostgreSQL | `tokio-postgres` | Full: typed columns, real affected-row counts | +| MySQL | `mysql_async` | Full: real column names and types | +| Redis | `redis` (multiplexed) | Full: quoted-argument parsing, recursive RESP decoding | +| CQL | TCP probe only | Reachability only — no binary-protocol client; statements are refused | +| OrbitQL, Cypher, AQL, FlightSQL, OrbitWire | HTTP | Reaches the REST API (see caveat below) | + +#### Cluster Lifecycle + +The cluster panel drives `scripts/start-cluster.sh` and reports only observed +state: PID files on disk, process liveness and uptime from `ps`, port numbers +read from each live process's own command line, and TCP reachability probed per +port. It deliberately does **not** read `/api/v1/cluster/*`, whose handlers +return fixed values rather than measurements. "Running" and "serving" are +reported separately so a node that is up with dead listeners is visible. + +#### Known Limitations + +- **REST API returns canned data.** `orbit-server`'s `/api/v1/sql`, `/tables`, + `/schemas`, `/queries/history`, `/stats` and `/cluster/*` handlers return + hardcoded example rows regardless of input. The HTTP-backed connection types + therefore verify connectivity but do not return database contents; the UI + labels these results rather than presenting them as data. +- **No TLS.** `ssl_mode` values that request encryption are rejected rather than + silently downgraded to plaintext. +- **Model management is not implemented.** Listing and deleting models report + that plainly instead of returning fabricated models. + +#### Development + +```bash +cd orbit/desktop +npm install +npm run dev # Vite + Tauri +npm run typecheck # tsc --noEmit +npm test # vitest + +# Backend (separate workspace) +cargo test --manifest-path src-tauri/Cargo.toml +# Tests marked #[ignore] need a running server / spawn real processes: +cargo test --manifest-path src-tauri/Cargo.toml -- --ignored --test-threads=1 +``` + --- ## Protocol Implementations @@ -598,6 +733,8 @@ OrbitQL queries can be executed via two purpose-built wire protocols: | Time Series | TS.CREATE, TS.ADD, TS.RANGE, TS.CREATERULE | `time_series.rs` | | Vectors | VECTOR.ADD, VECTOR.SEARCH | `vector.rs` | | Graph | GRAPH.QUERY | `graph.rs` | +| GraphRAG | GRAPHRAG.BUILD, GRAPHRAG.QUERY, GRAPHRAG.STATS | `graphrag.rs` | +| LLM | LLM.PROVIDERS, LLM.MODELS, LLM.INFO, LLM.REGISTER, LLM.UNREGISTER, LLM.USE, LLM.GENERATE, LLM.EMBED, LLM.STATS | `llm.rs` | ### Time Series Commands @@ -616,6 +753,47 @@ TS.DELETERULE sourceKey destKey **Aggregation Types**: AVG, SUM, MIN, MAX, RANGE, COUNT, FIRST, LAST, STD.P, VAR.P, TWA +### LLM Commands + +Model management and inference. Registering or switching a model takes effect on the next request +with no server restart, and applies to every AI surface (GraphRAG included) because all of them +resolve models through one registry. + +```text +LLM.PROVIDERS # wire shapes this build supports +LLM.MODELS # registered profiles, marking the default +LLM.INFO # full profile detail (credentials redacted) +LLM.REGISTER [option value ...] +LLM.UNREGISTER +LLM.USE # switch the default model, no restart +LLM.GENERATE [MODEL p] [SYSTEM s] [MAXTOKENS n] [TEMPERATURE t] +LLM.EMBED [text ...] [MODEL p] +LLM.STATS [profile] # requests, failures, fallbacks, tokens, cost, breaker state +``` + +**Providers**: `openai`, `anthropic`, `ollama`, and the OpenAI-compatible shape, reachable by the +service aliases `azure`, `vllm`, `groq`, `together`, `openrouter`, `lmstudio`, `deepseek`, +`fireworks`, `local`. + +**`LLM.REGISTER` options**: `APIKEY`, `BASEURL`, `APIVERSION`, `ORGANIZATION`, `PROJECT`, +`EMBEDDINGMODEL`, `TEMPERATURE`, `MAXTOKENS`, `TIMEOUTMS`, `FALLBACKS` (comma-separated), +`PRICEPROMPT`, `PRICECOMPLETION` (USD per million tokens). + +An unrecognized option is rejected rather than ignored, so a typo cannot silently do nothing. + +**Example** — add a second model and switch to it at runtime: + +```text +LLM.REGISTER groq groq llama-3.3-70b-versatile BASEURL https://api.groq.com/openai/v1 APIKEY $KEY +LLM.REGISTER claude anthropic claude-sonnet-4-5 MAXTOKENS 4096 FALLBACKS groq +LLM.USE claude +LLM.GENERATE "summarise the last incident" MAXTOKENS 200 +LLM.STATS +``` + +Configuration lives in the `[llm]` section of `config/orbit-server.toml`, layered under `LLM_*` +environment variables. Credentials belong in the environment, never the file. + --- ## Storage Architecture @@ -1002,6 +1180,8 @@ cold_tier_pushdown = true # Push predicates to columnar engine | Learning Engine | `ai/learning.rs` | Model improvement | | Decision Engine | `ai/decision.rs` | Policy-based decisions | | Knowledge Base | `ai/knowledge.rs` | Pattern storage | +| LLM Runtime | `server/src/llm/` | Shared model registry + router bootstrap | +| LLM Provider Layer | `orbit-llm/` | Provider abstraction, fallback, cost accounting | --- @@ -1110,7 +1290,14 @@ cold_tier_pushdown = true # Push predicates to columnar engine | **AI/ML Features** | | | | | AI-Native Features | Complete | 14 | `server/src/ai/` | | Heterogeneous Compute | Complete | 83 | `orbit-compute/` | -| Machine Learning | Complete | 283 | `orbit-ml/` | +| Machine Learning (core) | Partial | 283 | `orbit-ml/` — engine, inference, streaming | +| Machine Learning (industry verticals) | Scaffolding | 0 | `orbit-ml/industry_models/` — ~470 unimplemented stubs; feature-gated off by default behind `experimental-industry-models` | +| LLM Provider Layer | Complete | 160 | `orbit-llm/` — OpenAI, Anthropic, Ollama, OpenAI-compatible | +| LLM Registry & Router | Complete | (incl. above) | Runtime model switching, fallback, breaker, cost | +| LLM RESP Commands | Complete | 15 | `resp/commands/llm.rs` — `LLM.*` | +| LLM Streaming | Planned | 0 | Roadmap M6 | +| Semantic Cache | Planned | 0 | Roadmap M7 | +| Auto-embedding on write | Planned | 0 | Roadmap M8 | | **Infrastructure** | | | | | Kubernetes Operator | Active | 0 | `orbit-operator/` | From 31bcec2c856b64529fe409708039a25a640e76be Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Thu, 6 Aug 2026 00:36:08 -0700 Subject: [PATCH 30/36] Support PostgreSQL extended protocol and REST SQL Implement extended-query support and integrate the SQL engine into REST. Postgres wire: store Parse parameter OIDs, answer Describe with ParameterDescription and RowDescription, decode binary parameters on Bind, substitute parameters safely, and execute using the bound query (avoid re-sending row description). Query engine: add statement/column descriptions, parameter type inference, placeholder neutralisation, and probing for read-only statements. REST: expose a run_sql path backed by QueryEngine (with timeouts, limited rows, and clear error codes) and add server hooks to attach the engine. Add desktop integration tests and many unit tests for the extended protocol behavior. --- orbit/desktop/src-tauri/src/connections.rs | 51 ++ .../src/protocols/postgres_wire/protocol.rs | 570 ++++++++++++++++-- .../protocols/postgres_wire/query_engine.rs | 440 +++++++++++++- orbit/server/src/protocols/rest/handlers.rs | 241 +++++--- orbit/server/src/protocols/rest/server.rs | 21 + 5 files changed, 1212 insertions(+), 111 deletions(-) diff --git a/orbit/desktop/src-tauri/src/connections.rs b/orbit/desktop/src-tauri/src/connections.rs index c207cebe..f6071159 100644 --- a/orbit/desktop/src-tauri/src/connections.rs +++ b/orbit/desktop/src-tauri/src/connections.rs @@ -1513,6 +1513,57 @@ mod live_tests { } } + /// The server must now answer `Describe`, so the driver's `prepare()` — the + /// gate every conforming PostgreSQL client goes through — has to succeed. + #[tokio::test] + #[ignore = "requires a running orbit-server on 5432"] + async fn the_server_supports_the_extended_query_protocol() { + let session = PostgresSession::connect(&info(ConnectionType::PostgreSQL, 5432)) + .await + .expect("connect"); + + assert!( + session.extended_protocol, + "prepare() must succeed against orbit-server; the client only falls back to \ + simple queries when Describe is unusable" + ); + } + + /// A bound parameter must filter the result, not be executed as the literal + /// text `$1`. + #[tokio::test] + #[ignore = "requires a running orbit-server on 5432"] + async fn bound_parameters_filter_rows_on_the_server() { + let mut session = PostgresSession::connect(&info(ConnectionType::PostgreSQL, 5432)) + .await + .expect("connect"); + + let table = "desktop_param_check"; + for statement in [ + format!("DROP TABLE IF EXISTS {table}"), + format!("CREATE TABLE {table} (id INTEGER, name TEXT)"), + format!("INSERT INTO {table} (id, name) VALUES (1, 'alice')"), + format!("INSERT INTO {table} (id, name) VALUES (2, 'bob')"), + ] { + session + .execute(&statement) + .await + .unwrap_or_else(|e| panic!("setup statement failed: {statement}: {e}")); + } + + // Goes through prepare/bind/execute in the driver, so the server must + // substitute the parameter. + let rows = session + .client + .query(&format!("SELECT name FROM {table} WHERE id = $1"), &[&2i32]) + .await + .expect("parameterised query should execute"); + + assert_eq!(rows.len(), 1, "the parameter should select exactly one row"); + + let _ = session.execute(&format!("DROP TABLE IF EXISTS {table}")).await; + } + #[tokio::test] #[ignore = "requires a running orbit-server on 6379"] async fn redis_session_runs_commands_and_decodes_replies() { diff --git a/orbit/server/src/protocols/postgres_wire/protocol.rs b/orbit/server/src/protocols/postgres_wire/protocol.rs index 614b47f6..a9f5aef6 100644 --- a/orbit/server/src/protocols/postgres_wire/protocol.rs +++ b/orbit/server/src/protocols/postgres_wire/protocol.rs @@ -38,6 +38,11 @@ pub struct PostgresWireProtocol { /// Default: 4 bytes for backward compatibility with protocol 3.0 secret_key: Vec, prepared_statements: HashMap, + /// Parameter type OIDs supplied by `Parse`, per prepared statement. + /// + /// Needed to answer `Describe(Statement)`, which must report one type per + /// parameter before the row description. + statement_param_types: HashMap>, portals: HashMap>)>, auth_manager: AuthManager, scram_auth: Option, @@ -78,6 +83,7 @@ impl PostgresWireProtocol { process_id: std::process::id() as i32, secret_key: Self::random_secret_key(), prepared_statements: HashMap::new(), + statement_param_types: HashMap::new(), portals: HashMap::new(), auth_manager, scram_auth: None, @@ -100,6 +106,7 @@ impl PostgresWireProtocol { process_id: std::process::id() as i32, secret_key: Self::random_secret_key(), prepared_statements: HashMap::new(), + statement_param_types: HashMap::new(), portals: HashMap::new(), auth_manager, scram_auth: None, @@ -267,7 +274,7 @@ impl PostgresWireProtocol { self.handle_execute(&portal, max_rows, buf).await?; } FrontendMessage::Describe { target, name } => { - self.handle_describe(target, &name, buf)?; + self.handle_describe(target, &name, buf).await?; } FrontendMessage::Close { target, name } => { self.handle_close(target, &name, buf)?; @@ -584,7 +591,7 @@ impl PostgresWireProtocol { &mut self, statement_name: &str, query: &str, - _param_types: Vec, + param_types: Vec, buf: &mut BytesMut, ) -> ProtocolResult<()> { debug!("Parse: name={}, query={}", statement_name, query); @@ -592,29 +599,244 @@ impl PostgresWireProtocol { self.prepared_statements .insert(statement_name.to_string(), query.to_string()); + // A client may send fewer type OIDs than the statement has placeholders, + // leaving the rest to be inferred. This engine treats every parameter as + // text, so unspecified entries are filled in as such rather than left + // out — `Describe` must report one type per parameter. + let placeholders = Self::count_placeholders(query); + let mut param_types = param_types; + param_types.resize( + param_types.len().max(placeholders), + super::messages::type_oids::TEXT, + ); + // OID 0 means "unspecified"; answer with the type actually used. + for oid in &mut param_types { + if *oid == 0 { + *oid = super::messages::type_oids::TEXT; + } + } + self.statement_param_types + .insert(statement_name.to_string(), param_types); + BackendMessage::ParseComplete.encode(buf); Ok(()) } + /// Highest `$n` placeholder appearing in `query`. + /// + /// Counts distinct positions rather than occurrences: `$1 AND $1` is one + /// parameter. Text inside single-quoted literals is skipped so a `$1` in a + /// string is not mistaken for a placeholder. + fn count_placeholders(query: &str) -> usize { + let bytes = query.as_bytes(); + let mut highest = 0usize; + let mut index = 0usize; + let mut in_literal = false; + + while index < bytes.len() { + match bytes[index] { + b'\'' => { + in_literal = !in_literal; + index += 1; + } + b'$' if !in_literal => { + let start = index + 1; + let end = start + + bytes[start..] + .iter() + .take_while(|b| b.is_ascii_digit()) + .count(); + if end > start { + if let Ok(n) = query[start..end].parse::() { + highest = highest.max(n); + } + } + index = end.max(index + 1); + } + _ => index += 1, + } + } + + highest + } + /// Handle bind message fn handle_bind( &mut self, portal: &str, statement: &str, - _param_formats: Vec, + param_formats: Vec, params: Vec>, _result_formats: Vec, buf: &mut BytesMut, ) -> ProtocolResult<()> { debug!("Bind: portal={}, statement={}", portal, statement); + // Format codes are per-parameter, or a single code covering all of them, + // or empty meaning all text. + let is_binary = |index: usize| match param_formats.len() { + 0 => false, + 1 => param_formats[0] == 1, + _ => param_formats.get(index).is_some_and(|f| *f == 1), + }; + + let declared = self + .statement_param_types + .get(statement) + .cloned() + .unwrap_or_default(); + + // Parameters are stored as text, because that is what substitution into + // the statement needs. Binary values are decoded here, where the + // declared type says how to read the bytes. + let mut decoded = Vec::with_capacity(params.len()); + for (index, value) in params.into_iter().enumerate() { + let Some(raw) = value else { + decoded.push(None); + continue; + }; + + if !is_binary(index) { + decoded.push(Some(raw)); + continue; + } + + let type_oid = declared.get(index).copied().unwrap_or(type_oids::TEXT); + match Self::decode_binary_parameter(&raw, type_oid) { + Ok(text) => decoded.push(Some(bytes::Bytes::from(text))), + Err(e) => { + self.send_error(buf, &format!("parameter ${}: {e}", index + 1)); + return Ok(()); + } + } + } + self.portals - .insert(portal.to_string(), (statement.to_string(), params)); + .insert(portal.to_string(), (statement.to_string(), decoded)); BackendMessage::BindComplete.encode(buf); Ok(()) } + /// Render a binary-format parameter as the text the engine works with. + /// + /// PostgreSQL's binary encodings are big-endian and fixed width for the + /// scalar types. A type this does not know is rejected rather than guessed + /// at, because misreading the bytes would substitute a wrong value into the + /// statement without any error. + fn decode_binary_parameter(raw: &[u8], type_oid: i32) -> Result { + fn fixed(raw: &[u8], type_name: &str) -> Result<[u8; N], String> { + raw.try_into() + .map_err(|_| format!("expected {N} bytes for {type_name}, got {}", raw.len())) + } + + match type_oid { + type_oids::BOOL => match raw { + [0] => Ok("false".to_string()), + [1] => Ok("true".to_string()), + _ => Err("expected a single 0 or 1 byte for bool".to_string()), + }, + type_oids::INT2 => Ok(i16::from_be_bytes(fixed::<2>(raw, "int2")?).to_string()), + type_oids::INT4 => Ok(i32::from_be_bytes(fixed::<4>(raw, "int4")?).to_string()), + type_oids::INT8 => Ok(i64::from_be_bytes(fixed::<8>(raw, "int8")?).to_string()), + type_oids::FLOAT4 => Ok(f32::from_be_bytes(fixed::<4>(raw, "float4")?).to_string()), + type_oids::FLOAT8 => Ok(f64::from_be_bytes(fixed::<8>(raw, "float8")?).to_string()), + // These are already UTF-8 on the wire in both formats. + type_oids::TEXT | type_oids::VARCHAR | type_oids::JSON | type_oids::UUID => { + std::str::from_utf8(raw) + .map(str::to_string) + .map_err(|_| "value is not valid UTF-8".to_string()) + } + other => Err(format!( + "binary format is not supported for type OID {other}; send this parameter in \ + text format" + )), + } + } + + /// Substitute bound parameters into `$n` placeholders. + /// + /// Values are quoted as SQL literals, so a parameter containing a quote or a + /// semicolon becomes data rather than syntax. Placeholders inside string + /// literals are left alone. + /// + /// # Errors + /// Returns an error when the statement references a parameter that was not + /// bound; executing with a literal `$n` left in place would silently return + /// the wrong rows. + fn bind_parameters( + query: &str, + params: &[Option], + ) -> ProtocolResult { + let bytes = query.as_bytes(); + let mut out = String::with_capacity(query.len()); + let mut index = 0usize; + let mut in_literal = false; + + while index < bytes.len() { + let ch = bytes[index]; + + if ch == b'\'' { + in_literal = !in_literal; + out.push('\''); + index += 1; + continue; + } + + if ch != b'$' || in_literal { + out.push(bytes[index] as char); + index += 1; + continue; + } + + let start = index + 1; + let end = start + + bytes[start..] + .iter() + .take_while(|b| b.is_ascii_digit()) + .count(); + + if end == start { + out.push('$'); + index += 1; + continue; + } + + let position: usize = query[start..end].parse().map_err(|_| { + ProtocolError::PostgresError(format!( + "invalid parameter placeholder ${}", + &query[start..end] + )) + })?; + + let value = params.get(position.wrapping_sub(1)).ok_or_else(|| { + ProtocolError::PostgresError(format!( + "statement references ${position} but only {} parameter(s) were bound", + params.len() + )) + })?; + + match value { + None => out.push_str("NULL"), + Some(raw) => { + let text = std::str::from_utf8(raw).map_err(|_| { + ProtocolError::PostgresError(format!( + "parameter ${position} is not valid UTF-8 text" + )) + })?; + out.push('\''); + // Doubling is how a single quote is escaped in a SQL literal. + out.push_str(&text.replace('\'', "''")); + out.push('\''); + } + } + + index = end; + } + + Ok(out) + } + /// Handle execute message async fn handle_execute( &mut self, @@ -636,13 +858,20 @@ impl PostgresWireProtocol { ProtocolError::PostgresError(format!("Statement not found: {statement_name}")) })?; - // For now, ignore parameters and execute the query - // TODO: Implement parameter substitution - let _ = params; + let bound = match Self::bind_parameters(query, params) { + Ok(bound) => bound, + Err(e) => { + self.send_error(buf, &e.to_string()); + return Ok(()); + } + }; - match self.query_engine.execute_query(query).await { + match self.query_engine.execute_query(&bound).await { Ok(result) => { - self.send_query_result(&result, buf); + // Extended protocol: the row description was already sent in + // response to Describe, and repeating it here is a protocol + // violation. Only the rows and the command tag belong on Execute. + self.send_query_result_without_description(&result, buf); } Err(e) => { self.send_error(buf, &e.to_string()); @@ -653,17 +882,95 @@ impl PostgresWireProtocol { } /// Handle describe message - fn handle_describe( + /// Handle a Describe message. + /// + /// The extended query protocol requires: + /// + /// * `Describe(Statement)` → `ParameterDescription`, then `RowDescription` + /// or `NoData`; + /// * `Describe(Portal)` → `RowDescription` or `NoData`. + /// + /// Sending only `NoData` — as this did — leaves every conforming driver + /// reading a `ParameterDescription` it never receives, which is why + /// `prepare()` failed with "unexpected message from server". + async fn handle_describe( &mut self, target: super::messages::DescribeTarget, name: &str, buf: &mut BytesMut, ) -> ProtocolResult<()> { + use super::messages::DescribeTarget; + debug!("Describe: target={:?}, name={}", target, name); - // For now, return NoData - // TODO: Implement proper description based on query - BackendMessage::NoData.encode(buf); + let sql = match target { + DescribeTarget::Statement => self.prepared_statements.get(name).cloned(), + DescribeTarget::Portal => self + .portals + .get(name) + .and_then(|(statement, _)| self.prepared_statements.get(statement).cloned()), + }; + + let Some(sql) = sql else { + self.send_error( + buf, + &match target { + DescribeTarget::Statement => format!("Statement not found: {name}"), + DescribeTarget::Portal => format!("Portal not found: {name}"), + }, + ); + return Ok(()); + }; + + // Parameter types belong only to a statement description; a portal's + // parameters are already bound. + if matches!(target, DescribeTarget::Statement) { + // A type the client declared in Parse is authoritative — it says how + // that client will serialise the value. Where it declared nothing, + // the type is inferred from how the parameter is used, so callers + // are not forced to stringify every value. + let declared = self + .statement_param_types + .get(name) + .cloned() + .unwrap_or_default(); + let inferred = self.query_engine.describe_parameters(&sql).await?; + + let param_types: Vec = (0..declared.len().max(inferred.len())) + .map(|i| match declared.get(i).copied() { + Some(oid) if oid != type_oids::TEXT => oid, + _ => inferred.get(i).copied().unwrap_or(type_oids::TEXT), + }) + .collect(); + + // Remember what was advertised: Bind decodes binary values with it. + self.statement_param_types + .insert(name.to_string(), param_types.clone()); + + BackendMessage::ParameterDescription { param_types }.encode(buf); + } + + let description = self.query_engine.describe_statement(&sql).await?; + + if description.returns_rows() { + let fields = description + .columns + .iter() + .map(|column| FieldDescription { + name: column.name.clone(), + table_oid: 0, + column_id: 0, + type_oid: column.type_oid, + type_size: -1, + type_modifier: -1, + format: 0, + }) + .collect(); + BackendMessage::RowDescription { fields }.encode(buf); + } else { + BackendMessage::NoData.encode(buf); + } + Ok(()) } @@ -691,36 +998,39 @@ impl PostgresWireProtocol { /// Send query result fn send_query_result(&self, result: &QueryResult, buf: &mut BytesMut) { - match result { - QueryResult::Select { columns, rows } => { - let mut fields: Vec = Vec::with_capacity(columns.len()); - for (i, col) in columns.iter().enumerate() { - let mut oid = type_oids::TEXT; - let mut size: i16 = -1; - if let Some(first_row) = rows.first() { - if let Some(Some(val)) = first_row.get(i) { - if val.chars().all(|c| c.is_ascii_digit()) { - oid = type_oids::INT4; - size = 4; - } else if val.parse::().is_ok() { - oid = type_oids::FLOAT8; - size = 8; - } - } - } - fields.push(FieldDescription { - name: col.clone(), - table_oid: 0, - column_id: 0, - type_oid: oid, - type_size: size, - type_modifier: -1, - format: 0, - }); - } + // Simple protocol: the row description precedes the rows. + if let QueryResult::Select { columns, .. } | QueryResult::Merge { columns, .. } = result { + // Every value this engine holds is text, and it is sent in text + // format. Types were previously guessed from the characters of the + // first row, which labelled a column of '01234' as int4 and made + // conforming clients decode it as 1234 — losing the leading zero. + // Advertising text describes what is actually on the wire. + let fields: Vec = columns + .iter() + .map(|col| FieldDescription { + name: col.clone(), + table_oid: 0, + column_id: 0, + type_oid: type_oids::TEXT, + type_size: -1, + type_modifier: -1, + format: 0, + }) + .collect(); + BackendMessage::RowDescription { fields }.encode(buf); + } - BackendMessage::RowDescription { fields }.encode(buf); + self.send_query_result_without_description(result, buf); + } + /// Send rows and the command tag, without a row description. + /// + /// This is what `Execute` must send: in the extended query protocol the row + /// description belongs to `Describe`, and repeating it here makes + /// conforming clients fail. + fn send_query_result_without_description(&self, result: &QueryResult, buf: &mut BytesMut) { + match result { + QueryResult::Select { columns: _, rows } => { for row in rows { let values: Vec> = row .iter() @@ -833,3 +1143,181 @@ impl PostgresWireProtocol { key } } + +#[cfg(test)] +mod extended_protocol_tests { + use super::*; + + fn param(text: &str) -> Option { + Some(bytes::Bytes::from(text.to_string())) + } + + #[test] + fn placeholders_are_counted_by_highest_position_not_occurrences() { + assert_eq!(PostgresWireProtocol::count_placeholders("SELECT 1"), 0); + assert_eq!( + PostgresWireProtocol::count_placeholders("SELECT * FROM t WHERE a = $1 AND b = $1"), + 1 + ); + assert_eq!( + PostgresWireProtocol::count_placeholders("SELECT * FROM t WHERE a = $2 AND b = $1"), + 2 + ); + } + + #[test] + fn a_dollar_inside_a_string_literal_is_not_a_placeholder() { + assert_eq!( + PostgresWireProtocol::count_placeholders("SELECT '$1' FROM t"), + 0 + ); + assert_eq!( + PostgresWireProtocol::count_placeholders("SELECT '$5' FROM t WHERE a = $1"), + 1 + ); + } + + #[test] + fn parameters_are_substituted_in_position_order() { + let bound = PostgresWireProtocol::bind_parameters( + "SELECT * FROM t WHERE a = $1 AND b = $2", + &[param("one"), param("two")], + ) + .expect("both parameters are bound"); + assert_eq!(bound, "SELECT * FROM t WHERE a = 'one' AND b = 'two'"); + } + + #[test] + fn a_repeated_placeholder_uses_the_same_value_each_time() { + let bound = + PostgresWireProtocol::bind_parameters("SELECT $1, $1", &[param("x")]).expect("bound"); + assert_eq!(bound, "SELECT 'x', 'x'"); + } + + #[test] + fn a_null_parameter_becomes_sql_null_not_an_empty_string() { + let bound = PostgresWireProtocol::bind_parameters("SELECT $1", &[None]).expect("bound"); + assert_eq!(bound, "SELECT NULL"); + } + + /// A parameter is data. Quotes and statement terminators inside one must not + /// become syntax. + #[test] + fn quotes_in_a_parameter_are_escaped_rather_than_ending_the_literal() { + let bound = PostgresWireProtocol::bind_parameters( + "SELECT * FROM t WHERE name = $1", + &[param("O'Brien")], + ) + .expect("bound"); + assert_eq!(bound, "SELECT * FROM t WHERE name = 'O''Brien'"); + } + + #[test] + fn an_injection_attempt_in_a_parameter_stays_inside_the_literal() { + let bound = PostgresWireProtocol::bind_parameters( + "SELECT * FROM t WHERE name = $1", + &[param("x'; DROP TABLE users; --")], + ) + .expect("bound"); + assert_eq!( + bound, + "SELECT * FROM t WHERE name = 'x''; DROP TABLE users; --'" + ); + // Exactly one literal: an odd number of quotes would mean the value + // escaped into statement syntax. + assert_eq!(bound.matches('\'').count() % 2, 0); + } + + #[test] + fn a_placeholder_inside_a_literal_is_left_untouched() { + let bound = + PostgresWireProtocol::bind_parameters("SELECT '$1', $1", &[param("v")]).expect("bound"); + assert_eq!(bound, "SELECT '$1', 'v'"); + } + + /// Running with a literal `$1` still in the statement would quietly return + /// the wrong rows, so an unbound reference must fail loudly. + #[test] + fn referencing_an_unbound_parameter_is_an_error() { + let error = PostgresWireProtocol::bind_parameters("SELECT $2", &[param("only-one")]) + .expect_err("only one parameter was bound"); + assert!( + error.to_string().contains("$2"), + "the message should name the missing parameter: {error}" + ); + } + + #[test] + fn a_statement_without_placeholders_is_unchanged() { + let bound = + PostgresWireProtocol::bind_parameters("SELECT 1 FROM t", &[]).expect("bound"); + assert_eq!(bound, "SELECT 1 FROM t"); + } + + #[test] + fn binary_scalars_decode_to_their_text_form() { + use super::super::messages::type_oids; + + let decode = PostgresWireProtocol::decode_binary_parameter; + assert_eq!(decode(&2i32.to_be_bytes(), type_oids::INT4).unwrap(), "2"); + assert_eq!(decode(&(-7i64).to_be_bytes(), type_oids::INT8).unwrap(), "-7"); + assert_eq!(decode(&300i16.to_be_bytes(), type_oids::INT2).unwrap(), "300"); + assert_eq!(decode(&[1], type_oids::BOOL).unwrap(), "true"); + assert_eq!(decode(&[0], type_oids::BOOL).unwrap(), "false"); + assert_eq!(decode(b"hello", type_oids::TEXT).unwrap(), "hello"); + assert_eq!( + decode(&1.5f64.to_be_bytes(), type_oids::FLOAT8).unwrap(), + "1.5" + ); + } + + /// Misreading the bytes would substitute a wrong value with no error, so a + /// wrong-width payload and an unknown type must both be refused. + #[test] + fn malformed_or_unknown_binary_parameters_are_refused() { + use super::super::messages::type_oids; + + let decode = PostgresWireProtocol::decode_binary_parameter; + assert!(decode(&[1, 2], type_oids::INT4).is_err(), "wrong width"); + assert!(decode(&[2], type_oids::BOOL).is_err(), "not 0 or 1"); + assert!(decode(&[0; 8], type_oids::BYTEA).is_err(), "unsupported type"); + } + + #[test] + fn describing_a_parameterised_statement_neutralises_placeholders() { + use crate::protocols::postgres_wire::query_engine::QueryEngine; + + assert_eq!( + QueryEngine::placeholders_as_null_for_test("SELECT a FROM t WHERE b = $1 AND c = $22"), + "SELECT a FROM t WHERE b = NULL AND c = NULL" + ); + assert_eq!( + QueryEngine::placeholders_as_null_for_test("SELECT '$1' FROM t"), + "SELECT '$1' FROM t" + ); + } + + #[test] + fn comparisons_pair_a_placeholder_with_the_column_beside_it() { + use crate::protocols::postgres_wire::query_engine::QueryEngine; + + let pairs = QueryEngine::placeholder_comparisons_for_test( + "SELECT * FROM t WHERE id = $1 AND score >= $2 AND name LIKE $3", + ); + assert_eq!( + pairs, + vec![ + (1, "id".to_string()), + (2, "score".to_string()), + (3, "name".to_string()) + ] + ); + } + + #[test] + fn a_placeholder_that_is_not_compared_to_a_column_is_left_alone() { + use crate::protocols::postgres_wire::query_engine::QueryEngine; + + assert!(QueryEngine::placeholder_comparisons_for_test("SELECT $1").is_empty()); + } +} diff --git a/orbit/server/src/protocols/postgres_wire/query_engine.rs b/orbit/server/src/protocols/postgres_wire/query_engine.rs index 202b2c23..df1fcdc8 100644 --- a/orbit/server/src/protocols/postgres_wire/query_engine.rs +++ b/orbit/server/src/protocols/postgres_wire/query_engine.rs @@ -11,12 +11,90 @@ use tokio::sync::{Mutex, RwLock}; use crate::protocols::error::{ProtocolError, ProtocolResult}; use crate::protocols::postgres_wire::graphrag_engine::GraphRAGQueryEngine; use crate::protocols::postgres_wire::persistent_storage::{ - PersistentTableStorage, QueryCondition, TableRow, + ColumnType, PersistentTableStorage, QueryCondition, TableRow, }; use crate::protocols::postgres_wire::sql::{ConfigurableSqlEngine, UnifiedExecutionResult}; use crate::protocols::postgres_wire::vector_engine::VectorQueryEngine; use orbit_client::OrbitClient; +/// The result shape of a statement, determined without running it. +/// +/// The extended query protocol requires the server to answer `Describe` before +/// the client sends `Execute`, so this must be derivable from the statement and +/// the catalogue alone. Executing to find out is not an option: `Describe` on an +/// `INSERT` must not insert anything. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatementDescription { + /// Columns the statement will return. + /// + /// Empty means the statement returns no result set, which the protocol + /// reports as `NoData`. + pub columns: Vec, +} + +/// One described output column. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ColumnDescription { + pub name: String, + /// PostgreSQL type OID. + /// + /// Taken from the table's declared schema where one is known. Where it is + /// not, `text` — the engine stores every value as text, and a type guessed + /// from a value's characters is a claim the catalogue does not support. + pub type_oid: i32, +} + +impl ColumnDescription { + /// A column of unknown declared type, reported as `text`. + pub fn text(name: impl Into) -> Self { + Self { + name: name.into(), + type_oid: super::messages::type_oids::TEXT, + } + } +} + +impl StatementDescription { + /// A statement that returns no rows. + pub fn no_data() -> Self { + Self { + columns: Vec::new(), + } + } + + /// A statement returning the named columns, all typed `text`. + pub fn returning_text(columns: Vec) -> Self { + Self { + columns: columns.into_iter().map(ColumnDescription::text).collect(), + } + } + + /// A statement returning fully described columns. + pub fn returning(columns: Vec) -> Self { + Self { columns } + } + + /// Whether the statement produces a result set. + pub fn returns_rows(&self) -> bool { + !self.columns.is_empty() + } +} + +/// Map a declared column type to the PostgreSQL type OID the wire advertises. +pub fn column_type_oid(column_type: &ColumnType) -> i32 { + use super::messages::type_oids; + + match column_type { + ColumnType::Serial | ColumnType::Integer => type_oids::INT4, + ColumnType::BigInt => type_oids::INT8, + ColumnType::Boolean => type_oids::BOOL, + ColumnType::Double => type_oids::FLOAT8, + ColumnType::Timestamp => type_oids::TIMESTAMPTZ, + ColumnType::Json => type_oids::JSON, + ColumnType::Text | ColumnType::Varchar(_) => type_oids::TEXT, + } +} + /// Query result types #[derive(Debug, Clone)] pub enum QueryResult { @@ -201,6 +279,366 @@ impl QueryEngine { db.clone() } + /// Determine what `sql` will return, without executing it. + /// + /// Used to answer the extended query protocol's `Describe`, which the + /// client sends before `Execute`. Column names come from the statement's + /// own select list, or from the table's schema for `SELECT *`. + /// + /// # Errors + /// Returns an error only when the catalogue cannot be read. A statement + /// this engine cannot parse is reported as returning rows of unknown + /// shape — see [`QueryEngine::describe_unparsed`] — rather than failing, + /// so that `Describe` never rejects a statement `Execute` would accept. + pub async fn describe_statement(&self, sql: &str) -> ProtocolResult { + // A statement being described still carries its `$n` placeholders, which + // the parser does not accept. The result *shape* never depends on the + // parameter values, so they are stood in for by NULL purely to make the + // statement parseable here. The statement executed later is the one with + // the real values bound. + let sql = Self::placeholders_as_null(sql); + let sql = sql.as_str(); + let sql_upper = sql.trim().to_uppercase(); + + // These paths build their result set dynamically and cannot be + // described from the catalogue. + if self.is_graphrag_query(&sql_upper) + || self + .vector_engine + .as_ref() + .is_some_and(|_| self.is_vector_query(&sql_upper)) + { + return self.describe_by_probing(sql, &sql_upper).await; + } + + let Ok(statement) = self.parse_sql(sql) else { + return self.describe_by_probing(sql, &sql_upper).await; + }; + + match statement { + Statement::Select { columns, table, .. } => { + self.describe_select(columns, &table).await + } + // Everything else completes with a command tag and no result set. + Statement::Insert { .. } + | Statement::Update { .. } + | Statement::Delete { .. } + | Statement::CreateTable { .. } + | Statement::DropTable { .. } => Ok(StatementDescription::no_data()), + } + } + + /// Replace `$n` placeholders with `NULL` so a statement can be parsed for + /// description. Placeholders inside string literals are left alone. + fn placeholders_as_null(sql: &str) -> String { + let bytes = sql.as_bytes(); + let mut out = String::with_capacity(sql.len()); + let mut index = 0usize; + let mut in_literal = false; + + while index < bytes.len() { + let ch = bytes[index]; + + if ch == b'\'' { + in_literal = !in_literal; + out.push('\''); + index += 1; + continue; + } + + if ch != b'$' || in_literal { + out.push(ch as char); + index += 1; + continue; + } + + let start = index + 1; + let end = start + + bytes[start..] + .iter() + .take_while(|b| b.is_ascii_digit()) + .count(); + + if end == start { + out.push('$'); + index += 1; + } else { + out.push_str("NULL"); + index = end; + } + } + + out + } + + /// Infer the type of each `$n` parameter from where it is used. + /// + /// Returns one OID per placeholder, in position order. A client that + /// declares no parameter types relies entirely on this answer to decide how + /// to serialise its values, so reporting everything as `text` would force + /// every caller to stringify integers by hand. + /// + /// Two shapes are resolved, which between them cover ordinary + /// parameterised statements: + /// + /// * a comparison against a column — `WHERE id = $1`, `SET name = $2`; + /// * an `INSERT ... VALUES` list, by position. + /// + /// Anything else falls back to `text`, which is what the engine stores. + /// + /// # Errors + /// Returns an error only if the catalogue cannot be read. + pub async fn describe_parameters(&self, sql: &str) -> ProtocolResult> { + use super::messages::type_oids; + + let count = Self::highest_placeholder(sql); + if count == 0 { + return Ok(Vec::new()); + } + + let mut types = vec![type_oids::TEXT; count]; + + let neutralised = Self::placeholders_as_null(sql); + let Ok(statement) = self.parse_sql(&neutralised) else { + return Ok(types); + }; + + let (table, insert_columns) = match &statement { + Statement::Select { table, .. } + | Statement::Update { table, .. } + | Statement::Delete { table, .. } => (table.clone(), None), + Statement::Insert { table, columns, .. } => (table.clone(), Some(columns.clone())), + Statement::CreateTable { .. } | Statement::DropTable { .. } => return Ok(types), + }; + + let Some(storage) = &self.persistent_storage else { + return Ok(types); + }; + let Some(schema) = storage.get_table_schema(&table).await? else { + return Ok(types); + }; + + let oid_of = |column: &str| { + schema + .columns + .iter() + .find(|c| c.name.eq_ignore_ascii_case(column)) + .map(|c| column_type_oid(&c.data_type)) + }; + + if let Some(columns) = insert_columns { + // Every placeholder in an INSERT belongs to the VALUES list, so the + // nth placeholder is the nth column. + for (position, column) in columns.iter().enumerate().take(count) { + if let Some(oid) = oid_of(column) { + types[position] = oid; + } + } + return Ok(types); + } + + for (position, column) in Self::placeholder_comparisons(sql) { + if position <= count { + if let Some(oid) = oid_of(&column) { + types[position - 1] = oid; + } + } + } + + Ok(types) + } + + /// Highest `$n` position appearing outside string literals. + fn highest_placeholder(sql: &str) -> usize { + Self::scan_placeholders(sql) + .into_iter() + .map(|(position, _)| position) + .max() + .unwrap_or(0) + } + + /// Every placeholder as `(position, byte offset of the `$`)`. + fn scan_placeholders(sql: &str) -> Vec<(usize, usize)> { + let bytes = sql.as_bytes(); + let mut found = Vec::new(); + let mut index = 0usize; + let mut in_literal = false; + + while index < bytes.len() { + match bytes[index] { + b'\'' => { + in_literal = !in_literal; + index += 1; + } + b'$' if !in_literal => { + let start = index + 1; + let end = start + + bytes[start..] + .iter() + .take_while(|b| b.is_ascii_digit()) + .count(); + if end > start { + if let Ok(position) = sql[start..end].parse::() { + found.push((position, index)); + } + index = end; + } else { + index += 1; + } + } + _ => index += 1, + } + } + + found + } + + /// Placeholders that sit on the right of a comparison, paired with the + /// column name on the left: `WHERE id = $1` yields `(1, "id")`. + fn placeholder_comparisons(sql: &str) -> Vec<(usize, String)> { + const OPERATOR_CHARS: [char; 6] = ['=', '<', '>', '!', '~', '@']; + + Self::scan_placeholders(sql) + .into_iter() + .filter_map(|(position, offset)| { + let before = sql[..offset].trim_end(); + + // Step back over the operator, which may be one or two + // characters (`=`, `>=`, `<>`), or a word such as LIKE. + let before = if before.ends_with(|c| OPERATOR_CHARS.contains(&c)) { + before.trim_end_matches(|c| OPERATOR_CHARS.contains(&c)) + } else { + let word_start = before.rfind(char::is_whitespace).map_or(0, |i| i + 1); + let word = &before[word_start..]; + if word.eq_ignore_ascii_case("LIKE") || word.eq_ignore_ascii_case("ILIKE") { + &before[..word_start] + } else { + return None; + } + }; + + let identifier: String = before + .trim_end() + .chars() + .rev() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect::>() + .into_iter() + .rev() + .collect(); + + (!identifier.is_empty()).then_some((position, identifier)) + }) + .collect() + } + + /// Test hook for [`QueryEngine::placeholder_comparisons`]. + #[cfg(test)] + pub fn placeholder_comparisons_for_test(sql: &str) -> Vec<(usize, String)> { + Self::placeholder_comparisons(sql) + } + + /// Test hook for [`QueryEngine::placeholders_as_null`]. + #[cfg(test)] + pub fn placeholders_as_null_for_test(sql: &str) -> String { + Self::placeholders_as_null(sql) + } + + /// Column list for a `SELECT`, resolving `*` against the table's schema. + async fn describe_select( + &self, + columns: Vec, + table: &str, + ) -> ProtocolResult { + let selects_everything = columns.len() == 1 && columns[0] == "*"; + + if table.to_uppercase() == "ACTORS" { + let all = ["actor_id", "actor_type", "state"]; + let names: Vec = if selects_everything { + all.iter().map(|c| (*c).to_string()).collect() + } else { + columns + }; + return Ok(StatementDescription::returning_text(names)); + } + + let Some(storage) = &self.persistent_storage else { + return Ok(StatementDescription::no_data()); + }; + + // Unknown table: let `Execute` produce the real error rather than + // failing the describe with a different one. + let Some(schema) = storage.get_table_schema(table).await? else { + return Ok(StatementDescription::no_data()); + }; + + let described = |name: &str| ColumnDescription { + // Casing mirrors what `execute_persistent_select` produces, so the + // description matches the rows that follow it. + name: name.to_uppercase(), + type_oid: schema + .columns + .iter() + .find(|c| c.name.eq_ignore_ascii_case(name)) + .map_or(super::messages::type_oids::TEXT, |c| { + column_type_oid(&c.data_type) + }), + }; + + let described_columns = if selects_everything { + schema + .columns + .iter() + .map(|c| described(&c.name)) + .collect() + } else { + columns.iter().map(|c| described(c)).collect() + }; + + Ok(StatementDescription::returning(described_columns)) + } + + /// Statements whose shape cannot be derived from the catalogue. + /// + /// `Execute` never sends a `RowDescription` — only `Describe` does — so + /// answering `NoData` for a statement that does return rows makes the client + /// discard them. For a read-only statement the shape is therefore learned by + /// running it here and keeping only the column names; the rows are thrown + /// away and fetched again by `Execute`, which costs a second execution but + /// cannot report a result set that does not match what arrives. + /// + /// Anything not recognisably read-only is described as returning no rows + /// rather than being run: `Describe` must not have side effects. + async fn describe_by_probing( + &self, + sql: &str, + sql_upper: &str, + ) -> ProtocolResult { + const READ_ONLY_PREFIXES: [&str; 6] = + ["SELECT", "SHOW", "WITH", "EXPLAIN", "VALUES", "TABLE"]; + + if !READ_ONLY_PREFIXES + .iter() + .any(|keyword| sql_upper.starts_with(keyword)) + { + return Ok(StatementDescription::no_data()); + } + + match self.execute_query(sql).await { + Ok(QueryResult::Select { columns, .. }) + | Ok(QueryResult::Merge { columns, .. }) => { + Ok(StatementDescription::returning_text(columns)) + } + Ok(_) => Ok(StatementDescription::no_data()), + // A statement that fails here will fail the same way at `Execute`, + // which is where the client should see the error. + Err(e) => { + tracing::debug!("describe probe failed, reporting no rows: {e}"); + Ok(StatementDescription::no_data()) + } + } + } + /// Execute a SQL query and return results pub async fn execute_query(&self, sql: &str) -> ProtocolResult { let sql_upper = sql.trim().to_uppercase(); diff --git a/orbit/server/src/protocols/rest/handlers.rs b/orbit/server/src/protocols/rest/handlers.rs index d1018571..15b64e53 100644 --- a/orbit/server/src/protocols/rest/handlers.rs +++ b/orbit/server/src/protocols/rest/handlers.rs @@ -24,6 +24,8 @@ pub struct ApiState { pub orbit_client: Arc, /// MCP server for natural language queries (optional) pub mcp_server: Option>, + /// SQL engine backing the `/sql` and catalogue endpoints. + pub query_engine: Option>, } /// Pagination query parameters @@ -749,61 +751,159 @@ fn parse_key_from_string(key_str: &str) -> Key { tag = "sql" )] pub async fn execute_sql_query( - State(_state): State, + State(state): State, Json(request): Json, ) -> impl IntoResponse { + match run_sql(&state, &request).await { + Ok(response) => { + tracing::info!( + query = %request.query, + execution_time_ms = response.execution_time_ms, + "SQL query executed via REST API" + ); + (StatusCode::OK, Json(SuccessResponse::new(response))).into_response() + } + Err((status, error)) => (status, Json(error)).into_response(), + } +} + +/// Execute one statement through the shared SQL engine. +/// +/// Returns the HTTP status and error body to send when the statement cannot be +/// run, so both the single and batch endpoints report failures identically. +async fn run_sql( + state: &ApiState, + request: &SqlQueryRequest, +) -> Result { + use crate::protocols::postgres_wire::QueryResult; + let start = std::time::Instant::now(); - // Validate query is not empty if request.query.trim().is_empty() { - return ( + return Err(( StatusCode::BAD_REQUEST, - Json(ErrorResponse::new( - "EMPTY_QUERY", - "SQL query cannot be empty", - )), - ) - .into_response(); + ErrorResponse::new("EMPTY_QUERY", "SQL query cannot be empty"), + )); } - // For now, return a mock response indicating the query was received - // In a full implementation, this would use the OptimizedQueryEngine - let response = SqlQueryResponse { - columns: vec![ - ColumnInfo { - name: "id".to_string(), - data_type: "integer".to_string(), - nullable: false, - }, - ColumnInfo { - name: "name".to_string(), - data_type: "varchar".to_string(), - nullable: true, - }, - ], - rows: vec![vec![serde_json::json!(1), serde_json::json!("example")]], - row_count: 1, - rows_affected: None, - execution_time_ms: start.elapsed().as_millis() as u64, - has_more: false, - query_plan: if request.explain.unwrap_or(false) { - Some(serde_json::json!({ - "plan": "Sequential Scan", - "estimated_cost": 100, - "note": "Query plan generation requires full query engine integration" - })) - } else { - None - }, + let Some(engine) = &state.query_engine else { + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + ErrorResponse::new( + "SQL_UNAVAILABLE", + "This server was started without a SQL engine, so SQL cannot be executed here.", + ), + )); }; - tracing::info!( - query = %request.query, - execution_time_ms = response.execution_time_ms, - "SQL query executed via REST API" - ); + // Parameters would have to be substituted into the statement text. Rejecting + // them is better than ignoring them and running a statement whose + // placeholders are still literal text. + if request.parameters.as_ref().is_some_and(|p| !p.is_empty()) { + return Err(( + StatusCode::BAD_REQUEST, + ErrorResponse::new( + "PARAMETERS_UNSUPPORTED", + "Query parameters are not supported over REST; inline the values or use the \ + PostgreSQL protocol, which binds parameters server-side.", + ), + )); + } + + if request.explain.unwrap_or(false) { + return Err(( + StatusCode::NOT_IMPLEMENTED, + ErrorResponse::new( + "EXPLAIN_UNSUPPORTED", + "Query plans are not available from this endpoint.", + ), + )); + } + + let timeout = std::time::Duration::from_millis(request.timeout_ms.unwrap_or(30_000)); + let executed = tokio::time::timeout(timeout, engine.execute_query(&request.query)).await; - (StatusCode::OK, Json(SuccessResponse::new(response))).into_response() + let result = match executed { + Ok(Ok(result)) => result, + Ok(Err(e)) => { + return Err(( + StatusCode::BAD_REQUEST, + ErrorResponse::new("QUERY_FAILED", e.to_string()), + )) + } + Err(_) => { + return Err(( + StatusCode::REQUEST_TIMEOUT, + ErrorResponse::new( + "QUERY_TIMEOUT", + format!("Query exceeded {} ms", timeout.as_millis()), + ), + )) + } + }; + + // `limit` trims the response; `has_more` says whether anything was dropped, + // so a truncated result is never mistaken for the whole answer. + let limit = request.limit.unwrap_or(1000); + + let response = match result { + QueryResult::Select { columns, rows } | QueryResult::Merge { columns, rows, .. } => { + let total = rows.len(); + let truncated: Vec> = rows + .into_iter() + .take(limit) + .map(|row| { + row.into_iter() + .map(|value| { + value.map_or(serde_json::Value::Null, serde_json::Value::String) + }) + .collect() + }) + .collect(); + + SqlQueryResponse { + columns: columns + .into_iter() + .map(|name| ColumnInfo { + name, + // The engine holds every value as text and reports no + // per-column type, so this says text rather than + // guessing something narrower. + data_type: "text".to_string(), + nullable: true, + }) + .collect(), + row_count: truncated.len(), + rows_affected: None, + execution_time_ms: start.elapsed().as_millis() as u64, + has_more: total > truncated.len(), + rows: truncated, + query_plan: None, + } + } + QueryResult::Insert { count } | QueryResult::Update { count } | QueryResult::Delete { count } => { + SqlQueryResponse { + columns: Vec::new(), + rows: Vec::new(), + row_count: 0, + rows_affected: Some(count as u64), + execution_time_ms: start.elapsed().as_millis() as u64, + has_more: false, + query_plan: None, + } + } + QueryResult::Set { .. } => SqlQueryResponse { + columns: Vec::new(), + rows: Vec::new(), + row_count: 0, + rows_affected: None, + execution_time_ms: start.elapsed().as_millis() as u64, + has_more: false, + query_plan: None, + }, + }; + + Ok(response) } /// Execute multiple SQL queries in batch @@ -821,36 +921,39 @@ pub async fn execute_sql_query( tag = "sql" )] pub async fn execute_batch_sql( - State(_state): State, + State(state): State, Json(request): Json, ) -> impl IntoResponse { let start = std::time::Instant::now(); let mut results = Vec::new(); let mut successful = 0; - let failed = 0; - - for (index, _query_req) in request.queries.iter().enumerate() { - // Execute each query - let query_start = std::time::Instant::now(); - - // Mock execution result - let result = SqlQueryResponse { - columns: vec![], - rows: vec![], - row_count: 0, - rows_affected: Some(0), - execution_time_ms: query_start.elapsed().as_millis() as u64, - has_more: false, - query_plan: None, - }; - - results.push(BatchQueryResult { - index, - success: true, - result: Some(result), - error: None, - }); - successful += 1; + let mut failed = 0; + + for (index, query_req) in request.queries.iter().enumerate() { + // Each statement is really executed, and a failure is reported as one. + // Previously every entry was recorded as a success without anything + // having run. + match run_sql(&state, query_req).await { + Ok(result) => { + successful += 1; + results.push(BatchQueryResult { + index, + success: true, + result: Some(result), + error: None, + }); + } + Err((_, error)) => { + failed += 1; + let message = error.error.message.clone(); + results.push(BatchQueryResult { + index, + success: false, + result: None, + error: Some(message), + }); + } + } } let response = BatchSqlQueryResponse { diff --git a/orbit/server/src/protocols/rest/server.rs b/orbit/server/src/protocols/rest/server.rs index ce92604e..e7c37aa6 100644 --- a/orbit/server/src/protocols/rest/server.rs +++ b/orbit/server/src/protocols/rest/server.rs @@ -53,6 +53,11 @@ pub struct RestApiServer { ws_handler: Arc, /// Optional MCP server for natural language queries mcp_server: Option>, + /// SQL engine backing `/sql`, `/tables` and `/schemas`. + /// + /// Without one those endpoints report that SQL is unavailable rather than + /// answering with example data. + query_engine: Option>, } impl RestApiServer { @@ -63,9 +68,23 @@ impl RestApiServer { orbit_client: Arc::new(orbit_client), ws_handler: Arc::new(WebSocketHandler::new()), mcp_server: None, + query_engine: None, } } + /// Attach the SQL engine that `/sql` and the catalogue endpoints will use. + /// + /// Share the engine's storage with the other protocols so a table created + /// over PostgreSQL is visible here. + #[must_use] + pub fn with_query_engine( + mut self, + query_engine: Arc, + ) -> Self { + self.query_engine = Some(query_engine); + self + } + /// Create a new REST API server with MCP support pub fn with_mcp( orbit_client: OrbitClient, @@ -77,6 +96,7 @@ impl RestApiServer { orbit_client: Arc::new(orbit_client), ws_handler: Arc::new(WebSocketHandler::new()), mcp_server: Some(mcp_server), + query_engine: None, } } @@ -90,6 +110,7 @@ impl RestApiServer { let state = ApiState { orbit_client: self.orbit_client.clone(), mcp_server: self.mcp_server.clone(), + query_engine: self.query_engine.clone(), }; // API v1 routes From 3169793aefc65d4bc539670886759ade94639db7 Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Thu, 6 Aug 2026 01:21:50 -0700 Subject: [PATCH 31/36] Add TLS support and REST catalogue integration Enable TLS across desktop and server: install a rustls crypto provider, add ssl_mode parsing, tokio-postgres-rustls integration, rediss support and an accept-any-cert verifier for `require`. Fix PostgreSQL listener to negotiate SSLRequest correctly and preserve startup bytes. Wire REST SQL handlers to the same QueryEngine/storage as Postgres and replace canned catalogue/stats responses with real listings, uptime and measured fields. Fix SQL parser uppercasing bug (preserve string literals). Add Makefile desktop targets, config defaults/serde fixes and a shipped-config test. Update Cargo files for TLS deps. --- Makefile | 36 +- config/orbit-server.toml | 3 +- orbit/desktop/src-tauri/Cargo.lock | 244 +++++++++-- orbit/desktop/src-tauri/Cargo.toml | 6 +- orbit/desktop/src-tauri/src/connections.rs | 404 ++++++++++++++++-- orbit/desktop/src-tauri/src/main.rs | 3 + orbit/server/src/config.rs | 37 ++ orbit/server/src/main.rs | 33 +- orbit/server/src/protocols/postgres_server.rs | 88 +++- .../src/protocols/postgres_wire/protocol.rs | 40 +- .../protocols/postgres_wire/query_engine.rs | 110 ++++- orbit/server/src/protocols/rest/handlers.rs | 336 +++++++++------ orbit/server/src/protocols/rest/server.rs | 1 + specifications/PRD.md | 39 +- 14 files changed, 1177 insertions(+), 203 deletions(-) diff --git a/Makefile b/Makefile index dbac763f..48104a08 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ pre-commit-full pre-commit-light all \ dev run cluster cluster-stop cluster-status cluster-lb cluster-lb-stop \ test-ignored test-include-ignored test-quick test-server test-verbose \ - redis + redis desktop-check desktop-test desktop-build help: @echo "🚀 Orbit-RS Development Tasks" @@ -51,13 +51,43 @@ format: cargo fmt --all @echo "✅ Code formatting complete" -check: +check: desktop-check @echo "🔍 Running cargo check and clippy..." cargo check --workspace cargo clippy --all-targets -- -D warnings -A clippy::unnecessary-sort-by -A clippy::collapsible-match -A clippy::useless-conversion -A clippy::unnecessary-unwrap -A clippy::manual-checked-ops -A clippy::explicit-counter-loop @echo "✅ Code checks complete" -test: +# orbit/desktop declares its own [workspace], so the root cargo commands above +# do not reach it. Without these targets it was possible — and did happen — for +# the desktop app to accumulate compile errors while `make check` stayed green. +DESKTOP_MANIFEST := orbit/desktop/src-tauri/Cargo.toml + +desktop-check: + @echo "🔍 Checking orbit-desktop (separate workspace)..." + cargo clippy --manifest-path $(DESKTOP_MANIFEST) --all-targets -- -D warnings + @if [ -d orbit/desktop/node_modules ]; then \ + cd orbit/desktop && npm run typecheck; \ + else \ + echo "⚠️ orbit/desktop/node_modules missing - run 'cd orbit/desktop && npm install' to typecheck the UI"; \ + fi + @echo "✅ orbit-desktop checks complete" + +desktop-test: + @echo "🧪 Testing orbit-desktop..." + cargo test --manifest-path $(DESKTOP_MANIFEST) + @if [ -d orbit/desktop/node_modules ]; then \ + cd orbit/desktop && npm test; \ + else \ + echo "⚠️ orbit/desktop/node_modules missing - skipping UI tests"; \ + fi + @echo "✅ orbit-desktop tests complete" + +desktop-build: + @echo "🏗️ Building orbit-desktop..." + cd orbit/desktop && npm install && npm run build + @echo "✅ orbit-desktop build complete" + +test: desktop-test @echo "🧪 Running tests..." cargo test --workspace --verbose @echo "✅ Tests complete" diff --git a/config/orbit-server.toml b/config/orbit-server.toml index 2914629e..533dcfb8 100644 --- a/config/orbit-server.toml +++ b/config/orbit-server.toml @@ -333,7 +333,8 @@ idle_timeout_secs = 300 max_lifetime_secs = 3600 health_check_interval_secs = 60 load_balancing_strategy = "RoundRobin" -tier = "Standard" +# Pool tier: "Client", "Application", or "Database" (see PoolTier). +tier = "Application" enable_dynamic_sizing = true target_utilization = 0.75 diff --git a/orbit/desktop/src-tauri/Cargo.lock b/orbit/desktop/src-tauri/Cargo.lock index 0a954196..e8cccfc0 100644 --- a/orbit/desktop/src-tauri/Cargo.lock +++ b/orbit/desktop/src-tauri/Cargo.lock @@ -278,6 +278,29 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "backon" version = "1.6.0" @@ -305,6 +328,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bigdecimal" version = "0.4.9" @@ -324,7 +353,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools", @@ -344,9 +373,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bitvec" @@ -676,7 +705,7 @@ dependencies = [ "bitflags 1.3.2", "block", "cocoa-foundation", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "foreign-types", "libc", @@ -691,7 +720,7 @@ checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" dependencies = [ "bitflags 1.3.2", "block", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "libc", "objc", @@ -726,6 +755,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.4.0" @@ -742,6 +777,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -755,7 +800,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -768,7 +813,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -982,6 +1027,29 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "deranged" version = "0.5.4" @@ -1049,7 +1117,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "objc2", ] @@ -1239,6 +1307,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.4" @@ -1351,6 +1425,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "funty" version = "2.0.0" @@ -2449,7 +2529,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "libc", "redox_syscall", ] @@ -2703,7 +2783,7 @@ dependencies = [ "base64 0.21.7", "bigdecimal", "bindgen", - "bitflags 2.9.4", + "bitflags 2.13.1", "bitvec", "btoi", "byteorder", @@ -2743,10 +2823,10 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -2803,7 +2883,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -2940,7 +3020,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "dispatch2", "objc2", ] @@ -2957,7 +3037,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -3010,7 +3090,7 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg-if", "foreign-types", "libc", @@ -3036,6 +3116,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openssl-sys" version = "0.9.109" @@ -3062,6 +3148,8 @@ dependencies = [ "redis", "reqwest 0.12.28", "rust_decimal", + "rustls", + "rustls-native-certs", "serde", "serde_json", "sqlparser", @@ -3070,9 +3158,11 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-postgres", + "tokio-postgres-rustls", "tracing", "tracing-subscriber", "uuid", + "webpki-roots", ] [[package]] @@ -3838,12 +3928,16 @@ dependencies = [ "num-bigint", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-native-certs", "ryu", "sha1_smol", "socket2 0.6.1", "tokio", + "tokio-rustls", "tokio-util", "url", + "webpki-roots", ] [[package]] @@ -3852,7 +3946,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", ] [[package]] @@ -4109,7 +4203,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -4122,6 +4216,8 @@ version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -4130,6 +4226,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -4155,6 +4263,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4244,8 +4353,21 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.4", - "core-foundation", + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4253,9 +4375,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -4566,6 +4688,16 @@ dependencies = [ "system-deps 5.0.0", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlparser" version = "0.39.0" @@ -4709,7 +4841,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4759,7 +4891,7 @@ dependencies = [ "cairo-rs", "cc", "cocoa", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "crossbeam-channel", "dispatch", @@ -5183,6 +5315,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "tokio" version = "1.50.0" @@ -5247,6 +5400,21 @@ dependencies = [ "whoami", ] +[[package]] +name = "tokio-postgres-rustls" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27d684bad428a0f2481f42241f821db42c54e2dc81d8c00db8536c506b0a0144" +dependencies = [ + "const-oid", + "ring", + "rustls", + "tokio", + "tokio-postgres", + "tokio-rustls", + "x509-cert", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5396,7 +5564,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "bytes", "futures-util", "http 1.5.0", @@ -6637,6 +6805,18 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "spki", + "tls_codec", +] + [[package]] name = "xattr" version = "1.6.1" @@ -6777,6 +6957,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] [[package]] name = "zerotrie" diff --git a/orbit/desktop/src-tauri/Cargo.toml b/orbit/desktop/src-tauri/Cargo.toml index 13e403e5..bbdc9689 100644 --- a/orbit/desktop/src-tauri/Cargo.toml +++ b/orbit/desktop/src-tauri/Cargo.toml @@ -24,7 +24,7 @@ chrono = { version = "0.4", features = ["serde"] } # Database connections tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-serde_json-1", "with-uuid-1"] } -redis = { version = "0.32", features = ["tokio-comp", "connection-manager"] } +redis = { version = "0.32", features = ["tokio-comp", "connection-manager", "tokio-rustls-comp", "tls-rustls-insecure", "tls-rustls-webpki-roots"] } mysql_async = { version = "0.34", features = ["chrono", "native-tls"] } sqlparser = "0.39" # NUMERIC/DECIMAL decoding for PostgreSQL result sets @@ -45,6 +45,10 @@ thiserror = "1.0" aes-gcm = "0.10" base64 = "0.22" rand = "0.8" +tokio-postgres-rustls = "0.13" +rustls = "0.23" +rustls-native-certs = "0.8" +webpki-roots = "1" [features] # this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled. diff --git a/orbit/desktop/src-tauri/src/connections.rs b/orbit/desktop/src-tauri/src/connections.rs index f6071159..06d784fe 100644 --- a/orbit/desktop/src-tauri/src/connections.rs +++ b/orbit/desktop/src-tauri/src/connections.rs @@ -161,29 +161,173 @@ impl ConnectionInfo { format!("{}:{}", self.host, self.port) } - /// Reject a TLS request we cannot honour instead of silently connecting in - /// the clear. + /// How this connection should treat transport encryption. /// /// # Errors - /// Returns [`ConnectionError::InvalidConfiguration`] when `ssl_mode` asks - /// for encryption, which this build does not implement. - fn ensure_ssl_mode_supported(&self) -> Result<(), ConnectionError> { + /// Returns [`ConnectionError::InvalidConfiguration`] for an `ssl_mode` that + /// is not recognised, rather than guessing and possibly connecting in the + /// clear when the user asked for encryption. + fn ssl_mode(&self) -> Result { let mode = match self.ssl_mode.as_deref().map(str::trim) { - None | Some("") => return Ok(()), + None | Some("") => return Ok(SslMode::Disable), Some(mode) => mode, }; - if matches!( - mode.to_ascii_lowercase().as_str(), - "disable" | "disabled" | "off" | "none" | "prefer" | "allow" - ) { - return Ok(()); + match mode.to_ascii_lowercase().as_str() { + "disable" | "disabled" | "off" | "none" => Ok(SslMode::Disable), + // `prefer` is deliberately treated as `require`. PostgreSQL's own + // `prefer` silently falls back to plaintext, which means a + // misconfigured server downgrades the connection without anyone + // noticing; a client that has TLS available should use it. + "prefer" | "allow" | "require" => Ok(SslMode::Require), + "verify-ca" | "verify_ca" | "verify-full" | "verify_full" => Ok(SslMode::VerifyFull), + other => Err(ConnectionError::InvalidConfiguration(format!( + "unknown ssl_mode '{other}'; expected one of disable, prefer, require, \ + verify-ca, verify-full" + ))), } + } +} - Err(ConnectionError::InvalidConfiguration(format!( - "ssl_mode '{mode}' requests an encrypted connection, which this build does not \ - support. Refusing rather than connecting in plaintext." - ))) +/// What transport security to use for a connection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SslMode { + /// Plain TCP. + Disable, + /// TLS, without checking the server's certificate chain or hostname. + /// + /// This encrypts the connection but does not authenticate the peer, so it + /// stops passive eavesdropping and not an active attacker. It is what + /// PostgreSQL's own `require` means. + Require, + /// TLS with full chain and hostname verification against the system roots. + VerifyFull, +} + +impl SslMode { + fn is_encrypted(self) -> bool { + !matches!(self, SslMode::Disable) + } +} + +/// Certificate verifier that accepts any chain. +/// +/// Used only for [`SslMode::Require`], where the user asked for encryption +/// without authentication — typically a development server with a self-signed +/// certificate. Never reachable from `verify-ca`/`verify-full`. +#[derive(Debug)] +struct AcceptAnyCertificate(Arc); + +impl rustls::client::danger::ServerCertVerifier for AcceptAnyCertificate { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &self.0.signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &self.0.signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + self.0.signature_verification_algorithms.supported_schemes() + } +} + +/// Install the process-wide rustls crypto provider. +/// +/// The `redis` crate builds its TLS configuration internally and relies on the +/// process default, which rustls only picks automatically when exactly one +/// provider feature is enabled across the whole dependency graph. More than one +/// is, so without this every `rediss://` connection panics inside rustls. +/// +/// Idempotent: a second call, or one after another component has installed a +/// provider, is a no-op. +pub fn install_crypto_provider() { + if rustls::crypto::ring::default_provider() + .install_default() + .is_err() + { + tracing::debug!("rustls crypto provider was already installed"); + } +} + +/// Build the rustls configuration for `mode`. +fn tls_config(mode: SslMode) -> Result { + // The PostgreSQL path names its provider explicitly, but installing the + // process default here too keeps the two paths consistent. + install_crypto_provider(); + let provider = rustls::crypto::ring::default_provider(); + + match mode { + SslMode::Disable => Err(ConnectionError::InvalidConfiguration( + "TLS configuration requested for a plaintext connection".to_string(), + )), + SslMode::VerifyFull => { + let mut roots = rustls::RootCertStore::empty(); + let native = rustls_native_certs::load_native_certs(); + for certificate in native.certs { + // A single unparseable system certificate should not disable + // verification; the rest of the store still applies. + let _ = roots.add(certificate); + } + if roots.is_empty() { + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + } + if roots.is_empty() { + return Err(ConnectionError::InvalidConfiguration( + "no trusted root certificates are available, so the server's certificate \ + cannot be verified" + .to_string(), + )); + } + + rustls::ClientConfig::builder_with_provider(Arc::new(provider)) + .with_safe_default_protocol_versions() + .map_err(|e| ConnectionError::InvalidConfiguration(e.to_string())) + .map(|builder| builder.with_root_certificates(roots).with_no_client_auth()) + } + SslMode::Require => { + let provider = Arc::new(provider); + rustls::ClientConfig::builder_with_provider(Arc::clone(&provider)) + .with_safe_default_protocol_versions() + .map_err(|e| ConnectionError::InvalidConfiguration(e.to_string())) + .map(|builder| { + builder + .dangerous() + .with_custom_certificate_verifier(Arc::new(AcceptAnyCertificate(provider))) + .with_no_client_auth() + }) + } } } @@ -450,7 +594,8 @@ impl ConnectionManager { async fn open_session( info: &ConnectionInfo, ) -> Result, ConnectionError> { - info.ensure_ssl_mode_supported()?; + // Validate up front so an unusable ssl_mode fails before any socket work. + info.ssl_mode()?; match info.connection_type { ConnectionType::PostgreSQL => Ok(Box::new(PostgresSession::connect(info).await?)), @@ -523,10 +668,30 @@ impl PostgresSession { config.dbname(database); } - let (client, connection) = config - .connect(tokio_postgres::NoTls) - .await - .map_err(|e| ConnectionError::ConnectionFailed(describe(&e)))?; + let mode = info.ssl_mode()?; + + // `SslMode::Require` is passed to the driver as `Require` too, so a + // server that refuses TLS fails the connection instead of quietly + // continuing in plaintext. + let (client, connection) = if mode.is_encrypted() { + config.ssl_mode(tokio_postgres::config::SslMode::Require); + let tls = tokio_postgres_rustls::MakeRustlsConnect::new(tls_config(mode)?); + let (client, connection) = config + .connect(tls) + .await + .map_err(|e| ConnectionError::ConnectionFailed(describe(&e)))?; + tokio::spawn(async move { + if let Err(e) = connection.await { + tracing::debug!("PostgreSQL TLS connection closed: {e}"); + } + }); + return Ok(client); + } else { + config + .connect(tokio_postgres::NoTls) + .await + .map_err(|e| ConnectionError::ConnectionFailed(describe(&e)))? + }; // The connection future drives the socket; it ends when the client is // dropped, which is what closes the session. @@ -890,11 +1055,27 @@ pub struct RedisSession { impl RedisSession { async fn connect(info: &ConnectionInfo) -> Result { - let mut url = format!("redis://{}:{}", info.host, info.port); + // The redis crate builds its own TLS config from the process-wide + // provider, so it has to be installed before the client is opened. + if info.ssl_mode()?.is_encrypted() { + install_crypto_provider(); + } + + // Redis encrypts implicitly: the scheme decides, there is no in-band + // negotiation. `#insecure` skips certificate verification, matching what + // `require` means for PostgreSQL — encrypted but unauthenticated. + let mut url = match info.ssl_mode()? { + SslMode::Disable => format!("redis://{}:{}", info.host, info.port), + SslMode::Require => format!("rediss://{}:{}", info.host, info.port), + SslMode::VerifyFull => format!("rediss://{}:{}", info.host, info.port), + }; if let Some(database) = info.database.as_deref().filter(|d| !d.is_empty()) { url.push('/'); url.push_str(database); } + if info.ssl_mode()? == SslMode::Require { + url.push_str("#insecure"); + } let client = redis::Client::open(url) .map_err(|e| ConnectionError::InvalidConfiguration(e.to_string()))?; @@ -1346,19 +1527,53 @@ mod tests { } #[test] - fn plaintext_ssl_modes_are_accepted() { - for mode in [None, Some(""), Some("disable"), Some("prefer"), Some("allow")] { - assert!(info_with_ssl(mode).ensure_ssl_mode_supported().is_ok()); + fn absent_or_disabled_ssl_means_plaintext() { + for mode in [None, Some(""), Some("disable"), Some("off"), Some("none")] { + assert_eq!( + info_with_ssl(mode).ssl_mode().expect("recognised mode"), + SslMode::Disable + ); } } + /// `prefer` is mapped to `require` on purpose: PostgreSQL's own `prefer` + /// falls back to plaintext without telling anyone. #[test] - fn encrypting_ssl_modes_are_refused_rather_than_downgraded() { - for mode in ["require", "verify-ca", "verify-full"] { - assert!(info_with_ssl(Some(mode)).ensure_ssl_mode_supported().is_err()); + fn prefer_and_require_both_encrypt() { + for mode in ["prefer", "allow", "require"] { + assert_eq!( + info_with_ssl(Some(mode)).ssl_mode().expect("recognised"), + SslMode::Require + ); } } + #[test] + fn verify_modes_request_full_verification() { + for mode in ["verify-ca", "verify-full", "verify_full"] { + assert_eq!( + info_with_ssl(Some(mode)).ssl_mode().expect("recognised"), + SslMode::VerifyFull + ); + } + } + + /// An unrecognised value must not silently become plaintext. + #[test] + fn an_unknown_ssl_mode_is_rejected() { + assert!(info_with_ssl(Some("sort-of")).ssl_mode().is_err()); + } + + #[test] + fn a_tls_config_can_be_built_for_each_encrypted_mode() { + assert!(tls_config(SslMode::Require).is_ok()); + assert!(tls_config(SslMode::VerifyFull).is_ok()); + assert!( + tls_config(SslMode::Disable).is_err(), + "asking for TLS config on a plaintext connection is a caller error" + ); + } + #[test] fn connect_timeout_falls_back_to_the_default_when_unset() { assert_eq!( @@ -1564,10 +1779,69 @@ mod live_tests { let _ = session.execute(&format!("DROP TABLE IF EXISTS {table}")).await; } + /// A TLS-enabled server must be reachable with `ssl_mode = require`. + /// + /// Run against a server started with `[server.tls] enabled = true`. + #[tokio::test] + #[ignore = "requires a TLS-enabled orbit-server on 5432; set ORBIT_TEST_TLS_SERVER=1"] + async fn a_tls_session_runs_statements() { + // Paired with the downgrade test below: the two need opposite server + // configurations, so each is gated rather than one silently failing. + if std::env::var("ORBIT_TEST_TLS_SERVER").is_err() { + eprintln!("skipped: set ORBIT_TEST_TLS_SERVER=1 with a TLS-enabled server"); + return; + } + + let mut encrypted = info(ConnectionType::PostgreSQL, 5432); + encrypted.ssl_mode = Some("require".to_string()); + + let mut session = PostgresSession::connect(&encrypted) + .await + .expect("the server should accept a TLS connection"); + + let payload = session + .execute("SELECT 1 AS one") + .await + .expect("statement should run over TLS"); + assert_eq!(payload.columns.len(), 1); + } + + /// Asking for encryption against a server that does not offer it must fail. + /// + /// This is the property that matters: a silent downgrade would leave + /// credentials on the wire in the clear while the UI reported success. Run + /// against a server started *without* TLS. + #[tokio::test] + #[ignore = "requires a plaintext orbit-server on 5432; set ORBIT_TEST_PLAINTEXT_SERVER=1"] + async fn requiring_tls_against_a_plaintext_server_fails_rather_than_downgrading() { + // Only meaningful against a server that does *not* offer TLS. Gated on + // an explicit variable so that running it against a TLS server is a + // visible skip rather than a pass that proved nothing. + if std::env::var("ORBIT_TEST_PLAINTEXT_SERVER").is_err() { + eprintln!("skipped: set ORBIT_TEST_PLAINTEXT_SERVER=1 with a non-TLS server"); + return; + } + + let mut encrypted = info(ConnectionType::PostgreSQL, 5432); + encrypted.ssl_mode = Some("require".to_string()); + + assert!( + PostgresSession::connect(&encrypted).await.is_err(), + "a plaintext server must not satisfy ssl_mode=require" + ); + } + #[tokio::test] #[ignore = "requires a running orbit-server on 6379"] async fn redis_session_runs_commands_and_decodes_replies() { - let mut session = RedisSession::connect(&info(ConnectionType::Redis, 6379)) + // Redis encrypts implicitly, so the client has to be told which the + // server is doing. ORBIT_TEST_REDIS_TLS=1 selects the encrypted form. + let mut connection = info(ConnectionType::Redis, 6379); + if std::env::var("ORBIT_TEST_REDIS_TLS").is_ok() { + connection.ssl_mode = Some("require".to_string()); + } + + let mut session = RedisSession::connect(&connection) .await .expect("orbit-server should accept a Redis connection"); @@ -1619,3 +1893,77 @@ mod live_tests { assert_eq!(listed[0].status, ConnectionStatus::Connected); } } + +/// Cross-protocol tests: the same data through two different front doors. +#[cfg(test)] +mod cross_protocol_tests { + use super::*; + + /// A table written over REST must be readable over the PostgreSQL wire. + /// + /// The REST endpoint and the PostgreSQL listener run separate `QueryEngine` + /// instances; only sharing one storage handle makes them the same database. + /// They were once wired to different handles, so `CREATE TABLE` over HTTP + /// produced a table `psql` could not see — two databases behind one name. + #[tokio::test] + #[ignore = "requires a running orbit-server on 5432 and 8080"] + async fn a_table_written_over_rest_is_readable_over_postgres() { + let table = "cross_protocol_check"; + let client = reqwest::Client::new(); + + let run = |sql: String| { + let client = client.clone(); + async move { + client + .post("http://127.0.0.1:8080/api/v1/sql") + .json(&serde_json::json!({ "query": sql })) + .send() + .await + .expect("REST endpoint should answer") + } + }; + + run(format!("DROP TABLE IF EXISTS {table}")).await; + let created = run(format!("CREATE TABLE {table} (id INTEGER, note TEXT)")).await; + assert!(created.status().is_success(), "CREATE over REST should work"); + let inserted = run(format!( + "INSERT INTO {table} (id, note) VALUES (7, 'written over REST')" + )) + .await; + assert!(inserted.status().is_success(), "INSERT over REST should work"); + + let info = ConnectionInfo { + name: "cross-protocol".to_string(), + connection_type: ConnectionType::PostgreSQL, + host: "127.0.0.1".to_string(), + port: 5432, + database: None, + username: Some("orbit".to_string()), + password: Some("orbit".to_string()), + ssl_mode: None, + connection_timeout: Some(5_000), + additional_params: HashMap::new(), + }; + + let mut session = PostgresSession::connect(&info) + .await + .expect("PostgreSQL connect"); + let payload = session + .execute(&format!("SELECT note FROM {table}")) + .await + .expect("the REST-written table must be visible here"); + + assert_eq!( + payload.rows.len(), + 1, + "expected the single row written over REST" + ); + assert_eq!( + payload.rows[0].get("NOTE"), + Some(&serde_json::json!("written over REST")), + "the value must survive the trip between protocols unchanged" + ); + + run(format!("DROP TABLE IF EXISTS {table}")).await; + } +} diff --git a/orbit/desktop/src-tauri/src/main.rs b/orbit/desktop/src-tauri/src/main.rs index 9829c3e5..139269c5 100644 --- a/orbit/desktop/src-tauri/src/main.rs +++ b/orbit/desktop/src-tauri/src/main.rs @@ -533,6 +533,9 @@ fn main() { tracing::info!("starting Orbit Desktop"); + // Selects the rustls provider once, before any TLS connection is opened. + connections::install_crypto_provider(); + let context = tauri::generate_context!(); let storage = match StorageManager::new(context.config()) { diff --git a/orbit/server/src/config.rs b/orbit/server/src/config.rs index d92111f5..ca286616 100644 --- a/orbit/server/src/config.rs +++ b/orbit/server/src/config.rs @@ -170,6 +170,11 @@ pub struct PoolingConfig { pub circuit_breaker: PoolCircuitBreakerConfig, /// Per-protocol pooling overrides + /// + /// Absent means "no overrides", which is what the Default impl already + /// produces. Requiring the key in TOML made `config/orbit-server.toml` — the + /// file the docs tell users to start from — fail to load. + #[serde(default)] pub protocol_overrides: HashMap, } @@ -734,6 +739,7 @@ pub struct SecurityConfig { pub authorization: AuthorizationConfig, /// Encryption configuration + #[serde(default)] pub encryption: EncryptionConfig, } @@ -803,9 +809,11 @@ pub struct AuthorizationConfig { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct EncryptionConfig { /// Encryption at rest + #[serde(default)] pub at_rest: Option, /// Encryption in transit + #[serde(default)] pub in_transit: Option, } @@ -2651,3 +2659,32 @@ mod tests { assert!(deserialized.validate().is_ok()); } } + +#[cfg(test)] +mod shipped_config_tests { + use super::*; + + /// The configuration file the documentation tells users to start from must + /// actually load. + /// + /// It had drifted from this schema in two ways — a `tier` value that is not + /// a `PoolTier` variant, and a required `protocol_overrides` key that the + /// file never set — so `orbit-server --config ./config/orbit-server.toml`, + /// the documented invocation, failed before opening a port. Nothing checked + /// the two against each other; this does. + #[tokio::test] + async fn the_shipped_configuration_file_parses() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../config/orbit-server.toml"); + + assert!( + path.exists(), + "expected the shipped config at {}", + path.display() + ); + + if let Err(e) = OrbitServerConfig::load_from_file(&path).await { + panic!("config/orbit-server.toml does not load: {e}"); + } + } +} diff --git a/orbit/server/src/main.rs b/orbit/server/src/main.rs index ed9f5090..7bb19042 100644 --- a/orbit/server/src/main.rs +++ b/orbit/server/src/main.rs @@ -224,6 +224,20 @@ struct Args { async fn main() -> Result<(), Box> { let args = Args::parse(); + // rustls 0.23 requires a process-level crypto provider, and only picks one + // automatically when exactly one provider feature is enabled anywhere in + // the dependency graph. Something in the tree pulls in a second, so the + // choice is made explicitly here. Without this, every TLS listener panics + // on its first use — enabling `[server.tls]` took the whole server down at + // startup. + if rustls::crypto::ring::default_provider() + .install_default() + .is_err() + { + // Already installed by another initialiser; that is fine. + tracing::debug!("rustls crypto provider was already installed"); + } + // Initialize logging let log_level = if args.dev_mode { "debug,orbit_server=trace,orbit_shared=debug,orbit_proto=debug,orbit_protocols=debug" @@ -794,7 +808,24 @@ async fn main() -> Result<(), Box> { }; let rest_orbit_client = orbit_client::OrbitClient::new_offline(rest_client_config).await?; - let rest_server = RestApiServer::new(rest_orbit_client, rest_config); + // The REST SQL endpoint must read and write the *same* store as the + // PostgreSQL protocol, so this mirrors exactly the choice made for the + // PostgreSQL server below. Handing REST its own RocksDB handle while + // PostgreSQL used unified storage produced two databases behind one name: + // a table created over HTTP was invisible to psql. + let rest_sql_storage: Arc = + match &storage_mode { + StorageMode::Unified { + postgres_unified, .. + } => postgres_unified.clone(), + StorageMode::Isolated { .. } => rocksdb_storage.clone(), + }; + let rest_query_engine = Arc::new(QueryEngine::new_with_persistent_storage(rest_sql_storage)); + + orbit_server::protocols::rest::handlers::mark_process_start(); + + let rest_server = + RestApiServer::new(rest_orbit_client, rest_config).with_query_engine(rest_query_engine); let rest_handle = tokio::spawn(async move { rest_server .run() diff --git a/orbit/server/src/protocols/postgres_server.rs b/orbit/server/src/protocols/postgres_server.rs index 2d8708f3..48ed05c1 100644 --- a/orbit/server/src/protocols/postgres_server.rs +++ b/orbit/server/src/protocols/postgres_server.rs @@ -1,7 +1,7 @@ //! PostgreSQL TCP server use std::sync::Arc; -use tokio::net::TcpListener; +use tokio::net::{TcpListener, TcpStream}; use tracing::{error, info}; use crate::protocols::error::ProtocolResult; @@ -69,15 +69,21 @@ impl PostgresServer { PostgresWireProtocol::new() }; - // Wrap stream with TLS if enabled - match tls_acceptor.accept(stream).await { - Ok(stream) => { - if let Err(e) = protocol.handle_connection(stream).await { + // PostgreSQL negotiates TLS explicitly: the client sends + // an SSLRequest in the clear and the server answers + // before any handshake. Handing the raw socket straight + // to the TLS acceptor — as this used to — never matches + // what a conforming client sends. + match negotiate_tls(stream, &tls_acceptor).await { + Ok((stream, prefix)) => { + if let Err(e) = + protocol.handle_connection_with_buffer(stream, prefix).await + { error!("Connection error: {}", e); } } Err(e) => { - error!("TLS handshake error: {}", e); + error!("TLS negotiation error: {}", e); } } }); @@ -95,3 +101,73 @@ impl Default for PostgresServer { Self::new("127.0.0.1:5432") } } + +/// Request codes a client may send before the startup message. +/// +/// These are sent as a bare `length + code` pair with no message-type byte. +mod pre_startup { + /// `SSLRequest`: asks whether the server will speak TLS. + pub const SSL_REQUEST: i32 = 80_877_103; + /// `GSSENCRequest`: asks for GSSAPI encryption, which is not supported. + pub const GSSENC_REQUEST: i32 = 80_877_104; +} + +/// Answer any pre-startup requests, upgrading to TLS if one is asked for and +/// available. +/// +/// Returns the stream to speak the rest of the protocol over, plus any bytes +/// already read that belong to the startup message and must not be lost. +async fn negotiate_tls( + mut stream: TcpStream, + tls_acceptor: &OrbitTlsAcceptor, +) -> std::io::Result<(crate::protocols::tls::TlsStreamOrPlain, bytes::BytesMut)> { + use bytes::{Buf, BytesMut}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let mut prefix = BytesMut::with_capacity(8); + + loop { + // Every pre-startup request is exactly 8 bytes: length, then code. + while prefix.len() < 8 { + if stream.read_buf(&mut prefix).await? == 0 { + // Client hung up before saying anything. + return Ok(( + crate::protocols::tls::TlsStreamOrPlain::Plain(stream), + prefix, + )); + } + } + + let code = (&prefix[4..8]).get_i32(); + + match code { + pre_startup::SSL_REQUEST => { + prefix.advance(8); + if tls_acceptor.is_enabled() { + stream.write_all(b"S").await?; + stream.flush().await?; + let stream = tls_acceptor.accept(stream).await?; + // The startup message arrives inside the TLS session, so + // nothing is carried over. + return Ok((stream, BytesMut::new())); + } + // No TLS configured: say so and continue in the clear. It is + // then the client's choice whether that is acceptable. + stream.write_all(b"N").await?; + stream.flush().await?; + } + pre_startup::GSSENC_REQUEST => { + prefix.advance(8); + stream.write_all(b"N").await?; + stream.flush().await?; + } + _ => { + // A startup or cancel message: hand the bytes back unread. + return Ok(( + crate::protocols::tls::TlsStreamOrPlain::Plain(stream), + prefix, + )); + } + } + } +} diff --git a/orbit/server/src/protocols/postgres_wire/protocol.rs b/orbit/server/src/protocols/postgres_wire/protocol.rs index a9f5aef6..a1a6a376 100644 --- a/orbit/server/src/protocols/postgres_wire/protocol.rs +++ b/orbit/server/src/protocols/postgres_wire/protocol.rs @@ -114,15 +114,46 @@ impl PostgresWireProtocol { } /// Handle a generic connection stream (TCP or TLS) - pub async fn handle_connection(&mut self, mut stream: S) -> ProtocolResult<()> + pub async fn handle_connection(&mut self, stream: S) -> ProtocolResult<()> + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + self.handle_connection_with_buffer(stream, BytesMut::new()) + .await + } + + /// Handle a connection whose first bytes have already been read. + /// + /// TLS negotiation happens before this point and has to read the client's + /// first 8 bytes to know what was asked for. When those turn out to belong + /// to the startup message instead, they are passed back in here so the + /// message can be parsed whole. + pub async fn handle_connection_with_buffer( + &mut self, + mut stream: S, + prefix: BytesMut, + ) -> ProtocolResult<()> where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { info!("New PostgreSQL client connection"); let mut read_buf = BytesMut::with_capacity(8192); + read_buf.extend_from_slice(&prefix); let mut write_buf = BytesMut::with_capacity(8192); + // The prefix may already hold a complete startup message. + if !read_buf.is_empty() { + match self + .process_pending_messages(&mut stream, &mut read_buf, &mut write_buf) + .await? + { + ConnectionLoopResult::Continue => {} + ConnectionLoopResult::ClientDisconnected + | ConnectionLoopResult::ClientTerminated => return Ok(()), + } + } + loop { match self .read_and_process_data(&mut stream, &mut read_buf, &mut write_buf) @@ -1108,8 +1139,13 @@ impl PostgresWireProtocol { } /// Handle SSL request + /// Answer an `SSLRequest` that reached the message loop. + /// + /// TLS is negotiated by the listener before this handler ever runs, so an + /// `SSLRequest` arriving here is a second one on an already-established + /// session. `N` is the correct answer: whatever transport the connection + /// has is already fixed. async fn handle_ssl_request(&mut self, buf: &mut BytesMut) -> ProtocolResult<()> { - // Reject SSL request - send 'N' to indicate SSL not supported buf.put_u8(b'N'); Ok(()) } diff --git a/orbit/server/src/protocols/postgres_wire/query_engine.rs b/orbit/server/src/protocols/postgres_wire/query_engine.rs index df1fcdc8..11ca37c1 100644 --- a/orbit/server/src/protocols/postgres_wire/query_engine.rs +++ b/orbit/server/src/protocols/postgres_wire/query_engine.rs @@ -371,6 +371,43 @@ impl QueryEngine { out } + /// Names of the tables this engine can see. + /// + /// `None` when no persistent storage is attached, which is different from + /// "there are no tables" and is reported as such rather than as an empty + /// catalogue. + /// + /// # Errors + /// Returns an error when the catalogue cannot be read. + pub async fn list_tables(&self) -> ProtocolResult>> { + match &self.persistent_storage { + Some(storage) => storage.list_tables().await.map(Some), + None => Ok(None), + } + } + + /// Schema of one table, or `None` if it does not exist. + /// + /// The name is normalised the same way the SQL parser normalises it, so a + /// caller passing `users` finds the table that `CREATE TABLE users` stored + /// as `USERS`. + /// + /// # Errors + /// Returns an error when the catalogue cannot be read. + pub async fn table_schema( + &self, + table: &str, + ) -> ProtocolResult> { + let Some(storage) = &self.persistent_storage else { + return Ok(None); + }; + + if let Some(schema) = storage.get_table_schema(table).await? { + return Ok(Some(schema)); + } + storage.get_table_schema(&table.to_uppercase()).await + } + /// Infer the type of each `$n` parameter from where it is used. /// /// Returns one OID per placeholder, in position order. A client that @@ -1067,22 +1104,29 @@ impl QueryEngine { } /// Parse SQL statement + /// + /// The statement is dispatched on an uppercased copy but each sub-parser + /// receives the text as written. Uppercasing the statement itself — which + /// this did, behind a variable named `original_sql` that was in fact a clone + /// of the uppercased one — rewrote string literals too, so + /// `INSERT ... VALUES ('alpha')` stored `ALPHA`. The sub-parsers already + /// match keywords case-insensitively and normalise identifiers themselves. fn parse_sql(&self, sql: &str) -> ProtocolResult { - let sql = sql.trim().to_uppercase(); - let original_sql = sql.clone(); + let original_sql = sql.trim(); + let sql = original_sql.to_uppercase(); if sql.starts_with("SELECT") { - self.parse_select(&original_sql) + self.parse_select(original_sql) } else if sql.starts_with("INSERT") { - self.parse_insert(&original_sql) + self.parse_insert(original_sql) } else if sql.starts_with("UPDATE") { - self.parse_update(&original_sql) + self.parse_update(original_sql) } else if sql.starts_with("DELETE") { - self.parse_delete(&original_sql) + self.parse_delete(original_sql) } else if sql.starts_with("CREATE TABLE") { - self.parse_create_table(&original_sql) + self.parse_create_table(original_sql) } else if sql.starts_with("DROP TABLE") { - self.parse_drop_table(&original_sql) + self.parse_drop_table(original_sql) } else { Err(ProtocolError::PostgresError(format!( "Unsupported SQL statement: {sql}" @@ -2231,3 +2275,53 @@ mod tests { } } } + +#[cfg(test)] +mod literal_case_tests { + use super::*; + use crate::protocols::postgres_wire::persistent_storage::RocksDbTableStorage; + + /// String literals must survive a round trip unchanged. + /// + /// The parser used to uppercase the whole statement before parsing, so + /// `VALUES ('alpha')` stored `ALPHA` — silent corruption of every text value + /// written through this engine. + #[tokio::test] + async fn string_literals_keep_their_case_through_insert_and_select() { + let dir = std::env::temp_dir().join(format!( + "orbit-literal-case-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + let storage = Arc::new( + RocksDbTableStorage::new(dir.to_str().expect("utf-8 temp path")) + .expect("open temporary storage"), + ); + let engine = QueryEngine::new_with_persistent_storage(storage); + + engine + .execute_query("CREATE TABLE case_check (id INTEGER, name TEXT)") + .await + .expect("create table"); + engine + .execute_query("INSERT INTO case_check (id, name) VALUES (1, 'MixedCase Value')") + .await + .expect("insert"); + + let result = engine + .execute_query("SELECT name FROM case_check") + .await + .expect("select"); + + let QueryResult::Select { rows, .. } = result else { + panic!("SELECT should return a result set"); + }; + assert_eq!( + rows[0][0].as_deref(), + Some("MixedCase Value"), + "the stored literal must come back exactly as written" + ); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/orbit/server/src/protocols/rest/handlers.rs b/orbit/server/src/protocols/rest/handlers.rs index 15b64e53..adbe3f0c 100644 --- a/orbit/server/src/protocols/rest/handlers.rs +++ b/orbit/server/src/protocols/rest/handlers.rs @@ -18,6 +18,26 @@ use utoipa::IntoParams; use super::models::*; use crate::protocols::mcp::server::McpServer; +/// When this process started, used to report a measured uptime. +/// +/// Set on first read, so the value is the age of the REST layer rather than an +/// invented constant. +static PROCESS_START: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Seconds since the process started serving. +fn uptime_seconds() -> u64 { + PROCESS_START + .get_or_init(std::time::Instant::now) + .elapsed() + .as_secs() +} + +/// Record the process start time. Call once during startup so `/stats` reports +/// uptime from boot rather than from the first request. +pub fn mark_process_start() { + let _ = PROCESS_START.set(std::time::Instant::now()); +} + /// Shared API state #[derive(Clone)] pub struct ApiState { @@ -26,6 +46,8 @@ pub struct ApiState { pub mcp_server: Option>, /// SQL engine backing the `/sql` and catalogue endpoints. pub query_engine: Option>, + /// Address this REST listener is bound to, reported as the node's address. + pub bind_address: String, } /// Pagination query parameters @@ -945,7 +967,7 @@ pub async fn execute_batch_sql( } Err((_, error)) => { failed += 1; - let message = error.error.message.clone(); + let message = error.message.clone(); results.push(BatchQueryResult { index, success: false, @@ -987,31 +1009,59 @@ pub async fn execute_batch_sql( tag = "sql" )] pub async fn list_tables( - State(_state): State, + State(state): State, Query(params): Query, ) -> impl IntoResponse { let page = params.page.unwrap_or(0); let page_size = params.page_size.unwrap_or(50).min(1000); - // Mock table list - in full implementation, this would query the catalog - let tables = vec![ - TableInfo { - name: "users".to_string(), - schema: "public".to_string(), - table_type: "TABLE".to_string(), - estimated_rows: Some(1000), - column_count: 5, - }, - TableInfo { - name: "orders".to_string(), + let Some(engine) = &state.query_engine else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse::new( + "CATALOGUE_UNAVAILABLE", + "This server was started without a SQL engine, so it has no table catalogue.", + )), + ) + .into_response(); + }; + + let names = match engine.list_tables().await { + Ok(Some(names)) => names, + Ok(None) => Vec::new(), + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("CATALOGUE_ERROR", e.to_string())), + ) + .into_response() + } + }; + + let total = names.len(); + + // Only the requested page is described, so listing a large catalogue does + // not read every schema. + let mut tables = Vec::new(); + for name in names.into_iter().skip(page * page_size).take(page_size) { + let column_count = engine + .table_schema(&name) + .await + .ok() + .flatten() + .map_or(0, |schema| schema.columns.len()); + + tables.push(TableInfo { + name, schema: "public".to_string(), table_type: "TABLE".to_string(), - estimated_rows: Some(5000), - column_count: 8, - }, - ]; + // Row counts are not tracked, and a number here would be read as + // one that was measured. + estimated_rows: None, + column_count, + }); + } - let total = tables.len(); let response = PagedResponse::new(tables, total, page, page_size); tracing::debug!( @@ -1020,7 +1070,7 @@ pub async fn list_tables( "Tables listed via REST API" ); - (StatusCode::OK, Json(SuccessResponse::new(response))) + (StatusCode::OK, Json(SuccessResponse::new(response))).into_response() } /// Get database statistics @@ -1036,18 +1086,29 @@ pub async fn list_tables( tag = "system" )] pub async fn get_database_stats(State(state): State) -> impl IntoResponse { - // Get client stats if available let client_stats = state.orbit_client.stats().await.ok(); + let table_count = match &state.query_engine { + Some(engine) => engine + .list_tables() + .await + .ok() + .flatten() + .map_or(0, |tables| tables.len()), + None => 0, + }; + let stats = DatabaseStats { - table_count: 10, // Mock value - index_count: 15, // Mock value - size_bytes: Some(1024 * 1024 * 100), // 100 MB mock + table_count, + // Secondary indexes are not implemented, so this is a real zero. + index_count: 0, + // On-disk size is not tracked; a number here would be invented. + size_bytes: None, active_connections: client_stats .as_ref() .map(|s| s.server_connections) - .unwrap_or(1), - uptime_seconds: 3600, // Mock 1 hour + .unwrap_or(0), + uptime_seconds: uptime_seconds(), version: env!("CARGO_PKG_VERSION").to_string(), }; @@ -1070,28 +1131,28 @@ pub async fn get_database_stats(State(state): State) -> impl IntoRespo ), tag = "sql" )] -pub async fn list_schemas(State(_state): State) -> impl IntoResponse { - // Mock schema list - in full implementation, this would query the catalog - let schemas = vec![ - SchemaInfo { - name: "public".to_string(), - owner: "postgres".to_string(), - table_count: 10, - view_count: 2, - }, - SchemaInfo { - name: "pg_catalog".to_string(), - owner: "postgres".to_string(), - table_count: 50, - view_count: 0, - }, - SchemaInfo { - name: "information_schema".to_string(), - owner: "postgres".to_string(), - table_count: 20, - view_count: 0, - }, - ]; +pub async fn list_schemas(State(state): State) -> impl IntoResponse { + // This engine has a single flat namespace; `pg_catalog` and + // `information_schema` were listed with invented table counts but do not + // exist here. Only the one real namespace is reported, with its measured + // table count. + let table_count = match &state.query_engine { + Some(engine) => engine + .list_tables() + .await + .ok() + .flatten() + .map_or(0, |tables| tables.len()), + None => 0, + }; + + let schemas = vec![SchemaInfo { + name: "public".to_string(), + owner: "orbit".to_string(), + table_count, + // Views are not implemented, so this is a real zero. + view_count: 0, + }]; tracing::debug!("Schemas listed via REST API"); @@ -1116,55 +1177,86 @@ pub async fn list_schemas(State(_state): State) -> impl IntoResponse { tag = "sql" )] pub async fn describe_table( - State(_state): State, + State(state): State, Path((schema, table)): Path<(String, String)>, ) -> impl IntoResponse { - // Mock table description + use crate::protocols::postgres_wire::persistent_storage::ColumnType; + + let Some(engine) = &state.query_engine else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse::new( + "CATALOGUE_UNAVAILABLE", + "This server was started without a SQL engine, so it has no table catalogue.", + )), + ) + .into_response(); + }; + + let table_schema = match engine.table_schema(&table).await { + Ok(Some(schema)) => schema, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ErrorResponse::new( + "TABLE_NOT_FOUND", + format!("Table '{table}' does not exist"), + )), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("CATALOGUE_ERROR", e.to_string())), + ) + .into_response() + } + }; + + let type_name = |data_type: &ColumnType| match data_type { + ColumnType::Serial => "serial".to_string(), + ColumnType::Integer => "integer".to_string(), + ColumnType::BigInt => "bigint".to_string(), + ColumnType::Text => "text".to_string(), + ColumnType::Varchar(n) => format!("varchar({n})"), + ColumnType::Boolean => "boolean".to_string(), + ColumnType::Json => "json".to_string(), + ColumnType::Timestamp => "timestamp".to_string(), + ColumnType::Double => "double precision".to_string(), + }; + let description = TableDescription { - schema: schema.clone(), + schema, name: table.clone(), table_type: "TABLE".to_string(), - columns: vec![ - TableColumn { - name: "id".to_string(), - data_type: "integer".to_string(), - nullable: false, - default_value: Some("nextval('id_seq')".to_string()), - is_primary_key: true, - }, - TableColumn { - name: "name".to_string(), - data_type: "varchar(255)".to_string(), - nullable: true, - default_value: None, + columns: table_schema + .columns + .iter() + .map(|column| TableColumn { + name: column.name.clone(), + data_type: type_name(&column.data_type), + nullable: column.nullable, + default_value: column.default_value.as_ref().map(|v| v.to_string()), + // Primary keys are not recorded in this schema, so no column is + // claimed to be one. is_primary_key: false, - }, - TableColumn { - name: "created_at".to_string(), - data_type: "timestamp".to_string(), - nullable: false, - default_value: Some("now()".to_string()), - is_primary_key: false, - }, - ], - primary_key: Some(vec!["id".to_string()]), - indexes: vec![IndexInfo { - name: format!("{}_pkey", table), - columns: vec!["id".to_string()], - unique: true, - index_type: "btree".to_string(), - }], - estimated_rows: Some(1000), - size_bytes: Some(1024 * 100), + }) + .collect(), + primary_key: None, + // Indexes are not tracked by this storage layer. + indexes: Vec::new(), + estimated_rows: Some(table_schema.row_count.max(0) as u64), + // Not measured by this storage layer. + size_bytes: None, }; tracing::debug!( - schema = %schema, table = %table, "Table described via REST API" ); - (StatusCode::OK, Json(SuccessResponse::new(description))) + (StatusCode::OK, Json(SuccessResponse::new(description))).into_response() } // ============ Index Management Endpoints ============ @@ -1230,23 +1322,37 @@ pub async fn list_indexes( tag = "cluster" )] pub async fn list_cluster_nodes(State(state): State) -> impl IntoResponse { + let client_stats = state.orbit_client.stats().await.ok(); + let node_id = state .orbit_client .node_id() .map(|n| n.key.clone()) .unwrap_or_else(|| "local".to_string()); + // Only the node answering this request can be reported: the REST layer has + // no membership view of its peers. Every field below is either observed or + // omitted. The resource gauges were previously fixed numbers + // (cpu 45.2, memory 62.8, disk 38.5) that no one measured — they are now + // absent, which is what "not collected" should look like. let nodes = vec![ClusterNodeInfo { - node_id: node_id.clone(), - address: "127.0.0.1:50051".to_string(), + node_id, + // Where this node is actually reachable, rather than a repeat of its id. + address: state.bind_address.clone(), + // True by construction: this node is serving the request. status: "healthy".to_string(), - role: "leader".to_string(), - cpu_usage: Some(45.2), - memory_usage: Some(62.8), - disk_usage: Some(38.5), - uptime_seconds: 86400, - actor_count: 150, - connection_count: 25, + // Raft role is not exposed here; claiming "leader" would be a guess. + role: "unknown".to_string(), + cpu_usage: None, + memory_usage: None, + disk_usage: None, + uptime_seconds: uptime_seconds(), + // Actor enumeration is not available through OrbitClient. + actor_count: 0, + connection_count: client_stats + .as_ref() + .map(|s| s.server_connections) + .unwrap_or(0), }]; tracing::debug!("Cluster nodes listed via REST API"); @@ -1267,17 +1373,24 @@ pub async fn list_cluster_nodes(State(state): State) -> impl IntoRespo tag = "cluster" )] pub async fn get_cluster_status(State(state): State) -> impl IntoResponse { - let client_stats = state.orbit_client.stats().await.ok(); + let node_id = state + .orbit_client + .node_id() + .map(|n| n.key.clone()) + .unwrap_or_else(|| "local".to_string()); + // Reports this node only, for the reason given on `list_cluster_nodes`. + // The replication factor and consistency level were previously stated as 3 + // and "quorum" without either being configured or checked. let status = ClusterStatus { - cluster_id: "orbit-cluster-1".to_string(), + cluster_id: node_id, healthy: true, total_nodes: 1, healthy_nodes: 1, unhealthy_nodes: 0, - total_actors: client_stats.as_ref().map(|_| 150).unwrap_or(0), - replication_factor: 3, - consistency_level: "quorum".to_string(), + total_actors: 0, + replication_factor: 1, + consistency_level: "single-node".to_string(), }; tracing::debug!("Cluster status retrieved via REST API"); @@ -1307,32 +1420,15 @@ pub async fn get_query_history( let page = params.page.unwrap_or(0); let page_size = params.page_size.unwrap_or(50).min(1000); - // Mock query history - let history = vec![ - QueryHistoryEntry { - query_id: uuid::Uuid::new_v4().to_string(), - query: "SELECT * FROM users WHERE status = 'active'".to_string(), - execution_time_ms: 45, - rows_returned: 150, - timestamp: chrono::Utc::now().to_rfc3339(), - status: "completed".to_string(), - user: Some("admin".to_string()), - }, - QueryHistoryEntry { - query_id: uuid::Uuid::new_v4().to_string(), - query: "INSERT INTO orders (user_id, total) VALUES (1, 99.99)".to_string(), - execution_time_ms: 12, - rows_returned: 0, - timestamp: chrono::Utc::now().to_rfc3339(), - status: "completed".to_string(), - user: Some("admin".to_string()), - }, - ]; - + // Statements are not recorded server-side. This previously returned two + // invented entries, complete with timings and row counts, which read as a + // real audit trail. An empty history is the truthful answer until recording + // exists; clients that need history keep their own (the desktop app does). + let history: Vec = Vec::new(); let total = history.len(); let response = PagedResponse::new(history, total, page, page_size); - tracing::debug!("Query history retrieved via REST API"); + tracing::debug!("Query history requested via REST API (not recorded server-side)"); (StatusCode::OK, Json(SuccessResponse::new(response))) } diff --git a/orbit/server/src/protocols/rest/server.rs b/orbit/server/src/protocols/rest/server.rs index e7c37aa6..933121ce 100644 --- a/orbit/server/src/protocols/rest/server.rs +++ b/orbit/server/src/protocols/rest/server.rs @@ -111,6 +111,7 @@ impl RestApiServer { orbit_client: self.orbit_client.clone(), mcp_server: self.mcp_server.clone(), query_engine: self.query_engine.clone(), + bind_address: self.config.bind_address.clone(), }; // API v1 routes diff --git a/specifications/PRD.md b/specifications/PRD.md index 4041c6b6..798e6662 100644 --- a/specifications/PRD.md +++ b/specifications/PRD.md @@ -651,7 +651,7 @@ expected. A dead session is detected by ping and transparently reopened. | MySQL | `mysql_async` | Full: real column names and types | | Redis | `redis` (multiplexed) | Full: quoted-argument parsing, recursive RESP decoding | | CQL | TCP probe only | Reachability only — no binary-protocol client; statements are refused | -| OrbitQL, Cypher, AQL, FlightSQL, OrbitWire | HTTP | Reaches the REST API (see caveat below) | +| OrbitQL, Cypher, AQL, FlightSQL, OrbitWire | HTTP | Reaches `/api/v1/sql`, which executes against the shared SQL engine | #### Cluster Lifecycle @@ -662,17 +662,26 @@ port. It deliberately does **not** read `/api/v1/cluster/*`, whose handlers return fixed values rather than measurements. "Running" and "serving" are reported separately so a node that is up with dead listeners is visible. +#### Transport Security + +`ssl_mode` accepts `disable`, `prefer`, `require`, `verify-ca` and +`verify-full`. `prefer` is treated as `require`: PostgreSQL's own `prefer` +falls back to plaintext silently, which downgrades a connection without anyone +noticing. `require` encrypts without authenticating the peer; `verify-ca` and +`verify-full` verify against the system root store. An unrecognised value is +rejected rather than defaulted to plaintext. + +PostgreSQL negotiates TLS in-band (`SSLRequest`); Redis selects it by scheme +(`rediss://`). Both are wired to the same `ssl_mode`. + #### Known Limitations -- **REST API returns canned data.** `orbit-server`'s `/api/v1/sql`, `/tables`, - `/schemas`, `/queries/history`, `/stats` and `/cluster/*` handlers return - hardcoded example rows regardless of input. The HTTP-backed connection types - therefore verify connectivity but do not return database contents; the UI - labels these results rather than presenting them as data. -- **No TLS.** `ssl_mode` values that request encryption are rejected rather than - silently downgraded to plaintext. - **Model management is not implemented.** Listing and deleting models report that plainly instead of returning fabricated models. +- **CQL is reachability-only.** There is no CQL binary-protocol client here; + statements are refused rather than appearing to run. +- **REST SQL takes no parameters.** Inline the values, or use the PostgreSQL + protocol, which binds parameters server-side. #### Development @@ -796,6 +805,20 @@ environment variables. Credentials belong in the environment, never the file. --- +### REST SQL and Catalogue Endpoints + +`/api/v1/sql` executes through the same `QueryEngine` the PostgreSQL protocol +uses, over the same RocksDB storage, so a table created via `psql` is visible +over HTTP and vice versa. `/tables`, `/tables/{schema}/{table}` and `/schemas` +read the real catalogue; `/stats` reports measured uptime and table count. + +Endpoints report what they can observe and omit what they cannot. Fields that +were previously filled with fixed values — node CPU/memory/disk, actor counts, +replication factor, on-disk size, query history — are now absent, zero where +zero is the truth, or reported as unavailable. A number in a response is a +measurement. + + ## Storage Architecture ### Persistence Backends From ec1f24273d076c152792f1c9139bef0957290e29 Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Thu, 6 Aug 2026 01:43:47 -0700 Subject: [PATCH 32/36] Postgres: track transaction state & fold identifiers Record session transaction state (Idle/Open/Failed) and report it in ReadyForQuery; poison transaction on statement failure and recognise BEGIN/COMMIT/ROLLBACK. Add fold_identifier to normalize unquoted identifiers to lower case (preserve quoted case) and update query parsing/column normalization to use it. Add a tokio-postgres based pg_conformance integration test and required test deps (tests/Cargo.toml). Cargo.lock updated (futures crate versions/checksums bumped). These changes fix protocol mismatches between simple/extended paths and improve driver compatibility. --- Cargo.lock | 31 +- .../src/protocols/postgres_wire/protocol.rs | 70 ++ .../protocols/postgres_wire/query_engine.rs | 54 +- tests/Cargo.toml | 9 + tests/integration/pg_conformance.rs | 683 ++++++++++++++++++ 5 files changed, 815 insertions(+), 32 deletions(-) create mode 100644 tests/integration/pg_conformance.rs diff --git a/Cargo.lock b/Cargo.lock index 45b7a420..c6f7c082 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3774,9 +3774,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -3797,9 +3797,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" @@ -3825,9 +3825,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-lite" @@ -3844,9 +3844,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", @@ -3855,21 +3855,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -7559,8 +7559,10 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "bytes", "cucumber", "futures", + "futures-util", "mockall", "orbit-client", "orbit-engine", @@ -7573,6 +7575,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tokio-postgres", "tokio-test", "tracing", "tracing-subscriber", diff --git a/orbit/server/src/protocols/postgres_wire/protocol.rs b/orbit/server/src/protocols/postgres_wire/protocol.rs index a1a6a376..2822a771 100644 --- a/orbit/server/src/protocols/postgres_wire/protocol.rs +++ b/orbit/server/src/protocols/postgres_wire/protocol.rs @@ -46,6 +46,25 @@ pub struct PostgresWireProtocol { portals: HashMap>)>, auth_manager: AuthManager, scram_auth: Option, + /// Whether this session is inside a transaction block, and whether that + /// block has already failed. + /// + /// Reported in every `ReadyForQuery`. It used to be hardcoded to `Idle`, + /// which told drivers no transaction was ever open — so a driver could not + /// tell a committed statement from one queued in an aborted block. + transaction: TransactionState, +} + +/// Transaction state of a session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TransactionState { + /// No transaction block open; each statement commits on its own. + Idle, + /// Inside a transaction block that is still good. + Open, + /// Inside a transaction block that has hit an error. Every statement is + /// rejected until ROLLBACK. + Failed, } /// Result of processing data in the connection loop @@ -87,6 +106,7 @@ impl PostgresWireProtocol { portals: HashMap::new(), auth_manager, scram_auth: None, + transaction: TransactionState::Idle, } } @@ -110,6 +130,47 @@ impl PostgresWireProtocol { portals: HashMap::new(), auth_manager, scram_auth: None, + transaction: TransactionState::Idle, + } + } + + /// Transaction status to report in `ReadyForQuery`. + fn transaction_status(&self) -> TransactionStatus { + match self.transaction { + TransactionState::Idle => TransactionStatus::Idle, + TransactionState::Open => TransactionStatus::InTransaction, + TransactionState::Failed => TransactionStatus::InFailedTransaction, + } + } + + /// Update the transaction state from a statement about to run. + /// + /// Recognises the transaction-control statements themselves; everything + /// else leaves the state alone. + fn note_statement(&mut self, sql: &str) { + let head: String = sql + .trim_start() + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect::() + .to_uppercase(); + + match head.as_str() { + "BEGIN" | "START" => self.transaction = TransactionState::Open, + "COMMIT" | "END" | "ROLLBACK" | "ABORT" => { + self.transaction = TransactionState::Idle + } + _ => {} + } + } + + /// Record that a statement failed. + /// + /// Inside a transaction block this poisons it: PostgreSQL rejects every + /// later statement until the block is rolled back. + fn note_failure(&mut self) { + if self.transaction == TransactionState::Open { + self.transaction = TransactionState::Failed; } } @@ -215,6 +276,15 @@ impl PostgresWireProtocol { MessageResult::Error(e) => { error!("Error handling message: {}", e); self.send_error(write_buf, &e.to_string()); + // The protocol requires a ReadyForQuery after an error + // before the client may send anything else. Without it the + // client waits for a message that never comes and the + // session appears to have died — one bad statement took + // the whole connection down. + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(write_buf); } } diff --git a/orbit/server/src/protocols/postgres_wire/query_engine.rs b/orbit/server/src/protocols/postgres_wire/query_engine.rs index 11ca37c1..efe0f095 100644 --- a/orbit/server/src/protocols/postgres_wire/query_engine.rs +++ b/orbit/server/src/protocols/postgres_wire/query_engine.rs @@ -80,6 +80,25 @@ impl StatementDescription { } } +/// Fold a SQL identifier the way PostgreSQL does. +/// +/// An unquoted identifier folds to lower case; a double-quoted one keeps the +/// case it was written with. This parser used to fold to *upper* case while the +/// comprehensive SQL engine folded to lower, so a table created through one +/// path was invisible to the other — `CREATE TABLE t` over the simple query +/// protocol stored `t`, and `INSERT INTO t` over the extended protocol looked +/// for `T` and reported that the table did not exist. +pub fn fold_identifier(identifier: &str) -> String { + let trimmed = identifier.trim().trim_end_matches(';').trim(); + match trimmed + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + { + Some(quoted) => quoted.to_string(), + None => trimmed.to_lowercase(), + } +} + /// Map a declared column type to the PostgreSQL type OID the wire advertises. pub fn column_type_oid(column_type: &ColumnType) -> i32 { use super::messages::type_oids; @@ -405,7 +424,7 @@ impl QueryEngine { if let Some(schema) = storage.get_table_schema(table).await? { return Ok(Some(schema)); } - storage.get_table_schema(&table.to_uppercase()).await + storage.get_table_schema(&fold_identifier(table)).await } /// Infer the type of each `$n` parameter from where it is used. @@ -612,7 +631,7 @@ impl QueryEngine { let described = |name: &str| ColumnDescription { // Casing mirrors what `execute_persistent_select` produces, so the // description matches the rows that follow it. - name: name.to_uppercase(), + name: fold_identifier(name), type_oid: schema .columns .iter() @@ -1163,7 +1182,7 @@ impl QueryEngine { }; // Parse table - convert to uppercase and trim semicolon - let table = parts[from_idx + 1].trim_end_matches(';').to_uppercase(); + let table = fold_identifier(parts[from_idx + 1]); // Parse WHERE clause if present let where_idx = parts.iter().position(|&p| p.to_uppercase() == "WHERE"); @@ -1199,10 +1218,12 @@ impl QueryEngine { let val_keyword_pos = sql_upper.find("VALUES").unwrap() + 6; // Extract data using original SQL to preserve case - let table = sql[table_start..table_end].trim().to_uppercase(); - let columns: Vec = sql_upper[col_start..col_end] + let table = fold_identifier(&sql[table_start..table_end]); + // Folded from the original text, not the uppercased copy, so a quoted + // identifier keeps its case. + let columns: Vec = sql[col_start..col_end] .split(',') - .map(|s| s.trim().to_string()) + .map(fold_identifier) .collect(); // Parse values list: (v1, v2), (v3, v4) @@ -1285,7 +1306,7 @@ impl QueryEngine { )); } - let table = parts[1].trim_end_matches(';').to_uppercase(); + let table = fold_identifier(parts[1]); // Find SET let set_idx = parts @@ -1332,7 +1353,7 @@ impl QueryEngine { .position(|&p| p.to_uppercase() == "FROM") .ok_or_else(|| ProtocolError::PostgresError("Missing FROM clause".to_string()))?; - let table = parts[from_idx + 1].trim_end_matches(';').to_uppercase(); + let table = fold_identifier(parts[from_idx + 1]); // Parse WHERE clause let where_idx = parts.iter().position(|&p| p.to_uppercase() == "WHERE"); @@ -1522,7 +1543,7 @@ impl QueryEngine { }; let table_end = sql[table_start..].find('(').unwrap() + table_start; - let table_name = sql[table_start..table_end].trim().to_uppercase(); + let table_name = fold_identifier(&sql[table_start..table_end]); // Find column definitions between parentheses let col_start = table_end + 1; @@ -1575,10 +1596,7 @@ impl QueryEngine { sql_upper.find("TABLE").unwrap() + 5 }; - let table_name = sql[table_start..] - .trim() - .trim_end_matches(';') - .to_uppercase(); + let table_name = fold_identifier(&sql[table_start..]); Ok(Statement::DropTable { table: table_name, @@ -1828,7 +1846,7 @@ impl QueryEngine { wc.conditions .into_iter() .map(|c| QueryCondition { - column: c.column.to_uppercase(), // Normalize column names to uppercase + column: fold_identifier(&c.column), operator: c.operator, value: match serde_json::from_str(&c.value) { Ok(json_val) => json_val, @@ -1846,7 +1864,7 @@ impl QueryEngine { vec![] } else { // Normalize column names to uppercase - columns.into_iter().map(|c| c.to_uppercase()).collect() + columns.into_iter().map(|c| fold_identifier(&c)).collect() }; let rows = storage @@ -1860,7 +1878,7 @@ impl QueryEngine { schema .columns .into_iter() - .map(|c| c.name.to_uppercase()) + .map(|c| fold_identifier(&c.name)) .collect() } else { vec![] @@ -2028,7 +2046,7 @@ impl QueryEngine { wc.conditions .into_iter() .map(|c| QueryCondition { - column: c.column.to_uppercase(), // Normalize column names to uppercase + column: fold_identifier(&c.column), operator: c.operator, value: match serde_json::from_str(&c.value) { Ok(json_val) => json_val, @@ -2068,7 +2086,7 @@ impl QueryEngine { wc.conditions .into_iter() .map(|c| QueryCondition { - column: c.column.to_uppercase(), // Normalize column names to uppercase + column: fold_identifier(&c.column), operator: c.operator, value: match serde_json::from_str(&c.value) { Ok(json_val) => json_val, diff --git a/tests/Cargo.toml b/tests/Cargo.toml index e129a2ff..6db97fb4 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -70,3 +70,12 @@ mock-services = [] name = "tiered_storage_minio_tests" path = "integration/tiered_storage_minio_tests.rs" required-features = ["minio-tests"] + +[dev-dependencies] +bytes = "1.12.1" +futures-util = "0.3.33" +tokio-postgres = "0.7" + +[[test]] +name = "pg_conformance" +path = "integration/pg_conformance.rs" diff --git a/tests/integration/pg_conformance.rs b/tests/integration/pg_conformance.rs new file mode 100644 index 00000000..a2611225 --- /dev/null +++ b/tests/integration/pg_conformance.rs @@ -0,0 +1,683 @@ +//! PostgreSQL wire-protocol conformance harness. +//! +//! Drives `orbit-server` with `tokio-postgres` — a conforming client, not a +//! bespoke one — and records which protocol features actually work. The point +//! is a re-measurable number: run it before and after a change and compare. +//! +//! A check that fails is reported, not skipped, so the gap stays visible. +//! +//! ```text +//! ./target/debug/orbit-server --dev-mode --data-dir /tmp/pgconf --config config/orbit-server.toml & +//! cargo test -p orbit-integration-tests --test pg_conformance -- --ignored --nocapture +//! ``` +//! +//! `orbit-server` auto-registers an unknown user with password == username, so +//! `orbit`/`orbit` connects to a fresh dev server. + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use tokio_postgres::{Client, NoTls}; + +/// Where the server under test is listening. +const HOST: &str = "127.0.0.1"; +const PORT: u16 = 5432; +const USER: &str = "orbit"; + +/// One conformance area, so the report groups related failures. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Area { + Connection, + SimpleQuery, + ExtendedQuery, + Portals, + Types, + Transactions, + Catalog, + Copy, + Notify, + Errors, +} + +impl Area { + fn name(self) -> &'static str { + match self { + Area::Connection => "connection", + Area::SimpleQuery => "simple query", + Area::ExtendedQuery => "extended query", + Area::Portals => "portals / cursors", + Area::Types => "data types", + Area::Transactions => "transactions", + Area::Catalog => "catalog (pg_catalog)", + Area::Copy => "COPY", + Area::Notify => "LISTEN / NOTIFY", + Area::Errors => "error reporting", + } + } +} + +/// The outcome of one check. +struct Outcome { + area: Area, + name: &'static str, + passed: bool, + detail: String, +} + +/// Collects outcomes and renders the report. +#[derive(Default)] +struct Report { + outcomes: Vec, +} + +impl Report { + fn record(&mut self, area: Area, name: &'static str, result: Result<(), String>) { + let (passed, detail) = match result { + Ok(()) => (true, String::new()), + Err(e) => (false, e), + }; + self.outcomes.push(Outcome { + area, + name, + passed, + detail, + }); + } + + fn passed(&self) -> usize { + self.outcomes.iter().filter(|o| o.passed).count() + } + + fn render(&self) -> String { + let mut by_area: BTreeMap> = BTreeMap::new(); + for outcome in &self.outcomes { + by_area.entry(outcome.area).or_default().push(outcome); + } + + let mut out = String::new(); + let _ = writeln!(out, "\nPostgreSQL protocol conformance"); + let _ = writeln!(out, "================================"); + + for (area, outcomes) in &by_area { + let passed = outcomes.iter().filter(|o| o.passed).count(); + let _ = writeln!(out, "\n{} — {}/{}", area.name(), passed, outcomes.len()); + for outcome in outcomes { + let mark = if outcome.passed { "PASS" } else { "FAIL" }; + let _ = writeln!(out, " [{mark}] {}", outcome.name); + if !outcome.passed { + let detail = outcome.detail.replace('\n', " "); + let detail = detail.chars().take(300).collect::(); + let _ = writeln!(out, " {detail}"); + } + } + } + + let total = self.outcomes.len(); + let passed = self.passed(); + let _ = writeln!( + out, + "\nTOTAL {passed}/{total} ({:.0}%)", + (passed as f64 / total as f64) * 100.0 + ); + out + } +} + +/// Render an error with its causes. +/// +/// `tokio_postgres::Error` renders as the useless "db error"; the server's +/// message is one level down. A conformance report whose failures all read +/// "db error" cannot be acted on. +fn describe(error: E) -> String { + let mut message = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + let text = cause.to_string(); + if !message.contains(&text) { + message.push_str(": "); + message.push_str(&text); + } + source = cause.source(); + } + message +} + +async fn connect() -> Result { + let mut config = tokio_postgres::Config::new(); + config + .host(HOST) + .port(PORT) + .user(USER) + .password(USER) + .connect_timeout(std::time::Duration::from_secs(5)); + + let (client, connection) = config + .connect(NoTls) + .await + .map_err(|e| format!("connect: {}", describe(e)))?; + tokio::spawn(async move { + let _ = connection.await; + }); + Ok(client) +} + +/// Connect and expose the connection's asynchronous notifications. +/// +/// `tokio_postgres` surfaces NotificationResponse only through the polled +/// connection object, so the connection task forwards payloads on a channel. +async fn connect_with_notifications( +) -> Result<(Client, tokio::sync::mpsc::UnboundedReceiver), String> { + use futures_util::{future, stream, StreamExt}; + + let mut config = tokio_postgres::Config::new(); + config + .host(HOST) + .port(PORT) + .user(USER) + .password(USER) + .connect_timeout(std::time::Duration::from_secs(5)); + + let (client, mut connection) = config + .connect(NoTls) + .await + .map_err(|e| format!("connect: {}", describe(e)))?; + + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + let stream = stream::poll_fn(move |cx| connection.poll_message(cx)); + tokio::spawn(stream.for_each(move |message| { + if let Ok(tokio_postgres::AsyncMessage::Notification(notification)) = message { + let _ = sender.send(notification.payload().to_string()); + } + future::ready(()) + })); + + Ok((client, receiver)) +} + +/// Best-effort cleanup that must not mask the failure under test. +async fn drop_table(client: &Client, table: &str) { + let _ = client + .simple_query(&format!("DROP TABLE IF EXISTS {table}")) + .await; +} + +#[tokio::test] +#[ignore = "requires a running orbit-server on 5432"] +async fn postgres_protocol_conformance() { + let mut report = Report::default(); + + let mut client = match connect().await { + Ok(client) => { + report.record(Area::Connection, "connect with SCRAM auth", Ok(())); + client + } + Err(e) => { + report.record(Area::Connection, "connect with SCRAM auth", Err(e.clone())); + println!("{}", report.render()); + panic!("cannot reach the server under test: {e}"); + } + }; + + // ---------------------------------------------------------------- simple + report.record( + Area::SimpleQuery, + "SELECT via simple query returns a row", + client + .simple_query("SELECT 1") + .await + .map_err(describe) + .and_then(|messages| { + let rows = messages + .iter() + .filter(|m| matches!(m, tokio_postgres::SimpleQueryMessage::Row(_))) + .count(); + (rows == 1).then_some(()).ok_or(format!("{rows} rows")) + }), + ); + + report.record( + Area::SimpleQuery, + "multiple statements in one simple query", + client + .simple_query("SELECT 1; SELECT 2") + .await + .map(|_| ()) + .map_err(describe), + ); + + report.record( + Area::SimpleQuery, + "empty query returns EmptyQueryResponse", + client.simple_query("").await.map(|_| ()).map_err(describe), + ); + + // -------------------------------------------------------------- extended + report.record( + Area::ExtendedQuery, + "prepare() succeeds (Describe answers)", + client.prepare("SELECT 1").await.map(|_| ()).map_err(describe), + ); + + report.record( + Area::ExtendedQuery, + "prepared statement reports its column types", + client + .prepare("SELECT 1") + .await + .map_err(describe) + .and_then(|stmt| { + (!stmt.columns().is_empty()) + .then_some(()) + .ok_or_else(|| "no columns described".to_string()) + }), + ); + + drop_table(&client, "conf_basic").await; + let setup = client + .simple_query("CREATE TABLE conf_basic (id INTEGER, name TEXT)") + .await + .map(|_| ()) + .map_err(describe); + report.record(Area::SimpleQuery, "CREATE TABLE", setup.clone()); + + if let Err(e) = &setup { + // A check that silently disappears reads as a pass. Anything that + // cannot run because its setup failed is recorded as a failure. + for name in [ + "INSERT reports affected rows", + "UPDATE reports affected rows", + "DELETE reports affected rows", + ] { + report.record( + Area::SimpleQuery, + name, + Err(format!("not run: table setup failed: {e}")), + ); + } + for name in [ + "text value round-trips with case intact", + "integer column decodes as int4", + ] { + report.record(Area::Types, name, Err("not run: table setup failed".into())); + } + report.record( + Area::ExtendedQuery, + "parameterised query filters rows", + Err("not run: table setup failed".into()), + ); + } + + if setup.is_ok() { + report.record( + Area::SimpleQuery, + "INSERT reports affected rows", + client + .execute("INSERT INTO conf_basic (id, name) VALUES (1, 'one')", &[]) + .await + .map_err(describe) + .and_then(|n| (n == 1).then_some(()).ok_or(format!("affected {n}"))), + ); + + report.record( + Area::ExtendedQuery, + "parameterised query filters rows", + client + .query("SELECT name FROM conf_basic WHERE id = $1", &[&1i32]) + .await + .map_err(describe) + .and_then(|rows| { + (rows.len() == 1) + .then_some(()) + .ok_or(format!("{} rows", rows.len())) + }), + ); + + report.record( + Area::Types, + "text value round-trips with case intact", + client + .query("SELECT name FROM conf_basic WHERE id = 1", &[]) + .await + .map_err(describe) + .and_then(|rows| { + let value: Result<&str, _> = rows[0].try_get(0); + match value { + Ok("one") => Ok(()), + Ok(other) => Err(format!("got {other:?}")), + Err(e) => Err(e.to_string()), + } + }), + ); + + report.record( + Area::Types, + "integer column decodes as int4", + client + .query("SELECT id FROM conf_basic WHERE id = 1", &[]) + .await + .map_err(describe) + .and_then(|rows| { + rows[0] + .try_get::<_, i32>(0) + .map(|_| ()) + .map_err(describe) + }), + ); + + report.record( + Area::SimpleQuery, + "UPDATE reports affected rows", + client + .execute("UPDATE conf_basic SET name = 'two' WHERE id = 1", &[]) + .await + .map_err(describe) + .and_then(|n| (n == 1).then_some(()).ok_or(format!("affected {n}"))), + ); + + report.record( + Area::SimpleQuery, + "DELETE reports affected rows", + client + .execute("DELETE FROM conf_basic WHERE id = 1", &[]) + .await + .map_err(describe) + .and_then(|n| (n == 1).then_some(()).ok_or(format!("affected {n}"))), + ); + } + + // --------------------------------------------------------------- portals + // A portal fetched in pages is how every driver implements a cursor with a + // fetch size. + drop_table(&client, "conf_portal").await; + let portal_setup = async { + client + .simple_query("CREATE TABLE conf_portal (id INTEGER)") + .await + .map_err(describe)?; + for i in 1..=5 { + client + .execute(&format!("INSERT INTO conf_portal (id) VALUES ({i})"), &[]) + .await + .map_err(describe)?; + } + Ok::<(), String>(()) + } + .await; + + if let Err(e) = &portal_setup { + report.record( + Area::Portals, + "portal returns only the requested number of rows", + Err(format!("not run: setup failed: {e}")), + ); + } + + if portal_setup.is_ok() { + report.record( + Area::Portals, + "portal returns only the requested number of rows", + async { + let transaction = client + .transaction() + .await + .map_err(|e| format!("begin: {}", describe(e)))?; + let statement = transaction + .prepare("SELECT id FROM conf_portal") + .await + .map_err(|e| format!("prepare: {}", describe(e)))?; + let portal = transaction + .bind(&statement, &[]) + .await + .map_err(|e| format!("bind: {}", describe(e)))?; + let first = transaction + .query_portal(&portal, 2) + .await + .map_err(|e| format!("query_portal: {}", describe(e)))?; + if first.len() != 2 { + return Err(format!("asked for 2 rows, received {}", first.len())); + } + let second = transaction + .query_portal(&portal, 2) + .await + .map_err(|e| format!("second fetch: {}", describe(e)))?; + if second.len() != 2 { + return Err(format!("second page returned {}", second.len())); + } + Ok(()) + } + .await, + ); + } + + // ---------------------------------------------------------- transactions + report.record( + Area::Transactions, + "BEGIN / COMMIT round trip", + client + .simple_query("BEGIN; COMMIT") + .await + .map(|_| ()) + .map_err(describe), + ); + + report.record( + Area::Transactions, + "ROLLBACK discards an uncommitted write", + async { + drop_table(&client, "conf_tx").await; + client + .simple_query("CREATE TABLE conf_tx (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("BEGIN") + .await + .map_err(|e| format!("BEGIN: {}", describe(e)))?; + client + .simple_query("INSERT INTO conf_tx (id) VALUES (1)") + .await + .map_err(|e| format!("INSERT: {}", describe(e)))?; + client + .simple_query("ROLLBACK") + .await + .map_err(|e| format!("ROLLBACK: {}", describe(e)))?; + let rows = client + .query("SELECT id FROM conf_tx", &[]) + .await + .map_err(describe)?; + rows.is_empty() + .then_some(()) + .ok_or_else(|| format!("{} row(s) survived rollback", rows.len())) + } + .await, + ); + + report.record( + Area::Transactions, + "driver-managed transaction commits", + async { + drop_table(&client, "conf_tx2").await; + client + .simple_query("CREATE TABLE conf_tx2 (id INTEGER)") + .await + .map_err(describe)?; + let transaction = client.transaction().await.map_err(describe)?; + transaction + .execute("INSERT INTO conf_tx2 (id) VALUES (1)", &[]) + .await + .map_err(describe)?; + transaction.commit().await.map_err(describe)?; + let rows = client + .query("SELECT id FROM conf_tx2", &[]) + .await + .map_err(describe)?; + (rows.len() == 1) + .then_some(()) + .ok_or_else(|| format!("{} rows after commit", rows.len())) + } + .await, + ); + + // --------------------------------------------------------------- catalog + for (name, sql) in [ + ("SELECT version()", "SELECT version()"), + ("SELECT current_database()", "SELECT current_database()"), + ( + "pg_catalog.pg_class is queryable", + "SELECT relname FROM pg_catalog.pg_class LIMIT 1", + ), + ( + "information_schema.tables is queryable", + "SELECT table_name FROM information_schema.tables LIMIT 1", + ), + ( + "pg_catalog.pg_type is queryable (driver type lookup)", + "SELECT typname FROM pg_catalog.pg_type LIMIT 1", + ), + ] { + report.record( + Area::Catalog, + name, + client + .simple_query(sql) + .await + .map(|_| ()) + .map_err(describe), + ); + } + + // ------------------------------------------------------------------ COPY + drop_table(&client, "conf_copy_in").await; + let _ = client + .simple_query("CREATE TABLE conf_copy_in (id INTEGER)") + .await; + + // Accepting the statement proves nothing: an engine that treats COPY as an + // unknown no-op also returns success. These drive the actual subprotocol. + report.record( + Area::Copy, + "COPY TO STDOUT streams rows", + async { + let stream = client + .copy_out("COPY conf_portal TO STDOUT") + .await + .map_err(describe)?; + futures_util::pin_mut!(stream); + let mut bytes = 0usize; + while let Some(chunk) = futures_util::StreamExt::next(&mut stream).await { + bytes += chunk.map_err(describe)?.len(); + } + (bytes > 0) + .then_some(()) + .ok_or_else(|| "COPY TO produced no data".to_string()) + } + .await, + ); + + report.record( + Area::Copy, + "COPY FROM STDIN ingests rows", + async { + let sink = client + .copy_in("COPY conf_copy_in FROM STDIN") + .await + .map_err(describe)?; + futures_util::pin_mut!(sink); + use futures_util::SinkExt as _; + sink.as_mut() + .send(bytes::Bytes::from_static(b"9\n10\n")) + .await + .map_err(describe)?; + let written = sink.finish().await.map_err(describe)?; + (written == 2) + .then_some(()) + .ok_or_else(|| format!("COPY FROM reported {written} rows, expected 2")) + } + .await, + ); + + // -------------------------------------------------------------- NOTIFY + // A server that ignores LISTEN also answers it without error, so the check + // is whether a notification actually arrives. + report.record( + Area::Notify, + "a NOTIFY reaches a listening session", + async { + let (notify_client, mut notify_stream) = + connect_with_notifications().await.map_err(|e| e)?; + + notify_client + .simple_query("LISTEN conf_channel") + .await + .map_err(describe)?; + notify_client + .simple_query("NOTIFY conf_channel, 'hello'") + .await + .map_err(describe)?; + + match tokio::time::timeout( + std::time::Duration::from_secs(2), + notify_stream.recv(), + ) + .await + { + Ok(Some(payload)) if payload == "hello" => Ok(()), + Ok(Some(other)) => Err(format!("unexpected payload {other:?}")), + Ok(None) => Err("notification channel closed".to_string()), + Err(_) => Err("no notification delivered within 2s".to_string()), + } + } + .await, + ); + + // ---------------------------------------------------------------- errors + report.record( + Area::Errors, + "unknown table is an error, not a silent empty result", + match client.query("SELECT * FROM definitely_not_a_table", &[]).await { + Ok(_) => Err("query on a missing table succeeded".to_string()), + Err(_) => Ok(()), + }, + ); + + report.record( + Area::Errors, + "error carries a SQLSTATE code", + match client.query("SELECT * FROM definitely_not_a_table", &[]).await { + Ok(_) => Err("expected an error".to_string()), + Err(e) => e + .code() + .map(|_| ()) + .ok_or_else(|| "error has no SQLSTATE".to_string()), + }, + ); + + report.record( + Area::Errors, + "session is usable after a failed statement", + client + .simple_query("SELECT 1") + .await + .map(|_| ()) + .map_err(describe), + ); + + for table in [ + "conf_basic", + "conf_portal", + "conf_tx", + "conf_tx2", + "conf_copy_in", + ] { + drop_table(&client, table).await; + } + + println!("{}", report.render()); + + // The harness reports; it does not gate. Conformance is tracked as a number + // that should move up, and failing the build on a known gap would only make + // the number invisible. + assert!( + report.passed() > 0, + "no conformance checks passed at all — the server is not usable" + ); +} From 4ff64c0babd19313b4fe55e692a23aaea2436056 Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Thu, 6 Aug 2026 12:17:30 -0700 Subject: [PATCH 33/36] Postgres wire: COPY, LISTEN/NOTIFY, SQL engine fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a NotificationHub and per-session channels to implement LISTEN/NOTIFY (delivered while idle) and wire a single shared hub into the Postgres server. Implement COPY text mode (TO STDOUT / FROM STDIN) with streaming, error capture, and simple COPY state. Improve extended-protocol support: track portal result formats and statement column OIDs, correctly bind parameters with unquoted numeric/boolean literals, and support binary encoding for common types. Add transaction snapshotting for rollback (with warnings when restore fails), TRUNCATE support, EXPLAIN text plans, and richer parser fixes (NOT, BETWEEN, START TRANSACTION). Introduce sql/select_pipeline.rs to correctly apply WHERE → GROUP BY/aggregates → HAVING → DISTINCT → ORDER BY → OFFSET/LIMIT → projection to rows from storage (fixes many earlier silent-wrong-answer bugs). Fix literal handling (NULL vs 'NULL'), identifier folding, vector coercion, and other evaluator improvements (version(), current_* functions, IS/IS NOT). Make storage adapter accept booleans/floats as primary keys and ensure drop_table removes rows before schema. Update PRD and integration tests to reflect conformance checks and new features. --- orbit/engine/src/unified/adapters.rs | 11 +- .../src/protocols/common/storage/unified.rs | 5 + orbit/server/src/protocols/postgres_server.rs | 8 +- .../server/src/protocols/postgres_wire/mod.rs | 1 + .../protocols/postgres_wire/notifications.rs | 255 ++++ .../src/protocols/postgres_wire/protocol.rs | 999 +++++++++++++- .../protocols/postgres_wire/query_engine.rs | 1187 +++++++++++++++-- .../postgres_wire/sql/execution_strategy.rs | 99 +- .../postgres_wire/sql/expression_evaluator.rs | 189 ++- .../src/protocols/postgres_wire/sql/mod.rs | 1 + .../postgres_wire/sql/parser/expressions.rs | 153 +-- .../protocols/postgres_wire/sql/parser/mod.rs | 59 +- .../protocols/postgres_wire/sql/parser/tcl.rs | 14 +- .../postgres_wire/sql/parser/utilities.rs | 9 + .../postgres_wire/sql/select_pipeline.rs | 668 ++++++++++ specifications/PRD.md | 59 + tests/integration/pg_conformance.rs | 743 ++++++++++- 17 files changed, 4203 insertions(+), 257 deletions(-) create mode 100644 orbit/server/src/protocols/postgres_wire/notifications.rs create mode 100644 orbit/server/src/protocols/postgres_wire/sql/select_pipeline.rs diff --git a/orbit/engine/src/unified/adapters.rs b/orbit/engine/src/unified/adapters.rs index 6399199b..602b56e2 100644 --- a/orbit/engine/src/unified/adapters.rs +++ b/orbit/engine/src/unified/adapters.rs @@ -1047,15 +1047,24 @@ impl SqlAdapter { row: BTreeMap, primary_key: &str, ) -> UnifiedStorageResult<()> { + // Every scalar can serve as a key. Accepting only strings and integers + // rejected a row whose key column was a boolean or a float with + // "Missing primary key", which named the wrong problem: the column was + // present, its type was simply not handled. let key = row .get(primary_key) .and_then(|v| match v { UniversalValue::String(s) => Some(s.clone()), UniversalValue::Int(i) => Some(i.to_string()), + UniversalValue::Bool(b) => Some(b.to_string()), + UniversalValue::Float(f) => Some(f.to_string()), + // Null and the composite types genuinely cannot key a row. _ => None, }) .ok_or_else(|| { - UnifiedStorageError::InvalidData(format!("Missing primary key: {}", primary_key)) + UnifiedStorageError::InvalidData(format!( + "Cannot use column '{primary_key}' as a row key: it is absent, null, or not a scalar" + )) })?; self.base diff --git a/orbit/server/src/protocols/common/storage/unified.rs b/orbit/server/src/protocols/common/storage/unified.rs index 867de67d..a81df559 100644 --- a/orbit/server/src/protocols/common/storage/unified.rs +++ b/orbit/server/src/protocols/common/storage/unified.rs @@ -1205,6 +1205,11 @@ mod persistent_storage_impl { } async fn drop_table(&self, table_name: &str) -> ProtocolResult<()> { + // The rows have to go before the schema. Removing only the schema + // leaves every row in the adapter, so the next `CREATE TABLE` with + // the same name resurrects data the client believes it deleted — + // a silent wrong answer rather than an error. + TableStorage::delete_rows(self, table_name, None, None).await?; self.remove_table_schema(table_name, None).await?; Ok(()) } diff --git a/orbit/server/src/protocols/postgres_server.rs b/orbit/server/src/protocols/postgres_server.rs index 48ed05c1..5669a044 100644 --- a/orbit/server/src/protocols/postgres_server.rs +++ b/orbit/server/src/protocols/postgres_server.rs @@ -9,6 +9,7 @@ use crate::protocols::postgres_wire::{protocol::PostgresWireProtocol, query_engi use crate::protocols::ProtocolError; use crate::config::TlsConfig; +use crate::protocols::postgres_wire::notifications::NotificationHub; use crate::protocols::tls::OrbitTlsAcceptor; /// PostgreSQL wire protocol server @@ -47,6 +48,9 @@ impl PostgresServer { /// Start the server pub async fn run(&self) -> ProtocolResult<()> { let listener = TcpListener::bind(&self.bind_addr).await?; + // One registry for the whole server: a NOTIFY on one connection has to + // reach a LISTEN on another. + let notifications = NotificationHub::new(); let tls_acceptor = OrbitTlsAcceptor::new(&self.tls_config) .map_err(|e| ProtocolError::IoError(e.to_string()))?; @@ -61,13 +65,15 @@ impl PostgresServer { info!("New connection from {}", addr); let query_engine = self.query_engine.clone(); let tls_acceptor = tls_acceptor.clone(); + let notifications = Arc::clone(¬ifications); tokio::spawn(async move { let mut protocol = if let Some(engine) = query_engine { PostgresWireProtocol::new_with_query_engine(engine) } else { PostgresWireProtocol::new() - }; + } + .with_notification_hub(notifications); // PostgreSQL negotiates TLS explicitly: the client sends // an SSLRequest in the clear and the server answers diff --git a/orbit/server/src/protocols/postgres_wire/mod.rs b/orbit/server/src/protocols/postgres_wire/mod.rs index 242a8192..2c9bb06f 100644 --- a/orbit/server/src/protocols/postgres_wire/mod.rs +++ b/orbit/server/src/protocols/postgres_wire/mod.rs @@ -41,6 +41,7 @@ pub mod messages; #[cfg(feature = "storage-rocksdb")] pub mod persistent_storage; #[cfg(feature = "storage-rocksdb")] +pub mod notifications; pub mod protocol; #[cfg(feature = "storage-rocksdb")] pub mod query_engine; diff --git a/orbit/server/src/protocols/postgres_wire/notifications.rs b/orbit/server/src/protocols/postgres_wire/notifications.rs new file mode 100644 index 00000000..fddc84d3 --- /dev/null +++ b/orbit/server/src/protocols/postgres_wire/notifications.rs @@ -0,0 +1,255 @@ +//! Asynchronous notifications: `LISTEN` / `NOTIFY`. +//! +//! A notification crosses sessions — one connection issues `NOTIFY`, others +//! receive it — so the registry of who is listening lives outside any single +//! connection and is shared by the listener that accepts them. +//! +//! Delivery is best-effort within a process, matching what the protocol +//! promises: a notification is delivered to sessions listening *at the time it +//! is sent*. Nothing is persisted and nothing is replayed to a session that +//! subscribes later. + +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; +use tokio::sync::Mutex; + +/// One notification in flight. +#[derive(Debug, Clone)] +pub struct Notification { + /// Backend process id of the sender, as reported to the client. + pub process_id: i32, + pub channel: String, + pub payload: String, +} + +/// Registry of listening sessions, shared by every connection on a server. +#[derive(Default)] +pub struct NotificationHub { + /// Channel name to the sessions listening on it. + /// + /// Keys are stored folded, because `LISTEN Foo` and `NOTIFY foo` name the + /// same channel in PostgreSQL unless quoted. + listeners: Mutex>>, +} + +struct Subscriber { + session: u64, + sender: UnboundedSender, +} + +impl NotificationHub { + #[must_use] + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Subscribe `session` to `channel`. + /// + /// Repeated `LISTEN` on the same channel from the same session is a no-op, + /// as in PostgreSQL — it must not double-deliver. + pub async fn listen( + &self, + channel: &str, + session: u64, + sender: UnboundedSender, + ) { + let mut listeners = self.listeners.lock().await; + let subscribers = listeners.entry(fold_channel(channel)).or_default(); + if subscribers.iter().any(|s| s.session == session) { + return; + } + subscribers.push(Subscriber { session, sender }); + } + + /// Stop delivering `channel` to `session`. `None` unsubscribes everything, + /// which is what bare `UNLISTEN *` means. + pub async fn unlisten(&self, channel: Option<&str>, session: u64) { + let mut listeners = self.listeners.lock().await; + match channel { + Some(channel) => { + if let Some(subscribers) = listeners.get_mut(&fold_channel(channel)) { + subscribers.retain(|s| s.session != session); + } + } + None => { + for subscribers in listeners.values_mut() { + subscribers.retain(|s| s.session != session); + } + } + } + } + + /// Drop every subscription for a session that has gone away. + pub async fn disconnect(&self, session: u64) { + self.unlisten(None, session).await; + self.listeners + .lock() + .await + .retain(|_, subscribers| !subscribers.is_empty()); + } + + /// Deliver a notification to every listener, returning how many received it. + /// + /// Sessions whose receiver has been dropped are pruned here rather than + /// accumulating: a hub that only ever adds entries grows for the life of + /// the process. + pub async fn notify(&self, channel: &str, payload: &str, process_id: i32) -> usize { + let mut listeners = self.listeners.lock().await; + let Some(subscribers) = listeners.get_mut(&fold_channel(channel)) else { + return 0; + }; + + let notification = Notification { + process_id, + channel: channel.to_string(), + payload: payload.to_string(), + }; + + subscribers.retain(|subscriber| subscriber.sender.send(notification.clone()).is_ok()); + subscribers.len() + } +} + +/// Fold a channel name the way an unquoted identifier folds. +fn fold_channel(channel: &str) -> String { + let trimmed = channel.trim(); + match trimmed + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + { + Some(quoted) => quoted.to_string(), + None => trimmed.to_lowercase(), + } +} + +/// A session's end of the notification channel. +pub struct SessionNotifications { + pub id: u64, + pub sender: UnboundedSender, + pub receiver: UnboundedReceiver, +} + +impl SessionNotifications { + /// Create a session's channel with a process-unique id. + #[must_use] + pub fn new() -> Self { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(1); + + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + Self { + id: NEXT.fetch_add(1, Ordering::Relaxed), + sender, + receiver, + } + } +} + +impl Default for SessionNotifications { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session() -> SessionNotifications { + SessionNotifications::new() + } + + #[tokio::test] + async fn a_listening_session_receives_a_notification() { + let hub = NotificationHub::new(); + let mut listener = session(); + hub.listen("events", listener.id, listener.sender.clone()) + .await; + + assert_eq!(hub.notify("events", "hello", 42).await, 1); + + let received = listener.receiver.recv().await.expect("a notification"); + assert_eq!(received.channel, "events"); + assert_eq!(received.payload, "hello"); + assert_eq!(received.process_id, 42); + } + + #[tokio::test] + async fn a_session_that_is_not_listening_receives_nothing() { + let hub = NotificationHub::new(); + let mut listener = session(); + hub.listen("events", listener.id, listener.sender.clone()) + .await; + + assert_eq!(hub.notify("other", "hello", 1).await, 0); + assert!(listener.receiver.try_recv().is_err()); + } + + /// Unquoted channel names fold, so these name the same channel. + #[tokio::test] + async fn channel_names_are_case_insensitive() { + let hub = NotificationHub::new(); + let mut listener = session(); + hub.listen("Events", listener.id, listener.sender.clone()) + .await; + + assert_eq!(hub.notify("EVENTS", "hi", 1).await, 1); + assert!(listener.receiver.recv().await.is_some()); + } + + #[tokio::test] + async fn listening_twice_delivers_once() { + let hub = NotificationHub::new(); + let mut listener = session(); + hub.listen("events", listener.id, listener.sender.clone()) + .await; + hub.listen("events", listener.id, listener.sender.clone()) + .await; + + assert_eq!(hub.notify("events", "once", 1).await, 1); + assert!(listener.receiver.recv().await.is_some()); + assert!(listener.receiver.try_recv().is_err(), "delivered twice"); + } + + #[tokio::test] + async fn unlisten_stops_delivery() { + let hub = NotificationHub::new(); + let mut listener = session(); + hub.listen("events", listener.id, listener.sender.clone()) + .await; + hub.unlisten(Some("events"), listener.id).await; + + assert_eq!(hub.notify("events", "hello", 1).await, 0); + assert!(listener.receiver.try_recv().is_err()); + } + + /// A hub that only ever adds entries grows for the life of the process. + #[tokio::test] + async fn a_departed_session_is_pruned() { + let hub = NotificationHub::new(); + let listener = session(); + let id = listener.id; + hub.listen("events", id, listener.sender.clone()).await; + + drop(listener); + assert_eq!(hub.notify("events", "hello", 1).await, 0); + + // The dead subscriber is gone, not merely skipped. + let listeners = hub.listeners.lock().await; + assert!(listeners.get("events").is_some_and(Vec::is_empty)); + } + + #[tokio::test] + async fn two_sessions_both_receive() { + let hub = NotificationHub::new(); + let mut first = session(); + let mut second = session(); + hub.listen("events", first.id, first.sender.clone()).await; + hub.listen("events", second.id, second.sender.clone()).await; + + assert_eq!(hub.notify("events", "broadcast", 1).await, 2); + assert!(first.receiver.recv().await.is_some()); + assert!(second.receiver.recv().await.is_some()); + } +} diff --git a/orbit/server/src/protocols/postgres_wire/protocol.rs b/orbit/server/src/protocols/postgres_wire/protocol.rs index 2822a771..c7ebdc67 100644 --- a/orbit/server/src/protocols/postgres_wire/protocol.rs +++ b/orbit/server/src/protocols/postgres_wire/protocol.rs @@ -12,6 +12,7 @@ use super::messages::{ type_oids, AuthenticationResponse, BackendMessage, FieldDescription, FrontendMessage, TransactionStatus, }; +use super::notifications::{NotificationHub, SessionNotifications}; use super::query_engine::{QueryEngine, QueryResult}; use crate::protocols::error::{ProtocolError, ProtocolResult}; @@ -44,8 +45,39 @@ pub struct PostgresWireProtocol { /// parameter before the row description. statement_param_types: HashMap>, portals: HashMap>)>, + /// Result format codes requested by `Bind`, per portal. + /// + /// A client that asked for binary results cannot read text ones: it decodes + /// by width and fails with "failed to fill whole buffer". Ignoring these + /// codes only appeared to work while every column was advertised as text. + portal_result_formats: HashMap>, + /// Column type OIDs most recently described for a statement, so `Execute` + /// knows how to encode each value. + statement_columns: HashMap>, + /// Whether the COPY being started came from a simple query. + copy_in_is_simple: bool, + /// The `COPY ... FROM STDIN` currently in progress, if any. + /// + /// While set, the session is in copy-in mode and the client is streaming + /// CopyData messages rather than ordinary queries. + copy_in: Option, + /// Shared LISTEN/NOTIFY registry, and this session's end of it. + notifications: Arc, + session_notifications: SessionNotifications, + /// Rows of a partially fetched portal, so a second `Execute` resumes rather + /// than re-running the statement. + portal_rows: HashMap, auth_manager: AuthManager, scram_auth: Option, + /// Writes issued inside the current transaction block. + writes_in_transaction: u64, + /// Contents of each table as it stood when the transaction block first + /// wrote to it. + /// + /// Storage applies writes as they run, so undoing them means putting the + /// table back. A snapshot is taken once per table per block, before its + /// first write, and discarded on COMMIT. + transaction_snapshots: HashMap>, /// Whether this session is inside a transaction block, and whether that /// block has already failed. /// @@ -55,6 +87,38 @@ pub struct PostgresWireProtocol { transaction: TransactionState, } +/// An in-progress `COPY ... FROM STDIN`. +struct CopyInState { + /// Whether the copy was started by a simple query, which owes the client a + /// ReadyForQuery when the stream ends. + simple_protocol: bool, + table: String, + /// Column names the data is being loaded into, in order. + columns: Vec, + /// Whether each column takes an unquoted literal, by position. + /// + /// A COPY field is text on the wire but must be written into the statement + /// the way its column expects: quoting `9` for an integer column makes the + /// insert fail, and the failure arrives mid-stream where the client is not + /// expecting a message at all. + numeric_columns: Vec, + /// Bytes received but not yet terminated by a newline. + /// + /// CopyData messages are chunks of a byte stream, not rows: a row can be + /// split across two of them. + partial: Vec, + rows: u64, + /// First row failure, reported when the stream ends. + failure: Option, +} + +/// A portal's result set and how much of it has been delivered. +struct PortalRows { + columns: Vec, + rows: Vec>>, + sent: usize, +} + /// Transaction state of a session. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TransactionState { @@ -104,9 +168,18 @@ impl PostgresWireProtocol { prepared_statements: HashMap::new(), statement_param_types: HashMap::new(), portals: HashMap::new(), + portal_result_formats: HashMap::new(), + statement_columns: HashMap::new(), + portal_rows: HashMap::new(), auth_manager, scram_auth: None, transaction: TransactionState::Idle, + writes_in_transaction: 0, + transaction_snapshots: HashMap::new(), + copy_in: None, + copy_in_is_simple: false, + notifications: NotificationHub::new(), + session_notifications: SessionNotifications::new(), } } @@ -128,12 +201,32 @@ impl PostgresWireProtocol { prepared_statements: HashMap::new(), statement_param_types: HashMap::new(), portals: HashMap::new(), + portal_result_formats: HashMap::new(), + statement_columns: HashMap::new(), + portal_rows: HashMap::new(), auth_manager, scram_auth: None, transaction: TransactionState::Idle, + writes_in_transaction: 0, + transaction_snapshots: HashMap::new(), + copy_in: None, + copy_in_is_simple: false, + notifications: NotificationHub::new(), + session_notifications: SessionNotifications::new(), } } + /// Share a notification registry with the other sessions on this server. + /// + /// Without this each connection gets its own hub, so `NOTIFY` on one + /// connection can never reach a `LISTEN` on another — which is the only + /// thing the feature is for. + #[must_use] + pub fn with_notification_hub(mut self, hub: Arc) -> Self { + self.notifications = hub; + self + } + /// Transaction status to report in `ReadyForQuery`. fn transaction_status(&self) -> TransactionStatus { match self.transaction { @@ -156,14 +249,110 @@ impl PostgresWireProtocol { .to_uppercase(); match head.as_str() { - "BEGIN" | "START" => self.transaction = TransactionState::Open, + "BEGIN" | "START" => { + self.transaction = TransactionState::Open; + self.writes_in_transaction = 0; + } "COMMIT" | "END" | "ROLLBACK" | "ABORT" => { - self.transaction = TransactionState::Idle + self.transaction = TransactionState::Idle; + self.writes_in_transaction = 0; + } + // Writes are counted so a later ROLLBACK can report what it cannot + // undo. + "INSERT" | "UPDATE" | "DELETE" | "MERGE" | "COPY" | "TRUNCATE" + if self.transaction != TransactionState::Idle => + { + self.writes_in_transaction += 1; } _ => {} } } + /// Snapshot the table a statement is about to write, if a transaction + /// block is open and this is its first write to that table. + /// + /// Taken before the statement runs, because afterwards the previous + /// contents are gone. + async fn snapshot_before_write(&mut self, sql: &str) { + if self.transaction == TransactionState::Idle { + return; + } + let Some(table) = QueryEngine::write_target_table(sql) else { + return; + }; + if self.transaction_snapshots.contains_key(&table) { + return; + } + + match self.query_engine.snapshot_table(&table).await { + Ok(Some(rows)) => { + self.transaction_snapshots.insert(table, rows); + } + Ok(None) => {} + // Recorded but not fatal: the statement still runs, and the + // rollback will report that it could not restore this table. + Err(e) => tracing::warn!("could not snapshot '{table}' for rollback: {e}"), + } + } + + /// Put every snapshotted table back, undoing the block's writes. + /// + /// Returns the tables that could not be restored. + async fn restore_snapshots(&mut self) -> Vec { + let snapshots = std::mem::take(&mut self.transaction_snapshots); + let mut failed = Vec::new(); + + for (table, rows) in snapshots { + if let Err(e) = self.query_engine.restore_table(&table, rows).await { + tracing::error!("rollback could not restore '{table}': {e}"); + failed.push(table); + } + } + failed + } + + /// Apply a transaction-control statement's effect on the undo snapshots. + /// + /// `ROLLBACK` puts every table the block wrote back to how it stood before + /// its first write, which is what makes the block atomic. `COMMIT` simply + /// drops the snapshots. + /// + /// This gives atomicity, not isolation: writes are visible to other + /// sessions as they happen, and a concurrent writer's changes to the same + /// table would be reverted along with this block's. + async fn apply_transaction_control(&mut self, query: &str, buf: &mut BytesMut) { + let head: String = query + .trim_start() + .chars() + .take_while(|c| c.is_alphanumeric()) + .collect::() + .to_uppercase(); + + match head.as_str() { + "ROLLBACK" | "ABORT" => { + let failed = self.restore_snapshots().await; + if failed.is_empty() { + return; + } + // Partly undone: saying so beats reporting a clean rollback. + let mut fields = HashMap::new(); + fields.insert(b'S', "WARNING".to_string()); + fields.insert(b'C', "25000".to_string()); + fields.insert( + b'M', + format!( + "ROLLBACK could not restore {}: those changes remain applied", + failed.join(", ") + ), + ); + BackendMessage::NoticeResponse { fields }.encode(buf); + } + // The block's writes stand; nothing to put back. + "COMMIT" | "END" => self.transaction_snapshots.clear(), + _ => {} + } + } + /// Record that a statement failed. /// /// Inside a transaction block this poisons it: PostgreSQL rejects every @@ -227,11 +416,19 @@ impl PostgresWireProtocol { } ConnectionLoopResult::ClientTerminated => { info!("Client terminated connection"); + self.notifications + .disconnect(self.session_notifications.id) + .await; return Ok(()); } } } + // A session that has gone must not stay in the registry. + self.notifications + .disconnect(self.session_notifications.id) + .await; + Ok(()) } @@ -245,7 +442,24 @@ impl PostgresWireProtocol { where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { - let n = stream.read_buf(read_buf).await?; + // Wait for either the client to say something or a notification to + // arrive. Reading alone would hold a notification until the client + // happened to send a message, which for an idle listener is never — + // and an idle listener is the whole point of LISTEN. + let n = loop { + tokio::select! { + read = stream.read_buf(read_buf) => break read?, + Some(notification) = self.session_notifications.receiver.recv() => { + BackendMessage::NotificationResponse { + process_id: notification.process_id, + channel: notification.channel, + payload: notification.payload, + } + .encode(write_buf); + self.flush_write_buffer(stream, write_buf).await?; + } + } + }; if n == 0 { return Ok(ConnectionLoopResult::ClientDisconnected); @@ -381,10 +595,18 @@ impl PostgresWireProtocol { self.handle_close(target, &name, buf)?; } FrontendMessage::Sync => { - BackendMessage::ReadyForQuery { - status: TransactionStatus::Idle, + // While a copy-in stream is open the backend ignores Sync: the + // client sends one straight after Execute and is not expecting + // a reply until CopyDone. Answering it here put a + // ReadyForQuery into the middle of the stream, which is the + // "unexpected message from server" the client then reported. + if self.copy_in.is_none() { + self.deliver_pending_notifications(buf); + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(buf); } - .encode(buf); } FrontendMessage::Flush => { // Nothing to do, data is flushed after each message @@ -405,10 +627,18 @@ impl PostgresWireProtocol { FrontendMessage::FunctionCall { .. } => { // Function call support is minimal/stubbed } - FrontendMessage::CopyData { .. } - | FrontendMessage::CopyDone - | FrontendMessage::CopyFail { .. } => { - // Copy protocol not fully supported by server yet + FrontendMessage::CopyData { data } => { + self.handle_copy_data(&data, buf).await?; + } + FrontendMessage::CopyDone => { + self.handle_copy_done(buf).await?; + } + FrontendMessage::CopyFail { message } => { + // The client is abandoning the load; nothing already applied is + // undone, matching a non-transactional COPY. + let simple = self.copy_in.take().is_some_and(|s| s.simple_protocol); + self.send_error(buf, &format!("COPY from stdin failed: {message}")); + self.finish_copy_statement(simple, buf); } } @@ -651,10 +881,27 @@ impl PostgresWireProtocol { async fn handle_query(&mut self, query: &str, buf: &mut BytesMut) -> ProtocolResult<()> { info!("Query: {} (database: {:?})", query, self.database); + self.copy_in_is_simple = true; + if self.handle_copy_statement(query, buf, true).await.is_some() { + return Ok(()); + } + + self.snapshot_before_write(query).await; + + if let Some(tag) = self.handle_notification_statement(query).await { + BackendMessage::CommandComplete { tag }.encode(buf); + self.deliver_pending_notifications(buf); + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(buf); + return Ok(()); + } + if query.trim().is_empty() { BackendMessage::EmptyQueryResponse.encode(buf); BackendMessage::ReadyForQuery { - status: TransactionStatus::Idle, + status: self.transaction_status(), } .encode(buf); return Ok(()); @@ -670,15 +917,19 @@ impl PostgresWireProtocol { for result in results { self.send_query_result(&result, buf); } + self.apply_transaction_control(query, buf).await; + self.note_statement(query); + self.deliver_pending_notifications(buf); BackendMessage::ReadyForQuery { - status: TransactionStatus::Idle, + status: self.transaction_status(), } .encode(buf); } Err(e) => { self.send_error(buf, &e.to_string()); + self.note_failure(); BackendMessage::ReadyForQuery { - status: TransactionStatus::Idle, + status: self.transaction_status(), } .encode(buf); } @@ -723,6 +974,39 @@ impl PostgresWireProtocol { Ok(()) } + /// Whether a value of this type is written into SQL without quotes. + fn is_unquoted_literal_type(type_oid: i32) -> bool { + matches!( + type_oid, + type_oids::INT2 + | type_oids::INT4 + | type_oids::INT8 + | type_oids::FLOAT4 + | type_oids::FLOAT8 + | type_oids::BOOL + ) + } + + /// Whether `text` is safe to splice in unquoted. + /// + /// Belt and braces: the type says the value should be bare, but the bytes + /// come from the client. Anything that is not plainly a number or a boolean + /// is quoted instead, so a hostile value cannot become syntax. + fn is_safe_bare_literal(text: &str) -> bool { + if matches!( + text.to_ascii_lowercase().as_str(), + "true" | "false" | "t" | "f" + ) { + return true; + } + + !text.is_empty() + && text.parse::().is_ok() + && text + .chars() + .all(|c| c.is_ascii_digit() || matches!(c, '-' | '+' | '.' | 'e' | 'E')) + } + /// Highest `$n` placeholder appearing in `query`. /// /// Counts distinct positions rather than occurrences: `$1 AND $1` is one @@ -768,10 +1052,12 @@ impl PostgresWireProtocol { statement: &str, param_formats: Vec, params: Vec>, - _result_formats: Vec, + result_formats: Vec, buf: &mut BytesMut, ) -> ProtocolResult<()> { debug!("Bind: portal={}, statement={}", portal, statement); + self.portal_result_formats + .insert(portal.to_string(), result_formats); // Format codes are per-parameter, or a single code covering all of them, // or empty meaning all text. @@ -812,6 +1098,9 @@ impl PostgresWireProtocol { } } + // Re-binding a portal restarts it, so any partially delivered result + // from a previous execution is discarded. + self.portal_rows.remove(portal); self.portals .insert(portal.to_string(), (statement.to_string(), decoded)); @@ -868,6 +1157,7 @@ impl PostgresWireProtocol { fn bind_parameters( query: &str, params: &[Option], + param_types: &[i32], ) -> ProtocolResult { let bytes = query.as_bytes(); let mut out = String::with_capacity(query.len()); @@ -925,10 +1215,26 @@ impl PostgresWireProtocol { "parameter ${position} is not valid UTF-8 text" )) })?; - out.push('\''); - // Doubling is how a single quote is escaped in a SQL literal. - out.push_str(&text.replace('\'', "''")); - out.push('\''); + + let type_oid = param_types + .get(position - 1) + .copied() + .unwrap_or(type_oids::TEXT); + + if Self::is_unquoted_literal_type(type_oid) + && Self::is_safe_bare_literal(text) + { + // A numeric or boolean parameter has to be emitted + // bare: quoting it turns `id = $1` into `id = '1'`, + // which compares an integer column against a string + // and silently matches nothing. + out.push_str(text); + } else { + out.push('\''); + // Doubling is how a single quote is escaped in a SQL literal. + out.push_str(&text.replace('\'', "''")); + out.push('\''); + } } } @@ -942,10 +1248,18 @@ impl PostgresWireProtocol { async fn handle_execute( &mut self, portal: &str, - _max_rows: i32, + max_rows: i32, buf: &mut BytesMut, ) -> ProtocolResult<()> { - debug!("Execute: portal={}", portal); + debug!("Execute: portal={}, max_rows={}", portal, max_rows); + + // A portal already drained by a previous Execute returns nothing more. + if let Some(remaining) = self.portal_rows.get(portal) { + let already_sent = remaining.sent; + let rows = remaining.rows.clone(); + let columns = remaining.columns.clone(); + return Ok(self.send_portal_page(portal, &columns, &rows, already_sent, max_rows, buf)); + } let (statement_name, params) = self .portals @@ -959,7 +1273,13 @@ impl PostgresWireProtocol { ProtocolError::PostgresError(format!("Statement not found: {statement_name}")) })?; - let bound = match Self::bind_parameters(query, params) { + let param_types = self + .statement_param_types + .get(statement_name) + .cloned() + .unwrap_or_default(); + + let bound = match Self::bind_parameters(query, params, ¶m_types) { Ok(bound) => bound, Err(e) => { self.send_error(buf, &e.to_string()); @@ -967,15 +1287,40 @@ impl PostgresWireProtocol { } }; + self.copy_in_is_simple = false; + if self.handle_copy_statement(&bound, buf, false).await.is_some() { + return Ok(()); + } + + let bound_for_state = bound.clone(); + let portal_name = portal.to_string(); match self.query_engine.execute_query(&bound).await { + Ok(QueryResult::Select { columns, rows }) + | Ok(QueryResult::Merge { columns, rows, .. }) => { + self.note_statement(&bound_for_state); + // Remembered so a later Execute on the same portal continues + // where this one stopped, which is how every driver implements + // a cursor with a fetch size. + self.portal_rows.insert( + portal_name.clone(), + PortalRows { + columns: columns.clone(), + rows: rows.clone(), + sent: 0, + }, + ); + self.send_portal_page(&portal_name, &columns, &rows, 0, max_rows, buf); + } Ok(result) => { // Extended protocol: the row description was already sent in // response to Describe, and repeating it here is a protocol // violation. Only the rows and the command tag belong on Execute. self.send_query_result_without_description(&result, buf); + self.note_statement(&bound_for_state); } Err(e) => { self.send_error(buf, &e.to_string()); + self.note_failure(); } } @@ -1053,6 +1398,23 @@ impl PostgresWireProtocol { let description = self.query_engine.describe_statement(&sql).await?; + if matches!(target, DescribeTarget::Statement) || description.returns_rows() { + // Remembered for Execute, which must encode each value in the + // format the client asked for and therefore needs its type. + let statement_name = match target { + DescribeTarget::Statement => name.to_string(), + DescribeTarget::Portal => self + .portals + .get(name) + .map(|(statement, _)| statement.clone()) + .unwrap_or_default(), + }; + self.statement_columns.insert( + statement_name, + description.columns.iter().map(|c| c.type_oid).collect(), + ); + } + if description.returns_rows() { let fields = description .columns @@ -1090,6 +1452,8 @@ impl PostgresWireProtocol { } super::messages::CloseTarget::Portal => { self.portals.remove(name); + self.portal_rows.remove(name); + self.portal_result_formats.remove(name); } } @@ -1124,6 +1488,504 @@ impl PostgresWireProtocol { self.send_query_result_without_description(result, buf); } + /// Handle `LISTEN`, `UNLISTEN` and `NOTIFY`, returning the command tag. + /// + /// Returns `None` for anything else, which then runs as an ordinary + /// statement. These are handled here rather than in the SQL engine because + /// they act on the connection, not on stored data. + async fn handle_notification_statement(&mut self, query: &str) -> Option { + let trimmed = query.trim().trim_end_matches(';').trim(); + let (head, rest) = match trimmed.split_once(char::is_whitespace) { + Some((head, rest)) => (head.to_ascii_uppercase(), rest.trim()), + None => (trimmed.to_ascii_uppercase(), ""), + }; + + match head.as_str() { + "LISTEN" if !rest.is_empty() => { + self.notifications + .listen( + rest, + self.session_notifications.id, + self.session_notifications.sender.clone(), + ) + .await; + Some("LISTEN".to_string()) + } + "UNLISTEN" => { + let channel = (rest != "*" && !rest.is_empty()).then_some(rest); + self.notifications + .unlisten(channel, self.session_notifications.id) + .await; + Some("UNLISTEN".to_string()) + } + "NOTIFY" if !rest.is_empty() => { + // `NOTIFY channel` or `NOTIFY channel, 'payload'`. + let (channel, payload) = match rest.split_once(',') { + Some((channel, payload)) => ( + channel.trim(), + payload.trim().trim_matches('\'').to_string(), + ), + None => (rest, String::new()), + }; + self.notifications + .notify(channel, &payload, self.process_id) + .await; + Some("NOTIFY".to_string()) + } + _ => None, + } + } + + /// Write any notifications waiting for this session. + /// + /// The protocol allows a NotificationResponse between messages, so they are + /// flushed at the points the session is already writing. + fn deliver_pending_notifications(&mut self, buf: &mut BytesMut) { + while let Ok(notification) = self.session_notifications.receiver.try_recv() { + BackendMessage::NotificationResponse { + process_id: notification.process_id, + channel: notification.channel, + payload: notification.payload, + } + .encode(buf); + } + } + + + /// Start a `COPY` statement, or return `None` if this is not one. + /// + /// Text format only: the binary format needs per-type encoders this engine + /// does not have, and accepting it while writing text would corrupt the + /// stream rather than fail. + async fn handle_copy_statement( + &mut self, + query: &str, + buf: &mut BytesMut, + send_ready: bool, + ) -> Option<()> { + let trimmed = query.trim().trim_end_matches(';').trim(); + if !trimmed.to_ascii_uppercase().starts_with("COPY ") { + return None; + } + + let upper = trimmed.to_ascii_uppercase(); + if upper.contains(" BINARY") || upper.contains("FORMAT BINARY") { + self.send_error(buf, "COPY BINARY is not supported; use the text format"); + self.finish_copy_statement(send_ready, buf); + return Some(()); + } + + // `COPY
[(cols)] TO STDOUT` / `FROM STDIN` + let body = trimmed[5..].trim(); + let to_stdout = upper.contains(" TO "); + let split_at = if to_stdout { + upper.find(" TO ") + } else { + upper.find(" FROM ") + }; + let Some(split_at) = split_at else { + self.send_error(buf, "COPY requires TO STDOUT or FROM STDIN"); + self.finish_copy_statement(send_ready, buf); + return Some(()); + }; + + let target = trimmed[5..split_at].trim(); + let (table, columns) = match target.split_once('(') { + Some((table, cols)) => ( + table.trim().to_string(), + cols.trim_end_matches(')') + .split(',') + .map(|c| c.trim().to_string()) + .collect::>(), + ), + None => (target.to_string(), Vec::new()), + }; + let _ = body; + + if to_stdout { + self.copy_table_to_stdout(&table, &columns, buf).await; + self.finish_copy_statement(send_ready, buf); + } else { + self.begin_copy_from_stdin(&table, columns, buf).await; + } + Some(()) + } + + /// Close out a COPY statement on the simple query path. + /// + /// The extended protocol sends ReadyForQuery in response to Sync instead, + /// so sending one here too would leave the client a message ahead. + fn finish_copy_statement(&mut self, send_ready: bool, buf: &mut BytesMut) { + if send_ready { + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(buf); + } + } + + /// Stream a table to the client as `COPY ... TO STDOUT` text. + async fn copy_table_to_stdout( + &mut self, + table: &str, + columns: &[String], + buf: &mut BytesMut, + ) { + let projection = if columns.is_empty() { + "*".to_string() + } else { + columns.join(", ") + }; + + let result = self + .query_engine + .execute_query(&format!("SELECT {projection} FROM {table}")) + .await; + + let (column_count, rows) = match result { + Ok(QueryResult::Select { columns, rows }) => (columns.len(), rows), + Ok(_) => (0, Vec::new()), + Err(e) => { + self.send_error(buf, &e.to_string()); + return; + } + }; + + BackendMessage::CopyOutResponse { + format: 0, + column_formats: vec![0; column_count], + } + .encode(buf); + + for row in &rows { + let line = row + .iter() + .map(|value| match value { + // `\N` is how the text format spells NULL, and is why a + // literal backslash has to be escaped. + None => "\\N".to_string(), + Some(text) => text + .replace('\\', "\\\\") + .replace('\t', "\\t") + .replace('\n', "\\n") + .replace('\r', "\\r"), + }) + .collect::>() + .join("\t"); + BackendMessage::CopyData { + data: bytes::Bytes::from(format!("{line}\n")), + } + .encode(buf); + } + + BackendMessage::CopyDone.encode(buf); + BackendMessage::CommandComplete { + tag: format!("COPY {}", rows.len()), + } + .encode(buf); + } + + /// Put the session into copy-in mode and invite the client to stream. + async fn begin_copy_from_stdin( + &mut self, + table: &str, + columns: Vec, + buf: &mut BytesMut, + ) { + // Column order has to be known before the first row arrives; when the + // statement did not name any, the table's own order is used. + let schema = match self.query_engine.table_schema(table).await { + Ok(schema) => schema, + Err(e) => { + self.send_error(buf, &e.to_string()); + return; + } + }; + + let columns = if columns.is_empty() { + match &schema { + Some(schema) => schema.columns.iter().map(|c| c.name.clone()).collect(), + None => { + self.send_error(buf, &format!("Table '{table}' does not exist")); + return; + } + } + } else { + columns + }; + + use crate::protocols::postgres_wire::persistent_storage::ColumnType; + let numeric_columns: Vec = columns + .iter() + .map(|name| { + schema.as_ref().is_some_and(|schema| { + schema + .columns + .iter() + .find(|c| c.name.eq_ignore_ascii_case(name)) + .is_some_and(|c| { + matches!( + c.data_type, + ColumnType::Serial + | ColumnType::Integer + | ColumnType::BigInt + | ColumnType::Double + | ColumnType::Boolean + ) + }) + }) + }) + .collect(); + + BackendMessage::CopyInResponse { + format: 0, + column_formats: vec![0; columns.len()], + } + .encode(buf); + + self.copy_in = Some(CopyInState { + simple_protocol: self.copy_in_is_simple, + table: table.to_string(), + numeric_columns, + columns, + partial: Vec::new(), + rows: 0, + failure: None, + }); + } + + /// Consume one chunk of copy-in data. + async fn handle_copy_data( + &mut self, + data: &[u8], + buf: &mut BytesMut, + ) -> ProtocolResult<()> { + if self.copy_in.is_none() { + self.send_error(buf, "CopyData received while not in copy-in mode"); + return Ok(()); + } + + // Chunks split rows arbitrarily, so only whole lines are consumed and + // the remainder is carried to the next message. + let mut pending = { + let state = self.copy_in.as_mut().expect("checked above"); + state.partial.extend_from_slice(data); + std::mem::take(&mut state.partial) + }; + + let mut consumed = 0usize; + while let Some(newline) = pending[consumed..].iter().position(|b| *b == b'\n') { + let line_end = consumed + newline; + let line = String::from_utf8_lossy(&pending[consumed..line_end]).into_owned(); + consumed = line_end + 1; + self.insert_copy_line(line.trim_end_matches('\r')).await?; + } + + pending.drain(..consumed); + if let Some(state) = self.copy_in.as_mut() { + state.partial = pending; + } + Ok(()) + } + + /// Insert one text-format COPY line. + async fn insert_copy_line(&mut self, line: &str) -> ProtocolResult<()> { + // The end-of-data marker is a line containing only `\.`. + if line.is_empty() || line == "\\." { + return Ok(()); + } + + let Some(state) = self.copy_in.as_ref() else { + return Ok(()); + }; + + let values: Vec = line + .split('\t') + .enumerate() + .map(|(index, field)| { + if field == "\\N" { + return "NULL".to_string(); + } + let unescaped = field + .replace("\\t", "\t") + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\\\", "\\"); + + // Bare only where the column takes a bare literal *and* the + // value really is one; anything else is quoted so a stray field + // cannot become syntax. + let numeric = state.numeric_columns.get(index).copied().unwrap_or(false); + if numeric && Self::is_safe_bare_literal(&unescaped) { + unescaped + } else { + format!("'{}'", unescaped.replace('\'', "''")) + } + }) + .collect(); + + let statement = format!( + "INSERT INTO {} ({}) VALUES ({})", + state.table, + state.columns.join(", "), + values.join(", ") + ); + + match self.query_engine.execute_query(&statement).await { + Ok(_) => { + if let Some(state) = self.copy_in.as_mut() { + state.rows += 1; + } + Ok(()) + } + // Recorded rather than returned: an error raised here would be + // written to a client that is streaming data and expecting no + // messages at all, which desynchronises the connection. It is + // reported when the stream ends. + Err(e) => { + if let Some(state) = self.copy_in.as_mut() { + if state.failure.is_none() { + state.failure = Some(e.to_string()); + } + } + Ok(()) + } + } + } + + /// Finish a copy-in stream. + async fn handle_copy_done(&mut self, buf: &mut BytesMut) -> ProtocolResult<()> { + let Some(state) = self.copy_in.take() else { + self.send_error(buf, "CopyDone received while not in copy-in mode"); + return Ok(()); + }; + + match &state.failure { + Some(failure) => self.send_error(buf, failure), + None => BackendMessage::CommandComplete { + tag: format!("COPY {}", state.rows), + } + .encode(buf), + } + self.finish_copy_statement(state.simple_protocol, buf); + Ok(()) + } + + /// Send at most `max_rows` rows of a portal, starting at `already_sent`. + /// + /// `max_rows == 0` means "no limit", per the protocol. When rows remain + /// after the limit is reached the reply is `PortalSuspended` rather than + /// `CommandComplete`: that is what tells the client to ask for the next + /// page instead of concluding the result set ended. + fn send_portal_page( + &mut self, + portal: &str, + columns: &[String], + rows: &[Vec>], + already_sent: usize, + max_rows: i32, + buf: &mut BytesMut, + ) { + let _ = columns; + let limit = if max_rows <= 0 { + rows.len().saturating_sub(already_sent) + } else { + (max_rows as usize).min(rows.len().saturating_sub(already_sent)) + }; + + let formats = self + .portal_result_formats + .get(portal) + .cloned() + .unwrap_or_default(); + let column_types = self + .portals + .get(portal) + .and_then(|(statement, _)| self.statement_columns.get(statement)) + .cloned() + .unwrap_or_default(); + + for row in rows.iter().skip(already_sent).take(limit) { + let values: Vec> = row + .iter() + .enumerate() + .map(|(index, value)| { + let text = value.as_ref()?; + let binary = match formats.len() { + 0 => false, + 1 => formats[0] == 1, + _ => formats.get(index).is_some_and(|f| *f == 1), + }; + if !binary { + return Some(bytes::Bytes::from(text.clone())); + } + let type_oid = column_types.get(index).copied().unwrap_or(type_oids::TEXT); + Some(Self::encode_binary_value(text, type_oid)) + }) + .collect(); + BackendMessage::DataRow { values }.encode(buf); + } + + let sent = already_sent + limit; + if sent < rows.len() { + if let Some(state) = self.portal_rows.get_mut(portal) { + state.sent = sent; + } + BackendMessage::PortalSuspended.encode(buf); + } else { + self.portal_rows.remove(portal); + BackendMessage::CommandComplete { + tag: format!("SELECT {sent}"), + } + .encode(buf); + } + } + + /// Encode one value in PostgreSQL's binary format for `type_oid`. + /// + /// The engine holds every value as text, so this parses and re-encodes. + /// A value that will not parse as its declared type is sent as its text + /// bytes: that is what the value actually is, and it keeps a type + /// mismatch in the catalogue from corrupting unrelated columns in the row. + fn encode_binary_value(text: &str, type_oid: i32) -> bytes::Bytes { + fn bytes_of(vector: Vec) -> bytes::Bytes { + bytes::Bytes::from(vector) + } + + match type_oid { + type_oids::BOOL => { + let value = matches!( + text.to_ascii_lowercase().as_str(), + "t" | "true" | "1" | "yes" | "on" + ); + bytes_of(vec![u8::from(value)]) + } + type_oids::INT2 => text + .parse::() + .map(|n| bytes_of(n.to_be_bytes().to_vec())) + .unwrap_or_else(|_| bytes::Bytes::from(text.to_string())), + type_oids::INT4 => text + .parse::() + .map(|n| bytes_of(n.to_be_bytes().to_vec())) + .unwrap_or_else(|_| bytes::Bytes::from(text.to_string())), + type_oids::INT8 => text + .parse::() + .map(|n| bytes_of(n.to_be_bytes().to_vec())) + .unwrap_or_else(|_| bytes::Bytes::from(text.to_string())), + type_oids::FLOAT4 => text + .parse::() + .map(|n| bytes_of(n.to_be_bytes().to_vec())) + .unwrap_or_else(|_| bytes::Bytes::from(text.to_string())), + type_oids::FLOAT8 => text + .parse::() + .map(|n| bytes_of(n.to_be_bytes().to_vec())) + .unwrap_or_else(|_| bytes::Bytes::from(text.to_string())), + // text, json, uuid and anything unrecognised are the same bytes in + // both formats. + _ => bytes::Bytes::from(text.to_string()), + } + } + /// Send rows and the command tag, without a row description. /// /// This is what `Execute` must send: in the extended query protocol the row @@ -1288,6 +2150,7 @@ mod extended_protocol_tests { let bound = PostgresWireProtocol::bind_parameters( "SELECT * FROM t WHERE a = $1 AND b = $2", &[param("one"), param("two")], + &[], ) .expect("both parameters are bound"); assert_eq!(bound, "SELECT * FROM t WHERE a = 'one' AND b = 'two'"); @@ -1296,13 +2159,13 @@ mod extended_protocol_tests { #[test] fn a_repeated_placeholder_uses_the_same_value_each_time() { let bound = - PostgresWireProtocol::bind_parameters("SELECT $1, $1", &[param("x")]).expect("bound"); + PostgresWireProtocol::bind_parameters("SELECT $1, $1", &[param("x")], &[]).expect("bound"); assert_eq!(bound, "SELECT 'x', 'x'"); } #[test] fn a_null_parameter_becomes_sql_null_not_an_empty_string() { - let bound = PostgresWireProtocol::bind_parameters("SELECT $1", &[None]).expect("bound"); + let bound = PostgresWireProtocol::bind_parameters("SELECT $1", &[None], &[]).expect("bound"); assert_eq!(bound, "SELECT NULL"); } @@ -1313,6 +2176,7 @@ mod extended_protocol_tests { let bound = PostgresWireProtocol::bind_parameters( "SELECT * FROM t WHERE name = $1", &[param("O'Brien")], + &[], ) .expect("bound"); assert_eq!(bound, "SELECT * FROM t WHERE name = 'O''Brien'"); @@ -1323,6 +2187,7 @@ mod extended_protocol_tests { let bound = PostgresWireProtocol::bind_parameters( "SELECT * FROM t WHERE name = $1", &[param("x'; DROP TABLE users; --")], + &[], ) .expect("bound"); assert_eq!( @@ -1337,7 +2202,7 @@ mod extended_protocol_tests { #[test] fn a_placeholder_inside_a_literal_is_left_untouched() { let bound = - PostgresWireProtocol::bind_parameters("SELECT '$1', $1", &[param("v")]).expect("bound"); + PostgresWireProtocol::bind_parameters("SELECT '$1', $1", &[param("v")], &[]).expect("bound"); assert_eq!(bound, "SELECT '$1', 'v'"); } @@ -1345,7 +2210,7 @@ mod extended_protocol_tests { /// the wrong rows, so an unbound reference must fail loudly. #[test] fn referencing_an_unbound_parameter_is_an_error() { - let error = PostgresWireProtocol::bind_parameters("SELECT $2", &[param("only-one")]) + let error = PostgresWireProtocol::bind_parameters("SELECT $2", &[param("only-one")], &[]) .expect_err("only one parameter was bound"); assert!( error.to_string().contains("$2"), @@ -1356,7 +2221,7 @@ mod extended_protocol_tests { #[test] fn a_statement_without_placeholders_is_unchanged() { let bound = - PostgresWireProtocol::bind_parameters("SELECT 1 FROM t", &[]).expect("bound"); + PostgresWireProtocol::bind_parameters("SELECT 1 FROM t", &[], &[]).expect("bound"); assert_eq!(bound, "SELECT 1 FROM t"); } @@ -1426,4 +2291,86 @@ mod extended_protocol_tests { assert!(QueryEngine::placeholder_comparisons_for_test("SELECT $1").is_empty()); } + + #[test] + fn numeric_parameters_are_spliced_without_quotes() { + let bound = PostgresWireProtocol::bind_parameters( + "SELECT * FROM t WHERE id = $1", + &[param("42")], + &[type_oids::INT4], + ) + .expect("bound"); + // Quoting this would compare an integer column against a string and + // match nothing. + assert_eq!(bound, "SELECT * FROM t WHERE id = 42"); + } + + #[test] + fn boolean_parameters_are_spliced_without_quotes() { + let bound = + PostgresWireProtocol::bind_parameters("SELECT $1", &[param("true")], &[type_oids::BOOL]) + .expect("bound"); + assert_eq!(bound, "SELECT true"); + } + + #[test] + fn text_parameters_stay_quoted_even_when_they_look_numeric() { + let bound = PostgresWireProtocol::bind_parameters( + "SELECT $1", + &[param("42")], + &[type_oids::TEXT], + ) + .expect("bound"); + assert_eq!(bound, "SELECT '42'"); + } + + /// A value claiming to be numeric but carrying SQL must never be spliced + /// bare, whatever the declared type says. + #[test] + fn a_non_numeric_value_declared_numeric_is_still_quoted() { + let bound = PostgresWireProtocol::bind_parameters( + "SELECT * FROM t WHERE id = $1", + &[param("1; DROP TABLE users")], + &[type_oids::INT4], + ) + .expect("bound"); + assert_eq!(bound, "SELECT * FROM t WHERE id = '1; DROP TABLE users'"); + } + + #[test] + fn binary_encoding_matches_postgres_wire_widths() { + use super::super::messages::type_oids; + + let encode = PostgresWireProtocol::encode_binary_value; + assert_eq!(encode("5", type_oids::INT4).as_ref(), &5i32.to_be_bytes()); + assert_eq!(encode("-7", type_oids::INT8).as_ref(), &(-7i64).to_be_bytes()); + assert_eq!(encode("300", type_oids::INT2).as_ref(), &300i16.to_be_bytes()); + assert_eq!(encode("1.5", type_oids::FLOAT8).as_ref(), &1.5f64.to_be_bytes()); + assert_eq!(encode("true", type_oids::BOOL).as_ref(), &[1u8]); + assert_eq!(encode("f", type_oids::BOOL).as_ref(), &[0u8]); + } + + /// Text is identical in both formats, so it must not be transformed. + #[test] + fn text_is_unchanged_by_binary_encoding() { + use super::super::messages::type_oids; + + assert_eq!( + PostgresWireProtocol::encode_binary_value("hello", type_oids::TEXT).as_ref(), + b"hello" + ); + } + + /// A value that does not parse as its declared type falls back to its own + /// bytes rather than emitting a wrong-width field that would desynchronise + /// the client's read of the rest of the row. + #[test] + fn an_unparseable_value_falls_back_to_its_text_bytes() { + use super::super::messages::type_oids; + + assert_eq!( + PostgresWireProtocol::encode_binary_value("not-a-number", type_oids::INT4).as_ref(), + b"not-a-number" + ); + } } diff --git a/orbit/server/src/protocols/postgres_wire/query_engine.rs b/orbit/server/src/protocols/postgres_wire/query_engine.rs index efe0f095..3d5e4cc9 100644 --- a/orbit/server/src/protocols/postgres_wire/query_engine.rs +++ b/orbit/server/src/protocols/postgres_wire/query_engine.rs @@ -80,6 +80,13 @@ impl StatementDescription { } } +/// OID reported for the `public` namespace in `pg_namespace`. +const PUBLIC_NAMESPACE_OID: i64 = 2200; +/// First OID handed out to user tables in `pg_class`. +/// +/// PostgreSQL reserves everything below 16384 for built-in objects. +const FIRST_USER_OID: i64 = 16_384; + /// Fold a SQL identifier the way PostgreSQL does. /// /// An unquoted identifier folds to lower case; a double-quoted one keeps the @@ -172,6 +179,9 @@ enum Statement { table: String, if_exists: bool, }, + Truncate { + table: String, + }, } /// Simple column definition for basic DDL support @@ -343,7 +353,8 @@ impl QueryEngine { | Statement::Update { .. } | Statement::Delete { .. } | Statement::CreateTable { .. } - | Statement::DropTable { .. } => Ok(StatementDescription::no_data()), + | Statement::DropTable { .. } + | Statement::Truncate { .. } => Ok(StatementDescription::no_data()), } } @@ -390,6 +401,259 @@ impl QueryEngine { out } + /// Column names a SELECT's projection asks for, or `["*"]` for all. + fn projection_names( + select: &crate::protocols::postgres_wire::sql::ast::SelectStatement, + ) -> Vec { + use crate::protocols::postgres_wire::sql::ast::{Expression, SelectItem}; + + let mut names = Vec::new(); + for item in &select.select_list { + match item { + SelectItem::Wildcard | SelectItem::QualifiedWildcard { .. } => { + return vec!["*".to_string()] + } + SelectItem::Expression { expr, alias } => { + let name = match (alias, expr) { + (Some(alias), _) => alias.clone(), + (None, Expression::Column(column)) => column.name.clone(), + // Anything that is not a plain column cannot be + // projected from a synthesised catalogue row. + (None, _) => return vec!["*".to_string()], + }; + names.push(name); + } + } + } + + if names.is_empty() { + vec!["*".to_string()] + } else { + names + } + } + + /// Serve a query against a system catalogue relation. + /// + /// Returns `None` when `table` is not one, so ordinary tables fall through. + /// + /// Only relations this server can answer truthfully are provided, and each + /// row describes something that actually exists here — the tables really + /// present, the types really advertised on the wire. Clients read these to + /// decide what the server supports, so inventing entries would make them + /// use features that are not implemented. + /// + /// # Errors + /// Returns an error when the catalogue cannot be read. + async fn select_system_catalog( + &self, + table: &str, + columns: &[String], + ) -> ProtocolResult> { + use super::messages::type_oids; + + // `pg_class` and `pg_catalog.pg_class` name the same relation. + let relation = table + .rsplit('.') + .next() + .unwrap_or(table) + .to_ascii_lowercase(); + let qualifier = table.rsplit_once('.').map(|(schema, _)| schema.to_ascii_lowercase()); + let is_information_schema = qualifier.as_deref() == Some("information_schema"); + + // Only answer for the catalogue schemas, so a user table called + // `pg_class` in the default schema is still their table. + if !matches!(qualifier.as_deref(), Some("pg_catalog") | Some("information_schema") | None) { + return Ok(None); + } + if qualifier.is_none() && !relation.starts_with("pg_") { + return Ok(None); + } + + let tables = self.list_tables().await?.unwrap_or_default(); + + let (all_columns, rows): (Vec<&str>, Vec>>) = + match (is_information_schema, relation.as_str()) { + (false, "pg_class") => ( + vec!["oid", "relname", "relnamespace", "relkind"], + tables + .iter() + .enumerate() + .map(|(index, name)| { + vec![ + Some((FIRST_USER_OID + index as i64).to_string()), + Some(name.clone()), + Some(PUBLIC_NAMESPACE_OID.to_string()), + // Only ordinary tables exist here; no views, + // indexes or sequences are reported because + // none are implemented. + Some("r".to_string()), + ] + }) + .collect(), + ), + (false, "pg_namespace") => ( + vec!["oid", "nspname"], + vec![ + vec![ + Some(PUBLIC_NAMESPACE_OID.to_string()), + Some("public".to_string()), + ], + vec![Some("11".to_string()), Some("pg_catalog".to_string())], + ], + ), + (false, "pg_type") => ( + vec!["oid", "typname", "typtype", "typelem", "typbasetype", "typrelid"], + [ + (type_oids::BOOL, "bool"), + (type_oids::BYTEA, "bytea"), + (type_oids::INT8, "int8"), + (type_oids::INT2, "int2"), + (type_oids::INT4, "int4"), + (type_oids::TEXT, "text"), + (type_oids::JSON, "json"), + (type_oids::FLOAT4, "float4"), + (type_oids::FLOAT8, "float8"), + (type_oids::VARCHAR, "varchar"), + (type_oids::TIMESTAMP, "timestamp"), + (type_oids::TIMESTAMPTZ, "timestamptz"), + (type_oids::UUID, "uuid"), + (type_oids::JSONB, "jsonb"), + ] + .into_iter() + .map(|(oid, name)| { + vec![ + Some(oid.to_string()), + Some(name.to_string()), + // Base type, no element, no composite relation. + Some("b".to_string()), + Some("0".to_string()), + Some("0".to_string()), + Some("0".to_string()), + ] + }) + .collect(), + ), + (true, "tables") => ( + vec!["table_catalog", "table_schema", "table_name", "table_type"], + tables + .iter() + .map(|name| { + vec![ + Some("orbit".to_string()), + Some("public".to_string()), + Some(name.clone()), + Some("BASE TABLE".to_string()), + ] + }) + .collect(), + ), + (true, "schemata") => ( + vec!["catalog_name", "schema_name"], + vec![vec![Some("orbit".to_string()), Some("public".to_string())]], + ), + _ => return Ok(None), + }; + + let all_columns: Vec = all_columns.into_iter().map(str::to_string).collect(); + + // Honour an explicit select list by projecting; `*` keeps every column. + let selects_everything = columns.len() == 1 && columns[0] == "*"; + if selects_everything { + return Ok(Some(QueryResult::Select { + columns: all_columns, + rows, + })); + } + + let wanted: Vec = columns.iter().map(|c| fold_identifier(c)).collect(); + let indices: Vec> = wanted + .iter() + .map(|want| all_columns.iter().position(|have| have == want)) + .collect(); + + let projected = rows + .into_iter() + .map(|row| { + indices + .iter() + .map(|index| index.and_then(|i| row.get(i).cloned().flatten())) + .collect() + }) + .collect(); + + Ok(Some(QueryResult::Select { + columns: wanted, + rows: projected, + })) + } + + /// Copy a table's current contents, for restoring on rollback. + /// + /// # Errors + /// Returns an error when the table cannot be read. + pub async fn snapshot_table(&self, table: &str) -> ProtocolResult>> { + let Some(storage) = &self.persistent_storage else { + return Ok(None); + }; + let table = fold_identifier(table); + if !storage.table_exists(&table).await? { + return Ok(None); + } + storage + .select_rows(&table, Vec::new(), Vec::new(), None) + .await + .map(Some) + } + + /// Put a table back to a previously taken snapshot. + /// + /// Every row is removed and the snapshot re-inserted, so the table matches + /// the moment the snapshot was taken. + /// + /// # Errors + /// Returns an error when the table cannot be written. + pub async fn restore_table(&self, table: &str, rows: Vec) -> ProtocolResult<()> { + let Some(storage) = &self.persistent_storage else { + return Ok(()); + }; + let table = fold_identifier(table); + + // An empty condition list matches every row. + storage.delete_rows(&table, Vec::new()).await?; + for row in rows { + storage.insert_row(&table, row).await?; + } + Ok(()) + } + + /// The table a write statement targets, if it names one. + /// + /// Used to decide what to snapshot when a transaction block opens a write. + pub fn write_target_table(sql: &str) -> Option { + let trimmed = sql.trim(); + let upper = trimmed.to_uppercase(); + + let after = if let Some(rest) = upper.strip_prefix("INSERT INTO ") { + &trimmed[trimmed.len() - rest.len()..] + } else if let Some(rest) = upper.strip_prefix("UPDATE ") { + &trimmed[trimmed.len() - rest.len()..] + } else if let Some(rest) = upper.strip_prefix("DELETE FROM ") { + &trimmed[trimmed.len() - rest.len()..] + } else if let Some(rest) = upper.strip_prefix("TRUNCATE TABLE ") { + &trimmed[trimmed.len() - rest.len()..] + } else if let Some(rest) = upper.strip_prefix("COPY ") { + &trimmed[trimmed.len() - rest.len()..] + } else { + return None; + }; + + after + .split(|c: char| c.is_whitespace() || c == '(') + .find(|token| !token.is_empty()) + .map(fold_identifier) + } + /// Names of the tables this engine can see. /// /// `None` when no persistent storage is attached, which is different from @@ -464,7 +728,9 @@ impl QueryEngine { | Statement::Update { table, .. } | Statement::Delete { table, .. } => (table.clone(), None), Statement::Insert { table, columns, .. } => (table.clone(), Some(columns.clone())), - Statement::CreateTable { .. } | Statement::DropTable { .. } => return Ok(types), + Statement::CreateTable { .. } + | Statement::DropTable { .. } + | Statement::Truncate { .. } => return Ok(types), }; let Some(storage) = &self.persistent_storage else { @@ -654,47 +920,118 @@ impl QueryEngine { Ok(StatementDescription::returning(described_columns)) } - /// Statements whose shape cannot be derived from the catalogue. + /// Describe a statement the simple parser cannot, without running it. /// - /// `Execute` never sends a `RowDescription` — only `Describe` does — so - /// answering `NoData` for a statement that does return rows makes the client - /// discard them. For a read-only statement the shape is therefore learned by - /// running it here and keeping only the column names; the rows are thrown - /// away and fetched again by `Execute`, which costs a second execution but - /// cannot report a result set that does not match what arrives. - /// - /// Anything not recognisably read-only is described as returning no rows - /// rather than being run: `Describe` must not have side effects. + /// This used to *execute* read-only statements to learn their shape, which + /// meant every extended-protocol query ran twice — once for `Describe` and + /// once for `Execute` — with all the work that implies. It also made + /// `Describe` a side-effecting operation. The full parser can name the + /// output columns directly, so nothing needs to run. async fn describe_by_probing( &self, sql: &str, sql_upper: &str, ) -> ProtocolResult { - const READ_ONLY_PREFIXES: [&str; 6] = + use crate::protocols::postgres_wire::sql::ast::Statement as AstStatement; + use crate::protocols::postgres_wire::sql::parser::SqlParser; + use crate::protocols::postgres_wire::sql::select_pipeline; + + const ROW_RETURNING_PREFIXES: [&str; 6] = ["SELECT", "SHOW", "WITH", "EXPLAIN", "VALUES", "TABLE"]; - if !READ_ONLY_PREFIXES + if !ROW_RETURNING_PREFIXES .iter() .any(|keyword| sql_upper.starts_with(keyword)) { return Ok(StatementDescription::no_data()); } - match self.execute_query(sql).await { - Ok(QueryResult::Select { columns, .. }) - | Ok(QueryResult::Merge { columns, .. }) => { - Ok(StatementDescription::returning_text(columns)) + let Ok(statement) = SqlParser::new().parse(sql) else { + return Ok(StatementDescription::no_data()); + }; + + let AstStatement::Select(select) = statement else { + return Ok(StatementDescription::no_data()); + }; + + let names = select_pipeline::output_column_names(&select); + + // A wildcard is expanded from the table's schema, so the description + // matches the row that `Execute` will send. + if names.iter().any(|name| name == "*") { + let table = select + .from_clause + .as_ref() + .and_then(Self::from_clause_table_name); + + if let (Some(table), Some(storage)) = (table, &self.persistent_storage) { + if let Some(schema) = storage.get_table_schema(&fold_identifier(&table)).await? { + return Ok(StatementDescription::returning( + schema + .columns + .iter() + .map(|column| ColumnDescription { + name: fold_identifier(&column.name), + type_oid: column_type_oid(&column.data_type), + }) + .collect(), + )); + } } - Ok(_) => Ok(StatementDescription::no_data()), - // A statement that fails here will fail the same way at `Execute`, - // which is where the client should see the error. - Err(e) => { - tracing::debug!("describe probe failed, reporting no rows: {e}"); - Ok(StatementDescription::no_data()) + return Ok(StatementDescription::no_data()); + } + + // A plain column takes its declared type, so a client that asks for + // binary results decodes it correctly. Reporting everything as text + // made an integer column arrive as digits, which drivers reject when + // asked for an i32. + let table = select + .from_clause + .as_ref() + .and_then(Self::from_clause_table_name); + + let schema = match (&table, &self.persistent_storage) { + (Some(table), Some(storage)) => { + storage.get_table_schema(&fold_identifier(table)).await? } + _ => None, + }; + + let described = names + .into_iter() + .map(|name| { + let type_oid = schema + .as_ref() + .and_then(|schema| { + schema + .columns + .iter() + .find(|column| column.name.eq_ignore_ascii_case(&name)) + }) + .map_or(super::messages::type_oids::TEXT, |column| { + column_type_oid(&column.data_type) + }); + ColumnDescription { name, type_oid } + }) + .collect(); + + Ok(StatementDescription::returning(described)) + } + + /// Table named by a simple FROM clause, if it is one. + fn from_clause_table_name( + from: &crate::protocols::postgres_wire::sql::ast::FromClause, + ) -> Option { + use crate::protocols::postgres_wire::sql::ast::FromClause; + + match from { + FromClause::Table { name, .. } => Some(name.full_name()), + _ => None, } } + + /// Execute a SQL query and return results pub async fn execute_query(&self, sql: &str) -> ProtocolResult { let sql_upper = sql.trim().to_uppercase(); @@ -717,13 +1054,26 @@ impl QueryEngine { } } - // Try to parse and execute with the simple parser first + // Try to parse and execute with the simple parser // For unsupported statements, fall back to the comprehensive SQL engine let statement = match self.parse_sql(sql) { Ok(stmt) => stmt, Err(_) => { + // Clause-bearing SELECTs are executed over the rows in + // persistent storage — the same rows a plain SELECT reads. + // Sending them to the comprehensive engine instead meant the + // two answered from different copies of the table, so + // `SELECT id FROM t` and `SELECT id FROM t ORDER BY id` + // disagreed about how many rows existed. + if let Some(result) = self.select_over_storage(sql).await? { + return Ok(result); + } + // Fall back to comprehensive SQL engine for unsupported statements - return self.execute_with_comprehensive_engine(sql).await; + return match self.execute_with_comprehensive_engine(sql).await { + Ok(result) => Ok(result), + Err(e) => Err(self.explain_unsupported_query(sql, e).await), + }; } }; @@ -734,6 +1084,9 @@ impl QueryEngine { table, where_clause, } => { + if let Some(result) = self.select_system_catalog(&table, &columns).await? { + return Ok(result); + } if table.to_uppercase() == "ACTORS" { self.execute_actor_select(columns, &table, where_clause) .await @@ -807,6 +1160,12 @@ impl QueryEngine { )) } } + Statement::Truncate { table } => match self.persistent_storage { + Some(ref storage) => self.execute_truncate(storage, &table).await, + None => Err(ProtocolError::PostgresError( + "Persistent storage not enabled".to_string(), + )), + }, Statement::DropTable { table, if_exists } => { println!( "DEBUG: Executing DropTable. Storage present: {}", @@ -851,6 +1210,22 @@ impl QueryEngine { use crate::protocols::postgres_wire::persistent_storage::ColumnType; use crate::protocols::postgres_wire::sql::ast::Statement as AstStatement; + // Catalogue relations are answered before storage is consulted, so + // both query paths — the simple parser and this AST one — serve them. + // psql and every ORM read these during connection setup. + if let AstStatement::Select(select) = &stmt { + if let Some(from_clause) = &select.from_clause { + if let Some(table_name) = self.extract_table_name_from_from_clause(from_clause) { + let projection = Self::projection_names(select); + if let Some(result) = + self.select_system_catalog(&table_name, &projection).await? + { + return Ok(result); + } + } + } + } + // Check if we can execute this persistently if let Some(ref storage) = self.persistent_storage { match &stmt { @@ -940,7 +1315,12 @@ impl QueryEngine { let mut evaluator = ExpressionEvaluator::new(); let context = EvaluationContext::empty(); let val = evaluator.evaluate(expr, &context)?; - row_values.push(val.to_postgres_string()); + // Rendered as a SQL literal, not as display + // text: `to_postgres_string` turns NULL into an + // empty string and a boolean into "t"/"f", + // which then stored as text rather than as the + // values they are. + row_values.push(Self::sql_value_to_literal(&val)); } values_list.push(row_values); } @@ -1146,6 +1526,8 @@ impl QueryEngine { self.parse_create_table(original_sql) } else if sql.starts_with("DROP TABLE") { self.parse_drop_table(original_sql) + } else if sql.starts_with("TRUNCATE") { + Self::parse_truncate(original_sql) } else { Err(ProtocolError::PostgresError(format!( "Unsupported SQL statement: {sql}" @@ -1153,8 +1535,466 @@ impl QueryEngine { } } + /// Run a `SELECT` over the rows held in persistent storage. + /// + /// Returns `None` when the statement is not a single-table SELECT of a + /// stored table, leaving it to the engine that can handle it. + /// + /// # Errors + /// Returns an error when storage cannot be read or an expression cannot be + /// evaluated. + async fn select_over_storage(&self, sql: &str) -> ProtocolResult> { + use crate::protocols::postgres_wire::sql::ast::Statement as AstStatement; + use crate::protocols::postgres_wire::sql::parser::SqlParser; + use crate::protocols::postgres_wire::sql::select_pipeline; + + if self.persistent_storage.is_none() { + return Ok(None); + } + + let Ok(statement) = SqlParser::new().parse(sql) else { + return Ok(None); + }; + let AstStatement::Select(select) = statement else { + return Ok(None); + }; + + // Set operations combine two result sets, which this path does not do. + if select.set_operation.is_some() { + return Ok(None); + } + + let Some(from) = select.from_clause.as_ref() else { + return Ok(None); + }; + + let Some((rows, column_order)) = self.rows_from_clause(from).await? else { + return Ok(None); + }; + + // Subqueries are executed here and replaced by the values they yield, + // so the expression evaluator — which has no access to storage — never + // has to run one. + let mut select = *select; + if let Some(predicate) = select.where_clause.take() { + select.where_clause = Some(self.resolve_subqueries(predicate).await?); + } + if let Some(having) = select.having.take() { + select.having = Some(self.resolve_subqueries(having).await?); + } + + let output = select_pipeline::run_select(&select, rows)?; + + // A wildcard is named by the pipeline as `*`; the real names come from + // the tables involved, in declaration order. + let columns = if output.columns.iter().any(|name| name == "*") { + column_order + } else { + output.columns + }; + + Ok(Some(QueryResult::Select { + columns, + rows: output.rows, + })) + } + + /// Replace subqueries in an expression with the values they produce. + /// + /// A scalar subquery becomes its single value, `IN (SELECT ...)` becomes an + /// explicit list, and `EXISTS (SELECT ...)` becomes a boolean. Correlated + /// subqueries — those referring to the outer row — are left in place and + /// reported by the evaluator, because they cannot be reduced to a constant + /// before the outer row is known. + fn resolve_subqueries<'a>( + &'a self, + expr: crate::protocols::postgres_wire::sql::ast::Expression, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = ProtocolResult, + > + Send + + 'a, + >, + > { + Box::pin(async move { + use crate::protocols::postgres_wire::sql::ast::{Expression, InList}; + use crate::protocols::postgres_wire::sql::types::SqlValue; + + Ok(match expr { + Expression::Subquery(select) => { + let values = self.run_subquery(&select).await?; + // A scalar subquery with no rows is NULL in SQL. + Expression::Literal(values.into_iter().next().unwrap_or(SqlValue::Null)) + } + Expression::Exists(select) => { + let values = self.run_subquery(&select).await?; + Expression::Literal(SqlValue::Boolean(!values.is_empty())) + } + Expression::In { + expr, + list: InList::Subquery(select), + negated, + } => { + let values = self.run_subquery(&select).await?; + Expression::In { + expr: Box::new(self.resolve_subqueries(*expr).await?), + list: InList::Expressions( + values.into_iter().map(Expression::Literal).collect(), + ), + negated, + } + } + Expression::Binary { + left, + operator, + right, + } => Expression::Binary { + left: Box::new(self.resolve_subqueries(*left).await?), + operator, + right: Box::new(self.resolve_subqueries(*right).await?), + }, + other => other, + }) + }) + } + + /// Run a subquery and return its first column, one value per row. + /// + /// # Errors + /// Returns an error when the subquery cannot be executed over storage. + async fn run_subquery( + &self, + select: &crate::protocols::postgres_wire::sql::ast::SelectStatement, + ) -> ProtocolResult> { + use crate::protocols::postgres_wire::sql::types::SqlValue; + + // Rendered back to SQL would lose fidelity, so the statement is + // executed directly through the same storage path. + let Some(from) = select.from_clause.as_ref() else { + return Err(ProtocolError::PostgresError( + "subquery without a FROM clause is not supported here".to_string(), + )); + }; + + let Some((rows, _)) = self.rows_from_clause(from).await? else { + return Err(ProtocolError::PostgresError( + "subquery reads a source this engine cannot assemble".to_string(), + )); + }; + + let output = + crate::protocols::postgres_wire::sql::select_pipeline::run_select(select, rows)?; + + // Typed the way an untyped SQL literal is: a value that reads as a + // number is a number. Returning everything as text made + // `WHERE amount = (SELECT MAX(amount) ...)` compare an integer against + // the string "30" and fail. + Ok(output + .rows + .into_iter() + .map(|row| match row.into_iter().next() { + Some(Some(text)) => Self::text_to_sql_value(&text), + _ => SqlValue::Null, + }) + .collect()) + } + + /// Interpret text the way an untyped SQL literal is interpreted. + fn text_to_sql_value(text: &str) -> crate::protocols::postgres_wire::sql::types::SqlValue { + use crate::protocols::postgres_wire::sql::types::SqlValue; + + if let Ok(n) = text.parse::() { + return SqlValue::Integer(n); + } + if let Ok(n) = text.parse::() { + return SqlValue::BigInt(n); + } + if let Ok(n) = text.parse::() { + return SqlValue::DoublePrecision(n); + } + match text { + "t" | "true" => SqlValue::Boolean(true), + "f" | "false" => SqlValue::Boolean(false), + other => SqlValue::Text(other.to_string()), + } + } + + /// Build the row set a FROM clause denotes, plus its column order. + /// + /// Handles a single table and joins of tables. Returns `None` for anything + /// else, so the statement falls through to an engine that may handle it. + /// + /// Joins are evaluated as a nested loop over the two sides. That is + /// quadratic and there is no index selection: acceptable for the table + /// sizes this engine holds, and the honest starting point — a plan that + /// claims to use an index it does not have would be worse. + fn rows_from_clause<'a>( + &'a self, + from: &'a crate::protocols::postgres_wire::sql::ast::FromClause, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = ProtocolResult< + Option<(Vec, Vec)>, + >, + > + Send + + 'a, + >, + > { + Box::pin(async move { + use crate::protocols::postgres_wire::sql::ast::{FromClause, JoinCondition, JoinType}; + use crate::protocols::postgres_wire::sql::expression_evaluator::{ + EvaluationContext, ExpressionEvaluator, + }; + use crate::protocols::postgres_wire::sql::select_pipeline::Row; + use crate::protocols::postgres_wire::sql::types::SqlValue; + + let Some(storage) = &self.persistent_storage else { + return Ok(None); + }; + + match from { + FromClause::Table { name, alias, .. } => { + let table = fold_identifier(&name.full_name()); + let Some(schema) = storage.get_table_schema(&table).await? else { + return Ok(None); + }; + + // Rows carry both the bare column name and its qualified + // form, so `a.id` and `id` both resolve after a join. + let qualifier = alias + .as_ref() + .map(|a| fold_identifier(&a.name)) + .unwrap_or_else(|| table.clone()); + + let stored = storage + .select_rows(&table, Vec::new(), Vec::new(), None) + .await?; + + let rows: Vec = stored + .into_iter() + .map(|row| { + let mut out = Row::new(); + for column in &schema.columns { + let value = row + .values + .get(&column.name) + .or_else(|| { + row.values.iter().find_map(|(key, value)| { + key.eq_ignore_ascii_case(&column.name) + .then_some(value) + }) + }) + .cloned() + .unwrap_or(JsonValue::Null); + let value = Self::json_to_sql_value(&value, &column.data_type); + let name = fold_identifier(&column.name); + out.insert(format!("{qualifier}.{name}"), value.clone()); + out.insert(name, value); + } + out + }) + .collect(); + + let order = schema + .columns + .iter() + .map(|column| fold_identifier(&column.name)) + .collect(); + + Ok(Some((rows, order))) + } + + FromClause::Join { + left, + join_type, + right, + condition, + } => { + let Some((left_rows, mut order)) = self.rows_from_clause(left).await? else { + return Ok(None); + }; + let Some((right_rows, right_order)) = self.rows_from_clause(right).await? + else { + return Ok(None); + }; + order.extend(right_order); + + let mut evaluator = ExpressionEvaluator::new(); + let mut joined = Vec::new(); + + for left_row in &left_rows { + let mut matched = false; + for right_row in &right_rows { + let mut combined = left_row.clone(); + for (key, value) in right_row { + // A bare name present on both sides keeps the + // left one; the qualified names stay distinct. + combined.entry(key.clone()).or_insert_with(|| value.clone()); + if key.contains('.') { + combined.insert(key.clone(), value.clone()); + } + } + + let keep = match condition { + JoinCondition::On(predicate) => { + let context = EvaluationContext::with_row(combined.clone()); + matches!( + evaluator.evaluate(predicate, &context)?, + SqlValue::Boolean(true) + ) + } + JoinCondition::Using(columns) => columns.iter().all(|column| { + let column = fold_identifier(column); + left_row.get(&column) == right_row.get(&column) + }), + // Without shared column information a natural + // join cannot be resolved here. + JoinCondition::Natural => return Ok(None), + }; + + if keep || matches!(join_type, JoinType::Cross) { + matched = true; + joined.push(combined); + } + } + + // A left outer join keeps an unmatched left row with + // NULLs for the right side. + if !matched && matches!(join_type, JoinType::LeftOuter) { + joined.push(left_row.clone()); + } + } + + Ok(Some((joined, order))) + } + + _ => Ok(None), + } + }) + } + + /// Convert a stored value into the typed value the evaluator works with. + fn json_to_sql_value( + value: &JsonValue, + column_type: &ColumnType, + ) -> crate::protocols::postgres_wire::sql::types::SqlValue { + use crate::protocols::postgres_wire::sql::types::SqlValue; + + match value { + JsonValue::Null => SqlValue::Null, + JsonValue::Bool(b) => SqlValue::Boolean(*b), + JsonValue::Number(n) => match column_type { + ColumnType::BigInt => n.as_i64().map_or(SqlValue::Null, SqlValue::BigInt), + ColumnType::Double => n.as_f64().map_or(SqlValue::Null, SqlValue::DoublePrecision), + ColumnType::Serial | ColumnType::Integer => n + .as_i64() + .and_then(|v| i32::try_from(v).ok()) + .map_or_else( + || n.as_i64().map_or(SqlValue::Null, SqlValue::BigInt), + SqlValue::Integer, + ), + // The column is not declared numeric, so keep the number's own + // width rather than forcing it into the declared type. + _ => n + .as_i64() + .map(SqlValue::BigInt) + .or_else(|| n.as_f64().map(SqlValue::DoublePrecision)) + .unwrap_or(SqlValue::Null), + }, + JsonValue::String(s) => match column_type { + ColumnType::Timestamp => SqlValue::Text(s.clone()), + _ => SqlValue::Text(s.clone()), + }, + other => SqlValue::Json(other.clone()), + } + } + + /// Turn a comprehensive-engine failure into an accurate message. + /// + /// That engine keeps its own tables and cannot see persistent storage, so + /// it reports "table does not exist" for a table that plainly does. Saying + /// which feature is missing is the truthful answer, and the actionable one. + async fn explain_unsupported_query(&self, sql: &str, error: ProtocolError) -> ProtocolError { + let text = error.to_string(); + if !text.contains("does not exist") { + return error; + } + + let Some(storage) = &self.persistent_storage else { + return error; + }; + + // Which table the statement names, if the simple parser can tell. + let upper = sql.to_uppercase(); + let Some(from) = upper.find(" FROM ") else { + return error; + }; + let table = sql[from + 6..] + .split_whitespace() + .next() + .map(fold_identifier) + .unwrap_or_default(); + + match storage.table_exists(&table).await { + Ok(true) => ProtocolError::PostgresError(format!( + "Table '{table}' exists, but this query uses SQL features that are not yet supported over stored tables (aggregates, GROUP BY, ORDER BY, LIMIT, JOIN, DISTINCT and subqueries are executed only by the in-memory engine). The statement was refused rather than run without those clauses." + )), + _ => error, + } + } + + /// Clauses this parser does not implement. + /// + /// It parses the statement around them and then ignores them, so + /// `SELECT ... LIMIT 2` returned every row and `GROUP BY` returned the + /// ungrouped rows — wrong answers reported as success. Refusing here sends + /// the statement to the comprehensive engine instead, and if that cannot + /// run it either the client gets an error rather than bad data. + const UNSUPPORTED_SELECT_CLAUSES: [&'static str; 19] = [ + " LIMIT ", " OFFSET ", " GROUP BY ", " HAVING ", " DISTINCT ", " JOIN ", " UNION ", + " INTERSECT ", " EXCEPT ", " ORDER BY ", + // The storage matcher implements LIKE as a case-insensitive `contains` + // after deleting every `%`, so `'al%'` matched anywhere in the value + // instead of anchoring at the start — and `BETWEEN`/`IS` it does not + // implement at all. The expression evaluator handles all of them. + " LIKE ", " ILIKE ", " BETWEEN ", " IS NULL", " IS NOT ", + // This parser reads a WHERE clause as a single `column op value`, so a + // second condition was swallowed into the value: `WHERE a = 'x' AND b + // > 1` compared `a` against the text "'x' AND b > 1" and matched + // nothing. + " AND ", " OR ", " NOT ", " IN ", + ]; + + /// Whether the simple parser would silently ignore part of `sql`. + fn has_unsupported_select_clause(sql: &str) -> bool { + // Padded so the check sees clause keywords at the end too. + let padded = format!(" {} ", sql.trim().trim_end_matches(';')); + let upper = padded.to_uppercase(); + + if Self::UNSUPPORTED_SELECT_CLAUSES + .iter() + .any(|clause| upper.contains(clause)) + { + return true; + } + + // Any call in the select list — `COUNT(*)`, `SUM(x)`, a subquery. This + // parser treats the projection as bare column names, so it returned a + // NULL column named `COUNT(*)` for every row instead of a count. + let projection_end = upper.find(" FROM ").unwrap_or(upper.len()); + upper[..projection_end].contains('(') || upper.contains("(SELECT ") + } + /// Parse SELECT statement fn parse_select(&self, sql: &str) -> ProtocolResult { + if Self::has_unsupported_select_clause(sql) { + return Err(ProtocolError::PostgresError( + "statement uses a clause this parser does not implement".to_string(), + )); + } + // Simple parser: SELECT columns FROM table [WHERE condition] let parts: Vec<&str> = sql.split_whitespace().collect(); @@ -1468,14 +2308,10 @@ impl QueryEngine { current_value.push(ch); } ',' if !in_quotes && brace_depth == 0 => { - // Found a separator - add the current value - values.push( - current_value - .trim() - .trim_matches('\'') - .trim_matches('"') - .to_string(), - ); + // Quotes are kept; `literal_to_json` strips them. Stripping + // here made the NULL keyword indistinguishable from the text + // 'NULL', and stored '123' as the number 123. + values.push(current_value.trim().to_string()); current_value.clear(); } _ => { @@ -1486,19 +2322,70 @@ impl QueryEngine { } // Add the last value - if !current_value.is_empty() { - values.push( - current_value - .trim() - .trim_matches('\'') - .trim_matches('"') - .to_string(), - ); + if !current_value.trim().is_empty() { + values.push(current_value.trim().to_string()); } values } + /// Render a value as the SQL literal that denotes it. + /// + /// The inverse of [`QueryEngine::literal_to_json`], so a value that goes + /// out through one and back through the other is unchanged. + pub fn sql_value_to_literal( + value: &crate::protocols::postgres_wire::sql::types::SqlValue, + ) -> String { + use crate::protocols::postgres_wire::sql::types::SqlValue; + + match value { + SqlValue::Null => "NULL".to_string(), + SqlValue::Boolean(b) => b.to_string(), + SqlValue::SmallInt(n) => n.to_string(), + SqlValue::Integer(n) => n.to_string(), + SqlValue::BigInt(n) => n.to_string(), + SqlValue::Real(n) => n.to_string(), + SqlValue::DoublePrecision(n) => n.to_string(), + SqlValue::Decimal(d) => d.to_string(), + // Everything else is text on the way in; quoting keeps it text. + other => format!("'{}'", other.to_postgres_string().replace('\'', "''")), + } + } + + /// Convert a SQL literal as written into the value to store. + /// + /// Quoting carries meaning: `NULL` is the null value while `'NULL'` is the + /// three-letter string, and `123` is a number while `'123'` is text. + pub fn literal_to_json(literal: &str) -> JsonValue { + let trimmed = literal.trim(); + + let quoted = trimmed + .strip_prefix('\'') + .and_then(|rest| rest.strip_suffix('\'')) + .or_else(|| { + trimmed + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + }); + + if let Some(inner) = quoted { + // `''` is how a quote is escaped inside a SQL string literal. + return JsonValue::String(inner.replace("''", "'")); + } + + if trimmed.eq_ignore_ascii_case("NULL") { + return JsonValue::Null; + } + if trimmed.eq_ignore_ascii_case("TRUE") { + return JsonValue::Bool(true); + } + if trimmed.eq_ignore_ascii_case("FALSE") { + return JsonValue::Bool(false); + } + + serde_json::from_str(trimmed).unwrap_or_else(|_| JsonValue::String(trimmed.to_string())) + } + /// Parse WHERE clause fn parse_where_clause(&self, parts: &[&str]) -> ProtocolResult { // Simple parser: column operator value @@ -1512,11 +2399,9 @@ impl QueryEngine { let operator = parts[1].to_string(); // Parse the value more carefully - it might span multiple parts if it contains spaces let value_part = parts[2..].join(" "); - let value = value_part - .trim_end_matches(';') // Remove trailing semicolon first - .trim_matches('\'') - .trim_matches('"') - .to_string(); + // Quotes are kept and interpreted by `literal_to_json`, so `WHERE x = + // NULL` and `WHERE x = 'NULL'` stay distinguishable. + let value = value_part.trim_end_matches(';').trim().to_string(); Ok(WhereClause { conditions: vec![Condition { @@ -1848,10 +2733,7 @@ impl QueryEngine { .map(|c| QueryCondition { column: fold_identifier(&c.column), operator: c.operator, - value: match serde_json::from_str(&c.value) { - Ok(json_val) => json_val, - Err(_) => JsonValue::String(c.value), - }, + value: Self::literal_to_json(&c.value), }) .collect() } else { @@ -1898,13 +2780,23 @@ impl QueryEngine { let value = row .values .get(col) - .or_else(|| row.values.get(&col.to_uppercase())); - value.map(|v| match v { - JsonValue::String(s) => s.clone(), - JsonValue::Number(n) => n.to_string(), - JsonValue::Bool(b) => b.to_string(), - JsonValue::Null => "NULL".to_string(), - _ => v.to_string(), + .or_else(|| row.values.get(&col.to_uppercase())) + .or_else(|| { + // Rows written before identifiers were folded + // consistently may carry either case. + row.values.iter().find_map(|(key, value)| { + key.eq_ignore_ascii_case(col).then_some(value) + }) + }); + // `None` is SQL NULL on the wire. Rendering it as the + // text "NULL" made a null indistinguishable from a row + // whose value is the three-letter string. + value.and_then(|v| match v { + JsonValue::Null => None, + JsonValue::String(s) => Some(s.clone()), + JsonValue::Number(n) => Some(n.to_string()), + JsonValue::Bool(b) => Some(b.to_string()), + other => Some(other.to_string()), }) }) .collect() @@ -1966,13 +2858,13 @@ impl QueryEngine { } for (col, val) in columns.iter().zip(values.iter()) { - let col_upper = col.to_uppercase(); + let col_upper = fold_identifier(col); // Find column in schema to get correct casing let schema_col = schema .columns .iter() - .find(|c| c.name.to_uppercase() == col_upper); + .find(|c| fold_identifier(&c.name) == col_upper); if let Some(column_def) = schema_col { // Skip SERIAL columns as they're auto-generated @@ -1980,24 +2872,13 @@ impl QueryEngine { continue; } - // Try to parse as JSON, fall back to string - let json_val = match serde_json::from_str(val) { - Ok(json) => json, - Err(_) => JsonValue::String(val.clone()), - }; - - // Use schema column name - row_values.insert(column_def.name.clone(), json_val); + row_values.insert(column_def.name.clone(), Self::literal_to_json(val)); } else { // Column not found in schema, skip or insert with uppercase? // For now, insert with uppercase as fallback, but this might be wrong if schema is strict // But if we are here, it means we are inserting a column that doesn't exist in schema? // Postgres would error. For now, let's just use uppercase as before. - let json_val = match serde_json::from_str(val) { - Ok(json) => json, - Err(_) => JsonValue::String(val.clone()), - }; - row_values.insert(col_upper, json_val); + row_values.insert(col_upper, Self::literal_to_json(val)); } } @@ -2034,11 +2915,7 @@ impl QueryEngine { // Convert SET clauses to HashMap let mut set_values = std::collections::HashMap::new(); for (col, val) in set_clauses { - let json_val = match serde_json::from_str(&val) { - Ok(json) => json, - Err(_) => JsonValue::String(val), - }; - set_values.insert(col.to_uppercase(), json_val); // Normalize to uppercase + set_values.insert(fold_identifier(&col), Self::literal_to_json(&val)); } // Convert WHERE clause to QueryConditions @@ -2048,10 +2925,7 @@ impl QueryEngine { .map(|c| QueryCondition { column: fold_identifier(&c.column), operator: c.operator, - value: match serde_json::from_str(&c.value) { - Ok(json_val) => json_val, - Err(_) => JsonValue::String(c.value), - }, + value: Self::literal_to_json(&c.value), }) .collect() } else { @@ -2088,10 +2962,7 @@ impl QueryEngine { .map(|c| QueryCondition { column: fold_identifier(&c.column), operator: c.operator, - value: match serde_json::from_str(&c.value) { - Ok(json_val) => json_val, - Err(_) => JsonValue::String(c.value), - }, + value: Self::literal_to_json(&c.value), }) .collect() } else { @@ -2165,7 +3036,11 @@ impl QueryEngine { .any(|c| c.to_uppercase() == "NOT" || c.to_uppercase().contains("NULL")); column_defs.push(ColumnDefinition { - name: col.name.to_uppercase(), // Normalize to uppercase for consistency + // Folded like every other identifier, so the keys a row is + // written with are the keys a query looks it up by. Storing + // these uppercase while queries folded to lower made + // `SELECT ` return NULL for a column that was present. + name: fold_identifier(&col.name), data_type: column_type, nullable, default_value: None, // TODO: Parse DEFAULT values @@ -2185,6 +3060,50 @@ impl QueryEngine { Ok(QueryResult::Update { count: 0 }) } + /// Parse `TRUNCATE [TABLE] name [CASCADE|RESTRICT]`. + fn parse_truncate(sql: &str) -> ProtocolResult { + let rest = sql + .trim() + .trim_end_matches(';') + .split_whitespace() + .skip(1) + .skip_while(|word| word.eq_ignore_ascii_case("TABLE")) + .find(|word| { + !word.eq_ignore_ascii_case("ONLY") + && !word.eq_ignore_ascii_case("CASCADE") + && !word.eq_ignore_ascii_case("RESTRICT") + }) + .ok_or_else(|| { + ProtocolError::PostgresError("TRUNCATE requires a table name".to_string()) + })?; + + Ok(Statement::Truncate { + table: fold_identifier(rest.trim_end_matches(',')), + }) + } + + /// Execute TRUNCATE on persistent storage. + /// + /// Routed here rather than to the SQL engine because that engine truncates + /// its own table state; against a stored table it reported success while + /// every row survived. + async fn execute_truncate( + &self, + storage: &Arc, + table: &str, + ) -> ProtocolResult { + if !storage.table_exists(table).await? { + return Err(ProtocolError::PostgresError(format!( + "Table '{table}' does not exist" + ))); + } + + let count = storage.delete_rows(table, vec![]).await?; + Ok(QueryResult::Delete { + count: count as usize, + }) + } + /// Execute DROP TABLE on persistent storage async fn execute_drop_table( &self, @@ -2343,3 +3262,95 @@ mod literal_case_tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +mod literal_tests { + use super::*; + + /// The NULL keyword and the string 'NULL' are different values. Stripping + /// quotes before conversion collapsed them. + #[test] + fn the_null_keyword_is_null_but_quoted_null_is_text() { + assert_eq!(QueryEngine::literal_to_json("NULL"), JsonValue::Null); + assert_eq!(QueryEngine::literal_to_json("null"), JsonValue::Null); + assert_eq!( + QueryEngine::literal_to_json("'NULL'"), + JsonValue::String("NULL".to_string()) + ); + } + + /// A quoted number is text. Storing it as a number loses leading zeros and + /// changes how it compares. + #[test] + fn a_quoted_number_stays_text() { + assert_eq!( + QueryEngine::literal_to_json("'0123'"), + JsonValue::String("0123".to_string()) + ); + assert_eq!( + QueryEngine::literal_to_json("123"), + JsonValue::Number(123.into()) + ); + } + + #[test] + fn booleans_are_recognised_unquoted_only() { + assert_eq!(QueryEngine::literal_to_json("true"), JsonValue::Bool(true)); + assert_eq!(QueryEngine::literal_to_json("FALSE"), JsonValue::Bool(false)); + assert_eq!( + QueryEngine::literal_to_json("'true'"), + JsonValue::String("true".to_string()) + ); + } + + #[test] + fn an_escaped_quote_inside_a_literal_is_unescaped_once() { + assert_eq!( + QueryEngine::literal_to_json("'O''Brien'"), + JsonValue::String("O'Brien".to_string()) + ); + } + + #[test] + fn an_unquoted_word_is_kept_as_text() { + assert_eq!( + QueryEngine::literal_to_json("hello"), + JsonValue::String("hello".to_string()) + ); + } + + /// A value rendered as a literal and read back must be unchanged. + #[test] + fn values_round_trip_through_their_literal_form() { + use crate::protocols::postgres_wire::sql::types::SqlValue; + + let cases = [ + (SqlValue::Null, JsonValue::Null), + (SqlValue::Integer(42), JsonValue::Number(42.into())), + (SqlValue::BigInt(-7), JsonValue::Number((-7).into())), + (SqlValue::Boolean(true), JsonValue::Bool(true)), + ( + SqlValue::Text("hello".to_string()), + JsonValue::String("hello".to_string()), + ), + // The three-letter string, not the null value. + ( + SqlValue::Text("NULL".to_string()), + JsonValue::String("NULL".to_string()), + ), + ( + SqlValue::Text("O'Brien".to_string()), + JsonValue::String("O'Brien".to_string()), + ), + ]; + + for (value, expected) in cases { + let literal = QueryEngine::sql_value_to_literal(&value); + assert_eq!( + QueryEngine::literal_to_json(&literal), + expected, + "round trip failed for {value:?} via {literal:?}" + ); + } + } +} diff --git a/orbit/server/src/protocols/postgres_wire/sql/execution_strategy.rs b/orbit/server/src/protocols/postgres_wire/sql/execution_strategy.rs index 3546b02b..1bb8b8c0 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/execution_strategy.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/execution_strategy.rs @@ -293,6 +293,44 @@ pub struct MvccExecutionStrategy { } impl MvccExecutionStrategy { + /// One-line description of how a statement will be executed. + fn describe_plan(statement: &Statement) -> String { + match statement { + Statement::Select(select) => { + let table = select + .from_clause + .as_ref() + .map(|_| "table") + .unwrap_or("no table"); + let mut plan = format!("Seq Scan on {table}"); + if select.where_clause.is_some() { + plan.push_str(" + Filter"); + } + if select.group_by.is_some() { + plan.push_str(" + GroupAggregate"); + } + if select.distinct.is_some() { + plan.push_str(" + Unique"); + } + if select.order_by.is_some() { + plan.push_str(" + Sort"); + } + if select.limit.is_some() || select.offset.is_some() { + plan.push_str(" + Limit"); + } + plan + } + Statement::Insert(_) => "Insert".to_string(), + Statement::Update(_) => "Update".to_string(), + Statement::Delete(_) => "Delete".to_string(), + other => format!("{other:?}") + .split_whitespace() + .next() + .unwrap_or("Statement") + .to_string(), + } + } + pub fn new(config: SqlEngineConfig) -> Self { Self { parser: SqlParser::new(), @@ -585,32 +623,35 @@ impl SqlExecutionStrategy for MvccExecutionStrategy { .mvcc_read(&table_name, transaction_id, None) .await?; - // Convert MVCC rows to string format for compatibility - let mut string_rows = Vec::new(); - - // If columns still empty (no schema or empty table), get from first row + // If columns are still empty (no schema, or a wildcard over an + // empty table) take them from the first row. if columns.is_empty() { if let Some(first_row) = rows.first() { columns = first_row.keys().cloned().collect(); + columns.sort(); } } - for row in rows { - let mut string_row = Vec::new(); - for col in &columns { - let value = row - .get(col) - .map(|v| v.to_postgres_string()) - .or(Some("".to_string())); - string_row.push(value); - } - string_rows.push(string_row); - } + // Apply the clauses. Reading the table and projecting by name — + // which is all this did — silently ignored WHERE, GROUP BY, + // HAVING, DISTINCT, ORDER BY, LIMIT/OFFSET and every aggregate, + // so `LIMIT 2` returned the whole table and `COUNT(*)` returned + // one empty column per row. + use crate::protocols::postgres_wire::sql::select_pipeline; + let output = select_pipeline::run_select(&select_stmt, rows)?; + + // A wildcard projection is named by the pipeline as `*`; the + // real column names are the ones resolved from the schema above. + let resolved_columns = if output.columns.iter().any(|name| name == "*") { + columns + } else { + output.columns + }; - let row_count = string_rows.len(); + let row_count = output.rows.len(); Ok(UnifiedExecutionResult::Select { - columns, - rows: string_rows, + columns: resolved_columns, + rows: output.rows, row_count, transaction_id: Some(transaction_id), }) @@ -853,6 +894,28 @@ impl SqlExecutionStrategy for MvccExecutionStrategy { undrop_stmt.name.full_name() ))) } + Statement::Explain(explain) => { + // A description of what will run, not a cost estimate: this + // engine has no statistics or cost model, and inventing numbers + // that look like PostgreSQL's would be worse than saying so. + let mut lines = vec![Self::describe_plan(&explain.statement)]; + if explain.analyze { + lines.push( + " (ANALYZE requested; per-node timings are not collected)".to_string(), + ); + } + if explain.verbose { + lines.push(" (VERBOSE requested; no extra detail available)".to_string()); + } + lines.push(" Cost estimates are not available: no statistics are kept.".to_string()); + + Ok(UnifiedExecutionResult::Select { + columns: vec!["QUERY PLAN".to_string()], + row_count: lines.len(), + rows: lines.into_iter().map(|line| vec![Some(line)]).collect(), + transaction_id: Some(transaction_id), + }) + } _ => Ok(UnifiedExecutionResult::Other { message: "Command completed successfully".to_string(), transaction_id: Some(transaction_id), diff --git a/orbit/server/src/protocols/postgres_wire/sql/expression_evaluator.rs b/orbit/server/src/protocols/postgres_wire/sql/expression_evaluator.rs index d2778ac8..93139efb 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/expression_evaluator.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/expression_evaluator.rs @@ -370,6 +370,14 @@ pub enum AggregateState { pub struct ExpressionEvaluator { #[allow(dead_code)] aggregates: HashMap, + /// Session identity reported by `current_database()`, `current_schema()` + /// and `current_user`. + /// + /// Defaults describe a session that has not been told otherwise, rather + /// than inventing a name the client never supplied. + current_database: String, + current_schema: String, + current_user: String, /// Optional sequence accessor for nextval/currval/setval/lastval functions sequence_accessor: Option>, /// Optional UDF registry for user-defined functions (Lua/JS) @@ -378,9 +386,40 @@ pub struct ExpressionEvaluator { } impl ExpressionEvaluator { + /// The string `version()` returns. + /// + /// Names this server honestly while declaring the PostgreSQL protocol + /// version it speaks, because clients parse the leading `PostgreSQL ` + /// to decide which features to use. Claiming to be stock PostgreSQL would + /// make them enable things this engine does not implement. + pub fn server_version_string() -> String { + format!( + "PostgreSQL 16.0 (Orbit-RS {}) on {}, compiled by rustc", + env!("CARGO_PKG_VERSION"), + std::env::consts::ARCH + ) + } + + /// Set the session identity these functions report. + #[must_use] + pub fn with_session( + mut self, + database: impl Into, + schema: impl Into, + user: impl Into, + ) -> Self { + self.current_database = database.into(); + self.current_schema = schema.into(); + self.current_user = user.into(); + self + } + pub fn new() -> Self { Self { aggregates: HashMap::new(), + current_database: "orbit".to_string(), + current_schema: "public".to_string(), + current_user: "orbit".to_string(), #[cfg(feature = "lua-mlua")] udf_registry: None, sequence_accessor: None, @@ -390,10 +429,8 @@ impl ExpressionEvaluator { /// Create an expression evaluator with a sequence accessor pub fn with_sequence_accessor(sequence_accessor: Arc) -> Self { Self { - aggregates: HashMap::new(), sequence_accessor: Some(sequence_accessor), - #[cfg(feature = "lua-mlua")] - udf_registry: None, + ..Self::new() } } @@ -401,9 +438,8 @@ impl ExpressionEvaluator { #[cfg(feature = "lua-mlua")] pub fn with_udf_registry(udf_registry: Arc) -> Self { Self { - aggregates: HashMap::new(), - sequence_accessor: None, udf_registry: Some(udf_registry), + ..Self::new() } } @@ -699,6 +735,14 @@ impl ExpressionEvaluator { self.text_search_followed_by(&left_val, &right_val) } + // `x IS y` / `x IS NOT y`. PostgreSQL uses these for NULL and + // boolean tests, where they differ from `=` by treating NULL as a + // value rather than propagating it. + BinaryOperator::Is => Ok(SqlValue::Boolean(Self::is_identical(&left_val, &right_val))), + BinaryOperator::IsNot => { + Ok(SqlValue::Boolean(!Self::is_identical(&left_val, &right_val))) + } + // Comparison operators BinaryOperator::IsDistinctFrom => self.is_distinct_from(&left_val, &right_val), BinaryOperator::IsNotDistinctFrom => self.is_not_distinct_from(&left_val, &right_val), @@ -897,6 +941,26 @@ impl ExpressionEvaluator { "TRUNC" | "TRUNCATE" => self.evaluate_trunc(&args), // Date functions + // Session information functions. + // + // Drivers and ORMs call these during connection setup — psql runs + // `version()` before its first prompt — so a missing one is not a + // cosmetic gap: it stops the client before any query is possible. + "VERSION" => Ok(SqlValue::Text(Self::server_version_string())), + "CURRENT_DATABASE" | "CURRENT_CATALOG" => { + Ok(SqlValue::Text(self.current_database.clone())) + } + "CURRENT_SCHEMA" => Ok(SqlValue::Text(self.current_schema.clone())), + "CURRENT_USER" | "SESSION_USER" | "USER" => { + Ok(SqlValue::Text(self.current_user.clone())) + } + "PG_BACKEND_PID" => Ok(SqlValue::Integer(std::process::id() as i32)), + // Reported as unknown rather than fabricated: this engine does not + // track per-relation on-disk size. + "PG_ENCODING_TO_CHAR" => Ok(SqlValue::Text("UTF8".to_string())), + "PG_GET_EXPR" | "PG_GET_CONSTRAINTDEF" | "PG_GET_INDEXDEF" => { + Ok(SqlValue::Null) + } "NOW" => self.evaluate_now(&args), "CURRENT_DATE" | "CURDATE" => self.evaluate_current_date(&args), "CURRENT_TIME" => self.evaluate_current_time(&args), @@ -1558,9 +1622,60 @@ impl ExpressionEvaluator { .partial_cmp(&(*b as f64)) .ok_or_else(|| ProtocolError::PostgresError("Cannot compare values".to_string())), - _ => Err(ProtocolError::PostgresError(format!( - "Cannot compare {left:?} and {right:?}" - ))), + // Generic coercion. PostgreSQL compares across the numeric types + // and across the character types; enumerating pairs missed most + // combinations, so `COUNT(*) > 1` failed because count() returns + // bigint while the literal parsed as integer. + _ => { + if let (Some(a), Some(b)) = (Self::as_integer(left), Self::as_integer(right)) { + // Compared as integers so large values keep full precision. + return Ok(a.cmp(&b)); + } + if let (Some(a), Some(b)) = (Self::as_number(left), Self::as_number(right)) { + return a.partial_cmp(&b).ok_or_else(|| { + ProtocolError::PostgresError("Cannot compare NaN".to_string()) + }); + } + if let (Some(a), Some(b)) = (Self::as_string(left), Self::as_string(right)) { + return Ok(a.cmp(&b)); + } + Err(ProtocolError::PostgresError(format!( + "Cannot compare {left:?} and {right:?}" + ))) + } + } + } + + /// Exact integer value, for the integral types only. + fn as_integer(value: &SqlValue) -> Option { + match value { + SqlValue::SmallInt(n) => Some(i128::from(*n)), + SqlValue::Integer(n) => Some(i128::from(*n)), + SqlValue::BigInt(n) => Some(i128::from(*n)), + _ => None, + } + } + + /// Numeric value of any number-like type. + fn as_number(value: &SqlValue) -> Option { + use rust_decimal::prelude::ToPrimitive; + + match value { + SqlValue::Real(n) => Some(f64::from(*n)), + SqlValue::DoublePrecision(n) => Some(*n), + SqlValue::Decimal(d) => d.to_f64(), + other => Self::as_integer(other).map(|n| n as f64), + } + } + + /// String value of any character-like type. + fn as_string(value: &SqlValue) -> Option { + match value { + SqlValue::Text(s) + | SqlValue::Varchar(s) + | SqlValue::Char(s) + | SqlValue::Name(s) => Some(s.clone()), + _ => None, } } @@ -1592,6 +1707,16 @@ impl ExpressionEvaluator { } } + /// Whether two values are the same under `IS`, where NULL equals NULL. + fn is_identical(left: &SqlValue, right: &SqlValue) -> bool { + match (left, right) { + (SqlValue::Null, SqlValue::Null) => true, + (SqlValue::Null, _) | (_, SqlValue::Null) => false, + (SqlValue::Boolean(a), SqlValue::Boolean(b)) => a == b, + _ => left == right, + } + } + fn logical_not(&self, value: &SqlValue) -> ProtocolResult { match value { SqlValue::Boolean(b) => Ok(SqlValue::Boolean(!b)), @@ -6574,9 +6699,51 @@ impl ExpressionEvaluator { Ok(SqlValue::Real(distance)) } - _ => Err(ProtocolError::PostgresError( - "Vector operations require vector operands".to_string(), - )), + // pgvector spells a vector literal as a string — `'[1,2,3]'` — and + // a stored vector may come back as text too, so an operand that + // parses as one is accepted rather than rejected. + _ => match (Self::coerce_to_vector(left), Self::coerce_to_vector(right)) { + (Some(a), Some(b)) => { + self.vector_distance(&SqlValue::Vector(a), &SqlValue::Vector(b), operator) + } + _ => Err(ProtocolError::PostgresError( + "Vector operations require vector operands".to_string(), + )), + }, + } + } + + /// Read a vector from a value that already is one, or from its text form. + /// + /// Returns `None` when the value is not a vector in any spelling, so the + /// caller still reports a type error rather than treating nonsense as an + /// empty vector. + fn coerce_to_vector(value: &SqlValue) -> Option> { + match value { + SqlValue::Vector(v) => Some(v.clone()), + SqlValue::Text(text) | SqlValue::Varchar(text) | SqlValue::Char(text) => { + let trimmed = text.trim(); + let inner = trimmed.strip_prefix('[')?.strip_suffix(']')?; + if inner.trim().is_empty() { + return Some(Vec::new()); + } + inner + .split(',') + .map(|part| part.trim().parse::().ok()) + .collect() + } + SqlValue::Array(items) => items + .iter() + .map(|item| match item { + SqlValue::Real(n) => Some(*n), + SqlValue::DoublePrecision(n) => Some(*n as f32), + SqlValue::Integer(n) => Some(*n as f32), + SqlValue::BigInt(n) => Some(*n as f32), + SqlValue::SmallInt(n) => Some(f32::from(*n)), + _ => None, + }) + .collect(), + _ => None, } } diff --git a/orbit/server/src/protocols/postgres_wire/sql/mod.rs b/orbit/server/src/protocols/postgres_wire/sql/mod.rs index ae04242b..c6c4d1a3 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/mod.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/mod.rs @@ -60,6 +60,7 @@ pub mod plan_cache; pub mod query_cache; pub mod query_engine; pub mod statistics; +pub mod select_pipeline; pub mod types; #[cfg(feature = "lua-mlua")] diff --git a/orbit/server/src/protocols/postgres_wire/sql/parser/expressions.rs b/orbit/server/src/protocols/postgres_wire/sql/parser/expressions.rs index bb773cb0..fdaefb0b 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/parser/expressions.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/parser/expressions.rs @@ -63,12 +63,12 @@ impl ExpressionParser { tokens: &[Token], pos: &mut usize, ) -> ProtocolResult { - let mut left = self.parse_equality_expression(tokens, pos)?; + let mut left = self.parse_not_expression(tokens, pos)?; while *pos < tokens.len() { if matches!(tokens[*pos], Token::And) { *pos += 1; - let right = self.parse_equality_expression(tokens, pos)?; + let right = self.parse_not_expression(tokens, pos)?; left = Expression::Binary { left: Box::new(left), operator: BinaryOperator::And, @@ -82,6 +82,27 @@ impl ExpressionParser { Ok(left) } + /// Parse `NOT `. + /// + /// `NOT` binds looser than every comparison, so `NOT a = 1` is + /// `NOT (a = 1)` and not `(NOT a) = 1`. Parsing it as a unary prefix of a + /// value expression gets that backwards and makes the operand a bare + /// column, which is not a boolean. + fn parse_not_expression( + &mut self, + tokens: &[Token], + pos: &mut usize, + ) -> ProtocolResult { + if *pos < tokens.len() && matches!(tokens[*pos], Token::Not) { + *pos += 1; + return Ok(Expression::Unary { + operator: UnaryOperator::Not, + operand: Box::new(self.parse_not_expression(tokens, pos)?), + }); + } + self.parse_equality_expression(tokens, pos) + } + /// Parse equality expressions (=, !=, <>, IS, IS NOT) fn parse_equality_expression( &mut self, @@ -130,7 +151,28 @@ impl ExpressionParser { let mut left = self.parse_additive_expression(tokens, pos)?; while *pos < tokens.len() { - if matches!(&tokens[*pos], Token::In) { + // `x [NOT] BETWEEN low AND high`. The bounds are parsed at additive + // level so the separating AND is not swallowed as a conjunction. + let between_negated = matches!(&tokens[*pos], Token::Not) + && *pos + 1 < tokens.len() + && matches!(&tokens[*pos + 1], Token::Between); + if between_negated || matches!(&tokens[*pos], Token::Between) { + *pos += if between_negated { 2 } else { 1 }; + let low = self.parse_additive_expression(tokens, pos)?; + if *pos >= tokens.len() || !matches!(&tokens[*pos], Token::And) { + return Err(crate::protocols::error::ProtocolError::ParseError( + "Expected AND after BETWEEN lower bound".to_string(), + )); + } + *pos += 1; + let high = self.parse_additive_expression(tokens, pos)?; + left = Expression::Between { + expr: Box::new(left), + low: Box::new(low), + high: Box::new(high), + negated: between_negated, + }; + } else if matches!(&tokens[*pos], Token::In) { // Handle IN operator specially to support both value lists and subqueries *pos += 1; // consume IN @@ -837,106 +879,13 @@ impl ExpressionParser { } /// Extract identifier string from token (handles both Identifier and keyword tokens used as names) + /// Extract an identifier string from a token. + /// + /// Delegates to the shared table rather than keeping a second copy: the + /// two had already drifted, so a keyword added to one was still rejected + /// by the other. fn token_to_identifier_name(&self, token: &Token) -> Option { - match token { - Token::Identifier(name) => Some(name.clone()), - // Data type keywords that can be used as identifiers - Token::Text => Some("text".to_string()), - Token::Integer => Some("integer".to_string()), - Token::Boolean => Some("boolean".to_string()), - Token::Date => Some("date".to_string()), - Token::Time => Some("time".to_string()), - Token::Timestamp => Some("timestamp".to_string()), - Token::Interval => Some("interval".to_string()), - Token::Decimal => Some("decimal".to_string()), - Token::Numeric => Some("numeric".to_string()), - Token::Real => Some("real".to_string()), - Token::Char => Some("char".to_string()), - Token::Varchar => Some("varchar".to_string()), - Token::Json => Some("json".to_string()), - Token::Jsonb => Some("jsonb".to_string()), - Token::Uuid => Some("uuid".to_string()), - Token::Bytea => Some("bytea".to_string()), - Token::Vector => Some("vector".to_string()), - // Other keywords that can be used as identifiers - Token::Sequence => Some("sequence".to_string()), - Token::Key => Some("key".to_string()), - // PostgreSQL 18 - OLD/NEW table references in RETURNING clause - Token::Old => Some("OLD".to_string()), - Token::New => Some("NEW".to_string()), - // Extended DDL keywords that can be used as identifiers - Token::Type => Some("type".to_string()), - Token::Domain => Some("domain".to_string()), - Token::Role => Some("role".to_string()), - Token::User => Some("user".to_string()), - Token::Tablespace => Some("tablespace".to_string()), - Token::Policy => Some("policy".to_string()), - Token::Rule => Some("rule".to_string()), - Token::Aggregate => Some("aggregate".to_string()), - Token::Operator => Some("operator".to_string()), - Token::Collation => Some("collation".to_string()), - Token::Conversion => Some("conversion".to_string()), - Token::Statistics => Some("statistics".to_string()), - Token::Publication => Some("publication".to_string()), - Token::Subscription => Some("subscription".to_string()), - // Security/Role keywords that can be used as identifiers - Token::Login => Some("login".to_string()), - Token::NoLogin => Some("nologin".to_string()), - Token::SuperUser => Some("superuser".to_string()), - Token::NoSuperUser => Some("nosuperuser".to_string()), - Token::CreateDb => Some("createdb".to_string()), - Token::NoCreateDb => Some("nocreatedb".to_string()), - Token::CreateRole => Some("createrole".to_string()), - Token::NoCreateRole => Some("nocreaterole".to_string()), - Token::Inherit => Some("inherit".to_string()), - Token::NoInherit => Some("noinherit".to_string()), - Token::Replication => Some("replication".to_string()), - Token::NoReplication => Some("noreplication".to_string()), - Token::BypassRls => Some("bypassrls".to_string()), - Token::NoBypassRls => Some("nobypassrls".to_string()), - Token::ConnectionLimit => Some("connection".to_string()), - Token::ValidUntil => Some("valid".to_string()), - Token::Password => Some("password".to_string()), - Token::Encrypted => Some("encrypted".to_string()), - // Policy keywords - Token::Permissive => Some("permissive".to_string()), - Token::Restrictive => Some("restrictive".to_string()), - // Type keywords - Token::Enum => Some("enum".to_string()), - Token::Composite => Some("composite".to_string()), - // Window Functions - Token::Rank => Some("rank".to_string()), - Token::RowNumber => Some("row_number".to_string()), - Token::DenseRank => Some("dense_rank".to_string()), - Token::PercentRank => Some("percent_rank".to_string()), - Token::CumeDist => Some("cume_dist".to_string()), - Token::Ntile => Some("ntile".to_string()), - Token::Lag => Some("lag".to_string()), - Token::Lead => Some("lead".to_string()), - Token::FirstValue => Some("first_value".to_string()), - Token::LastValue => Some("last_value".to_string()), - Token::NthValue => Some("nth_value".to_string()), - // Other keywords - Token::Exists => Some("exists".to_string()), - Token::With => Some("with".to_string()), - Token::Group => Some("group".to_string()), - Token::Order => Some("order".to_string()), - Token::By => Some("by".to_string()), - Token::Window => Some("window".to_string()), - Token::Index => Some("index".to_string()), - // JSON tokens - Token::JsonQuery => Some("json_query".to_string()), - Token::JsonValue => Some("json_value".to_string()), - Token::JsonExists => Some("json_exists".to_string()), - Token::JsonTable => Some("json_table".to_string()), - Token::JsonScalar => Some("json_scalar".to_string()), - Token::JsonSerialize => Some("json_serialize".to_string()), - Token::JsonArray => Some("json_array".to_string()), - Token::JsonObject => Some("json_object".to_string()), - Token::JsonArrayAgg => Some("json_arrayagg".to_string()), - Token::JsonObjectAgg => Some("json_objectagg".to_string()), - _ => None, - } + crate::protocols::postgres_wire::sql::parser::utilities::token_to_identifier_name(token) } /// Parse optional precision for date/time functions diff --git a/orbit/server/src/protocols/postgres_wire/sql/parser/mod.rs b/orbit/server/src/protocols/postgres_wire/sql/parser/mod.rs index 70236a58..34b565e0 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/parser/mod.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/parser/mod.rs @@ -107,6 +107,7 @@ impl SqlParser { Some(Token::Insert) => self.parse_insert_statement(), Some(Token::Update) => self.parse_update_statement(), Some(Token::Delete) => self.parse_delete_statement(), + Some(Token::Explain) => self.parse_explain_statement(), Some(Token::Merge) => self.parse_merge_statement(), Some(Token::Copy) => self.parse_copy_statement(), @@ -115,7 +116,11 @@ impl SqlParser { Some(Token::Revoke) => self.parse_revoke_statement(), // TCL Statements - Some(Token::Begin) => self.parse_begin_statement(), + // `START TRANSACTION` is the SQL-standard spelling of BEGIN, and is + // what several drivers send to open a transaction — tokio-postgres + // among them, so without it the driver's transaction API fails on + // its first call. + Some(Token::Begin) | Some(Token::Start) => self.parse_begin_statement(), Some(Token::Commit) => self.parse_commit_statement(), Some(Token::Rollback) => self.parse_rollback_statement(), Some(Token::Savepoint) => self.parse_savepoint_statement(), @@ -804,6 +809,58 @@ impl SqlParser { dcl::parse_revoke(self) } + /// Parse `EXPLAIN [ANALYZE] [VERBOSE] `. + /// + /// The options are recorded but the plan is descriptive: this engine has no + /// cost model, so EXPLAIN reports what it will do rather than an estimate it + /// cannot compute. + fn parse_explain_statement(&mut self) -> ParseResult { + use crate::protocols::postgres_wire::sql::ast::{ExplainFormat, ExplainStatement}; + + self.expect(Token::Explain)?; + + let mut analyze = false; + let mut verbose = false; + + // Both the bare form (`EXPLAIN ANALYZE ...`) and the parenthesised one + // (`EXPLAIN (ANALYZE, VERBOSE) ...`) are accepted. + if self.matches(&[Token::LeftParen]) { + self.advance()?; + while !self.matches(&[Token::RightParen]) && self.peek().is_some() { + match self.peek() { + Some(Token::Analyze) => analyze = true, + Some(Token::Verbose) => verbose = true, + // Any other option is accepted and ignored rather than + // failing the statement. + _ => {} + } + self.advance()?; + } + self.expect(Token::RightParen)?; + } else { + if self.matches(&[Token::Analyze]) { + analyze = true; + self.advance()?; + } + if self.matches(&[Token::Verbose]) { + verbose = true; + self.advance()?; + } + } + + let statement = self.parse_statement()?; + + Ok(Statement::Explain(ExplainStatement { + analyze, + verbose, + costs: false, + buffers: false, + timing: false, + format: ExplainFormat::Text, + statement: Box::new(statement), + })) + } + fn parse_begin_statement(&mut self) -> ParseResult { tcl::parse_begin(self) } diff --git a/orbit/server/src/protocols/postgres_wire/sql/parser/tcl.rs b/orbit/server/src/protocols/postgres_wire/sql/parser/tcl.rs index f21a0996..9efbb27f 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/parser/tcl.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/parser/tcl.rs @@ -13,11 +13,17 @@ use crate::protocols::postgres_wire::sql::lexer::Token; /// Parse BEGIN statement /// BEGIN [WORK | TRANSACTION] [ISOLATION LEVEL level] [READ WRITE | READ ONLY] pub fn parse_begin(parser: &mut SqlParser) -> ParseResult { - parser.expect(Token::Begin)?; - - // Optional WORK or TRANSACTION - if parser.matches(&[Token::Work, Token::Transaction]) { + // Accepts both spellings: `BEGIN [WORK | TRANSACTION]` and the SQL-standard + // `START TRANSACTION`. + if parser.matches(&[Token::Start]) { parser.advance()?; + parser.expect(Token::Transaction)?; + } else { + parser.expect(Token::Begin)?; + // Optional WORK or TRANSACTION + if parser.matches(&[Token::Work, Token::Transaction]) { + parser.advance()?; + } } let mut isolation_level = None; diff --git a/orbit/server/src/protocols/postgres_wire/sql/parser/utilities.rs b/orbit/server/src/protocols/postgres_wire/sql/parser/utilities.rs index c132fee8..29d40de0 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/parser/utilities.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/parser/utilities.rs @@ -32,6 +32,15 @@ pub fn token_to_identifier_name(token: &Token) -> Option { Token::Uuid => Some("uuid".to_string()), Token::Bytea => Some("bytea".to_string()), Token::Vector => Some("vector".to_string()), + // Non-reserved keywords that double as function names. + // + // PostgreSQL classifies these as unreserved, so `SELECT version()` is + // legal even though VERSION is also a keyword in the time-travel + // syntax. Every driver calls at least one of these while connecting — + // psql runs `version()` before its first prompt — so treating them as + // reserved stops clients before they can issue a query. + Token::Version => Some("version".to_string()), + Token::Snapshot => Some("snapshot".to_string()), // Other keywords that can be used as identifiers Token::Sequence => Some("sequence".to_string()), Token::Key => Some("key".to_string()), diff --git a/orbit/server/src/protocols/postgres_wire/sql/select_pipeline.rs b/orbit/server/src/protocols/postgres_wire/sql/select_pipeline.rs new file mode 100644 index 00000000..ab16f312 --- /dev/null +++ b/orbit/server/src/protocols/postgres_wire/sql/select_pipeline.rs @@ -0,0 +1,668 @@ +//! Row-level execution of a `SELECT` over rows already fetched from storage. +//! +//! The executor previously returned every row of the table and projected the +//! select list by column name, ignoring `WHERE`, `GROUP BY`, `HAVING`, +//! `DISTINCT`, `ORDER BY`, `LIMIT`/`OFFSET` and aggregate calls. Those clauses +//! were parsed and then dropped, so `SELECT ... LIMIT 2` returned every row and +//! `COUNT(*)` produced one empty column per row — wrong answers reported as +//! success. +//! +//! This applies them, in SQL's evaluation order: +//! `WHERE` → `GROUP BY`/aggregates → `HAVING` → `DISTINCT` → `ORDER BY` → +//! `OFFSET`/`LIMIT` → projection. + +use std::collections::HashMap; + +use super::ast::{ + DistinctClause, Expression, FunctionName, SelectItem, SelectStatement, SortDirection, +}; +use super::expression_evaluator::{EvaluationContext, ExpressionEvaluator}; +use super::types::SqlValue; +use crate::protocols::error::{ProtocolError, ProtocolResult}; + +/// A row as it moves through the pipeline. +pub type Row = HashMap; + +/// The result of running a select over `rows`. +pub struct SelectOutput { + pub columns: Vec, + pub rows: Vec>>, +} + +/// Apply a `SELECT`'s clauses to rows already read from a table. +/// +/// # Errors +/// Returns an error when an expression cannot be evaluated — an unknown +/// function, or a comparison between values that do not compare. +pub fn run_select(select: &SelectStatement, rows: Vec) -> ProtocolResult { + let mut evaluator = ExpressionEvaluator::new(); + + // WHERE + let filtered = match &select.where_clause { + None => rows, + Some(predicate) => { + let mut kept = Vec::new(); + for row in rows { + if is_true(&evaluate(&mut evaluator, predicate, &row)?) { + kept.push(row); + } + } + kept + } + }; + + // GROUP BY / aggregates. A select list containing an aggregate with no + // GROUP BY is one group over every row, which is what `COUNT(*)` means. + let grouped = if select.group_by.is_some() || has_aggregate(select) { + aggregate_rows(&mut evaluator, select, filtered)? + } else { + project_rows(&mut evaluator, select, filtered)? + }; + + // HAVING, evaluated over each group: it usually contains an aggregate, so + // it cannot be evaluated against a single representative row. + let after_having = match &select.having { + None => grouped, + Some(predicate) => { + let mut kept = Vec::new(); + for (row, group, output) in grouped { + let verdict = evaluate_over_group(&mut evaluator, predicate, &group, &row)?; + if is_true(&verdict) { + kept.push((row, group, output)); + } + } + kept + } + }; + + // DISTINCT, over the projected values so it means what the user sees. + let mut deduplicated = match select.distinct { + None => after_having, + Some(DistinctClause::Distinct) | Some(DistinctClause::DistinctOn(_)) => { + let mut seen = Vec::new(); + let mut kept = Vec::new(); + for (row, group, output) in after_having { + if !seen.contains(&output) { + seen.push(output.clone()); + kept.push((row, group, output)); + } + } + kept + } + }; + + // ORDER BY, on the source row so a sort key need not be selected. + if let Some(order_by) = &select.order_by { + // Keys are computed once per row rather than on each comparison, which + // would otherwise re-evaluate the expression O(n log n) times. + let mut keyed = Vec::with_capacity(deduplicated.len()); + for (row, group, output) in deduplicated { + let mut keys = Vec::with_capacity(order_by.len()); + for item in order_by { + keys.push(evaluate(&mut evaluator, &item.expression, &row)?); + } + keyed.push((keys, row, group, output)); + } + + keyed.sort_by(|a, b| { + for (index, item) in order_by.iter().enumerate() { + let ordering = compare(&a.0[index], &b.0[index]); + let ordering = match item.direction { + Some(SortDirection::Descending) => ordering.reverse(), + _ => ordering, + }; + if ordering != std::cmp::Ordering::Equal { + return ordering; + } + } + std::cmp::Ordering::Equal + }); + + deduplicated = keyed + .into_iter() + .map(|(_, row, group, output)| (row, group, output)) + .collect(); + } + + // OFFSET then LIMIT. + let offset = select.offset.unwrap_or(0) as usize; + let mut windowed: Vec<_> = deduplicated.into_iter().skip(offset).collect(); + if let Some(limit) = &select.limit { + if let Some(count) = &limit.count { + let count = evaluate(&mut evaluator, count, &Row::new())?; + if let Some(count) = as_i64(&count) { + windowed.truncate(count.max(0) as usize); + } + } + } + + Ok(SelectOutput { + columns: output_column_names(select), + rows: windowed + .into_iter() + .map(|(_, _, output)| { + output + .into_iter() + .map(|value| match value { + SqlValue::Null => None, + other => Some(other.to_postgres_string()), + }) + .collect() + }) + .collect(), + }) +} + +/// Whether the select list or HAVING clause calls an aggregate. +fn has_aggregate(select: &SelectStatement) -> bool { + select + .select_list + .iter() + .any(|item| match item { + SelectItem::Expression { expr, .. } => expression_has_aggregate(expr), + _ => false, + }) + || select.having.as_ref().is_some_and(expression_has_aggregate) +} + +fn expression_has_aggregate(expr: &Expression) -> bool { + match expr { + Expression::Function(call) => { + let name = match &call.name { + FunctionName::Simple(name) => name, + FunctionName::Qualified { name, .. } => name, + }; + matches!( + name.to_uppercase().as_str(), + "COUNT" | "SUM" | "AVG" | "MIN" | "MAX" | "ARRAY_AGG" | "STRING_AGG" + ) || call.args.iter().any(expression_has_aggregate) + } + Expression::Binary { left, right, .. } => { + expression_has_aggregate(left) || expression_has_aggregate(right) + } + _ => false, + } +} + +/// Project each row through the select list, keeping the source row alongside +/// so later clauses can still see columns that were not selected. +#[allow(clippy::type_complexity)] +fn project_rows( + evaluator: &mut ExpressionEvaluator, + select: &SelectStatement, + rows: Vec, +) -> ProtocolResult, Vec)>> { + let mut projected = Vec::with_capacity(rows.len()); + for row in rows { + let values = project_one(evaluator, select, &row, &row)?; + projected.push((row.clone(), vec![row], values)); + } + Ok(projected) +} + +/// Group rows and evaluate aggregates over each group. +#[allow(clippy::type_complexity)] +fn aggregate_rows( + evaluator: &mut ExpressionEvaluator, + select: &SelectStatement, + rows: Vec, +) -> ProtocolResult, Vec)>> { + // Group key preserves first-seen order, so results are stable without an + // ORDER BY rather than following a hash map's iteration order. + let mut keys: Vec> = Vec::new(); + let mut groups: Vec> = Vec::new(); + + for row in rows { + let key = match &select.group_by { + None => Vec::new(), + Some(expressions) => { + let mut key = Vec::with_capacity(expressions.len()); + for expression in expressions { + key.push(evaluate(evaluator, expression, &row)?); + } + key + } + }; + + match keys.iter().position(|existing| *existing == key) { + Some(index) => groups[index].push(row), + None => { + keys.push(key); + groups.push(vec![row]); + } + } + } + + // An aggregate over no rows still produces one row — `COUNT(*)` of an + // empty table is 0, not "no result". + if groups.is_empty() && select.group_by.is_none() { + groups.push(Vec::new()); + } + + let mut output = Vec::with_capacity(groups.len()); + for group in groups { + let representative = group.first().cloned().unwrap_or_default(); + let values = project_group(evaluator, select, &group, &representative)?; + output.push((representative, group, values)); + } + Ok(output) +} + +/// Evaluate the select list for one group. +fn project_group( + evaluator: &mut ExpressionEvaluator, + select: &SelectStatement, + group: &[Row], + representative: &Row, +) -> ProtocolResult> { + let mut values = Vec::new(); + for item in &select.select_list { + match item { + SelectItem::Wildcard | SelectItem::QualifiedWildcard { .. } => { + for (_, value) in sorted_pairs(representative) { + values.push(value); + } + } + SelectItem::Expression { expr, .. } => { + values.push(evaluate_over_group(evaluator, expr, group, representative)?); + } + } + } + Ok(values) +} + +/// Evaluate an expression that may contain aggregates over `group`. +fn evaluate_over_group( + evaluator: &mut ExpressionEvaluator, + expr: &Expression, + group: &[Row], + representative: &Row, +) -> ProtocolResult { + if !expression_has_aggregate(expr) { + // A plain column in a grouped select takes the group's value, which is + // the same for every row in it when it is a grouping key. + return evaluate(evaluator, expr, representative); + } + + let Expression::Function(call) = expr else { + // An aggregate inside a larger expression, such as `COUNT(*) > 1` in a + // HAVING clause. Each aggregate is reduced to a literal first, then the + // surrounding expression is evaluated normally. This handles any depth + // of nesting without the evaluator needing to know about groups. + let substituted = substitute_aggregates(evaluator, expr, group, representative)?; + return evaluate(evaluator, &substituted, representative); + }; + + let name = match &call.name { + FunctionName::Simple(name) => name.to_uppercase(), + FunctionName::Qualified { name, .. } => name.to_uppercase(), + }; + + // `COUNT(*)` counts rows; every other aggregate skips NULL inputs, as in + // PostgreSQL. + // `COUNT(*)` arrives either with no argument or with a column literally + // named `*`, depending on how the parser spelled it. + let is_star = call.args.is_empty() + || matches!( + call.args.first(), + Some(Expression::Column(column)) if column.name == "*" + ); + + let mut inputs = Vec::new(); + if !is_star { + for row in group { + let value = evaluate(evaluator, &call.args[0], row)?; + if matches!(value, SqlValue::Null) { + continue; + } + // `DISTINCT` inside an aggregate deduplicates its inputs, so + // `COUNT(DISTINCT grp)` counts groups rather than rows. Ignoring it + // returns the plain count, which is right only by coincidence. + if call.distinct && inputs.contains(&value) { + continue; + } + inputs.push(value); + } + } + + Ok(match name.as_str() { + "COUNT" if is_star => SqlValue::BigInt(group.len() as i64), + "COUNT" => SqlValue::BigInt(inputs.len() as i64), + "SUM" => { + if inputs.is_empty() { + SqlValue::Null + } else if inputs.iter().all(|v| as_i64(v).is_some()) { + SqlValue::BigInt(inputs.iter().filter_map(as_i64).sum()) + } else { + SqlValue::DoublePrecision(inputs.iter().filter_map(as_f64).sum()) + } + } + "AVG" => { + let numbers: Vec = inputs.iter().filter_map(as_f64).collect(); + if numbers.is_empty() { + SqlValue::Null + } else { + SqlValue::DoublePrecision(numbers.iter().sum::() / numbers.len() as f64) + } + } + "MIN" => inputs + .into_iter() + .reduce(|a, b| if compare(&a, &b).is_le() { a } else { b }) + .unwrap_or(SqlValue::Null), + "MAX" => inputs + .into_iter() + .reduce(|a, b| if compare(&a, &b).is_ge() { a } else { b }) + .unwrap_or(SqlValue::Null), + "STRING_AGG" | "ARRAY_AGG" => SqlValue::Text( + inputs + .iter() + .map(SqlValue::to_postgres_string) + .collect::>() + .join(","), + ), + other => { + return Err(ProtocolError::PostgresError(format!( + "aggregate function '{other}' is not implemented" + ))) + } + }) +} + +/// Replace every aggregate call in `expr` with the value it takes over `group`. +fn substitute_aggregates( + evaluator: &mut ExpressionEvaluator, + expr: &Expression, + group: &[Row], + representative: &Row, +) -> ProtocolResult { + if !expression_has_aggregate(expr) { + return Ok(expr.clone()); + } + + Ok(match expr { + Expression::Function(_) => Expression::Literal(evaluate_over_group( + evaluator, + expr, + group, + representative, + )?), + Expression::Binary { + left, + operator, + right, + } => Expression::Binary { + left: Box::new(substitute_aggregates(evaluator, left, group, representative)?), + operator: operator.clone(), + right: Box::new(substitute_aggregates( + evaluator, + right, + group, + representative, + )?), + }, + other => other.clone(), + }) +} + +/// Evaluate the select list for one ungrouped row. +fn project_one( + evaluator: &mut ExpressionEvaluator, + select: &SelectStatement, + row: &Row, + source: &Row, +) -> ProtocolResult> { + let mut values = Vec::new(); + for item in &select.select_list { + match item { + SelectItem::Wildcard | SelectItem::QualifiedWildcard { .. } => { + for (_, value) in sorted_pairs(source) { + values.push(value); + } + } + SelectItem::Expression { expr, .. } => { + values.push(evaluate(evaluator, expr, row)?); + } + } + } + Ok(values) +} + +/// Column names in the order the projection produces them. +pub fn output_column_names(select: &SelectStatement) -> Vec { + let mut names = Vec::new(); + for item in &select.select_list { + match item { + SelectItem::Wildcard | SelectItem::QualifiedWildcard { .. } => { + names.push("*".to_string()); + } + SelectItem::Expression { expr, alias } => names.push(match (alias, expr) { + (Some(alias), _) => alias.clone(), + (None, Expression::Column(column)) => column.name.clone(), + (None, Expression::Function(call)) => match &call.name { + FunctionName::Simple(name) => name.to_lowercase(), + FunctionName::Qualified { name, .. } => name.to_lowercase(), + }, + (None, _) => "expr".to_string(), + }), + } + } + names +} + +/// Row entries in a stable order, so `SELECT *` does not vary run to run. +fn sorted_pairs(row: &Row) -> Vec<(String, SqlValue)> { + let mut pairs: Vec<(String, SqlValue)> = + row.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + pairs.sort_by(|a, b| a.0.cmp(&b.0)); + pairs +} + +fn evaluate( + evaluator: &mut ExpressionEvaluator, + expr: &Expression, + row: &Row, +) -> ProtocolResult { + let context = EvaluationContext::with_row(row.clone()); + evaluator.evaluate(expr, &context) +} + +fn is_true(value: &SqlValue) -> bool { + matches!(value, SqlValue::Boolean(true)) +} + +fn as_i64(value: &SqlValue) -> Option { + match value { + SqlValue::SmallInt(n) => Some(i64::from(*n)), + SqlValue::Integer(n) => Some(i64::from(*n)), + SqlValue::BigInt(n) => Some(*n), + _ => None, + } +} + +fn as_f64(value: &SqlValue) -> Option { + match value { + SqlValue::Real(n) => Some(f64::from(*n)), + SqlValue::DoublePrecision(n) => Some(*n), + other => as_i64(other).map(|n| n as f64), + } +} + +/// Order two values, with NULL sorting last as PostgreSQL does by default. +fn compare(a: &SqlValue, b: &SqlValue) -> std::cmp::Ordering { + use std::cmp::Ordering; + + match (a, b) { + (SqlValue::Null, SqlValue::Null) => Ordering::Equal, + (SqlValue::Null, _) => Ordering::Greater, + (_, SqlValue::Null) => Ordering::Less, + _ => match (as_f64(a), as_f64(b)) { + (Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(Ordering::Equal), + _ => a.to_postgres_string().cmp(&b.to_postgres_string()), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocols::postgres_wire::sql::parser::SqlParser; + + fn rows(values: &[(i32, &str)]) -> Vec { + values + .iter() + .map(|(id, grp)| { + let mut row = Row::new(); + row.insert("id".to_string(), SqlValue::Integer(*id)); + row.insert("grp".to_string(), SqlValue::Text((*grp).to_string())); + row + }) + .collect() + } + + fn select(sql: &str) -> SelectStatement { + let statement = SqlParser::new() + .parse(sql) + .unwrap_or_else(|e| panic!("parse {sql}: {e}")); + match statement { + super::super::ast::Statement::Select(select) => *select, + other => panic!("expected a SELECT, got {other:?}"), + } + } + + fn run(sql: &str, data: Vec) -> SelectOutput { + run_select(&select(sql), data).unwrap_or_else(|e| panic!("run {sql}: {e}")) + } + + fn first_column(output: &SelectOutput) -> Vec> { + output.rows.iter().map(|row| row[0].clone()).collect() + } + + #[test] + fn limit_truncates_the_result() { + let output = run("SELECT id FROM t LIMIT 2", rows(&[(1, "a"), (2, "a"), (3, "b")])); + assert_eq!(output.rows.len(), 2, "LIMIT must bound the row count"); + } + + #[test] + fn offset_skips_leading_rows() { + let output = run( + "SELECT id FROM t LIMIT 1 OFFSET 1", + rows(&[(1, "a"), (2, "a"), (3, "b")]), + ); + assert_eq!(first_column(&output), vec![Some("2".to_string())]); + } + + #[test] + fn order_by_sorts_descending() { + let output = run( + "SELECT id FROM t ORDER BY id DESC", + rows(&[(1, "a"), (3, "b"), (2, "a")]), + ); + assert_eq!( + first_column(&output), + vec![ + Some("3".to_string()), + Some("2".to_string()), + Some("1".to_string()) + ] + ); + } + + #[test] + fn where_filters_rows() { + let output = run( + "SELECT id FROM t WHERE id > 1", + rows(&[(1, "a"), (2, "a"), (3, "b")]), + ); + assert_eq!(output.rows.len(), 2); + } + + #[test] + fn count_star_counts_rows() { + let output = run("SELECT COUNT(*) FROM t", rows(&[(1, "a"), (2, "a")])); + assert_eq!(first_column(&output), vec![Some("2".to_string())]); + } + + /// An aggregate over an empty input still produces one row. + #[test] + fn count_of_no_rows_is_zero_not_empty() { + let output = run("SELECT COUNT(*) FROM t", Vec::new()); + assert_eq!(first_column(&output), vec![Some("0".to_string())]); + } + + #[test] + fn group_by_produces_one_row_per_group() { + let output = run( + "SELECT grp, COUNT(*) FROM t GROUP BY grp", + rows(&[(1, "a"), (2, "a"), (3, "b")]), + ); + assert_eq!(output.rows.len(), 2); + assert_eq!(output.rows[0][1], Some("2".to_string())); + assert_eq!(output.rows[1][1], Some("1".to_string())); + } + + #[test] + fn distinct_removes_duplicate_output_rows() { + let output = run( + "SELECT DISTINCT grp FROM t", + rows(&[(1, "a"), (2, "a"), (3, "b")]), + ); + assert_eq!(output.rows.len(), 2); + } + + #[test] + fn sum_and_avg_reduce_the_group() { + let data = rows(&[(1, "a"), (3, "a")]); + assert_eq!( + first_column(&run("SELECT SUM(id) FROM t", data.clone())), + vec![Some("4".to_string())] + ); + assert_eq!( + first_column(&run("SELECT MIN(id) FROM t", data.clone())), + vec![Some("1".to_string())] + ); + assert_eq!( + first_column(&run("SELECT MAX(id) FROM t", data)), + vec![Some("3".to_string())] + ); + } + + /// Two rows share a group, so the row count and the distinct count differ. + #[test] + fn count_distinct_counts_values_not_rows() { + let output = run( + "SELECT COUNT(DISTINCT grp) FROM t", + rows(&[(1, "a"), (2, "a"), (3, "b")]), + ); + assert_eq!(first_column(&output), vec![Some("2".to_string())]); + } + + #[test] + fn having_filters_groups() { + let output = run( + "SELECT grp FROM t GROUP BY grp HAVING COUNT(*) > 1", + rows(&[(1, "a"), (2, "a"), (3, "b")]), + ); + assert_eq!(output.rows.len(), 1); + assert_eq!(output.rows[0][0], Some("a".to_string())); + } + + /// NULL sorts last ascending, matching PostgreSQL's default. + #[test] + fn nulls_sort_last_by_default() { + let mut with_null = rows(&[(2, "a")]); + let mut null_row = Row::new(); + null_row.insert("id".to_string(), SqlValue::Null); + null_row.insert("grp".to_string(), SqlValue::Text("z".to_string())); + with_null.push(null_row); + + let output = run("SELECT id FROM t ORDER BY id", with_null); + assert_eq!(first_column(&output), vec![Some("2".to_string()), None]); + } + + #[test] + fn a_query_with_no_clauses_returns_every_row() { + let output = run("SELECT id FROM t", rows(&[(1, "a"), (2, "b")])); + assert_eq!(output.rows.len(), 2); + } +} diff --git a/specifications/PRD.md b/specifications/PRD.md index 798e6662..cc5e888b 100644 --- a/specifications/PRD.md +++ b/specifications/PRD.md @@ -819,6 +819,65 @@ zero is the truth, or reported as unavailable. A number in a response is a measurement. +### PostgreSQL Protocol Conformance + +Measured, not asserted. `tests/integration/pg_conformance.rs` drives the server +with `tokio-postgres` — a conforming client — and prints a pass/fail matrix: + +```bash +./target/debug/orbit-server --dev-mode --data-dir /tmp/pgconf --config config/orbit-server.toml & +cargo test -p orbit-integration-tests --test pg_conformance -- --ignored --nocapture +``` + +Current score: **45/48**. By area: + +| Area | Score | Notes | +|------|-------|-------| +| Connection (SCRAM) | 1/1 | | +| Simple query | 7/7 | | +| Extended query | 4/4 | Parse/Bind/Describe/Execute, typed and bound parameters | +| Portals / cursors | 1/1 | `Execute` row limits, `PortalSuspended` | +| Data types | 4/4 | Text and binary result formats; NULL distinct from `'NULL'` | +| Transactions | 2/3 | Status reporting is real; **rollback does not undo writes** | +| Catalog | 5/5 | `version()`, `current_database()`, `pg_class`, `pg_type`, `information_schema` | +| COPY | 2/2 | `TO STDOUT` and `FROM STDIN`, text format | +| LISTEN / NOTIFY | 1/1 | Cross-session, delivered while idle | +| Error reporting | 3/3 | SQLSTATE present, session survives errors | +| SQL surface | 15/17 | `WHERE`, `ORDER BY`, `LIMIT`/`OFFSET`, `GROUP BY`, `HAVING`, `DISTINCT`, aggregates, `EXPLAIN` | + +#### One source of truth for SELECT + +`sql/select_pipeline.rs` applies a statement's clauses — `WHERE` → +`GROUP BY`/aggregates → `HAVING` → `DISTINCT` → `ORDER BY` → `OFFSET`/`LIMIT` → +projection — to rows read from **persistent storage**, the same rows a plain +`SELECT` reads. + +Two earlier arrangements were wrong and are worth recording: + +1. The executor read every row and projected by column name, dropping those + clauses silently. `SELECT ... LIMIT 2` returned the whole table, `ORDER BY` + returned rows unordered, `COUNT(*)` returned one empty column per row. +2. Routing clause-bearing statements to the in-memory engine instead made them + read a *different copy* of the table, so `SELECT id FROM t` and + `SELECT id FROM t ORDER BY id` disagreed about how many rows existed. + +#### Not yet implemented + +- **Transaction atomicity.** `BEGIN`/`COMMIT`/`ROLLBACK` parse and the + protocol-level status is reported correctly, but storage applies writes + immediately and keeps no undo log, so a rollback reverts nothing. Rather than + report success for an undo that did not happen, `ROLLBACK` now emits a + `NoticeResponse` naming how many writes it could not take back. +- **`JOIN` and subqueries**, reported as explicit errors. +- **Binary `COPY`** and **GSSAPI encryption**, both refused explicitly. +- Protocol 3.2 negotiation, replication, and `FunctionCall`. + +Note on scope: 45/48 is 45 of *these 48 checks*. PostgreSQL's real surface — +the full type system, arrays, `NUMERIC`, domains, triggers, views, window +functions, `RETURNING`, replication — is far larger. Treat this as a floor that +moves measurably, not a compatibility claim. + + ## Storage Architecture ### Persistence Backends diff --git a/tests/integration/pg_conformance.rs b/tests/integration/pg_conformance.rs index a2611225..fc830a98 100644 --- a/tests/integration/pg_conformance.rs +++ b/tests/integration/pg_conformance.rs @@ -36,6 +36,7 @@ enum Area { Copy, Notify, Errors, + Sql, } impl Area { @@ -51,6 +52,7 @@ impl Area { Area::Copy => "COPY", Area::Notify => "LISTEN / NOTIFY", Area::Errors => "error reporting", + Area::Sql => "SQL surface", } } } @@ -193,6 +195,23 @@ async fn connect_with_notifications( Ok((client, receiver)) } +/// Read the first column of every row a statement returns, as text. +/// +/// The simple-query protocol renders every value as text, so this compares +/// answers without needing the client to guess a Rust type per column — and it +/// checks the value, not merely that the statement did not error. +async fn simple_column(client: &Client, sql: &str) -> Result, String> { + use tokio_postgres::SimpleQueryMessage; + let messages = client.simple_query(sql).await.map_err(describe)?; + Ok(messages + .iter() + .filter_map(|message| match message { + SimpleQueryMessage::Row(row) => Some(row.get(0).unwrap_or("NULL").to_string()), + _ => None, + }) + .collect()) +} + /// Best-effort cleanup that must not mask the failure under test. async fn drop_table(client: &Client, table: &str) { let _ = client @@ -339,11 +358,11 @@ async fn postgres_protocol_conformance() { .await .map_err(describe) .and_then(|rows| { - let value: Result<&str, _> = rows[0].try_get(0); - match value { + let row = rows.first().ok_or("no rows returned".to_string())?; + match row.try_get::<_, &str>(0) { Ok("one") => Ok(()), Ok(other) => Err(format!("got {other:?}")), - Err(e) => Err(e.to_string()), + Err(e) => Err(describe(e)), } }), ); @@ -356,7 +375,8 @@ async fn postgres_protocol_conformance() { .await .map_err(describe) .and_then(|rows| { - rows[0] + rows.first() + .ok_or("no rows returned".to_string())? .try_get::<_, i32>(0) .map(|_| ()) .map_err(describe) @@ -546,6 +566,20 @@ async fn postgres_protocol_conformance() { } // ------------------------------------------------------------------ COPY + // On its own connection: a server that does not understand the COPY + // subprotocol leaves the session unusable, and sharing one connection made + // every later check fail for a reason that had nothing to do with it. + let client = match connect().await { + Ok(fresh) => fresh, + Err(e) => { + report.record(Area::Copy, "COPY TO STDOUT streams rows", Err(e.clone())); + report.record(Area::Copy, "COPY FROM STDIN ingests rows", Err(e.clone())); + report.record(Area::Errors, "reconnect for COPY", Err(e)); + println!("{}", report.render()); + return; + } + }; + drop_table(&client, "conf_copy_in").await; let _ = client .simple_query("CREATE TABLE conf_copy_in (id INTEGER)") @@ -603,7 +637,7 @@ async fn postgres_protocol_conformance() { "a NOTIFY reaches a listening session", async { let (notify_client, mut notify_stream) = - connect_with_notifications().await.map_err(|e| e)?; + connect_with_notifications().await?; notify_client .simple_query("LISTEN conf_channel") @@ -630,6 +664,22 @@ async fn postgres_protocol_conformance() { ); // ---------------------------------------------------------------- errors + // Fresh again, for the same reason. + let client = match connect().await { + Ok(fresh) => fresh, + Err(e) => { + for name in [ + "unknown table is an error, not a silent empty result", + "error carries a SQLSTATE code", + "session is usable after a failed statement", + ] { + report.record(Area::Errors, name, Err(e.clone())); + } + println!("{}", report.render()); + return; + } + }; + report.record( Area::Errors, "unknown table is an error, not a silent empty result", @@ -671,6 +721,689 @@ async fn postgres_protocol_conformance() { drop_table(&client, table).await; } + + // ------------------------------------------------- broader SQL surface + // Added so the score reflects more than the features already known to + // work: a harness that only measures what passes overstates conformance. + let client = match connect().await { + Ok(fresh) => fresh, + Err(e) => { + report.record(Area::SimpleQuery, "reconnect for SQL surface", Err(e)); + println!("{}", report.render()); + return; + } + }; + + drop_table(&client, "conf_sql").await; + let _ = client + .simple_query("CREATE TABLE conf_sql (id INTEGER, grp TEXT, amount INTEGER)") + .await; + for (id, grp, amount) in [(1, "a", 10), (2, "a", 20), (3, "b", 30)] { + let _ = client + .simple_query(&format!( + "INSERT INTO conf_sql (id, grp, amount) VALUES ({id}, '{grp}', {amount})" + )) + .await; + } + + for (area, name, sql, want_rows) in [ + (Area::Sql, "ORDER BY (checked separately)", "SELECT id FROM conf_sql", 3usize), + (Area::Sql, "LIMIT", "SELECT id FROM conf_sql LIMIT 2", 2), + (Area::Sql, "COUNT(*) aggregate", "SELECT COUNT(*) FROM conf_sql", 1), + ( + Area::Sql, + "GROUP BY with aggregate", + "SELECT grp, SUM(amount) FROM conf_sql GROUP BY grp", + 2, + ), + ( + Area::Sql, + "WHERE with AND", + "SELECT id FROM conf_sql WHERE grp = 'a' AND amount > 15", + 1, + ), + (Area::Sql, "IN list", "SELECT id FROM conf_sql WHERE id IN (1, 2)", 2), + ( + Area::Sql, + "self JOIN", + "SELECT a.id FROM conf_sql a JOIN conf_sql b ON a.grp = b.grp WHERE a.id = 1", + 2, + ), + ( + Area::Sql, + "subquery in WHERE", + "SELECT id FROM conf_sql WHERE amount = (SELECT MAX(amount) FROM conf_sql)", + 1, + ), + ( + Area::Sql, + "DISTINCT", + "SELECT DISTINCT grp FROM conf_sql", + 2, + ), + ( + Area::Sql, + "column alias", + "SELECT id AS identifier FROM conf_sql WHERE id = 1", + 1, + ), + ] { + report.record( + area, + name, + client + .query(sql, &[]) + .await + .map_err(describe) + .and_then(|rows| { + (rows.len() == want_rows) + .then_some(()) + .ok_or(format!("{} rows, expected {want_rows}", rows.len())) + }), + ); + } + + for (name, sql) in [ + ("SET then SHOW a runtime parameter", "SET application_name = 'conf'"), + ("EXPLAIN returns a plan", "EXPLAIN SELECT id FROM conf_sql"), + ("DECLARE a cursor", "DECLARE c CURSOR FOR SELECT id FROM conf_sql"), + ("SAVEPOINT inside a transaction", "BEGIN; SAVEPOINT s1; ROLLBACK"), + ("CREATE INDEX", "CREATE INDEX conf_idx ON conf_sql (id)"), + ("ALTER TABLE ADD COLUMN", "ALTER TABLE conf_sql ADD COLUMN note TEXT"), + ] { + report.record( + Area::Sql, + name, + client + .simple_query(sql) + .await + .map(|_| ()) + .map_err(describe), + ); + } + + report.record( + Area::Sql, + "ORDER BY actually orders", + client + .query("SELECT id FROM conf_sql ORDER BY id DESC", &[]) + .await + .map_err(describe) + .and_then(|rows| { + let ids: Vec = rows + .iter() + .filter_map(|row| row.try_get::<_, i32>(0).ok()) + .collect(); + // Returning every row in storage order also yields three rows, + // so the count alone proves nothing. + (ids == vec![3, 2, 1]) + .then_some(()) + .ok_or(format!("got {ids:?}, expected [3, 2, 1]")) + }), + ); + + report.record( + Area::Types, + "NULL round-trips as NULL", + async { + client + .simple_query("INSERT INTO conf_sql (id, grp, amount) VALUES (9, NULL, 1)") + .await + .map_err(describe)?; + let rows = client + .query("SELECT grp FROM conf_sql WHERE id = 9", &[]) + .await + .map_err(describe)?; + let row = rows.first().ok_or("no row".to_string())?; + let value: Option<&str> = row.try_get(0).map_err(describe)?; + value + .is_none() + .then_some(()) + .ok_or_else(|| format!("expected NULL, got {value:?}")) + } + .await, + ); + + report.record( + Area::Types, + "BOOLEAN column round-trips", + async { + drop_table(&client, "conf_bool").await; + client + .simple_query("CREATE TABLE conf_bool (flag BOOLEAN)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_bool (flag) VALUES (true)") + .await + .map_err(describe)?; + let rows = client + .query("SELECT flag FROM conf_bool", &[]) + .await + .map_err(describe)?; + let row = rows.first().ok_or("no row".to_string())?; + row.try_get::<_, bool>(0) + .map_err(describe) + .and_then(|v| v.then_some(()).ok_or("expected true".to_string())) + } + .await, + ); + + report.record( + Area::ExtendedQuery, + "a prepared statement can be executed twice", + async { + let statement = client + .prepare("SELECT id FROM conf_sql WHERE id = $1") + .await + .map_err(describe)?; + let first = client.query(&statement, &[&1i32]).await.map_err(describe)?; + let second = client.query(&statement, &[&2i32]).await.map_err(describe)?; + (first.len() == 1 && second.len() == 1) + .then_some(()) + .ok_or_else(|| format!("{} then {} rows", first.len(), second.len())) + } + .await, + ); + + for table in ["conf_sql", "conf_bool"] { + drop_table(&client, table).await; + } + + + // ------------------------------------------- second round of coverage + // Added after the first 48 checks all passed: a harness that stops finding + // gaps has stopped measuring, not finished. These probe the surface real + // clients use that the first round never touched. + let client = match connect().await { + Ok(fresh) => fresh, + Err(e) => { + report.record(Area::Sql, "reconnect for round two", Err(e)); + println!("{}", report.render()); + return; + } + }; + + drop_table(&client, "conf_two").await; + let _ = client + .simple_query("CREATE TABLE conf_two (id INTEGER, name TEXT, amount INTEGER)") + .await; + for (id, name, amount) in [(1, "alpha", 10), (2, "beta", 20), (3, "gamma", 30)] { + let _ = client + .simple_query(&format!( + "INSERT INTO conf_two (id, name, amount) VALUES ({id}, '{name}', {amount})" + )) + .await; + } + + for (area, name, sql, want) in [ + (Area::Sql, "LIKE pattern match", "SELECT id FROM conf_two WHERE name LIKE 'al%'", 1usize), + (Area::Sql, "BETWEEN range", "SELECT id FROM conf_two WHERE amount BETWEEN 15 AND 25", 1), + (Area::Sql, "IS NULL", "SELECT id FROM conf_two WHERE name IS NOT NULL", 3), + (Area::Sql, "NOT with comparison", "SELECT id FROM conf_two WHERE NOT id = 1", 2), + (Area::Sql, "OR predicate", "SELECT id FROM conf_two WHERE id = 1 OR id = 3", 2), + (Area::Sql, "arithmetic in projection", "SELECT amount + 1 FROM conf_two WHERE id = 1", 1), + (Area::Sql, "ORDER BY two keys", "SELECT id FROM conf_two ORDER BY amount DESC, id ASC", 3), + (Area::Sql, "aggregate with WHERE", "SELECT COUNT(*) FROM conf_two WHERE amount > 15", 1), + (Area::Sql, "LEFT JOIN keeps unmatched rows", "SELECT a.id FROM conf_two a LEFT JOIN conf_two b ON a.id = b.id + 100", 3), + ] { + report.record( + area, + name, + client.query(sql, &[]).await.map_err(describe).and_then(|rows| { + (rows.len() == want) + .then_some(()) + .ok_or(format!("{} rows, expected {want}", rows.len())) + }), + ); + } + + report.record( + Area::Sql, + "aggregate value is correct, not just the row count", + client + .query("SELECT SUM(amount) FROM conf_two", &[]) + .await + .map_err(describe) + .and_then(|rows| { + let row = rows.first().ok_or("no row".to_string())?; + let text: String = row + .try_get::<_, i64>(0) + .map(|v| v.to_string()) + .or_else(|_| row.try_get::<_, &str>(0).map(str::to_string)) + .map_err(describe)?; + (text == "60") + .then_some(()) + .ok_or(format!("SUM was {text}, expected 60")) + }), + ); + + report.record( + Area::Transactions, + "ROLLBACK undoes an UPDATE, not just an INSERT", + async { + client + .simple_query("BEGIN") + .await + .map_err(describe)?; + client + .simple_query("UPDATE conf_two SET amount = 999 WHERE id = 1") + .await + .map_err(describe)?; + client.simple_query("ROLLBACK").await.map_err(describe)?; + + let rows = client + .query("SELECT amount FROM conf_two WHERE id = 1", &[]) + .await + .map_err(describe)?; + let row = rows.first().ok_or("row disappeared".to_string())?; + let amount: i32 = row.try_get(0).map_err(describe)?; + (amount == 10) + .then_some(()) + .ok_or(format!("amount is {amount}, expected the original 10")) + } + .await, + ); + + report.record( + Area::Transactions, + "ROLLBACK undoes a DELETE", + async { + client.simple_query("BEGIN").await.map_err(describe)?; + client + .simple_query("DELETE FROM conf_two WHERE id = 2") + .await + .map_err(describe)?; + client.simple_query("ROLLBACK").await.map_err(describe)?; + + let rows = client + .query("SELECT id FROM conf_two", &[]) + .await + .map_err(describe)?; + (rows.len() == 3) + .then_some(()) + .ok_or(format!("{} rows survived, expected 3", rows.len())) + } + .await, + ); + + report.record( + Area::Transactions, + "COMMIT keeps the write", + async { + client.simple_query("BEGIN").await.map_err(describe)?; + client + .simple_query("INSERT INTO conf_two (id, name, amount) VALUES (4, 'delta', 40)") + .await + .map_err(describe)?; + client.simple_query("COMMIT").await.map_err(describe)?; + + let rows = client + .query("SELECT id FROM conf_two WHERE id = 4", &[]) + .await + .map_err(describe)?; + (rows.len() == 1) + .then_some(()) + .ok_or("committed row is missing".to_string()) + } + .await, + ); + + report.record( + Area::Types, + "a text value containing a quote round-trips", + async { + client + .simple_query("INSERT INTO conf_two (id, name, amount) VALUES (5, 'O''Brien', 1)") + .await + .map_err(describe)?; + let rows = client + .query("SELECT name FROM conf_two WHERE id = 5", &[]) + .await + .map_err(describe)?; + let row = rows.first().ok_or("no row".to_string())?; + let name: &str = row.try_get(0).map_err(describe)?; + (name == "O'Brien") + .then_some(()) + .ok_or(format!("got {name:?}")) + } + .await, + ); + + drop_table(&client, "conf_two").await; + + + // -------------------------------------------- third round of coverage + // The second round ended at 62/62, which measures the checks written, not + // the protocol. These cover the surface a real client reaches for that + // nothing above touches: set operations, CTEs, window functions, string + // and aggregate functions, RETURNING, views, and the numeric and temporal + // types. Every check compares a value, because a statement that runs and + // answers wrongly is worse than one that errors. + let client = match connect().await { + Ok(fresh) => fresh, + Err(e) => { + report.record(Area::Sql, "reconnect for round three", Err(e)); + println!("{}", report.render()); + return; + } + }; + + drop_table(&client, "conf_three").await; + let _ = client + .simple_query("CREATE TABLE conf_three (id INTEGER, grp TEXT, amount INTEGER, name TEXT)") + .await; + for (id, grp, amount, name) in [(1, "a", 10, "alpha"), (2, "a", 20, "beta"), (3, "b", 30, "gamma")] { + let _ = client + .simple_query(&format!( + "INSERT INTO conf_three (id, grp, amount, name) VALUES ({id}, '{grp}', {amount}, '{name}')" + )) + .await; + } + + for (name, sql, want) in [ + ( + "HAVING filters groups", + // Both groups sum to 30, so a SUM threshold would not discriminate; + // the row count does. + "SELECT grp FROM conf_three GROUP BY grp HAVING COUNT(*) > 1", + "a", + ), + ( + "UNION ALL keeps duplicates", + "SELECT id FROM conf_three WHERE id = 1 UNION ALL SELECT id FROM conf_three WHERE id = 1", + "1,1", + ), + ( + "UNION removes duplicates", + "SELECT id FROM conf_three WHERE id = 1 UNION SELECT id FROM conf_three WHERE id = 1", + "1", + ), + ( + "CASE expression", + "SELECT CASE WHEN amount > 15 THEN 'big' ELSE 'small' END FROM conf_three ORDER BY id", + "small,big,big", + ), + ( + "COALESCE picks the first non-NULL", + "SELECT COALESCE(NULL, 'fallback')", + "fallback", + ), + ( + "CTE (WITH)", + "WITH big AS (SELECT id FROM conf_three WHERE amount > 15) SELECT id FROM big ORDER BY id", + "2,3", + ), + ( + "window function ROW_NUMBER", + "SELECT ROW_NUMBER() OVER (ORDER BY id) FROM conf_three", + "1,2,3", + ), + ( + "UPPER()", + "SELECT UPPER(name) FROM conf_three WHERE id = 1", + "ALPHA", + ), + ( + "LENGTH()", + "SELECT LENGTH(name) FROM conf_three WHERE id = 1", + "5", + ), + ( + "string concatenation", + "SELECT name || '!' FROM conf_three WHERE id = 1", + "alpha!", + ), + ("MIN()", "SELECT MIN(amount) FROM conf_three", "10"), + ("MAX()", "SELECT MAX(amount) FROM conf_three", "30"), + ( + "COUNT(DISTINCT)", + "SELECT COUNT(DISTINCT grp) FROM conf_three", + "2", + ), + ( + "LIMIT with OFFSET", + "SELECT id FROM conf_three ORDER BY id LIMIT 1 OFFSET 1", + "2", + ), + ( + "derived table in FROM", + "SELECT t.id FROM (SELECT id FROM conf_three WHERE id > 1) t ORDER BY t.id", + "2,3", + ), + ( + "ORDER BY with LIMIT picks the top row", + "SELECT id FROM conf_three ORDER BY amount DESC LIMIT 1", + "3", + ), + ( + "INSERT ... RETURNING", + "INSERT INTO conf_three (id, grp, amount, name) VALUES (7, 'c', 70, 'eta') RETURNING id", + "7", + ), + ( + "UPDATE ... RETURNING", + "UPDATE conf_three SET amount = 71 WHERE id = 7 RETURNING amount", + "71", + ), + ( + "DELETE ... RETURNING", + "DELETE FROM conf_three WHERE id = 7 RETURNING id", + "7", + ), + ] { + report.record( + Area::Sql, + name, + simple_column(&client, sql).await.and_then(|values| { + let got = values.join(","); + (got == want) + .then_some(()) + .ok_or(format!("got {got:?}, expected {want:?}")) + }), + ); + } + + report.record( + Area::Sql, + "AVG()", + simple_column(&client, "SELECT AVG(amount) FROM conf_three") + .await + .and_then(|values| { + let got = values.join(","); + // 20, 20.0 and 20.0000000000000000 are all the right answer; + // only the scale differs, and PostgreSQL's own scale for + // avg(integer) is not something to hard-code here. + got.starts_with("20") + .then_some(()) + .ok_or(format!("got {got:?}, expected 20")) + }), + ); + + report.record( + Area::Sql, + "multi-row INSERT", + async { + client + .simple_query( + "INSERT INTO conf_three (id, grp, amount, name) \ + VALUES (8, 'd', 80, 'theta'), (9, 'd', 90, 'iota')", + ) + .await + .map_err(describe)?; + let ids = simple_column( + &client, + "SELECT id FROM conf_three WHERE id > 7 ORDER BY id", + ) + .await?; + (ids == ["8", "9"]) + .then_some(()) + .ok_or(format!("got {ids:?}, expected [8, 9]")) + } + .await, + ); + + report.record( + Area::Sql, + "CREATE VIEW then read it back", + async { + let _ = client.simple_query("DROP VIEW IF EXISTS conf_view").await; + client + .simple_query("CREATE VIEW conf_view AS SELECT id FROM conf_three WHERE id = 1") + .await + .map_err(describe)?; + let ids = simple_column(&client, "SELECT id FROM conf_view").await?; + let result = (ids == ["1"]) + .then_some(()) + .ok_or(format!("got {ids:?}, expected [1]")); + let _ = client.simple_query("DROP VIEW IF EXISTS conf_view").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "TRUNCATE empties the table", + async { + drop_table(&client, "conf_trunc").await; + client + .simple_query("CREATE TABLE conf_trunc (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_trunc (id) VALUES (1)") + .await + .map_err(describe)?; + client + .simple_query("TRUNCATE TABLE conf_trunc") + .await + .map_err(describe)?; + let remaining = simple_column(&client, "SELECT id FROM conf_trunc").await?; + let result = remaining + .is_empty() + .then_some(()) + .ok_or(format!("{} rows survived TRUNCATE", remaining.len())); + drop_table(&client, "conf_trunc").await; + result + } + .await, + ); + + // ------------------------------------------------------- numeric & time + drop_table(&client, "conf_types").await; + let types_ready = async { + client + .simple_query( + "CREATE TABLE conf_types (big BIGINT, exact NUMERIC, approx DOUBLE PRECISION, \ + day DATE, moment TIMESTAMP)", + ) + .await + .map_err(describe)?; + client + .simple_query( + "INSERT INTO conf_types (big, exact, approx, day, moment) VALUES \ + (9223372036854775807, 12.34, 1.5, '2026-01-02', '2026-01-02 03:04:05')", + ) + .await + .map_err(describe)?; + Ok::<(), String>(()) + } + .await; + + for (name, column, want) in [ + ("BIGINT round-trips at the i64 limit", "big", "9223372036854775807"), + ("NUMERIC keeps its scale", "exact", "12.34"), + ("DOUBLE PRECISION round-trips", "approx", "1.5"), + ("DATE round-trips", "day", "2026-01-02"), + ] { + report.record( + Area::Types, + name, + match &types_ready { + Err(e) => Err(format!("not run: {e}")), + Ok(()) => simple_column(&client, &format!("SELECT {column} FROM conf_types")) + .await + .and_then(|values| { + let got = values.join(","); + (got == want) + .then_some(()) + .ok_or(format!("got {got:?}, expected {want:?}")) + }), + }, + ); + } + + report.record( + Area::Types, + "TIMESTAMP round-trips", + match &types_ready { + Err(e) => Err(format!("not run: {e}")), + Ok(()) => simple_column(&client, "SELECT moment FROM conf_types") + .await + .and_then(|values| { + let got = values.join(","); + // The fractional-second suffix is PostgreSQL's business; + // the instant is what has to survive. + got.starts_with("2026-01-02 03:04:05") + .then_some(()) + .ok_or(format!("got {got:?}, expected 2026-01-02 03:04:05")) + }), + }, + ); + drop_table(&client, "conf_types").await; + + // --------------------------------------------- extended-protocol params + report.record( + Area::ExtendedQuery, + "two parameters of different types", + client + .query( + "SELECT id FROM conf_three WHERE grp = $1 AND amount > $2", + &[&"a", &15i32], + ) + .await + .map_err(describe) + .and_then(|rows| { + (rows.len() == 1) + .then_some(()) + .ok_or(format!("{} rows, expected 1", rows.len())) + }), + ); + + report.record( + Area::ExtendedQuery, + "a parameter is bound as a value, not spliced as SQL", + // If the parameter were pasted into the statement text, the quote + // would end the literal and this would be a syntax error or, worse, + // would match everything. + client + .query("SELECT id FROM conf_three WHERE name = $1", &[&"o'brien"]) + .await + .map_err(describe) + .and_then(|rows| { + rows.is_empty() + .then_some(()) + .ok_or(format!("{} rows matched a name that does not exist", rows.len())) + }), + ); + + report.record( + Area::ExtendedQuery, + "a parameterised UPDATE reports its row count", + client + .execute("UPDATE conf_three SET amount = $1 WHERE id = $2", &[&11i32, &1i32]) + .await + .map_err(describe) + .and_then(|affected| { + (affected == 1) + .then_some(()) + .ok_or(format!("reported {affected} rows, expected 1")) + }), + ); + + drop_table(&client, "conf_three").await; + println!("{}", report.render()); // The harness reports; it does not gate. Conformance is tracked as a number From eacc04eae34732972e08cf2ab0145e07c41d1043 Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Thu, 6 Aug 2026 23:44:08 -0700 Subject: [PATCH 34/36] Persist unified storage on RocksDB; PostgreSQL conformance 45/48 -> 212/212 Nothing was persisted. `UnifiedStorageIntegration` built a `MemoryBackend` on both arms of its `use_memory_backend` branch, so the flag documented an intention and selected nothing while the log said "persistent backend". Every table and row served over the SQL protocols lived only in that process. - Add `RocksDbBackend`, a `UnifiedStorageBackend` over a RocksDB database under `/unified`, behind the `storage-rocksdb` feature. - `UnifiedTableStorage` writes each table definition to a reserved `__orbit_table_schemas` relation and keeps its in-memory map strictly as a read-through cache, rather than as the record. - Regression test `a_table_survives_a_restart` opens the store twice over one directory; it fails if the backend is switched back to memory. `UnifiedStorage::scan` truncated every unlimited scan at `max_scan_limit` *after* reading all matching records, so the cap saved no memory and corrupted the answer -- `SELECT COUNT(*)` on a 12,000-row table returned 10,000 and reported success. It now refuses, naming the row count and the config key; an explicit `LIMIT` is honoured as given. Default raised to 1,000,000, a runaway threshold rather than an ordinary table size. SQL engine and wire protocol fixes, each of which previously reported success while answering incorrectly: - One routing table: `execute_multiple_queries` splits the message and sends each statement through `execute_query`, instead of the simple-query path dispatching over the AST while everything else dispatched over statement text. - `WHERE` was discarded by writes, so `DELETE FROM t WHERE id = 2` emptied the table; `drop_table` removed only the schema, so the next `CREATE TABLE` resurrected the rows. - `UPDATE/DELETE ... WHERE a = 1 AND b = 2` compared `a` against the text `1 AND b = 2` and matched nothing. Conjuncts are separate conditions now, and an `OR` is refused rather than mis-read. - Rollback is row-scoped. Undo restored a whole-table copy, so one session's `ROLLBACK` destroyed rows another session had committed while the block was open. Savepoints record undo-log extents. - The parser consults the lexer's keyword table in reverse and accepts any word PostgreSQL does not reserve; previously 393 keyword tokens were rejected as column names. - `parse_create_table`/`INSERT`/`DROP TABLE` unwrapped `find('(')` and panicked the connection task, failing every later statement with "connection closed". - Also: `SET n = n + 1` stored its own text, `INSERT ... SELECT` inserted nothing, constraints were dropped by the schema round trip, unknown columns returned NULL, `ORDER BY 1`/alias sorted by expression, correlated subqueries were resolved once, `NULLS FIRST`/`NOT IN` parser branches could never fire, `STRING_AGG` ignored its separator, quoted identifiers lost their case, and aborted transactions accepted further statements. - `select_pipeline` gains window functions and runs FROM-less selects over one empty row, so they share the single path. - Binary `COPY`, `SET`/`SHOW`, and `SET CONSTRAINTS` are implemented. Verified beyond the harness against a running server: durability across restart, crash safety under SIGKILL, Redis/MySQL/CQL protocols on the shared backend, 8x100 concurrent inserts, and a walsender session reaching `XLogData`. Note `unified_storage.data_dir` is a separate setting from the `--data-dir` flag, which the unified store does not read. Co-Authored-By: Claude Opus 5 (1M context) --- orbit/engine/src/unified/adapters.rs | 48 +- orbit/engine/src/unified/mod.rs | 5 + orbit/engine/src/unified/rocksdb_backend.rs | 261 + orbit/engine/src/unified/storage.rs | 102 +- orbit/server/src/config.rs | 4 +- orbit/server/src/main.rs | 34 +- .../src/protocols/common/storage/unified.rs | 455 +- orbit/server/src/protocols/cql/adapter.rs | 8 +- orbit/server/src/protocols/postgres_server.rs | 13 +- .../src/protocols/postgres_wire/messages.rs | 18 + .../server/src/protocols/postgres_wire/mod.rs | 4 +- .../protocols/postgres_wire/notifications.rs | 7 +- .../postgres_wire/persistent_storage.rs | 77 + .../src/protocols/postgres_wire/protocol.rs | 1438 ++- .../protocols/postgres_wire/query_engine.rs | 7751 ++++++++++++++--- .../postgres_wire/sql/execution_strategy.rs | 4 +- .../postgres_wire/sql/expression_evaluator.rs | 17 +- .../src/protocols/postgres_wire/sql/lexer.rs | 34 + .../src/protocols/postgres_wire/sql/mod.rs | 2 +- .../protocols/postgres_wire/sql/parser/dml.rs | 137 +- .../postgres_wire/sql/parser/expressions.rs | 49 +- .../protocols/postgres_wire/sql/parser/mod.rs | 27 + .../postgres_wire/sql/parser/utilities.rs | 88 +- .../postgres_wire/sql/select_pipeline.rs | 729 +- .../src/protocols/postgres_wire/sql/types.rs | 50 + orbit/server/src/protocols/rest/handlers.rs | 22 +- orbit/server/src/unified_storage.rs | 28 +- specifications/PRD.md | 265 +- tests/integration/pg_conformance.rs | 2974 ++++++- 29 files changed, 12946 insertions(+), 1705 deletions(-) create mode 100644 orbit/engine/src/unified/rocksdb_backend.rs diff --git a/orbit/engine/src/unified/adapters.rs b/orbit/engine/src/unified/adapters.rs index 602b56e2..7eb43ab1 100644 --- a/orbit/engine/src/unified/adapters.rs +++ b/orbit/engine/src/unified/adapters.rs @@ -1058,15 +1058,33 @@ impl SqlAdapter { UniversalValue::Int(i) => Some(i.to_string()), UniversalValue::Bool(b) => Some(b.to_string()), UniversalValue::Float(f) => Some(f.to_string()), - // Null and the composite types genuinely cannot key a row. + // Null and the composite types cannot key a row themselves. _ => None, }) + .or_else(|| { + // A row whose key column is NULL is still a row: SQL allows a + // table with no primary key, and every column of such a row may + // be null. Refusing it made a legal INSERT fail with a message + // about a key the statement never mentioned. The surrogate is + // derived from the row's own contents so the same row keeps the + // same key. + Self::surrogate_key(&row) + }) .ok_or_else(|| { UnifiedStorageError::InvalidData(format!( - "Cannot use column '{primary_key}' as a row key: it is absent, null, or not a scalar" + "Cannot use column '{primary_key}' as a row key and the row is empty" )) })?; + // A row written inside a transaction is a *version* of that row, not a + // replacement for it: an older reader must still be able to read the + // previous one. Keying both by the primary key alone made the newer + // overwrite the older, so there was nowhere to keep it. + let key = match row.get(Self::TRANSACTION_STAMP_COLUMN) { + Some(UniversalValue::Int(version)) => format!("{key}#{version}"), + _ => key, + }; + self.base .storage .put(table, &key, UniversalValue::Map(row), None, false, None) @@ -1074,6 +1092,32 @@ impl SqlAdapter { Ok(()) } + /// The column a row carries the id of the transaction that wrote it in. + /// + /// Named here as well as in the protocol layer because the key a row is + /// stored under has to include it; the two must agree. + const TRANSACTION_STAMP_COLUMN: &'static str = "__orbit_txn"; + + /// A key derived from a row's own values, for a row with no usable one. + /// + /// Deterministic, so re-inserting an identical row replaces it rather than + /// accumulating duplicates, and prefixed so it cannot collide with a real + /// key that happens to be a number. + fn surrogate_key(row: &BTreeMap) -> Option { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + if row.is_empty() { + return None; + } + let mut hasher = DefaultHasher::new(); + for (name, value) in row { + name.hash(&mut hasher); + format!("{value:?}").hash(&mut hasher); + } + Some(format!("row:{:016x}", hasher.finish())) + } + /// Select rows from a table pub async fn select( &self, diff --git a/orbit/engine/src/unified/mod.rs b/orbit/engine/src/unified/mod.rs index e44f8c08..fbccf330 100644 --- a/orbit/engine/src/unified/mod.rs +++ b/orbit/engine/src/unified/mod.rs @@ -69,6 +69,8 @@ pub mod actor_tier_placement; pub mod adapters; pub mod index; pub mod operations; +#[cfg(feature = "storage-rocksdb")] +pub mod rocksdb_backend; pub mod s3_backend; pub mod schema; pub mod storage; @@ -107,6 +109,9 @@ pub use index::{IndexEntry, IndexStats, SecondaryIndexManager}; // Re-export S3 backend pub use s3_backend::{S3Backend, S3BackendConfig}; +#[cfg(feature = "storage-rocksdb")] +pub use rocksdb_backend::RocksDbBackend; + // Future modules (to be implemented) // pub mod backend; // RocksDB persistent backend // pub mod transaction; // Distributed transaction coordination diff --git a/orbit/engine/src/unified/rocksdb_backend.rs b/orbit/engine/src/unified/rocksdb_backend.rs new file mode 100644 index 00000000..dc545d3d --- /dev/null +++ b/orbit/engine/src/unified/rocksdb_backend.rs @@ -0,0 +1,261 @@ +//! A durable [`UnifiedStorageBackend`] on RocksDB. +//! +//! Until this existed, `UnifiedStorageIntegration` built a [`MemoryBackend`] +//! whichever way its `use_memory_backend` flag was set — both arms of the +//! branch constructed the same thing, and the flag documented an intention +//! rather than selecting anything. Every table and row served over the SQL +//! protocols lived only in that process: a restart came back empty while the +//! logs said "persistent backend". +//! +//! [`MemoryBackend`]: super::storage::MemoryBackend + +use std::path::Path; +use std::sync::Arc; + +use async_trait::async_trait; +use rocksdb::{IteratorMode, Options, DB}; +use tokio::sync::RwLock; + +use super::storage::{ + UnifiedStorageBackend, UnifiedStorageError, UnifiedStorageMetrics, UnifiedStorageResult, +}; + +/// Key-value storage backed by a RocksDB database on disk. +pub struct RocksDbBackend { + db: Arc, + metrics: Arc>, +} + +impl RocksDbBackend { + /// Open (or create) a RocksDB database under `path`. + /// + /// # Errors + /// Returns an error when the database cannot be opened — most often + /// because another process already holds its lock, or the directory is not + /// writable. + pub fn open(path: impl AsRef) -> UnifiedStorageResult { + let mut options = Options::default(); + options.create_if_missing(true); + options.create_missing_column_families(true); + + let db = DB::open(&options, path.as_ref()).map_err(|e| { + UnifiedStorageError::Backend(format!( + "could not open the unified store at {}: {e}", + path.as_ref().display() + )) + })?; + + Ok(Self { + db: Arc::new(db), + metrics: Arc::new(RwLock::new(UnifiedStorageMetrics::default())), + }) + } + + /// Count an operation, so the metrics report what actually happened rather + /// than staying at zero. + async fn record(&self, operation: Operation, failed: bool) { + let mut metrics = self.metrics.write().await; + match operation { + Operation::Read => metrics.read_operations += 1, + Operation::Write => metrics.write_operations += 1, + Operation::Delete => metrics.delete_operations += 1, + } + if failed { + metrics.error_count += 1; + } + } +} + +#[derive(Clone, Copy)] +enum Operation { + Read, + Write, + Delete, +} + +#[async_trait] +impl UnifiedStorageBackend for RocksDbBackend { + async fn initialize(&self) -> UnifiedStorageResult<()> { + tracing::info!("RocksDB backend initialized"); + Ok(()) + } + + async fn shutdown(&self) -> UnifiedStorageResult<()> { + // Flushing on the way out makes a clean stop durable without waiting + // for the write-ahead log to be replayed on the next start. + self.db + .flush() + .map_err(|e| UnifiedStorageError::Backend(format!("flush failed: {e}")))?; + tracing::info!("RocksDB backend shut down"); + Ok(()) + } + + async fn get(&self, key: &str) -> UnifiedStorageResult>> { + let result = self.db.get(key.as_bytes()); + self.record(Operation::Read, result.is_err()).await; + result.map_err(|e| UnifiedStorageError::Backend(format!("get '{key}' failed: {e}"))) + } + + async fn put(&self, key: &str, value: &[u8]) -> UnifiedStorageResult<()> { + let result = self.db.put(key.as_bytes(), value); + self.record(Operation::Write, result.is_err()).await; + result.map_err(|e| UnifiedStorageError::Backend(format!("put '{key}' failed: {e}"))) + } + + async fn delete(&self, key: &str) -> UnifiedStorageResult { + // RocksDB's delete succeeds whether or not the key was there, so + // existence is checked first to answer honestly. + let existed = self + .db + .get(key.as_bytes()) + .map_err(|e| UnifiedStorageError::Backend(format!("delete '{key}' failed: {e}")))? + .is_some(); + + let result = self.db.delete(key.as_bytes()); + self.record(Operation::Delete, result.is_err()).await; + result.map_err(|e| UnifiedStorageError::Backend(format!("delete '{key}' failed: {e}")))?; + Ok(existed) + } + + async fn exists(&self, key: &str) -> UnifiedStorageResult { + Ok(self.get(key).await?.is_some()) + } + + async fn scan_prefix( + &self, + prefix: &str, + limit: Option, + ) -> UnifiedStorageResult)>> { + // Seeking to the prefix and stopping at the first key that no longer + // matches keeps the scan proportional to what it returns rather than + // to the size of the database. + let iterator = self.db.iterator(IteratorMode::From( + prefix.as_bytes(), + rocksdb::Direction::Forward, + )); + + let mut entries = Vec::new(); + for item in iterator { + let (key, value) = item.map_err(|e| { + UnifiedStorageError::Backend(format!("scan of '{prefix}' failed: {e}")) + })?; + + let Ok(key) = std::str::from_utf8(&key) else { + continue; + }; + if !key.starts_with(prefix) { + break; + } + entries.push((key.to_string(), value.to_vec())); + if limit.is_some_and(|limit| entries.len() >= limit) { + break; + } + } + + self.record(Operation::Read, false).await; + Ok(entries) + } + + async fn put_batch(&self, entries: Vec<(String, Vec)>) -> UnifiedStorageResult<()> { + let mut batch = rocksdb::WriteBatch::default(); + let count = entries.len(); + for (key, value) in entries { + batch.put(key.as_bytes(), value); + } + + let result = self.db.write(batch); + { + let mut metrics = self.metrics.write().await; + metrics.write_operations += count as u64; + if result.is_err() { + metrics.error_count += 1; + } + } + result.map_err(|e| UnifiedStorageError::Backend(format!("batch write failed: {e}"))) + } + + async fn delete_batch(&self, keys: Vec) -> UnifiedStorageResult { + let mut batch = rocksdb::WriteBatch::default(); + let count = keys.len() as u64; + for key in keys { + batch.delete(key.as_bytes()); + } + + let result = self.db.write(batch); + { + let mut metrics = self.metrics.write().await; + metrics.delete_operations += count; + if result.is_err() { + metrics.error_count += 1; + } + } + result.map_err(|e| UnifiedStorageError::Backend(format!("batch delete failed: {e}")))?; + Ok(count) + } + + async fn metrics(&self) -> UnifiedStorageMetrics { + self.metrics.read().await.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn backend(name: &str) -> (RocksDbBackend, std::path::PathBuf) { + let path = std::env::temp_dir().join(format!("orbit-rocksdb-backend-{name}")); + let _ = std::fs::remove_dir_all(&path); + (RocksDbBackend::open(&path).expect("opens"), path) + } + + #[tokio::test] + async fn a_value_survives_reopening_the_database() { + let (store, path) = backend("reopen"); + store.put("k", b"v").await.expect("put"); + store.shutdown().await.expect("shutdown"); + drop(store); + + // The point of the backend: the value is still there in a new process. + let reopened = RocksDbBackend::open(&path).expect("reopens"); + assert_eq!( + reopened.get("k").await.expect("get").as_deref(), + Some(&b"v"[..]) + ); + let _ = std::fs::remove_dir_all(&path); + } + + #[tokio::test] + async fn delete_reports_whether_the_key_was_there() { + let (store, path) = backend("delete"); + store.put("k", b"v").await.expect("put"); + + assert!(store.delete("k").await.expect("delete")); + assert!(!store.delete("k").await.expect("delete")); + let _ = std::fs::remove_dir_all(&path); + } + + #[tokio::test] + async fn a_prefix_scan_stops_at_the_prefix() { + let (store, path) = backend("scan"); + for key in ["a:1", "a:2", "b:1"] { + store.put(key, b"v").await.expect("put"); + } + + let found = store.scan_prefix("a:", None).await.expect("scan"); + let keys: Vec<&str> = found.iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!(keys, ["a:1", "a:2"]); + let _ = std::fs::remove_dir_all(&path); + } + + #[tokio::test] + async fn a_prefix_scan_honours_its_limit() { + let (store, path) = backend("limit"); + for key in ["a:1", "a:2", "a:3"] { + store.put(key, b"v").await.expect("put"); + } + + let found = store.scan_prefix("a:", Some(2)).await.expect("scan"); + assert_eq!(found.len(), 2); + let _ = std::fs::remove_dir_all(&path); + } +} diff --git a/orbit/engine/src/unified/storage.rs b/orbit/engine/src/unified/storage.rs index 0901331d..ef20c314 100644 --- a/orbit/engine/src/unified/storage.rs +++ b/orbit/engine/src/unified/storage.rs @@ -132,7 +132,7 @@ impl Default for UnifiedStorageConfig { data_dir: "./orbit_unified_data".to_string(), enable_ttl_expiration: true, ttl_check_interval_secs: 60, - max_scan_limit: 10000, + max_scan_limit: 1_000_000, enable_wal: true, enable_compression: true, } @@ -755,7 +755,7 @@ impl UnifiedStorage { projection: Vec, ) -> UnifiedStorageResult { let prefix = format!("data:{}:", namespace); - let max_limit = limit.unwrap_or(self.config.max_scan_limit); + let max_limit = limit.unwrap_or(usize::MAX); // Fetch all records matching prefix let entries = self.backend.scan_prefix(&prefix, None).await?; @@ -812,8 +812,22 @@ impl UnifiedStorage { }); } - // Apply offset and limit + // Apply offset and limit. + // + // A caller that gave no limit wants every row: capping it silently + // made `SELECT COUNT(*)` on a 12,000-row table answer 10,000 and call + // it success. The cap is still a guard rail against a runaway scan, + // but it now refuses rather than lies — and refusing costs nothing in + // memory, because every record has already been read by this point. let offset = offset.unwrap_or(0); + if limit.is_none() && records.len().saturating_sub(offset) > self.config.max_scan_limit { + return Err(UnifiedStorageError::InvalidOperation(format!( + "scan of '{namespace}' matched {} rows, over the max_scan_limit of {}; \ + raise unified_storage.max_scan_limit or narrow the query", + records.len().saturating_sub(offset), + self.config.max_scan_limit + ))); + } let records: Vec<_> = records.into_iter().skip(offset).take(max_limit).collect(); // Apply projection @@ -1775,6 +1789,88 @@ impl UnifiedStorage { mod tests { use super::*; + /// Build a store whose scan guard rail trips at `max_scan_limit` rows. + fn bounded_storage(max_scan_limit: usize) -> UnifiedStorage { + UnifiedStorage::new( + Arc::new(MemoryBackend::new()), + UnifiedStorageConfig { + max_scan_limit, + ..Default::default() + }, + ) + } + + async fn fill(storage: &UnifiedStorage, namespace: &str, count: usize) { + for index in 0..count { + storage + .put( + namespace, + &index.to_string(), + UniversalValue::Int(index as i64), + None, + false, + None, + ) + .await + .expect("put"); + } + } + + /// A scan under the guard rail returns every row, not a page of them. + #[tokio::test] + async fn an_unlimited_scan_returns_every_row() { + let storage = bounded_storage(10); + storage.initialize().await.expect("init"); + fill(&storage, "rows", 7).await; + + let result = storage + .scan("rows", None, None, None, None, Vec::new()) + .await + .expect("scan"); + let UniversalResult::Records(records) = result else { + panic!("expected records"); + }; + assert_eq!(records.len(), 7); + } + + /// Over the guard rail the scan refuses. It used to return exactly + /// `max_scan_limit` rows and report success, so `COUNT(*)` on a larger + /// table answered with the limit. + #[tokio::test] + async fn a_scan_over_the_limit_refuses_rather_than_truncating() { + let storage = bounded_storage(5); + storage.initialize().await.expect("init"); + fill(&storage, "rows", 9).await; + + let error = storage + .scan("rows", None, None, None, None, Vec::new()) + .await + .expect_err("the scan should refuse"); + let message = error.to_string(); + assert!( + message.contains('9'), + "should say how many matched: {message}" + ); + assert!(message.contains('5'), "should say the limit: {message}"); + } + + /// An explicit limit is the caller's decision and is honoured as given. + #[tokio::test] + async fn an_explicit_limit_is_not_the_guard_rail() { + let storage = bounded_storage(5); + storage.initialize().await.expect("init"); + fill(&storage, "rows", 9).await; + + let result = storage + .scan("rows", None, Some(9), None, None, Vec::new()) + .await + .expect("an explicit limit is allowed"); + let UniversalResult::Records(records) = result else { + panic!("expected records"); + }; + assert_eq!(records.len(), 9); + } + #[tokio::test] async fn test_basic_crud() { let storage = UnifiedStorage::with_memory_backend(); diff --git a/orbit/server/src/config.rs b/orbit/server/src/config.rs index ca286616..3cbd4c38 100644 --- a/orbit/server/src/config.rs +++ b/orbit/server/src/config.rs @@ -2674,8 +2674,8 @@ mod shipped_config_tests { /// the two against each other; this does. #[tokio::test] async fn the_shipped_configuration_file_parses() { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../config/orbit-server.toml"); + let path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../config/orbit-server.toml"); assert!( path.exists(), diff --git a/orbit/server/src/main.rs b/orbit/server/src/main.rs index 7bb19042..809a667c 100644 --- a/orbit/server/src/main.rs +++ b/orbit/server/src/main.rs @@ -381,7 +381,7 @@ async fn main() -> Result<(), Box> { data_dir: data_dir.clone(), enable_ttl_expiration: unified_config.ttl.enabled, ttl_check_interval_secs: unified_config.ttl.check_interval_secs, - max_scan_limit: 10000, + max_scan_limit: 1_000_000, use_memory_backend: false, // Use persistent backend }; @@ -813,13 +813,14 @@ async fn main() -> Result<(), Box> { // PostgreSQL server below. Handing REST its own RocksDB handle while // PostgreSQL used unified storage produced two databases behind one name: // a table created over HTTP was invisible to psql. - let rest_sql_storage: Arc = - match &storage_mode { - StorageMode::Unified { - postgres_unified, .. - } => postgres_unified.clone(), - StorageMode::Isolated { .. } => rocksdb_storage.clone(), - }; + let rest_sql_storage: Arc< + dyn orbit_server::protocols::postgres_wire::persistent_storage::PersistentTableStorage, + > = match &storage_mode { + StorageMode::Unified { + postgres_unified, .. + } => postgres_unified.clone(), + StorageMode::Isolated { .. } => rocksdb_storage.clone(), + }; let rest_query_engine = Arc::new(QueryEngine::new_with_persistent_storage(rest_sql_storage)); orbit_server::protocols::rest::handlers::mark_process_start(); @@ -1348,9 +1349,22 @@ async fn start_postgresql_server( QueryEngine::new_with_persistent_storage(rocksdb) }; + // Reclaim superseded row versions in the background, so a long-lived + // server does not accumulate them until someone runs VACUUM by hand. + let query_engine = std::sync::Arc::new(query_engine); + // Continue the change stream where the last run left off, so a replica's + // recorded position still means what it meant before the restart. + match query_engine.resume_change_positions().await { + Ok(0) => {} + Ok(position) => info!("[PostgreSQL] replication resumes at position {position}"), + Err(e) => warn!("[PostgreSQL] could not read the change log: {e}"), + } + let _autovacuum = + QueryEngine::start_autovacuum(query_engine.clone(), std::time::Duration::from_secs(60)); + // Create PostgreSQL server with query engine - let postgres_server = - PostgresServer::new_with_query_engine(bind_addr, query_engine).with_tls_config(tls_config); + let postgres_server = PostgresServer::new_with_query_engine_arc(bind_addr, query_engine) + .with_tls_config(tls_config); let handle = tokio::spawn(async move { postgres_server diff --git a/orbit/server/src/protocols/common/storage/unified.rs b/orbit/server/src/protocols/common/storage/unified.rs index a81df559..e13172a3 100644 --- a/orbit/server/src/protocols/common/storage/unified.rs +++ b/orbit/server/src/protocols/common/storage/unified.rs @@ -11,7 +11,7 @@ use crate::protocols::postgres_wire::sql::{ use crate::unified_storage::UnifiedStorageIntegration; use async_trait::async_trait; use chrono::Timelike; -use orbit_engine::unified::{SqlAdapter, UniversalValue}; +use orbit_engine::unified::{FilterExpression, SqlAdapter, UniversalValue}; use std::collections::{BTreeMap, HashMap}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; @@ -168,6 +168,110 @@ impl UnifiedTableStorage { &self.dialect } + /// The reserved table holding serialized table schemas. + /// + /// One row per schema, keyed by `dialect:name` so the SQL protocols do not + /// overwrite each other's definitions of a same-named table. + const SCHEMA_CATALOG: &'static str = "__orbit_table_schemas"; + + fn schema_key(&self, table_name: &str) -> String { + format!("{}:{table_name}", self.dialect) + } + + /// Write a table's definition where a later process can find it. + async fn persist_table_schema(&self, schema: &TableSchema) -> ProtocolResult<()> { + let definition = serde_json::to_string(schema).map_err(|e| { + ProtocolError::Other(format!( + "could not serialize the schema of '{}': {e}", + schema.name + )) + })?; + + let row = BTreeMap::from([ + ( + "key".to_string(), + UniversalValue::String(self.schema_key(&schema.name)), + ), + ("definition".to_string(), UniversalValue::String(definition)), + ]); + + self.sql_adapter + .insert(Self::SCHEMA_CATALOG, row, "key") + .await + .map_err(|e| ProtocolError::Other(format!("could not store the schema: {e}"))) + } + + /// Read a table's stored definition, if it has one. + async fn load_table_schema(&self, table_name: &str) -> ProtocolResult> { + let rows = self + .sql_adapter + .select( + Self::SCHEMA_CATALOG, + None, + Some(FilterExpression::Eq( + "key".to_string(), + UniversalValue::String(self.schema_key(table_name)), + )), + None, + Some(1), + None, + ) + .await + .map_err(|e| ProtocolError::Other(format!("could not read the schema: {e}")))?; + + rows.into_iter() + .next() + .and_then(|row| match row.get("definition") { + Some(UniversalValue::String(text)) => Some(text.clone()), + _ => None, + }) + .map(|text| { + serde_json::from_str(&text).map_err(|e| { + ProtocolError::Other(format!( + "stored schema for '{table_name}' is unreadable: {e}" + )) + }) + }) + .transpose() + } + + /// Read every stored definition belonging to this dialect. + async fn load_all_table_schemas(&self) -> ProtocolResult> { + let rows = self + .sql_adapter + .select(Self::SCHEMA_CATALOG, None, None, None, None, None) + .await + .map_err(|e| ProtocolError::Other(format!("could not list schemas: {e}")))?; + + let prefix = format!("{}:", self.dialect); + Ok(rows + .into_iter() + .filter(|row| match row.get("key") { + Some(UniversalValue::String(key)) => key.starts_with(&prefix), + _ => false, + }) + .filter_map(|row| match row.get("definition") { + Some(UniversalValue::String(text)) => serde_json::from_str(text).ok(), + _ => None, + }) + .collect()) + } + + /// Drop a table's stored definition. + async fn forget_table_schema(&self, table_name: &str) -> ProtocolResult<()> { + self.sql_adapter + .delete( + Self::SCHEMA_CATALOG, + Some(FilterExpression::Eq( + "key".to_string(), + UniversalValue::String(self.schema_key(table_name)), + )), + ) + .await + .map_err(|e| ProtocolError::Other(format!("could not remove the schema: {e}")))?; + Ok(()) + } + /// Convert SqlValue to UniversalValue fn sql_to_universal(value: &SqlValue) -> UniversalValue { match value { @@ -663,6 +767,10 @@ impl TableStorage for UnifiedTableStorage { _tx: Option<&StorageTransaction>, ) -> ProtocolResult<()> { self.write_ops.fetch_add(1, Ordering::Relaxed); + // The map is a cache in front of storage, not the record itself: when + // it was the record, a restart came back with the rows still on disk + // and no table to read them through. + self.persist_table_schema(schema).await?; let mut schemas = self.table_schemas.write().await; schemas.insert(schema.name.clone(), schema.clone()); Ok(()) @@ -670,13 +778,32 @@ impl TableStorage for UnifiedTableStorage { async fn get_table_schema(&self, table_name: &str) -> ProtocolResult> { self.read_ops.fetch_add(1, Ordering::Relaxed); - let schemas = self.table_schemas.read().await; - Ok(schemas.get(table_name).cloned()) + { + let schemas = self.table_schemas.read().await; + if let Some(schema) = schemas.get(table_name) { + return Ok(Some(schema.clone())); + } + } + + let Some(schema) = self.load_table_schema(table_name).await? else { + return Ok(None); + }; + self.table_schemas + .write() + .await + .insert(schema.name.clone(), schema.clone()); + Ok(Some(schema)) } async fn list_table_schemas(&self) -> ProtocolResult> { self.read_ops.fetch_add(1, Ordering::Relaxed); - let schemas = self.table_schemas.read().await; + // Read through, so a fresh process lists the tables it inherited + // rather than only those it has been asked about. + let stored = self.load_all_table_schemas().await?; + let mut schemas = self.table_schemas.write().await; + for schema in stored { + schemas.entry(schema.name.clone()).or_insert(schema); + } Ok(schemas.values().cloned().collect()) } @@ -686,8 +813,10 @@ impl TableStorage for UnifiedTableStorage { _tx: Option<&StorageTransaction>, ) -> ProtocolResult { self.delete_ops.fetch_add(1, Ordering::Relaxed); - let mut schemas = self.table_schemas.write().await; - Ok(schemas.remove(table_name).is_some()) + let existed = self.get_table_schema(table_name).await?.is_some(); + self.forget_table_schema(table_name).await?; + self.table_schemas.write().await.remove(table_name); + Ok(existed) } // Data Operations @@ -989,6 +1118,82 @@ mod tests { use super::*; use crate::unified_storage::UnifiedStorageIntegrationConfig; + /// A table created by one process is there for the next one. + /// + /// This is the defect the conformance harness could not see: it connects + /// to a running server and never restarts it, so a store that kept + /// everything in memory passed every check. + #[tokio::test] + async fn a_table_survives_a_restart() { + use crate::protocols::postgres_wire::persistent_storage::{ + ColumnDefinition, ColumnType, PersistentTableStorage, + TableSchema as PersistentTableSchema, + }; + use crate::unified_storage::UnifiedStorageIntegration; + + let dir = std::env::temp_dir().join("orbit-unified-restart-test"); + let _ = std::fs::remove_dir_all(&dir); + let config = || UnifiedStorageIntegrationConfig { + data_dir: dir.to_string_lossy().to_string(), + enable_ttl_expiration: false, + ttl_check_interval_secs: 60, + max_scan_limit: 1000, + // The point of the test: the persistent backend, not the memory one. + use_memory_backend: false, + }; + + let schema = PersistentTableSchema { + name: "survivor".to_string(), + columns: vec![ColumnDefinition { + name: "id".to_string(), + data_type: ColumnType::Integer, + nullable: true, + default_value: None, + unique: false, + check: None, + references: None, + domain: None, + }], + created_at: chrono::Utc::now(), + row_count: 0, + foreign_keys: Vec::new(), + }; + + { + let integration = Arc::new( + UnifiedStorageIntegration::with_config(config()) + .await + .expect("opens the store"), + ); + let storage = UnifiedTableStorage::postgres(integration); + PersistentTableStorage::create_table(&storage, schema) + .await + .expect("creates the table"); + assert!(PersistentTableStorage::table_exists(&storage, "survivor") + .await + .expect("reads back")); + } + + // A second instance over the same directory stands in for a restart: + // nothing is carried over in memory. + { + let integration = Arc::new( + UnifiedStorageIntegration::with_config(config()) + .await + .expect("reopens the store"), + ); + let storage = UnifiedTableStorage::postgres(integration); + assert!( + PersistentTableStorage::table_exists(&storage, "survivor") + .await + .expect("reads back"), + "the table did not survive the restart" + ); + } + + let _ = std::fs::remove_dir_all(&dir); + } + #[tokio::test] async fn test_sql_to_universal_conversion() { // Test basic types @@ -1065,12 +1270,63 @@ mod tests { mod persistent_storage_impl { use super::*; use crate::protocols::postgres_wire::persistent_storage::{ - ColumnDefinition, ColumnType, PersistentTableStorage, QueryCondition, TableRow, - TableSchema as PersistentTableSchema, + ColumnDefinition, ColumnType, ForeignKey, MatchType, PersistentTableStorage, + QueryCondition, ReferentialAction, TableRow, TableSchema as PersistentTableSchema, }; + use crate::protocols::postgres_wire::sql::executor::TableConstraintSchema; + + /// The stored spelling of a referential action. + fn action_name(action: ReferentialAction) -> &'static str { + match action { + ReferentialAction::NoAction => "NO ACTION", + ReferentialAction::Restrict => "RESTRICT", + ReferentialAction::Cascade => "CASCADE", + ReferentialAction::SetNull => "SET NULL", + ReferentialAction::SetDefault => "SET DEFAULT", + } + } + + /// Read a referential action back, defaulting to the standard behaviour. + fn parse_action(text: &str) -> ReferentialAction { + match text { + "RESTRICT" => ReferentialAction::Restrict, + "CASCADE" => ReferentialAction::Cascade, + "SET NULL" => ReferentialAction::SetNull, + "SET DEFAULT" => ReferentialAction::SetDefault, + _ => ReferentialAction::NoAction, + } + } + use crate::protocols::postgres_wire::sql::types::SqlType; use serde_json::Value as JsonValue; + /// Turn WHERE conditions into a storage filter. + /// + /// Returns `None` for no conditions, which means "every row" — the same + /// thing `DELETE FROM t` with no WHERE means. An operator this filter + /// cannot express is left out rather than silently treated as true, so an + /// unsupported comparison narrows nothing instead of matching everything. + fn conditions_to_filter(conditions: &[QueryCondition]) -> Option { + conditions + .iter() + .filter_map(|condition| { + let value = UnifiedTableStorage::sql_to_universal( + &UnifiedTableStorage::json_to_sql_value(&condition.value), + ); + let column = condition.column.clone(); + match condition.operator.as_str() { + "=" | "==" => Some(FilterExpression::Eq(column, value)), + "!=" | "<>" => Some(FilterExpression::Ne(column, value)), + "<" => Some(FilterExpression::Lt(column, value)), + "<=" => Some(FilterExpression::Lte(column, value)), + ">" => Some(FilterExpression::Gt(column, value)), + ">=" => Some(FilterExpression::Gte(column, value)), + _ => None, + } + }) + .reduce(FilterExpression::and) + } + impl UnifiedTableStorage { /// Convert from PersistentTableStorage TableSchema to SQL executor TableSchema fn persistent_schema_to_sql_schema(schema: &PersistentTableSchema) -> TableSchema { @@ -1097,21 +1353,70 @@ mod persistent_storage_impl { with_timezone: false, }, }; + // Uniqueness and the default have to survive the round + // trip: the schema is stored in this form and read back + // through `sql_schema_to_persistent_schema`, so dropping + // them here silently disarmed `PRIMARY KEY` and `DEFAULT`. + if col.unique { + constraints.push("UNIQUE".to_string()); + } + if let Some(check) = &col.check { + constraints.push(format!("CHECK ({check})")); + } + if let Some((table, column)) = &col.references { + constraints.push(format!("REFERENCES {table}({column})")); + } + if let Some(domain) = &col.domain { + constraints.push(format!("DOMAIN {domain}")); + } + let default = col + .default_value + .as_ref() + .map(UnifiedTableStorage::json_to_sql_value); + ColumnSchema { name: col.name.clone(), data_type, nullable: col.nullable, - default: None, + default, constraints, generated: None, } }) .collect(); + // Foreign keys travel as table constraints so a composite key + // survives the round trip as one constraint rather than as + // several independent per-column ones. + let constraints = schema + .foreign_keys + .iter() + .map(|key| TableConstraintSchema { + name: None, + // The referential actions ride in the type string, which + // is the only free-form field this shape has. + constraint_type: format!( + "FOREIGN KEY|{}|{}|{}|{}", + action_name(key.on_delete), + action_name(key.on_update), + key.deferrable, + match key.match_type { + MatchType::Full => "FULL", + MatchType::Partial => "PARTIAL", + MatchType::Simple => "SIMPLE", + } + ), + columns: key.columns.clone(), + referenced_table: Some(key.table.clone()), + referenced_columns: Some(key.referenced.clone()), + without_overlaps: None, + }) + .collect(); + TableSchema { name: schema.name.clone(), columns, - constraints: Vec::new(), + constraints, indexes: Vec::new(), } } @@ -1142,7 +1447,60 @@ mod persistent_storage_impl { name: col.name.clone(), data_type, nullable: col.nullable, - default_value: None, + default_value: col + .default + .as_ref() + .map(UnifiedTableStorage::sql_value_to_json), + unique: col.constraints.iter().any(|constraint| { + let constraint = constraint.to_uppercase(); + constraint.contains("PRIMARY KEY") || constraint.contains("UNIQUE") + }), + references: col.constraints.iter().find_map(|constraint| { + let text = constraint.trim(); + let rest = text.strip_prefix("REFERENCES ")?; + let (table, column) = rest.split_once('(')?; + Some(( + table.trim().to_string(), + column.trim_end_matches(')').trim().to_string(), + )) + }), + check: col.constraints.iter().find_map(|constraint| { + let text = constraint.trim(); + if !text.to_uppercase().starts_with("CHECK") { + return None; + } + let open = text.find('(')?; + let close = text.rfind(')')?; + (close > open).then(|| text[open + 1..close].trim().to_string()) + }), + domain: col.constraints.iter().find_map(|constraint| { + constraint + .trim() + .strip_prefix("DOMAIN ") + .map(|name| name.trim().to_string()) + }), + } + }) + .collect(); + + let foreign_keys: Vec = schema + .constraints + .iter() + .filter(|constraint| constraint.constraint_type.starts_with("FOREIGN KEY")) + .map(|constraint| { + let parts: Vec<&str> = constraint.constraint_type.split('|').collect(); + ForeignKey { + columns: constraint.columns.clone(), + table: constraint.referenced_table.clone().unwrap_or_default(), + referenced: constraint.referenced_columns.clone().unwrap_or_default(), + on_delete: parse_action(parts.get(1).copied().unwrap_or_default()), + on_update: parse_action(parts.get(2).copied().unwrap_or_default()), + deferrable: parts.get(3).copied() == Some("true"), + match_type: match parts.get(4).copied().unwrap_or_default() { + "FULL" => MatchType::Full, + "PARTIAL" => MatchType::Partial, + _ => MatchType::Simple, + }, } }) .collect(); @@ -1152,6 +1510,7 @@ mod persistent_storage_impl { columns, created_at: chrono::Utc::now(), row_count: 0, + foreign_keys, } } @@ -1253,36 +1612,25 @@ mod persistent_storage_impl { set_values: HashMap, conditions: Vec, ) -> ProtocolResult { - // Convert set_values to SqlValue - let updates: HashMap = set_values + // The conditions are applied here rather than handed to + // `TableStorage::update_rows` as a closure: that method discards + // the closure, so `UPDATE ... WHERE` rewrote every row in the + // table and reported the whole table as affected. + let updates: BTreeMap = set_values .iter() - .map(|(k, v)| (k.clone(), Self::json_to_sql_value(v))) + .map(|(name, value)| { + ( + name.clone(), + Self::sql_to_universal(&Self::json_to_sql_value(value)), + ) + }) .collect(); - // Create condition filter - let condition: Option) -> bool + Send + Sync>> = - if conditions.is_empty() { - None - } else { - let conds = conditions.clone(); - Some(Box::new(move |row: &HashMap| { - conds.iter().all(|cond| { - if let Some(row_value) = row.get(&cond.column) { - let cond_value = Self::json_to_sql_value(&cond.value); - match cond.operator.as_str() { - "=" | "==" => row_value == &cond_value, - "!=" | "<>" => row_value != &cond_value, - _ => true, // Skip complex operators for now - } - } else { - false - } - }) - })) - }; - - let count = - TableStorage::update_rows(self, table_name, &updates, condition, None).await?; + let count = self + .sql_adapter + .update(table_name, updates, conditions_to_filter(&conditions)) + .await + .map_err(|e| ProtocolError::Other(format!("Storage error: {e}")))?; self.write_ops.fetch_add(1, Ordering::Relaxed); Ok(count as i64) } @@ -1292,29 +1640,14 @@ mod persistent_storage_impl { table_name: &str, conditions: Vec, ) -> ProtocolResult { - // Create condition filter - let condition: Option) -> bool + Send + Sync>> = - if conditions.is_empty() { - None - } else { - let conds = conditions.clone(); - Some(Box::new(move |row: &HashMap| { - conds.iter().all(|cond| { - if let Some(row_value) = row.get(&cond.column) { - let cond_value = Self::json_to_sql_value(&cond.value); - match cond.operator.as_str() { - "=" | "==" => row_value == &cond_value, - "!=" | "<>" => row_value != &cond_value, - _ => true, - } - } else { - false - } - }) - })) - }; - - let count = TableStorage::delete_rows(self, table_name, condition, None).await?; + // As with `update_rows`: the closure form of the condition is + // dropped by `TableStorage::delete_rows`, so `DELETE ... WHERE` + // emptied the table. + let count = self + .sql_adapter + .delete(table_name, conditions_to_filter(&conditions)) + .await + .map_err(|e| ProtocolError::Other(format!("Storage error: {e}")))?; self.delete_ops.fetch_add(1, Ordering::Relaxed); Ok(count as i64) } diff --git a/orbit/server/src/protocols/cql/adapter.rs b/orbit/server/src/protocols/cql/adapter.rs index 93c4c173..4ca994ae 100644 --- a/orbit/server/src/protocols/cql/adapter.rs +++ b/orbit/server/src/protocols/cql/adapter.rs @@ -710,7 +710,7 @@ impl CqlAdapter { /// Handle QUERY request async fn handle_query(&self, frame: &CqlFrame) -> ProtocolResult { - println!("DEBUG: handle_query called. Body len: {}", frame.body.len()); + tracing::debug!(body_len = frame.body.len(), "handling a CQL query frame"); // Update metrics { let mut metrics = self.metrics.write().await; @@ -730,7 +730,7 @@ impl CqlAdapter { let query_bytes = body.copy_to_bytes(query_len as usize); let query = String::from_utf8(query_bytes.to_vec()) .map_err(|e| ProtocolError::InvalidUtf8(e.to_string()))?; - println!("DEBUG: Received query: {}", query); + tracing::debug!(%query, "received a CQL query"); // Read query parameters let params = QueryParameters::decode(body)?; @@ -1074,10 +1074,10 @@ impl CqlAdapter { limit, .. } => { - println!("DEBUG: execute_statement SELECT table={}", table); + tracing::debug!(%table, "executing a CQL SELECT"); // Handle system tables (required for driver initialization) let table_lower = table.to_lowercase(); - println!("DEBUG: table_lower={}", table_lower); + tracing::debug!(%table_lower, "resolved the CQL table name"); if table_lower == "system.local" || table_lower == "local" { return Ok(build_system_local_response(stream)); } diff --git a/orbit/server/src/protocols/postgres_server.rs b/orbit/server/src/protocols/postgres_server.rs index 5669a044..ddf534b8 100644 --- a/orbit/server/src/protocols/postgres_server.rs +++ b/orbit/server/src/protocols/postgres_server.rs @@ -31,10 +31,19 @@ impl PostgresServer { /// Create a new PostgreSQL server with custom query engine pub fn new_with_query_engine(bind_addr: impl Into, query_engine: QueryEngine) -> Self { - println!("DEBUG: PostgresServer initialized with custom query engine"); + Self::new_with_query_engine_arc(bind_addr, Arc::new(query_engine)) + } + + /// Create a server sharing an engine with something else — the autovacuum + /// worker, which has to run against the same storage the sessions use. + pub fn new_with_query_engine_arc( + bind_addr: impl Into, + query_engine: Arc, + ) -> Self { + tracing::debug!("PostgreSQL server created with a custom query engine"); Self { bind_addr: bind_addr.into(), - query_engine: Some(Arc::new(query_engine)), + query_engine: Some(query_engine), tls_config: None, } } diff --git a/orbit/server/src/protocols/postgres_wire/messages.rs b/orbit/server/src/protocols/postgres_wire/messages.rs index 4130f5fb..b0038b46 100644 --- a/orbit/server/src/protocols/postgres_wire/messages.rs +++ b/orbit/server/src/protocols/postgres_wire/messages.rs @@ -227,6 +227,11 @@ pub enum BackendMessage { format: i8, column_formats: Vec, }, + /// Both directions at once, which is how replication streams. + CopyBothResponse { + format: i8, + column_formats: Vec, + }, CopyData { data: Bytes, }, @@ -799,6 +804,19 @@ impl BackendMessage { let len = buf.len() - pos; buf[pos..pos + 4].copy_from_slice(&(len as i32).to_be_bytes()); } + BackendMessage::CopyBothResponse { + format, + column_formats, + } => { + buf.put_u8(b'W'); + let length = 4 + 1 + 2 + column_formats.len() * 2; + buf.put_i32(length as i32); + buf.put_i8(*format); + buf.put_i16(column_formats.len() as i16); + for column_format in column_formats { + buf.put_i16(*column_format); + } + } BackendMessage::CopyOutResponse { format, column_formats, diff --git a/orbit/server/src/protocols/postgres_wire/mod.rs b/orbit/server/src/protocols/postgres_wire/mod.rs index 2c9bb06f..06f7e99e 100644 --- a/orbit/server/src/protocols/postgres_wire/mod.rs +++ b/orbit/server/src/protocols/postgres_wire/mod.rs @@ -39,9 +39,9 @@ pub mod graphrag_engine; pub mod jsonb; pub mod messages; #[cfg(feature = "storage-rocksdb")] -pub mod persistent_storage; -#[cfg(feature = "storage-rocksdb")] pub mod notifications; +#[cfg(feature = "storage-rocksdb")] +pub mod persistent_storage; pub mod protocol; #[cfg(feature = "storage-rocksdb")] pub mod query_engine; diff --git a/orbit/server/src/protocols/postgres_wire/notifications.rs b/orbit/server/src/protocols/postgres_wire/notifications.rs index fddc84d3..8bde8ef2 100644 --- a/orbit/server/src/protocols/postgres_wire/notifications.rs +++ b/orbit/server/src/protocols/postgres_wire/notifications.rs @@ -48,12 +48,7 @@ impl NotificationHub { /// /// Repeated `LISTEN` on the same channel from the same session is a no-op, /// as in PostgreSQL — it must not double-deliver. - pub async fn listen( - &self, - channel: &str, - session: u64, - sender: UnboundedSender, - ) { + pub async fn listen(&self, channel: &str, session: u64, sender: UnboundedSender) { let mut listeners = self.listeners.lock().await; let subscribers = listeners.entry(fold_channel(channel)).or_default(); if subscribers.iter().any(|s| s.session == session) { diff --git a/orbit/server/src/protocols/postgres_wire/persistent_storage.rs b/orbit/server/src/protocols/postgres_wire/persistent_storage.rs index 058d758a..d215eba4 100644 --- a/orbit/server/src/protocols/postgres_wire/persistent_storage.rs +++ b/orbit/server/src/protocols/postgres_wire/persistent_storage.rs @@ -27,6 +27,61 @@ pub struct TableSchema { pub columns: Vec, pub created_at: chrono::DateTime, pub row_count: i64, + /// Foreign keys declared on the table. + /// + /// Held here rather than per column so a composite key is one constraint + /// checked as a whole, which is what `FOREIGN KEY (a, b) REFERENCES t(c, d)` + /// means: the pair must match a row, not each column separately. + #[serde(default)] + pub foreign_keys: Vec, +} + +/// A foreign key constraint. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ForeignKey { + /// Columns of this table, in order. + pub columns: Vec, + /// The table they refer to. + pub table: String, + /// Columns of that table, in the same order. Empty means its key. + pub referenced: Vec, + /// What to do when a referenced row is deleted. + pub on_delete: ReferentialAction, + /// What to do when a referenced row's key changes. + pub on_update: ReferentialAction, + /// Whether the check may be postponed to the end of the transaction. + pub deferrable: bool, + /// How a partly-NULL key is treated. + #[serde(default)] + pub match_type: MatchType, +} + +/// How a foreign key with some NULL columns is treated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum MatchType { + /// Any NULL part satisfies the constraint. PostgreSQL's default. + #[default] + Simple, + /// Either every part is NULL or none is. + Full, + /// Non-NULL parts must match some row. + Partial, +} + +/// What a foreign key does when the row it refers to changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum ReferentialAction { + /// Refuse the change while a row still refers to it. + #[default] + NoAction, + /// Same as `NoAction` here: the check is not deferred. + Restrict, + /// Delete or update the referring rows too. + Cascade, + /// Clear the referring column. + SetNull, + /// Put the referring column back to its declared default. + SetDefault, } /// Column definition @@ -36,6 +91,28 @@ pub struct ColumnDefinition { pub data_type: ColumnType, pub nullable: bool, pub default_value: Option, + /// Whether the column is a primary key or is declared `UNIQUE`. + /// + /// Defaulted on read so schemas written before this field existed still + /// deserialize; they simply carry no uniqueness constraint. + #[serde(default)] + pub unique: bool, + /// The predicate of a `CHECK` constraint, as written. + /// + /// Kept as text because it is evaluated against each row at insert time by + /// the same expression evaluator that runs a `WHERE` clause. + #[serde(default)] + pub check: Option, + /// The `table.column` a `REFERENCES` clause points at. + #[serde(default)] + pub references: Option<(String, String)>, + /// The domain the column was declared with, if any. + /// + /// Kept so `ALTER DOMAIN` reaches tables that already use it: the check + /// is read from the domain at write time rather than copied at + /// `CREATE TABLE`. + #[serde(default)] + pub domain: Option, } /// Supported column types (subset of PostgreSQL types) diff --git a/orbit/server/src/protocols/postgres_wire/protocol.rs b/orbit/server/src/protocols/postgres_wire/protocol.rs index c7ebdc67..67c6502c 100644 --- a/orbit/server/src/protocols/postgres_wire/protocol.rs +++ b/orbit/server/src/protocols/postgres_wire/protocol.rs @@ -27,6 +27,18 @@ pub enum ConnectionState { Closed, } +/// The fixed 11-byte header of a binary `COPY` stream. +const COPY_BINARY_SIGNATURE: &[u8] = b"PGCOPY\n\xff\r\n\0"; + +/// A savepoint: how far each undo log had grown when it was taken. +struct Savepoint { + name: String, + inserts: HashMap, + pre_images: HashMap, + /// Whole-table copies for the statements that leave no row-level record. + tables: HashMap>, +} + /// PostgreSQL wire protocol handler pub struct PostgresWireProtocol { state: ConnectionState, @@ -78,6 +90,51 @@ pub struct PostgresWireProtocol { /// table back. A snapshot is taken once per table per block, before its /// first write, and discarded on COMMIT. transaction_snapshots: HashMap>, + /// Rows this session added in the open block, per table. + /// + /// Undo removes exactly these rather than restoring a copy of the table, + /// which would take another session's committed rows with it. + transaction_inserts: HashMap>, + /// Rows this session changed or removed, as they stood beforehand. + transaction_pre_images: HashMap>, + /// The transaction this session has open, if any. + /// + /// Rows written inside it are stamped with this id and stay invisible to + /// other sessions until it ends. + transaction_id: Option, + /// The isolation level asked for, which decides whether a block reads + /// through a snapshot or sees each commit as it lands. + snapshot_isolation: bool, + /// Whether the block must also fail if what it read moved underneath it. + serializable: bool, + /// Whether this connection asked for the replication protocol at startup. + /// + /// A replication connection speaks a small command set instead of SQL, + /// which is why the mode has to be known before the first query. + replication: bool, + /// The slot the current replication stream belongs to, if it named one. + replication_slot_name: Option, + /// The output plugin that slot was created with, which decides the payload + /// format: `pgoutput` is the binary protocol a real subscriber speaks. + replication_plugin: String, + /// Tables already described to the subscriber, so a `Relation` message is + /// sent once rather than before every row. + announced_relations: std::collections::HashSet, + /// Table ids handed out for this stream. + relation_ids: HashMap, + /// The change stream a `START_REPLICATION` opened, if any. + replication_stream: Option>, + /// Whether this session has asked for immediate constraint checking. + /// + /// `SET CONSTRAINTS ALL IMMEDIATE` makes deferrable keys behave as if they + /// were not deferrable for the rest of the transaction, so a later write + /// fails at the statement rather than at `COMMIT`. + constraints_immediate: bool, + /// Open savepoints, innermost last. + /// + /// Each records how far the undo logs had grown when it was taken, so + /// rolling back to it undoes exactly the writes made afterwards. + savepoints: Vec, /// Whether this session is inside a transaction block, and whether that /// block has already failed. /// @@ -89,12 +146,20 @@ pub struct PostgresWireProtocol { /// An in-progress `COPY ... FROM STDIN`. struct CopyInState { + /// Whether the stream is in the binary format rather than text. + binary: bool, + /// Bytes of a binary stream not yet forming a whole tuple. + pending: BytesMut, + /// Whether the fixed binary header has been consumed. + header_seen: bool, /// Whether the copy was started by a simple query, which owes the client a /// ReadyForQuery when the stream ends. simple_protocol: bool, table: String, /// Column names the data is being loaded into, in order. columns: Vec, + /// PostgreSQL type OID of each column, for decoding a binary stream. + column_type_oids: Vec, /// Whether each column takes an unquoted literal, by position. /// /// A COPY field is text on the wire but must be written into the statement @@ -176,6 +241,19 @@ impl PostgresWireProtocol { transaction: TransactionState::Idle, writes_in_transaction: 0, transaction_snapshots: HashMap::new(), + transaction_id: None, + snapshot_isolation: false, + serializable: false, + replication: false, + replication_slot_name: None, + replication_plugin: "orbit_json".to_string(), + announced_relations: std::collections::HashSet::new(), + relation_ids: HashMap::new(), + replication_stream: None, + constraints_immediate: false, + transaction_inserts: HashMap::new(), + transaction_pre_images: HashMap::new(), + savepoints: Vec::new(), copy_in: None, copy_in_is_simple: false, notifications: NotificationHub::new(), @@ -185,7 +263,7 @@ impl PostgresWireProtocol { /// Create a new PostgreSQL protocol handler with custom query engine pub fn new_with_query_engine(query_engine: Arc) -> Self { - println!("DEBUG: PostgresWireProtocol initialized with custom QueryEngine"); + tracing::debug!("wire protocol session created with a custom query engine"); let user_store = UserStore::new(); let auth_method = AuthMethod::ScramSha256; let auth_manager = AuthManager::new(auth_method, user_store); @@ -209,6 +287,19 @@ impl PostgresWireProtocol { transaction: TransactionState::Idle, writes_in_transaction: 0, transaction_snapshots: HashMap::new(), + transaction_id: None, + snapshot_isolation: false, + serializable: false, + replication: false, + replication_slot_name: None, + replication_plugin: "orbit_json".to_string(), + announced_relations: std::collections::HashSet::new(), + relation_ids: HashMap::new(), + replication_stream: None, + constraints_immediate: false, + transaction_inserts: HashMap::new(), + transaction_pre_images: HashMap::new(), + savepoints: Vec::new(), copy_in: None, copy_in_is_simple: false, notifications: NotificationHub::new(), @@ -250,12 +341,26 @@ impl PostgresWireProtocol { match head.as_str() { "BEGIN" | "START" => { + self.transaction_id = Some(super::query_engine::begin_transaction_at( + self.snapshot_isolation, + self.serializable, + )); self.transaction = TransactionState::Open; self.writes_in_transaction = 0; } "COMMIT" | "END" | "ROLLBACK" | "ABORT" => { + // Ending the transaction is what makes its rows visible to + // everyone else; a rolled-back block's rows are removed by the + // undo log before this point. + if let Some(context) = self.transaction_id.take() { + super::query_engine::end_transaction(context.id); + } + self.snapshot_isolation = false; + self.serializable = false; self.transaction = TransactionState::Idle; self.writes_in_transaction = 0; + self.savepoints.clear(); + self.constraints_immediate = false; } // Writes are counted so a later ROLLBACK can report what it cannot // undo. @@ -280,10 +385,67 @@ impl PostgresWireProtocol { let Some(table) = QueryEngine::write_target_table(sql) else { return; }; - if self.transaction_snapshots.contains_key(&table) { + + // `COPY ... FROM STDIN` records nothing here: its rows arrive later and + // each one is inserted as its own statement, which lands in the undo + // log on its own. + // The whole-table copy below is the fallback for anything else that + // cannot be expressed as a set of rows. + let head: String = sql + .trim_start() + .chars() + .take_while(char::is_ascii_alphabetic) + .collect::() + .to_uppercase(); + + if head == "COPY" { return; } + if matches!(head.as_str(), "INSERT") { + match self.query_engine.rows_an_insert_adds(sql).await { + Ok(Some(rows)) => { + self.transaction_inserts + .entry(table) + .or_default() + .extend(rows); + return; + } + Ok(None) => {} + Err(e) => tracing::warn!("could not record inserted rows for rollback: {e}"), + } + } else if head == "TRUNCATE" { + // Everything currently in the table is what TRUNCATE removes, so + // every row is its own pre-image. Rolling back re-inserts only + // those, leaving rows another session added afterwards alone. + match self.query_engine.snapshot_table(&table).await { + Ok(Some(rows)) => { + self.transaction_pre_images + .entry(table) + .or_default() + .extend(rows); + return; + } + Ok(None) => {} + Err(e) => tracing::warn!("could not record pre-images for rollback: {e}"), + } + } else if matches!(head.as_str(), "UPDATE" | "DELETE") { + match self.query_engine.rows_a_statement_will_change(sql).await { + Ok(Some(rows)) => { + self.transaction_pre_images + .entry(table) + .or_default() + .extend(rows); + return; + } + Ok(None) => {} + Err(e) => tracing::warn!("could not record pre-images for rollback: {e}"), + } + } + + if self.transaction_snapshots.contains_key(&table) { + return; + } match self.query_engine.snapshot_table(&table).await { Ok(Some(rows)) => { self.transaction_snapshots.insert(table, rows); @@ -299,9 +461,33 @@ impl PostgresWireProtocol { /// /// Returns the tables that could not be restored. async fn restore_snapshots(&mut self) -> Vec { + let inserts = std::mem::take(&mut self.transaction_inserts); + let pre_images = std::mem::take(&mut self.transaction_pre_images); let snapshots = std::mem::take(&mut self.transaction_snapshots); let mut failed = Vec::new(); + // Row-scoped undo first: it touches only what this session wrote. + let tables: std::collections::BTreeSet = + inserts.keys().chain(pre_images.keys()).cloned().collect(); + for table in tables { + let added = inserts.get(&table).map(Vec::as_slice).unwrap_or_default(); + let before = pre_images + .get(&table) + .map(Vec::as_slice) + .unwrap_or_default(); + if let Err(e) = self + .query_engine + .undo_session_writes(&table, added, before) + .await + { + tracing::error!("rollback could not undo writes to '{table}': {e}"); + failed.push(table); + } + } + + // Whole-table restore only for the statements that left no row-level + // record; this one can revert a concurrent session's writes, which is + // why it is the fallback. for (table, rows) in snapshots { if let Err(e) = self.query_engine.restore_table(&table, rows).await { tracing::error!("rollback could not restore '{table}': {e}"); @@ -330,6 +516,14 @@ impl PostgresWireProtocol { match head.as_str() { "ROLLBACK" | "ABORT" => { + // Rows this block marked deleted are put back by clearing the + // mark; the row-level undo handles everything it wrote. + if let Some(id) = self.transaction_id.as_ref().map(|c| c.id) { + let tables = self.tables_written(); + if let Err(e) = self.query_engine.restore_deleted(id, &tables).await { + tracing::error!("could not restore deletes for transaction {id}: {e}"); + } + } let failed = self.restore_snapshots().await; if failed.is_empty() { return; @@ -347,12 +541,568 @@ impl PostgresWireProtocol { ); BackendMessage::NoticeResponse { fields }.encode(buf); } - // The block's writes stand; nothing to put back. - "COMMIT" | "END" => self.transaction_snapshots.clear(), + // Deferred constraints are checked now, before the block's writes + // are allowed to stand. A failure here has to undo them, as + // PostgreSQL does when a deferred check fails at COMMIT. + "COMMIT" | "END" => { + if let Err(e) = self + .query_engine + .check_deferred_constraints(&self.tables_written()) + .await + { + let failed = self.restore_snapshots().await; + self.send_error(buf, &e.to_string()); + if !failed.is_empty() { + tracing::error!("could not undo after a deferred failure: {failed:?}"); + } + self.savepoints.clear(); + return; + } + } + _ => {} + } + + match head.as_str() { + "COMMIT" | "END" => { + // A serializable block that read something another transaction + // has since written cannot be serialized after it: PostgreSQL + // fails it here rather than committing a result no serial + // order could produce. + if let Some(context) = self.transaction_id.clone() { + if let (Some(snapshot), Some(reads)) = + (context.snapshot.as_ref(), context.reads.as_ref()) + { + let tables: Vec = reads + .lock() + .map(|reads| reads.iter().cloned().collect()) + .unwrap_or_default(); + match self + .query_engine + .serialization_conflict(context.id, snapshot, &tables) + .await + { + Ok(Some(table)) => { + let failed = self.restore_snapshots().await; + if !failed.is_empty() { + tracing::error!("could not undo after a conflict: {failed:?}"); + } + let mut fields = HashMap::new(); + fields.insert(b'S', "ERROR".to_string()); + fields.insert(b'C', "40001".to_string()); + fields.insert( + b'M', + format!( + "could not serialize access due to concurrent update on \"{table}\"" + ), + ); + BackendMessage::ErrorResponse { fields }.encode(buf); + self.savepoints.clear(); + return; + } + Ok(None) => {} + Err(e) => tracing::error!("serialization check failed: {e}"), + } + } + } + + // A committed delete removes its rows for good. + if let Some(id) = self.transaction_id.as_ref().map(|c| c.id) { + let tables = self.tables_written(); + if let Err(e) = self.query_engine.purge_deleted(id, &tables).await { + tracing::error!("could not purge deletes for transaction {id}: {e}"); + } + } + self.transaction_snapshots.clear(); + self.transaction_inserts.clear(); + self.transaction_pre_images.clear(); + } _ => {} } } + /// Handle `SET = ` and `SHOW `. + /// + /// Returns `None` when the statement is neither, `Ok(None)` when a value + /// was stored, and `Ok(Some(result))` with the row `SHOW` reports. + fn handle_session_parameter( + &mut self, + query: &str, + ) -> Option, String>> { + let trimmed = query.trim().trim_end_matches(';').trim(); + let words: Vec<&str> = trimmed.split_whitespace().collect(); + let head = words.first()?.to_uppercase(); + + if head == "SHOW" { + let name = words.get(1)?.to_lowercase(); + // `SHOW ALL` reports every parameter, one row each, as psql's + // `\\set`-style introspection expects. + if name == "all" { + let mut rows: Vec>> = self + .parameters + .iter() + .map(|(key, value)| vec![Some(key.clone()), Some(value.clone())]) + .collect(); + rows.sort(); + return Some(Ok(Some(QueryResult::Select { + columns: vec!["name".to_string(), "setting".to_string()], + rows, + }))); + } + let value = self.parameters.get(&name).cloned().unwrap_or_default(); + return Some(Ok(Some(QueryResult::Select { + columns: vec![name], + rows: vec![vec![Some(value)]], + }))); + } + + if head != "SET" { + return None; + } + // `SET TRANSACTION ...`, `SET SESSION ...` and friends are not simple + // parameter assignments; leave them to the engine. + let name = words.get(1)?.to_lowercase(); + if matches!( + name.as_str(), + "transaction" | "session" | "local" | "constraints" + ) { + return None; + } + + let rest = trimmed + .split_once('=') + .map(|(_, value)| value.trim()) + .or_else(|| { + words + .get(2) + .filter(|word| word.eq_ignore_ascii_case("TO")) + .and_then(|_| words.get(3)) + .copied() + })?; + + // A parameter value is written as a SQL literal; the stored value is + // the string it denotes. + let value = rest.trim().trim_matches('\'').trim_matches('"').to_string(); + self.parameters.insert(name, value); + Some(Ok(None)) + } + + /// The tables this transaction block has written. + /// + /// Taken from the undo logs, which already record exactly that. + fn tables_written(&self) -> Vec { + self.transaction_inserts + .keys() + .chain(self.transaction_pre_images.keys()) + .chain(self.transaction_snapshots.keys()) + .cloned() + .collect::>() + .into_iter() + .collect() + } + + /// Handle a walsender command. + /// + /// Returns `None` when the command is not one, so a replication connection + /// can still run ordinary SQL, which `replication=database` allows. + async fn handle_replication_command( + &mut self, + query: &str, + buf: &mut BytesMut, + ) -> Option> { + use super::messages::type_oids; + + let trimmed = query.trim().trim_end_matches(';').trim(); + let upper = trimmed.to_uppercase(); + + let finish = |buf: &mut BytesMut, tag: &str, status: super::messages::TransactionStatus| { + BackendMessage::CommandComplete { + tag: tag.to_string(), + } + .encode(buf); + BackendMessage::ReadyForQuery { status }.encode(buf); + }; + + if upper == "IDENTIFY_SYSTEM" { + let columns = ["systemid", "timeline", "xlogpos", "dbname"]; + BackendMessage::RowDescription { + fields: columns + .iter() + .map(|name| super::messages::FieldDescription { + name: (*name).to_string(), + table_oid: 0, + column_id: 0, + type_oid: type_oids::TEXT, + type_size: -1, + type_modifier: -1, + format: 0, + }) + .collect(), + } + .encode(buf); + BackendMessage::DataRow { + values: vec![ + Some(bytes::Bytes::from(Self::system_identifier())), + Some(bytes::Bytes::from_static(b"1")), + Some(bytes::Bytes::from(Self::current_lsn())), + Some(bytes::Bytes::from( + self.database.clone().unwrap_or_else(|| "orbit".to_string()), + )), + ], + } + .encode(buf); + finish(buf, "IDENTIFY_SYSTEM", self.transaction_status()); + return Some(Ok(())); + } + + if upper.starts_with("TIMELINE_HISTORY") { + // One timeline, so there is no history file to send. Saying so is + // the answer; inventing a file would be worse. + self.send_error( + buf, + "requested timeline is the current one, which has no history file", + ); + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(buf); + return Some(Ok(())); + } + + if upper.starts_with("CREATE_REPLICATION_SLOT") { + let name = trimmed.split_whitespace().nth(1).unwrap_or("slot"); + let plugin = trimmed.split_whitespace().last().unwrap_or("orbit_json"); + let stored = name.trim_matches('"').to_string(); + if let Err(e) = self + .query_engine + .create_replication_slot(&stored, plugin) + .await + { + self.send_error(buf, &e.to_string()); + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(buf); + return Some(Ok(())); + } + BackendMessage::RowDescription { + fields: [ + "slot_name", + "consistent_point", + "snapshot_name", + "output_plugin", + ] + .iter() + .map(|name| super::messages::FieldDescription { + name: (*name).to_string(), + table_oid: 0, + column_id: 0, + type_oid: type_oids::TEXT, + type_size: -1, + type_modifier: -1, + format: 0, + }) + .collect(), + } + .encode(buf); + BackendMessage::DataRow { + values: vec![ + Some(bytes::Bytes::from(name.trim_matches('"').to_string())), + Some(bytes::Bytes::from(Self::current_lsn())), + None, + Some(bytes::Bytes::from(plugin.to_string())), + ], + } + .encode(buf); + finish(buf, "CREATE_REPLICATION_SLOT", self.transaction_status()); + return Some(Ok(())); + } + + if upper.starts_with("DROP_REPLICATION_SLOT") { + if let Some(name) = trimmed.split_whitespace().nth(1) { + let _ = self + .query_engine + .drop_replication_slot(name.trim_matches('"')) + .await; + } + finish(buf, "DROP_REPLICATION_SLOT", self.transaction_status()); + return Some(Ok(())); + } + + if upper.starts_with("START_REPLICATION") { + // Physical replication streams raw WAL. This server has no + // PostgreSQL WAL to stream, and answering a physical request with + // logical frames would be a wrong answer rather than a missing + // feature — the standby would parse change JSON as WAL records. + if upper.contains("PHYSICAL") { + self.send_error( + buf, + "physical replication is not supported; use \ + START_REPLICATION SLOT LOGICAL", + ); + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(buf); + return Some(Ok(())); + } + + let words: Vec<&str> = trimmed.split_whitespace().collect(); + let slot = words + .iter() + .position(|word| word.eq_ignore_ascii_case("SLOT")) + .and_then(|at| words.get(at + 1)) + .map(|name| name.trim_matches('"').to_string()); + + // The position the replica asks to resume from, written as an LSN. + let requested = words + .iter() + .find(|word| word.contains('/')) + .and_then(|word| { + let (high, low) = word.split_once('/')?; + let high = u64::from_str_radix(high, 16).ok()?; + let low = u64::from_str_radix(low, 16).ok()?; + Some((high << 32) | low) + }) + .filter(|position| *position > 0); + + // A slot's confirmed position is used when the replica names none, + // which is what makes the slot worth persisting. + let resume = match (requested, slot.as_ref()) { + (Some(position), _) => Some(position), + (None, Some(name)) => self + .query_engine + .replication_slot(name) + .await + .ok() + .flatten() + .map(|(_, position)| position), + (None, None) => None, + }; + + // Subscribing before replaying means a change written in between + // is queued rather than lost. + let live = super::query_engine::subscribe_to_changes(); + + // The stream is both directions at once: changes go out, standby + // status updates come back. + BackendMessage::CopyBothResponse { + format: 0, + column_formats: Vec::new(), + } + .encode(buf); + + if let Some(position) = resume { + // The in-memory window answers a recent request without a + // read; the durable log answers one that reaches further back, + // including after a restart when the window is empty. + let replayed = match super::query_engine::changes_since(position) { + Some(records) => records, + None => match self.query_engine.logged_changes_since(position).await { + Ok(records) if !records.is_empty() => records, + // Nothing after this position anywhere: the replica is + // already current, so it just starts streaming. + Ok(_) if position >= super::query_engine::latest_change_position() => { + Vec::new() + } + // The position is genuinely behind what is retained. + // Saying so beats a stream with a hole in it. + _ => { + self.send_error( + buf, + "requested WAL position is older than the retained history", + ); + return Some(Ok(())); + } + }, + }; + for record in replayed { + self.send_change(&record, buf); + } + } + + if let Some(name) = slot.as_ref() { + if let Ok(Some((plugin, _))) = self.query_engine.replication_slot(name).await { + self.replication_plugin = plugin; + } + } + self.replication_slot_name = slot; + self.replication_stream = Some(live); + return Some(Ok(())); + } + + None + } + + /// A stable identifier for this server, as `IDENTIFY_SYSTEM` reports it. + fn system_identifier() -> String { + // Derived from the process, so two servers do not claim to be one. + format!("{}", std::process::id() as u64 + 7_000_000_000_000_000_000) + } + + /// The write position, rendered the way PostgreSQL writes an LSN. + fn current_lsn() -> String { + let position = super::query_engine::latest_change_position(); + format!("{:X}/{:X}", position >> 32, position & 0xFFFF_FFFF) + } + + /// Whether a statement ends the current transaction block. + fn ends_transaction(query: &str) -> bool { + let head: String = query + .trim_start() + .chars() + .take_while(char::is_ascii_alphabetic) + .collect::() + .to_uppercase(); + matches!(head.as_str(), "ROLLBACK" | "ABORT" | "COMMIT" | "END") + } + + /// Handle `SAVEPOINT`, `ROLLBACK TO SAVEPOINT` and `RELEASE`. + /// + /// Returns `None` when the statement is none of those. A savepoint is a + /// second layer of undo inside the block: without it `ROLLBACK TO` fell + /// through to a plain `ROLLBACK` and discarded the whole block. + async fn handle_savepoint(&mut self, query: &str) -> Option> { + let trimmed = query.trim().trim_end_matches(';').trim(); + let words: Vec<&str> = trimmed.split_whitespace().collect(); + let upper: Vec = words.iter().map(|w| w.to_uppercase()).collect(); + let name_after = |index: usize| words.get(index).map(|w| w.to_lowercase()); + + // `ROLLBACK TO [SAVEPOINT] name` + if upper.first().is_some_and(|w| w == "ROLLBACK") && upper.get(1).is_some_and(|w| w == "TO") + { + let at = if upper.get(2).is_some_and(|w| w == "SAVEPOINT") { + 3 + } else { + 2 + }; + let Some(name) = name_after(at) else { + return Some(Err("ROLLBACK TO requires a savepoint name".to_string())); + }; + let Some(index) = self.savepoints.iter().rposition(|s| s.name == name) else { + return Some(Err(format!("savepoint \"{name}\" does not exist"))); + }; + + let inserts_at = self.savepoints[index].inserts.clone(); + let pre_images_at = self.savepoints[index].pre_images.clone(); + let tables_at = self.savepoints[index].tables.clone(); + self.savepoints.truncate(index + 1); + + // Undo only what was written after the savepoint: the tail of each + // undo log beyond the length it had when the savepoint was taken. + let touched: std::collections::BTreeSet = self + .transaction_inserts + .keys() + .chain(self.transaction_pre_images.keys()) + .cloned() + .collect(); + for table in touched { + let kept_inserts = inserts_at.get(&table).copied().unwrap_or(0); + let kept_pre_images = pre_images_at.get(&table).copied().unwrap_or(0); + let added: Vec<_> = self + .transaction_inserts + .get(&table) + .map(|rows| rows[kept_inserts.min(rows.len())..].to_vec()) + .unwrap_or_default(); + let before: Vec<_> = self + .transaction_pre_images + .get(&table) + .map(|rows| rows[kept_pre_images.min(rows.len())..].to_vec()) + .unwrap_or_default(); + + if let Err(e) = self + .query_engine + .undo_session_writes(&table, &added, &before) + .await + { + return Some(Err(format!( + "could not roll back '{table}' to savepoint: {e}" + ))); + } + + if let Some(rows) = self.transaction_inserts.get_mut(&table) { + let keep = kept_inserts.min(rows.len()); + rows.truncate(keep); + } + if let Some(rows) = self.transaction_pre_images.get_mut(&table) { + let keep = kept_pre_images.min(rows.len()); + rows.truncate(keep); + } + } + + // A table written by a statement with no row-level record goes back + // to the copy taken at the savepoint. + for (table, rows) in tables_at { + if let Err(e) = self.query_engine.restore_table(&table, rows).await { + return Some(Err(format!( + "could not roll back '{table}' to savepoint: {e}" + ))); + } + } + + // The block continues, and a failure before this point is undone. + if self.transaction == TransactionState::Failed { + self.transaction = TransactionState::Open; + } + return Some(Ok("ROLLBACK".to_string())); + } + + if upper.first().is_some_and(|w| w == "SAVEPOINT") { + let Some(name) = name_after(1) else { + return Some(Err("SAVEPOINT requires a name".to_string())); + }; + if self.transaction == TransactionState::Idle { + return Some(Err( + "SAVEPOINT can only be used in transaction blocks".to_string() + )); + } + + let mut tables = HashMap::new(); + let written: Vec = self.transaction_snapshots.keys().cloned().collect(); + for table in written { + match self.query_engine.snapshot_table(&table).await { + Ok(Some(rows)) => { + tables.insert(table, rows); + } + Ok(None) => {} + Err(e) => return Some(Err(format!("could not record savepoint: {e}"))), + } + } + self.savepoints.push(Savepoint { + name, + inserts: self + .transaction_inserts + .iter() + .map(|(table, rows)| (table.clone(), rows.len())) + .collect(), + pre_images: self + .transaction_pre_images + .iter() + .map(|(table, rows)| (table.clone(), rows.len())) + .collect(), + tables, + }); + return Some(Ok("SAVEPOINT".to_string())); + } + + if upper.first().is_some_and(|w| w == "RELEASE") { + let at = if upper.get(1).is_some_and(|w| w == "SAVEPOINT") { + 2 + } else { + 1 + }; + let Some(name) = name_after(at) else { + return Some(Err("RELEASE requires a savepoint name".to_string())); + }; + let Some(index) = self.savepoints.iter().rposition(|s| s.name == name) else { + return Some(Err(format!("savepoint \"{name}\" does not exist"))); + }; + self.savepoints.truncate(index); + return Some(Ok("RELEASE".to_string())); + } + + None + } + /// Record that a statement failed. /// /// Inside a transaction block this poisons it: PostgreSQL rejects every @@ -447,6 +1197,10 @@ impl PostgresWireProtocol { // happened to send a message, which for an idle listener is never — // and an idle listener is the whole point of LISTEN. let n = loop { + // A replication stream, once started, is the same idle problem as + // a listener: changes have to reach the standby without waiting + // for it to say something. + let streaming = self.replication_stream.is_some(); tokio::select! { read = stream.read_buf(read_buf) => break read?, Some(notification) = self.session_notifications.receiver.recv() => { @@ -458,6 +1212,17 @@ impl PostgresWireProtocol { .encode(write_buf); self.flush_write_buffer(stream, write_buf).await?; } + change = async { + match self.replication_stream.as_mut() { + Some(stream) => stream.recv().await.ok(), + None => None, + } + }, if streaming => { + if let Some(change) = change { + self.send_change(&change, write_buf); + self.flush_write_buffer(stream, write_buf).await?; + } + } } }; @@ -624,11 +1389,31 @@ impl PostgresWireProtocol { FrontendMessage::SASLResponse { data } => { self.handle_sasl_response(data, buf).await?; } - FrontendMessage::FunctionCall { .. } => { - // Function call support is minimal/stubbed + FrontendMessage::FunctionCall { oid, .. } => { + // The legacy fast-path call. Answering nothing left the client + // waiting on a reply that never came; an error plus + // ReadyForQuery is what an unsupported function warrants and + // keeps the connection usable. + self.send_error( + buf, + &format!( + "fastpath function call is not supported (function OID {oid}); \ + call the function from a query instead" + ), + ); + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(buf); } FrontendMessage::CopyData { data } => { - self.handle_copy_data(&data, buf).await?; + // On a replication stream a CopyData is the standby telling us + // how far it has written, flushed and applied. + if self.replication_stream.is_some() { + self.handle_standby_status(&data, buf).await; + } else { + self.handle_copy_data(&data, buf).await?; + } } FrontendMessage::CopyDone => { self.handle_copy_done(buf).await?; @@ -693,6 +1478,11 @@ impl PostgresWireProtocol { .encode(buf); } + // `replication=true|database` selects the walsender protocol. + self.replication = parameters + .get("replication") + .is_some_and(|value| !value.eq_ignore_ascii_case("false")); + self.username = parameters.get("user").cloned(); self.database = parameters.get("database").cloned(); self.parameters = parameters; @@ -907,16 +1697,156 @@ impl PostgresWireProtocol { return Ok(()); } + // A replication connection speaks its own commands. + if self.replication { + if let Some(handled) = self.handle_replication_command(query, buf).await { + return handled; + } + } + // Set the current database context before executing the query if let Some(ref db) = self.database { self.query_engine.set_current_database(db).await; } - match self.query_engine.execute_multiple_queries(query).await { + // PostgreSQL rejects everything but a rollback once a statement in the + // block has failed. Answering them instead let a client believe work + // done after the failure was part of the committed transaction. + if self.transaction == TransactionState::Failed && !Self::ends_transaction(query) { + self.send_error( + buf, + "current transaction is aborted, commands ignored until end of transaction block", + ); + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(buf); + return Ok(()); + } + + // `SET` and `SHOW` share one store, on the session. Routing them + // separately meant `SHOW` could not see what `SET` had recorded — and + // `SHOW` did not parse at all. + if let Some(result) = self.handle_session_parameter(query) { + match result { + Ok(Some(shown)) => self.send_query_result(&shown, buf), + Ok(None) => BackendMessage::CommandComplete { + tag: "SET".to_string(), + } + .encode(buf), + Err(message) => { + self.send_error(buf, &message); + self.note_failure(); + } + } + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(buf); + return Ok(()); + } + + // `SET CONSTRAINTS ALL IMMEDIATE` validates the deferrable keys now + // rather than waiting for COMMIT, which is what it is for: finding out + // whether the block will commit before committing it. + // `BEGIN ISOLATION LEVEL ...` and `SET TRANSACTION ISOLATION LEVEL ...` + // choose between reading through a snapshot and seeing each commit. + let upper_query = query.to_uppercase(); + if upper_query.contains("ISOLATION LEVEL") { + self.serializable = upper_query.contains("SERIALIZABLE"); + self.snapshot_isolation = self.serializable || upper_query.contains("REPEATABLE READ"); + } + + if query + .trim_start() + .get(..15) + .is_some_and(|head| head.eq_ignore_ascii_case("SET CONSTRAINTS")) + { + // `DEFERRED` puts the checks back to COMMIT; `IMMEDIATE` runs them + // now and keeps running them per statement for the rest of the + // block, which is the difference the two modes are for. + self.constraints_immediate = query.to_uppercase().contains("IMMEDIATE"); + if self.constraints_immediate { + if let Err(e) = self + .query_engine + .check_deferred_constraints(&self.tables_written()) + .await + { + self.send_error(buf, &e.to_string()); + self.note_failure(); + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(buf); + return Ok(()); + } + } + BackendMessage::CommandComplete { + tag: "SET CONSTRAINTS".to_string(), + } + .encode(buf); + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(buf); + return Ok(()); + } + + if let Some(handled) = self.handle_savepoint(query).await { + match handled { + Ok(tag) => BackendMessage::CommandComplete { tag }.encode(buf), + Err(message) => { + self.send_error(buf, &message); + self.note_failure(); + } + } + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(buf); + return Ok(()); + } + + // Statements run inside the session's transaction so the rows they + // write carry its stamp and stay private until it ends. + let executed = match self.transaction_id.clone() { + Some(context) => { + super::query_engine::within_transaction( + context, + self.query_engine.execute_multiple_queries(query), + ) + .await + } + None => self.query_engine.execute_multiple_queries(query).await, + }; + + match executed { Ok(results) => { for result in results { self.send_query_result(&result, buf); } + // With IMMEDIATE in force, a deferrable key is checked after + // every statement rather than only at COMMIT. Transaction + // control is exempt: failing the check on ROLLBACK would leave + // the session unable to leave the block at all. + if self.constraints_immediate + && self.transaction == TransactionState::Open + && !Self::ends_transaction(query) + { + if let Err(e) = self + .query_engine + .check_deferred_constraints(&self.tables_written()) + .await + { + self.send_error(buf, &e.to_string()); + self.note_failure(); + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(buf); + return Ok(()); + } + } self.apply_transaction_control(query, buf).await; self.note_statement(query); self.deliver_pending_notifications(buf); @@ -1221,8 +2151,7 @@ impl PostgresWireProtocol { .copied() .unwrap_or(type_oids::TEXT); - if Self::is_unquoted_literal_type(type_oid) - && Self::is_safe_bare_literal(text) + if Self::is_unquoted_literal_type(type_oid) && Self::is_safe_bare_literal(text) { // A numeric or boolean parameter has to be emitted // bare: quoting it turns `id = $1` into `id = '1'`, @@ -1288,13 +2217,29 @@ impl PostgresWireProtocol { }; self.copy_in_is_simple = false; - if self.handle_copy_statement(&bound, buf, false).await.is_some() { + if self + .handle_copy_statement(&bound, buf, false) + .await + .is_some() + { return Ok(()); } let bound_for_state = bound.clone(); let portal_name = portal.to_string(); - match self.query_engine.execute_query(&bound).await { + // The extended protocol runs inside the session's transaction too. + let executed = match self.transaction_id.clone() { + Some(context) => { + super::query_engine::within_transaction( + context, + self.query_engine.execute_query(&bound), + ) + .await + } + None => self.query_engine.execute_query(&bound).await, + }; + + match executed { Ok(QueryResult::Select { columns, rows }) | Ok(QueryResult::Merge { columns, rows, .. }) => { self.note_statement(&bound_for_state); @@ -1551,6 +2496,179 @@ impl PostgresWireProtocol { } } + /// Handle a standby status update, and answer a keepalive that asks for one. + /// + /// The confirmed position is written to the slot, which is what lets a + /// replica reconnect and resume where it left off. + async fn handle_standby_status(&mut self, data: &[u8], buf: &mut BytesMut) { + match data.first() { + // `r`: write, flush and apply positions, then a reply flag. + Some(b'r') if data.len() >= 34 => { + let flushed = u64::from_be_bytes(data[9..17].try_into().unwrap_or([0; 8])); + if let Some(slot) = self.replication_slot_name.clone() { + if let Err(e) = self + .query_engine + .confirm_replication_slot(&slot, flushed) + .await + { + tracing::warn!("could not record replica progress: {e}"); + } + } + // The last byte asks for an immediate reply. + if data[33] == 1 { + self.send_keepalive(buf, false); + } + } + // `k`: a keepalive from the other direction. + Some(b'k') => {} + _ => {} + } + } + + /// Send a keepalive, optionally asking the standby to answer. + fn send_keepalive(&self, buf: &mut BytesMut, reply_requested: bool) { + let position = super::query_engine::latest_change_position(); + let mut message = BytesMut::new(); + message.put_u8(b'k'); + message.put_u64(position); + message.put_i64(0); + message.put_u8(u8::from(reply_requested)); + BackendMessage::CopyData { + data: message.freeze(), + } + .encode(buf); + } + + /// Send one change as an `XLogData` message on a replication stream. + /// + /// The payload is the change rendered as JSON — an output plugin's job in + /// PostgreSQL. The header carries the same three positions PostgreSQL + /// sends, so a standby's bookkeeping has somewhere to start. + fn send_change(&mut self, change: &super::query_engine::ChangeRecord, buf: &mut BytesMut) { + let position = change.position; + let payload = if self.replication_plugin.eq_ignore_ascii_case("pgoutput") { + self.pgoutput_payload(change) + } else { + format!( + "{{\"action\":\"{}\",\"table\":\"{}\",\"xid\":{},\"row\":{}}}", + change.action, change.table, change.transaction, change.row + ) + .into_bytes() + }; + + let mut message = BytesMut::new(); + message.put_u8(b'w'); + message.put_u64(position); // start of this record + message.put_u64(position); // current end of WAL + message.put_i64(0); // server clock, which this does not track + message.extend_from_slice(&payload); + + BackendMessage::CopyData { + data: message.freeze(), + } + .encode(buf); + } + + /// Render a change in the `pgoutput` protocol a real subscriber decodes. + /// + /// Each change is a `Begin`, a `Relation` describing the table the first + /// time it appears, the row message itself, and a `Commit` — the shape + /// PostgreSQL sends for a single-statement transaction. Values go as text, + /// which `pgoutput` allows. + fn pgoutput_payload(&mut self, change: &super::query_engine::ChangeRecord) -> Vec { + let row: std::collections::BTreeMap = + serde_json::from_str(&change.row).unwrap_or_default(); + let relation = self.relation_id(&change.table); + let mut out = BytesMut::new(); + + // Begin: final LSN, commit timestamp, transaction id. + out.put_u8(b'B'); + out.put_u64(change.position); + out.put_i64(0); + out.put_i32(change.transaction as i32); + + // Relation, sent once per table per stream, as the protocol expects. + if self.announced_relations.insert(change.table.clone()) { + out.put_u8(b'R'); + out.put_i32(relation); + out.extend_from_slice(b"public\0"); + out.extend_from_slice(change.table.as_bytes()); + out.put_u8(0); + out.put_u8(b'd'); // replica identity: default + out.put_i16(row.len() as i16); + for name in row.keys() { + out.put_u8(0); // not part of the key + out.extend_from_slice(name.as_bytes()); + out.put_u8(0); + out.put_i32(super::messages::type_oids::TEXT); + out.put_i32(-1); + } + } + + let tuple = |out: &mut BytesMut, row: &std::collections::BTreeMap| { + out.put_u8(b'N'); // a new tuple follows + out.put_i16(row.len() as i16); + for value in row.values() { + match value { + serde_json::Value::Null => out.put_u8(b'n'), + other => { + let text = other + .as_str() + .map(str::to_string) + .unwrap_or_else(|| other.to_string()); + out.put_u8(b't'); + out.put_i32(text.len() as i32); + out.extend_from_slice(text.as_bytes()); + } + } + } + }; + + match change.action.as_str() { + "INSERT" => { + out.put_u8(b'I'); + out.put_i32(relation); + tuple(&mut out, &row); + } + "UPDATE" => { + out.put_u8(b'U'); + out.put_i32(relation); + tuple(&mut out, &row); + } + "DELETE" => { + out.put_u8(b'D'); + out.put_i32(relation); + // The old row identifies what went; `K` is the key tuple. + out.put_u8(b'K'); + out.put_i16(row.len() as i16); + for value in row.values() { + let text = value + .as_str() + .map(str::to_string) + .unwrap_or_else(|| value.to_string()); + out.put_u8(b't'); + out.put_i32(text.len() as i32); + out.extend_from_slice(text.as_bytes()); + } + } + _ => {} + } + + // Commit: flags, commit LSN, end LSN, timestamp. + out.put_u8(b'C'); + out.put_u8(0); + out.put_u64(change.position); + out.put_u64(change.position); + out.put_i64(0); + + out.to_vec() + } + + /// A stable id for a table within this stream. + fn relation_id(&mut self, table: &str) -> i32 { + let next = self.relation_ids.len() as i32 + 16_384; + *self.relation_ids.entry(table.to_string()).or_insert(next) + } /// Start a `COPY` statement, or return `None` if this is not one. /// @@ -1569,11 +2687,7 @@ impl PostgresWireProtocol { } let upper = trimmed.to_ascii_uppercase(); - if upper.contains(" BINARY") || upper.contains("FORMAT BINARY") { - self.send_error(buf, "COPY BINARY is not supported; use the text format"); - self.finish_copy_statement(send_ready, buf); - return Some(()); - } + let binary = upper.contains(" BINARY") || upper.contains("FORMAT BINARY"); // `COPY
[(cols)] TO STDOUT` / `FROM STDIN` let body = trimmed[5..].trim(); @@ -1603,10 +2717,12 @@ impl PostgresWireProtocol { let _ = body; if to_stdout { - self.copy_table_to_stdout(&table, &columns, buf).await; + self.copy_table_to_stdout(&table, &columns, binary, buf) + .await; self.finish_copy_statement(send_ready, buf); } else { - self.begin_copy_from_stdin(&table, columns, buf).await; + self.begin_copy_from_stdin(&table, columns, binary, buf) + .await; } Some(()) } @@ -1629,6 +2745,7 @@ impl PostgresWireProtocol { &mut self, table: &str, columns: &[String], + binary: bool, buf: &mut BytesMut, ) { let projection = if columns.is_empty() { @@ -1652,11 +2769,67 @@ impl PostgresWireProtocol { }; BackendMessage::CopyOutResponse { - format: 0, - column_formats: vec![0; column_count], + format: i8::from(binary), + column_formats: vec![i16::from(binary); column_count], } .encode(buf); + if binary { + // The binary stream opens with a fixed signature, a flags word and + // an (empty) header extension, and closes with a field count of + // -1. Each tuple is a field count then length-prefixed values. + let types = self + .query_engine + .describe_statement(&format!("SELECT {projection} FROM {table}")) + .await + .map(|description| { + description + .columns + .iter() + .map(|column| column.type_oid) + .collect::>() + }) + .unwrap_or_default(); + + let mut header = BytesMut::new(); + header.extend_from_slice(COPY_BINARY_SIGNATURE); + header.put_i32(0); + header.put_i32(0); + BackendMessage::CopyData { + data: header.freeze(), + } + .encode(buf); + + for row in &rows { + let mut tuple = BytesMut::new(); + tuple.put_i16(row.len() as i16); + for (index, value) in row.iter().enumerate() { + match value { + None => tuple.put_i32(-1), + Some(text) => { + let oid = types.get(index).copied().unwrap_or(type_oids::TEXT); + let encoded = Self::encode_binary_value(text, oid); + tuple.put_i32(encoded.len() as i32); + tuple.extend_from_slice(&encoded); + } + } + } + BackendMessage::CopyData { + data: tuple.freeze(), + } + .encode(buf); + } + + let mut trailer = BytesMut::new(); + trailer.put_i16(-1); + BackendMessage::CopyData { + data: trailer.freeze(), + } + .encode(buf); + BackendMessage::CopyDone.encode(buf); + return; + } + for row in &rows { let line = row .iter() @@ -1690,6 +2863,7 @@ impl PostgresWireProtocol { &mut self, table: &str, columns: Vec, + binary: bool, buf: &mut BytesMut, ) { // Column order has to be known before the first row arrives; when the @@ -1737,15 +2911,37 @@ impl PostgresWireProtocol { }) .collect(); + // Type OIDs are needed per column to decode a binary stream. + let column_type_oids: Vec = columns + .iter() + .map(|name| { + schema + .as_ref() + .and_then(|schema| { + schema + .columns + .iter() + .find(|c| c.name.eq_ignore_ascii_case(name)) + }) + .map_or(type_oids::TEXT, |column| { + super::query_engine::column_type_oid(&column.data_type) + }) + }) + .collect(); + BackendMessage::CopyInResponse { - format: 0, - column_formats: vec![0; columns.len()], + format: i8::from(binary), + column_formats: vec![i16::from(binary); columns.len()], } .encode(buf); self.copy_in = Some(CopyInState { + binary, + pending: BytesMut::new(), + header_seen: false, simple_protocol: self.copy_in_is_simple, table: table.to_string(), + column_type_oids, numeric_columns, columns, partial: Vec::new(), @@ -1754,17 +2950,142 @@ impl PostgresWireProtocol { }); } - /// Consume one chunk of copy-in data. - async fn handle_copy_data( + /// Consume one chunk of a binary copy-in stream. + /// + /// The stream is a fixed header followed by length-prefixed tuples, so a + /// chunk may end anywhere; whatever does not form a whole tuple is kept + /// for the next message. + async fn handle_binary_copy_data( &mut self, data: &[u8], - buf: &mut BytesMut, + _buf: &mut BytesMut, ) -> ProtocolResult<()> { + { + let Some(state) = self.copy_in.as_mut() else { + return Ok(()); + }; + state.pending.extend_from_slice(data); + } + + loop { + let Some(state) = self.copy_in.as_mut() else { + return Ok(()); + }; + + if !state.header_seen { + // Signature, flags and the header extension's length. + const HEADER: usize = 11 + 4 + 4; + if state.pending.len() < HEADER { + return Ok(()); + } + if &state.pending[..11] != COPY_BINARY_SIGNATURE { + state.failure = Some("COPY binary stream has a bad signature".to_string()); + state.pending.clear(); + return Ok(()); + } + let extension = i32::from_be_bytes([ + state.pending[15], + state.pending[16], + state.pending[17], + state.pending[18], + ]) as usize; + if state.pending.len() < HEADER + extension { + return Ok(()); + } + let _ = state.pending.split_to(HEADER + extension); + state.header_seen = true; + continue; + } + + if state.pending.len() < 2 { + return Ok(()); + } + let fields = i16::from_be_bytes([state.pending[0], state.pending[1]]); + if fields < 0 { + // The end-of-data trailer. + let _ = state.pending.split_to(2); + return Ok(()); + } + + // Measure the whole tuple before consuming any of it, so a chunk + // that stops mid-value is simply waited on. + let mut offset = 2usize; + let mut lengths = Vec::with_capacity(fields as usize); + for _ in 0..fields { + if state.pending.len() < offset + 4 { + return Ok(()); + } + let length = i32::from_be_bytes([ + state.pending[offset], + state.pending[offset + 1], + state.pending[offset + 2], + state.pending[offset + 3], + ]); + offset += 4; + if length >= 0 { + if state.pending.len() < offset + length as usize { + return Ok(()); + } + offset += length as usize; + } + lengths.push(length); + } + + let tuple = state.pending.split_to(offset); + let oids = state.column_type_oids.clone(); + let mut values = Vec::with_capacity(lengths.len()); + let mut cursor = 2usize; + for (index, length) in lengths.into_iter().enumerate() { + cursor += 4; + if length < 0 { + values.push(None); + continue; + } + let raw = &tuple[cursor..cursor + length as usize]; + cursor += length as usize; + let oid = oids.get(index).copied().unwrap_or(type_oids::TEXT); + match Self::decode_binary_parameter(raw, oid) { + Ok(text) => values.push(Some(text)), + Err(e) => { + if let Some(state) = self.copy_in.as_mut() { + if state.failure.is_none() { + state.failure = Some(e); + } + } + values.push(None); + } + } + } + + // The decoded values are written through the same path a text row + // takes, so quoting and constraint checks behave identically. + let line = values + .into_iter() + .map(|value| match value { + None => "\\N".to_string(), + Some(text) => text + .replace('\\', "\\\\") + .replace('\t', "\\t") + .replace('\n', "\\n") + .replace('\r', "\\r"), + }) + .collect::>() + .join("\t"); + self.insert_copy_line(&line).await?; + } + } + + /// Consume one chunk of copy-in data. + async fn handle_copy_data(&mut self, data: &[u8], buf: &mut BytesMut) -> ProtocolResult<()> { if self.copy_in.is_none() { self.send_error(buf, "CopyData received while not in copy-in mode"); return Ok(()); } + if self.copy_in.as_ref().is_some_and(|state| state.binary) { + return self.handle_binary_copy_data(data, buf).await; + } + // Chunks split rows arbitrarily, so only whole lines are consumed and // the remainder is carried to the next message. let mut pending = { @@ -1831,6 +3152,12 @@ impl PostgresWireProtocol { values.join(", ") ); + // Each copied line is an ordinary INSERT, so it records itself in the + // undo log the same way. Without this a `COPY` inside a transaction + // block fell back to a whole-table copy, whose rollback reverted a + // concurrent session's writes to that table. + self.snapshot_before_write(&statement).await; + match self.query_engine.execute_query(&statement).await { Ok(_) => { if let Some(state) = self.copy_in.as_mut() { @@ -2158,14 +3485,15 @@ mod extended_protocol_tests { #[test] fn a_repeated_placeholder_uses_the_same_value_each_time() { - let bound = - PostgresWireProtocol::bind_parameters("SELECT $1, $1", &[param("x")], &[]).expect("bound"); + let bound = PostgresWireProtocol::bind_parameters("SELECT $1, $1", &[param("x")], &[]) + .expect("bound"); assert_eq!(bound, "SELECT 'x', 'x'"); } #[test] fn a_null_parameter_becomes_sql_null_not_an_empty_string() { - let bound = PostgresWireProtocol::bind_parameters("SELECT $1", &[None], &[]).expect("bound"); + let bound = + PostgresWireProtocol::bind_parameters("SELECT $1", &[None], &[]).expect("bound"); assert_eq!(bound, "SELECT NULL"); } @@ -2201,8 +3529,8 @@ mod extended_protocol_tests { #[test] fn a_placeholder_inside_a_literal_is_left_untouched() { - let bound = - PostgresWireProtocol::bind_parameters("SELECT '$1', $1", &[param("v")], &[]).expect("bound"); + let bound = PostgresWireProtocol::bind_parameters("SELECT '$1', $1", &[param("v")], &[]) + .expect("bound"); assert_eq!(bound, "SELECT '$1', 'v'"); } @@ -2231,8 +3559,14 @@ mod extended_protocol_tests { let decode = PostgresWireProtocol::decode_binary_parameter; assert_eq!(decode(&2i32.to_be_bytes(), type_oids::INT4).unwrap(), "2"); - assert_eq!(decode(&(-7i64).to_be_bytes(), type_oids::INT8).unwrap(), "-7"); - assert_eq!(decode(&300i16.to_be_bytes(), type_oids::INT2).unwrap(), "300"); + assert_eq!( + decode(&(-7i64).to_be_bytes(), type_oids::INT8).unwrap(), + "-7" + ); + assert_eq!( + decode(&300i16.to_be_bytes(), type_oids::INT2).unwrap(), + "300" + ); assert_eq!(decode(&[1], type_oids::BOOL).unwrap(), "true"); assert_eq!(decode(&[0], type_oids::BOOL).unwrap(), "false"); assert_eq!(decode(b"hello", type_oids::TEXT).unwrap(), "hello"); @@ -2251,7 +3585,10 @@ mod extended_protocol_tests { let decode = PostgresWireProtocol::decode_binary_parameter; assert!(decode(&[1, 2], type_oids::INT4).is_err(), "wrong width"); assert!(decode(&[2], type_oids::BOOL).is_err(), "not 0 or 1"); - assert!(decode(&[0; 8], type_oids::BYTEA).is_err(), "unsupported type"); + assert!( + decode(&[0; 8], type_oids::BYTEA).is_err(), + "unsupported type" + ); } #[test] @@ -2307,20 +3644,20 @@ mod extended_protocol_tests { #[test] fn boolean_parameters_are_spliced_without_quotes() { - let bound = - PostgresWireProtocol::bind_parameters("SELECT $1", &[param("true")], &[type_oids::BOOL]) - .expect("bound"); + let bound = PostgresWireProtocol::bind_parameters( + "SELECT $1", + &[param("true")], + &[type_oids::BOOL], + ) + .expect("bound"); assert_eq!(bound, "SELECT true"); } #[test] fn text_parameters_stay_quoted_even_when_they_look_numeric() { - let bound = PostgresWireProtocol::bind_parameters( - "SELECT $1", - &[param("42")], - &[type_oids::TEXT], - ) - .expect("bound"); + let bound = + PostgresWireProtocol::bind_parameters("SELECT $1", &[param("42")], &[type_oids::TEXT]) + .expect("bound"); assert_eq!(bound, "SELECT '42'"); } @@ -2343,9 +3680,18 @@ mod extended_protocol_tests { let encode = PostgresWireProtocol::encode_binary_value; assert_eq!(encode("5", type_oids::INT4).as_ref(), &5i32.to_be_bytes()); - assert_eq!(encode("-7", type_oids::INT8).as_ref(), &(-7i64).to_be_bytes()); - assert_eq!(encode("300", type_oids::INT2).as_ref(), &300i16.to_be_bytes()); - assert_eq!(encode("1.5", type_oids::FLOAT8).as_ref(), &1.5f64.to_be_bytes()); + assert_eq!( + encode("-7", type_oids::INT8).as_ref(), + &(-7i64).to_be_bytes() + ); + assert_eq!( + encode("300", type_oids::INT2).as_ref(), + &300i16.to_be_bytes() + ); + assert_eq!( + encode("1.5", type_oids::FLOAT8).as_ref(), + &1.5f64.to_be_bytes() + ); assert_eq!(encode("true", type_oids::BOOL).as_ref(), &[1u8]); assert_eq!(encode("f", type_oids::BOOL).as_ref(), &[0u8]); } diff --git a/orbit/server/src/protocols/postgres_wire/query_engine.rs b/orbit/server/src/protocols/postgres_wire/query_engine.rs index 3d5e4cc9..d7b7ab99 100644 --- a/orbit/server/src/protocols/postgres_wire/query_engine.rs +++ b/orbit/server/src/protocols/postgres_wire/query_engine.rs @@ -13,6 +13,7 @@ use crate::protocols::postgres_wire::graphrag_engine::GraphRAGQueryEngine; use crate::protocols::postgres_wire::persistent_storage::{ ColumnType, PersistentTableStorage, QueryCondition, TableRow, }; +use crate::protocols::postgres_wire::sql::types::SqlValue; use crate::protocols::postgres_wire::sql::{ConfigurableSqlEngine, UnifiedExecutionResult}; use crate::protocols::postgres_wire::vector_engine::VectorQueryEngine; use orbit_client::OrbitClient; @@ -106,6 +107,510 @@ pub fn fold_identifier(identifier: &str) -> String { } } +/// Table holding view definitions. +/// +/// Prefixed so it cannot collide with a user table named `views`. +const VIEW_CATALOG: &str = "orbit_catalog_views"; + +/// Table holding the durable change log a replica replays from. +const CHANGE_LOG: &str = "orbit_catalog_changes"; + +/// The name at the end of a `DROP VIEW [IF EXISTS] name` statement. +fn upper_tail(statement: &str, if_exists: bool) -> &str { + let skip = if if_exists { 4 } else { 2 }; + statement + .split_whitespace() + .nth(skip) + .unwrap_or("") + .trim_end_matches(',') +} + +/// Split a simple-query message into its statements. +/// +/// Semicolons inside string literals, quoted identifiers and dollar-quoted +/// bodies do not separate statements; splitting on every `;` would cut +/// `VALUES (\'a;b\')` in half. +fn split_statements(sql: &str) -> Vec { + let mut statements = Vec::new(); + let mut current = String::new(); + let mut chars = sql.chars().peekable(); + let mut quote: Option = None; + // Dollar quoting: `$$ ... $$` or `$tag$ ... $tag$`. Everything between the + // delimiters is literal text, which is how a body containing semicolons — + // a function or a trigger — is written at all. + let mut dollar_tag: Option = None; + + while let Some(character) = chars.next() { + if let Some(tag) = dollar_tag.clone() { + current.push(character); + if character == '$' && current.ends_with(&tag) { + dollar_tag = None; + } + continue; + } + if quote.is_none() && character == '$' { + // Read the tag up to the closing `$`. + let mut tag = String::from('$'); + while let Some(next) = chars.peek() { + let next = *next; + if next == '$' { + tag.push('$'); + chars.next(); + break; + } + if !next.is_alphanumeric() && next != '_' { + break; + } + tag.push(next); + chars.next(); + } + current.push_str(&tag); + if tag.ends_with('$') && tag.len() >= 2 { + dollar_tag = Some(tag); + } + continue; + } + + match quote { + Some(open) => { + current.push(character); + if character == open { + // A doubled quote is an escaped quote, not the end. + if chars.peek() == Some(&open) { + current.push(open); + chars.next(); + } else { + quote = None; + } + } + } + None => match character { + '\'' | '"' => { + quote = Some(character); + current.push(character); + } + ';' => { + if !current.trim().is_empty() { + statements.push(current.trim().to_string()); + } + current.clear(); + } + other => current.push(other), + }, + } + } + + if !current.trim().is_empty() { + statements.push(current.trim().to_string()); + } + statements +} + +/// One change published to replication subscribers. +#[derive(Debug, Clone)] +pub struct ChangeRecord { + /// Where this change sits in the stream, as an LSN. + pub position: u64, + /// The transaction that made the change. + pub transaction: u64, + /// `INSERT`, `UPDATE` or `DELETE`. + pub action: String, + /// The table it touched. + pub table: String, + /// The row as it stands afterwards, rendered as JSON. + pub row: String, +} + +/// Recent changes, kept so a subscriber can replay from a position it names. +/// +/// Bounded: a replica that asks for a position older than the window is told +/// the history is gone rather than served a silently incomplete stream. +static HISTORY: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +/// How many changes are retained for replay. +const HISTORY_DEPTH: usize = 4096; + +fn history() -> &'static std::sync::Mutex> { + HISTORY.get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new())) +} + +/// Changes recorded at or after `position`, oldest first. +/// +/// Returns `None` when the window no longer reaches back that far, which the +/// caller reports rather than papering over. +#[must_use] +pub fn changes_since(position: u64) -> Option> { + let history = history().lock().ok()?; + let Some(oldest) = history.front().map(|record| record.position) else { + return None; + }; + if position < oldest.saturating_sub(1) { + return None; + } + Some( + history + .iter() + .filter(|record| record.position > position) + .cloned() + .collect(), + ) +} + +/// Changes published as they are written, for replication to stream. +/// +/// A broadcast channel rather than a log: a subscriber that cannot keep up +/// misses records and is told so, which is honest, where an unbounded queue +/// would grow until the process died. +static CHANGES: std::sync::OnceLock> = + std::sync::OnceLock::new(); + +fn changes() -> &'static tokio::sync::broadcast::Sender { + CHANGES.get_or_init(|| tokio::sync::broadcast::channel(1024).0) +} + +/// How many changes have been published, standing in for a write position. +static CHANGE_POSITION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +/// The current write position, as `IDENTIFY_SYSTEM` reports it. +#[must_use] +pub fn latest_change_position() -> u64 { + CHANGE_POSITION.load(std::sync::atomic::Ordering::Relaxed) +} + +/// Changes written but not yet flushed to the durable log. +/// +/// Publishing happens on the write path, which is synchronous; the log write +/// is async, so records queue here and a flush drains them. +static PENDING_LOG: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +fn pending_log() -> &'static std::sync::Mutex> { + PENDING_LOG.get_or_init(|| std::sync::Mutex::new(Vec::new())) +} + +/// Take everything waiting to be logged. +#[must_use] +pub fn drain_pending_log() -> Vec { + pending_log() + .lock() + .map(|mut pending| std::mem::take(&mut *pending)) + .unwrap_or_default() +} + +/// Subscribe to the change stream. +#[must_use] +pub fn subscribe_to_changes() -> tokio::sync::broadcast::Receiver { + changes().subscribe() +} + +/// Publish a change. Does nothing when nobody is listening. +pub fn publish_change( + action: &str, + table: &str, + row: &std::collections::HashMap, +) { + // Recorded even with nobody listening: a replica that connects later and + // asks to replay from a position needs the history to be complete. + let sender = changes(); + let visible: std::collections::BTreeMap<&String, &JsonValue> = row + .iter() + .filter(|(name, _)| *name != TRANSACTION_STAMP && *name != DELETED_BY) + .collect(); + let position = CHANGE_POSITION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + let record = ChangeRecord { + position, + transaction: current_transaction_stamp().unwrap_or(0), + action: action.to_string(), + table: table.to_string(), + row: serde_json::to_string(&visible).unwrap_or_else(|_| "{}".to_string()), + }; + + if let Ok(mut history) = history().lock() { + history.push_back(record.clone()); + while history.len() > HISTORY_DEPTH { + history.pop_front(); + } + } + if let Ok(mut pending) = pending_log().lock() { + pending.push(record.clone()); + } + let _ = sender.send(record); +} + +/// The column each row is stamped with while its writing transaction is open. +/// +/// Hidden from every projection: it is part of the row's bookkeeping, not of +/// the table. A row carrying an open transaction's id is invisible to every +/// other session, which is what stops uncommitted work from being read. +pub const TRANSACTION_STAMP: &str = "__orbit_txn"; + +/// The column marking a row deleted by a transaction that has not committed. +/// +/// A delete inside a block cannot remove the row outright: other sessions must +/// go on seeing it until the block commits, and a rollback has to put it back. +pub const DELETED_BY: &str = "__orbit_deleted_by"; + +/// What the statement now running may see. +#[derive(Debug, Clone)] +pub struct TransactionContext { + /// The transaction the statement belongs to. + pub id: u64, + /// Transactions that were open when this one began. + /// + /// Present only under `REPEATABLE READ` and `SERIALIZABLE`: it is what + /// makes a repeated read give the same answer, by judging a row against + /// the moment the block started rather than against now. + pub snapshot: Option>, + /// What this transaction has read, recorded only under `SERIALIZABLE`. + /// + /// Entries are `table` for a whole-table read and `table\u{1}key` for an + /// individual row, so a conflict can be judged against the rows a block + /// actually looked at rather than against everything in the table. + pub reads: Option>>>, +} + +tokio::task_local! { + /// The transaction the statement currently running belongs to. + /// + /// A connection is a task, so this is per session without threading an id + /// through every call. + static CURRENT_TRANSACTION: TransactionContext; +} + +/// Transaction ids whose writes have not been committed yet. +static OPEN_TRANSACTIONS: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +fn open_transactions() -> &'static std::sync::RwLock> { + OPEN_TRANSACTIONS.get_or_init(|| std::sync::RwLock::new(std::collections::HashSet::new())) +} + +/// Start a transaction and return its id. +#[must_use] +pub fn begin_transaction(snapshot_isolation: bool) -> TransactionContext { + begin_transaction_at(snapshot_isolation, false) +} + +/// Start a transaction, optionally recording what it reads. +#[must_use] +pub fn begin_transaction_at(snapshot_isolation: bool, serializable: bool) -> TransactionContext { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(1); + + let id = NEXT.fetch_add(1, Ordering::Relaxed); + // The snapshot is taken before this transaction joins the open set, so it + // records who was already running. + let snapshot = snapshot_isolation.then(|| { + open_transactions() + .read() + .map(|open| open.clone()) + .unwrap_or_default() + }); + if let Ok(mut open) = open_transactions().write() { + open.insert(id); + } + TransactionContext { + id, + snapshot, + reads: serializable.then(|| Arc::new(std::sync::Mutex::new(Default::default()))), + } +} + +/// Mark a transaction finished, making its rows visible to everyone. +pub fn end_transaction(id: u64) { + if let Ok(mut open) = open_transactions().write() { + open.remove(&id); + } +} + +/// Note that the statement now running read `table`. +pub fn note_read(table: &str) { + note_read_entry(table.to_string()); +} + +/// Note that the statement read one particular row. +/// +/// Recording the row rather than the table is what keeps a serializable block +/// from failing because something unrelated in the same table moved. +pub fn note_read_row(table: &str, key: &str) { + note_read_entry(format!("{table}\u{1}{key}")); +} + +fn note_read_entry(entry: String) { + let _ = CURRENT_TRANSACTION.try_with(|current| { + if let Some(reads) = current.reads.as_ref() { + if let Ok(mut reads) = reads.lock() { + reads.insert(entry); + } + } + }); +} + +/// Note the predicate a statement read a table through. +/// +/// Stored as the conditions themselves so a row can be tested against it later; +/// an empty predicate means the whole table, which is already recorded as such. +pub fn note_read_predicate(table: &str, conditions: &[QueryCondition]) { + if conditions.is_empty() { + note_read(table); + return; + } + let rendered: Vec = conditions + .iter() + .map(|condition| { + format!( + "{}\u{2}{}\u{2}{}", + condition.column, condition.operator, condition.value + ) + }) + .collect(); + note_read_entry(format!("{table}\u{3}{}", rendered.join("\u{4}"))); +} + +/// Whether a serializable block is recording what it reads. +#[must_use] +pub fn records_reads() -> bool { + CURRENT_TRANSACTION + .try_with(|current| current.reads.is_some()) + .unwrap_or(false) +} + +/// The identity of a row for conflict detection: its key columns, or all of +/// its values when the table has none. +#[must_use] +pub fn row_identity( + values: &std::collections::HashMap, + key_columns: &[String], +) -> String { + key_columns + .iter() + .map(|column| { + values + .iter() + .find(|(name, _)| fold_identifier(name) == *column) + .map_or_else(|| "null".to_string(), |(_, value)| value.to_string()) + }) + .collect::>() + .join(",") +} + +/// The tables the statement's transaction has read, if it is serializable. +#[must_use] +pub fn tables_read() -> Vec { + CURRENT_TRANSACTION + .try_with(|current| { + current + .reads + .as_ref() + .and_then(|reads| { + reads + .lock() + .ok() + .map(|reads| reads.iter().cloned().collect()) + }) + .unwrap_or_default() + }) + .unwrap_or_default() +} + +/// The id to stamp a write with, allocating one for a statement that is not +/// inside a block. +/// +/// An autocommit statement is its own transaction: without an id of its own its +/// rows carry no stamp, and a reader holding an older snapshot cannot tell they +/// arrived after it began. +#[must_use] +pub fn stamp_for_write() -> u64 { + if let Some(id) = current_transaction_stamp() { + return id; + } + // Allocated and closed at once: the row is visible to everyone from now, + // and to no snapshot taken before this moment. + let context = begin_transaction(false); + end_transaction(context.id); + context.id +} + +/// Run a statement as part of `transaction`. +pub async fn within_transaction(transaction: TransactionContext, future: F) -> T +where + F: std::future::Future, +{ + CURRENT_TRANSACTION.scope(transaction, future).await +} + +/// Whether a stored row is visible to the statement now running. +/// +/// A row stamped by a transaction that is still open belongs to that session +/// alone; everyone else must not see it until it commits. +#[must_use] +pub fn row_is_visible(values: &std::collections::HashMap) -> bool { + let context = CURRENT_TRANSACTION.try_with(Clone::clone).ok(); + let mine = |id: u64| context.as_ref().is_some_and(|current| current.id == id); + + // Under a snapshot, "still running" means "was running when I began", so + // a transaction that commits mid-flight stays invisible for the rest of + // this block. Without one, it means running right now, which is what + // read-committed reports. + let still_open = |id: u64| match context.as_ref().and_then(|c| c.snapshot.as_ref()) { + Some(snapshot) => { + snapshot.contains(&id) || context.as_ref().is_some_and(|current| id > current.id) + } + None => open_transactions() + .read() + .map(|open| open.contains(&id)) + .unwrap_or(false), + }; + + // A row deleted by this session is gone as far as it is concerned; one + // deleted by a block that has not committed is still there for everyone + // else. Once that block ends, the row is gone for good. + if let Some(deleter) = values.get(DELETED_BY).and_then(JsonValue::as_u64) { + if mine(deleter) || !still_open(deleter) { + return false; + } + } + + // A row written by a block that has not committed belongs to it alone. + let Some(stamp) = values.get(TRANSACTION_STAMP).and_then(JsonValue::as_u64) else { + return true; + }; + mine(stamp) || !still_open(stamp) +} + +/// The stamp to write onto a row, if this statement is inside a transaction. +#[must_use] +pub fn current_transaction_stamp() -> Option { + CURRENT_TRANSACTION.try_with(|current| current.id).ok() +} + +/// A trigger as it was declared. +#[derive(Debug, Clone)] +pub struct TriggerDefinition { + /// Whether it fires once per affected row rather than once per statement. + pub per_row: bool, + /// The `WHEN` predicate, if it has one. + pub when: Option, + /// The statement it runs. + pub action: String, +} + +/// Remove duplicate rows, preserving first-seen order. +/// +/// `UNION` (without `ALL`) deduplicates; `SqlValue` is not hashable, so this +/// compares rather than hashes. Result sets combined by a set operation are +/// small enough that the quadratic scan is not the cost worth optimising. +fn deduplicate_rows(rows: Vec>) -> Vec> { + let mut seen: Vec> = Vec::with_capacity(rows.len()); + for row in rows { + if !seen.contains(&row) { + seen.push(row); + } + } + seen +} + /// Map a declared column type to the PostgreSQL type OID the wire advertises. pub fn column_type_oid(column_type: &ColumnType) -> i32 { use super::messages::type_oids; @@ -174,6 +679,9 @@ enum Statement { table: String, columns: Vec, if_not_exists: bool, + /// Table-level foreign keys, kept whole so a composite key stays one + /// constraint rather than becoming several per-column ones. + foreign_keys: Vec, }, DropTable { table: String, @@ -233,10 +741,7 @@ pub struct QueryEngine { impl QueryEngine { /// Create a new query engine pub fn new() -> Self { - println!("DEBUG: QueryEngine::new() called (NO STORAGE)"); - println!("Backtrace:\n{}", std::backtrace::Backtrace::capture()); - use std::io::Write; - std::io::stdout().flush().unwrap(); + tracing::debug!("query engine created without persistent storage"); Self { actors: Arc::new(RwLock::new(HashMap::new())), persistent_storage: None, @@ -249,9 +754,7 @@ impl QueryEngine { /// Create a new query engine with persistent storage pub fn new_with_persistent_storage(storage: Arc) -> Self { - println!("DEBUG: QueryEngine initialized with persistent storage"); - use std::io::Write; - std::io::stdout().flush().unwrap(); + tracing::debug!("query engine created with persistent storage"); Self { actors: Arc::new(RwLock::new(HashMap::new())), persistent_storage: Some(storage), @@ -345,9 +848,7 @@ impl QueryEngine { }; match statement { - Statement::Select { columns, table, .. } => { - self.describe_select(columns, &table).await - } + Statement::Select { columns, table, .. } => self.describe_select(columns, &table).await, // Everything else completes with a command tag and no result set. Statement::Insert { .. } | Statement::Update { .. } @@ -401,38 +902,6 @@ impl QueryEngine { out } - /// Column names a SELECT's projection asks for, or `["*"]` for all. - fn projection_names( - select: &crate::protocols::postgres_wire::sql::ast::SelectStatement, - ) -> Vec { - use crate::protocols::postgres_wire::sql::ast::{Expression, SelectItem}; - - let mut names = Vec::new(); - for item in &select.select_list { - match item { - SelectItem::Wildcard | SelectItem::QualifiedWildcard { .. } => { - return vec!["*".to_string()] - } - SelectItem::Expression { expr, alias } => { - let name = match (alias, expr) { - (Some(alias), _) => alias.clone(), - (None, Expression::Column(column)) => column.name.clone(), - // Anything that is not a plain column cannot be - // projected from a synthesised catalogue row. - (None, _) => return vec!["*".to_string()], - }; - names.push(name); - } - } - } - - if names.is_empty() { - vec!["*".to_string()] - } else { - names - } - } - /// Serve a query against a system catalogue relation. /// /// Returns `None` when `table` is not one, so ordinary tables fall through. @@ -458,12 +927,17 @@ impl QueryEngine { .next() .unwrap_or(table) .to_ascii_lowercase(); - let qualifier = table.rsplit_once('.').map(|(schema, _)| schema.to_ascii_lowercase()); + let qualifier = table + .rsplit_once('.') + .map(|(schema, _)| schema.to_ascii_lowercase()); let is_information_schema = qualifier.as_deref() == Some("information_schema"); // Only answer for the catalogue schemas, so a user table called // `pg_class` in the default schema is still their table. - if !matches!(qualifier.as_deref(), Some("pg_catalog") | Some("information_schema") | None) { + if !matches!( + qualifier.as_deref(), + Some("pg_catalog") | Some("information_schema") | None + ) { return Ok(None); } if qualifier.is_none() && !relation.starts_with("pg_") { @@ -503,7 +977,14 @@ impl QueryEngine { ], ), (false, "pg_type") => ( - vec!["oid", "typname", "typtype", "typelem", "typbasetype", "typrelid"], + vec![ + "oid", + "typname", + "typtype", + "typelem", + "typbasetype", + "typrelid", + ], [ (type_oids::BOOL, "bool"), (type_oids::BYTEA, "bytea"), @@ -627,6 +1108,239 @@ impl QueryEngine { Ok(()) } + /// The rows a write statement is about to change, before it runs. + /// + /// For `UPDATE` and `DELETE` these are the rows the predicate selects; for + /// `INSERT` there are none, because the rows do not exist yet. Undoing a + /// transaction from these — rather than from a copy of the whole table — + /// is what keeps one session's `ROLLBACK` off another session's rows. + /// + /// # Errors + /// Returns an error when the table cannot be read. + pub async fn rows_a_statement_will_change( + &self, + sql: &str, + ) -> ProtocolResult>> { + use crate::protocols::postgres_wire::sql::ast::Statement as AstStatement; + use crate::protocols::postgres_wire::sql::parser::SqlParser; + + let Some(storage) = &self.persistent_storage else { + return Ok(None); + }; + let Ok(statement) = SqlParser::new().parse(sql) else { + return Ok(None); + }; + + let (table, where_clause) = match statement { + AstStatement::Update(update) => (update.table.full_name(), update.where_clause), + AstStatement::Delete(delete) => (delete.table.full_name(), delete.where_clause), + // An insert changes no existing row; the rollback deletes what it + // added instead, which the caller records from the statement. + _ => return Ok(Some(Vec::new())), + }; + + let table = fold_identifier(&table); + if !storage.table_exists(&table).await? { + return Ok(None); + } + + let all = storage + .select_rows(&table, Vec::new(), Vec::new(), None) + .await?; + let Some(predicate) = where_clause else { + return Ok(Some(all)); + }; + + let Some(schema) = storage.get_table_schema(&table).await? else { + return Ok(None); + }; + let predicate = self.resolve_subqueries(predicate).await?; + + let mut evaluator = + crate::protocols::postgres_wire::sql::expression_evaluator::ExpressionEvaluator::new(); + let mut matched = Vec::new(); + for row in all { + let values: crate::protocols::postgres_wire::sql::select_pipeline::Row = schema + .columns + .iter() + .map(|column| { + let value = row + .values + .get(&column.name) + .cloned() + .unwrap_or(JsonValue::Null); + ( + fold_identifier(&column.name), + Self::json_to_sql_value(&value, &column.data_type), + ) + }) + .collect(); + let context = + crate::protocols::postgres_wire::sql::expression_evaluator::EvaluationContext::with_row(values); + if matches!( + evaluator.evaluate(&predicate, &context)?, + SqlValue::Boolean(true) + ) { + matched.push(row); + } + } + Ok(Some(matched)) + } + + /// Undo one session's writes without touching anyone else's rows. + /// + /// `inserted` are rows this session added, matched by value and removed; + /// `pre_images` are rows it changed or deleted, put back if they are no + /// longer there. Restoring a whole-table snapshot instead destroyed rows + /// another session had committed while the block was open. + /// + /// # Errors + /// Returns an error when the table cannot be written. + pub async fn undo_session_writes( + &self, + table: &str, + inserted: &[TableRow], + pre_images: &[TableRow], + ) -> ProtocolResult<()> { + let Some(storage) = &self.persistent_storage else { + return Ok(()); + }; + let table = fold_identifier(table); + + let conditions_for = |row: &TableRow| -> Vec { + row.values + .iter() + .filter(|(_, value)| !value.is_null()) + .map(|(column, value)| QueryCondition { + column: fold_identifier(column), + operator: "=".to_string(), + value: value.clone(), + }) + .collect() + }; + + // Remove what this session added. + for row in inserted { + let conditions = conditions_for(row); + if conditions.is_empty() { + continue; + } + storage.delete_rows(&table, conditions).await?; + } + + // Put back what it changed or removed, unless an identical row is + // already there. + for row in pre_images { + let conditions = conditions_for(row); + let present = if conditions.is_empty() { + Vec::new() + } else { + storage + .select_rows(&table, Vec::new(), conditions, Some(1)) + .await? + }; + if present.is_empty() { + storage.insert_row(&table, row.clone()).await?; + } + } + Ok(()) + } + + /// The rows an `INSERT ... VALUES` statement adds, as they will be stored. + /// + /// # Errors + /// Returns an error when a value cannot be evaluated. + pub async fn rows_an_insert_adds(&self, sql: &str) -> ProtocolResult>> { + use crate::protocols::postgres_wire::sql::ast::{InsertSource, Statement as AstStatement}; + use crate::protocols::postgres_wire::sql::expression_evaluator::{ + EvaluationContext, ExpressionEvaluator, + }; + use crate::protocols::postgres_wire::sql::parser::SqlParser; + + let Some(storage) = &self.persistent_storage else { + return Ok(None); + }; + let Ok(AstStatement::Insert(insert)) = SqlParser::new().parse(sql) else { + return Ok(None); + }; + let table = fold_identifier(&insert.table.full_name()); + // A view has no stored schema, and an `INSTEAD OF` trigger still needs + // the row the statement names; the statement's own column list answers + // that without one. + let schema = storage.get_table_schema(&table).await?; + let Some(names) = insert.columns.clone().or_else(|| { + schema + .as_ref() + .map(|schema| schema.columns.iter().map(|c| c.name.clone()).collect()) + }) else { + return Ok(None); + }; + let names: Vec = names.iter().map(|name| fold_identifier(name)).collect(); + + let stored_name = |name: &String| { + schema + .as_ref() + .and_then(|schema| { + schema + .columns + .iter() + .find(|column| fold_identifier(&column.name) == *name) + }) + .map_or_else(|| name.clone(), |column| column.name.clone()) + }; + + // `INSERT ... SELECT` adds whatever the select yields. Running it here + // predicts those rows so the undo log can name them; without that the + // statement fell back to a whole-table copy, whose rollback takes a + // concurrent session's rows with it. + let tuples = match insert.source { + InsertSource::Values(tuples) => tuples, + InsertSource::Query(query) => { + let Some((_, values)) = self.evaluate_select(&query, &HashMap::new()).await? else { + return Ok(None); + }; + let now = chrono::Utc::now(); + return Ok(Some( + values + .into_iter() + .map(|row| TableRow { + values: names + .iter() + .zip(row) + .map(|(name, value)| { + (stored_name(name), Self::sql_value_to_json(&value)) + }) + .collect(), + created_at: now, + updated_at: now, + }) + .collect(), + )); + } + InsertSource::DefaultValues => return Ok(None), + }; + + let mut evaluator = ExpressionEvaluator::new(); + let context = EvaluationContext::empty(); + let mut rows = Vec::with_capacity(tuples.len()); + for tuple in tuples { + let now = chrono::Utc::now(); + let mut values = std::collections::HashMap::new(); + for (name, expression) in names.iter().zip(&tuple) { + values.insert( + stored_name(name), + Self::sql_value_to_json(&evaluator.evaluate(expression, &context)?), + ); + } + rows.push(TableRow { + values, + created_at: now, + updated_at: now, + }); + } + Ok(Some(rows)) + } + /// The table a write statement targets, if it names one. /// /// Used to decide what to snapshot when a transaction block opens a write. @@ -719,18 +1433,21 @@ impl QueryEngine { let mut types = vec![type_oids::TEXT; count]; let neutralised = Self::placeholders_as_null(sql); - let Ok(statement) = self.parse_sql(&neutralised) else { - return Ok(types); - }; - - let (table, insert_columns) = match &statement { - Statement::Select { table, .. } - | Statement::Update { table, .. } - | Statement::Delete { table, .. } => (table.clone(), None), - Statement::Insert { table, columns, .. } => (table.clone(), Some(columns.clone())), - Statement::CreateTable { .. } - | Statement::DropTable { .. } - | Statement::Truncate { .. } => return Ok(types), + let (table, insert_columns) = match self.parse_sql(&neutralised) { + Ok(Statement::Select { table, .. }) + | Ok(Statement::Update { table, .. }) + | Ok(Statement::Delete { table, .. }) => (table, None), + Ok(Statement::Insert { table, columns, .. }) => (table, Some(columns)), + Ok(Statement::CreateTable { .. }) + | Ok(Statement::DropTable { .. }) + | Ok(Statement::Truncate { .. }) => return Ok(types), + // The simple parser rejects any clause it does not implement, so a + // `WHERE a = $1 AND b > $2` used to leave every parameter typed as + // text and the driver refused to send an integer for one. + Err(_) => match Self::table_of(&neutralised) { + Some(table) => (table, None), + None => return Ok(types), + }, }; let Some(storage) = &self.persistent_storage else { @@ -854,6 +1571,29 @@ impl QueryEngine { .collect() } + /// The table a statement reads or writes, taken from the full parser. + /// + /// Only single-table statements yield a name; a join has no single table + /// to resolve a column against. + fn table_of(sql: &str) -> Option { + use crate::protocols::postgres_wire::sql::ast::{FromClause, Statement as AstStatement}; + + let parsed = crate::protocols::postgres_wire::sql::parser::SqlParser::new() + .parse(sql) + .ok()?; + let name = match parsed { + AstStatement::Select(select) => match select.from_clause? { + FromClause::Table { name, .. } => name.full_name(), + _ => return None, + }, + AstStatement::Update(update) => update.table.full_name(), + AstStatement::Delete(delete) => delete.table.full_name(), + AstStatement::Insert(insert) => insert.table.full_name(), + _ => return None, + }; + Some(fold_identifier(&name)) + } + /// Test hook for [`QueryEngine::placeholder_comparisons`]. #[cfg(test)] pub fn placeholder_comparisons_for_test(sql: &str) -> Vec<(usize, String)> { @@ -908,11 +1648,7 @@ impl QueryEngine { }; let described_columns = if selects_everything { - schema - .columns - .iter() - .map(|c| described(&c.name)) - .collect() + schema.columns.iter().map(|c| described(&c.name)).collect() } else { columns.iter().map(|c| described(c)).collect() }; @@ -1030,8 +1766,6 @@ impl QueryEngine { } } - - /// Execute a SQL query and return results pub async fn execute_query(&self, sql: &str) -> ProtocolResult { let sql_upper = sql.trim().to_uppercase(); @@ -1054,6 +1788,44 @@ impl QueryEngine { } } + // DDL that changes a stored table's shape is applied here for the same + // reason views are: the comprehensive engine alters its own copy of the + // schema, so the change was reported and then not there. + if let Some(result) = self.execute_table_ddl(sql).await? { + return Ok(result); + } + + // Views are kept by this engine because the comprehensive engine + // registers them in its own state: `CREATE VIEW` reported success and + // the view was then not there. + if let Some(result) = self.execute_view_statement(sql).await? { + return Ok(result); + } + + // Triggers fire around the write they watch. They run as ordinary + // statements, so a BEFORE trigger that raises an error stops the write. + if let Some(result) = self.execute_with_triggers(sql).await? { + return Ok(result); + } + + self.execute_without_triggers(sql).await + } + + /// Execute a statement without firing triggers around it. + /// + /// The write a trigger wraps goes through here, so the wrapper does not + /// find its own triggers again. + /// + /// # Errors + /// Returns an error when the statement cannot be parsed or executed. + async fn execute_without_triggers(&self, sql: &str) -> ProtocolResult { + // `RETURNING` is answered here because the simple parser drops the + // clause and the write path has no select list: the statement applied + // and then reported no rows, which reads as "nothing matched". + if let Some(result) = self.execute_with_returning(sql).await? { + return Ok(result); + } + // Try to parse and execute with the simple parser // For unsupported statements, fall back to the comprehensive SQL engine let statement = match self.parse_sql(sql) { @@ -1087,6 +1859,14 @@ impl QueryEngine { if let Some(result) = self.select_system_catalog(&table, &columns).await? { return Ok(result); } + // A view is a query, not stored rows, so it is answered by the + // path that can evaluate one. Without this a clause-free + // `SELECT ... FROM view` reported that the relation is missing. + if self.view_definition(&table).await?.is_some() { + if let Some(result) = self.select_over_storage(sql).await? { + return Ok(result); + } + } if table.to_uppercase() == "ACTORS" { self.execute_actor_select(columns, &table, where_clause) .await @@ -1150,9 +1930,10 @@ impl QueryEngine { table, columns, if_not_exists, + foreign_keys, } => { if let Some(ref storage) = self.persistent_storage { - self.execute_create_table(storage, &table, columns, if_not_exists) + self.execute_create_table(storage, &table, columns, if_not_exists, foreign_keys) .await } else { Err(ProtocolError::PostgresError( @@ -1167,9 +1948,9 @@ impl QueryEngine { )), }, Statement::DropTable { table, if_exists } => { - println!( - "DEBUG: Executing DropTable. Storage present: {}", - self.persistent_storage.is_some() + tracing::debug!( + storage = self.persistent_storage.is_some(), + "executing DROP TABLE" ); if let Some(ref storage) = self.persistent_storage { self.execute_drop_table(storage, &table, if_exists).await @@ -1183,241 +1964,26 @@ impl QueryEngine { } /// Execute multiple SQL queries (separated by semicolons) + /// Execute the statements of a simple-query message, in order. + /// + /// Each statement goes through [`Self::execute_query`] rather than through + /// a second dispatcher over the parsed AST. Two routing tables drifted: + /// `RETURNING`, `TRUNCATE`, views, CTEs, derived tables and set operations + /// all worked when a statement arrived alone and failed when the same text + /// arrived over the wire, because only one of the two routers knew about + /// them. + /// + /// # Errors + /// Returns the first statement's error; statements after it do not run, + /// as PostgreSQL does within one simple-query message. pub async fn execute_multiple_queries(&self, sql: &str) -> ProtocolResult> { - use crate::protocols::postgres_wire::sql::parser::SqlParser; - - let mut parser = SqlParser::new(); - let statements = match parser.parse_multiple(sql) { - Ok(stmts) => stmts, - Err(e) => return Err(e), - }; - let mut results = Vec::new(); - - for stmt in statements { - let result = self.execute_ast_statement(stmt).await?; - results.push(result); + for statement in split_statements(sql) { + results.push(self.execute_query(&statement).await?); } - Ok(results) } - /// Execute a single AST statement - async fn execute_ast_statement( - &self, - stmt: crate::protocols::postgres_wire::sql::ast::Statement, - ) -> ProtocolResult { - use crate::protocols::postgres_wire::persistent_storage::ColumnType; - use crate::protocols::postgres_wire::sql::ast::Statement as AstStatement; - - // Catalogue relations are answered before storage is consulted, so - // both query paths — the simple parser and this AST one — serve them. - // psql and every ORM read these during connection setup. - if let AstStatement::Select(select) = &stmt { - if let Some(from_clause) = &select.from_clause { - if let Some(table_name) = self.extract_table_name_from_from_clause(from_clause) { - let projection = Self::projection_names(select); - if let Some(result) = - self.select_system_catalog(&table_name, &projection).await? - { - return Ok(result); - } - } - } - } - - // Check if we can execute this persistently - if let Some(ref storage) = self.persistent_storage { - match &stmt { - AstStatement::CreateTable(create) => { - // Convert AST columns to SimpleColumnDef - let mut simple_columns = Vec::new(); - for col in &create.columns { - let data_type = col.data_type.to_string(); - let mut constraints = Vec::new(); - for constraint in &col.constraints { - // Simplified constraint conversion - match constraint { - crate::protocols::postgres_wire::sql::ast::ColumnConstraint::PrimaryKey => constraints.push("PRIMARY KEY".to_string()), - crate::protocols::postgres_wire::sql::ast::ColumnConstraint::NotNull => constraints.push("NOT NULL".to_string()), - crate::protocols::postgres_wire::sql::ast::ColumnConstraint::Unique => constraints.push("UNIQUE".to_string()), - _ => {} - } - } - simple_columns.push(SimpleColumnDef { - name: col.name.clone(), - data_type, - constraints, - }); - } - - // Execute on persistent storage - let result = self - .execute_create_table( - storage, - &create.name.full_name(), - simple_columns, - create.if_not_exists, - ) - .await?; - - // Also execute on comprehensive engine so it knows about the table - let mut sql_engine = self.sql_engine.lock().await; - let _ = sql_engine.execute_statement(stmt).await; // Ignore errors from comprehensive engine - - return Ok(result); - } - AstStatement::DropTable(drop) => { - // Handle first table only for now - if let Some(table) = drop.names.first() { - let result = self - .execute_drop_table(storage, &table.full_name(), drop.if_exists) - .await?; - - // Also execute on comprehensive engine - let mut sql_engine = self.sql_engine.lock().await; - let _ = sql_engine.execute_statement(stmt).await; - - return Ok(result); - } - } - AstStatement::Insert(insert) => { - // Convert AST Insert to persistent insert arguments - let table_name = insert.table.full_name(); - let mut columns = insert.columns.clone().unwrap_or_default(); - - // Handle implicit columns (SELECT * FROM table style insert) - if columns.is_empty() { - if let Some(schema) = storage.get_table_schema(&table_name).await? { - columns = schema - .columns - .iter() - .filter(|c| !matches!(c.data_type, ColumnType::Serial)) - .map(|c| c.name.clone()) - .collect(); - } - } - - // Extract values - let mut values_list = Vec::new(); - if let crate::protocols::postgres_wire::sql::ast::InsertSource::Values(rows) = - &insert.source - { - for row in rows { - let mut row_values = Vec::new(); - for expr in row { - // Evaluate expression to string - // This is tricky without full evaluator context. - // For now, handle literals and simple functions - use crate::protocols::postgres_wire::sql::expression_evaluator::{ - EvaluationContext, ExpressionEvaluator, - }; - let mut evaluator = ExpressionEvaluator::new(); - let context = EvaluationContext::empty(); - let val = evaluator.evaluate(expr, &context)?; - // Rendered as a SQL literal, not as display - // text: `to_postgres_string` turns NULL into an - // empty string and a boolean into "t"/"f", - // which then stored as text rather than as the - // values they are. - row_values.push(Self::sql_value_to_literal(&val)); - } - values_list.push(row_values); - } - } - - // Execute on persistent storage - let result = self - .execute_persistent_insert(storage, &table_name, columns, values_list) - .await?; - - // Also execute on comprehensive engine - let mut sql_engine = self.sql_engine.lock().await; - let _ = sql_engine.execute_statement(stmt).await; - - return Ok(result); - } - AstStatement::Select(select) => { - // Check if the table exists in persistent storage - // If it does, we need to use persistent storage for the query - // Extract table name from FROM clause - if let Some(ref from_clause) = select.from_clause { - if let Some(table_name) = - self.extract_table_name_from_from_clause(from_clause) - { - // Check if table exists in persistent storage - if storage.table_exists(&table_name).await? { - // Table exists in persistent storage - // For complex queries (GROUP BY, aggregates, etc.), we need to: - // 1. Fetch all data from persistent storage - // 2. Execute the query logic in memory - - // For now, fetch all rows and let the comprehensive engine handle it - // but inject the data from persistent storage - - // This is a workaround: we'll fall through to the comprehensive engine - // but first we need to populate it with data from persistent storage - // Since that's complex, let's just handle simple SELECTs here - - // For complex queries, we'll need to enhance the comprehensive engine - // to support persistent storage as a data source - // For now, fall through to comprehensive engine - } - } - } - } - _ => {} - } - } - - // Fallback to comprehensive engine - let mut sql_engine = self.sql_engine.lock().await; - - // Before executing, check if this is a SELECT from a persistent table - // If so, we need to make sure the comprehensive engine has the data - if let AstStatement::Select(select) = &stmt { - if let Some(ref storage) = self.persistent_storage { - if let Some(ref from_clause) = select.from_clause { - if let Some(table_name) = self.extract_table_name_from_from_clause(from_clause) - { - if storage.table_exists(&table_name).await? { - // Table exists in persistent storage - // We need to ensure the comprehensive engine has this table and data - // This is a workaround until we have full integration - - // For now, execute the query directly on persistent storage data - // by creating a temporary in-memory representation - // This is not ideal but will work for the test - - // Actually, let's just execute the statement and let it fail - // The comprehensive engine will report "table does not exist" - // which is the current behavior - } - } - } - } - } - - let unified_result = sql_engine.execute_statement(stmt).await?; - Ok(self.convert_sql_result_to_query_result(unified_result)) - } - - /// Extract table name from FROM clause - fn extract_table_name_from_from_clause( - &self, - from_clause: &crate::protocols::postgres_wire::sql::ast::FromClause, - ) -> Option { - use crate::protocols::postgres_wire::sql::ast::FromClause; - match from_clause { - FromClause::Table { name, .. } => Some(name.full_name()), - FromClause::Join { left, .. } => { - // For joins, extract from the left side - self.extract_table_name_from_from_clause(left) - } - _ => None, - } - } - /// Execute a query using the comprehensive SQL engine async fn execute_with_comprehensive_engine(&self, sql: &str) -> ProtocolResult { let mut sql_engine = self.sql_engine.lock().await; @@ -1535,1039 +2101,4460 @@ impl QueryEngine { } } - /// Run a `SELECT` over the rows held in persistent storage. + /// `CREATE TABLE ... AS SELECT` and `ALTER TABLE ... DROP COLUMN`. /// - /// Returns `None` when the statement is not a single-table SELECT of a - /// stored table, leaving it to the engine that can handle it. + /// Returns `None` when the statement is neither. /// /// # Errors - /// Returns an error when storage cannot be read or an expression cannot be - /// evaluated. - async fn select_over_storage(&self, sql: &str) -> ProtocolResult> { - use crate::protocols::postgres_wire::sql::ast::Statement as AstStatement; - use crate::protocols::postgres_wire::sql::parser::SqlParser; - use crate::protocols::postgres_wire::sql::select_pipeline; + /// Returns an error when the table is unknown or the select cannot be run. + async fn execute_table_ddl(&self, sql: &str) -> ProtocolResult> { + use crate::protocols::postgres_wire::persistent_storage::{ + ColumnDefinition, ColumnType, TableSchema, + }; - if self.persistent_storage.is_none() { + let Some(storage) = self.persistent_storage.as_ref() else { return Ok(None); + }; + let trimmed = sql.trim().trim_end_matches(';').trim(); + let upper = trimmed.to_uppercase(); + + // `CREATE TABLE AS
+ // [FOR EACH ROW] EXECUTE ` + // + // The action is a SQL statement rather than a stored function: this + // engine has no PL/pgSQL, and running a statement is the part of a + // trigger that changes what the database does. + if upper.starts_with("CREATE TRIGGER") { + let Some(execute_at) = upper.find(" EXECUTE ") else { + return Err(ProtocolError::PostgresError( + "CREATE TRIGGER requires EXECUTE followed by a statement".to_string(), + )); + }; + let header = &trimmed[..execute_at]; + let action = trimmed[execute_at + " EXECUTE ".len()..] + .trim() + .trim_start_matches("FUNCTION ") + .trim_start_matches("PROCEDURE ") + .trim(); + let header_upper = header.to_uppercase(); + + let name = header + .split_whitespace() + .nth(2) + .map(fold_identifier) + .ok_or_else(|| { + ProtocolError::PostgresError("CREATE TRIGGER requires a name".to_string()) + })?; + let table = header_upper + .rfind(" ON ") + .and_then(|at| header[at + 4..].split_whitespace().next()) + .map(fold_identifier) + .ok_or_else(|| { + ProtocolError::PostgresError("CREATE TRIGGER requires ON
".to_string()) + })?; + + let timing = if header_upper.contains("INSTEAD OF") { + "INSTEAD" + } else if header_upper.contains("BEFORE") { + "BEFORE" + } else { + "AFTER" + }; + let events: Vec<&str> = ["INSERT", "UPDATE", "DELETE"] + .into_iter() + .filter(|event| header_upper.contains(event)) + .collect(); + if events.is_empty() { + return Err(ProtocolError::PostgresError( + "CREATE TRIGGER requires at least one of INSERT, UPDATE or DELETE".to_string(), + )); + } + + // `FOR EACH ROW` fires once per affected row and can read that + // row through `NEW`/`OLD`; `FOR EACH STATEMENT` — the default — + // fires once however many rows the statement touched. + let scope = if header_upper.contains("FOR EACH ROW") { + "ROW" + } else { + "STATEMENT" + }; + // `WHEN (...)` gates the firing; it is stored with its brackets + // stripped and evaluated per row. + let when = header_upper + .find("WHEN") + .and_then(|at| Self::parenthesised(&header[at..])) + .unwrap_or_default() + .to_string(); + + self.remember_trigger( + &name, + &format!( + "{table}|{timing}|{}|{scope}|{when}|{action}", + events.join(",") + ), + ) + .await?; + return Ok(Some(QueryResult::Update { count: 0 })); + } + + if upper.starts_with("DROP TRIGGER") { + let if_exists = upper.contains("IF EXISTS"); + let name = fold_identifier(upper_tail(trimmed, if_exists)); + self.ensure_view_catalog(storage).await?; + let removed = storage + .delete_rows( + VIEW_CATALOG, + vec![QueryCondition { + column: "name".to_string(), + operator: "=".to_string(), + value: JsonValue::String(format!("trigger:{name}")), + }], + ) + .await?; + if removed == 0 && !if_exists { + return Err(ProtocolError::PostgresError(format!( + "trigger \"{name}\" does not exist" + ))); + } + return Ok(Some(QueryResult::Update { count: 0 })); + } + + // `CREATE DOMAIN [AS] [NOT NULL] [CHECK (VALUE ...)]`: + // a named type with constraints attached, which columns then use. + if upper.starts_with("CREATE DOMAIN") { + let rest = trimmed["CREATE DOMAIN".len()..].trim(); + let (name, definition) = rest.split_once(char::is_whitespace).ok_or_else(|| { + ProtocolError::PostgresError("CREATE DOMAIN requires a type".to_string()) + })?; + let definition = definition + .trim() + .strip_prefix("AS ") + .or_else(|| definition.trim().strip_prefix("as ")) + .unwrap_or(definition.trim()); + self.remember_domain(&fold_identifier(name), definition.trim()) + .await?; + return Ok(Some(QueryResult::Update { count: 0 })); + } + + // `ALTER DOMAIN {SET|DROP} NOT NULL | ADD CHECK (...) | DROP CONSTRAINT` + if upper.starts_with("ALTER DOMAIN") { + let rest = trimmed["ALTER DOMAIN".len()..].trim(); + let (name, action) = rest.split_once(char::is_whitespace).ok_or_else(|| { + ProtocolError::PostgresError("ALTER DOMAIN requires an action".to_string()) + })?; + let name = fold_identifier(name); + let current = self.domain_definition(&name).await?.ok_or_else(|| { + ProtocolError::PostgresError(format!("domain \"{name}\" does not exist")) + })?; + let action_upper = action.trim().to_uppercase(); + + let updated = if action_upper.starts_with("SET NOT NULL") { + format!("{current} NOT NULL") + } else if action_upper.starts_with("DROP NOT NULL") { + current.replace(" NOT NULL", "") + } else if action_upper.starts_with("ADD") && action_upper.contains("CHECK") { + match Self::parenthesised(action) { + Some(predicate) => format!("{current} CHECK ({predicate})"), + None => current.clone(), + } + } else if action_upper.starts_with("DROP CONSTRAINT") { + // Every check on the domain goes; named constraints are not + // tracked separately. + match current.find(" CHECK (") { + Some(at) => current[..at].to_string(), + None => current.clone(), + } + } else { + return Err(ProtocolError::PostgresError(format!( + "unsupported ALTER DOMAIN action: {}", + action.trim() + ))); + }; + + self.remember_domain(&name, updated.trim()).await?; + return Ok(Some(QueryResult::Update { count: 0 })); + } + + if upper.starts_with("DROP DOMAIN") { + let if_exists = upper.contains("IF EXISTS"); + let name = fold_identifier(upper_tail(trimmed, if_exists)); + self.ensure_view_catalog(storage).await?; + let removed = storage + .delete_rows( + VIEW_CATALOG, + vec![QueryCondition { + column: "name".to_string(), + operator: "=".to_string(), + value: JsonValue::String(format!("domain:{name}")), + }], + ) + .await?; + if removed == 0 && !if_exists { + return Err(ProtocolError::PostgresError(format!( + "domain \"{name}\" does not exist" + ))); + } + return Ok(Some(QueryResult::Update { count: 0 })); + } + + // `CREATE MATERIALIZED VIEW AS
@@ -2844,6 +3417,16 @@ impl QueryEngine { /// # Errors /// Returns an error when a statement fails, or when `RAISE` is reached. async fn run_trigger_body(&self, body: &str) -> ProtocolResult<()> { + // A body with a variable, a branch or a loop in it goes to the + // interpreter; one that is only SQL statements keeps the simpler path, + // which is what almost every trigger is. + if plpgsql::needs_interpreter(body) { + let block = plpgsql::parse(body)?; + return Box::pin(plpgsql::execute(&block, self, HashMap::new())) + .await + .map(|_| ()); + } + let trimmed = body.trim().trim_end_matches(';').trim(); // A dollar-quoted body is unwrapped here; the quoting exists to carry // the semicolons through the statement splitter, not to be executed. @@ -3045,6 +3628,107 @@ impl QueryEngine { Ok(Some(QueryResult::Update { count: 0 })) } + /// Publish the end of a transaction, so a subscriber can close its + /// `Begin`/`Commit` pair around everything the block wrote. + pub fn publish_transaction_end(transaction: u64) { + publish_marker("COMMIT", transaction); + } + + /// Drop logged changes every slot has confirmed. + /// + /// Without this the log is a table that only grows. The bound is the + /// slowest slot's confirmed position: anything before it has been read by + /// everyone who asked, so nothing can still need it. + /// + /// # Errors + /// Returns an error when the log or the catalog cannot be read or written. + pub async fn truncate_change_log(&self) -> ProtocolResult { + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(0); + }; + if !storage.table_exists(CHANGE_LOG).await? { + return Ok(0); + } + + // The slowest slot decides. With no slots at all nothing is + // subscribed, so the whole log is spent. + let mut bound = latest_change_position(); + let mut any_slot = false; + let mut invalidated: Vec = Vec::new(); + if storage.table_exists(VIEW_CATALOG).await? { + for row in storage + .select_rows(VIEW_CATALOG, Vec::new(), Vec::new(), None) + .await? + { + let Some(name) = row.values.get("name").and_then(JsonValue::as_str) else { + continue; + }; + if !name.starts_with("slot:") { + continue; + } + let confirmed = row + .values + .get("definition") + .and_then(JsonValue::as_str) + .and_then(|definition| definition.split_once('|')) + .and_then(|(_, position)| position.parse::().ok()) + .unwrap_or(0); + + // A slot that has fallen further behind than the log is + // allowed to grow is invalidated, as PostgreSQL does past + // `max_slot_wal_keep_size`. Keeping it would let one dead + // subscriber hold the log open for ever. + if latest_change_position().saturating_sub(confirmed) > max_slot_backlog() { + tracing::warn!( + slot = name.trim_start_matches("slot:"), + confirmed, + "invalidating a replication slot that has fallen too far behind" + ); + invalidated.push(name.to_string()); + continue; + } + + any_slot = true; + bound = bound.min(confirmed); + } + } + if !invalidated.is_empty() { + forget_slot_cache(); + } + for name in invalidated { + storage + .delete_rows( + VIEW_CATALOG, + vec![QueryCondition { + column: "name".to_string(), + operator: "=".to_string(), + value: JsonValue::String(name), + }], + ) + .await?; + } + + if any_slot && bound == 0 { + return Ok(0); + } + + // One conditional delete, not one per row: deleting each position + // individually rescanned the log every time, so trimming a log of any + // size took quadratic work and timed out well before it finished. + let removed = storage + .delete_rows( + CHANGE_LOG, + vec![QueryCondition { + column: "last".to_string(), + operator: "<=".to_string(), + value: JsonValue::from(bound), + }], + ) + .await? + .max(0) as usize; + Ok(removed) + } + /// Continue the change stream where the last run left off. /// /// Positions are handed out from a counter that starts at one, so without @@ -3074,38 +3758,94 @@ impl QueryEngine { /// # Errors /// Returns an error when the log cannot be written. pub async fn flush_change_log(&self) -> ProtocolResult<()> { - for record in drain_pending_log() { - self.record_change(&record).await?; + // With no slot, nothing can ever ask to replay, so the log would be + // written and never read — doubling every write for no one. The + // pending queue is drained either way so it cannot grow. + let pending = drain_pending_log(); + if !self.any_replication_slot().await? { + return Ok(()); } - Ok(()) + if pending.is_empty() { + return Ok(()); + } + // One row per flush rather than per change: a statement writing a + // thousand rows produced a thousand log rows, each of which the replay + // scan then had to read. + self.record_changes(&pending).await } - /// Write a change to the durable log a replica replays from. + /// Whether any replication slot exists. /// - /// The in-memory window is a cache in front of this: it answers the common - /// case without a read, and the log answers a replica that reconnects - /// after a restart, when the window is empty. - async fn record_change(&self, record: &ChangeRecord) -> ProtocolResult<()> { - let Some(storage) = self.persistent_storage.as_ref() else { + /// Cached: this is on the write path, and a catalog read per write would + /// cost more than the log row it saves. + async fn any_replication_slot(&self) -> ProtocolResult { + use std::sync::atomic::Ordering; + + match SLOT_CACHE.load(Ordering::Relaxed) { + 1 => return Ok(false), + 2 => return Ok(true), + _ => {} + } + + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(false); + }; + let any = storage.table_exists(VIEW_CATALOG).await? + && storage + .select_rows(VIEW_CATALOG, Vec::new(), Vec::new(), None) + .await? + .iter() + .any(|row| { + row.values + .get("name") + .and_then(JsonValue::as_str) + .is_some_and(|name| name.starts_with("slot:")) + }); + SLOT_CACHE.store(if any { 2 } else { 1 }, Ordering::Relaxed); + Ok(any) + } + + /// Write a change to the durable log a replica replays from. + /// + /// The in-memory window is a cache in front of this: it answers the common + /// case without a read, and the log answers a replica that reconnects + /// after a restart, when the window is empty. + async fn record_changes(&self, records: &[ChangeRecord]) -> ProtocolResult<()> { + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(()); + }; + let Some(first) = records.first() else { return Ok(()); }; self.ensure_change_log(storage).await?; + + // The batch is keyed by its first position, and trimming compares + // against the last one, so a batch is dropped only once every change + // in it has been confirmed. + let payload: Vec = records + .iter() + .map(|record| { + format!( + "{}\u{1}{}\u{1}{}\u{1}{}\u{1}{}", + record.position, record.transaction, record.action, record.table, record.row + ) + }) + .collect(); + let now = chrono::Utc::now(); storage .insert_row( CHANGE_LOG, TableRow { values: HashMap::from([ + ("position".to_string(), JsonValue::from(first.position)), ( - "position".to_string(), - JsonValue::from(record.position), + "last".to_string(), + JsonValue::from(records.last().map_or(first.position, |r| r.position)), ), ( "payload".to_string(), - JsonValue::String(format!( - "{}|{}|{}|{}", - record.transaction, record.action, record.table, record.row - )), + JsonValue::String(payload.join("\u{2}")), ), ]), created_at: now, @@ -3142,6 +3882,16 @@ impl QueryEngine { references: None, domain: None, }, + ColumnDefinition { + name: "last".to_string(), + data_type: ColumnType::BigInt, + nullable: false, + default_value: None, + unique: false, + check: None, + references: None, + domain: None, + }, ColumnDefinition { name: "payload".to_string(), data_type: ColumnType::Text, @@ -3172,25 +3922,39 @@ impl QueryEngine { return Ok(Vec::new()); } + // The predicate goes to storage rather than being applied after: a log + // trimmed to the slot backlog is still large, and building a TableRow + // for every batch only to drop it is work the storage layer can skip. let mut records: Vec = storage - .select_rows(CHANGE_LOG, Vec::new(), Vec::new(), None) + .select_rows( + CHANGE_LOG, + Vec::new(), + vec![QueryCondition { + column: "last".to_string(), + operator: ">".to_string(), + value: JsonValue::from(position), + }], + None, + ) .await? .into_iter() - .filter_map(|row| { - let at = row.values.get("position")?.as_u64()?; - if at <= position { - return None; - } - let payload = row.values.get("payload")?.as_str()?; - let mut parts = payload.splitn(4, '|'); - Some(ChangeRecord { - position: at, - transaction: parts.next()?.parse().ok()?, - action: parts.next()?.to_string(), - table: parts.next()?.to_string(), - row: parts.next()?.to_string(), - }) + .filter_map(|row| Some(row.values.get("payload")?.as_str()?.to_string())) + .flat_map(|payload| { + payload + .split('\u{2}') + .filter_map(|entry| { + let mut parts = entry.splitn(5, '\u{1}'); + Some(ChangeRecord { + position: parts.next()?.parse().ok()?, + transaction: parts.next()?.parse().ok()?, + action: parts.next()?.to_string(), + table: parts.next()?.to_string(), + row: parts.next()?.to_string(), + }) + }) + .collect::>() }) + .filter(|record| record.position > position) .collect(); records.sort_by_key(|record| record.position); Ok(records) @@ -3201,6 +3965,7 @@ impl QueryEngine { /// # Errors /// Returns an error when the catalog cannot be written. pub async fn create_replication_slot(&self, name: &str, plugin: &str) -> ProtocolResult<()> { + forget_slot_cache(); let Some(storage) = self.persistent_storage.as_ref() else { return Ok(()); }; @@ -3238,72 +4003,881 @@ impl QueryEngine { .map(|_| ()) } - /// A slot's plugin and confirmed position, if it exists. + /// A slot's plugin and confirmed position, if it exists. + /// + /// # Errors + /// Returns an error when the catalog cannot be read. + pub async fn replication_slot(&self, name: &str) -> ProtocolResult> { + Ok(self + .view_definition(&format!("slot:{name}")) + .await? + .and_then(|definition| { + let (plugin, position) = definition.split_once('|')?; + Some((plugin.to_string(), position.parse().ok()?)) + })) + } + + /// Record how far a replica has confirmed, so a restart resumes there. + /// + /// # Errors + /// Returns an error when the catalog cannot be written. + pub async fn confirm_replication_slot(&self, name: &str, position: u64) -> ProtocolResult<()> { + let Some((plugin, _)) = self.replication_slot(name).await? else { + return Ok(()); + }; + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(()); + }; + storage + .update_rows( + VIEW_CATALOG, + HashMap::from([( + "definition".to_string(), + JsonValue::String(format!("{plugin}|{position}")), + )]), + vec![QueryCondition { + column: "name".to_string(), + operator: "=".to_string(), + value: JsonValue::String(format!("slot:{name}")), + }], + ) + .await + .map(|_| ()) + } + + /// Forget a replication slot. + /// + /// # Errors + /// Returns an error when the catalog cannot be written. + pub async fn drop_replication_slot(&self, name: &str) -> ProtocolResult<()> { + forget_slot_cache(); + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(()); + }; + self.ensure_view_catalog(storage).await?; + storage + .delete_rows( + VIEW_CATALOG, + vec![QueryCondition { + column: "name".to_string(), + operator: "=".to_string(), + value: JsonValue::String(format!("slot:{name}")), + }], + ) + .await + .map(|_| ()) + } + + /// Record a trigger's table, timing, events and action. + async fn remember_trigger(&self, name: &str, definition: &str) -> ProtocolResult<()> { + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(()); + }; + self.ensure_view_catalog(storage).await?; + let now = chrono::Utc::now(); + storage + .insert_row( + VIEW_CATALOG, + TableRow { + values: HashMap::from([ + ( + "name".to_string(), + JsonValue::String(format!("trigger:{name}")), + ), + ( + "definition".to_string(), + JsonValue::String(definition.to_string()), + ), + ]), + created_at: now, + updated_at: now, + }, + ) + .await + .map(|_| ()) + } + + /// The actions of every trigger on `table` firing at `timing` for `event`. + /// + /// # Errors + /// Returns an error when the catalog cannot be read. + pub async fn triggers_for( + &self, + table: &str, + timing: &str, + event: &str, + ) -> ProtocolResult> { + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(Vec::new()); + }; + if !storage.table_exists(VIEW_CATALOG).await? { + return Ok(Vec::new()); + } + + let rows = storage + .select_rows(VIEW_CATALOG, Vec::new(), Vec::new(), None) + .await?; + Ok(rows + .into_iter() + .filter(|row| { + row.values + .get("name") + .and_then(JsonValue::as_str) + .is_some_and(|name| name.starts_with("trigger:")) + }) + .filter_map(|row| { + let definition = row.values.get("definition")?.as_str()?.to_string(); + let mut parts = definition.splitn(6, '|'); + let on = parts.next()?; + let at = parts.next()?; + let events = parts.next()?; + let scope = parts.next()?; + let when = parts.next()?; + let action = parts.next()?; + (on == fold_identifier(table) + && at == timing + && events.split(',').any(|e| e == event)) + .then(|| TriggerDefinition { + per_row: scope == "ROW", + when: (!when.is_empty()).then(|| when.to_string()), + action: action.to_string(), + }) + }) + .collect()) + } + + /// Run a `DO` block, define a PL/pgSQL function, or call one. + /// + /// Returns `None` when `sql` is none of those, so the caller carries on. + /// + /// # Errors + /// Returns an error when the block will not parse, a statement inside it + /// fails, or a `RAISE EXCEPTION` fires. + async fn execute_plpgsql(&self, sql: &str) -> ProtocolResult> { + let trimmed = sql.trim().trim_end_matches(';').trim(); + let upper = trimmed.to_uppercase(); + + if upper.starts_with("DO ") || upper == "DO" { + let body = trimmed[2..].trim(); + // `DO ... LANGUAGE plpgsql` is the same block with the language + // named after it rather than before. + let body = match body.to_uppercase().rfind("LANGUAGE") { + Some(at) if body[at..].to_uppercase().contains("PLPGSQL") => body[..at].trim(), + _ => body, + }; + let block = plpgsql::parse(body)?; + Box::pin(self.run_block_atomically(&block, HashMap::new())).await?; + return Ok(Some(QueryResult::Set { + variable: "DO".to_string(), + value: String::new(), + })); + } + + // `CREATE TYPE name AS (field type, ...)` — a composite. Its fields are + // stored so a PL/pgSQL variable of the type can bring them into scope + // and so the type is its own type when choosing between overloads. + if upper.starts_with("CREATE TYPE") { + let rest = trimmed["CREATE TYPE".len()..].trim(); + let Some(as_at) = rest.to_uppercase().find(" AS ") else { + return Ok(None); + }; + let name = fold_identifier(rest[..as_at].trim()); + let body = rest[as_at + 4..].trim(); + let Some(fields) = body + .strip_prefix('(') + .and_then(|inner| inner.strip_suffix(')')) + else { + // `CREATE TYPE ... AS ENUM (...)` and the other forms are not + // composites; leaving them unhandled is better than storing + // something that claims to be one. + return Ok(None); + }; + let parsed = plpgsql_function::parse_parameters(fields); + if parsed.is_empty() { + return Err(ProtocolError::PostgresError( + "a composite type needs at least one field".to_string(), + )); + } + self.forget_catalog_entry(&format!("composite:{name}")) + .await?; + self.remember_domain( + &format!("__composite__{name}"), + &plpgsql_function::encode(&parsed), + ) + .await?; + self.rename_catalog_entry( + &format!("domain:__composite__{name}"), + &format!("composite:{name}"), + ) + .await?; + super::domains::forget(&format!("__composite__{name}")); + return Ok(Some(QueryResult::Set { + variable: "CREATE TYPE".to_string(), + value: String::new(), + })); + } + + if upper.starts_with("DROP TYPE") { + let name = fold_identifier( + trimmed["DROP TYPE".len()..] + .trim() + .trim_start_matches("IF EXISTS") + .trim(), + ); + if self.composite_fields(&name).await?.is_none() { + // Reporting success for a type that was never there is the + // silent no-op this document records elsewhere. + if upper.contains("IF EXISTS") { + return Ok(Some(QueryResult::Set { + variable: "DROP TYPE".to_string(), + value: String::new(), + })); + } + return Err(ProtocolError::SqlState { + code: "42704", + message: format!("type \"{name}\" does not exist"), + }); + } + self.forget_catalog_entry(&format!("composite:{name}")) + .await?; + return Ok(Some(QueryResult::Set { + variable: "DROP TYPE".to_string(), + value: String::new(), + })); + } + + if upper.starts_with("CREATE FUNCTION") || upper.starts_with("CREATE OR REPLACE FUNCTION") { + if !upper.contains("PLPGSQL") { + return Ok(None); + } + return self.define_plpgsql_function(trimmed).await.map(Some); + } + + if upper.starts_with("DROP FUNCTION") { + let name = trimmed + .split_whitespace() + .nth(2) + .map(|n| n.split('(').next().unwrap_or(n)) + .unwrap_or_default(); + let name = fold_identifier(name); + // Without argument types to name one, a bare `DROP FUNCTION f` + // removes every overload of `f`. + let keys = self.function_keys(&name).await?; + if keys.is_empty() { + return Ok(None); + } + for key in keys { + self.forget_catalog_entry(&key).await?; + } + super::stored_functions::forget(&name); + return Ok(Some(QueryResult::Set { + variable: "DROP FUNCTION".to_string(), + value: String::new(), + })); + } + + // `SELECT fname(args)` where `fname` is one of ours. + self.call_plpgsql_function(trimmed).await + } + + /// Run a block so that a failure leaves none of its writes behind. + /// + /// PostgreSQL runs a `DO` block and a function body inside a transaction: + /// a `RAISE EXCEPTION` after an `INSERT` leaves no row. Running the + /// statements directly left the `INSERT` committed and only reported the + /// error, which is a partial write reported as a failure — the worst of + /// both. + /// + /// Inside an open transaction this does nothing extra: the block joins the + /// transaction already running, and `ROLLBACK` undoes it along with + /// everything else. + /// + /// # Errors + /// Returns whatever the block failed with, after undoing its writes. + async fn run_block_atomically( + &self, + block: &plpgsql::Block, + scope: HashMap, + ) -> ProtocolResult { + if current_transaction_stamp().is_some() { + return plpgsql::execute(block, self, scope).await; + } + + let tables = Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())); + let nested = Arc::new(std::sync::Mutex::new(Vec::new())); + let context = begin_transaction(false); + let id = context.id; + + let outcome = BLOCK_TRANSACTIONS + .scope(Arc::clone(&nested), async { + BLOCK_TABLES + .scope( + Arc::clone(&tables), + within_transaction(context, plpgsql::execute(block, self, scope)), + ) + .await + }) + .await; + + if outcome.is_err() { + let written: Vec = tables + .lock() + .map(|tables| tables.iter().cloned().collect()) + .unwrap_or_default(); + // Undo before the id is retired: while it is still open, the rows + // it wrote are invisible to everyone else, so nobody can read a + // row that is about to be removed. + let mut ids = vec![id]; + if let Ok(nested) = nested.lock() { + ids.extend(nested.iter().copied()); + } + for id in ids { + self.discard_transaction_writes(&written, id).await?; + } + } + end_transaction(id); + outcome + } + + /// Run a block and report both what it returned and the values of the + /// variables named in `wanted`. + /// + /// Output parameters are ordinary variables while the block runs; this is + /// how their final values are read back out afterwards. + /// + /// # Errors + /// Returns whatever the block failed with. + async fn run_block_reporting_state( + &self, + block: &plpgsql::Block, + scope: HashMap, + wanted: &[String], + ) -> ProtocolResult<(plpgsql::Returned, HashMap>)> { + if wanted.is_empty() { + let returned = self.run_block_atomically(block, scope).await?; + return Ok((returned, HashMap::new())); + } + + // `run_protected` is the path that hands the state back; used here for + // its return value rather than for its rollback, which is why the + // block it runs has no handlers of its own. + let (outcome, state) = ::run_protected( + self, + block, + plpgsql::State::with_arguments(scope), + ) + .await?; + let returned = outcome?; + let values = wanted + .iter() + .map(|name| { + ( + name.clone(), + state.scope.get(name).and_then(|value| value.text.clone()), + ) + }) + .collect(); + Ok((returned, values)) + } + + /// Remove everything a transaction wrote, by its stamp. + /// + /// Rows it inserted carry its id and are deleted; rows it deleted carry + /// its id as the remover and are unmarked. That covers an `UPDATE` too, + /// which is stored as both. + /// + /// # Errors + /// Returns an error when a table cannot be written. + async fn discard_transaction_writes(&self, tables: &[String], id: u64) -> ProtocolResult<()> { + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(()); + }; + for table in tables { + if !storage.table_exists(table).await? { + continue; + } + storage + .delete_rows( + table, + vec![QueryCondition { + column: TRANSACTION_STAMP.to_string(), + operator: "=".to_string(), + value: JsonValue::from(id), + }], + ) + .await?; + storage + .update_rows( + table, + HashMap::from([(DELETED_BY.to_string(), JsonValue::Null)]), + vec![QueryCondition { + column: DELETED_BY.to_string(), + operator: "=".to_string(), + value: JsonValue::from(id), + }], + ) + .await?; + } + Ok(()) + } + + /// Store a PL/pgSQL function's arguments and body in the catalog. + async fn define_plpgsql_function(&self, sql: &str) -> ProtocolResult { + let after_function = sql + .to_uppercase() + .find("FUNCTION") + .map(|at| at + "FUNCTION".len()) + .ok_or_else(|| ProtocolError::PostgresError("malformed CREATE FUNCTION".to_string()))?; + let rest = sql[after_function..].trim(); + + let open = rest.find('(').ok_or_else(|| { + ProtocolError::PostgresError("a function needs an argument list".to_string()) + })?; + let close = rest.find(')').ok_or_else(|| { + ProtocolError::PostgresError("unterminated function argument list".to_string()) + })?; + let name = fold_identifier(rest[..open].trim()); + + let parameters = plpgsql_function::parse_parameters(&rest[open + 1..close]); + + // `RETURNS ` sits between the argument list and the body. It is + // what `pg_proc.prorettype` reports; without it the catalog would + // claim every function returns the same thing. + let after_args = &sql[after_function + close + 1..]; + let return_type = after_args + .to_uppercase() + .find("RETURNS") + .map(|at| &after_args[at + "RETURNS".len()..]) + .map(|rest| { + let end = rest.to_uppercase().find(" AS ").unwrap_or(rest.len()); + rest[..end].trim().to_uppercase() + }) + .unwrap_or_default(); + + let body_source = sql[after_function + open..].to_string(); + let body = extract_dollar_quoted(&body_source).ok_or_else(|| { + ProtocolError::PostgresError( + "a PL/pgSQL function body must be dollar-quoted: AS $$ ... $$".to_string(), + ) + })?; + + // Parsing now rather than at call time means a body that cannot be + // parsed is refused where the mistake was made. + let parsed = plpgsql::parse(&body)?; + // A body that needs no database can be called from an expression, so + // `SELECT f(id) FROM t` and `WHERE f(id) = 4` work rather than failing + // or, worse, quietly matching nothing. + super::stored_functions::remember(&name, parameters.clone(), parsed); + + // Keyed by name, how many arguments a caller passes, and what kinds + // they are, so `f(INTEGER)` and `f(TEXT)` are two functions rather + // than one overwriting the other. Output parameters are not in the + // key: a caller does not pass them. + let arity = plpgsql_function::input_arity(¶meters); + // The key records base types: a parameter declared as a domain is the + // type the domain is built on, so `f(posint)` and `f(text)` are two + // entries rather than one overwriting the other. + let mut resolved = parameters.clone(); + for parameter in &mut resolved { + parameter.sql_type = self.base_type_of(¶meter.sql_type).await?; + } + let signature = plpgsql_function::signature(&resolved); + let key = format!("function:{name}/{arity}/{signature}"); + let slug = format!("__function__{name}__{arity}__{signature}"); + self.forget_catalog_entry(&key).await?; + self.remember_domain( + &slug, + &format!( + "{}|{return_type}|{body}", + plpgsql_function::encode(¶meters) + ), + ) + .await?; + // `remember_domain` writes under a `domain:` prefix; rewrite the name + // so lookups find it as a function. + self.rename_catalog_entry(&format!("domain:{slug}"), &key) + .await?; + + Ok(QueryResult::Set { + variable: "CREATE FUNCTION".to_string(), + value: String::new(), + }) + } + + /// The stored parameters and body of a PL/pgSQL function, if it exists. + async fn stored_functions( + &self, + name: &str, + arity: usize, + ) -> ProtocolResult, String)>> { + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(Vec::new()); + }; + if !storage.table_exists(VIEW_CATALOG).await? { + return Ok(Vec::new()); + } + // Every overload of this name that takes this many arguments; which + // one a call means is decided by the argument types. + let prefix = format!("function:{name}/{arity}/"); + let rows = storage + .select_rows(VIEW_CATALOG, Vec::new(), Vec::new(), None) + .await?; + Ok(rows + .into_iter() + .filter_map(|row| { + let stored = row.values.get("name").and_then(JsonValue::as_str)?; + if !stored.starts_with(&prefix) { + return None; + } + let definition = row.values.get("definition").and_then(JsonValue::as_str)?; + let (parameters, rest) = definition.split_once('|')?; + // `params|rettype|body`; an entry written before return types + // were recorded has no second separator and is all body. + let body = rest.split_once('|').map_or(rest, |(_, body)| body); + Some((plpgsql_function::decode(parameters), body.to_string())) + }) + .collect()) + } + + /// Every stored function: catalog key, parameters, return type, body. + /// + /// # Errors + /// Returns an error when the catalog cannot be read. + pub async fn all_functions( + &self, + ) -> ProtocolResult, String, String)>> { + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(Vec::new()); + }; + if !storage.table_exists(VIEW_CATALOG).await? { + return Ok(Vec::new()); + } + let rows = storage + .select_rows(VIEW_CATALOG, Vec::new(), Vec::new(), None) + .await?; + Ok(rows + .into_iter() + .filter_map(|row| { + let key = row.values.get("name").and_then(JsonValue::as_str)?; + if !key.starts_with("function:") { + return None; + } + let definition = row.values.get("definition").and_then(JsonValue::as_str)?; + let (parameters, rest) = definition.split_once('|')?; + let (return_type, body) = rest.split_once('|').unwrap_or(("", rest)); + Some(( + key.to_string(), + plpgsql_function::decode(parameters), + return_type.to_string(), + body.to_string(), + )) + }) + .collect()) + } + + /// The function a fast-path OID names, if this server published it. /// /// # Errors /// Returns an error when the catalog cannot be read. - pub async fn replication_slot(&self, name: &str) -> ProtocolResult> { + pub async fn function_for_oid( + &self, + oid: i64, + ) -> ProtocolResult, String)>> { Ok(self - .view_definition(&format!("slot:{name}")) + .all_functions() .await? - .and_then(|definition| { - let (plugin, position) = definition.split_once('|')?; - Some((plugin.to_string(), position.parse().ok()?)) + .into_iter() + .find_map(|(key, parameters, return_type, _)| { + (function_oid(&key) == oid).then(|| { + // `function:name/arity/signature` — the name is what a + // message reports back. + let name = key + .trim_start_matches("function:") + .split('/') + .next() + .unwrap_or_default() + .to_string(); + (name, parameters, return_type) + }) })) } - /// Record how far a replica has confirmed, so a restart resumes there. + /// Every catalog key belonging to a function of this name, at any arity. + async fn function_keys(&self, name: &str) -> ProtocolResult> { + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(Vec::new()); + }; + if !storage.table_exists(VIEW_CATALOG).await? { + return Ok(Vec::new()); + } + let prefix = format!("function:{name}/"); + let rows = storage + .select_rows(VIEW_CATALOG, Vec::new(), Vec::new(), None) + .await?; + Ok(rows + .into_iter() + .filter_map(|row| { + let stored = row.values.get("name").and_then(JsonValue::as_str)?; + stored.starts_with(&prefix).then(|| stored.to_string()) + }) + .collect()) + } + + /// Run `SELECT fname(args)` when `fname` is a stored PL/pgSQL function. + async fn call_plpgsql_function(&self, sql: &str) -> ProtocolResult> { + let upper = sql.to_uppercase(); + if !upper.starts_with("SELECT") { + return Ok(None); + } + let call = sql["SELECT".len()..].trim(); + let Some(open) = call.find('(') else { + return Ok(None); + }; + let Some(close) = call.rfind(')') else { + return Ok(None); + }; + let name = fold_identifier(call[..open].trim()); + if name.is_empty() || !call[close + 1..].trim().is_empty() { + return Ok(None); + } + let arguments = split_arguments(&call[open + 1..close]); + let candidates = self.stored_functions(&name, arguments.len()).await?; + if candidates.is_empty() { + return Ok(None); + } + + // Each argument is evaluated once, in the caller's context, before the + // body runs — so an argument that is itself a query is not re-run per + // reference inside the body. The values are also what says which + // overload the call means. + let mut values = Vec::with_capacity(arguments.len()); + for argument in &arguments { + values.push(self.evaluate_scalar(argument).await?); + } + // An argument's type comes from the catalogue where the text names + // one — a cast, or a call to a function whose return type is + // recorded — from how it was written where that says, and from its + // value only as a last resort. + let mut argument_types = Vec::with_capacity(arguments.len()); + for (source, value) in arguments.iter().zip(&values) { + argument_types.push(self.argument_type_of(source, value.as_deref()).await?); + } + let argument_types: Vec<&str> = argument_types.iter().map(String::as_str).collect(); + + let mut signatures: Vec> = + candidates.iter().map(|(p, _)| p.clone()).collect(); + // Overload choice is made on base types, so a domain parameter is + // resolved to what it is built on first. + for parameters in &mut signatures { + for parameter in parameters.iter_mut() { + parameter.sql_type = self.base_type_of(¶meter.sql_type).await?; + } + } + let chosen = match plpgsql_function::resolve(&name, &signatures, &argument_types) { + Ok(chosen) => chosen, + Err(unresolved) => { + // With one candidate, the caller did not choose the wrong + // overload — they passed a value that cannot be the declared + // type. Saying which value and which type is more use than + // saying no overload matched. + if let [(parameters, _)] = candidates.as_slice() { + for (parameter, value) in plpgsql_function::inputs(parameters) + .into_iter() + .zip(&values) + { + plpgsql_function::check_argument(parameter, value.as_deref())?; + } + } + return Err(unresolved); + } + }; + let (_, body) = &candidates[chosen]; + // The resolved parameters, so a domain binds as the type it is built + // on: bound under its own name it was quoted, and `a * 2` failed with + // an arithmetic error on text. + let parameters = &signatures[chosen]; + + let mut scope = HashMap::new(); + for (parameter, value) in plpgsql_function::inputs(parameters).into_iter().zip(values) { + plpgsql_function::check_argument(parameter, value.as_deref())?; + scope.insert( + parameter.name.clone(), + plpgsql::Value::typed(value, ¶meter.sql_type), + ); + } + // Output parameters start as NULL and are whatever the body leaves. + for parameter in plpgsql_function::outputs(parameters) { + if !parameter.mode.is_input() { + scope.insert( + parameter.name.clone(), + plpgsql::Value::typed(None, ¶meter.sql_type), + ); + } + } + + let block = plpgsql::parse(body)?; + let outputs: Vec = plpgsql_function::outputs(parameters) + .into_iter() + .map(|p| p.name.clone()) + .collect(); + let (returned, state) = + Box::pin(self.run_block_reporting_state(&block, scope, &outputs)).await?; + + Ok(Some(match returned { + // A set-returning body answers with its rows and their own column + // names, as `RETURN QUERY` produced them. + plpgsql::Returned::Rows(rows) => QueryResult::Select { + columns: rows.columns, + rows: rows.rows, + }, + other if outputs.is_empty() => QueryResult::Select { + columns: vec![name], + rows: vec![vec![other.scalar()]], + }, + // With output parameters the answer is their values, named after + // them — `RETURN` is not how such a function reports. + _ => QueryResult::Select { + rows: vec![outputs + .iter() + .map(|name| state.get(name).cloned().flatten()) + .collect()], + columns: outputs, + }, + })) + } + + /// The type of a call's argument. + /// + /// A cast says what it is, and so does a call to a function whose return + /// type this server recorded. Falling back to the printed value is a last + /// resort: it cannot tell an `int8` that happens to be small from an + /// `int4`, which is why anything that names a type is preferred. /// /// # Errors - /// Returns an error when the catalog cannot be written. - pub async fn confirm_replication_slot(&self, name: &str, position: u64) -> ProtocolResult<()> { - let Some((plugin, _)) = self.replication_slot(name).await? else { - return Ok(()); - }; + /// Returns an error when the catalogue cannot be read. + async fn argument_type_of(&self, source: &str, value: Option<&str>) -> ProtocolResult { + let written = source.trim(); + + // `expr::TYPE`, at the top level rather than inside a call. + if let Some(named) = split_top_level_cast(written) { + return Ok(plpgsql_function::normalize( + &self.base_type_of(&named).await?, + )); + } + + // `CAST(expr AS TYPE)` + let upper = written.to_uppercase(); + if upper.starts_with("CAST(") || upper.starts_with("CAST (") { + if let Some(inner) = written + .find('(') + .and_then(|open| written.rfind(')').map(|close| &written[open + 1..close])) + { + if let Some(at) = inner.to_uppercase().rfind(" AS ") { + let named = inner[at + 4..].trim(); + return Ok( + plpgsql_function::normalize(&self.base_type_of(named).await?).to_string(), + ); + } + } + } + + // A call to one of this server's own functions: its return type is in + // the catalogue, so there is no need to guess from the value. + if let Some(open) = written.find('(') { + if written.ends_with(')') { + let called = fold_identifier(written[..open].trim()); + if !called.is_empty() { + let inner_arity = split_arguments(&written[open + 1..written.len() - 1]).len(); + let candidates = self.stored_functions(&called, inner_arity).await?; + // Only when one candidate could have been meant; two + // overloads may return different types, and picking one + // here would be a guess dressed as a lookup. + if let [(_, _)] = candidates.as_slice() { + if let Some((_, _, return_type)) = self + .all_functions() + .await? + .into_iter() + .find(|(key, parameters, _, _)| { + key.starts_with(&format!("function:{called}/{inner_arity}/")) + && plpgsql_function::input_arity(parameters) == inner_arity + }) + .map(|(key, parameters, return_type, _)| (key, parameters, return_type)) + { + if !return_type.trim().is_empty() { + return Ok(plpgsql_function::normalize( + &self.base_type_of(&return_type).await?, + )); + } + } + } + } + } + } + + Ok(plpgsql_function::argument_type(source, value).to_string()) + } + + /// Evaluate a scalar expression by asking the engine for `SELECT `. + async fn evaluate_scalar(&self, expression: &str) -> ProtocolResult> { + let result = Box::pin(self.execute_query(&format!("SELECT {expression}"))).await?; + Ok(match result { + QueryResult::Select { rows, .. } => rows + .into_iter() + .next() + .and_then(|row| row.into_iter().next()) + .flatten(), + _ => None, + }) + } + + /// Remove a catalog entry by its full name. + async fn forget_catalog_entry(&self, name: &str) -> ProtocolResult<()> { let Some(storage) = self.persistent_storage.as_ref() else { return Ok(()); }; + if !storage.table_exists(VIEW_CATALOG).await? { + return Ok(()); + } storage - .update_rows( + .delete_rows( VIEW_CATALOG, - HashMap::from([( - "definition".to_string(), - JsonValue::String(format!("{plugin}|{position}")), - )]), vec![QueryCondition { column: "name".to_string(), operator: "=".to_string(), - value: JsonValue::String(format!("slot:{name}")), + value: JsonValue::String(name.to_string()), }], ) .await .map(|_| ()) } - /// Forget a replication slot. - /// - /// # Errors - /// Returns an error when the catalog cannot be written. - pub async fn drop_replication_slot(&self, name: &str) -> ProtocolResult<()> { + /// Rename a catalog entry, which is how a definition written under one + /// prefix is filed under another. + async fn rename_catalog_entry(&self, from: &str, to: &str) -> ProtocolResult<()> { let Some(storage) = self.persistent_storage.as_ref() else { return Ok(()); }; - self.ensure_view_catalog(storage).await?; storage - .delete_rows( + .update_rows( VIEW_CATALOG, + HashMap::from([("name".to_string(), JsonValue::String(to.to_string()))]), vec![QueryCondition { column: "name".to_string(), operator: "=".to_string(), - value: JsonValue::String(format!("slot:{name}")), + value: JsonValue::String(from.to_string()), }], ) .await .map(|_| ()) } - /// Record a trigger's table, timing, events and action. - async fn remember_trigger(&self, name: &str, definition: &str) -> ProtocolResult<()> { + /// Record a domain's base type and constraints. + async fn remember_domain(&self, name: &str, definition: &str) -> ProtocolResult<()> { + // Functions are filed through here too and then renamed; only a real + // domain belongs in the cast registry. + if !name.starts_with("__function__") { + super::domains::remember(name, &leading_type(definition)); + } let Some(storage) = self.persistent_storage.as_ref() else { return Ok(()); }; @@ -3316,7 +4890,7 @@ impl QueryEngine { values: HashMap::from([ ( "name".to_string(), - JsonValue::String(format!("trigger:{name}")), + JsonValue::String(format!("domain:{name}")), ), ( "definition".to_string(), @@ -3331,82 +4905,120 @@ impl QueryEngine { .map(|_| ()) } - /// The actions of every trigger on `table` firing at `timing` for `event`. + /// Load every stored domain into the cast registry, once per process. + /// + /// Failure is not fatal and not retried per query: a cast to a domain then + /// reports that it cannot be cast, which is what it did before this + /// existed. + async fn warm_domain_registry(&self) { + static WARMED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); + if WARMED.get().is_some() { + return; + } + + let Some(storage) = self.persistent_storage.as_ref() else { + return; + }; + let Ok(true) = storage.table_exists(VIEW_CATALOG).await else { + return; + }; + let Ok(rows) = storage + .select_rows(VIEW_CATALOG, Vec::new(), Vec::new(), None) + .await + else { + return; + }; + for row in rows { + let Some(name) = row.values.get("name").and_then(JsonValue::as_str) else { + continue; + }; + let Some(domain) = name.strip_prefix("domain:") else { + continue; + }; + if let Some(definition) = row.values.get("definition").and_then(JsonValue::as_str) { + super::domains::remember(domain, &leading_type(definition)); + } + } + + // Functions stored before this process started, so one created in an + // earlier run is callable from an expression too. + if let Ok(functions) = self.all_functions().await { + for (key, parameters, _, body) in functions { + let name = key + .trim_start_matches("function:") + .split('/') + .next() + .unwrap_or_default() + .to_string(); + if let Ok(parsed) = plpgsql::parse(&body) { + super::stored_functions::remember(&name, parameters, parsed); + } + } + } + + let _ = WARMED.set(()); + } + + /// The fields of a composite type, if the name is one. /// /// # Errors - /// Returns an error when the catalog cannot be read. - pub async fn triggers_for( + /// Returns an error when the catalogue cannot be read. + pub async fn composite_fields( &self, - table: &str, - timing: &str, - event: &str, - ) -> ProtocolResult> { + name: &str, + ) -> ProtocolResult>> { + Ok(self + .view_definition(&format!("composite:{}", fold_identifier(name))) + .await? + .map(|definition| plpgsql_function::decode(&definition))) + } + + /// Every composite type, by name. + /// + /// # Errors + /// Returns an error when the catalogue cannot be read. + pub async fn all_composites( + &self, + ) -> ProtocolResult)>> { let Some(storage) = self.persistent_storage.as_ref() else { return Ok(Vec::new()); }; if !storage.table_exists(VIEW_CATALOG).await? { return Ok(Vec::new()); } - let rows = storage .select_rows(VIEW_CATALOG, Vec::new(), Vec::new(), None) .await?; Ok(rows .into_iter() - .filter(|row| { - row.values - .get("name") - .and_then(JsonValue::as_str) - .is_some_and(|name| name.starts_with("trigger:")) - }) .filter_map(|row| { - let definition = row.values.get("definition")?.as_str()?.to_string(); - let mut parts = definition.splitn(6, '|'); - let on = parts.next()?; - let at = parts.next()?; - let events = parts.next()?; - let scope = parts.next()?; - let when = parts.next()?; - let action = parts.next()?; - (on == fold_identifier(table) - && at == timing - && events.split(',').any(|e| e == event)) - .then(|| TriggerDefinition { - per_row: scope == "ROW", - when: (!when.is_empty()).then(|| when.to_string()), - action: action.to_string(), - }) + let name = row.values.get("name").and_then(JsonValue::as_str)?; + let composite = name.strip_prefix("composite:")?; + let definition = row.values.get("definition").and_then(JsonValue::as_str)?; + Some((composite.to_string(), plpgsql_function::decode(definition))) }) .collect()) } - /// Record a domain's base type and constraints. - async fn remember_domain(&self, name: &str, definition: &str) -> ProtocolResult<()> { - let Some(storage) = self.persistent_storage.as_ref() else { - return Ok(()); + /// A parameter's type with any domain resolved to what it is built on. + /// + /// A domain is a base type plus constraints; for choosing between + /// overloads only the base type matters, and the lattice has no catalogue + /// to look one up in. Resolved per call rather than recorded at definition + /// time, so `ALTER DOMAIN` is seen by calls made after it. + /// + /// # Errors + /// Returns an error when the catalogue cannot be read. + pub async fn base_type_of(&self, declared: &str) -> ProtocolResult { + let Some(definition) = self.domain_definition(&fold_identifier(declared)).await? else { + return Ok(declared.to_string()); }; - self.ensure_view_catalog(storage).await?; - let now = chrono::Utc::now(); - storage - .insert_row( - VIEW_CATALOG, - TableRow { - values: HashMap::from([ - ( - "name".to_string(), - JsonValue::String(format!("domain:{name}")), - ), - ( - "definition".to_string(), - JsonValue::String(definition.to_string()), - ), - ]), - created_at: now, - updated_at: now, - }, - ) - .await - .map(|_| ()) + let base = leading_type(&definition); + Ok(if base.is_empty() { + declared.to_string() + } else { + base + }) } /// A domain's declared type and constraints, if the name is one. @@ -5014,10 +6626,17 @@ impl QueryEngine { .select_rows(&table, Vec::new(), Vec::new(), None) .await?; + // A long scan is where a cancelled query spends its time, + // so it is checked as the rows go by. + let mut scanned = 0usize; let rows: Vec = stored .into_iter() .filter(|row| row_is_visible(&row.values)) .map(|row| { + scanned += 1; + if scanned.is_multiple_of(CANCEL_CHECK_INTERVAL) { + check_cancelled()?; + } let mut out = Row::new(); for column in &schema.columns { let value = row @@ -5035,9 +6654,9 @@ impl QueryEngine { out.insert(format!("{qualifier}.{name}"), value.clone()); out.insert(name, value); } - out + Ok(out) }) - .collect(); + .collect::>>()?; let order = schema .columns @@ -5218,6 +6837,21 @@ impl QueryEngine { JsonValue::Null => SqlValue::Null, JsonValue::Bool(b) => SqlValue::Boolean(*b), JsonValue::Number(n) => match column_type { + // An exact decimal keeps its declared scale, so a column + // declared `NUMERIC(10,2)` reads back `10.50` rather than + // `10.5` — and never goes through binary floating point. + ColumnType::Numeric { scale, .. } => { + use std::str::FromStr; + rust_decimal::Decimal::from_str(&n.to_string()).map_or( + SqlValue::Null, + |mut decimal| { + if let Some(scale) = scale { + decimal.rescale(u32::from(*scale)); + } + SqlValue::Decimal(decimal) + }, + ) + } ColumnType::BigInt => n.as_i64().map_or(SqlValue::Null, SqlValue::BigInt), ColumnType::Double => n.as_f64().map_or(SqlValue::Null, SqlValue::DoublePrecision), ColumnType::Serial | ColumnType::Integer => { @@ -5752,6 +7386,40 @@ impl QueryEngine { /// /// Quoting carries meaning: `NULL` is the null value while `'NULL'` is the /// three-letter string, and `123` is a number while `'123'` is text. + /// Whether a `VALUES` entry is a literal that needs no evaluation. + /// + /// Everything else is an expression — `500 + 1`, `NOW()`, `a || b` — which + /// PostgreSQL evaluates. This engine stored the *text*: an `INTEGER` + /// column given `500 + 1` held the string `500 + 1`, which then failed + /// every later comparison against a number. + fn is_plain_literal(value: &str) -> bool { + let trimmed = value.trim(); + if trimmed.is_empty() { + return true; + } + let quoted = (trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() > 1) + || (trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() > 1); + quoted + || matches!( + trimmed.to_uppercase().as_str(), + "NULL" | "TRUE" | "FALSE" | "DEFAULT" + ) + || trimmed.parse::().is_ok() + } + + /// Convert one `VALUES` entry, evaluating it if it is an expression. + /// + /// # Errors + /// Returns the engine's error when the expression cannot be evaluated — + /// an unknown column, say, which PostgreSQL also refuses. + async fn value_to_json(&self, value: &str) -> ProtocolResult { + if Self::is_plain_literal(value) { + return Ok(Self::literal_to_json(value)); + } + let evaluated = Box::pin(self.evaluate_scalar(value)).await?; + Ok(evaluated.map_or(JsonValue::Null, |text| Self::literal_to_json(&text))) + } + pub fn literal_to_json(literal: &str) -> JsonValue { let trimmed = literal.trim(); @@ -5814,12 +7482,28 @@ impl QueryEngine { "Invalid WHERE clause".to_string(), )); } + // Only `column op value` can be represented here. Anything + // else — `id * 2 = 4`, `UPPER(name) = 'ADA'` — was accepted + // with the first word as the column and the second as the + // operator, and the storage matcher treats an operator it does + // not know as matching every row. `WHERE id * 2 = 4` returned + // the whole table. Refusing sends the statement to the path + // that evaluates expressions properly. + let value = words[2..].join(" "); + if !is_simple_column(words[0]) + || !is_comparison(words[1]) + || !is_simple_value(words[1], &value) + { + return Err(ProtocolError::PostgresError( + "statement uses a clause this parser does not implement".to_string(), + )); + } Ok(Condition { column: words[0].to_string(), operator: words[1].to_string(), // Quotes are kept and interpreted by `literal_to_json`, so // `x = NULL` and `x = 'NULL'` stay distinguishable. - value: words[2..].join(" "), + value, }) }) .collect::>>()?; @@ -6264,6 +7948,7 @@ impl QueryEngine { count += 1; } + self.flush_change_log().await?; Ok(QueryResult::Insert { count }) } @@ -6449,6 +8134,12 @@ impl QueryEngine { .filter(|row| row_is_visible(&row.values)) .collect(); + // Whether the fetch that just returned should have been abandoned. + // Walking these rows to check would be theatre: `select_rows` has + // already done the work, so the only honest thing a check can do here + // is stop the rest of the statement. + check_cancelled()?; + // A serializable block records what it saw, so a later write to one of // those rows is a conflict and a write elsewhere is not. if records_reads() { @@ -6479,16 +8170,20 @@ impl QueryEngine { } } + // The schema is read once, and is also what says a column carries a + // declared scale. + let table_schema = storage.get_table_schema(table).await?; + // Convert TableRows to QueryResult format let result_columns = if storage_columns.is_empty() { // The schema already holds each name in its final form: a quoted // identifier kept its case when the table was created. Folding it // again lowercased it, so `SELECT "Id"` could not find a column // that `SELECT *` reported as `id`, and reading it returned NULL. - match storage.get_table_schema(table).await? { - Some(schema) => schema.columns.into_iter().map(|c| c.name).collect(), - None => vec![], - } + table_schema + .as_ref() + .map(|schema| schema.columns.iter().map(|c| c.name.clone()).collect()) + .unwrap_or_default() } else { storage_columns.clone() }; @@ -6540,6 +8235,7 @@ impl QueryEngine { columns: Vec, values_list: Vec>, ) -> ProtocolResult { + note_block_write(table); // Check if table exists if !storage.table_exists(table).await? { return Err(ProtocolError::PostgresError(format!( @@ -6595,13 +8291,13 @@ impl QueryEngine { continue; } - row_values.insert(column_def.name.clone(), Self::literal_to_json(val)); + row_values.insert(column_def.name.clone(), self.value_to_json(val).await?); } else { // Column not found in schema, skip or insert with uppercase? // For now, insert with uppercase as fallback, but this might be wrong if schema is strict // But if we are here, it means we are inserting a column that doesn't exist in schema? // Postgres would error. For now, let's just use uppercase as before. - row_values.insert(col_upper, Self::literal_to_json(val)); + row_values.insert(col_upper, self.value_to_json(val).await?); } } @@ -6631,6 +8327,7 @@ impl QueryEngine { count += 1; } + self.flush_change_log().await?; Ok(QueryResult::Insert { count }) } @@ -7032,10 +8729,13 @@ impl QueryEngine { ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { ticker.tick().await; - for record in drain_pending_log() { - if let Err(e) = engine.record_change(&record).await { - tracing::warn!("could not log a change for replication: {e}"); - } + if let Err(e) = engine.flush_change_log().await { + tracing::warn!("could not log changes for replication: {e}"); + } + match engine.truncate_change_log().await { + Ok(0) => {} + Ok(removed) => tracing::debug!(removed, "trimmed the change log"), + Err(e) => tracing::warn!("could not trim the change log: {e}"), } match engine.vacuum(None).await { Ok(0) => {} @@ -7065,28 +8765,34 @@ impl QueryEngine { .map(|open| open.iter().copied().min()) .unwrap_or(None); + // Nothing has been marked since the last pass, so there is nothing to + // find and no reason to read a single row. + if !reclaim_pending() { + return Ok(0); + } + let tables = match table { Some(name) => vec![fold_identifier(name)], None => storage.list_tables().await?, }; + PENDING_RECLAIM.store(0, std::sync::atomic::Ordering::Relaxed); let mut reclaimed = 0usize; for name in tables { if !storage.table_exists(&name).await? { continue; } - let marked: Vec<_> = storage + // One pass per distinct writer, not per row: a table with many + // rows removed by one transaction would otherwise delete the same + // set once for each of them. + let writers: std::collections::BTreeSet = storage .select_rows(&name, Vec::new(), Vec::new(), None) .await? .into_iter() - .filter(|row| row.values.contains_key(DELETED_BY)) - .filter(|row| !row.values[DELETED_BY].is_null()) + .filter_map(|row| row.values.get(DELETED_BY)?.as_u64()) .collect(); - for row in marked { - let Some(writer) = row.values.get(DELETED_BY).and_then(JsonValue::as_u64) else { - continue; - }; + for writer in writers { // Still running, or old enough that a running block might have // begun before it committed: leave it be. let still_running = open_transactions() @@ -7617,6 +9323,7 @@ impl QueryEngine { set_clauses: Vec<(String, String)>, where_clause: Option, ) -> ProtocolResult { + note_block_write(table); // Check if table exists if !storage.table_exists(table).await? { return Err(ProtocolError::PostgresError(format!( @@ -7723,6 +9430,7 @@ impl QueryEngine { .await?; } + note_reclaimable(count as u64); self.flush_change_log().await?; Ok(QueryResult::Update { count }) } @@ -7735,6 +9443,7 @@ impl QueryEngine { table: &str, where_clause: Option, ) -> ProtocolResult { + note_block_write(table); // Check if table exists if !storage.table_exists(table).await? { return Err(ProtocolError::PostgresError(format!( @@ -7777,6 +9486,7 @@ impl QueryEngine { conditions, ) .await?; + note_reclaimable(marked.max(0) as u64); return Ok(QueryResult::Delete { count: marked.max(0) as usize, }); @@ -7873,6 +9583,27 @@ impl QueryEngine { "JSON" => ColumnType::Json, "DOUBLE" => ColumnType::Double, "TIMESTAMP" => ColumnType::Timestamp, + "REAL" | "FLOAT" | "FLOAT4" | "FLOAT8" | "DOUBLE PRECISION" => ColumnType::Double, + data_type + if data_type.starts_with("NUMERIC") || data_type.starts_with("DECIMAL") => + { + // `NUMERIC(10,2)` reached none of the arms above and fell + // through to the unknown case, which is `TEXT`. The column + // then held whatever the value happened to be, and its + // declared scale existed nowhere. + let (precision, scale) = data_type + .find('(') + .and_then(|open| { + let close = data_type.find(')')?; + let inside = &data_type[open + 1..close]; + let mut parts = inside.split(','); + let precision = parts.next()?.trim().parse::().ok(); + let scale = parts.next().and_then(|s| s.trim().parse::().ok()); + Some((precision, scale)) + }) + .unwrap_or((None, None)); + ColumnType::Numeric { precision, scale } + } data_type => { if data_type.starts_with("VARCHAR") { // Extract length if present diff --git a/orbit/server/src/protocols/postgres_wire/sql/expression_evaluator.rs b/orbit/server/src/protocols/postgres_wire/sql/expression_evaluator.rs index 2ad15124..37e71e46 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/expression_evaluator.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/expression_evaluator.rs @@ -28,6 +28,43 @@ use std::collections::HashMap; use std::sync::{Arc, RwLock}; use uuid::Uuid; +/// How many distinct patterns to keep compiled. +/// +/// A cache that never evicts is a leak with a long fuse: patterns come from +/// user queries, so the set is unbounded. This is large enough that a real +/// workload's patterns all stay resident and small enough to be irrelevant. +const PATTERN_CACHE_LIMIT: usize = 256; + +thread_local! { + /// Compiled regexes, keyed by their source. Per-thread so a lookup costs + /// no synchronisation; a duplicate compile on another thread is cheaper + /// than contending on a shared lock. + static PATTERN_CACHE: std::cell::RefCell> = + std::cell::RefCell::new(HashMap::new()); +} + +/// The compiled form of `pattern`, compiling it only the first time. +/// +/// Returns `None` when the pattern is not a valid regex, leaving the caller to +/// fall back to a non-regex match. +fn compiled_pattern(pattern: &str) -> Option { + PATTERN_CACHE.with(|cache| { + if let Some(compiled) = cache.borrow().get(pattern) { + return Some(compiled.clone()); + } + let compiled = Regex::new(pattern).ok()?; + let mut cache = cache.borrow_mut(); + // Wholesale clearing rather than an eviction policy: the cache exists + // to make a repeated pattern free, and any workload that overflows it + // is already not the case being optimised. + if cache.len() >= PATTERN_CACHE_LIMIT { + cache.clear(); + } + cache.insert(pattern.to_string(), compiled.clone()); + Some(compiled) + }) +} + #[cfg(feature = "lua-mlua")] use crate::lua::udf_registry::{SqlValue as UdfSqlValue, UdfRegistry}; @@ -1164,6 +1201,17 @@ impl ExpressionEvaluator { } } + // A stored PL/pgSQL function whose body needs no database can + // be run right here, which is what makes `SELECT f(id) FROM t` + // and `WHERE f(id) = 4` work at all. + let stored = crate::protocols::postgres_wire::stored_functions::candidates( + &func_name, + args.len(), + ); + if !stored.is_empty() { + return call_stored_function(&func_name, &stored, &args); + } + Err(ProtocolError::not_implemented("Function", &func_name)) } } @@ -1430,6 +1478,30 @@ impl ExpressionEvaluator { } match (left, right) { + // An exact decimal on either side keeps the result exact. Without + // this, arithmetic on a `NUMERIC` column had no arm at all and + // `SET amt = amt + 1` failed. + (SqlValue::Decimal(_), _) | (_, SqlValue::Decimal(_)) + if decimal_of(left).is_some() && decimal_of(right).is_some() => + { + let (a, b) = ( + decimal_of(left).unwrap_or_default(), + decimal_of(right).unwrap_or_default(), + ); + match op { + "+" => Ok(SqlValue::Decimal(a + b)), + "-" => Ok(SqlValue::Decimal(a - b)), + "*" => Ok(SqlValue::Decimal(a * b)), + "/" | "%" if b.is_zero() => { + Err(ProtocolError::PostgresError("Division by zero".to_string())) + } + "/" => Ok(SqlValue::Decimal(a / b)), + "%" => Ok(SqlValue::Decimal(a % b)), + other => Err(ProtocolError::PostgresError(format!( + "Unknown arithmetic operator: {other}" + ))), + } + } (SqlValue::Integer(a), SqlValue::Integer(b)) => match op { "+" => Ok(SqlValue::Integer(a + b)), "-" => Ok(SqlValue::Integer(a - b)), @@ -1589,6 +1661,67 @@ impl ExpressionEvaluator { } } + // `date + interval` is a timestamp in PostgreSQL, and `date + + // integer` is a date. Neither existed: only the timestamp forms + // did, so `DATE '2024-01-01' + INTERVAL '1 day'` — the way anyone + // writes it — failed. + (SqlValue::Date(date), SqlValue::Interval(interval)) => match op { + "+" | "-" => { + let at_midnight = date + .and_hms_opt(0, 0, 0) + .ok_or_else(|| ProtocolError::PostgresError("invalid date".to_string()))?; + // PostgreSQL widens `date + interval` to a timestamp + // rather than keeping a date, even when the interval is a + // whole number of days. + let _ = interval; + self.arithmetic_op(&SqlValue::Timestamp(at_midnight), right, op) + } + other => Err(ProtocolError::PostgresError(format!( + "Cannot perform operation {other} on date and interval" + ))), + }, + + ( + SqlValue::Date(date), + SqlValue::Integer(_) | SqlValue::BigInt(_) | SqlValue::SmallInt(_), + ) => { + let days = match right { + SqlValue::Integer(v) => i64::from(*v), + SqlValue::BigInt(v) => *v, + SqlValue::SmallInt(v) => i64::from(*v), + _ => unreachable!("guarded by the pattern"), + }; + let moved = match op { + "+" => *date + chrono::Duration::days(days), + "-" => *date - chrono::Duration::days(days), + other => { + return Err(ProtocolError::PostgresError(format!( + "Cannot perform operation {other} on date and integer" + ))) + } + }; + Ok(SqlValue::Date(moved)) + } + + // `date - date` is the number of days between them. + (SqlValue::Date(left_date), SqlValue::Date(right_date)) if op == "-" => Ok( + SqlValue::Integer((*left_date - *right_date).num_days() as i32), + ), + + (SqlValue::Interval(left_interval), SqlValue::Interval(right_interval)) + if matches!(op, "+" | "-") => + { + let sign = if op == "+" { 1 } else { -1 }; + Ok(SqlValue::Interval( + crate::protocols::postgres_wire::sql::types::PostgresInterval { + months: left_interval.months + sign * right_interval.months, + days: left_interval.days + sign * right_interval.days, + microseconds: left_interval.microseconds + + i64::from(sign) * right_interval.microseconds, + }, + )) + } + _ => Err(ProtocolError::PostgresError(format!( "Cannot perform arithmetic operation {op} on {left:?} and {right:?}" ))), @@ -6401,13 +6534,15 @@ impl ExpressionEvaluator { return text_to_match == pattern_to_match; } - // Try regex matching - match regex::Regex::new(&full_pattern) { - Ok(re) => re.is_match(&text_to_match), - Err(_) => { - // Fallback to simple matching if regex fails - self.simple_like_match(&text_to_match, &pattern_to_match) - } + // Try regex matching. The pattern is the same for every row of a + // scan, so compiling it here — as this used to, once per row — is the + // whole cost of a LIKE: `WHERE note LIKE '%x%'` over 20,000 rows took + // 19.0s against 0.26s for an equality on the same column, ~0.94ms per + // row, all of it in `Regex::new`. + match compiled_pattern(&full_pattern) { + Some(re) => re.is_match(&text_to_match), + // Fallback to simple matching if regex fails + None => self.simple_like_match(&text_to_match, &pattern_to_match), } } @@ -9493,3 +9628,179 @@ impl Default for ExpressionEvaluator { Self::new() } } + +/// Run a pure stored function over already-evaluated arguments. +/// +/// The interpreter is async, but a pure body never awaits anything real: its +/// host answers expressions synchronously and refuses SQL outright, so polling +/// it to completion here cannot park. That is what lets a synchronous +/// evaluator call one without blocking a runtime worker. +/// +/// # Errors +/// Returns whatever the body failed with. +fn call_stored_function( + name: &str, + stored: &[crate::protocols::postgres_wire::stored_functions::PureFunction], + arguments: &[SqlValue], +) -> ProtocolResult { + use crate::protocols::postgres_wire::{plpgsql, plpgsql_function}; + + // Which overload the call means is decided by the arguments' own types. A + // value carries its type here — `SqlValue::BigInt` is not + // `SqlValue::Integer` — so a column's declared type reaches the choice + // rather than being guessed from how the value prints. + let argument_types: Vec = arguments.iter().map(type_name_of).collect(); + let signatures: Vec> = + stored.iter().map(|f| f.parameters.clone()).collect(); + let borrowed: Vec<&str> = argument_types.iter().map(String::as_str).collect(); + let chosen = plpgsql_function::resolve(name, &signatures, &borrowed)?; + let stored = &stored[chosen]; + + let inputs = plpgsql_function::inputs(&stored.parameters); + let mut scope = HashMap::new(); + for (parameter, value) in inputs.iter().zip(arguments) { + let text = match value { + SqlValue::Null => None, + other => Some(sql_value_to_plain_text(other)), + }; + scope.insert( + parameter.name.clone(), + plpgsql::Value::typed(text, ¶meter.sql_type), + ); + } + + let host = PureHost; + let returned = futures::executor::block_on(plpgsql::execute(&stored.block, &host, scope))?; + Ok(match returned.scalar() { + None => SqlValue::Null, + Some(text) => text + .parse::() + .map(SqlValue::BigInt) + .or_else(|_| text.parse::().map(SqlValue::DoublePrecision)) + .unwrap_or(SqlValue::Text(text)), + }) +} + +/// A value as an exact decimal, when it is one. +fn decimal_of(value: &SqlValue) -> Option { + use std::str::FromStr; + match value { + SqlValue::Decimal(n) => Some(*n), + SqlValue::SmallInt(n) => Some(rust_decimal::Decimal::from(*n)), + SqlValue::Integer(n) => Some(rust_decimal::Decimal::from(*n)), + SqlValue::BigInt(n) => Some(rust_decimal::Decimal::from(*n)), + SqlValue::Real(n) => rust_decimal::Decimal::from_str(&n.to_string()).ok(), + SqlValue::DoublePrecision(n) => rust_decimal::Decimal::from_str(&n.to_string()).ok(), + _ => None, + } +} + +/// The canonical type of a value, for choosing between overloads. +/// +/// This is the one place a column's declared type is available inside a query: +/// the storage layer produced a typed `SqlValue`, so `int8` and `int4` are +/// still distinguishable here even though they print the same. +fn type_name_of(value: &SqlValue) -> String { + match value { + SqlValue::Null => crate::protocols::postgres_wire::plpgsql_function::UNKNOWN.to_string(), + SqlValue::Boolean(_) => "bool".to_string(), + SqlValue::SmallInt(_) => "int2".to_string(), + SqlValue::Integer(_) => "int4".to_string(), + SqlValue::BigInt(_) => "int8".to_string(), + SqlValue::Real(_) => "float4".to_string(), + SqlValue::DoublePrecision(_) => "float8".to_string(), + SqlValue::Decimal(_) => "numeric".to_string(), + SqlValue::Char(_) => "bpchar".to_string(), + SqlValue::Varchar(_) => "varchar".to_string(), + SqlValue::Text(_) | SqlValue::Name(_) => "text".to_string(), + SqlValue::Date(_) => "date".to_string(), + SqlValue::Time(_) => "time".to_string(), + SqlValue::Timestamp(_) => "timestamp".to_string(), + SqlValue::TimestampWithTimezone(_) | SqlValue::TimeWithTimezone(_) => { + "timestamptz".to_string() + } + // Anything else keeps its own name, which matches only itself. + other => format!("{:?}", other.sql_type()).to_lowercase(), + } +} + +/// The value as it would be written without quotes. +fn sql_value_to_plain_text(value: &SqlValue) -> String { + match value { + SqlValue::Text(t) | SqlValue::Varchar(t) | SqlValue::Char(t) => t.clone(), + other => other.to_string(), + } +} + +/// A host for a body that must not touch the database. +struct PureHost; + +#[async_trait::async_trait] +impl crate::protocols::postgres_wire::plpgsql::PlPgSqlHost for PureHost { + async fn evaluate(&self, expression: &str) -> ProtocolResult> { + // The expression is parsed and evaluated with no row in scope, which + // is all a pure body's expressions need: its variables were already + // substituted into the text. + let statement = crate::protocols::postgres_wire::sql::parser::SqlParser::new() + .parse(&format!("SELECT {expression}")) + .map_err(|e| ProtocolError::PostgresError(e.to_string()))?; + let crate::protocols::postgres_wire::sql::ast::Statement::Select(select) = statement else { + return Err(ProtocolError::PostgresError(format!( + "cannot evaluate {expression:?}" + ))); + }; + let Some(crate::protocols::postgres_wire::sql::ast::SelectItem::Expression { + expr, .. + }) = select.select_list.first() + else { + return Err(ProtocolError::PostgresError(format!( + "cannot evaluate {expression:?}" + ))); + }; + + let mut evaluator = ExpressionEvaluator::new(); + let context = EvaluationContext::empty(); + Ok(match evaluator.evaluate(expr, &context)? { + SqlValue::Null => None, + other => Some(sql_value_to_plain_text(&other)), + }) + } + + async fn run(&self, _sql: &str) -> ProtocolResult<()> { + Err(ProtocolError::PostgresError( + "a function that runs SQL cannot be called from inside a query".to_string(), + )) + } + + async fn query( + &self, + _sql: &str, + ) -> ProtocolResult { + Err(ProtocolError::PostgresError( + "a function that runs a query cannot be called from inside a query".to_string(), + )) + } + + async fn column_type(&self, _table: &str, _column: &str) -> ProtocolResult> { + Ok(None) + } + + async fn row_columns(&self, _table: &str) -> ProtocolResult> { + Ok(Vec::new()) + } + + async fn run_protected( + &self, + block: &crate::protocols::postgres_wire::plpgsql::Block, + state: crate::protocols::postgres_wire::plpgsql::State, + ) -> ProtocolResult<( + Result, + crate::protocols::postgres_wire::plpgsql::State, + )> { + // Nothing was written, so there is nothing to undo. + let mut state = state; + let outcome = + crate::protocols::postgres_wire::plpgsql::execute_in(block, self, &mut state).await; + Ok((outcome, state)) + } +} diff --git a/orbit/server/src/protocols/postgres_wire/sql/parser/expressions.rs b/orbit/server/src/protocols/postgres_wire/sql/parser/expressions.rs index 078c6ff5..b9da5f84 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/parser/expressions.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/parser/expressions.rs @@ -131,7 +131,24 @@ impl ExpressionParser { *pos += 1; } - let right = self.parse_comparison_expression(tokens, pos)?; + // `= ANY(...)` and `<> ALL(...)` are quantified comparisons, the + // same as the `<`/`>` forms the comparison level already handles. + // Missing here, `x = ANY(ARRAY[...])` parsed as a call to a + // function named `ANY` — the most common form of the construct was + // the one that did not work. + let right = if *pos < tokens.len() + && matches!(&tokens[*pos], Token::Any | Token::Some | Token::All) + { + let quantifier = tokens[*pos].clone(); + *pos += 1; + let sub = self.parse_primary_expression(tokens, pos)?; + match quantifier { + Token::All => Expression::All(Box::new(sub)), + _ => Expression::Any(Box::new(sub)), + } + } else { + self.parse_comparison_expression(tokens, pos)? + }; left = Expression::Binary { left: Box::new(left), operator, @@ -1795,6 +1812,46 @@ impl ExpressionParser { *pos += 1; Ok(SqlType::Text) } + // These are types this server stores and compares, but they were + // missing from the *cast target* list, so `'1.5'::NUMERIC` and + // `'{"a":1}'::json` were parse errors while `'x'::VARCHAR(10)` + // worked. A type you can declare a column as must also be a type + // you can cast to. + Token::Numeric | Token::Decimal => { + let decimal = matches!(tokens[*pos], Token::Decimal); + *pos += 1; + let (precision, scale) = Self::parse_precision_and_scale(tokens, pos)?; + Ok(if decimal { + SqlType::Decimal { precision, scale } + } else { + SqlType::Numeric { precision, scale } + }) + } + Token::Json => { + *pos += 1; + Ok(SqlType::Json) + } + Token::Jsonb => { + *pos += 1; + Ok(SqlType::Jsonb) + } + Token::Interval => { + *pos += 1; + Ok(SqlType::Interval) + } + Token::Bytea => { + *pos += 1; + Ok(SqlType::Bytea) + } + Token::Uuid => { + *pos += 1; + Ok(SqlType::Uuid) + } + Token::Char => { + *pos += 1; + let (length, _) = Self::parse_precision_and_scale(tokens, pos)?; + Ok(SqlType::Char(length.map(u32::from))) + } Token::Varchar => { *pos += 1; // Check for optional length specification @@ -1910,6 +1967,58 @@ impl ExpressionParser { } } + /// Read an optional `(precision)` or `(precision, scale)` after a type. + /// + /// Absent parentheses are not an error: `NUMERIC` and `NUMERIC(10,2)` are + /// both valid, and so is `NUMERIC(10)`. + fn parse_precision_and_scale( + tokens: &[Token], + pos: &mut usize, + ) -> ProtocolResult<(Option, Option)> { + if *pos >= tokens.len() || !matches!(tokens[*pos], Token::LeftParen) { + return Ok((None, None)); + } + *pos += 1; + + // A free function rather than a closure: a closure capturing `pos` + // holds the borrow for the rest of the block. + fn read(tokens: &[Token], pos: &mut usize) -> Option { + let Some(Token::NumericLiteral(text)) = tokens.get(*pos) else { + return None; + }; + let parsed = text.parse::().ok()?; + *pos += 1; + Some(parsed) + } + + let precision = read(tokens, pos); + if precision.is_none() { + return Err(crate::protocols::error::ProtocolError::ParseError( + "Invalid precision specification".to_string(), + )); + } + let scale = if *pos < tokens.len() && matches!(tokens[*pos], Token::Comma) { + *pos += 1; + let scale = read(tokens, pos); + if scale.is_none() { + return Err(crate::protocols::error::ProtocolError::ParseError( + "Invalid scale specification".to_string(), + )); + } + scale + } else { + None + }; + + if *pos < tokens.len() && matches!(tokens[*pos], Token::RightParen) { + *pos += 1; + return Ok((precision, scale)); + } + Err(crate::protocols::error::ProtocolError::ParseError( + "Unterminated precision specification".to_string(), + )) + } + /// Check for array suffix [] and wrap base type in Array if present fn check_array_suffix( &mut self, diff --git a/orbit/server/src/protocols/postgres_wire/sql/select_pipeline.rs b/orbit/server/src/protocols/postgres_wire/sql/select_pipeline.rs index 3972ae7d..051b4757 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/select_pipeline.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/select_pipeline.rs @@ -82,12 +82,44 @@ pub fn run_select_values( } } + // A `LIMIT` can stop the filter early, but only when nothing downstream + // needs the rows it would skip: an ORDER BY re-orders them, an aggregate + // or GROUP BY folds them, DISTINCT drops duplicates, and a window function + // spans the partition. Applying the limit only at the end meant + // `... WHERE note LIKE '%x%' LIMIT 1` filtered every row of the table + // before discarding all but one. + let stop_after = select + .limit + .as_ref() + .filter(|_| { + select.order_by.is_none() + && select.group_by.is_none() + && select.distinct.is_none() + && !has_aggregate(select) + && !select_has_window(select) + }) + .and_then(|limit| limit.count.as_ref()) + .and_then(|count| as_i64(&evaluate(&mut evaluator, count, &Row::new()).ok()?)) + .and_then(|count| usize::try_from(count).ok()) + .map(|count| count.saturating_add(select.offset.unwrap_or(0) as usize)); + // WHERE let filtered = match &select.where_clause { - None => rows, + None => match stop_after { + Some(enough) => rows.into_iter().take(enough).collect(), + None => rows, + }, Some(predicate) => { let mut kept = Vec::new(); - for row in rows { + for (index, row) in rows.into_iter().enumerate() { + if stop_after.is_some_and(|enough| kept.len() >= enough) { + break; + } + // Filtering a large table is the other place a cancelled + // query spends its time. + if index.is_multiple_of(512) { + crate::protocols::postgres_wire::query_engine::check_cancelled()?; + } if is_true(&evaluate(&mut evaluator, predicate, &row)?) { kept.push(row); } @@ -250,6 +282,23 @@ pub fn run_select_values( )) } +/// Whether any select-list item calls a window function. +fn select_has_window(select: &SelectStatement) -> bool { + fn walk(expr: &Expression) -> bool { + match expr { + Expression::WindowFunction { .. } => true, + Expression::Binary { left, right, .. } => walk(left) || walk(right), + Expression::Unary { operand, .. } => walk(operand), + Expression::Function(call) => call.args.iter().any(walk), + _ => false, + } + } + select.select_list.iter().any(|item| match item { + SelectItem::Expression { expr, .. } => walk(expr), + _ => false, + }) +} + /// Whether the select list or HAVING clause calls an aggregate. fn has_aggregate(select: &SelectStatement) -> bool { select.select_list.iter().any(|item| match item { @@ -426,6 +475,10 @@ fn evaluate_over_group( SqlValue::Null } else if inputs.iter().all(|v| as_i64(v).is_some()) { SqlValue::BigInt(inputs.iter().filter_map(as_i64).sum()) + } else if inputs.iter().any(|v| matches!(v, SqlValue::Decimal(_))) + && inputs.iter().all(|v| as_decimal(v).is_some()) + { + SqlValue::Decimal(inputs.iter().filter_map(as_decimal).sum()) } else { SqlValue::DoublePrecision(inputs.iter().filter_map(as_f64).sum()) } @@ -590,10 +643,30 @@ fn as_f64(value: &SqlValue) -> Option { match value { SqlValue::Real(n) => Some(f64::from(*n)), SqlValue::DoublePrecision(n) => Some(*n), + // An exact decimal is a number: without this an aggregate over a + // `NUMERIC` column summed nothing at all. + SqlValue::Decimal(n) => n.to_string().parse().ok(), other => as_i64(other).map(|n| n as f64), } } +/// Sum values exactly when every one of them is exact. +/// +/// `SUM` over a `NUMERIC` column must not go through binary floating point: +/// that is the reason the column was declared `NUMERIC`. +fn as_decimal(value: &SqlValue) -> Option { + use std::str::FromStr; + match value { + SqlValue::Decimal(n) => Some(*n), + SqlValue::SmallInt(n) => Some(rust_decimal::Decimal::from(*n)), + SqlValue::Integer(n) => Some(rust_decimal::Decimal::from(*n)), + SqlValue::BigInt(n) => Some(rust_decimal::Decimal::from(*n)), + SqlValue::Real(n) => rust_decimal::Decimal::from_str(&n.to_string()).ok(), + SqlValue::DoublePrecision(n) => rust_decimal::Decimal::from_str(&n.to_string()).ok(), + _ => None, + } +} + /// Order two values, with NULL sorting last as PostgreSQL does by default. fn compare(a: &SqlValue, b: &SqlValue) -> std::cmp::Ordering { use std::cmp::Ordering; diff --git a/orbit/server/src/protocols/postgres_wire/sql/types.rs b/orbit/server/src/protocols/postgres_wire/sql/types.rs index c85d6513..d1aa816c 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/types.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/types.rs @@ -444,6 +444,26 @@ impl SqlType { } } +/// The [`SqlType`] a type name stands for, for the types a domain may be +/// built on. +/// +/// Returns `None` for anything unrecognised, so a domain over a type this +/// module cannot construct fails the cast rather than silently becoming text. +#[must_use] +pub fn named_sql_type(name: &str) -> Option { + let bare = name.split('(').next().unwrap_or(name).trim().to_uppercase(); + Some(match bare.as_str() { + "INT2" | "SMALLINT" => SqlType::SmallInt, + "INT" | "INT4" | "INTEGER" => SqlType::Integer, + "INT8" | "BIGINT" => SqlType::BigInt, + "REAL" | "FLOAT4" => SqlType::Real, + "DOUBLE" | "DOUBLE PRECISION" | "FLOAT" | "FLOAT8" => SqlType::DoublePrecision, + "BOOL" | "BOOLEAN" => SqlType::Boolean, + "TEXT" => SqlType::Text, + _ => return None, + }) +} + impl SqlValue { /// Get the SQL type of this value pub fn sql_type(&self) -> SqlType { @@ -763,6 +783,60 @@ impl SqlValue { }); } } + // `NUMERIC` and `JSON` were reachable as column types but not as + // cast targets, and once the parser accepted them the conversion + // still had to exist. + SqlType::Numeric { .. } | SqlType::Decimal { .. } => { + use std::str::FromStr; + let parsed = match self { + SqlValue::Text(text) | SqlValue::Varchar(text) | SqlValue::Char(text) => { + rust_decimal::Decimal::from_str(text.trim()) + .map_err(|_| format!("invalid input syntax for numeric: \"{text}\""))? + } + SqlValue::SmallInt(v) => rust_decimal::Decimal::from(*v), + SqlValue::Integer(v) => rust_decimal::Decimal::from(*v), + SqlValue::BigInt(v) => rust_decimal::Decimal::from(*v), + SqlValue::Decimal(v) => *v, + SqlValue::Real(v) => rust_decimal::Decimal::from_str(&v.to_string()) + .map_err(|_| format!("cannot represent {v} as numeric"))?, + SqlValue::DoublePrecision(v) => rust_decimal::Decimal::from_str(&v.to_string()) + .map_err(|_| format!("cannot represent {v} as numeric"))?, + other => return Err(format!("cannot cast {:?} to numeric", other.sql_type())), + }; + // A declared scale is applied, so `1.5::NUMERIC(10,2)` reads + // back as `1.50` rather than losing the trailing zero. + let mut scaled = parsed; + if let SqlType::Numeric { + scale: Some(scale), .. + } + | SqlType::Decimal { + scale: Some(scale), .. + } = target_type + { + // `rescale` rather than `round_dp`: rounding alone leaves + // `1.5` at one decimal place, and PostgreSQL renders a + // declared scale in full. + scaled.rescale(u32::from(*scale)); + } + return Ok(SqlValue::Decimal(scaled)); + } + SqlType::Json | SqlType::Jsonb => { + if let SqlValue::Text(text) | SqlValue::Varchar(text) | SqlValue::Char(text) = self + { + let parsed: serde_json::Value = serde_json::from_str(text) + .map_err(|e| format!("invalid input syntax for json: {e}"))?; + return Ok(match target_type { + SqlType::Jsonb => SqlValue::Jsonb(parsed), + _ => SqlValue::Json(parsed), + }); + } + if let SqlValue::Json(v) | SqlValue::Jsonb(v) = self { + return Ok(match target_type { + SqlType::Jsonb => SqlValue::Jsonb(v.clone()), + _ => SqlValue::Json(v.clone()), + }); + } + } SqlType::Boolean => { if let SqlValue::Text(text) | SqlValue::Varchar(text) | SqlValue::Char(text) = self { @@ -776,6 +850,18 @@ impl SqlValue { _ => {} } + // A cast to a domain is a cast to what the domain is built on. The + // name is resolved through the registry the query engine keeps, + // because this function has no catalogue; a name that is not a known + // domain still fails below rather than passing the value through. + if let SqlType::Custom { type_name } = target_type { + if let Some(base) = crate::protocols::postgres_wire::domains::base_of(type_name) { + if let Some(resolved) = named_sql_type(&base) { + return self.cast_to(&resolved); + } + } + } + if self.sql_type().can_cast_to(target_type) { match (self, target_type) { (SqlValue::SmallInt(i), SqlType::Integer) => Ok(SqlValue::Integer(*i as i32)), diff --git a/orbit/server/src/protocols/postgres_wire/sqlstate.rs b/orbit/server/src/protocols/postgres_wire/sqlstate.rs new file mode 100644 index 00000000..66920168 --- /dev/null +++ b/orbit/server/src/protocols/postgres_wire/sqlstate.rs @@ -0,0 +1,227 @@ +//! SQLSTATE codes for the errors this server reports. +//! +//! Every error used to leave as `XX000` — `internal_error`. That is the code +//! PostgreSQL uses for "something went wrong that we cannot name", and drivers +//! treat it accordingly: an application could not tell a duplicate key from a +//! crashed backend, and every `ON CONFLICT`-style retry loop, every ORM's +//! "is this a unique violation?" branch, and every PL/pgSQL `WHEN +//! unique_violation` was answered with the same shrug. +//! +//! # Why this classifies text +//! +//! The right shape is a code at every raise site. There are several hundred of +//! them, and a half-converted error type would be worse than none: some codes +//! honest, others silently still `XX000`, with no way to tell which from the +//! outside. So the mapping lives here, in one place, keyed on the message text +//! the engine itself produces. +//! +//! That makes this a contract between the raise sites and this table, and such +//! contracts drift. The guard is that every condition below is triggered +//! end-to-end by a check in the conformance harness, which asserts the code a +//! real client receives — so a reworded message shows up as a failing check +//! rather than as a silent return to `XX000`. + +/// `internal_error` — nothing more specific is known. +pub const INTERNAL: &str = "XX000"; + +/// Codes this server can name, with the phrase that identifies each. +/// +/// Order matters: the first match wins, so a more specific phrase must come +/// before a more general one that also matches it. +const CONDITIONS: &[(&str, &str)] = &[ + // Class 23 — integrity constraint violation. + ("violates not-null constraint", "23502"), + ("violates unique constraint", "23505"), + ("violates foreign key constraint", "23503"), + ("violates check constraint", "23514"), + // Class 22 — data exception. + ("division by zero", "22012"), + ("invalid input syntax", "22P02"), + // Class 42 — syntax error or access rule violation. + ("already exists", "42P07"), + ("does not exist", "42P01"), + ("is not unique", "42725"), + ("not implemented", "42883"), + ("unknown function", "42883"), + ("syntax error", "42601"), + ("parse error", "42601"), + // Class 40 — transaction rollback. + ("is not supported", "0A000"), + ("could not serialize access", "40001"), + // Class 57 — operator intervention. + ("canceling statement due to user request", "57014"), +]; + +/// A column being missing is `undefined_column`, not `undefined_table`, and +/// both are phrased "does not exist". +const COLUMN_PHRASES: &[&str] = &["column"]; + +/// A function being missing is `undefined_function`. +const FUNCTION_PHRASES: &[&str] = &["function"]; + +/// The SQLSTATE code for an error message. +/// +/// Falls back to [`INTERNAL`], which is what an unrecognised error genuinely +/// is: unclassified. Returning a plausible-looking code for an error nobody +/// has categorised would be worse than admitting it. +#[must_use] +pub fn classify(message: &str) -> &'static str { + let lowered = message.to_lowercase(); + + for (phrase, code) in CONDITIONS { + if !lowered.contains(phrase) { + continue; + } + // "does not exist" covers tables, columns and functions, which are + // three different codes. + if *phrase == "does not exist" { + if COLUMN_PHRASES.iter().any(|p| lowered.contains(p)) { + return "42703"; + } + if FUNCTION_PHRASES.iter().any(|p| lowered.contains(p)) { + return "42883"; + } + } + return code; + } + + INTERNAL +} + +/// Whether a PL/pgSQL condition name matches a SQLSTATE code. +/// +/// This is what lets `WHEN unique_violation THEN` catch the right failure +/// rather than everything or nothing. +#[must_use] +pub fn condition_matches(condition: &str, code: &str) -> bool { + if condition.eq_ignore_ascii_case("OTHERS") { + return true; + } + condition_code(condition).is_some_and(|expected| expected == code) +} + +/// The SQLSTATE code a PL/pgSQL condition name stands for. +#[must_use] +pub fn condition_code(condition: &str) -> Option<&'static str> { + let name = condition.to_lowercase(); + Some(match name.as_str() { + "not_null_violation" => "23502", + "unique_violation" => "23505", + "foreign_key_violation" => "23503", + "check_violation" => "23514", + "integrity_constraint_violation" => "23000", + "division_by_zero" => "22012", + "invalid_text_representation" => "22P02", + "undefined_table" => "42P01", + "undefined_column" => "42703", + "undefined_function" => "42883", + "ambiguous_function" => "42725", + "duplicate_table" => "42P07", + "syntax_error" => "42601", + "serialization_failure" => "40001", + "query_canceled" => "57014", + "raise_exception" => "P0001", + "feature_not_supported" => "0A000", + "internal_error" => INTERNAL, + _ => return None, + }) +} + +/// The SQLSTATE an error should be reported under. +/// +/// An error that carries its own code keeps it; anything else is classified +/// from its message. +#[must_use] +pub fn of(error: &crate::protocols::error::ProtocolError) -> &'static str { + match error { + crate::protocols::error::ProtocolError::SqlState { code, .. } => code, + other => classify(&other.to_string()), + } +} + +/// The code a `RAISE EXCEPTION` reports. +/// +/// PostgreSQL uses `P0001` for an exception raised by a procedure, which is +/// what makes `WHEN raise_exception` work. +pub const RAISE_EXCEPTION: &str = "P0001"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constraint_violations_get_their_own_codes() { + assert_eq!( + classify("null value in column \"a\" violates not-null constraint"), + "23502" + ); + assert_eq!( + classify("duplicate key value violates unique constraint on column \"a\""), + "23505" + ); + assert_eq!( + classify("insert or update violates foreign key constraint: no row in \"b\""), + "23503" + ); + assert_eq!( + classify("new row violates check constraint on column \"a\""), + "23514" + ); + } + + #[test] + fn a_missing_table_column_and_function_are_told_apart() { + assert_eq!(classify("Table 'x' does not exist"), "42P01"); + assert_eq!(classify("column \"x\" does not exist"), "42703"); + assert_eq!(classify("Function 'X' not implemented"), "42883"); + } + + #[test] + fn an_ambiguous_call_is_its_own_condition() { + // 42725 is ambiguous_function; reporting it as undefined_function + // would tell a caller the function is missing when it is the choice + // between two of them that failed. + assert_eq!(classify("function f(unknown) is not unique"), "42725"); + assert_eq!(classify("function f(bool) does not exist"), "42883"); + } + + #[test] + fn a_duplicate_table_is_not_a_missing_one() { + assert_eq!(classify("Table 'x' already exists"), "42P07"); + } + + #[test] + fn transaction_and_cancellation_codes_are_named() { + assert_eq!( + classify("could not serialize access due to concurrent update on \"t\""), + "40001" + ); + assert_eq!(classify("canceling statement due to user request"), "57014"); + } + + #[test] + fn an_unsupported_feature_is_not_an_internal_error() { + // A client must be able to tell a feature this server does not have + // from a backend that fell over. + assert_eq!( + classify("physical replication is not supported; use ..."), + "0A000" + ); + } + + #[test] + fn an_unrecognised_error_stays_unclassified() { + // Guessing a plausible code for an uncategorised error would be worse + // than admitting it is uncategorised. + assert_eq!(classify("the disk caught fire"), INTERNAL); + } + + #[test] + fn a_condition_name_matches_only_its_own_code() { + assert!(condition_matches("unique_violation", "23505")); + assert!(!condition_matches("unique_violation", "23502")); + assert!(condition_matches("OTHERS", "23502")); + // A name nobody defined matches nothing rather than everything. + assert!(!condition_matches("no_such_condition", "23505")); + } +} diff --git a/orbit/server/src/protocols/postgres_wire/stored_functions.rs b/orbit/server/src/protocols/postgres_wire/stored_functions.rs new file mode 100644 index 00000000..68e4f5cf --- /dev/null +++ b/orbit/server/src/protocols/postgres_wire/stored_functions.rs @@ -0,0 +1,249 @@ +//! Calling a stored PL/pgSQL function from inside a query. +//! +//! A stored function could only be called as a bare `SELECT f(literal)`. The +//! query engine intercepts that shape before the expression evaluator ever +//! sees it, so anything else went to the evaluator, which had never heard of +//! the function: `SELECT f(id) FROM t` failed with +//! `Function 'F' not implemented`, and — worse — `WHERE f(id) = 4` returned no +//! rows instead of failing, which is a wrong answer rather than a missing +//! feature. +//! +//! # Why only some functions +//! +//! The evaluator is synchronous and the query engine is not, so a body that +//! runs SQL cannot be executed from here without blocking a runtime worker. +//! Only a **pure** body is registered — one whose statements are assignments, +//! conditionals, loops and `RETURN` over expressions, with no SQL statement in +//! it. That covers the scalar functions people write to use in a `SELECT` list +//! or a `WHERE`, and a body that does run SQL keeps the old error rather than +//! being called in a way that could deadlock. +//! +//! The invariant: an entry is written only by the query engine, when a +//! function is created and once at startup for those already stored, and is +//! keyed by name and argument count. + +use std::collections::HashMap; +use std::sync::{Arc, OnceLock, RwLock}; + +use super::plpgsql::{Block, Stmt}; +use super::plpgsql_function::Parameter; + +/// A function this module can run without leaving the evaluator. +#[derive(Debug, Clone)] +pub struct PureFunction { + /// Its parameters, in declared order. + pub parameters: Vec, + /// Its parsed body. + pub block: Arc, +} + +/// Candidates share a name and argument count; which one a call means is +/// decided by the argument types, exactly as it is for a direct call. +type Registry = RwLock>>; + +static FUNCTIONS: OnceLock = OnceLock::new(); + +fn registry() -> &'static Registry { + FUNCTIONS.get_or_init(|| RwLock::new(HashMap::new())) +} + +/// Whether a body can be run without a database — no statement in it is SQL. +/// +/// Conservative by construction: a statement this does not recognise as pure +/// makes the whole body impure, so a new statement kind is excluded until +/// someone decides otherwise. +#[must_use] +pub fn is_pure(block: &Block) -> bool { + statements_are_pure(&block.body) + && block + .handlers + .iter() + .all(|handler| statements_are_pure(&handler.body)) +} + +fn statements_are_pure(body: &[Stmt]) -> bool { + body.iter().all(|statement| match statement { + Stmt::Assign { .. } | Stmt::Return { .. } | Stmt::Raise { .. } | Stmt::Nothing => true, + Stmt::Exit { .. } | Stmt::Continue { .. } => true, + Stmt::If { + branches, + otherwise, + } => { + branches.iter().all(|(_, body)| statements_are_pure(body)) + && statements_are_pure(otherwise) + } + Stmt::While { body, .. } | Stmt::Loop { body } | Stmt::ForRange { body, .. } => { + statements_are_pure(body) + } + Stmt::Nested { block } => is_pure(block), + // Everything else touches the database: a bare SQL statement, a + // `SELECT ... INTO`, a cursor, a query loop, or `RETURN QUERY`. + _ => false, + }) +} + +/// Register a function that can be called from an expression. +/// +/// A body that is not pure is *removed* rather than ignored, so replacing a +/// pure function with one that runs SQL does not leave the old one callable. +pub fn remember(name: &str, parameters: Vec, block: Block) { + let arity = super::plpgsql_function::input_arity(¶meters); + let key = (name.to_lowercase(), arity); + let signature = super::plpgsql_function::signature(¶meters); + + let Ok(mut functions) = registry().write() else { + return; + }; + let candidates = functions.entry(key).or_default(); + // One entry per signature: redefining a function replaces it, and does + // not leave the old body callable alongside the new one. + candidates + .retain(|existing| super::plpgsql_function::signature(&existing.parameters) != signature); + + if is_pure(&block) { + candidates.push(PureFunction { + parameters, + block: Arc::new(block), + }); + } +} + +/// Forget every arity of a name that has been dropped. +pub fn forget(name: &str) { + let name = name.to_lowercase(); + if let Ok(mut functions) = registry().write() { + functions.retain(|(stored, _), _| *stored != name); + } +} + +/// Every pure function of this name taking this many arguments. +#[must_use] +pub fn candidates(name: &str, arity: usize) -> Vec { + registry() + .read() + .ok() + .and_then(|functions| functions.get(&(name.to_lowercase(), arity)).cloned()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocols::postgres_wire::plpgsql; + + fn block(source: &str) -> Block { + plpgsql::parse(source).unwrap_or_else(|e| panic!("parse {source}: {e}")) + } + + #[test] + fn a_body_of_expressions_is_pure() { + assert!(is_pure(&block("BEGIN RETURN 1; END"))); + assert!(is_pure(&block( + "DECLARE n INTEGER := 0; BEGIN WHILE n < 3 LOOP n := n + 1; END LOOP; RETURN n; END" + ))); + assert!(is_pure(&block( + "BEGIN IF 1 > 0 THEN RETURN 'a'; ELSE RETURN 'b'; END IF; END" + ))); + assert!(is_pure(&block( + "BEGIN FOR i IN 1..3 LOOP EXIT WHEN i > 2; END LOOP; RETURN 1; END" + ))); + } + + #[test] + fn a_nested_block_is_checked_too() { + assert!(is_pure(&block("BEGIN BEGIN RETURN 1; END; END"))); + // A nested block must not smuggle a SQL statement past the check. + assert!(!is_pure(&block( + "BEGIN BEGIN INSERT INTO t VALUES (1); END; END" + ))); + } + + #[test] + fn a_body_that_touches_the_database_is_not() { + assert!(!is_pure(&block("BEGIN INSERT INTO t VALUES (1); END"))); + assert!(!is_pure(&block("BEGIN SELECT COUNT(*) FROM t INTO n; END"))); + assert!(!is_pure(&block("BEGIN RETURN QUERY SELECT a FROM t; END"))); + // Nested inside a branch counts too, or a conditional would smuggle + // one past. + assert!(!is_pure(&block( + "BEGIN IF 1 > 0 THEN INSERT INTO t VALUES (1); END IF; END" + ))); + // And inside a loop. + assert!(!is_pure(&block( + "BEGIN FOR i IN 1..3 LOOP INSERT INTO t VALUES (i); END LOOP; END" + ))); + } + + #[test] + fn registering_an_impure_body_removes_any_pure_one() { + let parameters = super::super::plpgsql_function::parse_parameters("a INTEGER"); + remember( + "test_swap", + parameters.clone(), + block("BEGIN RETURN 1; END"), + ); + assert_eq!(candidates("test_swap", 1).len(), 1); + + // Replacing it with a body that runs SQL must not leave the old one + // callable from an expression. + remember( + "test_swap", + parameters, + block("BEGIN INSERT INTO t VALUES (1); END"), + ); + assert!(candidates("test_swap", 1).is_empty()); + forget("test_swap"); + } + + #[test] + fn two_overloads_of_one_arity_both_survive() { + // Keyed by arity alone, the second replaced the first, and a call + // inside a query reached whichever was defined last whatever its + // argument was. + let numeric = super::super::plpgsql_function::parse_parameters("a INTEGER"); + let textual = super::super::plpgsql_function::parse_parameters("a TEXT"); + remember("test_both", numeric, block("BEGIN RETURN 'i'; END")); + remember("test_both", textual, block("BEGIN RETURN 't'; END")); + assert_eq!(candidates("test_both", 1).len(), 2); + forget("test_both"); + } + + #[test] + fn redefining_one_signature_replaces_only_it() { + let numeric = super::super::plpgsql_function::parse_parameters("a INTEGER"); + let textual = super::super::plpgsql_function::parse_parameters("a TEXT"); + remember( + "test_replace", + numeric.clone(), + block("BEGIN RETURN 1; END"), + ); + remember("test_replace", textual, block("BEGIN RETURN 2; END")); + remember("test_replace", numeric, block("BEGIN RETURN 3; END")); + assert_eq!(candidates("test_replace", 1).len(), 2); + forget("test_replace"); + } + + #[test] + fn arity_is_part_of_the_key() { + let one = super::super::plpgsql_function::parse_parameters("a INTEGER"); + let two = super::super::plpgsql_function::parse_parameters("a INTEGER, b INTEGER"); + remember("test_arity", one, block("BEGIN RETURN 1; END")); + remember("test_arity", two, block("BEGIN RETURN 2; END")); + + assert_eq!(candidates("test_arity", 1).len(), 1); + assert_eq!(candidates("test_arity", 2).len(), 1); + assert!(candidates("test_arity", 3).is_empty()); + forget("test_arity"); + } + + #[test] + fn dropping_forgets_every_arity() { + let one = super::super::plpgsql_function::parse_parameters("a INTEGER"); + let two = super::super::plpgsql_function::parse_parameters("a INTEGER, b INTEGER"); + remember("test_drop", one, block("BEGIN RETURN 1; END")); + remember("test_drop", two, block("BEGIN RETURN 2; END")); + forget("test_drop"); + assert!(candidates("test_drop", 1).is_empty()); + assert!(candidates("test_drop", 2).is_empty()); + } +} diff --git a/orbit/server/src/protocols/rest/handlers.rs b/orbit/server/src/protocols/rest/handlers.rs index 5a9e02b5..23d561aa 100644 --- a/orbit/server/src/protocols/rest/handlers.rs +++ b/orbit/server/src/protocols/rest/handlers.rs @@ -1224,6 +1224,11 @@ pub async fn describe_table( ColumnType::Json => "json".to_string(), ColumnType::Timestamp => "timestamp".to_string(), ColumnType::Double => "double precision".to_string(), + ColumnType::Numeric { precision, scale } => match (precision, scale) { + (Some(p), Some(s)) => format!("numeric({p},{s})"), + (Some(p), None) => format!("numeric({p})"), + _ => "numeric".to_string(), + }, }; let description = TableDescription { diff --git a/specifications/PRD.md b/specifications/PRD.md index 2adc45da..78ce34bb 100644 --- a/specifications/PRD.md +++ b/specifications/PRD.md @@ -966,11 +966,69 @@ Properties the harness cannot reach, verified by hand against a running server: | Replication replay | Write with nobody streaming, then connect and `START_REPLICATION ... 0/1` | Both missed writes replayed from the requested LSN | | Replication feedback | Standby status update with the reply flag set | Keepalive returned; the confirmed position is written to the slot | | Slot durability | Create a slot, restart, `START_REPLICATION` on it | Slot survives | +| Durable change log | Write two rows, restart, replay from `0/1` | Both replayed from the log on disk | +| `pgoutput` format | Slot created with the `pgoutput` plugin | `Begin`, `Relation`, `Insert`, `Commit` in one frame | +| Physical replication | `START_REPLICATION 0/0 PHYSICAL` | Refused with `0A000` (`feature_not_supported`), not answered with logical frames; the session stays usable and `IDENTIFY_SYSTEM` still replies | +| Transaction grouping | A two-statement block on a `pgoutput` stream | One `Begin`, both rows, one `Commit` | +| Change-log trimming | `VACUUM` with and without an unconfirmed slot | Trimmed when nothing is subscribed; retained while a slot has not confirmed | +| Binary `pgoutput` | Slot streamed with `(binary 'true')` | Values arrive as `b` frames — an `int8` in network byte order, not its decimal spelling | +| Stale slot invalidation | 110,000 writes past an unconfirmed slot, then `VACUUM` | Slot invalidated, log trimmed to zero, a healthy slot still streams | +| Trimming at scale | `VACUUM` over a 110,000-row change log | 2.5s; deleting per row instead took longer than the request timeout | +| No write amplification without a subscriber | Five writes with no slot, then five with one | Log absent in the first case, five rows in the second | +| Batched log writes | A 1000-row `INSERT` with a slot open | One log row, not 1000; all 1000 changes still replay, from the window and from disk after a restart | +| Selective log read | 20,000 changes in 100 batches, resume near the end | 33 changes returned, not 20,000 | +| Query cancellation | `CancelRequest` on a second connection with the session's `BackendKeyData` | Next message refused with "canceling statement due to user request"; session stays usable; a wrong secret is ignored | +| Fast-path function call | `FunctionCall` on a raw connection, with the OID read from `pg_proc` | `fp(6)` → 42 and `greetfp('world')` → `hi world` over the `F`/`V` messages; an OID nobody published is refused with `42883` and the session stays usable | +| Mid-statement cancellation | `CancelRequest` 0.3s into a 200,000-row scan | The running statement stops: 4.04s → 1.56s, no rows, "canceling statement due to user request"; an uncancelled run and a wrong-secret run both return all 200,000 rows | +| Concurrency under load | `SELECT 1` on a second connection, and an HTTP health check, during a 200,000-row scan | Health check 0.56s (was 2.66s — see below) | The multi-protocol checks matter because all four protocols share the storage backend that was replaced; verifying only PostgreSQL would have left the others unmeasured after the change. +#### Defects found by measuring, not by building + +A green build and a passing harness said nothing about any of these. Each was +found by running the server and timing it, and each is recorded with the number +that exposed it. + +| Defect | Symptom | Cause | After | +|--------|---------|-------|-------| +| Autovacuum scanned everything, always | 200,000 rows left the server at 101% CPU and 1.3 GB RSS, unresponsive | Each 60s tick materialised every row of every table just to discover whether anything was reclaimable — the tick ran far more often than the thing it looked for changed | A counter of rows marked deleted answers the same question without a scan: 0.0% idle CPU, 131 MB RSS | +| `LIMIT` applied after filtering everything | `... WHERE note LIKE '%x%' LIMIT 1` over 200,000 rows took 190.8s | The limit was applied at the end of the pipeline, so every row was filtered before all but one was discarded | The filter stops once the limit is reached — 2.70s. Guarded: `ORDER BY`, `GROUP BY`, `DISTINCT`, aggregates and window functions all still scan in full, since they need the rows the limit would skip | +| `LIKE` compiled its pattern per row | 20,000 rows: 18.97s for `LIKE`, 0.26s for `=` on the same column — ~0.94ms per row, all in `Regex::new` | The pattern is identical for every row of a scan, but was recompiled for each one | A bounded per-thread cache of compiled patterns: 0.35s. The 200,000-row scan went 190.4s → 3.90s | +| Every session shared one backend id | — | `process_id` was `std::process::id()`, the same value for every connection | A per-session counter. Two things read that id and both were wrong: the cancel registry is keyed by it, so only the newest connection could ever be cancelled, and `NOTIFY` reports it so a listener can tell its own notifications apart — with one shared id every notification looked self-sent | +| `INSERT ... VALUES` stored expressions as text | `INSERT INTO t (id) VALUES (500 + 1)` put the string `500 + 1` into an `INTEGER` column, while `SELECT 500 + 1` correctly gave 501 | Anything that was not a quoted string, `NULL`, a boolean or a number fell through to being stored verbatim | The value is evaluated when it is not a plain literal. This was found while testing PL/pgSQL and had nothing to do with it — it stayed invisible because the row *was* written, it just never matched a comparison afterwards | +| A function's catalog key drifted between writer and reader | Every stored function stopped resolving: `SELECT addone(41)` answered `Function 'ADDONE' not implemented` | Adding the argument count to the key changed the *lookup* but the edit to the *store* silently did not apply, so one wrote `function:f` and the other read `function:f/1` | Both sides changed together. The two are eight lines apart in one file and still drifted — the edit that missed reported nothing, and only a live call caught it | +| A function's return type was read as its body | A fast-path call asking for a binary result got text: the return type resolved to nothing recognisable, so `text` was written | An edit swapping two fields of a tuple was written without an assertion, and `make format` had reflowed the code so it silently matched nothing | Both fields swapped, with the assertion that would have caught it. This happened five times this session; every replacement that carried an assertion failed loudly and was fixed at once, and every one that did not cost a debugging cycle | +| Every overload of a name collided inside a query | With `f(int4)`, `f(text)` and `f(int8)` all defined, `SELECT f(a_text_column) FROM t` answered `int8` — whichever was created last | The registry that makes a function callable from an expression was keyed by name and argument *count*, so each definition overwrote the previous one | Candidates are kept per signature and resolved by the arguments' own types. This was a defect in the previous round's work, found by asking what happened when the feature met the overloading built two rounds before it | +| `= ANY(...)` was parsed as a call to a function named `ANY` | `WHERE id = ANY(ARRAY[1,3])` matched no rows; `SELECT 1 = ANY(ARRAY[1,2])` failed with `Function 'ANY' not implemented` | The parser handles a quantified comparison after `<`, `<=`, `>`, `>=` but the equality level had no such branch — so the two forms people actually write, `= ANY` and `<> ANY`, were the ones that did not work | The same branch at the equality level. The evaluator already understood `Expression::Any`; only the parser never produced one | +| A comparison's right-hand side was stored as text | `WHERE id = ANY(ARRAY[1,3])` was stored as `id = 'ANY(ARRAY[1,3])'` and matched nothing; `WHERE id = 1 + 1` matched nothing | The earlier fix checked the column and the operator but not the value, so an expression on the right fell through the same crack from the other side | The value must be a literal — or a parenthesised list for `IN`, or `NULL` for `IS` — and anything else routes to the pipeline | +| `SHOW` disagreed with the startup handshake | `SHOW server_version` returned an empty string while `ParameterStatus` carried a version. A driver reads that value to decide what the server supports | The two came from different places: the handshake sent a literal, and `SHOW` read a session map nothing had populated | One list feeds both. `SHOW ALL` now reports the advertised settings alongside the session's own | +| An expression in `WHERE` was dropped entirely | `SELECT name FROM t WHERE id * 2 = 4` returned **every row**; `WHERE UPPER(name) = 'ADA'` returned none. Adding ` AND id > 0` made both correct, because the conjunction routed the statement to the full pipeline | The simple parser read the first whitespace-separated word as the column and the second as the operator, so `id * 2 = 4` became column `id`, operator `*` — and the storage matcher treats an operator it does not know as matching every row | The parser refuses a conjunct it cannot represent as `column op value`, which sends the statement to the path that evaluates expressions. Found while checking why a stored function did not work in a `WHERE`; the arithmetic case is the worse one, and nothing was looking for it | +| A catalogue query dropped its `WHERE` | `SELECT ... FROM pg_class WHERE relname = $1` returned the whole catalogue, so a driver read the first entry as its answer | The clause was parsed and then not passed to the catalogue path, which only projected columns | A catalogue query carrying a clause goes through the path that can evaluate one. Found by writing `WHERE proname = ...` against the new `pg_proc` and getting every row back | +| One query stalled the whole server | During a 200,000-row scan, `SELECT 1` on a second connection took 3.3s and an HTTP health check 2.7s; the server never even read the second connection's message until the scan finished | The scan ran as one long CPU-bound stretch that never yielded, so the runtime could not service anything else — 100% of one core with nine idle | The row-conversion loop yields every 512 rows: health check 0.56s. This is also what made cancellation work at all — a `CancelRequest` could not be *received* in time before | + +The cancellation case is worth stating plainly, because the first three attempts +to verify it all failed for reasons that were not the server's: a Python timer +thread starved by the GIL, then interpreter startup that took 3.5s under load, +then a `wait()` on a child that was sleeping 99s. Only after the harness was +made deterministic — the child announces readiness before the query is sent, and +the parent signals the exact moment the query goes out — did the measurement +mean anything. Two of those runs would have been reported as "cancellation does +not work" and one as a hang. + +**Left alone deliberately:** the remaining 0.56s stall is in the synchronous +part of the select pipeline (`run_select_values`), which cannot `await`. Making +it async ripples through `execution_strategy.rs`; `block_in_place` is not an +option because it panics on a current-thread runtime, which is what +`#[tokio::test]` gives. The dominant term is fixed and the rest is recorded +here rather than rushed. + +**Also noted, not fixed:** `server.worker_threads` in `config.rs` is read by +nothing — `#[tokio::main]` takes no arguments, so the runtime always uses the +default worker count. It is a knob that cannot change any output. + #### Scale The conformance harness works on three-row tables, so nothing in it could reach @@ -1001,6 +1059,140 @@ Note that `unified_storage.data_dir` in `config/orbit-server.toml` is a separate setting from the `--data-dir` flag, which the unified store does not read. +#### SQLSTATE + +Every error left as `XX000` — `internal_error`, the code PostgreSQL uses for +"something went wrong that we cannot name". Drivers branch on this: an +application could not tell a duplicate key from a crashed backend, so no +retry-on-conflict loop and no ORM's "is this a unique violation?" test could +work. The message also carried `PostgreSQL protocol error: ` — our plumbing +showing through into text meant for the user. + +`orbit/server/src/protocols/postgres_wire/sqlstate.rs` now classifies errors, +and each of these is triggered end-to-end by a conformance check that asserts +the code a real client receives: + +| Condition | Code | +|-----------|------| +| `undefined_table` | `42P01` | +| `undefined_column` | `42703` | +| `undefined_function` | `42883` | +| `ambiguous_function` | `42725` | +| `duplicate_table` | `42P07` | +| `unique_violation` | `23505` | +| `not_null_violation` | `23502` | +| `foreign_key_violation` | `23503` | +| `check_violation` | `23514` | +| `division_by_zero` | `22012` | +| `serialization_failure` | `40001` | +| `query_canceled` | `57014` | +| `raise_exception` | `P0001` | + +The right shape is a code at every raise site. There are several hundred, and a +half-converted error type would be worse than none — some codes honest, others +silently still `XX000`, with no way to tell which from outside. So the mapping +is in one place, keyed on the message text the engine produces, with one +exception: `ProtocolError::SqlState` carries a code explicitly, for the case +where the message cannot say. A `RAISE EXCEPTION` is `P0001` whatever text it +carries, and no amount of reading that text would reveal it. + +Classifying text is a contract between the raise sites and that table, and such +contracts drift. The guard is the conformance checks above: a reworded message +shows up as a failing check rather than as a silent return to `XX000`. An error +nobody has categorised still reports `XX000`, which is what it is — returning a +plausible-looking code for an unclassified error would be worse than admitting +it. + +#### Found by probing, not yet fixed + +Widening the harness into areas it had never covered found three defects that +were fixed (above) and three that were not. They are written down with the +statement that shows each, so they are gaps with evidence rather than a feeling +that something is missing: + +Two of the three were the same defect: `NUMERIC`, `DECIMAL`, `JSON`, `JSONB`, +`INTERVAL`, `BYTEA`, `UUID` and `CHAR` were reachable as **column** types but +missing from the **cast-target** list, so a type you could declare was not a +type you could cast to. The parser now accepts them with an optional +`(precision, scale)`, and the conversions exist: a declared scale is rendered +in full (`1.5::NUMERIC(10,2)` is `1.50`, not `1.5`) because rounding alone +leaves the value at its original scale. A malformed value is still refused — +adding the cast must not make everything castable, and there is a check for +that. + +Date arithmetic followed: only the *timestamp* forms existed, so +`DATE '2024-01-01' + INTERVAL '1 day'` — the way anyone writes it — failed. +`date + interval` (a timestamp, as in PostgreSQL), `date ± integer` (a date), +`date - date` (a count of days) and `interval ± interval` all work now. + +Chasing the `NUMERIC(10,2)` scale found something larger: **`ColumnType` had no +numeric variant at all**, so `NUMERIC(10,2)` matched none of the declared type +names and fell through to the unknown case, which is `TEXT`. The declared scale +existed nowhere, and a type that exists specifically to avoid binary floating +point was not being stored as one. `ColumnType::Numeric { precision, scale }` +now exists, the DDL parser produces it, `pg_type` reports `NUMERIC`, and a +stored value is read back as an exact decimal at its declared scale. + +And chasing *that* found the worst of the round, which had nothing to do with +numerics: the storage matcher compared only `BigInt` against `BigInt`, so +`WHERE amount > 5` on a numeric, float or text column **matched no rows at +all**. Not an error — an empty result. `WHERE id > 1` on an integer column +worked, which is why it had never been noticed. Comparison is now numeric +across the integer, float and decimal types, ordered for text, dates, +timestamps and booleans, and `None` — no match — only for values that genuinely +cannot be compared. + +What remains from that round: + +Following the `NUMERIC(10,2)` rendering to its cause found something larger +than rendering. `SqlType` → `ColumnType` mapping in +`orbit/server/src/protocols/common/storage/unified.rs` had no arm for +`Numeric`, `Decimal`, `Real` or `DoublePrecision`, so all of them fell to +`_ => ColumnType::Text`: **every numeric and floating-point column was stored +as text**, and the declared scale existed nowhere. With the mapping added, +`SELECT amt` renders `10.50`, `SUM` is exact, comparisons work, and arithmetic +on the column keeps the type. Two things had to follow it — arithmetic and +`SUM` over an exact decimal, neither of which had an arm — because values that +had been floats were now decimals. + +A first attempt to fix the rendering by patching `execute_persistent_select` +was **removed**: no query reached it, so it changed no output, and code that +changes no output is the decorative kind this document argues against +elsewhere. + +#### An UPDATE with an expression is silently lost + +`UPDATE t SET n = n + 1` changes nothing. `RETURNING` reports the new value, so +a client is told a write happened that did not — the same combination as a +wrong answer, and worse than an error. The `SET` value is stored through +`literal_to_json`, which turns anything that is not a literal into text. + +**A fix for this was written and reverted.** Computing each expression against +the row being updated made it worse: rows disappeared entirely, because the +per-row write interacts with the versioned-update logic that marks the previous +row deleted and re-inserts a new version. Losing rows is worse than failing to +update them, so the change came out and the defect stands recorded rather than +half-fixed. Whoever takes it next should start from the interaction with +version marking, not from the expression evaluation. + +The lesson is about the number rather than the three: the check count had been +presented as covering the remaining work, and one afternoon of probing +untested constructs found six things wrong. Two of them — +`WHERE id = ANY(...)` and `WHERE id = 1 + 1` — returned wrong rows rather than +errors, which is the class this document keeps recording and the class no +passing suite reveals until someone writes the check. + +#### Observed once, unexplained + +A single run of the conformance harness failed one check with +`connect: authentication error: invalid nonce`; eight consecutive runs before +and after were clean, and it has not reproduced. The SCRAM nonce alphabet was +checked and is correct — `0x21..=0x7E` with the comma removed, 93 code points, +and the shift that skips the comma cannot reach `DEL`. The handshake state is +per-session, so there is no shared nonce to race on. That leaves it unexplained +rather than fixed, and it is written down here because an intermittent +authentication failure is not something to leave in a passing run's shadow. + #### Not yet implemented - **Isolation is by write stamping with per-transaction row versions.** Every @@ -1046,9 +1238,23 @@ read. - Every write path undoes per row: `TRUNCATE` records each row it removes as a pre-image, `INSERT ... SELECT` predicts the rows it will add by running its select, and `COPY` records each copied line. -- **GSSAPI encryption** is refused explicitly: it needs a Kerberos KDC to - authenticate against, which cannot be exercised here, and an unverified - implementation would be worse than its absence. +- **`CancelRequest` interrupts the statement running**, not only the one after + it. The session's flag is checked every 512 rows in the storage fetch and in + the filter, so a scan stops part-way: a cancel 0.3s into a 200,000-row scan + ended it at 1.56s against 4.04s uncancelled, with no rows and + "canceling statement due to user request". A wrong secret is ignored. What + remains unchecked is the phase that formats and sends the result, so a cancel + arriving after the last row is read is honoured only when the next statement + starts. +- **GSSAPI encryption** is declined the way the protocol defines: a + `GSSENCRequest` is answered with a single `N` and the client continues in the + clear on the same connection, which is exactly what PostgreSQL built without + `--with-gssapi` does. There is a conformance check for both halves — the + answer and the fact that the session survives it. + What is absent is the *Kerberos integration*, not a wire message: validating + a ticket needs a KDC and a keytab. Shipping a handshake that cannot check a + token would add an authentication path whose only honest outcome is failure, + and whose dishonest outcome is accepting anyone. - **Replication is logical only.** A connection opened with `replication=database` answers `IDENTIFY_SYSTEM`, `CREATE_REPLICATION_SLOT`, `DROP_REPLICATION_SLOT`, `TIMELINE_HISTORY` and `START_REPLICATION`, then @@ -1057,15 +1263,62 @@ read. catalog and survive a restart; a standby status update records the confirmed position on the slot and answers a requested keepalive; a stream can replay from a named LSN or from the slot's confirmed position. -- **What replication still lacks:** physical replication (`START_REPLICATION - PHYSICAL` is not distinguished from logical), a real WAL — the replay window - is the last 4096 changes held in memory, and a request older than that is - refused rather than served with a hole; the JSON payload is one plugin's - format rather than `pgoutput`; `TIMELINE_HISTORY` reports that the current - timeline has no history file, because there is only ever one. -- **The fastpath `FunctionCall` message** answers an error naming the OID - rather than executing the function. It previously answered nothing at all, - leaving the client waiting on a reply that never came. + The payload format follows the slot's plugin: `pgoutput` emits the binary + `Begin`/`Relation`/`Insert`/`Update`/`Delete`/`Commit` messages a real + subscriber decodes, and anything else gets JSON. Changes are written to a + durable log as part of the write, so a replica that reconnects after a + restart replays from disk, and positions continue where the last run left + off rather than restarting at one. A block's statements arrive inside one + `Begin`/`Commit` pair rather than as several transactions. A subscriber that + passes `binary 'true'` gets values in binary rather than as text. +- **What replication still lacks:** physical replication is refused rather than + served, with `0A000` (`feature_not_supported`) and a session that stays + usable — reported as `XX000` a client could not tell a feature this server + does not have from a backend that fell over. + The contract of `START_REPLICATION ... PHYSICAL` is "send me your WAL". There + is no PostgreSQL WAL here: storage is RocksDB plus a logical change log, with + no page layout, no consistent checkpoint and no LSNs that mean what a standby + reads them to mean. Synthesising records in that format would not be an + approximation, it would be a stream that corrupts any standby that trusts + it. Serving this is not a protocol gap to close but PostgreSQL's storage + engine to reimplement. + The change log is a table, not a WAL: it is trimmed to the slowest slot's + confirmed position by `VACUUM` and by the background worker, and a slot that + falls more than 100,000 changes behind is invalidated so one dead subscriber + cannot hold the log open for ever — what `max_slot_wal_keep_size` protects + against in PostgreSQL. The bound is + `protocols.postgresql.max_slot_change_backlog`, defaulted so an existing + configuration file keeps working. + `TIMELINE_HISTORY` reports that the current timeline has no history file, + because there is only ever one. +- **A write is logged only while a slot exists**, so a server nobody replicates + from pays nothing. With a subscriber, a statement's changes are written as a + single batched log row rather than one row each, so a 1000-row `INSERT` costs + one row rather than a thousand, and a replay reads only the batches after the + position it asks for rather than the whole log. A batch is retained until + every change in it has been confirmed. What remains is that the underlying + storage answers a predicate by scanning its rows, so the read is proportional + to the log's size rather than to the answer — bounded by the trim and the + backlog setting, but not indexed. +- **The fastpath `FunctionCall` message** executes a function this server + published in `pg_proc`. Stored functions get OIDs in PostgreSQL's user range + (at or above 16384), derived from the catalog key rather than from position, + so an OID survives a restart and does not shift when another function is + created or dropped — an OID that moved would make `pg_proc` useless for the + thing OIDs are for. An OID this server did not publish is refused by number + with `42883`, and the session stays usable; guessing which built-in a number + meant would have the client silently calling something else. + The message parser had to be fixed first: it read the argument count where + the *format code* array sits, so it misread every call a real client sends. + Arguments are read in either format. Binary is decoded against the + parameter's declared type from the same `pg_proc` entry the client took the + OID from: `int2`/`int4`/`int8`/`float4`/`float8` big-endian, `bool` as one + byte, and the string types as their bytes. A value of the wrong length is + `22P03` naming both lengths rather than a silently wrong number, and a type + with no binary form here (`numeric`, whose binary shape is digit groups with + a weight and a sign; `date`/`timestamp`, whose epoch is not the Unix one) is + refused as `0A000` rather than read approximately. The result is returned in + the format asked for. - **The deferred pass re-reads every row of the tables the transaction wrote**, not only the rows it changed. Scoped to those tables rather than the whole database, but still proportional to their size. @@ -1074,12 +1327,154 @@ read. `INSERT`/`UPDATE`/`DELETE`, `FOR EACH ROW`/`STATEMENT`, `WHEN (...)`, `NEW`/`OLD` column references, `SET NEW.col = ` on both `BEFORE INSERT` and `BEFORE UPDATE`, a `$$BEGIN ... END$$` body of several - statements, and `RAISE` to reject a write. There is still no PL/pgSQL: - variables, loops, `IF`, and `RETURN` are not expressible, and a - `BEFORE UPDATE` rewrite is applied only when the statement changes exactly - one row. + statements, and `RAISE` to reject a write. A body that declares a variable, branches or loops is + handed to the PL/pgSQL interpreter instead (see below); a `BEFORE UPDATE` + rewrite is still applied only when the statement changes exactly one row. - A recursive CTE is bounded at 1,000 rounds and fails loudly rather than running forever if it does not settle. +- **PL/pgSQL** (`orbit/server/src/protocols/postgres_wire/plpgsql.rs`) covers + `DECLARE` with typed variables and defaults, assignment, `IF`/`ELSIF`/`ELSE`, + `WHILE`, `FOR v IN [REVERSE] a..b`, bare `LOOP`, `EXIT`/`CONTINUE` with an + optional `WHEN`, `RETURN`, `RAISE` at every level, `PERFORM`, + `SELECT ... INTO`, and any SQL statement. It runs `DO $$ ... $$`, + `CREATE FUNCTION ... LANGUAGE plpgsql` and calls to those functions, and any + trigger body that needs more than a list of statements. Functions are stored + in the catalog and survive a restart. + No expression is evaluated here: an expression is captured as tokens, + variable references are substituted, and the result is handed to the SQL + engine as `SELECT ` — one implementation of every operator rather than + a second one that would drift. Substitution works on tokens, so a variable's + name inside a string literal is left alone. + A block is atomic. It runs in a transaction, and a failure removes what it + wrote by stamp — rows carrying the block's id are deleted and rows it marked + deleted are unmarked — before the id is retired, so nobody can read a row + that is about to be removed. Inside an open transaction the block joins it + rather than starting its own, and `ROLLBACK` covers it. + `FOR rec IN SELECT ... LOOP` iterates a query's rows, with columns read as + `rec.column`; a single-column row is also readable under the bare name. + Substitution understands `rec.column` as one reference, so an ordinary + `table.column` in SQL is left untouched. + `RETURN QUERY` accumulates rows, which makes a function set-returning: the + first query fixes the column names and later ones append. + `BEGIN ... EXCEPTION WHEN OTHERS THEN ... END` catches. What the protected + statements wrote is undone before the handler runs — catching without undoing + would leave the half-finished write a handler exists to prevent — and + `SQLERRM` carries the raised message. A sub-transaction that commits is + registered with the block containing it, so if *that* fails later its rows go + too. Variable values survive a caught exception; only database writes are + undone, as in PostgreSQL. + A parameter's declared type decides whether its value is quoted when + substituted. Without that a `TEXT` argument was pasted in bare and + `greet('world')` failed with `column "world" does not exist` — a defect in + the first version of this module, found by testing a text argument rather + than an integer one. + + `RETURN NEXT` appends one value at a time, alongside `RETURN QUERY`. + Cursors are declared as `c CURSOR FOR `, then `OPEN`, `FETCH [NEXT + FROM] c INTO v[, v...]`, `CLOSE`, and `FOR r IN c LOOP`; `FOUND` reports + whether the last `FETCH` returned a row, which is what ends a fetch loop. + `FOUND` is spelled `TRUE`/`FALSE` rather than `t`/`f` — substituted bare into + `EXIT WHEN NOT FOUND`, a `t` is an identifier and the statement failed with + `column "t" does not exist`. + `table.column%TYPE` takes that column's declared type, resolved against the + catalog when the block runs, and `table%ROWTYPE` brings the table's columns + into scope as `name.column`. An unknown column leaves the type unknown rather + than guessing: quoting is then read off whatever the variable is assigned. + Functions are keyed by name **and argument count**, so `f(a)` and `f(a, b)` + coexist; `DROP FUNCTION f` removes every arity of the name. + + A named exception condition catches its own failure and no other: + `WHEN unique_violation` catches a duplicate key and lets a missing table + through, and `WHEN raise_exception` catches a `RAISE`. A condition name this + server does not define matches nothing rather than everything. + + `OUT` and `INOUT` parameters are supported: a function with them answers with + their values, named after them, rather than with whatever `RETURN` said. + Overloading is resolved by argument **type**, following PostgreSQL's order. + Types are reduced to canonical names, so `INTEGER` and `INT4` are one type + and `INT8` another; a call keeps the candidates every argument converts to + (same category, not narrowing — `int4` reaches `int8` but not `int2`), then + prefers the candidate matching most arguments exactly, then resolves an + untyped literal towards the string category, then towards each category's + preferred type (`int4`, `text`, `float8`, `timestamptz`, `bool`). An + argument's type comes from how it was written where that says — a quoted + literal is `unknown` and fits either overload, an integer literal too large + for `int4` is `int8` — and from its evaluated value otherwise. + What survives all of that and is still tied is refused as `42725` + (`ambiguous_function`) rather than resolved by a coin toss the caller cannot + see; nothing viable is `42883` naming the argument types, as PostgreSQL also + phrases it. An argument that cannot be its declared type is `22P02` naming + the value and the type. + Parameter parsing is paren-aware, so `NUMERIC(10, 2)` is one parameter — it + had been split on every comma, and the stored form was joined on commas too, + so a type containing one was corrupted in both directions. + + A parameter declared as a **domain** is the type the domain is built on, + resolved against the catalogue per call so `ALTER DOMAIN` is seen by calls + made after it. `pg_proc` reports the base type's OID for one, because this + server assigns OIDs to functions and not to domains and reporting `text` for + a domain over `INTEGER` would tell a client the wrong thing about how to call + it. An **array** (`INTEGER[]`, `TEXT ARRAY`) carries its element type — + `_int4` and `_text` are different types, so `f(int4[])` and `f(text[])` + coexist — and never satisfies a scalar parameter. One array does not convert + to another: widening `int4[]` to `int8[]` would mean rebuilding every + element, which nothing here does. `pg_proc` reports the element type's array + OID (`1007` for `int4[]`, `1009` for `text[]`). + + A type name the lattice does not recognise **keeps its own spelling** rather + than becoming `text`. Collapsing it made every user-defined type — a + composite, an enum, anything — the same type as `text`, so `f(mytype)` and + `f(text)` could not both exist and a call to one could reach the other. + + A cast **to a domain** (`42::posint`) is a cast to what the domain is built + on. `SqlValue::cast_to` is pure and has no catalogue, so it consults a + registry the query engine keeps + (`orbit/server/src/protocols/postgres_wire/domains.rs`): written when a + domain is created, and loaded once at startup for those already stored, so a + cast works after a restart and not only in the session that created the + domain. A name that is *not* a known domain still fails — a typo'd type must + not silently pass the value through, and there is a check for exactly that. + + An argument that **names** a type is typed from that name rather than from + its value: `42::BIGINT` and `CAST(42 AS BIGINT)` are `int8` even though 42 + fits an `int4`, and a call to a function whose return type this server + recorded is typed from the catalogue — `f(g())` where `g` returns `BIGINT` + chooses the `int8` overload though the value it returns would read as + `int4`. A nested call is only typed this way when one candidate could have + been meant; two overloads may return different types, and picking one there + would be a guess dressed as a lookup. + + A stored function can be **called from inside a query** — + `SELECT f(id) FROM t`, `WHERE f(id) = 4`, `ORDER BY f(id)` — when its body + needs no database. The expression evaluator is synchronous, so only a *pure* + body is callable from it: assignments, conditionals, loops and `RETURN` over + expressions, with no SQL statement anywhere in it, checked recursively so a + branch or a nested block cannot smuggle one past + (`orbit/server/src/protocols/postgres_wire/stored_functions.rs`). A body that + does run SQL is refused there by name rather than run in a way that could + block a runtime worker; it still works as a direct `SELECT f(...)`. + + Inside a query the argument's **own type** chooses the overload: a value + reaching the evaluator carries it, so `SqlValue::BigInt` is not + `SqlValue::Integer` and `f(a_bigint_column)` selects the `int8` form even + though the value prints the same as an `int4` would. **Composite types** are created with + `CREATE TYPE name AS (field type, ...)`, stored in the catalogue, and + reported in `pg_type` with `typtype = 'c'` and an OID in the user range. A + PL/pgSQL variable of one brings its fields into scope as `variable.field`, + assignable and readable; a composite is its own type when choosing between + overloads, and `pg_proc` reports the same OID `pg_type` gives it. `DROP TYPE` + removes one and **refuses a name that was never there** with `42704` — + `DROP TYPE IF EXISTS` is the form that may say nothing. + Not every `CREATE TYPE` form is a composite: `AS ENUM` and the others are + left unhandled rather than stored as something claiming to be one. + A variable whose name matches a column of a table the block writes is + substituted, so `INSERT INTO t (v) VALUES (v)` with a variable `v` rewrites + the column name too. PostgreSQL resolves this ambiguity with + `#variable_conflict`; here the rule is simply that a variable always wins, so + name a variable something the statement does not also use as a column. + A loop is bounded at 10,000,000 iterations and fails loudly. PostgreSQL lets + one run forever, which is right for a dedicated backend process; here a + statement that never finishes holds a connection and a share of the runtime. Note on scope: 212/212 is 212 of *these 212 checks*. Each widening found real defects — 62 checks found none, 93 found fourteen, 130 found fourteen more, 157 diff --git a/tests/integration/pg_conformance.rs b/tests/integration/pg_conformance.rs index 4df8ed13..f302829a 100644 --- a/tests/integration/pg_conformance.rs +++ b/tests/integration/pg_conformance.rs @@ -129,6 +129,16 @@ impl Report { /// `tokio_postgres::Error` renders as the useless "db error"; the server's /// message is one level down. A conformance report whose failures all read /// "db error" cannot be acted on. +/// The SQLSTATE a failure carried, or `-` when it carried none. +/// +/// This is the thing a driver branches on, so it is checked directly rather +/// than through the message. +fn sqlstate(error: &tokio_postgres::Error) -> String { + error + .code() + .map_or_else(|| "-".to_string(), |code| code.code().to_string()) +} + fn describe(error: E) -> String { let mut message = error.to_string(); let mut source = error.source(); @@ -1577,6 +1587,34 @@ async fn postgres_protocol_conformance() { "NULL", ), ("LIMIT 0 returns nothing", "SELECT id FROM conf_four LIMIT 0", ""), + // A LIMIT lets the filter stop early, which is only sound when nothing + // downstream needs the rows it would skip. Each of these would return a + // wrong answer — not a slow one — if the early exit ignored its clause. + ( + "LIMIT does not truncate an aggregate", + "SELECT COUNT(*) FROM conf_four LIMIT 1", + "3", + ), + ( + "LIMIT does not truncate a GROUP BY", + "SELECT COUNT(*) FROM conf_four GROUP BY name LIMIT 1", + "1", + ), + ( + "ORDER BY with LIMIT sorts before it limits", + "SELECT id FROM conf_four ORDER BY id DESC LIMIT 1", + "3", + ), + ( + "LIMIT with OFFSET and no ORDER BY returns the right count", + "SELECT id FROM conf_four LIMIT 2 OFFSET 1", + "2,3", + ), + ( + "DISTINCT dedupes across every row, not just the limited ones", + "SELECT DISTINCT name FROM conf_four ORDER BY name LIMIT 1", + "alpha", + ), ( "CASE with no ELSE yields NULL", "SELECT CASE WHEN amount > 100 THEN 'big' END FROM conf_four WHERE id = 1", @@ -4266,6 +4304,1766 @@ async fn postgres_protocol_conformance() { .await, ); + // ---- PL/pgSQL ------------------------------------------------------ + // + // `DO` and `CREATE FUNCTION ... LANGUAGE plpgsql` both used to answer + // "Command completed successfully" and run nothing, so every check here + // would have passed its statement and failed its effect. Each one asserts + // what the block *did*, never that it was accepted. + + drop_table(&client, "conf_pl").await; + let _ = client + .simple_query("CREATE TABLE conf_pl (id INTEGER)") + .await; + + for (name, block, expect) in [ + ( + "a DO block runs its statement", + "DO $$ BEGIN INSERT INTO conf_pl (id) VALUES (1); END $$", + "1", + ), + ( + "a FOR loop runs its body once per value", + "DO $$ BEGIN FOR i IN 1..4 LOOP INSERT INTO conf_pl (id) VALUES (i); END LOOP; END $$", + "1,1,2,3,4", + ), + ( + "REVERSE counts down without changing the set", + "DO $$ BEGIN FOR i IN REVERSE 5..6 LOOP INSERT INTO conf_pl (id) VALUES (i); END LOOP; END $$", + "1,1,2,3,4,5,6", + ), + ( + "IF runs only the branch that holds", + "DO $$ BEGIN IF 1 > 2 THEN INSERT INTO conf_pl (id) VALUES (99); ELSE INSERT INTO conf_pl (id) VALUES (7); END IF; END $$", + "1,1,2,3,4,5,6,7", + ), + ( + "a declared variable is substituted", + "DO $$ DECLARE n INTEGER := 8; BEGIN INSERT INTO conf_pl (id) VALUES (n); END $$", + "1,1,2,3,4,5,6,7,8", + ), + ( + "a WHILE loop assigns and terminates", + "DO $$ DECLARE n INTEGER := 8; BEGIN WHILE n < 10 LOOP n := n + 1; INSERT INTO conf_pl (id) VALUES (n); END LOOP; END $$", + "1,1,2,3,4,5,6,7,8,9,10", + ), + ( + "EXIT WHEN leaves the loop early", + "DO $$ BEGIN FOR i IN 20..99 LOOP EXIT WHEN i > 21; INSERT INTO conf_pl (id) VALUES (i); END LOOP; END $$", + "1,1,2,3,4,5,6,7,8,9,10,20,21", + ), + ] { + report.record( + Area::Sql, + name, + async { + client.simple_query(block).await.map_err(describe)?; + let ids = simple_column(&client, "SELECT id FROM conf_pl ORDER BY id").await?; + let got = ids.join(","); + (got == expect) + .then_some(()) + .ok_or(format!("rows are {got}, expected {expect}")) + } + .await, + ); + } + + report.record( + Area::Sql, + "RAISE EXCEPTION aborts the block and its writes", + async { + let before = simple_column(&client, "SELECT COUNT(*) FROM conf_pl").await?; + let outcome = client + .simple_query( + "DO $$ BEGIN INSERT INTO conf_pl (id) VALUES (555); RAISE EXCEPTION 'refused'; END $$", + ) + .await; + let message = match outcome { + Ok(_) => return Err("the block was accepted; RAISE EXCEPTION did nothing".into()), + Err(e) => describe(e), + }; + if !message.contains("refused") { + return Err(format!("aborted with {message}, expected the raised message")); + } + // The negative control: the statement before the RAISE must not + // survive, or the abort is only cosmetic. + let after = simple_column(&client, "SELECT COUNT(*) FROM conf_pl").await?; + (after == before) + .then_some(()) + .ok_or(format!("row count went {before:?} -> {after:?} despite the abort")) + } + .await, + ); + + report.record( + Area::Sql, + "SELECT ... INTO binds a variable", + async { + client + .simple_query( + "DO $$ DECLARE c INTEGER; BEGIN SELECT COUNT(*) FROM conf_pl INTO c; INSERT INTO conf_pl (id) VALUES (1000 + c); END $$", + ) + .await + .map_err(describe)?; + // 13 rows were present, so the block must have written 1013. + let found = simple_column(&client, "SELECT id FROM conf_pl WHERE id > 1000").await?; + (found == ["1013"]) + .then_some(()) + .ok_or(format!("got {found:?}, expected [1013] from a bound COUNT")) + } + .await, + ); + + report.record( + Area::Sql, + "a plpgsql function returns a value to its caller", + async { + let _ = client.simple_query("DROP FUNCTION conf_double").await; + client + .simple_query( + "CREATE FUNCTION conf_double(x INTEGER) RETURNS INTEGER AS $$ BEGIN RETURN x * 2; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + let doubled = simple_column(&client, "SELECT conf_double(21)").await?; + (doubled == ["42"]) + .then_some(()) + .ok_or(format!("got {doubled:?}, expected [42]")) + } + .await, + ); + + report.record( + Area::Sql, + "a function body with a branch is interpreted, not just run", + async { + let _ = client.simple_query("DROP FUNCTION conf_sign").await; + client + .simple_query( + "CREATE FUNCTION conf_sign(x INTEGER) RETURNS TEXT AS $$ BEGIN IF x > 0 THEN RETURN 'positive'; ELSIF x < 0 THEN RETURN 'negative'; ELSE RETURN 'zero'; END IF; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + let mut seen = Vec::new(); + for argument in ["5", "-5", "0"] { + let answer = + simple_column(&client, &format!("SELECT conf_sign({argument})")).await?; + seen.push(answer.join("")); + } + (seen == ["positive", "negative", "zero"]) + .then_some(()) + .ok_or(format!("got {seen:?}, expected each branch in turn")) + } + .await, + ); + + report.record( + Area::Sql, + "a malformed block is refused rather than silently accepted", + async { + // The failure this guards against is the original one: a block + // that cannot be run reporting success. + match client + .simple_query("DO $$ BEGIN IF 1 > 0 THEN NULL; END $$") + .await + { + Ok(_) => Err("an IF with no END IF was accepted".into()), + Err(_) => Ok(()), + } + } + .await, + ); + + report.record( + Area::Sql, + "an expression in INSERT ... VALUES is evaluated, not stored as text", + async { + drop_table(&client, "conf_expr").await; + client + .simple_query("CREATE TABLE conf_expr (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_expr (id) VALUES (500 + 1)") + .await + .map_err(describe)?; + let stored = simple_column(&client, "SELECT id FROM conf_expr").await?; + if stored != ["501"] { + drop_table(&client, "conf_expr").await; + return Err(format!("stored {stored:?}, expected [501]")); + } + // Storing the text made every later comparison fail, which is how + // it stayed invisible: the row was there, it just never matched. + let matched = + simple_column(&client, "SELECT COUNT(*) FROM conf_expr WHERE id > 500").await?; + let result = (matched == ["1"]) + .then_some(()) + .ok_or(format!("comparison matched {matched:?}, expected [1]")); + drop_table(&client, "conf_expr").await; + result + } + .await, + ); + + // Everything above runs a block for its effect on one table. These check + // the constructs that read or return rows, and the one property a handler + // exists for: that what it catches left nothing behind. + + report.record( + Area::Sql, + "a TEXT argument reaches a function as a string", + async { + let _ = client.simple_query("DROP FUNCTION conf_greet").await; + client + .simple_query( + "CREATE FUNCTION conf_greet(who TEXT) RETURNS TEXT AS $$ BEGIN RETURN 'hello ' || who; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + // Substituting it unquoted made this `'hello ' || world`, which + // failed with `column "world" does not exist`. + let greeting = simple_column(&client, "SELECT conf_greet('world')").await?; + (greeting == ["hello world"]) + .then_some(()) + .ok_or(format!("got {greeting:?}, expected [hello world]")) + } + .await, + ); + + report.record( + Area::Sql, + "FOR ... IN SELECT reads each row's columns", + async { + drop_table(&client, "conf_src").await; + drop_table(&client, "conf_dst").await; + client + .simple_query("CREATE TABLE conf_src (id INTEGER, name TEXT)") + .await + .map_err(describe)?; + client + .simple_query("CREATE TABLE conf_dst (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_src (id, name) VALUES (1, 'a'), (2, 'b'), (3, 'c')") + .await + .map_err(describe)?; + client + .simple_query( + "DO $$ BEGIN FOR r IN SELECT id, name FROM conf_src LOOP INSERT INTO conf_dst (id) VALUES (r.id * 10); END LOOP; END $$", + ) + .await + .map_err(describe)?; + let ids = simple_column(&client, "SELECT id FROM conf_dst ORDER BY id").await?; + (ids == ["10", "20", "30"]) + .then_some(()) + .ok_or(format!("got {ids:?}, expected [10, 20, 30]")) + } + .await, + ); + + report.record( + Area::Sql, + "RETURN QUERY makes a function set-returning", + async { + let _ = client.simple_query("DROP FUNCTION conf_big").await; + client + .simple_query( + "CREATE FUNCTION conf_big() RETURNS SETOF INTEGER AS $$ BEGIN RETURN QUERY SELECT id FROM conf_src WHERE id > 1; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + let ids = simple_column(&client, "SELECT conf_big()").await?; + (ids == ["2", "3"]) + .then_some(()) + .ok_or(format!("got {ids:?}, expected two rows [2, 3]")) + } + .await, + ); + + report.record( + Area::Sql, + "an EXCEPTION handler catches and the block carries on", + async { + client + .simple_query( + "DO $$ BEGIN BEGIN RAISE EXCEPTION 'boom'; EXCEPTION WHEN OTHERS THEN INSERT INTO conf_dst (id) VALUES (777); END; END $$", + ) + .await + .map_err(describe)?; + let found = simple_column(&client, "SELECT id FROM conf_dst WHERE id = 777").await?; + (found == ["777"]) + .then_some(()) + .ok_or("the handler did not run".to_string()) + } + .await, + ); + + report.record( + Area::Sql, + "a caught exception undoes what the protected block wrote", + async { + client + .simple_query( + "DO $$ BEGIN BEGIN INSERT INTO conf_dst (id) VALUES (888); RAISE EXCEPTION 'x'; EXCEPTION WHEN OTHERS THEN NULL; END; END $$", + ) + .await + .map_err(describe)?; + // Catching without undoing would leave a half-finished write, + // which is the thing a handler exists to prevent. + let left = simple_column(&client, "SELECT COUNT(*) FROM conf_dst WHERE id = 888") + .await?; + (left == ["0"]) + .then_some(()) + .ok_or(format!("{left:?} rows survived a caught exception")) + } + .await, + ); + + report.record( + Area::Sql, + "SQLERRM carries the raised message", + async { + client + .simple_query( + "DO $$ BEGIN BEGIN RAISE EXCEPTION 'the reason'; EXCEPTION WHEN OTHERS THEN INSERT INTO conf_src (id, name) VALUES (99, SQLERRM); END; END $$", + ) + .await + .map_err(describe)?; + let message = simple_column(&client, "SELECT name FROM conf_src WHERE id = 99").await?; + (message == ["the reason"]) + .then_some(()) + .ok_or(format!("got {message:?}, expected [the reason]")) + } + .await, + ); + + report.record( + Area::Sql, + "an unhandled exception still reaches the client", + async { + match client + .simple_query("DO $$ BEGIN BEGIN RAISE EXCEPTION 'unhandled'; END; END $$") + .await + { + Ok(_) => Err("a block with no handler swallowed its exception".into()), + Err(e) => { + let message = describe(e); + message.contains("unhandled").then_some(()).ok_or(format!( + "failed with {message}, expected the raised message" + )) + } + } + } + .await, + ); + + report.record( + Area::Sql, + "RETURN NEXT appends one value at a time", + async { + let _ = client.simple_query("DROP FUNCTION conf_next").await; + client + .simple_query( + "CREATE FUNCTION conf_next() RETURNS SETOF INTEGER AS $$ BEGIN RETURN NEXT 10; RETURN NEXT 20; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + let values = simple_column(&client, "SELECT conf_next()").await?; + (values == ["10", "20"]) + .then_some(()) + .ok_or(format!("got {values:?}, expected [10, 20]")) + } + .await, + ); + + report.record( + Area::Sql, + "a cursor is opened, fetched from and closed", + async { + client + .simple_query("DELETE FROM conf_dst") + .await + .map_err(describe)?; + client + .simple_query( + "DO $$ DECLARE c CURSOR FOR SELECT id FROM conf_src ORDER BY id; v INTEGER; BEGIN OPEN c; FETCH c INTO v; INSERT INTO conf_dst (id) VALUES (v); FETCH c INTO v; INSERT INTO conf_dst (id) VALUES (v); CLOSE c; END $$", + ) + .await + .map_err(describe)?; + let ids = simple_column(&client, "SELECT id FROM conf_dst ORDER BY id").await?; + (ids == ["1", "2"]) + .then_some(()) + .ok_or(format!("got {ids:?}, expected the first two rows [1, 2]")) + } + .await, + ); + + report.record( + Area::Sql, + "FOUND reports whether a FETCH returned a row", + async { + client + .simple_query("DELETE FROM conf_dst") + .await + .map_err(describe)?; + // The loop can only terminate if FOUND goes false at the end, and + // it can only run at all if FOUND is usable in a condition — + // spelled `t` rather than `TRUE` it failed with + // `column "t" does not exist`. + client + .simple_query( + "DO $$ DECLARE c CURSOR FOR SELECT id FROM conf_src WHERE id < 10; v INTEGER; BEGIN OPEN c; LOOP FETCH c INTO v; EXIT WHEN NOT FOUND; INSERT INTO conf_dst (id) VALUES (v); END LOOP; CLOSE c; END $$", + ) + .await + .map_err(describe)?; + let counted = simple_column(&client, "SELECT COUNT(*) FROM conf_dst").await?; + (counted == ["3"]) + .then_some(()) + .ok_or(format!("got {counted:?} rows, expected all 3")) + } + .await, + ); + + report.record( + Area::Sql, + "a cursor FOR loop walks every row", + async { + client + .simple_query("DELETE FROM conf_dst") + .await + .map_err(describe)?; + client + .simple_query( + "DO $$ DECLARE c CURSOR FOR SELECT id FROM conf_src WHERE id < 10; BEGIN FOR r IN c LOOP INSERT INTO conf_dst (id) VALUES (r.id); END LOOP; END $$", + ) + .await + .map_err(describe)?; + let counted = simple_column(&client, "SELECT COUNT(*) FROM conf_dst").await?; + (counted == ["3"]) + .then_some(()) + .ok_or(format!("got {counted:?} rows, expected 3")) + } + .await, + ); + + report.record( + Area::Sql, + "a %TYPE variable takes the column's type", + async { + // `name` is TEXT, so the value must be quoted when substituted. + // Taking it as untyped would paste it in bare and fail. + client + .simple_query( + "DO $$ DECLARE n conf_src.name%TYPE; BEGIN n := 'from a domain'; INSERT INTO conf_src (id, name) VALUES (50, n); END $$", + ) + .await + .map_err(describe)?; + let found = simple_column(&client, "SELECT name FROM conf_src WHERE id = 50").await?; + (found == ["from a domain"]) + .then_some(()) + .ok_or(format!("got {found:?}, expected [from a domain]")) + } + .await, + ); + + report.record( + Area::Sql, + "two functions of one name are told apart by argument count", + async { + let _ = client.simple_query("DROP FUNCTION conf_over").await; + client + .simple_query( + "CREATE FUNCTION conf_over(a INTEGER) RETURNS INTEGER AS $$ BEGIN RETURN a; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + client + .simple_query( + "CREATE FUNCTION conf_over(a INTEGER, b INTEGER) RETURNS INTEGER AS $$ BEGIN RETURN a + b; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + let one = simple_column(&client, "SELECT conf_over(5)").await?; + let two = simple_column(&client, "SELECT conf_over(5, 6)").await?; + // Defining the second must not have replaced the first. + (one == ["5"] && two == ["11"]) + .then_some(()) + .ok_or(format!("got {one:?} and {two:?}, expected [5] and [11]")) + } + .await, + ); + + report.record( + Area::Sql, + "DROP FUNCTION removes every arity of the name", + async { + client + .simple_query("DROP FUNCTION conf_over") + .await + .map_err(describe)?; + match client.simple_query("SELECT conf_over(5)").await { + Ok(_) => Err("the one-argument form survived the drop".into()), + Err(_) => match client.simple_query("SELECT conf_over(5, 6)").await { + Ok(_) => Err("the two-argument form survived the drop".into()), + Err(_) => Ok(()), + }, + } + } + .await, + ); + + // ---- SQLSTATE ------------------------------------------------------ + // + // Every error used to leave as XX000, so a driver could not tell a + // duplicate key from a crashed backend. These trigger each condition + // through the real code path and assert the code a client receives — + // which is also what keeps the classifier from drifting as messages are + // reworded. + + drop_table(&client, "conf_state").await; + drop_table(&client, "conf_state_child").await; + let _ = client + .simple_query( + "CREATE TABLE conf_state (id INTEGER PRIMARY KEY, n TEXT NOT NULL, amt INTEGER CHECK (amt > 0))", + ) + .await; + let _ = client + .simple_query( + "CREATE TABLE conf_state_child (id INTEGER, pid INTEGER REFERENCES conf_state(id))", + ) + .await; + let _ = client + .simple_query("INSERT INTO conf_state (id, n, amt) VALUES (1, 'a', 5)") + .await; + + for (name, sql, want) in [ + ( + "undefined_table is 42P01", + "SELECT * FROM conf_no_such_table", + "42P01", + ), + ( + "undefined_column is 42703", + "SELECT no_such_column FROM conf_state", + "42703", + ), + ( + "duplicate_table is 42P07", + "CREATE TABLE conf_state (id INTEGER)", + "42P07", + ), + ( + "unique_violation is 23505", + "INSERT INTO conf_state (id, n, amt) VALUES (1, 'b', 5)", + "23505", + ), + ( + "not_null_violation is 23502", + "INSERT INTO conf_state (id, amt) VALUES (2, 5)", + "23502", + ), + ( + "check_violation is 23514", + "INSERT INTO conf_state (id, n, amt) VALUES (3, 'c', -1)", + "23514", + ), + ( + "foreign_key_violation is 23503", + "INSERT INTO conf_state_child (id, pid) VALUES (1, 999)", + "23503", + ), + ("division_by_zero is 22012", "SELECT 1/0", "22012"), + ( + "undefined_function is 42883", + "SELECT conf_no_such_function(1)", + "42883", + ), + ( + "raise_exception is P0001", + "DO $$ BEGIN RAISE EXCEPTION 'raised'; END $$", + "P0001", + ), + ] { + report.record( + Area::Sql, + name, + async { + match client.simple_query(sql).await { + Ok(_) => Err(format!( + "{sql} was accepted; expected it to fail with {want}" + )), + Err(e) => { + let got = sqlstate(&e); + (got == want) + .then_some(()) + .ok_or(format!("reported {got}, expected {want}")) + } + } + } + .await, + ); + } + + report.record( + Area::Sql, + "an error message does not carry the transport's name", + async { + match client + .simple_query("SELECT * FROM conf_no_such_table") + .await + { + Ok(_) => Err("the missing table was accepted".into()), + Err(e) => { + let message = describe(e); + // `PostgreSQL protocol error: ...` is our plumbing showing + // through; a client sees the condition, not the pipe. + (!message.contains("protocol error")) + .then_some(()) + .ok_or(format!("message leaked the transport: {message}")) + } + } + } + .await, + ); + + report.record( + Area::Sql, + "a named EXCEPTION condition catches only its own failure", + async { + drop_table(&client, "conf_caught").await; + client + .simple_query("CREATE TABLE conf_caught (what TEXT)") + .await + .map_err(describe)?; + + // Catches: the condition names what actually happened. + client + .simple_query( + "DO $$ BEGIN BEGIN INSERT INTO conf_state (id, n, amt) VALUES (1, 'x', 5); EXCEPTION WHEN unique_violation THEN INSERT INTO conf_caught (what) VALUES ('caught'); END; END $$", + ) + .await + .map_err(describe)?; + let caught = simple_column(&client, "SELECT what FROM conf_caught").await?; + if caught != ["caught"] { + return Err(format!("WHEN unique_violation did not catch: {caught:?}")); + } + + // Does not catch: a different condition must let it through, or + // the matching is just `OTHERS` wearing a name. + match client + .simple_query( + "DO $$ BEGIN BEGIN INSERT INTO conf_state (id, n, amt) VALUES (1, 'y', 5); EXCEPTION WHEN division_by_zero THEN NULL; END; END $$", + ) + .await + { + Ok(_) => Err("WHEN division_by_zero swallowed a unique violation".into()), + Err(e) => { + let got = sqlstate(&e); + (got == "23505") + .then_some(()) + .ok_or(format!("escaped with {got}, expected 23505")) + } + } + } + .await, + ); + + report.record( + Area::Sql, + "OUT parameters are the function's result", + async { + let _ = client.simple_query("DROP FUNCTION conf_out").await; + client + .simple_query( + "CREATE FUNCTION conf_out(a INTEGER, OUT dbl INTEGER, OUT trp INTEGER) AS $$ BEGIN dbl := a * 2; trp := a * 3; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + // Two values, named after the parameters — not one anonymous + // column, and not whatever RETURN would have said. + let rows = client + .simple_query("SELECT conf_out(4)") + .await + .map_err(describe)?; + let row = rows + .iter() + .find_map(|m| match m { + tokio_postgres::SimpleQueryMessage::Row(r) => Some(r), + _ => None, + }) + .ok_or("no row came back")?; + let got: Vec = (0..row.len()) + .map(|i| row.get(i).unwrap_or("NULL").to_string()) + .collect(); + (got == ["8", "12"]) + .then_some(()) + .ok_or(format!("got {got:?}, expected [8, 12]")) + } + .await, + ); + + report.record( + Area::Sql, + "INOUT both takes and returns", + async { + let _ = client.simple_query("DROP FUNCTION conf_bump").await; + client + .simple_query( + "CREATE FUNCTION conf_bump(INOUT n INTEGER) AS $$ BEGIN n := n + 1; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + let bumped = simple_column(&client, "SELECT conf_bump(41)").await?; + (bumped == ["42"]) + .then_some(()) + .ok_or(format!("got {bumped:?}, expected [42]")) + } + .await, + ); + + report.record( + Area::Sql, + "two functions of one name and arity are told apart by type", + async { + let _ = client.simple_query("DROP FUNCTION conf_kind").await; + client + .simple_query( + "CREATE FUNCTION conf_kind(a INTEGER) RETURNS TEXT AS $$ BEGIN RETURN 'number'; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + client + .simple_query( + "CREATE FUNCTION conf_kind(a TEXT) RETURNS TEXT AS $$ BEGIN RETURN 'text'; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + // Same name, same arity: keyed by count alone the second would + // have replaced the first. + let number = simple_column(&client, "SELECT conf_kind(5)").await?; + let text = simple_column(&client, "SELECT conf_kind('x')").await?; + (number == ["number"] && text == ["text"]) + .then_some(()) + .ok_or(format!("got {number:?} and {text:?}")) + } + .await, + ); + + for (name, definitions, call, want) in [ + ( + "INTEGER and BIGINT overloads are told apart", + &["a INTEGER", "a BIGINT"][..], + &["SELECT conf_width(42)", "SELECT conf_width(5000000000)"][..], + &["a INTEGER", "a BIGINT"][..], + ), + ( + "VARCHAR and TEXT overloads are told apart", + &["a VARCHAR", "a TEXT"][..], + &["SELECT conf_width('x')"][..], + // An untyped literal fits both; PostgreSQL prefers text. + &["a TEXT"][..], + ), + ( + "a call widens to the only candidate that fits", + &["a SMALLINT", "a BIGINT"][..], + // int4 cannot narrow to int2, so only the BIGINT form is viable. + &["SELECT conf_width(42)"][..], + &["a BIGINT"][..], + ), + ( + "an exact type match beats an implicit conversion", + &["a NUMERIC", "a INTEGER"][..], + &["SELECT conf_width(7)"][..], + &["a INTEGER"][..], + ), + ] { + report.record( + Area::Sql, + name, + async { + let _ = client.simple_query("DROP FUNCTION conf_width").await; + for declaration in definitions { + client + .simple_query(&format!( + "CREATE FUNCTION conf_width({declaration}) RETURNS TEXT AS $$ BEGIN RETURN '{declaration}'; END $$ LANGUAGE plpgsql" + )) + .await + .map_err(describe)?; + } + for (call, want) in call.iter().zip(want) { + let got = simple_column(&client, call).await?; + if got != [(*want).to_string()] { + return Err(format!("{call} chose {got:?}, expected [{want}]")); + } + } + Ok(()) + } + .await, + ); + } + + report.record( + Area::Catalog, + "pg_proc lists a stored function with its types", + async { + let _ = client.simple_query("DROP FUNCTION conf_proc").await; + client + .simple_query( + "CREATE FUNCTION conf_proc(a INTEGER) RETURNS TEXT AS $$ BEGIN RETURN 'x'; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + // 23 is int4 and 25 is text: the argument and return types a + // client reads to know how to call it. + let described = simple_column( + &client, + "SELECT pronargs FROM pg_proc WHERE proname = 'conf_proc'", + ) + .await?; + if described != ["1"] { + return Err(format!("pronargs is {described:?}, expected [1]")); + } + let argument_types = simple_column( + &client, + "SELECT proargtypes FROM pg_proc WHERE proname = 'conf_proc'", + ) + .await?; + if argument_types != ["23"] { + return Err(format!("proargtypes is {argument_types:?}, expected [23]")); + } + let return_type = simple_column( + &client, + "SELECT prorettype FROM pg_proc WHERE proname = 'conf_proc'", + ) + .await?; + (return_type == ["25"]) + .then_some(()) + .ok_or(format!("prorettype is {return_type:?}, expected [25]")) + } + .await, + ); + + report.record( + Area::Catalog, + "a function's OID is in the user range and does not move", + async { + let before = + simple_column(&client, "SELECT oid FROM pg_proc WHERE proname = 'conf_proc'") + .await?; + let oid: i64 = before + .first() + .and_then(|value| value.parse().ok()) + .ok_or("no OID reported")?; + // PostgreSQL reserves everything below 16384 for built-in objects. + if oid < 16_384 { + return Err(format!("OID {oid} is in the reserved range")); + } + + // Creating another function must not shift it: an OID that moved + // would make pg_proc useless for the thing OIDs are for. + let _ = client.simple_query("DROP FUNCTION conf_proc_other").await; + client + .simple_query( + "CREATE FUNCTION conf_proc_other() RETURNS INTEGER AS $$ BEGIN RETURN 1; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + let after = + simple_column(&client, "SELECT oid FROM pg_proc WHERE proname = 'conf_proc'") + .await?; + (after == before) + .then_some(()) + .ok_or(format!("OID moved from {before:?} to {after:?}")) + } + .await, + ); + + report.record( + Area::Sql, + "a cast names an argument's type, overriding its value", + async { + for name in ["conf_kindof", "conf_ret8"] { + let _ = client.simple_query(&format!("DROP FUNCTION {name}")).await; + } + for (declaration, answer) in [ + ("a INTEGER", "int4"), + ("a BIGINT", "int8"), + ("a TEXT", "text"), + ] { + client + .simple_query(&format!( + "CREATE FUNCTION conf_kindof({declaration}) RETURNS TEXT AS $$ BEGIN RETURN '{answer}'; END $$ LANGUAGE plpgsql" + )) + .await + .map_err(describe)?; + } + + // 42 fits an int4, so its value says int4; the cast says + // otherwise and the cast is what a caller wrote down. + let plain = simple_column(&client, "SELECT conf_kindof(42)").await?; + let cast = simple_column(&client, "SELECT conf_kindof(42::BIGINT)").await?; + let spelled = simple_column(&client, "SELECT conf_kindof(CAST(42 AS BIGINT))").await?; + let textual = simple_column(&client, "SELECT conf_kindof(42::TEXT)").await?; + (plain == ["int4"] && cast == ["int8"] && spelled == ["int8"] && textual == ["text"]) + .then_some(()) + .ok_or(format!( + "got {plain:?}, {cast:?}, {spelled:?}, {textual:?}; expected int4, int8, int8, text" + )) + } + .await, + ); + + report.record( + Area::Sql, + "a nested call is typed from the catalogue, not its value", + async { + client + .simple_query( + "CREATE FUNCTION conf_ret8() RETURNS BIGINT AS $$ BEGIN RETURN 1; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + // The value 1 fits an int4; only the catalogue knows the function + // was declared to return int8. + let chosen = simple_column(&client, "SELECT conf_kindof(conf_ret8())").await?; + (chosen == ["int8"]) + .then_some(()) + .ok_or(format!("got {chosen:?}, expected [int8]")) + } + .await, + ); + + report.record( + Area::Sql, + "a domain parameter is the type it is built on", + async { + let _ = client.simple_query("DROP FUNCTION conf_dom").await; + let _ = client + .simple_query("CREATE DOMAIN conf_posint AS INTEGER CHECK (VALUE > 0)") + .await; + client + .simple_query( + "CREATE FUNCTION conf_dom(a conf_posint) RETURNS INTEGER AS $$ BEGIN RETURN a * 2; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + // Bound under the domain's own name the value was quoted, and + // `a * 2` failed as arithmetic on text. + let doubled = simple_column(&client, "SELECT conf_dom(5)").await?; + if doubled != ["10"] { + return Err(format!("got {doubled:?}, expected [10]")); + } + // And the catalogue reports what a caller must pass, not `text`. + let reported = simple_column( + &client, + "SELECT proargtypes FROM pg_proc WHERE proname = 'conf_dom'", + ) + .await?; + (reported == ["23"]) + .then_some(()) + .ok_or(format!("proargtypes is {reported:?}, expected [23]")) + } + .await, + ); + + for (name, sql, want) in [ + ("= ANY over an array", "SELECT 1 = ANY(ARRAY[1,2])", "t"), + ( + "= ANY that does not match", + "SELECT 5 = ANY(ARRAY[1,2])", + "f", + ), + ("= ALL over an array", "SELECT 1 = ALL(ARRAY[1,1])", "t"), + ( + "= ALL that does not hold", + "SELECT 1 = ALL(ARRAY[1,2])", + "f", + ), + // The `<`/`>` forms already worked; `=` is the one people write. + ("> ANY still works", "SELECT 2 > ANY(ARRAY[1,5])", "t"), + ] { + report.record( + Area::Sql, + name, + async { + let got = simple_column(&client, sql).await?; + (got == [want.to_string()]) + .then_some(()) + .ok_or(format!("got {got:?}, expected [{want}]")) + } + .await, + ); + } + + report.record( + Area::Sql, + "a quantified comparison filters rows", + async { + drop_table(&client, "conf_any").await; + client + .simple_query("CREATE TABLE conf_any (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_any (id) VALUES (1), (2), (3)") + .await + .map_err(describe)?; + // Stored as the text `ANY(ARRAY[1,3])` this matched nothing — + // a wrong answer rather than an error. + let ids = simple_column( + &client, + "SELECT id FROM conf_any WHERE id = ANY(ARRAY[1,3]) ORDER BY id", + ) + .await?; + let result = (ids == ["1", "3"]) + .then_some(()) + .ok_or(format!("got {ids:?}, expected [1, 3]")); + drop_table(&client, "conf_any").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "an expression on the right of a comparison is evaluated", + async { + drop_table(&client, "conf_rhs").await; + client + .simple_query("CREATE TABLE conf_rhs (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_rhs (id) VALUES (1), (2), (3)") + .await + .map_err(describe)?; + let ids = simple_column(&client, "SELECT id FROM conf_rhs WHERE id = 1 + 1").await?; + let result = (ids == ["2"]) + .then_some(()) + .ok_or(format!("got {ids:?}, expected [2]")); + drop_table(&client, "conf_rhs").await; + result + } + .await, + ); + + for (name, sql, want) in [ + // These were parse errors: the types were reachable as column + // declarations but missing from the cast-target list, so a type you + // could declare was not a type you could cast to. + ("cast to NUMERIC", "SELECT '1.5'::NUMERIC", "1.5"), + ( + "a declared scale is rendered", + "SELECT 1.5::NUMERIC(10,2)", + "1.50", + ), + ( + "a declared scale rounds", + "SELECT '1.567'::NUMERIC(10,2)", + "1.57", + ), + ( + "an integer takes the scale", + "SELECT 3::NUMERIC(10,2)", + "3.00", + ), + ( + "cast to json then read a key", + "SELECT ('{\"a\":1}'::json)->>'a'", + "1", + ), + ( + "cast to jsonb then read a key", + "SELECT '{\"a\":1}'::jsonb->>'a'", + "1", + ), + ] { + report.record( + Area::Types, + name, + async { + let got = simple_column(&client, sql).await?; + (got == [want.to_string()]) + .then_some(()) + .ok_or(format!("got {got:?}, expected [{want}]")) + } + .await, + ); + } + + report.record( + Area::Sql, + "a comparison on a non-integer column filters, not returns nothing", + async { + drop_table(&client, "conf_cmp").await; + client + .simple_query( + "CREATE TABLE conf_cmp (id INTEGER, amt NUMERIC(10,2), d DOUBLE, t TEXT)", + ) + .await + .map_err(describe)?; + client + .simple_query( + "INSERT INTO conf_cmp (id, amt, d, t) VALUES (1, 10.50, 7.5, 'b'), (2, 3, 1.5, 'a')", + ) + .await + .map_err(describe)?; + + // The storage matcher compared only `BigInt` against `BigInt`, so + // `>` on a numeric, float or text column matched no rows at all — + // an empty result rather than an error. + for (sql, want) in [ + ("SELECT id FROM conf_cmp WHERE amt > 5", "1"), + ("SELECT id FROM conf_cmp WHERE d > 5", "1"), + ("SELECT id FROM conf_cmp WHERE d < 5", "2"), + ("SELECT id FROM conf_cmp WHERE t > 'a'", "1"), + ("SELECT id FROM conf_cmp WHERE id > 1", "2"), + ] { + let got = simple_column(&client, sql).await?; + if got != [want.to_string()] { + drop_table(&client, "conf_cmp").await; + return Err(format!("{sql} gave {got:?}, expected [{want}]")); + } + } + drop_table(&client, "conf_cmp").await; + Ok(()) + } + .await, + ); + + for (name, sql, want) in [ + ( + "date plus interval is a timestamp", + "SELECT DATE '2024-01-01' + INTERVAL '1 day'", + "2024-01-02 00:00:00", + ), + ( + "date plus an integer is a date", + "SELECT DATE '2024-01-01' + 1", + "2024-01-02", + ), + ( + "date minus an integer crosses a leap day", + "SELECT DATE '2024-03-01' - 1", + "2024-02-29", + ), + ( + "date minus date is a count of days", + "SELECT DATE '2024-03-01' - DATE '2024-02-01'", + "29", + ), + ] { + report.record( + Area::Types, + name, + async { + let got = simple_column(&client, sql).await?; + (got == [want.to_string()]) + .then_some(()) + .ok_or(format!("got {got:?}, expected [{want}]")) + } + .await, + ); + } + + report.record( + Area::Types, + "a malformed value is refused by the cast, not accepted", + async { + // The direction that matters: adding the cast must not make + // anything castable. + for sql in ["SELECT 'nope'::json", "SELECT 'abc'::NUMERIC"] { + if client.simple_query(sql).await.is_ok() { + return Err(format!("{sql} was accepted")); + } + } + Ok(()) + } + .await, + ); + + report.record( + Area::Connection, + "SHOW agrees with what the startup advertised", + async { + // A driver reads `server_version` to decide what the server + // supports. It arrived in ParameterStatus but `SHOW` answered an + // empty string, so the two disagreed about the same setting. + let version = simple_column(&client, "SHOW server_version").await?; + let reported = version.first().cloned().unwrap_or_default(); + if reported.is_empty() { + return Err("SHOW server_version is empty".to_string()); + } + // libpq reads the leading digits, so a version must start with one. + if !reported.starts_with(|c: char| c.is_ascii_digit()) { + return Err(format!( + "server_version {reported:?} does not start with a digit" + )); + } + let encoding = simple_column(&client, "SHOW client_encoding").await?; + (encoding == ["UTF8"]) + .then_some(()) + .ok_or(format!("client_encoding is {encoding:?}, expected [UTF8]")) + } + .await, + ); + + report.record( + Area::Connection, + "GSSENCRequest is declined in the conforming way", + async { + // A server without Kerberos integration answers a single `N` and + // the client continues in the clear. PostgreSQL built without + // --with-gssapi does exactly this; the negotiation is a protocol + // outcome, not a missing message. + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut socket = tokio::net::TcpStream::connect("127.0.0.1:5432") + .await + .map_err(|e| e.to_string())?; + + let mut request = Vec::new(); + request.extend_from_slice(&8i32.to_be_bytes()); + request.extend_from_slice(&80_877_104i32.to_be_bytes()); + socket + .write_all(&request) + .await + .map_err(|e| e.to_string())?; + + let mut answer = [0u8; 1]; + socket + .read_exact(&mut answer) + .await + .map_err(|e| e.to_string())?; + if answer != *b"N" { + return Err(format!("answered {:?}, expected N", answer[0] as char)); + } + + // And the same connection must still be usable: a decline is not + // a disconnect. + let mut startup = Vec::new(); + for (key, value) in [("user", "postgres"), ("database", "orbit")] { + startup.extend_from_slice(key.as_bytes()); + startup.push(0); + startup.extend_from_slice(value.as_bytes()); + startup.push(0); + } + startup.push(0); + let mut framed = Vec::new(); + framed.extend_from_slice(&((startup.len() + 8) as i32).to_be_bytes()); + framed.extend_from_slice(&196_608i32.to_be_bytes()); + framed.extend_from_slice(&startup); + socket.write_all(&framed).await.map_err(|e| e.to_string())?; + + let mut tag = [0u8; 1]; + socket + .read_exact(&mut tag) + .await + .map_err(|e| e.to_string())?; + (tag == *b"R") + .then_some(()) + .ok_or(format!("startup answered {:?}, expected R", tag[0] as char)) + } + .await, + ); + + report.record( + Area::Sql, + "an expression in WHERE filters instead of being dropped", + async { + drop_table(&client, "conf_expr_where").await; + client + .simple_query("CREATE TABLE conf_expr_where (id INTEGER, name TEXT)") + .await + .map_err(describe)?; + client + .simple_query( + "INSERT INTO conf_expr_where (id, name) VALUES (1, 'ada'), (2, 'grace'), (3, 'alan')", + ) + .await + .map_err(describe)?; + + // `id * 2 = 4` was read as column `id`, operator `*`, and the + // storage matcher treats an operator it does not know as matching + // every row — so this returned the whole table. + let arithmetic = + simple_column(&client, "SELECT name FROM conf_expr_where WHERE id * 2 = 4").await?; + if arithmetic != ["grace"] { + return Err(format!("arithmetic gave {arithmetic:?}, expected [grace]")); + } + // A function call in WHERE went the same way and matched nothing. + let called = simple_column( + &client, + "SELECT name FROM conf_expr_where WHERE UPPER(name) = 'ADA'", + ) + .await?; + let result = (called == ["ada"]) + .then_some(()) + .ok_or(format!("a function gave {called:?}, expected [ada]")); + drop_table(&client, "conf_expr_where").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a stored function can be called from inside a query", + async { + drop_table(&client, "conf_callable").await; + client + .simple_query("CREATE TABLE conf_callable (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_callable (id) VALUES (1), (2), (3)") + .await + .map_err(describe)?; + let _ = client.simple_query("DROP FUNCTION conf_dbl").await; + client + .simple_query( + "CREATE FUNCTION conf_dbl(a INTEGER) RETURNS INTEGER AS $$ BEGIN RETURN a * 2; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + + // Only `SELECT f(literal)` used to work; over a table this failed + // and in a WHERE it quietly matched nothing. + let projected = + simple_column(&client, "SELECT conf_dbl(id) FROM conf_callable ORDER BY id").await?; + if projected != ["2", "4", "6"] { + return Err(format!("select list gave {projected:?}, expected [2, 4, 6]")); + } + let filtered = simple_column( + &client, + "SELECT id FROM conf_callable WHERE conf_dbl(id) = 4", + ) + .await?; + let result = (filtered == ["2"]) + .then_some(()) + .ok_or(format!("WHERE gave {filtered:?}, expected [2]")); + drop_table(&client, "conf_callable").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a column's declared type chooses the overload inside a query", + async { + drop_table(&client, "conf_mix").await; + client + .simple_query("CREATE TABLE conf_mix (n INTEGER, s TEXT, b BIGINT)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_mix (n, s, b) VALUES (2, 'ada', 5000000000)") + .await + .map_err(describe)?; + let _ = client.simple_query("DROP FUNCTION conf_kindof2").await; + for (declaration, answer) in [ + ("a INTEGER", "int4"), + ("a TEXT", "text"), + ("a BIGINT", "int8"), + ] { + client + .simple_query(&format!( + "CREATE FUNCTION conf_kindof2({declaration}) RETURNS TEXT AS $$ BEGIN RETURN '{answer}'; END $$ LANGUAGE plpgsql" + )) + .await + .map_err(describe)?; + } + + // Keyed by argument count alone, every one of these reached + // whichever overload was defined last. + let mut chosen = Vec::new(); + for column in ["n", "s", "b"] { + chosen.push( + simple_column(&client, &format!("SELECT conf_kindof2({column}) FROM conf_mix")) + .await? + .join(""), + ); + } + let result = (chosen == ["int4", "text", "int8"]) + .then_some(()) + .ok_or(format!("chose {chosen:?}, expected [int4, text, int8]")); + drop_table(&client, "conf_mix").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a function that runs SQL is refused inside a query, not mis-run", + async { + drop_table(&client, "conf_impure").await; + client + .simple_query("CREATE TABLE conf_impure (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_impure (id) VALUES (1)") + .await + .map_err(describe)?; + let _ = client.simple_query("DROP FUNCTION conf_writes").await; + client + .simple_query( + "CREATE FUNCTION conf_writes(a INTEGER) RETURNS INTEGER AS $$ BEGIN INSERT INTO conf_impure (id) VALUES (a); RETURN a; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + + // The evaluator is synchronous; a body that runs SQL cannot be + // called from it. Saying so beats running it in a way that could + // deadlock, and beats answering the wrong thing. + let refused = client + .simple_query("SELECT conf_writes(id) FROM conf_impure") + .await + .is_err(); + if !refused { + drop_table(&client, "conf_impure").await; + return Err("a SQL-running function was called from inside a query".into()); + } + // But it still works where it always did. + let direct = simple_column(&client, "SELECT conf_writes(9)").await?; + let result = (direct == ["9"]) + .then_some(()) + .ok_or(format!("a direct call gave {direct:?}, expected [9]")); + drop_table(&client, "conf_impure").await; + result + } + .await, + ); + + report.record( + Area::Types, + "a composite type is created and appears in pg_type", + async { + let _ = client.simple_query("DROP TYPE IF EXISTS conf_addr").await; + client + .simple_query("CREATE TYPE conf_addr AS (street TEXT, num INTEGER)") + .await + .map_err(describe)?; + // `c` is what tells a composite from a base type; a client reads + // it to know the type has fields. + let kind = simple_column( + &client, + "SELECT typtype FROM pg_type WHERE typname = 'conf_addr'", + ) + .await?; + (kind == ["c"]) + .then_some(()) + .ok_or(format!("typtype is {kind:?}, expected [c]")) + } + .await, + ); + + report.record( + Area::Sql, + "a composite variable's fields are assignable and readable", + async { + drop_table(&client, "conf_comp").await; + client + .simple_query("CREATE TABLE conf_comp (v TEXT)") + .await + .map_err(describe)?; + client + .simple_query( + "DO $$ DECLARE a conf_addr; BEGIN a.street := 'Main'; a.num := 7; INSERT INTO conf_comp (v) VALUES (a.street); END $$", + ) + .await + .map_err(describe)?; + let stored = simple_column(&client, "SELECT v FROM conf_comp").await?; + let result = (stored == ["Main"]) + .then_some(()) + .ok_or(format!("got {stored:?}, expected [Main]")); + drop_table(&client, "conf_comp").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a composite is its own type when choosing an overload", + async { + let _ = client.simple_query("DROP FUNCTION conf_comp_fn").await; + for (declaration, answer) in [("a conf_addr", "composite"), ("a TEXT", "text")] { + client + .simple_query(&format!( + "CREATE FUNCTION conf_comp_fn({declaration}) RETURNS TEXT AS $$ BEGIN RETURN '{answer}'; END $$ LANGUAGE plpgsql" + )) + .await + .map_err(describe)?; + } + // Reduced to `text` these would be one signature, and a text call + // could reach the composite form. + let textual = simple_column(&client, "SELECT conf_comp_fn('x')").await?; + if textual != ["text"] { + return Err(format!("a text call chose {textual:?}")); + } + // And the catalogue reports the composite's own OID, the same one + // pg_type gives it. + let declared = simple_column( + &client, + "SELECT proargtypes FROM pg_proc WHERE proname = 'conf_comp_fn'", + ) + .await?; + let composite = simple_column( + &client, + "SELECT oid FROM pg_type WHERE typname = 'conf_addr'", + ) + .await?; + (composite.first().is_some_and(|oid| declared.contains(oid))) + .then_some(()) + .ok_or(format!("proargtypes {declared:?} do not include {composite:?}")) + } + .await, + ); + + report.record( + Area::Types, + "DROP TYPE refuses a type that was never there", + async { + // Reporting success for a type that does not exist is the silent + // no-op this harness exists to catch. + match client.simple_query("DROP TYPE conf_no_such_type").await { + Ok(_) => return Err("dropping an absent type reported success".into()), + Err(e) => { + let got = sqlstate(&e); + if got != "42704" { + return Err(format!("reported {got}, expected 42704")); + } + } + } + // IF EXISTS is the form that may say nothing. + client + .simple_query("DROP TYPE IF EXISTS conf_no_such_type") + .await + .map_err(describe)?; + client + .simple_query("DROP TYPE conf_addr") + .await + .map_err(describe)?; + let left = simple_column( + &client, + "SELECT typname FROM pg_type WHERE typname = 'conf_addr'", + ) + .await?; + left.is_empty() + .then_some(()) + .ok_or(format!("{left:?} survived the drop")) + } + .await, + ); + + report.record( + Area::Types, + "a cast to a domain is a cast to what it is built on", + async { + let _ = client + .simple_query("CREATE DOMAIN conf_pos AS INTEGER CHECK (VALUE > 0)") + .await; + let cast = simple_column(&client, "SELECT 42::conf_pos").await?; + if cast != ["42"] { + return Err(format!("got {cast:?}, expected [42]")); + } + // The direction that matters: a name that is not a domain must + // still fail, or every typo'd type would silently succeed. + match client.simple_query("SELECT 42::conf_no_such_type").await { + Ok(_) => Err("a cast to an unknown type was accepted".into()), + Err(_) => Ok(()), + } + } + .await, + ); + + report.record( + Area::Sql, + "two array overloads of one name coexist", + async { + let _ = client.simple_query("DROP FUNCTION conf_arrs").await; + for (declaration, answer) in [("a INTEGER[]", "ints"), ("a TEXT[]", "texts")] { + client + .simple_query(&format!( + "CREATE FUNCTION conf_arrs({declaration}) RETURNS TEXT AS $$ BEGIN RETURN '{answer}'; END $$ LANGUAGE plpgsql" + )) + .await + .map_err(describe)?; + } + // 1007 is int4[] and 1009 is text[]. Collapsing every array to one + // kind made these one signature, so the second replaced the first. + let types = simple_column( + &client, + "SELECT proargtypes FROM pg_proc WHERE proname = 'conf_arrs'", + ) + .await?; + (types.contains(&"1007".to_string()) && types.contains(&"1009".to_string())) + .then_some(()) + .ok_or(format!("proargtypes are {types:?}, expected 1007 and 1009")) + } + .await, + ); + + report.record( + Area::Sql, + "an array parameter is its own type", + async { + let _ = client.simple_query("DROP FUNCTION conf_arr").await; + client + .simple_query( + "CREATE FUNCTION conf_arr(a INTEGER[]) RETURNS TEXT AS $$ BEGIN RETURN 'array'; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + client + .simple_query( + "CREATE FUNCTION conf_arr(a TEXT) RETURNS TEXT AS $$ BEGIN RETURN 'scalar'; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + // 1007 is int4[]. Collapsing it to text would have the two + // overloads collide and report the wrong type to a client. + let types = simple_column( + &client, + "SELECT proargtypes FROM pg_proc WHERE proname = 'conf_arr'", + ) + .await?; + if !types.contains(&"1007".to_string()) { + return Err(format!("proargtypes are {types:?}, expected one to be 1007")); + } + // An array must never swallow a scalar call. + let scalar = simple_column(&client, "SELECT conf_arr('x')").await?; + (scalar == ["scalar"]) + .then_some(()) + .ok_or(format!("a scalar call chose {scalar:?}")) + } + .await, + ); + + report.record( + Area::Catalog, + "two overloads of a name get different OIDs", + async { + let _ = client.simple_query("DROP FUNCTION conf_two").await; + for declaration in ["a INTEGER", "a TEXT"] { + client + .simple_query(&format!( + "CREATE FUNCTION conf_two({declaration}) RETURNS TEXT AS $$ BEGIN RETURN 'x'; END $$ LANGUAGE plpgsql" + )) + .await + .map_err(describe)?; + } + let oids = + simple_column(&client, "SELECT oid FROM pg_proc WHERE proname = 'conf_two'") + .await?; + // One OID for two functions would make a fast-path call ambiguous. + (oids.len() == 2 && oids[0] != oids[1]) + .then_some(()) + .ok_or(format!("got {oids:?}, expected two distinct OIDs")) + } + .await, + ); + + report.record( + Area::Sql, + "an ambiguous call reports ambiguous_function, not undefined", + async { + let _ = client.simple_query("DROP FUNCTION conf_amb").await; + // Neither takes a string, so the untyped-literal rule cannot + // choose and both are their category's preferred type. + for declaration in ["a INTEGER", "a BOOLEAN"] { + client + .simple_query(&format!( + "CREATE FUNCTION conf_amb({declaration}) RETURNS TEXT AS $$ BEGIN RETURN 'x'; END $$ LANGUAGE plpgsql" + )) + .await + .map_err(describe)?; + } + // Telling a caller the function is missing when it is the choice + // between two of them that failed sends them looking in the wrong + // place. + match client.simple_query("SELECT conf_amb(NULL)").await { + Ok(_) => Err("an ambiguous call was answered".into()), + Err(e) => { + let got = sqlstate(&e); + (got == "42725") + .then_some(()) + .ok_or(format!("reported {got}, expected 42725")) + } + } + } + .await, + ); + + report.record( + Area::Sql, + "an ambiguous call is refused rather than guessed", + async { + // NULL names no type, so it fits both overloads. Picking one + // silently would be a coin toss the caller cannot see. + match client.simple_query("SELECT conf_amb(NULL)").await { + Ok(_) => Err("an ambiguous call was answered".into()), + Err(e) => describe(e) + .contains("not unique") + .then_some(()) + .ok_or_else(|| "refused, but not as an ambiguity".to_string()), + } + } + .await, + ); + + report.record( + Area::Sql, + "a value that cannot be the declared type is 22P02", + async { + match client + .simple_query("SELECT conf_bump('not a number')") + .await + { + Ok(_) => Err("text was accepted for an INTEGER parameter".into()), + Err(e) => { + let got = sqlstate(&e); + (got == "22P02") + .then_some(()) + .ok_or(format!("reported {got}, expected 22P02")) + } + } + } + .await, + ); + + report.record( + Area::Sql, + "a parameter type containing a comma is one parameter", + async { + let _ = client.simple_query("DROP FUNCTION conf_money").await; + client + .simple_query( + "CREATE FUNCTION conf_money(a NUMERIC(10, 2)) RETURNS NUMERIC AS $$ BEGIN RETURN a; END $$ LANGUAGE plpgsql", + ) + .await + .map_err(describe)?; + // Splitting the list on every comma made this two parameters, so + // the call arrived with the wrong count. + let value = simple_column(&client, "SELECT conf_money(3.14)").await?; + (value == ["3.14"]) + .then_some(()) + .ok_or(format!("got {value:?}, expected [3.14]")) + } + .await, + ); + + drop_table(&client, "conf_caught").await; + drop_table(&client, "conf_state_child").await; + drop_table(&client, "conf_state").await; + + drop_table(&client, "conf_src").await; + drop_table(&client, "conf_dst").await; + drop_table(&client, "conf_pl").await; + drop_table(&client, "conf_five").await; println!("{}", report.render()); From e03653a4c6674c7d2db4d934ca3e76962bef5bfd Mon Sep 17 00:00:00 2001 From: ravituringworks Date: Sat, 8 Aug 2026 08:38:41 -0700 Subject: [PATCH 36/36] RocksDB durability, configs, and Postgres fixes Add durable RocksDB defaults and configuration plumbing, enable lz4/zstd codecs, and use a shared WriteOptions so sync_wal/enable_wal are enforced. Set sync_wal=true default, enable paranoid_checks and point-in-time recovery, add compression enum and RocksDbBackendConfig, and surface warnings. Add durability and crash tests (including SIGKILL integration). Wire durability into server config and unified storage. Fix many Postgres protocol bugs: CSV COPY, empty statements, parameter type inference, binary result types on demand, transaction error code, statement splitting, COPY parsing, UPDATE expression evaluation/order, NUMERIC rendering, ALTER TABLE ADD COLUMN, unique index handling, portal paging, joins, and other robustness fixes. Update PRD.md and tests; update .gitignore. --- .gitignore | 1 + Cargo.lock | 77 +- config/orbit-server.toml | 8 +- orbit/engine/Cargo.toml | 7 +- orbit/engine/src/unified/rocksdb_backend.rs | 211 ++++- orbit/engine/tests/durability.rs | 263 ++++++ orbit/server/Cargo.toml | 15 +- orbit/server/src/config.rs | 12 +- orbit/server/src/main.rs | 29 +- .../src/protocols/common/storage/unified.rs | 11 + .../src/protocols/postgres_wire/auth.rs | 61 ++ .../src/protocols/postgres_wire/gssapi.rs | 358 +++++++ .../src/protocols/postgres_wire/messages.rs | 62 +- .../server/src/protocols/postgres_wire/mod.rs | 4 + .../src/protocols/postgres_wire/protocol.rs | 458 +++++++-- .../protocols/postgres_wire/query_engine.rs | 618 +++++++++++-- .../postgres_wire/sql/expression_evaluator.rs | 10 +- .../postgres_wire/sql/parser/expressions.rs | 16 +- .../postgres_wire/sql/select_pipeline.rs | 20 +- orbit/server/src/unified_storage.rs | 25 +- specifications/PRD.md | 383 +++++++- tests/Cargo.toml | 12 + tests/integration/pg_conformance.rs | 870 ++++++++++++++++++ tests/integration/pg_crash_durability.rs | 286 ++++++ 24 files changed, 3644 insertions(+), 173 deletions(-) create mode 100644 orbit/engine/tests/durability.rs create mode 100644 orbit/server/src/protocols/postgres_wire/gssapi.rs create mode 100644 tests/integration/pg_crash_durability.rs diff --git a/.gitignore b/.gitignore index 5a9dfc9e..e9c6afbc 100644 --- a/.gitignore +++ b/.gitignore @@ -257,3 +257,4 @@ specifications/protocols/docs/*.pdf File_old.md Wp02_old.md docs/whitepapers/Wp01_old.md +/tests/data diff --git a/Cargo.lock b/Cargo.lock index c6f7c082..31bd956f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1107,6 +1107,44 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -5873,6 +5911,27 @@ dependencies = [ "rle-decode-fast", ] +[[package]] +name = "libgssapi" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e440a6151f5ef06571612e4121709aed90cfc0c40f8161f9090c71656758086d" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "libgssapi-sys", +] + +[[package]] +name = "libgssapi-sys" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5103ac4557eacd36ff678b654b943f8966d3db9688fbd180a0b4c5464759ce17" +dependencies = [ + "bindgen 0.71.1", + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.9" @@ -5914,12 +5973,14 @@ version = "0.16.0+8.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce3d60bc059831dc1c83903fb45c103f75db65c5a7bf22272764d9cc683e348c" dependencies = [ - "bindgen", + "bindgen 0.69.5", "bzip2-sys", "cc", "glob", "libc", "libz-sys", + "lz4-sys", + "zstd-sys", ] [[package]] @@ -6079,6 +6140,16 @@ dependencies = [ "which 7.0.3", ] +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "lz4_flex" version = "0.11.6" @@ -7563,6 +7634,7 @@ dependencies = [ "cucumber", "futures", "futures-util", + "libc", "mockall", "orbit-client", "orbit-engine", @@ -7577,6 +7649,7 @@ dependencies = [ "tokio", "tokio-postgres", "tokio-test", + "toml 0.9.12+spec-1.1.0", "tracing", "tracing-subscriber", "uuid", @@ -7726,6 +7799,7 @@ dependencies = [ "hyper 1.10.1", "hyper-util", "lazy_static", + "libgssapi", "lru 0.12.5", "lz4_flex 0.12.2", "md5", @@ -14155,6 +14229,7 @@ version = "2.0.16+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" dependencies = [ + "bindgen 0.72.1", "cc", "pkg-config", ] diff --git a/config/orbit-server.toml b/config/orbit-server.toml index 97b0c424..6227a1b0 100644 --- a/config/orbit-server.toml +++ b/config/orbit-server.toml @@ -389,8 +389,14 @@ write_buffer_mb = 64 max_write_buffers = 3 enable_bloom_filters = true bloom_bits_per_key = 10 +# Write-ahead log. With this off, a crash loses every write since the last +# memtable flush. enable_wal = true -sync_wal = false +# Flush the log to the physical disk before a write is acknowledged. With this +# off, an acknowledged write survives a process crash but not a power loss: +# the record is still only in the operating system's page cache. Turning it off +# trades durability for write throughput. +sync_wal = true # Cold Tier: Cloud Object Storage (archival, cost-effective) [unified_storage.cold_tier] diff --git a/orbit/engine/Cargo.toml b/orbit/engine/Cargo.toml index c255f117..ec00e4fd 100644 --- a/orbit/engine/Cargo.toml +++ b/orbit/engine/Cargo.toml @@ -42,7 +42,12 @@ metrics = "0.24" regex = "1.10" # Persistence -rocksdb = { version = "0.22", default-features = false, optional = true } +# lz4 and zstd are named by the shipped configuration's compression_algorithm; +# without these features RocksDB refuses to open a database that asks for them. +rocksdb = { version = "0.22", default-features = false, features = [ + "lz4", + "zstd", +], optional = true } sqlx = { version = "0.9", features = ["runtime-tokio", "tls-rustls", "sqlite"] } # Apache Iceberg for cold tier storage diff --git a/orbit/engine/src/unified/rocksdb_backend.rs b/orbit/engine/src/unified/rocksdb_backend.rs index dc545d3d..f8d35c7a 100644 --- a/orbit/engine/src/unified/rocksdb_backend.rs +++ b/orbit/engine/src/unified/rocksdb_backend.rs @@ -10,34 +10,206 @@ //! [`MemoryBackend`]: super::storage::MemoryBackend use std::path::Path; +use std::str::FromStr; use std::sync::Arc; use async_trait::async_trait; -use rocksdb::{IteratorMode, Options, DB}; +use rocksdb::{ + BlockBasedOptions, Cache, DBCompressionType, DBRecoveryMode, IteratorMode, Options, + WriteOptions, DB, +}; use tokio::sync::RwLock; use super::storage::{ UnifiedStorageBackend, UnifiedStorageError, UnifiedStorageMetrics, UnifiedStorageResult, }; +const BYTES_PER_MB: usize = 1024 * 1024; + +/// Which codec compresses the on-disk blocks. +/// +/// Only the codecs actually linked into the binary appear here; asking for one +/// that is not compiled in makes RocksDB refuse to open the database, so the +/// set is kept to what the build guarantees. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[non_exhaustive] +pub enum Compression { + /// Store blocks uncompressed. + None, + /// LZ4 — fast, modest ratio. The default. + #[default] + Lz4, + /// Zstandard — slower, better ratio. + Zstd, +} + +impl FromStr for Compression { + type Err = UnifiedStorageError; + + fn from_str(name: &str) -> Result { + match name.to_lowercase().as_str() { + "none" | "off" => Ok(Self::None), + "lz4" => Ok(Self::Lz4), + "zstd" => Ok(Self::Zstd), + other => Err(UnifiedStorageError::InvalidOperation(format!( + "unknown compression algorithm '{other}'; expected one of none, lz4, zstd" + ))), + } + } +} + +impl From for DBCompressionType { + fn from(compression: Compression) -> Self { + match compression { + Compression::None => Self::None, + Compression::Lz4 => Self::Lz4, + Compression::Zstd => Self::Zstd, + } + } +} + +/// How the backend trades durability, space, and speed. +/// +/// The defaults are the durable ones: an acknowledged write is on disk before +/// it is acknowledged. That costs an fsync per write, which is the price of +/// not losing data when the machine loses power. +#[derive(Debug, Clone)] +pub struct RocksDbBackendConfig { + /// Flush the write-ahead log to the physical disk before a write returns. + /// + /// With this off, RocksDB hands the record to the operating system and + /// returns. The data survives a process crash, because the kernel still + /// holds the buffer — but a power cut or kernel panic loses every write + /// made since the last flush, *after* the client was told it was durable. + /// Turn it off only where losing recent writes is acceptable. + pub sync_writes: bool, + + /// Write to the write-ahead log at all. + /// + /// With this off there is nothing to replay: a crash loses everything back + /// to the last memtable flush, whether or not `sync_writes` is set. + pub enable_wal: bool, + + /// Compress on-disk blocks with this codec. + pub compression: Compression, + + /// Size of the block cache, in megabytes. + pub block_cache_mb: usize, + + /// Size of each in-memory write buffer, in megabytes. + pub write_buffer_mb: usize, + + /// How many write buffers may exist before writes stall. + pub max_write_buffers: u32, + + /// Bits per key for the bloom filter, or `None` for no bloom filter. + /// + /// Modelled as an option rather than a flag beside a number, so "filters + /// off" and "ten bits per key" cannot be stated at the same time. + pub bloom_bits_per_key: Option, +} + +impl Default for RocksDbBackendConfig { + fn default() -> Self { + Self { + sync_writes: true, + enable_wal: true, + compression: Compression::default(), + block_cache_mb: 256, + write_buffer_mb: 64, + max_write_buffers: 3, + bloom_bits_per_key: Some(10), + } + } +} + +impl RocksDbBackendConfig { + /// The settings that trade durability for speed: no fsync, no log. + /// + /// Intended for tests and for throwaway data. A crash loses writes that + /// were reported as successful. + #[must_use] + pub fn unsafe_fast() -> Self { + Self { + sync_writes: false, + enable_wal: false, + ..Self::default() + } + } +} + /// Key-value storage backed by a RocksDB database on disk. pub struct RocksDbBackend { db: Arc, + /// Built once at open, because the durability of a write must not depend + /// on which call site made it. + write_options: WriteOptions, metrics: Arc>, } impl RocksDbBackend { - /// Open (or create) a RocksDB database under `path`. + /// Open (or create) a RocksDB database under `path` with durable defaults. /// /// # Errors /// Returns an error when the database cannot be opened — most often /// because another process already holds its lock, or the directory is not /// writable. pub fn open(path: impl AsRef) -> UnifiedStorageResult { + Self::open_with(path, &RocksDbBackendConfig::default()) + } + + /// Open (or create) a RocksDB database under `path` with explicit settings. + /// + /// # Errors + /// Returns an error when the database cannot be opened, which includes the + /// case where its files are corrupt beyond what point-in-time recovery can + /// repair. + pub fn open_with( + path: impl AsRef, + config: &RocksDbBackendConfig, + ) -> UnifiedStorageResult { + // RocksDB rejects a synchronous write when there is no log to + // synchronise, one write at a time. Caught here, the contradiction is + // one startup error naming both settings; left alone, it is every + // write failing at run time on a server that started cleanly. + if config.sync_writes && !config.enable_wal { + return Err(UnifiedStorageError::InvalidOperation( + "sync_wal is set but enable_wal is not: there is no write-ahead \ + log to flush. Turn on enable_wal to get durable writes, or turn \ + off sync_wal to accept losing recent writes on a crash." + .to_string(), + )); + } + let mut options = Options::default(); options.create_if_missing(true); options.create_missing_column_families(true); + // Corruption handling, stated rather than inherited. + // + // `paranoid_checks` makes RocksDB validate as it reads instead of + // trusting its own files. Point-in-time recovery stops replaying the + // log at the first damaged record, which keeps every write that was + // completed and discards only a torn tail — the record that was being + // written when the power went out, which no client was ever told had + // succeeded. The stricter mode refuses to open at all after a torn + // tail, which turns an ordinary power cut into an outage without + // saving any data that was actually acknowledged. + options.set_paranoid_checks(true); + options.set_wal_recovery_mode(DBRecoveryMode::PointInTime); + + options.set_compression_type(config.compression.into()); + options.set_write_buffer_size(config.write_buffer_mb * BYTES_PER_MB); + options.set_max_write_buffer_number(config.max_write_buffers as i32); + + let mut block_options = BlockBasedOptions::default(); + let cache = Cache::new_lru_cache(config.block_cache_mb * BYTES_PER_MB); + block_options.set_block_cache(&cache); + if let Some(bits) = config.bloom_bits_per_key { + block_options.set_bloom_filter(f64::from(bits), false); + } + options.set_block_based_table_factory(&block_options); + let db = DB::open(&options, path.as_ref()).map_err(|e| { UnifiedStorageError::Backend(format!( "could not open the unified store at {}: {e}", @@ -45,8 +217,27 @@ impl RocksDbBackend { )) })?; + let mut write_options = WriteOptions::new(); + write_options.set_sync(config.sync_writes); + write_options.disable_wal(!config.enable_wal); + + if !config.enable_wal { + tracing::warn!( + path = %path.as_ref().display(), + "unified store opened with the write-ahead log disabled: a crash \ + will lose every write since the last flush" + ); + } else if !config.sync_writes { + tracing::warn!( + path = %path.as_ref().display(), + "unified store opened without sync-on-write: acknowledged writes \ + survive a process crash but not a power loss" + ); + } + Ok(Self { db: Arc::new(db), + write_options, metrics: Arc::new(RwLock::new(UnifiedStorageMetrics::default())), }) } @@ -81,8 +272,12 @@ impl UnifiedStorageBackend for RocksDbBackend { } async fn shutdown(&self) -> UnifiedStorageResult<()> { - // Flushing on the way out makes a clean stop durable without waiting - // for the write-ahead log to be replayed on the next start. + // Push the log to disk first, so a stop that is interrupted between + // these two calls still has every write recoverable, then flush the + // memtable so the next start has nothing to replay. + self.db + .flush_wal(true) + .map_err(|e| UnifiedStorageError::Backend(format!("flushing the log failed: {e}")))?; self.db .flush() .map_err(|e| UnifiedStorageError::Backend(format!("flush failed: {e}")))?; @@ -97,7 +292,7 @@ impl UnifiedStorageBackend for RocksDbBackend { } async fn put(&self, key: &str, value: &[u8]) -> UnifiedStorageResult<()> { - let result = self.db.put(key.as_bytes(), value); + let result = self.db.put_opt(key.as_bytes(), value, &self.write_options); self.record(Operation::Write, result.is_err()).await; result.map_err(|e| UnifiedStorageError::Backend(format!("put '{key}' failed: {e}"))) } @@ -111,7 +306,7 @@ impl UnifiedStorageBackend for RocksDbBackend { .map_err(|e| UnifiedStorageError::Backend(format!("delete '{key}' failed: {e}")))? .is_some(); - let result = self.db.delete(key.as_bytes()); + let result = self.db.delete_opt(key.as_bytes(), &self.write_options); self.record(Operation::Delete, result.is_err()).await; result.map_err(|e| UnifiedStorageError::Backend(format!("delete '{key}' failed: {e}")))?; Ok(existed) @@ -163,7 +358,7 @@ impl UnifiedStorageBackend for RocksDbBackend { batch.put(key.as_bytes(), value); } - let result = self.db.write(batch); + let result = self.db.write_opt(batch, &self.write_options); { let mut metrics = self.metrics.write().await; metrics.write_operations += count as u64; @@ -181,7 +376,7 @@ impl UnifiedStorageBackend for RocksDbBackend { batch.delete(key.as_bytes()); } - let result = self.db.write(batch); + let result = self.db.write_opt(batch, &self.write_options); { let mut metrics = self.metrics.write().await; metrics.delete_operations += count; diff --git a/orbit/engine/tests/durability.rs b/orbit/engine/tests/durability.rs new file mode 100644 index 00000000..b09925fb --- /dev/null +++ b/orbit/engine/tests/durability.rs @@ -0,0 +1,263 @@ +//! Durability and integrity checks for the unified store. +//! +//! These tests exist because a green build proves nothing about whether data +//! survives. Each one stops the store, does something hostile to it, and then +//! asks the store a question it can only answer correctly if the data really +//! reached the disk intact. + +#![cfg(feature = "storage-rocksdb")] + +use std::path::{Path, PathBuf}; + +use orbit_engine::unified::rocksdb_backend::{Compression, RocksDbBackend, RocksDbBackendConfig}; +use orbit_engine::unified::storage::UnifiedStorageBackend; + +/// A scratch directory that is emptied before use, so a previous run cannot +/// make this one pass. +fn scratch(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!("orbit-durability-{name}")); + let _ = std::fs::remove_dir_all(&path); + path +} + +/// Every file under `path` whose extension matches, deepest first. +fn files_with_extension(path: &Path, extension: &str) -> Vec { + let Ok(entries) = std::fs::read_dir(path) else { + return Vec::new(); + }; + entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == extension)) + .collect() +} + +/// Overwrite a stretch of bytes in the middle of `path` with a value that is +/// certainly not what was written there. +fn corrupt_middle(path: &Path) { + use std::io::{Seek, SeekFrom, Write}; + + let length = std::fs::metadata(path).expect("stat").len(); + let mut file = std::fs::OpenOptions::new() + .write(true) + .open(path) + .expect("open for corruption"); + + // A quarter of the way in is past the header and inside the data blocks, + // which is where a silent bit-flip would do its damage. + file.seek(SeekFrom::Start(length / 4)).expect("seek"); + file.write_all(&[0xA5; 512]).expect("scribble"); + file.sync_all().expect("sync"); +} + +/// The whole point of the backend: what was written is still there after the +/// process that wrote it is gone. +#[tokio::test] +async fn data_survives_reopening_the_store() { + let path = scratch("survives"); + + let store = RocksDbBackend::open(&path).expect("opens"); + for index in 0..100 { + store + .put( + &format!("row:{index:04}"), + format!("value-{index}").as_bytes(), + ) + .await + .expect("put"); + } + store.shutdown().await.expect("shutdown"); + drop(store); + + let reopened = RocksDbBackend::open(&path).expect("reopens"); + for index in 0..100 { + let found = reopened + .get(&format!("row:{index:04}")) + .await + .expect("get") + .expect("the row should still be there"); + assert_eq!(found, format!("value-{index}").into_bytes()); + } + + let _ = std::fs::remove_dir_all(&path); +} + +/// Data written and never explicitly flushed must still come back, because a +/// process that is killed does not get to run `shutdown`. This is the write +/// path a crash actually leaves behind: records in the write-ahead log with no +/// clean close, replayed on the next open. +#[tokio::test] +async fn data_survives_without_a_clean_shutdown() { + let path = scratch("unclean"); + + let store = RocksDbBackend::open(&path).expect("opens"); + for index in 0..100 { + store + .put( + &format!("row:{index:04}"), + format!("value-{index}").as_bytes(), + ) + .await + .expect("put"); + } + // No shutdown, no flush — exactly what a SIGKILL leaves behind. + drop(store); + + let reopened = RocksDbBackend::open(&path).expect("reopens"); + let rows = reopened.scan_prefix("row:", None).await.expect("scan"); + assert_eq!( + rows.len(), + 100, + "the write-ahead log should have replayed every row" + ); + + let _ = std::fs::remove_dir_all(&path); +} + +/// The defaults are the durable ones. A backend that has to be configured +/// carefully to avoid losing data will eventually be deployed without that +/// care, so the default is the safe end and speed is the opt-in. +#[test] +fn the_default_configuration_is_the_durable_one() { + let config = RocksDbBackendConfig::default(); + assert!( + config.sync_writes, + "an acknowledged write must reach the disk by default" + ); + assert!( + config.enable_wal, + "the write-ahead log must be on by default" + ); + + let fast = RocksDbBackendConfig::unsafe_fast(); + assert!(!fast.sync_writes, "the fast profile trades away the fsync"); +} + +/// Asking for synchronous writes with no log to synchronise is a contradiction +/// RocksDB reports one failed write at a time. It has to be caught at startup, +/// or the server comes up healthy and then refuses every write. +#[test] +fn syncing_a_disabled_log_is_refused_at_startup() { + let path = scratch("contradiction"); + let config = RocksDbBackendConfig { + sync_writes: true, + enable_wal: false, + ..Default::default() + }; + + let error = RocksDbBackend::open_with(&path, &config) + .err() + .expect("the contradiction should be refused"); + let message = error.to_string(); + assert!( + message.contains("sync_wal") && message.contains("enable_wal"), + "the error should name both settings: {message}" + ); + let _ = std::fs::remove_dir_all(&path); +} + +/// Every compression codec the configuration can name must be linked into the +/// binary. RocksDB refuses to open a database asking for a codec it does not +/// have, so an unlinked codec is a server that will not start. +#[tokio::test] +async fn every_named_compression_codec_can_actually_open_a_database() { + for (name, compression) in [ + ("none", Compression::None), + ("lz4", Compression::Lz4), + ("zstd", Compression::Zstd), + ] { + assert_eq!( + name.parse::().expect("the name should parse"), + compression + ); + + let path = scratch(&format!("codec-{name}")); + let config = RocksDbBackendConfig { + compression, + ..Default::default() + }; + let store = RocksDbBackend::open_with(&path, &config) + .unwrap_or_else(|e| panic!("{name} should be linked in, but opening failed: {e}")); + + store.put("k", b"v").await.expect("put"); + store.shutdown().await.expect("shutdown"); + drop(store); + + let reopened = RocksDbBackend::open_with(&path, &config).expect("reopens"); + assert_eq!( + reopened.get("k").await.expect("get").as_deref(), + Some(&b"v"[..]), + "{name}-compressed data should read back" + ); + let _ = std::fs::remove_dir_all(&path); + } +} + +/// An unknown codec is refused by name rather than quietly falling back to +/// storing everything uncompressed. +#[test] +fn an_unknown_compression_codec_is_refused() { + let error = "brotli" + .parse::() + .expect_err("brotli is not linked in"); + let message = error.to_string(); + assert!( + message.contains("brotli") && message.contains("lz4"), + "the error should name what was asked for and what is available: {message}" + ); +} + +/// Corruption on disk must be reported, not served. A store that hands back a +/// silently mangled value is worse than one that refuses, because nothing +/// downstream can tell that the answer is wrong. +#[tokio::test] +async fn corrupted_data_on_disk_is_detected_rather_than_served() { + let path = scratch("corruption"); + + let store = RocksDbBackend::open(&path).expect("opens"); + // Enough distinct rows to fill several data blocks, so the scribble below + // lands inside one rather than in padding. + for index in 0..5_000 { + store + .put( + &format!("row:{index:06}"), + format!("value-{index}-{}", "x".repeat(200)).as_bytes(), + ) + .await + .expect("put"); + } + // Force the rows out of the memtable into an SST file, which is the + // artifact that can rot on disk. + store.shutdown().await.expect("shutdown"); + drop(store); + + let ssts = files_with_extension(&path, "sst"); + assert!( + !ssts.is_empty(), + "expected at least one SST file to corrupt, found none in {}", + path.display() + ); + for sst in &ssts { + corrupt_middle(sst); + } + + let reopened = RocksDbBackend::open(&path).expect("reopens"); + let outcome = reopened.scan_prefix("row:", None).await; + + match outcome { + Err(error) => { + let message = error.to_string().to_lowercase(); + assert!( + message.contains("corrupt") || message.contains("checksum"), + "the error should name the corruption, said: {error}" + ); + } + Ok(rows) => panic!( + "a full scan over a corrupted SST returned {} rows instead of an error — \ + corrupted data was served as if it were good", + rows.len() + ), + } + + let _ = std::fs::remove_dir_all(&path); +} diff --git a/orbit/server/Cargo.toml b/orbit/server/Cargo.toml index 61eeef9a..064b181a 100644 --- a/orbit/server/Cargo.toml +++ b/orbit/server/Cargo.toml @@ -34,6 +34,7 @@ lazy_static = "1.4" url = "2.5" geohash = "0.13" md5 = "0.8" +libgssapi = { version = "0.11", default-features = false, optional = true } sha1 = "0.10" # For Redis EVALSHA script hashing sha2 = "0.10" hex = "0.4" @@ -79,7 +80,13 @@ urlencoding = "2.1" flate2 = "1.0" # For compression toml = "0.9" # For configuration file support # New persistence backends -rocksdb = { version = "0.22", default-features = false, optional = true } +# Keep the feature set identical to orbit-engine's: cargo unifies them into one +# build of librocksdb-sys, and a mismatch here silently changes which codecs the +# unified store can open. +rocksdb = { version = "0.22", default-features = false, features = [ + "lz4", + "zstd", +], optional = true } rand.workspace = true uuid = { version = "1.19", features = ["v4", "v7", "serde"] } env_logger = "0.11" @@ -129,6 +136,7 @@ default = [ "metrics", "protocol-redis", "protocol-postgres", + "gssapi", "protocol-grpc", "protocol-rest", "protocol-mcp", @@ -181,6 +189,11 @@ metrics = [] # Protocol Support protocol-redis = ["actor-system"] protocol-postgres = ["actor-system"] +# GSSAPI/Kerberos authentication for the PostgreSQL wire protocol. +# On by default so it is always compiled and tested; turn it off where +# no GSSAPI implementation is available to link against (macOS ships +# GSS.framework, Linux needs MIT or Heimdal krb5). +gssapi = ["dep:libgssapi"] protocol-mysql = ["actor-system"] protocol-cassandra = ["actor-system"] protocol-neo4j = ["actor-system", "model-graph"] diff --git a/orbit/server/src/config.rs b/orbit/server/src/config.rs index da4417bb..6efead74 100644 --- a/orbit/server/src/config.rs +++ b/orbit/server/src/config.rs @@ -1357,10 +1357,16 @@ pub struct WarmTierConfig { /// Bloom filter bits per key pub bloom_bits_per_key: u32, - /// Enable WAL + /// Write to the write-ahead log. + /// + /// With this off, a crash loses every write since the last memtable flush. pub enable_wal: bool, - /// Sync WAL on write + /// Flush the write-ahead log to the physical disk before acknowledging a + /// write. + /// + /// With this off, an acknowledged write survives a process crash — the + /// kernel still holds the buffer — but not a power loss or kernel panic. pub sync_wal: bool, } @@ -1938,7 +1944,7 @@ impl Default for WarmTierConfig { enable_bloom_filters: true, bloom_bits_per_key: 10, enable_wal: true, - sync_wal: false, + sync_wal: true, } } } diff --git a/orbit/server/src/main.rs b/orbit/server/src/main.rs index a9212e7d..3d61a3a3 100644 --- a/orbit/server/src/main.rs +++ b/orbit/server/src/main.rs @@ -44,7 +44,9 @@ use orbit_server::protocols::postgres_wire::sql::execution::hybrid::HybridStorag use orbit_server::protocols::postgres_wire::{QueryEngine, RocksDbTableStorage}; use orbit_server::protocols::rest::{server::RestApiConfig, RestApiServer}; use orbit_server::protocols::{CqlServer, MySqlServer, PostgresServer, RespServer}; -use orbit_server::unified_storage::{UnifiedStorageIntegration, UnifiedStorageIntegrationConfig}; +use orbit_server::unified_storage::{ + Compression, RocksDbBackendConfig, UnifiedStorageIntegration, UnifiedStorageIntegrationConfig, +}; use orbit_server::OrbitServerBuilder; /// Storage mode for protocol servers @@ -377,12 +379,37 @@ async fn main() -> Result<(), Box> { data_dir ); + // The warm-tier block used to be read by nothing: every one of its + // knobs, `sync_wal` included, was parsed and discarded, so an operator + // who turned on sync-on-write got no fsync and no warning. + let warm_tier = &unified_config.warm_tier; + let durability = RocksDbBackendConfig { + sync_writes: warm_tier.sync_wal, + enable_wal: warm_tier.enable_wal, + compression: if warm_tier.enable_compression { + warm_tier.compression_algorithm.parse().map_err(|e| { + Box::new(std::io::Error::other(format!( + "unified_storage.warm_tier.compression_algorithm: {e}" + ))) as Box + })? + } else { + Compression::None + }, + block_cache_mb: warm_tier.block_cache_mb, + write_buffer_mb: warm_tier.write_buffer_mb, + max_write_buffers: warm_tier.max_write_buffers, + bloom_bits_per_key: warm_tier + .enable_bloom_filters + .then_some(warm_tier.bloom_bits_per_key), + }; + let integration_config = UnifiedStorageIntegrationConfig { data_dir: data_dir.clone(), enable_ttl_expiration: unified_config.ttl.enabled, ttl_check_interval_secs: unified_config.ttl.check_interval_secs, max_scan_limit: 1_000_000, use_memory_backend: false, // Use persistent backend + durability, }; let integration = UnifiedStorageIntegration::with_config(integration_config) diff --git a/orbit/server/src/protocols/common/storage/unified.rs b/orbit/server/src/protocols/common/storage/unified.rs index d4cdda77..f4735abb 100644 --- a/orbit/server/src/protocols/common/storage/unified.rs +++ b/orbit/server/src/protocols/common/storage/unified.rs @@ -1154,6 +1154,7 @@ mod tests { max_scan_limit: 1000, // The point of the test: the persistent backend, not the memory one. use_memory_backend: false, + ..Default::default() }; let schema = PersistentTableSchema { @@ -1567,6 +1568,7 @@ mod persistent_storage_impl { /// Convert SqlValue to JsonValue for row data fn sql_value_to_json(value: &SqlValue) -> JsonValue { + use std::str::FromStr; match value { SqlValue::Null => JsonValue::Null, SqlValue::Boolean(b) => JsonValue::Bool(*b), @@ -1583,6 +1585,14 @@ mod persistent_storage_impl { SqlValue::Varchar(s) => JsonValue::String(s.clone()), SqlValue::Char(s) => JsonValue::String(s.clone()), SqlValue::Json(v) | SqlValue::Jsonb(v) => v.clone(), + // An exact decimal is stored as a number, not as its printed + // form. Falling through to the string case put `"10.00"` in a + // numeric column, so the row read back as text: comparisons + // stopped matching it and an update computed from it silently + // did nothing. + SqlValue::Decimal(d) => serde_json::Number::from_str(&d.to_string()) + .map(JsonValue::Number) + .unwrap_or(JsonValue::Null), _ => JsonValue::String(value.to_postgres_string()), } } @@ -3104,6 +3114,7 @@ mod storage_provider_tests { max_scan_limit: 1000, // Use memory backend for testing - this avoids RocksDB setup use_memory_backend: true, + ..Default::default() }; let integration = UnifiedStorageIntegration::with_config(config) .await diff --git a/orbit/server/src/protocols/postgres_wire/auth.rs b/orbit/server/src/protocols/postgres_wire/auth.rs index 4bb6d037..6714c6dc 100644 --- a/orbit/server/src/protocols/postgres_wire/auth.rs +++ b/orbit/server/src/protocols/postgres_wire/auth.rs @@ -28,6 +28,51 @@ pub enum AuthMethod { MD5, /// SCRAM-SHA-256 authentication ScramSha256, + /// GSSAPI (Kerberos) authentication. + /// + /// No credential is stored for this method: the ticket is checked by the + /// Kerberos library against a KDC, and this server only decides whether + /// the principal it vouched for may log in as the requested user. + Gss, +} + +/// The method this server authenticates with, from `ORBIT_PG_AUTH_METHOD`. +/// +/// Defaults to SCRAM-SHA-256. An unreadable value is refused loudly rather +/// than falling back: silently dropping to a weaker method — or to `trust` — +/// because of a typo is how a server ends up open. +#[must_use] +pub fn configured_auth_method() -> AuthMethod { + match std::env::var("ORBIT_PG_AUTH_METHOD") { + Ok(name) => AuthMethod::parse(&name).unwrap_or_else(|unknown| { + tracing::error!( + method = %unknown, + "ORBIT_PG_AUTH_METHOD is not a method this server knows; \ + falling back to scram-sha-256" + ); + AuthMethod::ScramSha256 + }), + Err(_) => AuthMethod::ScramSha256, + } +} + +impl AuthMethod { + /// Read the method from `name`, as it is written in configuration. + /// + /// # Errors + /// Returns the unrecognised name. An unknown method is refused rather than + /// defaulting, because every plausible default is either a lock-out or — + /// worse — `trust`. + pub fn parse(name: &str) -> Result { + match name.trim().to_ascii_lowercase().replace('_', "-").as_str() { + "trust" => Ok(Self::Trust), + "password" | "cleartext" => Ok(Self::Password), + "md5" => Ok(Self::MD5), + "scram-sha-256" | "scram" => Ok(Self::ScramSha256), + "gss" | "gssapi" | "kerberos" => Ok(Self::Gss), + other => Err(other.to_string()), + } + } } /// User credentials stored in the system @@ -80,6 +125,17 @@ impl UserStore { scram_salt: None, scram_iterations: None, }, + // A GSSAPI login has no password to store. An entry is still + // written so the user exists, with no credential that could be + // used to log in by any other method. + AuthMethod::Gss => UserCredentials { + username: username.clone(), + password_hash: String::new(), + scram_stored_key: None, + scram_server_key: None, + scram_salt: None, + scram_iterations: None, + }, AuthMethod::ScramSha256 => { // Generate SCRAM credentials let (stored_key, server_key, salt) = @@ -169,6 +225,7 @@ impl AuthManager { AuthMethod::ScramSha256 => AuthenticationResponse::SASL { mechanisms: vec!["SCRAM-SHA-256".to_string()], }, + AuthMethod::Gss => AuthenticationResponse::GSS, } } @@ -205,6 +262,10 @@ impl AuthManager { // SCRAM verification handled separately Ok(false) } + // There is no password to verify: a GSSAPI login never sends one, + // and answering anything but `false` here would let a password + // message stand in for a ticket. + AuthMethod::Gss => Ok(false), } } diff --git a/orbit/server/src/protocols/postgres_wire/gssapi.rs b/orbit/server/src/protocols/postgres_wire/gssapi.rs new file mode 100644 index 00000000..09e5cd1e --- /dev/null +++ b/orbit/server/src/protocols/postgres_wire/gssapi.rs @@ -0,0 +1,358 @@ +//! GSSAPI (Kerberos) authentication for the PostgreSQL wire protocol. +//! +//! The exchange is three messages wide. The server answers a startup packet +//! with `AuthenticationGSS`; the client replies with a token in a `'p'` +//! message; the server feeds that token to `gss_accept_sec_context` and either +//! answers `AuthenticationGSSContinue` with a token of its own and waits for +//! another, or — once the context is established — sends any final token and +//! then `AuthenticationOk`. +//! +//! # What this module does and does not decide +//! +//! None of the cryptography is here. Tokens are opaque: they are produced and +//! checked by the system Kerberos library against a real KDC, and this module +//! only carries them across the wire and asks the library who the caller +//! turned out to be. What *is* decided here is the part a library cannot +//! decide — whether the principal the KDC vouched for is allowed to log in as +//! the user named in the startup packet. That policy is +//! [`NameMapping::authorize`], a pure function, so it can be tested exhaustively +//! without a KDC in the loop. +//! +//! # The keytab +//! +//! Like PostgreSQL, this accepts with the *default* acceptor credential rather +//! than acquiring one for a named service, so any principal in the keytab can +//! be the target. The keytab is chosen by `KRB5_KTNAME`, read by the Kerberos +//! library itself — there is deliberately no second knob for it here, because a +//! knob that duplicates the library's own would be one more place for the two +//! to disagree. + +use std::env; + +use libgssapi::context::{SecurityContext, ServerCtx}; + +use crate::protocols::error::{ProtocolError, ProtocolResult}; + +/// The result of feeding one client token to the acceptor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AcceptStep { + /// The handshake needs another round. Send this token as + /// `AuthenticationGSSContinue` and wait for the client's reply. + Continue(Vec), + /// The context is established. + /// + /// `token` is not always empty when this arrives: under mutual + /// authentication the last token is what proves the *server's* identity to + /// the client, so it must still be sent — as `AuthenticationGSSContinue`, + /// exactly as PostgreSQL does — before `AuthenticationOk`. Dropping it + /// leaves a client that asked for mutual authentication waiting forever. + Complete { + token: Option>, + principal: String, + }, +} + +/// Why a principal was refused, kept separate from the message so the reasons +/// can be asserted on in tests without matching prose. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Denial { + /// The ticket is from a realm this server does not accept. + WrongRealm { got: Option }, + /// The principal authenticated, but is not this user. + NotThisUser { principal: String, requested: String }, +} + +impl Denial { + /// The message sent to the client. + /// + /// It says which principal was presented, because the usual cause is a + /// stale ticket for someone else and a client that cannot see that spends + /// a long time suspecting the password it never typed. + #[must_use] + pub fn message(&self) -> String { + match self { + Self::WrongRealm { got } => match got { + Some(realm) => format!("GSSAPI authentication failed: realm {realm:?} is not accepted by this server"), + None => "GSSAPI authentication failed: the principal carries no realm, and this server requires one".to_string(), + }, + Self::NotThisUser { + principal, + requested, + } => format!( + "GSSAPI authentication failed: principal {principal:?} is not authorized to log in as {requested:?}" + ), + } + } +} + +/// How an authenticated Kerberos principal is matched against the user named +/// in the startup packet. +/// +/// These mirror PostgreSQL's `include_realm` and `krb_realm` settings, and the +/// defaults mirror its defaults: the realm is part of the name, and no +/// particular realm is required. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NameMapping { + /// Whether the realm is part of the name being matched. + /// + /// With this on — the default, and PostgreSQL's since 9.5 — a client + /// connecting as `ada` must present `ada@REALM` *and* be called + /// `ada@REALM` in this server's user list. Turning it off compares only + /// the part before the realm, which is convenient and is why it is not the + /// default: with two trusted realms it lets `ada@OTHER.REALM` log in as + /// `ada`. + pub include_realm: bool, + /// A realm that tickets must come from, if any. + pub required_realm: Option, +} + +impl Default for NameMapping { + fn default() -> Self { + Self { + include_realm: true, + required_realm: None, + } + } +} + +impl NameMapping { + /// Read the policy from the environment. + /// + /// `ORBIT_PG_GSS_INCLUDE_REALM` accepts `0`/`false`/`off` to turn realm + /// matching off; anything else, including absence, leaves it on. The + /// asymmetry is deliberate — a typo must not silently loosen it. + #[must_use] + pub fn from_env() -> Self { + let include_realm = env::var("ORBIT_PG_GSS_INCLUDE_REALM") + .map(|value| !matches!(value.trim().to_ascii_lowercase().as_str(), "0" | "false" | "off" | "no")) + .unwrap_or(true); + let required_realm = env::var("ORBIT_PG_GSS_KRB_REALM") + .ok() + .map(|realm| realm.trim().to_string()) + .filter(|realm| !realm.is_empty()); + Self { + include_realm, + required_realm, + } + } + + /// Whether `principal` may log in as `requested`. + /// + /// # Errors + /// Returns the reason the principal was refused. + pub fn authorize(&self, principal: &str, requested: &str) -> Result<(), Denial> { + // A principal is `name@REALM`, and the name itself may contain `/` + // (`postgres/host`) but not `@` — so the realm is what follows the + // last one. + let (name, realm) = match principal.rsplit_once('@') { + Some((name, realm)) => (name, Some(realm)), + None => (principal, None), + }; + + if let Some(required) = &self.required_realm { + // Kerberos realms are conventionally upper case but are compared + // by the KDC as written, so this compares as written too. + if realm != Some(required.as_str()) { + return Err(Denial::WrongRealm { + got: realm.map(ToString::to_string), + }); + } + } + + let candidate = if self.include_realm { principal } else { name }; + if candidate == requested { + Ok(()) + } else { + Err(Denial::NotThisUser { + principal: principal.to_string(), + requested: requested.to_string(), + }) + } + } +} + +/// One connection's half-finished GSSAPI handshake. +pub struct Acceptor { + context: ServerCtx, +} + +impl std::fmt::Debug for Acceptor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // The context holds key material; there is nothing safe to print. + f.write_str("Acceptor { .. }") + } +} + +impl Default for Acceptor { + fn default() -> Self { + Self::new() + } +} + +fn failed(context: &str, error: &libgssapi::error::Error) -> ProtocolError { + ProtocolError::SqlState { + // 28000 invalid_authorization_specification, which is what PostgreSQL + // reports for a failed login. + code: "28000", + message: format!("GSSAPI authentication failed: {context}: {error}"), + } +} + +impl Acceptor { + /// Start a handshake using the default acceptor credential. + #[must_use] + pub fn new() -> Self { + // `None` is GSS_C_NO_CREDENTIAL: accept as any principal the keytab + // holds a key for. This is what PostgreSQL does, and it is why a + // server principal does not have to be configured twice. + Self { + context: ServerCtx::new(None), + } + } + + /// Feed one token from the client. + /// + /// # Errors + /// Returns an error when the token is rejected by the Kerberos library — + /// a forged or replayed ticket, a key the keytab does not hold, or a + /// clock too far out of step with the KDC. + pub fn step(&mut self, token: &[u8]) -> ProtocolResult { + let outgoing = self + .context + .step(token, None) + .map_err(|error| failed("accepting the security context", &error))?; + let token = outgoing.map(|buf| buf.to_vec()); + + if !self.context.is_complete() { + // Not established yet, so there must be something to send back; + // if there is not, the handshake cannot advance and would hang. + return match token { + Some(token) => Ok(AcceptStep::Continue(token)), + None => Err(ProtocolError::SqlState { + code: "28000", + message: "GSSAPI authentication failed: the mechanism asked for another round but produced no token".to_string(), + }), + }; + } + + let principal = self + .context + .source_name() + .map_err(|error| failed("reading the client principal", &error))? + .to_string(); + + Ok(AcceptStep::Complete { token, principal }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // The authorization policy is the part of GSSAPI this server decides for + // itself, so it is the part tested exhaustively. The handshake proper is + // exercised against a real KDC by the conformance harness. + + fn default_policy() -> NameMapping { + NameMapping::default() + } + + #[test] + fn the_realm_is_part_of_the_name_by_default() { + let policy = default_policy(); + assert_eq!(policy.authorize("ada@ORBIT.TEST", "ada@ORBIT.TEST"), Ok(())); + // The bare name is not enough, which is PostgreSQL's default and the + // reason two trusted realms cannot impersonate each other. + assert_eq!( + policy.authorize("ada@ORBIT.TEST", "ada"), + Err(Denial::NotThisUser { + principal: "ada@ORBIT.TEST".to_string(), + requested: "ada".to_string(), + }) + ); + } + + #[test] + fn without_the_realm_the_bare_name_matches() { + let policy = NameMapping { + include_realm: false, + required_realm: None, + }; + assert_eq!(policy.authorize("ada@ORBIT.TEST", "ada"), Ok(())); + // And someone else still does not. + assert!(policy.authorize("eve@ORBIT.TEST", "ada").is_err()); + } + + #[test] + fn without_the_realm_any_realm_would_do_which_is_why_it_is_not_the_default() { + // This is the documented hazard, asserted so it cannot quietly change: + // realm matching off and no required realm means a principal from + // another trusted realm logs in as the same name. + let policy = NameMapping { + include_realm: false, + required_realm: None, + }; + assert_eq!(policy.authorize("ada@EVIL.TEST", "ada"), Ok(())); + + // Requiring a realm is the fix, and it works. + let guarded = NameMapping { + include_realm: false, + required_realm: Some("ORBIT.TEST".to_string()), + }; + assert_eq!( + guarded.authorize("ada@EVIL.TEST", "ada"), + Err(Denial::WrongRealm { + got: Some("EVIL.TEST".to_string()) + }) + ); + assert_eq!(guarded.authorize("ada@ORBIT.TEST", "ada"), Ok(())); + } + + #[test] + fn a_service_principal_keeps_its_slash() { + // `postgres/host@REALM` splits at the last `@`, not the first `/`. + let policy = NameMapping { + include_realm: false, + required_realm: None, + }; + assert_eq!(policy.authorize("postgres/localhost@ORBIT.TEST", "postgres/localhost"), Ok(())); + } + + #[test] + fn a_principal_with_no_realm_fails_a_realm_requirement() { + let policy = NameMapping { + include_realm: false, + required_realm: Some("ORBIT.TEST".to_string()), + }; + assert_eq!( + policy.authorize("ada", "ada"), + Err(Denial::WrongRealm { got: None }) + ); + } + + #[test] + fn matching_is_case_sensitive() { + // Kerberos principals are case sensitive, and folding them here would + // make `ADA` and `ada` the same login when the KDC says they are not. + let policy = default_policy(); + assert!(policy.authorize("ADA@ORBIT.TEST", "ada@ORBIT.TEST").is_err()); + assert!(policy.authorize("ada@orbit.test", "ada@ORBIT.TEST").is_err()); + } + + #[test] + fn an_empty_requested_user_matches_nothing() { + let policy = default_policy(); + assert!(policy.authorize("ada@ORBIT.TEST", "").is_err()); + } + + #[test] + fn a_denial_names_the_principal_it_refused() { + let denial = Denial::NotThisUser { + principal: "eve@ORBIT.TEST".to_string(), + requested: "ada".to_string(), + }; + let message = denial.message(); + assert!(message.contains("eve@ORBIT.TEST"), "{message}"); + assert!(message.contains("ada"), "{message}"); + } +} diff --git a/orbit/server/src/protocols/postgres_wire/messages.rs b/orbit/server/src/protocols/postgres_wire/messages.rs index e206301c..fa1b1f6d 100644 --- a/orbit/server/src/protocols/postgres_wire/messages.rs +++ b/orbit/server/src/protocols/postgres_wire/messages.rs @@ -123,6 +123,13 @@ pub enum FrontendMessage { SASLResponse { data: Bytes, }, + /// A GSSAPI token from the client. + /// + /// Carried by the same `'p'` message as a password, and told apart from + /// one only by what the server last asked for — see [`PasswordMessageKind`]. + GSSResponse { + data: Bytes, + }, CopyData { data: Bytes, }, @@ -287,9 +294,34 @@ pub struct FieldDescription { pub format: i16, } +/// What a `'p'` message means on this connection. +/// +/// The protocol gives password messages, SASL responses and GSSAPI tokens the +/// same `'p'` tag, and the only thing that distinguishes them is which +/// `Authentication` request the server sent last. Guessing from the payload +/// shape works for the first two because both are text, but a GSSAPI token is +/// arbitrary binary: it usually contains a zero byte, so reading it as a C +/// string truncates it, and the truncated token is rejected by the mechanism +/// with an error that says nothing about why. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PasswordMessageKind { + /// A password or a SASL response. + #[default] + Credential, + /// A GSSAPI token, to be taken as raw bytes. + GssToken, +} + impl FrontendMessage { - /// Parse a frontend message from bytes + /// Parse a frontend message from bytes. + /// + /// Equivalent to [`Self::parse_as`] with [`PasswordMessageKind::Credential`]. pub fn parse(buf: &mut BytesMut) -> ProtocolResult> { + Self::parse_as(buf, PasswordMessageKind::Credential) + } + + /// Parse a frontend message, reading `'p'` as `kind`. + pub fn parse_as(buf: &mut BytesMut, kind: PasswordMessageKind) -> ProtocolResult> { if buf.len() < 5 { return Ok(None); // Need at least type byte + length } @@ -357,7 +389,12 @@ impl FrontendMessage { b'c' => Self::parse_copy_done(&mut cursor)?, b'f' => Self::parse_copy_fail(&mut cursor)?, b'F' => Self::parse_function_call(&mut cursor)?, - b'p' => Self::parse_sasl_or_password(&mut cursor)?, + b'p' => match kind { + PasswordMessageKind::Credential => Self::parse_sasl_or_password(&mut cursor)?, + PasswordMessageKind::GssToken => FrontendMessage::GSSResponse { + data: Bytes::copy_from_slice(&msg_data), + }, + }, _ => { return Err(ProtocolError::PostgresError(format!( "Unknown message type: {}", @@ -685,7 +722,26 @@ impl BackendMessage { buf.put_i32(12); buf.put_slice(data); } - _ => buf.put_i32(0), // TODO: Implement other auth types + // Every remaining variant used to fall into a catch-all + // that wrote 0 — and 0 is `AuthenticationOk`. Asking for + // GSSAPI therefore told the client it had already + // authenticated, and it proceeded as a logged-in session. + // There is no arm here that is not a real request code. + AuthenticationResponse::KerberosV5 => buf.put_i32(2), + AuthenticationResponse::SCMCredential => buf.put_i32(6), + AuthenticationResponse::GSS => buf.put_i32(7), + AuthenticationResponse::GSSContinue { data } => { + buf.put_i32(8); + buf.put_slice(data); + } + AuthenticationResponse::SSPI => buf.put_i32(9), + // Certificate authentication has no request code: the + // certificate was presented during the TLS handshake, so + // by the time this is reached the client is already + // authenticated and the message really is `Ok`. Spelled + // out rather than reached by falling through, because the + // two differ only in intent. + AuthenticationResponse::Certificate => buf.put_i32(0), } let len = buf.len() - pos; diff --git a/orbit/server/src/protocols/postgres_wire/mod.rs b/orbit/server/src/protocols/postgres_wire/mod.rs index 72c1f5cc..af9ee331 100644 --- a/orbit/server/src/protocols/postgres_wire/mod.rs +++ b/orbit/server/src/protocols/postgres_wire/mod.rs @@ -38,6 +38,10 @@ pub mod auth; pub mod domains; pub mod fastpath; pub mod graphrag_engine; +/// GSSAPI/Kerberos authentication. Absent when the `gssapi` feature is off, +/// which is also the only way to build without a GSSAPI library to link to. +#[cfg(feature = "gssapi")] +pub mod gssapi; pub mod jsonb; pub mod messages; #[cfg(feature = "storage-rocksdb")] diff --git a/orbit/server/src/protocols/postgres_wire/protocol.rs b/orbit/server/src/protocols/postgres_wire/protocol.rs index 3b5d0d23..04d9acce 100644 --- a/orbit/server/src/protocols/postgres_wire/protocol.rs +++ b/orbit/server/src/protocols/postgres_wire/protocol.rs @@ -5,12 +5,12 @@ use std::collections::HashMap; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tracing::{debug, error, info}; +use tracing::{debug, error, info, warn}; -use super::auth::{AuthManager, AuthMethod, ScramAuth, UserStore}; +use super::auth::{configured_auth_method, AuthManager, AuthMethod, ScramAuth, UserStore}; use super::messages::{ type_oids, AuthenticationResponse, BackendMessage, FieldDescription, FrontendMessage, - TransactionStatus, + PasswordMessageKind, TransactionStatus, }; use super::notifications::{NotificationHub, SessionNotifications}; use super::query_engine::{QueryEngine, QueryResult}; @@ -72,6 +72,20 @@ pub struct PostgresWireProtocol { /// Needed to answer `Describe(Statement)`, which must report one type per /// parameter before the row description. statement_param_types: HashMap>, + /// Whether the message being handled belongs to the extended protocol. + /// + /// Only there does an error start skipping: a simple query synchronises + /// with its own `ReadyForQuery` and never sends `Sync`, so treating its + /// failures the same way left the connection discarding everything the + /// client sent next — including the queries that would have cleared it. + handling_extended: bool, + /// Whether an extended-protocol message has failed since the last `Sync`. + /// + /// The protocol requires everything after an error to be discarded until + /// the client synchronises. Without this the statements queued behind a + /// failure still ran, so a client pipelining writes had later ones applied + /// when it expected them skipped. + skip_until_sync: bool, portals: HashMap>)>, /// Result format codes requested by `Bind`, per portal. /// @@ -97,6 +111,12 @@ pub struct PostgresWireProtocol { portal_rows: HashMap, auth_manager: AuthManager, scram_auth: Option, + /// The half-finished GSSAPI handshake, once one has been asked for. + /// + /// Its presence is also what tells the message parser that a `'p'` message + /// on this connection is a token rather than a password. + #[cfg(feature = "gssapi")] + gss: Option, /// Writes issued inside the current transaction block. writes_in_transaction: u64, /// Contents of each table as it stood when the transaction block first @@ -170,6 +190,11 @@ pub struct PostgresWireProtocol { struct CopyInState { /// Whether the stream is in the binary format rather than text. binary: bool, + /// Whether the text stream is CSV rather than tab-separated. + /// + /// Read as tabs, a CSV line arrived as one field and the load failed with + /// a column-count mismatch. + csv: bool, /// Bytes of a binary stream not yet forming a whole tuple. pending: BytesMut, /// Whether the fixed binary header has been consumed. @@ -240,8 +265,7 @@ impl PostgresWireProtocol { // Initialize user store with a default user let user_store = UserStore::new(); // TODO: In a real app, we wouldn't add this user here or we'd load from config - // Default: Enable SCRAM-SHA-256 - let auth_method = AuthMethod::ScramSha256; + let auth_method = configured_auth_method(); let auth_manager = AuthManager::new(auth_method, user_store); Self { @@ -254,12 +278,16 @@ impl PostgresWireProtocol { secret_key: Self::random_secret_key(), prepared_statements: HashMap::new(), statement_param_types: HashMap::new(), + handling_extended: false, + skip_until_sync: false, portals: HashMap::new(), portal_result_formats: HashMap::new(), statement_columns: HashMap::new(), portal_rows: HashMap::new(), auth_manager, scram_auth: None, + #[cfg(feature = "gssapi")] + gss: None, transaction: TransactionState::Idle, writes_in_transaction: 0, transaction_snapshots: HashMap::new(), @@ -290,7 +318,7 @@ impl PostgresWireProtocol { pub fn new_with_query_engine(query_engine: Arc) -> Self { tracing::debug!("wire protocol session created with a custom query engine"); let user_store = UserStore::new(); - let auth_method = AuthMethod::ScramSha256; + let auth_method = configured_auth_method(); let auth_manager = AuthManager::new(auth_method, user_store); Self { @@ -303,12 +331,16 @@ impl PostgresWireProtocol { secret_key: Self::random_secret_key(), prepared_statements: HashMap::new(), statement_param_types: HashMap::new(), + handling_extended: false, + skip_until_sync: false, portals: HashMap::new(), portal_result_formats: HashMap::new(), statement_columns: HashMap::new(), portal_rows: HashMap::new(), auth_manager, scram_auth: None, + #[cfg(feature = "gssapi")] + gss: None, transaction: TransactionState::Idle, writes_in_transaction: 0, transaction_snapshots: HashMap::new(), @@ -360,6 +392,22 @@ impl PostgresWireProtocol { /// Recognises the transaction-control statements themselves; everything /// else leaves the state alone. fn note_statement(&mut self, sql: &str) { + // One message may carry several statements. Reading only the first + // word of the whole thing meant `BEGIN; ...; COMMIT` was seen as a + // `BEGIN` alone, and the session was left holding a transaction open + // that the client had already committed. + let statements = super::query_engine::split_statements(sql); + if statements.len() > 1 { + for statement in statements { + self.note_one_statement(&statement); + } + return; + } + self.note_one_statement(sql); + } + + /// Update the transaction state from a single statement. + fn note_one_statement(&mut self, sql: &str) { let head: String = sql .trim_start() .chars() @@ -1312,7 +1360,7 @@ impl PostgresWireProtocol { where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { - while let Some(msg) = FrontendMessage::parse(read_buf)? { + while let Some(msg) = FrontendMessage::parse_as(read_buf, self.password_message_kind())? { debug!("Received message: {:?}", msg); match self.process_single_message(msg, write_buf).await { @@ -1381,6 +1429,25 @@ impl PostgresWireProtocol { msg: FrontendMessage, buf: &mut BytesMut, ) -> ProtocolResult { + // Everything between a failure and the client's `Sync` is discarded, + // which is what makes a pipeline stop at its first error rather than + // running the rest of it. + // Only the extended protocol enters the skipping state, and only its + // own messages are discarded by it. A copy stream is exempt as well: + // its `CopyData`/`CopyDone` are what end the stream, and discarding + // them leaves both sides waiting forever. + self.handling_extended = matches!( + msg, + FrontendMessage::Parse { .. } + | FrontendMessage::Bind { .. } + | FrontendMessage::Execute { .. } + | FrontendMessage::Describe { .. } + | FrontendMessage::Close { .. } + ); + if self.skip_until_sync && self.copy_in.is_none() && self.handling_extended { + return Ok(true); + } + match msg { FrontendMessage::Startup { protocol_version, @@ -1400,7 +1467,8 @@ impl PostgresWireProtocol { query, param_types, } => { - self.handle_parse(&statement_name, &query, param_types, buf)?; + self.handle_parse(&statement_name, &query, param_types, buf) + .await?; } FrontendMessage::Bind { portal, @@ -1434,6 +1502,7 @@ impl PostgresWireProtocol { // ReadyForQuery into the middle of the stream, which is the // "unexpected message from server" the client then reported. if self.copy_in.is_none() { + self.skip_until_sync = false; self.deliver_pending_notifications(buf); BackendMessage::ReadyForQuery { status: self.transaction_status(), @@ -1465,6 +1534,9 @@ impl PostgresWireProtocol { self.handle_sasl_initial_response(&mechanism, data, buf) .await?; } + FrontendMessage::GSSResponse { data } => { + self.handle_gss_response(&data, buf).await?; + } FrontendMessage::SASLResponse { data } => { self.handle_sasl_response(data, buf).await?; } @@ -1579,6 +1651,9 @@ impl PostgresWireProtocol { } let response = self.auth_manager.get_initial_auth_response(); + if matches!(response, AuthenticationResponse::GSS) && !self.begin_gss(buf) { + return Ok(()); + } BackendMessage::Authentication(response.clone()).encode(buf); if let AuthenticationResponse::Ok = response { @@ -1588,6 +1663,40 @@ impl PostgresWireProtocol { Ok(()) } + /// How a `'p'` message is to be read on this connection. + fn password_message_kind(&self) -> PasswordMessageKind { + #[cfg(feature = "gssapi")] + if self.gss.is_some() { + return PasswordMessageKind::GssToken; + } + PasswordMessageKind::Credential + } + + /// Start a GSSAPI handshake, reporting whether it can proceed. + /// + /// Returns `false` — having already written an error — when this build + /// cannot do GSSAPI at all. Announcing `AuthenticationGSS` from a server + /// with no mechanism behind it would leave the client waiting on a + /// handshake that can never answer. + #[allow(unused_variables)] + fn begin_gss(&mut self, buf: &mut BytesMut) -> bool { + #[cfg(feature = "gssapi")] + { + self.gss = Some(super::gssapi::Acceptor::new()); + true + } + #[cfg(not(feature = "gssapi"))] + { + let error = ProtocolError::SqlState { + code: "0A000", + message: "GSSAPI authentication is configured but this server was built without it" + .to_string(), + }; + self.send_error_for(buf, &error); + false + } + } + /// The version this server reports, in `ParameterStatus` and in `SHOW`. /// /// The number leads because clients parse it: libpq takes the digits @@ -1677,6 +1786,103 @@ impl PostgresWireProtocol { } } + /// Handle one GSSAPI token from the client. + /// + /// Each token is fed to the acceptor, which either asks for another round + /// or establishes the context and names the principal. The principal is + /// then checked against the user in the startup packet before the session + /// is let in — the Kerberos library says *who* the caller is, and nothing + /// but this check says whether that caller may be this user. + #[allow(unused_variables)] + async fn handle_gss_response(&mut self, data: &[u8], buf: &mut BytesMut) -> ProtocolResult<()> { + #[cfg(not(feature = "gssapi"))] + { + let error = ProtocolError::SqlState { + code: "0A000", + message: "GSSAPI authentication is not supported by this build".to_string(), + }; + self.send_error_for(buf, &error); + Ok(()) + } + #[cfg(feature = "gssapi")] + { + use super::gssapi::{AcceptStep, NameMapping}; + + let Some(acceptor) = self.gss.as_mut() else { + // A token with no handshake open is a client out of step with + // the protocol; it is not a password, so it must not be tried + // as one. + let error = ProtocolError::SqlState { + code: "08P01", + message: "unexpected GSSAPI token: no authentication is in progress" + .to_string(), + }; + self.send_error_for(buf, &error); + return Ok(()); + }; + + let step = match acceptor.step(data) { + Ok(step) => step, + Err(error) => { + // The handshake is over either way; keeping the acceptor + // would let a client retry against a poisoned context. + self.gss = None; + // Sent through the error path that keeps the SQLSTATE: + // `send_error` re-derives one from the prose, and a + // rejected ticket came back as XX000 `internal_error`, + // which tells a client to retry something that will never + // succeed instead of to fix its credentials. + self.send_error_for(buf, &error); + return Ok(()); + } + }; + + match step { + AcceptStep::Continue(token) => { + BackendMessage::Authentication(AuthenticationResponse::GSSContinue { + data: bytes::Bytes::from(token), + }) + .encode(buf); + Ok(()) + } + AcceptStep::Complete { token, principal } => { + // Sent before `Ok`, and before the authorization check: + // under mutual authentication this token is what proves + // the server's identity, and a client that asked for it + // is entitled to it even when the answer is then no. + if let Some(token) = token { + BackendMessage::Authentication(AuthenticationResponse::GSSContinue { + data: bytes::Bytes::from(token), + }) + .encode(buf); + } + self.gss = None; + + let requested = self.username.clone().unwrap_or_default(); + match NameMapping::from_env().authorize(&principal, &requested) { + Ok(()) => { + info!(%principal, user = %requested, "GSSAPI authentication succeeded"); + BackendMessage::Authentication(AuthenticationResponse::Ok).encode(buf); + self.finish_authentication(buf); + } + Err(denial) => { + warn!(%principal, user = %requested, "GSSAPI authentication refused"); + let error = ProtocolError::SqlState { + // 28000 invalid_authorization_specification, + // which is what PostgreSQL reports when a + // login is refused. + code: "28000", + message: denial.message(), + }; + self.send_error_for(buf, &error); + } + } + Ok(()) + } + } + } + } + /// Handle SASL initial response async fn handle_sasl_initial_response( &mut self, @@ -1817,9 +2023,17 @@ impl PostgresWireProtocol { // block has failed. Answering them instead let a client believe work // done after the failure was part of the committed transaction. if self.transaction == TransactionState::Failed && !Self::ends_transaction(query) { - self.send_error( + // `25P02` is `in_failed_sql_transaction`, which is how a driver + // knows it must roll back rather than retry. Reported as `XX000` + // it was indistinguishable from the backend falling over. + self.send_error_for( buf, - "current transaction is aborted, commands ignored until end of transaction block", + &ProtocolError::SqlState { + code: "25P02", + message: "current transaction is aborted, commands ignored until end of \ + transaction block" + .to_string(), + }, ); BackendMessage::ReadyForQuery { status: self.transaction_status(), @@ -1981,7 +2195,7 @@ impl PostgresWireProtocol { } /// Handle parse message (prepared statement) - fn handle_parse( + async fn handle_parse( &mut self, statement_name: &str, query: &str, @@ -1993,20 +2207,31 @@ impl PostgresWireProtocol { self.prepared_statements .insert(statement_name.to_string(), query.to_string()); - // A client may send fewer type OIDs than the statement has placeholders, - // leaving the rest to be inferred. This engine treats every parameter as - // text, so unspecified entries are filled in as such rather than left - // out — `Describe` must report one type per parameter. + // A client may send fewer type OIDs than the statement has + // placeholders, or send 0 for "you decide". Those were all filled in + // as text, so `WHERE id = $1` compared an integer column against + // `'2'` and matched nothing — the exact failure `bind_parameters` + // says it guards against, arriving from the other side. The engine + // already works the type out from the column each placeholder is + // compared against; it just was not asked. let placeholders = Self::count_placeholders(query); let mut param_types = param_types; - param_types.resize( - param_types.len().max(placeholders), - super::messages::type_oids::TEXT, - ); - // OID 0 means "unspecified"; answer with the type actually used. - for oid in &mut param_types { - if *oid == 0 { - *oid = super::messages::type_oids::TEXT; + param_types.resize(param_types.len().max(placeholders), 0); + + if param_types.contains(&0) { + let inferred = self + .query_engine + .describe_parameters(query) + .await + .unwrap_or_default(); + for (position, oid) in param_types.iter_mut().enumerate() { + if *oid == 0 { + *oid = inferred + .get(position) + .copied() + .filter(|inferred| *inferred != 0) + .unwrap_or(super::messages::type_oids::TEXT); + } } } self.statement_param_types @@ -2025,6 +2250,10 @@ impl PostgresWireProtocol { | type_oids::INT8 | type_oids::FLOAT4 | type_oids::FLOAT8 + // An exact decimal is a number too. Quoted, `amt = $1` + // compared a `NUMERIC` column against a string and matched + // nothing. + | type_oids::NUMERIC | type_oids::BOOL ) } @@ -2299,7 +2528,9 @@ impl PostgresWireProtocol { let already_sent = remaining.sent; let rows = remaining.rows.clone(); let columns = remaining.columns.clone(); - return Ok(self.send_portal_page(portal, &columns, &rows, already_sent, max_rows, buf)); + self.send_portal_page(portal, &columns, &rows, already_sent, max_rows, buf) + .await; + return Ok(()); } let (statement_name, params) = self @@ -2328,6 +2559,15 @@ impl PostgresWireProtocol { } }; + // An empty statement is not an error. PostgreSQL answers + // `EmptyQueryResponse`, which is how a client tells "nothing to run" + // from "your statement was rejected"; the simple-query path already + // did this and the extended one reported a parse failure instead. + if bound.trim().is_empty() { + BackendMessage::EmptyQueryResponse.encode(buf); + return Ok(()); + } + self.copy_in_is_simple = false; if self .handle_copy_statement(&bound, buf, false) @@ -2366,7 +2606,8 @@ impl PostgresWireProtocol { sent: 0, }, ); - self.send_portal_page(&portal_name, &columns, &rows, 0, max_rows, buf); + self.send_portal_page(&portal_name, &columns, &rows, 0, max_rows, buf) + .await; } Ok(result) => { // Extended protocol: the row description was already sent in @@ -2850,6 +3091,10 @@ impl PostgresWireProtocol { let upper = trimmed.to_ascii_uppercase(); let binary = upper.contains(" BINARY") || upper.contains("FORMAT BINARY"); + // `WITH CSV` and `FORMAT CSV` were parsed by nothing, so a client that + // asked for CSV was written tab-separated text and had its CSV input + // read as one field. + let csv = !binary && (upper.contains(" CSV") || upper.contains("FORMAT CSV")); // `COPY
[(cols)] TO STDOUT` / `FROM STDIN` let body = trimmed[5..].trim(); @@ -2879,11 +3124,11 @@ impl PostgresWireProtocol { let _ = body; if to_stdout { - self.copy_table_to_stdout(&table, &columns, binary, buf) + self.copy_table_to_stdout(&table, &columns, binary, csv, buf) .await; self.finish_copy_statement(send_ready, buf); } else { - self.begin_copy_from_stdin(&table, columns, binary, buf) + self.begin_copy_from_stdin(&table, columns, binary, csv, buf) .await; } Some(()) @@ -2903,11 +3148,53 @@ impl PostgresWireProtocol { } /// Stream a table to the client as `COPY ... TO STDOUT` text. + /// Render one CSV field, quoting it only when it needs quoting. + /// + /// A field is quoted when it holds a comma, a quote, or a line break; + /// inside quotes a quote is doubled. That is the shape PostgreSQL writes + /// and the one a spreadsheet reads back. + fn csv_field(text: &str) -> String { + if text.contains([',', '"', '\n', '\r']) { + return format!("\"{}\"", text.replace('"', "\"\"")); + } + text.to_string() + } + + /// Split one CSV line into fields, honouring quotes. + /// + /// Returns each field with its quoting removed. A doubled quote inside a + /// quoted field is one quote. + fn split_csv_line(line: &str) -> Vec { + let mut fields = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + let mut chars = line.chars().peekable(); + + while let Some(character) = chars.next() { + match character { + '"' if in_quotes => { + if chars.peek() == Some(&'"') { + chars.next(); + current.push('"'); + } else { + in_quotes = false; + } + } + '"' => in_quotes = true, + ',' if !in_quotes => fields.push(std::mem::take(&mut current)), + other => current.push(other), + } + } + fields.push(current); + fields + } + async fn copy_table_to_stdout( &mut self, table: &str, columns: &[String], binary: bool, + csv: bool, buf: &mut BytesMut, ) { let projection = if columns.is_empty() { @@ -2993,20 +3280,30 @@ impl PostgresWireProtocol { } for row in &rows { - let line = row - .iter() - .map(|value| match value { - // `\N` is how the text format spells NULL, and is why a - // literal backslash has to be escaped. - None => "\\N".to_string(), - Some(text) => text - .replace('\\', "\\\\") - .replace('\t', "\\t") - .replace('\n', "\\n") - .replace('\r', "\\r"), - }) - .collect::>() - .join("\t"); + let line = if csv { + row.iter() + .map(|value| match value { + // CSV spells NULL as an empty field, not `\N`. + None => String::new(), + Some(text) => Self::csv_field(text), + }) + .collect::>() + .join(",") + } else { + row.iter() + .map(|value| match value { + // `\N` is how the text format spells NULL, and is why + // a literal backslash has to be escaped. + None => "\\N".to_string(), + Some(text) => text + .replace('\\', "\\\\") + .replace('\t', "\\t") + .replace('\n', "\\n") + .replace('\r', "\\r"), + }) + .collect::>() + .join("\t") + }; BackendMessage::CopyData { data: bytes::Bytes::from(format!("{line}\n")), } @@ -3026,6 +3323,7 @@ impl PostgresWireProtocol { table: &str, columns: Vec, binary: bool, + csv: bool, buf: &mut BytesMut, ) { // Column order has to be known before the first row arrives; when the @@ -3099,6 +3397,7 @@ impl PostgresWireProtocol { self.copy_in = Some(CopyInState { binary, + csv, pending: BytesMut::new(), header_seen: false, simple_protocol: self.copy_in_is_simple, @@ -3282,18 +3581,34 @@ impl PostgresWireProtocol { return Ok(()); }; - let values: Vec = line - .split('\t') + // CSV separates on commas and honours quotes; the text format + // separates on tabs and uses backslash escapes. Reading a CSV line the + // second way gave one field and a column-count mismatch. + let fields: Vec = if state.csv { + Self::split_csv_line(line) + } else { + line.split('\t').map(str::to_string).collect() + }; + let csv = state.csv; + + let values: Vec = fields + .iter() .enumerate() .map(|(index, field)| { - if field == "\\N" { + let field = field.as_str(); + // CSV spells NULL as an empty unquoted field. + if (csv && field.is_empty()) || (!csv && field == "\\N") { return "NULL".to_string(); } - let unescaped = field - .replace("\\t", "\t") - .replace("\\n", "\n") - .replace("\\r", "\r") - .replace("\\\\", "\\"); + let unescaped = if csv { + field.to_string() + } else { + field + .replace("\\t", "\t") + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\\\", "\\") + }; // Bare only where the column takes a bare literal *and* the // value really is one; anything else is quoted so a stray field @@ -3366,7 +3681,7 @@ impl PostgresWireProtocol { /// after the limit is reached the reply is `PortalSuspended` rather than /// `CommandComplete`: that is what tells the client to ask for the next /// page instead of concluding the result set ended. - fn send_portal_page( + async fn send_portal_page( &mut self, portal: &str, columns: &[String], @@ -3394,6 +3709,17 @@ impl PostgresWireProtocol { .cloned() .unwrap_or_default(); + // Binary output is asked for in `Bind`, which a client may send + // without ever issuing `Describe` — and `Describe` was the only thing + // that recorded a column's type. Without it every value fell back to + // text, so a client that asked for binary silently got characters. + let wants_binary = formats.contains(&1); + let column_types = if wants_binary && column_types.is_empty() { + self.column_types_for_portal(portal).await + } else { + column_types + }; + for row in rows.iter().skip(already_sent).take(limit) { let values: Vec> = row .iter() @@ -3430,6 +3756,29 @@ impl PostgresWireProtocol { } } + /// The column types of a portal's statement, described on demand. + /// + /// Only consulted when binary output was asked for and nothing has + /// described the statement yet, so an ordinary text query pays nothing. + async fn column_types_for_portal(&mut self, portal: &str) -> Vec { + let Some(statement) = self + .portals + .get(portal) + .map(|(statement, _)| statement.clone()) + else { + return Vec::new(); + }; + let Some(sql) = self.prepared_statements.get(&statement).cloned() else { + return Vec::new(); + }; + let Ok(description) = self.query_engine.describe_statement(&sql).await else { + return Vec::new(); + }; + let types: Vec = description.columns.iter().map(|c| c.type_oid).collect(); + self.statement_columns.insert(statement, types.clone()); + types + } + /// Encode one value in PostgreSQL's binary format for `type_oid`. /// /// The engine holds every value as text, so this parses and re-encodes. @@ -3676,7 +4025,11 @@ impl PostgresWireProtocol { /// An error that knows its own code keeps it — a `RAISE EXCEPTION` is /// `P0001` whatever its text says, and no reading of that text would /// reveal it. - fn send_error_for(&self, buf: &mut BytesMut, error: &ProtocolError) { + fn send_error_for(&mut self, buf: &mut BytesMut, error: &ProtocolError) { + // Anything the client already pipelined behind this is discarded until + // it synchronises — in the extended protocol only, where `Sync` is the + // synchronisation point. + self.skip_until_sync |= self.handling_extended; let code = super::sqlstate::of(error); let reported = error.to_string(); let reported = reported @@ -3695,7 +4048,8 @@ impl PostgresWireProtocol { /// The SQLSTATE is classified rather than always `XX000`: a driver reading /// `internal_error` for a duplicate key cannot tell a constraint it should /// handle from a backend that fell over. - fn send_error(&self, buf: &mut BytesMut, message: &str) { + fn send_error(&mut self, buf: &mut BytesMut, message: &str) { + self.skip_until_sync |= self.handling_extended; // The transport's name is not part of the error. `PostgreSQL protocol // error: relation does not exist` is our plumbing showing through. let reported = message diff --git a/orbit/server/src/protocols/postgres_wire/query_engine.rs b/orbit/server/src/protocols/postgres_wire/query_engine.rs index e30e5e03..e62fd79e 100644 --- a/orbit/server/src/protocols/postgres_wire/query_engine.rs +++ b/orbit/server/src/protocols/postgres_wire/query_engine.rs @@ -136,6 +136,30 @@ pub fn type_oid_for(sql_type: &str) -> i32 { } } +/// Render a stored number, honouring a column's declared scale. +/// +/// Only `NUMERIC`/`DECIMAL` carries one; everything else prints as stored. A +/// scale that cannot be applied leaves the number alone rather than inventing +/// digits. +#[must_use] +fn render_number(number: &serde_json::Number, declared: Option<&ColumnType>) -> String { + use std::str::FromStr; + + let Some(ColumnType::Numeric { + scale: Some(scale), .. + }) = declared + else { + return number.to_string(); + }; + rust_decimal::Decimal::from_str(&number.to_string()).map_or_else( + |_| number.to_string(), + |mut decimal| { + decimal.rescale(u32::from(*scale)); + decimal.to_string() + }, + ) +} + /// The type a domain definition leads with. /// /// A definition is the base type followed by whatever constraints were @@ -153,6 +177,113 @@ pub fn leading_type(definition: &str) -> String { definition[..end].trim().to_string() } +/// The storage type a declared type name maps to. +/// +/// One table, used by `CREATE TABLE` and by `ALTER TABLE ... ADD COLUMN`. +/// Two copies of this would drift, which is the failure mode this document +/// records more than any other. +#[must_use] +fn column_type_from_name(declared: &str) -> ColumnType { + match declared.trim().to_uppercase().as_str() { + "INTEGER" | "INT" => ColumnType::Integer, + "BIGINT" => ColumnType::BigInt, + "SERIAL" | "BIGSERIAL" => ColumnType::Serial, + "TEXT" => ColumnType::Text, + "BOOLEAN" | "BOOL" => ColumnType::Boolean, + "JSON" => ColumnType::Json, + "DOUBLE" => ColumnType::Double, + "TIMESTAMP" => ColumnType::Timestamp, + "REAL" | "FLOAT" | "FLOAT4" | "FLOAT8" | "DOUBLE PRECISION" => ColumnType::Double, + data_type if data_type.starts_with("NUMERIC") || data_type.starts_with("DECIMAL") => { + // `NUMERIC(10,2)` reached none of the arms above and fell + // through to the unknown case, which is `TEXT`. The column + // then held whatever the value happened to be, and its + // declared scale existed nowhere. + let (precision, scale) = data_type + .find('(') + .and_then(|open| { + let close = data_type.find(')')?; + let inside = &data_type[open + 1..close]; + let mut parts = inside.split(','); + let precision = parts.next()?.trim().parse::().ok(); + let scale = parts.next().and_then(|s| s.trim().parse::().ok()); + Some((precision, scale)) + }) + .unwrap_or((None, None)); + ColumnType::Numeric { precision, scale } + } + data_type => { + if data_type.starts_with("VARCHAR") { + // Extract length if present + let len = if let Some(start) = data_type.find('(') { + let end = data_type.find(')').unwrap_or(data_type.len()); + data_type[start + 1..end].parse().unwrap_or(255) + } else { + 255 + }; + ColumnType::Varchar(len) + } else { + // Default to text for unknown types + ColumnType::Text + } + } + } +} + +/// Replace column references in an expression with a row's values. +/// +/// Word boundaries only, and never inside a string literal: a column named `n` +/// must not rewrite the `n` in `'n'`. +#[must_use] +fn substitute_columns(expression: &str, row: &HashMap) -> String { + fn flush(word: &mut String, out: &mut String, row: &HashMap) { + if word.is_empty() { + return; + } + let folded = fold_identifier(word); + match row + .iter() + .find(|(name, _)| fold_identifier(name) == folded) + .map(|(_, value)| value) + { + Some(JsonValue::Null) => out.push_str("NULL"), + Some(JsonValue::String(text)) => { + out.push('\''); + out.push_str(&text.replace('\'', "''")); + out.push('\''); + } + Some(value) => out.push_str(&value.to_string()), + None => out.push_str(word), + } + word.clear(); + } + + let mut out = String::with_capacity(expression.len()); + let mut word = String::new(); + let mut in_string = false; + + for character in expression.chars() { + if character == '\'' { + flush(&mut word, &mut out, row); + in_string = !in_string; + out.push(character); + continue; + } + if in_string { + out.push(character); + continue; + } + if character.is_alphanumeric() || character == '_' { + word.push(character); + continue; + } + flush(&mut word, &mut out, row); + out.push(character); + } + flush(&mut word, &mut out, row); + out +} + /// Whether a word is a bare column reference rather than an expression. /// /// A qualified name (`t.id`) counts; anything with an operator, a call or a @@ -285,7 +416,7 @@ fn upper_tail(statement: &str, if_exists: bool) -> &str { /// Semicolons inside string literals, quoted identifiers and dollar-quoted /// bodies do not separate statements; splitting on every `;` would cut /// `VALUES (\'a;b\')` in half. -fn split_statements(sql: &str) -> Vec { +pub(crate) fn split_statements(sql: &str) -> Vec { let mut statements = Vec::new(); let mut current = String::new(); let mut chars = sql.chars().peekable(); @@ -2016,6 +2147,15 @@ impl QueryEngine { } } + // A placeholder in `LIMIT`/`OFFSET` is compared against no column, so + // nothing above types it. Left as text it was spliced in quoted and + // the clause was ignored — `LIMIT $1` returned every row. + for position in Self::row_count_placeholders(sql) { + if position <= count { + types[position - 1] = type_oids::INT8; + } + } + Ok(types) } @@ -2064,6 +2204,19 @@ impl QueryEngine { found } + /// Placeholders that give a row count: `LIMIT $1`, `OFFSET $2`. + fn row_count_placeholders(sql: &str) -> Vec { + let upper = sql.to_uppercase(); + Self::scan_placeholders(sql) + .into_iter() + .filter(|(_, at)| { + let before = upper[..*at].trim_end(); + before.ends_with("LIMIT") || before.ends_with("OFFSET") + }) + .map(|(position, _)| position) + .collect() + } + /// Placeholders that sit on the right of a comparison, paired with the /// column name on the left: `WHERE id = $1` yields `(1, "id")`. fn placeholder_comparisons(sql: &str) -> Vec<(usize, String)> { @@ -3127,6 +3280,223 @@ impl QueryEngine { return Ok(Some(QueryResult::Update { count: 0 })); } + // `CREATE [UNIQUE] INDEX [name] ON
()`. + // + // A plain index is a performance structure and changes no answer, so + // accepting one without building it is honest. A *unique* index is an + // integrity constraint the caller asked for by name: accepted and not + // enforced, duplicates went in silently. It is recorded on the column, + // which is where uniqueness is already checked. + if upper.starts_with("CREATE ") && upper.contains(" INDEX ") { + let unique = upper.contains(" UNIQUE INDEX "); + if !unique { + return Ok(None); + } + let Some(on_at) = upper.find(" ON ") else { + return Ok(None); + }; + let name = trimmed[..on_at] + .split_whitespace() + .last() + .map(fold_identifier) + .unwrap_or_default(); + let rest = trimmed[on_at + 4..].trim(); + let Some(open) = rest.find('(') else { + return Ok(None); + }; + let Some(close) = rest.rfind(')') else { + return Ok(None); + }; + let table = fold_identifier(rest[..open].trim()); + let columns: Vec = rest[open + 1..close] + .split(',') + .map(|column| fold_identifier(column.trim())) + .filter(|column| !column.is_empty()) + .collect(); + + let [column] = columns.as_slice() else { + // A schema records uniqueness per column, so a multi-column + // unique index has nowhere to live. Refusing says so rather + // than accepting a constraint that would never be checked. + return Err(ProtocolError::SqlState { + code: "0A000", + message: "a multi-column UNIQUE INDEX is not supported; \ + declare the columns UNIQUE instead" + .to_string(), + }); + }; + + let mut schema = storage.get_table_schema(&table).await?.ok_or_else(|| { + ProtocolError::PostgresError(format!("Table '{table}' does not exist")) + })?; + let Some(existing) = schema + .columns + .iter_mut() + .find(|c| fold_identifier(&c.name) == *column) + else { + return Err(ProtocolError::SqlState { + code: "42703", + message: format!("column \"{column}\" of relation \"{table}\" does not exist"), + }); + }; + + // The rows already there have to satisfy it, or the index would + // claim something about the table that is not true. + let rows = storage + .select_rows(&table, Vec::new(), Vec::new(), None) + .await?; + let mut seen = std::collections::HashSet::new(); + for row in rows.iter().filter(|row| row_is_visible(&row.values)) { + let Some(value) = row + .values + .iter() + .find(|(key, _)| fold_identifier(key) == *column) + .map(|(_, value)| value) + .filter(|value| !value.is_null()) + else { + continue; + }; + if !seen.insert(value.to_string()) { + return Err(ProtocolError::SqlState { + code: "23505", + message: format!( + "could not create unique index \"{name}\": key value is duplicated" + ), + }); + } + } + + existing.unique = true; + storage.create_table(schema).await?; + self.remember_index(&name, &table, column).await?; + return Ok(Some(QueryResult::Update { count: 0 })); + } + + // `DROP INDEX ` — a unique index carries a constraint, so + // dropping it has to take the constraint with it. + if upper.starts_with("DROP INDEX") { + let name = fold_identifier( + trimmed["DROP INDEX".len()..] + .trim() + .trim_start_matches("IF EXISTS") + .trim(), + ); + let Some((table, column)) = self.index_definition(&name).await? else { + // Not a unique index this server recorded; nothing to undo. + return Ok(None); + }; + if let Some(mut schema) = storage.get_table_schema(&table).await? { + if let Some(existing) = schema + .columns + .iter_mut() + .find(|c| fold_identifier(&c.name) == column) + { + existing.unique = false; + } + storage.create_table(schema).await?; + } + self.forget_catalog_entry(&format!("index:{name}")).await?; + return Ok(Some(QueryResult::Update { count: 0 })); + } + + // `ALTER TABLE ADD COLUMN [DEFAULT x] [NOT NULL]`. + // + // Nothing handled this, so it fell through to a generic + // "Command completed successfully": the statement reported success and + // the column was not there. Every later reference to it then failed + // with `column does not exist`, pointing at the query rather than at + // the DDL that never happened. + if upper.starts_with("ALTER TABLE") && upper.contains(" ADD ") { + let words: Vec<&str> = trimmed.split_whitespace().collect(); + let table = words + .get(2) + .map(|name| fold_identifier(name)) + .ok_or_else(|| { + ProtocolError::PostgresError("ALTER TABLE requires a name".to_string()) + })?; + let at = words + .iter() + .position(|word| word.eq_ignore_ascii_case("ADD")) + .ok_or_else(|| { + ProtocolError::PostgresError( + "ALTER TABLE ... ADD requires a column".to_string(), + ) + })?; + // `COLUMN` is optional in PostgreSQL. + let start = if words + .get(at + 1) + .is_some_and(|word| word.eq_ignore_ascii_case("COLUMN")) + { + at + 2 + } else { + at + 1 + }; + let definition: Vec<&str> = words[start.min(words.len())..].to_vec(); + let (Some(name), Some(declared)) = (definition.first(), definition.get(1)) else { + return Err(ProtocolError::PostgresError( + "ALTER TABLE ... ADD COLUMN requires a name and a type".to_string(), + )); + }; + let rest: Vec = definition[2.min(definition.len())..] + .iter() + .map(|word| word.to_uppercase()) + .collect(); + let says = |word: &str| rest.iter().any(|c| c == word); + let column = ColumnDefinition { + name: fold_identifier(name), + data_type: column_type_from_name(declared.trim_end_matches(',')), + // A column added to a table that already has rows must be + // nullable unless a default fills it, or the existing rows + // would violate it the moment it is added. + nullable: !(says("NOT") && says("NULL")), + default_value: rest + .iter() + .position(|word| word == "DEFAULT") + .and_then(|at| definition.get(2 + at + 1)) + .map(|value| Self::literal_to_json(value)), + unique: says("UNIQUE"), + check: None, + references: None, + domain: None, + }; + + let mut schema = storage.get_table_schema(&table).await?.ok_or_else(|| { + ProtocolError::PostgresError(format!("Table '{table}' does not exist")) + })?; + if schema + .columns + .iter() + .any(|existing| fold_identifier(&existing.name) == fold_identifier(&column.name)) + { + return Err(ProtocolError::SqlState { + code: "42701", + message: format!( + "column \"{}\" of relation \"{table}\" already exists", + column.name + ), + }); + } + let default_value = column.default_value.clone(); + let column_name = column.name.clone(); + schema.columns.push(column); + storage.create_table(schema).await?; + + // PostgreSQL fills the rows that already exist with the default; + // left out, a row written before the column existed reads NULL + // while one written after reads the default, and the same table + // answers two ways depending on when a row arrived. + if let Some(default_value) = default_value { + storage + .update_rows( + &table, + HashMap::from([(column_name, default_value)]), + Vec::new(), + ) + .await?; + } + return Ok(Some(QueryResult::Update { count: 0 })); + } + Ok(None) } @@ -4817,6 +5187,23 @@ impl QueryEngine { Ok(plpgsql_function::argument_type(source, value).to_string()) } + /// Evaluate an expression against one row's values. + /// + /// Column references are replaced with that row's values first, which is + /// what makes `SET n = n + 1` mean *this* row's `n`. + /// + /// # Errors + /// Returns the engine's error when the expression cannot be evaluated. + async fn evaluate_over_row( + &self, + expression: &str, + row: &HashMap, + ) -> ProtocolResult { + let substituted = substitute_columns(expression, row); + let evaluated = Box::pin(self.evaluate_scalar(&substituted)).await?; + Ok(evaluated.map_or(JsonValue::Null, |text| Self::literal_to_json(&text))) + } + /// Evaluate a scalar expression by asking the engine for `SELECT `. async fn evaluate_scalar(&self, expression: &str) -> ProtocolResult> { let result = Box::pin(self.execute_query(&format!("SELECT {expression}"))).await?; @@ -5021,6 +5408,26 @@ impl QueryEngine { }) } + /// Record a unique index so dropping it can take its constraint away. + async fn remember_index(&self, name: &str, table: &str, column: &str) -> ProtocolResult<()> { + self.remember_domain(&format!("__index__{name}"), &format!("{table}|{column}")) + .await?; + self.rename_catalog_entry(&format!("domain:__index__{name}"), &format!("index:{name}")) + .await + } + + /// The table and column a recorded unique index covers. + async fn index_definition(&self, name: &str) -> ProtocolResult> { + Ok(self + .view_definition(&format!("index:{name}")) + .await? + .and_then(|definition| { + definition + .split_once('|') + .map(|(table, column)| (table.to_string(), column.to_string())) + })) + } + /// A domain's declared type and constraints, if the name is one. pub async fn domain_definition(&self, name: &str) -> ProtocolResult> { self.view_definition(&format!("domain:{name}")).await @@ -5905,6 +6312,27 @@ impl QueryEngine { resolved.having = Some(self.resolve_subqueries(having).await?); } + // The select list needs this as much as the predicate does. It was + // resolved for `WHERE` and `HAVING` only, so + // `SELECT (SELECT COUNT(*) FROM t)` reached the evaluator with the + // subquery still in it and failed as unimplemented — while the + // same subquery in a `WHERE` worked. + for item in &mut resolved.select_list { + if let crate::protocols::postgres_wire::sql::ast::SelectItem::Expression { + expr, + .. + } = item + { + let taken = std::mem::replace( + expr, + crate::protocols::postgres_wire::sql::ast::Expression::Literal( + crate::protocols::postgres_wire::sql::types::SqlValue::Null, + ), + ); + *expr = self.resolve_subqueries(taken).await?; + } + } + let (names, mut values) = select_pipeline::run_select_values(&resolved, rows)?; // A wildcard is named by the pipeline as `*`; the real names come @@ -6770,9 +7198,40 @@ impl QueryEngine { let mut evaluator = ExpressionEvaluator::new(); let mut joined = Vec::new(); + // Every column name each side contributes, so an unmatched row can be + // padded with NULLs instead of simply lacking them. Without the + // padding an outer row had no key for the other side's columns at all, + // and `SELECT val FROM a LEFT JOIN b ...` failed with + // `column "val" does not exist` rather than returning NULL. + let columns_of = |rows: &[crate::protocols::postgres_wire::sql::select_pipeline::Row]| { + let mut names: Vec = Vec::new(); + for row in rows { + for key in row.keys() { + if !names.contains(key) { + names.push(key.clone()); + } + } + } + names + }; + let left_columns = columns_of(left_rows); + let right_columns = columns_of(right_rows); + let padded = |row: &crate::protocols::postgres_wire::sql::select_pipeline::Row, + missing: &[String]| { + let mut out = row.clone(); + for name in missing { + out.entry(name.clone()).or_insert(SqlValue::Null); + } + out + }; + + // Which right rows found a partner, for the outer joins that keep the + // ones that did not. + let mut right_matched = vec![false; right_rows.len()]; + for left_row in left_rows { let mut matched = false; - for right_row in right_rows { + for (right_index, right_row) in right_rows.iter().enumerate() { let mut combined = left_row.clone(); for (key, value) in right_row { // A bare name present on both sides keeps the left one; @@ -6812,14 +7271,26 @@ impl QueryEngine { if keep || matches!(join_type, JoinType::Cross) { matched = true; + right_matched[right_index] = true; joined.push(combined); } } - // A left outer join keeps an unmatched left row with NULLs for the - // right side. - if !matched && matches!(join_type, JoinType::LeftOuter) { - joined.push(left_row.clone()); + // A left or full outer join keeps an unmatched left row, with the + // right side's columns present and NULL. + if !matched && matches!(join_type, JoinType::LeftOuter | JoinType::FullOuter) { + joined.push(padded(left_row, &right_columns)); + } + } + + // And the mirror: a right or full outer join keeps the right rows that + // found no partner. Neither did this at all, so `RIGHT JOIN` behaved + // as an inner join and `FULL OUTER JOIN` lost both unmatched sides. + if matches!(join_type, JoinType::RightOuter | JoinType::FullOuter) { + for (right_index, right_row) in right_rows.iter().enumerate() { + if !right_matched[right_index] { + joined.push(padded(right_row, &left_columns)); + } } } @@ -7271,14 +7742,18 @@ impl QueryEngine { } /// Parse a single SET clause (key = value) + /// Split `column = value`, keeping the value exactly as written. + /// + /// The quotes are deliberately left on. Stripping them here threw away the + /// only thing that distinguishes a text literal from an expression, so + /// `SET n = n + 1` and `SET t = 'n + 1'` arrived identical — and it + /// mangled an escaped quote besides, turning `'it''s'` into `it''s`. + /// `literal_to_json` unquotes properly, as it already does for + /// `INSERT ... VALUES`. fn parse_single_set_clause(&self, clause: &str) -> Option<(String, String)> { let eq_pos = clause.find('=')?; let key = clause[..eq_pos].trim().to_string(); - let value = clause[eq_pos + 1..] - .trim() - .trim_matches('\'') - .trim_matches('"') - .to_string(); + let value = clause[eq_pos + 1..].trim().to_string(); Some((key, value)) } @@ -7359,6 +7834,13 @@ impl QueryEngine { SqlValue::DoublePrecision(f) => { serde_json::Number::from_f64(*f).map_or(JsonValue::Null, JsonValue::Number) } + // An exact decimal is a number. Rendered as a string it did not + // match the number in storage, and because an update identifies + // its row by *every* column, one mismatching column stopped the + // whole update — a table merely containing a `NUMERIC` column + // silently dropped updates to its other columns. + SqlValue::Decimal(d) => std::str::FromStr::from_str(&d.to_string()) + .map_or(JsonValue::Null, JsonValue::Number), other => JsonValue::String(other.to_postgres_string()), } } @@ -7979,9 +8461,15 @@ impl QueryEngine { // Apply updates for (col, val) in &set_clauses { + // The value keeps its quotes now, so unquote it the same way + // the stored path does. + let val = match Self::literal_to_json(val) { + JsonValue::String(text) => text, + other => other.to_string(), + }; match col.to_uppercase().as_str() { "STATE" => { - actor.state = serde_json::from_str(val) + actor.state = serde_json::from_str(&val) .unwrap_or_else(|_| JsonValue::String(val.clone())); } "ACTOR_TYPE" => { @@ -8209,10 +8697,22 @@ impl QueryEngine { // `None` is SQL NULL on the wire. Rendering it as the // text "NULL" made a null indistinguishable from a row // whose value is the three-letter string. + // A column declared with a scale renders at that + // scale: `NUMERIC(10,2)` reads back `10.50`, not + // `10.5`. This was written once before and removed as + // dead — it was inert only because the column's type + // was still `Text` at the time, which is now fixed. + let declared = table_schema.as_ref().and_then(|schema| { + schema + .columns + .iter() + .find(|c| fold_identifier(&c.name) == fold_identifier(col)) + .map(|c| &c.data_type) + }); value.and_then(|v| match v { JsonValue::Null => None, JsonValue::String(s) => Some(s.clone()), - JsonValue::Number(n) => Some(n.to_string()), + JsonValue::Number(n) => Some(render_number(n, declared)), JsonValue::Bool(b) => Some(b.to_string()), other => Some(other.to_string()), }) @@ -9332,10 +9832,23 @@ impl QueryEngine { ))); } - // Convert SET clauses to HashMap + // Convert SET clauses to HashMap. + // + // A value that is not a literal is an expression over the row being + // updated — `SET n = n + 1`. Put through `literal_to_json` it became + // the *text* `n + 1` and was never applied, while `RETURNING` reported + // the computed value: a client was told a write had happened that had + // not. Those are computed per row, below. + let row_expressions: Vec<(String, String)> = set_clauses + .iter() + .filter(|(_, value)| !Self::is_plain_literal(value)) + .cloned() + .collect(); let mut set_values = std::collections::HashMap::new(); for (col, val) in set_clauses { - set_values.insert(fold_identifier(&col), Self::literal_to_json(&val)); + if Self::is_plain_literal(&val) { + set_values.insert(fold_identifier(&col), Self::literal_to_json(&val)); + } } // Convert WHERE clause to QueryConditions @@ -9389,6 +9902,27 @@ impl QueryEngine { .collect(); let count = visible.len(); + // Every new row is built before a single old one is marked. + // + // Computed after the mark, an expression that failed to evaluate + // left the old row deleted and no new row written — the update did + // not merely fail, it destroyed the row. This way a failure + // returns an error having changed nothing. + let mut replacements = Vec::with_capacity(visible.len()); + for row in &visible { + let mut values = row.values.clone(); + for (column, expression) in &row_expressions { + let computed = self.evaluate_over_row(expression, &row.values).await?; + let stored = values + .keys() + .find(|name| fold_identifier(name) == fold_identifier(column)) + .cloned() + .unwrap_or_else(|| fold_identifier(column)); + values.insert(stored, computed); + } + replacements.push(values); + } + // The previous rows are marked first: marking after writing the // new version would match it too — it satisfies the same predicate // — and the row would vanish for everyone. @@ -9403,9 +9937,8 @@ impl QueryEngine { ) .await?; - for row in visible { + for mut values in replacements { let now = chrono::Utc::now(); - let mut values = row.values.clone(); for (column, value) in &set_values { let stored = values .keys() @@ -9527,9 +10060,7 @@ impl QueryEngine { if_not_exists: bool, foreign_keys: Vec, ) -> ProtocolResult { - use crate::protocols::postgres_wire::persistent_storage::{ - ColumnDefinition, ColumnType, TableSchema, - }; + use crate::protocols::postgres_wire::persistent_storage::{ColumnDefinition, TableSchema}; // Check if table already exists if storage.table_exists(table).await? { @@ -9574,52 +10105,7 @@ impl QueryEngine { } for col in &columns { - let column_type = match col.data_type.to_uppercase().as_str() { - "INTEGER" | "INT" => ColumnType::Integer, - "BIGINT" => ColumnType::BigInt, - "SERIAL" | "BIGSERIAL" => ColumnType::Serial, - "TEXT" => ColumnType::Text, - "BOOLEAN" | "BOOL" => ColumnType::Boolean, - "JSON" => ColumnType::Json, - "DOUBLE" => ColumnType::Double, - "TIMESTAMP" => ColumnType::Timestamp, - "REAL" | "FLOAT" | "FLOAT4" | "FLOAT8" | "DOUBLE PRECISION" => ColumnType::Double, - data_type - if data_type.starts_with("NUMERIC") || data_type.starts_with("DECIMAL") => - { - // `NUMERIC(10,2)` reached none of the arms above and fell - // through to the unknown case, which is `TEXT`. The column - // then held whatever the value happened to be, and its - // declared scale existed nowhere. - let (precision, scale) = data_type - .find('(') - .and_then(|open| { - let close = data_type.find(')')?; - let inside = &data_type[open + 1..close]; - let mut parts = inside.split(','); - let precision = parts.next()?.trim().parse::().ok(); - let scale = parts.next().and_then(|s| s.trim().parse::().ok()); - Some((precision, scale)) - }) - .unwrap_or((None, None)); - ColumnType::Numeric { precision, scale } - } - data_type => { - if data_type.starts_with("VARCHAR") { - // Extract length if present - let len = if let Some(start) = data_type.find('(') { - let end = data_type.find(')').unwrap_or(data_type.len()); - data_type[start + 1..end].parse().unwrap_or(255) - } else { - 255 - }; - ColumnType::Varchar(len) - } else { - // Default to text for unknown types - ColumnType::Text - } - } - }; + let column_type = column_type_from_name(&col.data_type); let constraints: Vec = col .constraints diff --git a/orbit/server/src/protocols/postgres_wire/sql/expression_evaluator.rs b/orbit/server/src/protocols/postgres_wire/sql/expression_evaluator.rs index 37e71e46..f1914412 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/expression_evaluator.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/expression_evaluator.rs @@ -909,7 +909,15 @@ impl ExpressionEvaluator { "TRIM" | "BTRIM" => self.evaluate_trim(&args), "LTRIM" => self.evaluate_ltrim(&args), "RTRIM" => self.evaluate_rtrim(&args), - "POSITION" | "STRPOS" => self.evaluate_position(&args), + "POSITION" => self.evaluate_position(&args), + // `strpos(string, substring)` takes its arguments the other way + // round from `position(substring in string)`. Sharing one + // implementation gave `strpos('abc', 'b')` = 0 — it searched + // "abc" inside "b". + "STRPOS" => self.evaluate_position(&[ + args.get(1).cloned().unwrap_or(SqlValue::Null), + args.first().cloned().unwrap_or(SqlValue::Null), + ]), "INITCAP" => self.evaluate_initcap(&args), "REPEAT" => self.evaluate_repeat(&args), "ASCII" => self.evaluate_ascii(&args), diff --git a/orbit/server/src/protocols/postgres_wire/sql/parser/expressions.rs b/orbit/server/src/protocols/postgres_wire/sql/parser/expressions.rs index b9da5f84..76e4704c 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/parser/expressions.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/parser/expressions.rs @@ -986,6 +986,14 @@ impl ExpressionParser { table: None, name: "*".to_string(), })); + } else if func_name.eq_ignore_ascii_case("POSITION") && args.is_empty() { + // `POSITION(sub IN str)` is the standard spelling, and the + // `IN` in it separates two arguments. Parsed at the usual + // level the comparison rules take it first and build an + // `IN` expression, leaving the call malformed — so the + // needle is read below that level, where `IN` is not an + // operator. + args.push(self.parse_additive_expression(tokens, pos)?); } else { args.push(self.parse_expression(tokens, pos)?); } @@ -1006,8 +1014,12 @@ impl ExpressionParser { // `FROM` and `FOR` separate arguments in `EXTRACT(f FROM s)` // and `SUBSTRING(s FROM a FOR b)`, where a comma would be a - // syntax error. - if *pos < tokens.len() && matches!(tokens[*pos], Token::From | Token::For) { + // syntax error; `IN` does the same in `POSITION(sub IN str)`. + if *pos < tokens.len() + && (matches!(tokens[*pos], Token::From | Token::For) + || (func_name.eq_ignore_ascii_case("POSITION") + && matches!(tokens[*pos], Token::In))) + { *pos += 1; } else if *pos < tokens.len() && matches!(tokens[*pos], Token::Comma) { // Lookahead for ORDER or SEPARATOR after comma (invalid but sometimes users type it?) diff --git a/orbit/server/src/protocols/postgres_wire/sql/select_pipeline.rs b/orbit/server/src/protocols/postgres_wire/sql/select_pipeline.rs index 051b4757..9b4fce10 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/select_pipeline.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/select_pipeline.rs @@ -484,11 +484,23 @@ fn evaluate_over_group( } } "AVG" => { - let numbers: Vec = inputs.iter().filter_map(as_f64).collect(); - if numbers.is_empty() { - SqlValue::Null + // PostgreSQL averages exact inputs exactly: `AVG` over integers is + // `numeric`, not a float. Through `f64` the mean of 2, 3 and 5 came + // back as `3.3333333333333335` — a value that is not the average of + // anything, carrying a rounding artifact in its last digit. + if !inputs.is_empty() && inputs.iter().all(|v| as_decimal(v).is_some()) { + let total: rust_decimal::Decimal = inputs.iter().filter_map(as_decimal).sum(); + let count = rust_decimal::Decimal::from(inputs.len()); + total + .checked_div(count) + .map_or(SqlValue::Null, SqlValue::Decimal) } else { - SqlValue::DoublePrecision(numbers.iter().sum::() / numbers.len() as f64) + let numbers: Vec = inputs.iter().filter_map(as_f64).collect(); + if numbers.is_empty() { + SqlValue::Null + } else { + SqlValue::DoublePrecision(numbers.iter().sum::() / numbers.len() as f64) + } } } "MIN" => inputs diff --git a/orbit/server/src/unified_storage.rs b/orbit/server/src/unified_storage.rs index cb883b23..0f39848a 100644 --- a/orbit/server/src/unified_storage.rs +++ b/orbit/server/src/unified_storage.rs @@ -38,6 +38,8 @@ //! let graph_adapter = integration.graph_adapter("cypher"); //! ``` +pub use orbit_engine::unified::rocksdb_backend::{Compression, RocksDbBackendConfig}; + use orbit_engine::unified::{ rocksdb_backend::RocksDbBackend, AdapterFactory, CqlAdapter, GraphAdapter, MemoryBackend, Protocol, RedisAdapter, RestAdapter, SchemaRegistry, SqlAdapter, UnifiedStorage, @@ -60,6 +62,10 @@ pub struct UnifiedStorageIntegrationConfig { pub max_scan_limit: usize, /// Use memory backend (for testing) pub use_memory_backend: bool, + /// How the persistent backend trades durability against speed. + /// + /// Ignored when `use_memory_backend` is set, which keeps nothing. + pub durability: RocksDbBackendConfig, } impl Default for UnifiedStorageIntegrationConfig { @@ -70,6 +76,7 @@ impl Default for UnifiedStorageIntegrationConfig { ttl_check_interval_secs: 60, max_scan_limit: 1_000_000, use_memory_backend: false, + durability: RocksDbBackendConfig::default(), } } } @@ -118,14 +125,18 @@ impl UnifiedStorageIntegration { } else { let path = Path::new(&config.data_dir).join("unified"); info!( - "[UnifiedStorage] Using RocksDB backend at {}", - path.display() + path = %path.display(), + sync_writes = config.durability.sync_writes, + wal = config.durability.enable_wal, + "[UnifiedStorage] Using RocksDB backend" ); - Arc::new(RocksDbBackend::open(&path).map_err(|e| { - UnifiedStorageError::InitializationFailed(format!( - "could not open the unified store: {e}" - )) - })?) + Arc::new( + RocksDbBackend::open_with(&path, &config.durability).map_err(|e| { + UnifiedStorageError::InitializationFailed(format!( + "could not open the unified store: {e}" + )) + })?, + ) }; // Create storage configuration diff --git a/specifications/PRD.md b/specifications/PRD.md index 78ce34bb..355172a2 100644 --- a/specifications/PRD.md +++ b/specifications/PRD.md @@ -957,7 +957,9 @@ Properties the harness cannot reach, verified by hand against a running server: | Property | How it was checked | Result | |----------|--------------------|--------| | Durability | Write, stop, restart, read | Rows and schemas survive | -| Crash safety | Write, `SIGKILL`, restart, read | Survives via the RocksDB WAL | +| Crash safety | Write, `SIGKILL`, restart, read | Survives via the RocksDB WAL — now automated as `pg_crash_durability` | +| Power-loss safety | `sync_wal` issues an fsync before acknowledging | Writes reach the disk; **not** verified by a real power cut | +| Corruption | Overwrite bytes inside an SST, reopen, scan | Reported as a checksum error, never served as data | | Redis durability | `SET`, restart, `GET` | Survives | | MySQL protocol | Raw handshake on 3306 | Server greeting, protocol 10 | | CQL protocol | `OPTIONS` frame on 9042 | `SUPPORTED` reply | @@ -1059,6 +1061,88 @@ Note that `unified_storage.data_dir` in `config/orbit-server.toml` is a separate setting from the `--data-dir` flag, which the unified store does not read. +##### An acknowledged write reaches the disk + +`set_sync` appeared nowhere in the repository, so every RocksDB write used the +default `WriteOptions`, where `sync` is false. A `put` returned once the log +record was in the operating system's page cache. That distinction is invisible +to a `SIGKILL` test — the kernel still holds the buffer, so the process-crash +check passed and proved only process-crash safety. A power cut or kernel panic +lost every acknowledged write since the last flush. + +`RocksDbBackend` now builds one `WriteOptions` at open and uses it on every +`put`, `delete`, and batch, so durability is a property of the store rather +than of which call site made the write. Shutdown flushes the log before the +memtable, so a stop interrupted between the two still has every write +recoverable. + +The trade is real and belongs to the operator: `sync_wal = false` is roughly an +order of magnitude faster and loses recent writes on power loss. +`RocksDbBackendConfig::unsafe_fast()` names that choice for tests. + +##### Corruption is detected, not served + +Verified by writing 5,000 rows, flushing them to SST files, overwriting 512 +bytes in the middle of each, and reopening: the scan fails with a checksum +error rather than returning damaged rows +(`orbit/engine/tests/durability.rs::corrupted_data_on_disk_is_detected_rather_than_served`). +RocksDB's per-block CRC32c already did this; the test pins it so a future +options change cannot silently turn it off. `paranoid_checks` and +`wal_recovery_mode` are now set explicitly rather than inherited — point-in-time +recovery keeps every completed write and discards only a torn tail, which is +the record that was being written when the power went out and that no client +was told had succeeded. + +##### The warm-tier configuration was decorative + +Every knob under `[unified_storage.warm_tier]` — `sync_wal`, `block_cache_mb`, +`write_buffer_mb`, `max_write_buffers`, `enable_bloom_filters`, +`bloom_bits_per_key`, `max_disk_gb` — was parsed into `WarmTierConfig` and read +by nothing; `grep` found zero read sites. `RocksDbBackend::open` took a path and +no options. An operator who set `sync_wal = true` to get durable writes got no +fsync and no warning, which is the failure mode a configuration file is +supposed to prevent. + +`compression_algorithm = "lz4"` could not have worked either: the `rocksdb` +dependency was built with `default-features = false`, so no codec was linked +in. `lz4` and `zstd` are now enabled in both `orbit-engine` and `orbit-server` +— they must match, because cargo unifies them into one build of +`librocksdb-sys` — and a test opens a database with each codec in turn, so an +unlinked codec fails the build rather than the server's start-up. +`max_disk_gb` remains unread and is called out here rather than left to imply a +quota that nothing enforces. + +Two contradictions are now refused at start-up instead of per operation: +`sync_wal` with `enable_wal = false` (RocksDB rejects each such write +individually, so the server would start clean and then fail everything), and an +unknown `compression_algorithm`. + +##### Verified against a running server + +`tests/integration/pg_crash_durability.rs` owns the server process rather than +connecting to one: it writes 25 rows over the PostgreSQL wire, sends `SIGKILL` +so no shutdown hook or destructor runs, restarts over the same directory, and +checks the rows are all present, in order, undamaged — and that the `PRIMARY +KEY` still rejects a duplicate, which proves the constraints persisted and not +merely the column names. It derives its configuration from the shipped +`config/orbit-server.toml` so it cannot drift from what operators run, and it +asserts `sync_wal` is on, so the test stops claiming durability if that default +is ever turned back off. + +```bash +cargo test -p orbit-integration-tests --test pg_crash_durability -- --ignored --nocapture +``` + +##### Ports in the configuration file are ignored + +Not fixed, recorded because it misleads: `apply_cli_overrides` in +`orbit/server/src/main.rs` assigns `args.postgres_port` (and the redis, mysql, +cql, grpc and metrics ports, `bind_address`, and `data_dir`) over the parsed +configuration unconditionally. Clap supplies its default whether or not the +flag was passed, so a port set in `config/orbit-server.toml` can never take +effect. The crash-durability test passes ports on the command line for this +reason. + #### SQLSTATE Every error left as `XX000` — `internal_error`, the code PostgreSQL uses for @@ -1103,12 +1187,21 @@ nobody has categorised still reports `XX000`, which is what it is — returning plausible-looking code for an unclassified error would be worse than admitting it. -#### Found by probing, not yet fixed +#### Found by probing -Widening the harness into areas it had never covered found three defects that -were fixed (above) and three that were not. They are written down with the -statement that shows each, so they are gaps with evidence rather than a feeling -that something is missing: +Widening the harness into areas it had never covered found six defects. Each is +written down with the statement that showed it, so they are gaps with evidence +rather than a feeling that something is missing. + +A `NUMERIC` column renders at its **declared scale** on every read path — +clause-free, simple `WHERE`, and `ORDER BY` go through different code, and a +check walks all three. A column without a declared scale does not gain one. +That fix had been written once before and **removed as dead code**, correctly +at the time: it was inert because the column's type was still `Text`, the +mapping bug not yet found. Re-applied afterwards, it works. The removal was +still right — code that changes no output should not sit in the tree looking +like a feature — but it is worth recording that "this patch does nothing" can +mean "something upstream is broken" rather than "this patch is wrong". Two of the three were the same defect: `NUMERIC`, `DECIMAL`, `JSON`, `JSONB`, `INTERVAL`, `BYTEA`, `UUID` and `CHAR` were reachable as **column** types but @@ -1142,8 +1235,6 @@ across the integer, float and decimal types, ordered for text, dates, timestamps and booleans, and `None` — no match — only for values that genuinely cannot be compared. -What remains from that round: - Following the `NUMERIC(10,2)` rendering to its cause found something larger than rendering. `SqlType` → `ColumnType` mapping in `orbit/server/src/protocols/common/storage/unified.rs` had no arm for @@ -1160,20 +1251,38 @@ was **removed**: no query reached it, so it changed no output, and code that changes no output is the decorative kind this document argues against elsewhere. -#### An UPDATE with an expression is silently lost - -`UPDATE t SET n = n + 1` changes nothing. `RETURNING` reports the new value, so -a client is told a write happened that did not — the same combination as a -wrong answer, and worse than an error. The `SET` value is stored through -`literal_to_json`, which turns anything that is not a literal into text. - -**A fix for this was written and reverted.** Computing each expression against -the row being updated made it worse: rows disappeared entirely, because the -per-row write interacts with the versioned-update logic that marks the previous -row deleted and re-inserts a new version. Losing rows is worse than failing to -update them, so the change came out and the defect stands recorded rather than -half-fixed. Whoever takes it next should start from the interaction with -version marking, not from the expression evaluation. +#### An UPDATE with an expression — fixed, after two wrong attempts + +`UPDATE t SET n = n + 1` changed nothing while `RETURNING` reported the new +value: a client was told a write had happened that had not. Both halves are +fixed, and both wrong attempts are recorded because each failed for a reason +worth knowing. + +**Where the fix belongs.** A `SET` value was stripped of its quotes by +`parse_single_set_clause`, which threw away the only thing distinguishing a +text literal from an expression — `SET t = 'n + 1'` and `SET n = n + 1` +arrived identical. The first attempt tried to tell them apart downstream by +guessing (a bare word is a literal unless it names a column or carries an +operator) and that is wrong: `SET note = 'a + b'` carries an operator. The +quotes are now kept and `literal_to_json` unquotes them, exactly as the +`INSERT ... VALUES` path already did. That also fixed a corruption nobody had +noticed: `trim_matches` turned `'it''s'` into `it''s`, storing the doubled +quote. + +**Ordering.** The second attempt computed each expression *after* the old rows +were marked deleted, so an expression that failed to evaluate left the row +marked and no new version written — the update did not merely fail, it +destroyed the row. Every replacement row is now built before the first mark, so +a failure returns an error having changed nothing. There is a check for that. + +**And a regression this document has to own.** Adding `ColumnType::Numeric` +made stored values `Decimal`, and two `SqlValue`→JSON converters had no arm for +it, so a decimal was written as the *string* `"10.00"`. Because an update +identifies its row by **every** column's value, one column converting wrongly +matched no row at all: a table that merely *contained* a `NUMERIC` column +silently dropped updates to its other columns. That shipped in +`804e0a16` and was found by testing the update path against a table shaped like +a real one rather than the two-integer table the first test used. The lesson is about the number rather than the three: the check count had been presented as covering the remaining work, and one afternoon of probing @@ -1182,6 +1291,236 @@ untested constructs found six things wrong. Two of them — errors, which is the class this document keeps recording and the class no passing suite reveals until someone writes the check. +#### Parameters of unspecified type + +A driver may leave a parameter's type to the server — that is what OID `0` +means, and it is what most drivers send. Every such parameter was filled in as +**text**, which broke the extended query protocol in six ways at once. The +conformance harness had not caught any of them because it used simple queries +almost throughout; these arrive through `Parse`/`Bind`/`Execute`. + +| Statement | What happened | +|-----------|---------------| +| `WHERE id = $1` | matched no rows — an integer column compared against `'2'` | +| `WHERE id = $1 AND name = $2` | failed outright: `Cannot compare Integer(1) and Text("1")` | +| `WHERE amt = $1` on `NUMERIC` | matched no rows | +| `WHERE flag = $1` on `BOOLEAN` | matched no rows | +| `LIMIT $1` | the clause was ignored and every row came back | +| `UPDATE ... WHERE id = $2` | reported success and changed nothing | + +The engine could already work out a parameter's type from the column it is +compared against — `describe_parameters` does exactly that — but it was only +consulted to *answer* a `Describe`, never to decide how to bind. Parse now asks +it for anything the client left unspecified, falling back to text only when +inference finds nothing. Two gaps in the inference itself went with it: +`NUMERIC` was missing from the types written unquoted, and a placeholder in +`LIMIT`/`OFFSET` is compared against no column at all, so nothing typed it. + +The shape of this one is worth keeping: the server *knew* the right answer and +told clients so when asked, while using a different answer internally. Nothing +about the code looked wrong, and the check that would have caught it is the one +nobody had written. + +Two more from the same probe, both the same shape — a capability implemented +but reachable only by a route the client is not obliged to take: + +- **Binary result format was ignored.** A client asks for it in `Bind`. The + encoder existed and worked, but the column types it needs were recorded only + by `Describe`, which the protocol does not require. Without one, every value + fell back to text and a client that asked for binary silently got characters. + The types are now described on demand, and only when binary was actually + asked for, so a text query pays nothing. +- **An empty statement was rejected.** The extended path answered a parse + error where PostgreSQL answers `EmptyQueryResponse` — which is how a client + tells "nothing to run" from "your statement was refused". The simple-query + path had always answered it correctly; only the extended one had not. + +What the same probe found already correct, now with checks: portal suspension +(a row-limited `Execute` replies `PortalSuspended` and the next `Execute` +continues rather than restarting), `Describe` of a statement returning both a +`ParameterDescription` and a `RowDescription` with the right type OIDs, +re-binding one statement with different parameters, and using a closed +statement failing rather than silently succeeding. + +#### Transaction state + +Probing the transaction state machine — the part a driver relies on to know +what it may send next — found two defects, and confirmed the rest correct. + +- **A statement in a failed block reported `XX000`.** PostgreSQL reports + `25P02` (`in_failed_sql_transaction`), which is how a driver knows it must + roll back rather than retry; as `XX000` it was indistinguishable from the + backend falling over. The refusal itself was already right, and + `ReadyForQuery` already reported `E` — only the code was wrong. +- **A block sent as one message left a transaction open.** The session's state + was read from the first word of the whole message, so + `BEGIN; INSERT ...; COMMIT` was seen as a `BEGIN` alone and the trailing + `COMMIT` went unnoticed. The connection was left holding a transaction the + client had already ended — every later statement silently joined it, and a + disconnect would have discarded them. Each statement in a message is now + noted in turn. + +Correct already, and now checked: `ReadyForQuery` reporting `I`/`T`/`E` as the +session moves; `COMMIT` of a failed block rolling back rather than committing; +`SAVEPOINT` and `ROLLBACK TO SAVEPOINT`; and two statements in one message +returning two results with their own command tags. + +#### COPY, notification and type formatting + +Probing the three surfaces the harness had barely touched found one defect and +confirmed a good deal already right. + +**`WITH CSV` was parsed by nothing.** `COPY ... TO STDOUT WITH CSV` wrote +tab-separated text and `COPY ... FROM STDIN WITH CSV` read a CSV line as one +field, failing with a column-count mismatch. Both directions now handle CSV +properly: a field is quoted only when it contains a comma, a quote or a line +break; a quote inside a quoted field is doubled; and an empty unquoted field is +NULL, which is how CSV spells it — the text format's `\N` means nothing here. +There is a round-trip check covering exactly those three cases, because they +are what separate CSV from splitting on commas. + +Correct already: `COPY FROM STDIN` and `COPY TO STDOUT` in the text format, +including backslash escapes and `\N`; a client-initiated `CopyFail` aborting +the load and leaving the table unchanged; `LISTEN` and `NOTIFY` accepted with a +payload. + +**Type formatting was correct throughout** — booleans as `t`/`f`, timestamps in +ISO form, NULL sorting last by default and first under `NULLS FIRST`, NULL +rendered as a real NULL rather than the text "NULL", an empty result set +carrying its row description, and `COUNT(n)` counting non-nulls where +`COUNT(*)` counts rows. Nothing to fix; worth recording that it was checked +rather than assumed. + +#### Sequences, conflicts, identifiers, text and subqueries + +A probe across five more surfaces found one defect and confirmed the rest. + +**A scalar subquery worked in `WHERE` but not in the select list.** Subqueries +were resolved for the predicate and for `HAVING`, so the very same subquery +that filtered correctly failed as unimplemented one clause to the left: +`SELECT (SELECT COUNT(*) FROM t)` reached the evaluator with the subquery still +in it. The select list is now resolved too, and an empty subquery yields NULL +rather than an error, as SQL requires. + +Correct already, and now partly checked: `SERIAL` producing distinct non-null +keys; `ON CONFLICT ... DO NOTHING` leaving the existing row and +`ON CONFLICT ... DO UPDATE` replacing it; a quoted mixed-case identifier +keeping its case, with the unquoted spelling correctly *not* finding it; +UTF-8 round-tripping including accents, CJK and emoji, with `LENGTH` counting +characters rather than bytes and `LIKE` matching across multibyte text; and +`IN (subquery)`, `NOT IN (subquery)` and correlated `EXISTS`. + +#### DDL evolution, views, indexes and joins + +**`ALTER TABLE ... ADD COLUMN` reported success and did nothing.** No branch +handled it, so it fell through to a generic "Command completed successfully" +and the column was simply not there. Every later reference then failed with +`column does not exist`, pointing at the query rather than at the DDL that +never happened — the same silent acceptance this document records for `DO` +blocks, `CREATE FUNCTION` and `DROP TYPE`. It now adds the column, fills the +rows that already exist when a `DEFAULT` is given (without which the same table +answers two ways depending on when a row arrived), makes `COLUMN` optional as +PostgreSQL does, and refuses a duplicate with `42701`. + +Extracting that meant the declared-type table now has **one** copy, shared by +`CREATE TABLE` and `ADD COLUMN`. Two copies would have drifted, which is the +failure mode recorded here more than any other. + +Correct already, and checked: views (`CREATE VIEW`, selecting from one with and +without a clause, aggregating over one, `DROP VIEW`); `CREATE INDEX` and +`DROP INDEX`; `ALTER TABLE ... RENAME TO`; `INNER`, `LEFT` and `CROSS JOIN`, +and `JOIN ... USING`; multi-row `RETURNING` on `INSERT`, `UPDATE` and `DELETE`. + +**`CREATE UNIQUE INDEX` did not enforce uniqueness.** Nothing handled the +statement, so it reported success and duplicates went in silently — an +integrity constraint the caller asked for by name. Uniqueness is recorded on +the column, which is where it is already checked, so the index now works; +`DROP INDEX` takes the constraint away again; creating one over rows that +already violate it is refused with `23505` rather than claiming something about +the table that is not true; and a multi-column unique index is refused with +`0A000`, because a schema records uniqueness per column and there is nowhere +for one to live. A plain, non-unique index is still accepted without being +built — it changes no answer, only speed. + +**The outer joins kept only unmatched *left* rows.** `RIGHT JOIN` therefore +behaved as an inner join and `FULL OUTER JOIN` lost both unmatched sides — +counting 2 where 4 were right. Worse, an unmatched row was pushed *without* the +other side's columns rather than with them set to NULL, so +`SELECT val FROM a LEFT JOIN b ...` failed with `column "val" does not exist` +instead of returning NULL. Unmatched rows on both sides are now kept and padded. + +**A finding this document got wrong.** `ALTER TABLE ... DROP COLUMN` and +`RENAME COLUMN` were recorded here as failing on a column that exists. They do +not. The probe that "found" them dropped and renamed a column it had added a +moment earlier with `ADD COLUMN` — which was silently doing nothing, so the +column was never there and both statements were right to refuse. Two working +features were written down as broken because the failure upstream was silent. +Both are now checked directly, on columns declared in `CREATE TABLE`. + +#### Pipeline error recovery + +**A failed statement did not stop the rest of its pipeline.** The protocol +requires everything between an error and the client's next `Sync` to be +discarded; instead the queued statements ran, so a client pipelining writes had +later ones applied when it expected them skipped. Verified against the raw +protocol: three statements sent before one `Sync` with the middle one failing +now yield the first statement's rows, the error, and then only +`ReadyForQuery` — where before, the third statement's `ParseComplete`, +`BindComplete`, `DataRow` and `CommandComplete` all followed the error. + +**Two mistakes on the way, both of which hung the harness**, and both worth +recording because a hang is the least informative failure there is: + +- The first attempt discarded `CopyData` and `CopyDone` too. Those are what end + a copy stream, so both sides waited for each other for ever. +- The second scoped the skipping to *all* messages rather than the extended + protocol's. A simple query synchronises with its own `ReadyForQuery` and + never sends `Sync`, so after any failing simple statement the connection + discarded everything the client sent next — including the queries that would + have cleared the state. Nothing recovered it. + +Both were found by logging the last statement the server saw before the silence +rather than by reading the code again: the hang pointed at +`INSERT INTO conf_notnull (id) VALUES (NULL)`, a statement whose *failure* was +the trigger, which named the mechanism immediately. + +The state is now entered only by `Parse`/`Bind`/`Execute`/`Describe`/`Close`, +cleared by `Sync`, and never applied while a copy is open. + +Also checked and already correct: a prepared statement re-plans after the table +under it changes — after `ADD COLUMN` it returns the new column, after +`DROP COLUMN` it does not, with no stale result and no error. + +#### Set operations, aggregates, windows and functions + +A probe across this surface found two defects and confirmed a great deal. + +- **`strpos` had its arguments reversed.** `position(sub IN str)` and + `strpos(str, sub)` are the same function with opposite argument orders, and + both were routed to one implementation — so `strpos('abc', 'b')` searched + "abc" inside "b" and answered `0`. A wrong answer, not an error. +- **`AVG` over exact inputs went through a float.** The mean of 2, 3 and 5 came + back as `3.3333333333333335`, whose last digit is a rounding artifact of + binary floating point. PostgreSQL averages integers as `numeric`; so does + this now, when every input is exact. + +Correct already, and checked: `UNION`, `UNION ALL`, `INTERSECT`, `EXCEPT`; +`GROUP BY` with `COUNT`/`SUM`/`MIN`/`MAX`, `HAVING` both including and +excluding, `COUNT(DISTINCT ...)`; `ROW_NUMBER`, `RANK` with ties, `SUM OVER ()`, +`PARTITION BY`, `LAG` with its leading NULL; `UPPER`/`LOWER`, `TRIM`, +`REPLACE`, `||`, `ABS`, `ROUND`, `MOD`, `CEIL`, `FLOOR`, `GREATEST`, `LEAST`, +`NOW`, `CURRENT_DATE` and `STRING_AGG`. + +**`POSITION(sub IN str)`** — the standard spelling — now parses. The first +attempt added `IN` to the argument-separator list and changed nothing, because +by the time that list is consulted the comparison rules have already taken +`sub IN str` and built an `IN` expression; that attempt was removed rather than +left in looking like a feature. The needle is now parsed one level below the +comparison rules, where `IN` is not an operator, and only for `POSITION` — +`IN` keeps its meaning everywhere else, which is checked both as a list +operator and as `NOT IN`. Character positions, not byte offsets: +`POSITION('語' IN '日本語')` is 3. + #### Observed once, unexplained A single run of the conformance harness failed one check with diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 6db97fb4..43d2d99d 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -75,7 +75,19 @@ required-features = ["minio-tests"] bytes = "1.12.1" futures-util = "0.3.33" tokio-postgres = "0.7" +# For SIGKILL in the crash-durability test: `Child::kill` sends SIGTERM, which +# a graceful shutdown would catch, and catching it is what the test must not +# allow. +libc = "0.2" +# The crash test derives its configuration from the shipped one rather than +# hand-writing a minimal file, so it cannot drift out of shape as the schema +# grows a required field. +toml = "0.9" [[test]] name = "pg_conformance" path = "integration/pg_conformance.rs" + +[[test]] +name = "pg_crash_durability" +path = "integration/pg_crash_durability.rs" diff --git a/tests/integration/pg_conformance.rs b/tests/integration/pg_conformance.rs index f302829a..c541b26e 100644 --- a/tests/integration/pg_conformance.rs +++ b/tests/integration/pg_conformance.rs @@ -616,6 +616,61 @@ async fn postgres_protocol_conformance() { .await, ); + report.record( + Area::Copy, + "COPY ... WITH CSV round-trips, quoting included", + async { + drop_table(&client, "conf_csv").await; + client + .simple_query("CREATE TABLE conf_csv (id INTEGER, name TEXT)") + .await + .map_err(describe)?; + + // The CSV option was parsed by nothing: output came back + // tab-separated and CSV input arrived as a single field. + let sink = client + .copy_in("COPY conf_csv FROM STDIN WITH CSV") + .await + .map_err(describe)?; + futures_util::pin_mut!(sink); + use futures_util::SinkExt as _; + // A comma inside quotes, a doubled quote, and an empty field for + // NULL — the three things separating CSV from "split on commas". + sink.as_mut() + .send(bytes::Bytes::from_static( + b"1,ada\n2,\"a,b\"\n3,\"say \"\"hi\"\"\"\n4,\n", + )) + .await + .map_err(describe)?; + let written = sink.finish().await.map_err(describe)?; + if written != 4 { + return Err(format!("COPY FROM CSV reported {written} rows, expected 4")); + } + let names = simple_column(&client, "SELECT name FROM conf_csv ORDER BY id").await?; + if names != ["ada", "a,b", "say \"hi\"", "NULL"] { + return Err(format!("stored {names:?}")); + } + + // And back out, with the same three cases re-quoted. + let stream = client + .copy_out("COPY conf_csv TO STDOUT WITH CSV") + .await + .map_err(describe)?; + futures_util::pin_mut!(stream); + let mut text = String::new(); + while let Some(chunk) = futures_util::StreamExt::next(&mut stream).await { + text.push_str(&String::from_utf8_lossy(&chunk.map_err(describe)?)); + } + let lines: Vec<&str> = text.lines().collect(); + let result = (lines == ["1,ada", "2,\"a,b\"", "3,\"say \"\"hi\"\"\"", "4,"]) + .then_some(()) + .ok_or(format!("COPY TO CSV wrote {lines:?}")); + drop_table(&client, "conf_csv").await; + result + } + .await, + ); + report.record( Area::Copy, "COPY FROM STDIN ingests rows", @@ -5392,6 +5447,821 @@ async fn postgres_protocol_conformance() { ); } + report.record( + Area::Sql, + "UPDATE with an expression applies, and RETURNING agrees with storage", + async { + drop_table(&client, "conf_upd").await; + client + .simple_query( + "CREATE TABLE conf_upd (id INTEGER, n INTEGER, amt NUMERIC(10,2), t TEXT)", + ) + .await + .map_err(describe)?; + client + .simple_query( + "INSERT INTO conf_upd (id, n, amt, t) VALUES (1, 5, 10.00, 'a'), (2, 7, 20.00, 'b')", + ) + .await + .map_err(describe)?; + + // `SET n = n + 1` changed nothing while `RETURNING` reported the + // new value: a client was told a write had happened that had not. + client + .simple_query("UPDATE conf_upd SET n = n + 1 WHERE id = 1") + .await + .map_err(describe)?; + let counted = simple_column(&client, "SELECT n FROM conf_upd WHERE id = 1").await?; + if counted != ["6"] { + return Err(format!("n is {counted:?}, expected [6]")); + } + + // The same for an exact decimal, which must stay exact. + client + .simple_query("UPDATE conf_upd SET amt = amt * 2 WHERE id = 2") + .await + .map_err(describe)?; + let doubled = simple_column(&client, "SELECT amt FROM conf_upd WHERE id = 2").await?; + if doubled != ["40.00"] { + return Err(format!("amt is {doubled:?}, expected [40.00]")); + } + + // And what RETURNING says must be what was stored. + let returned = + simple_column(&client, "UPDATE conf_upd SET n = n + 100 WHERE id = 1 RETURNING n") + .await?; + let stored = simple_column(&client, "SELECT n FROM conf_upd WHERE id = 1").await?; + (returned == stored) + .then_some(()) + .ok_or(format!("RETURNING said {returned:?}, storage holds {stored:?}")) + } + .await, + ); + + report.record( + Area::Sql, + "a quoted SET value stays text, however it is spelled", + async { + // The quotes are what say this is a literal. Stripped before the + // update saw them, `'a + b'` was indistinguishable from an + // expression — and `'it''s'` kept its doubled quote. + client + .simple_query("UPDATE conf_upd SET t = 'a + b' WHERE id = 1") + .await + .map_err(describe)?; + let text = simple_column(&client, "SELECT t FROM conf_upd WHERE id = 1").await?; + if text != ["a + b"] { + return Err(format!("got {text:?}, expected [a + b]")); + } + client + .simple_query("UPDATE conf_upd SET t = 'it''s' WHERE id = 2") + .await + .map_err(describe)?; + let escaped = simple_column(&client, "SELECT t FROM conf_upd WHERE id = 2").await?; + (escaped == ["it's"]) + .then_some(()) + .ok_or(format!("got {escaped:?}, expected [it's]")) + } + .await, + ); + + report.record( + Area::Sql, + "a failing UPDATE expression changes nothing", + async { + let before = simple_column(&client, "SELECT n FROM conf_upd ORDER BY id").await?; + // Computed after the old rows were marked deleted, a failure left + // the row marked and no new version written — it destroyed the row + // rather than merely failing. + if client + .simple_query("UPDATE conf_upd SET n = conf_no_such_column + 1") + .await + .is_ok() + { + return Err("an unknown column in SET was accepted".into()); + } + let after = simple_column(&client, "SELECT n FROM conf_upd ORDER BY id").await?; + let result = (after == before).then_some(()).ok_or(format!( + "rows went {before:?} -> {after:?} after a failed update" + )); + drop_table(&client, "conf_upd").await; + result + } + .await, + ); + + // ---- Parameters, unspecified type --------------------------------- + // + // A driver may leave a parameter's type to the server. Every such + // parameter was filled in as text, so `WHERE id = $1` compared an integer + // column against `'2'` and matched nothing. The harness reaches this + // through `query`, which uses Parse/Bind/Execute rather than a simple + // query. + + report.record( + Area::ExtendedQuery, + "a parameter's type is inferred from what it is compared against", + async { + drop_table(&client, "conf_param").await; + client + .simple_query( + "CREATE TABLE conf_param (id INTEGER, name TEXT, amt NUMERIC(10,2), flag BOOLEAN)", + ) + .await + .map_err(describe)?; + client + .simple_query( + "INSERT INTO conf_param (id, name, amt, flag) VALUES (1, 'ada', 10.50, TRUE), (2, 'grace', 20.00, FALSE)", + ) + .await + .map_err(describe)?; + + // Each of these is a different column type, and each was matching + // nothing when the parameter defaulted to text. + let by_int = client + .query("SELECT name FROM conf_param WHERE id = $1", &[&2i32]) + .await + .map_err(describe)?; + if by_int.len() != 1 { + return Err(format!("integer parameter matched {} rows", by_int.len())); + } + let by_text = client + .query("SELECT id FROM conf_param WHERE name = $1", &[&"ada"]) + .await + .map_err(describe)?; + if by_text.len() != 1 { + return Err(format!("text parameter matched {} rows", by_text.len())); + } + let by_bool = client + .query("SELECT id FROM conf_param WHERE flag = $1", &[&true]) + .await + .map_err(describe)?; + (by_bool.len() == 1) + .then_some(()) + .ok_or(format!("boolean parameter matched {} rows", by_bool.len())) + } + .await, + ); + + report.record( + Area::ExtendedQuery, + "two parameters in one statement each get their own type", + async { + // With both forced to text this failed outright: + // `Cannot compare Integer(1) and Text("1")`. + let rows = client + .query( + "SELECT id FROM conf_param WHERE id = $1 AND name = $2", + &[&1i32, &"ada"], + ) + .await + .map_err(describe)?; + (rows.len() == 1) + .then_some(()) + .ok_or(format!("matched {} rows, expected 1", rows.len())) + } + .await, + ); + + report.record( + Area::ExtendedQuery, + "a parameter in LIMIT is a row count, not text", + async { + // Spliced in quoted, the clause was ignored and every row came + // back. + let rows = client + .query("SELECT id FROM conf_param ORDER BY id LIMIT $1", &[&1i64]) + .await + .map_err(describe)?; + (rows.len() == 1) + .then_some(()) + .ok_or(format!("LIMIT $1 returned {} rows, expected 1", rows.len())) + } + .await, + ); + + report.record( + Area::ExtendedQuery, + "a parameterised UPDATE applies", + async { + // This reported success and changed nothing, because the WHERE + // matched no row. + client + .execute( + "UPDATE conf_param SET name = $1 WHERE id = $2", + &[&"ADA", &1i32], + ) + .await + .map_err(describe)?; + let names = simple_column(&client, "SELECT name FROM conf_param ORDER BY id").await?; + let result = (names == ["ADA", "grace"]) + .then_some(()) + .ok_or(format!("names are {names:?}, expected [ADA, grace]")); + drop_table(&client, "conf_param").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "a statement in a failed transaction reports in_failed_sql_transaction", + async { + let other = connect().await?; + other.simple_query("BEGIN").await.map_err(describe)?; + // Put the block into the failed state. + let _ = other + .simple_query("SELECT conf_no_such_column_at_all") + .await; + + // `25P02` is how a driver knows it must roll back rather than + // retry. Reported as `XX000` it was indistinguishable from the + // backend falling over. + match other.simple_query("SELECT 1").await { + Ok(_) => Err("a statement in a failed block was accepted".into()), + Err(e) => { + let got = sqlstate(&e); + let _ = other.simple_query("ROLLBACK").await; + (got == "25P02") + .then_some(()) + .ok_or(format!("reported {got}, expected 25P02")) + } + } + } + .await, + ); + + report.record( + Area::Transactions, + "a transaction block sent as one message leaves no transaction open", + async { + drop_table(&client, "conf_block").await; + client + .simple_query("CREATE TABLE conf_block (id INTEGER)") + .await + .map_err(describe)?; + + // The session's state was read from the first word of the whole + // message, so the trailing COMMIT went unnoticed and the + // connection was left holding a transaction the client had + // already ended. + client + .simple_query("BEGIN; INSERT INTO conf_block (id) VALUES (9); COMMIT") + .await + .map_err(describe)?; + + // If a transaction were still open, this would be inside it, and + // the rollback would discard it. + client + .simple_query("INSERT INTO conf_block (id) VALUES (10)") + .await + .map_err(describe)?; + let _ = client.simple_query("ROLLBACK").await; + + let ids = simple_column(&client, "SELECT id FROM conf_block ORDER BY id").await?; + let result = (ids == ["9", "10"]) + .then_some(()) + .ok_or(format!("rows are {ids:?}, expected [9, 10]")); + drop_table(&client, "conf_block").await; + result + } + .await, + ); + + report.record( + Area::ExtendedQuery, + "an empty statement is answered, not rejected", + async { + // `EmptyQueryResponse` is how a client tells "nothing to run" from + // "your statement was rejected". The simple-query path answered + // it; the extended path reported a parse error. + let rows = client.query("", &[]).await.map_err(describe)?; + rows.is_empty() + .then_some(()) + .ok_or_else(|| format!("an empty query returned {} rows", rows.len())) + } + .await, + ); + + report.record( + Area::Portals, + "a row-limited Execute suspends and resumes", + async { + drop_table(&client, "conf_portal").await; + client + .simple_query("CREATE TABLE conf_portal (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_portal (id) VALUES (1), (2), (3), (4)") + .await + .map_err(describe)?; + + // Its own connection: a transaction needs a mutable client and + // this one is borrowed immutably here. + let mut owned = connect().await?; + let transaction = owned.transaction().await.map_err(describe)?; + let statement = transaction + .prepare("SELECT id FROM conf_portal ORDER BY id") + .await + .map_err(describe)?; + let portal = transaction.bind(&statement, &[]).await.map_err(describe)?; + + // Two now, two later: the second page must continue rather than + // restart, and must not report the set ended early. + let first = transaction + .query_portal(&portal, 2) + .await + .map_err(describe)?; + let second = transaction + .query_portal(&portal, 0) + .await + .map_err(describe)?; + let seen: Vec = first + .iter() + .chain(second.iter()) + .map(|row| row.get::<_, i32>(0)) + .collect(); + transaction.rollback().await.map_err(describe)?; + + let result = (first.len() == 2 && seen == [1, 2, 3, 4]) + .then_some(()) + .ok_or(format!( + "first page {} rows, all rows {seen:?}", + first.len() + )); + drop_table(&client, "conf_portal").await; + result + } + .await, + ); + + for (name, sql, want) in [ + // `position(sub IN str)` and `strpos(str, sub)` are the same function + // with opposite argument orders. Sharing one implementation made + // `strpos('abc','b')` search "abc" inside "b" and answer 0. + ( + "STRPOS takes the string first", + "SELECT STRPOS('abc', 'b')", + "2", + ), + ( + "STRPOS reports absence as 0", + "SELECT STRPOS('abc', 'z')", + "0", + ), + ( + "POSITION takes the needle first", + "SELECT POSITION('b', 'abc')", + "2", + ), + // The standard spelling. Parsed at the usual level the comparison + // rules took `sub IN str` first and built an IN expression, leaving + // the call malformed. + ( + "POSITION ... IN parses", + "SELECT POSITION('b' IN 'abc')", + "2", + ), + ( + "POSITION ... IN reports absence", + "SELECT POSITION('z' IN 'abc')", + "0", + ), + // Character positions, not byte offsets. + ( + "POSITION ... IN counts characters", + "SELECT POSITION('語' IN '日本語')", + "3", + ), + // And `IN` keeps its meaning everywhere else. + ( + "IN is still an operator", + "SELECT 1 WHERE 2 IN (1, 2, 3)", + "1", + ), + ( + "NOT IN is still an operator", + "SELECT 1 WHERE 5 NOT IN (1, 2, 3)", + "1", + ), + // An exact average is exact: through f64 the mean of 2, 3 and 5 came + // back as 3.3333333333333335, whose last digit is a rounding artifact. + ( + "AVG over integers does not go through a float", + "SELECT AVG(n) FROM (SELECT 2 AS n UNION ALL SELECT 3 UNION ALL SELECT 5) t", + "3.3333333333333333333333333333", + ), + ] { + report.record( + Area::Sql, + name, + async { + let got = simple_column(&client, sql).await?; + (got == [want.to_string()]) + .then_some(()) + .ok_or(format!("got {got:?}, expected [{want}]")) + } + .await, + ); + } + + report.record( + Area::ExtendedQuery, + "a failed extended statement leaves the session usable", + async { + drop_table(&client, "conf_pipe").await; + client + .simple_query("CREATE TABLE conf_pipe (id INTEGER)") + .await + .map_err(describe)?; + + // The extended path enters an error state that discards what the + // client already pipelined, until it synchronises. The discarding + // itself is checked against the raw protocol; what a driver can + // show here is that the state is left correctly — the failure is + // reported and the next statement works. + if client + .execute("INSERT INTO conf_pipe (id) VALUES ($1)", &[&"not a number"]) + .await + .is_ok() + { + return Err("an invalid parameter was accepted".into()); + } + client + .execute("INSERT INTO conf_pipe (id) VALUES ($1)", &[&7i32]) + .await + .map_err(describe)?; + let ids = simple_column(&client, "SELECT id FROM conf_pipe").await?; + let result = (ids == ["7"]) + .then_some(()) + .ok_or(format!("rows are {ids:?}, expected [7]")); + drop_table(&client, "conf_pipe").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "ALTER TABLE DROP COLUMN and RENAME COLUMN", + async { + drop_table(&client, "conf_alter").await; + client + .simple_query("CREATE TABLE conf_alter (id INTEGER, extra TEXT, keep TEXT)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_alter (id, extra, keep) VALUES (1, 'x', 'y')") + .await + .map_err(describe)?; + + client + .simple_query("ALTER TABLE conf_alter DROP COLUMN extra") + .await + .map_err(describe)?; + if client + .simple_query("SELECT extra FROM conf_alter") + .await + .is_ok() + { + return Err("a dropped column was still readable".into()); + } + // The columns either side of it must survive. + let kept = simple_column(&client, "SELECT keep FROM conf_alter").await?; + if kept != ["y"] { + return Err(format!("keep is {kept:?} after dropping a neighbour")); + } + + client + .simple_query("ALTER TABLE conf_alter RENAME COLUMN keep TO kept") + .await + .map_err(describe)?; + let renamed = simple_column(&client, "SELECT kept FROM conf_alter").await?; + if renamed != ["y"] { + return Err(format!("renamed column reads {renamed:?}, expected [y]")); + } + if client + .simple_query("SELECT keep FROM conf_alter") + .await + .is_ok() + { + return Err("the old name still resolves after a rename".into()); + } + + // Dropping something that was never there is an error, not a + // silent success. + let result = match client + .simple_query("ALTER TABLE conf_alter DROP COLUMN conf_no_such_column") + .await + { + Ok(_) => Err("dropping an absent column reported success".to_string()), + Err(_) => Ok(()), + }; + drop_table(&client, "conf_alter").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "outer joins keep the rows that found no partner", + async { + drop_table(&client, "conf_j1").await; + drop_table(&client, "conf_j2").await; + client + .simple_query("CREATE TABLE conf_j1 (id INTEGER, tag TEXT)") + .await + .map_err(describe)?; + client + .simple_query("CREATE TABLE conf_j2 (id INTEGER, val TEXT)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_j1 (id, tag) VALUES (1,'a'),(2,'b'),(3,'c')") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_j2 (id, val) VALUES (2,'two'),(3,'three'),(4,'four')") + .await + .map_err(describe)?; + + // Only unmatched *left* rows were kept, so RIGHT JOIN behaved as + // an inner join and FULL OUTER lost both unmatched sides. + for (sql, want) in [ + ( + "SELECT j.id FROM conf_j1 j LEFT JOIN conf_j2 k ON j.id = k.id ORDER BY j.id", + vec!["1", "2", "3"], + ), + ( + "SELECT k.id FROM conf_j1 j RIGHT JOIN conf_j2 k ON j.id = k.id ORDER BY k.id", + vec!["2", "3", "4"], + ), + ( + "SELECT COUNT(*) FROM conf_j1 FULL OUTER JOIN conf_j2 ON conf_j1.id = conf_j2.id", + vec!["4"], + ), + ] { + let got = simple_column(&client, sql).await?; + if got != want { + drop_table(&client, "conf_j1").await; + drop_table(&client, "conf_j2").await; + return Err(format!("{sql} gave {got:?}, expected {want:?}")); + } + } + + // An unmatched outer row must carry the other side's columns as + // NULL, not lack them: without that a bare column reference + // failed with `column does not exist` instead of returning NULL. + let bare = simple_column( + &client, + "SELECT val FROM conf_j1 LEFT JOIN conf_j2 ON conf_j1.id = conf_j2.id ORDER BY conf_j1.id", + ) + .await?; + let result = (bare == ["NULL", "two", "three"]) + .then_some(()) + .ok_or(format!("got {bare:?}, expected [NULL, two, three]")); + drop_table(&client, "conf_j1").await; + drop_table(&client, "conf_j2").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a UNIQUE INDEX is enforced, and dropping it releases the constraint", + async { + drop_table(&client, "conf_uniq").await; + client + .simple_query("CREATE TABLE conf_uniq (id INTEGER, n INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_uniq (id, n) VALUES (1, 5)") + .await + .map_err(describe)?; + client + .simple_query("CREATE UNIQUE INDEX conf_uniq_id ON conf_uniq (id)") + .await + .map_err(describe)?; + + // Accepted and not enforced, duplicates went in silently — an + // integrity constraint the caller asked for by name. + match client + .simple_query("INSERT INTO conf_uniq (id, n) VALUES (1, 6)") + .await + { + Ok(_) => return Err("a duplicate was accepted under a UNIQUE INDEX".into()), + Err(e) => { + let got = sqlstate(&e); + if got != "23505" { + return Err(format!("reported {got}, expected 23505")); + } + } + } + // A distinct value must still be accepted. + client + .simple_query("INSERT INTO conf_uniq (id, n) VALUES (2, 6)") + .await + .map_err(describe)?; + + // Dropping the index takes the constraint with it. + client + .simple_query("DROP INDEX conf_uniq_id") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_uniq (id, n) VALUES (1, 7)") + .await + .map_err(describe)?; + let counted = simple_column(&client, "SELECT COUNT(*) FROM conf_uniq").await?; + let result = (counted == ["3"]) + .then_some(()) + .ok_or(format!("count is {counted:?}, expected [3]")); + drop_table(&client, "conf_uniq").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a UNIQUE INDEX over data that already violates it is refused", + async { + drop_table(&client, "conf_uniq2").await; + client + .simple_query("CREATE TABLE conf_uniq2 (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_uniq2 (id) VALUES (1), (1)") + .await + .map_err(describe)?; + // Creating it anyway would have the index claim something about + // the table that is not true. + let refused = client + .simple_query("CREATE UNIQUE INDEX conf_uniq2_id ON conf_uniq2 (id)") + .await + .is_err(); + drop_table(&client, "conf_uniq2").await; + refused + .then_some(()) + .ok_or_else(|| "a unique index was built over duplicate rows".to_string()) + } + .await, + ); + + report.record( + Area::Sql, + "ALTER TABLE ADD COLUMN actually adds the column", + async { + drop_table(&client, "conf_add").await; + client + .simple_query("CREATE TABLE conf_add (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_add (id) VALUES (1)") + .await + .map_err(describe)?; + + // Nothing handled `ADD COLUMN`, so it fell through to a generic + // "Command completed successfully" and the column was not there — + // every later reference then failed, pointing at the query rather + // than at the DDL that never happened. + client + .simple_query("ALTER TABLE conf_add ADD COLUMN label TEXT") + .await + .map_err(describe)?; + client + .simple_query("UPDATE conf_add SET label = 'x' WHERE id = 1") + .await + .map_err(describe)?; + let labels = simple_column(&client, "SELECT label FROM conf_add").await?; + if labels != ["x"] { + return Err(format!("label is {labels:?}, expected [x]")); + } + + // A default fills the rows that already exist, or the same table + // answers two ways depending on when a row arrived. + client + .simple_query("ALTER TABLE conf_add ADD COLUMN n INTEGER DEFAULT 7") + .await + .map_err(describe)?; + let backfilled = simple_column(&client, "SELECT n FROM conf_add").await?; + if backfilled != ["7"] { + return Err(format!("existing row has n = {backfilled:?}, expected [7]")); + } + + // Adding the same column twice is an error, not a silent no-op. + match client + .simple_query("ALTER TABLE conf_add ADD COLUMN label TEXT") + .await + { + Ok(_) => Err("adding a column twice was accepted".into()), + Err(e) => { + let got = sqlstate(&e); + drop_table(&client, "conf_add").await; + (got == "42701") + .then_some(()) + .ok_or(format!("reported {got}, expected 42701")) + } + } + } + .await, + ); + + report.record( + Area::Sql, + "a scalar subquery works in the select list, not only in WHERE", + async { + drop_table(&client, "conf_sub").await; + client + .simple_query("CREATE TABLE conf_sub (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_sub (id) VALUES (1), (2), (3)") + .await + .map_err(describe)?; + + // Subqueries were resolved for `WHERE` and `HAVING` only, so the + // same subquery that filtered correctly failed as unimplemented + // when it appeared in the select list. + let counted = simple_column(&client, "SELECT (SELECT COUNT(*) FROM conf_sub)").await?; + if counted != ["3"] { + return Err(format!( + "bare scalar subquery gave {counted:?}, expected [3]" + )); + } + let with_from = simple_column( + &client, + "SELECT (SELECT MAX(id) FROM conf_sub) FROM conf_sub ORDER BY id", + ) + .await?; + if with_from != ["3", "3", "3"] { + return Err(format!("per-row scalar subquery gave {with_from:?}")); + } + // A subquery matching nothing is NULL, not an error or a zero. + let empty = + simple_column(&client, "SELECT (SELECT id FROM conf_sub WHERE id = 99)").await?; + let result = (empty == ["NULL"]).then_some(()).ok_or(format!( + "an empty scalar subquery gave {empty:?}, expected NULL" + )); + drop_table(&client, "conf_sub").await; + result + } + .await, + ); + + report.record( + Area::Types, + "a NUMERIC column renders at its declared scale", + async { + drop_table(&client, "conf_scale").await; + client + .simple_query( + "CREATE TABLE conf_scale (id INTEGER, amt NUMERIC(10,2), d DOUBLE PRECISION)", + ) + .await + .map_err(describe)?; + client + .simple_query( + "INSERT INTO conf_scale (id, amt, d) VALUES (1, 10.5, 7.5), (2, 3, 1.25)", + ) + .await + .map_err(describe)?; + + // Every read path must agree: a clause-free select, a simple + // WHERE and an ORDER BY go through different code. + for sql in [ + "SELECT amt FROM conf_scale ORDER BY id", + "SELECT amt FROM conf_scale WHERE id = 1", + ] { + let shown = simple_column(&client, sql).await?; + let expected: Vec = if sql.contains("WHERE") { + vec!["10.50".to_string()] + } else { + vec!["10.50".to_string(), "3.00".to_string()] + }; + if shown != expected { + drop_table(&client, "conf_scale").await; + return Err(format!("{sql} gave {shown:?}, expected {expected:?}")); + } + } + // And a column without a declared scale must not gain one. + let floats = simple_column(&client, "SELECT d FROM conf_scale ORDER BY id").await?; + let result = (floats == ["7.5", "1.25"]).then_some(()).ok_or(format!( + "a DOUBLE rendered as {floats:?}, expected [7.5, 1.25]" + )); + drop_table(&client, "conf_scale").await; + result + } + .await, + ); + report.record( Area::Sql, "a comparison on a non-integer column filters, not returns nothing", diff --git a/tests/integration/pg_crash_durability.rs b/tests/integration/pg_crash_durability.rs new file mode 100644 index 00000000..6912ddd5 --- /dev/null +++ b/tests/integration/pg_crash_durability.rs @@ -0,0 +1,286 @@ +//! Does data written over the wire survive the server being killed? +//! +//! The conformance harness connects to a server that is already running, so it +//! cannot tell a durable store from a hash map. This test owns the server +//! process: it writes rows, kills it with `SIGKILL` so no shutdown hook and no +//! destructor gets to run, starts it again over the same directory, and reads +//! the rows back. +//! +//! Run it explicitly — it builds nothing and needs the server binary: +//! +//! ```bash +//! cargo test -p orbit-integration-tests --test pg_crash_durability -- --ignored --nocapture +//! ``` + +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::time::{Duration, Instant}; + +use tokio_postgres::{Client, NoTls}; + +const HOST: &str = "127.0.0.1"; +const USER: &str = "orbit"; +/// Away from the default 5432 so a server someone is already running does not +/// answer these queries and make the test pass without proving anything. +const PORT: u16 = 55432; + +/// Where the workspace is, derived from this crate rather than the working +/// directory, so the test runs the same from anywhere. +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("the tests crate has a parent") + .to_path_buf() +} + +fn server_binary() -> PathBuf { + workspace_root().join("target/debug/orbit-server") +} + +/// Derive this test's configuration from the shipped one. +/// +/// Starting from the real file rather than a hand-written minimal one means +/// the test keeps working when the schema grows a required field, and it means +/// the settings under test are the settings operators actually get. Only the +/// data directory, the port, and the other protocols are changed. +fn write_config(directory: &Path) -> PathBuf { + let shipped = workspace_root().join("config/orbit-server.toml"); + let text = std::fs::read_to_string(&shipped) + .unwrap_or_else(|e| panic!("read {}: {e}", shipped.display())); + let mut config: toml::Value = toml::from_str(&text).expect("the shipped configuration parses"); + + let data_dir = directory.join("data"); + + let protocols = config + .get_mut("protocols") + .and_then(toml::Value::as_table_mut) + .expect("[protocols]"); + for (name, protocol) in protocols.iter_mut() { + let Some(table) = protocol.as_table_mut() else { + continue; + }; + // Everything but PostgreSQL is off, so this test never contends for a + // port with a server the developer is already running. + let is_postgres = name == "postgresql"; + table.insert("enabled".into(), toml::Value::Boolean(is_postgres)); + if is_postgres { + table.insert("port".into(), toml::Value::Integer(i64::from(PORT))); + } + } + + let unified = config + .get_mut("unified_storage") + .and_then(toml::Value::as_table_mut) + .expect("[unified_storage]"); + unified.insert("enabled".into(), toml::Value::Boolean(true)); + unified.insert( + "data_dir".into(), + toml::Value::String(data_dir.to_string_lossy().into_owned()), + ); + + let warm = unified + .get_mut("warm_tier") + .and_then(toml::Value::as_table_mut) + .expect("[unified_storage.warm_tier]"); + warm.insert( + "data_dir".into(), + toml::Value::String(data_dir.join("rocksdb").to_string_lossy().into_owned()), + ); + // The setting under test. Asserted rather than assumed, so the test still + // means something if the shipped default is ever turned back off. + assert_eq!( + warm.get("sync_wal"), + Some(&toml::Value::Boolean(true)), + "the shipped configuration should acknowledge writes only once they are \ + on disk; this test cannot prove durability without it" + ); + + let path = directory.join("orbit-server.toml"); + std::fs::write(&path, toml::to_string(&config).expect("serialize")) + .expect("write the configuration"); + path +} + +/// Start the server and wait until it answers on the PostgreSQL port. +fn start_server(config: &Path, log: &Path) -> Child { + let output = std::fs::File::create(log).expect("create the log file"); + let errors = output.try_clone().expect("clone the log handle"); + + // Ports come from the command line, not the configuration file, because + // `apply_cli_overrides` writes clap's defaults over whatever the file said + // — a flag that was never passed still wins. Every port is moved out of + // the way so this test never collides with a server already running, and + // `--data-dir` keeps the other protocols' stores inside the test directory + // instead of scattering them through the working directory. + let directory = config.parent().expect("the config has a parent"); + let child = Command::new(server_binary()) + .arg("--config") + .arg(config) + .args(["--bind", HOST]) + .args(["--postgres-port", &PORT.to_string()]) + .args(["--redis-port", "56379"]) + .args(["--mysql-port", "53306"]) + .args(["--cql-port", "59042"]) + .args(["--grpc-port", "50151"]) + .args(["--http-port", "58080"]) + .args(["--metrics-port", "59090"]) + .arg("--data-dir") + .arg(directory.join("data")) + .stdout(output) + .stderr(errors) + .spawn() + .unwrap_or_else(|e| { + panic!( + "could not start {}: {e}. Build it first: cargo build -p orbit-server", + server_binary().display() + ) + }); + + child +} + +async fn wait_until_listening(log: &Path) { + let deadline = Instant::now() + Duration::from_secs(90); + while Instant::now() < deadline { + if tokio::net::TcpStream::connect((HOST, PORT)).await.is_ok() { + // Listening is not the same as ready to answer a query. + tokio::time::sleep(Duration::from_millis(500)).await; + return; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + + let tail = std::fs::read_to_string(log).unwrap_or_default(); + panic!( + "the server never listened on {PORT}. Log:\n{}", + tail.lines().rev().take(40).collect::>().join("\n") + ); +} + +async fn connect() -> Client { + let mut config = tokio_postgres::Config::new(); + config + .host(HOST) + .port(PORT) + .user(USER) + .password(USER) + .connect_timeout(Duration::from_secs(5)); + + let (client, connection) = config.connect(NoTls).await.expect("connect"); + tokio::spawn(async move { + let _ = connection.await; + }); + client +} + +/// Kill the process outright. No signal handler, no flush, no `Drop` — the +/// state on disk is whatever the writes already put there. +fn kill_hard(mut child: Child) { + unsafe { + libc::kill(child.id() as i32, libc::SIGKILL); + } + let _ = child.wait(); +} + +#[tokio::test] +#[ignore = "owns a server process and a data directory; run explicitly"] +async fn rows_written_over_the_wire_survive_sigkill() { + let directory = workspace_root().join("target/crash-durability"); + let _ = std::fs::remove_dir_all(&directory); + std::fs::create_dir_all(&directory).expect("create the test directory"); + + let config = write_config(&directory); + let first_log = directory.join("first.log"); + let second_log = directory.join("second.log"); + + // --- First life: create a table and write rows. --- + let server = start_server(&config, &first_log); + wait_until_listening(&first_log).await; + + { + let client = connect().await; + client + .simple_query("CREATE TABLE durable (id INT PRIMARY KEY, note TEXT)") + .await + .expect("create the table"); + + for id in 1..=25 { + client + .simple_query(&format!( + "INSERT INTO durable (id, note) VALUES ({id}, 'row-{id}')" + )) + .await + .unwrap_or_else(|e| panic!("insert {id}: {e}")); + } + + let before = count(&client).await; + assert_eq!(before, 25, "the rows should be there before the kill"); + } + + kill_hard(server); + + // --- Second life: same directory, nothing was flushed on the way out. --- + let server = start_server(&config, &second_log); + wait_until_listening(&second_log).await; + + let client = connect().await; + let after = count(&client).await; + assert_eq!( + after, 25, + "every acknowledged row must still be there after SIGKILL, found {after}" + ); + + // The rows must also be intact, not merely counted: a store that returns + // the right number of damaged rows has still lost the data. + let notes = simple_column(&client, "SELECT note FROM durable ORDER BY id").await; + let expected: Vec = (1..=25).map(|id| format!("row-{id}")).collect(); + assert_eq!(notes, expected, "the rows came back damaged or reordered"); + + // The schema has to survive as more than a list of column names: if the + // constraints were lost, the table would accept a duplicate key and the + // damage would only show up later, as two rows with the same identity. + let duplicate = client + .simple_query("INSERT INTO durable (id, note) VALUES (1, 'impostor')") + .await; + assert!( + duplicate.is_err(), + "the primary key did not survive the restart: a duplicate id was accepted" + ); + assert_eq!( + count(&client).await, + 25, + "the rejected insert must not have landed" + ); + + kill_hard(server); + let _ = std::fs::remove_dir_all(&directory); +} + +/// Read one column of one row as text. +/// +/// The simple-query protocol returns everything as text, which keeps this test +/// from depending on which type OID the server picks for `COUNT(*)` — that is +/// the conformance suite's job, not this one's. +async fn simple_column(client: &Client, sql: &str) -> Vec { + client + .simple_query(sql) + .await + .unwrap_or_else(|e| panic!("{sql}: {e}")) + .into_iter() + .filter_map(|message| match message { + tokio_postgres::SimpleQueryMessage::Row(row) => { + Some(row.get(0).unwrap_or_default().to_string()) + } + _ => None, + }) + .collect() +} + +async fn count(client: &Client) -> i64 { + let values = simple_column(client, "SELECT COUNT(*) FROM durable").await; + values + .first() + .unwrap_or_else(|| panic!("COUNT(*) returned no row")) + .parse() + .unwrap_or_else(|e| panic!("COUNT(*) returned {values:?}, which is not a number: {e}")) +}