diff --git a/.cursorrules b/.cursorrules index e049336a7..1ea7c264d 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/.gitignore b/.gitignore index 5a9dfc9ed..e9c6afbc1 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/AGENTS.md b/AGENTS.md index 883210375..d12be43c4 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 6456f6d55..c97aad105 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. diff --git a/Cargo.lock b/Cargo.lock index 13d5f417e..31bd956f5 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", ] [[package]] @@ -621,7 +748,7 @@ dependencies = [ "memchr", "num-traits", "regex", - "regex-syntax 0.8.8", + "regex-syntax", ] [[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,56 @@ 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]] +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]] @@ -1003,27 +1168,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 +1222,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 +1269,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 +1300,7 @@ dependencies = [ "bytemuck", "cfg-if", "cow-utils", - "dashmap 6.1.0", + "dashmap 6.2.1", "dynify", "fast-float2", "float16", @@ -1153,9 +1318,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 +1336,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 +1348,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 +1358,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 +1391,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 +1420,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 +1470,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 +1496,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 +1512,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 +1568,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 +1594,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 +1618,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 +1628,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 +1785,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]] @@ -1647,7 +1822,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -1664,15 +1839,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 +1856,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 +1928,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 +1938,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 +1951,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 +1978,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 +2002,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 +2022,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 +2033,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,12 +2057,12 @@ 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", + "convert_case 0.6.0", "json5", "pathdiff", "ron", @@ -1895,22 +2070,21 @@ 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", ] [[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]] @@ -1919,6 +2093,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 +2114,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", ] @@ -1948,6 +2128,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" @@ -1985,15 +2174,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 +2209,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 +2237,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 +2248,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 +2267,7 @@ dependencies = [ "log", "pulley-interpreter", "regalloc2", - "rustc-hash 2.1.1", + "rustc-hash 2.1.3", "serde", "smallvec", "target-lexicon", @@ -2096,9 +2276,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 +2289,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 +2316,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 +2328,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 +2345,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 +2360,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 +2478,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 +2497,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 +2525,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 +2599,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" @@ -2456,9 +2646,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", @@ -2466,18 +2656,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", @@ -2485,39 +2673,39 @@ 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", - "syn 2.0.111", + "syn 2.0.119", "synthez", ] [[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]] 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 +2741,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 +2772,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 +2805,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 +2840,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 +2878,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 +2892,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" @@ -2719,23 +2906,53 @@ dependencies = [ ] [[package]] -name = "der" -version = "0.7.10" +name = "defmt" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" dependencies = [ - "const-oid", + "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 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 +2964,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2789,7 +3006,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2809,18 +3026,30 @@ 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]] name = "derive_more" -version = "0.99.20" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case 0.10.0", "proc-macro2", "quote", - "syn 2.0.111", + "rustc_version 0.4.1", + "syn 2.0.119", + "unicode-xid", ] [[package]] @@ -2830,7 +3059,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 +3070,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 +3120,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" @@ -2951,12 +3181,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" @@ -3017,7 +3241,7 @@ checksum = "1ec431cd708430d5029356535259c5d645d60edd3d39c54e5eea9782d46caa7d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3029,14 +3253,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 +3307,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3101,36 +3325,36 @@ 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", ] [[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 +3368,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 +3406,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3193,9 +3417,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 +3433,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 +3454,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 +3462,7 @@ dependencies = [ "tonic-build", "tonic-prost", "tonic-prost-build", - "tower 0.5.2", + "tower 0.5.3", "tower-service", ] @@ -3273,6 +3497,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 +3565,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 +3577,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 +3594,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 +3612,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 +3650,7 @@ checksum = "c2d1f04709a8ac06e8e8042875a3c466cc4832d3c1a18dbcb9dba3c6e83046bc" dependencies = [ "half", "num-traits", - "rand 0.9.4", + "rand 0.9.5", "rand_distr 0.5.1", ] @@ -3475,7 +3710,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3501,9 +3736,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 +3797,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", @@ -3574,9 +3812,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", @@ -3597,9 +3835,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" @@ -3625,9 +3863,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" @@ -3644,32 +3882,32 @@ 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", - "syn 2.0.111", + "syn 2.0.119", ] [[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", @@ -3697,9 +3935,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 +4078,7 @@ dependencies = [ "num-traits", "once_cell", "paste", - "pulp 0.22.2", + "pulp 0.22.3", "raw-cpuid", "rayon", "seq-macro", @@ -3943,6 +4181,21 @@ dependencies = [ "seq-macro", ] +[[package]] +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" @@ -3955,9 +4208,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 +4219,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 +4250,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 +4277,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]] @@ -4048,19 +4301,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.111", + "syn 2.0.119", "textwrap", - "thiserror 1.0.69", - "typed-builder 0.15.2", + "thiserror 2.0.18", + "typed-builder 0.23.2", ] [[package]] @@ -4167,9 +4420,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 = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7" + +[[package]] +name = "glam" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd47b05dddf0005d850e5644cae7f2b14ac3df487979dbfff3b56f20b1a6ae46" +checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" [[package]] name = "glob" @@ -4179,15 +4444,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", ] [[package]] @@ -4196,7 +4461,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 +4499,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 +4526,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 +4589,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 +4605,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 +4617,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 +4710,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 +4731,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 +4766,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 +4805,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 +4827,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 +4850,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 +4865,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 +4886,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 +4901,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.8.1", + "hyper 1.10.1", "hyper-util", "native-tls", "tokio", @@ -4652,24 +4911,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 +4936,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 +4967,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 +4993,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 +5020,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 +5041,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 +5063,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 +5088,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 +5154,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 +5204,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 +5220,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", @@ -5020,15 +5274,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" @@ -5074,9 +5319,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 +5344,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 +5356,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5167,9 +5412,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 +5462,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 +5475,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 +5513,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys 0.3.0", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", @@ -5301,14 +5547,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 +5575,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 +5645,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 +5692,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 +5715,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.26.2", "tokio-util", - "tower 0.5.2", + "tower 0.5.3", "tower-http", "tracing", ] @@ -5498,7 +5728,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 +5749,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -5550,29 +5780,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "lazy-regex" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "191898e17ddee19e60bccb3945aa02339e81edd4a8c50e21fd4d48cdecda7b29" -dependencies = [ - "lazy-regex-proc_macros", - "once_cell", - "regex", -] - -[[package]] -name = "lazy-regex-proc_macros" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c35dc8b0da83d1a9507e12122c80dea71a9c7c613014347392483a83ea593e04" -dependencies = [ - "proc-macro2", - "quote", - "regex", - "syn 2.0.111", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -5590,9 +5797,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 +5807,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,28 +5889,49 @@ 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", ] +[[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" @@ -5721,13 +5960,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]] @@ -5736,19 +5973,21 @@ 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]] 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", @@ -5756,25 +5995,22 @@ dependencies = [ ] [[package]] -name = "libz-rs-sys" -version = "0.5.2" +name = "libz-sys" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" -dependencies = [ - "zlib-rs", -] - -[[package]] -name = "libz-sys" -version = "1.1.23" -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 +6026,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 +6046,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 +6067,22 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loom" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] [[package]] name = "lru" @@ -5885,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" @@ -5943,6 +6208,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 +6254,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 +6290,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 +6315,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 +6330,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 +6353,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 +6364,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 +6397,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 +6446,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 +6468,7 @@ dependencies = [ "mlua-sys", "num-traits", "parking_lot", - "rustc-hash 2.1.1", + "rustc-hash 2.1.3", "rustversion", "serde", "serde-value", @@ -6199,9 +6489,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", @@ -6213,21 +6503,21 @@ 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", "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 +6528,6 @@ dependencies = [ "futures-util", "parking_lot", "portable-atomic", - "rustc_version 0.4.1", "smallvec", "tagptr", "uuid", @@ -6263,7 +6552,7 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -6286,18 +6575,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 +6608,7 @@ dependencies = [ "mysql_common", "pem", "percent-encoding", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_json", "socket2 0.5.10", @@ -6337,7 +6626,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 +6640,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 +6648,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 +6665,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 +6685,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 +6705,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 +6720,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework", "security-framework-sys", "tempfile", ] @@ -6463,12 +6754,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" @@ -6479,22 +6779,31 @@ 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]] 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 +6814,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 +6833,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 +6853,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -6573,7 +6882,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -6587,11 +6896,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 +6937,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 +6947,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 +6977,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 +6990,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 +7007,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 +7030,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 +7044,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 +7074,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 +7092,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 +7104,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 +7130,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 +7165,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 +7174,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 +7218,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 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", + "opendal-core 0.57.0", ] [[package]] @@ -6871,27 +7242,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 +7285,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 +7305,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 +7327,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 +7346,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 +7359,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 +7373,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 +7393,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 +7426,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 +7443,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 +7469,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 +7487,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "dashmap 6.1.0", + "dashmap 6.2.1", "futures", "mockall", "once_cell", @@ -7095,7 +7496,7 @@ dependencies = [ "orbit-util", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-test", @@ -7109,12 +7510,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 +7525,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 +7552,7 @@ dependencies = [ "memmap2", "metal", "metrics", - "nalgebra 0.34.1", + "nalgebra 0.34.2", "ndarray", "num-complex", "num-traits", @@ -7160,14 +7561,14 @@ dependencies = [ "orbit-shared", "orbit-util", "proptest", - "rand 0.9.4", + "rand 0.10.2", "raw-cpuid", "rayon", "serde", "serde_json", "sysinfo", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tracing", "vulkano", @@ -7189,7 +7590,7 @@ dependencies = [ "chrono", "criterion 0.7.0", "crossbeam", - "dashmap 6.1.0", + "dashmap 6.2.1", "fastrand", "futures", "iceberg", @@ -7197,21 +7598,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.10.2", "regex", "rocksdb", "serde", "serde_json", "sqlx", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-test", "tokio-util", @@ -7229,8 +7630,11 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "bytes", "cucumber", "futures", + "futures-util", + "libc", "mockall", "orbit-client", "orbit-engine", @@ -7239,16 +7643,37 @@ dependencies = [ "orbit-shared", "orbit-util", "proptest", - "rand 0.8.5", + "rand 0.8.7", "serde", "serde_json", "tokio", + "tokio-postgres", "tokio-test", + "toml 0.9.12+spec-1.1.0", "tracing", "tracing-subscriber", "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" @@ -7264,7 +7689,7 @@ dependencies = [ "criterion 0.7.0", "crossbeam", "cucumber", - "dashmap 6.1.0", + "dashmap 6.2.1", "futures", "mockall", "ndarray", @@ -7274,7 +7699,7 @@ dependencies = [ "proptest", "prost-build", "pyo3", - "rand 0.9.4", + "rand 0.10.2", "rand_distr 0.5.1", "rayon", "rmp-serde", @@ -7282,7 +7707,7 @@ dependencies = [ "serde_json", "statrs", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-test", "tonic-build", @@ -7300,7 +7725,7 @@ dependencies = [ "clap", "futures", "http-body-util", - "hyper 1.8.1", + "hyper 1.10.1", "hyper-util", "k8s-openapi", "kube", @@ -7312,7 +7737,7 @@ dependencies = [ "serde_json", "serde_yaml_ng", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -7350,7 +7775,7 @@ dependencies = [ "async-recursion", "async-stream", "async-trait", - "axum 0.8.7", + "axum 0.8.9", "base64 0.22.1", "bcrypt", "bincode", @@ -7362,7 +7787,7 @@ dependencies = [ "clap", "comfy-table", "criterion 0.5.1", - "dashmap 6.1.0", + "dashmap 6.2.1", "env_logger", "fastbloom-rs", "flate2", @@ -7371,9 +7796,10 @@ dependencies = [ "hex", "hmac 0.12.1", "http-body-util", - "hyper 1.8.1", + "hyper 1.10.1", "hyper-util", "lazy_static", + "libgssapi", "lru 0.12.5", "lz4_flex 0.12.2", "md5", @@ -7381,10 +7807,11 @@ dependencies = [ "metrics-exporter-prometheus", "mlua", "mockall", - "nom", + "nom 7.1.3", "orbit-client", "orbit-compute", "orbit-engine", + "orbit-llm", "orbit-ml", "orbit-proto", "orbit-shared", @@ -7395,10 +7822,10 @@ dependencies = [ "petgraph 0.8.3", "postgres-protocol", "postgres-types", - "rand 0.9.4", + "rand 0.10.2", "redis", "regex", - "reqwest 0.12.24", + "reqwest 0.12.28", "rmp-serde", "rocksdb", "rquickjs", @@ -7407,22 +7834,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 +7870,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "chrono", - "dashmap 6.1.0", + "dashmap 6.2.1", "etcd-client", "futures", "once_cell", @@ -7452,7 +7879,7 @@ dependencies = [ "orbit-util", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -7465,10 +7892,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 +7905,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 +7927,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.10.2", "rayon", "regex", "serde", @@ -7519,7 +7946,7 @@ dependencies = [ "sha2 0.10.9", "sqlx", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-util", @@ -7527,7 +7954,7 @@ dependencies = [ "tonic-build", "tonic-prost", "tonic-prost-build", - "tower 0.5.2", + "tower 0.5.3", "tower-lsp", "tracing", "tracing-subscriber", @@ -7541,12 +7968,12 @@ dependencies = [ "anyhow", "async-trait", "chrono", - "dashmap 6.1.0", + "dashmap 6.2.1", "metrics", - "rand 0.9.4", + "rand 0.10.2", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tracing", "tracing-subscriber", @@ -7578,7 +8005,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 +8030,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 +8065,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 +8106,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 +8148,6 @@ dependencies = [ "seq-macro", "simdutf8", "snap", - "thrift", "tokio", "twox-hash", "zstd", @@ -7796,9 +8242,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 +8252,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 +8262,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 +8292,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 +8350,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 +8373,7 @@ dependencies = [ "phf_shared 0.13.1", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -7970,35 +8405,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 +8469,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 +8528,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 +8555,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 +8584,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 +8608,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 +8618,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 +8639,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" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.23.7", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -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 +8699,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 +8711,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 +8734,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", "rusty-fork", "tempfile", "unarray", @@ -8337,9 +8753,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 +8763,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 +8846,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 +8878,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 +8903,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,43 +8920,38 @@ 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" -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", @@ -8549,27 +8959,26 @@ 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", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[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.111", + "syn 2.0.119", ] [[package]] @@ -8619,20 +9028,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 +9060,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 +9083,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 +9147,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 +9159,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 +9205,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 +9223,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 +9249,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 +9259,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 +9271,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 +9295,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 +9313,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 +9342,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 +9393,7 @@ dependencies = [ "pin-project-lite", "ryu", "sha1_smol", - "socket2 0.6.3", + "socket2 0.6.5", "tokio", "tokio-util", "url", @@ -8967,7 +9405,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 +9414,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 +9436,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -9011,50 +9449,44 @@ 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", ] [[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", ] [[package]] name = "regex-lite" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" - -[[package]] -name = "regex-syntax" -version = "0.7.5" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[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 +9518,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 +9556,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 +9571,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 +9621,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper 0.1.2", - "system-configuration", + "system-configuration 0.5.1", "tokio", "tower-service", "url", @@ -9198,19 +9633,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 +9662,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 +9673,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 +9699,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-util", - "tower 0.5.2", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -9282,9 +9717,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 +9760,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 +9799,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 +9843,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 +9864,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 +9875,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 +9919,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 +9948,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 +9967,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 +9976,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 +9989,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 +10008,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 +10024,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 +10045,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 +10065,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 +10089,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 +10116,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 +10134,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 +10200,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 +10234,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 +10253,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" @@ -9860,14 +10287,13 @@ 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.111", + "syn 2.0.119", ] [[package]] @@ -9881,24 +10307,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 +10320,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 +10339,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 +10427,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -10025,21 +10438,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 +10474,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -10075,9 +10488,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 +10509,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 +10529,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 +10567,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 +10630,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 +10671,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 +10691,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 +10708,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 +10735,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 +10750,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 +10777,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", ] @@ -10402,7 +10811,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", ] @@ -10439,7 +10848,7 @@ dependencies = [ "futures-io", "futures-util", "hashbrown 0.16.1", - "hashlink 0.11.0", + "hashlink", "indexmap 2.14.0", "log", "memchr", @@ -10468,7 +10877,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -10491,7 +10900,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.111", + "syn 2.0.119", "thiserror 2.0.18", "tokio", "url", @@ -10503,7 +10912,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 +10942,7 @@ checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.13.1", "byteorder", "chrono", "crc", @@ -10549,7 +10958,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 +10968,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "uuid", - "whoami 2.1.2", + "whoami", ] [[package]] @@ -10607,9 +11016,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 +11069,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -10672,7 +11081,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -10694,9 +11103,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 +11135,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -10741,7 +11150,7 @@ dependencies = [ "once_cell", "onig", "plist", - "regex-syntax 0.8.8", + "regex-syntax", "serde", "serde_derive", "serde_json", @@ -10752,35 +11161,35 @@ 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.111", + "syn 2.0.119", "synthez-codegen", "synthez-core", ] [[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.111", + "syn 2.0.119", "synthez-core", ] [[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", "sealed", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -10789,7 +11198,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", @@ -10819,7 +11228,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "system-configuration-sys", + "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 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", ] [[package]] @@ -10832,6 +11252,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 +11370,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" dependencies = [ "byteorder", - "regex-syntax 0.8.8", + "regex-syntax", "utf8-ranges", ] @@ -10961,7 +11391,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "847434d4af57b32e309f4ab1b4f1707a6c566656264caa427ff4285c4d9d0b82" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -11004,9 +11434,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 +11445,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 +11462,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 +11519,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -11100,14 +11530,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 +11555,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 +11571,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 +11596,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 +11617,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 +11649,11 @@ dependencies = [ "monostate", "onig", "paste", - "rand 0.9.4", + "rand 0.9.5", "rayon", "rayon-cond", "regex", - "regex-syntax 0.8.8", + "regex-syntax", "serde", "serde_json", "spm_precompiled", @@ -11237,9 +11665,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 +11675,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 +11704,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -11291,9 +11719,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 +11736,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 +11755,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 +11767,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 +11790,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 +11828,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 +11865,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 +11896,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 +11953,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 +11976,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 +12020,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 +12044,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 +12095,7 @@ checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -11658,9 +12106,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 +12124,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 +12160,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 +12193,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]] @@ -11777,15 +12224,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" @@ -11805,14 +12243,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.111", + "typed-builder-macro 0.23.2", ] [[package]] @@ -11823,7 +12259,7 @@ checksum = "f9534daa9fd3ed0bd911d462a37f172228077e7abf18c18a5f67199d959205f8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -11834,7 +12270,18 @@ checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "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]] @@ -11851,9 +12298,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 +12323,7 @@ checksum = "cf808357c6ed7e13ba0f3277ec8d8f21b2d501274895104263985330c726c1c5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -11941,9 +12388,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 +12400,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 +12436,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" @@ -12017,12 +12464,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" @@ -12039,12 +12480,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 +12488,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 +12537,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 +12549,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 +12579,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", @@ -12194,7 +12630,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", @@ -12221,7 +12657,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -12265,47 +12701,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 +12753,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 +12763,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 +12800,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 +12812,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 +12833,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 +12870,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 +12895,7 @@ dependencies = [ "pulley-interpreter", "rayon", "rustix 1.1.4", - "semver 1.0.27", + "semver 1.0.28", "serde", "serde_derive", "serde_json", @@ -12517,9 +12923,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 +12939,7 @@ dependencies = [ "object", "postcard", "rustc-demangle", - "semver 1.0.27", + "semver 1.0.28", "serde", "serde_derive", "sha2 0.10.9", @@ -12548,9 +12954,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 +12966,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 +12974,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 +13007,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 +13034,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 +13049,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 +13061,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 +13073,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 +13086,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 +13114,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 +13143,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 +13157,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 +13179,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 +13221,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 +13263,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 +13286,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 +13300,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 +13346,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 +13357,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 +13492,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -13101,7 +13503,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -13219,15 +13621,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 +13669,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 +13712,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 +13730,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 +13748,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 +13772,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 +13790,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 +13808,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 +13826,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 +13837,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 +13872,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 +13893,7 @@ dependencies = [ "id-arena", "indexmap 2.14.0", "log", - "semver 1.0.27", + "semver 1.0.28", "serde", "serde_derive", "serde_json", @@ -13669,9 +13915,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 +13936,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 +13999,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 +14014,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 +14037,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 +14054,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 +14183,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" @@ -13990,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/Cargo.toml b/Cargo.toml index 9e1891e21..1ee6eb7b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "orbit/operator", "orbit/compute", "orbit/ml", + "orbit/llm", "orbit/cli", # Tests @@ -65,7 +66,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" @@ -94,7 +95,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"] } @@ -118,11 +119,11 @@ 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.9" +rand = "0.10" # Security patches # Note: tikv-client has been removed from dependencies due to unresolved protobuf vulnerabilities diff --git a/Makefile b/Makefile index dbac763f6..48104a085 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 56cb223c3..6227a1b08 100644 --- a/config/orbit-server.toml +++ b/config/orbit-server.toml @@ -58,6 +58,10 @@ enabled = true port = 5432 max_connections = 1000 connection_timeout_secs = 30 +# Changes a logical replication slot may fall behind by before it is +# invalidated. A slot that stops confirming would otherwise hold the change log +# open indefinitely; this is the equivalent of max_slot_wal_keep_size. +max_slot_change_backlog = 100000 # SQL Engine Configuration [protocols.postgresql.sql_engine] @@ -333,7 +337,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 @@ -384,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] @@ -632,6 +643,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 +775,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 cc457ccd1..534f1fce4 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/cli/Cargo.toml b/orbit/cli/Cargo.toml index 6f2a7d028..da55a6edb 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 a6f1dcd89..0f7719a5a 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 6e4e8ee3e..e1e2e45e3 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/compute/benches/columnar_analytics_bench.rs b/orbit/compute/benches/columnar_analytics_bench.rs index accf8c182..869074924 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 c8147ce30..8c093bb22 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 422757e82..34a9a820b 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 2cd346dc2..a364f7d8d 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/desktop/dist/assets/index-CuG3nLo2.js b/orbit/desktop/dist/assets/index-CuG3nLo2.js new file mode 100644 index 000000000..f9bfd9cb1 --- /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 5ea37f07e..000000000 --- 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 658b77440..0111236f2 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 f15fe5164..6325c8f37 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 234802ce6..1d9831710 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 65840b2ba..e8cccfc03 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,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "backtrace" -version = "0.3.76" +name = "aws-lc-rs" +version = "1.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link 0.2.1", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", ] [[package]] @@ -320,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" @@ -339,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", @@ -359,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" @@ -627,6 +641,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" @@ -680,7 +705,7 @@ dependencies = [ "bitflags 1.3.2", "block", "cocoa-foundation", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "foreign-types", "libc", @@ -695,7 +720,7 @@ checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" dependencies = [ "bitflags 1.3.2", "block", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "libc", "objc", @@ -730,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" @@ -746,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" @@ -759,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", @@ -772,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", ] @@ -785,6 +826,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" @@ -977,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" @@ -1044,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", ] @@ -1234,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" @@ -1346,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" @@ -1362,21 +1447,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 +1522,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 +1669,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 +1683,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 +1711,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 +1882,7 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", + "http 0.2.12", "indexmap 2.11.4", "slab", "tokio", @@ -1898,6 +1977,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 +1994,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 +2050,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 +2063,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 +2106,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 +2360,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" @@ -2383,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", ] @@ -2450,6 +2596,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" @@ -2631,7 +2783,7 @@ dependencies = [ "base64 0.21.7", "bigdecimal", "bindgen", - "bitflags 2.9.4", + "bitflags 2.13.1", "bitvec", "btoi", "byteorder", @@ -2671,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", ] @@ -2731,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", @@ -2868,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", ] @@ -2885,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", @@ -2910,15 +3062,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" @@ -2947,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", @@ -2973,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" @@ -2991,12 +3140,16 @@ version = "0.1.0" dependencies = [ "aes-gcm", "anyhow", - "base64 0.21.7", + "async-trait", + "base64 0.22.1", "chrono", "mysql_async", "rand 0.8.5", "redis", - "reqwest", + "reqwest 0.12.28", + "rust_decimal", + "rustls", + "rustls-native-certs", "serde", "serde_json", "sqlparser", @@ -3005,9 +3158,11 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-postgres", + "tokio-postgres-rustls", "tracing", "tracing-subscriber", "uuid", + "webpki-roots", ] [[package]] @@ -3345,7 +3500,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 +3686,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 +3757,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 +3780,7 @@ dependencies = [ "rand_chacha 0.2.2", "rand_core 0.5.1", "rand_hc", - "rand_pcg", + "rand_pcg 0.2.1", ] [[package]] @@ -3587,6 +3804,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 +3872,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 +3896,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,26 +3913,31 @@ 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", + "rustls", + "rustls-native-certs", "ryu", "sha1_smol", - "socket2 0.4.10", + "socket2 0.6.1", "tokio", - "tokio-retry", + "tokio-rustls", "tokio-util", "url", + "webpki-roots", ] [[package]] @@ -3698,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]] @@ -3782,10 +4030,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 +4042,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 +4057,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" @@ -3893,18 +4175,13 @@ dependencies = [ "borsh", "bytes", "num-traits", + "postgres-types", "rand 0.8.5", "rkyv", "serde", "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" @@ -3926,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", @@ -3935,14 +4212,30 @@ 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 = [ + "aws-lc-rs", "log", + "once_cell", "ring", + "rustls-pki-types", "rustls-webpki", - "sct", + "subtle", + "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]] @@ -3954,13 +4247,25 @@ 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 = [ + "aws-lc-rs", "ring", + "rustls-pki-types", "untrusted", ] @@ -4036,16 +4341,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" @@ -4058,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", @@ -4067,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", @@ -4251,7 +4559,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4268,7 +4576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4332,16 +4640,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" @@ -4390,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" @@ -4506,6 +4814,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" @@ -4524,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", ] @@ -4574,7 +4891,7 @@ dependencies = [ "cairo-rs", "cc", "cocoa", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "crossbeam-channel", "dispatch", @@ -4665,7 +4982,7 @@ dependencies = [ "glob", "gtk", "heck 0.5.0", - "http", + "http 0.2.12", "ignore", "indexmap 1.9.3", "log", @@ -4679,7 +4996,7 @@ dependencies = [ "rand 0.8.5", "raw-window-handle", "regex", - "reqwest", + "reqwest 0.11.27", "rfd", "semver", "serde", @@ -4768,7 +5085,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", @@ -4998,31 +5315,49 @@ 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.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", @@ -5066,21 +5401,25 @@ dependencies = [ ] [[package]] -name = "tokio-retry" -version = "0.3.0" +name = "tokio-postgres-rustls" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f57eb36ecbe0fc510036adff84824dd3c24bb781e21bfa67b69d556aa85214f" +checksum = "27d684bad428a0f2481f42241f821db42c54e2dc81d8c00db8536c506b0a0144" dependencies = [ - "pin-project", - "rand 0.8.5", + "const-oid", + "ring", + "rustls", "tokio", + "tokio-postgres", + "tokio-rustls", + "x509-cert", ] [[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 +5543,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.13.1", + "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 +5758,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 +5968,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 +6027,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 +6754,7 @@ dependencies = [ "glib", "gtk", "html5ever", - "http", + "http 0.2.12", "kuchikiki", "libc", "log", @@ -6414,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" @@ -6549,6 +6952,26 @@ dependencies = [ "synstructure", ] +[[package]] +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" version = "0.2.2" diff --git a/orbit/desktop/src-tauri/Cargo.toml b/orbit/desktop/src-tauri/Cargo.toml index 89b249331..bbdc96897 100644 --- a/orbit/desktop/src-tauri/Cargo.toml +++ b/orbit/desktop/src-tauri/Cargo.toml @@ -17,15 +17,18 @@ 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"] } # 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 +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 } @@ -42,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/cluster.rs b/orbit/desktop/src-tauri/src/cluster.rs new file mode 100644 index 000000000..647837079 --- /dev/null +++ b/orbit/desktop/src-tauri/src/cluster.rs @@ -0,0 +1,711 @@ +//! 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`. +#[derive(Debug)] +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(..))); + } +} + +/// 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 b1963b2e2..06d784fee 100644 --- a/orbit/desktop/src-tauri/src/connections.rs +++ b/orbit/desktop/src-tauri/src/connections.rs @@ -1,18 +1,135 @@ -//! 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}; + +/// 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, + 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 +140,1830 @@ 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) + } + + /// How this connection should treat transport encryption. + /// + /// # Errors + /// 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(SslMode::Disable), + Some(mode) => mode, + }; + + 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" + ))), + } + } } -/// Connection status -#[derive(Debug, Clone, Serialize, Deserialize)] +/// 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() + }) + } + } +} + +/// 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>; +/// 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 } -/// 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 +/// Open a live session for `info`, dispatching on protocol. +async fn open_session( + info: &ConnectionInfo, +) -> Result, ConnectionError> { + // 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?)), + 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 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)) - } +// --------------------------------------------------------------------------- +// PostgreSQL +// --------------------------------------------------------------------------- + +/// 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" + ); } - } - 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()) - ); + 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. + 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); + } + if let Some(database) = &info.database { + config.dbname(database); + } - 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); - } - }); + let mode = info.ssl_mode()?; - // 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))), + // `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. + 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))), - } + }); + + Ok(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())), + /// 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(describe(&e)))?; + + if prepared.columns().is_empty() { + let affected = self + .client + .execute(&prepared, &[]) + .await + .map_err(|e| ConnectionError::QueryFailed(describe(&e)))?; + return Ok(QueryPayload::affected(affected)); } - } - 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 - ))), - } + let columns: Vec = prepared + .columns() + .iter() + .map(|column| ColumnInfo::new(column.name(), column.type_().name())) + .collect(); + + let rows = self + .client + .query(&prepared, &[]) + .await + .map_err(|e| ConnectionError::QueryFailed(describe(&e)))?; + + 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)) + } + + /// 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(); } - Err(e) => Ok(ConnectionStatus::Error(format!("Connection failed: {}", e))), + 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. + _ => {} } - Err(e) => Ok(ConnectionStatus::Error(format!("Invalid Redis URL: {}", e))), } - } - - async fn test_mysql_connection( - &self, - info: &ConnectionInfo, - ) -> Result { - use mysql_async::prelude::*; - 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))), - } - } - Err(e) => Ok(ConnectionStatus::Error(format!("Connection failed: {}", e))), + if !columns.is_empty() { + return Ok(QueryPayload::returned(columns, rows)); } - } - 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())), + match affected { + Some(count) => Ok(QueryPayload::affected(count)), + None => Ok(QueryPayload { + columns: Vec::new(), + rows: Vec::new(), + outcome: crate::queries::StatementOutcome::Completed, + }), } } +} - 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(); +#[async_trait] +impl DatabaseSession for PostgresSession { + fn connection_type(&self) -> ConnectionType { + ConnectionType::PostgreSQL + } - // 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)) + async fn execute(&mut self, statement: &str) -> Result { + if self.extended_protocol { + self.execute_extended(statement).await } 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(), - ); + self.execute_simple(statement).await } + } - match request - .json(&serde_json::json!({ - "statements": [{"statement": "RETURN 1 as result"}] - })) - .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))), + 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_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(); +/// 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())), + } +} - // Test with version endpoint - let version_url = format!("{}/_api/version", base_url); - let mut request = client.get(&version_url); +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 + }) +} - if let (Some(user), Some(pass)) = (&info.username, &info.password) { - request = request.basic_auth(user, Some(pass)); - } +// --------------------------------------------------------------------------- +// MySQL +// --------------------------------------------------------------------------- - 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))), - } - } +/// A MySQL session held open across statements. +pub struct MySqlSession { + conn: mysql_async::Conn, +} - 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(); +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()); - // 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(), + let conn = tokio::time::timeout( + info.connect_timeout(), + mysql_async::Conn::new(opts), ) .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())), - } - } + .map_err(|_| ConnectionError::Timeout(info.connect_timeout()))? + .map_err(|e| ConnectionError::ConnectionFailed(e.to_string()))?; - async fn test_orbitwire_connection( - &self, - info: &ConnectionInfo, - ) -> Result { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; + Ok(Self { conn }) + } +} - let addr = format!("{}:{}", info.host, info.port); +#[async_trait] +impl DatabaseSession for MySqlSession { + fn connection_type(&self) -> ConnectionType { + ConnectionType::MySQL + } - 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 - ))); - } + async fn execute(&mut self, statement: &str) -> Result { + use mysql_async::prelude::Queryable; - // Read handshake response - let mut response = [0u8; 5]; - match tokio::time::timeout( - std::time::Duration::from_secs(3), - stream.read_exact(&mut response), + 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(); + + if columns.is_empty() { + let affected = result.affected_rows(); + result + .drop_result() .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())), + .map_err(|e| ConnectionError::QueryFailed(e.to_string()))?; + return Ok(QueryPayload::affected(affected)); } + + let rows: Vec = result + .collect() + .await + .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> { + use mysql_async::prelude::Queryable; + + self.conn + .ping() + .await + .map_err(|e| ConnectionError::NetworkError(e.to_string())) } } -// Concrete database connection implementations - -/// PostgreSQL connection -pub struct PostgreSQLConnection { - client: Option, - connected: bool, +/// 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}" + )) + } + } } -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()) - ); +// --------------------------------------------------------------------------- +// Redis +// --------------------------------------------------------------------------- - 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); - } - }); +/// 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, +} - Ok(Self { - client: Some(client), - connected: true, - }) - } - Err(e) => Err(ConnectionError::ConnectionFailed(e.to_string())), +impl RedisSession { + async fn connect(info: &ConnectionInfo) -> Result { + // 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(); } - } - 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(), - )) + // 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"); } - } -} -impl DatabaseConnection for PostgreSQLConnection { - fn connection_type(&self) -> ConnectionType { - ConnectionType::PostgreSQL - } + let client = redis::Client::open(url) + .map_err(|e| ConnectionError::InvalidConfiguration(e.to_string()))?; - fn is_connected(&self) -> bool { - self.connected && self.client.is_some() - } + 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()))?; - fn disconnect(&mut self) -> Result<(), ConnectionError> { - self.client = None; - self.connected = false; - Ok(()) + 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(format!("AUTH failed: {e}")))?; + } + + Ok(Self { conn }) } } -/// 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_trait] +impl DatabaseSession for RedisSession { + fn connection_type(&self) -> ConnectionType { + ConnectionType::Redis + } -impl OrbitQLConnection { - pub async fn new(info: &ConnectionInfo) -> Result { - let base_url = format!("http://{}:{}", info.host, info.port); - let client = reqwest::Client::new(); + 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()))?; - // 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 command = redis::cmd(&name.to_uppercase()); + for arg in rest { + command.arg(arg.as_str()); } - } - pub async fn execute_orbitql(&self, query: &str) -> Result { - if !self.connected { - return Err(ConnectionError::ConnectionNotFound( - "Not connected".to_string(), - )); - } + let value = command + .query_async::(&mut self.conn) + .await + .map_err(|e| ConnectionError::QueryFailed(e.to_string()))?; - let query_url = format!("{}/query", self.base_url); - let request_body = serde_json::json!({ - "query": query - }); + Ok(QueryPayload::single_value( + "result", + "redis", + redis_value_to_json(value), + )) + } - match self - .client - .post(&query_url) - .json(&request_body) - .send() + async fn ping(&mut self) -> Result<(), ConnectionError> { + redis::cmd("PING") + .query_async::(&mut self.conn) .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(|_| ()) + .map_err(|e| ConnectionError::NetworkError(e.to_string())) } } -impl DatabaseConnection for OrbitQLConnection { - fn connection_type(&self) -> ConnectionType { - ConnectionType::OrbitQL +/// 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; + } + (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; + } + } + (None, c) => current.push(c), + } } - fn is_connected(&self) -> bool { - self.connected + if started || !current.is_empty() { + args.push(current); } - fn disconnect(&mut self) -> Result<(), ConnectionError> { - self.connected = false; - Ok(()) - } + args } -/// Redis connection -pub struct RedisConnection { - client: Option, - connected: bool, +/// 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:?}")), + } } -impl RedisConnection { - pub async fn new(info: &ConnectionInfo) -> Result { - let redis_url = format!("redis://{}:{}/", info.host, info.port); +// --------------------------------------------------------------------------- +// HTTP-backed protocols +// --------------------------------------------------------------------------- - 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()))?; +/// 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, +} - // Test with PING - redis::cmd("PING") - .query_async::<_, String>(&mut conn) - .await - .map_err(|e| ConnectionError::ConnectionFailed(e.to_string()))?; +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(), + }; - Ok(Self { - client: Some(client), - connected: true, - }) - } - Err(e) => Err(ConnectionError::InvalidConfiguration(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_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()))?; + /// 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 mut redis_cmd = redis::cmd(cmd); - for arg in args { - redis_cmd.arg(*arg); - } + let response = self + .authenticated(self.client.get(&url)) + .send() + .await + .map_err(|e| ConnectionError::NetworkError(e.to_string()))?; - redis_cmd - .query_async::<_, redis::Value>(&mut conn) - .await - .map_err(|e| ConnectionError::ConnectionFailed(e.to_string())) + // 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::ConnectionNotFound( - "Redis connection not available".to_string(), - )) + Err(ConnectionError::ConnectionFailed(format!( + "health probe returned HTTP {}", + response.status() + ))) + } + } + + 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 RedisConnection { +#[async_trait] +impl DatabaseSession for HttpSession { fn connection_type(&self) -> ConnectionType { - ConnectionType::Redis + self.flavor } - fn is_connected(&self) -> bool { - self.connected && self.client.is_some() + async fn execute(&mut self, statement: &str) -> Result { + let (url, body) = self.request_for(statement); + + let response = self + .authenticated(self.client.post(&url)) + .json(&body) + .send() + .await + .map_err(|e| ConnectionError::NetworkError(e.to_string()))?; + + let status = response.status(); + let payload: serde_json::Value = response + .json() + .await + .map_err(|e| ConnectionError::QueryFailed(format!("HTTP {status}: {e}")))?; + + if !status.is_success() { + return Err(ConnectionError::QueryFailed(format!( + "HTTP {status}: {payload}" + ))); + } + + Ok(QueryPayload::from_json(&payload)) } - fn disconnect(&mut self) -> Result<(), ConnectionError> { - self.client = None; - self.connected = false; - Ok(()) + async fn ping(&mut self) -> Result<(), ConnectionError> { + self.probe().await } } -/// MySQL connection -pub struct MySQLConnection { - pool: Option, - connected: bool, +// --------------------------------------------------------------------------- +// 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, } -impl MySQLConnection { - pub async fn new(info: &ConnectionInfo) -> Result { - use mysql_async::prelude::*; - - 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())), - } +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) } - 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(), - )) - } + 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 MySQLConnection { +#[async_trait] +impl DatabaseSession for TcpProbeSession { fn connection_type(&self) -> ConnectionType { - ConnectionType::MySQL + self.flavor } - fn is_connected(&self) -> bool { - self.connected && self.pool.is_some() + 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.pool = None; - self.connected = false; - Ok(()) + async fn ping(&mut self) -> Result<(), ConnectionError> { + self.probe().await } } -/// CQL (Cassandra) connection -pub struct CQLConnection { - base_url: String, - client: reqwest::Client, - 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 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(); +impl OrbitWireSession { + /// `"ORBT"` plus protocol version 1. + const HANDSHAKE: [u8; 5] = [0x4F, 0x52, 0x42, 0x54, 0x01]; - // 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, - }) - } - Err(e) => Err(ConnectionError::ConnectionFailed(e.to_string())), - } - } + async fn connect(info: &ConnectionInfo) -> Result { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; - pub async fn execute_cql(&self, query: &str) -> Result { - if !self.connected { - return Err(ConnectionError::ConnectionNotFound( - "Not connected".to_string(), - )); - } + 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()))?; - // 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 - }); + stream + .write_all(&Self::HANDSHAKE) + .await + .map_err(|e| ConnectionError::NetworkError(e.to_string()))?; - match self - .client - .post(&query_url) - .json(&request_body) - .send() + let mut response = [0u8; 5]; + tokio::time::timeout(timeout, stream.read_exact(&mut response)) .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())), + .map_err(|_| ConnectionError::Timeout(timeout))? + .map_err(|e| ConnectionError::NetworkError(e.to_string()))?; + + if &response[..4] != b"ORBT" { + return Err(ConnectionError::ConnectionFailed( + "peer did not answer the OrbitWire handshake".to_string(), + )); } + + // 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); + + let http = HttpSession::connect(&ConnectionInfo { + port: rest_port, + connection_type: ConnectionType::OrbitWire, + ..info.clone() + }) + .await?; + + Ok(Self { http }) } } -impl DatabaseConnection for CQLConnection { +#[async_trait] +impl DatabaseSession for OrbitWireSession { fn connection_type(&self) -> ConnectionType { - ConnectionType::CQL + 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 } } -/// Cypher (Neo4j) connection -pub struct CypherConnection { - client: reqwest::Client, - base_url: String, - 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); - - if let (Some(user), Some(pass)) = (&info.username, &info.password) { - request = request.basic_auth(user, Some(pass)); +#[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); } + } - 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())), + #[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(), } } - pub async fn execute_cypher(&self, query: &str) -> Result { - if !self.connected { - return Err(ConnectionError::ConnectionNotFound( - "Not connected".to_string(), - )); + #[test] + 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 + ); } + } - let query_url = format!("{}/db/data/transaction/commit", self.base_url); - let mut request = self.client.post(&query_url); - - if let (Some(user), Some(pass)) = (&self.username, &self.password) { - request = request.basic_auth(user, Some(pass)); + /// `prefer` is mapped to `require` on purpose: PostgreSQL's own `prefer` + /// falls back to plaintext without telling anyone. + #[test] + 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 + ); } + } - 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())), + #[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 + ); } } -} -impl DatabaseConnection for CypherConnection { - fn connection_type(&self) -> ConnectionType { - ConnectionType::Cypher + /// 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()); } - fn is_connected(&self) -> bool { - self.connected + #[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" + ); } - fn disconnect(&mut self) -> Result<(), ConnectionError> { - self.connected = false; - Ok(()) + #[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)); } -} -/// AQL (ArangoDB) connection -pub struct AQLConnection { - client: reqwest::Client, - base_url: String, - username: Option, - password: Option, - database: Option, - connected: bool, -} - -impl AQLConnection { - pub async fn new(info: &ConnectionInfo) -> Result { - let base_url = format!("http://{}:{}", info.host, info.port); - let client = reqwest::Client::new(); + #[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"]]) + ); + } - // Test connection - let version_url = format!("{}/_api/version", base_url); - let mut request = client.get(&version_url); + #[test] + fn hex_encoding_pads_every_byte() { + assert_eq!(hex_encode(&[0x00, 0x0f, 0xff]), "000fff"); + } +} - if let (Some(user), Some(pass)) = (&info.username, &info.password) { - request = request.basic_auth(user, Some(pass)); +/// 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(), } + } - 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())), - } + #[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}" + ); } - pub async fn execute_aql(&self, query: &str) -> Result { - if !self.connected { - return Err(ConnectionError::ConnectionNotFound( - "Not connected".to_string(), - )); - } + #[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"); - 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); + session.ping().await.expect("ping should succeed"); - if let (Some(user), Some(pass)) = (&self.username, &self.password) { - request = request.basic_auth(user, Some(pass)); - } + let payload = session + .execute("SELECT 1 AS one") + .await + .expect("SELECT 1 should execute"); - let request_body = serde_json::json!({ - "query": query, - "count": true - }); + 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 } + ); - 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())), - } + // 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}" + ); } -} -impl DatabaseConnection for AQLConnection { - fn connection_type(&self) -> ConnectionType { - ConnectionType::AQL + #[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}" + ); + } } - fn is_connected(&self) -> bool { - self.connected - } + /// 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"); - fn disconnect(&mut self) -> Result<(), ConnectionError> { - self.connected = false; - Ok(()) + assert!( + session.extended_protocol, + "prepare() must succeed against orbit-server; the client only falls back to \ + simple queries when Describe is unusable" + ); } -} -/// Arrow Flight SQL connection -pub struct FlightSQLConnection { - client: reqwest::Client, - endpoint: String, - database: Option, - connected: bool, -} + /// 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}")); + } -impl FlightSQLConnection { - pub async fn new(info: &ConnectionInfo) -> Result { - let endpoint = format!("http://{}:{}", info.host, info.port); - let client = reqwest::Client::new(); + // 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"); - // 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), - } + assert_eq!(rows.len(), 1, "the parameter should select exactly one row"); + + let _ = session.execute(&format!("DROP TABLE IF EXISTS {table}")).await; } - pub async fn execute_query(&self, query: &str) -> Result { - if !self.connected { - return Err(ConnectionError::ConnectionNotFound( - "Not connected".to_string(), - )); + /// 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 url = format!("{}/api/v1/flight/sql", self.endpoint); - let request_body = serde_json::json!({ - "query": query, - "database": self.database - }); + let mut encrypted = info(ConnectionType::PostgreSQL, 5432); + encrypted.ssl_mode = Some("require".to_string()); - 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 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; } - } -} -impl DatabaseConnection for FlightSQLConnection { - fn connection_type(&self) -> ConnectionType { - ConnectionType::FlightSQL - } + let mut encrypted = info(ConnectionType::PostgreSQL, 5432); + encrypted.ssl_mode = Some("require".to_string()); - fn is_connected(&self) -> bool { - self.connected + assert!( + PostgresSession::connect(&encrypted).await.is_err(), + "a plaintext server must not satisfy ssl_mode=require" + ); } - fn disconnect(&mut self) -> Result<(), ConnectionError> { - self.connected = false; - Ok(()) - } -} + #[tokio::test] + #[ignore = "requires a running orbit-server on 6379"] + async fn redis_session_runs_commands_and_decodes_replies() { + // 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()); + } -/// OrbitWire (native binary protocol) connection -pub struct OrbitWireConnection { - client: reqwest::Client, - base_url: String, - database: Option, - connected: bool, -} + let mut session = RedisSession::connect(&connection) + .await + .expect("orbit-server should accept a Redis connection"); -impl OrbitWireConnection { - pub async fn new(info: &ConnectionInfo) -> Result { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; + session.ping().await.expect("PING should succeed"); - let addr = format!("{}:{}", info.host, info.port); + 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" + ); + } - // 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()))?; + #[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(); - // 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), - } - } + // 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; - pub async fn execute_query(&self, query: &str) -> Result { - if !self.connected { - return Err(ConnectionError::ConnectionNotFound( - "Not connected".to_string(), - )); - } + let session = manager + .session("restored") + .await + .expect("a restored connection must be usable without being recreated"); - let url = format!("{}/api/v1/sql", self.base_url); - let request_body = serde_json::json!({ - "query": query, - "protocol": "orbitwire" - }); + 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}" + ); - 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 listed = manager.list_connections().await; + assert_eq!(listed[0].status, ConnectionStatus::Connected); } } -impl DatabaseConnection for OrbitWireConnection { - fn connection_type(&self) -> ConnectionType { - ConnectionType::OrbitWire - } +/// 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(); - fn is_connected(&self) -> bool { - self.connected - } + 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" + ); - fn disconnect(&mut self) -> Result<(), ConnectionError> { - self.connected = false; - Ok(()) + run(format!("DROP TABLE IF EXISTS {table}")).await; } } diff --git a/orbit/desktop/src-tauri/src/encryption.rs b/orbit/desktop/src-tauri/src/encryption.rs index 364b57876..d48a88730 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 a2cfb7b4b..139269c53 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,89 @@ 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 ============================ + +/// 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 explain_query( - request: QueryRequest, +async fn get_cluster_status( 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> { + with_cluster!(state, |manager| manager.status().await) } -/// ML Model Management Commands +/// Point the cluster panel at an Orbit-RS checkout. +#[tauri::command] +async fn set_cluster_root( + path: String, + state: State<'_, AppState>, +) -> 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| manager.start(size).await.map(|()| true)) +} + +#[tauri::command] +async fn stop_cluster(state: State<'_, AppState>) -> Result, String> { + with_cluster!(state, |manager| manager.stop().await.map(|()| true)) +} + +#[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| match node_id.as_deref() { + Some(node_id) => manager.node_log(node_id, lines), + None => manager.control_log(lines), + }) +} + +// ============================ 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 +321,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 +331,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 +341,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 +372,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(value) = settings.get("auto_save").and_then(serde_json::Value::as_bool) { + current.auto_save = value; } - 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("query_timeout").and_then(serde_json::Value::as_u64) { + current.query_timeout = 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("editor_font_size") + .and_then(serde_json::Value::as_u64) + { + current.editor_font_size = value as u16; } - 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_theme").and_then(|v| v.as_str()) { + current.editor_theme = value.to_string(); } - 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("show_line_numbers") + .and_then(serde_json::Value::as_bool) + { + current.show_line_numbers = value; } - 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("word_wrap").and_then(serde_json::Value::as_bool) { + current.word_wrap = 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("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 +421,190 @@ 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"); + + // Selects the rustls provider once, before any TLS connection is opened. + connections::install_crypto_provider(); 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 +615,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/models.rs b/orbit/desktop/src-tauri/src/models.rs index 660ad3ec2..b4a073f63 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/queries.rs b/orbit/desktop/src-tauri/src/queries.rs index 183a92dcd..f7b72e13b 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/desktop/src-tauri/src/storage.rs b/orbit/desktop/src-tauri/src/storage.rs index 9271d0f68..e3aa11cd1 100644 --- a/orbit/desktop/src-tauri/src/storage.rs +++ b/orbit/desktop/src-tauri/src/storage.rs @@ -5,13 +5,12 @@ 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::{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,8 +114,8 @@ impl Default for AppStorage { } /// Storage manager for persisting application data +#[derive(Clone)] pub struct StorageManager { - storage_dir: PathBuf, storage_file: PathBuf, } @@ -115,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"); @@ -135,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 @@ -151,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!( @@ -169,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", @@ -188,35 +194,28 @@ 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::Parse`] when the stored protocol name is + /// not one this build knows. pub fn to_connection( &self, enc_manager: &crate::encryption::EncryptionManager, @@ -224,33 +223,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::Parse(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 +270,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, }) } @@ -289,29 +286,19 @@ 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 }; - 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 +308,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/desktop/src/App.tsx b/orbit/desktop/src/App.tsx index 113d37d7d..e087e20a5 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 000000000..351e66a93 --- /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 a374303c5..83b92c268 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 b8fbb423c..2932aa795 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 c306a4bf9..0fe805a25 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 e25ad572b..3c23b080b 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 18ea5c013..f4413eaf9 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 010d0e68f..de0663963 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 92bc83fa9..f7e41d45f 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 fe68fe5b5..a008559c4 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 518009b24..015263073 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 28a9ca1d7..1d796bb85 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 000000000..efce88011 --- /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 d81ac71a0..bb5dce77d 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(); + }); +}); diff --git a/orbit/engine/Cargo.toml b/orbit/engine/Cargo.toml index 1640517ab..ec00e4fde 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 @@ -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/query/execution.rs b/orbit/engine/src/query/execution.rs index 58e73eb2d..d185c95a5 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() + }), }) } @@ -603,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, @@ -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(); diff --git a/orbit/engine/src/unified/adapters.rs b/orbit/engine/src/unified/adapters.rs index 6399199b7..7eb43ab11 100644 --- a/orbit/engine/src/unified/adapters.rs +++ b/orbit/engine/src/unified/adapters.rs @@ -1047,17 +1047,44 @@ 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 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!("Missing primary key: {}", primary_key)) + UnifiedStorageError::InvalidData(format!( + "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) @@ -1065,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 e44f8c08f..fbccf330a 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 000000000..f8d35c7a8 --- /dev/null +++ b/orbit/engine/src/unified/rocksdb_backend.rs @@ -0,0 +1,456 @@ +//! 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::str::FromStr; +use std::sync::Arc; + +use async_trait::async_trait; +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` 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}", + path.as_ref().display() + )) + })?; + + 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())), + }) + } + + /// 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<()> { + // 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}")))?; + 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_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}"))) + } + + 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_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) + } + + 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_opt(batch, &self.write_options); + { + 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_opt(batch, &self.write_options); + { + 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 0901331df..ef20c314e 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/engine/tests/durability.rs b/orbit/engine/tests/durability.rs new file mode 100644 index 000000000..b09925fb0 --- /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/llm/Cargo.toml b/orbit/llm/Cargo.toml new file mode 100644 index 000000000..252799a64 --- /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 000000000..4ace4d985 --- /dev/null +++ b/orbit/llm/src/breaker.rs @@ -0,0 +1,349 @@ +//! 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::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::*; + use std::time::Duration; + + 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/compat.rs b/orbit/llm/src/compat.rs new file mode 100644 index 000000000..f1fefcb67 --- /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 new file mode 100644 index 000000000..0c7b562dd --- /dev/null +++ b/orbit/llm/src/config.rs @@ -0,0 +1,939 @@ +//! 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. + #[serde(rename = "openai")] + 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. + /// + /// 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)] + 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(()), + } + } +} + +/// 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 +/// 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. + /// + /// 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)] + 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"); + } +} diff --git a/orbit/llm/src/error.rs b/orbit/llm/src/error.rs new file mode 100644 index 000000000..165782b1c --- /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/http.rs b/orbit/llm/src/http.rs new file mode 100644 index 000000000..c92943b0d --- /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 000000000..bd4d2dee9 --- /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 new file mode 100644 index 000000000..04017dd6d --- /dev/null +++ b/orbit/llm/src/provider.rs @@ -0,0 +1,258 @@ +//! 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. + #[serde(rename = "openai")] + 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/providers/anthropic.rs b/orbit/llm/src/providers/anthropic.rs new file mode 100644 index 000000000..29130fe11 --- /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 000000000..8e46e929d --- /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 000000000..e60bad796 --- /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 000000000..7d63f0bc6 --- /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 000000000..e7f988964 --- /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 000000000..0d8e533c3 --- /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 000000000..4f7854834 --- /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/retry.rs b/orbit/llm/src/retry.rs new file mode 100644 index 000000000..5a3f64e06 --- /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/router.rs b/orbit/llm/src/router.rs new file mode 100644 index 000000000..86c766f3a --- /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 new file mode 100644 index 000000000..e0a2e0d6a --- /dev/null +++ b/orbit/llm/src/secret.rs @@ -0,0 +1,134 @@ +//! 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")); + 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 000000000..1457bbee4 --- /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 new file mode 100644 index 000000000..88c9ddafe --- /dev/null +++ b/orbit/llm/src/types.rs @@ -0,0 +1,450 @@ +//! 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()), + } + } + + /// 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. +/// +/// 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/orbit/llm/src/usage.rs b/orbit/llm/src/usage.rs new file mode 100644 index 000000000..028adc5f4 --- /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}" + ); + } +} diff --git a/orbit/ml/Cargo.toml b/orbit/ml/Cargo.toml index b1e7b0bab..5ec65b0cd 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/industry_models/cross_cutting_horizontal/anomaly_detection.rs b/orbit/ml/src/industry_models/cross_cutting_horizontal/anomaly_detection.rs index 5d8251b70..430733811 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 66b2023a8..64483cede 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 e2f84cdf8..93382fafa 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 2179f9ac2..faa731b72 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/lib.rs b/orbit/ml/src/lib.rs index 93516a523..361679469 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; diff --git a/orbit/ml/src/neural_networks/gru.rs b/orbit/ml/src/neural_networks/gru.rs index da0a0104a..d66489a96 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 8e932b7ca..25980a371 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 82339ce05..e4533c062 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 1efbf456e..f60855806 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/proto/src/converters.rs b/orbit/proto/src/converters.rs index 16e0c7767..94ad7bd69 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); diff --git a/orbit/server-etcd/Cargo.toml b/orbit/server-etcd/Cargo.toml index 628cc472b..c5e4fe157 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 9cabe7780..e3f70f5d8 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" diff --git a/orbit/server/Cargo.toml b/orbit/server/Cargo.toml index 7e75c205f..064b181a8 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 @@ -33,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" @@ -78,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" @@ -128,6 +136,7 @@ default = [ "metrics", "protocol-redis", "protocol-postgres", + "gssapi", "protocol-grpc", "protocol-rest", "protocol-mcp", @@ -180,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 543760313..6efead746 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 @@ -162,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, } @@ -278,6 +291,11 @@ pub struct GrpcConfig { pub tls: Option, } +/// Changes a replication slot may fall behind by before it is invalidated. +fn default_max_slot_change_backlog() -> u64 { + 100_000 +} + /// PostgreSQL server configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PostgresqlConfig { @@ -301,6 +319,15 @@ pub struct PostgresqlConfig { /// PostgreSQL-specific features pub features: PostgresqlFeatures, + + /// How far a replication slot may fall behind before it is invalidated, + /// counted in changes. + /// + /// A slot that stops confirming would otherwise hold the change log open + /// for ever; this is the equivalent of `max_slot_wal_keep_size`. Defaulted + /// so an existing configuration file keeps working. + #[serde(default = "default_max_slot_change_backlog")] + pub max_slot_change_backlog: u64, } /// Redis server configuration @@ -726,6 +753,7 @@ pub struct SecurityConfig { pub authorization: AuthorizationConfig, /// Encryption configuration + #[serde(default)] pub encryption: EncryptionConfig, } @@ -795,9 +823,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, } @@ -1327,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, } @@ -1908,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, } } } @@ -2020,6 +2056,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, } } } @@ -2149,6 +2188,7 @@ impl Default for PostgresqlConfig { sql_engine: SqlEngineConfig::default(), vector_ops: VectorOpsConfig::default(), features: PostgresqlFeatures::default(), + max_slot_change_backlog: default_max_slot_change_backlog(), } } } @@ -2640,3 +2680,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/lib.rs b/orbit/server/src/lib.rs index 09797ee60..9f5322c5e 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 000000000..b076e911a --- /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/main.rs b/orbit/server/src/main.rs index ed9f50909..3d61a3a37 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 @@ -224,6 +226,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" @@ -363,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: 10000, + max_scan_limit: 1_000_000, use_memory_backend: false, // Use persistent backend + durability, }; let integration = UnifiedStorageIntegration::with_config(integration_config) @@ -583,6 +624,11 @@ async fn main() -> Result<(), Box> { rocksdb_storage.clone(), unified_postgres, toml_config.server.tls.clone(), + toml_config + .protocols + .postgresql + .as_ref() + .map_or(100_000, |postgres| postgres.max_slot_change_backlog), ) .await?; protocol_handles.push(postgres_handle); @@ -794,7 +840,25 @@ 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< + 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(); + + 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() @@ -1304,6 +1368,7 @@ async fn start_postgresql_server( rocksdb: Arc, unified_storage: Option>, tls_config: Option, + max_slot_change_backlog: u64, ) -> Result>>, Box> { use orbit_server::protocols::postgres_wire::persistent_storage::PersistentTableStorage; @@ -1317,9 +1382,28 @@ 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. + // The replication backlog bound comes from configuration, so an operator + // can trade log growth against how long a slow subscriber is tolerated. + orbit_server::protocols::postgres_wire::query_engine::set_max_slot_backlog( + max_slot_change_backlog, + ); + + 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/aql/query_engine.rs b/orbit/server/src/protocols/aql/query_engine.rs index 51d01d69b..9fa24e708 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/common/cancel.rs b/orbit/server/src/protocols/common/cancel.rs new file mode 100644 index 000000000..87a2ca06e --- /dev/null +++ b/orbit/server/src/protocols/common/cancel.rs @@ -0,0 +1,182 @@ +//! Cancelling a statement that is already running. +//! +//! PostgreSQL's `CancelRequest` arrives on a second connection while the first +//! is busy, so the flag it sets has to be readable from wherever the busy +//! statement happens to be. That is the whole reason this lives here rather +//! than in the PostgreSQL query engine: most of a large scan's time is spent +//! in the storage layer, and a check the storage layer cannot reach is a check +//! that fires only after the expensive part is over. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use crate::protocols::error::{ProtocolError, ProtocolResult}; + +/// Sessions that can be cancelled, by backend id, with the key that proves a +/// caller is allowed to cancel them. +type Sessions = Mutex, Arc)>>; + +static CANCELLABLE: OnceLock = OnceLock::new(); + +fn cancellable() -> &'static Sessions { + CANCELLABLE.get_or_init(|| Mutex::new(std::collections::HashMap::new())) +} + +/// Register a session so its queries can be cancelled. +/// +/// The returned flag is the one [`check_cancelled`] reads; hold it for the +/// life of the session and scope it around each statement with +/// [`with_cancel`]. +#[must_use] +pub fn register_cancellable(process_id: i32, secret_key: Vec) -> Arc { + let flag = Arc::new(AtomicBool::new(false)); + if let Ok(mut sessions) = cancellable().lock() { + sessions.insert(process_id, (secret_key, Arc::clone(&flag))); + } + flag +} + +/// Forget a session that has gone away. +pub fn forget_cancellable(process_id: i32) { + if let Ok(mut sessions) = cancellable().lock() { + sessions.remove(&process_id); + } +} + +tokio::task_local! { + /// The cancel flag of the session running this statement. + static CANCEL_FLAG: Arc; +} + +/// Run a statement with a cancel flag the rest of the stack can consult. +pub async fn with_cancel(flag: Arc, future: F) -> T +where + F: std::future::Future, +{ + CANCEL_FLAG.scope(flag, future).await +} + +/// Whether a cancel has been asked for and not yet acted on. +/// +/// Reading clears it, so one cancel stops one statement rather than every +/// statement that follows. +#[must_use] +pub fn cancel_requested() -> bool { + CANCEL_FLAG + .try_with(|flag| flag.swap(false, Ordering::Relaxed)) + .unwrap_or(false) +} + +/// Stop if a cancel is waiting, without consuming it for a later check. +/// +/// Called inside the loops a long statement spends its time in, so a cancel +/// interrupts the statement running rather than only the one after it. +/// +/// # Errors +/// Returns the error PostgreSQL reports for a cancelled statement. +pub fn check_cancelled() -> ProtocolResult<()> { + let pending = CANCEL_FLAG + .try_with(|flag| flag.load(Ordering::Relaxed)) + .unwrap_or(false); + if pending { + return Err(ProtocolError::PostgresError( + "canceling statement due to user request".to_string(), + )); + } + Ok(()) +} + +/// How many rows pass between cancel checks. +/// +/// Checking every row would put an atomic load in the innermost loop; once per +/// batch is often enough for a person waiting on a query and costs nothing +/// measurable. +pub const CANCEL_CHECK_INTERVAL: usize = 512; + +/// Ask a session to stop what it is doing. +/// +/// The key must match: without that check any client could cancel any other's +/// work by guessing a backend id. +pub fn request_cancel(process_id: i32, secret_key: &[u8]) { + let Ok(sessions) = cancellable().lock() else { + return; + }; + if let Some((expected, flag)) = sessions.get(&process_id) { + if expected == secret_key { + flag.store(true, Ordering::Relaxed); + tracing::debug!(process_id, "cancel requested"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn a_registered_session_sees_its_own_cancel() { + let flag = register_cancellable(9001, b"key".to_vec()); + request_cancel(9001, b"key"); + + with_cancel(flag, async { + assert!(check_cancelled().is_err()); + }) + .await; + forget_cancellable(9001); + } + + #[tokio::test] + async fn the_wrong_key_cancels_nothing() { + let flag = register_cancellable(9002, b"key".to_vec()); + request_cancel(9002, b"guess"); + + with_cancel(flag, async { + assert!(check_cancelled().is_ok()); + }) + .await; + forget_cancellable(9002); + } + + #[tokio::test] + async fn checking_does_not_consume_but_asking_does() { + let flag = register_cancellable(9003, b"key".to_vec()); + request_cancel(9003, b"key"); + + with_cancel(flag, async { + // Two checks in a row both see it: a statement checks many times. + assert!(check_cancelled().is_err()); + assert!(check_cancelled().is_err()); + // Asking consumes it, so the next statement is not also killed. + assert!(cancel_requested()); + assert!(!cancel_requested()); + assert!(check_cancelled().is_ok()); + }) + .await; + forget_cancellable(9003); + } + + #[tokio::test] + async fn outside_a_session_nothing_is_cancelled() { + // Background work runs with no flag in scope and must not be stopped. + assert!(check_cancelled().is_ok()); + assert!(!cancel_requested()); + } + + #[tokio::test] + async fn each_session_is_cancelled_separately() { + let one = register_cancellable(9004, b"a".to_vec()); + let two = register_cancellable(9005, b"b".to_vec()); + request_cancel(9004, b"a"); + + with_cancel(Arc::clone(&two), async { + assert!(check_cancelled().is_ok(), "cancelling one hit the other"); + }) + .await; + with_cancel(one, async { + assert!(check_cancelled().is_err()); + }) + .await; + forget_cancellable(9004); + forget_cancellable(9005); + } +} diff --git a/orbit/server/src/protocols/common/mod.rs b/orbit/server/src/protocols/common/mod.rs index 902b65218..6e2c96402 100644 --- a/orbit/server/src/protocols/common/mod.rs +++ b/orbit/server/src/protocols/common/mod.rs @@ -5,11 +5,13 @@ //! //! ## Modules //! +//! - `cancel`: Cancelling a statement that is already running //! - `formatting`: Common formatting utilities for query results //! - `fts`: Shared full-text search engine with SIMD/GPU acceleration //! - `graph_algorithms`: Shared graph algorithms (BFS, DFS, Dijkstra, PageRank, etc.) //! - `storage`: Common storage abstractions +pub mod cancel; pub mod formatting; #[cfg(feature = "fts")] pub mod fts; diff --git a/orbit/server/src/protocols/common/storage/unified.rs b/orbit/server/src/protocols/common/storage/unified.rs index 867de67d5..f4735abb5 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 @@ -735,10 +864,24 @@ impl TableStorage for UnifiedTableStorage { .await .map_err(|e| ProtocolError::Other(format!("Storage error: {}", e)))?; - Ok(rows - .into_iter() - .map(|r| Self::universal_row_to_sql(&r)) - .collect()) + // Converting the fetched rows is most of what a large scan costs, so + // this is where a cancel has to be able to land. A check further up + // the stack only runs once this has already finished. + // Converting the fetched rows is most of what a large scan costs, so + // this is where a cancel has to be able to land — and where the task + // has to hand the runtime back. A scan of 200,000 rows never yielded, + // and because it never yielded the runtime could not service anything + // else: a `SELECT 1` on a second connection took 3.3s and an HTTP + // health check 2.7s, both simply waiting for this to finish. + let mut converted = Vec::with_capacity(rows.len()); + for (index, row) in rows.iter().enumerate() { + if index.is_multiple_of(crate::protocols::common::cancel::CANCEL_CHECK_INTERVAL) { + crate::protocols::common::cancel::check_cancelled()?; + tokio::task::yield_now().await; + } + converted.push(Self::universal_row_to_sql(row)); + } + Ok(converted) } async fn update_rows( @@ -989,6 +1132,83 @@ 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, + ..Default::default() + }; + + 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 +1285,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 { @@ -1093,25 +1364,77 @@ mod persistent_storage_impl { ColumnType::Boolean => SqlType::Boolean, ColumnType::Json => SqlType::Json, ColumnType::Double => SqlType::DoublePrecision, + ColumnType::Numeric { precision, scale } => { + SqlType::Numeric { precision, scale } + } ColumnType::Timestamp => SqlType::Timestamp { 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(), } } @@ -1134,7 +1457,22 @@ mod persistent_storage_impl { SqlType::Boolean => ColumnType::Boolean, SqlType::Json | SqlType::Jsonb => ColumnType::Json, SqlType::Timestamp { .. } => ColumnType::Timestamp, - _ => ColumnType::Text, // Default fallback + // These have their own storage types and were + // falling through to the default: a column + // declared `NUMERIC(10,2)` or `DOUBLE` was stored + // as `TEXT`, so its declared scale existed nowhere + // and nothing downstream could know it was a + // number. + SqlType::Numeric { precision, scale } + | SqlType::Decimal { precision, scale } => ColumnType::Numeric { + precision: *precision, + scale: *scale, + }, + SqlType::Real | SqlType::DoublePrecision => ColumnType::Double, + SqlType::Char(Some(n)) => ColumnType::Varchar(*n as i32), + // Anything `ColumnType` cannot name stays text, + // which is how it is stored and compared. + _ => ColumnType::Text, } }; @@ -1142,7 +1480,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 +1543,7 @@ mod persistent_storage_impl { columns, created_at: chrono::Utc::now(), row_count: 0, + foreign_keys, } } @@ -1176,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), @@ -1192,11 +1585,53 @@ 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()), } } } + /// Order two stored values when both are numbers or both are text. + /// + /// Returns `None` when they cannot be compared, which the caller reads + /// as "does not match" — the same answer as before for genuinely + /// incomparable values, but now only for those. + fn compare_values(left: &SqlValue, right: &SqlValue) -> Option { + fn as_number(value: &SqlValue) -> Option { + match value { + SqlValue::SmallInt(v) => Some(f64::from(*v)), + SqlValue::Integer(v) => Some(f64::from(*v)), + SqlValue::BigInt(v) => Some(*v as f64), + SqlValue::Real(v) => Some(f64::from(*v)), + SqlValue::DoublePrecision(v) => Some(*v), + SqlValue::Decimal(v) => v.to_string().parse().ok(), + _ => None, + } + } + + if let (Some(left), Some(right)) = (as_number(left), as_number(right)) { + return left.partial_cmp(&right); + } + + match (left, right) { + ( + SqlValue::Text(left) | SqlValue::Varchar(left) | SqlValue::Char(left), + SqlValue::Text(right) | SqlValue::Varchar(right) | SqlValue::Char(right), + ) => Some(left.cmp(right)), + (SqlValue::Date(left), SqlValue::Date(right)) => Some(left.cmp(right)), + (SqlValue::Timestamp(left), SqlValue::Timestamp(right)) => Some(left.cmp(right)), + (SqlValue::Boolean(left), SqlValue::Boolean(right)) => Some(left.cmp(right)), + _ => None, + } + } + #[async_trait] impl PersistentTableStorage for UnifiedTableStorage { async fn create_table(&self, schema: PersistentTableSchema) -> ProtocolResult<()> { @@ -1205,6 +1640,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(()) } @@ -1248,36 +1688,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) } @@ -1287,29 +1716,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) } @@ -1336,22 +1750,19 @@ mod persistent_storage_impl { match cond.operator.as_str() { "=" | "==" => row_value == &cond_value, "!=" | "<>" => row_value != &cond_value, - "<" => { - if let (SqlValue::BigInt(a), SqlValue::BigInt(b)) = - (row_value, &cond_value) - { - a < b - } else { - false - } - } - ">" => { - if let (SqlValue::BigInt(a), SqlValue::BigInt(b)) = - (row_value, &cond_value) - { - a > b - } else { - false + "<" | "<=" | ">" | ">=" => { + // Only `BigInt` against `BigInt` compared, + // so `WHERE amount > 5` on a float or a + // numeric column matched nothing at all — + // a silent empty result, not an error. + match compare_values(row_value, &cond_value) { + None => false, + Some(ordering) => match cond.operator.as_str() { + "<" => ordering.is_lt(), + "<=" => ordering.is_le(), + ">" => ordering.is_gt(), + _ => ordering.is_ge(), + }, } } _ => true, @@ -2703,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/cql/adapter.rs b/orbit/server/src/protocols/cql/adapter.rs index 93c4c1738..4ca994aec 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/error.rs b/orbit/server/src/protocols/error.rs index d39b758ca..99c652465 100644 --- a/orbit/server/src/protocols/error.rs +++ b/orbit/server/src/protocols/error.rs @@ -16,6 +16,20 @@ pub enum ProtocolError { #[error("PostgreSQL protocol error: {0}")] PostgresError(String), + /// An error carrying the SQLSTATE code it should be reported under. + /// + /// Most errors have their code worked out from their message; this is for + /// the ones where the message cannot say. A `RAISE EXCEPTION` in PL/pgSQL + /// is `P0001` no matter what text it carries, and no amount of reading + /// that text would reveal it. + #[error("{message}")] + SqlState { + /// The five-character SQLSTATE. + code: &'static str, + /// The message shown to the client. + message: String, + }, + /// Cypher query parsing error #[error("Cypher query error: {0}")] CypherError(String), diff --git a/orbit/server/src/protocols/graphrag/entity_extraction.rs b/orbit/server/src/protocols/graphrag/entity_extraction.rs index b2cfbe5d6..0d837b8fe 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 44b2c9bc3..21f9624cd 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 08ea098c8..21502ee33 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/mcp/integration.rs b/orbit/server/src/protocols/mcp/integration.rs index 78d3251f7..f4d317715 100644 --- a/orbit/server/src/protocols/mcp/integration.rs +++ b/orbit/server/src/protocols/mcp/integration.rs @@ -264,6 +264,11 @@ impl OrbitMcpIntegration { ColumnType::Boolean => "BOOLEAN".to_string(), ColumnType::Json => "JSON".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(), + }, ColumnType::Timestamp => "TIMESTAMP".to_string(), }; diff --git a/orbit/server/src/protocols/mongodb/server.rs b/orbit/server/src/protocols/mongodb/server.rs index 488354f50..0f4d3a644 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_server.rs b/orbit/server/src/protocols/postgres_server.rs index 2d8708f36..64dd3d52f 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; @@ -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 @@ -30,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, } } @@ -47,6 +57,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,23 +74,31 @@ 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); - // 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 +116,75 @@ 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. +pub 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; + /// A request to cancel the query running on another connection. + pub const CANCEL_REQUEST: i32 = 80_877_102; +} + +/// 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/auth.rs b/orbit/server/src/protocols/postgres_wire/auth.rs index c793dbc97..6714c6dcd 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; @@ -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), } } @@ -258,6 +319,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 +338,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 +589,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:?}"); + } + } } diff --git a/orbit/server/src/protocols/postgres_wire/domains.rs b/orbit/server/src/protocols/postgres_wire/domains.rs new file mode 100644 index 000000000..65868a76b --- /dev/null +++ b/orbit/server/src/protocols/postgres_wire/domains.rs @@ -0,0 +1,86 @@ +//! What each domain is built on, for code that cannot reach the catalogue. +//! +//! A cast names a type: `42::posint`. Resolving that name needs the +//! catalogue, and the expression evaluator has none — `SqlValue::cast_to` is a +//! pure function over a value and a type, and it refused every cast to a +//! domain with `Cannot cast Integer to Custom { .. }`. Casting to a domain is +//! ordinary SQL, so the alternative to this registry was leaving it broken. +//! +//! The invariant: an entry maps a domain's name, folded to lower case, to the +//! type it is built on, and is written only by the query engine — when a +//! domain is created, and once at startup for the domains already stored. A +//! name that is not here is not known to be a domain, and a cast to it still +//! fails rather than passing the value through. That direction matters: a +//! typo'd type name must not silently succeed. + +use std::collections::HashMap; +use std::sync::{OnceLock, RwLock}; + +type Registry = RwLock>; + +static DOMAINS: OnceLock = OnceLock::new(); + +fn registry() -> &'static Registry { + DOMAINS.get_or_init(|| RwLock::new(HashMap::new())) +} + +/// Record that `name` is a domain over `base_type`. +pub fn remember(name: &str, base_type: &str) { + if base_type.trim().is_empty() { + return; + } + if let Ok(mut domains) = registry().write() { + domains.insert(name.to_lowercase(), base_type.trim().to_string()); + } +} + +/// Forget a domain that has been dropped. +pub fn forget(name: &str) { + if let Ok(mut domains) = registry().write() { + domains.remove(&name.to_lowercase()); + } +} + +/// The type `name` is built on, if it is a known domain. +#[must_use] +pub fn base_of(name: &str) -> Option { + registry() + .read() + .ok() + .and_then(|domains| domains.get(&name.to_lowercase()).cloned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_recorded_domain_reports_its_base_type() { + remember("Test_PosInt", "INTEGER"); + // Names fold, as identifiers do everywhere else. + assert_eq!(base_of("test_posint").as_deref(), Some("INTEGER")); + assert_eq!(base_of("TEST_POSINT").as_deref(), Some("INTEGER")); + forget("test_posint"); + } + + #[test] + fn an_unknown_name_is_not_a_domain() { + // The important direction: a typo must not resolve to something. + assert!(base_of("test_no_such_domain_anywhere").is_none()); + } + + #[test] + fn a_dropped_domain_is_forgotten() { + remember("test_gone", "TEXT"); + forget("test_gone"); + assert!(base_of("test_gone").is_none()); + } + + #[test] + fn an_empty_base_type_is_not_recorded() { + // Recording one would make a cast to it succeed while resolving to + // nothing, which is worse than not knowing the domain at all. + remember("test_empty", " "); + assert!(base_of("test_empty").is_none()); + } +} diff --git a/orbit/server/src/protocols/postgres_wire/fastpath.rs b/orbit/server/src/protocols/postgres_wire/fastpath.rs new file mode 100644 index 000000000..48e46ce95 --- /dev/null +++ b/orbit/server/src/protocols/postgres_wire/fastpath.rs @@ -0,0 +1,298 @@ +//! Reading and writing fast-path arguments in text or binary. +//! +//! A `FunctionCall` message carries each argument as bytes plus a format code, +//! and asks for its result in a format too. Binary is not a variant of text: +//! an `int4` arrives as four big-endian bytes and would read as mojibake if +//! taken as a string, so the argument's declared type — read from the same +//! `pg_proc` entry the client took the OID from — is what makes decoding +//! possible rather than a guess. +//! +//! Everything here is pure: bytes and a type name in, SQL literal out. + +use bytes::Bytes; + +use super::plpgsql_function::normalize; +use crate::protocols::error::{ProtocolError, ProtocolResult}; + +/// The format code for text. +pub const TEXT_FORMAT: i16 = 0; +/// The format code for binary. +pub const BINARY_FORMAT: i16 = 1; + +fn unsupported(what: &str, sql_type: &str) -> ProtocolError { + ProtocolError::SqlState { + code: "0A000", + message: format!("{what} is not supported for type {sql_type} in a fast-path call"), + } +} + +fn malformed(sql_type: &str, wanted: usize, got: usize) -> ProtocolError { + ProtocolError::SqlState { + code: "22P03", + message: format!("binary value for type {sql_type} is {got} byte(s), expected {wanted}"), + } +} + +/// Quote a value for substitution into `SELECT f(...)`. +fn quote(text: &str) -> String { + format!("'{}'", text.replace('\'', "''")) +} + +/// Turn one fast-path argument into the SQL literal for a call. +/// +/// # Errors +/// Returns an error when a binary value is the wrong length for its type, or +/// when its type has no binary form this server reads. +pub fn decode_argument( + value: Option<&[u8]>, + format: i16, + sql_type: &str, +) -> ProtocolResult { + let Some(bytes) = value else { + return Ok("NULL".to_string()); + }; + + if format != BINARY_FORMAT { + // Text: a number goes in bare, anything else is quoted. The declared + // type is not consulted, because in text form the value already reads + // as what it is. + let text = String::from_utf8_lossy(bytes); + return Ok(if text.parse::().is_ok() { + text.to_string() + } else { + quote(&text) + }); + } + + let canonical = normalize(sql_type); + let literal = match canonical.as_str() { + "int2" => i16::from_be_bytes( + bytes + .try_into() + .map_err(|_| malformed(&canonical, 2, bytes.len()))?, + ) + .to_string(), + "int4" => i32::from_be_bytes( + bytes + .try_into() + .map_err(|_| malformed(&canonical, 4, bytes.len()))?, + ) + .to_string(), + "int8" => i64::from_be_bytes( + bytes + .try_into() + .map_err(|_| malformed(&canonical, 8, bytes.len()))?, + ) + .to_string(), + "float4" => f32::from_be_bytes( + bytes + .try_into() + .map_err(|_| malformed(&canonical, 4, bytes.len()))?, + ) + .to_string(), + "float8" => f64::from_be_bytes( + bytes + .try_into() + .map_err(|_| malformed(&canonical, 8, bytes.len()))?, + ) + .to_string(), + "bool" => match bytes { + [0] => "FALSE".to_string(), + [_] => "TRUE".to_string(), + other => return Err(malformed(&canonical, 1, other.len())), + }, + "text" | "varchar" | "bpchar" => quote(&String::from_utf8_lossy(bytes)), + // `numeric` has a binary form of digit groups with a weight and a + // sign, and `date`/`timestamp` are offsets from an epoch that is not + // the Unix one. Reading either approximately would corrupt the value + // silently, which is worse than refusing. + other => return Err(unsupported("binary input", other)), + }; + Ok(literal) +} + +/// Render a function's result in the format the client asked for. +/// +/// # Errors +/// Returns an error when binary was asked for and the return type has no +/// binary form this server writes. +pub fn encode_result( + value: Option, + format: i16, + return_type: &str, +) -> ProtocolResult> { + let Some(text) = value else { + return Ok(None); + }; + + if format != BINARY_FORMAT { + return Ok(Some(Bytes::from(text.into_bytes()))); + } + + let canonical = normalize(return_type); + let bytes = match canonical.as_str() { + "int2" => Bytes::copy_from_slice(&parse::(&text, &canonical)?.to_be_bytes()), + "int4" => Bytes::copy_from_slice(&parse::(&text, &canonical)?.to_be_bytes()), + "int8" => Bytes::copy_from_slice(&parse::(&text, &canonical)?.to_be_bytes()), + "float4" => Bytes::copy_from_slice(&parse::(&text, &canonical)?.to_be_bytes()), + "float8" => Bytes::copy_from_slice(&parse::(&text, &canonical)?.to_be_bytes()), + "bool" => Bytes::copy_from_slice(&[u8::from(matches!( + text.trim(), + "t" | "true" | "TRUE" | "True" | "1" + ))]), + "text" | "varchar" | "bpchar" => Bytes::from(text.into_bytes()), + other => return Err(unsupported("binary output", other)), + }; + Ok(Some(bytes)) +} + +/// Parse a value the server produced, which should always be well formed. +fn parse(text: &str, sql_type: &str) -> ProtocolResult { + text.trim() + .parse::() + .map_err(|_| ProtocolError::SqlState { + code: "22P03", + message: format!("cannot render {text:?} as binary {sql_type}"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_null_argument_is_null_in_either_format() { + assert_eq!( + decode_argument(None, TEXT_FORMAT, "INTEGER").expect("decodes"), + "NULL" + ); + assert_eq!( + decode_argument(None, BINARY_FORMAT, "INTEGER").expect("decodes"), + "NULL" + ); + } + + #[test] + fn text_numbers_go_in_bare_and_text_is_quoted() { + assert_eq!( + decode_argument(Some(b"42"), TEXT_FORMAT, "INTEGER").expect("decodes"), + "42" + ); + assert_eq!( + decode_argument(Some(b"ada"), TEXT_FORMAT, "TEXT").expect("decodes"), + "'ada'" + ); + // A quote in the value must not end the literal. + assert_eq!( + decode_argument(Some(b"it's"), TEXT_FORMAT, "TEXT").expect("decodes"), + "'it''s'" + ); + } + + #[test] + fn binary_integers_are_read_big_endian() { + assert_eq!( + decode_argument(Some(&42i32.to_be_bytes()), BINARY_FORMAT, "INTEGER").expect("decodes"), + "42" + ); + assert_eq!( + decode_argument(Some(&(-7i64).to_be_bytes()), BINARY_FORMAT, "BIGINT") + .expect("decodes"), + "-7" + ); + assert_eq!( + decode_argument(Some(&300i16.to_be_bytes()), BINARY_FORMAT, "SMALLINT") + .expect("decodes"), + "300" + ); + } + + #[test] + fn a_binary_integer_read_as_text_would_be_nonsense() { + // The reason the declared type is needed: these four bytes are not + // the characters "42". + let bytes = 42i32.to_be_bytes(); + let as_text = decode_argument(Some(&bytes), TEXT_FORMAT, "INTEGER").expect("decodes"); + assert_ne!(as_text, "42"); + } + + #[test] + fn binary_booleans_and_strings_round_trip() { + assert_eq!( + decode_argument(Some(&[1]), BINARY_FORMAT, "BOOLEAN").expect("decodes"), + "TRUE" + ); + assert_eq!( + decode_argument(Some(&[0]), BINARY_FORMAT, "BOOLEAN").expect("decodes"), + "FALSE" + ); + assert_eq!( + decode_argument(Some(b"ada"), BINARY_FORMAT, "TEXT").expect("decodes"), + "'ada'" + ); + } + + #[test] + fn a_binary_value_of_the_wrong_length_is_refused() { + // Reading three bytes as an int4 would silently give a wrong number. + let failure = + decode_argument(Some(&[0, 0, 1]), BINARY_FORMAT, "INTEGER").expect_err("refused"); + assert!(failure.to_string().contains("expected 4")); + } + + #[test] + fn a_type_with_no_binary_reader_is_refused_not_guessed() { + let failure = + decode_argument(Some(&[1, 2, 3]), BINARY_FORMAT, "NUMERIC").expect_err("refused"); + assert!(failure.to_string().contains("not supported")); + } + + #[test] + fn a_text_result_is_the_bytes_of_the_value() { + let encoded = encode_result(Some("42".to_string()), TEXT_FORMAT, "INTEGER") + .expect("encodes") + .expect("some"); + assert_eq!(&encoded[..], b"42"); + } + + #[test] + fn a_binary_result_is_big_endian() { + let encoded = encode_result(Some("42".to_string()), BINARY_FORMAT, "INTEGER") + .expect("encodes") + .expect("some"); + assert_eq!(&encoded[..], &42i32.to_be_bytes()); + } + + #[test] + fn a_null_result_stays_null() { + assert!(encode_result(None, BINARY_FORMAT, "INTEGER") + .expect("encodes") + .is_none()); + } + + #[test] + fn a_binary_result_of_an_unwritable_type_is_refused() { + let failure = + encode_result(Some("1".to_string()), BINARY_FORMAT, "NUMERIC").expect_err("refused"); + assert!(failure.to_string().contains("not supported")); + } + + #[test] + fn a_round_trip_holds_for_every_type_with_a_binary_form() { + for (sql_type, text) in [ + ("SMALLINT", "300"), + ("INTEGER", "-42"), + ("BIGINT", "5000000000"), + ("FLOAT8", "1.5"), + ("TEXT", "ada"), + ] { + let encoded = encode_result(Some(text.to_string()), BINARY_FORMAT, sql_type) + .expect("encodes") + .expect("some"); + let decoded = + decode_argument(Some(&encoded), BINARY_FORMAT, sql_type).expect("decodes"); + let bare = decoded.trim_matches('\''); + assert_eq!(bare, text, "{sql_type} did not survive a round trip"); + } + } +} 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 000000000..09e5cd1e2 --- /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 4130f5fba..fa1b1f6d4 100644 --- a/orbit/server/src/protocols/postgres_wire/messages.rs +++ b/orbit/server/src/protocols/postgres_wire/messages.rs @@ -67,6 +67,14 @@ pub enum FrontendMessage { Query { query: String, }, + /// Cancel the query running on the connection with this key. + /// + /// Arrives on a connection of its own, in the startup packet's shape + /// rather than as a tagged message, which is why it is parsed separately. + CancelRequest { + process_id: i32, + secret_key: Vec, + }, /// Parse (prepared statement) Parse { statement_name: String, @@ -115,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, }, @@ -128,6 +143,10 @@ pub enum FrontendMessage { FunctionCall { oid: i32, args: Vec>, + /// Format of each argument: 0 text, 1 binary. + arg_formats: Vec, + /// Format wanted for the result. + result_format: i16, }, } @@ -227,6 +246,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, }, @@ -270,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 } @@ -285,8 +334,18 @@ impl FrontendMessage { if len >= 8 && buf.len() >= len { // Check if this looks like a startup message let protocol_version = (&buf[4..8]).get_i32(); - if protocol_version == 196608 || protocol_version == 80877103 { - // Valid startup or SSL request + // Every packet in the startup shape: a protocol-3 startup, and + // the SSL, GSS-encryption and cancel requests. Listing only + // two of them left a cancel request to be read as a tagged + // message, where its first length byte became the type byte + // and the packet was silently discarded. + let major = protocol_version >> 16; + use crate::protocols::postgres_server::pre_startup; + if major == 3 + || protocol_version == pre_startup::CANCEL_REQUEST + || protocol_version == pre_startup::SSL_REQUEST + || protocol_version == pre_startup::GSSENC_REQUEST + { return Self::parse_startup(buf); } } @@ -330,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: {}", @@ -359,6 +423,19 @@ impl FrontendMessage { return Ok(Some(FrontendMessage::SSLRequest)); } + // A cancel request has the startup packet's shape but carries a key + // rather than parameters. Reading it as a startup message would have + // produced a connection with no user and no database. + if protocol_version == crate::protocols::postgres_server::pre_startup::CANCEL_REQUEST { + let process_id = cursor.get_i32(); + let secret_key = buf[12..len].to_vec(); + buf.advance(len); + return Ok(Some(FrontendMessage::CancelRequest { + process_id, + secret_key, + })); + } + let mut parameters = HashMap::new(); while cursor.position() < len as u64 - 1 { let key = read_cstring(&mut cursor)?; @@ -474,28 +551,74 @@ impl FrontendMessage { } /// Parse function call + /// + /// The message carries an argument *format code* array between the OID and + /// the arguments, and a result format code after them. Reading the + /// argument count where the format count sits — as this used to — misreads + /// every call a real client sends. fn parse_function_call(cursor: &mut Cursor<&[u8]>) -> ProtocolResult { let oid = cursor.get_i32(); - let num_args = cursor.get_i16(); + let num_formats = cursor.get_i16(); + if num_formats < 0 { + return Err(ProtocolError::PostgresError( + "negative format count in FunctionCall".to_string(), + )); + } + let mut arg_formats = Vec::with_capacity(num_formats as usize); + for _ in 0..num_formats { + if cursor.remaining() < 2 { + return Err(ProtocolError::PostgresError( + "Unexpected EOF in FunctionCall format codes".to_string(), + )); + } + arg_formats.push(cursor.get_i16()); + } + + let num_args = cursor.get_i16(); + if num_args < 0 { + return Err(ProtocolError::PostgresError( + "negative argument count in FunctionCall".to_string(), + )); + } let mut args = Vec::with_capacity(num_args as usize); for _ in 0..num_args { + if cursor.remaining() < 4 { + return Err(ProtocolError::PostgresError( + "Unexpected EOF in FunctionCall args".to_string(), + )); + } let arg_len = cursor.get_i32(); if arg_len == -1 { args.push(None); - } else { - let mut arg_data = vec![0u8; arg_len as usize]; - if cursor.remaining() < arg_len as usize { - return Err(ProtocolError::PostgresError( - "Unexpected EOF in FunctionCall args".to_string(), - )); - } - cursor.copy_to_slice(&mut arg_data); - args.push(Some(Bytes::from(arg_data))); + continue; + } + let wanted = usize::try_from(arg_len).map_err(|_| { + ProtocolError::PostgresError("negative argument length".to_string()) + })?; + if cursor.remaining() < wanted { + return Err(ProtocolError::PostgresError( + "Unexpected EOF in FunctionCall args".to_string(), + )); } + let mut arg_data = vec![0u8; wanted]; + cursor.copy_to_slice(&mut arg_data); + args.push(Some(Bytes::from(arg_data))); } - Ok(FrontendMessage::FunctionCall { oid, args }) + // A client that omits the trailing result format means text. + let result_format = if cursor.remaining() >= 2 { + cursor.get_i16() + } else { + 0 + }; + + Ok(FrontendMessage::FunctionCall { + oid, + args, + arg_formats, + result_format, + }) } /// Parse password or SASL response @@ -599,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; @@ -799,6 +941,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, @@ -870,6 +1025,10 @@ pub mod type_oids { pub const INT2: i32 = 21; pub const INT4: i32 = 23; pub const TEXT: i32 = 25; + pub const NUMERIC: i32 = 1700; + pub const BPCHAR: i32 = 1042; + pub const DATE: i32 = 1082; + pub const TIME: i32 = 1083; pub const FLOAT4: i32 = 700; pub const FLOAT8: i32 = 701; pub const JSON: i32 = 114; @@ -893,3 +1052,48 @@ pub mod type_oids { pub const HALFVEC: i32 = 16386; // halfvec type (half precision) pub const SPARSEVEC: i32 = 16387; // sparsevec type } + +#[cfg(test)] +mod fastpath_message_tests { + use super::*; + + #[test] + fn a_function_call_keeps_its_formats_and_result_format() { + // oid, one format code (binary), one argument of 4 bytes, result + // format binary. + let mut body = Vec::new(); + body.extend_from_slice(&7i32.to_be_bytes()); + body.extend_from_slice(&1i16.to_be_bytes()); + body.extend_from_slice(&1i16.to_be_bytes()); + body.extend_from_slice(&1i16.to_be_bytes()); + body.extend_from_slice(&4i32.to_be_bytes()); + body.extend_from_slice(&42i32.to_be_bytes()); + body.extend_from_slice(&1i16.to_be_bytes()); + + let mut framed = vec![b'F']; + framed.extend_from_slice(&((body.len() + 4) as i32).to_be_bytes()); + framed.extend_from_slice(&body); + + match FrontendMessage::parse(&mut BytesMut::from(&framed[..])) + .expect("parses") + .expect("a message") + { + FrontendMessage::FunctionCall { + oid, + args, + arg_formats, + result_format, + } => { + assert_eq!(oid, 7); + assert_eq!(arg_formats, vec![1]); + assert_eq!(args.len(), 1); + assert_eq!( + &args[0].as_ref().expect("an argument")[..], + &42i32.to_be_bytes() + ); + assert_eq!(result_format, 1, "the result format was dropped"); + } + other => panic!("expected a FunctionCall, got {other:?}"), + } + } +} diff --git a/orbit/server/src/protocols/postgres_wire/mod.rs b/orbit/server/src/protocols/postgres_wire/mod.rs index 242a81925..af9ee3314 100644 --- a/orbit/server/src/protocols/postgres_wire/mod.rs +++ b/orbit/server/src/protocols/postgres_wire/mod.rs @@ -35,15 +35,26 @@ pub mod auth; #[cfg(feature = "fts")] // pub mod fts; // Temporarily disabled - needs API update +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")] -pub mod persistent_storage; +pub mod notifications; #[cfg(feature = "storage-rocksdb")] +pub mod persistent_storage; +pub mod plpgsql; +pub mod plpgsql_function; pub mod protocol; #[cfg(feature = "storage-rocksdb")] pub mod query_engine; +pub mod sqlstate; +pub mod stored_functions; // pub mod server; // Moved to orbit_server::protocols pub mod sql; #[cfg(feature = "storage-rocksdb")] 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 000000000..8bde8ef22 --- /dev/null +++ b/orbit/server/src/protocols/postgres_wire/notifications.rs @@ -0,0 +1,250 @@ +//! 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/persistent_storage.rs b/orbit/server/src/protocols/postgres_wire/persistent_storage.rs index 058d758a8..194a8f75c 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) @@ -50,6 +127,18 @@ pub enum ColumnType { Json, // JSON data Timestamp, // Timestamp with timezone Double, // Double precision float + /// Exact decimal, with the precision and scale it was declared with. + /// + /// Without this a `NUMERIC(10,2)` column was stored as a `Double`: the + /// declared scale was lost, so `10.50` read back as `10.5`, and a value + /// that cannot be represented in binary floating point was rounded to one + /// that can. `NUMERIC` exists to avoid exactly that. + Numeric { + /// Total digits, when declared. + precision: Option, + /// Digits after the point, when declared. + scale: Option, + }, } /// Row data for a table diff --git a/orbit/server/src/protocols/postgres_wire/plpgsql.rs b/orbit/server/src/protocols/postgres_wire/plpgsql.rs new file mode 100644 index 000000000..f1df4a98c --- /dev/null +++ b/orbit/server/src/protocols/postgres_wire/plpgsql.rs @@ -0,0 +1,2303 @@ +//! PL/pgSQL: variables, conditionals, loops and `RETURN`. +//! +//! Triggers could already run a `$$BEGIN ... END$$` body, but only as a list +//! of SQL statements — there was no way to declare a variable, branch, or +//! loop. `DO $$ ... $$` and `CREATE FUNCTION ... LANGUAGE plpgsql` were worse +//! than absent: both answered "Command completed successfully" and ran +//! nothing, so a block that should have inserted a row reported success and +//! inserted nothing. +//! +//! The parser here is pure — text in, [`Block`] out — so it is tested without +//! a server. Execution is behind [`PlPgSqlHost`], which the query engine +//! implements; the interpreter never touches storage itself. +//! +//! # Expressions are evaluated by the SQL engine +//! +//! Nothing here evaluates arithmetic or comparisons. An expression is captured +//! as tokens, variable references are substituted with their values, and the +//! result is handed to the SQL engine as `SELECT `. That keeps one +//! implementation of every operator and function rather than a second one that +//! would drift from it. + +use std::collections::HashMap; + +use async_trait::async_trait; + +use crate::protocols::error::{ProtocolError, ProtocolResult}; + +/// One piece of PL/pgSQL source. +/// +/// Text is kept with its quotes so an expression can be rendered back exactly +/// as written, which is what makes variable substitution safe: a name inside a +/// string literal is a [`Tok::Str`] and is never mistaken for a reference. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Tok { + /// An identifier or keyword. + Word(String), + /// A quoted string, stored with its surrounding quotes. + Str(String), + /// A number. + Num(String), + /// Punctuation or an operator. + Sym(String), +} + +impl Tok { + /// The token as it appeared in the source. + fn text(&self) -> &str { + match self { + Tok::Word(t) | Tok::Str(t) | Tok::Num(t) | Tok::Sym(t) => t, + } + } + + /// Whether this word matches `keyword`, ignoring case. + fn is_word(&self, keyword: &str) -> bool { + matches!(self, Tok::Word(w) if w.eq_ignore_ascii_case(keyword)) + } +} + +/// Split PL/pgSQL source into tokens. +/// +/// Comments are dropped. An unterminated string is not an error here: it +/// becomes a token running to end of input, and the SQL engine rejects it with +/// a better message than this lexer could give. +#[must_use] +pub fn lex(source: &str) -> Vec { + let chars: Vec = source.chars().collect(); + let mut tokens = Vec::new(); + let mut i = 0; + + while i < chars.len() { + let c = chars[i]; + + if c.is_whitespace() { + i += 1; + } else if c == '-' && chars.get(i + 1) == Some(&'-') { + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + } else if c == '/' && chars.get(i + 1) == Some(&'*') { + i += 2; + while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '/') { + i += 1; + } + i = (i + 2).min(chars.len()); + } else if c == '\'' || c == '"' { + let quote = c; + let start = i; + i += 1; + while i < chars.len() { + if chars[i] == quote { + // A doubled quote is an escaped quote, not the end. + if chars.get(i + 1) == Some("e) { + i += 2; + continue; + } + i += 1; + break; + } + i += 1; + } + tokens.push(Tok::Str(chars[start..i].iter().collect())); + } else if c.is_ascii_digit() { + let start = i; + while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') { + // `1..10` is a range, not a decimal point. + if chars[i] == '.' && chars.get(i + 1) == Some(&'.') { + break; + } + i += 1; + } + tokens.push(Tok::Num(chars[start..i].iter().collect())); + } else if c.is_alphabetic() || c == '_' { + let start = i; + while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') { + i += 1; + } + tokens.push(Tok::Word(chars[start..i].iter().collect())); + } else { + // Two-character operators must be matched before single ones, or + // `:=` lexes as `:` then `=` and an assignment reads as a + // comparison. + let two: String = chars[i..(i + 2).min(chars.len())].iter().collect(); + if matches!(two.as_str(), ":=" | ".." | "<=" | ">=" | "<>" | "!=" | "||") { + tokens.push(Tok::Sym(two)); + i += 2; + } else { + tokens.push(Tok::Sym(c.to_string())); + i += 1; + } + } + } + + tokens +} + +/// An expression, kept as tokens so variables can be substituted precisely. +pub type Expr = Vec; + +/// Where a declaration's type comes from. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum TypeSource { + /// Written out: `INTEGER`, `TEXT`, `NUMERIC(10,2)`. + Named(String), + /// `table.column%TYPE` — whatever that column is declared as. + LikeColumn { + /// The table holding the column. + table: String, + /// The column whose type is borrowed. + column: String, + }, + /// `table%ROWTYPE` — a record shaped like one of the table's rows. + LikeRow { + /// The table whose shape is borrowed. + table: String, + }, + /// `CURSOR FOR `. + Cursor { + /// The query the cursor runs when opened. + query: Expr, + }, +} + +/// A variable declared in a `DECLARE` section. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Declaration { + /// The variable's name, folded to lower case. + pub name: String, + /// Where its type comes from. + pub sql_type: TypeSource, + /// The expression giving its initial value, if it has one. + pub default: Option, +} + +/// One PL/pgSQL statement. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Stmt { + /// `name := expr;` + Assign { + /// The variable being written. + name: String, + /// The value to write. + value: Expr, + }, + /// `IF ... THEN ... ELSIF ... ELSE ... END IF;` + If { + /// Each `(condition, body)` in order; the first true one runs. + branches: Vec<(Expr, Vec)>, + /// The `ELSE` body, empty when there is none. + otherwise: Vec, + }, + /// `WHILE cond LOOP ... END LOOP;` + While { + /// Runs while this holds. + condition: Expr, + /// The loop body. + body: Vec, + }, + /// `FOR v IN [REVERSE] low..high LOOP ... END LOOP;` + ForRange { + /// The loop variable, visible only inside the body. + name: String, + /// The low bound — still the low bound when `REVERSE` is given. + from: Expr, + /// The high bound. + to: Expr, + /// Whether to count down. + reverse: bool, + /// The loop body. + body: Vec, + }, + /// `LOOP ... END LOOP;` — exited only by `EXIT`. + Loop { + /// The loop body. + body: Vec, + }, + /// `EXIT [WHEN cond];` + Exit { + /// Leave the loop only when this holds; always when absent. + when: Option, + }, + /// `CONTINUE [WHEN cond];` + Continue { + /// Skip to the next iteration only when this holds; always when absent. + when: Option, + }, + /// `RETURN [expr];` + Return { + /// The value to return, if any. + value: Option, + }, + /// `RAISE [level] 'message';` + Raise { + /// The message, already unquoted. + message: String, + /// Whether this aborts — `EXCEPTION` does, `NOTICE` and friends do not. + aborts: bool, + }, + /// `FOR rec IN SELECT ... LOOP ... END LOOP;` + ForQuery { + /// The record variable; its columns are read as `rec.column`. + name: String, + /// The query whose rows are iterated. + query: Expr, + /// The loop body. + body: Vec, + }, + /// `RETURN NEXT expr;` — append one value to the result. + ReturnNext { + /// The value to append. + value: Expr, + }, + /// `OPEN c;` + OpenCursor { + /// The cursor to open. + name: String, + }, + /// `FETCH [NEXT FROM] c INTO var[, var...];` + Fetch { + /// The cursor to read from. + name: String, + /// Where to put the row's columns, in order. + targets: Vec, + }, + /// `CLOSE c;` + CloseCursor { + /// The cursor to close. + name: String, + }, + /// `FOR rec IN c LOOP ... END LOOP;` over an open-able cursor. + ForCursor { + /// The record variable. + name: String, + /// The cursor iterated. + cursor: String, + /// The loop body. + body: Vec, + }, + /// `RETURN QUERY SELECT ...;` — append a query's rows to the result. + ReturnQuery { + /// The query whose rows are returned. + query: Expr, + }, + /// A nested `BEGIN ... [EXCEPTION ...] END;`. + Nested { + /// The inner block. + block: Box, + }, + /// `SELECT expr INTO var;` + SelectInto { + /// The expression whose value is stored. + value: Expr, + /// The variable to store it in. + target: String, + }, + /// Any SQL statement, run for its effect. + Sql(Expr), + /// `NULL;` — does nothing, and is how an empty branch is written. + Nothing, +} + +/// An `EXCEPTION WHEN ... THEN ...` arm. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Handler { + /// The conditions it catches, upper-cased. `OTHERS` catches everything. + pub conditions: Vec, + /// What to run when it catches. + pub body: Vec, +} + +/// A parsed PL/pgSQL block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Block { + /// Variables declared before `BEGIN`. + pub declarations: Vec, + /// The statements between `BEGIN` and `END`. + pub body: Vec, + /// `EXCEPTION` arms, empty when the block has none. + pub handlers: Vec, +} + +/// Strip a dollar-quoted wrapper, if the body has one. +/// +/// `$$ ... $$` and `$tag$ ... $tag$` both carry a body through a statement +/// splitter; neither is part of the program. +#[must_use] +pub fn strip_dollar_quotes(body: &str) -> &str { + let trimmed = body.trim(); + let Some(rest) = trimmed.strip_prefix('$') else { + return trimmed; + }; + let Some(tag_end) = rest.find('$') else { + return trimmed; + }; + let tag = &rest[..tag_end]; + let opener_len = tag.len() + 2; + let closer = format!("${tag}$"); + trimmed + .get(opener_len..) + .and_then(|inner| inner.strip_suffix(&closer)) + .map_or(trimmed, str::trim) +} + +/// Whether a body needs the PL/pgSQL interpreter rather than plain SQL. +/// +/// A trigger body that is only SQL statements keeps its existing path; this +/// asks whether anything in it could not be run that way. +#[must_use] +pub fn needs_interpreter(body: &str) -> bool { + lex(strip_dollar_quotes(body)).iter().any(|token| { + [ + "DECLARE", "IF", "LOOP", "WHILE", "RETURN", "EXIT", "CONTINUE", + ] + .iter() + .any(|keyword| token.is_word(keyword)) + }) +} + +/// Parse a PL/pgSQL block. +/// +/// The body may be dollar-quoted and may begin with `DECLARE`. +/// +/// # Errors +/// Returns an error when the block is not well formed — a missing `BEGIN`, +/// an `IF` with no `END IF`, or a statement the parser does not recognise. +pub fn parse(body: &str) -> ProtocolResult { + let tokens = lex(strip_dollar_quotes(body)); + let mut parser = Parser { tokens, pos: 0 }; + parser.block() +} + +struct Parser { + tokens: Vec, + pos: usize, +} + +/// Keywords that end a statement list. +const BLOCK_ENDERS: [&str; 5] = ["END", "ELSIF", "ELSEIF", "ELSE", "EXCEPTION"]; + +fn error(message: impl Into) -> ProtocolError { + ProtocolError::PostgresError(message.into()) +} + +impl Parser { + fn peek(&self) -> Option<&Tok> { + self.tokens.get(self.pos) + } + + fn peek_is(&self, keyword: &str) -> bool { + self.peek().is_some_and(|t| t.is_word(keyword)) + } + + fn next(&mut self) -> Option { + let token = self.tokens.get(self.pos).cloned(); + if token.is_some() { + self.pos += 1; + } + token + } + + /// Consume `keyword` if it is next, reporting whether it was. + fn eat(&mut self, keyword: &str) -> bool { + if self.peek_is(keyword) { + self.pos += 1; + return true; + } + false + } + + fn eat_sym(&mut self, symbol: &str) -> bool { + if self + .peek() + .is_some_and(|t| matches!(t, Tok::Sym(s) if s == symbol)) + { + self.pos += 1; + return true; + } + false + } + + fn expect(&mut self, keyword: &str) -> ProtocolResult<()> { + if self.eat(keyword) { + return Ok(()); + } + Err(error(format!( + "expected {keyword} in PL/pgSQL block, found {}", + self.peek().map_or("end of block", Tok::text) + ))) + } + + fn expect_sym(&mut self, symbol: &str) -> ProtocolResult<()> { + if self.eat_sym(symbol) { + return Ok(()); + } + Err(error(format!( + "expected {symbol} in PL/pgSQL block, found {}", + self.peek().map_or("end of block", Tok::text) + ))) + } + + fn identifier(&mut self) -> ProtocolResult { + match self.next() { + Some(Tok::Word(name)) => Ok(name.to_lowercase()), + other => Err(error(format!( + "expected a name in PL/pgSQL block, found {}", + other.as_ref().map_or("end of block", Tok::text) + ))), + } + } + + /// Collect tokens up to — but not including — any of `stops`. + /// + /// Parentheses are tracked so a stop word inside a call, such as the `end` + /// of `date_trunc('day', end)`, does not terminate the expression early. + fn expression(&mut self, stops: &[&str]) -> Expr { + let mut depth = 0i32; + let mut collected = Vec::new(); + while let Some(token) = self.peek() { + match token { + Tok::Sym(s) if s == "(" => depth += 1, + Tok::Sym(s) if s == ")" => depth -= 1, + _ => {} + } + let stop = depth <= 0 + && stops.iter().any(|s| { + if s.chars().next().is_some_and(char::is_alphabetic) { + token.is_word(s) + } else { + matches!(token, Tok::Sym(sym) if sym == s) + } + }); + if stop { + break; + } + collected.push(token.clone()); + self.pos += 1; + } + collected + } + + fn block(&mut self) -> ProtocolResult { + let declarations = if self.eat("DECLARE") { + self.declarations()? + } else { + Vec::new() + }; + self.expect("BEGIN")?; + let body = self.statements()?; + let handlers = self.handlers()?; + self.expect("END")?; + Ok(Block { + declarations, + body, + handlers, + }) + } + + /// `EXCEPTION WHEN a OR b THEN ... WHEN OTHERS THEN ...` + fn handlers(&mut self) -> ProtocolResult> { + if !self.eat("EXCEPTION") { + return Ok(Vec::new()); + } + let mut handlers = Vec::new(); + while self.eat("WHEN") { + let mut conditions = vec![self.identifier()?.to_uppercase()]; + while self.eat("OR") { + conditions.push(self.identifier()?.to_uppercase()); + } + self.expect("THEN")?; + handlers.push(Handler { + conditions, + body: self.statements()?, + }); + } + if handlers.is_empty() { + return Err(error("EXCEPTION with no WHEN arm")); + } + Ok(handlers) + } + + fn declarations(&mut self) -> ProtocolResult> { + let mut declared = Vec::new(); + while !self.peek_is("BEGIN") { + if self.peek().is_none() { + return Err(error("PL/pgSQL block has DECLARE but no BEGIN")); + } + let name = self.identifier()?; + + // `c CURSOR FOR SELECT ...` declares a cursor rather than a value. + if self.eat("CURSOR") { + self.expect("FOR")?; + let query = self.expression(&[";"]); + self.expect_sym(";")?; + declared.push(Declaration { + name, + sql_type: TypeSource::Cursor { query }, + default: None, + }); + continue; + } + + // The type may be several words (`DOUBLE PRECISION`) or carry a + // precision (`NUMERIC(10,2)`); it is kept only to decide whether a + // value is quoted when substituted. + let type_tokens = self.expression(&[":=", ";"]); + let sql_type = Self::type_source(&type_tokens); + let default = self.eat_sym(":=").then(|| self.expression(&[";"])); + self.expect_sym(";")?; + declared.push(Declaration { + name, + sql_type, + default, + }); + } + Ok(declared) + } + + /// Read a declaration's type, which may borrow one. + /// + /// `%TYPE` and `%ROWTYPE` are resolved when the block runs, not here: the + /// table's schema is the server's to answer, and the parser has no server. + fn type_source(tokens: &[Tok]) -> TypeSource { + let percent = tokens + .iter() + .position(|t| matches!(t, Tok::Sym(s) if s == "%")); + + if let Some(at) = percent { + let borrowed = tokens.get(at + 1).map(Tok::text).unwrap_or_default(); + let path: Vec<&str> = tokens[..at] + .iter() + .filter(|t| matches!(t, Tok::Word(_))) + .map(Tok::text) + .collect(); + + if borrowed.eq_ignore_ascii_case("ROWTYPE") { + if let Some(table) = path.first() { + return TypeSource::LikeRow { + table: (*table).to_lowercase(), + }; + } + } + if borrowed.eq_ignore_ascii_case("TYPE") { + if let [table, column] = path.as_slice() { + return TypeSource::LikeColumn { + table: (*table).to_lowercase(), + column: (*column).to_lowercase(), + }; + } + } + } + + TypeSource::Named( + tokens + .iter() + .map(Tok::text) + .collect::>() + .join(" ") + .to_uppercase(), + ) + } + + fn statements(&mut self) -> ProtocolResult> { + let mut body = Vec::new(); + loop { + // A stray semicolon between statements is not an error. + while self.eat_sym(";") {} + match self.peek() { + None => return Ok(body), + Some(token) if BLOCK_ENDERS.iter().any(|k| token.is_word(k)) => return Ok(body), + Some(_) => body.push(self.statement()?), + } + } + } + + fn statement(&mut self) -> ProtocolResult { + if self.eat("IF") { + return self.if_statement(); + } + if self.eat("WHILE") { + let condition = self.expression(&["LOOP"]); + self.expect("LOOP")?; + let body = self.statements()?; + self.end_loop()?; + return Ok(Stmt::While { condition, body }); + } + if self.eat("FOR") { + return self.for_statement(); + } + if self.eat("LOOP") { + let body = self.statements()?; + self.end_loop()?; + return Ok(Stmt::Loop { body }); + } + if self.eat("EXIT") { + let when = self.exit_condition(); + self.eat_sym(";"); + return Ok(Stmt::Exit { when }); + } + if self.eat("CONTINUE") { + let when = self.exit_condition(); + self.eat_sym(";"); + return Ok(Stmt::Continue { when }); + } + if self.peek_is("BEGIN") || (self.peek_is("DECLARE") && self.nested_declare()) { + let block = self.block()?; + self.eat_sym(";"); + return Ok(Stmt::Nested { + block: Box::new(block), + }); + } + if self.eat("OPEN") { + let name = self.identifier()?; + self.eat_sym(";"); + return Ok(Stmt::OpenCursor { name }); + } + if self.eat("CLOSE") { + let name = self.identifier()?; + self.eat_sym(";"); + return Ok(Stmt::CloseCursor { name }); + } + if self.eat("FETCH") { + // `FETCH c INTO v` and `FETCH NEXT FROM c INTO v` are the same + // thing written two ways. + self.eat("NEXT"); + self.eat("FROM"); + let name = self.identifier()?; + self.expect("INTO")?; + let mut targets = vec![self.identifier()?]; + while self.eat_sym(",") { + targets.push(self.identifier()?); + } + self.eat_sym(";"); + return Ok(Stmt::Fetch { name, targets }); + } + if self.eat("RETURN") { + if self.eat("NEXT") { + let value = self.expression(&[";"]); + self.eat_sym(";"); + return Ok(Stmt::ReturnNext { value }); + } + if self.eat("QUERY") { + let query = self.expression(&[";"]); + self.eat_sym(";"); + return Ok(Stmt::ReturnQuery { query }); + } + let value = self.expression(&[";"]); + self.eat_sym(";"); + return Ok(Stmt::Return { + value: (!value.is_empty()).then_some(value), + }); + } + if self.eat("RAISE") { + return self.raise_statement(); + } + if self.peek_is("NULL") { + // Only when it stands alone: `NULL` also begins `NULL::text`. + let save = self.pos; + self.pos += 1; + if self.eat_sym(";") { + return Ok(Stmt::Nothing); + } + self.pos = save; + } + if self.eat("PERFORM") { + // `PERFORM expr` is `SELECT expr` with the result discarded. + let mut value = vec![Tok::Word("SELECT".to_string())]; + value.extend(self.expression(&[";"])); + self.eat_sym(";"); + return Ok(Stmt::Sql(value)); + } + + // `name := expr;` and `record.field := expr;` — an assignment is the + // only statement whose second or fourth token is `:=`, so a little + // lookahead tells it from a SQL statement starting with a word. + let assigns_at = |offset: usize| matches!(self.tokens.get(self.pos + offset), Some(Tok::Sym(s)) if s == ":="); + if matches!(self.peek(), Some(Tok::Word(_))) && assigns_at(1) { + let name = self.identifier()?; + self.expect_sym(":=")?; + let value = self.expression(&[";"]); + self.eat_sym(";"); + return Ok(Stmt::Assign { name, value }); + } + if matches!(self.peek(), Some(Tok::Word(_))) + && matches!(self.tokens.get(self.pos + 1), Some(Tok::Sym(s)) if s == ".") + && matches!(self.tokens.get(self.pos + 2), Some(Tok::Word(_))) + && assigns_at(3) + { + let record = self.identifier()?; + self.expect_sym(".")?; + let field = self.identifier()?; + self.expect_sym(":=")?; + let value = self.expression(&[";"]); + self.eat_sym(";"); + // Fields live in the scope under their dotted name, which is what + // `render` looks up when the field is read back. + return Ok(Stmt::Assign { + name: format!("{record}.{field}"), + value, + }); + } + + // Anything else is SQL. `SELECT ... INTO var` assigns rather than + // returning, which is how a block reads a value out of a table. + let statement = self.expression(&[";"]); + self.eat_sym(";"); + if statement.is_empty() { + return Ok(Stmt::Nothing); + } + Ok(split_select_into(statement)) + } + + /// Whether a `DECLARE` here opens a nested block rather than being a stray + /// keyword. A nested block always reaches a `BEGIN` before any `;`. + fn nested_declare(&self) -> bool { + self.tokens[self.pos..] + .iter() + .take_while(|t| !matches!(t, Tok::Sym(s) if s == ";")) + .any(|t| t.is_word("BEGIN")) + } + + /// `EXIT`/`CONTINUE` take an optional `WHEN`; a bare one always fires. + fn exit_condition(&mut self) -> Option { + self.eat("WHEN").then(|| self.expression(&[";"])) + } + + fn end_loop(&mut self) -> ProtocolResult<()> { + self.expect("END")?; + self.expect("LOOP")?; + self.eat_sym(";"); + Ok(()) + } + + fn if_statement(&mut self) -> ProtocolResult { + let mut branches = Vec::new(); + let mut otherwise = Vec::new(); + + let condition = self.expression(&["THEN"]); + self.expect("THEN")?; + branches.push((condition, self.statements()?)); + + loop { + if self.eat("ELSIF") || self.eat("ELSEIF") { + let condition = self.expression(&["THEN"]); + self.expect("THEN")?; + branches.push((condition, self.statements()?)); + continue; + } + if self.eat("ELSE") { + otherwise = self.statements()?; + } + break; + } + + self.expect("END")?; + self.expect("IF")?; + self.eat_sym(";"); + Ok(Stmt::If { + branches, + otherwise, + }) + } + + fn for_statement(&mut self) -> ProtocolResult { + let name = self.identifier()?; + self.expect("IN")?; + let reverse = self.eat("REVERSE"); + // `FOR rec IN SELECT ...` iterates a query's rows; `FOR i IN 1..10` + // counts. The keyword after IN says which. + if !reverse && self.peek_is("SELECT") { + let query = self.expression(&["LOOP"]); + self.expect("LOOP")?; + let body = self.statements()?; + self.end_loop()?; + return Ok(Stmt::ForQuery { name, query, body }); + } + // `FOR r IN c LOOP` over a declared cursor: a bare name then LOOP. + if !reverse + && matches!(self.peek(), Some(Tok::Word(_))) + && self + .tokens + .get(self.pos + 1) + .is_some_and(|t| t.is_word("LOOP")) + { + let cursor = self.identifier()?; + self.expect("LOOP")?; + let body = self.statements()?; + self.end_loop()?; + return Ok(Stmt::ForCursor { name, cursor, body }); + } + let from = self.expression(&["..", "LOOP"]); + if !self.eat_sym("..") { + return Err(error( + "a FOR loop takes an integer range (FOR i IN 1..10 LOOP) or a query \ + (FOR r IN SELECT ... LOOP)" + .to_string(), + )); + } + let to = self.expression(&["LOOP"]); + self.expect("LOOP")?; + let body = self.statements()?; + self.end_loop()?; + Ok(Stmt::ForRange { + name, + from, + to, + reverse, + body, + }) + } + + fn raise_statement(&mut self) -> ProtocolResult { + // `RAISE [level] 'message'`. Only EXCEPTION aborts; the rest are + // reports, and treating them all as aborts would turn a NOTICE into a + // failed statement. + let mut aborts = true; + if let Some(Tok::Word(word)) = self.peek() { + let level = word.to_uppercase(); + if matches!( + level.as_str(), + "EXCEPTION" | "NOTICE" | "WARNING" | "INFO" | "LOG" | "DEBUG" + ) { + aborts = level == "EXCEPTION"; + self.pos += 1; + } + } + let parts = self.expression(&[";"]); + self.eat_sym(";"); + let message = parts + .first() + .map(|token| match token { + Tok::Str(text) => unquote(text), + other => other.text().to_string(), + }) + .unwrap_or_else(|| "raised by a PL/pgSQL block".to_string()); + Ok(Stmt::Raise { message, aborts }) + } +} + +/// Turn `SELECT INTO ` into an assignment. +/// +/// Any other statement is returned unchanged. +fn split_select_into(statement: Expr) -> Stmt { + if !statement.first().is_some_and(|t| t.is_word("SELECT")) { + return Stmt::Sql(statement); + } + // The last `INTO` at paren depth zero: a subquery may contain its own. + let mut depth = 0i32; + let mut into_at = None; + for (index, token) in statement.iter().enumerate() { + match token { + Tok::Sym(s) if s == "(" => depth += 1, + Tok::Sym(s) if s == ")" => depth -= 1, + _ if depth == 0 && token.is_word("INTO") => into_at = Some(index), + _ => {} + } + } + let Some(index) = into_at else { + return Stmt::Sql(statement); + }; + // `SELECT ... INTO t` with more than one name after INTO is a row + // assignment, which is not supported; leave it as SQL so the engine + // reports it rather than silently binding the first name. + let Some([Tok::Word(target)]) = statement.get(index + 1..) else { + return Stmt::Sql(statement); + }; + Stmt::SelectInto { + value: statement[1..index].to_vec(), + target: target.to_lowercase(), + } +} + +/// Remove the quotes from a SQL string literal, undoubling escaped quotes. +fn unquote(text: &str) -> String { + let inner = text + .strip_prefix('\'') + .and_then(|t| t.strip_suffix('\'')) + .unwrap_or(text); + inner.replace("''", "'") +} + +/// A variable's current value, with enough type information to render it back +/// into SQL. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Value { + /// The value as text, or `None` for SQL `NULL`. + pub text: Option, + /// Whether it must be quoted when substituted into an expression. + pub quoted: bool, +} + +impl Value { + /// A value whose quoting is read off the value itself. + /// + /// Used where no declared type says: a `%TYPE` variable, or a row read + /// from a query. Anything that parses as a number or a boolean is left + /// bare, everything else is quoted. + #[must_use] + pub fn infer(text: Option) -> Self { + // `t` and `f` are not treated as booleans: unquoted they are + // identifiers, and a text column holding "t" would be substituted as + // one. Quoting them is wrong only for a boolean read out of a query, + // which compares correctly either way. + let quoted = text.as_deref().is_none_or(|text| { + text.parse::().is_err() + && !text.eq_ignore_ascii_case("true") + && !text.eq_ignore_ascii_case("false") + }); + Self { text, quoted } + } + + /// A value of a declared type. + #[must_use] + pub fn typed(text: Option, sql_type: &str) -> Self { + if sql_type.trim().is_empty() { + return Self::infer(text); + } + Self { + text, + quoted: needs_quoting(sql_type), + } + } + + /// A NULL of a declared type. + fn null(sql_type: &str) -> Self { + Self { + text: None, + quoted: needs_quoting(sql_type), + } + } + + /// Render for substitution into SQL. + fn render(&self) -> String { + match &self.text { + None => "NULL".to_string(), + Some(text) if self.quoted => format!("'{}'", text.replace('\'', "''")), + Some(text) => text.clone(), + } + } +} + +/// Whether a declared type's values must be quoted in SQL. +/// +/// A number written as `'1'` still compares and adds correctly in PostgreSQL, +/// but quoting a number makes `v + 1` a text concatenation in some engines, so +/// the numeric types are rendered bare. +fn needs_quoting(sql_type: &str) -> bool { + let bare = sql_type.split('(').next().unwrap_or(sql_type).trim(); + !matches!( + bare, + "INT" + | "INT2" + | "INT4" + | "INT8" + | "INTEGER" + | "SMALLINT" + | "BIGINT" + | "NUMERIC" + | "DECIMAL" + | "REAL" + | "DOUBLE" + | "DOUBLE PRECISION" + | "FLOAT" + | "FLOAT4" + | "FLOAT8" + | "BOOL" + | "BOOLEAN" + ) +} + +/// A query's columns and rows. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Rows { + /// Column names, in order. + pub columns: Vec, + /// One entry per row. + pub rows: Vec>>, +} + +/// What a block produced. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Returned { + /// It ran off its end, or returned nothing. + Nothing, + /// `RETURN expr`. + Scalar(Option), + /// One or more `RETURN QUERY`. + Rows(Rows), +} + +impl Returned { + /// The single value this stands for, if it is one. + #[must_use] + pub fn scalar(&self) -> Option { + match self { + Returned::Scalar(value) => value.clone(), + Returned::Nothing | Returned::Rows(_) => None, + } + } +} + +/// A declared cursor: the query it runs, and where it has got to. +#[derive(Debug, Clone, Default)] +pub struct Cursor { + /// The query, rendered when the cursor is opened. + pub query: Expr, + /// Rows fetched when it was opened; `None` until then. + pub rows: Option, + /// How many rows have been fetched. + pub position: usize, +} + +/// Everything a running block can see. +/// +/// Cursors cannot live in the variable map: a cursor is not a value, and +/// substituting one into SQL would be meaningless. +#[derive(Debug, Clone, Default)] +pub struct State { + /// Variables by name; a record's columns are keyed `record.column`. + pub scope: HashMap, + /// Cursors by name. + pub cursors: HashMap, +} + +impl State { + /// Start from a set of bound arguments. + #[must_use] + pub fn with_arguments(scope: HashMap) -> Self { + Self { + scope, + cursors: HashMap::new(), + } + } + + /// Record whether the last `FETCH` found a row, which `FOUND` reports. + /// + /// Spelled out 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`. + fn set_found(&mut self, found: bool) { + self.scope.insert( + "found".to_string(), + Value { + text: Some(if found { "TRUE" } else { "FALSE" }.to_string()), + quoted: false, + }, + ); + } +} + +/// What the interpreter needs from the server. +/// +/// Keeping this a trait is what lets the interpreter be tested without a +/// database: the tests below implement it over a `Vec`. +#[async_trait] +pub trait PlPgSqlHost: Send + Sync { + /// Evaluate a scalar expression, already free of variable references. + /// + /// # Errors + /// Returns whatever the SQL engine reports. + async fn evaluate(&self, expression: &str) -> ProtocolResult>; + + /// Run a statement for its effect. + /// + /// # Errors + /// Returns whatever the SQL engine reports. + async fn run(&self, sql: &str) -> ProtocolResult<()>; + + /// Run a query and return its columns and rows. + /// + /// # Errors + /// Returns whatever the SQL engine reports. + async fn query(&self, sql: &str) -> ProtocolResult; + + /// The declared type of a column, for `%TYPE`. + /// + /// Returning `None` means the column is unknown, and the variable falls + /// back to taking its quoting from whatever it is assigned. + /// + /// # Errors + /// Returns whatever the catalog lookup reports. + async fn column_type(&self, table: &str, column: &str) -> ProtocolResult>; + + /// The column names of a table, for `%ROWTYPE`. + /// + /// # Errors + /// Returns whatever the catalog lookup reports. + async fn row_columns(&self, table: &str) -> ProtocolResult>; + + /// Run a block so that a failure undoes only what the block wrote. + /// + /// This is what an `EXCEPTION` handler needs: the statements it protects + /// must leave nothing behind when one of them fails. The default runs the + /// block with no such protection and is only for hosts without + /// transactions — a real one overrides it. + /// + /// # Errors + /// Returns an error only when the rollback itself fails; a failure of the + /// block is reported as the inner `Err`. + async fn run_protected( + &self, + block: &Block, + state: State, + ) -> ProtocolResult<(Result, State)>; + + /// Report a non-aborting `RAISE`. + fn notice(&self, message: &str) { + tracing::info!(message, "PL/pgSQL notice"); + } +} + +/// How many iterations a loop may run before the interpreter gives up. +/// +/// PostgreSQL lets a loop run forever, which is the right answer for a +/// dedicated backend process. Here a statement that never finishes is a +/// statement that holds a connection and a share of the runtime, so a runaway +/// loop fails loudly instead — the same choice already made for recursive +/// CTEs. +const MAX_ITERATIONS: u64 = 10_000_000; + +/// Why a statement list stopped. +enum Flow { + /// Ran to the end. + Normal, + /// `EXIT` — leave the innermost loop. + Exit, + /// `CONTINUE` — start the innermost loop's next iteration. + Continue, + /// `RETURN` — leave the block with this value. + Return(Option), +} + +/// Rows accumulated by `RETURN QUERY` in the block currently running. +/// +/// A task-local rather than a threaded-through accumulator: `RETURN QUERY` can +/// appear inside a loop inside a nested block, and every level would otherwise +/// have to carry it. +type Collected = std::sync::Mutex; + +tokio::task_local! { + static RETURNED_ROWS: std::sync::Arc; +} + +/// Run a parsed block. +/// +/// Returns the value given to `RETURN`, or `None` if the block ran off its end. +/// +/// # Errors +/// Returns an error when an expression cannot be evaluated, a statement fails, +/// a `RAISE EXCEPTION` fires, or a loop exceeds [`MAX_ITERATIONS`]. +pub async fn execute( + block: &Block, + host: &dyn PlPgSqlHost, + arguments: HashMap, +) -> ProtocolResult { + let collected = std::sync::Arc::new(Collected::default()); + let mut state = State::with_arguments(arguments); + let outcome = RETURNED_ROWS + .scope(std::sync::Arc::clone(&collected), async { + execute_in(block, host, &mut state).await + }) + .await?; + + // `RETURN QUERY` wins over falling off the end: a set-returning function + // that appended rows returns those, not nothing. + let rows = collected + .lock() + .map(|rows| rows.clone()) + .unwrap_or_default(); + Ok(match outcome { + Returned::Nothing if !rows.rows.is_empty() || !rows.columns.is_empty() => { + Returned::Rows(rows) + } + other => other, + }) +} + +/// Run a block against an existing scope, without starting a new row +/// collector — the entry point for a nested block and for a handler. +/// +/// # Errors +/// Returns whatever the block failed with, once no handler catches it. +pub async fn execute_in( + block: &Block, + host: &dyn PlPgSqlHost, + state: &mut State, +) -> ProtocolResult { + for declaration in &block.declarations { + declare(declaration, host, state).await?; + } + + match run_statements(&block.body, host, state).await? { + Flow::Return(value) => Ok(Returned::Scalar(value)), + Flow::Normal | Flow::Exit | Flow::Continue => Ok(Returned::Nothing), + } +} + +/// Append rows to what the block is returning. +fn append_rows(fetched: &Rows) -> ProtocolResult<()> { + RETURNED_ROWS + .try_with(|collected| { + if let Ok(mut collected) = collected.lock() { + if collected.columns.is_empty() { + collected.columns = fetched.columns.clone(); + } + collected.rows.extend(fetched.rows.clone()); + } + }) + .map_err(|_| error("RETURN NEXT or RETURN QUERY outside a block")) +} + +/// Bring one declaration into scope. +/// +/// A `%TYPE` or `%ROWTYPE` is resolved here, against the server, because the +/// parser has no schema to ask. +async fn declare( + declaration: &Declaration, + host: &dyn PlPgSqlHost, + state: &mut State, +) -> ProtocolResult<()> { + let sql_type = match &declaration.sql_type { + TypeSource::Named(name) + if !crate::protocols::postgres_wire::plpgsql_function::is_builtin(name) => + { + // A name that is not a built-in may be a composite type or a + // table, either of which declares a record rather than a scalar: + // its fields come into scope as `variable.field`. + let fields = host.row_columns(name).await?; + if !fields.is_empty() { + for field in fields { + state.scope.insert( + format!("{}.{}", declaration.name, field.to_lowercase()), + Value::null(""), + ); + } + return Ok(()); + } + name.clone() + } + TypeSource::Named(name) => name.clone(), + + TypeSource::Cursor { query } => { + state.cursors.insert( + declaration.name.clone(), + Cursor { + query: query.clone(), + rows: None, + position: 0, + }, + ); + return Ok(()); + } + + TypeSource::LikeColumn { table, column } => { + // An unknown column leaves the type unknown rather than guessing: + // quoting is then read off whatever the variable is assigned. + host.column_type(table, column).await?.unwrap_or_default() + } + + TypeSource::LikeRow { table } => { + // A row variable has no value of its own; its columns appear as + // `name.column` once something assigns them. + for column in host.row_columns(table).await? { + state.scope.insert( + format!("{}.{}", declaration.name, column.to_lowercase()), + Value::null(""), + ); + } + return Ok(()); + } + }; + + let value = match &declaration.default { + None => Value::null(&sql_type), + Some(expression) => Value::typed(evaluate(host, expression, state).await?, &sql_type), + }; + state.scope.insert(declaration.name.clone(), value); + Ok(()) +} + +/// Whether a handler catches `error`. +/// +/// A named condition matches its own SQLSTATE and no other, so +/// `WHEN unique_violation` catches a duplicate key and lets a missing table +/// through. A condition name this server does not define matches nothing +/// rather than everything. +fn catches(handler: &Handler, error: &ProtocolError) -> bool { + let code = crate::protocols::postgres_wire::sqlstate::of(error); + handler.conditions.iter().any(|condition| { + crate::protocols::postgres_wire::sqlstate::condition_matches(condition, code) + }) +} + +/// Substitute variables into an expression and render it as SQL. +fn render(expression: &Expr, state: &State) -> String { + let mut parts: Vec = Vec::with_capacity(expression.len()); + let mut index = 0; + + while index < expression.len() { + // `rec.column` is one reference, not a name followed by a field: a + // record's columns are held in the scope under their dotted names. + if let (Some(Tok::Word(record)), Some(Tok::Sym(dot)), Some(Tok::Word(field))) = ( + expression.get(index), + expression.get(index + 1), + expression.get(index + 2), + ) { + if dot == "." { + let key = format!("{}.{}", record.to_lowercase(), field.to_lowercase()); + if let Some(value) = state.scope.get(&key) { + parts.push(value.render()); + index += 3; + continue; + } + } + } + parts.push(match &expression[index] { + Tok::Word(word) => state + .scope + .get(&word.to_lowercase()) + .map_or_else(|| word.clone(), Value::render), + other => other.text().to_string(), + }); + index += 1; + } + + parts.join(" ") +} + +async fn evaluate( + host: &dyn PlPgSqlHost, + expression: &Expr, + state: &State, +) -> ProtocolResult> { + host.evaluate(&render(expression, state)).await +} + +/// Whether an evaluated expression counts as true. +fn is_true(value: Option<&String>) -> bool { + value.is_some_and(|text| matches!(text.trim(), "t" | "true" | "TRUE" | "True" | "1")) +} + +async fn condition_holds( + host: &dyn PlPgSqlHost, + expression: &Expr, + state: &State, +) -> ProtocolResult { + let value = evaluate(host, expression, state).await?; + Ok(is_true(value.as_ref())) +} + +/// Run statements until one diverts control. +async fn run_statements( + body: &[Stmt], + host: &dyn PlPgSqlHost, + state: &mut State, +) -> ProtocolResult { + for statement in body { + match Box::pin(run_statement(statement, host, state)).await? { + Flow::Normal => {} + diverted => return Ok(diverted), + } + } + Ok(Flow::Normal) +} + +/// Store a value under `name`, keeping the quoting its declaration asked for. +fn assign(state: &mut State, name: &str, text: Option) { + let quoted = state.scope.get(name).is_none_or(|existing| existing.quoted); + state.scope.insert(name.to_string(), Value { text, quoted }); +} + +async fn run_statement( + statement: &Stmt, + host: &dyn PlPgSqlHost, + state: &mut State, +) -> ProtocolResult { + match statement { + Stmt::Nothing => Ok(Flow::Normal), + + Stmt::Assign { name, value } => { + let evaluated = evaluate(host, value, state).await?; + assign(state, name, evaluated); + Ok(Flow::Normal) + } + + Stmt::SelectInto { value, target } => { + let evaluated = evaluate(host, value, state).await?; + assign(state, target, evaluated); + Ok(Flow::Normal) + } + + Stmt::Sql(sql) => { + host.run(&render(sql, state)).await?; + Ok(Flow::Normal) + } + + Stmt::Raise { message, aborts } => { + if *aborts { + // `P0001` is what PostgreSQL reports for an exception raised + // by a procedure, and it is what `WHEN raise_exception` + // matches. The message cannot say so; the code has to. + return Err(ProtocolError::SqlState { + code: crate::protocols::postgres_wire::sqlstate::RAISE_EXCEPTION, + message: message.clone(), + }); + } + host.notice(message); + Ok(Flow::Normal) + } + + Stmt::Return { value } => match value { + None => Ok(Flow::Return(None)), + Some(expression) => Ok(Flow::Return(evaluate(host, expression, state).await?)), + }, + + Stmt::Exit { when } => match when { + None => Ok(Flow::Exit), + Some(condition) => Ok(if condition_holds(host, condition, state).await? { + Flow::Exit + } else { + Flow::Normal + }), + }, + + Stmt::Continue { when } => match when { + None => Ok(Flow::Continue), + Some(condition) => Ok(if condition_holds(host, condition, state).await? { + Flow::Continue + } else { + Flow::Normal + }), + }, + + Stmt::If { + branches, + otherwise, + } => { + for (condition, body) in branches { + if condition_holds(host, condition, state).await? { + return run_statements(body, host, state).await; + } + } + run_statements(otherwise, host, state).await + } + + Stmt::While { condition, body } => { + let mut iterations = 0u64; + while condition_holds(host, condition, state).await? { + iterations += 1; + if iterations > MAX_ITERATIONS { + return Err(error(format!( + "PL/pgSQL loop did not finish within {MAX_ITERATIONS} iterations" + ))); + } + match run_statements(body, host, state).await? { + Flow::Normal | Flow::Continue => {} + Flow::Exit => break, + Flow::Return(value) => return Ok(Flow::Return(value)), + } + } + Ok(Flow::Normal) + } + + Stmt::Loop { body } => { + let mut iterations = 0u64; + loop { + iterations += 1; + if iterations > MAX_ITERATIONS { + return Err(error(format!( + "PL/pgSQL loop did not finish within {MAX_ITERATIONS} iterations" + ))); + } + match run_statements(body, host, state).await? { + Flow::Normal | Flow::Continue => {} + Flow::Exit => break, + Flow::Return(value) => return Ok(Flow::Return(value)), + } + } + Ok(Flow::Normal) + } + + Stmt::ForRange { + name, + from, + to, + reverse, + body, + } => run_for_range(host, state, name, from, to, *reverse, body).await, + + Stmt::ForQuery { name, query, body } => run_for_query(host, state, name, query, body).await, + + Stmt::ReturnQuery { query } => { + let fetched = host.query(&render(query, state)).await?; + RETURNED_ROWS + .try_with(|collected| { + if let Ok(mut collected) = collected.lock() { + // The first query fixes the shape; later ones append. + if collected.columns.is_empty() { + collected.columns = fetched.columns.clone(); + } + collected.rows.extend(fetched.rows.clone()); + } + }) + .map_err(|_| error("RETURN QUERY outside a block"))?; + Ok(Flow::Normal) + } + + Stmt::ReturnNext { value } => { + let evaluated = evaluate(host, value, state).await?; + append_rows(&Rows { + columns: vec!["value".to_string()], + rows: vec![vec![evaluated]], + })?; + Ok(Flow::Normal) + } + + Stmt::OpenCursor { name } => { + let Some(cursor) = state.cursors.get(name).cloned() else { + return Err(error(format!("cursor \"{name}\" does not exist"))); + }; + let rows = host.query(&render(&cursor.query, state)).await?; + if let Some(cursor) = state.cursors.get_mut(name) { + cursor.rows = Some(rows); + cursor.position = 0; + } + Ok(Flow::Normal) + } + + Stmt::CloseCursor { name } => { + // Closing forgets the rows but keeps the declaration, so the + // cursor can be opened again — as PostgreSQL allows. + if let Some(cursor) = state.cursors.get_mut(name) { + cursor.rows = None; + cursor.position = 0; + return Ok(Flow::Normal); + } + Err(error(format!("cursor \"{name}\" does not exist"))) + } + + Stmt::Fetch { name, targets } => { + let row = { + let Some(cursor) = state.cursors.get_mut(name) else { + return Err(error(format!("cursor \"{name}\" does not exist"))); + }; + let Some(rows) = cursor.rows.as_ref() else { + return Err(error(format!("cursor \"{name}\" is not open"))); + }; + let row = rows.rows.get(cursor.position).cloned(); + if row.is_some() { + cursor.position += 1; + } + row + }; + + // `FOUND` is how a FETCH loop knows to stop. + state.set_found(row.is_some()); + if let Some(row) = row { + for (target, value) in targets.iter().zip(row) { + let quoted = state + .scope + .get(target) + .map_or_else(|| Value::infer(value.clone()).quoted, |v| v.quoted); + state.scope.insert( + target.clone(), + Value { + text: value, + quoted, + }, + ); + } + } + Ok(Flow::Normal) + } + + Stmt::ForCursor { name, cursor, body } => { + let declared = state + .cursors + .get(cursor) + .cloned() + .ok_or_else(|| error(format!("cursor \"{cursor}\" does not exist")))?; + // A cursor FOR loop opens it, walks it and closes it, so the body + // reads the same rows a FETCH loop would. + let query = declared.query.clone(); + run_for_query(host, state, name, &query, body).await + } + + Stmt::Nested { block } => { + if block.handlers.is_empty() { + return match Box::pin(execute_in(block, host, state)).await? { + Returned::Scalar(value) => Ok(Flow::Return(value)), + Returned::Nothing | Returned::Rows(_) => Ok(Flow::Normal), + }; + } + run_protected_block(block, host, state).await + } + } +} + +/// Run a block with `EXCEPTION` arms. +/// +/// The protected statements run so that a failure undoes what they wrote — +/// otherwise a caught exception would leave a half-finished write behind, +/// which is the thing a handler exists to prevent. +async fn run_protected_block( + block: &Block, + host: &dyn PlPgSqlHost, + state: &mut State, +) -> ProtocolResult { + let protected = Block { + declarations: block.declarations.clone(), + body: block.body.clone(), + handlers: Vec::new(), + }; + let (outcome, returned) = host.run_protected(&protected, state.clone()).await?; + // Variable values survive a caught exception; only database writes are + // undone. That is what PostgreSQL does. + *state = returned; + + let failure = match outcome { + Ok(Returned::Scalar(value)) => return Ok(Flow::Return(value)), + Ok(Returned::Nothing | Returned::Rows(_)) => return Ok(Flow::Normal), + Err(failure) => failure, + }; + + let Some(handler) = block + .handlers + .iter() + .find(|handler| catches(handler, &failure)) + else { + return Err(failure); + }; + + // `SQLERRM` is what a handler reads to find out what happened. It carries + // the message the block raised, not how the message reached here — a + // handler that logs it should not be logging our transport's name. + let reported = failure.to_string(); + let message = reported + .rsplit_once("error: ") + .map_or(reported.as_str(), |(_, message)| message) + .to_string(); + state.scope.insert( + "sqlerrm".to_string(), + Value { + text: Some(message), + quoted: true, + }, + ); + run_statements(&handler.body, host, state).await +} + +/// `FOR rec IN SELECT ... LOOP` — one iteration per row. +async fn run_for_query( + host: &dyn PlPgSqlHost, + state: &mut State, + name: &str, + query: &Expr, + body: &[Stmt], +) -> ProtocolResult { + let fetched = host.query(&render(query, state)).await?; + + // The record's columns shadow anything of the same dotted name and are + // removed afterwards, so the variable does not outlive its loop. + let keys: Vec = fetched + .columns + .iter() + .map(|column| format!("{}.{}", name.to_lowercase(), column.to_lowercase())) + .collect(); + + let mut flow = Flow::Normal; + for row in fetched.rows { + for (key, value) in keys.iter().zip(row.iter()) { + state.scope.insert(key.clone(), Value::infer(value.clone())); + } + // A single-column row is also readable as the bare name, which is how + // `FOR id IN SELECT id FROM t` is usually written. + if keys.len() == 1 { + state.scope.insert( + name.to_lowercase(), + Value::infer(row.first().cloned().flatten()), + ); + } + match run_statements(body, host, state).await? { + Flow::Normal | Flow::Continue => {} + Flow::Exit => break, + Flow::Return(value) => { + flow = Flow::Return(value); + break; + } + } + } + + for key in &keys { + state.scope.remove(key); + } + state.scope.remove(&name.to_lowercase()); + Ok(flow) +} + +/// Parse a bound of a `FOR` range, which must be an integer. +fn bound(value: Option, which: &str) -> ProtocolResult { + value + .as_deref() + .and_then(|text| text.trim().parse::().ok()) + .ok_or_else(|| { + error(format!( + "the {which} bound of a PL/pgSQL FOR loop must be an integer, got {}", + value.as_deref().unwrap_or("NULL") + )) + }) +} + +#[allow(clippy::too_many_arguments)] +async fn run_for_range( + host: &dyn PlPgSqlHost, + state: &mut State, + name: &str, + from: &Expr, + to: &Expr, + reverse: bool, + body: &[Stmt], +) -> ProtocolResult { + let low = bound(evaluate(host, from, state).await?, "low")?; + let high = bound(evaluate(host, to, state).await?, "high")?; + + // The loop variable shadows anything of the same name and is restored + // afterwards, as PostgreSQL scopes it to the loop. + let shadowed = state.scope.get(name).cloned(); + let counter: Box + Send> = if reverse { + Box::new((low..=high).rev()) + } else { + Box::new(low..=high) + }; + + let mut flow = Flow::Normal; + for index in counter { + state.scope.insert( + name.to_string(), + Value { + text: Some(index.to_string()), + quoted: false, + }, + ); + match run_statements(body, host, state).await? { + Flow::Normal | Flow::Continue => {} + Flow::Exit => break, + Flow::Return(value) => { + flow = Flow::Return(value); + break; + } + } + } + + match shadowed { + Some(previous) => state.scope.insert(name.to_string(), previous), + None => state.scope.remove(name), + }; + Ok(flow) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + /// A host that evaluates nothing and only records what it was asked to run. + struct Recorder { + answers: Mutex)>>, + ran: Mutex>, + notices: Mutex>, + } + + impl Recorder { + fn new(answers: &[(&str, Option<&str>)]) -> Self { + Self { + answers: Mutex::new( + answers + .iter() + .map(|(q, a)| ((*q).to_string(), a.map(str::to_string))) + .collect(), + ), + ran: Mutex::new(Vec::new()), + notices: Mutex::new(Vec::new()), + } + } + } + + #[async_trait] + impl PlPgSqlHost for Recorder { + async fn evaluate(&self, expression: &str) -> ProtocolResult> { + let answers = self.answers.lock().expect("lock"); + answers + .iter() + .find(|(q, _)| q == expression) + .map(|(_, a)| a.clone()) + .ok_or_else(|| error(format!("test host has no answer for {expression:?}"))) + } + + async fn run(&self, sql: &str) -> ProtocolResult<()> { + self.ran.lock().expect("lock").push(sql.to_string()); + Ok(()) + } + + async fn query(&self, sql: &str) -> ProtocolResult { + self.ran.lock().expect("lock").push(sql.to_string()); + let answer = self.evaluate(sql).await?; + Ok(Rows { + columns: vec!["value".to_string()], + rows: answer.into_iter().map(|v| vec![Some(v)]).collect(), + }) + } + + /// No transactions here, so nothing is undone: these tests check which + /// handler runs, not what a rollback reverses. The rollback itself is + /// checked against a live server in the conformance harness. + async fn column_type(&self, _table: &str, _column: &str) -> ProtocolResult> { + Ok(Some("INTEGER".to_string())) + } + + async fn row_columns(&self, _table: &str) -> ProtocolResult> { + Ok(vec!["id".to_string(), "name".to_string()]) + } + + async fn run_protected( + &self, + block: &Block, + state: State, + ) -> ProtocolResult<(Result, State)> { + let mut state = state; + let outcome = execute_in(block, self, &mut state).await; + Ok((outcome, state)) + } + + fn notice(&self, message: &str) { + self.notices.lock().expect("lock").push(message.to_string()); + } + } + + fn parsed(source: &str) -> Block { + parse(source).unwrap_or_else(|e| panic!("parse {source}: {e}")) + } + + #[test] + fn a_bare_block_parses() { + let block = parsed("$$ BEGIN NULL; END $$"); + assert!(block.declarations.is_empty()); + assert_eq!(block.body, vec![Stmt::Nothing]); + } + + #[test] + fn declarations_carry_their_type_and_default() { + let block = parsed("DECLARE n INTEGER := 1; s TEXT; BEGIN NULL; END"); + assert_eq!(block.declarations.len(), 2); + assert_eq!(block.declarations[0].name, "n"); + assert_eq!( + block.declarations[0].sql_type, + TypeSource::Named("INTEGER".to_string()) + ); + assert!(block.declarations[0].default.is_some()); + assert_eq!( + block.declarations[1].sql_type, + TypeSource::Named("TEXT".to_string()) + ); + assert!(block.declarations[1].default.is_none()); + } + + #[test] + fn a_multi_word_type_stays_intact() { + let block = parsed("DECLARE d DOUBLE PRECISION; BEGIN NULL; END"); + assert_eq!( + block.declarations[0].sql_type, + TypeSource::Named("DOUBLE PRECISION".to_string()) + ); + assert!(!needs_quoting("DOUBLE PRECISION")); + } + + #[test] + fn an_if_keeps_every_branch_in_order() { + let block = parsed("BEGIN IF a THEN NULL; ELSIF b THEN NULL; ELSE NULL; END IF; END"); + match &block.body[0] { + Stmt::If { + branches, + otherwise, + } => { + assert_eq!(branches.len(), 2); + assert_eq!(otherwise.len(), 1); + } + other => panic!("expected an IF, got {other:?}"), + } + } + + #[test] + fn a_for_range_records_its_direction() { + match &parsed("BEGIN FOR i IN REVERSE 1..3 LOOP NULL; END LOOP; END").body[0] { + Stmt::ForRange { name, reverse, .. } => { + assert_eq!(name, "i"); + assert!(*reverse); + } + other => panic!("expected a FOR, got {other:?}"), + } + } + + #[test] + fn select_into_becomes_an_assignment() { + match &parsed("BEGIN SELECT COUNT(*) FROM t INTO n; END").body[0] { + Stmt::SelectInto { target, .. } => assert_eq!(target, "n"), + other => panic!("expected a SELECT INTO, got {other:?}"), + } + } + + #[test] + fn a_plain_select_is_left_as_sql() { + assert!(matches!( + &parsed("BEGIN SELECT 1; END").body[0], + Stmt::Sql(_) + )); + } + + #[test] + fn raise_notice_does_not_abort_but_exception_does() { + match &parsed("BEGIN RAISE NOTICE 'hi'; END").body[0] { + Stmt::Raise { message, aborts } => { + assert_eq!(message, "hi"); + assert!(!aborts); + } + other => panic!("expected a RAISE, got {other:?}"), + } + match &parsed("BEGIN RAISE EXCEPTION 'no'; END").body[0] { + Stmt::Raise { aborts, .. } => assert!(aborts), + other => panic!("expected a RAISE, got {other:?}"), + } + } + + #[test] + fn a_keyword_inside_a_string_is_not_a_keyword() { + // 'END' here is data. If the lexer missed that, the block would end + // early and the INSERT would be dropped. + let block = parsed("BEGIN INSERT INTO t (a) VALUES ('END'); END"); + assert_eq!(block.body.len(), 1); + assert!(matches!(&block.body[0], Stmt::Sql(_))); + } + + #[test] + fn a_missing_end_if_is_an_error_not_a_silent_truncation() { + assert!(parse("BEGIN IF a THEN NULL; END").is_err()); + } + + #[test] + fn a_missing_begin_is_an_error() { + assert!(parse("DECLARE n INTEGER; NULL; END").is_err()); + } + + #[test] + fn a_comment_is_not_a_statement() { + let block = parsed("BEGIN -- nothing to see\n NULL; END"); + assert_eq!(block.body.len(), 1); + } + + #[test] + fn substitution_does_not_reach_inside_a_string() { + let scope = HashMap::from([( + "n".to_string(), + Value { + text: Some("7".to_string()), + quoted: false, + }, + )]); + let rendered = render(&lex("SELECT n, 'n'"), &State::with_arguments(scope)); + assert_eq!(rendered, "SELECT 7 , 'n'"); + } + + #[test] + fn a_text_value_is_quoted_and_escaped() { + let value = Value { + text: Some("it's".to_string()), + quoted: true, + }; + assert_eq!(value.render(), "'it''s'"); + } + + #[tokio::test] + async fn an_if_runs_only_the_true_branch() { + let host = Recorder::new(&[("1 < 2", Some("t"))]); + let block = parsed("BEGIN IF 1 < 2 THEN INSERT INTO t VALUES (1); ELSE INSERT INTO t VALUES (2); END IF; END"); + execute(&block, &host, HashMap::new()).await.expect("runs"); + assert_eq!( + *host.ran.lock().expect("lock"), + vec!["INSERT INTO t VALUES ( 1 )"] + ); + } + + #[tokio::test] + async fn a_for_loop_runs_its_body_once_per_value() { + let host = Recorder::new(&[("1", Some("1")), ("3", Some("3"))]); + let block = parsed("BEGIN FOR i IN 1..3 LOOP INSERT INTO t VALUES (i); END LOOP; END"); + execute(&block, &host, HashMap::new()).await.expect("runs"); + assert_eq!( + *host.ran.lock().expect("lock"), + vec![ + "INSERT INTO t VALUES ( 1 )", + "INSERT INTO t VALUES ( 2 )", + "INSERT INTO t VALUES ( 3 )", + ] + ); + } + + #[tokio::test] + async fn a_reverse_for_loop_counts_down() { + let host = Recorder::new(&[("1", Some("1")), ("2", Some("2"))]); + let block = + parsed("BEGIN FOR i IN REVERSE 1..2 LOOP INSERT INTO t VALUES (i); END LOOP; END"); + execute(&block, &host, HashMap::new()).await.expect("runs"); + assert_eq!( + *host.ran.lock().expect("lock"), + vec!["INSERT INTO t VALUES ( 2 )", "INSERT INTO t VALUES ( 1 )"] + ); + } + + #[tokio::test] + async fn a_loop_variable_does_not_outlive_its_loop() { + let host = Recorder::new(&[("1", Some("1")), ("2", Some("2"))]); + let block = + parsed("BEGIN FOR i IN 1..2 LOOP NULL; END LOOP; INSERT INTO t VALUES (i); END"); + execute(&block, &host, HashMap::new()).await.expect("runs"); + // `i` is gone, so it stays a bare name rather than its last value. + assert_eq!( + *host.ran.lock().expect("lock"), + vec!["INSERT INTO t VALUES ( i )"] + ); + } + + #[tokio::test] + async fn return_stops_the_block() { + let host = Recorder::new(&[("42", Some("42"))]); + let block = parsed("BEGIN RETURN 42; INSERT INTO t VALUES (1); END"); + let returned = execute(&block, &host, HashMap::new()).await.expect("runs"); + assert_eq!(returned, Returned::Scalar(Some("42".to_string()))); + assert!(host.ran.lock().expect("lock").is_empty()); + } + + #[tokio::test] + async fn exit_when_leaves_the_loop_early() { + let host = Recorder::new(&[ + ("1", Some("1")), + ("3", Some("3")), + ("1 >= 2", Some("f")), + ("2 >= 2", Some("t")), + ]); + let block = parsed( + "BEGIN FOR i IN 1..3 LOOP INSERT INTO t VALUES (i); EXIT WHEN i >= 2; END LOOP; END", + ); + execute(&block, &host, HashMap::new()).await.expect("runs"); + assert_eq!(host.ran.lock().expect("lock").len(), 2); + } + + #[tokio::test] + async fn a_while_loop_that_never_settles_fails_loudly() { + let host = Recorder::new(&[("true", Some("t"))]); + let block = parsed("BEGIN WHILE true LOOP NULL; END LOOP; END"); + let outcome = execute(&block, &host, HashMap::new()).await; + // The alternative is a statement that never returns, holding a + // connection and a share of the runtime for ever. + assert!(outcome.is_err(), "a runaway loop must not run for ever"); + } + + #[tokio::test] + async fn raise_exception_aborts_with_its_message() { + let host = Recorder::new(&[]); + let block = parsed("BEGIN RAISE EXCEPTION 'no good'; END"); + let outcome = execute(&block, &host, HashMap::new()).await; + assert!(outcome.expect_err("aborts").to_string().contains("no good")); + } + + #[tokio::test] + async fn raise_notice_reports_and_carries_on() { + let host = Recorder::new(&[]); + let block = parsed("BEGIN RAISE NOTICE 'just saying'; INSERT INTO t VALUES (1); END"); + execute(&block, &host, HashMap::new()).await.expect("runs"); + assert_eq!(*host.notices.lock().expect("lock"), vec!["just saying"]); + assert_eq!(host.ran.lock().expect("lock").len(), 1); + } + + #[tokio::test] + async fn a_declared_variable_is_substituted_into_sql() { + let host = Recorder::new(&[("5", Some("5"))]); + let block = parsed("DECLARE n INTEGER := 5; BEGIN INSERT INTO t VALUES (n); END"); + execute(&block, &host, HashMap::new()).await.expect("runs"); + assert_eq!( + *host.ran.lock().expect("lock"), + vec!["INSERT INTO t VALUES ( 5 )"] + ); + } + + #[tokio::test] + async fn an_undeclared_variable_is_left_for_sql_to_reject() { + // Substituting nothing means the engine sees the bare name and reports + // it — better than this interpreter inventing a NULL. + let host = Recorder::new(&[]); + let block = parsed("BEGIN INSERT INTO t VALUES (nope); END"); + execute(&block, &host, HashMap::new()).await.expect("runs"); + assert_eq!( + *host.ran.lock().expect("lock"), + vec!["INSERT INTO t VALUES ( nope )"] + ); + } + + #[test] + fn needs_interpreter_tells_a_plain_body_from_a_procedural_one() { + assert!(!needs_interpreter( + "$$BEGIN INSERT INTO t VALUES (1); END$$" + )); + assert!(needs_interpreter("$$DECLARE n INT; BEGIN NULL; END$$")); + assert!(needs_interpreter("$$BEGIN IF a THEN NULL; END IF; END$$")); + } + + #[test] + fn an_exception_arm_parses_with_its_conditions() { + let block = parsed("BEGIN NULL; EXCEPTION WHEN division_by_zero OR OTHERS THEN NULL; END"); + assert_eq!(block.handlers.len(), 1); + assert_eq!( + block.handlers[0].conditions, + ["DIVISION_BY_ZERO".to_string(), "OTHERS".to_string()] + ); + } + + #[test] + fn a_named_condition_alone_catches_nothing() { + // Errors here carry no SQLSTATE, so matching a named condition would + // be a promise that could not be kept. Refusing to catch is the safe + // direction: the error propagates rather than being swallowed. + let handler = Handler { + conditions: vec!["DIVISION_BY_ZERO".to_string()], + body: Vec::new(), + }; + assert!(!catches(&handler, &error("anything"))); + + let others = Handler { + conditions: vec!["OTHERS".to_string()], + body: Vec::new(), + }; + assert!(catches(&others, &error("anything"))); + } + + #[test] + fn for_over_a_query_parses_as_a_query_loop() { + match &parsed("BEGIN FOR r IN SELECT a FROM t LOOP NULL; END LOOP; END").body[0] { + Stmt::ForQuery { name, .. } => assert_eq!(name, "r"), + other => panic!("expected a query loop, got {other:?}"), + } + } + + #[test] + fn a_range_loop_is_still_a_range_loop() { + assert!(matches!( + &parsed("BEGIN FOR i IN 1..3 LOOP NULL; END LOOP; END").body[0], + Stmt::ForRange { .. } + )); + } + + #[test] + fn return_query_is_not_return() { + match &parsed("BEGIN RETURN QUERY SELECT a FROM t; END").body[0] { + Stmt::ReturnQuery { .. } => {} + other => panic!("expected RETURN QUERY, got {other:?}"), + } + assert!(matches!( + &parsed("BEGIN RETURN 1; END").body[0], + Stmt::Return { .. } + )); + } + + #[test] + fn a_nested_block_parses_as_one_statement() { + let block = parsed("BEGIN BEGIN NULL; END; NULL; END"); + assert_eq!(block.body.len(), 2); + assert!(matches!(&block.body[0], Stmt::Nested { .. })); + } + + #[test] + fn a_record_field_can_be_assigned() { + match &parsed("BEGIN a.street := 'Main'; END").body[0] { + Stmt::Assign { name, .. } => assert_eq!(name, "a.street"), + other => panic!("expected an assignment, got {other:?}"), + } + } + + #[test] + fn a_record_field_is_one_reference() { + let scope = HashMap::from([( + "r.name".to_string(), + Value { + text: Some("ada".to_string()), + quoted: true, + }, + )]); + assert_eq!( + render(&lex("SELECT r.name"), &State::with_arguments(scope)), + "SELECT 'ada'" + ); + } + + #[test] + fn an_unknown_record_field_is_left_alone() { + // `t.col` in ordinary SQL must survive untouched. + assert_eq!( + render(&lex("SELECT t.col"), &State::default()), + "SELECT t . col" + ); + } + + #[test] + fn quoting_is_inferred_when_no_type_says() { + assert!(!Value::infer(Some("42".to_string())).quoted); + assert!(!Value::infer(Some("true".to_string())).quoted); + assert!(Value::infer(Some("ada".to_string())).quoted); + } + + #[test] + fn a_declared_type_decides_quoting() { + assert!(Value::typed(Some("42".to_string()), "TEXT").quoted); + assert!(!Value::typed(Some("42".to_string()), "INTEGER").quoted); + } + + #[tokio::test] + async fn a_handler_catches_and_the_block_carries_on() { + let host = Recorder::new(&[]); + let block = parsed( + "BEGIN BEGIN RAISE EXCEPTION 'boom'; EXCEPTION WHEN OTHERS THEN INSERT INTO t VALUES (1); END; END", + ); + execute(&block, &host, HashMap::new()) + .await + .expect("the handler catches"); + assert_eq!( + *host.ran.lock().expect("lock"), + vec!["INSERT INTO t VALUES ( 1 )"] + ); + } + + #[tokio::test] + async fn an_uncaught_failure_still_propagates() { + let host = Recorder::new(&[]); + let block = parsed("BEGIN BEGIN RAISE EXCEPTION 'boom'; END; END"); + assert!(execute(&block, &host, HashMap::new()).await.is_err()); + } + + #[tokio::test] + async fn a_handler_can_read_sqlerrm() { + let host = Recorder::new(&[]); + let block = parsed( + "BEGIN BEGIN RAISE EXCEPTION 'boom'; EXCEPTION WHEN OTHERS THEN INSERT INTO t VALUES (SQLERRM); END; END", + ); + execute(&block, &host, HashMap::new()).await.expect("runs"); + let ran = host.ran.lock().expect("lock"); + assert!(ran[0].contains("boom"), "SQLERRM was not bound: {ran:?}"); + } + + #[test] + fn a_tagged_dollar_quote_is_stripped() { + assert_eq!(strip_dollar_quotes("$body$ BEGIN END $body$"), "BEGIN END"); + assert_eq!(strip_dollar_quotes("$$ BEGIN END $$"), "BEGIN END"); + assert_eq!(strip_dollar_quotes("BEGIN END"), "BEGIN END"); + } +} diff --git a/orbit/server/src/protocols/postgres_wire/plpgsql_function.rs b/orbit/server/src/protocols/postgres_wire/plpgsql_function.rs new file mode 100644 index 000000000..a46454253 --- /dev/null +++ b/orbit/server/src/protocols/postgres_wire/plpgsql_function.rs @@ -0,0 +1,854 @@ +//! A stored PL/pgSQL function's parameters: parsing, storing and resolving. +//! +//! Split out of the query engine because three separate things needed it and +//! all three got it slightly wrong when it was inline: the parameter list was +//! split on commas, so `NUMERIC(10, 2)` became two parameters; the stored form +//! was joined on commas, so reading it back split the same type again; and a +//! call was resolved by argument *count* alone, so two functions of one name +//! and arity could not coexist. + +use crate::protocols::error::{ProtocolError, ProtocolResult}; + +/// Separates parameters in the stored form. +/// +/// A control character rather than a comma: a declared type may contain a +/// comma (`NUMERIC(10, 2)`), and joining on one made the stored form +/// unreadable. +const PARAMETER_SEPARATOR: char = '\u{1}'; + +/// Separates a parameter's fields in the stored form. +const FIELD_SEPARATOR: char = '\u{2}'; + +/// How a parameter passes its value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[non_exhaustive] +pub enum Mode { + /// Passed in by the caller. The default when nothing is written. + #[default] + In, + /// Set by the body and returned to the caller. + Out, + /// Both. + InOut, +} + +impl Mode { + /// Whether a caller supplies this parameter. + #[must_use] + pub fn is_input(self) -> bool { + matches!(self, Mode::In | Mode::InOut) + } + + /// Whether this parameter is part of what the function returns. + #[must_use] + pub fn is_output(self) -> bool { + matches!(self, Mode::Out | Mode::InOut) + } + + fn as_str(self) -> &'static str { + match self { + Mode::In => "i", + Mode::Out => "o", + Mode::InOut => "b", + } + } + + fn from_str(text: &str) -> Self { + match text { + "o" => Mode::Out, + "b" => Mode::InOut, + _ => Mode::In, + } + } +} + +/// One declared parameter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Parameter { + /// Its name, folded to lower case. + pub name: String, + /// Its declared type, upper-cased, as written. + pub sql_type: String, + /// How it passes its value. + pub mode: Mode, +} + +/// What kind of thing a type is. +/// +/// PostgreSQL groups types into categories and resolves an overload within +/// one; a numeric argument never selects a string parameter however few +/// candidates remain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Category { + /// `int2`, `int4`, `int8`, `numeric`, `float4`, `float8`. + Numeric, + /// `text`, `varchar`, `char`. + String, + /// `bool`. + Boolean, + /// `date`, `time`, `timestamp`, `timestamptz`. + DateTime, + /// An array of some element type. + Array, + /// Anything else, which matches only itself. + Other, +} + +/// A declared type, reduced to the canonical name PostgreSQL uses. +/// +/// `INTEGER` and `INT4` are the same type written two ways; resolution has to +/// see them as one, and as different from `INT8`. +/// +/// A name this 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. +#[must_use] +pub fn normalize(sql_type: &str) -> String { + normalize_known(sql_type).map_or_else( + || { + sql_type + .split('(') + .next() + .unwrap_or(sql_type) + .trim() + .to_lowercase() + }, + str::to_string, + ) +} + +/// Whether this is a built-in scalar type rather than a name to look up. +/// +/// Used to avoid a catalogue lookup for every `DECLARE n INTEGER`, and to keep +/// a built-in name from being read as a composite because something else in +/// the database happens to share it. +#[must_use] +pub fn is_builtin(sql_type: &str) -> bool { + normalize_known(sql_type).is_some() || array_element(sql_type).is_some() +} + +/// The canonical name of a type this module knows, if it is one. +#[must_use] +fn normalize_known(sql_type: &str) -> Option<&'static str> { + let written = sql_type.trim(); + + // `int4[]` and `int4 ARRAY` are the same type, and an array carries its + // element type: collapsing every array to one kind made `f(int4[])` and + // `f(text[])` the same signature, so the second replaced the first. + // `_int4` is what PostgreSQL calls the array over `int4`. + if let Some(element) = array_element(written) { + return Some(match normalize(element).as_str() { + "int2" => "_int2", + "int4" => "_int4", + "int8" => "_int8", + "float4" => "_float4", + "float8" => "_float8", + "numeric" => "_numeric", + "bool" => "_bool", + "varchar" => "_varchar", + "bpchar" => "_bpchar", + "date" => "_date", + "time" => "_time", + "timestamp" => "_timestamp", + "timestamptz" => "_timestamptz", + _ => "_text", + }); + } + + let bare = written + .split('(') + .next() + .unwrap_or(written) + .trim() + .to_uppercase(); + Some(match bare.as_str() { + "INT2" | "SMALLINT" => "int2", + "INT" | "INT4" | "INTEGER" | "SERIAL" => "int4", + "INT8" | "BIGINT" | "BIGSERIAL" => "int8", + "NUMERIC" | "DECIMAL" => "numeric", + "REAL" | "FLOAT4" => "float4", + "DOUBLE" | "DOUBLE PRECISION" | "FLOAT" | "FLOAT8" => "float8", + "BOOL" | "BOOLEAN" => "bool", + "CHAR" | "CHARACTER" | "BPCHAR" => "bpchar", + "VARCHAR" | "CHARACTER VARYING" => "varchar", + "TEXT" => "text", + "DATE" => "date", + "TIME" => "time", + "TIMESTAMP" => "timestamp", + "TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" => "timestamptz", + "JSON" => "json", + "JSONB" => "jsonb", + "UUID" => "uuid", + "BYTEA" => "bytea", + "" => UNKNOWN, + // Anything else keeps its own name, decided by the caller. + _ => return None, + }) +} + +/// The element type of an array declaration, if it is one. +/// +/// `INTEGER[]` and `INTEGER ARRAY` both name an array over `INTEGER`. +#[must_use] +pub fn array_element(sql_type: &str) -> Option<&str> { + let written = sql_type.trim(); + if let Some(element) = written.strip_suffix("[]") { + return Some(element.trim()); + } + // Case-insensitively, without allocating a lowered copy of the whole type. + let upper = written.to_uppercase(); + upper + .strip_suffix(" ARRAY") + .map(|kept| written[..kept.len()].trim()) +} + +/// The type of a value nobody has assigned a type to yet. +/// +/// A quoted literal in SQL is `unknown` until context gives it a type, which +/// is what lets `f('5')` select either `f(text)` or `f(int4)` depending on +/// what exists. +pub const UNKNOWN: &str = "unknown"; + +/// Which category a canonical type belongs to. +#[must_use] +pub fn category(canonical: &str) -> Category { + match canonical { + "int2" | "int4" | "int8" | "numeric" | "float4" | "float8" => Category::Numeric, + "text" | "varchar" | "bpchar" => Category::String, + "bool" => Category::Boolean, + "date" | "time" | "timestamp" | "timestamptz" => Category::DateTime, + name if name.starts_with('_') => Category::Array, + _ => Category::Other, + } +} + +/// How wide a type is within its category. +/// +/// A value converts implicitly to a type of the same category and equal or +/// higher rank — `int4` to `int8` but not the reverse, which is what stops a +/// call losing precision without being asked. +#[must_use] +pub fn rank(canonical: &str) -> u8 { + match canonical { + "int2" => 1, + "int4" => 2, + "int8" => 3, + "numeric" => 4, + "float4" => 5, + "float8" => 6, + "bpchar" => 1, + "varchar" => 2, + "text" => 3, + "time" => 1, + "date" => 2, + "timestamp" => 3, + "timestamptz" => 4, + _ => 0, + } +} + +/// Whether this is the type its category prefers. +/// +/// PostgreSQL breaks a remaining tie towards the preferred type: `int4` among +/// the integers, `text` among the strings, `timestamptz` among the times. +#[must_use] +pub fn is_preferred(canonical: &str) -> bool { + matches!( + canonical, + "int4" | "text" | "float8" | "timestamptz" | "bool" + ) +} + +/// Whether a value of `given` may be passed where `wanted` is declared. +#[must_use] +pub fn converts_to(given: &str, wanted: &str) -> bool { + if given == UNKNOWN || wanted == UNKNOWN || given == wanted { + return true; + } + // An array converts only to the identical array type: widening an + // `int4[]` into an `int8[]` would mean rebuilding every element, which + // nothing here does. + if category(given) == Category::Array || category(wanted) == Category::Array { + return false; + } + + // Same category, and not narrowing. + category(given) == category(wanted) + && category(given) != Category::Other + && rank(given) <= rank(wanted) +} + +/// The type of an argument, from how it was written and what it evaluated to. +/// +/// A quoted literal stays [`UNKNOWN`] — that is what PostgreSQL does, and it +/// is what lets one literal fit either of two overloads. An integer literal +/// too large for `int4` is `int8`, as PostgreSQL also promotes it. +#[must_use] +pub fn argument_type(source: &str, value: Option<&str>) -> &'static str { + let written = source.trim(); + + if written.starts_with('\'') || written.starts_with('"') { + return UNKNOWN; + } + if written.eq_ignore_ascii_case("NULL") { + return UNKNOWN; + } + if written.eq_ignore_ascii_case("TRUE") || written.eq_ignore_ascii_case("FALSE") { + return "bool"; + } + if let Ok(whole) = written.parse::() { + return if i32::try_from(whole).is_ok() { + "int4" + } else { + "int8" + }; + } + if written.parse::().is_ok() { + return "numeric"; + } + + // Not a literal: fall back to what it evaluated to. An expression's type + // is the server's to know, and this only sees its printed value. + match value { + None => UNKNOWN, + Some(text) if text.parse::().is_ok() => "int4", + Some(text) if text.parse::().is_ok() => "numeric", + Some(text) + if text.eq_ignore_ascii_case("true") + || text.eq_ignore_ascii_case("false") + || matches!(text, "t" | "f") => + { + "bool" + } + Some(_) => "text", + } +} + +/// Split a declared parameter list. +/// +/// Commas inside parentheses do not separate parameters, so +/// `a NUMERIC(10, 2), b TEXT` is two parameters and not three. +#[must_use] +pub fn parse_parameters(declaration: &str) -> Vec { + split_top_level(declaration) + .into_iter() + .filter(|part| !part.trim().is_empty()) + .map(|part| parse_one(&part)) + .collect() +} + +/// Split on commas that are not inside parentheses. +fn split_top_level(text: &str) -> Vec { + let mut parts = Vec::new(); + let mut current = String::new(); + let mut depth = 0i32; + + for c in text.chars() { + match c { + '(' => { + depth += 1; + current.push(c); + } + ')' => { + depth -= 1; + current.push(c); + } + ',' if depth == 0 => { + parts.push(std::mem::take(&mut current)); + } + _ => current.push(c), + } + } + parts.push(current); + parts +} + +/// Read one parameter: `[mode] name type`. +/// +/// A parameter may also be written as just a type, with no name. It is kept +/// with an empty name; nothing can refer to it, which is what PostgreSQL also +/// gives you. +fn parse_one(declaration: &str) -> Parameter { + let mut words = declaration.split_whitespace().peekable(); + + let mode = match words.peek().map(|w| w.to_uppercase()) { + Some(word) if word == "OUT" => { + words.next(); + Mode::Out + } + Some(word) if word == "INOUT" => { + words.next(); + Mode::InOut + } + Some(word) if word == "IN" => { + words.next(); + Mode::In + } + _ => Mode::In, + }; + + let rest: Vec<&str> = words.collect(); + // One word is a bare type; two or more is a name followed by its type. + let (name, sql_type) = match rest.split_first() { + None => (String::new(), String::new()), + Some((only, [])) => (String::new(), (*only).to_uppercase()), + Some((name, tail)) => ((*name).to_lowercase(), tail.join(" ").to_uppercase()), + }; + + Parameter { + name, + sql_type, + mode, + } +} + +/// How many parameters a caller passes. +#[must_use] +pub fn input_arity(parameters: &[Parameter]) -> usize { + parameters.iter().filter(|p| p.mode.is_input()).count() +} + +/// The parameters a caller passes, in order. +#[must_use] +pub fn inputs(parameters: &[Parameter]) -> Vec<&Parameter> { + parameters.iter().filter(|p| p.mode.is_input()).collect() +} + +/// The parameters the function returns, in order. +#[must_use] +pub fn outputs(parameters: &[Parameter]) -> Vec<&Parameter> { + parameters.iter().filter(|p| p.mode.is_output()).collect() +} + +/// The input types, for the catalog key. +/// +/// Two functions of one name and arity are different functions only if this +/// differs. Canonical names rather than a coarse class, so `f(int4)` and +/// `f(int8)` are two entries and not one overwriting the other. +#[must_use] +pub fn signature(parameters: &[Parameter]) -> String { + inputs(parameters) + .iter() + .map(|p| normalize(&p.sql_type)) + .collect::>() + .join("_") +} + +/// Render parameters for the catalog. +#[must_use] +pub fn encode(parameters: &[Parameter]) -> String { + parameters + .iter() + .map(|p| { + format!( + "{}{FIELD_SEPARATOR}{}{FIELD_SEPARATOR}{}", + p.name, + p.sql_type, + p.mode.as_str() + ) + }) + .collect::>() + .join(&PARAMETER_SEPARATOR.to_string()) +} + +/// Read parameters back from the catalog. +#[must_use] +pub fn decode(stored: &str) -> Vec { + if stored.is_empty() { + return Vec::new(); + } + stored + .split(PARAMETER_SEPARATOR) + .filter(|part| !part.is_empty()) + .map(|part| { + let mut fields = part.split(FIELD_SEPARATOR); + Parameter { + name: fields.next().unwrap_or_default().to_string(), + sql_type: fields.next().unwrap_or_default().to_string(), + mode: Mode::from_str(fields.next().unwrap_or("i")), + } + }) + .collect() +} + +/// How well a candidate fits, for breaking a tie. +struct Fit { + index: usize, + /// Arguments whose type is exactly the parameter's. + exact: usize, + /// Parameters that are their category's preferred type. + preferred: usize, +} + +/// Choose which of several same-named functions a call means. +/// +/// Follows PostgreSQL's order: keep the candidates every argument can be +/// converted to, prefer the one matching most arguments exactly, then the one +/// whose parameters are their categories' preferred types. Anything still tied +/// is ambiguous. +/// +/// Returns the index of the chosen candidate. +/// +/// # Errors +/// Returns an error when no candidate accepts the arguments, or when more than +/// one does and nothing distinguishes them — the same two answers PostgreSQL +/// gives, rather than silently picking one. +pub fn resolve( + name: &str, + candidates: &[Vec], + argument_types: &[&str], +) -> ProtocolResult { + let viable: Vec = candidates + .iter() + .enumerate() + .filter_map(|(index, parameters)| { + let wanted = inputs(parameters); + if wanted.len() != argument_types.len() { + return None; + } + let declared: Vec = wanted.iter().map(|p| normalize(&p.sql_type)).collect(); + if !declared + .iter() + .zip(argument_types) + .all(|(wanted, given)| converts_to(given, wanted)) + { + return None; + } + Some(Fit { + index, + exact: declared + .iter() + .zip(argument_types) + .filter(|(wanted, given)| **given == **wanted) + .count(), + preferred: declared.iter().filter(|t| is_preferred(t)).count(), + }) + }) + .collect(); + + if viable.is_empty() { + return Err(ProtocolError::PostgresError(format!( + "function {name}({}) does not exist", + argument_types.join(", ") + ))); + } + + // Most exact matches wins. + let best_exact = viable.iter().map(|f| f.exact).max().unwrap_or(0); + let mut shortlist: Vec<&Fit> = viable.iter().filter(|f| f.exact == best_exact).collect(); + + // Then PostgreSQL's rule for untyped literals: at a position holding one, + // if any candidate takes a string there, only those candidates stay. It is + // why `f('x')` picks `f(text)` over `f(integer)` rather than being + // ambiguous — a quoted literal reads as text unless something says + // otherwise. + let unknown_at: Vec = argument_types + .iter() + .enumerate() + .filter(|(_, given)| **given == UNKNOWN) + .map(|(index, _)| index) + .collect(); + + if !unknown_at.is_empty() && shortlist.len() > 1 { + let takes_string = |fit: &&Fit| { + let wanted = inputs(&candidates[fit.index]); + unknown_at.iter().all(|position| { + wanted + .get(*position) + .is_some_and(|p| category(&normalize(&p.sql_type)) == Category::String) + }) + }; + if shortlist.iter().any(takes_string) { + shortlist.retain(takes_string); + } + } + + // Finally the category's preferred type. + let best_preferred = shortlist.iter().map(|f| f.preferred).max().unwrap_or(0); + let finalists: Vec<&&Fit> = shortlist + .iter() + .filter(|f| f.preferred == best_preferred) + .collect(); + + match finalists.as_slice() { + [only] => Ok(only.index), + _ => Err(ProtocolError::PostgresError(format!( + "function {name}({}) is not unique", + argument_types.join(", ") + ))), + } +} + +/// Check an argument against the type its parameter declares. +/// +/// Only a mismatch PostgreSQL would also refuse is refused: text that is not a +/// number, passed where a number is wanted. A numeric-looking literal is +/// accepted, because an unadorned literal in SQL has no type until it is +/// assigned one. +/// +/// # Errors +/// Returns `invalid_text_representation` when the value cannot be the declared +/// type. +pub fn check_argument(parameter: &Parameter, value: Option<&str>) -> ProtocolResult<()> { + let canonical = normalize(¶meter.sql_type); + let (Some(text), Category::Numeric) = (value, category(&canonical)) else { + return Ok(()); + }; + if text.parse::().is_ok() { + return Ok(()); + } + Err(ProtocolError::SqlState { + code: "22P02", + message: format!( + "invalid input syntax for type {}: \"{text}\"", + parameter.sql_type.to_lowercase() + ), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_type_with_a_comma_is_one_parameter() { + // Splitting on every comma made this two parameters, the second of + // them named `2)`. + let parsed = parse_parameters("a NUMERIC(10, 2), b TEXT"); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].name, "a"); + assert_eq!(parsed[0].sql_type, "NUMERIC(10, 2)"); + assert_eq!(parsed[1].name, "b"); + } + + #[test] + fn modes_are_read_and_default_to_in() { + let parsed = parse_parameters("a INTEGER, OUT b INTEGER, INOUT c TEXT, IN d TEXT"); + assert_eq!(parsed[0].mode, Mode::In); + assert_eq!(parsed[1].mode, Mode::Out); + assert_eq!(parsed[2].mode, Mode::InOut); + assert_eq!(parsed[3].mode, Mode::In); + } + + #[test] + fn only_input_parameters_are_counted_for_a_call() { + let parsed = parse_parameters("a INTEGER, OUT b INTEGER, INOUT c INTEGER"); + assert_eq!(input_arity(&parsed), 2); + assert_eq!(outputs(&parsed).len(), 2); + } + + #[test] + fn a_bare_type_has_no_name() { + let parsed = parse_parameters("INTEGER"); + assert_eq!(parsed[0].name, ""); + assert_eq!(parsed[0].sql_type, "INTEGER"); + } + + #[test] + fn an_empty_list_is_no_parameters() { + assert!(parse_parameters("").is_empty()); + assert!(parse_parameters(" ").is_empty()); + } + + #[test] + fn encoding_survives_a_type_containing_a_comma() { + let parsed = parse_parameters("a NUMERIC(10, 2), OUT b TEXT"); + let restored = decode(&encode(&parsed)); + assert_eq!(restored, parsed); + } + + #[test] + fn a_type_is_reduced_to_its_canonical_name() { + assert_eq!(normalize("INTEGER"), "int4"); + assert_eq!(normalize("INT"), "int4"); + assert_eq!(normalize("BIGINT"), "int8"); + assert_eq!(normalize("NUMERIC(10,2)"), "numeric"); + assert_eq!(normalize("VARCHAR(20)"), "varchar"); + assert_eq!(normalize("TEXT"), "text"); + assert_eq!(normalize(""), UNKNOWN); + } + + #[test] + fn an_array_carries_its_element_type() { + assert_eq!(normalize("INTEGER[]"), "_int4"); + assert_eq!(normalize("TEXT ARRAY"), "_text"); + assert_eq!(normalize("BIGINT[]"), "_int8"); + assert_eq!(category("_int4"), Category::Array); + + // Two arrays of different elements are two types, so two overloads of + // one name can take them. + assert_ne!(normalize("INTEGER[]"), normalize("TEXT[]")); + assert!(!converts_to("_int4", "_text")); + assert!(converts_to("_int4", "_int4")); + // Widening the element would mean rebuilding every entry. + assert!(!converts_to("_int4", "_int8")); + // An array never satisfies a scalar parameter, or a call would pass a + // list where a value was wanted. + assert!(!converts_to("_int4", "text")); + assert!(!converts_to("int4", "_int4")); + } + + #[test] + fn arrays_of_different_elements_are_different_signatures() { + assert_ne!( + signature(&parse_parameters("a INTEGER[]")), + signature(&parse_parameters("a TEXT[]")) + ); + } + + #[test] + fn conversion_widens_but_does_not_narrow() { + assert!(converts_to("int4", "int8")); + assert!(!converts_to("int8", "int4")); + assert!(converts_to("int4", "numeric")); + assert!(converts_to("varchar", "text")); + assert!(!converts_to("text", "varchar")); + // Categories do not mix, however few candidates remain. + assert!(!converts_to("int4", "text")); + assert!(!converts_to("bool", "int4")); + // An untyped literal fits anything. + assert!(converts_to(UNKNOWN, "int4")); + assert!(converts_to(UNKNOWN, "text")); + } + + #[test] + fn an_arguments_type_comes_from_how_it_was_written() { + assert_eq!(argument_type("42", Some("42")), "int4"); + // Too large for int4, as PostgreSQL also promotes it. + assert_eq!(argument_type("5000000000", Some("5000000000")), "int8"); + assert_eq!(argument_type("3.14", Some("3.14")), "numeric"); + assert_eq!(argument_type("TRUE", Some("t")), "bool"); + // A quoted literal has no type until context gives it one. + assert_eq!(argument_type("'5'", Some("5")), UNKNOWN); + assert_eq!(argument_type("NULL", None), UNKNOWN); + } + + #[test] + fn int4_and_int8_are_told_apart() { + // The whole point: these look identical once evaluated to text, and + // resolving on the value alone could not choose between them. + let small = parse_parameters("a INTEGER"); + let large = parse_parameters("a BIGINT"); + let candidates = vec![small, large]; + + assert_eq!(resolve("f", &candidates, &["int4"]).expect("picks"), 0); + assert_eq!(resolve("f", &candidates, &["int8"]).expect("picks"), 1); + } + + #[test] + fn varchar_and_text_are_told_apart() { + let candidates = vec![parse_parameters("a VARCHAR"), parse_parameters("a TEXT")]; + assert_eq!(resolve("f", &candidates, &["varchar"]).expect("picks"), 0); + assert_eq!(resolve("f", &candidates, &["text"]).expect("picks"), 1); + } + + #[test] + fn a_widening_call_reaches_the_only_candidate_that_fits() { + // int4 does not fit int2, so only the int8 form is viable. + let candidates = vec![parse_parameters("a SMALLINT"), parse_parameters("a BIGINT")]; + assert_eq!(resolve("f", &candidates, &["int4"]).expect("picks"), 1); + } + + #[test] + fn an_untyped_literal_prefers_the_preferred_type() { + // `f('x')` against f(varchar) and f(text): both accept an unknown, and + // PostgreSQL breaks the tie towards text. + let candidates = vec![parse_parameters("a VARCHAR"), parse_parameters("a TEXT")]; + assert_eq!(resolve("f", &candidates, &[UNKNOWN]).expect("picks"), 1); + + // Among the integers the preferred type is int4. + let integers = vec![parse_parameters("a BIGINT"), parse_parameters("a INTEGER")]; + assert_eq!(resolve("f", &integers, &[UNKNOWN]).expect("picks"), 1); + } + + #[test] + fn an_exact_match_beats_a_conversion() { + let candidates = vec![parse_parameters("a NUMERIC"), parse_parameters("a INTEGER")]; + // int4 converts to numeric, but matches int4 exactly. + assert_eq!(resolve("f", &candidates, &["int4"]).expect("picks"), 1); + } + + #[test] + fn a_call_picks_the_overload_whose_types_fit() { + let candidates = vec![parse_parameters("a INTEGER"), parse_parameters("a TEXT")]; + assert_eq!(resolve("f", &candidates, &["int4"]).expect("picks"), 0); + assert_eq!(resolve("f", &candidates, &["text"]).expect("picks"), 1); + } + + #[test] + fn arity_still_separates_overloads() { + let candidates = vec![ + parse_parameters("a INTEGER"), + parse_parameters("a INTEGER, b INTEGER"), + ]; + assert_eq!(resolve("f", &candidates, &["int4"]).expect("picks"), 0); + assert_eq!( + resolve("f", &candidates, &["int4", "int4"]).expect("picks"), + 1 + ); + } + + #[test] + fn an_untyped_literal_reads_as_text_when_a_candidate_takes_one() { + // PostgreSQL resolves an unknown literal towards the string category, + // so this is not ambiguous even though both candidates accept it. + let candidates = vec![parse_parameters("a INTEGER"), parse_parameters("a TEXT")]; + assert_eq!(resolve("f", &candidates, &[UNKNOWN]).expect("picks"), 1); + } + + #[test] + fn an_ambiguous_call_is_refused_rather_than_guessed() { + // No candidate takes a string, and both are their category's preferred + // type, so nothing chooses between them. + let candidates = vec![parse_parameters("a INTEGER"), parse_parameters("a BOOLEAN")]; + let failure = resolve("f", &candidates, &[UNKNOWN]).expect_err("ambiguous"); + assert!(failure.to_string().contains("not unique")); + } + + #[test] + fn no_candidate_is_an_error_naming_the_types() { + let candidates = vec![parse_parameters("a INTEGER")]; + let failure = + resolve("f", &candidates, &["int4", "int4"]).expect_err("no candidate takes two"); + assert!(failure.to_string().contains("does not exist")); + } + + #[test] + fn text_where_a_number_is_wanted_is_refused() { + let parameter = &parse_parameters("a INTEGER")[0]; + assert!(check_argument(parameter, Some("42")).is_ok()); + assert!(check_argument(parameter, Some("-1.5")).is_ok()); + assert!(check_argument(parameter, None).is_ok()); + + let failure = check_argument(parameter, Some("ada")).expect_err("refused"); + assert!(failure.to_string().contains("invalid input syntax")); + } + + #[test] + fn a_text_parameter_accepts_anything() { + let parameter = &parse_parameters("a TEXT")[0]; + assert!(check_argument(parameter, Some("ada")).is_ok()); + assert!(check_argument(parameter, Some("42")).is_ok()); + } + + #[test] + fn a_signature_tells_two_same_arity_functions_apart() { + assert_ne!( + signature(&parse_parameters("a INTEGER")), + signature(&parse_parameters("a TEXT")) + ); + // And two numeric widths are two functions, not one. + assert_ne!( + signature(&parse_parameters("a INTEGER")), + signature(&parse_parameters("a BIGINT")) + ); + // Output parameters are not part of what a caller passes, so they do + // not belong in the key. + assert_eq!( + signature(&parse_parameters("a INTEGER, OUT b TEXT")), + signature(&parse_parameters("a INTEGER")) + ); + } +} diff --git a/orbit/server/src/protocols/postgres_wire/protocol.rs b/orbit/server/src/protocols/postgres_wire/protocol.rs index e38e48cd9..04d9acce6 100644 --- a/orbit/server/src/protocols/postgres_wire/protocol.rs +++ b/orbit/server/src/protocols/postgres_wire/protocol.rs @@ -5,13 +5,14 @@ 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}; use crate::protocols::error::{ProtocolError, ProtocolResult}; @@ -26,6 +27,34 @@ 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>, +} + +/// A distinct backend id for each session. +/// +/// PostgreSQL gives every backend its own process id; this used to report +/// `std::process::id()`, the same value for every connection. Two things read +/// it and both were wrong: the cancel registry is keyed by it, so a map with +/// one slot meant 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. +fn next_process_id() -> i32 { + static NEXT: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(1); + // Wrapping keeps it positive: a negative id would be a valid i32 but not + // something a client would expect from a pid. + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + .rem_euclid(i32::MAX) +} + /// PostgreSQL wire protocol handler pub struct PostgresWireProtocol { state: ConnectionState, @@ -38,9 +67,180 @@ 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>, + /// 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. + /// + /// 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, + /// 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 + /// 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>, + /// 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 transaction a `Begin` has been sent for and not yet closed. + replication_open_transaction: Option, + /// Whether the subscriber asked for binary values rather than text. + replication_binary: bool, + /// Set when another connection asks to cancel this session's work. + cancelled: Option>, + /// 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. + /// + /// 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, +} + +/// An in-progress `COPY ... FROM STDIN`. +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. + 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 + /// 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 { + /// 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 @@ -65,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 { @@ -75,20 +274,51 @@ impl PostgresWireProtocol { database: None, parameters: HashMap::new(), query_engine: Arc::new(QueryEngine::new()), - process_id: std::process::id() as i32, + process_id: next_process_id(), 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(), + 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_open_transaction: None, + replication_binary: false, + cancelled: None, + 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(), + session_notifications: SessionNotifications::new(), } } /// 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_method = configured_auth_method(); let auth_manager = AuthManager::new(auth_method, user_store); Self { @@ -97,25 +327,949 @@ impl PostgresWireProtocol { database: None, parameters: HashMap::new(), query_engine, - process_id: std::process::id() as i32, + process_id: next_process_id(), 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(), + 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_open_transaction: None, + replication_binary: false, + cancelled: None, + 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(), + 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 { + 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) { + // 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() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect::() + .to_uppercase(); + + 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() { + // The subscriber's `Begin`/`Commit` pair closes here, so a + // multi-statement block arrives as one transaction rather + // than as several. + if matches!(head.as_str(), "COMMIT" | "END") { + QueryEngine::publish_transaction_end(context.id); + } + 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. + "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; + }; + + // `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); + } + 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 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}"); + 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" => { + // 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; + } + // 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); + } + // 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_for(buf, &e); + 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_for(buf, &e); + 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") { + // `0A000` is `feature_not_supported`, which is what this is. + // Reported as `XX000` a client could not tell a feature this + // server does not have from a backend that fell over. + self.send_error_for( + buf, + &ProtocolError::SqlState { + code: "0A000", + message: "physical replication is not supported; use \ + START_REPLICATION SLOT LOGICAL" + .to_string(), + }, + ); + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(buf); + return Some(Ok(())); + } + + // `(proto_version '1', binary 'true')` — the options a subscriber + // passes to the output plugin. + self.replication_binary = trimmed + .split_once('(') + .map(|(_, options)| options.to_lowercase()) + .is_some_and(|options| options.contains("binary") && options.contains("true")); + + 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, + }; + + // A named slot has to exist. Streaming from one that was dropped — + // or invalidated for falling too far behind — would look like a + // healthy subscription that silently starts from nowhere. + if let Some(name) = slot.as_ref() { + match self.query_engine.replication_slot(name).await { + Ok(Some(_)) => {} + _ => { + self.send_error( + buf, + &format!("replication slot \"{name}\" does not exist"), + ); + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(buf); + return Some(Ok(())); + } + } + } + + // 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 + /// later statement until the block is rolled back. + fn note_failure(&mut self) { + if self.transaction == TransactionState::Open { + self.transaction = TransactionState::Failed; } } /// 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) @@ -128,11 +1282,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(()) } @@ -146,7 +1308,39 @@ 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 { + // 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() => { + BackendMessage::NotificationResponse { + process_id: notification.process_id, + channel: notification.channel, + payload: notification.payload, + } + .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?; + } + } + } + }; if n == 0 { return Ok(ConnectionLoopResult::ClientDisconnected); @@ -166,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 { @@ -176,7 +1370,16 @@ impl PostgresWireProtocol { } MessageResult::Error(e) => { error!("Error handling message: {}", e); - self.send_error(write_buf, &e.to_string()); + self.send_error_for(write_buf, &e); + // 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); } } @@ -226,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, @@ -245,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, @@ -267,21 +1490,41 @@ 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)?; } 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.skip_until_sync = false; + 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 } FrontendMessage::Terminate => { + super::query_engine::forget_cancellable(self.process_id); + return Ok(false); + } + FrontendMessage::CancelRequest { + process_id, + secret_key, + } => { + // The connection carrying a cancel request is not a session: + // it sends nothing back and closes, which is what the protocol + // specifies and what stops it being used to probe for keys. + super::query_engine::request_cancel(process_id, &secret_key); return Ok(false); } FrontendMessage::SSLRequest => { @@ -291,16 +1534,39 @@ 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?; } - FrontendMessage::FunctionCall { .. } => { - // Function call support is minimal/stubbed + FrontendMessage::FunctionCall { + oid, + args, + arg_formats, + result_format, + } => { + self.handle_function_call(oid, &args, &arg_formats, result_format, buf) + .await?; + } + FrontendMessage::CopyData { data } => { + // 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::CopyData { .. } - | FrontendMessage::CopyDone - | FrontendMessage::CopyFail { .. } => { - // Copy protocol not fully supported by server yet + 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); } } @@ -355,6 +1621,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; @@ -380,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 { @@ -389,12 +1663,73 @@ impl PostgresWireProtocol { Ok(()) } - /// Finish authentication and unblock connection - fn finish_authentication(&mut self, buf: &mut BytesMut) { + /// 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 + /// before the first non-numeric character, so anything else has to follow. + const SERVER_VERSION: &'static str = "14.0 (Orbit-RS Protocol Adapter)"; + + /// The settings a session starts with, reported by `SHOW`. + fn advertised_parameters() -> [(&'static str, &'static str); 6] { + [ + ("server_version", Self::SERVER_VERSION), + ("server_encoding", "UTF8"), + ("client_encoding", "UTF8"), + ("DateStyle", "ISO, MDY"), + ("integer_datetimes", "on"), + ("standard_conforming_strings", "on"), + ] + } + + /// Finish authentication and unblock connection + fn finish_authentication(&mut self, buf: &mut BytesMut) { + // Whatever is advertised here is also what `SHOW` must answer. They + // came from different places, so the server told a client one thing at + // connect and another when asked: `SHOW server_version` returned an + // empty string while `ParameterStatus` carried a version, and a driver + // reading the empty one cannot tell what it is talking to. + for (name, value) in Self::advertised_parameters() { + self.parameters.insert(name.to_string(), value.to_string()); + } + // Send parameter status BackendMessage::ParameterStatus { name: "server_version".to_string(), - value: "14.0 (Orbit-RS Protocol Adapter)".to_string(), + value: Self::SERVER_VERSION.to_string(), } .encode(buf); @@ -410,7 +1745,13 @@ impl PostgresWireProtocol { } .encode(buf); - // Send backend key data (PostgreSQL 18: supports variable-length keys) + // Send backend key data (PostgreSQL 18: supports variable-length keys). + // The same key registers the session, so a cancel arriving on another + // connection can find it. + self.cancelled = Some(super::query_engine::register_cancellable( + self.process_id, + self.secret_key.clone(), + )); BackendMessage::BackendKeyData { process_id: self.process_id, secret_key: self.secret_key.clone(), @@ -445,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, @@ -543,34 +1981,211 @@ 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(()); } + // 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) { + // `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, + &ProtocolError::SqlState { + code: "25P02", + message: "current transaction is aborted, commands ignored until end of \ + transaction block" + .to_string(), + }, + ); + 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_for(buf, &e); + 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 run = async { + 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, + } + }; + // The session's cancel flag travels with the statement, so the engine + // can check it between the statements of one message. + let executed = match self.cancelled.clone() { + Some(flag) => super::query_engine::with_cancel(flag, run).await, + None => run.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_for(buf, &e); + 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); BackendMessage::ReadyForQuery { - status: TransactionStatus::Idle, + status: self.transaction_status(), } .encode(buf); } Err(e) => { - self.send_error(buf, &e.to_string()); + self.send_error_for(buf, &e); + self.note_failure(); BackendMessage::ReadyForQuery { - status: TransactionStatus::Idle, + status: self.transaction_status(), } .encode(buf); } @@ -580,11 +2195,11 @@ impl PostgresWireProtocol { } /// Handle parse message (prepared statement) - fn handle_parse( + async fn handle_parse( &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,37 +2207,331 @@ 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, 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), 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 + .insert(statement_name.to_string(), param_types); + BackendMessage::ParseComplete.encode(buf); 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 + // 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 + ) + } + + /// 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 + /// 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, + 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. + 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(()); + } + } + } + // 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(), 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], + param_types: &[i32], + ) -> 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" + )) + })?; + + 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('\''); + } + } + } + + index = end; + } + + Ok(out) + } + /// Handle execute message 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(); + self.send_portal_page(portal, &columns, &rows, already_sent, max_rows, buf) + .await; + return Ok(()); + } let (statement_name, params) = self .portals @@ -636,16 +2545,80 @@ 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 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_for(buf, &e); + return Ok(()); + } + }; + + // 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) + .await + .is_some() + { + return Ok(()); + } + + let bound_for_state = bound.clone(); + let portal_name = portal.to_string(); + // 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 self.query_engine.execute_query(query).await { + match executed { + 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) + .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); + self.note_statement(&bound_for_state); } Err(e) => { - self.send_error(buf, &e.to_string()); + self.send_error_for(buf, &e); + self.note_failure(); } } @@ -653,17 +2626,112 @@ 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 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 + .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(()) } @@ -682,6 +2750,8 @@ impl PostgresWireProtocol { } super::messages::CloseTarget::Portal => { self.portals.remove(name); + self.portal_rows.remove(name); + self.portal_result_formats.remove(name); } } @@ -691,78 +2761,1119 @@ 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); + } - for row in rows { - let values: Vec> = row - .iter() - .map(|v| v.as_ref().map(|s| bytes::Bytes::from(s.clone()))) - .collect(); - BackendMessage::DataRow { values }.encode(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(), ""), + }; - BackendMessage::CommandComplete { - tag: format!("SELECT {}", rows.len()), - } - .encode(buf); + 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()) } - QueryResult::Insert { count } => { - BackendMessage::CommandComplete { - tag: format!("INSERT 0 {count}"), - } - .encode(buf); + "UNLISTEN" => { + let channel = (rest != "*" && !rest.is_empty()).then_some(rest); + self.notifications + .unlisten(channel, self.session_notifications.id) + .await; + Some("UNLISTEN".to_string()) } - QueryResult::Update { count } => { - BackendMessage::CommandComplete { - tag: format!("UPDATE {count}"), - } - .encode(buf); + "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()) } - QueryResult::Delete { count } => { - BackendMessage::CommandComplete { - tag: format!("DELETE {count}"), + _ => 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); + } + } + + /// 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); } - .encode(buf); } - QueryResult::Merge { - count, - rows, - columns, - } => { - // If rows are present (RETURNING clause), we need to send RowDescription and DataRow - if !rows.is_empty() { - let fields: Vec = columns - .iter() - .map(|col| FieldDescription { - name: col.clone(), + // `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 if change.action == "COMMIT" { + // The JSON plugin has no transaction framing, so a marker carries + // nothing a subscriber could use. + return; + } 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(); + + // A `COMMIT` marker closes the pair a block opened. + if change.action == "COMMIT" { + out.put_u8(b'C'); + out.put_u8(0); + out.put_u64(change.position); + out.put_u64(change.position); + out.put_i64(0); + self.replication_open_transaction = None; + return out.to_vec(); + } + + // Begin once per transaction: a block's statements belong to one. + let grouped = change.transaction != 0 + && self.replication_open_transaction == Some(change.transaction); + if !grouped { + out.put_u8(b'B'); + out.put_u64(change.position); + out.put_i64(0); + out.put_i32(change.transaction as i32); + self.replication_open_transaction = Some(change.transaction); + } + + // 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 binary = self.replication_binary; + let tuple = + move |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 => Self::put_replication_value(out, other, binary), + } + } + }; + + 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); + let binary = self.replication_binary; + for value in row.values() { + Self::put_replication_value(&mut out, value, binary); + } + } + _ => {} + } + + // A statement outside a block is its own transaction, so it commits + // straight away; one inside a block waits for the marker. + if change.transaction == 0 { + out.put_u8(b'C'); + out.put_u8(0); + out.put_u64(change.position); + out.put_u64(change.position); + out.put_i64(0); + self.replication_open_transaction = None; + } + + out.to_vec() + } + + /// Write one column value into a `pgoutput` tuple. + /// + /// `t` is the text form the protocol defaults to; `b` is the binary form a + /// subscriber gets when it asks for it, which for a number is the network + /// byte order PostgreSQL sends rather than its decimal spelling. + fn put_replication_value(out: &mut BytesMut, value: &serde_json::Value, binary: bool) { + if binary { + if let Some(number) = value.as_i64() { + out.put_u8(b'b'); + out.put_i32(8); + out.put_i64(number); + return; + } + if let Some(number) = value.as_f64() { + out.put_u8(b'b'); + out.put_i32(8); + out.put_f64(number); + return; + } + if let Some(flag) = value.as_bool() { + out.put_u8(b'b'); + out.put_i32(1); + out.put_u8(u8::from(flag)); + return; + } + } + + let text = value + .as_str() + .map(str::to_string) + .unwrap_or_else(|| value.to_string()); + out.put_u8(if binary { b'b' } else { b't' }); + out.put_i32(text.len() as i32); + out.extend_from_slice(text.as_bytes()); + } + + /// 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. + /// + /// 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(); + 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(); + 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, binary, csv, buf) + .await; + self.finish_copy_statement(send_ready, buf); + } else { + self.begin_copy_from_stdin(&table, columns, binary, csv, 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. + /// 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() { + "*".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_for(buf, &e); + return; + } + }; + + BackendMessage::CopyOutResponse { + 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 = 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")), + } + .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, + binary: bool, + csv: bool, + 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_for(buf, &e); + 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(); + + // 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: i8::from(binary), + column_formats: vec![i16::from(binary); columns.len()], + } + .encode(buf); + + self.copy_in = Some(CopyInState { + binary, + csv, + 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(), + rows: 0, + failure: None, + }); + } + + /// 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, + ) -> 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 = { + 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(()); + }; + + // 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)| { + 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 = 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 + // 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(", ") + ); + + // 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() { + 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. + async 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(); + + // 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() + .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); + } + } + + /// 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. + /// 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 + /// 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() + .map(|v| v.as_ref().map(|s| bytes::Bytes::from(s.clone()))) + .collect(); + BackendMessage::DataRow { values }.encode(buf); + } + + BackendMessage::CommandComplete { + tag: format!("SELECT {}", rows.len()), + } + .encode(buf); + } + QueryResult::Insert { count } => { + BackendMessage::CommandComplete { + tag: format!("INSERT 0 {count}"), + } + .encode(buf); + } + QueryResult::Update { count } => { + BackendMessage::CommandComplete { + tag: format!("UPDATE {count}"), + } + .encode(buf); + } + QueryResult::Delete { count } => { + BackendMessage::CommandComplete { + tag: format!("DELETE {count}"), + } + .encode(buf); + } + QueryResult::Merge { + count, + rows, + columns, + } => { + // If rows are present (RETURNING clause), we need to send RowDescription and DataRow + if !rows.is_empty() { + let fields: Vec = columns + .iter() + .map(|col| FieldDescription { + name: col.clone(), table_oid: 0, column_id: 0, type_oid: type_oids::TEXT, // Default to TEXT @@ -797,19 +3908,158 @@ impl PostgresWireProtocol { } } + /// Run a legacy fast-path function call. + /// + /// The OID names a function in `pg_proc`; this server publishes its own + /// there with OIDs in PostgreSQL's user range, so a client that looks one + /// up can call it this way. An OID it did not publish is refused by + /// number, because guessing which built-in a number meant would have the + /// client silently calling something else. + async fn handle_function_call( + &mut self, + oid: i32, + args: &[Option], + arg_formats: &[i16], + result_format: i16, + buf: &mut BytesMut, + ) -> ProtocolResult<()> { + let found = self.query_engine.function_for_oid(i64::from(oid)).await?; + + let Some((name, parameters, return_type)) = found else { + self.send_error( + buf, + &format!( + "function with OID {oid} does not exist; \ + look it up in pg_proc or call it from a query" + ), + ); + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(buf); + return Ok(()); + }; + + // Arguments arrive as bytes in whichever format the client chose. + // Their declared types come from the same catalogue entry the client + // read the OID from, which is what makes decoding a binary one + // possible rather than a guess. + let inputs = super::plpgsql_function::inputs(¶meters); + let mut rendered = Vec::with_capacity(args.len()); + for (index, arg) in args.iter().enumerate() { + let declared = inputs + .get(index) + .map_or("", |parameter| parameter.sql_type.as_str()); + match super::fastpath::decode_argument( + arg.as_deref(), + Self::format_at(arg_formats, index), + declared, + ) { + Ok(value) => rendered.push(value), + Err(e) => { + self.send_error_for(buf, &e); + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(buf); + return Ok(()); + } + } + } + + let call = format!("SELECT {name}({})", rendered.join(", ")); + match self.query_engine.execute_query(&call).await { + Ok(super::query_engine::QueryResult::Select { rows, .. }) => { + let value = rows + .into_iter() + .next() + .and_then(|row| row.into_iter().next()) + .flatten(); + match super::fastpath::encode_result(value, result_format, &return_type) { + Ok(val) => BackendMessage::FunctionCallResponse { val }.encode(buf), + Err(e) => self.send_error_for(buf, &e), + } + } + Ok(_) => { + BackendMessage::FunctionCallResponse { val: None }.encode(buf); + } + Err(e) => { + self.send_error_for(buf, &e); + } + } + + BackendMessage::ReadyForQuery { + status: self.transaction_status(), + } + .encode(buf); + Ok(()) + } + + /// The format code that applies to argument `index`. + /// + /// None means every argument is text; one means it applies to all of them; + /// otherwise there is one per argument. Reading the array as one-per- + /// argument regardless would misread the common single-code case. + fn format_at(formats: &[i16], index: usize) -> i16 { + match formats { + [] => 0, + [only] => *only, + many => many.get(index).copied().unwrap_or(0), + } + } + /// 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(()) } + /// Report an error under the SQLSTATE it carries. + /// + /// 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(&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 + .split_once("PostgreSQL protocol error: ") + .map_or(reported.as_str(), |(_, rest)| rest); + + let mut fields = HashMap::new(); + fields.insert(b'S', "ERROR".to_string()); + fields.insert(b'C', code.to_string()); + fields.insert(b'M', reported.to_string()); + BackendMessage::ErrorResponse { fields }.encode(buf); + } + /// Send error response - fn send_error(&self, buf: &mut BytesMut, message: &str) { + /// + /// 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(&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 + .split_once("PostgreSQL protocol error: ") + .map_or(message, |(_, rest)| rest); + let mut fields = HashMap::new(); fields.insert(b'S', "ERROR".to_string()); - fields.insert(b'C', "XX000".to_string()); // Internal error - fields.insert(b'M', message.to_string()); + fields.insert(b'C', super::sqlstate::classify(reported).to_string()); + fields.insert(b'M', reported.to_string()); BackendMessage::ErrorResponse { fields }.encode(buf); } @@ -822,7 +4072,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 @@ -833,3 +4083,285 @@ 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()); + } + + #[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 202b2c23e..e62fd79ea 100644 --- a/orbit/server/src/protocols/postgres_wire/query_engine.rs +++ b/orbit/server/src/protocols/postgres_wire/query_engine.rs @@ -11,1508 +11,9808 @@ 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::types::SqlValue; use crate::protocols::postgres_wire::sql::{ConfigurableSqlEngine, UnifiedExecutionResult}; use crate::protocols::postgres_wire::vector_engine::VectorQueryEngine; use orbit_client::OrbitClient; -/// Query result types -#[derive(Debug, Clone)] -pub enum QueryResult { - Select { - columns: Vec, - rows: Vec>>, - }, - Insert { - count: usize, - }, - Update { - count: usize, - }, - Delete { - count: usize, - }, - Set { - variable: String, - value: String, - }, - Merge { - count: usize, - rows: Vec>>, - columns: Vec, - }, +/// 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, } -/// Parsed SQL statement -#[derive(Debug, Clone)] -enum Statement { - Select { - columns: Vec, - table: String, - where_clause: Option, - }, - Insert { - table: String, - columns: Vec, - values: Vec>, - }, - Update { - table: String, - set_clauses: Vec<(String, String)>, - where_clause: Option, - }, - Delete { - table: String, - where_clause: Option, - }, - CreateTable { - table: String, - columns: Vec, - if_not_exists: bool, - }, - DropTable { - table: String, - if_exists: bool, - }, +/// 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, } -/// Simple column definition for basic DDL support -#[derive(Debug, Clone)] -struct SimpleColumnDef { - name: String, - data_type: String, - constraints: Vec, +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, + } + } } -#[derive(Debug, Clone)] -struct WhereClause { - conditions: Vec, +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() + } } -#[derive(Debug, Clone)] -struct Condition { - column: String, - operator: String, - value: String, +/// 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; + +/// The type OID a declared type maps to. +/// +/// Anything outside the set this server implements reports `text`, which is +/// also how it is stored and compared. +#[must_use] +pub fn type_oid_for(sql_type: &str) -> i32 { + use super::messages::type_oids; + + // An array has its own OID per element type, so it is read before the + // lattice collapses every array to one kind. + let written = sql_type.trim(); + if let Some(element) = written.strip_suffix("[]").or_else(|| { + written + .to_uppercase() + .strip_suffix(" ARRAY") + .map(|_| written[..written.len() - " ARRAY".len()].trim()) + }) { + return match plpgsql_function::normalize(element).as_str() { + "int2" => 1005, + "int4" => 1007, + "int8" => 1016, + "float4" => 1021, + "float8" => 1022, + "numeric" => 1231, + "bool" => 1000, + "varchar" => 1015, + _ => 1009, + }; + } + + match plpgsql_function::normalize(sql_type).as_str() { + "int2" => type_oids::INT2, + "int4" => type_oids::INT4, + "int8" => type_oids::INT8, + "float4" => type_oids::FLOAT4, + "float8" => type_oids::FLOAT8, + "numeric" => type_oids::NUMERIC, + "bool" => type_oids::BOOL, + "varchar" => type_oids::VARCHAR, + "bpchar" => type_oids::BPCHAR, + "date" => type_oids::DATE, + "time" => type_oids::TIME, + "timestamp" => type_oids::TIMESTAMP, + "timestamptz" => type_oids::TIMESTAMPTZ, + _ => type_oids::TEXT, + } } -/// In-memory actor storage for demonstration -/// In production, this would use OrbitClient -#[derive(Debug, Clone)] -struct ActorRecord { - actor_id: String, - actor_type: String, - state: JsonValue, +/// 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() + }, + ) } -/// Query engine that translates SQL to actor operations -pub struct QueryEngine { - // In-memory storage for demonstration (legacy actor operations) - // TODO: Replace with OrbitClient integration - actors: Arc>>, - // Optional persistent table storage for regular SQL tables - persistent_storage: Option>, - // Optional vector query engine for pgvector compatibility - vector_engine: Option, - // Optional GraphRAG query engine - graphrag_engine: Option, - // Comprehensive SQL engine for DDL and other advanced operations - sql_engine: Arc>, - // Current database context - current_database: Arc>, +/// The type a domain definition leads with. +/// +/// A definition is the base type followed by whatever constraints were +/// declared: `INTEGER CHECK (VALUE > 0)`. Only the leading type says what it is +/// built on — taking the whole string left the type lattice reading +/// `INTEGER CHECK (VALUE > 0)` as text. +#[must_use] +pub fn leading_type(definition: &str) -> String { + let upper = definition.to_uppercase(); + let end = ["CHECK", "NOT NULL", "DEFAULT", "CONSTRAINT", "|"] + .iter() + .filter_map(|keyword| upper.find(keyword)) + .min() + .unwrap_or(definition.len()); + definition[..end].trim().to_string() } -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(); - Self { - actors: Arc::new(RwLock::new(HashMap::new())), - persistent_storage: None, - vector_engine: None, - graphrag_engine: None, - sql_engine: Arc::new(Mutex::new(ConfigurableSqlEngine::new())), - current_database: Arc::new(RwLock::new("actors".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 + } } } +} - /// 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(); - Self { - actors: Arc::new(RwLock::new(HashMap::new())), - persistent_storage: Some(storage), - vector_engine: None, - graphrag_engine: None, - sql_engine: Arc::new(Mutex::new(ConfigurableSqlEngine::new())), - current_database: Arc::new(RwLock::new("actors".to_string())), +/// 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(); } - /// Create a new query engine with vector support - pub fn new_with_vector_support(orbit_client: OrbitClient) -> Self { - // Since OrbitClient doesn't implement Clone, we need to create separate instances - // For now, we'll create the GraphRAG engine in placeholder mode - // This needs to be fixed when OrbitClient supports cloning or sharing - Self { - actors: Arc::new(RwLock::new(HashMap::new())), - persistent_storage: None, - vector_engine: Some(VectorQueryEngine::new(orbit_client)), - graphrag_engine: Some(GraphRAGQueryEngine::new_placeholder()), - sql_engine: Arc::new(Mutex::new(ConfigurableSqlEngine::new())), - current_database: Arc::new(RwLock::new("actors".to_string())), + 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 +} - /// Create a new query engine with both persistent storage and vector support - pub fn new_with_persistent_and_vector_support( - storage: Arc, - orbit_client: OrbitClient, - ) -> Self { - Self { - actors: Arc::new(RwLock::new(HashMap::new())), - persistent_storage: Some(storage), - vector_engine: Some(VectorQueryEngine::new(orbit_client)), - graphrag_engine: Some(GraphRAGQueryEngine::new_placeholder()), - sql_engine: Arc::new(Mutex::new(ConfigurableSqlEngine::new())), - current_database: Arc::new(RwLock::new("actors".to_string())), +/// 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 +/// literal in it does not. +#[must_use] +fn is_simple_column(word: &str) -> bool { + let bare = word.trim_matches('"'); + !bare.is_empty() + && bare + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '.') + && !bare.chars().next().is_some_and(|c| c.is_ascii_digit()) +} + +/// Whether the right-hand side is something a stored condition can carry. +/// +/// The column and operator were checked but not this, so +/// `WHERE id = ANY(ARRAY[1,3])` was stored as `id = 'ANY(ARRAY[1,3])'` and +/// matched nothing — the same silent wrong answer as an unhandled operator, +/// arriving from the other side of the comparison. +#[must_use] +fn is_simple_value(operator: &str, value: &str) -> bool { + let trimmed = value.trim(); + if trimmed.is_empty() { + return false; + } + match operator.to_uppercase().as_str() { + // `IS NULL` / `IS NOT NULL`, and nothing else. + "IS" => matches!( + trimmed.to_uppercase().as_str(), + "NULL" | "NOT NULL" | "TRUE" | "FALSE" | "NOT TRUE" | "NOT FALSE" + ), + // A parenthesised list of literals. + "IN" | "NOT" => { + trimmed.starts_with('(') + && trimmed.ends_with(')') + && trimmed[1..trimmed.len() - 1].split(',').all(is_literal) } + _ => is_literal(trimmed), } +} - /// Set the current database context - pub async fn set_current_database(&self, database: &str) { - let mut current_db = self.current_database.write().await; - *current_db = database.to_string(); - - // Also update the SQL executor's current database - let mut sql_engine = self.sql_engine.lock().await; - sql_engine.set_current_database(database).await; +/// Whether a token is a literal rather than an expression. +#[must_use] +fn is_literal(value: &str) -> bool { + let trimmed = value.trim(); + if trimmed.len() > 1 && trimmed.starts_with('\'') && trimmed.ends_with('\'') { + // A quoted string, provided the quotes are the only ones in it: a + // value like `'a' || 'b'` is an expression. + return !trimmed[1..trimmed.len() - 1].contains('\''); } + matches!( + trimmed.to_uppercase().as_str(), + "NULL" | "TRUE" | "FALSE" | "DEFAULT" + ) || trimmed.parse::().is_ok() +} - /// Get the current database name - pub async fn get_current_database(&self) -> String { - let db = self.current_database.read().await; - db.clone() +/// Whether a word is a comparison this parser's conditions can carry. +#[must_use] +fn is_comparison(word: &str) -> bool { + matches!( + word.to_uppercase().as_str(), + "=" | "==" | "!=" | "<>" | "<" | "<=" | ">" | ">=" | "IS" | "LIKE" | "ILIKE" | "IN" | "NOT" + ) +} + +/// A stable OID for a composite type, in the same user range as a function's. +#[must_use] +pub fn composite_oid(name: &str) -> i64 { + function_oid(&format!("composite:{name}")) +} + +/// A stable OID for a stored function, in PostgreSQL's user-object range. +/// +/// Derived from the catalog key rather than from position, so it 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. +#[must_use] +pub fn function_oid(key: &str) -> i64 { + // FNV-1a: small, stable, and not sensitive to the order keys arrive in. + let hash = key.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3) + }); + let span = (i32::MAX as u64) - (FIRST_USER_OID as u64); + FIRST_USER_OID + (hash % span) as i64 +} + +/// 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(), } +} - /// Execute a SQL query and return results - pub async fn execute_query(&self, sql: &str) -> ProtocolResult { - let sql_upper = sql.trim().to_uppercase(); +/// Table holding view definitions. +/// +/// Prefixed so it cannot collide with a user table named `views`. +use super::plpgsql; +use super::plpgsql_function; + +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(',') +} - // Check if this is a GraphRAG function query - if self.is_graphrag_query(&sql_upper) { - if let Some(ref graphrag_engine) = self.graphrag_engine { - return graphrag_engine.execute_graphrag_query(sql).await; - } else { - return Err(ProtocolError::PostgresError( - "GraphRAG support not enabled. Use new_with_vector_support() to enable GraphRAG functions.".to_string() - )); +/// 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. +pub(crate) 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; } - - // Check if this is a vector-related query - if let Some(ref vector_engine) = self.vector_engine { - if self.is_vector_query(&sql_upper) { - return vector_engine.execute_vector_query(sql).await; + 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(); } - } - - // Try to parse and execute with the simple parser first - // For unsupported statements, fall back to the comprehensive SQL engine - let statement = match self.parse_sql(sql) { - Ok(stmt) => stmt, - Err(_) => { - // Fall back to comprehensive SQL engine for unsupported statements - return self.execute_with_comprehensive_engine(sql).await; + current.push_str(&tag); + if tag.ends_with('$') && tag.len() >= 2 { + dollar_tag = Some(tag); } - }; + continue; + } - // Route queries based on table type and storage availability - match statement { - Statement::Select { - columns, - table, - where_clause, - } => { - if table.to_uppercase() == "ACTORS" { - self.execute_actor_select(columns, &table, where_clause) - .await - } else if let Some(ref storage) = self.persistent_storage { - self.execute_persistent_select(storage, columns, &table, where_clause) - .await - } else { - Err(ProtocolError::PostgresError(format!( - "Table '{}' not found. Use actors table for actor queries or enable persistent storage.", - table - ))) + 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; + } } } - Statement::Insert { - table, - columns, - values, - } => { - if table.to_uppercase() == "ACTORS" { - // In-memory insert for actors - // ... (existing logic) - Ok(QueryResult::Insert { count: 1 }) - } else if let Some(ref storage) = self.persistent_storage { - self.execute_persistent_insert(storage, &table, columns, values) - .await - } else { - Err(ProtocolError::PostgresError(format!( - "Table '{}' not found. Enable persistent storage for table operations.", - table - ))) + None => match character { + '\'' | '"' => { + quote = Some(character); + current.push(character); } - } - Statement::Update { - table, - set_clauses, - where_clause, - } => { - if let Some(ref storage) = self.persistent_storage { - self.execute_persistent_update(storage, &table, set_clauses, where_clause) - .await - } else { - Err(ProtocolError::PostgresError( - "Persistent storage not enabled".to_string(), - )) - } - } - Statement::Delete { - table, - where_clause, - } => { - if let Some(ref storage) = self.persistent_storage { - self.execute_persistent_delete(storage, &table, where_clause) - .await - } else { - Err(ProtocolError::PostgresError( - "Persistent storage not enabled".to_string(), - )) - } - } - Statement::CreateTable { - table, - columns, - if_not_exists, - } => { - if let Some(ref storage) = self.persistent_storage { - self.execute_create_table(storage, &table, columns, if_not_exists) - .await - } else { - Err(ProtocolError::PostgresError( - "Persistent storage not enabled".to_string(), - )) - } - } - Statement::DropTable { table, if_exists } => { - println!( - "DEBUG: Executing DropTable. Storage present: {}", - self.persistent_storage.is_some() - ); - if let Some(ref storage) = self.persistent_storage { - self.execute_drop_table(storage, &table, if_exists).await - } else { - Err(ProtocolError::PostgresError( - "Persistent storage not enabled".to_string(), - )) + ';' => { + if !current.trim().is_empty() { + statements.push(current.trim().to_string()); + } + current.clear(); } - } + other => current.push(other), + }, } } - /// Execute multiple SQL queries (separated by semicolons) - pub async fn execute_multiple_queries(&self, sql: &str) -> ProtocolResult> { - use crate::protocols::postgres_wire::sql::parser::SqlParser; + if !current.trim().is_empty() { + statements.push(current.trim().to_string()); + } + statements +} - let mut parser = SqlParser::new(); - let statements = match parser.parse_multiple(sql) { - Ok(stmts) => stmts, - Err(e) => return Err(e), - }; +/// 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, +} - let mut results = Vec::new(); +/// 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 in memory. +const HISTORY_DEPTH: usize = 4096; + +/// How far a slot may fall behind before it is invalidated. +/// +/// The durable log is bounded by this: a subscriber that stops confirming +/// cannot hold it open indefinitely, which is what `max_slot_wal_keep_size` +/// protects against in PostgreSQL. Set from +/// `postgresql.max_slot_change_backlog`; the default stands when the setting +/// is absent. +static MAX_RETAINED_CHANGES: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(100_000); + +/// How many rows have been marked deleted since the last reclaim. +/// +/// Vacuum read every row of every table on each tick to find out whether there +/// was anything to do. On a large table that is continuous work for an idle +/// server — the tick ran far more often than the thing it was looking for +/// changed. This counter answers the same question without a scan. +static PENDING_RECLAIM: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Note that rows were marked deleted and will need reclaiming. +pub fn note_reclaimable(rows: u64) { + PENDING_RECLAIM.fetch_add(rows, std::sync::atomic::Ordering::Relaxed); +} - for stmt in statements { - let result = self.execute_ast_statement(stmt).await?; - results.push(result); - } +/// Whether anything is waiting to be reclaimed. +#[must_use] +pub fn reclaim_pending() -> bool { + PENDING_RECLAIM.load(std::sync::atomic::Ordering::Relaxed) > 0 +} - Ok(results) +pub use crate::protocols::common::cancel::{ + cancel_requested, check_cancelled, forget_cancellable, register_cancellable, request_cancel, + with_cancel, CANCEL_CHECK_INTERVAL, +}; + +/// Whether any replication slot exists: `0` unknown, `1` none, `2` at least one. +/// +/// Consulted on every write, so it is a cached answer rather than a catalog +/// read; creating or dropping a slot clears it. +static SLOT_CACHE: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0); + +/// Forget whether a slot exists, after one is created or dropped. +pub fn forget_slot_cache() { + SLOT_CACHE.store(0, std::sync::atomic::Ordering::Relaxed); +} + +/// Set how far a slot may fall behind before it is invalidated. +pub fn set_max_slot_backlog(changes: u64) { + // Zero would invalidate every slot the moment it was created, which is a + // configuration mistake rather than a policy anyone wants. + if changes > 0 { + MAX_RETAINED_CHANGES.store(changes, std::sync::atomic::Ordering::Relaxed); } +} - /// 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; +/// How far a slot may currently fall behind. +#[must_use] +pub fn max_slot_backlog() -> u64 { + MAX_RETAINED_CHANGES.load(std::sync::atomic::Ordering::Relaxed) +} - // 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, - }); - } +fn history() -> &'static std::sync::Mutex> { + HISTORY.get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new())) +} - // Execute on persistent storage - let result = self - .execute_create_table( - storage, - &create.name.full_name(), - simple_columns, - create.if_not_exists, - ) - .await?; +/// 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 oldest = history.front().map(|record| record.position)?; + if position < oldest.saturating_sub(1) { + return None; + } + Some( + history + .iter() + .filter(|record| record.position > position) + .cloned() + .collect(), + ) +} - // 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 +/// 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(); - 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?; +fn changes() -> &'static tokio::sync::broadcast::Sender { + CHANGES.get_or_init(|| tokio::sync::broadcast::channel(1024).0) +} - // Also execute on comprehensive engine - let mut sql_engine = self.sql_engine.lock().await; - let _ = sql_engine.execute_statement(stmt).await; +/// 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); - 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(); +/// 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) +} - // 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(); - } - } +/// 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(); - // 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)?; - row_values.push(val.to_postgres_string()); - } - values_list.push(row_values); - } - } +fn pending_log() -> &'static std::sync::Mutex> { + PENDING_LOG.get_or_init(|| std::sync::Mutex::new(Vec::new())) +} - // Execute on persistent storage - let result = self - .execute_persistent_insert(storage, &table_name, columns, values_list) - .await?; +/// 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() +} - // Also execute on comprehensive engine - let mut sql_engine = self.sql_engine.lock().await; - let _ = sql_engine.execute_statement(stmt).await; +/// Subscribe to the change stream. +#[must_use] +pub fn subscribe_to_changes() -> tokio::sync::broadcast::Receiver { + changes().subscribe() +} - 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 - } - } - } - } - _ => {} - } +/// Publish a marker that carries no row — the end of a transaction. +fn publish_marker(action: &str, transaction: u64) { + let position = CHANGE_POSITION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + let record = ChangeRecord { + position, + transaction, + action: action.to_string(), + table: String::new(), + row: "{}".to_string(), + }; + if let Ok(mut history) = history().lock() { + history.push_back(record.clone()); + while history.len() > HISTORY_DEPTH { + history.pop_front(); } + } + let _ = changes().send(record); +} - // 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 - } - } - } - } +/// 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"; - let unified_result = sql_engine.execute_statement(stmt).await?; - Ok(self.convert_sql_result_to_query_result(unified_result)) +/// 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()))), } +} - /// 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) +tokio::task_local! { + /// Tables written while a procedural block is running. + /// + /// A `DO` block is atomic in PostgreSQL: a `RAISE EXCEPTION` after an + /// `INSERT` leaves no row behind. Undoing needs to know where to look, and + /// recording it in the write paths is exact — parsing table names back out + /// of the statements would not be. + static BLOCK_TABLES: Arc>>; +} + +tokio::task_local! { + /// Transaction ids opened by nested blocks while a block is running. + /// + /// A sub-transaction that commits is still part of the block containing + /// it: if that block then fails, its rows must go too, and they carry the + /// sub-transaction's stamp rather than the outer one's. + static BLOCK_TRANSACTIONS: Arc>>; +} + +/// Note a sub-transaction opened inside the block currently running. +pub fn note_block_transaction(id: u64) { + BLOCK_TRANSACTIONS + .try_with(|ids| { + if let Ok(mut ids) = ids.lock() { + ids.push(id); } - _ => None, - } - } + }) + .ok(); +} - /// 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; - match sql_engine.execute(sql).await { - Ok(result) => { - // Debug: log what result we got from the comprehensive engine - tracing::debug!("Comprehensive SQL engine result: {:?}", result); - Ok(self.convert_sql_result_to_query_result(result)) +/// Note that `table` was written by the block currently running, if any. +pub fn note_block_write(table: &str) { + BLOCK_TABLES + .try_with(|tables| { + if let Ok(mut tables) = tables.lock() { + tables.insert(fold_identifier(table)); } - Err(e) => Err(e), - } - } + }) + .ok(); +} - /// Execute SQL using the comprehensive engine directly (bypasses persistent storage checks) - /// This is useful for testing and operations that don't require persistent storage - pub async fn execute_sql_direct(&self, sql: &str) -> ProtocolResult { - self.execute_with_comprehensive_engine(sql).await +/// 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); } +} - /// Convert SQL execution result to QueryResult - fn convert_sql_result_to_query_result(&self, result: UnifiedExecutionResult) -> QueryResult { - match result { - UnifiedExecutionResult::Select { columns, rows, .. } => { - QueryResult::Select { columns, rows } +/// 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); } - UnifiedExecutionResult::Insert { count, .. } => QueryResult::Insert { count }, - UnifiedExecutionResult::Update { count, .. } => QueryResult::Update { count }, - UnifiedExecutionResult::Delete { count, .. } => QueryResult::Delete { count }, - UnifiedExecutionResult::Merge { - count, - rows, - columns, - .. - } => QueryResult::Merge { - count, - rows, - columns, - }, - UnifiedExecutionResult::CreateTable { .. } => QueryResult::Update { count: 0 }, - UnifiedExecutionResult::CreateIndex { .. } => QueryResult::Update { count: 0 }, - UnifiedExecutionResult::Transaction { .. } => QueryResult::Update { count: 0 }, - UnifiedExecutionResult::CreateExtension { .. } => QueryResult::Update { count: 0 }, - UnifiedExecutionResult::CreateSchema { .. } => QueryResult::Update { count: 0 }, - UnifiedExecutionResult::CreateView { .. } => QueryResult::Update { count: 0 }, - UnifiedExecutionResult::DropTable { .. } => QueryResult::Update { count: 0 }, - UnifiedExecutionResult::DropIndex { .. } => QueryResult::Update { count: 0 }, - UnifiedExecutionResult::DropExtension { .. } => QueryResult::Update { count: 0 }, - UnifiedExecutionResult::DropSchema { .. } => QueryResult::Update { count: 0 }, - UnifiedExecutionResult::DropView { .. } => QueryResult::Update { count: 0 }, - UnifiedExecutionResult::Set { - variable, value, .. - } => QueryResult::Set { variable, value }, - UnifiedExecutionResult::Other { message, .. } => QueryResult::Select { - columns: vec!["message".to_string()], - rows: vec![vec![Some(message)]], - }, } + }); +} + +/// 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}"))); +} - /// Check if a query is vector-related - fn is_vector_query(&self, sql: &str) -> bool { - sql.contains("CREATE EXTENSION VECTOR") - || sql.contains("VECTOR(") - || sql.contains("HALFVEC(") - || sql.contains("<->") - || sql.contains("<#>") - || sql.contains("<=>") - || sql.contains("VECTOR_DIMS") - || sql.contains("VECTOR_NORM") - || (sql.contains("CREATE INDEX") - && (sql.contains("USING IVFFLAT") || sql.contains("USING HNSW"))) +/// 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 +} - /// Check if a query contains GraphRAG functions - fn is_graphrag_query(&self, sql: &str) -> bool { - sql.contains("GRAPHRAG_BUILD(") - || sql.contains("GRAPHRAG_QUERY(") - || sql.contains("GRAPHRAG_EXTRACT(") - || sql.contains("GRAPHRAG_REASON(") - || sql.contains("GRAPHRAG_STATS(") - || sql.contains("GRAPHRAG_ENTITIES(") - || sql.contains("GRAPHRAG_SIMILAR(") +/// 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; + } } - /// Parse SQL statement - fn parse_sql(&self, sql: &str) -> ProtocolResult { - let sql = sql.trim().to_uppercase(); - let original_sql = sql.clone(); + // 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) +} - if sql.starts_with("SELECT") { - self.parse_select(&original_sql) - } else if sql.starts_with("INSERT") { - self.parse_insert(&original_sql) - } else if sql.starts_with("UPDATE") { - self.parse_update(&original_sql) - } else if sql.starts_with("DELETE") { - self.parse_delete(&original_sql) - } else if sql.starts_with("CREATE TABLE") { - self.parse_create_table(&original_sql) - } else if sql.starts_with("DROP TABLE") { - self.parse_drop_table(&original_sql) - } else { - Err(ProtocolError::PostgresError(format!( - "Unsupported SQL statement: {sql}" - ))) - } - } - - /// Parse SELECT statement - fn parse_select(&self, sql: &str) -> ProtocolResult { - // Simple parser: SELECT columns FROM table [WHERE condition] - let parts: Vec<&str> = sql.split_whitespace().collect(); - - if parts.len() < 4 || parts[0].to_uppercase() != "SELECT" { - return Err(ProtocolError::PostgresError( - "Invalid SELECT syntax".to_string(), - )); - } - - // Find FROM - let from_idx = parts - .iter() - .position(|&p| p.to_uppercase() == "FROM") - .ok_or_else(|| ProtocolError::PostgresError("Missing FROM clause".to_string()))?; - - // Parse columns - let columns_str = parts[1..from_idx].join(" "); - let columns: Vec = if columns_str == "*" { - vec!["*".to_string()] - } else { - columns_str - .split(',') - .map(|s| s.trim().to_string()) - .collect() - }; - - // Parse table - convert to uppercase and trim semicolon - let table = parts[from_idx + 1].trim_end_matches(';').to_uppercase(); - - // Parse WHERE clause if present - let where_idx = parts.iter().position(|&p| p.to_uppercase() == "WHERE"); - let where_clause = if let Some(idx) = where_idx { - Some(self.parse_where_clause(&parts[idx + 1..])?) - } else { - None - }; +/// 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() +} - Ok(Statement::Select { - columns, - table, - where_clause, - }) - } +/// 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, +} - /// Parse INSERT statement - fn parse_insert(&self, sql: &str) -> ProtocolResult { - // Simple parser: INSERT INTO table (columns) VALUES (values), (values)... - let sql_upper = sql.to_uppercase(); +/// Pull the body out of `AS $$ ... $$` (or `$tag$ ... $tag$`). +/// +/// Returns `None` when there is no dollar-quoted section, which is the only +/// form a PL/pgSQL body is accepted in — a body in single quotes would have to +/// escape every quote inside it. +fn extract_dollar_quoted(source: &str) -> Option { + let open = source.find('$')?; + let tag_end = source[open + 1..].find('$')? + open + 1; + let tag = &source[open..=tag_end]; + let rest = &source[tag_end + 1..]; + let close = rest.find(tag)?; + Some(rest[..close].to_string()) +} - if !sql_upper.contains("INSERT INTO") || !sql_upper.contains("VALUES") { - return Err(ProtocolError::PostgresError( - "Invalid INSERT syntax".to_string(), - )); +/// The type named by a top-level `::` cast, if the expression ends in one. +/// +/// Only at paren depth zero and outside a string: `f('a::b')` casts nothing, +/// and neither does `f((x::int) + 1)` as a whole. +fn split_top_level_cast(expression: &str) -> Option { + let bytes: Vec = expression.chars().collect(); + let mut depth = 0i32; + let mut in_string = false; + let mut last = None; + + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + '\'' => in_string = !in_string, + '(' if !in_string => depth += 1, + ')' if !in_string => depth -= 1, + ':' if !in_string && depth == 0 && bytes.get(index + 1) == Some(&':') => { + last = Some(index + 2); + index += 2; + continue; + } + _ => {} } - - // Find positions using uppercase version - let table_start = sql_upper.find("INTO").unwrap() + 4; - let table_end = sql_upper[table_start..].find('(').unwrap() + table_start; - let col_start = table_end + 1; - let col_end = sql_upper[col_start..].find(')').unwrap() + col_start; - 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] - .split(',') - .map(|s| s.trim().to_string()) - .collect(); - - // Parse values list: (v1, v2), (v3, v4) - let values_str = sql[val_keyword_pos..].trim(); - let values = self.parse_values_list(values_str); - - Ok(Statement::Insert { - table, - columns, - values, - }) + index += 1; } - /// Parse list of value groups: (v1, v2), (v3, v4) - fn parse_values_list(&self, values_str: &str) -> Vec> { - let mut rows = Vec::new(); - let mut current_row_str = String::new(); - let mut in_quotes = false; - let mut quote_char = '\0'; - let mut paren_depth = 0; - let chars: Vec = values_str.chars().collect(); - let mut i = 0; + let at = last?; + let named: String = bytes[at..].iter().collect(); + let named = named.trim(); + (!named.is_empty() + && named + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == ' ' || c == '[' || c == ']')) + .then(|| named.to_string()) +} - while i < chars.len() { - let ch = chars[i]; - match ch { - '\'' | '"' if !in_quotes => { - in_quotes = true; - quote_char = ch; - if paren_depth > 0 { - current_row_str.push(ch); - } - } - c if in_quotes && c == quote_char => { - in_quotes = false; - if paren_depth > 0 { - current_row_str.push(ch); - } - } - '(' if !in_quotes => { - paren_depth += 1; - if paren_depth > 1 { - current_row_str.push(ch); - } - } - ')' if !in_quotes => { - paren_depth -= 1; - if paren_depth > 0 { - current_row_str.push(ch); - } else if paren_depth == 0 { - // End of a row - if !current_row_str.trim().is_empty() { - rows.push(self.parse_csv_values(¤t_row_str)); - } - current_row_str.clear(); - } - } - ',' if !in_quotes && paren_depth == 0 => { - // Separator between rows, ignore - } - _ => { - if paren_depth > 0 { - current_row_str.push(ch); - } - } +/// Split a call's argument list on commas that are not inside parentheses or +/// a string. +fn split_arguments(arguments: &str) -> Vec { + let mut parts = Vec::new(); + let mut current = String::new(); + let mut depth = 0i32; + let mut in_string = false; + + for c in arguments.chars() { + match c { + '\'' => { + in_string = !in_string; + current.push(c); } - i += 1; + '(' if !in_string => { + depth += 1; + current.push(c); + } + ')' if !in_string => { + depth -= 1; + current.push(c); + } + ',' if !in_string && depth == 0 => { + parts.push(current.trim().to_string()); + current = String::new(); + } + _ => current.push(c), } - rows } + if !current.trim().is_empty() { + parts.push(current.trim().to_string()); + } + parts +} - /// Parse UPDATE statement - fn parse_update(&self, sql: &str) -> ProtocolResult { - // Simple parser: UPDATE table SET col=val [WHERE condition] - let parts: Vec<&str> = sql.split_whitespace().collect(); +#[async_trait::async_trait] +impl plpgsql::PlPgSqlHost for QueryEngine { + async fn evaluate(&self, expression: &str) -> ProtocolResult> { + Box::pin(self.evaluate_scalar(expression)).await + } - if parts.len() < 4 || parts[0].to_uppercase() != "UPDATE" { - return Err(ProtocolError::PostgresError( - "Invalid UPDATE syntax".to_string(), - )); - } + async fn run(&self, sql: &str) -> ProtocolResult<()> { + Box::pin(self.execute_query(sql)).await.map(|_| ()) + } - let table = parts[1].trim_end_matches(';').to_uppercase(); + async fn query(&self, sql: &str) -> ProtocolResult { + Ok(match Box::pin(self.execute_query(sql)).await? { + QueryResult::Select { columns, rows } => plpgsql::Rows { columns, rows }, + // A statement that is not a query contributes no rows rather than + // failing: `PERFORM` and `RETURN QUERY` over a DML statement both + // reach here. + _ => plpgsql::Rows::default(), + }) + } - // Find SET - let set_idx = parts + async fn column_type(&self, table: &str, column: &str) -> ProtocolResult> { + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(None); + }; + let Some(schema) = storage.get_table_schema(&fold_identifier(table)).await? else { + return Ok(None); + }; + Ok(schema + .columns .iter() - .position(|&p| p.to_uppercase() == "SET") - .ok_or_else(|| ProtocolError::PostgresError("Missing SET clause".to_string()))?; - - // Find WHERE or end - let where_idx = parts.iter().position(|&p| p.to_uppercase() == "WHERE"); - let set_end = where_idx.unwrap_or(parts.len()); + .find(|c| fold_identifier(&c.name) == fold_identifier(column)) + .map(|c| format!("{:?}", c.data_type).to_uppercase())) + } - // Parse SET clauses safely with JSON support - let set_str = parts[set_idx + 1..set_end].join(" "); - let set_clauses = self.parse_set_clauses(&set_str); + async fn row_columns(&self, table: &str) -> ProtocolResult> { + // A composite type is a row shape too, so `DECLARE v mytype` brings + // its fields into scope exactly as `%ROWTYPE` does for a table. + if let Some(fields) = self.composite_fields(table).await? { + return Ok(fields.into_iter().map(|field| field.name).collect()); + } - // Parse WHERE clause - let where_clause = if let Some(idx) = where_idx { - Some(self.parse_where_clause(&parts[idx + 1..])?) - } else { - None + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(Vec::new()); }; - - Ok(Statement::Update { - table, - set_clauses, - where_clause, - }) + Ok(storage + .get_table_schema(&fold_identifier(table)) + .await? + .map(|schema| schema.columns.iter().map(|c| c.name.clone()).collect()) + .unwrap_or_default()) } - /// Parse DELETE statement - fn parse_delete(&self, sql: &str) -> ProtocolResult { - // Simple parser: DELETE FROM table [WHERE condition] - let parts: Vec<&str> = sql.split_whitespace().collect(); + async fn run_protected( + &self, + block: &plpgsql::Block, + state: plpgsql::State, + ) -> ProtocolResult<(Result, plpgsql::State)> { + let mut state = state; + let tables = Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())); + let context = begin_transaction(false); + let id = context.id; + // Registered with the enclosing block so that if *it* fails later, + // these rows go too — a sub-transaction that committed is still part + // of the block that contained it. + note_block_transaction(id); + + let outcome = BLOCK_TABLES + .scope(Arc::clone(&tables), async { + within_transaction(context, plpgsql::execute_in(block, self, &mut state)).await + }) + .await; - if parts.len() < 3 || parts[0].to_uppercase() != "DELETE" { - return Err(ProtocolError::PostgresError( - "Invalid DELETE syntax".to_string(), - )); + if outcome.is_err() { + let written: Vec = tables + .lock() + .map(|tables| tables.iter().cloned().collect()) + .unwrap_or_default(); + self.discard_transaction_writes(&written, id).await?; } + end_transaction(id); + Ok((outcome, state)) + } +} - // Find FROM - let from_idx = parts - .iter() - .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(); - - // Parse WHERE clause - let where_idx = parts.iter().position(|&p| p.to_uppercase() == "WHERE"); - let where_clause = if let Some(idx) = where_idx { - Some(self.parse_where_clause(&parts[idx + 1..])?) - } else { - None - }; - - Ok(Statement::Delete { - table, - where_clause, - }) +/// 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 +} - /// Parse SET clauses respecting quotes and JSON braces - fn parse_set_clauses(&self, set_str: &str) -> Vec<(String, String)> { - let mut clauses = Vec::new(); - let mut current_clause = String::new(); - let mut in_quotes = false; - let mut quote_char = '\0'; - let mut brace_depth = 0; - let chars: Vec = set_str.chars().collect(); - let mut i = 0; +/// 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::Numeric { .. } => type_oids::NUMERIC, + ColumnType::Timestamp => type_oids::TIMESTAMPTZ, + ColumnType::Json => type_oids::JSON, + ColumnType::Text | ColumnType::Varchar(_) => type_oids::TEXT, + } +} - while i < chars.len() { - let ch = chars[i]; +/// Query result types +#[derive(Debug, Clone)] +pub enum QueryResult { + Select { + columns: Vec, + rows: Vec>>, + }, + Insert { + count: usize, + }, + Update { + count: usize, + }, + Delete { + count: usize, + }, + Set { + variable: String, + value: String, + }, + Merge { + count: usize, + rows: Vec>>, + columns: Vec, + }, +} + +/// Parsed SQL statement +#[derive(Debug, Clone)] +enum Statement { + Select { + columns: Vec, + table: String, + where_clause: Option, + }, + Insert { + table: String, + columns: Vec, + values: Vec>, + }, + Update { + table: String, + set_clauses: Vec<(String, String)>, + where_clause: Option, + }, + Delete { + table: String, + where_clause: Option, + }, + CreateTable { + 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, + if_exists: bool, + }, + Truncate { + table: String, + }, +} + +/// Simple column definition for basic DDL support +#[derive(Debug, Clone)] +struct SimpleColumnDef { + name: String, + data_type: String, + constraints: Vec, +} + +#[derive(Debug, Clone)] +struct WhereClause { + conditions: Vec, +} + +#[derive(Debug, Clone)] +struct Condition { + column: String, + operator: String, + value: String, +} + +/// In-memory actor storage for demonstration +/// In production, this would use OrbitClient +#[derive(Debug, Clone)] +struct ActorRecord { + actor_id: String, + actor_type: String, + state: JsonValue, +} + +/// Query engine that translates SQL to actor operations +pub struct QueryEngine { + // In-memory storage for demonstration (legacy actor operations) + // TODO: Replace with OrbitClient integration + actors: Arc>>, + // Optional persistent table storage for regular SQL tables + persistent_storage: Option>, + // Optional vector query engine for pgvector compatibility + vector_engine: Option, + // Optional GraphRAG query engine + graphrag_engine: Option, + // Comprehensive SQL engine for DDL and other advanced operations + sql_engine: Arc>, + // Current database context + current_database: Arc>, +} + +impl QueryEngine { + /// Create a new query engine + pub fn new() -> Self { + tracing::debug!("query engine created without persistent storage"); + Self { + actors: Arc::new(RwLock::new(HashMap::new())), + persistent_storage: None, + vector_engine: None, + graphrag_engine: None, + sql_engine: Arc::new(Mutex::new(ConfigurableSqlEngine::new())), + current_database: Arc::new(RwLock::new("actors".to_string())), + } + } + + /// Create a new query engine with persistent storage + pub fn new_with_persistent_storage(storage: Arc) -> Self { + tracing::debug!("query engine created with persistent storage"); + Self { + actors: Arc::new(RwLock::new(HashMap::new())), + persistent_storage: Some(storage), + vector_engine: None, + graphrag_engine: None, + sql_engine: Arc::new(Mutex::new(ConfigurableSqlEngine::new())), + current_database: Arc::new(RwLock::new("actors".to_string())), + } + } + + /// Create a new query engine with vector support + pub fn new_with_vector_support(orbit_client: OrbitClient) -> Self { + // Since OrbitClient doesn't implement Clone, we need to create separate instances + // For now, we'll create the GraphRAG engine in placeholder mode + // This needs to be fixed when OrbitClient supports cloning or sharing + Self { + actors: Arc::new(RwLock::new(HashMap::new())), + persistent_storage: None, + vector_engine: Some(VectorQueryEngine::new(orbit_client)), + graphrag_engine: Some(GraphRAGQueryEngine::new_placeholder()), + sql_engine: Arc::new(Mutex::new(ConfigurableSqlEngine::new())), + current_database: Arc::new(RwLock::new("actors".to_string())), + } + } + + /// Create a new query engine with both persistent storage and vector support + pub fn new_with_persistent_and_vector_support( + storage: Arc, + orbit_client: OrbitClient, + ) -> Self { + Self { + actors: Arc::new(RwLock::new(HashMap::new())), + persistent_storage: Some(storage), + vector_engine: Some(VectorQueryEngine::new(orbit_client)), + graphrag_engine: Some(GraphRAGQueryEngine::new_placeholder()), + sql_engine: Arc::new(Mutex::new(ConfigurableSqlEngine::new())), + current_database: Arc::new(RwLock::new("actors".to_string())), + } + } + + /// Set the current database context + pub async fn set_current_database(&self, database: &str) { + let mut current_db = self.current_database.write().await; + *current_db = database.to_string(); + + // Also update the SQL executor's current database + let mut sql_engine = self.sql_engine.lock().await; + sql_engine.set_current_database(database).await; + } + + /// Get the current database name + pub async fn get_current_database(&self) -> String { + let db = self.current_database.read().await; + 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 { .. } + | Statement::Truncate { .. } => 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 + } + + /// 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_proc") => { + let composites: HashMap = self + .all_composites() + .await? + .into_iter() + .map(|(name, _)| (name.clone(), composite_oid(&name))) + .collect(); + let mut functions = self.all_functions().await?; + // A domain reports the OID of what it is built on. This + // server assigns OIDs to functions, not to domains, and + // reporting `text` for a domain over `INTEGER` would tell + // a client the wrong thing about how to call it. + for (_, parameters, return_type, _) in &mut functions { + for parameter in parameters.iter_mut() { + parameter.sql_type = self.base_type_of(¶meter.sql_type).await?; + } + *return_type = self.base_type_of(return_type).await?; + } + ( + vec![ + "oid", + "proname", + "pronamespace", + "pronargs", + "proargtypes", + "prorettype", + "prokind", + ], + functions + .iter() + .map(|(key, parameters, return_type, _)| { + let name = key + .trim_start_matches("function:") + .split('/') + .next() + .unwrap_or_default(); + let inputs = plpgsql_function::inputs(parameters); + vec![ + Some(function_oid(key).to_string()), + Some(name.to_string()), + Some(PUBLIC_NAMESPACE_OID.to_string()), + Some(inputs.len().to_string()), + // `oidvector` is space-separated, as + // PostgreSQL renders it. + Some( + inputs + .iter() + .map(|p| { + composites + .get(&fold_identifier(&p.sql_type)) + .copied() + .unwrap_or_else(|| { + i64::from(type_oid_for(&p.sql_type)) + }) + .to_string() + }) + .collect::>() + .join(" "), + ), + Some( + composites + .get(&fold_identifier(return_type)) + .copied() + .unwrap_or_else(|| i64::from(type_oid_for(return_type))) + .to_string(), + ), + // `f` is a plain function; this server has + // no procedures, aggregates or windows. + Some("f".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") => { + let composites = self.all_composites().await?; + ( + 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()), + ] + }) + // A composite created here is a real type and belongs in + // the catalogue a client reads to find out what exists. + // `c` is what tells one from a base type. + .chain(composites.iter().map(|(name, _)| { + vec![ + Some(composite_oid(name).to_string()), + Some(name.clone()), + Some("c".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 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. + 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 + /// "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(&fold_identifier(table)).await + } + + /// 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 (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 { + 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; + } + } + } + + // 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) + } + + /// 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 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)> { + 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() + } + + /// 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)> { + 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: fold_identifier(name), + 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)) + } + + /// Describe a statement the simple parser cannot, without running it. + /// + /// 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 { + 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 !ROW_RETURNING_PREFIXES + .iter() + .any(|keyword| sql_upper.starts_with(keyword)) + { + return Ok(StatementDescription::no_data()); + } + + 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(), + )); + } + } + 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 { + // Domains stored before this process started are loaded once, so a + // cast to one works after a restart and not only in the session that + // created it. + self.warm_domain_registry().await; + + let sql_upper = sql.trim().to_uppercase(); + + // Check if this is a GraphRAG function query + if self.is_graphrag_query(&sql_upper) { + if let Some(ref graphrag_engine) = self.graphrag_engine { + return graphrag_engine.execute_graphrag_query(sql).await; + } else { + return Err(ProtocolError::PostgresError( + "GraphRAG support not enabled. Use new_with_vector_support() to enable GraphRAG functions.".to_string() + )); + } + } + + // Check if this is a vector-related query + if let Some(ref vector_engine) = self.vector_engine { + if self.is_vector_query(&sql_upper) { + return vector_engine.execute_vector_query(sql).await; + } + } + + // PL/pgSQL before anything else looks at the text: a `DO` block and a + // `CREATE FUNCTION ... LANGUAGE plpgsql` were both answered with + // "Command completed successfully" and then not run, so a block that + // should have written a row reported success and wrote nothing. + if let Some(result) = self.execute_plpgsql(sql).await? { + return Ok(result); + } + + // 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) { + 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 match self.execute_with_comprehensive_engine(sql).await { + Ok(result) => Ok(result), + Err(e) => Err(self.explain_unsupported_query(sql, e).await), + }; + } + }; + + // Route queries based on table type and storage availability + match statement { + Statement::Select { + columns, + table, + where_clause, + } => { + // A catalogue query carrying a `WHERE` has to go through the + // path that can evaluate one. Answering it here dropped the + // clause and returned every row, so a driver asking + // `... FROM pg_class WHERE relname = $1` got the whole + // catalogue and read the first entry as its answer. + if where_clause.is_some() + && self + .select_system_catalog(&table, &["*".to_string()]) + .await? + .is_some() + { + if let Some(result) = self.select_over_storage(sql).await? { + return Ok(result); + } + } + 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 + } else if let Some(ref storage) = self.persistent_storage { + self.execute_persistent_select(storage, columns, &table, where_clause) + .await + } else { + Err(ProtocolError::PostgresError(format!( + "Table '{}' not found. Use actors table for actor queries or enable persistent storage.", + table + ))) + } + } + Statement::Insert { + table, + columns, + values, + } => { + if table.to_uppercase() == "ACTORS" { + // In-memory insert for actors + // ... (existing logic) + Ok(QueryResult::Insert { count: 1 }) + } else if let Some(ref storage) = self.persistent_storage { + self.execute_persistent_insert(storage, &table, columns, values) + .await + } else { + Err(ProtocolError::PostgresError(format!( + "Table '{}' not found. Enable persistent storage for table operations.", + table + ))) + } + } + Statement::Update { + table, + set_clauses, + where_clause, + } => { + if let Some(ref storage) = self.persistent_storage { + self.execute_persistent_update(storage, &table, set_clauses, where_clause) + .await + } else { + Err(ProtocolError::PostgresError( + "Persistent storage not enabled".to_string(), + )) + } + } + Statement::Delete { + table, + where_clause, + } => { + if let Some(ref storage) = self.persistent_storage { + self.execute_persistent_delete(storage, &table, where_clause) + .await + } else { + Err(ProtocolError::PostgresError( + "Persistent storage not enabled".to_string(), + )) + } + } + Statement::CreateTable { + 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, foreign_keys) + .await + } else { + Err(ProtocolError::PostgresError( + "Persistent storage not enabled".to_string(), + )) + } + } + 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 } => { + 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 + } else { + Err(ProtocolError::PostgresError( + "Persistent storage not enabled".to_string(), + )) + } + } + } + } + + /// 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> { + let mut results = Vec::new(); + for statement in split_statements(sql) { + // A cancel is honoured between statements, which is where + // PostgreSQL takes one too: the statement already running finishes, + // and nothing after it starts. + if cancel_requested() { + return Err(ProtocolError::PostgresError( + "canceling statement due to user request".to_string(), + )); + } + results.push(self.execute_query(&statement).await?); + } + Ok(results) + } + + /// 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; + match sql_engine.execute(sql).await { + Ok(result) => { + // Debug: log what result we got from the comprehensive engine + tracing::debug!("Comprehensive SQL engine result: {:?}", result); + Ok(self.convert_sql_result_to_query_result(result)) + } + Err(e) => Err(e), + } + } + + /// Execute SQL using the comprehensive engine directly (bypasses persistent storage checks) + /// This is useful for testing and operations that don't require persistent storage + pub async fn execute_sql_direct(&self, sql: &str) -> ProtocolResult { + self.execute_with_comprehensive_engine(sql).await + } + + /// Convert SQL execution result to QueryResult + fn convert_sql_result_to_query_result(&self, result: UnifiedExecutionResult) -> QueryResult { + match result { + UnifiedExecutionResult::Select { columns, rows, .. } => { + QueryResult::Select { columns, rows } + } + UnifiedExecutionResult::Insert { count, .. } => QueryResult::Insert { count }, + UnifiedExecutionResult::Update { count, .. } => QueryResult::Update { count }, + UnifiedExecutionResult::Delete { count, .. } => QueryResult::Delete { count }, + UnifiedExecutionResult::Merge { + count, + rows, + columns, + .. + } => QueryResult::Merge { + count, + rows, + columns, + }, + UnifiedExecutionResult::CreateTable { .. } => QueryResult::Update { count: 0 }, + UnifiedExecutionResult::CreateIndex { .. } => QueryResult::Update { count: 0 }, + UnifiedExecutionResult::Transaction { .. } => QueryResult::Update { count: 0 }, + UnifiedExecutionResult::CreateExtension { .. } => QueryResult::Update { count: 0 }, + UnifiedExecutionResult::CreateSchema { .. } => QueryResult::Update { count: 0 }, + UnifiedExecutionResult::CreateView { .. } => QueryResult::Update { count: 0 }, + UnifiedExecutionResult::DropTable { .. } => QueryResult::Update { count: 0 }, + UnifiedExecutionResult::DropIndex { .. } => QueryResult::Update { count: 0 }, + UnifiedExecutionResult::DropExtension { .. } => QueryResult::Update { count: 0 }, + UnifiedExecutionResult::DropSchema { .. } => QueryResult::Update { count: 0 }, + UnifiedExecutionResult::DropView { .. } => QueryResult::Update { count: 0 }, + UnifiedExecutionResult::Set { + variable, value, .. + } => QueryResult::Set { variable, value }, + UnifiedExecutionResult::Other { message, .. } => QueryResult::Select { + columns: vec!["message".to_string()], + rows: vec![vec![Some(message)]], + }, + } + } + + /// Check if a query is vector-related + fn is_vector_query(&self, sql: &str) -> bool { + sql.contains("CREATE EXTENSION VECTOR") + || sql.contains("VECTOR(") + || sql.contains("HALFVEC(") + || sql.contains("<->") + || sql.contains("<#>") + || sql.contains("<=>") + || sql.contains("VECTOR_DIMS") + || sql.contains("VECTOR_NORM") + || (sql.contains("CREATE INDEX") + && (sql.contains("USING IVFFLAT") || sql.contains("USING HNSW"))) + } + + /// Check if a query contains GraphRAG functions + fn is_graphrag_query(&self, sql: &str) -> bool { + sql.contains("GRAPHRAG_BUILD(") + || sql.contains("GRAPHRAG_QUERY(") + || sql.contains("GRAPHRAG_EXTRACT(") + || sql.contains("GRAPHRAG_REASON(") + || sql.contains("GRAPHRAG_STATS(") + || sql.contains("GRAPHRAG_ENTITIES(") + || sql.contains("GRAPHRAG_SIMILAR(") + } + + /// 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 original_sql = sql.trim(); + let sql = original_sql.to_uppercase(); + + if sql.starts_with("SELECT") { + self.parse_select(original_sql) + } else if sql.starts_with("INSERT") { + self.parse_insert(original_sql) + } else if sql.starts_with("UPDATE") { + self.parse_update(original_sql) + } else if sql.starts_with("DELETE") { + self.parse_delete(original_sql) + } else if sql.starts_with("CREATE TABLE") { + 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}" + ))) + } + } + + /// `CREATE TABLE ... AS SELECT` and `ALTER TABLE ... DROP COLUMN`. + /// + /// Returns `None` when the statement is neither. + /// + /// # Errors + /// 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, + }; + + 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
()`. + // + // 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) + } + + /// Run a write with its `BEFORE` and `AFTER` triggers around it. + /// + /// Returns `None` when the statement is not a write, or when the table it + /// writes has no trigger — the common case, which costs one catalog read. + /// + /// # Errors + /// Returns an error when a trigger's own statement fails; a `BEFORE` + /// failure stops the write. + async fn execute_with_triggers(&self, sql: &str) -> ProtocolResult> { + if self.persistent_storage.is_none() { + return Ok(None); + } + let event = match sql + .split_whitespace() + .next() + .map(str::to_uppercase) + .as_deref() + { + Some("INSERT") => "INSERT", + Some("UPDATE") => "UPDATE", + Some("DELETE") => "DELETE", + _ => return Ok(None), + }; + let Some(table) = Self::write_target_table(sql) else { + return Ok(None); + }; + + let before = self.triggers_for(&table, "BEFORE", event).await?; + let after = self.triggers_for(&table, "AFTER", event).await?; + let instead = self.triggers_for(&table, "INSTEAD", event).await?; + if before.is_empty() && after.is_empty() && instead.is_empty() { + return Ok(None); + } + + // `OLD` is the row as it stands before the statement; `NEW` is the + // row it will become. An INSERT has only NEW, a DELETE only OLD. + let old_rows = match event { + "UPDATE" | "DELETE" => self + .rows_a_statement_will_change(sql) + .await? + .unwrap_or_default(), + _ => Vec::new(), + }; + let new_rows = match event { + "INSERT" => self.rows_an_insert_adds(sql).await?.unwrap_or_default(), + "UPDATE" => self + .rows_a_statement_will_change(sql) + .await? + .unwrap_or_default(), + _ => Vec::new(), + }; + + // A `BEFORE` trigger may rewrite the row on its way in, spelled + // `SET NEW.col = ` — the one form a statement-based trigger can + // express without a procedural language. + let mut new_rows = new_rows; + let mut rewritten = None; + for trigger in &before { + if let Some(assignment) = trigger.action.to_uppercase().strip_prefix("SET NEW.") { + let _ = assignment; + rewritten = Some(Self::apply_new_assignment( + &trigger.action, + &mut new_rows, + &old_rows, + )?); + continue; + } + self.fire_trigger(trigger, &old_rows, &new_rows).await?; + } + + // `INSTEAD OF` replaces the write entirely, which is what makes a view + // writable. + if !instead.is_empty() { + for trigger in &instead { + self.fire_trigger(trigger, &old_rows, &new_rows).await?; + } + for trigger in &after { + self.fire_trigger(trigger, &old_rows, &new_rows).await?; + } + return Ok(Some(QueryResult::Update { + count: new_rows.len(), + })); + } + + // The write itself goes through the ordinary path; the recursion is + // bounded because that path finds no trigger left to fire for it. + let statement = match (rewritten, event) { + (Some(()), "INSERT") => Self::rewrite_insert(sql, &table, &new_rows), + // A rewritten UPDATE becomes a SET of the values the trigger left + // on each row, applied to the rows it already selected. + (Some(()), "UPDATE") => Self::rewrite_update(sql, &table, &old_rows, &new_rows) + .unwrap_or_else(|| sql.to_string()), + _ => sql.to_string(), + }; + let result = Box::pin(self.execute_without_triggers(&statement)).await?; + for trigger in &after { + self.fire_trigger(trigger, &old_rows, &new_rows).await?; + } + Ok(Some(result)) + } + + /// Apply a `SET NEW.col = ` trigger action to the incoming rows. + /// + /// # Errors + /// Returns an error when the assignment cannot be parsed or evaluated. + fn apply_new_assignment( + action: &str, + new_rows: &mut [TableRow], + old_rows: &[TableRow], + ) -> ProtocolResult<()> { + use crate::protocols::postgres_wire::sql::expression_evaluator::{ + EvaluationContext, ExpressionEvaluator, + }; + use crate::protocols::postgres_wire::sql::parser::SqlParser; + + let body = action.trim().get(4..).unwrap_or_default().trim(); + let Some((target, expression)) = body.split_once('=') else { + return Err(ProtocolError::PostgresError( + "a BEFORE trigger's SET requires NEW. = ".to_string(), + )); + }; + let column = fold_identifier( + target + .trim() + .trim_start_matches("NEW.") + .trim_start_matches("new."), + ); + + for (index, row) in new_rows.iter_mut().enumerate() { + // The expression may itself mention NEW/OLD, so it is substituted + // against the row it is about to change. + let text = Self::substitute_row_references( + expression.trim(), + old_rows.get(index), + Some(&row.clone()), + ); + let parsed = SqlParser::new().parse(&format!("SELECT {text}"))?; + let crate::protocols::postgres_wire::sql::ast::Statement::Select(select) = parsed + else { + continue; + }; + let Some(crate::protocols::postgres_wire::sql::ast::SelectItem::Expression { + expr, + .. + }) = select.select_list.first() + else { + continue; + }; + + let mut evaluator = ExpressionEvaluator::new(); + let value = evaluator.evaluate(expr, &EvaluationContext::empty())?; + let stored = row + .values + .keys() + .find(|name| fold_identifier(name) == column) + .cloned() + .unwrap_or(column.clone()); + row.values.insert(stored, Self::sql_value_to_json(&value)); + } + Ok(()) + } + + /// Rebuild an `INSERT` from the rows a `BEFORE` trigger rewrote. + fn rewrite_insert(original: &str, table: &str, rows: &[TableRow]) -> String { + let Some(first) = rows.first() else { + return original.to_string(); + }; + let columns: Vec = first.values.keys().cloned().collect(); + let tuples: Vec = rows + .iter() + .map(|row| { + let values: Vec = columns + .iter() + .map(|column| match row.values.get(column) { + None | Some(JsonValue::Null) => "NULL".to_string(), + Some(JsonValue::Number(number)) => number.to_string(), + Some(JsonValue::Bool(flag)) => flag.to_string(), + Some(other) => format!( + "'{}'", + other.as_str().unwrap_or_default().replace('\'', "''") + ), + }) + .collect(); + format!("({})", values.join(", ")) + }) + .collect(); + + format!( + "INSERT INTO {table} ({}) VALUES {}", + columns.join(", "), + tuples.join(", ") + ) + } + + /// Rebuild an `UPDATE` from the rows a `BEFORE` trigger rewrote. + /// + /// Returns `None` when the rows cannot be told apart, in which case the + /// original statement runs unchanged rather than a guess. + fn rewrite_update( + original: &str, + table: &str, + old_rows: &[TableRow], + new_rows: &[TableRow], + ) -> Option { + // One row is the case a per-row rewrite can express as a statement; + // more than one would need a different SET per row. + if new_rows.len() != 1 || old_rows.len() != 1 { + return None; + } + let new = new_rows.first()?; + let old = old_rows.first()?; + + let literal = |value: &JsonValue| match value { + JsonValue::Null => "NULL".to_string(), + JsonValue::Number(number) => number.to_string(), + JsonValue::Bool(flag) => flag.to_string(), + other => format!( + "'{}'", + other.as_str().unwrap_or_default().replace('\'', "''") + ), + }; + + let assignments: Vec = new + .values + .iter() + .map(|(column, value)| format!("{column} = {}", literal(value))) + .collect(); + if assignments.is_empty() { + return None; + } + // The original row identifies itself by every column it had. + let conditions: Vec = old + .values + .iter() + .filter(|(_, value)| !value.is_null()) + .map(|(column, value)| format!("{column} = {}", literal(value))) + .collect(); + if conditions.is_empty() { + return None; + } + let _ = original; + + Some(format!( + "UPDATE {table} SET {} WHERE {}", + assignments.join(", "), + conditions.join(" AND ") + )) + } + + /// Run one trigger, once per row or once per statement. + /// + /// # Errors + /// Returns an error when the trigger's own statement or `WHEN` clause + /// fails. + async fn fire_trigger( + &self, + trigger: &TriggerDefinition, + old_rows: &[TableRow], + new_rows: &[TableRow], + ) -> ProtocolResult<()> { + if !trigger.per_row { + // A statement-level trigger fires once, and has no row to read. + if Self::trigger_fires(self, &trigger.when, None, None).await? { + self.run_trigger_body(&trigger.action).await?; + } + return Ok(()); + } + + let count = old_rows.len().max(new_rows.len()); + for index in 0..count { + let old = old_rows.get(index); + let new = new_rows.get(index); + if !Self::trigger_fires(self, &trigger.when, old, new).await? { + continue; + } + let action = Self::substitute_row_references(&trigger.action, old, new); + self.run_trigger_body(&action).await?; + } + Ok(()) + } + + /// Run a trigger's body: one statement, or several between `BEGIN` and + /// `END`, with `RAISE` as a way to reject the write. + /// + /// # 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. + let trimmed = match trimmed + .strip_prefix("$$") + .and_then(|r| r.strip_suffix("$$")) + { + Some(inner) => inner.trim(), + None => trimmed, + }; + let upper = trimmed.to_uppercase(); + + // `BEGIN ... END` wraps a sequence; anything else is one statement. + let inner = match (upper.starts_with("BEGIN"), upper.ends_with("END")) { + (true, true) => trimmed[5..trimmed.len() - 3].trim(), + _ => trimmed, + }; + + for statement in split_statements(inner) { + let statement = statement.trim(); + if statement.is_empty() { + continue; + } + // `RAISE [level] 'message'` stops the trigger, and with it the + // write a BEFORE trigger guards. + if let Some(rest) = statement + .to_uppercase() + .strip_prefix("RAISE") + .map(|rest| rest.trim().to_string()) + { + let message = statement[statement.len() - rest.len()..] + .trim() + .trim_start_matches(|c: char| c.is_ascii_alphabetic()) + .trim() + .trim_matches('\'') + .to_string(); + return Err(ProtocolError::PostgresError(if message.is_empty() { + "raised by a trigger".to_string() + } else { + message + })); + } + Box::pin(self.execute_query(statement)).await?; + } + Ok(()) + } + + /// Whether a trigger's `WHEN` clause holds for a row. + async fn trigger_fires( + &self, + when: &Option, + old: Option<&TableRow>, + new: Option<&TableRow>, + ) -> ProtocolResult { + let Some(when) = when else { + return Ok(true); + }; + let predicate = Self::substitute_row_references(when, old, new); + let rows = Box::pin(self.execute_query(&format!("SELECT 1 WHERE {predicate}"))).await?; + Ok(match rows { + QueryResult::Select { rows, .. } => !rows.is_empty(), + _ => true, + }) + } + + /// Replace `NEW.col` and `OLD.col` with the values they stand for. + /// + /// Substituting text keeps the trigger's action an ordinary statement, + /// which is what makes it runnable without a procedural language. + fn substitute_row_references( + text: &str, + old: Option<&TableRow>, + new: Option<&TableRow>, + ) -> String { + let mut out = text.to_string(); + for (prefix, row) in [("NEW", new), ("OLD", old)] { + let Some(row) = row else { + continue; + }; + for (column, value) in &row.values { + let literal = + Self::sql_value_to_literal(&Self::json_to_sql_value(value, &ColumnType::Text)); + // Values are written as SQL literals, so a text column arrives + // quoted and a NULL arrives as NULL rather than as the word. + let literal = match value { + JsonValue::Null => "NULL".to_string(), + JsonValue::Number(number) => number.to_string(), + JsonValue::Bool(flag) => flag.to_string(), + _ => literal, + }; + for spelling in [ + format!("{prefix}.{column}"), + format!("{}.{column}", prefix.to_lowercase()), + ] { + out = out.replace(&spelling, &literal); + } + } + } + out + } + + /// Create or drop a view, recording its definition in storage. + /// + /// A view is a stored query, so its definition has to outlive the process + /// that created it; keeping it in memory would make `CREATE VIEW` a claim + /// that stops being true at the next restart. + /// + /// Returns `None` when the statement is not a view statement. + /// + /// # Errors + /// Returns an error when the catalog cannot be read or written. + async fn execute_view_statement(&self, sql: &str) -> ProtocolResult> { + 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(); + + if upper.starts_with("DROP VIEW") { + 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(name.clone()), + }], + ) + .await?; + if removed == 0 && !if_exists { + return Err(ProtocolError::PostgresError(format!( + "View '{name}' does not exist" + ))); + } + return Ok(Some(QueryResult::Update { count: 0 })); + } + + if !upper.starts_with("CREATE VIEW") && !upper.starts_with("CREATE OR REPLACE VIEW") { + return Ok(None); + } + + let Some(as_at) = upper.find(" AS ") else { + return Err(ProtocolError::PostgresError( + "CREATE VIEW requires AS followed by a query".to_string(), + )); + }; + let header = &trimmed[..as_at]; + let definition = trimmed[as_at + 4..].trim().to_string(); + let Some(name) = header.split_whitespace().last() else { + return Err(ProtocolError::PostgresError( + "CREATE VIEW requires a name".to_string(), + )); + }; + let name = fold_identifier(name); + + // The definition has to parse now rather than at first read, so a + // typo is an error at CREATE time as it is in PostgreSQL. + crate::protocols::postgres_wire::sql::parser::SqlParser::new().parse(&definition)?; + + self.ensure_view_catalog(storage).await?; + let replacing = upper.starts_with("CREATE OR REPLACE VIEW"); + if storage.table_exists(&name).await? + || (!replacing && self.view_definition(&name).await?.is_some()) + { + return Err(ProtocolError::PostgresError(format!( + "Relation '{name}' already exists" + ))); + } + storage + .delete_rows( + VIEW_CATALOG, + vec![QueryCondition { + column: "name".to_string(), + operator: "=".to_string(), + value: JsonValue::String(name.clone()), + }], + ) + .await?; + + let now = chrono::Utc::now(); + storage + .insert_row( + VIEW_CATALOG, + TableRow { + values: HashMap::from([ + ("name".to_string(), JsonValue::String(name)), + ("definition".to_string(), JsonValue::String(definition)), + ]), + created_at: now, + updated_at: now, + }, + ) + .await?; + + 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 + /// this a restart would reuse LSNs a replica had already seen and its + /// bookkeeping would silently skip the new records. + /// + /// # Errors + /// Returns an error when the log cannot be read. + pub async fn resume_change_positions(&self) -> ProtocolResult { + let highest = self + .logged_changes_since(0) + .await? + .last() + .map_or(0, |record| record.position); + if highest > 0 { + CHANGE_POSITION.store(highest, std::sync::atomic::Ordering::Relaxed); + } + Ok(highest) + } + + /// Write everything waiting to the durable log. + /// + /// Called at the end of each write, so a replica that reconnects after a + /// restart finds the change there. Queuing until the background tick would + /// have meant losing whatever was written in the last minute. + /// + /// # Errors + /// Returns an error when the log cannot be written. + pub async fn flush_change_log(&self) -> ProtocolResult<()> { + // 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(()); + } + 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 + } + + /// Whether any replication slot exists. + /// + /// 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)), + ( + "last".to_string(), + JsonValue::from(records.last().map_or(first.position, |r| r.position)), + ), + ( + "payload".to_string(), + JsonValue::String(payload.join("\u{2}")), + ), + ]), + created_at: now, + updated_at: now, + }, + ) + .await + .map(|_| ()) + } + + /// Create the change log if it is not there yet. + async fn ensure_change_log( + &self, + storage: &Arc, + ) -> ProtocolResult<()> { + use crate::protocols::postgres_wire::persistent_storage::{ + ColumnDefinition, ColumnType, TableSchema, + }; + + if storage.table_exists(CHANGE_LOG).await? { + return Ok(()); + } + storage + .create_table(TableSchema { + name: CHANGE_LOG.to_string(), + columns: vec![ + ColumnDefinition { + name: "position".to_string(), + data_type: ColumnType::BigInt, + nullable: false, + default_value: None, + unique: true, + check: None, + 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, + nullable: false, + default_value: None, + unique: false, + check: None, + references: None, + domain: None, + }, + ], + created_at: chrono::Utc::now(), + row_count: 0, + foreign_keys: Vec::new(), + }) + .await + } + + /// Changes after `position`, read from the durable log. + /// + /// # Errors + /// Returns an error when the log cannot be read. + pub async fn logged_changes_since(&self, position: u64) -> ProtocolResult> { + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(Vec::new()); + }; + if !storage.table_exists(CHANGE_LOG).await? { + 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![QueryCondition { + column: "last".to_string(), + operator: ">".to_string(), + value: JsonValue::from(position), + }], + None, + ) + .await? + .into_iter() + .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) + } + + /// Remember a replication slot, so a restart does not forget it. + /// + /// # 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(()); + }; + self.ensure_view_catalog(storage).await?; + let now = chrono::Utc::now(); + storage + .delete_rows( + VIEW_CATALOG, + vec![QueryCondition { + column: "name".to_string(), + operator: "=".to_string(), + value: JsonValue::String(format!("slot:{name}")), + }], + ) + .await?; + storage + .insert_row( + VIEW_CATALOG, + TableRow { + values: HashMap::from([ + ( + "name".to_string(), + JsonValue::String(format!("slot:{name}")), + ), + ( + "definition".to_string(), + JsonValue::String(format!("{plugin}|{}", latest_change_position())), + ), + ]), + created_at: now, + updated_at: now, + }, + ) + .await + .map(|_| ()) + } + + /// 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 function_for_oid( + &self, + oid: i64, + ) -> ProtocolResult, String)>> { + Ok(self + .all_functions() + .await? + .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) + }) + })) + } + + /// 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 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 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?; + 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 + .delete_rows( + VIEW_CATALOG, + vec![QueryCondition { + column: "name".to_string(), + operator: "=".to_string(), + value: JsonValue::String(name.to_string()), + }], + ) + .await + .map(|_| ()) + } + + /// 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(()); + }; + storage + .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(from.to_string()), + }], + ) + .await + .map(|_| ()) + } + + /// 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(()); + }; + 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(|_| ()) + } + + /// 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 catalogue cannot be read. + pub async fn composite_fields( + &self, + 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_map(|row| { + 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()) + } + + /// 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()); + }; + let base = leading_type(&definition); + Ok(if base.is_empty() { + declared.to_string() + } else { + base + }) + } + + /// 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 + } + + /// Record a materialized view's defining query so `REFRESH` can re-run it. + async fn remember_materialized(&self, name: &str, query: &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!("mat:{name}"))), + ( + "definition".to_string(), + JsonValue::String(query.to_string()), + ), + ]), + created_at: now, + updated_at: now, + }, + ) + .await + .map(|_| ()) + } + + /// The query behind a materialized view, if there is one. + async fn materialized_definition(&self, name: &str) -> ProtocolResult> { + self.view_definition(&format!("mat:{name}")).await + } + + /// Create the view catalog if it is not there yet. + async fn ensure_view_catalog( + &self, + storage: &Arc, + ) -> ProtocolResult<()> { + use crate::protocols::postgres_wire::persistent_storage::{ + ColumnDefinition, ColumnType, TableSchema, + }; + + if storage.table_exists(VIEW_CATALOG).await? { + return Ok(()); + } + + let column = |name: &str, unique: bool| ColumnDefinition { + name: name.to_string(), + data_type: ColumnType::Text, + nullable: false, + default_value: None, + unique, + check: None, + references: None, + domain: None, + }; + + storage + .create_table(TableSchema { + name: VIEW_CATALOG.to_string(), + columns: vec![column("name", true), column("definition", false)], + created_at: chrono::Utc::now(), + row_count: 0, + foreign_keys: Vec::new(), + }) + .await + } + + /// The query a view stands for, if `name` names one. + async fn view_definition(&self, name: &str) -> ProtocolResult> { + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(None); + }; + if !storage.table_exists(VIEW_CATALOG).await? { + return Ok(None); + } + + let rows = storage + .select_rows( + VIEW_CATALOG, + Vec::new(), + vec![QueryCondition { + column: "name".to_string(), + operator: "=".to_string(), + value: JsonValue::String(name.to_string()), + }], + None, + ) + .await?; + + Ok(rows.into_iter().next().and_then(|row| { + row.values + .get("definition") + .and_then(JsonValue::as_str) + .map(str::to_string) + })) + } + + /// `INSERT ... ON CONFLICT DO NOTHING | DO UPDATE SET ...`. + /// + /// The conflict is detected by reading the target columns before writing, + /// which is what the uniqueness check does on an ordinary insert. + /// + /// # Errors + /// Returns an error when the table is unknown or a value cannot be + /// evaluated. + async fn insert_on_conflict( + &self, + insert: &crate::protocols::postgres_wire::sql::ast::InsertStatement, + ) -> ProtocolResult { + use crate::protocols::postgres_wire::sql::ast::{ + AssignmentTarget, ConflictAction, ConflictTarget, InsertSource, + }; + use crate::protocols::postgres_wire::sql::expression_evaluator::{ + EvaluationContext, ExpressionEvaluator, + }; + + let storage = self.persistent_storage.as_ref().ok_or_else(|| { + ProtocolError::PostgresError("Persistent storage not enabled".to_string()) + })?; + let table = fold_identifier(&insert.table.full_name()); + let schema = storage.get_table_schema(&table).await?.ok_or_else(|| { + ProtocolError::PostgresError(format!("Table '{table}' does not exist")) + })?; + let InsertSource::Values(tuples) = &insert.source else { + return Err(ProtocolError::PostgresError( + "ON CONFLICT is only supported with a VALUES source".to_string(), + )); + }; + let clause = insert.on_conflict.as_ref().ok_or_else(|| { + ProtocolError::PostgresError("missing ON CONFLICT clause".to_string()) + })?; + + // Without an explicit target, any unique column is the conflict key. + let keys: Vec = match &clause.target { + Some(ConflictTarget::Columns(columns)) => { + columns.iter().map(|name| fold_identifier(name)).collect() + } + Some(ConflictTarget::Constraint(_)) | None => schema + .columns + .iter() + .filter(|column| column.unique) + .map(|column| fold_identifier(&column.name)) + .collect(), + }; + + let names: Vec = insert + .columns + .clone() + .unwrap_or_else(|| { + schema + .columns + .iter() + .map(|column| column.name.clone()) + .collect() + }) + .iter() + .map(|name| fold_identifier(name)) + .collect(); + + let mut evaluator = ExpressionEvaluator::new(); + let context = EvaluationContext::empty(); + let mut inserted = 0usize; + let mut updated = 0usize; + + for tuple in tuples { + let mut row: HashMap = HashMap::new(); + for (name, expression) in names.iter().zip(tuple) { + row.insert(name.clone(), evaluator.evaluate(expression, &context)?); + } + + let conditions: Vec = keys + .iter() + .filter_map(|key| { + row.get(key).map(|value| QueryCondition { + column: key.clone(), + operator: "=".to_string(), + value: Self::sql_value_to_json(value), + }) + }) + .collect(); + let conflicting = if conditions.is_empty() { + Vec::new() + } else { + storage + .select_rows(&table, Vec::new(), conditions.clone(), Some(1)) + .await? + }; + + if conflicting.is_empty() { + let now = chrono::Utc::now(); + let mut values: HashMap = row + .iter() + .map(|(name, value)| (name.clone(), Self::sql_value_to_json(value))) + .collect(); + Self::apply_defaults(&schema, &mut values); + Self::check_not_null(&schema, &values)?; + storage + .insert_row( + &table, + TableRow { + values, + created_at: now, + updated_at: now, + }, + ) + .await?; + inserted += 1; + continue; + } + + match &clause.action { + ConflictAction::DoNothing => {} + ConflictAction::DoUpdate { set, .. } => { + let set_values: HashMap = set + .iter() + .filter_map(|assignment| match &assignment.target { + AssignmentTarget::Column(name) => Some((name, &assignment.value)), + AssignmentTarget::Columns(_) => None, + }) + .map(|(name, expression)| { + let context = EvaluationContext::with_row(row.clone()); + evaluator.evaluate(expression, &context).map(|value| { + (fold_identifier(name), Self::sql_value_to_json(&value)) + }) + }) + .collect::>()?; + updated += storage + .update_rows(&table, set_values, conditions) + .await? + .max(0) as usize; + } + } + } + + Ok(QueryResult::Insert { + count: inserted + updated, + }) + } + + /// `INSERT INTO t (...) SELECT ...`. + /// + /// The rows come from evaluating the select, so the source may be any + /// query this engine can answer, including a join or a CTE. + /// + /// # Errors + /// Returns an error when the target table is unknown, the select cannot be + /// evaluated, or the column count does not match. + async fn insert_from_select( + &self, + table: &str, + columns: Option<&[String]>, + query: &crate::protocols::postgres_wire::sql::ast::SelectStatement, + returning: Option<&[crate::protocols::postgres_wire::sql::ast::SelectItem]>, + ) -> ProtocolResult { + let storage = self.persistent_storage.as_ref().ok_or_else(|| { + ProtocolError::PostgresError("Persistent storage not enabled".to_string()) + })?; + let schema = storage.get_table_schema(table).await?.ok_or_else(|| { + ProtocolError::PostgresError(format!("Table '{table}' does not exist")) + })?; + + let (source_columns, rows) = self + .evaluate_select(query, &HashMap::new()) + .await? + .ok_or_else(|| { + ProtocolError::PostgresError( + "the source of an INSERT ... SELECT uses a shape this engine cannot evaluate" + .to_string(), + ) + })?; + + // Without an explicit column list the target's own columns are filled + // in order, which is what PostgreSQL does. + let targets: Vec = match columns { + Some(columns) => columns.iter().map(|name| fold_identifier(name)).collect(), + None => schema + .columns + .iter() + .map(|column| fold_identifier(&column.name)) + .collect(), + }; + if !rows.is_empty() && targets.len() != source_columns.len() { + return Err(ProtocolError::PostgresError(format!( + "INSERT has {} target columns but the query returns {}", + targets.len(), + source_columns.len() + ))); + } + + let mut inserted = Vec::with_capacity(rows.len()); + for row in rows { + let now = chrono::Utc::now(); + let values: HashMap = targets + .iter() + .zip(&row) + .map(|(name, value)| { + let stored = schema + .columns + .iter() + .find(|column| fold_identifier(&column.name) == *name) + .map_or_else(|| name.clone(), |column| column.name.clone()); + (stored, Self::sql_value_to_json(value)) + }) + .collect(); + storage + .insert_row( + table, + TableRow { + values, + created_at: now, + updated_at: now, + }, + ) + .await?; + + let mut projected = crate::protocols::postgres_wire::sql::select_pipeline::Row::new(); + for (name, value) in targets.iter().zip(row) { + projected.insert(format!("{table}.{name}"), value.clone()); + projected.insert(name.clone(), value); + } + inserted.push(projected); + } + + let count = inserted.len(); + match returning { + None => Ok(QueryResult::Insert { count }), + Some(items) => self.project_returning(table, items, inserted).await, + } + } + + /// `UPDATE` whose `SET` reads columns, such as `SET n = n + 1`. + /// + /// Each matching row is recomputed from its own values and written back + /// individually; the storage layer takes one value map per call, which + /// cannot express a per-row result. + /// + /// # Errors + /// Returns an error when the table is unknown or an assignment cannot be + /// evaluated. + async fn update_with_expressions( + &self, + table: &str, + update: &crate::protocols::postgres_wire::sql::ast::UpdateStatement, + ) -> ProtocolResult { + use crate::protocols::postgres_wire::sql::ast::{AssignmentTarget, FromClause, TableName}; + + let storage = self.persistent_storage.as_ref().ok_or_else(|| { + ProtocolError::PostgresError("Persistent storage not enabled".to_string()) + })?; + if !storage.table_exists(table).await? { + return Err(ProtocolError::PostgresError(format!( + "Table '{table}' does not exist" + ))); + } + + let from = FromClause::Table { + name: TableName { + schema: None, + name: table.to_string(), + }, + alias: None, + time_travel: None, + }; + let Some((rows, _)) = self.rows_from_clause(&from, &HashMap::new()).await? else { + return Err(ProtocolError::PostgresError(format!( + "Table '{table}' does not exist" + ))); + }; + + let mut evaluator = + crate::protocols::postgres_wire::sql::expression_evaluator::ExpressionEvaluator::new(); + let assigned: Vec = update + .set + .iter() + .filter_map(|assignment| match &assignment.target { + AssignmentTarget::Column(name) => Some(fold_identifier(name)), + AssignmentTarget::Columns(_) => None, + }) + .collect(); + + // The originals identify the rows to rewrite; the updated copies carry + // the new values. + let originals = + Self::apply_assignments(&mut evaluator, rows, update.where_clause.as_ref(), &[])?; + let updated = + Self::apply_assignments(&mut evaluator, originals.clone(), None, &update.set)?; + + let mut count = 0usize; + for (original, new_row) in originals.iter().zip(&updated) { + let set_values: HashMap = assigned + .iter() + .filter_map(|name| { + new_row + .get(name) + .map(|value| (name.clone(), Self::sql_value_to_json(value))) + }) + .collect(); + + // Every column of the original row identifies it. Rows that are + // identical in every column compute the same new values, so + // matching more than one is not a wrong answer. + let conditions: Vec = original + .iter() + .filter(|(name, _)| !name.contains('.')) + .map(|(name, value)| QueryCondition { + column: name.clone(), + operator: "=".to_string(), + value: Self::sql_value_to_json(value), + }) + .collect(); + + count += storage + .update_rows(table, set_values, conditions) + .await? + .max(0) as usize; + } + + match update.returning.as_deref() { + None => Ok(QueryResult::Update { count }), + Some(items) => self.project_returning(table, items, updated).await, + } + } + + /// Execute a write statement that carries a `RETURNING` clause. + /// + /// Returns `None` when the statement has no `RETURNING`, leaving the normal + /// write path in charge. The rows are projected from the affected rows — + /// read before a `DELETE`, and with the assignments applied for an + /// `UPDATE` — so the values reflect the statement, not the table's state + /// at some other moment. + /// + /// # Errors + /// Returns an error when storage cannot be read or the write fails. + async fn execute_with_returning(&self, sql: &str) -> ProtocolResult> { + use crate::protocols::postgres_wire::sql::ast::{ + Expression, InsertSource, Statement as AstStatement, + }; + use crate::protocols::postgres_wire::sql::parser::SqlParser; + + if self.persistent_storage.is_none() { + return Ok(None); + } + // A cheap reject before parsing: only writes come through here. + let leading = sql.trim_start(); + if !["INSERT", "UPDATE", "DELETE"].iter().any(|verb| { + leading.len() >= verb.len() && leading[..verb.len()].eq_ignore_ascii_case(verb) + }) { + return Ok(None); + } + + let Ok(statement) = SqlParser::new().parse(sql) else { + return Ok(None); + }; + + // `ON CONFLICT` is part of the statement, not of the VALUES list; the + // simple parser read it as more values and rejected the statement for + // a column-count mismatch. + if let AstStatement::Insert(insert) = &statement { + if insert.on_conflict.is_some() { + return self.insert_on_conflict(insert).await.map(Some); + } + } + + // `INSERT ... SELECT` inserted nothing and reported success, because + // the simple parser reads the source as a VALUES list and finds none. + if let AstStatement::Insert(insert) = &statement { + if let InsertSource::Query(query) = &insert.source { + return self + .insert_from_select( + &fold_identifier(&insert.table.full_name()), + insert.columns.as_deref(), + query, + insert.returning.as_deref(), + ) + .await + .map(Some); + } + } + + // An assignment that is not a literal has to be evaluated per row. + // Passing the text through stored `"amount + 1"` into the column. + if let AstStatement::Update(update) = &statement { + if update + .set + .iter() + .any(|assignment| !matches!(assignment.value, Expression::Literal(_))) + { + return self + .update_with_expressions(&fold_identifier(&update.table.full_name()), update) + .await + .map(Some); + } + } + + // Everything else here is only interesting for its RETURNING clause. + if !sql.to_uppercase().contains("RETURNING") { + return Ok(None); + } + + let (table, returning, where_clause, assignments, inserted) = match statement { + AstStatement::Insert(insert) => { + let Some(returning) = insert.returning else { + return Ok(None); + }; + let InsertSource::Values(tuples) = insert.source else { + return Ok(None); + }; + ( + fold_identifier(&insert.table.full_name()), + returning, + None, + Vec::new(), + Some((insert.columns.unwrap_or_default(), tuples)), + ) + } + AstStatement::Update(update) => { + let Some(returning) = update.returning else { + return Ok(None); + }; + ( + fold_identifier(&update.table.full_name()), + returning, + update.where_clause, + update.set, + None, + ) + } + AstStatement::Delete(delete) => { + let Some(returning) = delete.returning else { + return Ok(None); + }; + ( + fold_identifier(&delete.table.full_name()), + returning, + delete.where_clause, + Vec::new(), + None, + ) + } + _ => return Ok(None), + }; + + // Assemble the rows the statement affects, before it runs. + let mut evaluator = + crate::protocols::postgres_wire::sql::expression_evaluator::ExpressionEvaluator::new(); + let affected = match &inserted { + Some((columns, tuples)) => { + let names: Vec = columns.iter().map(|name| fold_identifier(name)).collect(); + let context = crate::protocols::postgres_wire::sql::expression_evaluator::EvaluationContext::empty(); + let mut rows = Vec::with_capacity(tuples.len()); + for tuple in tuples { + let mut row = crate::protocols::postgres_wire::sql::select_pipeline::Row::new(); + for (name, expression) in names.iter().zip(tuple) { + let value = evaluator.evaluate(expression, &context)?; + row.insert(format!("{table}.{name}"), value.clone()); + row.insert(name.clone(), value); + } + rows.push(row); + } + rows + } + None => { + let from = crate::protocols::postgres_wire::sql::ast::FromClause::Table { + name: crate::protocols::postgres_wire::sql::ast::TableName { + schema: None, + name: table.clone(), + }, + alias: None, + time_travel: None, + }; + let Some((rows, _)) = self.rows_from_clause(&from, &HashMap::new()).await? else { + return Ok(None); + }; + Self::apply_assignments(&mut evaluator, rows, where_clause.as_ref(), &assignments)? + } + }; + + // Run the write itself, with the clause removed. The recursive call is + // cheap to reject: the stripped statement has no RETURNING. + let without_returning = Self::strip_returning(sql); + Box::pin(self.execute_query(&without_returning)).await?; + + self.project_returning(&table, &returning, affected) + .await + .map(Some) + } + + /// Project a `RETURNING` list over the rows a write affected. + /// + /// # Errors + /// Returns an error when an item cannot be evaluated. + async fn project_returning( + &self, + table: &str, + returning: &[crate::protocols::postgres_wire::sql::ast::SelectItem], + rows: Vec, + ) -> ProtocolResult { + use crate::protocols::postgres_wire::sql::ast::{ColumnRef, Expression, SelectItem}; + + // A wildcard is expanded from the schema rather than from the row's + // keys: a row carries each value twice, bare and table-qualified, so + // expanding over the keys would return every column twice and in hash + // order. + let expands_wildcard = returning + .iter() + .any(|item| matches!(item, SelectItem::Wildcard)); + let returning = match (expands_wildcard, self.persistent_storage.as_ref()) { + (true, Some(storage)) => match storage.get_table_schema(table).await? { + None => returning.to_vec(), + Some(schema) => schema + .columns + .iter() + .map(|column| SelectItem::Expression { + expr: Expression::Column(ColumnRef { + table: None, + name: fold_identifier(&column.name), + }), + alias: None, + }) + .collect(), + }, + _ => returning.to_vec(), + }; + + // A select with no FROM over rows already in hand is exactly what the + // pipeline's projection does. + let projection = crate::protocols::postgres_wire::sql::ast::SelectStatement { + with: None, + select_list: returning, + distinct: None, + from_clause: None, + where_clause: None, + group_by: None, + having: None, + order_by: None, + limit: None, + offset: None, + for_clause: None, + traverse: None, + set_operation: None, + }; + let (columns, values) = + crate::protocols::postgres_wire::sql::select_pipeline::run_select_values( + &projection, + rows, + )?; + + Ok(QueryResult::Select { + columns, + rows: values + .into_iter() + .map(|row| { + row.into_iter() + .map(|value| match value { + SqlValue::Null => None, + other => Some(other.to_postgres_string()), + }) + .collect() + }) + .collect(), + }) + } + + /// Keep the rows a predicate selects, with any assignments applied. + fn apply_assignments( + evaluator: &mut crate::protocols::postgres_wire::sql::expression_evaluator::ExpressionEvaluator, + rows: Vec, + where_clause: Option<&crate::protocols::postgres_wire::sql::ast::Expression>, + assignments: &[crate::protocols::postgres_wire::sql::ast::Assignment], + ) -> ProtocolResult> { + use crate::protocols::postgres_wire::sql::ast::AssignmentTarget; + use crate::protocols::postgres_wire::sql::expression_evaluator::EvaluationContext; + + let mut kept = Vec::new(); + for mut row in rows { + if let Some(predicate) = where_clause { + let context = EvaluationContext::with_row(row.clone()); + if !matches!( + evaluator.evaluate(predicate, &context)?, + SqlValue::Boolean(true) + ) { + continue; + } + } + for assignment in assignments { + let AssignmentTarget::Column(name) = &assignment.target else { + continue; + }; + let context = EvaluationContext::with_row(row.clone()); + let value = evaluator.evaluate(&assignment.value, &context)?; + row.insert(fold_identifier(name), value); + } + kept.push(row); + } + Ok(kept) + } + + /// Remove a trailing `RETURNING ...` clause from a statement. + fn strip_returning(sql: &str) -> String { + let upper = sql.to_uppercase(); + match upper.rfind(" RETURNING ") { + Some(index) => sql[..index].trim_end().trim_end_matches(';').to_string(), + None => sql.to_string(), + } + } + + /// 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; + + 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); + }; + + let Some((columns, rows)) = self.evaluate_select(&select, &HashMap::new()).await? else { + return Ok(None); + }; + + Ok(Some(QueryResult::Select { + columns, + rows: rows + .into_iter() + .map(|row| { + row.into_iter() + .map(|value| match value { + SqlValue::Null => None, + other => Some(other.to_postgres_string()), + }) + .collect() + }) + .collect(), + })) + } + + /// Evaluate a `SELECT` against storage, returning typed values. + /// + /// `ctes` carries the named queries a `WITH` clause introduced, so a + /// reference to one resolves to its rows rather than to a table that does + /// not exist. Returns `None` when the statement uses a shape this path does + /// not handle, leaving it to the engine that can. + /// + /// # Errors + /// Returns an error when storage cannot be read or an expression cannot be + /// evaluated. + #[allow(clippy::type_complexity)] + fn evaluate_select<'a>( + &'a self, + select: &'a crate::protocols::postgres_wire::sql::ast::SelectStatement, + ctes: &'a HashMap, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = ProtocolResult, Vec>)>>, + > + Send + + 'a, + >, + > { + use crate::protocols::postgres_wire::sql::ast::SetOperator; + use crate::protocols::postgres_wire::sql::select_pipeline; + + Box::pin(async move { + // A `WITH` clause adds names visible to this statement and to the + // queries nested inside it. + let mut scope = ctes.clone(); + let mut materialised: HashMap, Vec>)> = + HashMap::new(); + if let Some(with) = &select.with { + for cte in &with.ctes { + let name = fold_identifier(&cte.name); + let declared: Option> = cte.columns.as_ref().map(|columns| { + columns + .iter() + .map(|column| fold_identifier(column)) + .collect() + }); + + if with.recursive && Self::references_table(&cte.query, &name) { + // A recursive CTE is its non-recursive term plus every + // row the recursive term yields when applied to what is + // known so far, until it yields nothing new. + let rows = self + .evaluate_recursive_cte(&name, &cte.query, &scope, declared.as_deref()) + .await?; + match rows { + Some(rows) => { + materialised.insert(name.clone(), rows); + } + None => return Ok(None), + } + continue; + } + + // `WITH t(a, b) AS (...)` renames the query's output + // columns, so the rows have to be produced now rather than + // re-run later under their original names. + if let Some(columns) = declared { + let Some((_, values)) = self.evaluate_select(&cte.query, &scope).await? + else { + return Ok(None); + }; + materialised.insert(name.clone(), (columns, values)); + continue; + } + + scope.insert(name, (*cte.query).clone()); + } + } + let scope = scope; + + // A select with no FROM runs over exactly one empty row, which is + // what `SELECT 1` means. Sending it elsewhere gave a second + // rendering of values, where NULL came back as an empty string. + let (rows, column_order) = match select.from_clause.as_ref() { + None => ( + vec![crate::protocols::postgres_wire::sql::select_pipeline::Row::new()], + Vec::new(), + ), + // A recursive CTE has already been computed, so the FROM clause + // reads its rows rather than re-running a query. + Some(crate::protocols::postgres_wire::sql::ast::FromClause::Table { + name, + alias, + .. + }) if materialised.contains_key(&fold_identifier(&name.full_name())) => { + let key = fold_identifier(&name.full_name()); + let (columns, values) = materialised[&key].clone(); + let qualifier = alias + .as_ref() + .map(|a| fold_identifier(&a.name)) + .unwrap_or(key); + Self::rows_from_values(&columns, values, &qualifier) + } + Some(from) => match self.rows_from_clause(from, &scope).await? { + Some(assembled) => assembled, + None => 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 resolved = select.clone(); + + // A correlated subquery has a different answer per outer row, so + // resolving it once produces one wrong answer applied to every + // row. Those rows are filtered here, one at a time, and the + // pipeline then runs with the predicate already applied. + let rows = match resolved.where_clause.as_ref() { + Some(predicate) if Self::is_correlated(predicate) => { + let filtered = self.filter_correlated(predicate, rows).await?; + resolved.where_clause = None; + filtered + } + _ => { + if let Some(predicate) = resolved.where_clause.take() { + resolved.where_clause = Some(self.resolve_subqueries(predicate).await?); + } + rows + } + }; + + if let Some(having) = resolved.having.take() { + 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 + // from the tables involved, in declaration order. + let columns = if names.iter().any(|name| name == "*") { + column_order + } else { + names + }; + + let Some(set_operation) = &select.set_operation else { + return Ok(Some((columns, values))); + }; + + let Some((_, right)) = self.evaluate_select(&set_operation.right, &scope).await? else { + return Ok(None); + }; + + values = match set_operation.operator { + SetOperator::UnionAll => { + values.extend(right); + values + } + SetOperator::Union => { + values.extend(right); + deduplicate_rows(values) + } + SetOperator::IntersectAll => { + values.retain(|row| right.contains(row)); + values + } + SetOperator::Intersect => { + values.retain(|row| right.contains(row)); + deduplicate_rows(values) + } + SetOperator::ExceptAll => { + values.retain(|row| !right.contains(row)); + values + } + SetOperator::Except => { + values.retain(|row| !right.contains(row)); + deduplicate_rows(values) + } + }; + + Ok(Some((columns, values))) + }) + } + + /// Turn a nested select's output into rows the pipeline can read. + /// + /// Each value is keyed both bare and qualified by `qualifier`, so both + /// `id` and `t.id` resolve — the same convention stored tables use. + fn rows_from_values( + columns: &[String], + values: Vec>, + qualifier: &str, + ) -> ( + Vec, + Vec, + ) { + let names: Vec = columns.iter().map(|name| fold_identifier(name)).collect(); + let rows = values + .into_iter() + .map(|value_row| { + let mut row = crate::protocols::postgres_wire::sql::select_pipeline::Row::new(); + for (name, value) in names.iter().zip(value_row) { + row.insert(format!("{qualifier}.{name}"), value.clone()); + row.insert(name.clone(), value); + } + row + }) + .collect(); + (rows, names) + } + + /// Whether a select reads from a table of the given name. + /// + /// Used to tell a genuinely recursive CTE from one merely declared inside a + /// `WITH RECURSIVE`, which PostgreSQL also allows. + fn references_table( + select: &crate::protocols::postgres_wire::sql::ast::SelectStatement, + name: &str, + ) -> bool { + use crate::protocols::postgres_wire::sql::ast::FromClause; + + fn walk(from: &FromClause, name: &str) -> bool { + match from { + FromClause::Table { name: table, .. } => { + fold_identifier(&table.full_name()) == name + } + FromClause::Join { left, right, .. } => walk(left, name) || walk(right, name), + FromClause::Subquery { query, .. } => query + .from_clause + .as_ref() + .is_some_and(|from| walk(from, name)), + _ => false, + } + } + + let own = select + .from_clause + .as_ref() + .is_some_and(|from| walk(from, name)); + own || select + .set_operation + .as_ref() + .is_some_and(|operation| Self::references_table(&operation.right, name)) + } + + /// Evaluate `WITH RECURSIVE name AS (base UNION [ALL] recursive)`. + /// + /// The base term runs once; the recursive term then runs repeatedly over + /// the rows found so far until a round adds nothing. Returns `None` when + /// the statement is not in the shape this can evaluate. + /// + /// # Errors + /// Returns an error when a term cannot be evaluated, or when the recursion + /// does not settle within its bound. + #[allow(clippy::type_complexity)] + async fn evaluate_recursive_cte( + &self, + name: &str, + query: &crate::protocols::postgres_wire::sql::ast::SelectStatement, + scope: &HashMap, + declared: Option<&[String]>, + ) -> ProtocolResult, Vec>)>> { + use crate::protocols::postgres_wire::sql::ast::SetOperator; + + // The shape is `base UNION [ALL] recursive`; anything else is not a + // recursion this can run. + let Some(operation) = query.set_operation.as_ref() else { + return Ok(None); + }; + let distinct = matches!( + operation.operator, + SetOperator::Union | SetOperator::Intersect | SetOperator::Except + ); + + let mut base = query.clone(); + base.with = None; + base.set_operation = None; + let Some((columns, mut accumulated)) = self.evaluate_select(&base, scope).await? else { + return Ok(None); + }; + // `WITH RECURSIVE n(x) AS ...` names the columns; the base term's own + // names (`SELECT 1` yields `expr`) are not what the recursive term + // refers to. + let columns = match declared { + Some(declared) if declared.len() == columns.len() => declared.to_vec(), + _ => columns, + }; + + // Each round feeds the rows found so far back in under the CTE's name. + // A bound is kept so a recursion that does not settle fails loudly + // instead of running until the process is killed. + const MAX_ROUNDS: usize = 1_000; + let mut frontier = accumulated.clone(); + for round in 0..MAX_ROUNDS { + if frontier.is_empty() { + return Ok(Some((columns, accumulated))); + } + + let known = Self::rows_from_values(&columns, frontier.clone(), name); + let Some(produced) = self + .evaluate_recursive_term(&operation.right, scope, name, known.0) + .await? + else { + return Ok(None); + }; + + let fresh: Vec> = produced + .into_iter() + .filter(|row| !distinct || !accumulated.contains(row)) + .collect(); + accumulated.extend(fresh.clone()); + frontier = fresh; + + if round + 1 == MAX_ROUNDS { + return Err(ProtocolError::PostgresError(format!( + "recursive query '{name}' did not settle within {MAX_ROUNDS} rounds" + ))); + } + } + + Ok(Some((columns, accumulated))) + } + + /// Run one round of a recursive term against the rows found so far. + async fn evaluate_recursive_term( + &self, + term: &crate::protocols::postgres_wire::sql::ast::SelectStatement, + scope: &HashMap, + name: &str, + known: Vec, + ) -> ProtocolResult>>> { + use crate::protocols::postgres_wire::sql::ast::FromClause; + use crate::protocols::postgres_wire::sql::select_pipeline; + + // The term reads the CTE by name; those rows are supplied directly. + let reads_only_the_cte = matches!( + term.from_clause.as_ref(), + Some(FromClause::Table { name: table, .. }) + if fold_identifier(&table.full_name()) == name + ); + if !reads_only_the_cte { + // A join between the CTE and a stored table is assembled here. + let Some(from) = term.from_clause.as_ref() else { + return Ok(None); + }; + let Some((rows, _)) = self + .rows_from_clause_with(from, scope, name, &known) + .await? + else { + return Ok(None); + }; + let mut resolved = term.clone(); + resolved.set_operation = None; + let (_, values) = select_pipeline::run_select_values(&resolved, rows)?; + return Ok(Some(values)); + } + + let mut resolved = term.clone(); + resolved.set_operation = None; + let (_, values) = select_pipeline::run_select_values(&resolved, known)?; + Ok(Some(values)) + } + + /// Assemble a FROM clause where one table name stands for supplied rows. + #[allow(clippy::type_complexity)] + fn rows_from_clause_with<'a>( + &'a self, + from: &'a crate::protocols::postgres_wire::sql::ast::FromClause, + scope: &'a HashMap, + name: &'a str, + known: &'a [crate::protocols::postgres_wire::sql::select_pipeline::Row], + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = ProtocolResult< + Option<( + Vec, + Vec, + )>, + >, + > + Send + + 'a, + >, + > { + use crate::protocols::postgres_wire::sql::ast::{FromClause, JoinType}; + + Box::pin(async move { + match from { + FromClause::Table { name: table, .. } + if fold_identifier(&table.full_name()) == name => + { + Ok(Some((known.to_vec(), Vec::new()))) + } + FromClause::Join { + left, + join_type, + right, + condition, + } => { + let Some((left_rows, _)) = + self.rows_from_clause_with(left, scope, name, known).await? + else { + return Ok(None); + }; + let Some((right_rows, _)) = self + .rows_from_clause_with(right, scope, name, known) + .await? + else { + return Ok(None); + }; + let Some(joined) = + Self::join_rows(&left_rows, &right_rows, join_type, condition)? + else { + return Ok(None); + }; + Ok(Some((joined, Vec::new()))) + } + other => { + let _ = JoinType::Inner; + self.rows_from_clause(other, scope).await + } + } + }) + } + + /// Whether an expression contains a subquery that reads the outer row. + /// + /// A qualified column whose qualifier is not one of the subquery's own + /// sources can only come from outside it. + fn is_correlated(expr: &crate::protocols::postgres_wire::sql::ast::Expression) -> bool { + use crate::protocols::postgres_wire::sql::ast::{Expression, InList}; + + match expr { + Expression::Subquery(select) | Expression::Exists(select) => { + Self::reads_outer_columns(select) + } + Expression::In { + list: InList::Subquery(select), + .. + } => Self::reads_outer_columns(select), + Expression::Binary { left, right, .. } => { + Self::is_correlated(left) || Self::is_correlated(right) + } + Expression::Unary { operand, .. } => Self::is_correlated(operand), + _ => false, + } + } + + /// Whether a select's predicate names a table it does not read from. + fn reads_outer_columns( + select: &crate::protocols::postgres_wire::sql::ast::SelectStatement, + ) -> bool { + use crate::protocols::postgres_wire::sql::ast::{Expression, FromClause}; + + fn sources(from: &FromClause, into: &mut Vec) { + match from { + FromClause::Table { name, alias, .. } => { + into.push(fold_identifier(&name.full_name())); + if let Some(alias) = alias { + into.push(fold_identifier(&alias.name)); + } + } + FromClause::Join { left, right, .. } => { + sources(left, into); + sources(right, into); + } + _ => {} + } + } + + fn qualifiers(expr: &Expression, into: &mut Vec) { + match expr { + Expression::Column(column) => { + if let Some(table) = &column.table { + into.push(fold_identifier(table)); + } + } + Expression::Binary { left, right, .. } => { + qualifiers(left, into); + qualifiers(right, into); + } + Expression::Unary { operand, .. } => qualifiers(operand, into), + Expression::Function(call) => { + for arg in &call.args { + qualifiers(arg, into); + } + } + _ => {} + } + } + + let mut own = Vec::new(); + if let Some(from) = &select.from_clause { + sources(from, &mut own); + } + let mut referenced = Vec::new(); + if let Some(predicate) = &select.where_clause { + qualifiers(predicate, &mut referenced); + } + referenced.iter().any(|name| !own.contains(name)) + } + + /// Keep the rows a predicate containing a correlated subquery selects. + /// + /// The outer row's values are substituted into the subquery before it + /// runs, so each row is judged against its own answer. + /// + /// # Errors + /// Returns an error when a subquery cannot be run or the predicate cannot + /// be evaluated. + async fn filter_correlated( + &self, + predicate: &crate::protocols::postgres_wire::sql::ast::Expression, + rows: Vec, + ) -> ProtocolResult> { + use crate::protocols::postgres_wire::sql::expression_evaluator::{ + EvaluationContext, ExpressionEvaluator, + }; + + let mut evaluator = ExpressionEvaluator::new(); + let mut kept = Vec::with_capacity(rows.len()); + for row in rows { + let bound = Self::bind_outer_row(predicate.clone(), &row); + let resolved = self.resolve_subqueries(bound).await?; + let context = EvaluationContext::with_row(row.clone()); + if matches!( + evaluator.evaluate(&resolved, &context)?, + SqlValue::Boolean(true) + ) { + kept.push(row); + } + } + Ok(kept) + } + + /// Replace qualified column references the outer row can answer. + /// + /// Only qualified names are substituted: a bare name inside a subquery + /// belongs to the subquery's own table, which PostgreSQL resolves first. + fn bind_outer_row( + expr: crate::protocols::postgres_wire::sql::ast::Expression, + row: &crate::protocols::postgres_wire::sql::select_pipeline::Row, + ) -> crate::protocols::postgres_wire::sql::ast::Expression { + use crate::protocols::postgres_wire::sql::ast::{Expression, InList}; + + match expr { + Expression::Column(ref column) => match &column.table { + Some(table) => { + let key = format!( + "{}.{}", + fold_identifier(table), + fold_identifier(&column.name) + ); + match row.get(&key) { + Some(value) => Expression::Literal(value.clone()), + None => expr, + } + } + None => expr, + }, + Expression::Binary { + left, + operator, + right, + } => Expression::Binary { + left: Box::new(Self::bind_outer_row(*left, row)), + operator, + right: Box::new(Self::bind_outer_row(*right, row)), + }, + Expression::Unary { operator, operand } => Expression::Unary { + operator, + operand: Box::new(Self::bind_outer_row(*operand, row)), + }, + Expression::Subquery(select) => { + Expression::Subquery(Box::new(Self::bind_outer_select(*select, row))) + } + Expression::Exists(select) => { + Expression::Exists(Box::new(Self::bind_outer_select(*select, row))) + } + Expression::In { + expr: inner, + list: InList::Subquery(select), + negated, + } => Expression::In { + expr: Box::new(Self::bind_outer_row(*inner, row)), + list: InList::Subquery(Box::new(Self::bind_outer_select(*select, row))), + negated, + }, + other => other, + } + } + + /// Bind the outer row into a subquery. + /// + /// The select list is bound as well as the predicate: a `LATERAL` subquery + /// most often reads the outer row in what it projects, as in + /// `LATERAL (SELECT a.amount * 2)`. + fn bind_outer_select( + mut select: crate::protocols::postgres_wire::sql::ast::SelectStatement, + row: &crate::protocols::postgres_wire::sql::select_pipeline::Row, + ) -> crate::protocols::postgres_wire::sql::ast::SelectStatement { + use crate::protocols::postgres_wire::sql::ast::SelectItem; + + if let Some(predicate) = select.where_clause.take() { + select.where_clause = Some(Self::bind_outer_row(predicate, row)); + } + select.select_list = select + .select_list + .into_iter() + .map(|item| match item { + SelectItem::Expression { expr, alias } => SelectItem::Expression { + expr: Self::bind_outer_row(expr, row), + alias, + }, + other => other, + }) + .collect(); + select + } + + /// 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, &HashMap::new()).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, + ctes: &'a HashMap, + ) -> 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; + 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, .. } => { + // Tables live in one namespace here, so an explicit + // `public.` qualifier names the same table as a bare name. + let table = fold_identifier(&name.full_name()); + let table = table + .strip_prefix("public.") + .map_or(table.clone(), str::to_string); + + // A `WITH` name shadows storage: it is a query, not a table. + if let Some(cte) = ctes.get(&table) { + let Some((columns, values)) = self.evaluate_select(cte, ctes).await? else { + return Ok(None); + }; + let qualifier = alias + .as_ref() + .map(|a| fold_identifier(&a.name)) + .unwrap_or_else(|| table.clone()); + return Ok(Some(Self::rows_from_values(&columns, values, &qualifier))); + } + + let Some(schema) = storage.get_table_schema(&table).await? else { + // A catalogue relation is generated, not stored. It has + // to be reachable from here as well as from the plain + // select path: `SELECT relname FROM pg_class LIMIT 1` + // carries a clause and so arrives here. + if let Some(QueryResult::Select { columns, rows }) = self + .select_system_catalog(&table, &["*".to_string()]) + .await? + { + let values = rows + .into_iter() + .map(|row| { + row.into_iter() + .map(|value| value.map_or(SqlValue::Null, SqlValue::Text)) + .collect() + }) + .collect(); + let qualifier = alias + .as_ref() + .map(|a| fold_identifier(&a.name)) + .unwrap_or_else(|| table.clone()); + return Ok(Some(Self::rows_from_values(&columns, values, &qualifier))); + } + + // Not a table — it may be a view, which is a stored + // query rather than stored rows. + let Some(definition) = self.view_definition(&table).await? else { + return Ok(None); + }; + let parsed = crate::protocols::postgres_wire::sql::parser::SqlParser::new() + .parse(&definition)?; + let crate::protocols::postgres_wire::sql::ast::Statement::Select(view) = + parsed + else { + return Ok(None); + }; + let Some((columns, values)) = self.evaluate_select(&view, ctes).await? + else { + return Ok(None); + }; + let qualifier = alias + .as_ref() + .map(|a| fold_identifier(&a.name)) + .unwrap_or_else(|| table.clone()); + return Ok(Some(Self::rows_from_values(&columns, values, &qualifier))); + }; + + // 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()); + + note_read(&table); + let stored = storage + .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 + .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); + } + Ok(out) + }) + .collect::>>()?; + + let order = schema + .columns + .iter() + .map(|column| fold_identifier(&column.name)) + .collect(); + + Ok(Some((rows, order))) + } + + // A derived table: `FROM (SELECT ...) alias`. Its rows come + // from running the inner select, not from a stored table. + FromClause::Subquery { query, alias, .. } => { + // A `LATERAL` subquery may read the rows to its left. It is + // handled at the join, where those rows are known; on its + // own it is an ordinary derived table. + let Some((columns, values)) = self.evaluate_select(query, ctes).await? else { + return Ok(None); + }; + Ok(Some(Self::rows_from_values( + &columns, + values, + &fold_identifier(&alias.name), + ))) + } + + FromClause::Join { + left, + join_type, + right, + condition, + } => { + let Some((left_rows, mut order)) = self.rows_from_clause(left, ctes).await? + else { + return Ok(None); + }; + let left_order_len = order.len(); + // `LATERAL` re-evaluates its subquery for each row on the + // left, which is the whole point of the keyword: without + // it the subquery cannot refer to those rows. + if let FromClause::Subquery { + query, + alias, + lateral: true, + } = right.as_ref() + { + let qualifier = fold_identifier(&alias.name); + let mut joined = Vec::new(); + for left_row in &left_rows { + let bound = Self::bind_outer_select((**query).clone(), left_row); + let Some((columns, values)) = + self.evaluate_select(&bound, ctes).await? + else { + return Ok(None); + }; + let (right_rows, right_order) = + Self::rows_from_values(&columns, values, &qualifier); + if order.len() == left_order_len { + order.extend(right_order); + } + match Self::join_rows( + std::slice::from_ref(left_row), + &right_rows, + join_type, + condition, + )? { + Some(rows) => joined.extend(rows), + None => return Ok(None), + } + } + return Ok(Some((joined, order))); + } + + let Some((right_rows, right_order)) = + self.rows_from_clause(right, ctes).await? + else { + return Ok(None); + }; + order.extend(right_order); + + match Self::join_rows(&left_rows, &right_rows, join_type, condition)? { + Some(joined) => Ok(Some((joined, order))), + None => Ok(None), + } + } + + _ => Ok(None), + } + }) + } + + /// Combine two row sets under a join condition. + /// + /// Returns `None` when the condition cannot be resolved. + /// + /// # Errors + /// Returns an error when the join condition cannot be evaluated. + #[allow(clippy::type_complexity)] + fn join_rows( + left_rows: &[crate::protocols::postgres_wire::sql::select_pipeline::Row], + right_rows: &[crate::protocols::postgres_wire::sql::select_pipeline::Row], + join_type: &crate::protocols::postgres_wire::sql::ast::JoinType, + condition: &crate::protocols::postgres_wire::sql::ast::JoinCondition, + ) -> ProtocolResult>> + { + use crate::protocols::postgres_wire::sql::ast::{JoinCondition, JoinType}; + use crate::protocols::postgres_wire::sql::expression_evaluator::{ + EvaluationContext, ExpressionEvaluator, + }; + + 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_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; + // 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) + }), + // A natural join matches on every column name the two + // sides share. Bare keys only: the qualified duplicates + // each row carries would otherwise never match. + JoinCondition::Natural => { + let shared: Vec<&String> = left_row + .keys() + .filter(|key| !key.contains('.') && right_row.contains_key(*key)) + .collect(); + !shared.is_empty() + && shared + .iter() + .all(|key| left_row.get(*key) == right_row.get(*key)) + } + }; + + if keep || matches!(join_type, JoinType::Cross) { + matched = true; + right_matched[right_index] = true; + joined.push(combined); + } + } + + // 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)); + } + } + } + + Ok(Some(joined)) + } + + /// 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 { + // 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 => { + 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; + } + + // Anything in the select list that is not a bare column: a call such + // as `COUNT(*)`, an operator such as `name || '!'`, a literal, a + // subquery. This parser treats the projection as column names to look + // up, so it returned a NULL column named after the expression instead + // of evaluating it. + let projection_end = upper.find(" FROM ").unwrap_or(upper.len()); + let projection = upper[..projection_end] + .trim() + .strip_prefix("SELECT") + .unwrap_or_default(); + !Self::projection_is_plain_columns(projection) || upper.contains("(SELECT ") + } + + /// Whether a select list is only column names, `*`, or qualified names. + /// + /// Anything else has to be evaluated rather than looked up. + fn projection_is_plain_columns(projection: &str) -> bool { + let projection = projection.trim(); + !projection.is_empty() + && projection.split(',').all(|item| { + let item = item.trim(); + !item.is_empty() + && (item == "*" + || item.chars().all(|character| { + character.is_alphanumeric() + || matches!(character, '_' | '.' | '"' | '*') + })) + }) + } + + /// 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(); + + if parts.len() < 4 || parts[0].to_uppercase() != "SELECT" { + return Err(ProtocolError::PostgresError( + "Invalid SELECT syntax".to_string(), + )); + } + + // Find FROM + let from_idx = parts + .iter() + .position(|&p| p.to_uppercase() == "FROM") + .ok_or_else(|| ProtocolError::PostgresError("Missing FROM clause".to_string()))?; + + // Parse columns + let columns_str = parts[1..from_idx].join(" "); + let columns: Vec = if columns_str == "*" { + vec!["*".to_string()] + } else { + columns_str + .split(',') + .map(|s| s.trim().to_string()) + .collect() + }; + + // Parse table - convert to uppercase and trim semicolon + let table = fold_identifier(parts[from_idx + 1]); + + // Parse WHERE clause if present + let where_idx = parts.iter().position(|&p| p.to_uppercase() == "WHERE"); + let where_clause = if let Some(idx) = where_idx { + Some(self.parse_where_clause(&parts[idx + 1..])?) + } else { + None + }; + + Ok(Statement::Select { + columns, + table, + where_clause, + }) + } + + /// Parse INSERT statement + fn parse_insert(&self, sql: &str) -> ProtocolResult { + // Simple parser: INSERT INTO table (columns) VALUES (values), (values)... + let sql_upper = sql.to_uppercase(); + + if !sql_upper.contains("INSERT INTO") || !sql_upper.contains("VALUES") { + return Err(ProtocolError::PostgresError( + "Invalid INSERT syntax".to_string(), + )); + } + + // Find positions using uppercase version. Each of these was an + // `unwrap`: a statement without a column list panicked the connection's + // task rather than reporting a syntax error. + let invalid = || ProtocolError::PostgresError("Invalid INSERT syntax".to_string()); + let table_start = sql_upper.find("INTO").ok_or_else(invalid)? + 4; + let table_end = sql_upper[table_start..].find('(').ok_or_else(invalid)? + table_start; + let col_start = table_end + 1; + let col_end = sql_upper[col_start..].find(')').ok_or_else(invalid)? + col_start; + let val_keyword_pos = sql_upper.find("VALUES").ok_or_else(invalid)? + 6; + + // Extract data using original SQL to preserve case + 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(fold_identifier) + .collect(); + + // Parse values list: (v1, v2), (v3, v4) + let values_str = sql[val_keyword_pos..].trim(); + let values = self.parse_values_list(values_str); + + Ok(Statement::Insert { + table, + columns, + values, + }) + } + + /// Parse list of value groups: (v1, v2), (v3, v4) + fn parse_values_list(&self, values_str: &str) -> Vec> { + let mut rows = Vec::new(); + let mut current_row_str = String::new(); + let mut in_quotes = false; + let mut quote_char = '\0'; + let mut paren_depth = 0; + let chars: Vec = values_str.chars().collect(); + let mut i = 0; + + while i < chars.len() { + let ch = chars[i]; + match ch { + '\'' | '"' if !in_quotes => { + in_quotes = true; + quote_char = ch; + if paren_depth > 0 { + current_row_str.push(ch); + } + } + c if in_quotes && c == quote_char => { + in_quotes = false; + if paren_depth > 0 { + current_row_str.push(ch); + } + } + '(' if !in_quotes => { + paren_depth += 1; + if paren_depth > 1 { + current_row_str.push(ch); + } + } + ')' if !in_quotes => { + paren_depth -= 1; + if paren_depth > 0 { + current_row_str.push(ch); + } else if paren_depth == 0 { + // End of a row + if !current_row_str.trim().is_empty() { + rows.push(self.parse_csv_values(¤t_row_str)); + } + current_row_str.clear(); + } + } + ',' if !in_quotes && paren_depth == 0 => { + // Separator between rows, ignore + } + _ => { + if paren_depth > 0 { + current_row_str.push(ch); + } + } + } + i += 1; + } + rows + } + + /// Parse UPDATE statement + fn parse_update(&self, sql: &str) -> ProtocolResult { + // Simple parser: UPDATE table SET col=val [WHERE condition] + let parts: Vec<&str> = sql.split_whitespace().collect(); + + if parts.len() < 4 || parts[0].to_uppercase() != "UPDATE" { + return Err(ProtocolError::PostgresError( + "Invalid UPDATE syntax".to_string(), + )); + } + + let table = fold_identifier(parts[1]); + + // Find SET + let set_idx = parts + .iter() + .position(|&p| p.to_uppercase() == "SET") + .ok_or_else(|| ProtocolError::PostgresError("Missing SET clause".to_string()))?; + + // Find WHERE or end + let where_idx = parts.iter().position(|&p| p.to_uppercase() == "WHERE"); + let set_end = where_idx.unwrap_or(parts.len()); + + // Parse SET clauses safely with JSON support + let set_str = parts[set_idx + 1..set_end].join(" "); + let set_clauses = self.parse_set_clauses(&set_str); + + // Parse WHERE clause + let where_clause = if let Some(idx) = where_idx { + Some(self.parse_where_clause(&parts[idx + 1..])?) + } else { + None + }; + + Ok(Statement::Update { + table, + set_clauses, + where_clause, + }) + } + + /// Parse DELETE statement + fn parse_delete(&self, sql: &str) -> ProtocolResult { + // Simple parser: DELETE FROM table [WHERE condition] + let parts: Vec<&str> = sql.split_whitespace().collect(); + + if parts.len() < 3 || parts[0].to_uppercase() != "DELETE" { + return Err(ProtocolError::PostgresError( + "Invalid DELETE syntax".to_string(), + )); + } + + // Find FROM + let from_idx = parts + .iter() + .position(|&p| p.to_uppercase() == "FROM") + .ok_or_else(|| ProtocolError::PostgresError("Missing FROM clause".to_string()))?; + + let table = fold_identifier(parts[from_idx + 1]); + + // Parse WHERE clause + let where_idx = parts.iter().position(|&p| p.to_uppercase() == "WHERE"); + let where_clause = if let Some(idx) = where_idx { + Some(self.parse_where_clause(&parts[idx + 1..])?) + } else { + None + }; + + Ok(Statement::Delete { + table, + where_clause, + }) + } + + /// Parse SET clauses respecting quotes and JSON braces + fn parse_set_clauses(&self, set_str: &str) -> Vec<(String, String)> { + let mut clauses = Vec::new(); + let mut current_clause = String::new(); + let mut in_quotes = false; + let mut quote_char = '\0'; + let mut brace_depth = 0; + let chars: Vec = set_str.chars().collect(); + let mut i = 0; + + while i < chars.len() { + let ch = chars[i]; + + match ch { + '\'' | '"' if !in_quotes => { + in_quotes = true; + quote_char = ch; + current_clause.push(ch); + } + c if in_quotes && c == quote_char => { + in_quotes = false; + current_clause.push(ch); + } + '{' if !in_quotes => { + brace_depth += 1; + current_clause.push(ch); + } + '}' if !in_quotes => { + brace_depth -= 1; + current_clause.push(ch); + } + ',' if !in_quotes && brace_depth == 0 => { + // Found a separator - parse the current clause + if let Some(parsed) = self.parse_single_set_clause(¤t_clause) { + clauses.push(parsed); + } + current_clause.clear(); + } + _ => { + current_clause.push(ch); + } + } + i += 1; + } + + // Add the last clause + if !current_clause.is_empty() { + if let Some(parsed) = self.parse_single_set_clause(¤t_clause) { + clauses.push(parsed); + } + } + + clauses + } + + /// 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().to_string(); + Some((key, value)) + } + + /// Parse CSV values respecting quotes and JSON braces + fn parse_csv_values(&self, values_str: &str) -> Vec { + let mut values = Vec::new(); + let mut current_value = String::new(); + let mut in_quotes = false; + let mut quote_char = '\0'; + let mut brace_depth = 0; + let chars: Vec = values_str.chars().collect(); + let mut i = 0; + + while i < chars.len() { + let ch = chars[i]; match ch { '\'' | '"' if !in_quotes => { in_quotes = true; quote_char = ch; - current_clause.push(ch); + current_value.push(ch); } c if in_quotes && c == quote_char => { in_quotes = false; - current_clause.push(ch); + current_value.push(ch); } '{' if !in_quotes => { brace_depth += 1; - current_clause.push(ch); + current_value.push(ch); + } + '}' if !in_quotes => { + brace_depth -= 1; + current_value.push(ch); + } + ',' if !in_quotes && brace_depth == 0 => { + // 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(); + } + _ => { + current_value.push(ch); + } + } + i += 1; + } + + // Add the last value + 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. + /// Store a value as JSON, keeping its type rather than its rendering. + /// + /// Going through a SQL literal and back would turn an integer into the + /// string `"11"`, which then compares as text. + fn sql_value_to_json( + value: &crate::protocols::postgres_wire::sql::types::SqlValue, + ) -> JsonValue { + use crate::protocols::postgres_wire::sql::types::SqlValue; + + match value { + SqlValue::Null => JsonValue::Null, + SqlValue::Boolean(b) => JsonValue::Bool(*b), + SqlValue::SmallInt(i) => JsonValue::Number((*i).into()), + SqlValue::Integer(i) => JsonValue::Number((*i).into()), + SqlValue::BigInt(i) => JsonValue::Number((*i).into()), + SqlValue::Real(f) => serde_json::Number::from_f64(f64::from(*f)) + .map_or(JsonValue::Null, JsonValue::Number), + 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()), + } + } + + 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. + /// 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(); + + 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 { + if parts.len() < 3 { + return Err(ProtocolError::PostgresError( + "Invalid WHERE clause".to_string(), + )); + } + + // Conjuncts are separate conditions. Reading the whole clause as one + // made `WHERE a = 1 AND b = 2` compare `a` against the text + // `1 AND b = 2`, which matches nothing — so an `UPDATE` or `DELETE` + // with two conditions silently changed no rows and reported success. + let text = parts.join(" "); + let text = text.trim_end_matches(';').trim(); + + // Conditions are combined with AND; an OR cannot be expressed as a + // list of them, so it is refused rather than quietly mis-read. + if Self::splits_on_keyword(text, "OR").len() > 1 { + return Err(ProtocolError::PostgresError( + "statement uses a clause this parser does not implement".to_string(), + )); + } + + let conditions = Self::splits_on_keyword(text, "AND") + .into_iter() + .map(|conjunct| { + let words: Vec<&str> = conjunct.split_whitespace().collect(); + if words.len() < 3 { + return Err(ProtocolError::PostgresError( + "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, + }) + }) + .collect::>>()?; + + Ok(WhereClause { conditions }) + } + + /// Split a predicate on a keyword that is not inside quotes or brackets. + fn splits_on_keyword(text: &str, keyword: &str) -> Vec { + let upper = text.to_uppercase(); + let padded = format!(" {keyword} "); + let mut parts = Vec::new(); + let mut start = 0usize; + let mut depth = 0usize; + let mut quote: Option = None; + + let bytes: Vec = text.chars().collect(); + let upper_bytes: Vec = upper.chars().collect(); + let needle: Vec = padded.chars().collect(); + + let mut index = 0usize; + while index < bytes.len() { + let character = bytes[index]; + match quote { + Some(open) => { + if character == open { + quote = None; + } + } + None => match character { + '\'' | '"' => quote = Some(character), + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + _ => { + if depth == 0 + && index + needle.len() <= upper_bytes.len() + && upper_bytes[index..index + needle.len()] == needle[..] + { + parts.push(bytes[start..index].iter().collect::()); + index += needle.len(); + start = index; + continue; + } + } + }, + } + index += 1; + } + parts.push(bytes[start..].iter().collect::()); + parts + .into_iter() + .map(|part| part.trim().to_string()) + .filter(|part| !part.is_empty()) + .collect() + } + + /// Parse CREATE TABLE statement + fn parse_create_table(&self, sql: &str) -> ProtocolResult { + // Simple parser: CREATE TABLE [IF NOT EXISTS] table_name (column_definitions) + let sql_upper = sql.to_uppercase(); + + // Check for IF NOT EXISTS + let if_not_exists = sql_upper.contains("IF NOT EXISTS"); + + // Find table name. These were `unwrap`s: `CREATE TABLE t AS SELECT ...` + // has no `(`, so an ordinary statement panicked the connection's task + // and every later statement on it failed with "connection closed". + let table_start = if if_not_exists { + sql_upper.find("EXISTS").map(|at| at + 6) + } else { + sql_upper.find("TABLE").map(|at| at + 5) + } + .ok_or_else(|| ProtocolError::PostgresError("Invalid CREATE TABLE syntax".to_string()))?; + + let table_end = sql[table_start..] + .find('(') + .map(|at| at + table_start) + .ok_or_else(|| { + ProtocolError::PostgresError( + "Invalid CREATE TABLE syntax: expected a column list".to_string(), + ) + })?; + let table_name = fold_identifier(&sql[table_start..table_end]); + + // Find column definitions between parentheses + let col_start = table_end + 1; + let col_end = sql.rfind(')').ok_or_else(|| { + ProtocolError::PostgresError( + "Invalid CREATE TABLE syntax: missing closing parenthesis".to_string(), + ) + })?; + + let column_defs_str = &sql[col_start..col_end]; + let mut columns: Vec = Vec::new(); + + // Split by commas and parse each column definition + // Table-level clauses — `PRIMARY KEY (a)`, `UNIQUE (a)`, + // `FOREIGN KEY (a) REFERENCES t(b)`, `CHECK (...)`, optionally named + // with `CONSTRAINT` — are collected here and applied to the columns + // they name. Splitting on commas alone read them as columns called + // `PRIMARY`, so the constraint was silently dropped. + let mut table_unique: Vec = Vec::new(); + let mut foreign_keys: Vec = + Vec::new(); + let mut table_checks: Vec = Vec::new(); + + for col_def in Self::split_column_definitions(column_defs_str) { + let col_def = col_def.trim(); + let without_name = col_def + .strip_prefix("CONSTRAINT ") + .or_else(|| col_def.strip_prefix("constraint ")) + .and_then(|rest| rest.split_once(char::is_whitespace).map(|(_, rest)| rest)) + .unwrap_or(col_def) + .trim(); + let upper = without_name.to_uppercase(); + + if upper.starts_with("PRIMARY KEY") || upper.starts_with("UNIQUE") { + if let Some(columns) = Self::parenthesised(without_name) { + table_unique.extend(columns.split(',').map(fold_identifier)); + } + continue; + } + if upper.starts_with("FOREIGN KEY") { + if let Some(key) = Self::parse_foreign_key(without_name) { + foreign_keys.push(key); + } + continue; + } + if upper.starts_with("CHECK") { + if let Some(predicate) = Self::parenthesised(without_name) { + table_checks.push(predicate.to_string()); + } + continue; + } + + let parts: Vec<&str> = col_def.split_whitespace().collect(); + + if parts.len() >= 2 { + let name = parts[0].to_string(); + let data_type = parts[1].to_string(); + let constraints = parts[2..].iter().map(|s| s.to_string()).collect(); + + columns.push(SimpleColumnDef { + name, + data_type, + constraints, + }); + } + } + + // A table-level clause is written onto the column it names, so the + // executor sees one uniform description of each column. + for column in &mut columns { + let name = fold_identifier(&column.name); + if table_unique.contains(&name) { + column.constraints.push("PRIMARY".to_string()); + column.constraints.push("KEY".to_string()); + } + } + // A table-level CHECK may name any column, so it goes on the first + // one; the evaluator sees the whole row either way. + if let (Some(check), Some(column)) = (table_checks.first(), columns.first_mut()) { + column.constraints.push("CHECK".to_string()); + column.constraints.push(format!("({check})")); + } + + Ok(Statement::CreateTable { + table: table_name, + columns, + if_not_exists, + foreign_keys, + }) + } + + /// Parse a `FOREIGN KEY (...) REFERENCES t(...) [ON DELETE ...] [ON UPDATE ...]` + /// clause, or the column-level `REFERENCES t(c)` form. + fn parse_foreign_key( + text: &str, + ) -> Option { + use crate::protocols::postgres_wire::persistent_storage::{ForeignKey, ReferentialAction}; + + let upper = text.to_uppercase(); + let references_at = upper.find("REFERENCES")?; + + let columns: Vec = if upper.starts_with("FOREIGN KEY") { + Self::parenthesised(&text[..references_at])? + .split(',') + .map(fold_identifier) + .collect() + } else { + Vec::new() + }; + + let target = text[references_at + "REFERENCES".len()..].trim(); + let (table, referenced) = match target.split_once('(') { + Some((table, rest)) => { + let close = rest.find(')')?; + ( + fold_identifier(table), + rest[..close].split(',').map(fold_identifier).collect(), + ) + } + None => ( + fold_identifier(target.split_whitespace().next().unwrap_or(target)), + Vec::new(), + ), + }; + + // `ON DELETE`/`ON UPDATE` follow the reference; the default is the + // standard NO ACTION. + let action_after = |keyword: &str| -> ReferentialAction { + let Some(at) = upper.find(keyword) else { + return ReferentialAction::NoAction; + }; + let rest = upper[at + keyword.len()..].trim_start(); + if rest.starts_with("CASCADE") { + ReferentialAction::Cascade + } else if rest.starts_with("SET NULL") { + ReferentialAction::SetNull + } else if rest.starts_with("SET DEFAULT") { + ReferentialAction::SetDefault + } else if rest.starts_with("RESTRICT") { + ReferentialAction::Restrict + } else { + ReferentialAction::NoAction + } + }; + + use crate::protocols::postgres_wire::persistent_storage::MatchType; + let match_type = if upper.contains("MATCH FULL") { + MatchType::Full + } else if upper.contains("MATCH PARTIAL") { + MatchType::Partial + } else { + MatchType::Simple + }; + + Some(ForeignKey { + columns, + table, + referenced, + on_delete: action_after("ON DELETE"), + on_update: action_after("ON UPDATE"), + deferrable: upper.contains("DEFERRABLE") && !upper.contains("NOT DEFERRABLE"), + match_type, + }) + } + + /// Split a column-definition list on commas that are not inside brackets. + /// + /// `CHECK (a > 0)` and `FOREIGN KEY (a, b)` contain commas of their own; a + /// plain split cut them in half. + fn split_column_definitions(text: &str) -> Vec { + let mut parts = Vec::new(); + let mut current = String::new(); + let mut depth = 0usize; + + for character in text.chars() { + match character { + '(' => { + depth += 1; + current.push(character); + } + ')' => { + depth = depth.saturating_sub(1); + current.push(character); + } + ',' if depth == 0 => { + parts.push(std::mem::take(&mut current)); + } + other => current.push(other), + } + } + if !current.trim().is_empty() { + parts.push(current); + } + parts + } + + /// The text inside the first bracketed group of `text`. + /// + /// The closing bracket is the one that matches the opening one, not the + /// last in the string: `FOREIGN KEY (a) REFERENCES t(b)` has two groups, + /// and taking the last `)` returned `a) REFERENCES t(b`. + fn parenthesised(text: &str) -> Option<&str> { + let open = text.find('(')?; + let mut depth = 0usize; + for (offset, character) in text[open..].char_indices() { + match character { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + return Some(text[open + 1..open + offset].trim()); + } + } + _ => {} + } + } + None + } + + /// Parse DROP TABLE statement + fn parse_drop_table(&self, sql: &str) -> ProtocolResult { + // Simple parser: DROP TABLE [IF EXISTS] table_name + let sql_upper = sql.to_uppercase(); + + // Check for IF EXISTS + let if_exists = sql_upper.contains("IF EXISTS"); + + // Find table name + let table_start = if if_exists { + sql_upper.find("EXISTS").map(|at| at + 6) + } else { + sql_upper.find("TABLE").map(|at| at + 5) + } + .ok_or_else(|| ProtocolError::PostgresError("Invalid DROP TABLE syntax".to_string()))?; + + let table_name = fold_identifier(&sql[table_start..]); + + Ok(Statement::DropTable { + table: table_name, + if_exists, + }) + } + + /// Execute SELECT query on actors table + async fn execute_actor_select( + &self, + columns: Vec, + table: &str, + where_clause: Option, + ) -> ProtocolResult { + if table.to_uppercase() != "ACTORS" { + return Err(ProtocolError::PostgresError(format!( + "Unknown table: {table}" + ))); + } + + let actors = self.actors.read().await; + let mut rows = Vec::new(); + + for actor in actors.values() { + // Apply WHERE filter + if let Some(ref wc) = where_clause { + if !self.matches_where(actor, wc) { + continue; + } + } + + // Build row + let mut row = Vec::new(); + if columns.len() == 1 && columns[0] == "*" { + // For SELECT *, add all columns in the expected order + row.push(Some(actor.actor_id.clone())); + row.push(Some(actor.actor_type.clone())); + row.push(Some(actor.state.to_string())); + } else { + // For specific columns + for col in &columns { + let value = match col.to_uppercase().as_str() { + "ACTOR_ID" => Some(actor.actor_id.clone()), + "ACTOR_TYPE" => Some(actor.actor_type.clone()), + "STATE" => Some(actor.state.to_string()), + _ => None, + }; + row.push(value); + } + } + rows.push(row); + } + + // Determine columns + let result_columns = if columns.len() == 1 && columns[0] == "*" { + vec![ + "actor_id".to_string(), + "actor_type".to_string(), + "state".to_string(), + ] + } else { + columns + }; + + Ok(QueryResult::Select { + columns: result_columns, + rows, + }) + } + + /// Execute INSERT query on actors table + #[allow(dead_code)] + async fn execute_actor_insert( + &self, + table: &str, + columns: Vec, + values_list: Vec>, + ) -> ProtocolResult { + if table.to_uppercase() != "ACTORS" { + return Err(ProtocolError::PostgresError(format!( + "Unknown table: {table}" + ))); + } + + let mut count = 0; + let mut actors = self.actors.write().await; + + for values in values_list { + if columns.len() != values.len() { + return Err(ProtocolError::PostgresError( + "Column count doesn't match value count".to_string(), + )); + } + + let mut actor_id = None; + let mut actor_type = None; + let mut state = JsonValue::Object(serde_json::Map::new()); + + for (col, val) in columns.iter().zip(values.iter()) { + match col.to_uppercase().as_str() { + "ACTOR_ID" => actor_id = Some(val.clone()), + "ACTOR_TYPE" => actor_type = Some(val.clone()), + "STATE" => { + state = serde_json::from_str(val) + .unwrap_or_else(|_| JsonValue::String(val.clone())); + } + _ => {} } - '}' if !in_quotes => { - brace_depth -= 1; - current_clause.push(ch); + } + + let actor_id = actor_id + .ok_or_else(|| ProtocolError::PostgresError("Missing actor_id".to_string()))?; + let actor_type = actor_type + .ok_or_else(|| ProtocolError::PostgresError("Missing actor_type".to_string()))?; + + let record = ActorRecord { + actor_id: actor_id.clone(), + actor_type, + state, + }; + + actors.insert(actor_id, record); + count += 1; + } + + self.flush_change_log().await?; + Ok(QueryResult::Insert { count }) + } + + /// Execute UPDATE query on actors table + #[allow(dead_code)] + async fn execute_actor_update( + &self, + table: &str, + set_clauses: Vec<(String, String)>, + where_clause: Option, + ) -> ProtocolResult { + if table.to_uppercase() != "ACTORS" { + return Err(ProtocolError::PostgresError(format!( + "Unknown table: {table}" + ))); + } + + let mut actors = self.actors.write().await; + let mut count = 0; + + for actor in actors.values_mut() { + // Apply WHERE filter + if let Some(ref wc) = where_clause { + if !self.matches_where(actor, wc) { + continue; } - ',' if !in_quotes && brace_depth == 0 => { - // Found a separator - parse the current clause - if let Some(parsed) = self.parse_single_set_clause(¤t_clause) { - clauses.push(parsed); + } + + // 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) + .unwrap_or_else(|_| JsonValue::String(val.clone())); } - current_clause.clear(); - } - _ => { - current_clause.push(ch); + "ACTOR_TYPE" => { + actor.actor_type = val.clone(); + } + _ => {} } } - i += 1; + count += 1; } - // Add the last clause - if !current_clause.is_empty() { - if let Some(parsed) = self.parse_single_set_clause(¤t_clause) { - clauses.push(parsed); + Ok(QueryResult::Update { count }) + } + + /// Execute DELETE query on actors table + #[allow(dead_code)] + async fn execute_actor_delete( + &self, + table: &str, + where_clause: Option, + ) -> ProtocolResult { + if table.to_uppercase() != "ACTORS" { + return Err(ProtocolError::PostgresError(format!( + "Unknown table: {table}" + ))); + } + + let mut actors = self.actors.write().await; + let mut to_delete = Vec::new(); + + for (id, actor) in actors.iter() { + // Apply WHERE filter + if let Some(ref wc) = where_clause { + if !self.matches_where(actor, wc) { + continue; + } } + to_delete.push(id.clone()); } - clauses + let count = to_delete.len(); + for id in to_delete { + actors.remove(&id); + } + + Ok(QueryResult::Delete { count }) } - /// Parse a single SET clause (key = value) - 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(); - Some((key, value)) + /// Check if actor matches WHERE clause + fn matches_where(&self, actor: &ActorRecord, where_clause: &WhereClause) -> bool { + for condition in &where_clause.conditions { + let value = match condition.column.to_uppercase().as_str() { + "ACTOR_ID" => &actor.actor_id, + "ACTOR_TYPE" => &actor.actor_type, + _ => return false, + }; + + let matches = match condition.operator.as_str() { + "=" => value == &condition.value, + "!=" | "<>" => value != &condition.value, + _ => false, + }; + + if !matches { + return false; + } + } + true } - /// Parse CSV values respecting quotes and JSON braces - fn parse_csv_values(&self, values_str: &str) -> Vec { - let mut values = Vec::new(); - let mut current_value = String::new(); - let mut in_quotes = false; - let mut quote_char = '\0'; - let mut brace_depth = 0; - let chars: Vec = values_str.chars().collect(); - let mut i = 0; + /// Execute SELECT query on persistent storage + async fn execute_persistent_select( + &self, + storage: &Arc, + columns: Vec, + table: &str, + where_clause: Option, + ) -> ProtocolResult { + // Check if table exists + if !storage.table_exists(table).await? { + return Err(ProtocolError::PostgresError(format!( + "Table '{}' does not exist", + table + ))); + } - while i < chars.len() { - let ch = chars[i]; + // Convert WHERE clause to QueryConditions + let conditions = if let Some(wc) = where_clause { + wc.conditions + .into_iter() + .map(|c| QueryCondition { + column: fold_identifier(&c.column), + operator: c.operator, + value: Self::literal_to_json(&c.value), + }) + .collect() + } else { + vec![] + }; - match ch { - '\'' | '"' if !in_quotes => { - in_quotes = true; - quote_char = ch; - current_value.push(ch); - } - c if in_quotes && c == quote_char => { - in_quotes = false; - current_value.push(ch); - } - '{' if !in_quotes => { - brace_depth += 1; - current_value.push(ch); - } - '}' if !in_quotes => { - brace_depth -= 1; - 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(), - ); - current_value.clear(); - } - _ => { - current_value.push(ch); + // Execute select query - pass normalized column names to storage + let storage_columns: Vec = if columns.len() == 1 && columns[0] == "*" { + // For SELECT *, pass empty columns to storage (no filtering) + vec![] + } else { + columns.into_iter().map(|c| fold_identifier(&c)).collect() + }; + + // A column the table does not have is an error. Projecting it produced + // a column of NULLs, which reads as "every row has no value there". + // + // The wanted name is matched against the stored name exactly first, so + // a quoted identifier resolves, then case-insensitively, so an + // unquoted one still finds a column stored with different case. + let storage_columns: Vec = match storage.get_table_schema(table).await? { + None => storage_columns, + Some(schema) => { + let resolve = |wanted: &String| { + schema + .columns + .iter() + .find(|column| column.name == *wanted) + .or_else(|| { + schema + .columns + .iter() + .find(|column| column.name.eq_ignore_ascii_case(wanted)) + }) + .map(|column| column.name.clone()) + .ok_or_else(|| { + ProtocolError::PostgresError(format!( + "column \"{wanted}\" does not exist" + )) + }) + }; + storage_columns + .iter() + .map(resolve) + .collect::>>()? + } + }; + + // Every column is fetched even when only some are projected: the row's + // transaction stamp decides whether it may be seen at all, and asking + // storage for a projection threw it away before that could be judged. + let rows: Vec<_> = storage + .select_rows(table, Vec::new(), conditions.clone(), None) + .await? + .into_iter() + .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() { + let key_columns = match storage.get_table_schema(table).await? { + Some(schema) => schema + .columns + .iter() + .filter(|column| column.unique) + .map(|column| fold_identifier(&column.name)) + .collect::>(), + None => Vec::new(), + }; + if key_columns.is_empty() { + // Without a key a row cannot be named across a change: its + // identity would be its contents, and an update changes those. + // The whole table is watched instead — coarser, but it never + // misses a conflict. + note_read(table); + } else { + for row in &rows { + note_read_row(table, &row_identity(&row.values, &key_columns)); } + // The predicate is recorded as well as the rows, so a row that + // *starts* matching it counts as a conflict. Watching only the + // rows returned cannot see a phantom: it was not there to + // record. + note_read_predicate(table, &conditions); } - i += 1; } - // Add the last value - if !current_value.is_empty() { - values.push( - current_value - .trim() - .trim_matches('\'') - .trim_matches('"') - .to_string(), - ); - } - - values - } + // 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?; - /// Parse WHERE clause - fn parse_where_clause(&self, parts: &[&str]) -> ProtocolResult { - // Simple parser: column operator value - if parts.len() < 3 { - return Err(ProtocolError::PostgresError( - "Invalid WHERE clause".to_string(), - )); - } + // 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. + table_schema + .as_ref() + .map(|schema| schema.columns.iter().map(|c| c.name.clone()).collect()) + .unwrap_or_default() + } else { + storage_columns.clone() + }; - let column = parts[0].to_string(); - 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(); + let result_rows: Vec>> = rows + .into_iter() + .map(|row| { + result_columns + .iter() + .map(|col| { + // Try both original case and uppercase for compatibility + let value = row + .values + .get(col) + .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. + // 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(render_number(n, declared)), + JsonValue::Bool(b) => Some(b.to_string()), + other => Some(other.to_string()), + }) + }) + .collect() + }) + .collect(); - Ok(WhereClause { - conditions: vec![Condition { - column, - operator, - value, - }], + Ok(QueryResult::Select { + columns: result_columns, + rows: result_rows, }) } - /// Parse CREATE TABLE statement - fn parse_create_table(&self, sql: &str) -> ProtocolResult { - // Simple parser: CREATE TABLE [IF NOT EXISTS] table_name (column_definitions) - let sql_upper = sql.to_uppercase(); + /// Execute INSERT query on persistent storage + async fn execute_persistent_insert( + &self, + storage: &Arc, + table: &str, + 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!( + "Table '{}' does not exist", + table + ))); + } - // Check for IF NOT EXISTS - let if_not_exists = sql_upper.contains("IF NOT EXISTS"); + // Get table schema to handle column types properly + let schema = storage.get_table_schema(table).await?; + let schema = schema.ok_or_else(|| { + ProtocolError::PostgresError(format!("Table '{}' schema not found", table)) + })?; - // Find table name - let table_start = if if_not_exists { - sql_upper.find("EXISTS").unwrap() + 6 - } else { - sql_upper.find("TABLE").unwrap() + 5 - }; + let mut count = 0; - let table_end = sql[table_start..].find('(').unwrap() + table_start; - let table_name = sql[table_start..table_end].trim().to_uppercase(); + for values in values_list { + if columns.len() != values.len() { + return Err(ProtocolError::PostgresError( + "Column count doesn't match value count".to_string(), + )); + } - // Find column definitions between parentheses - let col_start = table_end + 1; - let col_end = sql.rfind(')').ok_or_else(|| { - ProtocolError::PostgresError( - "Invalid CREATE TABLE syntax: missing closing parenthesis".to_string(), - ) - })?; + // Build row data + let mut row_values = std::collections::HashMap::new(); + let now = chrono::Utc::now(); - let column_defs_str = &sql[col_start..col_end]; - let mut columns = Vec::new(); + // Handle SERIAL columns (auto-increment) + use crate::protocols::postgres_wire::persistent_storage::ColumnType; + for column_def in &schema.columns { + if matches!(column_def.data_type, ColumnType::Serial) { + // Generate next ID - for now use a simple counter based on current time + count + let next_id = chrono::Utc::now().timestamp_micros() % 1000000 + count as i64; + row_values.insert( + column_def.name.clone(), // Use schema name directly (don't uppercase) + JsonValue::Number(serde_json::Number::from(next_id)), + ); + } + } - // Split by commas and parse each column definition - for col_def in column_defs_str.split(',') { - let col_def = col_def.trim(); - let parts: Vec<&str> = col_def.split_whitespace().collect(); + for (col, val) in columns.iter().zip(values.iter()) { + let col_upper = fold_identifier(col); - if parts.len() >= 2 { - let name = parts[0].to_string(); - let data_type = parts[1].to_string(); - let constraints = parts[2..].iter().map(|s| s.to_string()).collect(); + // Find column in schema to get correct casing + let schema_col = schema + .columns + .iter() + .find(|c| fold_identifier(&c.name) == col_upper); - columns.push(SimpleColumnDef { - name, - data_type, - constraints, - }); + if let Some(column_def) = schema_col { + // Skip SERIAL columns as they're auto-generated + if matches!(column_def.data_type, ColumnType::Serial) { + continue; + } + + 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.value_to_json(val).await?); + } } - } - Ok(Statement::CreateTable { - table: table_name, - columns, - if_not_exists, - }) - } + // Domains are read once per statement rather than per row. + let domains = self.domains_for(&schema).await?; + Self::apply_defaults(&schema, &mut row_values); + row_values.insert( + TRANSACTION_STAMP.to_string(), + JsonValue::from(stamp_for_write()), + ); + Self::check_not_null(&schema, &row_values)?; + Self::check_constraints(&schema, &row_values, &domains)?; + self.check_references(storage, &schema, &row_values, false) + .await?; + self.check_unique(storage, table, &schema, &row_values) + .await?; - /// Parse DROP TABLE statement - fn parse_drop_table(&self, sql: &str) -> ProtocolResult { - // Simple parser: DROP TABLE [IF EXISTS] table_name - let sql_upper = sql.to_uppercase(); + let row = TableRow { + values: row_values, + created_at: now, + updated_at: now, + }; - // Check for IF EXISTS - let if_exists = sql_upper.contains("IF EXISTS"); + // Insert the row + publish_change("INSERT", table, &row.values); + storage.insert_row(table, row).await?; + count += 1; + } - // Find table name - let table_start = if if_exists { - sql_upper.find("EXISTS").unwrap() + 6 - } else { - sql_upper.find("TABLE").unwrap() + 5 - }; + self.flush_change_log().await?; + Ok(QueryResult::Insert { count }) + } - let table_name = sql[table_start..] - .trim() - .trim_end_matches(';') - .to_uppercase(); + /// Fill omitted columns that declare a `DEFAULT`. + fn apply_defaults( + schema: &crate::protocols::postgres_wire::persistent_storage::TableSchema, + row: &mut std::collections::HashMap, + ) { + for column in &schema.columns { + let Some(default) = column.default_value.as_ref() else { + continue; + }; + if !row.contains_key(&column.name) { + row.insert(column.name.clone(), default.clone()); + } + } + } - Ok(Statement::DropTable { - table: table_name, - if_exists, - }) + /// Reject a row that leaves a `NOT NULL` column empty or null. + /// + /// # Errors + /// Returns an error naming the column, as PostgreSQL does. + fn check_not_null( + schema: &crate::protocols::postgres_wire::persistent_storage::TableSchema, + row: &std::collections::HashMap, + ) -> ProtocolResult<()> { + for column in &schema.columns { + if column.nullable + || matches!( + column.data_type, + crate::protocols::postgres_wire::persistent_storage::ColumnType::Serial + ) + { + continue; + } + if row.get(&column.name).is_none_or(JsonValue::is_null) { + return Err(ProtocolError::PostgresError(format!( + "null value in column \"{}\" violates not-null constraint", + column.name + ))); + } + } + Ok(()) } - /// Execute SELECT query on actors table - async fn execute_actor_select( + /// The current definition of every domain a table's columns use. + async fn domains_for( &self, - columns: Vec, - table: &str, - where_clause: Option, - ) -> ProtocolResult { - if table.to_uppercase() != "ACTORS" { - return Err(ProtocolError::PostgresError(format!( - "Unknown table: {table}" - ))); + schema: &crate::protocols::postgres_wire::persistent_storage::TableSchema, + ) -> ProtocolResult> { + let mut domains = HashMap::new(); + for column in &schema.columns { + let Some(domain) = column.domain.as_ref() else { + continue; + }; + if domains.contains_key(domain) { + continue; + } + if let Some(definition) = self.domain_definition(domain).await? { + domains.insert(domain.clone(), definition); + } } + Ok(domains) + } - let actors = self.actors.read().await; - let mut rows = Vec::new(); + /// Reject a row whose `CHECK` predicate does not hold. + /// + /// PostgreSQL accepts a row whose check evaluates to NULL — only a + /// definite false is a violation. + /// + /// # Errors + /// Returns an error naming the column whose check failed. + fn check_constraints( + schema: &crate::protocols::postgres_wire::persistent_storage::TableSchema, + row: &std::collections::HashMap, + domains: &HashMap, + ) -> ProtocolResult<()> { + use crate::protocols::postgres_wire::sql::expression_evaluator::{ + EvaluationContext, ExpressionEvaluator, + }; + use crate::protocols::postgres_wire::sql::parser::SqlParser; - for actor in actors.values() { - // Apply WHERE filter - if let Some(ref wc) = where_clause { - if !self.matches_where(actor, wc) { - continue; - } - } + let mut checked: Vec<(String, String)> = schema + .columns + .iter() + .filter_map(|column| { + column + .check + .as_ref() + .map(|check| (column.name.clone(), check.clone())) + }) + .collect(); - // Build row - let mut row = Vec::new(); - if columns.len() == 1 && columns[0] == "*" { - // For SELECT *, add all columns in the expected order - row.push(Some(actor.actor_id.clone())); - row.push(Some(actor.actor_type.clone())); - row.push(Some(actor.state.to_string())); - } else { - // For specific columns - for col in &columns { - let value = match col.to_uppercase().as_str() { - "ACTOR_ID" => Some(actor.actor_id.clone()), - "ACTOR_TYPE" => Some(actor.actor_type.clone()), - "STATE" => Some(actor.state.to_string()), - _ => None, - }; - row.push(value); - } + // A domain's constraints are read now rather than copied when the + // table was created, so an `ALTER DOMAIN` reaches the tables already + // using it. + for column in &schema.columns { + let Some(domain) = column.domain.as_ref() else { + continue; + }; + let Some(definition) = domains.get(domain) else { + continue; + }; + let mut rest = definition.as_str(); + while let Some(at) = rest.to_uppercase().find("CHECK") { + let tail = &rest[at..]; + let Some(predicate) = Self::parenthesised(tail) else { + break; + }; + checked.push(( + column.name.clone(), + predicate + .replace("VALUE", &column.name) + .replace("value", &column.name), + )); + rest = &tail[tail.find(')').map_or(tail.len(), |end| end + 1)..]; } - rows.push(row); } - // Determine columns - let result_columns = if columns.len() == 1 && columns[0] == "*" { - vec![ - "actor_id".to_string(), - "actor_type".to_string(), - "state".to_string(), - ] - } else { - columns - }; + if checked.is_empty() { + return Ok(()); + } - Ok(QueryResult::Select { - columns: result_columns, - rows, - }) + // The row is keyed as the evaluator expects a row to be keyed. + let values: crate::protocols::postgres_wire::sql::select_pipeline::Row = schema + .columns + .iter() + .map(|column| { + let value = row.get(&column.name).map_or(JsonValue::Null, Clone::clone); + ( + fold_identifier(&column.name), + Self::json_to_sql_value(&value, &column.data_type), + ) + }) + .collect(); + + let mut evaluator = ExpressionEvaluator::new(); + for (name, predicate) in &checked { + // Parsed as the predicate of a select so the expression parser + // sees it in the position it was written for. + let statement = SqlParser::new().parse(&format!("SELECT 1 WHERE {predicate}"))?; + let crate::protocols::postgres_wire::sql::ast::Statement::Select(select) = statement + else { + continue; + }; + let Some(expression) = select.where_clause else { + continue; + }; + + let context = EvaluationContext::with_row(values.clone()); + if matches!( + evaluator.evaluate(&expression, &context)?, + SqlValue::Boolean(false) + ) { + return Err(ProtocolError::PostgresError(format!( + "new row violates check constraint on column \"{name}\"" + ))); + } + } + Ok(()) } - /// Execute INSERT query on actors table - #[allow(dead_code)] - async fn execute_actor_insert( + /// Whether anything a serializable block read has since been written by a + /// transaction it could not see. + /// + /// This is the check that makes `SERIALIZABLE` more than a label: without + /// it the level is `REPEATABLE READ` with a different name. + /// + /// # Errors + /// Returns an error when storage cannot be read. + pub async fn serialization_conflict( &self, - table: &str, - columns: Vec, - values_list: Vec>, - ) -> ProtocolResult { - if table.to_uppercase() != "ACTORS" { - return Err(ProtocolError::PostgresError(format!( - "Unknown table: {table}" - ))); + transaction: u64, + snapshot: &std::collections::HashSet, + tables: &[String], + ) -> ProtocolResult> { + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(None); + }; + + // Reads are recorded as `table` or `table\u{1}row-identity`; a bare + // table name means the whole table was read. + let mut whole_tables = std::collections::HashSet::new(); + let mut rows_read: std::collections::HashMap> = + std::collections::HashMap::new(); + let mut predicates: HashMap>> = HashMap::new(); + for entry in tables { + if let Some((table, rendered)) = entry.split_once('\u{3}') { + let conditions: Vec = rendered + .split('\u{4}') + .filter_map(|part| { + let mut fields = part.split('\u{2}'); + Some(QueryCondition { + column: fields.next()?.to_string(), + operator: fields.next()?.to_string(), + value: serde_json::from_str(fields.next()?).unwrap_or(JsonValue::Null), + }) + }) + .collect(); + predicates + .entry(fold_identifier(table)) + .or_default() + .push(conditions); + continue; + } + match entry.split_once('\u{1}') { + Some((table, identity)) => { + rows_read + .entry(fold_identifier(table)) + .or_default() + .insert(identity.to_string()); + } + None => { + whole_tables.insert(fold_identifier(entry)); + } + } } - let mut count = 0; - let mut actors = self.actors.write().await; + let names: std::collections::HashSet = whole_tables + .iter() + .chain(rows_read.keys()) + .chain(predicates.keys()) + .cloned() + .collect(); - for values in values_list { - if columns.len() != values.len() { - return Err(ProtocolError::PostgresError( - "Column count doesn't match value count".to_string(), - )); + for table in names { + if !storage.table_exists(&table).await? { + continue; } + let key_columns = match storage.get_table_schema(&table).await? { + Some(schema) => schema + .columns + .iter() + .filter(|column| column.unique) + .map(|column| fold_identifier(&column.name)) + .collect::>(), + None => Vec::new(), + }; - let mut actor_id = None; - let mut actor_type = None; - let mut state = JsonValue::Object(serde_json::Map::new()); + for row in storage + .select_rows(&table, Vec::new(), Vec::new(), None) + .await? + { + // Only a write to a row this block actually read is a + // conflict; a write elsewhere in the table is not. + let identity = row_identity(&row.values, &key_columns); + let watched = whole_tables.contains(&table) + || rows_read + .get(&table) + .is_some_and(|rows| rows.contains(&identity)) + // A row that now satisfies a predicate the block read is a + // phantom: it was not among the rows returned, but the + // block's answer would have differed had it been there. + || predicates.get(&table).is_some_and(|sets| { + sets.iter().any(|conditions| { + Self::row_matches_conditions(&row.values, conditions) + }) + }); + if !watched { + continue; + } - for (col, val) in columns.iter().zip(values.iter()) { - match col.to_uppercase().as_str() { - "ACTOR_ID" => actor_id = Some(val.clone()), - "ACTOR_TYPE" => actor_type = Some(val.clone()), - "STATE" => { - state = serde_json::from_str(val) - .unwrap_or_else(|_| JsonValue::String(val.clone())); + for column in [TRANSACTION_STAMP, DELETED_BY] { + let Some(writer) = row.values.get(column).and_then(JsonValue::as_u64) else { + continue; + }; + // A writer this block could not see, that is no longer + // running, committed underneath it. + let invisible = writer > transaction || snapshot.contains(&writer); + let finished = open_transactions() + .read() + .map(|open| !open.contains(&writer)) + .unwrap_or(true); + if writer != transaction && invisible && finished { + return Ok(Some(table)); } - _ => {} } } + } + Ok(None) + } - let actor_id = actor_id - .ok_or_else(|| ProtocolError::PostgresError("Missing actor_id".to_string()))?; - let actor_type = actor_type - .ok_or_else(|| ProtocolError::PostgresError("Missing actor_type".to_string()))?; - - let record = ActorRecord { - actor_id: actor_id.clone(), - actor_type, - state, + /// Whether a stored row satisfies every one of `conditions`. + /// + /// Only the comparisons a `WHERE` is reduced to here are understood; an + /// operator this does not know matches, so an unrecognised predicate + /// widens the watch rather than narrowing it. + fn row_matches_conditions( + values: &std::collections::HashMap, + conditions: &[QueryCondition], + ) -> bool { + conditions.iter().all(|condition| { + let Some(actual) = values + .iter() + .find(|(name, _)| fold_identifier(name) == fold_identifier(&condition.column)) + .map(|(_, value)| value) + else { + return false; }; + match condition.operator.as_str() { + "=" | "==" => *actual == condition.value, + "!=" | "<>" => *actual != condition.value, + "<" => Self::json_less_than(actual, &condition.value), + "<=" => { + *actual == condition.value || Self::json_less_than(actual, &condition.value) + } + ">" => Self::json_less_than(&condition.value, actual), + ">=" => { + *actual == condition.value || Self::json_less_than(&condition.value, actual) + } + "LIKE" | "ILIKE" => match (actual.as_str(), condition.value.as_str()) { + (Some(text), Some(pattern)) => Self::like_matches( + text, + pattern, + condition.operator.eq_ignore_ascii_case("ILIKE"), + ), + // Not text on both sides: widen rather than narrow. + _ => true, + }, + "IN" => condition + .value + .as_array() + .is_some_and(|values| values.contains(actual)), + _ => true, + } + }) + } - actors.insert(actor_id, record); - count += 1; + /// Whether `text` matches a SQL `LIKE` pattern. + /// + /// `%` stands for any run of characters and `_` for exactly one, anchored + /// at both ends — the anchoring is what a naive `contains` gets wrong. + fn like_matches(text: &str, pattern: &str, case_insensitive: bool) -> bool { + let (text, pattern) = if case_insensitive { + (text.to_lowercase(), pattern.to_lowercase()) + } else { + (text.to_string(), pattern.to_string()) + }; + let text: Vec = text.chars().collect(); + let pattern: Vec = pattern.chars().collect(); + + // Two-pointer wildcard match: linear, backtracking only to the last + // `%`. + let (mut t, mut p) = (0usize, 0usize); + let (mut star, mut resume) = (None, 0usize); + while t < text.len() { + match pattern.get(p) { + Some('%') => { + star = Some(p); + resume = t; + p += 1; + } + Some('_') => { + t += 1; + p += 1; + } + Some(expected) if *expected == text[t] => { + t += 1; + p += 1; + } + _ => match star { + Some(at) => { + p = at + 1; + resume += 1; + t = resume; + } + None => return false, + }, + } } - - Ok(QueryResult::Insert { count }) + pattern[p..].iter().all(|c| *c == '%') } - /// Execute UPDATE query on actors table - #[allow(dead_code)] - async fn execute_actor_update( - &self, - table: &str, - set_clauses: Vec<(String, String)>, - where_clause: Option, - ) -> ProtocolResult { - if table.to_uppercase() != "ACTORS" { - return Err(ProtocolError::PostgresError(format!( - "Unknown table: {table}" - ))); + /// Order two stored values, numerically when both are numbers. + fn json_less_than(left: &JsonValue, right: &JsonValue) -> bool { + match (left.as_f64(), right.as_f64()) { + (Some(left), Some(right)) => left < right, + _ => match (left.as_str(), right.as_str()) { + (Some(left), Some(right)) => left < right, + _ => false, + }, } + } - let mut actors = self.actors.write().await; - let mut count = 0; - - for actor in actors.values_mut() { - // Apply WHERE filter - if let Some(ref wc) = where_clause { - if !self.matches_where(actor, wc) { - continue; + /// Reclaim in the background, so old versions do not need a hand. + /// + /// The interval is the cadence at which the check runs; the reclaim itself + /// does nothing while any block is open, so a busy server pays only for + /// the check. Returns the task handle so a caller can stop it. + #[must_use] + pub fn start_autovacuum( + engine: Arc, + interval: std::time::Duration, + ) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + // A missed tick is not worth catching up on: the next one reclaims + // whatever accumulated. + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + ticker.tick().await; + if let Err(e) = engine.flush_change_log().await { + tracing::warn!("could not log changes for replication: {e}"); } - } - - // Apply updates - for (col, val) in &set_clauses { - match col.to_uppercase().as_str() { - "STATE" => { - actor.state = serde_json::from_str(val) - .unwrap_or_else(|_| JsonValue::String(val.clone())); - } - "ACTOR_TYPE" => { - actor.actor_type = val.clone(); + 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) => {} + Ok(reclaimed) => { + tracing::debug!(reclaimed, "autovacuum reclaimed superseded rows"); } - _ => {} + Err(e) => tracing::warn!("autovacuum failed: {e}"), } } - count += 1; - } - - Ok(QueryResult::Update { count }) + }) } - /// Execute DELETE query on actors table - #[allow(dead_code)] - async fn execute_actor_delete( - &self, - table: &str, - where_clause: Option, - ) -> ProtocolResult { - if table.to_uppercase() != "ACTORS" { - return Err(ProtocolError::PostgresError(format!( - "Unknown table: {table}" - ))); + /// Reclaim rows no open block can still need. + /// + /// # Errors + /// Returns an error when a table cannot be read or written. + pub async fn vacuum(&self, table: Option<&str>) -> ProtocolResult { + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(0); + }; + // A version is dead once no open block could still see it: the block + // that removed it finished, and it finished before the oldest block + // now running began. Waiting for *every* block to close instead meant + // one long-lived transaction stopped reclamation altogether. + let oldest_open = open_transactions() + .read() + .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 mut actors = self.actors.write().await; - let mut to_delete = Vec::new(); + let tables = match table { + Some(name) => vec![fold_identifier(name)], + None => storage.list_tables().await?, + }; - for (id, actor) in actors.iter() { - // Apply WHERE filter - if let Some(ref wc) = where_clause { - if !self.matches_where(actor, wc) { + PENDING_RECLAIM.store(0, std::sync::atomic::Ordering::Relaxed); + let mut reclaimed = 0usize; + for name in tables { + if !storage.table_exists(&name).await? { + continue; + } + // 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_map(|row| row.values.get(DELETED_BY)?.as_u64()) + .collect(); + + 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() + .read() + .map(|open| open.contains(&writer)) + .unwrap_or(true); + if still_running || oldest_open.is_some_and(|oldest| writer >= oldest) { continue; } + reclaimed += storage + .delete_rows( + &name, + vec![QueryCondition { + column: DELETED_BY.to_string(), + operator: "=".to_string(), + value: JsonValue::from(writer), + }], + ) + .await? + .max(0) as usize; } - to_delete.push(id.clone()); } + Ok(reclaimed) + } - let count = to_delete.len(); - for id in to_delete { - actors.remove(&id); + /// Remove the rows a committing transaction marked deleted. + /// + /// # Errors + /// Returns an error when a table cannot be written. + pub async fn purge_deleted(&self, transaction: u64, tables: &[String]) -> ProtocolResult<()> { + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(()); + }; + // A block still running may hold a snapshot that includes these rows, + // so they are left marked until nothing else is open. The mark already + // hides them from every new reader. + let others_open = open_transactions() + .read() + .map(|open| open.iter().any(|id| *id != transaction)) + .unwrap_or(false); + if others_open { + return Ok(()); + } + for table in tables { + let table = fold_identifier(table); + if !storage.table_exists(&table).await? { + continue; + } + storage + .delete_rows( + &table, + vec![QueryCondition { + column: DELETED_BY.to_string(), + operator: "=".to_string(), + value: JsonValue::from(transaction), + }], + ) + .await?; } + Ok(()) + } - Ok(QueryResult::Delete { count }) + /// Un-mark the rows a rolled-back transaction had deleted. + /// + /// # Errors + /// Returns an error when a table cannot be written. + pub async fn restore_deleted(&self, transaction: u64, tables: &[String]) -> ProtocolResult<()> { + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(()); + }; + for table in tables { + let table = fold_identifier(table); + if !storage.table_exists(&table).await? { + continue; + } + // The versions this block wrote go; the ones it marked come back. + storage + .delete_rows( + &table, + vec![QueryCondition { + column: TRANSACTION_STAMP.to_string(), + operator: "=".to_string(), + value: JsonValue::from(transaction), + }], + ) + .await?; + storage + .update_rows( + &table, + std::collections::HashMap::from([(DELETED_BY.to_string(), JsonValue::Null)]), + vec![QueryCondition { + column: DELETED_BY.to_string(), + operator: "=".to_string(), + value: JsonValue::from(transaction), + }], + ) + .await?; + } + Ok(()) } - /// Check if actor matches WHERE clause - fn matches_where(&self, actor: &ActorRecord, where_clause: &WhereClause) -> bool { - for condition in &where_clause.conditions { - let value = match condition.column.to_uppercase().as_str() { - "ACTOR_ID" => &actor.actor_id, - "ACTOR_TYPE" => &actor.actor_type, - _ => return false, - }; + /// Re-check every deferrable foreign key in the database. + /// + /// Called at `COMMIT` when checks were deferred, so a set of rows that + /// only makes sense together — a circular reference, most often — can be + /// inserted and validated as a whole. + /// + /// # Errors + /// Returns an error naming the first constraint that does not hold. + pub async fn check_deferred_constraints(&self, touched: &[String]) -> ProtocolResult<()> { + let Some(storage) = self.persistent_storage.as_ref() else { + return Ok(()); + }; - let matches = match condition.operator.as_str() { - "=" => value == &condition.value, - "!=" | "<>" => value != &condition.value, - _ => false, + // Only the tables this transaction wrote can have broken a constraint, + // so only those are re-read. Scanning every table made the cost of a + // COMMIT depend on the size of the database rather than on the work + // the transaction did. + let tables: Vec = storage + .list_tables() + .await? + .into_iter() + .filter(|name| touched.iter().any(|t| fold_identifier(t) == *name)) + .collect(); + + for name in tables { + let Some(schema) = storage.get_table_schema(&name).await? else { + continue; }; + if !schema.foreign_keys.iter().any(|key| key.deferrable) { + continue; + } - if !matches { - return false; + let deferred = crate::protocols::postgres_wire::persistent_storage::TableSchema { + foreign_keys: schema + .foreign_keys + .iter() + .filter(|key| key.deferrable) + .cloned() + .collect(), + ..schema.clone() + }; + for row in storage + .select_rows(&name, Vec::new(), Vec::new(), None) + .await? + { + self.check_references(storage, &deferred, &row.values, true) + .await?; } } - true + Ok(()) } - /// Execute SELECT query on persistent storage - async fn execute_persistent_select( + /// The columns a foreign key points at, filling in the target's key when + /// the clause named no columns. + async fn referenced_columns( &self, storage: &Arc, - columns: Vec, - table: &str, - where_clause: Option, - ) -> ProtocolResult { - // Check if table exists - if !storage.table_exists(table).await? { - return Err(ProtocolError::PostgresError(format!( - "Table '{}' does not exist", - table - ))); + key: &crate::protocols::postgres_wire::persistent_storage::ForeignKey, + ) -> ProtocolResult> { + if !key.referenced.is_empty() { + return Ok(key.referenced.iter().map(|c| fold_identifier(c)).collect()); } + Ok(match storage.get_table_schema(&key.table).await? { + Some(schema) => schema + .columns + .iter() + .filter(|column| column.unique) + .map(|column| fold_identifier(&column.name)) + .collect(), + None => Vec::new(), + }) + } - // Convert WHERE clause to QueryConditions - let conditions = if let Some(wc) = where_clause { - wc.conditions - .into_iter() - .map(|c| QueryCondition { - column: c.column.to_uppercase(), // Normalize column names to uppercase - operator: c.operator, - value: match serde_json::from_str(&c.value) { - Ok(json_val) => json_val, - Err(_) => JsonValue::String(c.value), - }, - }) - .collect() - } else { - vec![] - }; + /// Reject a delete that would orphan a row in another table. + /// + /// # Errors + /// Returns an error naming the table that still refers to the row. + async fn check_not_referenced( + &self, + storage: &Arc, + table: &str, + conditions: &[QueryCondition], + ) -> ProtocolResult<()> { + use crate::protocols::postgres_wire::persistent_storage::ReferentialAction; - // Execute select query - pass normalized column names to storage - let storage_columns = if columns.len() == 1 && columns[0] == "*" { - // For SELECT *, pass empty columns to storage (no filtering) - vec![] - } else { - // Normalize column names to uppercase - columns.into_iter().map(|c| c.to_uppercase()).collect() - }; + let referrers = self.tables_referring_to(storage, table).await?; + if referrers.is_empty() { + return Ok(()); + } - let rows = storage - .select_rows(table, storage_columns.clone(), conditions, None) + let doomed = storage + .select_rows(table, Vec::new(), conditions.to_vec(), None) .await?; + for row in &doomed { + for (child, key) in &referrers { + let referenced = self.referenced_columns(storage, key).await?; + let Some(conditions) = Self::child_conditions(row, key, &referenced) else { + continue; + }; - // Convert TableRows to QueryResult format - let result_columns = if storage_columns.is_empty() { - // For SELECT *, get columns from schema and normalize to uppercase - if let Some(schema) = storage.get_table_schema(table).await? { - schema - .columns - .into_iter() - .map(|c| c.name.to_uppercase()) - .collect() - } else { - vec![] - } - } else { - // Use the normalized column names - storage_columns.clone() - }; - - let result_rows: Vec>> = rows - .into_iter() - .map(|row| { - result_columns - .iter() - .map(|col| { - // Try both original case and uppercase for compatibility - 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(), - }) - }) - .collect() - }) - .collect(); + let referring = storage + .select_rows(child, Vec::new(), conditions.clone(), None) + .await?; + if referring.is_empty() { + continue; + } - Ok(QueryResult::Select { - columns: result_columns, - rows: result_rows, - }) + match key.on_delete { + // The referring rows go too. + ReferentialAction::Cascade => { + Box::pin(self.cascade_delete(storage, child, conditions)).await?; + } + // The referring column is cleared or reset instead. + ReferentialAction::SetNull | ReferentialAction::SetDefault => { + let child_schema = storage.get_table_schema(child).await?; + let mut set_values = HashMap::new(); + for column in &key.columns { + let replacement = if key.on_delete == ReferentialAction::SetDefault { + child_schema + .as_ref() + .and_then(|schema| { + schema + .columns + .iter() + .find(|c| fold_identifier(&c.name) == *column) + }) + .and_then(|c| c.default_value.clone()) + .unwrap_or(JsonValue::Null) + } else { + JsonValue::Null + }; + set_values.insert(column.clone(), replacement); + } + storage.update_rows(child, set_values, conditions).await?; + } + ReferentialAction::NoAction | ReferentialAction::Restrict => { + return Err(ProtocolError::PostgresError(format!( + "delete violates foreign key constraint: \"{child}\" still refers to \ + this row through ({})", + key.columns.join(", ") + ))); + } + } + } + } + Ok(()) } - /// Execute INSERT query on persistent storage - async fn execute_persistent_insert( + /// Apply each foreign key's `ON UPDATE` action before a key column moves. + /// + /// # Errors + /// Returns an error when a child still refers to the row and the action is + /// `NO ACTION` or `RESTRICT`. + async fn apply_update_actions( &self, storage: &Arc, table: &str, - columns: Vec, - values_list: Vec>, - ) -> ProtocolResult { - // Check if table exists - if !storage.table_exists(table).await? { - return Err(ProtocolError::PostgresError(format!( - "Table '{}' does not exist", - table - ))); + conditions: &[QueryCondition], + set_values: &std::collections::HashMap, + ) -> ProtocolResult<()> { + use crate::protocols::postgres_wire::persistent_storage::ReferentialAction; + + let referrers = self.tables_referring_to(storage, table).await?; + if referrers.is_empty() { + return Ok(()); } - // Get table schema to handle column types properly - let schema = storage.get_table_schema(table).await?; - let schema = schema.ok_or_else(|| { - ProtocolError::PostgresError(format!("Table '{}' schema not found", table)) - })?; - - let mut count = 0; + let changing = storage + .select_rows(table, Vec::new(), conditions.to_vec(), None) + .await?; + for row in &changing { + for (child, key) in &referrers { + let referenced = self.referenced_columns(storage, key).await?; + let Some(child_conditions) = Self::child_conditions(row, key, &referenced) else { + continue; + }; + let referring = storage + .select_rows(child, Vec::new(), child_conditions.clone(), Some(1)) + .await?; + if referring.is_empty() { + continue; + } - for values in values_list { - if columns.len() != values.len() { - return Err(ProtocolError::PostgresError( - "Column count doesn't match value count".to_string(), - )); + match key.on_update { + // The children follow the key to its new value. + ReferentialAction::Cascade => { + let mut updates = std::collections::HashMap::new(); + for (column, target) in key.columns.iter().zip(&referenced) { + if let Some(value) = set_values.iter().find_map(|(name, value)| { + (fold_identifier(name) == *target).then(|| value.clone()) + }) { + updates.insert(column.clone(), value); + } + } + if !updates.is_empty() { + storage + .update_rows(child, updates, child_conditions) + .await?; + } + } + ReferentialAction::SetNull | ReferentialAction::SetDefault => { + let child_schema = storage.get_table_schema(child).await?; + let mut updates = std::collections::HashMap::new(); + for column in &key.columns { + let replacement = if key.on_update == ReferentialAction::SetDefault { + child_schema + .as_ref() + .and_then(|schema| { + schema + .columns + .iter() + .find(|c| fold_identifier(&c.name) == *column) + }) + .and_then(|c| c.default_value.clone()) + .unwrap_or(JsonValue::Null) + } else { + JsonValue::Null + }; + updates.insert(column.clone(), replacement); + } + storage + .update_rows(child, updates, child_conditions) + .await?; + } + ReferentialAction::NoAction | ReferentialAction::Restrict => { + return Err(ProtocolError::PostgresError(format!( + "update violates foreign key constraint: \"{child}\" still refers to \ + this row through ({})", + key.columns.join(", ") + ))); + } + } } + } + Ok(()) + } - // Build row data - let mut row_values = std::collections::HashMap::new(); - let now = chrono::Utc::now(); + /// Delete the rows a cascade reaches, checking their own children first. + async fn cascade_delete( + &self, + storage: &Arc, + table: &str, + conditions: Vec, + ) -> ProtocolResult<()> { + Box::pin(self.check_not_referenced(storage, table, &conditions)).await?; + storage.delete_rows(table, conditions).await?; + Ok(()) + } - // Handle SERIAL columns (auto-increment) - use crate::protocols::postgres_wire::persistent_storage::ColumnType; - for column_def in &schema.columns { - if matches!(column_def.data_type, ColumnType::Serial) { - // Generate next ID - for now use a simple counter based on current time + count - let next_id = chrono::Utc::now().timestamp_micros() % 1000000 + count as i64; - row_values.insert( - column_def.name.clone(), // Use schema name directly (don't uppercase) - JsonValue::Number(serde_json::Number::from(next_id)), - ); + /// Every table that refers to `table`, with the constraint that does it. + #[allow(clippy::type_complexity)] + async fn tables_referring_to( + &self, + storage: &Arc, + table: &str, + ) -> ProtocolResult< + Vec<( + String, + crate::protocols::postgres_wire::persistent_storage::ForeignKey, + )>, + > { + let mut referrers = Vec::new(); + for name in storage.list_tables().await? { + let Some(schema) = storage.get_table_schema(&name).await? else { + continue; + }; + for key in &schema.foreign_keys { + if fold_identifier(&key.table) == fold_identifier(table) { + referrers.push((name.clone(), key.clone())); } } + } + Ok(referrers) + } - for (col, val) in columns.iter().zip(values.iter()) { - let col_upper = col.to_uppercase(); - - // Find column in schema to get correct casing - let schema_col = schema - .columns - .iter() - .find(|c| c.name.to_uppercase() == col_upper); + /// Conditions selecting the child rows that point at `parent`. + fn child_conditions( + parent: &TableRow, + key: &crate::protocols::postgres_wire::persistent_storage::ForeignKey, + referenced: &[String], + ) -> Option> { + if referenced.len() != key.columns.len() { + return None; + } + let mut conditions = Vec::with_capacity(key.columns.len()); + for (column, target) in key.columns.iter().zip(referenced) { + let value = parent + .values + .iter() + .find(|(name, _)| fold_identifier(name) == *target) + .map(|(_, value)| value.clone()) + .filter(|value| !value.is_null())?; + conditions.push(QueryCondition { + column: column.clone(), + operator: "=".to_string(), + value, + }); + } + Some(conditions) + } - if let Some(column_def) = schema_col { - // Skip SERIAL columns as they're auto-generated - if matches!(column_def.data_type, ColumnType::Serial) { - continue; - } + /// Reject a row whose foreign key names a row that is not there. + /// + /// # Errors + /// Returns an error naming the column and the table it references. + pub async fn check_references( + &self, + storage: &Arc, + schema: &crate::protocols::postgres_wire::persistent_storage::TableSchema, + row: &std::collections::HashMap, + deferred_pass: bool, + ) -> ProtocolResult<()> { + for key in &schema.foreign_keys { + // A deferrable key is checked at COMMIT, not when the row is + // written — that is what lets a circular reference be inserted at + // all. Each pass therefore looks at exactly the other's keys. + if key.deferrable != deferred_pass { + continue; + } + let referenced = self.referenced_columns(storage, key).await?; + if referenced.len() != key.columns.len() { + 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()), - }; + // What a partly-NULL key means depends on MATCH: + // SIMPLE — any NULL satisfies the constraint (the default) + // FULL — all NULL or none; a mixture is an error + // PARTIAL — the non-NULL parts must still match a row + use crate::protocols::postgres_wire::persistent_storage::MatchType; + let mut conditions = Vec::with_capacity(key.columns.len()); + let mut nulls = 0usize; + for (column, target) in key.columns.iter().zip(&referenced) { + let value = row + .iter() + .find(|(name, _)| fold_identifier(name) == *column) + .map(|(_, value)| value.clone()) + .filter(|value| !value.is_null()); + match value { + Some(value) => conditions.push(QueryCondition { + column: target.clone(), + operator: "=".to_string(), + value, + }), + None => nulls += 1, + } + } - // Use schema column name - row_values.insert(column_def.name.clone(), 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); + // An entirely NULL key refers to nothing under every MATCH type. + if nulls == key.columns.len() { + continue; + } + match key.match_type { + MatchType::Simple if nulls > 0 => continue, + MatchType::Full if nulls > 0 => { + return Err(ProtocolError::PostgresError(format!( + "MATCH FULL does not allow mixing null and nonnull key values in ({})", + key.columns.join(", ") + ))); } + // MATCH PARTIAL keeps checking with the parts it has, so the + // conditions built above already express it: a NULL column + // simply contributes no condition. + MatchType::Partial | MatchType::Full | MatchType::Simple => {} } - let row = TableRow { - values: row_values, - created_at: now, - updated_at: now, + let found = storage + .select_rows(&key.table, Vec::new(), conditions, Some(1)) + .await?; + if found.is_empty() { + return Err(ProtocolError::PostgresError(format!( + "insert or update violates foreign key constraint: no row in \"{}\" \ + matches ({})", + key.table, + key.columns.join(", ") + ))); + } + } + Ok(()) + } + + /// Reject a row that duplicates a unique or primary-key column. + /// + /// # Errors + /// Returns an error when the value is already present. + async fn check_unique( + &self, + storage: &Arc, + table: &str, + schema: &crate::protocols::postgres_wire::persistent_storage::TableSchema, + row: &std::collections::HashMap, + ) -> ProtocolResult<()> { + for column in &schema.columns { + if !column.unique { + continue; + } + let Some(value) = row.get(&column.name).filter(|v| !v.is_null()) else { + continue; }; - // Insert the row - storage.insert_row(table, row).await?; - count += 1; + let existing = storage + .select_rows( + table, + Vec::new(), + vec![QueryCondition { + column: fold_identifier(&column.name), + operator: "=".to_string(), + value: value.clone(), + }], + Some(1), + ) + .await?; + if !existing.is_empty() { + return Err(ProtocolError::PostgresError(format!( + "duplicate key value violates unique constraint on column \"{}\"", + column.name + ))); + } } - - Ok(QueryResult::Insert { count }) + Ok(()) } /// Execute UPDATE query on persistent storage @@ -1523,6 +9823,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!( @@ -1531,14 +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 { - 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 + if Self::is_plain_literal(&val) { + set_values.insert(fold_identifier(&col), Self::literal_to_json(&val)); + } } // Convert WHERE clause to QueryConditions @@ -1546,24 +9856,117 @@ 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, - Err(_) => JsonValue::String(c.value), - }, + value: Self::literal_to_json(&c.value), }) .collect() } else { vec![] }; + // Changing a key column moves the row out from under any child that + // points at it, so the same check a delete makes applies here. + let touches_key = match storage.get_table_schema(table).await? { + Some(schema) => set_values.keys().any(|column| { + schema + .columns + .iter() + .any(|c| c.unique && fold_identifier(&c.name) == fold_identifier(column)) + }), + None => false, + }; + if touches_key { + self.apply_update_actions(storage, table, &conditions, &set_values) + .await?; + } + // Execute update - let count = storage.update_rows(table, set_values, conditions).await?; + // An update always writes a new version: the previous row is marked + // deleted by the writing transaction and a fresh row carries the new + // values under a key that includes that transaction's id. + // + // Doing this only inside a block was not enough. An autocommit update + // overwrote in place, so it left no trace of having happened — a + // snapshot reader saw the new value, and a serializable block could + // not tell that what it read had moved. Old versions accumulate until + // `VACUUM`, which is the trade this model makes. + { + let stamp = stamp_for_write(); + let previous = storage + .select_rows(table, Vec::new(), conditions.clone(), None) + .await?; + let visible: Vec<_> = previous + .into_iter() + .filter(|row| row_is_visible(&row.values)) + .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); + } - Ok(QueryResult::Update { - count: count as usize, - }) + // 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. + storage + .update_rows( + table, + std::collections::HashMap::from([( + DELETED_BY.to_string(), + JsonValue::from(stamp), + )]), + conditions, + ) + .await?; + + for mut values in replacements { + let now = chrono::Utc::now(); + for (column, value) in &set_values { + let stored = values + .keys() + .find(|name| fold_identifier(name) == fold_identifier(column)) + .cloned() + .unwrap_or_else(|| column.clone()); + values.insert(stored, value.clone()); + } + values.insert(TRANSACTION_STAMP.to_string(), JsonValue::from(stamp)); + values.remove(DELETED_BY); + + publish_change("UPDATE", table, &values); + storage + .insert_row( + table, + TableRow { + values, + created_at: now, + updated_at: now, + }, + ) + .await?; + } + + note_reclaimable(count as u64); + self.flush_change_log().await?; + Ok(QueryResult::Update { count }) + } } /// Execute DELETE query on persistent storage @@ -1573,6 +9976,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!( @@ -1586,21 +9990,62 @@ 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, - Err(_) => JsonValue::String(c.value), - }, + value: Self::literal_to_json(&c.value), }) .collect() } else { vec![] }; - // Execute delete - let count = storage.delete_rows(table, conditions).await?; + // A row cannot be deleted while another table's row points at it. + // Checking only on insert let a parent be removed out from under its + // children, leaving foreign keys naming rows that are not there. + self.check_not_referenced(storage, table, &conditions) + .await?; + + // Inside a transaction the rows are marked rather than removed, so a + // concurrent session keeps seeing them until this block commits and a + // rollback has something to put back. + if let Some(stamp) = current_transaction_stamp() { + let marked = storage + .update_rows( + table, + std::collections::HashMap::from([( + DELETED_BY.to_string(), + JsonValue::from(stamp), + )]), + conditions, + ) + .await?; + note_reclaimable(marked.max(0) as u64); + return Ok(QueryResult::Delete { + count: marked.max(0) as usize, + }); + } + + // Superseded versions still match the predicate but are not rows any + // client can see, so they must not be counted as deleted. They are + // removed along with the visible ones. + let visible = storage + .select_rows(table, Vec::new(), conditions.clone(), None) + .await? + .into_iter() + .filter(|row| row_is_visible(&row.values)) + .count(); + for row in storage + .select_rows(table, Vec::new(), conditions.clone(), None) + .await? + { + if row_is_visible(&row.values) { + publish_change("DELETE", table, &row.values); + } + } + storage.delete_rows(table, conditions).await?; + let count = visible as i64; + self.flush_change_log().await?; Ok(QueryResult::Delete { count: count as usize, }) @@ -1613,10 +10058,9 @@ impl QueryEngine { table: &str, columns: Vec, 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? { @@ -1632,51 +10076,126 @@ impl QueryEngine { // Convert simple column definitions to persistent storage format let mut column_defs = Vec::new(); - 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, - 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 - } - } + // A column whose type names a domain takes that domain's base type and + // inherits its constraints, which is what makes a domain more than an + // alias. + let mut columns = columns; + let mut column_domains: HashMap = HashMap::new(); + for col in &mut columns { + let domain = fold_identifier(&col.data_type); + let Some(definition) = self.domain_definition(&domain).await? else { + continue; }; + column_domains.insert(fold_identifier(&col.name), domain); + let mut words = definition.split_whitespace(); + if let Some(base) = words.next() { + col.data_type = base.to_string(); + } + // A domain's `CHECK (VALUE > 0)` is written in terms of `VALUE`; + // on a column it has to name that column instead. + let rest = words + .collect::>() + .join(" ") + .replace("VALUE", &col.name) + .replace("value", &col.name); + if !rest.is_empty() { + col.constraints + .extend(rest.split_whitespace().map(str::to_string)); + } + } + + for col in &columns { + let column_type = column_type_from_name(&col.data_type); + + let constraints: Vec = col + .constraints + .iter() + .map(|word| word.to_uppercase()) + .collect(); + let says = |word: &str| constraints.iter().any(|c| c == word); + + let nullable = !(says("PRIMARY") || says("NOT") && says("NULL")); + let unique = says("UNIQUE") || (says("PRIMARY") && says("KEY")); + + // `DEFAULT `: the word after DEFAULT, kept as stored JSON + // so an omitted column is filled with the declared value rather + // than with NULL. + let default_value = col + .constraints + .iter() + .position(|word| word.eq_ignore_ascii_case("DEFAULT")) + .and_then(|at| col.constraints.get(at + 1)) + .map(|literal| Self::literal_to_json(literal)); + + // `CHECK ()`: the parenthesised text after CHECK, kept + // as written so the evaluator can run it against each row. + let check = col + .constraints + .iter() + .position(|word| word.to_uppercase().starts_with("CHECK")) + .map(|at| col.constraints[at..].join(" ")) + .and_then(|text| { + let open = text.find('(')?; + let close = text.rfind(')')?; + (close > open).then(|| text[open + 1..close].trim().to_string()) + }) + .filter(|predicate| !predicate.is_empty()); - let nullable = !col + // `REFERENCES other(column)`, or `REFERENCES other` naming its + // primary key. + let references = col .constraints .iter() - .any(|c| c.to_uppercase() == "NOT" || c.to_uppercase().contains("NULL")); + .position(|word| word.eq_ignore_ascii_case("REFERENCES")) + .and_then(|at| col.constraints.get(at + 1)) + .map(|target| { + let target = target.trim_end_matches(','); + match target.split_once('(') { + Some((table, column)) => ( + fold_identifier(table), + fold_identifier(column.trim_end_matches(')')), + ), + None => (fold_identifier(target), String::new()), + } + }); 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 + default_value, + unique, + check, + references, + domain: column_domains.get(&fold_identifier(&col.name)).cloned(), }); } + // A column-level `REFERENCES` is a one-column foreign key; both forms + // end up in the same list so the check does not care how it was + // written. + let mut foreign_keys = foreign_keys; + for (column, definition) in column_defs.iter().zip(&columns) { + let text = definition.constraints.join(" "); + if !text.to_uppercase().contains("REFERENCES") { + continue; + } + if let Some(mut key) = Self::parse_foreign_key(&text) { + key.columns = vec![fold_identifier(&column.name)]; + foreign_keys.push(key); + } + } + let schema = TableSchema { name: table.to_string(), columns: column_defs, created_at: chrono::Utc::now(), row_count: 0, + foreign_keys, }; // Create the table @@ -1685,6 +10204,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, @@ -1793,3 +10356,235 @@ 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); + } +} + +#[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:?}" + ); + } + } +} + +#[cfg(test)] +mod quoted_identifier_tests { + use super::{fold_identifier, QueryEngine}; + + /// A quoted identifier keeps its case; an unquoted one folds down. + #[test] + fn folding_respects_quotes() { + assert_eq!(fold_identifier("\"Id\""), "Id"); + assert_eq!(fold_identifier("Id"), "id"); + } + + /// The column list of a CREATE TABLE keeps quoted names as written. + #[test] + fn create_table_keeps_quoted_column_case() { + let engine = QueryEngine::new(); + let statement = engine + .parse_sql("CREATE TABLE qq (\"Id\" INTEGER, plain TEXT)") + .expect("parses"); + let super::Statement::CreateTable { columns, .. } = statement else { + panic!("not a CREATE TABLE"); + }; + let names: Vec = columns + .iter() + .map(|column| fold_identifier(&column.name)) + .collect(); + assert_eq!(names, vec!["Id".to_string(), "plain".to_string()]); + } +} + +#[cfg(test)] +mod transaction_visibility_tests { + use super::{ + begin_transaction, current_transaction_stamp, end_transaction, row_is_visible, + within_transaction, TRANSACTION_STAMP, + }; + use serde_json::Value as JsonValue; + use std::collections::HashMap; + + fn stamped(id: u64) -> HashMap { + HashMap::from([(TRANSACTION_STAMP.to_string(), JsonValue::from(id))]) + } + + #[tokio::test] + async fn a_statement_inside_a_transaction_knows_its_id() { + let context = begin_transaction(false); + let id = context.id; + let seen = within_transaction(context, async { current_transaction_stamp() }).await; + end_transaction(id); + assert_eq!(seen, Some(id)); + } + + #[tokio::test] + async fn an_open_transactions_rows_are_hidden_from_everyone_else() { + let context = begin_transaction(false); + let id = context.id; + + // The writer sees its own row... + assert!(within_transaction(context, async { row_is_visible(&stamped(id)) }).await); + // ...and nobody else does. + assert!(!row_is_visible(&stamped(id))); + + // Ending the transaction publishes it. + end_transaction(id); + assert!(row_is_visible(&stamped(id))); + } + + /// A snapshot judges a row against the moment the block began, so work + /// that commits afterwards stays invisible for its whole life. + #[tokio::test] + async fn a_snapshot_hides_work_committed_after_it_was_taken() { + let reader = begin_transaction(true); + let reader_id = reader.id; + + // A write that happens and commits after the snapshot was taken. + let later = begin_transaction(false); + end_transaction(later.id); + + assert!(!within_transaction(reader, async { row_is_visible(&stamped(later.id)) }).await); + end_transaction(reader_id); + + // Without a snapshot the same row is visible: it is committed. + assert!(row_is_visible(&stamped(later.id))); + } + + #[tokio::test] + async fn an_unstamped_row_is_visible() { + assert!(row_is_visible(&HashMap::new())); + } +} 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 3546b02b7..8686d20a9 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,30 @@ 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 1b3574886..f19144124 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}; @@ -370,6 +407,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 +423,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 +466,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 +475,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 +772,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), @@ -828,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), @@ -897,6 +986,24 @@ 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), @@ -1102,6 +1209,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)) } } @@ -1368,6 +1486,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)), @@ -1527,6 +1669,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:?}" ))), @@ -1558,9 +1761,59 @@ 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 +1845,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)), @@ -4047,7 +4310,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), @@ -6279,13 +6542,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), } } @@ -6574,9 +6839,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, } } @@ -9329,3 +9636,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/lexer.rs b/orbit/server/src/protocols/postgres_wire/sql/lexer.rs index 52e1d7f5c..5a1ccc985 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/lexer.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/lexer.rs @@ -543,6 +543,11 @@ pub struct Lexer { keywords: HashMap, } +/// Reverse map from keyword token to the word it was lexed from. +static KEYWORD_TEXT: std::sync::OnceLock< + std::sync::Mutex>, +> = std::sync::OnceLock::new(); + impl Lexer { /// Create a new lexer with SQL input pub fn new(input: &str) -> Self { @@ -560,6 +565,24 @@ impl Lexer { lexer } + /// The word a keyword token was lexed from, if it is a keyword at all. + /// + /// PostgreSQL reserves only a minority of its keywords; the rest are legal + /// column names. Without this, `INSERT INTO t (id, label)` failed to parse + /// because `LABEL` is a keyword token — a legal statement rejected. + #[must_use] + pub fn keyword_text(token: &Token) -> Option { + // Populating the map needs one lexer to have been built, which every + // parse does before it asks. + let _ = Lexer::new(""); + KEYWORD_TEXT + .get()? + .lock() + .ok()? + .get(&format!("{token:?}")) + .cloned() + } + /// Initialize keyword mapping fn init_keywords(&mut self) { let keywords = [ @@ -995,6 +1018,17 @@ impl Lexer { ]; for (keyword, token) in keywords.iter() { + // The reverse map is what lets an unreserved keyword be used as a + // column name: it gives the exact word the lexer turned into this + // token, with no guessing from the variant's name. + KEYWORD_TEXT + .get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) + .lock() + .map(|mut map| { + map.entry(format!("{token:?}")) + .or_insert_with(|| (*keyword).to_string()); + }) + .ok(); self.keywords.insert(keyword.to_string(), token.clone()); } } diff --git a/orbit/server/src/protocols/postgres_wire/sql/mod.rs b/orbit/server/src/protocols/postgres_wire/sql/mod.rs index ae04242bb..7e34066b8 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/mod.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/mod.rs @@ -59,6 +59,7 @@ pub mod parser; pub mod plan_cache; pub mod query_cache; pub mod query_engine; +pub mod select_pipeline; pub mod statistics; pub mod types; diff --git a/orbit/server/src/protocols/postgres_wire/sql/parser/dml.rs b/orbit/server/src/protocols/postgres_wire/sql/parser/dml.rs index cfa5b17ec..aeac8f2fe 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/parser/dml.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/parser/dml.rs @@ -14,10 +14,11 @@ use crate::protocols::postgres_wire::sql::{ Assignment, AssignmentTarget, ColumnRef, ConflictAction, ConflictTarget, CopyDirection, CopyFormat, CopyHeaderOption, CopyOnError, CopyOption, CopySource, CopyStatement, CopyTarget, DeleteStatement, DistinctClause, Expression, FromClause, InsertSource, - InsertStatement, JsonTable, JsonTableColumn, LimitClause, MergeAction, MergeInsert, - MergeInsertValues, MergeStatement, MergeUpdate, MergeWhenClause, NullsOrder, - OnConflictClause, OrderByItem, SelectItem, SelectStatement, SetOperation, SetOperator, - SortDirection, Statement, TableAlias, TraverseClause, TraverseDirection, UpdateStatement, + InsertStatement, JoinCondition, JoinType, JsonTable, JsonTableColumn, LimitClause, + MergeAction, MergeInsert, MergeInsertValues, MergeStatement, MergeUpdate, MergeWhenClause, + NullsOrder, OnConflictClause, OrderByItem, SelectItem, SelectStatement, SetOperation, + SetOperator, SortDirection, Statement, TableAlias, TraverseClause, TraverseDirection, + UpdateStatement, }, lexer::Token, types::SqlValue, @@ -66,10 +67,42 @@ pub fn parse_select(parser: &mut SqlParser) -> ParseResult { parser.expect(Token::Select)?; - // Parse DISTINCT clause + // Parse DISTINCT clause, including `DISTINCT ON (expr, ...)`. let distinct = if parser.matches(&[Token::Distinct]) { parser.advance()?; - Some(DistinctClause::Distinct) + if parser.matches(&[Token::On]) { + parser.advance()?; + if !parser.matches(&[Token::LeftParen]) { + return Err(ParseError { + message: "Expected '(' after DISTINCT ON".to_string(), + position: parser.position, + expected: vec!["(".to_string()], + found: parser.current_token.clone(), + }); + } + parser.advance()?; + let mut keys = Vec::new(); + loop { + keys.push(parse_expression_with_parser(parser)?); + if parser.matches(&[Token::Comma]) { + parser.advance()?; + } else { + break; + } + } + if !parser.matches(&[Token::RightParen]) { + return Err(ParseError { + message: "Expected ')' after DISTINCT ON list".to_string(), + position: parser.position, + expected: vec![")".to_string()], + found: parser.current_token.clone(), + }); + } + parser.advance()?; + Some(DistinctClause::DistinctOn(keys)) + } else { + Some(DistinctClause::Distinct) + } } else { None }; @@ -164,7 +197,22 @@ pub fn parse_select(parser: &mut SqlParser) -> ParseResult { // Parse FROM clause let from_clause = if parser.matches(&[Token::From]) { parser.advance()?; - Some(parse_from_clause(parser)?) + // `FROM a, b` is a cross join written with a comma, and + // `FROM a, LATERAL (...)` is how a lateral subquery is usually + // spelled. Parsing only one item left the comma as the start of a new + // statement. + let mut from = parse_from_clause(parser)?; + while parser.matches(&[Token::Comma]) { + parser.advance()?; + let right = parse_from_clause(parser)?; + from = FromClause::Join { + left: Box::new(from), + join_type: JoinType::Cross, + right: Box::new(right), + condition: JoinCondition::On(Expression::Literal(SqlValue::Boolean(true))), + }; + } + Some(from) } else { None }; @@ -701,6 +749,23 @@ fn parse_select_inner(parser: &mut SqlParser) -> ParseResult { /// Parse FROM clause fn parse_from_clause(parser: &mut SqlParser) -> ParseResult { + // `LATERAL (SELECT ...) alias`: the keyword marks a subquery that may read + // the rows to its left. Without it here the parser looked for a table name + // and reported "Expected table name". + let lateral = parser.matches(&[Token::Lateral]); + if lateral { + parser.advance()?; + let mut from = parse_from_clause(parser)?; + if let FromClause::Subquery { + lateral: ref mut flag, + .. + } = from + { + *flag = true; + } + return Ok(from); + } + // Check for JSON_TABLE if let Some(Token::Identifier(name)) = &parser.current_token { if name.to_uppercase() == "JSON_TABLE" { @@ -821,6 +886,7 @@ fn is_join_keyword(parser: &SqlParser) -> bool { | Some(Token::Right) | Some(Token::Full) | Some(Token::Cross) + | Some(Token::Natural) ) } @@ -828,6 +894,14 @@ fn is_join_keyword(parser: &SqlParser) -> bool { fn parse_join(parser: &mut SqlParser, left: FromClause) -> ParseResult { use crate::protocols::postgres_wire::sql::ast::{JoinCondition, JoinType}; + // `NATURAL JOIN` takes no ON or USING: the columns both sides share are + // the condition. Without this the keyword ended the FROM clause and the + // rest of the statement was read as a new one. + let natural = parser.matches(&[Token::Natural]); + if natural { + parser.advance()?; + } + // Determine join type let join_type = match &parser.current_token { Some(Token::Join) => { @@ -871,6 +945,8 @@ fn parse_join(parser: &mut SqlParser, left: FromClause) -> ParseResult JoinType::Inner, _ => { return Err(ParseError { message: "Expected JOIN keyword".to_string(), @@ -898,7 +974,9 @@ fn parse_join(parser: &mut SqlParser, left: FromClause) -> ParseResult ParseResult _ => None, }; - // Parse optional NULLS FIRST/LAST - let nulls = if let Some(Token::Identifier(nulls_kw)) = &parser.current_token { - if nulls_kw.to_uppercase() == "NULLS" { - parser.advance()?; - if let Some(Token::Identifier(order)) = &parser.current_token { - match order.to_uppercase().as_str() { - "FIRST" => { - parser.advance()?; - Some(NullsOrder::First) - } - "LAST" => { - parser.advance()?; - Some(NullsOrder::Last) - } - _ => None, - } - } else { - None + // Parse optional NULLS FIRST/LAST. + // + // The lexer emits `NULLS`, `FIRST` and `LAST` as keyword tokens, so + // matching them as identifiers — which this did — never fired, and + // `ORDER BY x NULLS FIRST` failed to parse at all. + let is_nulls = matches!(&parser.current_token, Some(Token::Nulls)) + || matches!(&parser.current_token, Some(Token::Identifier(word)) + if word.eq_ignore_ascii_case("NULLS")); + let nulls = if is_nulls { + parser.advance()?; + let order = match &parser.current_token { + Some(Token::First) => Some(NullsOrder::First), + Some(Token::Last) => Some(NullsOrder::Last), + Some(Token::Identifier(word)) if word.eq_ignore_ascii_case("FIRST") => { + Some(NullsOrder::First) } - } else { - None + Some(Token::Identifier(word)) if word.eq_ignore_ascii_case("LAST") => { + Some(NullsOrder::Last) + } + _ => None, + }; + if order.is_some() { + parser.advance()?; } + order } else { None }; 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 bb773cb03..76e4704cc 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, @@ -110,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, @@ -130,17 +168,36 @@ impl ExpressionParser { let mut left = self.parse_additive_expression(tokens, pos)?; while *pos < tokens.len() { - if matches!(&tokens[*pos], Token::In) { - // Handle IN operator specially to support both value lists and subqueries - *pos += 1; // consume IN - - // Check for NOT IN - let negated = if *pos < tokens.len() && matches!(&tokens[*pos], Token::Not) { - *pos += 1; - true - } else { - false + // `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) + || (matches!(&tokens[*pos], Token::Not) + && *pos + 1 < tokens.len() + && matches!(&tokens[*pos + 1], Token::In)) + { + // `NOT` precedes `IN` — `x NOT IN (...)`. Looking for it after + // `IN` never matched, so `NOT IN` failed to parse. + let negated = matches!(&tokens[*pos], Token::Not); + *pos += if negated { 2 } else { 1 }; // Parse IN list or subquery if *pos >= tokens.len() { @@ -837,106 +894,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 @@ -993,6 +957,28 @@ impl ExpressionParser { // Parse arguments if *pos < tokens.len() && !matches!(tokens[*pos], Token::RightParen) { loop { + // `EXTRACT(field FROM source)` names its field with a bare + // word: `EXTRACT(YEAR FROM d)`. It is a field name, not a + // column, so it is read as text — otherwise it resolved to a + // column that does not exist. + let extracts = matches!(func_name.to_uppercase().as_str(), "EXTRACT" | "DATE_PART"); + if extracts && args.is_empty() { + if let Some(field) = tokens + .get(*pos) + .and_then(crate::protocols::postgres_wire::sql::parser::utilities::token_to_identifier_name) + { + if tokens.get(*pos + 1).is_some_and(|next| matches!(next, Token::From)) { + // Consume the field and its `FROM`; the source + // expression is the next argument. + *pos += 2; + args.push(Expression::Literal( + crate::protocols::postgres_wire::sql::types::SqlValue::Text(field), + )); + continue; + } + } + } + // Handle special case for COUNT(*) if func_name.to_uppercase() == "COUNT" && matches!(tokens[*pos], Token::Multiply) { *pos += 1; @@ -1000,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)?); } @@ -1018,7 +1012,16 @@ impl ExpressionParser { } } - if *pos < tokens.len() && matches!(tokens[*pos], Token::Comma) { + // `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; `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?) // Actually comma MUST separate args. *pos += 1; // consume ',' @@ -1821,6 +1824,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 @@ -1936,6 +1979,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/parser/mod.rs b/orbit/server/src/protocols/postgres_wire/sql/parser/mod.rs index 70236a58c..260a7c40f 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) } @@ -909,3 +966,30 @@ impl Default for SqlParser { Self::new() } } + +#[cfg(test)] +mod parse_shape_tests { + use super::SqlParser; + + /// Statements the conformance harness found the parser rejecting. + #[test] + fn parses_the_clauses_a_client_writes() { + for sql in [ + "SELECT id FROM t ORDER BY id NULLS FIRST", + "SELECT id FROM t ORDER BY id DESC NULLS LAST", + "SELECT id FROM t ORDER BY name NULLS FIRST", + "SELECT id FROM t WHERE id NOT IN (1, 2)", + "SELECT id FROM t ORDER BY 1 DESC", + "SELECT amount AS a FROM t ORDER BY a DESC", + "SELECT REPLACE('abc', 'b', 'X')", + "SELECT CAST(amount AS TEXT) FROM t", + "SELECT NULLIF(1, 1)", + ] { + assert!( + SqlParser::new().parse(sql).is_ok(), + "failed to parse: {sql} -> {:?}", + SqlParser::new().parse(sql).err() + ); + } + } +} 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 f21a09961..9efbb27f6 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 c132fee8b..4039890a7 100644 --- a/orbit/server/src/protocols/postgres_wire/sql/parser/utilities.rs +++ b/orbit/server/src/protocols/postgres_wire/sql/parser/utilities.rs @@ -9,6 +9,80 @@ use crate::protocols::postgres_wire::sql::{ types::{SqlType, SqlValue}, }; +/// Keywords PostgreSQL reserves, which cannot name a column unquoted. +/// +/// Everything else in the lexer's keyword table is unreserved and may be used +/// as an identifier, which is what PostgreSQL itself allows. +const RESERVED_KEYWORDS: &[&str] = &[ + "ALL", + "ANALYZE", + "AND", + "ANY", + "ARRAY", + "AS", + "ASC", + "BOTH", + "CASE", + "CAST", + "CHECK", + "COLLATE", + "COLUMN", + "CONSTRAINT", + "CREATE", + "CURRENT_DATE", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "CURRENT_USER", + "DEFAULT", + "DEFERRABLE", + "DESC", + "DISTINCT", + "DO", + "ELSE", + "END", + "EXCEPT", + "FALSE", + "FETCH", + "FOR", + "FOREIGN", + "FROM", + "GRANT", + "GROUP", + "HAVING", + "IN", + "INTERSECT", + "INTO", + "LATERAL", + "LIMIT", + "LOCALTIME", + "LOCALTIMESTAMP", + "NOT", + "NULL", + "OFFSET", + "ON", + "ONLY", + "OR", + "ORDER", + "PRIMARY", + "REFERENCES", + "RETURNING", + "SELECT", + "SOME", + "TABLE", + "THEN", + "TO", + "TRUE", + "UNION", + "UNIQUE", + "USER", + "USING", + "VARIADIC", + "WHEN", + "WHERE", + "WINDOW", + "WITH", +]; + /// Extract identifier string from token (handles both Identifier and keyword tokens used as names) pub fn token_to_identifier_name(token: &Token) -> Option { match token { @@ -32,6 +106,18 @@ 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()), + // `REPLACE` is a keyword in `CREATE OR REPLACE` and a string function + // everywhere else; PostgreSQL classifies it as unreserved. + Token::Replace => Some("replace".to_string()), // Other keywords that can be used as identifiers Token::Sequence => Some("sequence".to_string()), Token::Key => Some("key".to_string()), @@ -53,6 +139,10 @@ pub fn token_to_identifier_name(token: &Token) -> Option { Token::Conversion => Some("conversion".to_string()), Token::Statistics => Some("statistics".to_string()), Token::Publication => Some("publication".to_string()), + // `public` is the default schema's name, so `public.t` is how most + // generated SQL spells a table. Treating it as reserved made every + // such statement a syntax error. + Token::Public => Some("public".to_string()), Token::Subscription => Some("subscription".to_string()), // Security/Role keywords that can be used as identifiers Token::Login => Some("login".to_string()), @@ -129,7 +219,12 @@ pub fn token_to_identifier_name(token: &Token) -> Option { Token::Schema => Some("schema".to_string()), Token::Database => Some("database".to_string()), Token::Level => Some("level".to_string()), - _ => None, + // Any other keyword PostgreSQL does not reserve is a legal identifier. + // The word comes from the lexer's own table, so no punctuation or + // literal token can be mistaken for a name. + other => crate::protocols::postgres_wire::sql::lexer::Lexer::keyword_text(other) + .filter(|word| !RESERVED_KEYWORDS.contains(&word.as_str())) + .map(|word| word.to_lowercase()), } } 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 000000000..9b4fce104 --- /dev/null +++ b/orbit/server/src/protocols/postgres_wire/sql/select_pipeline.rs @@ -0,0 +1,1388 @@ +//! 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, NullsOrder, SelectItem, SelectStatement, + SortDirection, WindowFunctionType, +}; +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 (columns, values) = run_select_values(select, rows)?; + Ok(SelectOutput { + columns, + rows: values + .into_iter() + .map(|row| { + row.into_iter() + .map(|value| match value { + SqlValue::Null => None, + other => Some(other.to_postgres_string()), + }) + .collect() + }) + .collect(), + }) +} + +/// Apply a `SELECT`'s clauses, keeping the results as typed values. +/// +/// A derived table or a set operation consumes the output of a select as rows +/// to compute over, not as display text; rendering to strings first would make +/// `WHERE t.id > 1` a string comparison. +/// +/// # Errors +/// Returns an error when an expression cannot be evaluated. +pub fn run_select_values( + select: &SelectStatement, + rows: Vec, +) -> ProtocolResult<(Vec, Vec>)> { + let mut evaluator = ExpressionEvaluator::new(); + + // A column that is not there is an error, not NULL. The evaluator answers + // NULL for an unknown name, so `SELECT no_such_column FROM t` returned a + // column of NULLs and `WHERE no_such_column = 1` returned no rows — both + // reported as success. + if let Some(sample) = rows.first() { + for item in &select.select_list { + if let SelectItem::Expression { expr, .. } = item { + check_columns_exist(expr, sample)?; + } + } + if let Some(predicate) = &select.where_clause { + check_columns_exist(predicate, sample)?; + } + } + + // 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 => match stop_after { + Some(enough) => rows.into_iter().take(enough).collect(), + None => rows, + }, + Some(predicate) => { + let mut kept = Vec::new(); + 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); + } + } + kept + } + }; + + // Window functions are computed over the filtered rows, before grouping + // and projection: each call's value is attached to its row and the call is + // replaced by a reference to it, so the rest of the pipeline sees an + // ordinary column. + let (windowed_select, filtered) = apply_window_functions(&mut evaluator, select, filtered)?; + let select = windowed_select.as_ref().unwrap_or(select); + + // 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) => { + 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 + } + // `DISTINCT ON (keys)` keeps the first row per key, not per output + // row; treating it as plain DISTINCT kept every row whose projection + // differed, which is a different answer. + Some(DistinctClause::DistinctOn(keys)) => { + let mut seen: Vec> = Vec::new(); + let mut kept = Vec::new(); + for (row, group, output) in after_having { + let mut key = Vec::with_capacity(keys.len()); + for expression in keys { + key.push(evaluate(&mut evaluator, expression, &row)?); + } + if !seen.contains(&key) { + seen.push(key); + 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 { + // `ORDER BY 1` and `ORDER BY ` name an output column, not a + // value to evaluate. Evaluating them gave the same constant for every + // row (an ordinal) or an unknown-column error (an alias), so the + // statement silently returned rows in storage order. + let output_names = output_column_names(select); + let sort_positions: Vec> = order_by + .iter() + .map(|item| output_position(&item.expression, &output_names)) + .collect(); + + // 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 (index, item) in order_by.iter().enumerate() { + keys.push(match sort_positions[index].and_then(|at| output.get(at)) { + Some(value) => value.clone(), + None => 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 (left, right) = (&a.0[index], &b.0[index]); + let descending = matches!(item.direction, Some(SortDirection::Descending)); + + // `NULLS FIRST`/`LAST` overrides where the comparison would + // put NULL. PostgreSQL's default is last when ascending and + // first when descending; parsing the clause and then ignoring + // it left `ORDER BY x NULLS FIRST` sorted the other way. + let nulls_first = match item.nulls { + Some(NullsOrder::First) => true, + Some(NullsOrder::Last) => false, + None => descending, + }; + let ordering = match ( + matches!(left, SqlValue::Null), + matches!(right, SqlValue::Null), + ) { + (true, true) => std::cmp::Ordering::Equal, + (true, false) if nulls_first => std::cmp::Ordering::Less, + (true, false) => std::cmp::Ordering::Greater, + (false, true) if nulls_first => std::cmp::Ordering::Greater, + (false, true) => std::cmp::Ordering::Less, + (false, false) => { + let ordering = compare(left, right); + if descending { + ordering.reverse() + } else { + 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(( + output_column_names(select), + windowed.into_iter().map(|(_, _, output)| output).collect(), + )) +} + +/// 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 { + 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 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()) + } + } + "AVG" => { + // 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 { + 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(x, sep)` takes its separator as the second argument; + // joining on a comma regardless produced a plausible-looking wrong + // answer. `ARRAY_AGG` has no separator and renders as an array. + "STRING_AGG" => { + let separator = call + .args + .get(1) + .map(|expr| evaluate(evaluator, expr, representative)) + .transpose()? + .map_or_else(|| ",".to_string(), |value| value.to_postgres_string()); + SqlValue::Text( + inputs + .iter() + .map(SqlValue::to_postgres_string) + .collect::>() + .join(&separator), + ) + } + "ARRAY_AGG" => SqlValue::Array(inputs), + 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), + // 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; + + 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()), + }, + } +} + +/// Fail if `expr` names a column the row does not have. +/// +/// Only bare and qualified column references are checked; a function's own +/// argument names, a literal, or a subquery are not columns of this row. +fn check_columns_exist(expr: &Expression, row: &Row) -> ProtocolResult<()> { + match expr { + Expression::Column(column) => { + if column.name == "*" { + return Ok(()); + } + let qualified = column + .table + .as_ref() + .map(|table| format!("{table}.{}", column.name)); + let known = row.contains_key(&column.name) + || qualified.is_some_and(|key| row.contains_key(&key)) + || row.keys().any(|key| key.eq_ignore_ascii_case(&column.name)); + known.then_some(()).ok_or_else(|| { + ProtocolError::PostgresError(format!("column \"{}\" does not exist", column.name)) + }) + } + Expression::Binary { left, right, .. } => { + check_columns_exist(left, row)?; + check_columns_exist(right, row) + } + Expression::Unary { operand, .. } => check_columns_exist(operand, row), + Expression::Function(call) => call + .args + .iter() + .try_for_each(|arg| check_columns_exist(arg, row)), + _ => Ok(()), + } +} + +/// The output column an `ORDER BY` item names, if it names one. +/// +/// A positive integer literal is a 1-based position into the select list; a +/// bare identifier that matches an output name — usually an alias — is that +/// column. Anything else is an expression to evaluate against the source row. +fn output_position(expr: &Expression, output_names: &[String]) -> Option { + match expr { + Expression::Literal(value) => { + let ordinal = as_i64(value)?; + let index = usize::try_from(ordinal - 1).ok()?; + (index < output_names.len()).then_some(index) + } + Expression::Column(column) if column.table.is_none() => output_names + .iter() + .position(|name| name.eq_ignore_ascii_case(&column.name)), + _ => None, + } +} + +/// Name under which a window function's value is stored on each row. +fn window_column(index: usize) -> String { + format!("__window_{index}") +} + +/// Replace each window function in the select list with its per-row value. +/// +/// Returns `None` for the statement when there are no window functions, so the +/// common case does not pay for a clone of the select list. +/// +/// # Errors +/// Returns an error when a window function's arguments cannot be evaluated. +#[allow(clippy::type_complexity)] +fn apply_window_functions( + evaluator: &mut ExpressionEvaluator, + select: &SelectStatement, + rows: Vec, +) -> ProtocolResult<(Option, Vec)> { + let mut calls = Vec::new(); + let mut rewritten = select.clone(); + for item in &mut rewritten.select_list { + if let SelectItem::Expression { expr, .. } = item { + extract_windows(expr, &mut calls); + } + } + + if calls.is_empty() { + return Ok((None, rows)); + } + + let mut rows = rows; + for (index, call) in calls.iter().enumerate() { + let values = window_values(evaluator, call, &rows)?; + let name = window_column(index); + for (row, value) in rows.iter_mut().zip(values) { + row.insert(name.clone(), value); + } + } + + Ok((Some(rewritten), rows)) +} + +/// Replace every window call in `expr` with a reference to its computed column, +/// collecting the calls in evaluation order. +fn extract_windows(expr: &mut Expression, calls: &mut Vec) { + match expr { + Expression::WindowFunction { .. } => { + let name = window_column(calls.len()); + calls.push(expr.clone()); + *expr = Expression::Column(super::ast::ColumnRef { table: None, name }); + } + Expression::Binary { left, right, .. } => { + extract_windows(left, calls); + extract_windows(right, calls); + } + Expression::Unary { operand, .. } => extract_windows(operand, calls), + Expression::Function(call) => { + for arg in &mut call.args { + extract_windows(arg, calls); + } + } + _ => {} + } +} + +/// Compute a window function's value for each row, in the rows' own order. +fn window_values( + evaluator: &mut ExpressionEvaluator, + call: &Expression, + rows: &[Row], +) -> ProtocolResult> { + let Expression::WindowFunction { + function, + partition_by, + order_by, + frame, + } = call + else { + return Ok(vec![SqlValue::Null; rows.len()]); + }; + + // Partition, preserving first-seen order so results are stable. + let mut keys: Vec> = Vec::new(); + let mut partitions: Vec> = Vec::new(); + for (index, row) in rows.iter().enumerate() { + let mut key = Vec::with_capacity(partition_by.len()); + for expression in partition_by { + key.push(evaluate(evaluator, expression, row)?); + } + match keys.iter().position(|existing| *existing == key) { + Some(at) => partitions[at].push(index), + None => { + keys.push(key); + partitions.push(vec![index]); + } + } + } + + let mut out = vec![SqlValue::Null; rows.len()]; + for partition in &partitions { + // Order within the partition. The sort is stable, so rows with equal + // keys keep their input order — which is what PostgreSQL leaves + // unspecified but every implementation has to pick something for. + let mut ordered = partition.clone(); + if !order_by.is_empty() { + let mut sort_keys: HashMap> = HashMap::new(); + for &index in partition { + let mut key = Vec::with_capacity(order_by.len()); + for item in order_by { + key.push(evaluate(evaluator, &item.expression, &rows[index])?); + } + sort_keys.insert(index, key); + } + ordered.sort_by(|a, b| { + let (left, right) = (&sort_keys[a], &sort_keys[b]); + for (position, item) in order_by.iter().enumerate() { + let ordering = compare(&left[position], &right[position]); + let ordering = match item.direction { + Some(SortDirection::Descending) => ordering.reverse(), + _ => ordering, + }; + if ordering != std::cmp::Ordering::Equal { + return ordering; + } + } + std::cmp::Ordering::Equal + }); + } + + let values = window_partition_values(evaluator, function, order_by, frame, &ordered, rows)?; + for (&index, value) in ordered.iter().zip(values) { + out[index] = value; + } + } + + Ok(out) +} + +/// Values for one ordered partition, in that partition's order. +fn window_partition_values( + evaluator: &mut ExpressionEvaluator, + function: &WindowFunctionType, + order_by: &[super::ast::OrderByItem], + frame: &Option, + ordered: &[usize], + rows: &[Row], +) -> ProtocolResult> { + let size = ordered.len(); + let offset_or = |expr: &Option>, evaluator: &mut ExpressionEvaluator| -> i64 { + expr.as_ref() + .and_then(|e| evaluate(evaluator, e, &Row::new()).ok()) + .and_then(|v| as_i64(&v)) + .unwrap_or(1) + }; + + Ok(match function { + WindowFunctionType::RowNumber => (1..=size as i64).map(SqlValue::BigInt).collect(), + + // RANK leaves gaps after ties; DENSE_RANK does not. With no ORDER BY + // every row ties, so both are 1 throughout. + WindowFunctionType::Rank | WindowFunctionType::DenseRank => { + let dense = matches!(function, WindowFunctionType::DenseRank); + let mut out = Vec::with_capacity(size); + let mut rank: i64 = 1; + for position in 0..size { + if position > 0 { + let tied = order_keys_equal( + evaluator, + order_by, + &rows[ordered[position - 1]], + &rows[ordered[position]], + )?; + if !tied { + rank = if dense { rank + 1 } else { position as i64 + 1 }; + } + } + out.push(SqlValue::BigInt(rank)); + } + out + } + + WindowFunctionType::Lag { expr, offset, .. } + | WindowFunctionType::Lead { expr, offset, .. } => { + let step = offset_or(offset, evaluator); + let backwards = matches!(function, WindowFunctionType::Lag { .. }); + let mut out = Vec::with_capacity(size); + for position in 0..size as i64 { + let target = if backwards { + position - step + } else { + position + step + }; + out.push( + match usize::try_from(target).ok().and_then(|t| ordered.get(t)) { + Some(&index) => evaluate(evaluator, expr, &rows[index])?, + None => SqlValue::Null, + }, + ); + } + out + } + + WindowFunctionType::FirstValue(expr) | WindowFunctionType::LastValue(expr) => { + let at = if matches!(function, WindowFunctionType::FirstValue(_)) { + ordered.first() + } else { + ordered.last() + }; + let value = match at { + Some(&index) => evaluate(evaluator, expr, &rows[index])?, + None => SqlValue::Null, + }; + vec![value; size] + } + + WindowFunctionType::NthValue { expr, n } => { + let n = as_i64(&evaluate(evaluator, n, &Row::new())?).unwrap_or(1); + let value = match usize::try_from(n - 1).ok().and_then(|at| ordered.get(at)) { + Some(&index) => evaluate(evaluator, expr, &rows[index])?, + None => SqlValue::Null, + }; + vec![value; size] + } + + // An aggregate covers the whole partition unless a frame narrows it; + // `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` is what makes a + // running total a running total rather than the partition's sum. + WindowFunctionType::Aggregate(call) => { + // Which rows tie with the one before them, so a RANGE or GROUPS + // frame can count peer groups rather than rows. + let mut peers = Vec::with_capacity(size); + for position in 0..size { + peers.push( + position > 0 + && order_keys_equal( + evaluator, + order_by, + &rows[ordered[position - 1]], + &rows[ordered[position]], + )?, + ); + } + + let mut out = Vec::with_capacity(size); + for position in 0..size { + let (from, to) = frame_bounds(evaluator, frame, position, size, &peers)?; + let group: Vec = ordered[from..to] + .iter() + .map(|&index| rows[index].clone()) + .collect(); + let representative = group.first().cloned().unwrap_or_default(); + out.push(evaluate_over_group( + evaluator, + &Expression::Function(call.clone()), + &group, + &representative, + )?); + } + out + } + + WindowFunctionType::Ntile(buckets) => { + let buckets = as_i64(&evaluate(evaluator, buckets, &Row::new())?) + .unwrap_or(1) + .max(1); + (0..size) + .map(|position| { + let bucket = (position as i64 * buckets) / size.max(1) as i64; + SqlValue::BigInt(bucket + 1) + }) + .collect() + } + + // Both are defined in terms of a row's rank within its partition. + // `PERCENT_RANK` is (rank - 1) / (rows - 1), and is 0 for a single-row + // partition rather than a division by zero. `CUME_DIST` is the share + // of rows at or before this one, so ties share the higher value. + WindowFunctionType::PercentRank | WindowFunctionType::CumeDist => { + let cumulative = matches!(function, WindowFunctionType::CumeDist); + let mut ranks = Vec::with_capacity(size); + let mut rank: usize = 1; + for position in 0..size { + if position > 0 + && !order_keys_equal( + evaluator, + order_by, + &rows[ordered[position - 1]], + &rows[ordered[position]], + )? + { + rank = position + 1; + } + ranks.push(rank); + } + + (0..size) + .map(|position| { + if cumulative { + // The number of rows in this row's peer group and all + // earlier ones. + let peers = ranks + .iter() + .filter(|other| **other <= ranks[position]) + .count(); + SqlValue::DoublePrecision(peers as f64 / size as f64) + } else if size <= 1 { + SqlValue::DoublePrecision(0.0) + } else { + SqlValue::DoublePrecision((ranks[position] - 1) as f64 / (size - 1) as f64) + } + }) + .collect() + } + }) +} + +/// The half-open row range a frame covers for the row at `position`. +/// +/// With no frame the range is the whole partition, which is what an unframed +/// window means. Only `ROWS` offsets are counted; `RANGE` and `GROUPS` need +/// peer-group arithmetic this does not do, and fall back to the partition +/// rather than quietly counting rows as if they were ranges. +fn frame_bounds( + evaluator: &mut ExpressionEvaluator, + frame: &Option, + position: usize, + size: usize, + peers: &[bool], +) -> ProtocolResult<(usize, usize)> { + use super::ast::{FrameBound, WindowFrameMode}; + + let Some(frame) = frame else { + return Ok((0, size)); + }; + + let offset = |bound: &FrameBound, evaluator: &mut ExpressionEvaluator| -> usize { + match bound { + FrameBound::Preceding(expr) | FrameBound::Following(expr) => { + evaluate(evaluator, expr, &Row::new()) + .ok() + .and_then(|value| as_i64(&value)) + .and_then(|n| usize::try_from(n).ok()) + .unwrap_or(0) + } + _ => 0, + } + }; + + // `RANGE` and `GROUPS` count peer groups — runs of rows that tie under the + // window's ORDER BY — rather than rows. `CURRENT ROW` in those modes means + // the whole peer group, which is why a `RANGE` running total repeats the + // same value across a tie where a `ROWS` one does not. + let group_starts = match frame.mode { + WindowFrameMode::Rows => Vec::new(), + WindowFrameMode::Range | WindowFrameMode::Groups => peer_group_starts(peers), + }; + let (position, size) = if group_starts.is_empty() { + (position, size) + } else { + ( + group_starts + .iter() + .rposition(|start| *start <= position) + .unwrap_or(0), + group_starts.len(), + ) + }; + + let start = match &frame.start_bound { + FrameBound::UnboundedPreceding => 0, + FrameBound::Preceding(_) => position.saturating_sub(offset(&frame.start_bound, evaluator)), + FrameBound::CurrentRow => position, + FrameBound::Following(_) => (position + offset(&frame.start_bound, evaluator)).min(size), + FrameBound::UnboundedFollowing => size, + }; + + // The end is exclusive here, so a bound that names a row includes it. + let end = match frame.end_bound.as_ref() { + None | Some(FrameBound::CurrentRow) => (position + 1).min(size), + Some(FrameBound::UnboundedFollowing) => size, + Some(bound @ FrameBound::Following(_)) => { + (position + offset(bound, evaluator) + 1).min(size) + } + Some(bound @ FrameBound::Preceding(_)) => { + position.saturating_sub(offset(bound, evaluator)) + 1 + } + Some(FrameBound::UnboundedPreceding) => 0, + }; + + let (start, end) = (start.min(size), end.max(start).min(size)); + + // Translate group indices back to row indices. + if group_starts.is_empty() { + return Ok((start, end)); + } + let row_start = group_starts.get(start).copied().unwrap_or(peers.len()); + let row_end = group_starts.get(end).copied().unwrap_or(peers.len()); + Ok((row_start, row_end.max(row_start))) +} + +/// The row index each peer group starts at. +/// +/// `peers[i]` is true when row `i` ties with row `i - 1`; a false entry starts +/// a new group. +fn peer_group_starts(peers: &[bool]) -> Vec { + (0..peers.len()) + .filter(|index| *index == 0 || !peers[*index]) + .collect() +} + +/// Whether two rows tie under the window's ORDER BY. +fn order_keys_equal( + evaluator: &mut ExpressionEvaluator, + order_by: &[super::ast::OrderByItem], + left: &Row, + right: &Row, +) -> ProtocolResult { + for item in order_by { + let a = evaluate(evaluator, &item.expression, left)?; + let b = evaluate(evaluator, &item.expression, right)?; + if compare(&a, &b) != std::cmp::Ordering::Equal { + return Ok(false); + } + } + Ok(true) +} + +#[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())] + ); + } + + #[test] + fn nulls_first_puts_nulls_first() { + let mut with_null = rows(&[(1, "a"), (2, "b")]); + with_null.push(HashMap::from([ + ("id".to_string(), SqlValue::Integer(3)), + ("grp".to_string(), SqlValue::Null), + ])); + let output = run("SELECT id FROM t ORDER BY grp NULLS FIRST", with_null); + assert_eq!(first_column(&output)[0], Some("3".to_string())); + } + + #[test] + fn order_by_an_ordinal_sorts_by_that_output_column() { + let output = run( + "SELECT id FROM t ORDER BY 1 DESC", + rows(&[(1, "a"), (2, "a"), (3, "b")]), + ); + assert_eq!( + first_column(&output), + vec![ + Some("3".to_string()), + Some("2".to_string()), + Some("1".to_string()) + ] + ); + } + + /// An alias is not a column of the source row, so it has to resolve + /// against the output. + #[test] + fn order_by_an_alias_sorts_by_that_output_column() { + let output = run( + "SELECT id AS ident FROM t ORDER BY ident DESC", + rows(&[(1, "a"), (2, "a"), (3, "b")]), + ); + assert_eq!( + first_column(&output), + vec![ + Some("3".to_string()), + Some("2".to_string()), + Some("1".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/orbit/server/src/protocols/postgres_wire/sql/types.rs b/orbit/server/src/protocols/postgres_wire/sql/types.rs index 3feaa352a..d1aa816c0 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 { @@ -726,6 +746,122 @@ impl SqlValue { _ => {} } + // Every type has a text representation in PostgreSQL, and text parses + // back to the numeric and boolean types. `can_cast_to` did not allow + // either direction, so `amount::text` and `CAST('7' AS INTEGER)` — + // both routine in generated SQL — were refused. + if matches!(self, SqlValue::Null) { + return Ok(SqlValue::Null); + } + match target_type { + SqlType::Text => return Ok(SqlValue::Text(self.to_postgres_string())), + SqlType::Varchar(_) => return Ok(SqlValue::Varchar(self.to_postgres_string())), + SqlType::Char(_) => return Ok(SqlValue::Char(self.to_postgres_string())), + SqlType::SmallInt | SqlType::Integer | SqlType::BigInt => { + if let SqlValue::Text(text) | SqlValue::Varchar(text) | SqlValue::Char(text) = self + { + let parsed = text + .trim() + .parse::() + .map_err(|_| format!("invalid input syntax for integer: \"{text}\""))?; + return Ok(match target_type { + SqlType::SmallInt => SqlValue::SmallInt(parsed as i16), + SqlType::Integer => SqlValue::Integer(parsed as i32), + _ => SqlValue::BigInt(parsed), + }); + } + } + SqlType::Real | SqlType::DoublePrecision => { + if let SqlValue::Text(text) | SqlValue::Varchar(text) | SqlValue::Char(text) = self + { + let parsed = text.trim().parse::().map_err(|_| { + format!("invalid input syntax for double precision: \"{text}\"") + })?; + return Ok(match target_type { + SqlType::Real => SqlValue::Real(parsed as f32), + _ => SqlValue::DoublePrecision(parsed), + }); + } + } + // `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 + { + return match text.trim().to_ascii_lowercase().as_str() { + "t" | "true" | "yes" | "on" | "1" => Ok(SqlValue::Boolean(true)), + "f" | "false" | "no" | "off" | "0" => Ok(SqlValue::Boolean(false)), + other => Err(format!("invalid input syntax for boolean: \"{other}\"")), + }; + } + } + _ => {} + } + + // 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 000000000..669201681 --- /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 000000000..68e4f5cfa --- /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/resp/commands/graphrag.rs b/orbit/server/src/protocols/resp/commands/graphrag.rs index 62ad7ec19..c0570b61a 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 000000000..e8b2828a7 --- /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 0fb3027a6..fc06af8b0 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, diff --git a/orbit/server/src/protocols/rest/handlers.rs b/orbit/server/src/protocols/rest/handlers.rs index d10185715..23d561aa1 100644 --- a/orbit/server/src/protocols/rest/handlers.rs +++ b/orbit/server/src/protocols/rest/handlers.rs @@ -18,12 +18,36 @@ 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 { 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>, + /// Address this REST listener is bound to, reported as the node's address. + pub bind_address: String, } /// Pagination query parameters @@ -749,61 +773,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.", + ), + )); + } - (StatusCode::OK, Json(SuccessResponse::new(response))).into_response() + 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; + + 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 +943,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.message.clone(); + results.push(BatchQueryResult { + index, + success: false, + result: None, + error: Some(message), + }); + } + } } let response = BatchSqlQueryResponse { @@ -884,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!( @@ -917,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 @@ -933,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(), }; @@ -967,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"); @@ -1013,55 +1177,91 @@ 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(), + 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 { - 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, - is_primary_key: false, - }, - TableColumn { - name: "created_at".to_string(), - data_type: "timestamp".to_string(), - nullable: false, - default_value: Some("now()".to_string()), + 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, - }, - ], - 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 ============ @@ -1127,23 +1327,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"); @@ -1164,17 +1378,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"); @@ -1204,32 +1425,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 ce92604ea..933121cef 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,8 @@ impl RestApiServer { let state = ApiState { 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/orbit/server/src/unified_storage.rs b/orbit/server/src/unified_storage.rs index 999a6c5ef..0f39848a1 100644 --- a/orbit/server/src/unified_storage.rs +++ b/orbit/server/src/unified_storage.rs @@ -38,9 +38,12 @@ //! let graph_adapter = integration.graph_adapter("cypher"); //! ``` +pub use orbit_engine::unified::rocksdb_backend::{Compression, RocksDbBackendConfig}; + use orbit_engine::unified::{ - AdapterFactory, CqlAdapter, GraphAdapter, MemoryBackend, Protocol, RedisAdapter, RestAdapter, - SchemaRegistry, SqlAdapter, UnifiedStorage, UnifiedStorageBackend, UnifiedStorageConfig, + rocksdb_backend::RocksDbBackend, AdapterFactory, CqlAdapter, GraphAdapter, MemoryBackend, + Protocol, RedisAdapter, RestAdapter, SchemaRegistry, SqlAdapter, UnifiedStorage, + UnifiedStorageBackend, UnifiedStorageConfig, }; use std::path::Path; use std::sync::Arc; @@ -59,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 { @@ -67,8 +74,9 @@ impl Default for UnifiedStorageIntegrationConfig { data_dir: "./data/unified".to_string(), enable_ttl_expiration: true, ttl_check_interval_secs: 60, - max_scan_limit: 10000, + max_scan_limit: 1_000_000, use_memory_backend: false, + durability: RocksDbBackendConfig::default(), } } } @@ -105,15 +113,30 @@ impl UnifiedStorageIntegration { config.data_dir ); - // Create storage backend + // Create storage backend. + // + // Both arms of this used to build a `MemoryBackend`, so the flag named + // a choice that was never made: every table and row served over the + // SQL protocols was lost on restart while the log said "persistent + // backend". let backend: Arc = if config.use_memory_backend { info!("[UnifiedStorage] Using in-memory backend"); Arc::new(MemoryBackend::new()) } else { - // For now, use memory backend. RocksDB backend can be added later. - // TODO: Add RocksDB backend option - info!("[UnifiedStorage] Using in-memory backend (persistent backend pending)"); - Arc::new(MemoryBackend::new()) + let path = Path::new(&config.data_dir).join("unified"); + info!( + path = %path.display(), + sync_writes = config.durability.sync_writes, + wal = config.durability.enable_wal, + "[UnifiedStorage] Using RocksDB backend" + ); + 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/orbit/server/tests/lb_tls_test.rs b/orbit/server/tests/lb_tls_test.rs index c79fae2c4..4a822d32b 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 5e46e0042..7edbc7c0a 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()); } diff --git a/orbit/shared/src/addressable.rs b/orbit/shared/src/addressable.rs index 4f2c32eb7..5081af722 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"); + } } diff --git a/orbit/shared/src/error.rs b/orbit/shared/src/error.rs index 7981f59bd..67cc84369 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:?}" + ); + } + } } diff --git a/orbit/shared/src/event_sourcing.rs b/orbit/shared/src/event_sourcing.rs index 99ae35657..73910f989 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( diff --git a/orbit/shared/src/lib.rs b/orbit/shared/src/lib.rs index 5df02108c..f928e45c2 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 82d8d5a10..5fd32257c 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 70693964c..4e454f461 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, @@ -178,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), } @@ -250,6 +257,12 @@ struct ConfigData { enabled_features: Vec, } +impl Default for SharedConfig { + fn default() -> Self { + Self::new() + } +} + impl SharedConfig { pub fn new() -> Self { Self { @@ -329,6 +342,12 @@ pub struct Observable { observers: RwLock>>, } +impl Default for Observable { + fn default() -> Self { + Self::new() + } +} + impl Observable { pub fn new() -> Self { Self { @@ -357,6 +376,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 b69b2cd41..7baa126ce 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)) } } @@ -371,13 +375,17 @@ 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 @@ -476,7 +484,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); @@ -530,7 +538,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); @@ -541,7 +549,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/phantom_types.rs b/orbit/shared/src/patterns/phantom_types.rs index 48785a243..3be46a51f 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 29339c7fb..de4a496de 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 601b2fc40..83974eb86 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 ee3730518..fc007da85 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 44fb58b31..7f8997864 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 { @@ -60,7 +66,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 +90,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)) } @@ -105,8 +114,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 { @@ -134,6 +143,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 119077665..0dc5e3953 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); diff --git a/orbit/shared/src/security/encryption.rs b/orbit/shared/src/security/encryption.rs index 154e74e5f..2f417864d 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 abdd50b52..7e17b1e7c 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/shared/src/security_patterns.rs b/orbit/shared/src/security_patterns.rs index cce8089ba..e5e9ed65a 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(); diff --git a/orbit/shared/src/timeseries/compression.rs b/orbit/shared/src/timeseries/compression.rs index abb32923f..24359cbd7 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,172 @@ 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 { + // `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 = match self.remaining().checked_div(min_bytes) { + Some(affordable) => affordable, + None => count, + }; + if count < affordable { + count + } else { + affordable } } - Ok(result) } // ============================================================================ @@ -136,14 +275,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 +286,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 +352,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 +363,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 +386,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 +401,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 +466,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 +610,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 +678,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 +691,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 +912,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; diff --git a/orbit/shared/src/transaction_log.rs b/orbit/shared/src/transaction_log.rs index 2e72e2a40..825a7c31c 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 { diff --git a/orbit/util/src/misc.rs b/orbit/util/src/misc.rs index a1a5b6670..6d5068212 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; diff --git a/specifications/AI_LLM_ROADMAP.md b/specifications/AI_LLM_ROADMAP.md new file mode 100644 index 000000000..f432b4eeb --- /dev/null +++ b/specifications/AI_LLM_ROADMAP.md @@ -0,0 +1,238 @@ +# 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 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 *(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 | + +--- + +## 1. Milestones + +### M1 — `orbit-llm` core ✅ *(this workstream)* + +The crate skeleton and everything provider-independent. + +- `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` + 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 +- `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). + +### 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 +- 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 +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:** 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. **Verified**, see §4.2. + +### 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** 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 — 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. + +### 4.5 Affordance audit + +- 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 new file mode 100644 index 000000000..a7a9e1c0a --- /dev/null +++ b/specifications/COMPETITIVE_ANALYSIS.md @@ -0,0 +1,298 @@ +# 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** ✅ *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** | +| 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 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 | +| 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.** ✅ **(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. + +--- + +## 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, 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 +`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 | +| Metrics endpoint | Yes | Counters via `LLM.STATS`; Prometheus export pending | +| 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) diff --git a/specifications/PRD.md b/specifications/PRD.md index b46fa06f9..355172a28 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 @@ -296,6 +298,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 @@ -405,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/` @@ -543,6 +595,109 @@ 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 `/api/v1/sql`, which executes against the shared SQL engine | + +#### 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. + +#### 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 + +- **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 + +```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 @@ -587,6 +742,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 @@ -605,8 +762,1069 @@ 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. + --- +### 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. + + +### 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: **212/212**. By area: + +| Area | Score | Notes | +|------|-------|-------| +| Connection (SCRAM) | 1/1 | | +| Simple query | 9/9 | Includes `SET`/`SHOW` and `SHOW ALL` | +| Extended query | 7/7 | Parse/Bind/Describe/Execute, typed and bound parameters | +| Portals / cursors | 1/1 | `Execute` row limits, `PortalSuspended` | +| Data types | 15/15 | Text, binary, `SMALLINT`, `BIGINT`, `NUMERIC`, `REAL`, `DOUBLE PRECISION`, `DATE`, `TIMESTAMP`, `JSON`; empty string distinct from NULL | +| Transactions | 26/26 | Per-row undo for every write path; `SET CONSTRAINTS` switches when deferred keys are checked | +| Catalog | 5/5 | `version()`, `current_database()`, `pg_class`, `pg_type`, `information_schema` | +| COPY | 3/3 | `TO STDOUT` and `FROM STDIN`, text and binary formats | +| LISTEN / NOTIFY | 1/1 | Cross-session, delivered while idle | +| Error reporting | 7/7 | SQLSTATE, session survives errors, aborted blocks reject work, statements after a failure do not run | +| SQL surface | 104/104 | See below | + +The SQL surface covers `WHERE` (`AND`/`OR`/`NOT`/`LIKE`/`ILIKE`/`BETWEEN`/`IS NULL`/`IN`/`NOT IN`), +`ORDER BY` (expressions, ordinals, aliases, `NULLS FIRST`/`LAST`), `LIMIT`/`OFFSET`, +`GROUP BY`, `HAVING`, `DISTINCT`, `DISTINCT ON`, `COUNT(DISTINCT)`, aggregates, +`STRING_AGG`/`ARRAY_AGG`, `JOIN`/`LEFT JOIN`, scalar, `IN`, `EXISTS` and +**correlated** subqueries, derived tables, CTEs, set operations, window functions, +`CASE`, `COALESCE`, `NULLIF`, `GREATEST`/`LEAST`, casts to and from text, +string, math and date functions (`EXTRACT`), `RETURNING`, `INSERT ... SELECT`, +multi-row `INSERT`, `ON CONFLICT`, `UPDATE` with expressions, `NOT NULL`, +`PRIMARY KEY`/`UNIQUE`, `DEFAULT`, `CHECK` and `REFERENCES` enforcement, +`WITH RECURSIVE`, `LATERAL` joins, comma joins, `NATURAL JOIN`, window frames +(`ROWS`, `RANGE` and `GROUPS`), `PERCENT_RANK`/`CUME_DIST`, table-level +`PRIMARY KEY`/`UNIQUE`/`FOREIGN KEY`/`CHECK` clauses, composite foreign keys +with `ON DELETE`/`ON UPDATE` actions, `MATCH FULL`/`PARTIAL`, `DEFERRABLE` checking with +`SET CONSTRAINTS`, domains with `ALTER DOMAIN` and `CHECK (VALUE ...)`, +triggers with `INSTEAD OF` and `NEW`/`OLD`, `ALTER TABLE RENAME`, +views, materialized views with `REFRESH`, +`CREATE TABLE AS SELECT`, `ALTER TABLE ADD`/`DROP COLUMN`, `TRUNCATE`, +schema-qualified and quoted identifiers, FROM-less selects, `EXPLAIN` and +`CREATE INDEX`. + +#### One source of truth for SELECT + +`sql/select_pipeline.rs` applies a statement's clauses — `WHERE` → window +functions → `GROUP BY`/aggregates → `HAVING` → `DISTINCT` → `ORDER BY` → +`OFFSET`/`LIMIT` → projection — to rows read from **persistent storage**, the +same rows a plain `SELECT` reads. A select with no `FROM` runs over one empty +row, so it uses the same path. + +Earlier arrangements that were wrong, and are worth recording because each one +reported success while answering incorrectly: + +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. +3. **Two routing tables.** The simple-query wire path dispatched over the parsed + AST while everything else dispatched over the statement text, so features + worked from one and not the other. `execute_multiple_queries` now splits the + message into statements and sends each through `execute_query`. +4. **`WHERE` was dropped by writes.** The condition was built as a closure and + handed to a `TableStorage` method that discards it, so + `DELETE FROM t WHERE id = 2` emptied the table. Likewise `drop_table` + removed only the schema, so the next `CREATE TABLE` resurrected the rows. +5. **`SET n = n + 1` stored the text `"amount + 1"`** in an integer column. +6. **`INSERT ... SELECT` inserted nothing** and reported success. +7. **Constraints were parsed and then dropped** by the schema round trip, so + `PRIMARY KEY` accepted duplicates and `DEFAULT` left NULL. +8. **An unknown column returned NULLs** rather than an error. +9. **`ORDER BY 1` and `ORDER BY ` were evaluated as expressions**, so the + statement returned rows in storage order while appearing to sort. +10. **A correlated subquery was resolved once** and applied to every row. +11. **Dead parser branches.** `NULLS FIRST`/`LAST` matched + `Token::Identifier("NULLS")` while the lexer emits `Token::Nulls`; + `NOT IN` was looked for as `IN NOT`. Neither could ever fire. +12. **`CREATE TABLE t AS SELECT` panicked the connection's task** — + `parse_create_table` unwrapped `find('(')`. Every later statement on that + connection failed with "connection closed". The same unwraps were reachable + in the `INSERT` and `DROP TABLE` parsers. +13. **`STRING_AGG(x, sep)` ignored its separator** and always joined on a comma. +14. **A quoted identifier lost its case on read.** The stored name is already + final; folding it again lowercased it, so `SELECT "Id"` could not find a + column that `SELECT *` reported as `id`. +15. **An aborted transaction accepted more statements.** PostgreSQL rejects + everything but a rollback once a statement in the block has failed. +16. **`UPDATE`/`DELETE ... WHERE a = 1 AND b = 2` changed nothing.** The + simple parser read the whole predicate as one condition, comparing `a` + against the text `1 AND b = 2`, so the statement matched no rows and + reported success. Conjuncts are now separate conditions, and an `OR` — + which a condition list cannot express — is refused rather than mis-read. +17. **Most keyword-named columns could not be parsed.** The lexer turns 393 + words into keyword tokens, and only a handful were accepted as + identifiers, so `INSERT INTO t (id, label)` failed to parse — PostgreSQL + reserves fewer than 70 of them. The parser now consults the lexer's own + keyword table in reverse and accepts any word not on the reserved list, + which is the rule PostgreSQL itself applies. This was found through a + trigger that silently never fired, because the AST parse it depended on + was failing while the statement itself succeeded through another path. +18. **One session's `ROLLBACK` destroyed another session's committed rows.** + Undo restored a copy of the whole table taken before the block's first + write, so a row a second session committed while that block was open was + wiped by the first session's rollback. That is data loss caused by an + unrelated connection, not a visibility anomaly. Undo is now row-scoped: + rows this session inserted are matched by value and removed, rows it + changed or deleted are put back only if they are no longer there, and + nothing else is touched. Savepoints record how far each undo log had + grown, so `ROLLBACK TO SAVEPOINT` undoes exactly the writes made after it. +19. **Every scan was silently truncated at 10,000 rows.** `UnifiedStorage::scan` + capped a caller that asked for no limit at `max_scan_limit`, *after* + reading every matching record into memory — so the cap saved nothing and + corrupted the answer. `SELECT COUNT(*)` on a 12,000-row table returned + 10,000 and reported success. The cap now refuses the scan, naming the row + count and the config key, rather than returning a short answer; an + explicit `LIMIT` is the caller's decision and is honoured as given. The + default moved from 10,000 to 1,000,000, which is a runaway threshold + rather than an ordinary table size. +20. **Nothing was persisted at all.** `UnifiedStorageIntegration` built a + `MemoryBackend` on *both* arms of its `use_memory_backend` branch — 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. `UnifiedTableStorage` compounded it by holding + schemas in a map that was the record rather than a cache. See + **Durability** below. + +#### Beyond the wire protocol + +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 — 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 | +| Write concurrency | 8 workers x 100 inserts | All 800 rows, no errors | +| Logical replication | Raw walsender session on 5432 | `IDENTIFY_SYSTEM`, `CREATE_REPLICATION_SLOT`, `START_REPLICATION` → `CopyBothResponse`, then an `XLogData` frame carrying a live `INSERT` | +| 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 +a page boundary. `SELECT COUNT(*)` over a 12,000-row table is now a check +(`a table larger than one scan page is counted in full`), and the guard-rail +behaviour has unit tests in `orbit/engine/src/unified/storage.rs`: a scan under +the limit returns every row, a scan over it errors, and an explicit limit is +never overridden. + +#### Durability + +Rows and schemas are held by `RocksDbBackend` +(`orbit/engine/src/unified/rocksdb_backend.rs`), which implements +`UnifiedStorageBackend` over a RocksDB database under +`/unified`. `UnifiedTableStorage` writes each table's +definition to a reserved `__orbit_table_schemas` relation keyed by +`dialect:name` and keeps its in-memory map strictly as a read-through cache. + +This was found by restarting the server and querying a table written before the +stop — not by the conformance harness, which connects to an already-running +server and so cannot see the difference between a durable store and a map. The +regression test for it is +`protocols::common::storage::unified::tests::a_table_survives_a_restart`, which +opens the store twice over one directory; it fails if the backend is switched +back to memory. + +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 +"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 + +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 +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. + +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 — 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 +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. + +#### 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 +`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 + row carries the id of the transaction that wrote it, and a delete marks the + row with the id that removed it. `READ COMMITTED` judges those ids against + what is open now; `REPEATABLE READ` and `SERIALIZABLE` judge them against the + set of transactions open when the block began, which is what makes a repeated + read return the same rows. An `UPDATE` inside a transaction writes a **new + row version** rather than overwriting: the previous row is marked deleted by + that block and the new values are stored under a key carrying the writing + transaction's id, so a reader holding an older snapshot still reads the row + as it stood. The storage key had to change for this — keying by the primary + key alone made the newer version replace the older, leaving nowhere to keep + it. An update outside a transaction still writes in place, since no reader + can observe the difference. `SERIALIZABLE` adds a check at commit: a block + that read a table another transaction has since written cannot be placed in + any serial order after it, so it fails with SQLSTATE `40001` rather than + committing. The grain is the **row** for a table with a key: the + block records the rows it read and only a write to one of those conflicts. + A table with no unique column falls back to whole-table grain, because a + keyless row cannot be named across a change — its identity would be its + contents, and an update changes those. The predicate a block read is recorded + alongside the rows, so a row that *starts* satisfying it — a phantom — is a + conflict too, while a row outside it is not. `=`, `!=`, `<`, `<=`, `>`, `>=`, `LIKE`, + `ILIKE` and `IN` are understood; an operator this does not know is treated + as matching, which widens the watch rather than narrowing it. Every + approximation refuses more than a real serializable scheduler would, never + fewer. +- Undo is row-scoped, so a rollback no longer damages another session's rows, + and concurrent writers were measured landing every row (8 workers x 100 + inserts, all 800 present). +- **Old versions and deleted rows are reclaimed only once no other block is + open**, so a snapshot reader cannot lose a row mid-transaction. Until then + the mark hides them, which means a long-running block delays reclamation — + versions accumulate while one is open. `VACUUM [table]` reclaims them + explicitly, and a background worker runs the same reclaim every 60 seconds. + A version is reclaimed once the block that removed it has finished *and* + finished before the oldest block now running began — so a long-lived + transaction holds back only the versions it could still see, not all of them. +- **An `UPDATE` is versioned even outside a transaction.** Overwriting in place + left no trace of the write, so a snapshot reader saw the new value and a + serializable block could not tell that what it read had moved. +- 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. +- **`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 + streams each write as an `XLogData` frame whose payload is the change as + JSON — an output plugin's job in PostgreSQL. Slots are persisted in the + 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. + 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. +- **Triggers run SQL statements, not a procedural function.** + `CREATE TRIGGER ... EXECUTE ` supports `BEFORE`/`AFTER`/`INSTEAD OF`, + `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. 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 +found fifteen including a reachable panic — so the number tracks the harness, +not the protocol. The two largest defects were invisible to every one of those +checks: nothing was persisted, because no check restarts the server, and every +scan was truncated at 10,000 rows, because no check used a table that large. +Treat the number as a floor that moves measurably, not a compatibility claim, +and keep asking what the harness cannot see. + + ## Storage Architecture ### Persistence Backends @@ -991,6 +2209,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 | --- @@ -1099,7 +2319,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/` | diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 4828c969f..43d2d99db 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" @@ -70,3 +70,24 @@ 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" +# 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 new file mode 100644 index 000000000..c541b26e8 --- /dev/null +++ b/tests/integration/pg_conformance.rs @@ -0,0 +1,6948 @@ +//! 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, + Sql, +} + +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", + Area::Sql => "SQL surface", + } + } +} + +/// 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. +/// 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(); + 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)) +} + +/// 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 + .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())); + 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 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(describe(e)), + } + }), + ); + + 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.first() + .ok_or("no rows returned".to_string())? + .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 + // 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)") + .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 ... 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", + 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?; + + 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 + // 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", + 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; + } + + // ------------------------------------------------- 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; + + // ------------------------------------------- fourth round of coverage + // Round three ended at 93/93. A harness that stops finding gaps has + // stopped measuring: these probe expressions, casts, constraints, + // defaults, `INSERT ... SELECT`, upserts and FROM-less selects — the + // surface an ORM and a migration tool both lean on. + let client = match connect().await { + Ok(fresh) => fresh, + Err(e) => { + report.record(Area::Sql, "reconnect for round four", Err(e)); + println!("{}", report.render()); + return; + } + }; + + drop_table(&client, "conf_four").await; + let _ = client + .simple_query("CREATE TABLE conf_four (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_four (id, name, amount) VALUES ({id}, '{name}', {amount})" + )) + .await; + } + + for (name, sql, want) in [ + // A select with no FROM. Every driver and migration tool issues one. + ("SELECT without FROM", "SELECT 1", "1"), + ("arithmetic without FROM", "SELECT 1 + 1", "2"), + ( + "ILIKE is case-insensitive", + "SELECT id FROM conf_four WHERE name ILIKE 'AL%'", + "1", + ), + ( + "NOT LIKE", + "SELECT id FROM conf_four WHERE name NOT LIKE 'a%' ORDER BY id", + "2,3", + ), + ( + "NOT IN list", + "SELECT id FROM conf_four WHERE id NOT IN (1, 2)", + "3", + ), + ( + "CAST to text", + "SELECT CAST(amount AS TEXT) FROM conf_four WHERE id = 1", + "10", + ), + ( + "cast with ::", + "SELECT amount::text FROM conf_four WHERE id = 1", + "10", + ), + ("NULLIF of equal values is NULL", "SELECT NULLIF(1, 1)", "NULL"), + ("GREATEST", "SELECT GREATEST(1, 5, 3)", "5"), + ("LEAST", "SELECT LEAST(4, 2, 9)", "2"), + ("ABS", "SELECT ABS(-5)", "5"), + ("UPPER and LOWER compose", "SELECT LOWER(UPPER('MiXeD'))", "mixed"), + ("TRIM", "SELECT TRIM(' x ')", "x"), + ("REPLACE", "SELECT REPLACE('abc', 'b', 'X')", "aXc"), + ("CONCAT()", "SELECT CONCAT('a', 'b')", "ab"), + ( + "SUBSTRING", + "SELECT SUBSTRING(name, 1, 2) FROM conf_four WHERE id = 1", + "al", + ), + ( + "ORDER BY an ordinal", + "SELECT id FROM conf_four ORDER BY 1 DESC", + "3,2,1", + ), + ( + "ORDER BY an alias", + "SELECT amount AS a FROM conf_four ORDER BY a DESC", + "30,20,10", + ), + ( + "GROUP BY two keys", + "SELECT COUNT(*) FROM conf_four GROUP BY name, amount", + "1,1,1", + ), + ( + "aggregate over no rows is zero, not empty", + "SELECT COUNT(*) FROM conf_four WHERE id = 999", + "0", + ), + ( + "SUM over no rows is NULL, not zero", + "SELECT SUM(amount) FROM conf_four WHERE id = 999", + "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", + "NULL", + ), + ( + "EXISTS subquery", + "SELECT id FROM conf_four WHERE EXISTS (SELECT 1 FROM conf_four WHERE id = 1) AND id = 2", + "2", + ), + ( + "UPDATE reads the column it writes", + "UPDATE conf_four SET amount = amount + 1 WHERE id = 1 RETURNING amount", + "11", + ), + ( + "mixed sort directions", + "SELECT id FROM conf_four ORDER BY name DESC, id ASC", + "3,2,1", + ), + ] { + 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, + "NULLs sort where the statement says", + async { + client + .simple_query("INSERT INTO conf_four (id, name, amount) VALUES (4, NULL, 40)") + .await + .map_err(describe)?; + let ids = simple_column( + &client, + "SELECT id FROM conf_four ORDER BY name NULLS FIRST", + ) + .await?; + (ids.first().map(String::as_str) == Some("4")) + .then_some(()) + .ok_or(format!("got {ids:?}, expected the NULL name first")) + } + .await, + ); + + report.record( + Area::Sql, + "INSERT ... SELECT copies rows", + async { + drop_table(&client, "conf_copy_sel").await; + client + .simple_query("CREATE TABLE conf_copy_sel (id INTEGER, amount INTEGER)") + .await + .map_err(describe)?; + client + .simple_query( + "INSERT INTO conf_copy_sel (id, amount) \ + SELECT id, amount FROM conf_four WHERE id < 3", + ) + .await + .map_err(describe)?; + let ids = simple_column(&client, "SELECT id FROM conf_copy_sel ORDER BY id").await?; + let result = (ids == ["1", "2"]) + .then_some(()) + .ok_or(format!("got {ids:?}, expected [1, 2]")); + drop_table(&client, "conf_copy_sel").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "correlated subquery", + simple_column( + &client, + "SELECT id FROM conf_four a \ + WHERE amount = (SELECT MAX(amount) FROM conf_four b WHERE b.id = a.id)", + ) + .await + .and_then(|values| { + (values.len() == 4) + .then_some(()) + .ok_or(format!("got {values:?}, expected every row")) + }), + ); + + // ------------------------------------------------------------ constraints + report.record( + Area::Sql, + "NOT NULL is enforced", + async { + drop_table(&client, "conf_notnull").await; + client + .simple_query("CREATE TABLE conf_notnull (id INTEGER NOT NULL)") + .await + .map_err(describe)?; + let outcome = client + .simple_query("INSERT INTO conf_notnull (id) VALUES (NULL)") + .await; + let result = outcome + .is_err() + .then_some(()) + .ok_or("a NULL was accepted into a NOT NULL column".to_string()); + drop_table(&client, "conf_notnull").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "PRIMARY KEY rejects a duplicate", + async { + drop_table(&client, "conf_pk").await; + client + .simple_query("CREATE TABLE conf_pk (id INTEGER PRIMARY KEY)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_pk (id) VALUES (1)") + .await + .map_err(describe)?; + let duplicate = client + .simple_query("INSERT INTO conf_pk (id) VALUES (1)") + .await; + let rows = simple_column(&client, "SELECT id FROM conf_pk").await?; + let result = if duplicate.is_err() { + Ok(()) + } else { + Err(format!( + "the duplicate was accepted; the table holds {rows:?}" + )) + }; + drop_table(&client, "conf_pk").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "DEFAULT fills an omitted column", + async { + drop_table(&client, "conf_default").await; + client + .simple_query("CREATE TABLE conf_default (id INTEGER, note TEXT DEFAULT 'none')") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_default (id) VALUES (1)") + .await + .map_err(describe)?; + let notes = simple_column(&client, "SELECT note FROM conf_default").await?; + let result = (notes == ["none"]) + .then_some(()) + .ok_or(format!("got {notes:?}, expected the default")); + drop_table(&client, "conf_default").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "ON CONFLICT DO NOTHING", + async { + drop_table(&client, "conf_upsert").await; + client + .simple_query("CREATE TABLE conf_upsert (id INTEGER PRIMARY KEY, note TEXT)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_upsert (id, note) VALUES (1, 'first')") + .await + .map_err(describe)?; + client + .simple_query( + "INSERT INTO conf_upsert (id, note) VALUES (1, 'second') \ + ON CONFLICT (id) DO NOTHING", + ) + .await + .map_err(describe)?; + let notes = simple_column(&client, "SELECT note FROM conf_upsert").await?; + let result = (notes == ["first"]) + .then_some(()) + .ok_or(format!("got {notes:?}, expected the original row kept")); + drop_table(&client, "conf_upsert").await; + result + } + .await, + ); + + // ------------------------------------------------------------ error paths + report.record( + Area::Errors, + "division by zero is an error, not NULL", + client + .simple_query("SELECT 1 / 0") + .await + .err() + .map(|_| ()) + .ok_or_else(|| "division by zero was answered".to_string()), + ); + + report.record( + Area::Errors, + "an unknown column is an error", + client + .simple_query("SELECT no_such_column FROM conf_four") + .await + .err() + .map(|_| ()) + .ok_or_else(|| "an unknown column produced a result".to_string()), + ); + + report.record( + Area::Types, + "an empty string is not NULL", + async { + client + .simple_query("INSERT INTO conf_four (id, name, amount) VALUES (5, '', 50)") + .await + .map_err(describe)?; + let rows = client + .query("SELECT name FROM conf_four WHERE id = 5", &[]) + .await + .map_err(describe)?; + let row = rows.first().ok_or("no row".to_string())?; + let name: Option<&str> = row.try_get(0).map_err(describe)?; + (name == Some("")) + .then_some(()) + .ok_or(format!("got {name:?}, expected an empty string")) + } + .await, + ); + + report.record( + Area::Types, + "a negative integer round-trips", + async { + client + .simple_query("INSERT INTO conf_four (id, name, amount) VALUES (6, 'neg', -7)") + .await + .map_err(describe)?; + let rows = client + .query("SELECT amount FROM conf_four WHERE id = 6", &[]) + .await + .map_err(describe)?; + let row = rows.first().ok_or("no row".to_string())?; + let amount: i32 = row.try_get(0).map_err(describe)?; + (amount == -7) + .then_some(()) + .ok_or(format!("got {amount}, expected -7")) + } + .await, + ); + + drop_table(&client, "conf_four").await; + // -------------------------------------------- fifth round of coverage + // The areas the PRD listed as unmeasured: arrays, CHECK and foreign keys, + // DDL beyond ADD COLUMN, quoted identifiers, session state, savepoint + // semantics, and the numeric and temporal functions. + let client = match connect().await { + Ok(fresh) => fresh, + Err(e) => { + report.record(Area::Sql, "reconnect for round five", Err(e)); + println!("{}", report.render()); + return; + } + }; + + drop_table(&client, "conf_five").await; + let _ = client + .simple_query("CREATE TABLE conf_five (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_five (id, name, amount) VALUES ({id}, '{name}', {amount})" + )) + .await; + } + + for (name, sql, want) in [ + // NULL is not a value that equals itself. + ( + "NULL never equals NULL", + "SELECT id FROM conf_five WHERE NULL = NULL", + "", + ), + ( + "concatenating NULL yields NULL", + "SELECT 'a' || NULL", + "NULL", + ), + ("LENGTH(NULL) is NULL", "SELECT LENGTH(NULL)", "NULL"), + ( + "LIKE with a single-character wildcard", + "SELECT id FROM conf_five WHERE name LIKE '_lpha'", + "1", + ), + ("MOD", "SELECT MOD(7, 3)", "1"), + ("CEIL", "SELECT CEIL(1.2)", "2"), + ("FLOOR", "SELECT FLOOR(1.8)", "1"), + ("POWER", "SELECT POWER(2, 3)", "8"), + ("SQRT", "SELECT SQRT(9)", "3"), + ( + "EXTRACT from a date", + "SELECT EXTRACT(YEAR FROM DATE '2026-01-02')", + "2026", + ), + ( + "STRING_AGG with a separator", + "SELECT STRING_AGG(name, '-') FROM conf_five", + "alpha-beta-gamma", + ), + ( + "COUNT of a column skips NULL", + "SELECT COUNT(name) FROM conf_five", + "3", + ), + ( + "a schema-qualified table name resolves", + "SELECT id FROM public.conf_five ORDER BY id", + "1,2,3", + ), + ( + "DISTINCT ON keeps one row per key", + "SELECT DISTINCT ON (amount) id FROM conf_five ORDER BY amount, id", + "1,2,3", + ), + ( + "boolean predicate without a comparison", + "SELECT id FROM conf_five WHERE true ORDER BY id", + "1,2,3", + ), + ] { + 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, + "SELECT * returns each column once, in declaration order", + client + .query("SELECT * FROM conf_five WHERE id = 1", &[]) + .await + .map_err(describe) + .and_then(|rows| { + let row = rows.first().ok_or("no row".to_string())?; + let names: Vec<&str> = row.columns().iter().map(|c| c.name()).collect(); + (names == ["id", "name", "amount"]) + .then_some(()) + .ok_or(format!("got {names:?}, expected [id, name, amount]")) + }), + ); + + report.record( + Area::Sql, + "CREATE TABLE AS SELECT", + async { + drop_table(&client, "conf_ctas").await; + client + .simple_query("CREATE TABLE conf_ctas AS SELECT id FROM conf_five WHERE id < 3") + .await + .map_err(describe)?; + let ids = simple_column(&client, "SELECT id FROM conf_ctas ORDER BY id").await?; + let result = (ids == ["1", "2"]) + .then_some(()) + .ok_or(format!("got {ids:?}, expected [1, 2]")); + drop_table(&client, "conf_ctas").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "ALTER TABLE DROP COLUMN", + async { + drop_table(&client, "conf_alter").await; + client + .simple_query("CREATE TABLE conf_alter (id INTEGER, spare TEXT)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_alter (id, spare) VALUES (1, 'x')") + .await + .map_err(describe)?; + client + .simple_query("ALTER TABLE conf_alter DROP COLUMN spare") + .await + .map_err(describe)?; + // The column is gone, so naming it is an error. + let result = client + .simple_query("SELECT spare FROM conf_alter") + .await + .err() + .map(|_| ()) + .ok_or_else(|| "the dropped column is still readable".to_string()); + drop_table(&client, "conf_alter").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a quoted identifier keeps its case", + async { + let _ = client + .simple_query("DROP TABLE IF EXISTS \"ConfCase\"") + .await; + client + .simple_query("CREATE TABLE \"ConfCase\" (\"Id\" INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO \"ConfCase\" (\"Id\") VALUES (7)") + .await + .map_err(describe)?; + let ids = simple_column(&client, "SELECT \"Id\" FROM \"ConfCase\"").await?; + let result = (ids == ["7"]) + .then_some(()) + .ok_or(format!("got {ids:?}, expected [7]")); + let _ = client + .simple_query("DROP TABLE IF EXISTS \"ConfCase\"") + .await; + result + } + .await, + ); + + report.record( + Area::Sql, + "CHECK rejects a violating row", + async { + drop_table(&client, "conf_check").await; + client + .simple_query("CREATE TABLE conf_check (id INTEGER CHECK (id > 0))") + .await + .map_err(describe)?; + let outcome = client + .simple_query("INSERT INTO conf_check (id) VALUES (-1)") + .await; + let result = outcome + .is_err() + .then_some(()) + .ok_or("a row violating CHECK was accepted".to_string()); + drop_table(&client, "conf_check").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "an array literal round-trips", + simple_column(&client, "SELECT ARRAY[1, 2, 3]") + .await + .and_then(|values| { + let got = values.join(","); + (got == "{1,2,3}") + .then_some(()) + .ok_or(format!("got {got:?}, expected {{1,2,3}}")) + }), + ); + + // ------------------------------------------------------------- types + report.record( + Area::Types, + "SMALLINT and REAL round-trip", + async { + drop_table(&client, "conf_narrow").await; + client + .simple_query("CREATE TABLE conf_narrow (small SMALLINT, approx REAL)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_narrow (small, approx) VALUES (7, 1.5)") + .await + .map_err(describe)?; + let values = simple_column(&client, "SELECT small FROM conf_narrow").await?; + let approx = simple_column(&client, "SELECT approx FROM conf_narrow").await?; + let result = (values == ["7"] && approx == ["1.5"]) + .then_some(()) + .ok_or(format!("got {values:?} and {approx:?}")); + drop_table(&client, "conf_narrow").await; + result + } + .await, + ); + + report.record( + Area::Types, + "JSON round-trips", + async { + drop_table(&client, "conf_json").await; + client + .simple_query("CREATE TABLE conf_json (doc JSON)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_json (doc) VALUES ('{\"a\": 1}')") + .await + .map_err(describe)?; + let docs = simple_column(&client, "SELECT doc FROM conf_json").await?; + let result = docs + .first() + .is_some_and(|doc| doc.contains("\"a\"") && doc.contains('1')) + .then_some(()) + .ok_or(format!("got {docs:?}")); + drop_table(&client, "conf_json").await; + result + } + .await, + ); + + // ------------------------------------------------------- session state + report.record( + Area::SimpleQuery, + "SHOW reports what SET stored", + async { + client + .simple_query("SET application_name = 'conformance'") + .await + .map_err(describe)?; + let values = simple_column(&client, "SHOW application_name").await?; + (values == ["conformance"]) + .then_some(()) + .ok_or(format!("got {values:?}, expected [conformance]")) + } + .await, + ); + + // -------------------------------------------------------- transactions + report.record( + Area::Transactions, + "ROLLBACK TO SAVEPOINT undoes only the later work", + async { + drop_table(&client, "conf_savepoint").await; + client + .simple_query("CREATE TABLE conf_savepoint (id INTEGER)") + .await + .map_err(describe)?; + client.simple_query("BEGIN").await.map_err(describe)?; + client + .simple_query("INSERT INTO conf_savepoint (id) VALUES (1)") + .await + .map_err(describe)?; + client.simple_query("SAVEPOINT s").await.map_err(describe)?; + client + .simple_query("INSERT INTO conf_savepoint (id) VALUES (2)") + .await + .map_err(describe)?; + client + .simple_query("ROLLBACK TO SAVEPOINT s") + .await + .map_err(describe)?; + client.simple_query("COMMIT").await.map_err(describe)?; + + let ids = simple_column(&client, "SELECT id FROM conf_savepoint ORDER BY id").await?; + let result = (ids == ["1"]) + .then_some(()) + .ok_or(format!("got {ids:?}, expected [1]")); + drop_table(&client, "conf_savepoint").await; + result + } + .await, + ); + + report.record( + Area::Errors, + "a statement after a failure inside a transaction is rejected", + async { + client.simple_query("BEGIN").await.map_err(describe)?; + let _ = client.simple_query("SELECT * FROM no_such_table").await; + let after = client.simple_query("SELECT 1").await; + let _ = client.simple_query("ROLLBACK").await; + after + .is_err() + .then_some(()) + .ok_or("the aborted transaction accepted another statement".to_string()) + } + .await, + ); + + report.record( + Area::Errors, + "statements after a failure in one message do not run", + async { + drop_table(&client, "conf_multi").await; + client + .simple_query("CREATE TABLE conf_multi (id INTEGER)") + .await + .map_err(describe)?; + let _ = client + .simple_query( + "INSERT INTO conf_multi (id) VALUES (1); \ + SELECT * FROM no_such_table; \ + INSERT INTO conf_multi (id) VALUES (2)", + ) + .await; + let ids = simple_column(&client, "SELECT id FROM conf_multi ORDER BY id").await?; + let result = (!ids.contains(&"2".to_string())) + .then_some(()) + .ok_or(format!("got {ids:?}; the statement after the failure ran")); + drop_table(&client, "conf_multi").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "one session's ROLLBACK spares another session's committed write", + async { + let other = connect().await?; + drop_table(&client, "conf_isolation").await; + client + .simple_query("CREATE TABLE conf_isolation (id INTEGER)") + .await + .map_err(describe)?; + + // One session opens a block and writes; the other commits its own + // row while that block is open. + client.simple_query("BEGIN").await.map_err(describe)?; + client + .simple_query("INSERT INTO conf_isolation (id) VALUES (1)") + .await + .map_err(describe)?; + other + .simple_query("INSERT INTO conf_isolation (id) VALUES (2)") + .await + .map_err(describe)?; + client.simple_query("ROLLBACK").await.map_err(describe)?; + + // Rolling back the first session must not take the second + // session's committed row with it. A table-level undo snapshot + // would restore the table to how it stood before the block and + // destroy row 2 — data loss, not merely a visibility anomaly. + let ids = simple_column(&other, "SELECT id FROM conf_isolation").await?; + let result = (ids == ["2"]) + .then_some(()) + .ok_or(format!("got {ids:?}, expected only the committed row [2]")); + drop_table(&client, "conf_isolation").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "ROLLBACK of an INSERT ... SELECT spares another session's row", + async { + let other = connect().await?; + drop_table(&client, "conf_iso_src").await; + drop_table(&client, "conf_iso_dst").await; + client + .simple_query("CREATE TABLE conf_iso_src (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("CREATE TABLE conf_iso_dst (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_iso_src (id) VALUES (1)") + .await + .map_err(describe)?; + + client.simple_query("BEGIN").await.map_err(describe)?; + client + .simple_query("INSERT INTO conf_iso_dst (id) SELECT id FROM conf_iso_src") + .await + .map_err(describe)?; + other + .simple_query("INSERT INTO conf_iso_dst (id) VALUES (9)") + .await + .map_err(describe)?; + client.simple_query("ROLLBACK").await.map_err(describe)?; + + let ids = simple_column(&other, "SELECT id FROM conf_iso_dst").await?; + let result = (ids == ["9"]) + .then_some(()) + .ok_or(format!("got {ids:?}, expected only the committed row [9]")); + drop_table(&client, "conf_iso_src").await; + drop_table(&client, "conf_iso_dst").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "ROLLBACK of a TRUNCATE restores the rows it removed", + async { + drop_table(&client, "conf_iso_trunc").await; + client + .simple_query("CREATE TABLE conf_iso_trunc (id INTEGER)") + .await + .map_err(describe)?; + for id in [1, 2] { + client + .simple_query(&format!("INSERT INTO conf_iso_trunc (id) VALUES ({id})")) + .await + .map_err(describe)?; + } + + client.simple_query("BEGIN").await.map_err(describe)?; + client + .simple_query("TRUNCATE TABLE conf_iso_trunc") + .await + .map_err(describe)?; + client.simple_query("ROLLBACK").await.map_err(describe)?; + + let ids = simple_column(&client, "SELECT id FROM conf_iso_trunc ORDER BY id").await?; + let result = (ids == ["1", "2"]) + .then_some(()) + .ok_or(format!("got {ids:?}, expected [1, 2]")); + drop_table(&client, "conf_iso_trunc").await; + result + } + .await, + ); + + for (name, sql, want) in [ + ( + "WITH RECURSIVE counts up", + "WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 5) \ + SELECT x FROM n", + "1,2,3,4,5", + ), + ( + "WITH RECURSIVE settles on a UNION", + "WITH RECURSIVE n(x) AS (SELECT 1 UNION SELECT x + 1 FROM n WHERE x < 3) \ + SELECT x FROM n", + "1,2,3", + ), + ( + "PERCENT_RANK", + "SELECT PERCENT_RANK() OVER (ORDER BY amount) FROM conf_five ORDER BY amount", + "0,0.5,1", + ), + ( + "CUME_DIST", + "SELECT CUME_DIST() OVER (ORDER BY amount) FROM conf_five ORDER BY amount", + "0.3333333333333333,0.6666666666666666,1", + ), + ( + "a running total honours its frame", + "SELECT SUM(amount) OVER (ORDER BY id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) \ + FROM conf_five ORDER BY id", + "10,30,60", + ), + ] { + 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, + "LATERAL reads the row to its left", + simple_column( + &client, + "SELECT t.doubled FROM conf_five a, \ + LATERAL (SELECT a.amount * 2 AS doubled) t ORDER BY t.doubled", + ) + .await + .and_then(|values| { + let got = values.join(","); + (got == "20,40,60") + .then_some(()) + .ok_or(format!("got {got:?}, expected 20,40,60")) + }), + ); + + report.record( + Area::Sql, + "a foreign key rejects an orphan row", + async { + drop_table(&client, "conf_child").await; + drop_table(&client, "conf_parent").await; + client + .simple_query("CREATE TABLE conf_parent (id INTEGER PRIMARY KEY)") + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TABLE conf_child (id INTEGER, parent INTEGER REFERENCES conf_parent(id))", + ) + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_parent (id) VALUES (1)") + .await + .map_err(describe)?; + + // The parent exists, so this is allowed. + client + .simple_query("INSERT INTO conf_child (id, parent) VALUES (1, 1)") + .await + .map_err(describe)?; + // This parent does not exist. + let orphan = client + .simple_query("INSERT INTO conf_child (id, parent) VALUES (2, 99)") + .await; + + let result = orphan + .is_err() + .then_some(()) + .ok_or("an orphan row was accepted".to_string()); + drop_table(&client, "conf_child").await; + drop_table(&client, "conf_parent").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "ALTER TABLE RENAME COLUMN keeps the values", + async { + drop_table(&client, "conf_rename").await; + client + .simple_query("CREATE TABLE conf_rename (id INTEGER, before TEXT)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_rename (id, before) VALUES (1, 'kept')") + .await + .map_err(describe)?; + client + .simple_query("ALTER TABLE conf_rename RENAME COLUMN before TO after") + .await + .map_err(describe)?; + + let values = simple_column(&client, "SELECT after FROM conf_rename").await?; + let result = (values == ["kept"]) + .then_some(()) + .ok_or(format!("got {values:?}, expected the value to move")); + drop_table(&client, "conf_rename").await; + result + } + .await, + ); + + report.record( + Area::SimpleQuery, + "SHOW ALL lists the parameters that were set", + async { + client + .simple_query("SET statement_timeout = '42'") + .await + .map_err(describe)?; + let names = simple_column(&client, "SHOW ALL").await?; + names + .iter() + .any(|name| name == "statement_timeout") + .then_some(()) + .ok_or_else(|| format!("statement_timeout is missing from {names:?}")) + } + .await, + ); + + report.record( + Area::Sql, + "NATURAL JOIN matches on the shared column", + async { + drop_table(&client, "conf_nat_a").await; + drop_table(&client, "conf_nat_b").await; + client + .simple_query("CREATE TABLE conf_nat_a (id INTEGER, left_value TEXT)") + .await + .map_err(describe)?; + client + .simple_query("CREATE TABLE conf_nat_b (id INTEGER, right_value TEXT)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_nat_a (id, left_value) VALUES (1, 'a')") + .await + .map_err(describe)?; + for (id, value) in [(1, "match"), (2, "other")] { + client + .simple_query(&format!( + "INSERT INTO conf_nat_b (id, right_value) VALUES ({id}, '{value}')" + )) + .await + .map_err(describe)?; + } + + let values = simple_column( + &client, + "SELECT right_value FROM conf_nat_a NATURAL JOIN conf_nat_b", + ) + .await?; + let result = (values == ["match"]) + .then_some(()) + .ok_or(format!("got {values:?}, expected only the matching row")); + drop_table(&client, "conf_nat_a").await; + drop_table(&client, "conf_nat_b").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a RANGE frame covers the whole peer group", + async { + drop_table(&client, "conf_peers").await; + client + .simple_query("CREATE TABLE conf_peers (id INTEGER, grade INTEGER)") + .await + .map_err(describe)?; + // Two rows tie on the sort key, so RANGE and ROWS differ. + for (id, grade) in [(1, 10), (2, 10), (3, 20)] { + client + .simple_query(&format!( + "INSERT INTO conf_peers (id, grade) VALUES ({id}, {grade})" + )) + .await + .map_err(describe)?; + } + + let ranged = simple_column( + &client, + "SELECT COUNT(*) OVER (ORDER BY grade RANGE BETWEEN UNBOUNDED PRECEDING \ + AND CURRENT ROW) FROM conf_peers ORDER BY grade, id", + ) + .await?; + // The tied rows both see both of themselves; ROWS would give 1,2,3. + let result = (ranged == ["2", "2", "3"]) + .then_some(()) + .ok_or(format!("got {ranged:?}, expected [2, 2, 3]")); + drop_table(&client, "conf_peers").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a table-level FOREIGN KEY is enforced", + async { + drop_table(&client, "conf_tl_child").await; + drop_table(&client, "conf_tl_parent").await; + client + .simple_query("CREATE TABLE conf_tl_parent (id INTEGER, PRIMARY KEY (id))") + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TABLE conf_tl_child (id INTEGER, parent INTEGER, \ + CONSTRAINT fk FOREIGN KEY (parent) REFERENCES conf_tl_parent(id))", + ) + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_tl_parent (id) VALUES (1)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_tl_child (id, parent) VALUES (1, 1)") + .await + .map_err(describe)?; + + let orphan = client + .simple_query("INSERT INTO conf_tl_child (id, parent) VALUES (2, 99)") + .await; + let result = orphan + .is_err() + .then_some(()) + .ok_or("a table-level foreign key was not enforced".to_string()); + drop_table(&client, "conf_tl_child").await; + drop_table(&client, "conf_tl_parent").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a referenced row cannot be deleted", + async { + drop_table(&client, "conf_ref_child").await; + drop_table(&client, "conf_ref_parent").await; + client + .simple_query("CREATE TABLE conf_ref_parent (id INTEGER PRIMARY KEY)") + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TABLE conf_ref_child (id INTEGER, \ + parent INTEGER REFERENCES conf_ref_parent(id))", + ) + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_ref_parent (id) VALUES (1)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_ref_child (id, parent) VALUES (1, 1)") + .await + .map_err(describe)?; + + let orphaning = client + .simple_query("DELETE FROM conf_ref_parent WHERE id = 1") + .await; + let survived = simple_column(&client, "SELECT id FROM conf_ref_parent").await?; + let result = (orphaning.is_err() && survived == ["1"]) + .then_some(()) + .ok_or_else(|| format!("the parent was deleted; rows left: {survived:?}")); + drop_table(&client, "conf_ref_child").await; + drop_table(&client, "conf_ref_parent").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a materialized view stores its rows and REFRESH recomputes them", + async { + drop_table(&client, "conf_mat_src").await; + let _ = client.simple_query("DROP TABLE IF EXISTS conf_mat").await; + client + .simple_query("CREATE TABLE conf_mat_src (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_mat_src (id) VALUES (1)") + .await + .map_err(describe)?; + client + .simple_query("CREATE MATERIALIZED VIEW conf_mat AS SELECT id FROM conf_mat_src") + .await + .map_err(describe)?; + + // A materialized view does not follow its source until refreshed. + client + .simple_query("INSERT INTO conf_mat_src (id) VALUES (2)") + .await + .map_err(describe)?; + let stale = simple_column(&client, "SELECT id FROM conf_mat ORDER BY id").await?; + client + .simple_query("REFRESH MATERIALIZED VIEW conf_mat") + .await + .map_err(describe)?; + let fresh = simple_column(&client, "SELECT id FROM conf_mat ORDER BY id").await?; + + let result = (stale == ["1"] && fresh == ["1", "2"]) + .then_some(()) + .ok_or(format!("stale {stale:?} then fresh {fresh:?}")); + let _ = client.simple_query("DROP TABLE IF EXISTS conf_mat").await; + drop_table(&client, "conf_mat_src").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a composite FOREIGN KEY is checked as a whole", + async { + drop_table(&client, "conf_ck_child").await; + drop_table(&client, "conf_ck_parent").await; + client + .simple_query( + "CREATE TABLE conf_ck_parent (a INTEGER, b INTEGER, PRIMARY KEY (a, b))", + ) + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TABLE conf_ck_child (a INTEGER, b INTEGER, \ + FOREIGN KEY (a, b) REFERENCES conf_ck_parent(a, b))", + ) + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_ck_parent (a, b) VALUES (1, 2)") + .await + .map_err(describe)?; + + // The pair (1, 2) exists. + client + .simple_query("INSERT INTO conf_ck_child (a, b) VALUES (1, 2)") + .await + .map_err(describe)?; + // Each value exists on its own, but the pair (1, 3) does not — a + // per-column check would wrongly accept this. + let mismatched = client + .simple_query("INSERT INTO conf_ck_child (a, b) VALUES (1, 3)") + .await; + + let result = mismatched + .is_err() + .then_some(()) + .ok_or("a pair that does not exist together was accepted".to_string()); + drop_table(&client, "conf_ck_child").await; + drop_table(&client, "conf_ck_parent").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "ON DELETE CASCADE removes the referring rows", + async { + drop_table(&client, "conf_cas_child").await; + drop_table(&client, "conf_cas_parent").await; + client + .simple_query("CREATE TABLE conf_cas_parent (id INTEGER PRIMARY KEY)") + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TABLE conf_cas_child (id INTEGER, parent INTEGER \ + REFERENCES conf_cas_parent(id) ON DELETE CASCADE)", + ) + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_cas_parent (id) VALUES (1)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_cas_child (id, parent) VALUES (1, 1)") + .await + .map_err(describe)?; + + client + .simple_query("DELETE FROM conf_cas_parent WHERE id = 1") + .await + .map_err(describe)?; + let remaining = simple_column(&client, "SELECT id FROM conf_cas_child").await?; + let result = remaining + .is_empty() + .then_some(()) + .ok_or(format!("{remaining:?} survived the cascade")); + drop_table(&client, "conf_cas_child").await; + drop_table(&client, "conf_cas_parent").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "ON DELETE SET NULL clears the referring column", + async { + drop_table(&client, "conf_sn_child").await; + drop_table(&client, "conf_sn_parent").await; + client + .simple_query("CREATE TABLE conf_sn_parent (id INTEGER PRIMARY KEY)") + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TABLE conf_sn_child (id INTEGER, parent INTEGER \ + REFERENCES conf_sn_parent(id) ON DELETE SET NULL)", + ) + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_sn_parent (id) VALUES (1)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_sn_child (id, parent) VALUES (1, 1)") + .await + .map_err(describe)?; + + client + .simple_query("DELETE FROM conf_sn_parent WHERE id = 1") + .await + .map_err(describe)?; + let rows = client + .query("SELECT parent FROM conf_sn_child", &[]) + .await + .map_err(describe)?; + let row = rows + .first() + .ok_or("the child row was deleted".to_string())?; + let parent: Option = row.try_get(0).map_err(describe)?; + let result = parent + .is_none() + .then_some(()) + .ok_or(format!("parent is {parent:?}, expected NULL")); + drop_table(&client, "conf_sn_child").await; + drop_table(&client, "conf_sn_parent").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a referenced key cannot be updated away", + async { + drop_table(&client, "conf_up_child").await; + drop_table(&client, "conf_up_parent").await; + client + .simple_query("CREATE TABLE conf_up_parent (id INTEGER PRIMARY KEY)") + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TABLE conf_up_child (id INTEGER, \ + parent INTEGER REFERENCES conf_up_parent(id))", + ) + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_up_parent (id) VALUES (1)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_up_child (id, parent) VALUES (1, 1)") + .await + .map_err(describe)?; + + let moved = client + .simple_query("UPDATE conf_up_parent SET id = 2 WHERE id = 1") + .await; + let still = simple_column(&client, "SELECT id FROM conf_up_parent").await?; + let result = (moved.is_err() && still == ["1"]) + .then_some(()) + .ok_or_else(|| format!("the key moved; parent now {still:?}")); + drop_table(&client, "conf_up_child").await; + drop_table(&client, "conf_up_parent").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "MATCH FULL rejects a half-null key", + async { + drop_table(&client, "conf_mf_child").await; + drop_table(&client, "conf_mf_parent").await; + client + .simple_query( + "CREATE TABLE conf_mf_parent (a INTEGER, b INTEGER, PRIMARY KEY (a, b))", + ) + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TABLE conf_mf_child (id INTEGER, a INTEGER, b INTEGER, \ + FOREIGN KEY (a, b) REFERENCES conf_mf_parent(a, b) MATCH FULL)", + ) + .await + .map_err(describe)?; + + // All-NULL is allowed; a mixture is not. + client + .simple_query("INSERT INTO conf_mf_child (id, a, b) VALUES (1, NULL, NULL)") + .await + .map_err(describe)?; + let mixed = client + .simple_query("INSERT INTO conf_mf_child (id, a, b) VALUES (2, 1, NULL)") + .await; + + let result = mixed + .is_err() + .then_some(()) + .ok_or("MATCH FULL accepted a half-null key".to_string()); + drop_table(&client, "conf_mf_child").await; + drop_table(&client, "conf_mf_parent").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "ON UPDATE CASCADE moves the children", + async { + drop_table(&client, "conf_ou_child").await; + drop_table(&client, "conf_ou_parent").await; + client + .simple_query("CREATE TABLE conf_ou_parent (id INTEGER PRIMARY KEY)") + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TABLE conf_ou_child (id INTEGER, parent INTEGER \ + REFERENCES conf_ou_parent(id) ON UPDATE CASCADE)", + ) + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_ou_parent (id) VALUES (1)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_ou_child (id, parent) VALUES (1, 1)") + .await + .map_err(describe)?; + + client + .simple_query("UPDATE conf_ou_parent SET id = 2 WHERE id = 1") + .await + .map_err(describe)?; + let parents = simple_column(&client, "SELECT parent FROM conf_ou_child").await?; + let result = (parents == ["2"]) + .then_some(()) + .ok_or(format!("child points at {parents:?}, expected [2]")); + drop_table(&client, "conf_ou_child").await; + drop_table(&client, "conf_ou_parent").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "a DEFERRABLE key is checked at COMMIT, not at INSERT", + async { + drop_table(&client, "conf_def_child").await; + drop_table(&client, "conf_def_parent").await; + client + .simple_query("CREATE TABLE conf_def_parent (id INTEGER PRIMARY KEY)") + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TABLE conf_def_child (id INTEGER, parent INTEGER \ + REFERENCES conf_def_parent(id) DEFERRABLE)", + ) + .await + .map_err(describe)?; + + // The child is written before its parent exists, which an + // immediate check would refuse. + client.simple_query("BEGIN").await.map_err(describe)?; + let early = client + .simple_query("INSERT INTO conf_def_child (id, parent) VALUES (1, 7)") + .await; + client + .simple_query("INSERT INTO conf_def_parent (id) VALUES (7)") + .await + .map_err(describe)?; + let committed = client.simple_query("COMMIT").await; + + let rows = simple_column(&client, "SELECT id FROM conf_def_child").await?; + let result = (early.is_ok() && committed.is_ok() && rows == ["1"]) + .then_some(()) + .ok_or_else(|| { + format!( + "insert {:?}, commit {:?}, rows {rows:?}", + early.is_ok(), + committed.is_ok() + ) + }); + drop_table(&client, "conf_def_child").await; + drop_table(&client, "conf_def_parent").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a domain carries its type and constraints", + async { + let _ = client + .simple_query("DROP DOMAIN IF EXISTS conf_positive") + .await; + drop_table(&client, "conf_domain").await; + client + .simple_query("CREATE DOMAIN conf_positive AS INTEGER NOT NULL") + .await + .map_err(describe)?; + client + .simple_query("CREATE TABLE conf_domain (id conf_positive)") + .await + .map_err(describe)?; + + // The base type accepts an integer... + client + .simple_query("INSERT INTO conf_domain (id) VALUES (5)") + .await + .map_err(describe)?; + // ...and the domain's NOT NULL is inherited by the column. + let null = client + .simple_query("INSERT INTO conf_domain (id) VALUES (NULL)") + .await; + + let values = simple_column(&client, "SELECT id FROM conf_domain").await?; + let result = (null.is_err() && values == ["5"]) + .then_some(()) + .ok_or_else(|| format!("null accepted: {}, values {values:?}", null.is_ok())); + drop_table(&client, "conf_domain").await; + let _ = client + .simple_query("DROP DOMAIN IF EXISTS conf_positive") + .await; + result + } + .await, + ); + + report.record( + Area::Sql, + "an AFTER INSERT trigger runs its statement", + async { + drop_table(&client, "conf_trig_src").await; + drop_table(&client, "conf_trig_log").await; + let _ = client + .simple_query("DROP TRIGGER IF EXISTS conf_trig") + .await; + client + .simple_query("CREATE TABLE conf_trig_src (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("CREATE TABLE conf_trig_log (note TEXT)") + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TRIGGER conf_trig AFTER INSERT ON conf_trig_src \ + FOR EACH ROW EXECUTE INSERT INTO conf_trig_log (note) VALUES ('fired')", + ) + .await + .map_err(describe)?; + + // Nothing is logged until the watched table is written. + let before = simple_column(&client, "SELECT note FROM conf_trig_log").await?; + client + .simple_query("INSERT INTO conf_trig_src (id) VALUES (1)") + .await + .map_err(describe)?; + let after = simple_column(&client, "SELECT note FROM conf_trig_log").await?; + + let result = (before.is_empty() && after == ["fired"]) + .then_some(()) + .ok_or(format!("log was {before:?} then {after:?}")); + let _ = client + .simple_query("DROP TRIGGER IF EXISTS conf_trig") + .await; + drop_table(&client, "conf_trig_src").await; + drop_table(&client, "conf_trig_log").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a domain CHECK applies to the column", + async { + let _ = client.simple_query("DROP DOMAIN IF EXISTS conf_pos").await; + drop_table(&client, "conf_domain_check").await; + client + .simple_query("CREATE DOMAIN conf_pos AS INTEGER CHECK (VALUE > 0)") + .await + .map_err(describe)?; + client + .simple_query("CREATE TABLE conf_domain_check (amount conf_pos)") + .await + .map_err(describe)?; + + client + .simple_query("INSERT INTO conf_domain_check (amount) VALUES (5)") + .await + .map_err(describe)?; + let negative = client + .simple_query("INSERT INTO conf_domain_check (amount) VALUES (-1)") + .await; + + let result = negative + .is_err() + .then_some(()) + .ok_or("the domain's CHECK did not apply".to_string()); + drop_table(&client, "conf_domain_check").await; + let _ = client.simple_query("DROP DOMAIN IF EXISTS conf_pos").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "SET CONSTRAINTS ALL IMMEDIATE reports a deferred failure early", + async { + drop_table(&client, "conf_sc_child").await; + drop_table(&client, "conf_sc_parent").await; + client + .simple_query("CREATE TABLE conf_sc_parent (id INTEGER PRIMARY KEY)") + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TABLE conf_sc_child (id INTEGER, parent INTEGER \ + REFERENCES conf_sc_parent(id) DEFERRABLE)", + ) + .await + .map_err(describe)?; + + client.simple_query("BEGIN").await.map_err(describe)?; + client + .simple_query("INSERT INTO conf_sc_child (id, parent) VALUES (1, 404)") + .await + .map_err(describe)?; + // The parent never arrives, so asking now must say so. + let early = client.simple_query("SET CONSTRAINTS ALL IMMEDIATE").await; + let _ = client.simple_query("ROLLBACK").await; + + let result = early + .is_err() + .then_some(()) + .ok_or("SET CONSTRAINTS ALL IMMEDIATE reported no problem".to_string()); + drop_table(&client, "conf_sc_child").await; + drop_table(&client, "conf_sc_parent").await; + result + } + .await, + ); + + report.record( + Area::Types, + "a row whose columns are all NULL can be stored", + async { + drop_table(&client, "conf_allnull").await; + client + .simple_query("CREATE TABLE conf_allnull (a INTEGER, b TEXT)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_allnull (a, b) VALUES (NULL, NULL)") + .await + .map_err(describe)?; + + let rows = client + .query("SELECT a FROM conf_allnull", &[]) + .await + .map_err(describe)?; + let result = (rows.len() == 1) + .then_some(()) + .ok_or(format!("{} rows, expected the all-NULL row", rows.len())); + drop_table(&client, "conf_allnull").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a FOR EACH ROW trigger reads NEW", + async { + drop_table(&client, "conf_new_src").await; + drop_table(&client, "conf_new_log").await; + let _ = client + .simple_query("DROP TRIGGER IF EXISTS conf_new_trig") + .await; + client + .simple_query("CREATE TABLE conf_new_src (id INTEGER, label TEXT)") + .await + .map_err(describe)?; + client + .simple_query("CREATE TABLE conf_new_log (seen TEXT)") + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TRIGGER conf_new_trig AFTER INSERT ON conf_new_src \ + FOR EACH ROW EXECUTE INSERT INTO conf_new_log (seen) VALUES (NEW.label)", + ) + .await + .map_err(describe)?; + + client + .simple_query("INSERT INTO conf_new_src (id, label) VALUES (1, 'written')") + .await + .map_err(describe)?; + let seen = simple_column(&client, "SELECT seen FROM conf_new_log").await?; + + let result = (seen == ["written"]) + .then_some(()) + .ok_or(format!("log holds {seen:?}, expected the NEW value")); + let _ = client + .simple_query("DROP TRIGGER IF EXISTS conf_new_trig") + .await; + drop_table(&client, "conf_new_src").await; + drop_table(&client, "conf_new_log").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a trigger WHEN clause gates the firing", + async { + drop_table(&client, "conf_when_src").await; + drop_table(&client, "conf_when_log").await; + let _ = client + .simple_query("DROP TRIGGER IF EXISTS conf_when_trig") + .await; + client + .simple_query("CREATE TABLE conf_when_src (id INTEGER, amount INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("CREATE TABLE conf_when_log (amount INTEGER)") + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TRIGGER conf_when_trig AFTER INSERT ON conf_when_src \ + FOR EACH ROW WHEN (NEW.amount > 10) \ + EXECUTE INSERT INTO conf_when_log (amount) VALUES (NEW.amount)", + ) + .await + .map_err(describe)?; + + // Below the threshold nothing is logged; above it, one row is. + client + .simple_query("INSERT INTO conf_when_src (id, amount) VALUES (1, 5)") + .await + .map_err(describe)?; + let quiet = simple_column(&client, "SELECT amount FROM conf_when_log").await?; + client + .simple_query("INSERT INTO conf_when_src (id, amount) VALUES (2, 50)") + .await + .map_err(describe)?; + let fired = simple_column(&client, "SELECT amount FROM conf_when_log").await?; + + let result = (quiet.is_empty() && fired == ["50"]) + .then_some(()) + .ok_or(format!("log was {quiet:?} then {fired:?}")); + let _ = client + .simple_query("DROP TRIGGER IF EXISTS conf_when_trig") + .await; + drop_table(&client, "conf_when_src").await; + drop_table(&client, "conf_when_log").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "SET CONSTRAINTS IMMEDIATE then DEFERRED changes when checks run", + async { + drop_table(&client, "conf_mode_child").await; + drop_table(&client, "conf_mode_parent").await; + client + .simple_query("CREATE TABLE conf_mode_parent (id INTEGER PRIMARY KEY)") + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TABLE conf_mode_child (id INTEGER, parent INTEGER \ + REFERENCES conf_mode_parent(id) DEFERRABLE)", + ) + .await + .map_err(describe)?; + + // Under IMMEDIATE the bad row is refused at the statement. + client.simple_query("BEGIN").await.map_err(describe)?; + client + .simple_query("SET CONSTRAINTS ALL IMMEDIATE") + .await + .map_err(describe)?; + let refused_now = client + .simple_query("INSERT INTO conf_mode_child (id, parent) VALUES (1, 404)") + .await; + let _ = client.simple_query("ROLLBACK").await; + + // Under DEFERRED the same row is accepted and only COMMIT objects. + client.simple_query("BEGIN").await.map_err(describe)?; + client + .simple_query("SET CONSTRAINTS ALL DEFERRED") + .await + .map_err(describe)?; + let accepted_now = client + .simple_query("INSERT INTO conf_mode_child (id, parent) VALUES (2, 404)") + .await; + let refused_at_commit = client.simple_query("COMMIT").await; + let _ = client.simple_query("ROLLBACK").await; + + let result = + (refused_now.is_err() && accepted_now.is_ok() && refused_at_commit.is_err()) + .then_some(()) + .ok_or_else(|| { + format!( + "immediate refused {}, deferred accepted {}, commit refused {}", + refused_now.is_err(), + accepted_now.is_ok(), + refused_at_commit.is_err() + ) + }); + drop_table(&client, "conf_mode_child").await; + drop_table(&client, "conf_mode_parent").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "an INSTEAD OF trigger makes a view writable", + async { + drop_table(&client, "conf_io_base").await; + let _ = client + .simple_query("DROP VIEW IF EXISTS conf_io_view") + .await; + let _ = client + .simple_query("DROP TRIGGER IF EXISTS conf_io_trig") + .await; + client + .simple_query("CREATE TABLE conf_io_base (id INTEGER, note TEXT)") + .await + .map_err(describe)?; + client + .simple_query("CREATE VIEW conf_io_view AS SELECT id FROM conf_io_base") + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TRIGGER conf_io_trig INSTEAD OF INSERT ON conf_io_view \ + FOR EACH ROW EXECUTE INSERT INTO conf_io_base (id, note) \ + VALUES (NEW.id, 'through the view')", + ) + .await + .map_err(describe)?; + + client + .simple_query("INSERT INTO conf_io_view (id) VALUES (7)") + .await + .map_err(describe)?; + let notes = simple_column(&client, "SELECT note FROM conf_io_base").await?; + + let result = (notes == ["through the view"]) + .then_some(()) + .ok_or(format!("base table holds {notes:?}")); + let _ = client + .simple_query("DROP TRIGGER IF EXISTS conf_io_trig") + .await; + let _ = client + .simple_query("DROP VIEW IF EXISTS conf_io_view") + .await; + drop_table(&client, "conf_io_base").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a BEFORE trigger rewrites the row being written", + async { + drop_table(&client, "conf_bt").await; + let _ = client + .simple_query("DROP TRIGGER IF EXISTS conf_bt_trig") + .await; + client + .simple_query("CREATE TABLE conf_bt (id INTEGER, amount INTEGER)") + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TRIGGER conf_bt_trig BEFORE INSERT ON conf_bt \ + FOR EACH ROW EXECUTE SET NEW.amount = NEW.amount * 2", + ) + .await + .map_err(describe)?; + + client + .simple_query("INSERT INTO conf_bt (id, amount) VALUES (1, 21)") + .await + .map_err(describe)?; + let stored = simple_column(&client, "SELECT amount FROM conf_bt").await?; + + let result = (stored == ["42"]) + .then_some(()) + .ok_or(format!("stored {stored:?}, expected the doubled value")); + let _ = client + .simple_query("DROP TRIGGER IF EXISTS conf_bt_trig") + .await; + drop_table(&client, "conf_bt").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "MATCH PARTIAL checks the parts that are present", + async { + drop_table(&client, "conf_mp_child").await; + drop_table(&client, "conf_mp_parent").await; + client + .simple_query( + "CREATE TABLE conf_mp_parent (a INTEGER, b INTEGER, PRIMARY KEY (a, b))", + ) + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TABLE conf_mp_child (id INTEGER, a INTEGER, b INTEGER, \ + FOREIGN KEY (a, b) REFERENCES conf_mp_parent(a, b) MATCH PARTIAL)", + ) + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_mp_parent (a, b) VALUES (1, 2)") + .await + .map_err(describe)?; + + // The present half matches a row, so this is allowed... + let matching = client + .simple_query("INSERT INTO conf_mp_child (id, a, b) VALUES (1, 1, NULL)") + .await; + // ...and this one does not match any row, so it is not. + let missing = client + .simple_query("INSERT INTO conf_mp_child (id, a, b) VALUES (2, 99, NULL)") + .await; + + let result = (matching.is_ok() && missing.is_err()) + .then_some(()) + .ok_or_else(|| { + format!( + "matching accepted {}, missing rejected {}", + matching.is_ok(), + missing.is_err() + ) + }); + drop_table(&client, "conf_mp_child").await; + drop_table(&client, "conf_mp_parent").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "ALTER DOMAIN reaches a table that already uses it", + async { + let _ = client + .simple_query("DROP DOMAIN IF EXISTS conf_alt_dom") + .await; + drop_table(&client, "conf_alt_use").await; + client + .simple_query("CREATE DOMAIN conf_alt_dom AS INTEGER") + .await + .map_err(describe)?; + client + .simple_query("CREATE TABLE conf_alt_use (amount conf_alt_dom)") + .await + .map_err(describe)?; + + // Unconstrained to begin with. + client + .simple_query("INSERT INTO conf_alt_use (amount) VALUES (-1)") + .await + .map_err(describe)?; + + // The constraint is added after the table already exists. + client + .simple_query("ALTER DOMAIN conf_alt_dom ADD CHECK (VALUE > 0)") + .await + .map_err(describe)?; + let refused = client + .simple_query("INSERT INTO conf_alt_use (amount) VALUES (-2)") + .await; + let allowed = client + .simple_query("INSERT INTO conf_alt_use (amount) VALUES (3)") + .await; + + let result = (refused.is_err() && allowed.is_ok()) + .then_some(()) + .ok_or_else(|| { + format!( + "negative refused {}, positive allowed {}", + refused.is_err(), + allowed.is_ok() + ) + }); + drop_table(&client, "conf_alt_use").await; + let _ = client + .simple_query("DROP DOMAIN IF EXISTS conf_alt_dom") + .await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a BEFORE UPDATE trigger rewrites the row", + async { + drop_table(&client, "conf_bu").await; + let _ = client + .simple_query("DROP TRIGGER IF EXISTS conf_bu_trig") + .await; + client + .simple_query("CREATE TABLE conf_bu (id INTEGER, amount INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_bu (id, amount) VALUES (1, 5)") + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TRIGGER conf_bu_trig BEFORE UPDATE ON conf_bu \ + FOR EACH ROW EXECUTE SET NEW.amount = 100", + ) + .await + .map_err(describe)?; + + client + .simple_query("UPDATE conf_bu SET amount = 7 WHERE id = 1") + .await + .map_err(describe)?; + let stored = simple_column(&client, "SELECT amount FROM conf_bu").await?; + + // The trigger's value wins over the statement's. + let result = (stored == ["100"]) + .then_some(()) + .ok_or(format!("stored {stored:?}, expected the trigger's value")); + let _ = client + .simple_query("DROP TRIGGER IF EXISTS conf_bu_trig") + .await; + drop_table(&client, "conf_bu").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a trigger body runs several statements", + async { + drop_table(&client, "conf_body_src").await; + drop_table(&client, "conf_body_log").await; + let _ = client + .simple_query("DROP TRIGGER IF EXISTS conf_body_trig") + .await; + client + .simple_query("CREATE TABLE conf_body_src (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("CREATE TABLE conf_body_log (note TEXT)") + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TRIGGER conf_body_trig AFTER INSERT ON conf_body_src \ + FOR EACH ROW EXECUTE $$BEGIN \ + INSERT INTO conf_body_log (note) VALUES ('first'); \ + INSERT INTO conf_body_log (note) VALUES ('second') END$$", + ) + .await + .map_err(describe)?; + + client + .simple_query("INSERT INTO conf_body_src (id) VALUES (1)") + .await + .map_err(describe)?; + let notes = + simple_column(&client, "SELECT note FROM conf_body_log ORDER BY note").await?; + + let result = (notes == ["first", "second"]) + .then_some(()) + .ok_or(format!("log holds {notes:?}, expected both statements")); + let _ = client + .simple_query("DROP TRIGGER IF EXISTS conf_body_trig") + .await; + drop_table(&client, "conf_body_src").await; + drop_table(&client, "conf_body_log").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "RAISE in a BEFORE trigger rejects the write", + async { + drop_table(&client, "conf_raise").await; + let _ = client + .simple_query("DROP TRIGGER IF EXISTS conf_raise_trig") + .await; + client + .simple_query("CREATE TABLE conf_raise (id INTEGER, amount INTEGER)") + .await + .map_err(describe)?; + client + .simple_query( + "CREATE TRIGGER conf_raise_trig BEFORE INSERT ON conf_raise \ + FOR EACH ROW WHEN (NEW.amount < 0) \ + EXECUTE RAISE EXCEPTION 'amount must not be negative'", + ) + .await + .map_err(describe)?; + + let allowed = client + .simple_query("INSERT INTO conf_raise (id, amount) VALUES (1, 5)") + .await; + let refused = client + .simple_query("INSERT INTO conf_raise (id, amount) VALUES (2, -5)") + .await; + let stored = simple_column(&client, "SELECT id FROM conf_raise").await?; + + let result = (allowed.is_ok() && refused.is_err() && stored == ["1"]) + .then_some(()) + .ok_or_else(|| { + format!( + "allowed {}, refused {}, rows {stored:?}", + allowed.is_ok(), + refused.is_err() + ) + }); + let _ = client + .simple_query("DROP TRIGGER IF EXISTS conf_raise_trig") + .await; + drop_table(&client, "conf_raise").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "an uncommitted write is invisible to another session", + async { + let other = connect().await?; + drop_table(&client, "conf_iso_read").await; + client + .simple_query("CREATE TABLE conf_iso_read (id INTEGER)") + .await + .map_err(describe)?; + + client.simple_query("BEGIN").await.map_err(describe)?; + client + .simple_query("INSERT INTO conf_iso_read (id) VALUES (1)") + .await + .map_err(describe)?; + + // The writer sees its own row; nobody else does yet. + let own = simple_column(&client, "SELECT id FROM conf_iso_read").await?; + let others = simple_column(&other, "SELECT id FROM conf_iso_read").await?; + + client.simple_query("COMMIT").await.map_err(describe)?; + let after_commit = simple_column(&other, "SELECT id FROM conf_iso_read").await?; + + let result = (own == ["1"] && others.is_empty() && after_commit == ["1"]) + .then_some(()) + .ok_or_else(|| { + format!("writer saw {own:?}, other saw {others:?} then {after_commit:?}") + }); + drop_table(&client, "conf_iso_read").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "a rolled-back write is never visible to another session", + async { + let other = connect().await?; + drop_table(&client, "conf_iso_roll").await; + client + .simple_query("CREATE TABLE conf_iso_roll (id INTEGER)") + .await + .map_err(describe)?; + + client.simple_query("BEGIN").await.map_err(describe)?; + client + .simple_query("INSERT INTO conf_iso_roll (id) VALUES (1)") + .await + .map_err(describe)?; + let during = simple_column(&other, "SELECT id FROM conf_iso_roll").await?; + client.simple_query("ROLLBACK").await.map_err(describe)?; + let after = simple_column(&other, "SELECT id FROM conf_iso_roll").await?; + + let result = (during.is_empty() && after.is_empty()) + .then_some(()) + .ok_or(format!("other saw {during:?} during and {after:?} after")); + drop_table(&client, "conf_iso_roll").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "an uncommitted delete is invisible to another session", + async { + let other = connect().await?; + drop_table(&client, "conf_iso_del").await; + client + .simple_query("CREATE TABLE conf_iso_del (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_iso_del (id) VALUES (1)") + .await + .map_err(describe)?; + + client.simple_query("BEGIN").await.map_err(describe)?; + client + .simple_query("DELETE FROM conf_iso_del WHERE id = 1") + .await + .map_err(describe)?; + + // The deleting session no longer sees it; nobody else has lost it + // yet, because the delete has not committed. + let own = simple_column(&client, "SELECT id FROM conf_iso_del").await?; + let others = simple_column(&other, "SELECT id FROM conf_iso_del").await?; + client.simple_query("COMMIT").await.map_err(describe)?; + let after = simple_column(&other, "SELECT id FROM conf_iso_del").await?; + + let result = (own.is_empty() && others == ["1"] && after.is_empty()) + .then_some(()) + .ok_or_else(|| format!("writer saw {own:?}, other saw {others:?} then {after:?}")); + drop_table(&client, "conf_iso_del").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "REPEATABLE READ sees the same rows twice", + async { + let other = connect().await?; + drop_table(&client, "conf_rr").await; + client + .simple_query("CREATE TABLE conf_rr (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_rr (id) VALUES (1)") + .await + .map_err(describe)?; + + client + .simple_query("BEGIN ISOLATION LEVEL REPEATABLE READ") + .await + .map_err(describe)?; + let first = simple_column(&client, "SELECT id FROM conf_rr").await?; + + // Another session commits a row after the snapshot was taken. + other + .simple_query("INSERT INTO conf_rr (id) VALUES (2)") + .await + .map_err(describe)?; + + let second = simple_column(&client, "SELECT id FROM conf_rr").await?; + client.simple_query("COMMIT").await.map_err(describe)?; + let after = simple_column(&client, "SELECT id FROM conf_rr ORDER BY id").await?; + + // The repeated read is unchanged; the new row appears once the + // block ends. + let result = (first == ["1"] && second == ["1"] && after == ["1", "2"]) + .then_some(()) + .ok_or_else(|| format!("{first:?} then {second:?}, after commit {after:?}")); + drop_table(&client, "conf_rr").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "READ COMMITTED sees a commit that lands mid-block", + async { + let other = connect().await?; + drop_table(&client, "conf_rc").await; + client + .simple_query("CREATE TABLE conf_rc (id INTEGER)") + .await + .map_err(describe)?; + + client.simple_query("BEGIN").await.map_err(describe)?; + let first = simple_column(&client, "SELECT id FROM conf_rc").await?; + other + .simple_query("INSERT INTO conf_rc (id) VALUES (5)") + .await + .map_err(describe)?; + let second = simple_column(&client, "SELECT id FROM conf_rc").await?; + client.simple_query("COMMIT").await.map_err(describe)?; + + // The default level is read-committed, so the second read differs. + let result = (first.is_empty() && second == ["5"]) + .then_some(()) + .ok_or(format!("{first:?} then {second:?}")); + drop_table(&client, "conf_rc").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "REPEATABLE READ sees a row as it stood, not as it was updated to", + async { + let other = connect().await?; + drop_table(&client, "conf_ver").await; + client + .simple_query("CREATE TABLE conf_ver (id INTEGER, amount INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_ver (id, amount) VALUES (1, 10)") + .await + .map_err(describe)?; + + client + .simple_query("BEGIN ISOLATION LEVEL REPEATABLE READ") + .await + .map_err(describe)?; + let first = simple_column(&client, "SELECT amount FROM conf_ver").await?; + + // Another session changes the row and commits, after the snapshot. + other.simple_query("BEGIN").await.map_err(describe)?; + other + .simple_query("UPDATE conf_ver SET amount = 99 WHERE id = 1") + .await + .map_err(describe)?; + other.simple_query("COMMIT").await.map_err(describe)?; + + let second = simple_column(&client, "SELECT amount FROM conf_ver").await?; + client.simple_query("COMMIT").await.map_err(describe)?; + let after = simple_column(&client, "SELECT amount FROM conf_ver").await?; + + // The snapshot keeps the old value; the new one appears after. + let result = (first == ["10"] && second == ["10"] && after == ["99"]) + .then_some(()) + .ok_or_else(|| format!("{first:?} then {second:?}, after commit {after:?}")); + drop_table(&client, "conf_ver").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "a rolled-back update leaves the original row", + async { + drop_table(&client, "conf_verroll").await; + client + .simple_query("CREATE TABLE conf_verroll (id INTEGER, amount INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_verroll (id, amount) VALUES (1, 10)") + .await + .map_err(describe)?; + + client.simple_query("BEGIN").await.map_err(describe)?; + client + .simple_query("UPDATE conf_verroll SET amount = 99 WHERE id = 1") + .await + .map_err(describe)?; + client.simple_query("ROLLBACK").await.map_err(describe)?; + + // Exactly one row, holding the value it started with. + let amounts = simple_column(&client, "SELECT amount FROM conf_verroll").await?; + let result = (amounts == ["10"]) + .then_some(()) + .ok_or(format!("got {amounts:?}, expected a single row of 10")); + drop_table(&client, "conf_verroll").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "SERIALIZABLE fails a block whose read moved underneath it", + async { + let other = connect().await?; + drop_table(&client, "conf_ser").await; + client + .simple_query("CREATE TABLE conf_ser (id INTEGER, amount INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_ser (id, amount) VALUES (1, 10)") + .await + .map_err(describe)?; + + client + .simple_query("BEGIN ISOLATION LEVEL SERIALIZABLE") + .await + .map_err(describe)?; + // Reading it is what puts the table under the block's protection. + let _ = simple_column(&client, "SELECT amount FROM conf_ser").await?; + + // Another session changes what was read, and commits first. + other + .simple_query("UPDATE conf_ser SET amount = 99 WHERE id = 1") + .await + .map_err(describe)?; + + let committed = client.simple_query("COMMIT").await; + let _ = client.simple_query("ROLLBACK").await; + + let result = committed + .is_err() + .then_some(()) + .ok_or("the serializable block committed over a concurrent write".to_string()); + drop_table(&client, "conf_ser").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "SERIALIZABLE commits when nothing moved", + async { + drop_table(&client, "conf_ser_ok").await; + client + .simple_query("CREATE TABLE conf_ser_ok (id INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_ser_ok (id) VALUES (1)") + .await + .map_err(describe)?; + + client + .simple_query("BEGIN ISOLATION LEVEL SERIALIZABLE") + .await + .map_err(describe)?; + let read = simple_column(&client, "SELECT id FROM conf_ser_ok").await?; + let committed = client.simple_query("COMMIT").await; + + // Nothing else touched the table, so the block must succeed — + // a check that only ever fails proves nothing. + let result = (read == ["1"] && committed.is_ok()) + .then_some(()) + .ok_or_else(|| format!("read {read:?}, commit ok {}", committed.is_ok())); + drop_table(&client, "conf_ser_ok").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "VACUUM reclaims the rows a committed delete left behind", + async { + drop_table(&client, "conf_vac").await; + client + .simple_query("CREATE TABLE conf_vac (id INTEGER)") + .await + .map_err(describe)?; + for id in [1, 2] { + client + .simple_query(&format!("INSERT INTO conf_vac (id) VALUES ({id})")) + .await + .map_err(describe)?; + } + + client.simple_query("BEGIN").await.map_err(describe)?; + client + .simple_query("DELETE FROM conf_vac WHERE id = 1") + .await + .map_err(describe)?; + client.simple_query("COMMIT").await.map_err(describe)?; + + client + .simple_query("VACUUM conf_vac") + .await + .map_err(describe)?; + + // The surviving row is untouched by the reclamation. + let remaining = simple_column(&client, "SELECT id FROM conf_vac").await?; + let result = (remaining == ["2"]).then_some(()).ok_or(format!( + "got {remaining:?}, expected only the row that stayed" + )); + drop_table(&client, "conf_vac").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "SERIALIZABLE tolerates a write to a row it did not read", + async { + let other = connect().await?; + drop_table(&client, "conf_ser_row").await; + client + .simple_query("CREATE TABLE conf_ser_row (id INTEGER PRIMARY KEY, amount INTEGER)") + .await + .map_err(describe)?; + for id in [1, 2] { + client + .simple_query(&format!( + "INSERT INTO conf_ser_row (id, amount) VALUES ({id}, 10)" + )) + .await + .map_err(describe)?; + } + + client + .simple_query("BEGIN ISOLATION LEVEL SERIALIZABLE") + .await + .map_err(describe)?; + // Only row 1 is read. + let read = + simple_column(&client, "SELECT amount FROM conf_ser_row WHERE id = 1").await?; + + // Another session writes row 2, which this block never looked at. + other + .simple_query("UPDATE conf_ser_row SET amount = 99 WHERE id = 2") + .await + .map_err(describe)?; + + let committed = client.simple_query("COMMIT").await; + let _ = client.simple_query("ROLLBACK").await; + + // A table-grained check would fail this; a row-grained one lets it + // through, which is the point of recording the rows. + let result = (read == ["10"] && committed.is_ok()) + .then_some(()) + .ok_or_else(|| format!("read {read:?}, commit ok {}", committed.is_ok())); + drop_table(&client, "conf_ser_row").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "SERIALIZABLE detects a phantom inserted into a range it read", + async { + let other = connect().await?; + drop_table(&client, "conf_phantom").await; + client + .simple_query("CREATE TABLE conf_phantom (id INTEGER PRIMARY KEY, grp INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_phantom (id, grp) VALUES (1, 7)") + .await + .map_err(describe)?; + + client + .simple_query("BEGIN ISOLATION LEVEL SERIALIZABLE") + .await + .map_err(describe)?; + // The block reads a range, not a row. + let seen = simple_column(&client, "SELECT id FROM conf_phantom WHERE grp = 7").await?; + + // Another session adds a row that would have been in that range. + other + .simple_query("INSERT INTO conf_phantom (id, grp) VALUES (2, 7)") + .await + .map_err(describe)?; + + let committed = client.simple_query("COMMIT").await; + let _ = client.simple_query("ROLLBACK").await; + + // Watching only the rows returned cannot see this: the phantom was + // not there to record. + let result = (seen == ["1"] && committed.is_err()) + .then_some(()) + .ok_or_else(|| format!("read {seen:?}, commit ok {}", committed.is_ok())); + drop_table(&client, "conf_phantom").await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "a phantom outside the range read is not a conflict", + async { + let other = connect().await?; + drop_table(&client, "conf_nophantom").await; + client + .simple_query("CREATE TABLE conf_nophantom (id INTEGER PRIMARY KEY, grp INTEGER)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_nophantom (id, grp) VALUES (1, 7)") + .await + .map_err(describe)?; + + client + .simple_query("BEGIN ISOLATION LEVEL SERIALIZABLE") + .await + .map_err(describe)?; + let seen = + simple_column(&client, "SELECT id FROM conf_nophantom WHERE grp = 7").await?; + + // A row in a different group: outside the predicate the block read. + other + .simple_query("INSERT INTO conf_nophantom (id, grp) VALUES (2, 8)") + .await + .map_err(describe)?; + + let committed = client.simple_query("COMMIT").await; + let _ = client.simple_query("ROLLBACK").await; + + let result = (seen == ["1"] && committed.is_ok()) + .then_some(()) + .ok_or_else(|| format!("read {seen:?}, commit ok {}", committed.is_ok())); + drop_table(&client, "conf_nophantom").await; + result + } + .await, + ); + + report.record( + Area::Copy, + "binary COPY round-trips through the server", + async { + use futures_util::{SinkExt, TryStreamExt}; + + drop_table(&client, "conf_bincopy").await; + client + .simple_query("CREATE TABLE conf_bincopy (id INTEGER, note TEXT)") + .await + .map_err(describe)?; + client + .simple_query("INSERT INTO conf_bincopy (id, note) VALUES (1, 'first')") + .await + .map_err(describe)?; + + // Read the table out in the binary format... + let stream = client + .copy_out("COPY conf_bincopy (id, note) TO STDOUT (FORMAT BINARY)") + .await + .map_err(describe)?; + let chunks: Vec = stream.try_collect().await.map_err(describe)?; + let payload: Vec = chunks.concat(); + if !payload.starts_with(b"PGCOPY\n\xff\r\n\0") { + let _ = client + .simple_query("DROP TABLE IF EXISTS conf_bincopy") + .await; + return Err(format!( + "stream did not start with the signature: {:?}", + &payload[..payload.len().min(16)] + )); + } + + // ...and load it straight back into a second table. + drop_table(&client, "conf_bincopy2").await; + client + .simple_query("CREATE TABLE conf_bincopy2 (id INTEGER, note TEXT)") + .await + .map_err(describe)?; + let sink = client + .copy_in("COPY conf_bincopy2 (id, note) FROM STDIN (FORMAT BINARY)") + .await + .map_err(describe)?; + futures_util::pin_mut!(sink); + sink.send(bytes::Bytes::from(payload)) + .await + .map_err(describe)?; + sink.close().await.map_err(describe)?; + + let notes = simple_column(&client, "SELECT note FROM conf_bincopy2").await?; + let result = (notes == ["first"]).then_some(()).ok_or(format!( + "got {notes:?}, expected the row to survive the round trip" + )); + let _ = client + .simple_query("DROP TABLE IF EXISTS conf_bincopy") + .await; + let _ = client + .simple_query("DROP TABLE IF EXISTS conf_bincopy2") + .await; + result + } + .await, + ); + + report.record( + Area::Transactions, + "ROLLBACK of a COPY spares another session's row", + async { + let other = connect().await?; + drop_table(&client, "conf_iso_copy").await; + client + .simple_query("CREATE TABLE conf_iso_copy (id INTEGER)") + .await + .map_err(describe)?; + + client.simple_query("BEGIN").await.map_err(describe)?; + let sink = client + .copy_in("COPY conf_iso_copy (id) FROM STDIN") + .await + .map_err(describe)?; + futures_util::pin_mut!(sink); + { + use futures_util::SinkExt; + sink.send(bytes::Bytes::from_static(b"1\n2\n")) + .await + .map_err(describe)?; + sink.close().await.map_err(describe)?; + } + other + .simple_query("INSERT INTO conf_iso_copy (id) VALUES (9)") + .await + .map_err(describe)?; + client.simple_query("ROLLBACK").await.map_err(describe)?; + + let ids = simple_column(&other, "SELECT id FROM conf_iso_copy").await?; + let result = (ids == ["9"]) + .then_some(()) + .ok_or(format!("got {ids:?}, expected only the committed row [9]")); + drop_table(&client, "conf_iso_copy").await; + result + } + .await, + ); + + report.record( + Area::Sql, + "a table larger than one scan page is counted in full", + async { + drop_table(&client, "conf_many").await; + client + .simple_query("CREATE TABLE conf_many (id INTEGER)") + .await + .map_err(describe)?; + + // Written in batches so the check costs a second, not a minute. + const ROWS: usize = 12_000; + for base in (0..ROWS).step_by(500) { + let values: Vec = (base..base + 500).map(|id| format!("({id})")).collect(); + client + .simple_query(&format!( + "INSERT INTO conf_many (id) VALUES {}", + values.join(", ") + )) + .await + .map_err(describe)?; + } + + // The storage layer capped an unlimited scan at its configured + // page size and reported success, so this answered with the cap. + let counted = simple_column(&client, "SELECT COUNT(*) FROM conf_many").await?; + let highest = simple_column(&client, "SELECT MAX(id) FROM conf_many").await?; + let result = (counted == [ROWS.to_string()] && highest == [(ROWS - 1).to_string()]) + .then_some(()) + .ok_or(format!( + "counted {counted:?} with a maximum of {highest:?}, expected {ROWS} rows" + )); + drop_table(&client, "conf_many").await; + result + } + .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, + "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", + 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()); + + // 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" + ); +} diff --git a/tests/integration/pg_crash_durability.rs b/tests/integration/pg_crash_durability.rs new file mode 100644 index 000000000..6912ddd50 --- /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}")) +}