diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d265f54 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.hooks/ + +target/ +../../target/ +**/target/ +**/node_modules/ +**/dist/ + +traildepot/ +docs/ + +Dockerfile* +.docker* + +.git/ +.git* + +*.image +.rustfmt.toml +.env diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..11d1ee2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,39 @@ +name: test + +on: + pull_request: + push: + branches: [main] + +jobs: + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: 'true' + - name: Install Dependencies + run: | + sudo apt-get update && \ + sudo apt-get install -y --no-install-recommends curl libssl-dev pkg-config libclang-dev protobuf-compiler libprotobuf-dev libsqlite3-dev + - uses: pnpm/action-setup@v4 + with: + version: 9 + - name: PNPM install + run: | + pnpm i + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: 3.24.3 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + default: true + - uses: actions/setup-python@v3 + - uses: pre-commit/action@v3.0.1 + # - name: Rust tests + # run: | + # cargo test -p trailbase-core -p trailbase-extension -p trailbase-sqlite -p trailbase-cli diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8163535 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Build artifacts +target/ +node_modules/ + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ + +# Dev artifacts +public/ +traildepot/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3a84216 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "vendor/refinery"] + path = vendor/refinery + url = git@github.com:trailbaseio/refinery.git +[submodule "vendor/sqlite_loadable"] + path = vendor/sqlite-loadable + url = git@github.com:trailbaseio/sqlite-loadable-rs.git +[submodule "vendor/sqlean/bundled/sqlean"] + path = vendor/sqlean/bundled/sqlean + url = https://github.com/trailbaseio/sqlean diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..09fed59 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,103 @@ +exclude: '(trailbase-core/bindings|bindings)/.*' + +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-byte-order-marker + - id: check-case-conflict + - id: check-merge-conflict + - id: check-symlinks + - id: check-yaml + - id: end-of-file-fixer + - id: mixed-line-ending + - id: trailing-whitespace + +# Self-validation for pre-commit manifest. +- repo: https://github.com/pre-commit/pre-commit + rev: v3.8.0 + hooks: + - id: validate_manifest + +- repo: local + hooks: + ### Rust ### + - id: cargofmt + name: Cargo Format + entry: cargo fmt -- --check + pass_filenames: false + # NOTE: language refers to the language in which the hook is implemented + # in, rather than the inputs. In this case we rely on cargo being + # installed on the system + language: system + # NOTE: types/files/exclude narrow the inputs the hook should run on. + types: [rust] + exclude: '^vendor/' + + - id: cargoclippy + name: Cargo Clippy + # Be verbose to at least still see warnings scroll by. + verbose: true + entry: cargo clippy --workspace --no-deps + language: system + types: [rust] + exclude: '^vendor/' + pass_filenames: false + + - id: cargotest + name: Cargo Test + entry: cargo test --workspace -- --show-output + language: system + types: [rust] + exclude: '^(vendor|bindings)/' + pass_filenames: false + + ### Auth, Admin, Docs UI ### + - id: prettier + name: Prettier + entry: pnpm -r format --check + language: system + types: [file] + files: .*\.(js|mjs|cjs|ts|jsx|tsx|astro|md|mdx)$ + pass_filenames: false + + - id: typescript_check + name: Typescript Check + entry: pnpm -r check + language: system + types: [file] + files: .*\.(js|mjs|cjs|ts|jsx|tsx|astro|mdx)$ + pass_filenames: false + + - id: javascript_test + name: JavaScript Test + entry: pnpm -r test + language: system + types: [file] + files: .*\.(js|mjs|cjs|ts|jsx|tsx|astro)$ + pass_filenames: false + + ### Dart client and example + - id: dart_format + name: Dart format + entry: dart format -o none --set-exit-if-changed client/trailbase-dart examples/blog/flutter + language: system + types: [file] + files: .*\.dart$ + pass_filenames: false + + - id: dart_analyze + name: Dart analyze + entry: sh -c 'dart pub -C client/trailbase-dart get && dart pub -C examples/blog/flutter get && dart analyze -- client/trailbase-dart examples/blog/flutter' + language: system + types: [file] + files: .*\.dart$ + pass_filenames: false + + - id: dart_test + name: Dart test + entry: sh -c 'cd client/trailbase-dart && dart pub get && dart test' + language: system + types: [file] + files: .*\.dart$ + pass_filenames: false diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..96caafd --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,8 @@ +# Docs: https://rust-lang.github.io/rustfmt/ +edition = "2021" +brace_style = "SameLineWhere" +empty_item_single_line = true +max_width = 100 +comment_width = 100 +wrap_comments = true +tab_spaces = 2 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..da44a32 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4658 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstream" +version = "0.6.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23a1e53f0f5d86382dafe1cf314783b2044280f406e7e1506368220ad11b1338" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8365de52b16c035ff4fcafe0092ba9390540e3e352870ac09933bebcaa2c8c56" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +dependencies = [ + "anstyle", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c042108f3ed77fd83760a5fd79b53be043192bb3b9dba91d8c574c0ada7850c8" + +[[package]] +name = "arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.3.1", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "444b0228950ee6501b3568d3c93bf1176a1fdbc3b758dcd9475046d30f4dc7e8" +dependencies = [ + "async-lock", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener 5.3.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" +dependencies = [ + "async-channel 2.3.1", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.3.1", + "futures-lite", + "rustix", + "tracing", +] + +[[package]] +name = "async-signal" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-std" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c634475f29802fde2b8f0b505b1bd00dfe4df7d4a000f0b36f7671197d5c3615" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto-future" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "axum" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504e3947307ac8326a5437504c517c4b56716c9d98fac0028c2acc7ca47d70ae" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http 1.1.0", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.1", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-client-ip" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eefda7e2b27e1bda4d6fa8a06b50803b8793769045918bc37ad062d48a6efac" +dependencies = [ + "axum", + "forwarded-header-value", + "serde", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.1.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.1", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73c3220b188aea709cf1b6c5f9b01c3bd936bb08bd2b5184a12b35ac8131b1f9" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-util", + "http 1.1.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "prost 0.12.6", + "serde", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-test" +version = "16.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ba5211f2e0b77d1cbe1e1c7c826edc20fb1f69720a7fdb1d2a25289c98978e" +dependencies = [ + "anyhow", + "assert-json-diff", + "auto-future", + "axum", + "bytes", + "bytesize", + "cookie", + "http 1.1.0", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower", + "url", +] + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "barrel" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9e605929a6964efbec5ac0884bd0fe93f12a3b1eb271f52c251316640c68d9" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + +[[package]] +name = "bindgen" +version = "0.66.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b84e06fc203107bfbad243f4aba2af864eb7db3b1cf46ea0a023b0b433d2a7" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn", + "which", +] + +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +dependencies = [ + "async-channel 2.3.1", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eeab4423108c5d7c744f4d234de88d18d636100093ae04caf4825134b9c3a32" + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "bytecount" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ce89b21cab1437276d2650d57e971f9d548a2d9037cc231abdc0562b97498ce" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" +dependencies = [ + "serde", +] + +[[package]] +name = "bytesize" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e368af43e418a04d52505cf3dbc23dda4e3407ae2fa99fd0e4f308ce546acc" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2e7962b54006dcfcc61cb72735f4d89bb97061dd6a7ed882ec6b8ee53714c6f" +dependencies = [ + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets", +] + +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.5.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +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 = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "futures", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "tokio", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "custom-binary" +version = "0.1.0" +dependencies = [ + "axum", + "env_logger", + "tokio", + "tracing-subscriber", + "trailbase-core", +] + +[[package]] +name = "der" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "email-encoding" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d1d33cdaede7e24091f039632eb5d3c7469fe5b066a985281a34fc70fa317f" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_filter" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +dependencies = [ + "event-listener 5.3.1", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.0.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "forwarded-header-value" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" +dependencies = [ + "nonempty", + "thiserror", +] + +[[package]] +name = "fraction" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f158e3ff0a1b334408dc9fb811cd99b446986f4d8b741bb08f9df1604085ae7" +dependencies = [ + "lazy_static", + "num", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +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", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "half" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" +dependencies = [ + "cfg-if", + "crunchy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.1.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http 1.1.0", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a397c49fec283e3d6211adbe480be95aae5f304cfb923e9970e08956d5168a" + +[[package]] +name = "httparse" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "hyper" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbff0a806a4728c99295b254c8838933b5b082d75e3cb70c8dab21fdfbcfa9a" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.1.0", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" +dependencies = [ + "futures-util", + "http 1.1.0", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.1.0", + "http-body", + "hyper", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd69211b9b519e98303c015e21a007e293db403b6c85b9b124e133d25e242cdd" +dependencies = [ + "icu_normalizer", + "icu_properties", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "indexmap" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +dependencies = [ + "equivalent", + "hashbrown 0.15.0", + "serde", +] + +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + +[[package]] +name = "infer" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc150e5ce2330295b8616ce0e3f53250e53af31759a9dbedad1621ba29151847" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" + +[[package]] +name = "is-terminal" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +dependencies = [ + "hermit-abi 0.4.0", + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "js-sys" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "893d6229c7315763ca0df9b29ab7661ee419f286577a02847c5521b462e071af" +dependencies = [ + "ahash", + "base64 0.22.1", + "bytecount", + "email_address", + "fancy-regex", + "fraction", + "idna 1.0.2", + "itoa", + "num-cmp", + "once_cell", + "percent-encoding", + "referencing", + "regex-syntax", + "serde", + "serde_json", + "uuid-simd", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f" +dependencies = [ + "base64 0.21.7", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "lettre" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0161e452348e399deb685ba05e55ee116cae9410f4f51fe42d597361444521d9" +dependencies = [ + "async-std", + "async-trait", + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna 1.0.2", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots", +] + +[[package]] +name = "libc" +version = "0.2.161" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" + +[[package]] +name = "libloading" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" +dependencies = [ + "cfg-if", + "windows-targets", +] + +[[package]] +name = "libmimalloc-sys" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23aa6811d3bd4deb8a84dde645f943476d13b248d818edcf8ce0b2f37f036b44" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "libsql" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18646e4ef8db446bc3e3f5fb96131483203bc5f4998ff149f79a067530c01c" +dependencies = [ + "async-trait", + "bitflags", + "bytes", + "futures", + "libsql-sys", + "serde", + "thiserror", + "tracing", +] + +[[package]] +name = "libsql-ffi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5919d202c2d296b4c44b6877d1b67fe6ad8f18520ce74bd70a29c383e44ccbee" +dependencies = [ + "bindgen 0.66.1", + "cc", +] + +[[package]] +name = "libsql-ffi" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2a50a585a1184a43621a9133b7702ba5cb7a87ca5e704056b19d8005de6faf" +dependencies = [ + "bindgen 0.66.1", + "cc", +] + +[[package]] +name = "libsql-rusqlite" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b811f72e13b9864601197d234621ffe89a490b2cb034cf28753b111334cf1db3" +dependencies = [ + "bitflags", + "fallible-iterator 0.2.0", + "fallible-streaming-iterator", + "hashlink", + "libsql-ffi 0.4.1", + "smallvec", +] + +[[package]] +name = "libsql-sys" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c05b61c226781d6f5e26e3e7364617f19c0c1d5332035802e9229d6024cec05" +dependencies = [ + "bytes", + "libsql-ffi 0.5.0", + "once_cell", + "tracing", + "zerocopy", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "litemap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "lockfree-object-pool" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +dependencies = [ + "value-bag", +] + +[[package]] +name = "logos" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c6b6e02facda28ca5fb8dbe4b152496ba3b1bd5a4b40bb2b1b2d8ad74e0f39b" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b32eb6b5f26efacd015b000bfc562186472cd9b34bdba3f6b264e2a052676d10" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "syn", +] + +[[package]] +name = "logos-derive" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e5d0c5463c911ef55624739fc353238b4e310f0144be1f875dc42fec6bfd5ec" +dependencies = [ + "logos-codegen", +] + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "mimalloc" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68914350ae34959d83f732418d51e2427a794055d0b9529f48259ac07af65633" +dependencies = [ + "libmimalloc-sys", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minijinja" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ca8daf4b0b4029777f1bc6e1aedd1aec7b74c276a43bc6f620a8e1a1c0a90e" +dependencies = [ + "serde", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "wasi", + "windows-sys 0.52.0", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.1.0", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "multimap" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nonempty" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oauth2" +version = "5.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d385da3c602d29036d2f70beed71c36604df7570be17fed4c5b839616785bf" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom", + "http 1.1.0", + "rand", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror", + "url", +] + +[[package]] +name = "object" +version = "0.36.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +dependencies = [ + "memchr", +] + +[[package]] +name = "object_store" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eb4c22c6154a1e759d7099f9ffad7cc5ef8245f9efbab4a41b92623079c82f3" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "humantime", + "itertools 0.13.0", + "parking_lot", + "percent-encoding", + "snafu", + "tokio", + "tracing", + "url", + "walkdir", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "oorandom" +version = "11.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9" + +[[package]] +name = "outref" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4030760ffd992bef45b0ae3f10ce1aba99e33464c90d14dd7c039884963ddc7a" + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "pem" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" +dependencies = [ + "base64 0.22.1", + "serde", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher 0.3.11", + "uncased", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "polling" +version = "3.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2790cd301dec6cd3b7a025e4815cf825724a51c98dccfe6a3e55f05ffb6511" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.4.0", + "pin-project-lite", + "rustix", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "prettyplease" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes", + "prost-derive 0.12.6", +] + +[[package]] +name = "prost" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b0487d90e047de87f984913713b85c601c05609aad5b0df4b4573fbf69aa13f" +dependencies = [ + "bytes", + "prost-derive 0.13.3", +] + +[[package]] +name = "prost-build" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1318b19085f08681016926435853bbf7858f9c082d0999b80550ff5d9abe15" +dependencies = [ + "bytes", + "heck", + "itertools 0.13.0", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost 0.13.3", + "prost-types 0.13.3", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-derive" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9552f850d5f0964a4e4d0bf306459ac29323ddfbae05e35a7c0d35cb0803cc5" +dependencies = [ + "anyhow", + "itertools 0.13.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-reflect" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f5eec97d5d34bdd17ad2db2219aabf46b054c6c41bd5529767c9ce55be5898f" +dependencies = [ + "logos", + "once_cell", + "prost 0.12.6", + "prost-reflect-derive 0.13.0", + "prost-types 0.12.6", +] + +[[package]] +name = "prost-reflect" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b7535b02f0e5efe3e1dbfcb428be152226ed0c66cad9541f2274c8ba8d4cd40" +dependencies = [ + "once_cell", + "prost 0.13.3", + "prost-reflect-derive 0.14.0", + "prost-types 0.13.3", +] + +[[package]] +name = "prost-reflect-build" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e2537231d94dd2778920c2ada37dd9eb1ac0325bb3ee3ee651bd44c1134123" +dependencies = [ + "prost-build", + "prost-reflect 0.14.2", +] + +[[package]] +name = "prost-reflect-derive" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64c3f519df051f8a700c5aa42b53f9c42d54959506b7ed58ac7a6af7991fdc22" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-reflect-derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fce6b22f15cc8d8d400a2b98ad29202b33bd56c7d9ddd815bc803a807ecb65" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +dependencies = [ + "prost 0.12.6", +] + +[[package]] +name = "prost-types" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4759aa0d3a6232fb8dbdb97b61de2c20047c68aca932c7ed76da9d788508d670" +dependencies = [ + "prost 0.13.3", +] + +[[package]] +name = "psm" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa37f80ca58604976033fae9515a8a2989fc13797d953f7c04fb8fa36a11f205" +dependencies = [ + "cc", +] + +[[package]] +name = "quinn" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c5fdde3cdae7203427dc4f0a68fe0ed09833edc525a03456b153b79828684" +dependencies = [ + "bytes", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.0.0", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "quinn-proto" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fadfaed2cd7f389d0161bb73eeb07b7b78f8691047a6f3e73caaeae55310a4a6" +dependencies = [ + "bytes", + "rand", + "ring", + "rustc-hash 2.0.0", + "rustls", + "slab", + "thiserror", + "tinyvec", + "tracing", +] + +[[package]] +name = "quinn-udp" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e346e016eacfff12233c243718197ca12f148c84e1e84268a896699b41c71780" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf0a6f84d5f1d581da8b41b47ec8600871962f2a528115b542b362d4b744931" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc303e793d3734489387d205e9b186fac9c6cfacedd98cbb2e8a5943595f3e6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "referencing" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb853437e467c693ac1dc8c1520105a31b8c2588544ff2f3cfa5a7c706c6c069" +dependencies = [ + "ahash", + "fluent-uri", + "once_cell", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "refinery" +version = "0.8.14" +dependencies = [ + "refinery-core", + "refinery-macros", +] + +[[package]] +name = "refinery-core" +version = "0.8.14" +dependencies = [ + "async-trait", + "cfg-if", + "log", + "regex", + "siphasher 1.0.1", + "thiserror", + "time", + "url", + "walkdir", +] + +[[package]] +name = "refinery-libsql" +version = "0.0.1" +dependencies = [ + "async-trait", + "barrel", + "libsql", + "refinery", + "refinery-core", + "tempfile", + "time", + "tokio", +] + +[[package]] +name = "refinery-macros" +version = "0.8.14" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "refinery-core", + "regex", + "syn", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "reqwest" +version = "0.12.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http 1.1.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.1", + "tokio", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "windows-registry", +] + +[[package]] +name = "reserve-port" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9838134a2bfaa8e1f40738fcc972ac799de6e0e06b5157acb95fc2b05a0ea283" +dependencies = [ + "lazy_static", + "thiserror", +] + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rust-embed" +version = "8.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa66af4a4fdd5e7ebc276f115e895611a34739a9c1c01028383d612d550953c0" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6125dbc8867951125eec87294137f4e9c2c96566e61bf72c45095a7c77761478" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5347777e9aacb56039b0e1f28785929a8a3b709e87482e7442c72e7c12529d" +dependencies = [ + "mime_guess", + "sha2", + "walkdir", +] + +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b748410c0afdef2ebbe3685a6a862e2ee937127cdaae623336a459451c8d57" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http 0.2.12", + "mime", + "mime_guess", + "rand", + "thiserror", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa260229e6538e52293eeb577aabd09945a09d6d9cc0fc550ed7529056c2e32a" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee87ff5d9b36712a58574e12e9f0ea80f915a5b0ac518d322b24a465617925e" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + +[[package]] +name = "serde" +version = "1.0.214" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.214" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.132" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simple_asn1" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "snafu" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "223891c85e2a29c3fe8fb900c1fae5e69c2e42415e3177752e8718475efa5019" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c3c6b7927ffe7ecaa769ee0e3994da3b8cafc8f444578982c83ecb161af917" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlean" +version = "0.0.1" +dependencies = [ + "bindgen 0.70.1", + "cc", + "libsql-ffi 0.5.0", +] + +[[package]] +name = "sqlformat" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" +dependencies = [ + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlite-loadable" +version = "0.0.6-alpha.6" +dependencies = [ + "bitflags", + "libsql-ffi 0.5.0", + "serde", + "serde_json", + "sqlite-loadable-macros", +] + +[[package]] +name = "sqlite-loadable-macros" +version = "0.0.3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sqlite3-parser" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb5307dad6cb84730ce8bdefde56ff4cf95fe516972d52e2bbdc8a8cd8f2520b" +dependencies = [ + "bitflags", + "cc", + "fallible-iterator 0.3.0", + "indexmap", + "log", + "memchr", + "phf", + "phf_codegen", + "phf_shared", + "uncased", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "stacker" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799c883d55abdb5e98af1a7b3f23b9b6de8ecada0ecac058672d7635eb48ca7b" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "temp-dir" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc1ee6eef34f12f765cb94725905c6312b6610ab2b0940889cfe58dae7bc3c72" + +[[package]] +name = "tempfile" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tinyvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145f3413504347a2be84393cc8a7d2fb4d863b375909ea59f2158261aa258bbb" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2873938d487c3cfb9aed7546dc9f2711d867c9f90c46b889989a2cb84eba6b4f" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 0.1.2", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-cookies" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fd0118512cf0b3768f7fcccf0bef1ae41d68f2b45edc1e77432b36c97c56c6d" +dependencies = [ + "async-trait", + "axum-core", + "cookie", + "futures-util", + "http 1.1.0", + "parking_lot", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8437150ab6bbc8c5f0f519e3d5ed4aa883a83dd4cdd3d1b21f9482936046cb97" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.1.0", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "trailbase-cli" +version = "0.1.0" +dependencies = [ + "axum", + "chrono", + "clap", + "env_logger", + "libsql", + "log", + "mimalloc", + "serde", + "serde_json", + "tokio", + "tracing-subscriber", + "trailbase-core", + "utoipa", + "utoipa-swagger-ui", + "uuid", +] + +[[package]] +name = "trailbase-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "arc-swap", + "argon2", + "async-trait", + "axum", + "axum-client-ip", + "axum-extra", + "axum-test", + "base64 0.22.1", + "chrono", + "cookie", + "criterion", + "ed25519-dalek", + "env_logger", + "fallible-iterator 0.3.0", + "form_urlencoded", + "futures", + "indexmap", + "indoc", + "itertools 0.13.0", + "jsonschema", + "jsonwebtoken", + "lazy_static", + "lettre", + "libsql", + "libsql-rusqlite", + "log", + "minijinja", + "oauth2", + "object_store", + "parking_lot", + "prost 0.12.6", + "prost-build", + "prost-reflect 0.13.1", + "prost-reflect-build", + "quoted_printable", + "rand", + "refinery", + "refinery-libsql", + "regex", + "reqwest", + "rust-embed", + "schemars", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha2", + "sqlformat", + "sqlite3-parser", + "temp-dir", + "thiserror", + "tokio", + "tower", + "tower-cookies", + "tower-http", + "tower-service", + "tracing", + "tracing-subscriber", + "trailbase-extension", + "trailbase-sqlite", + "ts-rs", + "url", + "utoipa", + "uuid", + "validator", +] + +[[package]] +name = "trailbase-extension" +version = "0.1.0" +dependencies = [ + "argon2", + "base64 0.22.1", + "jsonschema", + "libsql", + "lru", + "parking_lot", + "rand", + "regex", + "serde_json", + "sqlean", + "sqlite-loadable", + "tokio", + "uuid", + "validator", +] + +[[package]] +name = "trailbase-sqlite" +version = "0.1.0" +dependencies = [ + "infer", + "jsonschema", + "lazy_static", + "libsql", + "log", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "trailbase-extension", + "uuid", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ts-rs" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a2f31991cee3dce1ca4f929a8a04fdd11fd8801aac0f2030b0fa8a0a3fef6b9" +dependencies = [ + "lazy_static", + "serde_json", + "thiserror", + "ts-rs-macros", + "uuid", +] + +[[package]] +name = "ts-rs-macros" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea0b99e8ec44abd6f94a18f28f7934437809dd062820797c52401298116f70e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "termcolor", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicase" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df" + +[[package]] +name = "unicode-bidi" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +dependencies = [ + "form_urlencoded", + "idna 0.5.0", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utoipa" +version = "5.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d9ba0ade4e2f024cd1842dfbaf9dbc540639fc082299acf7649d71bd14eaca3" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf390d6503c9c9eac988447c38ba934a707b0b768b14511a493b4fc0e8ecb00" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "8.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5c80b4dd79ea382e8374d67dcce22b5c6663fa13a82ad3886441d1bbede5e35" +dependencies = [ + "axum", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "zip", +] + +[[package]] +name = "uuid" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +dependencies = [ + "getrandom", + "serde", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "uuid", + "vsimd", +] + +[[package]] +name = "validator" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db79c75af171630a3148bd3e6d7c4f42b6a9a014c2945bc5ed0020cbb8d9478e" +dependencies = [ + "idna 0.5.0", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", +] + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "value-bag" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ef4c4aa54d5d05a279399bfa921ec387b7aba77caf7a682ae8d86785b8fdad2" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" + +[[package]] +name = "web-sys" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841c67bff177718f1d4dfefde8d8f0e78f9b6589319ba88312f567fc5841a958" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-registry" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +dependencies = [ + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[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_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc5e4288ea4057ae23afc69a4472434a87a2495cafce6632fd1c4ec9f5cf3494" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + +[[package]] +name = "zopfli" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946" +dependencies = [ + "bumpalo", + "crc32fast", + "lockfree-object-pool", + "log", + "once_cell", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0c8e15c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,46 @@ +[workspace] +resolver = "2" +members = [ + "examples/custom-binary", + "trailbase-cli", + "trailbase-core", + "trailbase-extension", + "trailbase-sqlite", + "vendor/refinery-libsql", + "vendor/sqlean", +] +default-members = [ + "trailbase-cli", + "trailbase-core", + "trailbase-extension", + "trailbase-sqlite", +] +exclude = [ + "vendor/refinery", + "vendor/sqlite-loadable", +] + +# https://doc.rust-lang.org/cargo/reference/profiles.html +[profile.release] +panic = "unwind" +opt-level = 3 +# PGO doesn't work with LTO: https://github.com/llvm/llvm-project/issues/57501 +# lto = "off" +lto = true +codegen-units = 1 + +[workspace.dependencies] +libsql = { package = "libsql", version = "^0.6.0", default-features = false, features = ["core", "serde"] } +refinery = { package = "refinery", path = "vendor/refinery/refinery", default-features = false } +refinery-core = { package = "refinery-core", path = "vendor/refinery/refinery_core" } +refinery-libsql = { package = "refinery-libsql", path = "vendor/refinery-libsql" } +rusqlite = { package = "libsql-rusqlite", version = "^0.32", default-features = false, features = [ + "libsql-experimental", + "column_decltype", + "load_extension", + "modern_sqlite", + "functions", + "limits", + "backup", +] } +sqlite-loadable = { package = "sqlite-loadable", path = "./vendor/sqlite-loadable", features=["static"] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..675b947 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +FROM lukemathwalker/cargo-chef:latest-rust-1.81-slim AS chef + +# Install additional build dependencies. +# +# NOTE: we should consider building sqlean against +# libsql/libsql-sqlite3/src/sqlite3ext.h rather than upstrean libsqlite3-dev +# for increased consistency. +RUN apt-get update && apt-get install -y --no-install-recommends curl libssl-dev pkg-config libclang-dev protobuf-compiler libprotobuf-dev libsqlite3-dev + +ENV PATH=/usr/local/node/bin:$PATH +ARG NODE_VERSION=22.9.0 + +RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \ + /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \ + rm -rf /tmp/node-build-master + +RUN npm install -g pnpm +RUN pnpm --version + +FROM chef AS planner +WORKDIR /app +COPY . . +RUN cargo chef prepare --recipe-path recipe.json + + +FROM planner AS builder +# Re-build dependencies in case they have changed. +COPY --from=planner /app/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json + +COPY . . + +RUN RUSTFLAGS="-C target-feature=+crt-static" cargo build --target x86_64-unknown-linux-gnu --release --bin trail + +FROM alpine:3.20 AS runtime +RUN apk add --no-cache tini curl + +COPY --from=builder /app/target/x86_64-unknown-linux-gnu/release/trail /app/ + +# When `docker run` is executed, launch the binary as unprivileged user. +RUN adduser -D trailbase +USER trailbase + +WORKDIR /app + +EXPOSE 4000 +ENTRYPOINT ["tini", "--"] + +CMD ["/app/trail", "--data-dir", "/app/traildepot", "run", "--address", "0.0.0.0:4000"] + +HEALTHCHECK CMD curl --fail http://localhost:4000/api/healthcheck || exit 1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7ddce61 --- /dev/null +++ b/LICENSE @@ -0,0 +1,105 @@ +# Functional Source License, Version 1.1, Apache 2.0 Future License + +## Abbreviation + +FSL-1.1-Apache-2.0 + +## Notice + +Copyright 2024 Sebastian Jeltsch + +## Terms and Conditions + +### Licensor ("We") + +The party offering the Software under these Terms and Conditions. + +### The Software + +The "Software" is each version of the software that we make available under +these Terms and Conditions, as indicated by our inclusion of these Terms and +Conditions with the Software. + +### License Grant + +Subject to your compliance with this License Grant and the Patents, +Redistribution and Trademark clauses below, we hereby grant you the right to +use, copy, modify, create derivative works, publicly perform, publicly display +and redistribute the Software for any Permitted Purpose identified below. + +### Permitted Purpose + +A Permitted Purpose is any purpose other than a Competing Use. A Competing Use +means making the Software available to others in a commercial product or +service that: + +1. substitutes for the Software; + +2. substitutes for any other product or service we offer using the Software + that exists as of the date we make the Software available; or + +3. offers the same or substantially similar functionality as the Software. + +Permitted Purposes specifically include using the Software: + +1. for your internal use and access; + +2. for non-commercial education; + +3. for non-commercial research; and + +4. in connection with professional services that you provide to a licensee + using the Software in accordance with these Terms and Conditions. + +### Patents + +To the extent your use for a Permitted Purpose would necessarily infringe our +patents, the license grant above includes a license under our patents. If you +make a claim against any party that the Software infringes or contributes to +the infringement of any patent, then your patent license to the Software ends +immediately. + +### Redistribution + +The Terms and Conditions apply to all copies, modifications and derivatives of +the Software. + +If you redistribute any copies, modifications or derivatives of the Software, +you must include a copy of or a link to these Terms and Conditions and not +remove any copyright notices provided in or with the Software. + +### Disclaimer + +THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR +PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. + +IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE +SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, +EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. + +### Trademarks + +Except for displaying the License Details and identifying us as the origin of +the Software, you have no right under these Terms and Conditions to use our +trademarks, trade names, service marks or product names. + +## Grant of Future License + +We hereby irrevocably grant you an additional license to use the Software under +the Apache License, Version 2.0 that is effective on the second anniversary of +the date we make the Software available. On or after that date, you may use the +Software under the Apache License, Version 2.0, in which case the following +will apply: + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. + +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..da94511 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +default: format check + +target/x86_64-unknown-linux-gnu/release/trail: + RUSTFLAGS="-C target-feature=+crt-static" cargo build --target x86_64-unknown-linux-gnu --release --bin trail + +format: + pnpm -r format; \ + cargo +nightly fmt; \ + dart format client/trailbase-dart/ examples/blog/flutter/; \ + txtpbfmt `find . -regex ".*.textproto"` + +check: + pnpm -r check; \ + cargo clippy --workspace --no-deps; \ + dart analyze client/trailbase-dart examples/blog/flutter + +docker: + docker build . -t trailbase/trailbase + +.PHONY: default format check diff --git a/README.md b/README.md new file mode 100644 index 0000000..d214ea7 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# TrailBase + +A blazingly fast, single-file, and open-source server for your application with +type-safe restful APIs, auth, admin dashboard, etc. + +For more context, documentation, and an online live demo, check out our website +[trailbase.io](https://trailbase.io). + +## FAQ + +Check out our [website](https://trailbase.io/reference/faq/). + +## Project Structure + +This repository contains all components that make up TrailBase, as well as +tests, documentation and examples. +Only our [benchmarks](https://github.com/trailbaseio/trailbase-benchmark) are +kept separately due to their external dependencies. + +## Building + +If you have all the necessary build dependencies (rust, nodejs, pnpm, ...) +installed, you can simply build TrailBase by running: + +```bash +$ git submodule update --init --recursive +$ cargo build +``` + +Alternatively, you can build with docker: + +```bash +$ git submodule update --init --recursive +$ docker build . -t trailbase +``` + +## Contributing + +Contributions are very welcome, let's just talk upfront to see how a proposal +fits into the overall roadmap and avoid any surprises. diff --git a/assets/colors.svg b/assets/colors.svg new file mode 100644 index 0000000..091a750 --- /dev/null +++ b/assets/colors.svg @@ -0,0 +1,341 @@ + + + + + + + + + Accent + gray + + + 200 + 600 + 900 + 950 + + + + + 400 + + + + 100 + + + + + + 200 + 300 + 400 + 500 + 700 + + + + 800 + 900 + + + diff --git a/assets/favicon.svg b/assets/favicon.svg new file mode 100644 index 0000000..d66cff3 --- /dev/null +++ b/assets/favicon.svg @@ -0,0 +1,85 @@ + + + + diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..afa8056 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,191 @@ + + + + diff --git a/assets/logo_104.webp b/assets/logo_104.webp new file mode 100644 index 0000000..ea2dbb9 Binary files /dev/null and b/assets/logo_104.webp differ diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..3de1249 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,2 @@ +**/data/ +**/secrets/ diff --git a/client/testfixture/Makefile b/client/testfixture/Makefile new file mode 100644 index 0000000..114bbd5 --- /dev/null +++ b/client/testfixture/Makefile @@ -0,0 +1,4 @@ +clean: + rm -rf data/ uploads/ + +.PHONY: clean diff --git a/client/testfixture/config.textproto b/client/testfixture/config.textproto new file mode 100644 index 0000000..17a3eb7 --- /dev/null +++ b/client/testfixture/config.textproto @@ -0,0 +1,63 @@ +# Auto-generated config.Config textproto +email {} +server { + application_name: "TrailBase" + site_url: "http://localhost:4000" + logs_retention_sec: 604800 +} +auth {} +record_apis: [ + { + name: "_user_avatar" + table_name: "_user_avatar" + conflict_resolution: REPLACE + autofill_missing_user_id_columns: true + acl_world: [READ] + acl_authenticated: [CREATE, READ, UPDATE, DELETE] + create_access_rule: "_REQ_.user IS NULL OR _REQ_.user = _USER_.id" + update_access_rule: "_ROW_.user = _USER_.id" + delete_access_rule: "_ROW_.user = _USER_.id" + }, + { + name: "simple_strict_table" + table_name: "simple_strict_table" + acl_authenticated: [CREATE, READ, UPDATE, DELETE] + }, + { + name: "simple_complete_view" + table_name: "simple_complete_view" + acl_authenticated: [CREATE, READ, UPDATE, DELETE] + }, + { + name: "simple_subset_view" + table_name: "simple_subset_view" + acl_authenticated: [CREATE, READ, UPDATE, DELETE] + } +] +query_apis: [ + { + name: "simple_query_api" + virtual_table_name: "simple_query_api" + params: [ + { + name: "number" + type: INTEGER + } + ] + acl: WORLD + } +] +schemas: [ + { + name: "simple_schema" + schema: + "{" + " \"type\": \"object\"," + " \"properties\": {" + " \"name\": { \"type\": \"string\" }," + " \"obj\": { \"type\": \"object\" }" + " }," + " \"required\": [\"name\"]" + "}" + } +] diff --git a/client/testfixture/migrations/U1725019360__create_admin_user.sql b/client/testfixture/migrations/U1725019360__create_admin_user.sql new file mode 100644 index 0000000..8d97b06 --- /dev/null +++ b/client/testfixture/migrations/U1725019360__create_admin_user.sql @@ -0,0 +1,4 @@ +INSERT INTO _user + (id, email, password_hash, verified, admin) +VALUES + (uuid_v7(), 'admin@localhost', (hash_password('secret')), TRUE, TRUE); diff --git a/client/testfixture/migrations/U1725019361__add_users.sql b/client/testfixture/migrations/U1725019361__add_users.sql new file mode 100644 index 0000000..462e098 --- /dev/null +++ b/client/testfixture/migrations/U1725019361__add_users.sql @@ -0,0 +1,16 @@ +-- Add a a few non-admin users. +INSERT INTO _user (id, email, password_hash, verified) +VALUES + (uuid_v7(), '0@localhost', (hash_password('secret')), TRUE), + (uuid_v7(), '1@localhost', (hash_password('secret')), TRUE), + (uuid_v7(), '2@localhost', (hash_password('secret')), TRUE), + (uuid_v7(), '3@localhost', (hash_password('secret')), TRUE), + (uuid_v7(), '4@localhost', (hash_password('secret')), TRUE), + (uuid_v7(), '5@localhost', (hash_password('secret')), TRUE), + (uuid_v7(), '6@localhost', (hash_password('secret')), TRUE), + (uuid_v7(), '7@localhost', (hash_password('secret')), TRUE), + (uuid_v7(), '8@localhost', (hash_password('secret')), TRUE), + (uuid_v7(), '9@localhost', (hash_password('secret')), TRUE), + (uuid_v7(), '10@localhost', (hash_password('secret')), TRUE), + (uuid_v7(), '11@localhost', (hash_password('secret')), TRUE), + (uuid_v7(), '12@localhost', (hash_password('secret')), TRUE); diff --git a/client/testfixture/migrations/U1727439999__create_simple_strict_table.sql b/client/testfixture/migrations/U1727439999__create_simple_strict_table.sql new file mode 100644 index 0000000..a5304d2 --- /dev/null +++ b/client/testfixture/migrations/U1727439999__create_simple_strict_table.sql @@ -0,0 +1,69 @@ +-- Create a canonical table satisfying API requirements. +CREATE TABLE simple_strict_table ( + id BLOB PRIMARY KEY CHECK (is_uuid_v7(id)) DEFAULT (uuid_v7()) NOT NULL, + + text_null TEXT, + text_default TEXT DEFAULT '', + text_not_null TEXT NOT NULL DEFAULT '', + + int_null INTEGER, + int_default INTEGER DEFAULT 5, + int_not_null INTEGER NOT NULL DEFAULT 7, + + real_null REAL, + real_default REAL DEFAULT 5.1, + real_not_null REAL NOT NULL DEFAULT 7.1, + + blob_null BLOB, + blob_default BLOB DEFAULT X'AABBCCDD', + blob_not_null BLOB NOT NULL DEFAULT X'AABBCCDD' +) STRICT; + + +-- Create a variety of views. +CREATE VIEW simple_complete_view AS SELECT * FROM simple_strict_table; +CREATE VIEW simple_subset_view AS SELECT id, text_null AS t_null, text_default AS t_default, text_not_null AS t_not_null FROM simple_strict_table; +CREATE VIEW simple_subset_wo_id_view AS SELECT text_null, text_default, text_not_null FROM simple_strict_table; +CREATE VIEW simple_filter_view AS SELECT * FROM simple_strict_table WHERE (int_not_null % 2) = 0; + + +INSERT INTO simple_strict_table + (text_default, text_not_null, int_default, int_not_null, real_default, real_not_null, blob_default, blob_not_null) +VALUES + ('1', '1', 1, 1, 1.1, 1.2, X'01', X'01'), + ('2', '2', 2, 2, 2.1, 2.2, X'02', X'02'), + ('3', '3', 3, 3, 3.1, 3.2, X'03', X'03'), + ('4', '4', 4, 4, 4.1, 4.2, X'04', X'04'), + ('5', '5', 5, 5, 5.1, 5.2, X'05', X'05'), + ('6', '6', 6, 6, 6.1, 6.2, X'06', X'06'), + ('7', '7', 7, 7, 7.1, 7.2, X'07', X'07'), + ('8', '8', 8, 8, 8.1, 8.2, X'08', X'08'), + ('9', '9', 9, 9, 9.1, 9.2, X'09', X'09'), + ('10', '10', 10, 10, 10.1, 10.2, X'0A', X'0A'), + ('11', '11', 11, 11, 11.1, 11.2, X'0B', X'0B'), + ('12', '12', 12, 12, 12.1, 12.2, X'0C', X'0C'), + ('13', '13', 13, 13, 13.1, 13.2, X'0D', X'0D'), + ('14', '14', 14, 14, 14.1, 14.2, X'0E', X'0E'), + ('15', '15', 15, 15, 15.1, 15.2, X'0F', X'0F'), + ('16', '16', 16, 16, 16.1, 16.2, X'10', X'10'), + ('17', '17', 17, 17, 17.1, 17.2, X'11', X'11'), + ('18', '18', 18, 18, 18.1, 18.2, X'12', X'12'), + ('19', '19', 19, 19, 19.1, 19.2, X'13', X'13'), + ('20', '20', 20, 20, 20.1, 20.2, X'14', X'14'), + ('21', '21', 21, 21, 21.1, 21.2, X'15', X'15'); + +CREATE TABLE simple_strict_table_int ( + id INTEGER PRIMARY KEY, + + text_null TEXT, + blob_null BLOB, + int_null INTEGER, + real_null REAL, + any_col ANY +) STRICT; + +INSERT INTO simple_strict_table_int (id, text_null, blob_null, int_null, real_null, any_col) +VALUES + (NULL, '1', X'01', 1, 1.1, 'one'), + (NULL, '2', X'02', 2, 2.2, 2), + (NULL, '3', X'03', 3, 3.3, 3.3); diff --git a/client/testfixture/migrations/U1727956148__create_more_tables.sql b/client/testfixture/migrations/U1727956148__create_more_tables.sql new file mode 100644 index 0000000..a699066 --- /dev/null +++ b/client/testfixture/migrations/U1727956148__create_more_tables.sql @@ -0,0 +1,31 @@ +-- Create a table that doesn't satisfy record API requirements and uses +-- "affinity names" rather than strict storage types. +CREATE TABLE non_strict_table ( + id INTEGER PRIMARY KEY NOT NULL, + + tinyint_col TINYINT, + bigint_col BIGINT, + + varchar_col VARCHAR(64), + double_col DOUBLE, + float_col FLOAT, + + boolean_col BOOLEAN, + date_col DATE, + datetime_col DATETIME +); + +INSERT INTO non_strict_table + (id, tinyint_col, bigint_col, varchar_col, double_col, float_col, boolean_col, date_col, datetime_col) +VALUES + (0, 5, 64, 'varchar', 5.2, 2.4, FALSE, UNIXEPOCH(), UNIXEPOCH()), + (1, 5, 64, 'varchar', 5.2, 2.4, FALSE, UNIXEPOCH(), UNIXEPOCH()), + (2, 5, 64, 'varchar', 5.2, 2.4, FALSE, UNIXEPOCH(), UNIXEPOCH()), + (NULL, 5, 64, 'varchar', 5.2, 2.4, FALSE, UNIXEPOCH(), UNIXEPOCH()); + +CREATE TABLE non_strict_autoincrement_table ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + int4_col INT4 +); + +INSERT INTO non_strict_autoincrement_table (int4_col) VALUES (12); diff --git a/client/testfixture/migrations/U1728458183__create_virtual_table.sql b/client/testfixture/migrations/U1728458183__create_virtual_table.sql new file mode 100644 index 0000000..2cda4ca --- /dev/null +++ b/client/testfixture/migrations/U1728458183__create_virtual_table.sql @@ -0,0 +1,51 @@ +-- Create a virtual R-star table backed by physical, shadow tables. +-- +-- NOTE: The column types are here only for readability. rtree doesn't care. +CREATE VIRTUAL TABLE virtual_spatial_index USING rtree( + id INTEGER, + + -- Minimum and maximum X coordinate (rtree uses f32) + minX, + maxX, + + -- Minimum and maximum Y coordinate (rtree uses f32) + minY, + maxY, + + -- From the docs: + -- + -- "For auxiliary columns, only the name of the column matters. The type + -- affinity is ignored. Constraints such as NOT NULL, UNIQUE, REFERENCES, or + -- CHECK are also ignored. However, future versions of SQLite might start + -- paying attention to the type affinity and constraints, so users of + -- auxiliary columns are advised to leave both blank, to avoid future + -- compatibility problems." + +uuid BLOB +); + +-- 14 zipcodes near Charlotte, NC. Inspired by https://sqlite.org/rtree.html. +INSERT INTO virtual_spatial_index VALUES + (28215, -80.781227, -80.604706, 35.208813, 35.297367, uuid_v7()), + (28216, -80.957283, -80.840599, 35.235920, 35.367825, uuid_v7()), + (28217, -80.960869, -80.869431, 35.133682, 35.208233, uuid_v7()), + (28226, -80.878983, -80.778275, 35.060287, 35.154446, uuid_v7()), + (28227, -80.745544, -80.555382, 35.130215, 35.236916, uuid_v7()), + (28244, -80.844208, -80.841988, 35.223728, 35.225471, uuid_v7()), + (28262, -80.809074, -80.682938, 35.276207, 35.377747, uuid_v7()), + (28269, -80.851471, -80.735718, 35.272560, 35.407925, uuid_v7()), + (28270, -80.794983, -80.728966, 35.059872, 35.161823, uuid_v7()), + (28273, -80.994766, -80.875259, 35.074734, 35.172836, uuid_v7()), + (28277, -80.876793, -80.767586, 35.001709, 35.101063, uuid_v7()), + (28278, -81.058029, -80.956375, 35.044701, 35.223812, uuid_v7()), + (28280, -80.844208, -80.841972, 35.225468, 35.227203, uuid_v7()), + (28282, -80.846382, -80.844193, 35.223972, 35.225655, uuid_v7()); + +-- NOTE: define rejects mutating statements. +-- CREATE VIRTUAL TABLE virtual_spatial_index_writer USING define( +-- (INSERT INTO virtual_spatial_index VALUES ($1, $2, $3, $4, $5, uuid_v7()) RETURNING *)); + +-- Create a virtual table based on a stored procedure. +-- +-- This virtual table is also exposed as a Query API in the config. To see in +-- action browse to: http://localhost:4000/api/query/v1/simple_query_api?number=4. +CREATE VIRTUAL TABLE simple_query_api USING define((SELECT UNIXEPOCH() AS epoch, $1 AS random_number)); diff --git a/client/trailbase-dart/.gitignore b/client/trailbase-dart/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/client/trailbase-dart/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/client/trailbase-dart/.pubignore b/client/trailbase-dart/.pubignore new file mode 100644 index 0000000..6e3b0e5 --- /dev/null +++ b/client/trailbase-dart/.pubignore @@ -0,0 +1,2 @@ +test +analysis_options.yaml diff --git a/client/trailbase-dart/CHANGELOG.md b/client/trailbase-dart/CHANGELOG.md new file mode 100644 index 0000000..763ea3e --- /dev/null +++ b/client/trailbase-dart/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## 0.1.0 + +### Features + +- Initial client release including support for authentication and record APIs. diff --git a/client/trailbase-dart/LICENSE b/client/trailbase-dart/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/client/trailbase-dart/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/client/trailbase-dart/README.md b/client/trailbase-dart/README.md new file mode 100644 index 0000000..b0baeab --- /dev/null +++ b/client/trailbase-dart/README.md @@ -0,0 +1,13 @@ +# TrailBase client library for Dart and Flutter + +TrailBase is a blazingly fast, single-file, and open-source server for your +application with type-safe restful APIs, auth, admin dashboard, etc. + +For more context, documentation, and an online live demo, check out our website +[trailbase.io](https://trailbase.io). + +This is the first-party client for hooking up your Flutter or Dart applications +with your TrailBase server. +While working on documentation, an example setup can be found under +[`/examples/blog/flutter`](https://github.com/trailbaseio/trailbase/tree/main/examples/blog/flutter) +in the repository. diff --git a/client/trailbase-dart/analysis_options.yaml b/client/trailbase-dart/analysis_options.yaml new file mode 100644 index 0000000..fed045e --- /dev/null +++ b/client/trailbase-dart/analysis_options.yaml @@ -0,0 +1,31 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +linter: + rules: + prefer_single_quotes: true + unnecessary_brace_in_string_interps: false + unawaited_futures: true + sort_child_properties_last: false + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/client/trailbase-dart/lib/src/client.dart b/client/trailbase-dart/lib/src/client.dart new file mode 100644 index 0000000..8c69d8a --- /dev/null +++ b/client/trailbase-dart/lib/src/client.dart @@ -0,0 +1,548 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:jwt_decoder/jwt_decoder.dart'; +import 'package:logging/logging.dart'; +import 'package:dio/dio.dart' as dio; + +class User { + final String id; + final String email; + + const User({ + required this.id, + required this.email, + }); + + User.fromJson(Map json) + : id = json['id'], + email = json['email']; + + @override + String toString() => 'User(id=${id}, email=${email})'; +} + +class Tokens { + final String auth; + final String? refresh; + final String? csrf; + + const Tokens(this.auth, this.refresh, this.csrf); + + Tokens.fromJson(Map json) + : auth = json['auth_token'], + refresh = json['refresh_token'], + csrf = json['csrf_token']; + + Map toJson() => { + 'auth_token': auth, + 'refresh_token': refresh, + 'csrf_token': csrf, + }; + + @override + String toString() => 'Tokens(${auth}, ${refresh}, ${csrf})'; +} + +class JwtToken { + final String sub; + final int iat; + final int exp; + final String email; + final String csrfToken; + + const JwtToken({ + required this.sub, + required this.iat, + required this.exp, + required this.email, + required this.csrfToken, + }); + + JwtToken.fromJson(Map json) + : sub = json['sub'], + iat = json['iat'], + exp = json['exp'], + email = json['email'], + csrfToken = json['csrf_token']; +} + +class _TokenState { + final (Tokens, JwtToken)? state; + final Map headers; + + const _TokenState(this.state, this.headers); + + static _TokenState build(Tokens? tokens) { + return _TokenState( + tokens != null + ? (tokens, JwtToken.fromJson(JwtDecoder.decode(tokens.auth))) + : null, + buildHeaders(tokens), + ); + } +} + +class Pagination { + final String? cursor; + final int? limit; + + const Pagination({ + required this.cursor, + required this.limit, + }); +} + +class RecordId { + @override + String toString(); + + factory RecordId.integer(int id) => _IntegerRecordId(id); + factory RecordId.uuid(String id) => _UuidRecordId(id); +} + +class _ResponseRecordId implements RecordId { + final String id; + + const _ResponseRecordId(this.id); + + _ResponseRecordId.fromJson(Map json) : id = json['id']; + + int integer() => int.parse(id); + Uint8List uuid() => base64Decode(id); + + @override + String toString() => id; + + @override + bool operator ==(Object other) { + if (other is _ResponseRecordId) return id == other.id; + + if (other is int) return int.tryParse(id) == other; + if (other is _IntegerRecordId) return int.tryParse(id) == other.id; + if (other is String) return id == other; + if (other is _UuidRecordId) return id == other.id; + + return false; + } + + @override + int get hashCode => id.hashCode; +} + +class _IntegerRecordId implements RecordId { + final int id; + + const _IntegerRecordId(this.id); + + @override + String toString() => id.toString(); + + @override + bool operator ==(Object other) { + if (other is _IntegerRecordId) return id == other.id; + if (other is int) return id == other; + if (other is _ResponseRecordId) return id == int.tryParse(other.id); + return false; + } + + @override + int get hashCode => id.hashCode; +} + +extension RecordIdExtInt on int { + RecordId id() => _IntegerRecordId(this); +} + +class _UuidRecordId implements RecordId { + final String id; + + const _UuidRecordId(this.id); + + @override + String toString() => id; + + @override + bool operator ==(Object other) { + if (other is _UuidRecordId) return id == other.id; + if (other is String) return id == other; + if (other is _ResponseRecordId) return id == other.id; + return false; + } + + @override + int get hashCode => id.hashCode; +} + +extension RecordIdExtString on String { + RecordId id() => _UuidRecordId(this); +} + +class RecordApi { + static const String _recordApi = 'api/records/v1'; + + final String _name; + final Client _client; + + const RecordApi(this._client, this._name); + + Future>> list({ + Pagination? pagination, + List? order, + List? filters, + }) async { + final params = {}; + if (pagination != null) { + final cursor = pagination.cursor; + if (cursor != null) params['cursor'] = cursor; + + final limit = pagination.limit; + if (limit != null) params['limit'] = limit.toString(); + } + + if (order != null) params['order'] = order.join(','); + + if (filters != null) { + for (final filter in filters) { + final (nameOp, value) = splitOnce(filter, '='); + if (value == null) { + throw Exception( + 'Filter "${filter}" does not match: "name[op]=value"'); + } + params[nameOp] = value; + } + } + + final response = await _client.fetch( + '${RecordApi._recordApi}/${_name}', + queryParams: params, + ); + + return (response.data as List).cast>(); + } + + Future> read(RecordId id) async { + final response = await _client.fetch( + '${RecordApi._recordApi}/${_name}/${id}', + ); + return response.data; + } + + Future create(Map record) async { + final response = await _client.fetch( + '${RecordApi._recordApi}/${_name}', + method: 'POST', + data: record, + ); + + if ((response.statusCode ?? 400) > 200) { + throw Exception('${response.data} ${response.statusMessage}'); + } + return _ResponseRecordId.fromJson(response.data); + } + + Future update( + RecordId id, + Map record, + ) async { + await _client.fetch( + '${RecordApi._recordApi}/${_name}/${id}', + method: 'PATCH', + data: record, + ); + } + + Future delete(RecordId id) async { + await _client.fetch( + '${RecordApi._recordApi}/${_name}/${id}', + method: 'DELETE', + ); + } + + Uri imageUri(RecordId id, String colName, {int? index}) { + if (index != null) { + return Uri.parse( + '${_client.site()}/${RecordApi._recordApi}/${_name}/${id}/file/${colName}/${index}'); + } + return Uri.parse( + '${_client.site()}/${RecordApi._recordApi}/${_name}/${id}/file/${colName}'); + } +} + +class _ThinClient { + static final _dio = dio.Dio(); + + final String site; + + const _ThinClient(this.site); + + Future fetch( + String path, + _TokenState tokenState, { + Object? data, + String? method, + Map? queryParams, + }) async { + if (path.startsWith('/')) { + throw Exception('Path starts with "/". Relative path expected.'); + } + + final response = await _dio.request( + '${site}/${path}', + data: data, + queryParameters: queryParams, + options: dio.Options( + method: method, + headers: tokenState.headers, + validateStatus: (int? status) => true, + ), + ); + + return response; + } +} + +class Client { + static const String _authApi = 'api/auth/v1'; + + final _ThinClient _client; + final String _site; + _TokenState _tokenState; + final void Function(Client, Tokens?)? _authChange; + + Client._( + String site, { + Tokens? tokens, + void Function(Client, Tokens?)? onAuthChange, + }) : _client = _ThinClient(site), + _site = site, + _tokenState = _TokenState.build(tokens), + _authChange = onAuthChange; + + Client( + String site, { + void Function(Client, Tokens?)? onAuthChange, + }) : this._(site, onAuthChange: onAuthChange); + + static Future withTokens(String site, Tokens tokens, + {void Function(Client, Tokens?)? onAuthChange}) async { + final client = Client(site, onAuthChange: onAuthChange); + + try { + final statusResponse = await client._client + .fetch('${_authApi}/status', _TokenState.build(tokens)); + final Map response = statusResponse.data; + + final newTokens = Tokens( + response['auth_token'], + tokens.refresh, + response['csrf_token'], + ); + client._tokenState = _TokenState.build(newTokens); + client._authChange?.call(client, newTokens); + } catch (err) { + // Do nothing + } + + return client; + } + + /// Access to the raw tokens, can be used to persist login state. + Tokens? tokens() => _tokenState.state?.$1; + User? user() { + final authToken = tokens()?.auth; + if (authToken != null) { + return User.fromJson(JwtDecoder.decode(authToken)['user']); + } + return null; + } + + String site() => _site; + + RecordApi records(String name) => RecordApi(this, name); + + _TokenState _updateTokens(Tokens? tokens) { + final state = _TokenState.build(tokens); + + _tokenState = state; + _authChange?.call(this, state.state?.$1); + + final claims = state.state?.$2; + if (claims != null) { + final now = DateTime.now().millisecondsSinceEpoch / 1000; + if (claims.exp < now) { + _logger.warning('Token expired'); + } + } + + return state; + } + + Future login(String email, String password) async { + final response = await fetch( + '${_authApi}/login', + method: 'POST', + data: { + 'email': email, + 'password': password, + }, + ); + + final Map json = response.data; + final tokens = Tokens( + json['auth_token']!, + json['refresh_token'], + json['csrf_token'], + ); + + _updateTokens(tokens); + return tokens; + } + + Future loginWithAuthCode( + String authCode, { + String? pkceCodeVerifier, + }) async { + final response = await fetch( + '${Client._authApi}/token', + method: 'POST', + data: { + 'authorization_code': authCode, + 'pkce_code_verifier': pkceCodeVerifier, + }, + ); + + final Map tokenResponse = await response.data; + final tokens = Tokens( + tokenResponse['auth_token']!, + tokenResponse['refresh_token']!, + tokenResponse['csrf_token'], + ); + + _updateTokens(tokens); + return tokens; + } + + Future logout() async { + final refreshToken = _tokenState.state?.$1.refresh; + try { + if (refreshToken != null) { + await fetch('${_authApi}/logout', method: 'POST', data: { + 'refresh_token': refreshToken, + }); + } else { + await fetch('${_authApi}/logout'); + } + } catch (err) { + _logger.warning(err); + } + _updateTokens(null); + return true; + } + + Future deleteUser() async { + await fetch('${Client._authApi}/delete'); + _updateTokens(null); + } + + Future changeEmail(String email) async { + await fetch( + '${Client._authApi}/change_email', + method: 'POST', + data: { + 'new_email': email, + }, + ); + } + + Future refreshAuthToken() async { + final refreshToken = _shouldRefresh(_tokenState); + if (refreshToken != null) { + _tokenState = await _refreshTokensImpl(refreshToken); + } + } + + Future<_TokenState> _refreshTokensImpl(String refreshToken) async { + final response = await _client.fetch( + '${_authApi}/refresh', + _tokenState, + method: 'POST', + data: { + 'refresh_token': refreshToken, + }, + ); + + final Map tokenResponse = await response.data; + return _TokenState.build(Tokens( + tokenResponse['auth_token']!, + refreshToken, + tokenResponse['csrf_token'], + )); + } + + static String? _shouldRefresh(_TokenState tokenState) { + final state = tokenState.state; + final now = DateTime.now().millisecondsSinceEpoch / 1000; + if (state != null && state.$2.exp - 60 < now) { + return state.$1.refresh; + } + return null; + } + + Future fetch( + String path, { + bool? throwOnError, + Object? data, + String? method, + Map? queryParams, + }) async { + var tokenState = _tokenState; + final refreshToken = _shouldRefresh(tokenState); + if (refreshToken != null) { + tokenState = _tokenState = await _refreshTokensImpl(refreshToken); + } + + final response = await _client.fetch(path, tokenState, + data: data, method: method, queryParams: queryParams); + + if (response.statusCode != 200 && (throwOnError ?? true)) { + final errMsg = await response.data; + throw Exception( + '[${response.statusCode}] ${response.statusMessage}}: ${errMsg}'); + } + + return response; + } +} + +Map buildHeaders(Tokens? tokens) { + final Map base = { + 'Content-Type': 'application/json', + }; + + if (tokens != null) { + base['Authorization'] = 'Bearer ${tokens.auth}'; + + final refresh = tokens.refresh; + if (refresh != null) { + base['Refresh-Token'] = refresh; + } + + final csrf = tokens.csrf; + if (csrf != null) { + base['CSRF-Token'] = csrf; + } + } + + return base; +} + +(String, String?) splitOnce(String s, Pattern pattern) { + final int idx = s.indexOf(pattern); + if (idx < 0) { + return (s, null); + } + return (s.substring(0, idx), s.substring(idx + 1)); +} + +final _logger = Logger('trailbase'); diff --git a/client/trailbase-dart/lib/src/pkce.dart b/client/trailbase-dart/lib/src/pkce.dart new file mode 100644 index 0000000..2f98fce --- /dev/null +++ b/client/trailbase-dart/lib/src/pkce.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; + +/// A pair of (pkceCodeVerifier, pkceCodeChallenge). +typedef PkcePair = ({ + /// The random code verifier. + String verifier, + + /// The code challenge, computed as base64UrlNoPad(sha256(verifier)). + String challenge +}); + +extension Pkce on PkcePair { + /// Generates a [PkcePair]. + /// + /// [length] is the length used to generate the [verifier]. It must be + /// between 32 and 96, inclusive, which corresponds to a [verifier] of + /// length between 43 and 128, inclusive. The spec recommends a length of 32. + static PkcePair generate({int length = 32}) { + if (length < 32 || length > 96) { + throw ArgumentError.value( + length, + 'length', + 'The length must be between 32 and 96, inclusive.', + ); + } + + final random = Random.secure(); + final verifier = + base64UrlEncode(List.generate(length, (_) => random.nextInt(256))) + .split('=') + .first; + final challenge = + base64UrlEncode(sha256.convert(ascii.encode(verifier)).bytes) + .split('=') + .first; + + return (verifier: verifier, challenge: challenge); + } +} diff --git a/client/trailbase-dart/lib/trailbase.dart b/client/trailbase-dart/lib/trailbase.dart new file mode 100644 index 0000000..06a947f --- /dev/null +++ b/client/trailbase-dart/lib/trailbase.dart @@ -0,0 +1,4 @@ +library; + +export 'src/client.dart'; +export 'src/pkce.dart'; diff --git a/client/trailbase-dart/pubspec.yaml b/client/trailbase-dart/pubspec.yaml new file mode 100644 index 0000000..9d7a338 --- /dev/null +++ b/client/trailbase-dart/pubspec.yaml @@ -0,0 +1,18 @@ +name: trailbase +description: Thing client library for TrailBase. +homepage: https://trailbase.io +repository: https://github.com/trailbaseio/trailbase +version: 0.1.0 + +environment: + sdk: ^3.5.3 + +dependencies: + crypto: ^3.0.5 + dio: ^5.7.0 + jwt_decoder: ^2.0.1 + logging: ^1.2.0 + +dev_dependencies: + lints: ^5.0.0 + test: ^1.24.0 diff --git a/client/trailbase-dart/test/trailbase_test.dart b/client/trailbase-dart/test/trailbase_test.dart new file mode 100644 index 0000000..0462e45 --- /dev/null +++ b/client/trailbase-dart/test/trailbase_test.dart @@ -0,0 +1,140 @@ +import 'dart:io'; + +import 'package:trailbase/trailbase.dart'; +import 'package:test/test.dart'; +import 'package:dio/dio.dart'; + +const port = 4006; + +class SimpleStrict { + final String id; + + final String? textNull; + final String? textDefault; + final String textNotNull; + + SimpleStrict.fromJson(Map json) + : id = json['id'], + textNull = json['text_null'], + textDefault = json['text_default'], + textNotNull = json['text_not_null']; +} + +Future connect() async { + final client = Client('http://127.0.0.1:${port}'); + await client.login('admin@localhost', 'secret'); + return client; +} + +Future initTrailBase() async { + final result = await Process.run('cargo', ['build']); + if (result.exitCode > 0) { + throw Exception( + 'Cargo build failed.\n\nstdout: ${result.stdout}}\n\nstderr: ${result.stderr}}\n'); + } + final process = await Process.start('cargo', [ + 'run', + '--', + '--data-dir', + '../testfixture', + 'run', + '--dev', + '-a', + '127.0.0.1:${port}', + ]); + + final dio = Dio(); + for (int i = 0; i < 50; ++i) { + try { + final response = await dio.fetch( + RequestOptions(path: 'http://127.0.0.1:${port}/api/healthcheck')); + if (response.statusCode == 200) { + return process; + } + } catch (err) { + print('Trying to connect to TrailBase'); + } + + await Future.delayed(Duration(milliseconds: 500)); + } + + process.kill(ProcessSignal.sigkill); + final exitCode = await process.exitCode; + + await process.stdout.forEach(print); + await process.stderr.forEach(print); + throw Exception('Cargo run failed: ${exitCode}.'); +} + +Future main() async { + if (!Directory.current.path.endsWith('trailbase-dart')) { + throw Exception('Unexpected working directory'); + } + + await initTrailBase(); + + group('client tests', () { + test('auth', () async { + final client = await connect(); + + final oldTokens = client.tokens(); + expect(oldTokens, isNotNull); + + // We need to wait a little to push the expiry time in seconds to avoid just getting the same token minted again. + await Future.delayed(Duration(milliseconds: 1500)); + + await client.refreshAuthToken(); + final newTokens = client.tokens(); + expect(newTokens, isNot(equals(oldTokens!.auth))); + }); + + test('records', () async { + final client = await connect(); + final api = client.records('simple_strict_table'); + + final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final messages = [ + 'dart client test 0: ${now}', + 'dart client test 1: ${now}', + ]; + final ids = []; + for (final msg in messages) { + ids.add(await api.create({'text_not_null': msg})); + } + + { + final records = await api.list( + filters: ['text_not_null=${messages[0]}'], + ); + expect(records.length, 1); + expect(records[0]['text_not_null'], messages[0]); + } + + { + final recordsAsc = await api.list( + order: ['+text_not_null'], + filters: ['text_not_null[like]=%${now}'], + ); + expect(recordsAsc.map((el) => el['text_not_null']), + orderedEquals(messages)); + + final recordsDesc = await api.list( + order: ['-text_not_null'], + filters: ['text_not_null[like]=%${now}'], + ); + expect(recordsDesc.map((el) => el['text_not_null']).toList().reversed, + orderedEquals(messages)); + } + + final record = SimpleStrict.fromJson(await api.read(ids[0])); + + expect(ids[0] == record.id, isTrue); + // Note: the .id() is needed otherwise we call String's operator==. It's not ideal + // but we didn't come up with a better option. + expect(record.id.id() == ids[0], isTrue); + expect(RecordId.uuid(record.id) == ids[0], isTrue); + + expect(record.textNotNull, messages[0]); + }); + }); +} diff --git a/client/trailbase-ts/.gitignore b/client/trailbase-ts/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/client/trailbase-ts/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/client/trailbase-ts/eslint.config.mjs b/client/trailbase-ts/eslint.config.mjs new file mode 100644 index 0000000..c1105f9 --- /dev/null +++ b/client/trailbase-ts/eslint.config.mjs @@ -0,0 +1,30 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default [ + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + { + ignores: ["dist/", "node_modules/"], + }, + { + files: ["**/*.{js,mjs,cjs,mts,ts,tsx,jsx}"], + rules: { + // https://typescript-eslint.io/rules/no-explicit-any/ + "@typescript-eslint/no-explicit-any": "warn", + // http://eslint.org/docs/rules/no-unused-vars + "@typescript-eslint/no-unused-vars": [ + "error", + { + vars: "all", + args: "after-used", + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + "no-empty": ["error", { allowEmptyCatch: true }], + }, + languageOptions: { globals: globals.browser }, + }, +]; diff --git a/client/trailbase-ts/package.json b/client/trailbase-ts/package.json new file mode 100644 index 0000000..3f5051c --- /dev/null +++ b/client/trailbase-ts/package.json @@ -0,0 +1,53 @@ +{ + "name": "trailbase", + "version": "0.1.0", + "description": "Official TrailBase client", + "type": "module", + "main": "./src/index.ts", + "publishConfig": { + "access": "public", + "main": "./dist/client/trailbase-ts/src/index.js", + "types": "./dist/client/trailbase-ts/src/index.d.ts", + "exports": { + ".": { + "types": "./dist/client/trailbase-ts/src/index.d.ts", + "default": "./dist/client/trailbase-ts/src/index.js" + } + } + }, + "files": [ + "dist", + "package.json" + ], + "repository": { + "type": "git", + "url": "https://github.com/trailbaseio/trailbae.git", + "directory": "client/trailbase-ts" + }, + "homepage": "https://trailbase.io", + "scripts": { + "start": "tsc && node dist/client/trailbase-ts/src/index.js", + "build": "tsc", + "test": "vitest run && vite-node tests/integration_test_runner.ts", + "format": "prettier -w src tests", + "check": "tsc --noEmit --skipLibCheck && eslint" + }, + "devDependencies": { + "@eslint/js": "^9.13.0", + "eslint": "^9.13.0", + "execa": "^9.5.1", + "globals": "^15.11.0", + "http-status": "^2.0.0", + "jsdom": "^25.0.1", + "prettier": "^3.3.3", + "tinybench": "^3.0.0", + "typescript": "^5.6.3", + "typescript-eslint": "^8.12.1", + "vite-node": "^2.1.4", + "vitest": "^2.1.4" + }, + "dependencies": { + "jwt-decode": "^4.0.0", + "uuid": "^11.0.2" + } +} diff --git a/client/trailbase-ts/src/index.ts b/client/trailbase-ts/src/index.ts new file mode 100644 index 0000000..02ca8dc --- /dev/null +++ b/client/trailbase-ts/src/index.ts @@ -0,0 +1,531 @@ +import { jwtDecode } from "jwt-decode"; + +import type { ChangeEmailRequest } from "@bindings/ChangeEmailRequest"; +import type { LoginRequest } from "@bindings/LoginRequest"; +import type { LoginResponse } from "@bindings/LoginResponse"; +import type { LoginStatusResponse } from "@bindings/LoginStatusResponse"; +import type { LogoutRequest } from "@bindings/LogoutRequest"; +import type { RefreshRequest } from "@bindings/RefreshRequest"; +import type { RefreshResponse } from "@bindings/RefreshResponse"; + +export type User = { + id: string; + email: string; +}; + +export type Pagination = { + cursor?: string; + limit?: number; +}; + +export type Tokens = { + auth_token: string; + refresh_token: string | null; + csrf_token: string | null; +}; + +type TokenClaims = { + sub: string; + iat: number; + exp: number; + email: string; + csrf_token: string; +}; + +type TokenState = { + state?: { + tokens: Tokens; + claims: TokenClaims; + }; + headers: HeadersInit; +}; + +function buildTokenState(tokens?: Tokens): TokenState { + return { + state: tokens && { + tokens, + claims: jwtDecode(tokens.auth_token), + }, + headers: headers(tokens), + }; +} + +type FetchOptions = RequestInit & { + throwOnError?: boolean; +}; + +export class FetchError extends Error { + public status: number; + + constructor(status: number, msg: string) { + super(msg); + this.status = status; + } + + static async from(response: Response): Promise { + let body: string | undefined; + try { + body = await response.text(); + } catch {} + + console.warn(response); + + return new FetchError( + response.status, + body ? `${response.statusText}: ${body}` : response.statusText, + ); + } + + public isClient(): boolean { + return this.status >= 400 && this.status < 500; + } + + public isServer(): boolean { + return this.status >= 500; + } + + public toString(): string { + return `[${this.status}] ${this.message}`; + } +} + +export interface FileUpload { + content_type?: null | string; + filename?: null | string; + mime_type?: null | string; + objectstore_path: string; +} + +/// Provides CRUD access to records through TrailBase's record API. +/// +/// TODO: add file upload/download. +export class RecordApi { + private static readonly _recordApi = "api/records/v1"; + private readonly _createApi: string; + + constructor( + private readonly client: Client, + private readonly name: string, + ) { + this._createApi = `${RecordApi._recordApi}/${this.name}`; + } + + public async list>(opts?: { + pagination?: Pagination; + order?: string[]; + filters?: string[]; + }): Promise { + const params: [string, string][] = []; + const pagination = opts?.pagination; + if (pagination) { + const cursor = pagination.cursor; + if (cursor) params.push(["cursor", cursor]); + + const limit = pagination.limit; + if (limit) params.push(["limit", limit.toString()]); + } + const order = opts?.order; + if (order) params.push(["order", order.join(",")]); + + const filters = opts?.filters; + if (filters) { + for (const filter of filters) { + const [nameOp, value] = filter.split("=", 2); + if (value === undefined) { + throw Error(`Filter '${filter}' does not match: 'name[op]=value'`); + } + params.push([nameOp, value]); + } + } + + const queryParams = encodeURI( + params.map(([key, value]) => `${key}=${value}`).join("&"), + ); + const response = await this.client.fetch( + `${RecordApi._recordApi}/${this.name}?${queryParams}`, + ); + return (await response.json()) as T[]; + } + + public async read>( + id: string | number, + ): Promise { + const response = await this.client.fetch( + `${RecordApi._recordApi}/${this.name}/${id}`, + ); + return (await response.json()) as T; + } + + public async create>( + record: T, + ): Promise { + return this.client.fetch(this._createApi, { + method: "POST", + body: JSON.stringify(record), + }); + } + + public async createId>( + record: T, + ): Promise { + const response = await this.create(record); + return (await response.json()).id; + } + + public async update>( + id: string | number, + record: Partial, + ): Promise { + await this.client.fetch(`${RecordApi._recordApi}/${this.name}/${id}`, { + method: "PATCH", + body: JSON.stringify(record), + }); + } + + public async delete(id: string | number): Promise { + await this.client.fetch(`${RecordApi._recordApi}/${this.name}/${id}`, { + method: "DELETE", + }); + } + + public imageUri(id: string | number, colName: string): string { + return `${this.client.site}/${RecordApi._recordApi}/${this.name}/${id}/file/${colName}`; + } + + public imagesUri( + id: string | number, + colName: string, + index: number, + ): string { + return `${this.client.site}/${RecordApi._recordApi}/${this.name}/${id}/files/${colName}/${index}`; + } +} + +class ThinClient { + constructor(public readonly site: string) {} + + public async fetch( + path: string, + tokenState: TokenState, + init?: RequestInit, + ): Promise { + if (path.startsWith("/")) { + throw Error("Path starts with '/'. Relative path expected."); + } + + const response = await fetch(`${this.site}/${path}`, { + ...init, + credentials: isDev ? "include" : "same-origin", + headers: tokenState.headers, + }); + + return response; + } +} + +type ClientOptions = { + tokens?: Tokens; + onAuthChange?: (client: Client, user?: User) => void; +}; + +/// Client for interacting with TrailBase auth and record APIs. +/// +/// TODO: Add +/// * issue_password_reset_email +/// * issue_change_email +/// * status +export class Client { + private static readonly _authApi = "api/auth/v1"; + private static readonly _authUi = "_/auth"; + + private readonly _client: ThinClient; + private readonly _authChange: + | undefined + | ((client: Client, user?: User) => void); + private _tokenState: TokenState; + + constructor(site: string, opts?: ClientOptions) { + this._client = new ThinClient(site); + this._authChange = opts?.onAuthChange; + + this._tokenState = this.updateTokens(opts?.tokens); + } + + public static init(site: string, opts?: ClientOptions): Client { + return new Client(site, opts); + } + + public static async tryFromCookies( + site: string, + opts?: ClientOptions, + ): Promise { + const client = new Client(site, opts); + + // Prefer explicit tokens. When given, do not update/refresh infinite recursion + // with `($token) => Client` factories. + if (!client.tokens()) { + try { + const response = await client.fetch(`${Client._authApi}/status`); + const status: LoginStatusResponse = await response.json(); + + const authToken = status?.auth_token; + if (authToken) { + client.updateTokens({ + auth_token: authToken, + refresh_token: status.refresh_token, + csrf_token: status.csrf_token, + }); + } + } catch (err) { + console.debug("No valid cookies found: ", err); + } + } + + return client; + } + + private updateTokens(tokens?: Tokens): TokenState { + const state = buildTokenState(tokens); + + this._tokenState = state; + this._authChange?.(this, this.user()); + + const claims = state.state?.claims; + if (claims) { + const now = Date.now() / 1000; + if (claims.exp < now) { + console.warn("Token expired"); + } + } + + return state; + } + + public get site() { + return this._client.site; + } + + /// Low-level access to tokens (auth, refresh, csrf) useful for persisting them. + public tokens = (): Tokens | undefined => this._tokenState?.state?.tokens; + + /// Provides current user. + public user(): User | undefined { + const claims = this._tokenState.state?.claims; + if (claims) { + return { + id: claims.sub, + email: claims.email, + }; + } + } + public records = (name: string): RecordApi => new RecordApi(this, name); + + public async avatarUrl(): Promise { + const user = this.user(); + if (user) { + const response = await this.fetch(`${Client._authApi}/avatar/${user.id}`); + const json = (await response.json()) as { avatar_url: string }; + return json.avatar_url; + } + return undefined; + } + + public async login(email: string, password: string): Promise { + const response = await this.fetch(`${Client._authApi}/login`, { + method: "POST", + body: JSON.stringify({ + email: email, + password: password, + } as LoginRequest), + }); + + this.updateTokens((await response.json()) as LoginResponse); + } + + public loginUri(redirect?: string): string { + return `${this._client.site}/${Client._authUi}/login?${redirect ? `redirect_to=${redirect}` : ""}`; + } + + public async logout(): Promise { + try { + const refresh_token = this._tokenState.state?.tokens.refresh_token; + if (refresh_token) { + await this.fetch(`${Client._authApi}/logout`, { + method: "POST", + body: JSON.stringify({ + refresh_token, + } as LogoutRequest), + }); + } else { + await this.fetch(`${Client._authApi}/logout`); + } + } catch (err) { + console.warn(err); + } + this.updateTokens(undefined); + return true; + } + + public logoutUri(redirect?: string): string { + return `${this._client.site}/${Client._authApi}/logout?${redirect ? `redirect_to=${redirect}` : ""}`; + } + + public async deleteUser(): Promise { + await this.fetch(`${Client._authApi}/delete`); + this.updateTokens(undefined); + } + + public async changeEmail(email: string): Promise { + await this.fetch(`${Client._authApi}/change_email`, { + method: "POST", + body: JSON.stringify({ + new_email: email, + } as ChangeEmailRequest), + }); + } + + public async refreshAuthToken(): Promise { + const refreshToken = Client.shouldRefresh(this._tokenState); + if (refreshToken) { + this._tokenState = await this.refreshTokensImpl(refreshToken); + } + } + + /// Returns the refresh token if should refresh. + private static shouldRefresh(tokenState: TokenState): string | undefined { + const state = tokenState.state; + if (state && state.claims.exp - 60 < Date.now() / 1000) { + return state.tokens?.refresh_token ?? undefined; + } + } + + private async refreshTokensImpl(refreshToken: string): Promise { + const response = await this._client.fetch( + `${Client._authApi}/refresh`, + this._tokenState, + { + method: "POST", + body: JSON.stringify({ + refresh_token: refreshToken, + } as RefreshRequest), + }, + ); + + if (!response.ok) { + if (response.status === 401) { + this.logout(); + } + throw await FetchError.from(response); + } + + return buildTokenState({ + ...((await response.json()) as RefreshResponse), + refresh_token: refreshToken, + }); + } + + /// Fetches data from TrailBase endpoints, e.g.: + // const response = await client.fetch("api/auth/v1/status"); + // + // Unlike native fetch, will throw in case !response.ok. + public async fetch(path: string, init?: FetchOptions): Promise { + let tokenState = this._tokenState; + const refreshToken = Client.shouldRefresh(tokenState); + if (refreshToken) { + this._tokenState = tokenState = + await this.refreshTokensImpl(refreshToken); + } + + try { + const response = await this._client.fetch(path, tokenState, init); + if (!response.ok && (init?.throwOnError ?? true)) { + throw await FetchError.from(response); + } + return response; + } catch (err) { + if (err instanceof TypeError) { + throw Error(`Connection refused ${err}. TrailBase down or CORS?`); + } + throw err; + } + } +} + +function _isDev(): boolean { + type ImportMeta = { + env: object | undefined; + }; + const env = (import.meta as unknown as ImportMeta).env; + const key = "DEV" as keyof typeof env; + const isDev = env?.[key] ?? false; + + return isDev; +} +const isDev = _isDev(); + +export function headers(tokens?: Tokens): HeadersInit { + const base = { + "Content-Type": "application/json", + }; + + if (tokens) { + const { auth_token, refresh_token, csrf_token } = tokens; + return { + ...base, + ...(auth_token && { + Authorization: `Bearer ${auth_token}`, + }), + ...(refresh_token && { + "Refresh-Token": refresh_token, + }), + ...(csrf_token && { + "CSRF-Token": csrf_token, + }), + }; + } + + return base; +} + +export function textEncode(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +export function textDecode(ar: Uint8Array): string { + return new TextDecoder().decode(ar); +} + +/// Decode a base64 string to bytes. +export function base64Decode(base64: string): string { + return atob(base64); +} + +/// Decode a "url-safe" base64 string to bytes. +export function urlSafeBase64Decode(base64: string): string { + return base64Decode(base64.replace(/_/g, "/").replace(/-/g, "+")); +} + +/// Encode an arbitrary string input as base64 string. +export function base64Encode(s: string): string { + return btoa(s); +} + +/// Encode an arbitrary string input as a "url-safe" base64 string. +export function urlSafeBase64Encode(s: string): string { + return base64Encode(s).replace(/\//g, "_").replace(/\+/g, "-"); +} + +export function asyncBase64Encode(blob: Blob): Promise { + return new Promise((resolve, _) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + }); +} + +export const exportedForTesting = isDev + ? { + base64Decode, + base64Encode, + } + : undefined; diff --git a/client/trailbase-ts/tests/base64.test.ts b/client/trailbase-ts/tests/base64.test.ts new file mode 100644 index 0000000..517c946 --- /dev/null +++ b/client/trailbase-ts/tests/base64.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "vitest"; +import { + exportedForTesting, + urlSafeBase64Encode, + urlSafeBase64Decode, + textEncode, + textDecode, + asyncBase64Encode, +} from "../src/index"; + +const { base64Encode, base64Decode } = exportedForTesting!; + +test("encoding", async () => { + const input = ".,~`!@#$%^&*()_Hi!:)/|\\"; + + expect(textDecode(textEncode(input))).toBe(input); + expect(base64Decode(base64Encode(input))).toBe(input); + expect(urlSafeBase64Decode(urlSafeBase64Encode(input))).toBe(input); + + const blob = new Blob([textEncode(input)]); + const base64 = await asyncBase64Encode(blob); + const components = base64.split(","); + + expect(base64Decode(components[1])).toBe(input); +}); diff --git a/client/trailbase-ts/tests/encoding.bench.ts b/client/trailbase-ts/tests/encoding.bench.ts new file mode 100644 index 0000000..f7b9d4e --- /dev/null +++ b/client/trailbase-ts/tests/encoding.bench.ts @@ -0,0 +1,28 @@ +import { test } from "vitest"; +import { Bench } from "tinybench"; +import { + urlSafeBase64Encode, + urlSafeBase64Decode, + base64Encode, + base64Decode, +} from "../src/index"; + +test("encoding benchmark", async () => { + const bench = new Bench({ time: 500 }); + + const input = "!@#$%^&*(!@#$%^&*@".repeat(1000); + const standardInput = base64Encode(input); + const urlSafeInput = urlSafeBase64Encode(input); + + bench + .add("Url-Safe decode", () => { + urlSafeBase64Decode(urlSafeInput); + }) + .add("Standard decode", () => { + base64Decode(standardInput); + }); + + await bench.run(); + + console.table(bench.table()); +}); diff --git a/client/trailbase-ts/tests/integration/integration.test.ts b/client/trailbase-ts/tests/integration/integration.test.ts new file mode 100644 index 0000000..2f2d128 --- /dev/null +++ b/client/trailbase-ts/tests/integration/integration.test.ts @@ -0,0 +1,182 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ + +import { expect, test } from "vitest"; +import { Client, headers, urlSafeBase64Encode } from "../../src/index"; +import { status } from "http-status"; +import { v7 as uuidv7, parse as uuidParse } from "uuid"; + +test("headers", () => { + const h0 = headers(); + expect(Object.keys(h0).length).toBe(1); + const h1 = headers({ + auth_token: "foo", + refresh_token: "bar", + csrf_token: null, + }); + expect(Object.keys(h1).length).toBe(3); +}); + +type SimpleStrict = { + id: string; + + text_null?: string; + text_default?: string; + text_not_null: string; + + // Add or generate missing fields. +}; + +type NewSimpleStrict = Partial; + +type SimpleCompleteView = SimpleStrict; + +type SimpleSubsetView = { + id: string; + + t_null?: string; + t_default?: string; + t_not_null: string; +}; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const port: number = 4005; + +async function connect(): Promise { + const client = Client.init(`http://127.0.0.1:${port}`); + await client.login("admin@localhost", "secret"); + return client; +} + +// WARN: this test is not hermetic. I requires an appropriate TrailBase instance to be running. +test("auth integration tests", async () => { + const client = await connect(); + + const oldTokens = client.tokens(); + expect(oldTokens).not.undefined; + + // We need to wait a little to push the expiry time in seconds to avoid just getting the same token minted again. + await sleep(1500); + + await client.refreshAuthToken(); + const newTokens = client.tokens(); + expect(newTokens).not.undefined.and.not.equals(oldTokens!.auth_token); + + expect(await client.logout()).toBe(true); + expect(client.user()).toBe(undefined); +}); + +test("Record integration tests", async () => { + const client = await connect(); + const api = client.records("simple_strict_table"); + + const now = new Date().getTime(); + const messages = [`ts client test 0: ${now}`, `ts client test 1: ${now}`]; + + const ids: string[] = []; + for (const msg of messages) { + ids.push( + (await api.createId({ text_not_null: msg })) as string, + ); + } + + { + const records = await api.list({ + filters: [`text_not_null=${messages[0]}`], + }); + expect(records.length).toBe(1); + expect(records[0].text_not_null).toBe(messages[0]); + } + + { + const records = await api.list({ + filters: [`text_not_null[like]=%${now}`], + order: ["+text_not_null"], + }); + expect(records.map((el) => el.text_not_null)).toStrictEqual(messages); + } + + { + const records = await api.list({ + filters: [`text_not_null[like]=%${now}`], + order: ["-text_not_null"], + }); + expect(records.map((el) => el.text_not_null).reverse()).toStrictEqual( + messages, + ); + } + + const record: SimpleStrict = await api.read(ids[0]); + expect(record.id).toStrictEqual(ids[0]); + expect(record.text_not_null).toStrictEqual(messages[0]); + + // Test 1:1 view-bases record API. + const view_record: SimpleCompleteView = await client + .records("simple_complete_view") + .read(ids[0]); + expect(view_record.id).toStrictEqual(ids[0]); + expect(view_record.text_not_null).toStrictEqual(messages[0]); + + // Test view-based record API with column renames. + const subset_view_record: SimpleSubsetView = await client + .records("simple_subset_view") + .read(ids[0]); + expect(subset_view_record.id).toStrictEqual(ids[0]); + expect(subset_view_record.t_not_null).toStrictEqual(messages[0]); + + const updated_value: Partial = { + text_not_null: "updated not null", + text_default: "updated default", + text_null: "updated null", + }; + await api.update(ids[1], updated_value); + const updated_record: SimpleStrict = await api.read(ids[1]); + expect(updated_record).toEqual( + expect.objectContaining({ + id: ids[1], + ...updated_value, + }), + ); + + await api.delete(ids[1]); + + expect(await client.logout()).toBe(true); + expect(client.user()).toBe(undefined); + + expect(async () => await api.read(ids[0])).rejects.toThrowError( + expect.objectContaining({ + status: status.FORBIDDEN, + }), + ); +}); + +test("record error tests", async () => { + const client = await connect(); + + const nonExistantId = urlSafeBase64Encode( + String.fromCharCode.apply(null, uuidParse(uuidv7())), + ); + const nonExistantApi = client.records("non-existant"); + expect( + async () => await nonExistantApi.read(nonExistantId), + ).rejects.toThrowError( + expect.objectContaining({ + status: status.METHOD_NOT_ALLOWED, + }), + ); + + const api = client.records("simple_strict_table"); + expect( + async () => await api.read("invalid id"), + ).rejects.toThrowError( + expect.objectContaining({ + status: status.BAD_REQUEST, + }), + ); + expect( + async () => await api.read(nonExistantId), + ).rejects.toThrowError( + expect.objectContaining({ + status: status.NOT_FOUND, + }), + ); +}); diff --git a/client/trailbase-ts/tests/integration_test_runner.ts b/client/trailbase-ts/tests/integration_test_runner.ts new file mode 100644 index 0000000..f5045b6 --- /dev/null +++ b/client/trailbase-ts/tests/integration_test_runner.ts @@ -0,0 +1,72 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { createVitest } from "vitest/node"; +import { cwd } from "node:process"; +import { execa, type Subprocess } from "execa"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const port: number = 4005; + +async function initTrailBase(): Promise<{ subprocess: Subprocess }> { + const pwd = cwd(); + if (!pwd.endsWith("trailbase-ts")) { + throw Error(`Unxpected CWD: ${pwd}`); + } + + const build = await execa`cargo build`; + if (build.failed) { + console.error("STDOUT:", build.stdout); + console.error("STDERR:", build.stderr); + throw Error("cargo build failed"); + } + + const subprocess = execa`cargo run -- --data-dir ../testfixture run --dev -a 127.0.0.1:${port}`; + + for (let i = 0; i < 50; ++i) { + if ((subprocess.exitCode ?? 0) > 0) { + break; + } + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/healthcheck`); + if (response.ok) { + return { subprocess }; + } + + console.log(await response.text()); + } catch (err) { + console.info("Waiting for TrailBase to become healthy"); + } + + await sleep(500); + } + + subprocess.kill(); + + const result = await subprocess; + console.error("EXIT:", result.exitCode); + console.error("STDOUT:", result.stdout); + console.error("STDERR:", result.stderr); + + throw Error("Failed to start TrailBase"); +} + +const { subprocess } = await initTrailBase(); + +const ctx = await createVitest("test", { + watch: false, + environment: "jsdom", + include: ["tests/integration/*"], +}); +await ctx.start(); +await ctx.close(); + +if (subprocess.exitCode === null) { + // Still running + subprocess.kill(); +} else { + // Otherwise TrailBase terminated. Log output to provide a clue as to why. + const { stderr, stdout } = subprocess; + console.error(stdout); + console.error(stderr); +} diff --git a/client/trailbase-ts/tsconfig.json b/client/trailbase-ts/tsconfig.json new file mode 100644 index 0000000..ba76a1e --- /dev/null +++ b/client/trailbase-ts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../ui/common/tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "outDir": "./dist", + "paths": { + "@/*": ["./src/*"], + "@bindings/*": ["../../trailbase-core/bindings/*"] + } + }, + "include": [ + "./src/**/*" + ] +} diff --git a/client/trailbase-ts/vitest.config.ts b/client/trailbase-ts/vitest.config.ts new file mode 100644 index 0000000..06f1cb3 --- /dev/null +++ b/client/trailbase-ts/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + environment: "jsdom", + // We do not include transitively, since we rely on our own runner for + // executing tests/integration/** instead. + include: [ + 'tests/*.test.ts', + 'tests/*.bench.ts', + ], + }, +}) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bce7e1f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + + trail: + build: . + ports: + - "4000:4000" + restart: unless-stopped + volumes: + - ./traildepot:/app/traildepot + environment: + # Setup Rust's env-logger. + RUST_LOG: "info,refinery_core=warn" + RUST_BACKTRACE: "1" + command: "/app/trail --data-dir /app/traildepot run --address 0.0.0.0:4000" diff --git a/docs/.dockerignore b/docs/.dockerignore new file mode 100644 index 0000000..6c79c0e --- /dev/null +++ b/docs/.dockerignore @@ -0,0 +1,4 @@ +node_modules/ + +.git* +*.log diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..6240da8 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,21 @@ +# build output +dist/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store diff --git a/docs/.prettierignore b/docs/.prettierignore new file mode 100644 index 0000000..c4bfc42 --- /dev/null +++ b/docs/.prettierignore @@ -0,0 +1,9 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock + +src/components/ui + +# Prettier breaks {/* */} comments in MDX files :/ +**/*.mdx diff --git a/docs/.prettierrc.mjs b/docs/.prettierrc.mjs new file mode 100644 index 0000000..85ccfb5 --- /dev/null +++ b/docs/.prettierrc.mjs @@ -0,0 +1,13 @@ +// .prettierrc.mjs +/** @type {import("prettier").Config} */ +export default { + plugins: ['prettier-plugin-astro'], + overrides: [ + { + files: '*.astro', + options: { + parser: 'astro', + }, + }, + ], +}; diff --git a/docs/Dockerfile b/docs/Dockerfile new file mode 100644 index 0000000..a5990a3 --- /dev/null +++ b/docs/Dockerfile @@ -0,0 +1,8 @@ +FROM nginx:mainline-alpine AS runner + +COPY ./nginx.conf /etc/nginx/conf.d/default.conf +COPY ./dist /usr/share/nginx/html + +EXPOSE 80 + +HEALTHCHECK CMD curl --fail http://localhost:80 || exit 1 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e09bf55 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,55 @@ +# Starlight Starter Kit: Basics + +[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) + +``` +npm create astro@latest -- --template starlight +``` + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics) +[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics) +[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics) +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwithastro%2Fstarlight%2Ftree%2Fmain%2Fexamples%2Fbasics&project-name=my-starlight-docs&repository-name=my-starlight-docs) + +> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! + +## 🚀 Project Structure + +Inside of your Astro + Starlight project, you'll see the following folders and files: + +``` +. +├── public/ +├── src/ +│ ├── assets/ +│ ├── content/ +│ │ ├── docs/ +│ │ └── config.ts +│ └── env.d.ts +├── astro.config.mjs +├── package.json +└── tsconfig.json +``` + +Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name. + +Images can be added to `src/assets/` and embedded in Markdown with a relative link. + +Static assets, like favicons, can be placed in the `public/` directory. + +## 🧞 Commands + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +| :------------------------ | :----------------------------------------------- | +| `npm install` | Installs dependencies | +| `npm run dev` | Starts local dev server at `localhost:4321` | +| `npm run build` | Build your production site to `./dist/` | +| `npm run preview` | Preview your build locally, before deploying | +| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | +| `npm run astro -- --help` | Get help using the Astro CLI | + +## 👀 Want to learn more? + +Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat). diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs new file mode 100644 index 0000000..add3cfc --- /dev/null +++ b/docs/astro.config.mjs @@ -0,0 +1,68 @@ +import { defineConfig } from "astro/config"; + +import icon from "astro-icon"; +import robotsTxt from "astro-robots-txt"; +import sitemap from "@astrojs/sitemap"; +import solid from "@astrojs/solid-js"; +import starlight from "@astrojs/starlight"; +import tailwind from "@astrojs/tailwind"; + +// https://astro.build/config +export default defineConfig({ + site: "https://trailbase.io", + integrations: [ + icon(), + solid(), + starlight({ + title: "TrailBase", + customCss: ["./src/tailwind.css"], + social: { + github: "https://github.com/trailbaseio/trailbase", + discord: "https://discord.gg/X8cWs7YC22", + }, + sidebar: [ + { + label: "Getting Started", + items: [ + { + label: "Starting Up", + slug: "getting-started/starting-up", + }, + { + label: "First App", + slug: "getting-started/first-app", + }, + { + label: "Philosophy", + slug: "getting-started/philosophy", + }, + ], + }, + { + label: "Documentation", + autogenerate: { + directory: "documentation", + }, + }, + { + label: "Comparisons", + autogenerate: { + directory: "comparison", + }, + }, + { + label: "Reference", + autogenerate: { + directory: "reference", + }, + }, + ], + }), + sitemap(), + robotsTxt(), + tailwind({ + // Disable the default base styles: + applyBaseStyles: false, + }), + ], +}); diff --git a/docs/docker-compose.yml b/docs/docker-compose.yml new file mode 100644 index 0000000..819dd52 --- /dev/null +++ b/docs/docker-compose.yml @@ -0,0 +1,16 @@ +version: "3.9" + +services: + trailbase-docs: + container_name: trailbase-docs + build: . + ports: + - "127.0.0.1:3036:80/tcp" + restart: unless-stopped + + # By default containers get 1024 cpu shares. Setting it to 512 means half + # the resources compared to a default container. And 2048 double, + # respectively. + cpu_shares: 1024 + mem_limit: 128m + oom_score_adj: -200 diff --git a/docs/nginx.conf b/docs/nginx.conf new file mode 100644 index 0000000..bc4af87 --- /dev/null +++ b/docs/nginx.conf @@ -0,0 +1,32 @@ +server { + # TLS termination is done by the reverse proxy. + listen 80; + listen [::]:80; + server_name trailbase_documentation; + + #access_log /var/log/nginx/host.access.log main; + + # File root matching build target location in Dockerfile. + root /usr/share/nginx/html; + + # 404 and 500s should load our custom error pages. + error_page 404 /404.html; + # error_page 500 502 503 504 /50x/index.html; + + location / { + # Set long client-side cache TTLs for astro assets. Astro assets carry a + # content hash in their filename, thus can be cached safely for ever. + location /_astro/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + } + location /particles/ { + add_header Cache-Control "public, max-age=2592000, immutable"; + } + + # Try resolve $uri in the following order: + # * try $uri first + # * then $uri/index.html + # * finally fall back to 404 error_page below. + try_files $uri $uri/index.html =404; + } +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..bddf0b9 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,37 @@ +{ + "name": "", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro check && astro build", + "preview": "astro preview", + "astro": "astro", + "check": "astro check", + "format": "prettier -w tailwind.config.mjs astro.config.mjs src " + }, + "dependencies": { + "@astrojs/check": "^0.9.4", + "@astrojs/starlight": "^0.28.4", + "@astrojs/starlight-tailwind": "^2.0.3", + "@astrojs/tailwind": "^5.1.2", + "@iconify-json/tabler": "^1.2.6", + "astro": "^4.16.7", + "astro-icon": "^1.1.1", + "chart.js": "^4.4.6", + "chartjs-chart-error-bars": "^4.4.3", + "chartjs-plugin-deferred": "^2.0.0", + "sharp": "^0.33.5", + "solid-js": "^1.9.3", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.3" + }, + "devDependencies": { + "@astrojs/sitemap": "^3.2.1", + "@astrojs/solid-js": "^4.4.2", + "astro-robots-txt": "^1.0.0", + "prettier": "^3.3.3", + "prettier-plugin-astro": "^0.14.1" + } +} diff --git a/docs/public/favicon.svg b/docs/public/favicon.svg new file mode 120000 index 0000000..e6e7e56 --- /dev/null +++ b/docs/public/favicon.svg @@ -0,0 +1 @@ +../../assets/favicon.svg \ No newline at end of file diff --git a/docs/src/assets/flutter_logo.svg b/docs/src/assets/flutter_logo.svg new file mode 100644 index 0000000..8f2cf83 --- /dev/null +++ b/docs/src/assets/flutter_logo.svg @@ -0,0 +1 @@ + diff --git a/docs/src/assets/logo_512.webp b/docs/src/assets/logo_512.webp new file mode 100644 index 0000000..ea2dbb9 Binary files /dev/null and b/docs/src/assets/logo_512.webp differ diff --git a/docs/src/assets/screenshot.webp b/docs/src/assets/screenshot.webp new file mode 100644 index 0000000..be93992 Binary files /dev/null and b/docs/src/assets/screenshot.webp differ diff --git a/docs/src/assets/ts_logo.svg b/docs/src/assets/ts_logo.svg new file mode 100644 index 0000000..61866f0 --- /dev/null +++ b/docs/src/assets/ts_logo.svg @@ -0,0 +1 @@ + diff --git a/docs/src/components/BarChart.tsx b/docs/src/components/BarChart.tsx new file mode 100644 index 0000000..b9e8c50 --- /dev/null +++ b/docs/src/components/BarChart.tsx @@ -0,0 +1,168 @@ +import { onCleanup, createEffect } from "solid-js"; +import { + Chart, + type ChartData, + type Tick, + type ScaleOptions, +} from "chart.js/auto"; +import { + BarWithErrorBarsController, + BarWithErrorBar, +} from "chartjs-chart-error-bars"; +import ChartDeferred from "chartjs-plugin-deferred"; + +import { createDarkMode } from "@/lib/darkmode"; + +Chart.register(BarWithErrorBarsController, BarWithErrorBar, ChartDeferred); + +interface BarChartProps { + data: ChartData<"bar">; + scales?: { [key: string]: ScaleOptions<"linear"> }; +} + +export function BarChart(props: BarChartProps) { + const darkMode = createDarkMode(); + + let ref: HTMLCanvasElement | undefined; + let chart: Chart | undefined; + + createEffect(() => { + chart?.destroy(); + + chart = new Chart<"bar">(ref!, { + type: "bar", + data: props.data, + options: { + scales: adjustScaleColor(darkMode(), { + y: {}, + x: {}, + ...props.scales, + }), + maintainAspectRatio: false, + plugins: { + // Defers rendering and animation until on screen. + deferred: { + yOffset: "30%", // defer until 50% of the canvas height are inside the viewport + delay: 200, // delay of 500 ms after the canvas is considered inside the viewport + }, + colors: { + enabled: true, + forceOverride: false, + }, + legend: { + position: "bottom", + labels: { + color: darkMode() ? "white" : undefined, + }, + }, + }, + }, + }); + }); + + onCleanup(() => chart?.destroy()); + + return ( +
+ +
+ ); +} + +function adjustScaleColor( + dark: boolean, + scales: { [key: string]: ScaleOptions<"linear"> }, +) { + for (const axis of Object.keys(scales)) { + const scale = scales[axis]; + + scale.ticks = { + ...scales[axis].ticks, + color: dark ? "white" : undefined, + }; + + scale.title = { + ...scale.title, + color: dark ? "white" : undefined, + }; + } + + return scales; +} + +interface BarChartWithErrorsProps { + data: ChartData<"barWithErrorBars">; + yTickFormatter?: ( + value: number | string, + index: number, + ticks: Tick[], + ) => string; +} + +export function BarChartWithErrors(props: BarChartWithErrorsProps) { + const darkMode = createDarkMode(); + + let ref: HTMLCanvasElement | undefined; + let chart: Chart | undefined; + + createEffect(() => { + chart?.destroy(); + + const scaleIds = props.data.datasets.map((e) => e.yAxisID ?? "y"); + const yScaleStyle = { + ticks: { + color: darkMode() ? "white" : undefined, + display: true, + callback: props.yTickFormatter, + }, + grid: { + display: true, + lineWidth: 0, + tickWidth: 0.5, + tickLength: 2, + tickColor: darkMode() ? "white" : "black", + }, + }; + + chart = new Chart<"barWithErrorBars">(ref!, { + type: BarWithErrorBarsController.id, + data: props.data, + options: { + scales: { + x: { + ticks: { + color: darkMode() ? "white" : undefined, + }, + }, + ...Object.fromEntries(scaleIds.map((id) => [id, yScaleStyle])), + }, + maintainAspectRatio: false, + plugins: { + // Defers rendering and animation until on screen. + deferred: { + yOffset: "30%", // defer until 50% of the canvas height are inside the viewport + delay: 200, // delay of 500 ms after the canvas is considered inside the viewport + }, + colors: { + enabled: true, + forceOverride: false, + }, + legend: { + position: "bottom", + labels: { + color: darkMode() ? "white" : undefined, + }, + }, + }, + }, + }); + }); + + onCleanup(() => chart?.destroy()); + + return ( +
+ +
+ ); +} diff --git a/docs/src/components/LineChart.tsx b/docs/src/components/LineChart.tsx new file mode 100644 index 0000000..6254db8 --- /dev/null +++ b/docs/src/components/LineChart.tsx @@ -0,0 +1,85 @@ +import { onCleanup, createEffect } from "solid-js"; +import { Chart, type ChartData, type ScaleOptions } from "chart.js/auto"; +import ChartDeferred from "chartjs-plugin-deferred"; + +import { createDarkMode } from "@/lib/darkmode"; + +Chart.register(ChartDeferred); + +interface LineChartProps { + data: ChartData<"line">; + scales?: { [key: string]: ScaleOptions<"linear"> }; +} + +export function LineChart(props: LineChartProps) { + const darkMode = createDarkMode(); + + let ref: HTMLCanvasElement | undefined; + let chart: Chart | undefined; + + createEffect(() => { + chart?.destroy(); + + chart = new Chart(ref!, { + type: "line", + data: props.data, + options: { + scales: adjustScaleColor(darkMode(), { + ...props.scales, + }), + maintainAspectRatio: false, + plugins: { + // Defers rendering and animation until on screen. + deferred: { + yOffset: "30%", // defer until 50% of the canvas height are inside the viewport + delay: 200, // delay of 500 ms after the canvas is considered inside the viewport + }, + colors: { + enabled: true, + forceOverride: false, + }, + legend: { + position: "bottom", + labels: { + color: darkMode() ? "white" : undefined, + }, + }, + }, + interaction: { + mode: "nearest", + axis: "x", + intersect: false, + }, + }, + }); + }); + + onCleanup(() => chart?.destroy()); + + return ( +
+ +
+ ); +} + +function adjustScaleColor( + dark: boolean, + scales: { [key: string]: ScaleOptions<"linear"> }, +) { + for (const axis of Object.keys(scales)) { + const scale = scales[axis]; + + scale.ticks = { + ...scales[axis].ticks, + color: dark ? "white" : undefined, + }; + + scale.title = { + ...scale.title, + color: dark ? "white" : undefined, + }; + } + + return scales; +} diff --git a/docs/src/content/config.ts b/docs/src/content/config.ts new file mode 100644 index 0000000..a4eec59 --- /dev/null +++ b/docs/src/content/config.ts @@ -0,0 +1,6 @@ +import { defineCollection } from "astro:content"; +import { docsSchema } from "@astrojs/starlight/schema"; + +export const collections = { + docs: defineCollection({ schema: docsSchema() }), +}; diff --git a/docs/src/content/docs/_roadmap.md b/docs/src/content/docs/_roadmap.md new file mode 100644 index 0000000..2e860c1 --- /dev/null +++ b/docs/src/content/docs/_roadmap.md @@ -0,0 +1,21 @@ +Over time, we would like to make TrailBase the best application base it can be. +Tell us what's missing, what could be better, and what smells. +Independently, we're very open to contributions, just talk to us first so we +can figure out how any feature will fit into the overall picture and minimize +friction. +For context, some larger features we have on our Roadmap: + +- Realtime/notification APIs for subscribing to data changes. +- S3 buckets and other cloud storage. The backend already supports it but it isn't wired up. +- Support more Social/OAuth providers. +- More configurable authentication, more customizable auth UI, and multi-factor. +- Service-accounts for authenticating and authorizing backends not end-users. +- Custom scheduled operations. Also enabling more time series use-cases. +- Many SQLite databases: imagine a separate database by tenant or user. +- Maybe integrate an ES6 JavaScript runtime or similar. +- Streamline code-generation, the bindings life-cycle, and first-party + support for more languages. +- Geo-spatial extensions and Geo-Ip for logs. +- Maybe TLS termination and proxy capabilities. +- Consider a GraphQL layer to address fan-out and integrate external + resources. diff --git a/docs/src/content/docs/comparison/pocketbase.mdx b/docs/src/content/docs/comparison/pocketbase.mdx new file mode 100644 index 0000000..093fac7 --- /dev/null +++ b/docs/src/content/docs/comparison/pocketbase.mdx @@ -0,0 +1,96 @@ +--- +title: PocketBase +description: Comparing TrailBase & PocketBase. +--- + +Firstly, PocketBase is amazing! It based the trail for single-file, SQLite +application bases, is incredibly easy-to-use, and a polished experience. Gani, +the person behind it, is a mad scientist. + +At the surface-level there are a lot of similarities between PocketBase and +TrailBase. In this comparison, we'll dive a little deeper and have a closer +look at the technical as well as philosophical differences between the two. + +### Goals & Aspirations + +TrailBase was born out of admiration for PocketBase trying to move the needle +in a few areas: + +- Less abstraction, embracing standards (SQL[^1], JWT, UUID), and untethered access + to SQLite/libsql[^2] including features such as recursive CTEs, virtual tables + and vector search. + The goal is to not get in your way and avoid lock-in by bespoke solutions + making it easier adopt TrailBase either fully or as piece-meal as well as + getting rid of it based on your product needs. +- Be just as easy to self-host and be even easier to manage a fleet of + deployments across integration tests, development, and production by separating + data, configuration, and secrets. +- Super-powers through SQLite extensions (regex, GIS, ...) including your own [^3]. +- Be lightweight enough to rival plain SQLite performance at least for + higher-level languages. +- Be simple and flexible enough to be an attractive alternative to plain SQLite + for serving **and** data analysis use-cases. + +### Differences + +It's worth noting that PocketBase and TrailBase have a lot in common: they are +both single-file, static binaries providing data APIs, authentication and file +storage on top of SQLite. +That said and for the sake of this article, let's look at some of the +differences and extra features that PocketBase provides: + +- TrailBase does not yet provide realtime APIs allowing clients to subscribe to + data changes. +- PocketBase lets you register custom endpoints in + [ES5 JavaScript](https://pocketbase.io/docs/js-overview/). +- PocketBase can also be used as a Go framework, i.e. instead of using the + binary release one can build a custom binary with custom endpoints. + +Likewise, TrailBase has a few nifty tricks up its sleeve: + +- Language independent type-safety via JSON Schemas with strict typing + being enforced all the way down to the database level[^4]. +- First-class access to all of SQLite/libsql's features and capabilities. +- A simple auth UI. +- Stateless JWT auth-tokens for simple, hermetic authentication in other + backends. +- Efficient and stable cursor-based pagination. +- An admin UI that "works" on small screens and mobile :) + +### Language & Performance + +Another difference is that PocketBase and TrailBase are written in Go and Rust, +respectively, which may matter to you especially when modifying either or using +them as "frameworks". + +Beyond personal preferences, both languages are speedy options in practice. +That said, Rust's lack of a runtime and lower FFI overhead should make it the +more performant choice. +To our own surprise, we found a significant gap. TrailBase is roughly 3.5x to +7x faster, in our [simplistic micro-benchmarks](/reference/benchmarks) +depending on the use-case. +Not to toot our own horn, this is mostly thanks to combining a very low +overhead language, one of the fastest HTTP servers, and incredibly quick +SQLite/libsql. + +
+ +--- + +[^1]: Maybe more in line with SupaBase's philosophy. We suspect that PocketBase + relies on schema metadata by construction requiring alterations to be + mediated through PocketBase APIs to stay in sync. + +[^2]: We believe that SQL a ubiquitous evergreen technology, which in of itself + is already a high-level abstraction for efficient, unified cross-database + access. + Even higher-level abstractions, such as ORMs, often look nice for simple + examples but quickly fall flat for more complex ones. They're certainly + bespoke, non-transferable knowledge, and increase vendor lock-in. + +[^3]: + All extensions can be built into a small, standalone shared library and + imported by vanilla SQLite avoiding vendor lock-in. + +[^4]: SQLite is not strictly typed by default. Instead column types merely a + type affinity for value conversions. diff --git a/docs/src/content/docs/comparison/supabase.mdx b/docs/src/content/docs/comparison/supabase.mdx new file mode 100644 index 0000000..702cf71 --- /dev/null +++ b/docs/src/content/docs/comparison/supabase.mdx @@ -0,0 +1,44 @@ +--- +title: SupaBase +description: Comparing TrailBase & SupaBase. +--- + +Both SupaBase and Postgres are amazing. Comparing either to TrailBase and +SQLite, respectively, is challenging given how different they are +architecturally. + +For one, both Postgres and SupaBase are heck of a lot more modular. "Rule 34" of +the database world: if you can think of it, there's a Postgres extension for it. +And SupaBase doesn't an excellent job at making all that flexibility available +without getting in the way and giving you untethered access while further +expanding upon it. +In many ways, TrailBase is trying to evnetually do the same for SQLite: +combining PocketBase's simplicity with SupaBase's layering. + +One foundational difference is that Postgres itself is a multi-user, +client-server architecture already. +Extending it by building a layered services around it, like SupaBase did, +feels very natural. +However, SQLite is neither a multi-user system nor a server. Hence, extending +it by embedding it into a monolith, like PocketBase did, feels fairly natural +as well. +There are ups and downs to either approach. The layered service approach, for +example, allows for isolated failure domains and scaling of individual +components [^1]. The monolith, on the other hand, with its lesser need for modularity +can have fewer interaction points, fewer moving parts making it fundamentally +simpler, cheaper, and +[lower overhead (10+x performance difference)](/reference/benchmarks). + +Ultimately, the biggest difference is that SupaBase is a polished product with +a lot of mileage under its belt. Our simpler architecture will hopefully let us +get there but for now SupaBase is our north star. + +
+ +--- + +[^1]: + For example, in our performance testing we've found that PostgREST, + SupaBase's RESTful API layer in front of Postgres, is relatively resource + hungry. This might not be an issue since one can simply scale by pointing + many independent instances at the same database instance. diff --git a/docs/src/content/docs/contact.mdx b/docs/src/content/docs/contact.mdx new file mode 100644 index 0000000..732c7fe --- /dev/null +++ b/docs/src/content/docs/contact.mdx @@ -0,0 +1,11 @@ +--- +title: Contact +template: splash +--- + +
+ Sebastian + contact [at] trailbase.io + + 8047 Zurich, Switzerland +
diff --git a/docs/src/content/docs/documentation/APIs/query_apis.mdx b/docs/src/content/docs/documentation/APIs/query_apis.mdx new file mode 100644 index 0000000..1c9446e --- /dev/null +++ b/docs/src/content/docs/documentation/APIs/query_apis.mdx @@ -0,0 +1,56 @@ +--- +title: Query APIs +--- + +import { Aside } from "@astrojs/starlight/components"; + +Query APIs are a more free-form and type-unsafe way of exposing data using +virtual tables based on user inputs and stored procedures. Please make sure to +take a look at [record APIs](/documentation/apis/record_apis) first. Views and +generated columns may be a better fit for transforming data if no explicit user +input is required. + + + +## Example + +Using migrations and sqlean's `define` we can define a table query with unbound +inputs (see placeholder $1): + +```sql +CREATE VIRTUAL TABLE + _is_editor +USING + define((SELECT EXISTS (SELECT * FROM editors WHERE user = $1) AS is_editor)); +``` + +Subsequently, an API can be configured to query the newly created `VIRTUAL +TABLE`, also binding URL query parameters as inputs to above placeholders. + +```proto +query_apis: [ + { + name: "is_editor" + virtual_table_name: "_is_editor" + params: [ + { + name: "user" + type: BLOB + } + ] + acl: WORLD + } +] +``` + +Finally, we can query the API, e.g. using curl: + +```bash +curl -g 'localhost:4000/api/query/v1/is_editor?user=' +``` diff --git a/docs/src/content/docs/documentation/APIs/record_apis.mdx b/docs/src/content/docs/documentation/APIs/record_apis.mdx new file mode 100644 index 0000000..c60602b --- /dev/null +++ b/docs/src/content/docs/documentation/APIs/record_apis.mdx @@ -0,0 +1,299 @@ +--- +title: Record APIs +--- + +import { Aside } from "@astrojs/starlight/components"; + +The easiest and most type-safe path to access you `TABLE`s and `VIEW`s is to use +TrailBase's restful CRUD _Record APIs_. +The only requirements are: + +- Tables and views need to be `STRICT`ly[^1] typed to guarantee type-safety all the + way from your records, via JSON schema, to your client-side language bindings [^2]. +- They need to have a sequential primary key column to allow for stable sorting + and thus efficient cursor-based pagination. Either an explicit `INTEGER` or + UUIDv7 `PRIMARY KEY` will do, including `FOREIGN KEY` columns. + +## Configuring APIs + +Record APIs can be configured through the admin dashboard or immediately in +TrailBase's configuration file. +Note that there are certain features that aren't yet exposed in the dashboard, +like supporting multiple APIs based on the same table or view. +In this case you can drop down to the configuration to set up as many as you +like allowing for a lot of extra flexibility around permissions and visibility. + +An example API setup for managing user avatars: + +```json +record_apis: [ + { + name: "_user_avatar" + table_name: "_user_avatar" + conflict_resolution: REPLACE + autofill_missing_user_id_columns: true + acl_world: [READ] + acl_authenticated: [CREATE, READ, UPDATE, DELETE] + create_access_rule: "_REQ_.user IS NULL OR _REQ_.user = _USER_.id" + update_access_rule: "_ROW_.user = _USER_.id" + delete_access_rule: "_ROW_.user = _USER_.id" + } +] +``` + +A quick explanation: + +* The `name` needs to be unique. It's what is used to access the API via + `/api/v1/records//`. +* `table_name` references the table or view that is being exposed. +* `conflict_resolution` declares what should happen if a newly created record is + conflicting with an existing one. +* `autofill_missing_user_id_column` lets you omit fields for columns with a foreign + key relationship to `_user(id)`. The field will then be filled with the + credentials of the authenticated user. In most cases, this should probably be off, this + only useful if you cannot explicitly provide the user id yourself, e.g. in a + static HTML form. +* `acl_world` and `acl_authenticated` define that anyone can read avatars but + only authenticated users can modify them. The following `access_rules` further narrow + mutations to records where the `user` column (or request field for insertions) + match. In other words, user X cannot modify user Y's avatar. + + +### Access Control + +Access can be controlled through combination of a simple ACL-based system (a +matrix of who and what) and custom SQL access rules of the nature: +`f(req, user, row) -> bool`. +Generally, the ACLs are checked first and then the access rules are evaluated +when present. + +For example, to validate that the requestor provided a secret key and is member +of a group "mygroup": + +```sql +(_REQ_.secret = 'foo' AND EXISTS( + SELECT 1 FROM groups + WHERE + groups.member = _USER_.id + AND groups.name = 'mygroup' + )) +``` + +* `_REQ_` is an injected sub-query containing the request fields. It is + available in access rules for `CREATE` and `UPDATE` operations. +* Similarly, `_ROW_` is a sub-query of the target record. It is available in + access rules for `READ`, `UPDATE`, and `DELETE` operations. +* Lastly, `_USER_.id` references the id of the currently authenticated user and + `NULL` otherwise. + +Independently, you can use `VIEW`s to filter which rows and columns of +your `TABLE`s should be accessible. + +#### Building access groups and capabilities + +As hinted at by the example above, the SQL access rules can be used to +build higher-level access protection such as group ACLs or capabilities. +What makes the most sense in your case, is very application dependent. +The `/examples/blog` has an "editor" group to control who can write blog +posts. + +Somewhat on a tangent and pretty meta, group and capability tables can +themselves be exposed via Record APIs. +This can be used to programmatically manage permissions, e.g. for building a +moderation dashboard. +When exposing authorization primitives, make sure the permissions are +appropriately tight to avoid permission escalations. + +### Write-only columns + +Columns with names starting with an underscore can be written on insert or +update but are hidden on reads. This is meant as a convenient convention to +allow for internal data fields, e.g hiding the record owner in an otherwise public +data set or hiding a user's internal credit rating from their profile. A +similar effect could otherwise be achieved by exposing a table for inserts and +updates only while poxying reads through a VIEW. + + + +## Accessing Record APIs + +After configuring the APIs and setting up permissions, record APIs expose six +main endpoints[^3]: + +* **C**reate: endpoint for for inserting new and potentially overriding records + depending on conflict resolution strategy.
+ `POST /api/v1/records/` +* **R**ead: endpoint for reading specific records given the record id.
+ `GET /api/v1/records//` +* **U**pdate: partial updates to existing records given a record id and subset of fields
+ `PATCH /api/v1/records//` +* **D**elete: endpoints for deleting record given a record id.
+ `DELETE /api/v1/records//` +* List: endpoint for listing, filtering and sorting records based on the + configured read access rule and provided filters.
+ `GET /api/v1/records/?` +* Schema: endpoint for reading the APIs JSON schema definition. Can be used for + introspection and to drive code generation.
+ `GET /api/v1/records//schema` + +All of the above endpoints can be interacted with through requests that are +either JSON encoded, url-encoded, or `multipart/form-data` encoded, which makes +them accessible via rich client-side applications, progressive web apps, and +static HTML forms alike. + +### Listing, filtering & sorting records + +Using the `GET /api/v1/records/?` endpoint and given +sufficient permissions one can query records based the given `read_access_rule` +and query parameters. + +Parameters: + +* Pagination can be controlled with two parameters: `limit=N` (with a hard + limit of 1024) and `cursor=`. +* Ordering can be controlled via `order=[[+-]?]+`, e.g. + `order=created,-rank`, which would sort records first by their `created` + column in ascending order (same as "+") and then by the `rank` column in + descending order due to the "-". +* Lastly, one can filter records by matching against one or more columns like + `[op]=`, e.g. `revenue[gt]=0` to request only records + with revenue values "greater than" 0. The supported operators are: + * equal, is the empty operator, e.g. `?success=TRUE`. + * **not**|**ne**: not equal + * **gte**: greater-than-equal + * **gt**: greater-than + * **lte**: less-than-equal + * **lt**: less-than + * **like**: SQL `LIKE` operator + * **re**: SQL `REGEXP` operator + +For example, to query the 10 highest grossing movies with a watch time less +than 2 hours and an actor called John, one could query: + +```bash +curl -g '
/api/recrods/v1/movies?limit=10&order=grossing&watch_time_min[lt]=120&actors[like]=%John%' +``` + +## File Upload + +Record APIs can also support file uploads and downloads. There's some special +handling in place so that only metadata is stored in the underlying table while +the actual files are kept in an object store. + +By adding a `TEXT` column with a `CHECK(jsonschema('std.FileUpload'))` +constrained to your TABLE, you instruct TrailBase to store file metadata as +defined by the "std.FileUpload" JSON schema and write the contents off to +object storage. +Files can then be upload by sending the contents as part your JSON or +`multipart/form-data` POST request. +Downloading files is slightly different, since reading the column through +record APIs will only yield the metadata. There's a dedicated GET API endpoint +for file downloads: +`/api/v1/records///file/` + + + + +## Custom JSON Schemas + +Akin to `std.FileUpload` above, you can register your own nested JSON schemas +to be used with column `CHECK`s. +For now, the dashboard only allows viewing all registered JSON schemas, however +you can register schemas using the configuration: + +```json +schemas: [ + { + name: "simple_schema" + schema: + '{' + ' "type": "object",' + ' "properties": {' + ' "name": { "type": "string" },' + ' "obj": { "type": "object" }' + ' },' + ' "required": ["name"]' + '}' + } +] +``` + +Once registered, schemas can be added as column constraints: + +```sql +CREATE TALE test ( + simple TEXT CHECK(jsonschema('simple_schema')), + + -- ... +) STRICT; +``` + +When generating new client-side bindings for a table or view with such nested +schemas, they will be included ensuring type-safety all the way to the +client-side APIs. + +### Tangent: Querying JSON + +Independent of type-safety and Record APIs, +[SQLite](https://www.sqlite.org/json1.html) has first-class support for +querying nested properties of columns containing JSON in textual or binary +format [^4]. +For example, given a table: + +```sql +CREATE TABLE items (json TEXT NOT NULL); + +INSERT INTO items (json) VALUES ('{"name": "House", "color": "blue"}'); +INSERT INTO items (json) VALUES ('{"name": "Tent", "color": "red"}'); +``` + +You can query the names of red items: + +```sql +SELECT + json->>'name' AS name +FROM + items +WHERE + json->>'color' = 'red'; +``` + +Note that this requires SQLite to scan all rows and deserialize the JSON. +Instead, storing the color of items in a separate, indexed column and filter +on it would be a lot more efficient. +Yet, using JSON for complex structured or denormalized data can be powerful +addition to your toolbox. + + +
+ +--- + +[^1]: + By default, SQLite are not strictly typed. Column types merely express + type-affinities. Unless tables are explicitly created as `STRICT` columns can + store any data type. + +[^2]: + Views are more tricky to strictly type, since they're the result of an + arbitrary `SELECT` statement. TrailBase parses the `CREATE VIEW` statement + and will allow record APIs only on top of a conservative subset, where it + can infer the column types. Over time, TrailBase will be able to support + larger subsets. Let us know if you have provably strictly typed queries + that you think should be supported but aren't. + +[^3]: + There's also a few other endpoints, e.g. for downloading files as described + later in the document. + +[^4]: + Record APIs only support textual JSON. Binary JSON is more compact and more + efficient to parse, however its actual encoding is internal to SQLite and + thus opaque to TrailBase. diff --git a/docs/src/content/docs/documentation/_auth.svg b/docs/src/content/docs/documentation/_auth.svg new file mode 100644 index 0000000..87e95a5 --- /dev/null +++ b/docs/src/content/docs/documentation/_auth.svg @@ -0,0 +1,291 @@ + + + + + + + + + + + + + + + + + + + + + + TrailBase + + + + + + auth token (JWT) + + locally authenticateand/or forward JWT + + + Your Backend + + refresh token + + auth token (JWT) + + + + + + + + + diff --git a/docs/src/content/docs/documentation/auth.mdx b/docs/src/content/docs/documentation/auth.mdx new file mode 100644 index 0000000..fba0cef --- /dev/null +++ b/docs/src/content/docs/documentation/auth.mdx @@ -0,0 +1,122 @@ +--- +title: Auth +description: Managing Users and Access +--- + +import { Image } from "astro:assets"; +import { Aside } from "@astrojs/starlight/components"; + +import implementation from "./_auth.svg"; + +TrailBase provides core authentication flows and a basic UI out of the box[^1]. +These primitives let you establish the identity of your users in order to +authorize or deny access to your data, let users change their email address, +reset their password, etc. + + + +## Implementation + +TrailBase tries to offer a standard, safe and versatile auth implementation out +of the box. It combines: + +- Asymmetric cryptography based on elliptic curves (ed25519) +- Stateless, short-lived auth tokens (JWT) +- Stateful, long-lived, opaque refresh tokens. + +Breaking this apart, __asymmetric cryptography__ means that tokens signed with a +private key by the TrailBase "auth server", which can then be validated by +others ("resource servers") using only the corresponding public key. +The __Statelesss JWTs__ contain metadata that identities the user w/o having to +talk to the auth server. +Combining the two, other back-ends can authenticate, validate & identify, users +hermetically. +This is very easy and efficient, however means that hermetic auth tokens cannot +be invalidated. +A hermetic auth token released into the wild is valid until it expires. +To balance the risks and benefits, TrailBase uses short-lived auth tokens +expiring frequently[^2]. +To avoid burdening users by constantly re-authenticating, TrailBase issues an +additional __opaque, stateful refresh token__. +Refresh tokens are simply a unique identifier the server keeps track of as +sessions. +Only refresh tokens that have not been revoked can be exchanged for a new auth +token. + +
+ Screenshot of TrailBase's admin dashboard +
+ +## Flows & UI + +TrailBase currently implements the following auth flows: + +- Email + password based user registration and email verification. +- User registration using social OAuth providers (Google, ...) +- Login & logout. +- Change & reset password. +- Change email. +- User deletion. +- Avatar management. + +Besides the flows above, TrailBase also ships with a set of simple UIs to +support the above flows. By default it's accessible via the route: +`/_/auth/login`. Check out the [demo](https://demo.trailbase.io/_/auth/login). +The built-in auth UIs can be disabled with `--disable-auth-ui` in case you +prefer rolling your own or have no need web-based authentication. + +## Usernames and other metadata + +Strictly speaking, authentication is merely responsible for uniquely +identifying who's on the other side. +This only requires a __unique identifier__ and one or more __secrets__ +(e.g. a password, hardware token, ...) for the peer to proof they're them. + +Any unique identifier will do: a random string (painful to remember), a phone +number, a username, or an email address. +Email addresses are a popular choice, since they do double duty as a +communication channel letting you reach out to your users, e.g. to reset their +password. + +Even from a product angle, building an online shop for example, email addresses +are the natural choice. +Asking your customers to think up and remember a globally unique username adds +extra friction especially since you need their email address anyway to send +receipts. +Additional profile data, like a shipment address, is something you can ask for +at a later time and is independent from auth. +In contrast, when building a social network, chat app or messaging board, you +typically don't want to leak everyone's email address. +You will likely want an additional, more opaque identifier such as a username +or handle. + +Long story short, modeling __profile__ data is very product dependent. +It's for you to figure out. +That said, it is straight forward to join auth data, such as the user's email +address, and custom custom profile data in TrailBase. +We suggest creating a separate profile table with a `_user.id` `FOREIGN KEY` +primary key column. You can then freely expose profiles as dedicated record API +endpoints or join them with other data `_user.id`. +The blog example in `/examples/blog` demonstrates this, joining blog +posts with user profiles on the author id to get an author's name. + +
+ +--- + +[^1]: + Which can be disabled using `--disable-auth-ui`, if you prefer rolling your + own or have no need for a web-based authentication UI. + +[^2]: + A one hour TTL by default. diff --git a/docs/src/content/docs/documentation/extending.mdx b/docs/src/content/docs/documentation/extending.mdx new file mode 100644 index 0000000..95cd7bb --- /dev/null +++ b/docs/src/content/docs/documentation/extending.mdx @@ -0,0 +1,121 @@ +--- +title: Extending +description: Collocating your logic +--- + +import { Aside } from "@astrojs/starlight/components"; + +This article explores different ways to extend TrailBase and integrate your own +custom logic. + +## The Elephant in the Room + +The question on where your code should run is as old as the modern internets +becoming ever present since moving away from a static mainframe model and +hermetic desktop applications. +With pushing more interactive applications to slow platforms, such as early +browsers or mobile phone, there was an increased need to distribute +applications with interactivity happening in the front-end and heavy lifting +happening in a back-end. +That's not to say that there aren't other good reasons to not just run all your +code in an untrusted, potentially slow client-side sandbox. + +In any case, having a rich client-side application like a mobile, desktop or +progressive web apps will reduce your need for server-side integrations. +They're often a good place to start [^1], even if over time you decide to move more +logic to a backend to address issues like high fan-out, initial load +times, and SEO for web applications. + +Inversely, if you have an existing application that is mostly running +server-side, you probably already have a database, auth, and are hosting your +own APIs, ... . +If so, there's intrinsically less any application base can help you with. +Remaining use-cases might be piece-meal adoption to speed up existing APIs or +delegate authentication. +One advantage of lightweight, self-hosted solutions is that they can be +co-locate with your existing stack to reduce costs and latency. + +## Bring your own Backend + +The most flexible and likewise de-coupled way of running your own code is to +deploy a separate service alongside TrailBase. This gives you full control over +your destiny: runtime, scaling, deployment, etc. + +TrailBase is designed with the explicit goal of running along a sea of other +services. +Its stateless tokens using asymmetric crypto make it easy for other resource +servers to hermetically authenticate your users. +TrailBase's APIs can be accessed transitively, simply by forwarding user +tokens. +Alternatively, you can fall back to raw SQLite for reads, writes and even +schema alterations[^2]. + + + +## Custom APIs in TrailBase + +TrailBase provides three main ways to embed your code and expose custom APIs: + +1. Rust/Axum handlers. +2. Stored procedures & [Query APIs](/documentation/apis/query_apis/) +3. SQLite extensions, virtual table modules & [Query APIs](/documentation/apis/query_apis/) + +Beware that the Rust APIs and [Query APIs](/documentation/apis/query_apis/) are +likely subject to change. We rely on semantic versioning to explicitly signal +breaking changes. +Eventually, we would like to lower the barrier of entry by providing stable +bindings to a higher-level runtime within TrailBase, likely a +TypeScript/ES6/JavaScript runtime. + +### Using Rust + +The Rust APIs aren't yet stable and fairly undocumented. +That said, similar to using PocketBase as a Go framework, you can build your +own TrailBase binary and register custom Axum handlers written in rust with the +main application router, see `/examples/custom-binary`. + +### Stored Procedures & Query APIs + +Unlike Postgres or MySQL, SQLite does not supported stored procedures out of +the box. +TrailBase has adopted sqlean's +[user-defined functions](https://github.com/nalgeon/sqlean/blob/main/docs/define.md) +to provide similar functionality and minimize lock-in over vanilla SQLite. +Check out [Query APIs](/documentation/apis/query_apis/), to see how stored +procedures can be hooked up. + +### SQLite extensions, virtual table modules & Query APIs + +Likely the most bespoke approach is to expose your functionality as a custom +SQLite extension or module similar to how TrailBase extends SQLite itself. + +This approach can be somewhat limiting in terms of dependencies you have +access to and things you can do especially for extensions. Modules are quite a bit +more flexible but also involved. +Take a look at [SQLite's list](https://www.sqlite.org/vtablist.html) and +[osquery](https://osquery.readthedocs.io/en/stable/) to get a sense of what's +possible. + +Besides their limitations, major advantages of using extensions or +modules are: +* you have extremely low-overhead access to your data, +* extensions and modules can also be used by services accessing the + underlying SQLite databases. + +
+ +--- +[^1]: + There are genuinely good properties in terms of latency, interactivity, offline + capabilities and privacy when processing your users' data locally on their + device. + +[^2]: + SQLite is running in WAL mode, which allows for parallel reads and + concurrent writes. That said, when possible you should probably use the APIs + since falling back to raw database access is a priviledge practically reserved + to processes with access to a shared file-system. diff --git a/docs/src/content/docs/documentation/production.mdx b/docs/src/content/docs/documentation/production.mdx new file mode 100644 index 0000000..ad67863 --- /dev/null +++ b/docs/src/content/docs/documentation/production.mdx @@ -0,0 +1,85 @@ +--- +title: Productionize +description: Going to production. +--- + +import { Aside } from "@astrojs/starlight/components"; + + + +Going to production and depending on your requirements things to think about +could be: + +- HTTPS/TLS termination +- locking down access +- setting up Email +- deployment +- introspection +- disaster recovery + +## TLS termination + +The most important thing alongside ensuring proper access protection is to set +up TLS termination ensuring that all traffic from your users to your +termination point is encrypted. +In practice, this means putting TrailBase behind a reverse proxy such as NGinx, +Caddy, ... . The main benefit of using an established reverse proxy is the +availability of auto-renewal of self-signed certificates with SSL authorities +Let's encrypt. + +## Access + +### API access + +Make sure to use record API's authorization primitives to tighten access to +data as much as possible. It's a good idea to check `_REQ_. == +_USER_.id` on record creations and updates to avoid users can impersonate or +touch on other users records. + +### Admin access + +You can expose TrailBase's admin APIs and UIs on a separate private port as an +extra precaution and to simply expose a smaller surface. + +### Protect Configuration + +You can prevent TrailBase configuration from being accidentally changed in +prod, e.g. when you think you're actually configuring a dev instances. To do +so, you can read-only mount the configuration directory. However, make sure the +data directory remains writable. + +## Email + +By default TrailBase will be using your machine's sendmail setup. This can lead +to messages not being sent at all and likely getting stuck in spam filters not +coming from a well-known Email server. + +You should likely set up TrailBase with an SMTP server that can send Email +coming from your domain. If you don't have an Email provider yet, an option +could be Brevo, Mailchimp, SendGrid, ... . + +## Deployment + +We recommend containerization (e.g. Docker) for convenience. You can also +consider to mount certain directories and files such as `/secrets` +and `/config.textproto` as read only. + +## Introspection + +TrailBase's introspection is fairly non-existent at this point. There is a +`/api/healthcheck` endpoint for container orchestration systems to probe. +You could also consider setting up probers probing other endpoints. + +## Disaster Recovery + +The simplest option is to mount anothjer local or remote drive and use +TrailBase's periodic backups. +However, this may lead to significant data loss in case of a disaster, which +may be acceptable for first party content but likely not for user-generated +content. + +A more comprehensive approach may be to use [Litestream](https://litestream.io/) +to continuously replicate your database. diff --git a/docs/src/content/docs/documentation/type_safety.mdx b/docs/src/content/docs/documentation/type_safety.mdx new file mode 100644 index 0000000..2d6323a --- /dev/null +++ b/docs/src/content/docs/documentation/type_safety.mdx @@ -0,0 +1,82 @@ +--- +title: Type-Safety +--- + +import { Aside } from "@astrojs/starlight/components"; + +TrailBase provides end-to-end type-safety from the database level, through the +HTTP APIs, all the way up to the client bindings relying on JSON schemas. +It's worth noting that the JSON schema is derived directly from the database +schema as the source of truth meaning that any schema change will be reflected +independent of whether they were applied via the admin dashboard, `sqlite3` or +other means. +This also means that you should regenerate your type definitions +after changing the schema. We therefore recommend to integrate type generation +into your build process or check the generated types in following your database +migrations. + +Using JSON schema and relying on off-the-shelf code generation tools, allows to +keep the client-libraries very thin making it easy to integrate virtually any +language in a type-safe fashion. +`/examples/blog` provides a glimpse at using [quicktype](https://quicktype.io/) +to generate type-safe TypeScript and Dart APIs. + +Type-safety is the main reason why TrailBase APIs require `STRICT`ly typed +tables. By default SQLite only has a notion of "type affinity" on +inserts and updates, generally allowing any data in any column. + +## Generating Types from JSON Schemas + +The generated JSON schemas depend on two aspects: + +1. The actual database schema mapping columns and column types to fields and + data types in a data structure of your target language. +2. The specific API operation: `CREATE`, `UPDATE`, `READ`. + +Expanding on 2., the notion of default values for columns means that data for +`NOT NULL` columns is optional when creating a new record but guaranteed to be +present on record read. +`UPDATE`s are point updates of existing records, thus only requiring specific +column values to be overridden. + +Concretely, looking at `/examples/blog`, the data structure for inserting a new +blog article is less strict than the equivalent for retrieving an existing +article: + +```typescript +// Input data type when creating a new article record. +export interface NewArticle { + author: string; + body: string; + created?: number; + id?: string; + image?: FileUpload; + // ... +} + +// Result data type when reading an article record. +export interface Article { + author: string; + body: string; + created: number; + id: string; + image?: FileUpload; + // ... +} +``` + +## Nested JSON Columns + +TrailBase also supports generating type-safe bindings for columns containing +JSON data and enforcing a specific JSON schema, see +[here](/documentation/apis/record_apis/#custom-json-schemas). + + + +
+ +--- + +[^1]: + We do not support binary JSON, i.e. SQLite's internal JSONB + representation at this point. diff --git a/docs/src/content/docs/getting-started/first-app.mdx b/docs/src/content/docs/getting-started/first-app.mdx new file mode 100644 index 0000000..f08670a --- /dev/null +++ b/docs/src/content/docs/getting-started/first-app.mdx @@ -0,0 +1,197 @@ +--- +title: First App +description: A guide in my new Starlight docs site. +--- + +import { Code } from "@astrojs/starlight/components"; +import { Aside } from "@astrojs/starlight/components"; + +{/* +import Readme from "../../../../../examples/tutorial/README.md"; + +*/} + +In this tutorial, we'll set up a database with an IMDB test dataset, spin up +TrailBase and write a small program to access the data. + +In an effort to demonstrate TrailBase's loose coupling and the possibility of +simply trying out TrailBase with an existing SQLite-based data analysis +project, we will also offer a alternative path to bootstrapping the database +using the vanilla `sqlite3` CLI. + + + +## Create the Schema + +By simply starting TrailBase, the migrations in `traildepot/migrations` will be +applied, including `U1728810800__create_table_movies.sql`: + +```sql +CREATE TABLE movies IF NOT EXISTS ( + rank INTEGER PRIMARY KEY, + name TEXT NOT NULL, + year ANY NOT NULL, + watch_time INTEGER NOT NULL, + rating REAL NOT NULL, + metascore ANY, + gross ANY, + votes TEXT NOT NULL, + description TEXT NOT NULL +) STRICT; +``` + +Note that the only schema requirement for exposing an API is: `STRICT` typing +and an integer (or UUIDv7) primary key column. + +The main benefit of relying on TrailBase to apply the above schema as migrations +over manually applying the schema yourself, is to: + +- document your database's schema alongside your code and +- even more importantly, letting TrailBase bootstrap from scratch and + sync-up databases across your dev setup, your colleague's, every time + integration tests run, QA stages, and in production. + +That said, TrailBase will happily work on existing datasets, in which +case it is your responsibility to provide a SQLite database file that +meets expectations expressed as configured TrailBase API endpoints. + +Feel free to run: + +```bash +$ mkdir traildepot/data +$ sqlite3 traildepot/data/main.db < traildepot/migrations/U1728810800__create_table_movies.sql +``` + +before starting TrailBase the first time, if you prefer bootstrapping the +database yourself. + +## Importing the Data + +After creating the schema above, either manually or starting TrailBase to apply +migrations, we're ready to import the IMDB test dataset. +We could now expose an API endpoint and write a small program to first read the +CSV file to then write movie database records... and we'll do that in a little +later. +For now, let's start by harnessing the fact that SQLite databases are simply a +local file and import the data using the `sqlite3` CLI side-stepping TrailBase: + +``` +$ sqlite3 traildepot/data/main.db +sqlite> .mode csv +sqlite> .import ./data/Top_1000_IMDb_movies_New_version.csv movies +``` + +There will be a warning for the first line of the CSV, which contains textual +table headers rather than data matching our schema. That's expected. +We can validate that we successfully imported 1000 movies by running: + +```sql +sqlite> SELECT COUNT(*) FROM movies; +1000 +``` + +## Accessing the Data + +With TrailBase up and running (`trail run`), the easiest way to explore your +data is go to the admin dashboard under +[http://localhost:4000](http://localhost:4000) +and log in with the admin credentials provided to you in the terminal upon +first start (you can also use the `trail` CLI to reset the password `trail user +reset-password admin@localhost`). + +In this tutorial we want to explore more programmatic access and using +TrailBase record APIs. + +```json +record_apis: [ + # ... + { + name: "movies" + table_name: "movies" + acl_world: [READ] + acl_authenticated: [CREATE, READ, UPDATE, DELETE] + } +] +``` + +By adding the above snippet to your configuration (which is already the case +for the checked-in configuration) you expose a world-readable API. We're using +the config here but you can also configure the API using the admin dashboard +via the +[tables view](http://localhost:4000/_/admin/tables?pageIndex=0&pageSize=20&table=movies) +and the "Record API" settings in the top right. + +Let's try it out by querying the top-3 ranked movies with less than 120min +watch time: + +```bash +curl -g 'localhost:4000/api/records/v1/movies?limit=3&order=rank&watch_time[lt]=120' +``` + +You can also use your browser. Either way, you should see some JSON output with +the respective movies. + +## Type-Safe APIs and Mutations + +Finally, let's authenticate and use privileged APIs to first delete all movies +and then add them pack using type-safe APIs rather than `sqlite3`. + +Let's first create the JSON Schema type definitions from the database schema we +added above. Note, that the type definition for creation, reading, and updating +are all different. Creating a new record requires values for all `NOT NULL` +columns w/o a default value, while reads guarantees values for all `NOT NULL` +columns, and updates only require values for columns that are being updated. +In this tutorial we'll "cheat" by using the same type definition for reading +existing and creating new records, since our schema doesn't define any default +values (except implicitly for the primary key), they're almost identical. + +In preparation for deleting and re-adding the movies, let's run: + +```bash +$ trail schema movies --mode insert +``` + +This will output a standard JSON schema type definition file. There's quite a few +code-generators you can use to generate bindings for your favorite language. +For this example we'll use _quicktype_ to generate _TypeScript_ definitions, +which also happens to support some other ~25 languages. You can install it, but +for the tutorial we'll stick with the [browser](https://app.quicktype.io/) +version and copy&paste the JSON schema from above. + +With the generated types, we can use the TrailBase TypeScript client to write +the following program: + +import fillCode from "../../../../../examples/tutorial/scripts/src/fill.ts?raw"; + + + +## What's Next? + +Thanks for making it to the end. +Beyond the basic example above, the repository contains a more involved Blog +example (`/examples/blog`) including both, a Web and Flutter UI. +The blog example also demonstrates more complex APIs, authorization, custom +user profiles, etc. + +Any questions or suggestions? Reach out on GitHub and help us improve the docs. +Thanks! diff --git a/docs/src/content/docs/getting-started/philosophy.mdx b/docs/src/content/docs/getting-started/philosophy.mdx new file mode 100644 index 0000000..e84b352 --- /dev/null +++ b/docs/src/content/docs/getting-started/philosophy.mdx @@ -0,0 +1,87 @@ +--- +title: Philosophy +description: A quick look at TrailBase's philosophy and goals. +--- + +The ambition of TrailBase is to help solve common problems with +established, standard solutions, while being blazingly fast, avoiding +lock-in, or getting in your way. +You shouldn't have to fight your framework due to evolving product needs. +Instead, you should be able to use SQLite to its fullest, extend it, or move on +entirely if necessary. + +The key for TrailBase to achieve this goal is to focus on _loose coupling_ and +_rigorous simplicity_. +Performance is merely a desirable side-effect of keeping it simple and +carefully picking the right components for doing the heavy lifting. + +### _Simplicity_ + +To avoid demoting _simplicity_ to just another popular marketing term, let's +start by clarify what we mean. +Simplicity is a relative property in the context of a problem: +a simple solution for a simple problem is straightforward to understand, +validate, extend or even replace. +A simple solution solution for a hard problem will naturally be more involved +but retains above properties within a more complex context. + +"Simple" is different from "easy". +An easy solution may be very complex in an effort to take on as much +responsibility as possible for very specific scenarios. +Easy solutions will sometimes yield pleasing but magic solutions that fall flat +when straying off the path. +Magic solutions will always lead to tight coupling and lock-in. +In contrast, simple solutions are explicit, apply in a wide range of scenarios +and lead to easy-to-understand, easy-to-change outcomes. + +Why should I care? We believe that there are material benefits to simplicity. +For example and tying back to TrailBase: + +- A simple, single-file backend dependency lets you set up **consistent** + production, pre-prod, testing and development environments, which will help + to improve velocity, catch issues sooner, and reduce cognitive overhead. +- Lets you change production deployments or cloud providers more easily to + address soaring bills, ToS changes, geopolitics and policy requirements such as + data governance. +- Lets you more easily adopt TrailBase, also selectively, and more easily drop + it if you choose to. + +### _Coupling_ + +The way we defined _simplicity_ above, loose coupling is already an important +property of a good-natured, simple solution. Yet, we believe it's a critical +property in its own right that should be called out explicitly helping to +illustrate the guiding principles underpinning TrailBase. +Looking at two examples: + +__Admin Dashboard__: TrailBase offers an easy-to-use, integrated admin dashboard. +It doesn't make TrailBase architecturally simpler but hopefully easier to use. +Importantly, it's loosely coupled to the rest of the system. It's neither +critical for serving production traffic nor setting up TrailBase. +Any dashboard task can be equally accomplished using the CLI, the configs, or +SQL. + +__SQL over ORM__: TrailBase embraces SQL instead of trying to hide it. Eventually +all paths lead to SQL as the only truly cross-platform, cross-database, +cross-language solution 😉. +ORMs often yield simple looking examples but then fall flat soon after going +beyond the tutorial, sometimes already when joining tables. +Your data model should never be driven by the short-comings of an abstraction +forced upon you. +ORMs aren't without merit. Often they will provide type-safe APIs for accessing +the database. While more constraint, TrailBase's end-to-end type-safety +provides similar benefits. +Leaning into HTTP, JSON and JSON schema to makes it easy for TrailBase to +provide consistent cross-language client-side type-safety [^1], giving you more +freedom in choosing the right tool for a specific job. + +
+ +--- + +[^1]: + Having only TypeScript and Dart bindings at the moment, this may sound more + aspirational than practical. + However, client bindings are only very thin layers around HTTP + JSON. + It is straight forward to add new bindings or just use `curl` in a bunch + of shell scripts. We're planning to add more bindings in the future. diff --git a/docs/src/content/docs/getting-started/starting-up.mdx b/docs/src/content/docs/getting-started/starting-up.mdx new file mode 100644 index 0000000..8a4a1d2 --- /dev/null +++ b/docs/src/content/docs/getting-started/starting-up.mdx @@ -0,0 +1,72 @@ +--- +title: Starting Up +description: A guide in my new Starlight docs site. +--- + +import { Aside } from "@astrojs/starlight/components"; +import { Icon } from 'astro-icon/components' + +In getting-started guide we'll bring up a local TrailBase instance, explore the +admin dashboard, and implement our first, small application. + +## Starting TrailBase + +The quickest way to get TrailBase up and running is to use docker: + +```bash + $ mkdir traildepot + $ docker run -p 4000:4000 --mount type=bind,source=$PWD/traildepot,target=/app/traildepot trailbase/trailbase +``` + +On first start, TrailBase will generate a `traildepot` folder in your working +directory containing its configuration, the database, secrets and a few more +things. +It will also generate a new admin user for you. The credentials will be printed +on the command line as follows: + +``` +Created new admin user: + email: 'admin@localhost' + password: '' +``` + +If you like, feel free to change the Email or password later in the dashboard. +Independently, if you ever forget your password, you can reset it using the +`trail user reset-password admin@localhost ` command. + +## The Admin Dashboard + +After successfully starting TrailBase, we can check out the admin dashboard under +[http://localhost:4000/\_/admin/](http://localhost:4000/_/admin/). +After logging in with the admin credentials from the terminal, there's a couple +of pages to explore. + +* First and maybe most importantly: the data browser + () + that let's you explore and alter both the data as well as table schemas. It + provides access to _tables_, _views_, _virtual tables_, _indexes_, _trggiers_ + as well as your TrailBase API settings. +* The simple SQL editor + () + lets you run arbitrary queries against the database and take full control. + It also lets you access SQLite features which aren't (yet) exposed via the + dashboard. +* The accounts page + () + lets you manage your registered users. +* The logs page + () + lets you see what's going on. At this early stage you're probably just seeing + your own interactions with the admin dashboard. +* The settings page + () + lets you configure instance-wide settings. + Alternatively, uou can also directly edit TrailBase's config file, however, unlike + the UI you'll need to restart the server to apply the changes. + TrailBase uses protobuf for its configuration. The schema can be + found [here](https://github.com/trailbaseio/trailbase/proto/config.proto). + +We encourage you to take a minute, click around, and maybe create a few tables. +Don't worry about breaking anything. Also note that when creating, altering, or +deleting a table a schema migration file will be created in +`traildepot/migrations`. diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx new file mode 100644 index 0000000..add020f --- /dev/null +++ b/docs/src/content/docs/index.mdx @@ -0,0 +1,167 @@ +--- +title: Welcome to TrailBase +description: Blazingly fast, single-file, open-source server for your Applications +template: splash +hero: + tagline: A blazingly fast, single-file, and open-source server for your application with APIs, auth, admin dashboard, ... + image: + file: ../../assets/logo_512.webp + actions: + - text: Documentation + link: /getting-started/starting-up + icon: right-arrow + - text: FAQ + link: /reference/faq/ + icon: external + variant: secondary +--- + +import { Image } from "astro:assets"; +import { Aside, Card, CardGrid } from "@astrojs/starlight/components"; + +import screenshot from "@/assets/screenshot.webp"; +import flutterLogo from "@/assets/flutter_logo.svg"; +import tsLogo from "@/assets/ts_logo.svg"; + +import { Duration100kInsertsChart } from "./reference/_benchmarks/benchmarks.tsx"; + +
+
+ + + + #### Live Demo + + + + + +
+
+ +
+ + + + + Blazingly fast thanks to its constituents: + + * Rust: one of the lowest overhead languages, + * Axum: one of the fastest HTTP servers, + * SQLite/Libsql: one of the fastest full-SQL databases. + + TrailBase is [6-7x faster than PocketBase and 15x faster than SupaBase + needing only a fraction of the footprint](/reference/benchmarks), allowing + you to serve millions of customers from a tiny box. + + + + TrailBase is a small, single file, static binary that is incredibly easy + to deploy **consistently** across integration testing, development, pre-prod, + and production environments including edge. + Architecturally, TrailBase aims to be a simple, thin abstraction around + standards helping full or piece-meal adoption and avoiding lock-in. + + A simple architecture, both in your dependencies and your own App, will + let you move faster, more confidently and pivot when necessary. + + + + TrailBase ships with a builtin admin dashboard UI, see demo above, that + lets you quickly configure your instance and visually explore your data. + Following TrailBase's mantra of not getting in your way, the UI is + entirely optional letting you fall back to a purely config & + migration-based setup for integration tests or managing an entire fleet + of deployments. + + + + TrailBase comes with an authentication system and UI built-in supporting + both password-based and Social/OAuth (Google, Discord, ...) sign-ups. + + TrailBase authentication system follows standards and best-practices + combining short-lived, stateless JSON web tokens with long-lived stateful + refresh tokens letting you easily and efficiently authenticate your users + from any of your other back-ends relying on safe, asymmetric cryptography. + + + + Provide access to your tables and views through fast, flexible and + **type-safe** restful CRUD APIs. + Authorize users based on ACLs and SQL access rules letting you + easily build higher-level access management or moderation facilities + like groups or capabilities. + + + + Straightforward integration with any stack thanks to thin abstractions, + reliance on standards, and JSON Schema for type-safety allowing type-safe + bindings for virtually any language. + + Clients as well as code-generation examples for TypeScript and + Dart/Flutter are provided out of the box. + +
+ + TypeScript + + + Flutter + +
+
+ +
+
+ +import Roadmap from "./_roadmap.md"; + +
+
+ + +
+
+ +{/* Hero page footer */} + +
+
+ + diff --git a/docs/src/content/docs/license.mdx b/docs/src/content/docs/license.mdx new file mode 100644 index 0000000..3d6f5f6 --- /dev/null +++ b/docs/src/content/docs/license.mdx @@ -0,0 +1,108 @@ +--- +title: Functional Source License, Version 1.1, Apache 2.0 Future License +template: splash +--- + +## Abbreviation + +FSL-1.1-Apache-2.0 + +## Notice + +Copyright 2024 Sebastian Jeltsch + +## Terms and Conditions + +### Licensor ("We") + +The party offering the Software under these Terms and Conditions. + +### The Software + +The "Software" is each version of the software that we make available under +these Terms and Conditions, as indicated by our inclusion of these Terms and +Conditions with the Software. + +### License Grant + +Subject to your compliance with this License Grant and the Patents, +Redistribution and Trademark clauses below, we hereby grant you the right to +use, copy, modify, create derivative works, publicly perform, publicly display +and redistribute the Software for any Permitted Purpose identified below. + +### Permitted Purpose + +A Permitted Purpose is any purpose other than a Competing Use. A Competing Use +means making the Software available to others in a commercial product or +service that: + +1. substitutes for the Software; + +2. substitutes for any other product or service we offer using the Software + that exists as of the date we make the Software available; or + +3. offers the same or substantially similar functionality as the Software. + +Permitted Purposes specifically include using the Software: + +1. for your internal use and access; + +2. for non-commercial education; + +3. for non-commercial research; and + +4. in connection with professional services that you provide to a licensee + using the Software in accordance with these Terms and Conditions. + +### Patents + +To the extent your use for a Permitted Purpose would necessarily infringe our +patents, the license grant above includes a license under our patents. If you +make a claim against any party that the Software infringes or contributes to +the infringement of any patent, then your patent license to the Software ends +immediately. + +### Redistribution + +The Terms and Conditions apply to all copies, modifications and derivatives of +the Software. + +If you redistribute any copies, modifications or derivatives of the Software, +you must include a copy of or a link to these Terms and Conditions and not +remove any copyright notices provided in or with the Software. + +### Disclaimer + +THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR +PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. + +IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE +SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, +EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. + +### Trademarks + +Except for displaying the License Details and identifying us as the origin of +the Software, you have no right under these Terms and Conditions to use our +trademarks, trade names, service marks or product names. + +## Grant of Future License + +We hereby irrevocably grant you an additional license to use the Software under +the Apache License, Version 2.0 that is effective on the second anniversary of +the date we make the Software available. On or after that date, you may use the +Software under the Apache License, Version 2.0, in which case the following +will apply: + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. + +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/docs/src/content/docs/reference/_benchmarks/benchmarks.tsx b/docs/src/content/docs/reference/_benchmarks/benchmarks.tsx new file mode 100644 index 0000000..7b9621d --- /dev/null +++ b/docs/src/content/docs/reference/_benchmarks/benchmarks.tsx @@ -0,0 +1,378 @@ +import { type ChartData, type ChartDataset, type Tick } from "chart.js/auto"; + +import { BarChart } from "@/components/BarChart.tsx"; +import { LineChart } from "@/components/LineChart.tsx"; + +import { data as supabaseUtilization } from "./supabase_utilization"; +import { data as pocketbaseUtilization } from "./pocketbase_utilization"; +import { data as trailbaseUtilization } from "./trailbase_utilization"; + +const colors = { + supabase: "rgb(62, 207, 142)", + pocketbase0: "rgb(230, 128, 30)", + pocketbase1: "rgb(238, 175, 72)", + trailbase0: "rgb(0, 115, 170)", + trailbase1: "rgb(71, 161, 205)", + trailbase2: "rgb(146, 209, 242)", + drizzle: "rgb(249, 39, 100)", +}; + +function transformTimeTicks(factor: number = 0.5) { + return (_value: number | string, index: number): string | undefined => { + if (index % 10 === 0) { + // WARN: These are estimate time due to how we measure: periodic + // polling every 0.5s using `top` or `docker stats`, which themselves + // have sampling intervals. The actual value shouldn't matter that + // much, since we measure the actual duration in-situ. We do this + // transformation only to make the time scale more intuitive than + // just "time at sample X". + return `~${index * factor}s`; + } + }; +} + +const durations100k = [ + { + label: "SupaBase", + data: [151], + backgroundColor: colors.supabase, + }, + { + label: "PocketBase TS", + data: [67.721], + backgroundColor: colors.pocketbase0, + }, + // { + // label: "PocketBase Dart (AOT)", + // data: [62.8136], + // }, + { + label: "PocketBase Dart (JIT)", + data: [61.687], + backgroundColor: colors.pocketbase1, + }, + { + label: "TrailBase TS", + data: [16.742], + backgroundColor: colors.trailbase0, + }, + // { + // label: "TrailBase Dart (AOT)", + // data: [11.1], + // }, + { + // label: "TrailBase Dart (JIT)", + label: "TrailBase Dart", + data: [9.4247], + backgroundColor: colors.trailbase1, + }, + // { + // label: "TrailBase Dart (JIT + PGO)", + // data: [10.05], + // }, + // { + // label: "TrailBase Dart (INT PK)", + // data: [8.5249], + // backgroundColor: colors.trailbase2, + // }, + { + label: "In-process SQLite (Drizzle)", + data: [8.803], + backgroundColor: colors.drizzle, + }, +]; + +export function Duration100kInsertsChart() { + const data: ChartData<"bar"> = { + labels: ["Time [s] (lower is better)"], + datasets: durations100k as ChartDataset<"bar">[], + }; + + return ; +} + +export function PocketBaseAndTrailBaseReadLatencies() { + // 2024-10-12 + // Read 1000000 messages, took 0:00:57.952120 (limit=64) + const readTrailbaseMicroS = { + p50: 3504, + p75: 3947, + p90: 4393, + p95: 4725, + }; + + // 2024-10-12 + // Read 100000 messages, took 0:00:20.273054 (limit=64) + const readPocketbaseMicroS = { + p50: 12740, + p75: 13718, + p90: 14755, + p95: 15495, + }; + + const latenciesMs = (d: any) => + [d.p50, d.p75, d.p90, d.p95].map((p) => p / 1000); + + const data: ChartData<"bar"> = { + labels: ["p50", "p75", "p90", "p95"], + datasets: [ + { + label: "PocketBase", + data: latenciesMs(readPocketbaseMicroS), + backgroundColor: colors.pocketbase0, + }, + { + label: "TrailBase", + data: latenciesMs(readTrailbaseMicroS), + backgroundColor: colors.trailbase0, + }, + ], + }; + + return ( + + ); +} + +export function PocketBaseAndTrailBaseInsertLatencies() { + // 2024-10-12 + // Inserted 10000 messages, took 0:00:01.654810 (limit=64) + const insertTrailbaseMicroS = { + p50: 8107, + p75: 10897, + p90: 15327, + p95: 19627, + }; + // 2024-10-12 + //Inserted 10000 messages, took 0:00:07.759677 (limit=64) + const insertPocketbaseMicroS = { + p50: 28160, + p75: 58570, + p90: 108325, + p95: 157601, + }; + + const latenciesMs = (d: any) => + [d.p50, d.p75, d.p90, d.p95].map((p) => p / 1000); + + const data: ChartData<"bar"> = { + labels: ["p50", "p75", "p90", "p95"], + datasets: [ + { + label: "PocketBase", + data: latenciesMs(insertPocketbaseMicroS), + backgroundColor: colors.pocketbase0, + }, + { + label: "TrailBase", + data: latenciesMs(insertTrailbaseMicroS), + backgroundColor: colors.trailbase0, + }, + ], + }; + + return ( + + ); +} + +export function SupaBaseMemoryUsageChart() { + const data: ChartData<"line"> = { + labels: [...Array(330).keys()], + datasets: Object.keys(supabaseUtilization).map((key) => { + const data = supabaseUtilization[key].map((datum, index) => ({ + x: index, + y: datum.memUsageKb, + })); + + return { + label: key.replace("supabase-", ""), + data: data, + fill: true, + showLine: false, + pointStyle: false, + }; + }), + }; + + return ( + { + const v = value as number; + return `${(v / 1024 / 1024).toFixed(0)}`; + }, + }, + }, + x: { + ticks: { + display: true, + callback: transformTimeTicks(), + }, + }, + }} + /> + ); +} + +export function SupaBaseCpuUsageChart() { + const data: ChartData<"line"> = { + labels: [...Array(330).keys()], + datasets: Object.keys(supabaseUtilization).map((key) => { + const data = supabaseUtilization[key].map((datum, index) => ({ + x: index, + y: datum.cpuPercent ?? 0, + })); + + return { + label: key.replace("supabase-", ""), + data: data, + fill: true, + showLine: false, + pointStyle: false, + }; + }), + }; + + return ( + + ); +} + +export function PocketBaseAndTrailBaseUsageChart() { + // To roughly align start of benchmark on the time axis. + const xOffset = 3; + + const data: ChartData<"line"> = { + labels: [...Array(134).keys()], + datasets: [ + { + yAxisID: "yLeft", + label: "PocketBase CPU", + data: pocketbaseUtilization.slice(xOffset).map((datum, index) => ({ + x: index, + y: datum.cpu, + })), + borderColor: colors.pocketbase0, + backgroundColor: colors.pocketbase0, + }, + { + yAxisID: "yRight", + label: "PocketBase RSS", + data: pocketbaseUtilization.slice(xOffset).map((datum, index) => ({ + x: index, + y: datum.rss, + })), + borderColor: colors.pocketbase1, + backgroundColor: colors.pocketbase1, + }, + { + yAxisID: "yLeft", + label: "TrailBase CPU", + data: trailbaseUtilization.map((datum, index) => ({ + x: index, + y: datum.cpu, + })), + borderColor: colors.trailbase0, + backgroundColor: colors.trailbase0, + }, + { + yAxisID: "yRight", + label: "TrailBase RSS", + data: trailbaseUtilization.map((datum, index) => ({ + x: index, + y: datum.rss, + })), + borderColor: colors.trailbase1, + backgroundColor: colors.trailbase1, + }, + ], + }; + + return ( + { + const v = value as number; + return `${(v / 1024).toFixed(0)}`; + }, + }, + }, + x: { + ticks: { + display: true, + callback: transformTimeTicks(0.6), + }, + }, + }} + /> + ); +} diff --git a/docs/src/content/docs/reference/_benchmarks/pocketbase_utilization.ts b/docs/src/content/docs/reference/_benchmarks/pocketbase_utilization.ts new file mode 100644 index 0000000..3f7f11d --- /dev/null +++ b/docs/src/content/docs/reference/_benchmarks/pocketbase_utilization.ts @@ -0,0 +1,507 @@ +type Datum = { + cpu: number; + rss: number; +}; + +export const data: Datum[] = [ + { + cpu: 0, + rss: 35476, + }, + { + cpu: 0, + rss: 35476, + }, + { + cpu: 0, + rss: 35476, + }, + { + cpu: 0, + rss: 35476, + }, + { + cpu: 0, + rss: 35476, + }, + { + cpu: 0, + rss: 35476, + }, + { + cpu: 0, + rss: 35476, + }, + { + cpu: 0, + rss: 35476, + }, + { + cpu: 0, + rss: 35476, + }, + { + cpu: 0, + rss: 35476, + }, + { + cpu: 0, + rss: 37140, + }, + { + cpu: 2.1, + rss: 88196, + }, + { + cpu: 3, + rss: 110108, + }, + { + cpu: 2.5, + rss: 118060, + }, + { + cpu: 2.6, + rss: 123776, + }, + { + cpu: 2.6, + rss: 127716, + }, + { + cpu: 2.3, + rss: 131548, + }, + { + cpu: 2.4, + rss: 134740, + }, + { + cpu: 2.2, + rss: 138208, + }, + { + cpu: 2.7, + rss: 139436, + }, + { + cpu: 2.818, + rss: 141788, + }, + { + cpu: 2.8, + rss: 142532, + }, + { + cpu: 2.545, + rss: 142652, + }, + { + cpu: 2.4, + rss: 142796, + }, + { + cpu: 2.182, + rss: 143012, + }, + { + cpu: 3.1, + rss: 143072, + }, + { + cpu: 2.8, + rss: 142660, + }, + { + cpu: 2.7, + rss: 142292, + }, + { + cpu: 2.2, + rss: 143560, + }, + { + cpu: 2.7, + rss: 143236, + }, + { + cpu: 3.3, + rss: 143200, + }, + { + cpu: 2.636, + rss: 143136, + }, + { + cpu: 2.8, + rss: 143068, + }, + { + cpu: 2.8, + rss: 143144, + }, + { + cpu: 2.4, + rss: 142832, + }, + { + cpu: 2.4, + rss: 143120, + }, + { + cpu: 3.2, + rss: 143020, + }, + { + cpu: 2.9, + rss: 142884, + }, + { + cpu: 2.8, + rss: 143068, + }, + { + cpu: 2.8, + rss: 143024, + }, + { + cpu: 3, + rss: 143392, + }, + { + cpu: 2.636, + rss: 143276, + }, + { + cpu: 3.2, + rss: 143264, + }, + { + cpu: 2.3, + rss: 142436, + }, + { + cpu: 2.3, + rss: 142812, + }, + { + cpu: 3.1, + rss: 142564, + }, + { + cpu: 3, + rss: 142624, + }, + { + cpu: 2.8, + rss: 143296, + }, + { + cpu: 3, + rss: 142000, + }, + { + cpu: 2.9, + rss: 142264, + }, + { + cpu: 2.8, + rss: 143004, + }, + { + cpu: 2.273, + rss: 142336, + }, + { + cpu: 2.5, + rss: 142420, + }, + { + cpu: 2.5, + rss: 142696, + }, + { + cpu: 3, + rss: 141480, + }, + { + cpu: 3.2, + rss: 142084, + }, + { + cpu: 2.455, + rss: 142428, + }, + { + cpu: 3, + rss: 144056, + }, + { + cpu: 2.9, + rss: 143800, + }, + { + cpu: 2.9, + rss: 143408, + }, + { + cpu: 2.5, + rss: 143144, + }, + { + cpu: 2.7, + rss: 143076, + }, + { + cpu: 2.6, + rss: 143080, + }, + { + cpu: 2.636, + rss: 142248, + }, + { + cpu: 2.8, + rss: 142812, + }, + { + cpu: 2.9, + rss: 143836, + }, + { + cpu: 2.8, + rss: 142564, + }, + { + cpu: 3.2, + rss: 142868, + }, + { + cpu: 2.7, + rss: 143088, + }, + { + cpu: 2.3, + rss: 143516, + }, + { + cpu: 2.6, + rss: 142912, + }, + { + cpu: 2.636, + rss: 143428, + }, + { + cpu: 2.9, + rss: 142660, + }, + { + cpu: 3.3, + rss: 143012, + }, + { + cpu: 2.9, + rss: 143404, + }, + { + cpu: 2.9, + rss: 143512, + }, + { + cpu: 2.7, + rss: 143048, + }, + { + cpu: 2.3, + rss: 142480, + }, + { + cpu: 2.545, + rss: 142628, + }, + { + cpu: 3.2, + rss: 142744, + }, + { + cpu: 2.9, + rss: 143576, + }, + { + cpu: 3, + rss: 143284, + }, + { + cpu: 3, + rss: 143588, + }, + { + cpu: 2.7, + rss: 143340, + }, + { + cpu: 2.9, + rss: 142944, + }, + { + cpu: 2.5, + rss: 142972, + }, + { + cpu: 2.7, + rss: 142940, + }, + { + cpu: 2.6, + rss: 144108, + }, + { + cpu: 2.545, + rss: 143676, + }, + { + cpu: 2.9, + rss: 143480, + }, + { + cpu: 3, + rss: 143228, + }, + { + cpu: 3, + rss: 143420, + }, + { + cpu: 3.1, + rss: 143316, + }, + { + cpu: 2.8, + rss: 143324, + }, + { + cpu: 2.6, + rss: 143376, + }, + { + cpu: 2.4, + rss: 142712, + }, + { + cpu: 2.182, + rss: 142896, + }, + { + cpu: 2.9, + rss: 143616, + }, + { + cpu: 2.8, + rss: 144012, + }, + { + cpu: 3.2, + rss: 142724, + }, + { + cpu: 3, + rss: 142240, + }, + { + cpu: 2.9, + rss: 144172, + }, + { + cpu: 3, + rss: 143712, + }, + { + cpu: 2.6, + rss: 143144, + }, + { + cpu: 2.6, + rss: 142732, + }, + { + cpu: 2.6, + rss: 142924, + }, + { + cpu: 2.9, + rss: 142632, + }, + { + cpu: 2.9, + rss: 143912, + }, + { + cpu: 2.727, + rss: 143132, + }, + { + cpu: 2.8, + rss: 143212, + }, + { + cpu: 3, + rss: 143420, + }, + { + cpu: 2.3, + rss: 143480, + }, + { + cpu: 2.6, + rss: 143212, + }, + { + cpu: 2.455, + rss: 142700, + }, + { + cpu: 2.9, + rss: 142812, + }, + { + cpu: 2.7, + rss: 143088, + }, + { + cpu: 2.8, + rss: 143492, + }, + { + cpu: 2.9, + rss: 143276, + }, + { + cpu: 3.2, + rss: 143004, + }, + { + cpu: 0, + rss: 142328, + }, + { + cpu: 0, + rss: 142328, + }, + { + cpu: 0, + rss: 142328, + }, + { + cpu: 0, + rss: 142328, + }, + { + cpu: 0.1, + rss: 142328, + }, + { + cpu: 0, + rss: 142328, + }, +]; diff --git a/docs/src/content/docs/reference/_benchmarks/supabase_utilization.ts b/docs/src/content/docs/reference/_benchmarks/supabase_utilization.ts new file mode 100644 index 0000000..6d1d20c --- /dev/null +++ b/docs/src/content/docs/reference/_benchmarks/supabase_utilization.ts @@ -0,0 +1,15343 @@ +type Datum = { + cpuPercent: number | null; + memUsageKb: number; +}; + +export const data: Record = { + "supabase-storage": [ + { + cpuPercent: 0, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0178, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0178, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0043, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0043, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.019799999999999998, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.019799999999999998, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0046, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0046, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0212, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0212, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.022000000000000002, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.022000000000000002, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.021400000000000002, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.021400000000000002, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.021099999999999997, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.021099999999999997, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0226, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0226, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.02, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.02, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0046, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0046, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0042, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0042, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0218, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0218, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0213, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0213, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0222, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0222, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0040999999999999995, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0040999999999999995, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0246, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0246, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0040999999999999995, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0040999999999999995, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.021400000000000002, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.021400000000000002, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0233, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0233, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.021, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.021, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0040999999999999995, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0040999999999999995, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.021, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.021, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.024399999999999998, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.024399999999999998, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0219, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0219, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0219, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0199, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0199, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0215, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0215, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 129126.4, + }, + { + cpuPercent: 0.021099999999999997, + memUsageKb: 128307.2, + }, + { + cpuPercent: 0.021099999999999997, + memUsageKb: 128307.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128307.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128307.2, + }, + { + cpuPercent: 0.004, + memUsageKb: 128307.2, + }, + { + cpuPercent: 0.004, + memUsageKb: 128307.2, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0189, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0189, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.004, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.004, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.004, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0207, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0207, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0040999999999999995, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0040999999999999995, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.022400000000000003, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.022400000000000003, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0040999999999999995, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0040999999999999995, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.023799999999999998, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.023799999999999998, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 128409.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0046, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0046, + memUsageKb: 128512, + }, + { + cpuPercent: 0.022099999999999998, + memUsageKb: 128512, + }, + { + cpuPercent: 0.022099999999999998, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0037, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0037, + memUsageKb: 128512, + }, + { + cpuPercent: 0.004, + memUsageKb: 128512, + }, + { + cpuPercent: 0.004, + memUsageKb: 128512, + }, + { + cpuPercent: 0.004, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0263, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0263, + memUsageKb: 128512, + }, + { + cpuPercent: 0.004, + memUsageKb: 128512, + }, + { + cpuPercent: 0.004, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0037, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0037, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0219, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0219, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128512, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128512, + }, + { + cpuPercent: 0.004, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0040999999999999995, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0040999999999999995, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0231, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0231, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0233, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0233, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0151, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0151, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0022, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0022, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0022, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0022, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0022, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0022, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0028000000000000004, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.0028000000000000004, + memUsageKb: 128614.4, + }, + { + cpuPercent: 0.015700000000000002, + memUsageKb: 128716.8, + }, + { + cpuPercent: null, + memUsageKb: 0, + }, + ], + "supabase-edge-functions": [ + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: 0, + memUsageKb: 99676.16, + }, + { + cpuPercent: null, + memUsageKb: 0, + }, + ], + "realtime-dev.supabase-realtime": [ + { + cpuPercent: 0, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0311, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0311, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0042, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0042, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0075, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0075, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0333, + memUsageKb: 260608, + }, + { + cpuPercent: 0.0333, + memUsageKb: 260608, + }, + { + cpuPercent: 0.0053, + memUsageKb: 260608, + }, + { + cpuPercent: 0.0053, + memUsageKb: 260608, + }, + { + cpuPercent: 0.0388, + memUsageKb: 260608, + }, + { + cpuPercent: 0.0388, + memUsageKb: 260608, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 260608, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 260608, + }, + { + cpuPercent: 0.0146, + memUsageKb: 260608, + }, + { + cpuPercent: 0.0146, + memUsageKb: 260608, + }, + { + cpuPercent: 0.0206, + memUsageKb: 260608, + }, + { + cpuPercent: 0.0206, + memUsageKb: 260608, + }, + { + cpuPercent: 0.0097, + memUsageKb: 260608, + }, + { + cpuPercent: 0.0097, + memUsageKb: 260608, + }, + { + cpuPercent: 0.0361, + memUsageKb: 260608, + }, + { + cpuPercent: 0.0361, + memUsageKb: 260608, + }, + { + cpuPercent: 0.0070999999999999995, + memUsageKb: 260608, + }, + { + cpuPercent: 0.0070999999999999995, + memUsageKb: 260608, + }, + { + cpuPercent: 0.018799999999999997, + memUsageKb: 260608, + }, + { + cpuPercent: 0.018799999999999997, + memUsageKb: 260608, + }, + { + cpuPercent: 0.027999999999999997, + memUsageKb: 260608, + }, + { + cpuPercent: 0.027999999999999997, + memUsageKb: 260608, + }, + { + cpuPercent: 0.011899999999999999, + memUsageKb: 260608, + }, + { + cpuPercent: 0.011899999999999999, + memUsageKb: 260608, + }, + { + cpuPercent: 0.044500000000000005, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.044500000000000005, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0231, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0231, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0076, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0076, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0045000000000000005, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0045000000000000005, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.037000000000000005, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.037000000000000005, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0026, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0026, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0263, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0263, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.005, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.005, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.005, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0067, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0067, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0321, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0321, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0032, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0032, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0321, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0321, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0034000000000000002, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0034000000000000002, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0074, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0074, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.11259999999999999, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.11259999999999999, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0077, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0077, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0225, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0225, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0022, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0022, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0055000000000000005, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0055000000000000005, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0359, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0359, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0075, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0075, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0462, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0462, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0025, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0025, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0097, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0097, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0437, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0437, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 260710.4, + }, + { + cpuPercent: 0.0348, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0348, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0089, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0089, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0089, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0066, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0066, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0361, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0361, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.011699999999999999, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.011699999999999999, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.024300000000000002, + memUsageKb: 261017.6, + }, + { + cpuPercent: 0.024300000000000002, + memUsageKb: 261017.6, + }, + { + cpuPercent: 0.005600000000000001, + memUsageKb: 261017.6, + }, + { + cpuPercent: 0.005600000000000001, + memUsageKb: 261017.6, + }, + { + cpuPercent: 0.0045000000000000005, + memUsageKb: 261017.6, + }, + { + cpuPercent: 0.0045000000000000005, + memUsageKb: 261017.6, + }, + { + cpuPercent: 0.0374, + memUsageKb: 261017.6, + }, + { + cpuPercent: 0.0374, + memUsageKb: 261017.6, + }, + { + cpuPercent: 0.0144, + memUsageKb: 261017.6, + }, + { + cpuPercent: 0.0144, + memUsageKb: 261017.6, + }, + { + cpuPercent: 0.023399999999999997, + memUsageKb: 261017.6, + }, + { + cpuPercent: 0.023399999999999997, + memUsageKb: 261017.6, + }, + { + cpuPercent: 0.0103, + memUsageKb: 261017.6, + }, + { + cpuPercent: 0.0103, + memUsageKb: 261017.6, + }, + { + cpuPercent: 0.0045000000000000005, + memUsageKb: 261017.6, + }, + { + cpuPercent: 0.0045000000000000005, + memUsageKb: 261017.6, + }, + { + cpuPercent: 0.0394, + memUsageKb: 261017.6, + }, + { + cpuPercent: 0.0394, + memUsageKb: 261017.6, + }, + { + cpuPercent: 0.019299999999999998, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.019299999999999998, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.015700000000000002, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.015700000000000002, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.009399999999999999, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.009399999999999999, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0087, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0087, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0362, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0362, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.027200000000000002, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.027200000000000002, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0060999999999999995, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0060999999999999995, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0077, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0077, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.006999999999999999, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.006999999999999999, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.037599999999999995, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.037599999999999995, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.037599999999999995, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0371, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0371, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0058, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0058, + memUsageKb: 260812.8, + }, + { + cpuPercent: 0.0097, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0097, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.005699999999999999, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.005699999999999999, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.036000000000000004, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.036000000000000004, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0331, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0331, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0063, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0063, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0060999999999999995, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0060999999999999995, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0033, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0033, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0421, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0421, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.024900000000000002, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.024900000000000002, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.008199999999999999, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.008199999999999999, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.008100000000000001, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.008100000000000001, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0034999999999999996, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0034999999999999996, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0379, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0379, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0364, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0364, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0021, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0021, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0067, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0067, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0027, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0027, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0344, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0344, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0344, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.031400000000000004, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.031400000000000004, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0077, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0077, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0091, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0091, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0069, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0069, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0342, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0342, + memUsageKb: 260915.2, + }, + { + cpuPercent: 0.0262, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0262, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0034000000000000002, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0034000000000000002, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0097, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0097, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0032, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0032, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.049800000000000004, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.049800000000000004, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.018000000000000002, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.018000000000000002, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0069, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0069, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0026, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0026, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.051500000000000004, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.051500000000000004, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0152, + memUsageKb: 263065.6, + }, + { + cpuPercent: 0.0152, + memUsageKb: 263065.6, + }, + { + cpuPercent: 0.005699999999999999, + memUsageKb: 263065.6, + }, + { + cpuPercent: 0.005699999999999999, + memUsageKb: 263065.6, + }, + { + cpuPercent: 0.0046, + memUsageKb: 263065.6, + }, + { + cpuPercent: 0.0046, + memUsageKb: 263065.6, + }, + { + cpuPercent: 0.0027, + memUsageKb: 263065.6, + }, + { + cpuPercent: 0.0027, + memUsageKb: 263065.6, + }, + { + cpuPercent: 0.0785, + memUsageKb: 263065.6, + }, + { + cpuPercent: 0.0785, + memUsageKb: 263065.6, + }, + { + cpuPercent: 0.0098, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0098, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0072, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0072, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0072, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0078000000000000005, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0078000000000000005, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0072, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0072, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.06480000000000001, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.06480000000000001, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0024, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0024, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0087, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0087, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0045000000000000005, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0045000000000000005, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0036, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0036, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0649, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0649, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0040999999999999995, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0040999999999999995, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0068000000000000005, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0068000000000000005, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0059, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0059, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0074, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0074, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.060599999999999994, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.060599999999999994, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0095, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0095, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0023, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0023, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.008, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.008, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0632, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0632, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0029, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0029, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0083, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0083, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0083, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0075, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0075, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.003, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.003, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0608, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0608, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0021, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0021, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0073, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0073, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0043, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0043, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0073, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0073, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0652, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0652, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0033, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0033, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0075, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0075, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.006999999999999999, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.006999999999999999, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0143, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0143, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.053, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.053, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0031, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0031, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0058, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0058, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0073, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0073, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.033, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.033, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.033, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.030600000000000002, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.030600000000000002, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0026, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0026, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0067, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0067, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0036, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0036, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.024700000000000003, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.024700000000000003, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0287, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0287, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0062, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0062, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0043, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0043, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0079, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.0079, + memUsageKb: 262963.2, + }, + { + cpuPercent: 0.042199999999999994, + memUsageKb: 262963.2, + }, + { + cpuPercent: null, + memUsageKb: 0, + }, + ], + "supabase-meta": [ + { + cpuPercent: 0, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0046, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0046, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0043, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0043, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.222, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.222, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.004, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.004, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.1469, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.1469, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.11259999999999999, + memUsageKb: 159334.4, + }, + { + cpuPercent: 0.11259999999999999, + memUsageKb: 159334.4, + }, + { + cpuPercent: 0.1186, + memUsageKb: 139776, + }, + { + cpuPercent: 0.1186, + memUsageKb: 139776, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139776, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139776, + }, + { + cpuPercent: 0.0034000000000000002, + memUsageKb: 139776, + }, + { + cpuPercent: 0.0034000000000000002, + memUsageKb: 139776, + }, + { + cpuPercent: 0.004699999999999999, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.004699999999999999, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.24239999999999998, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.24239999999999998, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0037, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.20550000000000002, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.20550000000000002, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0037, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0037, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0037, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0037, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0037, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0037, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.2041, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.2041, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0037, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0037, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0036, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0036, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0036, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0036, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.2327, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.2327, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.2457, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.2457, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0664, + memUsageKb: 149606.4, + }, + { + cpuPercent: 0.0664, + memUsageKb: 149606.4, + }, + { + cpuPercent: 0.1558, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.1558, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0034999999999999996, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0034999999999999996, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.2232, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.2232, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.22769999999999999, + memUsageKb: 139264, + }, + { + cpuPercent: 0.22769999999999999, + memUsageKb: 139264, + }, + { + cpuPercent: 0.004, + memUsageKb: 139264, + }, + { + cpuPercent: 0.004, + memUsageKb: 139264, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139264, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139264, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139264, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139264, + }, + { + cpuPercent: 0.0034999999999999996, + memUsageKb: 139264, + }, + { + cpuPercent: 0.0034999999999999996, + memUsageKb: 139264, + }, + { + cpuPercent: 0.2206, + memUsageKb: 139264, + }, + { + cpuPercent: 0.2206, + memUsageKb: 139264, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139264, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139264, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139264, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139264, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139264, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139264, + }, + { + cpuPercent: 0.004, + memUsageKb: 139264, + }, + { + cpuPercent: 0.004, + memUsageKb: 139264, + }, + { + cpuPercent: 0.004, + memUsageKb: 139264, + }, + { + cpuPercent: 0.2155, + memUsageKb: 139366.4, + }, + { + cpuPercent: 0.2155, + memUsageKb: 139366.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139366.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139366.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 139366.4, + }, + { + cpuPercent: 0.004, + memUsageKb: 139366.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139366.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139366.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139366.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139366.4, + }, + { + cpuPercent: 0.25120000000000003, + memUsageKb: 144281.6, + }, + { + cpuPercent: 0.25120000000000003, + memUsageKb: 144281.6, + }, + { + cpuPercent: 0.0074, + memUsageKb: 139366.4, + }, + { + cpuPercent: 0.0074, + memUsageKb: 139366.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139366.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139366.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139366.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139366.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139366.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139366.4, + }, + { + cpuPercent: 0.0391, + memUsageKb: 145510.4, + }, + { + cpuPercent: 0.0391, + memUsageKb: 145510.4, + }, + { + cpuPercent: 0.1973, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.1973, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.0034999999999999996, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.0034999999999999996, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.2202, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.2202, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.004, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.004, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139468.8, + }, + { + cpuPercent: 0.2169, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.2169, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.2125, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.2125, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0040999999999999995, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0040999999999999995, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.004, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.004, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139571.2, + }, + { + cpuPercent: 0.2336, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.2336, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.004, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.004, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.2238, + memUsageKb: 139878.4, + }, + { + cpuPercent: 0.2238, + memUsageKb: 139878.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0036, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139673.6, + }, + { + cpuPercent: 0.0582, + memUsageKb: 148275.2, + }, + { + cpuPercent: 0.0582, + memUsageKb: 148275.2, + }, + { + cpuPercent: 0.0582, + memUsageKb: 148275.2, + }, + { + cpuPercent: 0.1663, + memUsageKb: 139776, + }, + { + cpuPercent: 0.1663, + memUsageKb: 139776, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139776, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139776, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139776, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139776, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139776, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139776, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139776, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139776, + }, + { + cpuPercent: 0.2155, + memUsageKb: 139776, + }, + { + cpuPercent: 0.2155, + memUsageKb: 139776, + }, + { + cpuPercent: 0.0045000000000000005, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0045000000000000005, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0038, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.26530000000000004, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.26530000000000004, + memUsageKb: 138854.4, + }, + { + cpuPercent: 0.0036, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0036, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0036, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0036, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.2273, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.2273, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0038, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0038, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0034999999999999996, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0034999999999999996, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0034999999999999996, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0034999999999999996, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 138956.8, + }, + { + cpuPercent: 0.20800000000000002, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.20800000000000002, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.20800000000000002, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.23850000000000002, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.23850000000000002, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139059.2, + }, + { + cpuPercent: 0.1158, + memUsageKb: 154009.6, + }, + { + cpuPercent: 0.1158, + memUsageKb: 154009.6, + }, + { + cpuPercent: 0.1359, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.1359, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.004, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.004, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.2249, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.2249, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.2249, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0039000000000000003, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0037, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0038, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.14400000000000002, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.14400000000000002, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0023, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0023, + memUsageKb: 139161.6, + }, + { + cpuPercent: 0.0022, + memUsageKb: 139264, + }, + { + cpuPercent: 0.0022, + memUsageKb: 139264, + }, + { + cpuPercent: 0.0021, + memUsageKb: 139264, + }, + { + cpuPercent: 0.0021, + memUsageKb: 139264, + }, + { + cpuPercent: 0.0024, + memUsageKb: 139264, + }, + { + cpuPercent: 0.0024, + memUsageKb: 139264, + }, + { + cpuPercent: 0.1499, + memUsageKb: 139264, + }, + { + cpuPercent: null, + memUsageKb: 0, + }, + ], + "supabase-studio": [ + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0542, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0542, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0694, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0694, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0684, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0684, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0669, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0669, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.060899999999999996, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.060899999999999996, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0663, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0663, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0706, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0706, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0005, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0005, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0725, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0725, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0662, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0662, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0678, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0678, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0632, + memUsageKb: 136089.6, + }, + { + cpuPercent: 0.0632, + memUsageKb: 136089.6, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.063, + memUsageKb: 136089.6, + }, + { + cpuPercent: 0.063, + memUsageKb: 136089.6, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0642, + memUsageKb: 136192, + }, + { + cpuPercent: 0.0642, + memUsageKb: 136192, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0002, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0002, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0002, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0622, + memUsageKb: 136089.6, + }, + { + cpuPercent: 0.0622, + memUsageKb: 136089.6, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0355, + memUsageKb: 141619.2, + }, + { + cpuPercent: 0.0355, + memUsageKb: 141619.2, + }, + { + cpuPercent: 0.0346, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0346, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0409, + memUsageKb: 142336, + }, + { + cpuPercent: 0.0409, + memUsageKb: 142336, + }, + { + cpuPercent: 0.029900000000000003, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.029900000000000003, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0437, + memUsageKb: 141824, + }, + { + cpuPercent: 0.0437, + memUsageKb: 141824, + }, + { + cpuPercent: 0.030899999999999997, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.030899999999999997, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.008199999999999999, + memUsageKb: 136908.8, + }, + { + cpuPercent: 0.008199999999999999, + memUsageKb: 136908.8, + }, + { + cpuPercent: 0.051399999999999994, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.051399999999999994, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0682, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0682, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0003, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0003, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0658, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0658, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0667, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0667, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0613, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0613, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0639, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0639, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0698, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0698, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0751, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0751, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0003, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0003, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0757, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0757, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0757, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0716, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0716, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0669, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0669, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0705, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0705, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0687, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.0687, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.042300000000000004, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0.042300000000000004, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: 0, + memUsageKb: 135987.2, + }, + { + cpuPercent: null, + memUsageKb: 0, + }, + ], + "supabase-kong": [ + { + cpuPercent: 0.0001, + memUsageKb: 2112880.64, + }, + { + cpuPercent: 0.0001, + memUsageKb: 2112880.64, + }, + { + cpuPercent: 0.0024, + memUsageKb: 2112880.64, + }, + { + cpuPercent: 0.0024, + memUsageKb: 2112880.64, + }, + { + cpuPercent: 0.003, + memUsageKb: 2112880.64, + }, + { + cpuPercent: 0.003, + memUsageKb: 2112880.64, + }, + { + cpuPercent: 0.2606, + memUsageKb: 2119172.096, + }, + { + cpuPercent: 0.2606, + memUsageKb: 2119172.096, + }, + { + cpuPercent: 0.3128, + memUsageKb: 2125463.552, + }, + { + cpuPercent: 0.3128, + memUsageKb: 2125463.552, + }, + { + cpuPercent: 0.3066, + memUsageKb: 2130706.432, + }, + { + cpuPercent: 0.3066, + memUsageKb: 2130706.432, + }, + { + cpuPercent: 0.3169, + memUsageKb: 2134900.736, + }, + { + cpuPercent: 0.3169, + memUsageKb: 2134900.736, + }, + { + cpuPercent: 0.3298, + memUsageKb: 2140143.616, + }, + { + cpuPercent: 0.3298, + memUsageKb: 2140143.616, + }, + { + cpuPercent: 0.3332, + memUsageKb: 2145386.496, + }, + { + cpuPercent: 0.3332, + memUsageKb: 2145386.496, + }, + { + cpuPercent: 0.2928, + memUsageKb: 2149580.8, + }, + { + cpuPercent: 0.2928, + memUsageKb: 2149580.8, + }, + { + cpuPercent: 0.3768, + memUsageKb: 2151677.952, + }, + { + cpuPercent: 0.3768, + memUsageKb: 2151677.952, + }, + { + cpuPercent: 0.3194, + memUsageKb: 2159017.984, + }, + { + cpuPercent: 0.3194, + memUsageKb: 2159017.984, + }, + { + cpuPercent: 0.3432, + memUsageKb: 2165309.44, + }, + { + cpuPercent: 0.3432, + memUsageKb: 2165309.44, + }, + { + cpuPercent: 0.36460000000000004, + memUsageKb: 2170552.32, + }, + { + cpuPercent: 0.36460000000000004, + memUsageKb: 2170552.32, + }, + { + cpuPercent: 0.3331, + memUsageKb: 2175795.2, + }, + { + cpuPercent: 0.3331, + memUsageKb: 2175795.2, + }, + { + cpuPercent: 0.34869999999999995, + memUsageKb: 2182086.656, + }, + { + cpuPercent: 0.34869999999999995, + memUsageKb: 2182086.656, + }, + { + cpuPercent: 0.319, + memUsageKb: 2187329.536, + }, + { + cpuPercent: 0.319, + memUsageKb: 2187329.536, + }, + { + cpuPercent: 0.322, + memUsageKb: 2192572.416, + }, + { + cpuPercent: 0.322, + memUsageKb: 2192572.416, + }, + { + cpuPercent: 0.32, + memUsageKb: 2197815.296, + }, + { + cpuPercent: 0.32, + memUsageKb: 2197815.296, + }, + { + cpuPercent: 0.3521, + memUsageKb: 2203058.176, + }, + { + cpuPercent: 0.3521, + memUsageKb: 2203058.176, + }, + { + cpuPercent: 0.4495, + memUsageKb: 2208301.056, + }, + { + cpuPercent: 0.4495, + memUsageKb: 2208301.056, + }, + { + cpuPercent: 0.32530000000000003, + memUsageKb: 2213543.936, + }, + { + cpuPercent: 0.32530000000000003, + memUsageKb: 2213543.936, + }, + { + cpuPercent: 0.3037, + memUsageKb: 2218786.816, + }, + { + cpuPercent: 0.3037, + memUsageKb: 2218786.816, + }, + { + cpuPercent: 0.3262, + memUsageKb: 2224029.696, + }, + { + cpuPercent: 0.3262, + memUsageKb: 2224029.696, + }, + { + cpuPercent: 0.3486, + memUsageKb: 2230321.152, + }, + { + cpuPercent: 0.3486, + memUsageKb: 2230321.152, + }, + { + cpuPercent: 0.3486, + memUsageKb: 2230321.152, + }, + { + cpuPercent: 0.3239, + memUsageKb: 2234515.456, + }, + { + cpuPercent: 0.3239, + memUsageKb: 2234515.456, + }, + { + cpuPercent: 0.3367, + memUsageKb: 2239758.336, + }, + { + cpuPercent: 0.3367, + memUsageKb: 2239758.336, + }, + { + cpuPercent: 0.29410000000000003, + memUsageKb: 2245001.216, + }, + { + cpuPercent: 0.29410000000000003, + memUsageKb: 2245001.216, + }, + { + cpuPercent: 0.34979999999999994, + memUsageKb: 2250244.096, + }, + { + cpuPercent: 0.34979999999999994, + memUsageKb: 2250244.096, + }, + { + cpuPercent: 0.34340000000000004, + memUsageKb: 2256535.552, + }, + { + cpuPercent: 0.34340000000000004, + memUsageKb: 2256535.552, + }, + { + cpuPercent: 0.42, + memUsageKb: 2261778.432, + }, + { + cpuPercent: 0.42, + memUsageKb: 2261778.432, + }, + { + cpuPercent: 0.297, + memUsageKb: 2265972.736, + }, + { + cpuPercent: 0.297, + memUsageKb: 2265972.736, + }, + { + cpuPercent: 0.3345, + memUsageKb: 2272264.192, + }, + { + cpuPercent: 0.3345, + memUsageKb: 2272264.192, + }, + { + cpuPercent: 0.32530000000000003, + memUsageKb: 2277507.072, + }, + { + cpuPercent: 0.32530000000000003, + memUsageKb: 2277507.072, + }, + { + cpuPercent: 0.31379999999999997, + memUsageKb: 2282749.952, + }, + { + cpuPercent: 0.31379999999999997, + memUsageKb: 2282749.952, + }, + { + cpuPercent: 0.34740000000000004, + memUsageKb: 2287992.832, + }, + { + cpuPercent: 0.34740000000000004, + memUsageKb: 2287992.832, + }, + { + cpuPercent: 0.3195, + memUsageKb: 2293235.712, + }, + { + cpuPercent: 0.3195, + memUsageKb: 2293235.712, + }, + { + cpuPercent: 0.3326, + memUsageKb: 2299527.168, + }, + { + cpuPercent: 0.3326, + memUsageKb: 2299527.168, + }, + { + cpuPercent: 0.32420000000000004, + memUsageKb: 2302672.896, + }, + { + cpuPercent: 0.32420000000000004, + memUsageKb: 2302672.896, + }, + { + cpuPercent: 0.33390000000000003, + memUsageKb: 2307915.776, + }, + { + cpuPercent: 0.33390000000000003, + memUsageKb: 2307915.776, + }, + { + cpuPercent: 0.45130000000000003, + memUsageKb: 2313158.656, + }, + { + cpuPercent: 0.45130000000000003, + memUsageKb: 2313158.656, + }, + { + cpuPercent: 0.3421, + memUsageKb: 2318401.536, + }, + { + cpuPercent: 0.3421, + memUsageKb: 2318401.536, + }, + { + cpuPercent: 0.3309, + memUsageKb: 2323644.416, + }, + { + cpuPercent: 0.3309, + memUsageKb: 2323644.416, + }, + { + cpuPercent: 0.3262, + memUsageKb: 2329935.872, + }, + { + cpuPercent: 0.3262, + memUsageKb: 2329935.872, + }, + { + cpuPercent: 0.3344, + memUsageKb: 2335178.752, + }, + { + cpuPercent: 0.3344, + memUsageKb: 2335178.752, + }, + { + cpuPercent: 0.3344, + memUsageKb: 2335178.752, + }, + { + cpuPercent: 0.31329999999999997, + memUsageKb: 2339373.056, + }, + { + cpuPercent: 0.31329999999999997, + memUsageKb: 2339373.056, + }, + { + cpuPercent: 0.30920000000000003, + memUsageKb: 2344615.936, + }, + { + cpuPercent: 0.30920000000000003, + memUsageKb: 2344615.936, + }, + { + cpuPercent: 0.2966, + memUsageKb: 2348810.24, + }, + { + cpuPercent: 0.2966, + memUsageKb: 2348810.24, + }, + { + cpuPercent: 0.3132, + memUsageKb: 2355101.696, + }, + { + cpuPercent: 0.3132, + memUsageKb: 2355101.696, + }, + { + cpuPercent: 0.28190000000000004, + memUsageKb: 2359296, + }, + { + cpuPercent: 0.28190000000000004, + memUsageKb: 2359296, + }, + { + cpuPercent: 0.4122, + memUsageKb: 2364538.88, + }, + { + cpuPercent: 0.4122, + memUsageKb: 2364538.88, + }, + { + cpuPercent: 0.3206, + memUsageKb: 2369781.76, + }, + { + cpuPercent: 0.3206, + memUsageKb: 2369781.76, + }, + { + cpuPercent: 0.3126, + memUsageKb: 2375024.64, + }, + { + cpuPercent: 0.3126, + memUsageKb: 2375024.64, + }, + { + cpuPercent: 0.3148, + memUsageKb: 2379218.944, + }, + { + cpuPercent: 0.3148, + memUsageKb: 2379218.944, + }, + { + cpuPercent: 0.3282, + memUsageKb: 2384461.824, + }, + { + cpuPercent: 0.3282, + memUsageKb: 2384461.824, + }, + { + cpuPercent: 0.3431, + memUsageKb: 2389704.704, + }, + { + cpuPercent: 0.3431, + memUsageKb: 2389704.704, + }, + { + cpuPercent: 0.3215, + memUsageKb: 2394947.584, + }, + { + cpuPercent: 0.3215, + memUsageKb: 2394947.584, + }, + { + cpuPercent: 0.322, + memUsageKb: 2400190.464, + }, + { + cpuPercent: 0.322, + memUsageKb: 2400190.464, + }, + { + cpuPercent: 0.32409999999999994, + memUsageKb: 2405433.344, + }, + { + cpuPercent: 0.32409999999999994, + memUsageKb: 2405433.344, + }, + { + cpuPercent: 0.321, + memUsageKb: 2409627.648, + }, + { + cpuPercent: 0.321, + memUsageKb: 2409627.648, + }, + { + cpuPercent: 0.42219999999999996, + memUsageKb: 2415919.104, + }, + { + cpuPercent: 0.42219999999999996, + memUsageKb: 2415919.104, + }, + { + cpuPercent: 0.2615, + memUsageKb: 2418016.256, + }, + { + cpuPercent: 0.2615, + memUsageKb: 2418016.256, + }, + { + cpuPercent: 0.34659999999999996, + memUsageKb: 2425356.288, + }, + { + cpuPercent: 0.34659999999999996, + memUsageKb: 2425356.288, + }, + { + cpuPercent: 0.318, + memUsageKb: 2430599.168, + }, + { + cpuPercent: 0.318, + memUsageKb: 2430599.168, + }, + { + cpuPercent: 0.3369, + memUsageKb: 2435842.048, + }, + { + cpuPercent: 0.3369, + memUsageKb: 2435842.048, + }, + { + cpuPercent: 0.36, + memUsageKb: 2441084.928, + }, + { + cpuPercent: 0.36, + memUsageKb: 2441084.928, + }, + { + cpuPercent: 0.3563, + memUsageKb: 2446327.808, + }, + { + cpuPercent: 0.3563, + memUsageKb: 2446327.808, + }, + { + cpuPercent: 0.3563, + memUsageKb: 2446327.808, + }, + { + cpuPercent: 0.32770000000000005, + memUsageKb: 2450522.112, + }, + { + cpuPercent: 0.32770000000000005, + memUsageKb: 2450522.112, + }, + { + cpuPercent: 0.348, + memUsageKb: 2455764.992, + }, + { + cpuPercent: 0.348, + memUsageKb: 2455764.992, + }, + { + cpuPercent: 0.3381, + memUsageKb: 2459959.296, + }, + { + cpuPercent: 0.3381, + memUsageKb: 2459959.296, + }, + { + cpuPercent: 0.4092, + memUsageKb: 2464153.6, + }, + { + cpuPercent: 0.4092, + memUsageKb: 2464153.6, + }, + { + cpuPercent: 0.34020000000000006, + memUsageKb: 2469396.48, + }, + { + cpuPercent: 0.34020000000000006, + memUsageKb: 2469396.48, + }, + { + cpuPercent: 0.3325, + memUsageKb: 2473590.784, + }, + { + cpuPercent: 0.3325, + memUsageKb: 2473590.784, + }, + { + cpuPercent: 0.3232, + memUsageKb: 2478833.664, + }, + { + cpuPercent: 0.3232, + memUsageKb: 2478833.664, + }, + { + cpuPercent: 0.3232, + memUsageKb: 2484076.544, + }, + { + cpuPercent: 0.3232, + memUsageKb: 2484076.544, + }, + { + cpuPercent: 0.32, + memUsageKb: 2488270.848, + }, + { + cpuPercent: 0.32, + memUsageKb: 2488270.848, + }, + { + cpuPercent: 0.3297, + memUsageKb: 2493513.728, + }, + { + cpuPercent: 0.3297, + memUsageKb: 2493513.728, + }, + { + cpuPercent: 0.3095, + memUsageKb: 2497708.032, + }, + { + cpuPercent: 0.3095, + memUsageKb: 2497708.032, + }, + { + cpuPercent: 0.3367, + memUsageKb: 2502950.912, + }, + { + cpuPercent: 0.3367, + memUsageKb: 2502950.912, + }, + { + cpuPercent: 0.3627, + memUsageKb: 2508193.792, + }, + { + cpuPercent: 0.3627, + memUsageKb: 2508193.792, + }, + { + cpuPercent: 0.44299999999999995, + memUsageKb: 2511339.52, + }, + { + cpuPercent: 0.44299999999999995, + memUsageKb: 2511339.52, + }, + { + cpuPercent: 0.33020000000000005, + memUsageKb: 2515533.824, + }, + { + cpuPercent: 0.33020000000000005, + memUsageKb: 2515533.824, + }, + { + cpuPercent: 0.3371, + memUsageKb: 2518679.552, + }, + { + cpuPercent: 0.3371, + memUsageKb: 2518679.552, + }, + { + cpuPercent: 0.35369999999999996, + memUsageKb: 2522873.856, + }, + { + cpuPercent: 0.35369999999999996, + memUsageKb: 2522873.856, + }, + { + cpuPercent: 0.3444, + memUsageKb: 2527068.16, + }, + { + cpuPercent: 0.3444, + memUsageKb: 2527068.16, + }, + { + cpuPercent: 0.31370000000000003, + memUsageKb: 2530213.888, + }, + { + cpuPercent: 0.31370000000000003, + memUsageKb: 2530213.888, + }, + { + cpuPercent: 0.34850000000000003, + memUsageKb: 2535456.768, + }, + { + cpuPercent: 0.34850000000000003, + memUsageKb: 2535456.768, + }, + { + cpuPercent: 0.34850000000000003, + memUsageKb: 2535456.768, + }, + { + cpuPercent: 0.34850000000000003, + memUsageKb: 2538602.496, + }, + { + cpuPercent: 0.34850000000000003, + memUsageKb: 2538602.496, + }, + { + cpuPercent: 0.3417, + memUsageKb: 2540699.648, + }, + { + cpuPercent: 0.3417, + memUsageKb: 2540699.648, + }, + { + cpuPercent: 0.3419, + memUsageKb: 2543845.376, + }, + { + cpuPercent: 0.3419, + memUsageKb: 2543845.376, + }, + { + cpuPercent: 0.3836, + memUsageKb: 2546991.104, + }, + { + cpuPercent: 0.3836, + memUsageKb: 2546991.104, + }, + { + cpuPercent: 0.3933, + memUsageKb: 2550136.832, + }, + { + cpuPercent: 0.3933, + memUsageKb: 2550136.832, + }, + { + cpuPercent: 0.35100000000000003, + memUsageKb: 2553282.56, + }, + { + cpuPercent: 0.35100000000000003, + memUsageKb: 2553282.56, + }, + { + cpuPercent: 0.3643, + memUsageKb: 2555379.712, + }, + { + cpuPercent: 0.3643, + memUsageKb: 2555379.712, + }, + { + cpuPercent: 0.3932, + memUsageKb: 2558525.44, + }, + { + cpuPercent: 0.3932, + memUsageKb: 2558525.44, + }, + { + cpuPercent: 0.341, + memUsageKb: 2561671.168, + }, + { + cpuPercent: 0.341, + memUsageKb: 2561671.168, + }, + { + cpuPercent: 0.38079999999999997, + memUsageKb: 2563768.32, + }, + { + cpuPercent: 0.38079999999999997, + memUsageKb: 2563768.32, + }, + { + cpuPercent: 0.32770000000000005, + memUsageKb: 2565865.472, + }, + { + cpuPercent: 0.32770000000000005, + memUsageKb: 2565865.472, + }, + { + cpuPercent: 0.3833, + memUsageKb: 2567962.624, + }, + { + cpuPercent: 0.3833, + memUsageKb: 2567962.624, + }, + { + cpuPercent: 0.37799999999999995, + memUsageKb: 2570059.776, + }, + { + cpuPercent: 0.37799999999999995, + memUsageKb: 2570059.776, + }, + { + cpuPercent: 0.5159, + memUsageKb: 2572156.928, + }, + { + cpuPercent: 0.5159, + memUsageKb: 2572156.928, + }, + { + cpuPercent: 0.35950000000000004, + memUsageKb: 2575302.656, + }, + { + cpuPercent: 0.35950000000000004, + memUsageKb: 2575302.656, + }, + { + cpuPercent: 0.3354, + memUsageKb: 2576351.232, + }, + { + cpuPercent: 0.3354, + memUsageKb: 2576351.232, + }, + { + cpuPercent: 0.34090000000000004, + memUsageKb: 2578448.384, + }, + { + cpuPercent: 0.34090000000000004, + memUsageKb: 2578448.384, + }, + { + cpuPercent: 0.34299999999999997, + memUsageKb: 2580545.536, + }, + { + cpuPercent: 0.34299999999999997, + memUsageKb: 2580545.536, + }, + { + cpuPercent: 0.3439, + memUsageKb: 2581594.112, + }, + { + cpuPercent: 0.3439, + memUsageKb: 2581594.112, + }, + { + cpuPercent: 0.3161, + memUsageKb: 2583691.264, + }, + { + cpuPercent: 0.3161, + memUsageKb: 2583691.264, + }, + { + cpuPercent: 0.3333, + memUsageKb: 2585788.416, + }, + { + cpuPercent: 0.3333, + memUsageKb: 2585788.416, + }, + { + cpuPercent: 0.34340000000000004, + memUsageKb: 2587885.568, + }, + { + cpuPercent: 0.34340000000000004, + memUsageKb: 2587885.568, + }, + { + cpuPercent: 0.34340000000000004, + memUsageKb: 2587885.568, + }, + { + cpuPercent: 0.3372, + memUsageKb: 2587885.568, + }, + { + cpuPercent: 0.3372, + memUsageKb: 2587885.568, + }, + { + cpuPercent: 0.42829999999999996, + memUsageKb: 2589982.72, + }, + { + cpuPercent: 0.42829999999999996, + memUsageKb: 2589982.72, + }, + { + cpuPercent: 0.3136, + memUsageKb: 2592079.872, + }, + { + cpuPercent: 0.3136, + memUsageKb: 2592079.872, + }, + { + cpuPercent: 0.3211, + memUsageKb: 2594177.024, + }, + { + cpuPercent: 0.3211, + memUsageKb: 2594177.024, + }, + { + cpuPercent: 0.3236, + memUsageKb: 2596274.176, + }, + { + cpuPercent: 0.3236, + memUsageKb: 2596274.176, + }, + { + cpuPercent: 0.32170000000000004, + memUsageKb: 2599419.904, + }, + { + cpuPercent: 0.32170000000000004, + memUsageKb: 2599419.904, + }, + { + cpuPercent: 0.2937, + memUsageKb: 2601517.056, + }, + { + cpuPercent: 0.2937, + memUsageKb: 2601517.056, + }, + { + cpuPercent: 0.3005, + memUsageKb: 2603614.208, + }, + { + cpuPercent: 0.3005, + memUsageKb: 2603614.208, + }, + { + cpuPercent: 0.3361, + memUsageKb: 2605711.36, + }, + { + cpuPercent: 0.3361, + memUsageKb: 2605711.36, + }, + { + cpuPercent: 0.33630000000000004, + memUsageKb: 2608857.088, + }, + { + cpuPercent: 0.33630000000000004, + memUsageKb: 2608857.088, + }, + { + cpuPercent: 0.35090000000000005, + memUsageKb: 2612002.816, + }, + { + cpuPercent: 0.35090000000000005, + memUsageKb: 2612002.816, + }, + { + cpuPercent: 0.401, + memUsageKb: 2614099.968, + }, + { + cpuPercent: 0.401, + memUsageKb: 2614099.968, + }, + { + cpuPercent: 0.3481, + memUsageKb: 2618294.272, + }, + { + cpuPercent: 0.3481, + memUsageKb: 2618294.272, + }, + { + cpuPercent: 0.2988, + memUsageKb: 2619342.848, + }, + { + cpuPercent: 0.2988, + memUsageKb: 2619342.848, + }, + { + cpuPercent: 0.3429, + memUsageKb: 2621440, + }, + { + cpuPercent: 0.3429, + memUsageKb: 2621440, + }, + { + cpuPercent: 0.3234, + memUsageKb: 2622488.576, + }, + { + cpuPercent: 0.3234, + memUsageKb: 2622488.576, + }, + { + cpuPercent: 0.31920000000000004, + memUsageKb: 2624585.728, + }, + { + cpuPercent: 0.31920000000000004, + memUsageKb: 2624585.728, + }, + { + cpuPercent: 0.29460000000000003, + memUsageKb: 2626682.88, + }, + { + cpuPercent: 0.29460000000000003, + memUsageKb: 2626682.88, + }, + { + cpuPercent: 0.308, + memUsageKb: 2627731.456, + }, + { + cpuPercent: 0.308, + memUsageKb: 2627731.456, + }, + { + cpuPercent: 0.3148, + memUsageKb: 2629828.608, + }, + { + cpuPercent: 0.3148, + memUsageKb: 2629828.608, + }, + { + cpuPercent: 0.42090000000000005, + memUsageKb: 2637168.64, + }, + { + cpuPercent: 0.42090000000000005, + memUsageKb: 2637168.64, + }, + { + cpuPercent: 0.42090000000000005, + memUsageKb: 2637168.64, + }, + { + cpuPercent: 0.3553, + memUsageKb: 2632974.336, + }, + { + cpuPercent: 0.3553, + memUsageKb: 2632974.336, + }, + { + cpuPercent: 0.3583, + memUsageKb: 2634022.912, + }, + { + cpuPercent: 0.3583, + memUsageKb: 2634022.912, + }, + { + cpuPercent: 0.35100000000000003, + memUsageKb: 2635071.488, + }, + { + cpuPercent: 0.35100000000000003, + memUsageKb: 2635071.488, + }, + { + cpuPercent: 0.3206, + memUsageKb: 2636120.064, + }, + { + cpuPercent: 0.3206, + memUsageKb: 2636120.064, + }, + { + cpuPercent: 0.3336, + memUsageKb: 2637168.64, + }, + { + cpuPercent: 0.3336, + memUsageKb: 2637168.64, + }, + { + cpuPercent: 0.3442, + memUsageKb: 2638217.216, + }, + { + cpuPercent: 0.3442, + memUsageKb: 2638217.216, + }, + { + cpuPercent: 0.3574, + memUsageKb: 2640314.368, + }, + { + cpuPercent: 0.3574, + memUsageKb: 2640314.368, + }, + { + cpuPercent: 0.3439, + memUsageKb: 2641362.944, + }, + { + cpuPercent: 0.3439, + memUsageKb: 2641362.944, + }, + { + cpuPercent: 0.3528, + memUsageKb: 2642411.52, + }, + { + cpuPercent: 0.3528, + memUsageKb: 2642411.52, + }, + { + cpuPercent: 0.4374, + memUsageKb: 2643460.096, + }, + { + cpuPercent: 0.4374, + memUsageKb: 2643460.096, + }, + { + cpuPercent: 0.3215, + memUsageKb: 2644508.672, + }, + { + cpuPercent: 0.3215, + memUsageKb: 2644508.672, + }, + { + cpuPercent: 0.32020000000000004, + memUsageKb: 2645557.248, + }, + { + cpuPercent: 0.32020000000000004, + memUsageKb: 2645557.248, + }, + { + cpuPercent: 0.3319, + memUsageKb: 2647654.4, + }, + { + cpuPercent: 0.3319, + memUsageKb: 2647654.4, + }, + { + cpuPercent: 0.3472, + memUsageKb: 2647654.4, + }, + { + cpuPercent: 0.3472, + memUsageKb: 2647654.4, + }, + { + cpuPercent: 0.3552, + memUsageKb: 2648702.976, + }, + { + cpuPercent: 0.3552, + memUsageKb: 2648702.976, + }, + { + cpuPercent: 0.3461, + memUsageKb: 2649751.552, + }, + { + cpuPercent: 0.3461, + memUsageKb: 2649751.552, + }, + { + cpuPercent: 0.3461, + memUsageKb: 2649751.552, + }, + { + cpuPercent: 0.3389, + memUsageKb: 2649751.552, + }, + { + cpuPercent: 0.3389, + memUsageKb: 2649751.552, + }, + { + cpuPercent: 0.3189, + memUsageKb: 2650800.128, + }, + { + cpuPercent: 0.3189, + memUsageKb: 2650800.128, + }, + { + cpuPercent: 0.3342, + memUsageKb: 2651848.704, + }, + { + cpuPercent: 0.3342, + memUsageKb: 2651848.704, + }, + { + cpuPercent: 0.2829, + memUsageKb: 2649751.552, + }, + { + cpuPercent: 0.2829, + memUsageKb: 2649751.552, + }, + { + cpuPercent: 0.0017000000000000001, + memUsageKb: 2649751.552, + }, + { + cpuPercent: 0.0017000000000000001, + memUsageKb: 2649751.552, + }, + { + cpuPercent: 0.0015, + memUsageKb: 2649751.552, + }, + { + cpuPercent: 0.0015, + memUsageKb: 2649751.552, + }, + { + cpuPercent: 0.0015, + memUsageKb: 2649751.552, + }, + { + cpuPercent: 0.0015, + memUsageKb: 2649751.552, + }, + { + cpuPercent: 0.0014000000000000002, + memUsageKb: 2649751.552, + }, + { + cpuPercent: 0.0014000000000000002, + memUsageKb: 2649751.552, + }, + { + cpuPercent: 0.0014000000000000002, + memUsageKb: 2649751.552, + }, + { + cpuPercent: 0.0014000000000000002, + memUsageKb: 2649751.552, + }, + { + cpuPercent: 0.0013, + memUsageKb: 2649751.552, + }, + { + cpuPercent: null, + memUsageKb: 0, + }, + ], + "supabase-auth": [ + { + cpuPercent: 0, + memUsageKb: 34775.04, + }, + { + cpuPercent: 0, + memUsageKb: 34775.04, + }, + { + cpuPercent: 0.04769999999999999, + memUsageKb: 34785.28, + }, + { + cpuPercent: 0.04769999999999999, + memUsageKb: 34785.28, + }, + { + cpuPercent: 0.008199999999999999, + memUsageKb: 34836.48, + }, + { + cpuPercent: 0.008199999999999999, + memUsageKb: 34836.48, + }, + { + cpuPercent: 0, + memUsageKb: 34836.48, + }, + { + cpuPercent: 0, + memUsageKb: 34836.48, + }, + { + cpuPercent: 0, + memUsageKb: 34836.48, + }, + { + cpuPercent: 0, + memUsageKb: 34836.48, + }, + { + cpuPercent: 0.0181, + memUsageKb: 34836.48, + }, + { + cpuPercent: 0.0181, + memUsageKb: 34836.48, + }, + { + cpuPercent: 0.0001, + memUsageKb: 34836.48, + }, + { + cpuPercent: 0.0001, + memUsageKb: 34836.48, + }, + { + cpuPercent: 0, + memUsageKb: 34836.48, + }, + { + cpuPercent: 0, + memUsageKb: 34836.48, + }, + { + cpuPercent: 0, + memUsageKb: 34836.48, + }, + { + cpuPercent: 0, + memUsageKb: 34836.48, + }, + { + cpuPercent: 0, + memUsageKb: 34836.48, + }, + { + cpuPercent: 0, + memUsageKb: 34836.48, + }, + { + cpuPercent: 0.0197, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0.0197, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0.0194, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0.0194, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0.021, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0.021, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34846.72, + }, + { + cpuPercent: 0, + memUsageKb: 34846.72, + }, + { + cpuPercent: 0.0197, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0.0197, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0.0187, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0.0187, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0.0189, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0.0189, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34846.72, + }, + { + cpuPercent: 0, + memUsageKb: 34846.72, + }, + { + cpuPercent: 0, + memUsageKb: 34846.72, + }, + { + cpuPercent: 0, + memUsageKb: 34846.72, + }, + { + cpuPercent: 0, + memUsageKb: 34846.72, + }, + { + cpuPercent: 0, + memUsageKb: 34846.72, + }, + { + cpuPercent: 0, + memUsageKb: 34846.72, + }, + { + cpuPercent: 0, + memUsageKb: 34846.72, + }, + { + cpuPercent: 0.0184, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0.0184, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0.0187, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0.0187, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0.0202, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0.0202, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0, + memUsageKb: 34856.96, + }, + { + cpuPercent: 0.0173, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0.0173, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0.019799999999999998, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0.019799999999999998, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0.0197, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0.0197, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0.0197, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0.0208, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0.0208, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0.0222, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0.0222, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0.0177, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0.0177, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0, + memUsageKb: 34887.68, + }, + { + cpuPercent: 0.018600000000000002, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0.018600000000000002, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0.018600000000000002, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0.016, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0.016, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0.0002, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0.0002, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0.019, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0.019, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0.0189, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0.0189, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0.0204, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0.0204, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0.019799999999999998, + memUsageKb: 34918.4, + }, + { + cpuPercent: 0.019799999999999998, + memUsageKb: 34918.4, + }, + { + cpuPercent: 0, + memUsageKb: 34918.4, + }, + { + cpuPercent: 0, + memUsageKb: 34918.4, + }, + { + cpuPercent: 0, + memUsageKb: 34918.4, + }, + { + cpuPercent: 0, + memUsageKb: 34918.4, + }, + { + cpuPercent: 0, + memUsageKb: 34918.4, + }, + { + cpuPercent: 0, + memUsageKb: 34918.4, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0, + memUsageKb: 34908.16, + }, + { + cpuPercent: 0.022000000000000002, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0.022000000000000002, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0.0196, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0.0196, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0.023700000000000002, + memUsageKb: 35031.04, + }, + { + cpuPercent: 0.023700000000000002, + memUsageKb: 35031.04, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0.0184, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0.0184, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0.018000000000000002, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0.018000000000000002, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0.0206, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0.0206, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0.0208, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0.0208, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0.0151, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0.0151, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0.0001, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0.0001, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0.0127, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0.0127, + memUsageKb: 34959.36, + }, + { + cpuPercent: 0, + memUsageKb: 34959.36, + }, + { + cpuPercent: null, + memUsageKb: 0, + }, + ], + "supabase-rest": [ + { + cpuPercent: 0.0023, + memUsageKb: 122572.8, + }, + { + cpuPercent: 0.0023, + memUsageKb: 122572.8, + }, + { + cpuPercent: 0.0007000000000000001, + memUsageKb: 122572.8, + }, + { + cpuPercent: 0.0007000000000000001, + memUsageKb: 122572.8, + }, + { + cpuPercent: 0.008, + memUsageKb: 122572.8, + }, + { + cpuPercent: 0.008, + memUsageKb: 122572.8, + }, + { + cpuPercent: 5.4510000000000005, + memUsageKb: 123494.4, + }, + { + cpuPercent: 5.4510000000000005, + memUsageKb: 123494.4, + }, + { + cpuPercent: 7.515, + memUsageKb: 123801.6, + }, + { + cpuPercent: 7.515, + memUsageKb: 123801.6, + }, + { + cpuPercent: 6.941599999999999, + memUsageKb: 124108.8, + }, + { + cpuPercent: 6.941599999999999, + memUsageKb: 124108.8, + }, + { + cpuPercent: 7.1073, + memUsageKb: 124313.6, + }, + { + cpuPercent: 7.1073, + memUsageKb: 124313.6, + }, + { + cpuPercent: 6.7280999999999995, + memUsageKb: 123187.2, + }, + { + cpuPercent: 6.7280999999999995, + memUsageKb: 123187.2, + }, + { + cpuPercent: 6.824299999999999, + memUsageKb: 123289.6, + }, + { + cpuPercent: 6.824299999999999, + memUsageKb: 123289.6, + }, + { + cpuPercent: 7.532, + memUsageKb: 123289.6, + }, + { + cpuPercent: 7.532, + memUsageKb: 123289.6, + }, + { + cpuPercent: 4.9386, + memUsageKb: 123801.6, + }, + { + cpuPercent: 4.9386, + memUsageKb: 123801.6, + }, + { + cpuPercent: 6.198099999999999, + memUsageKb: 124518.4, + }, + { + cpuPercent: 6.198099999999999, + memUsageKb: 124518.4, + }, + { + cpuPercent: 6.644500000000001, + memUsageKb: 124825.6, + }, + { + cpuPercent: 6.644500000000001, + memUsageKb: 124825.6, + }, + { + cpuPercent: 6.5789, + memUsageKb: 125030.4, + }, + { + cpuPercent: 6.5789, + memUsageKb: 125030.4, + }, + { + cpuPercent: 6.8591, + memUsageKb: 124620.8, + }, + { + cpuPercent: 6.8591, + memUsageKb: 124620.8, + }, + { + cpuPercent: 6.5525, + memUsageKb: 124211.2, + }, + { + cpuPercent: 6.5525, + memUsageKb: 124211.2, + }, + { + cpuPercent: 7.3416, + memUsageKb: 124006.4, + }, + { + cpuPercent: 7.3416, + memUsageKb: 124006.4, + }, + { + cpuPercent: 7.3687000000000005, + memUsageKb: 124620.8, + }, + { + cpuPercent: 7.3687000000000005, + memUsageKb: 124620.8, + }, + { + cpuPercent: 7.0484, + memUsageKb: 124313.6, + }, + { + cpuPercent: 7.0484, + memUsageKb: 124313.6, + }, + { + cpuPercent: 6.1887, + memUsageKb: 124313.6, + }, + { + cpuPercent: 6.1887, + memUsageKb: 124313.6, + }, + { + cpuPercent: 6.5864, + memUsageKb: 123904, + }, + { + cpuPercent: 6.5864, + memUsageKb: 123904, + }, + { + cpuPercent: 6.9802, + memUsageKb: 123699.2, + }, + { + cpuPercent: 6.9802, + memUsageKb: 123699.2, + }, + { + cpuPercent: 7.2010000000000005, + memUsageKb: 124108.8, + }, + { + cpuPercent: 7.2010000000000005, + memUsageKb: 124108.8, + }, + { + cpuPercent: 6.9484, + memUsageKb: 123801.6, + }, + { + cpuPercent: 6.9484, + memUsageKb: 123801.6, + }, + { + cpuPercent: 6.395599999999999, + memUsageKb: 123392, + }, + { + cpuPercent: 6.395599999999999, + memUsageKb: 123392, + }, + { + cpuPercent: 6.395599999999999, + memUsageKb: 123392, + }, + { + cpuPercent: 7.3609, + memUsageKb: 125235.2, + }, + { + cpuPercent: 7.3609, + memUsageKb: 125235.2, + }, + { + cpuPercent: 6.316599999999999, + memUsageKb: 124006.4, + }, + { + cpuPercent: 6.316599999999999, + memUsageKb: 124006.4, + }, + { + cpuPercent: 7.896, + memUsageKb: 124928, + }, + { + cpuPercent: 7.896, + memUsageKb: 124928, + }, + { + cpuPercent: 6.6251, + memUsageKb: 124928, + }, + { + cpuPercent: 6.6251, + memUsageKb: 124928, + }, + { + cpuPercent: 6.3812999999999995, + memUsageKb: 124825.6, + }, + { + cpuPercent: 6.3812999999999995, + memUsageKb: 124825.6, + }, + { + cpuPercent: 6.4813, + memUsageKb: 124518.4, + }, + { + cpuPercent: 6.4813, + memUsageKb: 124518.4, + }, + { + cpuPercent: 7.0246, + memUsageKb: 124006.4, + }, + { + cpuPercent: 7.0246, + memUsageKb: 124006.4, + }, + { + cpuPercent: 6.5925, + memUsageKb: 124723.2, + }, + { + cpuPercent: 6.5925, + memUsageKb: 124723.2, + }, + { + cpuPercent: 6.704, + memUsageKb: 124211.2, + }, + { + cpuPercent: 6.704, + memUsageKb: 124211.2, + }, + { + cpuPercent: 6.9665, + memUsageKb: 124620.8, + }, + { + cpuPercent: 6.9665, + memUsageKb: 124620.8, + }, + { + cpuPercent: 7.0328, + memUsageKb: 124723.2, + }, + { + cpuPercent: 7.0328, + memUsageKb: 124723.2, + }, + { + cpuPercent: 7.056699999999999, + memUsageKb: 124928, + }, + { + cpuPercent: 7.056699999999999, + memUsageKb: 124928, + }, + { + cpuPercent: 6.902200000000001, + memUsageKb: 124211.2, + }, + { + cpuPercent: 6.902200000000001, + memUsageKb: 124211.2, + }, + { + cpuPercent: 7.3953, + memUsageKb: 124006.4, + }, + { + cpuPercent: 7.3953, + memUsageKb: 124006.4, + }, + { + cpuPercent: 7.3386000000000005, + memUsageKb: 125030.4, + }, + { + cpuPercent: 7.3386000000000005, + memUsageKb: 125030.4, + }, + { + cpuPercent: 6.5895, + memUsageKb: 125030.4, + }, + { + cpuPercent: 6.5895, + memUsageKb: 125030.4, + }, + { + cpuPercent: 7.1568, + memUsageKb: 123904, + }, + { + cpuPercent: 7.1568, + memUsageKb: 123904, + }, + { + cpuPercent: 7.0473, + memUsageKb: 124313.6, + }, + { + cpuPercent: 7.0473, + memUsageKb: 124313.6, + }, + { + cpuPercent: 6.8214999999999995, + memUsageKb: 124313.6, + }, + { + cpuPercent: 6.8214999999999995, + memUsageKb: 124313.6, + }, + { + cpuPercent: 6.4877, + memUsageKb: 124313.6, + }, + { + cpuPercent: 6.4877, + memUsageKb: 124313.6, + }, + { + cpuPercent: 6.4877, + memUsageKb: 124313.6, + }, + { + cpuPercent: 7.285900000000001, + memUsageKb: 124723.2, + }, + { + cpuPercent: 7.285900000000001, + memUsageKb: 124723.2, + }, + { + cpuPercent: 7.0164, + memUsageKb: 124928, + }, + { + cpuPercent: 7.0164, + memUsageKb: 124928, + }, + { + cpuPercent: 7.8789, + memUsageKb: 125030.4, + }, + { + cpuPercent: 7.8789, + memUsageKb: 125030.4, + }, + { + cpuPercent: 6.8986, + memUsageKb: 124006.4, + }, + { + cpuPercent: 6.8986, + memUsageKb: 124006.4, + }, + { + cpuPercent: 7.1722, + memUsageKb: 124313.6, + }, + { + cpuPercent: 7.1722, + memUsageKb: 124313.6, + }, + { + cpuPercent: 7.0626999999999995, + memUsageKb: 124620.8, + }, + { + cpuPercent: 7.0626999999999995, + memUsageKb: 124620.8, + }, + { + cpuPercent: 6.653300000000001, + memUsageKb: 124108.8, + }, + { + cpuPercent: 6.653300000000001, + memUsageKb: 124108.8, + }, + { + cpuPercent: 7.4682, + memUsageKb: 124620.8, + }, + { + cpuPercent: 7.4682, + memUsageKb: 124620.8, + }, + { + cpuPercent: 6.981599999999999, + memUsageKb: 124211.2, + }, + { + cpuPercent: 6.981599999999999, + memUsageKb: 124211.2, + }, + { + cpuPercent: 7.1088, + memUsageKb: 125235.2, + }, + { + cpuPercent: 7.1088, + memUsageKb: 125235.2, + }, + { + cpuPercent: 6.5026, + memUsageKb: 124416, + }, + { + cpuPercent: 6.5026, + memUsageKb: 124416, + }, + { + cpuPercent: 7.13, + memUsageKb: 123904, + }, + { + cpuPercent: 7.13, + memUsageKb: 123904, + }, + { + cpuPercent: 6.8833, + memUsageKb: 123392, + }, + { + cpuPercent: 6.8833, + memUsageKb: 123392, + }, + { + cpuPercent: 7.424099999999999, + memUsageKb: 124108.8, + }, + { + cpuPercent: 7.424099999999999, + memUsageKb: 124108.8, + }, + { + cpuPercent: 7.0035, + memUsageKb: 124928, + }, + { + cpuPercent: 7.0035, + memUsageKb: 124928, + }, + { + cpuPercent: 6.6013, + memUsageKb: 124313.6, + }, + { + cpuPercent: 6.6013, + memUsageKb: 124313.6, + }, + { + cpuPercent: 5.494199999999999, + memUsageKb: 124211.2, + }, + { + cpuPercent: 5.494199999999999, + memUsageKb: 124211.2, + }, + { + cpuPercent: 6.8574, + memUsageKb: 123494.4, + }, + { + cpuPercent: 6.8574, + memUsageKb: 123494.4, + }, + { + cpuPercent: 7.738300000000001, + memUsageKb: 124108.8, + }, + { + cpuPercent: 7.738300000000001, + memUsageKb: 124108.8, + }, + { + cpuPercent: 6.9263, + memUsageKb: 124006.4, + }, + { + cpuPercent: 6.9263, + memUsageKb: 124006.4, + }, + { + cpuPercent: 6.4091, + memUsageKb: 123801.6, + }, + { + cpuPercent: 6.4091, + memUsageKb: 123801.6, + }, + { + cpuPercent: 7.27, + memUsageKb: 123494.4, + }, + { + cpuPercent: 7.27, + memUsageKb: 123494.4, + }, + { + cpuPercent: 7.27, + memUsageKb: 123494.4, + }, + { + cpuPercent: 7.5438, + memUsageKb: 123596.8, + }, + { + cpuPercent: 7.5438, + memUsageKb: 123596.8, + }, + { + cpuPercent: 6.8561000000000005, + memUsageKb: 123904, + }, + { + cpuPercent: 6.8561000000000005, + memUsageKb: 123904, + }, + { + cpuPercent: 7.2033000000000005, + memUsageKb: 123801.6, + }, + { + cpuPercent: 7.2033000000000005, + memUsageKb: 123801.6, + }, + { + cpuPercent: 7.889600000000001, + memUsageKb: 124825.6, + }, + { + cpuPercent: 7.889600000000001, + memUsageKb: 124825.6, + }, + { + cpuPercent: 6.5516, + memUsageKb: 124108.8, + }, + { + cpuPercent: 6.5516, + memUsageKb: 124108.8, + }, + { + cpuPercent: 7.3026, + memUsageKb: 123801.6, + }, + { + cpuPercent: 7.3026, + memUsageKb: 123801.6, + }, + { + cpuPercent: 6.9462, + memUsageKb: 124006.4, + }, + { + cpuPercent: 6.9462, + memUsageKb: 124006.4, + }, + { + cpuPercent: 6.8298000000000005, + memUsageKb: 123392, + }, + { + cpuPercent: 6.8298000000000005, + memUsageKb: 123392, + }, + { + cpuPercent: 7.132000000000001, + memUsageKb: 124211.2, + }, + { + cpuPercent: 7.132000000000001, + memUsageKb: 124211.2, + }, + { + cpuPercent: 6.864199999999999, + memUsageKb: 124928, + }, + { + cpuPercent: 6.864199999999999, + memUsageKb: 124928, + }, + { + cpuPercent: 6.7101999999999995, + memUsageKb: 124211.2, + }, + { + cpuPercent: 6.7101999999999995, + memUsageKb: 124211.2, + }, + { + cpuPercent: 6.931799999999999, + memUsageKb: 124108.8, + }, + { + cpuPercent: 6.931799999999999, + memUsageKb: 124108.8, + }, + { + cpuPercent: 6.9380999999999995, + memUsageKb: 123392, + }, + { + cpuPercent: 6.9380999999999995, + memUsageKb: 123392, + }, + { + cpuPercent: 6.3809000000000005, + memUsageKb: 124108.8, + }, + { + cpuPercent: 6.3809000000000005, + memUsageKb: 124108.8, + }, + { + cpuPercent: 6.9635, + memUsageKb: 123699.2, + }, + { + cpuPercent: 6.9635, + memUsageKb: 123699.2, + }, + { + cpuPercent: 6.9297, + memUsageKb: 124006.4, + }, + { + cpuPercent: 6.9297, + memUsageKb: 124006.4, + }, + { + cpuPercent: 6.8013, + memUsageKb: 124108.8, + }, + { + cpuPercent: 6.8013, + memUsageKb: 124108.8, + }, + { + cpuPercent: 6.601, + memUsageKb: 123392, + }, + { + cpuPercent: 6.601, + memUsageKb: 123392, + }, + { + cpuPercent: 7.3151, + memUsageKb: 124211.2, + }, + { + cpuPercent: 7.3151, + memUsageKb: 124211.2, + }, + { + cpuPercent: 6.898, + memUsageKb: 124211.2, + }, + { + cpuPercent: 6.898, + memUsageKb: 124211.2, + }, + { + cpuPercent: 6.898, + memUsageKb: 124211.2, + }, + { + cpuPercent: 6.9572, + memUsageKb: 124108.8, + }, + { + cpuPercent: 6.9572, + memUsageKb: 124108.8, + }, + { + cpuPercent: 6.636799999999999, + memUsageKb: 123801.6, + }, + { + cpuPercent: 6.636799999999999, + memUsageKb: 123801.6, + }, + { + cpuPercent: 6.5948, + memUsageKb: 124313.6, + }, + { + cpuPercent: 6.5948, + memUsageKb: 124313.6, + }, + { + cpuPercent: 6.635, + memUsageKb: 123801.6, + }, + { + cpuPercent: 6.635, + memUsageKb: 123801.6, + }, + { + cpuPercent: 7.0016, + memUsageKb: 123699.2, + }, + { + cpuPercent: 7.0016, + memUsageKb: 123699.2, + }, + { + cpuPercent: 6.9712, + memUsageKb: 124211.2, + }, + { + cpuPercent: 6.9712, + memUsageKb: 124211.2, + }, + { + cpuPercent: 6.7277, + memUsageKb: 123392, + }, + { + cpuPercent: 6.7277, + memUsageKb: 123392, + }, + { + cpuPercent: 6.7533, + memUsageKb: 123801.6, + }, + { + cpuPercent: 6.7533, + memUsageKb: 123801.6, + }, + { + cpuPercent: 7.2961, + memUsageKb: 123392, + }, + { + cpuPercent: 7.2961, + memUsageKb: 123392, + }, + { + cpuPercent: 6.5612, + memUsageKb: 123801.6, + }, + { + cpuPercent: 6.5612, + memUsageKb: 123801.6, + }, + { + cpuPercent: 5.6952, + memUsageKb: 123699.2, + }, + { + cpuPercent: 5.6952, + memUsageKb: 123699.2, + }, + { + cpuPercent: 6.3475, + memUsageKb: 124518.4, + }, + { + cpuPercent: 6.3475, + memUsageKb: 124518.4, + }, + { + cpuPercent: 6.8290999999999995, + memUsageKb: 124006.4, + }, + { + cpuPercent: 6.8290999999999995, + memUsageKb: 124006.4, + }, + { + cpuPercent: 6.9786, + memUsageKb: 124313.6, + }, + { + cpuPercent: 6.9786, + memUsageKb: 124313.6, + }, + { + cpuPercent: 6.6979999999999995, + memUsageKb: 124518.4, + }, + { + cpuPercent: 6.6979999999999995, + memUsageKb: 124518.4, + }, + { + cpuPercent: 7.2346, + memUsageKb: 124416, + }, + { + cpuPercent: 7.2346, + memUsageKb: 124416, + }, + { + cpuPercent: 6.916799999999999, + memUsageKb: 124416, + }, + { + cpuPercent: 6.916799999999999, + memUsageKb: 124416, + }, + { + cpuPercent: 6.6924, + memUsageKb: 125440, + }, + { + cpuPercent: 6.6924, + memUsageKb: 125440, + }, + { + cpuPercent: 7.0098, + memUsageKb: 125542.4, + }, + { + cpuPercent: 7.0098, + memUsageKb: 125542.4, + }, + { + cpuPercent: 7.2993, + memUsageKb: 124518.4, + }, + { + cpuPercent: 7.2993, + memUsageKb: 124518.4, + }, + { + cpuPercent: 7.369800000000001, + memUsageKb: 125235.2, + }, + { + cpuPercent: 7.369800000000001, + memUsageKb: 125235.2, + }, + { + cpuPercent: 6.4396, + memUsageKb: 124723.2, + }, + { + cpuPercent: 6.4396, + memUsageKb: 124723.2, + }, + { + cpuPercent: 6.4396, + memUsageKb: 124723.2, + }, + { + cpuPercent: 6.945399999999999, + memUsageKb: 124518.4, + }, + { + cpuPercent: 6.945399999999999, + memUsageKb: 124518.4, + }, + { + cpuPercent: 6.8262, + memUsageKb: 125337.6, + }, + { + cpuPercent: 6.8262, + memUsageKb: 125337.6, + }, + { + cpuPercent: 6.7144, + memUsageKb: 126976, + }, + { + cpuPercent: 6.7144, + memUsageKb: 126976, + }, + { + cpuPercent: 6.5632, + memUsageKb: 125644.8, + }, + { + cpuPercent: 6.5632, + memUsageKb: 125644.8, + }, + { + cpuPercent: 6.5489, + memUsageKb: 125747.2, + }, + { + cpuPercent: 6.5489, + memUsageKb: 125747.2, + }, + { + cpuPercent: 7.063300000000001, + memUsageKb: 125235.2, + }, + { + cpuPercent: 7.063300000000001, + memUsageKb: 125235.2, + }, + { + cpuPercent: 7.2982000000000005, + memUsageKb: 124825.6, + }, + { + cpuPercent: 7.2982000000000005, + memUsageKb: 124825.6, + }, + { + cpuPercent: 7.100599999999999, + memUsageKb: 126054.4, + }, + { + cpuPercent: 7.100599999999999, + memUsageKb: 126054.4, + }, + { + cpuPercent: 6.265499999999999, + memUsageKb: 124825.6, + }, + { + cpuPercent: 6.265499999999999, + memUsageKb: 124825.6, + }, + { + cpuPercent: 6.5175, + memUsageKb: 125644.8, + }, + { + cpuPercent: 6.5175, + memUsageKb: 125644.8, + }, + { + cpuPercent: 6.8184000000000005, + memUsageKb: 125440, + }, + { + cpuPercent: 6.8184000000000005, + memUsageKb: 125440, + }, + { + cpuPercent: 6.863200000000001, + memUsageKb: 125132.8, + }, + { + cpuPercent: 6.863200000000001, + memUsageKb: 125132.8, + }, + { + cpuPercent: 5.761699999999999, + memUsageKb: 125132.8, + }, + { + cpuPercent: 5.761699999999999, + memUsageKb: 125132.8, + }, + { + cpuPercent: 7.2849, + memUsageKb: 124723.2, + }, + { + cpuPercent: 7.2849, + memUsageKb: 124723.2, + }, + { + cpuPercent: 7.2735, + memUsageKb: 124723.2, + }, + { + cpuPercent: 7.2735, + memUsageKb: 124723.2, + }, + { + cpuPercent: 7.450399999999999, + memUsageKb: 125337.6, + }, + { + cpuPercent: 7.450399999999999, + memUsageKb: 125337.6, + }, + { + cpuPercent: 7.306900000000001, + memUsageKb: 125440, + }, + { + cpuPercent: 7.306900000000001, + memUsageKb: 125440, + }, + { + cpuPercent: 7.0162, + memUsageKb: 124928, + }, + { + cpuPercent: 7.0162, + memUsageKb: 124928, + }, + { + cpuPercent: 6.882899999999999, + memUsageKb: 125132.8, + }, + { + cpuPercent: 6.882899999999999, + memUsageKb: 125132.8, + }, + { + cpuPercent: 7.2808, + memUsageKb: 124620.8, + }, + { + cpuPercent: 7.2808, + memUsageKb: 124620.8, + }, + { + cpuPercent: 7.2808, + memUsageKb: 124620.8, + }, + { + cpuPercent: 6.579400000000001, + memUsageKb: 124825.6, + }, + { + cpuPercent: 6.579400000000001, + memUsageKb: 124825.6, + }, + { + cpuPercent: 7.4912, + memUsageKb: 125235.2, + }, + { + cpuPercent: 7.4912, + memUsageKb: 125235.2, + }, + { + cpuPercent: 7.0318, + memUsageKb: 125337.6, + }, + { + cpuPercent: 7.0318, + memUsageKb: 125337.6, + }, + { + cpuPercent: 7.333600000000001, + memUsageKb: 125644.8, + }, + { + cpuPercent: 7.333600000000001, + memUsageKb: 125644.8, + }, + { + cpuPercent: 7.358300000000001, + memUsageKb: 125132.8, + }, + { + cpuPercent: 7.358300000000001, + memUsageKb: 125132.8, + }, + { + cpuPercent: 6.936, + memUsageKb: 125849.6, + }, + { + cpuPercent: 6.936, + memUsageKb: 125849.6, + }, + { + cpuPercent: 7.0741, + memUsageKb: 124928, + }, + { + cpuPercent: 7.0741, + memUsageKb: 124928, + }, + { + cpuPercent: 7.0439, + memUsageKb: 124620.8, + }, + { + cpuPercent: 7.0439, + memUsageKb: 124620.8, + }, + { + cpuPercent: 7.263, + memUsageKb: 125337.6, + }, + { + cpuPercent: 7.263, + memUsageKb: 125337.6, + }, + { + cpuPercent: 7.0471, + memUsageKb: 124928, + }, + { + cpuPercent: 7.0471, + memUsageKb: 124928, + }, + { + cpuPercent: 7.1725, + memUsageKb: 124518.4, + }, + { + cpuPercent: 7.1725, + memUsageKb: 124518.4, + }, + { + cpuPercent: 7.3908000000000005, + memUsageKb: 125440, + }, + { + cpuPercent: 7.3908000000000005, + memUsageKb: 125440, + }, + { + cpuPercent: 7.0539, + memUsageKb: 124518.4, + }, + { + cpuPercent: 7.0539, + memUsageKb: 124518.4, + }, + { + cpuPercent: 7.1299, + memUsageKb: 124928, + }, + { + cpuPercent: 7.1299, + memUsageKb: 124928, + }, + { + cpuPercent: 6.9987, + memUsageKb: 124620.8, + }, + { + cpuPercent: 6.9987, + memUsageKb: 124620.8, + }, + { + cpuPercent: 6.1738, + memUsageKb: 124928, + }, + { + cpuPercent: 6.1738, + memUsageKb: 124928, + }, + { + cpuPercent: 6.4686, + memUsageKb: 124825.6, + }, + { + cpuPercent: 6.4686, + memUsageKb: 124825.6, + }, + { + cpuPercent: 6.4686, + memUsageKb: 124825.6, + }, + { + cpuPercent: 6.6322, + memUsageKb: 123699.2, + }, + { + cpuPercent: 6.6322, + memUsageKb: 123699.2, + }, + { + cpuPercent: 7.3991999999999996, + memUsageKb: 124620.8, + }, + { + cpuPercent: 7.3991999999999996, + memUsageKb: 124620.8, + }, + { + cpuPercent: 6.7237, + memUsageKb: 124416, + }, + { + cpuPercent: 6.7237, + memUsageKb: 124416, + }, + { + cpuPercent: 3.0658, + memUsageKb: 123392, + }, + { + cpuPercent: 3.0658, + memUsageKb: 123392, + }, + { + cpuPercent: 0.0062, + memUsageKb: 123392, + }, + { + cpuPercent: 0.0062, + memUsageKb: 123392, + }, + { + cpuPercent: 0.004699999999999999, + memUsageKb: 123392, + }, + { + cpuPercent: 0.004699999999999999, + memUsageKb: 123392, + }, + { + cpuPercent: 0.0049, + memUsageKb: 123392, + }, + { + cpuPercent: 0.0049, + memUsageKb: 123392, + }, + { + cpuPercent: 0.0046, + memUsageKb: 123392, + }, + { + cpuPercent: 0.0046, + memUsageKb: 123392, + }, + { + cpuPercent: 0.0052, + memUsageKb: 123392, + }, + { + cpuPercent: 0.0052, + memUsageKb: 123392, + }, + { + cpuPercent: 0.0049, + memUsageKb: 123392, + }, + { + cpuPercent: null, + memUsageKb: 0, + }, + ], + "supabase-analytics": [ + { + cpuPercent: 0.0003, + memUsageKb: 2079326.208, + }, + { + cpuPercent: 0.0003, + memUsageKb: 2079326.208, + }, + { + cpuPercent: 0.34990000000000004, + memUsageKb: 2079326.208, + }, + { + cpuPercent: 0.34990000000000004, + memUsageKb: 2079326.208, + }, + { + cpuPercent: 0.3788, + memUsageKb: 2079326.208, + }, + { + cpuPercent: 0.3788, + memUsageKb: 2079326.208, + }, + { + cpuPercent: 0.2747, + memUsageKb: 2079326.208, + }, + { + cpuPercent: 0.2747, + memUsageKb: 2079326.208, + }, + { + cpuPercent: 0.5447, + memUsageKb: 2087714.816, + }, + { + cpuPercent: 0.5447, + memUsageKb: 2087714.816, + }, + { + cpuPercent: 0.6021, + memUsageKb: 2098200.576, + }, + { + cpuPercent: 0.6021, + memUsageKb: 2098200.576, + }, + { + cpuPercent: 0.5385, + memUsageKb: 2104492.032, + }, + { + cpuPercent: 0.5385, + memUsageKb: 2104492.032, + }, + { + cpuPercent: 0.55, + memUsageKb: 2111832.064, + }, + { + cpuPercent: 0.55, + memUsageKb: 2111832.064, + }, + { + cpuPercent: 0.6314, + memUsageKb: 2146435.072, + }, + { + cpuPercent: 0.6314, + memUsageKb: 2146435.072, + }, + { + cpuPercent: 0.5783, + memUsageKb: 1977614.336, + }, + { + cpuPercent: 0.5783, + memUsageKb: 1977614.336, + }, + { + cpuPercent: 0.6244, + memUsageKb: 1946157.056, + }, + { + cpuPercent: 0.6244, + memUsageKb: 1946157.056, + }, + { + cpuPercent: 0.5526, + memUsageKb: 1983905.792, + }, + { + cpuPercent: 0.5526, + memUsageKb: 1983905.792, + }, + { + cpuPercent: 0.5512, + memUsageKb: 1944059.904, + }, + { + cpuPercent: 0.5512, + memUsageKb: 1944059.904, + }, + { + cpuPercent: 0.5717, + memUsageKb: 1956642.816, + }, + { + cpuPercent: 0.5717, + memUsageKb: 1956642.816, + }, + { + cpuPercent: 0.6275999999999999, + memUsageKb: 1990197.248, + }, + { + cpuPercent: 0.6275999999999999, + memUsageKb: 1990197.248, + }, + { + cpuPercent: 0.6591, + memUsageKb: 1996488.704, + }, + { + cpuPercent: 0.6591, + memUsageKb: 1996488.704, + }, + { + cpuPercent: 0.7243999999999999, + memUsageKb: 2015363.072, + }, + { + cpuPercent: 0.7243999999999999, + memUsageKb: 2015363.072, + }, + { + cpuPercent: 0.5687, + memUsageKb: 2060451.84, + }, + { + cpuPercent: 0.5687, + memUsageKb: 2060451.84, + }, + { + cpuPercent: 0.6327, + memUsageKb: 2060451.84, + }, + { + cpuPercent: 0.6327, + memUsageKb: 2060451.84, + }, + { + cpuPercent: 0.6433, + memUsageKb: 2068840.448, + }, + { + cpuPercent: 0.6433, + memUsageKb: 2068840.448, + }, + { + cpuPercent: 0.713, + memUsageKb: 2027945.984, + }, + { + cpuPercent: 0.713, + memUsageKb: 2027945.984, + }, + { + cpuPercent: 0.7051999999999999, + memUsageKb: 2077229.056, + }, + { + cpuPercent: 0.7051999999999999, + memUsageKb: 2077229.056, + }, + { + cpuPercent: 0.6173, + memUsageKb: 2100297.728, + }, + { + cpuPercent: 0.6173, + memUsageKb: 2100297.728, + }, + { + cpuPercent: 0.6018, + memUsageKb: 2097152, + }, + { + cpuPercent: 0.6018, + memUsageKb: 2097152, + }, + { + cpuPercent: 0.6724, + memUsageKb: 2100297.728, + }, + { + cpuPercent: 0.6724, + memUsageKb: 2100297.728, + }, + { + cpuPercent: 0.6724, + memUsageKb: 2100297.728, + }, + { + cpuPercent: 0.7857, + memUsageKb: 2168455.168, + }, + { + cpuPercent: 0.7857, + memUsageKb: 2168455.168, + }, + { + cpuPercent: 0.6920000000000001, + memUsageKb: 2146435.072, + }, + { + cpuPercent: 0.6920000000000001, + memUsageKb: 2146435.072, + }, + { + cpuPercent: 0.6822, + memUsageKb: 2140143.616, + }, + { + cpuPercent: 0.6822, + memUsageKb: 2140143.616, + }, + { + cpuPercent: 0.6543000000000001, + memUsageKb: 2144337.92, + }, + { + cpuPercent: 0.6543000000000001, + memUsageKb: 2144337.92, + }, + { + cpuPercent: 0.6986, + memUsageKb: 2146435.072, + }, + { + cpuPercent: 0.6986, + memUsageKb: 2146435.072, + }, + { + cpuPercent: 0.6836, + memUsageKb: 2104492.032, + }, + { + cpuPercent: 0.6836, + memUsageKb: 2104492.032, + }, + { + cpuPercent: 0.7555, + memUsageKb: 2174746.624, + }, + { + cpuPercent: 0.7555, + memUsageKb: 2174746.624, + }, + { + cpuPercent: 0.5365, + memUsageKb: 2188378.112, + }, + { + cpuPercent: 0.5365, + memUsageKb: 2188378.112, + }, + { + cpuPercent: 0.8197, + memUsageKb: 2174746.624, + }, + { + cpuPercent: 0.8197, + memUsageKb: 2174746.624, + }, + { + cpuPercent: 0.6381, + memUsageKb: 2165309.44, + }, + { + cpuPercent: 0.6381, + memUsageKb: 2165309.44, + }, + { + cpuPercent: 0.7326999999999999, + memUsageKb: 2181038.08, + }, + { + cpuPercent: 0.7326999999999999, + memUsageKb: 2181038.08, + }, + { + cpuPercent: 0.7961, + memUsageKb: 2129657.856, + }, + { + cpuPercent: 0.7961, + memUsageKb: 2129657.856, + }, + { + cpuPercent: 0.6969, + memUsageKb: 2136997.888, + }, + { + cpuPercent: 0.6969, + memUsageKb: 2136997.888, + }, + { + cpuPercent: 0.6526000000000001, + memUsageKb: 2227175.424, + }, + { + cpuPercent: 0.6526000000000001, + memUsageKb: 2227175.424, + }, + { + cpuPercent: 0.7486, + memUsageKb: 2247098.368, + }, + { + cpuPercent: 0.7486, + memUsageKb: 2247098.368, + }, + { + cpuPercent: 0.7058, + memUsageKb: 2250244.096, + }, + { + cpuPercent: 0.7058, + memUsageKb: 2250244.096, + }, + { + cpuPercent: 0.7474, + memUsageKb: 2256535.552, + }, + { + cpuPercent: 0.7474, + memUsageKb: 2256535.552, + }, + { + cpuPercent: 0.6092, + memUsageKb: 2249195.52, + }, + { + cpuPercent: 0.6092, + memUsageKb: 2249195.52, + }, + { + cpuPercent: 0.7182, + memUsageKb: 2254438.4, + }, + { + cpuPercent: 0.7182, + memUsageKb: 2254438.4, + }, + { + cpuPercent: 0.6047, + memUsageKb: 2188378.112, + }, + { + cpuPercent: 0.6047, + memUsageKb: 2188378.112, + }, + { + cpuPercent: 0.6047, + memUsageKb: 2188378.112, + }, + { + cpuPercent: 0.7743000000000001, + memUsageKb: 2186280.96, + }, + { + cpuPercent: 0.7743000000000001, + memUsageKb: 2186280.96, + }, + { + cpuPercent: 0.6785, + memUsageKb: 2251292.672, + }, + { + cpuPercent: 0.6785, + memUsageKb: 2251292.672, + }, + { + cpuPercent: 0.6227, + memUsageKb: 2257584.128, + }, + { + cpuPercent: 0.6227, + memUsageKb: 2257584.128, + }, + { + cpuPercent: 0.6747, + memUsageKb: 2262827.008, + }, + { + cpuPercent: 0.6747, + memUsageKb: 2262827.008, + }, + { + cpuPercent: 0.557, + memUsageKb: 2161115.136, + }, + { + cpuPercent: 0.557, + memUsageKb: 2161115.136, + }, + { + cpuPercent: 0.7212000000000001, + memUsageKb: 2167406.592, + }, + { + cpuPercent: 0.7212000000000001, + memUsageKb: 2167406.592, + }, + { + cpuPercent: 0.6074, + memUsageKb: 2144337.92, + }, + { + cpuPercent: 0.6074, + memUsageKb: 2144337.92, + }, + { + cpuPercent: 0.7323999999999999, + memUsageKb: 2148532.224, + }, + { + cpuPercent: 0.7323999999999999, + memUsageKb: 2148532.224, + }, + { + cpuPercent: 0.628, + memUsageKb: 2156920.832, + }, + { + cpuPercent: 0.628, + memUsageKb: 2156920.832, + }, + { + cpuPercent: 0.6475, + memUsageKb: 2144337.92, + }, + { + cpuPercent: 0.6475, + memUsageKb: 2144337.92, + }, + { + cpuPercent: 0.7868, + memUsageKb: 2149580.8, + }, + { + cpuPercent: 0.7868, + memUsageKb: 2149580.8, + }, + { + cpuPercent: 0.644, + memUsageKb: 2161115.136, + }, + { + cpuPercent: 0.644, + memUsageKb: 2161115.136, + }, + { + cpuPercent: 0.7264, + memUsageKb: 2265972.736, + }, + { + cpuPercent: 0.7264, + memUsageKb: 2265972.736, + }, + { + cpuPercent: 0.7322, + memUsageKb: 2198863.872, + }, + { + cpuPercent: 0.7322, + memUsageKb: 2198863.872, + }, + { + cpuPercent: 0.6534, + memUsageKb: 2177892.352, + }, + { + cpuPercent: 0.6534, + memUsageKb: 2177892.352, + }, + { + cpuPercent: 0.7541, + memUsageKb: 2190475.264, + }, + { + cpuPercent: 0.7541, + memUsageKb: 2190475.264, + }, + { + cpuPercent: 0.6814, + memUsageKb: 2186280.96, + }, + { + cpuPercent: 0.6814, + memUsageKb: 2186280.96, + }, + { + cpuPercent: 0.5697, + memUsageKb: 2182086.656, + }, + { + cpuPercent: 0.5697, + memUsageKb: 2182086.656, + }, + { + cpuPercent: 0.7661, + memUsageKb: 2183135.232, + }, + { + cpuPercent: 0.7661, + memUsageKb: 2183135.232, + }, + { + cpuPercent: 0.6331, + memUsageKb: 2171600.896, + }, + { + cpuPercent: 0.6331, + memUsageKb: 2171600.896, + }, + { + cpuPercent: 0.7271, + memUsageKb: 2183135.232, + }, + { + cpuPercent: 0.7271, + memUsageKb: 2183135.232, + }, + { + cpuPercent: 0.7996, + memUsageKb: 2188378.112, + }, + { + cpuPercent: 0.7996, + memUsageKb: 2188378.112, + }, + { + cpuPercent: 0.7996, + memUsageKb: 2188378.112, + }, + { + cpuPercent: 0.7903, + memUsageKb: 2199912.448, + }, + { + cpuPercent: 0.7903, + memUsageKb: 2199912.448, + }, + { + cpuPercent: 0.6593000000000001, + memUsageKb: 2190475.264, + }, + { + cpuPercent: 0.6593000000000001, + memUsageKb: 2190475.264, + }, + { + cpuPercent: 0.7315, + memUsageKb: 2195718.144, + }, + { + cpuPercent: 0.7315, + memUsageKb: 2195718.144, + }, + { + cpuPercent: 0.775, + memUsageKb: 2344615.936, + }, + { + cpuPercent: 0.775, + memUsageKb: 2344615.936, + }, + { + cpuPercent: 0.715, + memUsageKb: 2258632.704, + }, + { + cpuPercent: 0.715, + memUsageKb: 2258632.704, + }, + { + cpuPercent: 0.6586, + memUsageKb: 2239758.336, + }, + { + cpuPercent: 0.6586, + memUsageKb: 2239758.336, + }, + { + cpuPercent: 0.6707, + memUsageKb: 2248146.944, + }, + { + cpuPercent: 0.6707, + memUsageKb: 2248146.944, + }, + { + cpuPercent: 0.6416, + memUsageKb: 2239758.336, + }, + { + cpuPercent: 0.6416, + memUsageKb: 2239758.336, + }, + { + cpuPercent: 0.8136, + memUsageKb: 2222981.12, + }, + { + cpuPercent: 0.8136, + memUsageKb: 2222981.12, + }, + { + cpuPercent: 0.6307, + memUsageKb: 2243952.64, + }, + { + cpuPercent: 0.6307, + memUsageKb: 2243952.64, + }, + { + cpuPercent: 0.6889, + memUsageKb: 2225078.272, + }, + { + cpuPercent: 0.6889, + memUsageKb: 2225078.272, + }, + { + cpuPercent: 0.7904000000000001, + memUsageKb: 2229272.576, + }, + { + cpuPercent: 0.7904000000000001, + memUsageKb: 2229272.576, + }, + { + cpuPercent: 0.7531, + memUsageKb: 2275409.92, + }, + { + cpuPercent: 0.7531, + memUsageKb: 2275409.92, + }, + { + cpuPercent: 0.7404000000000001, + memUsageKb: 2247098.368, + }, + { + cpuPercent: 0.7404000000000001, + memUsageKb: 2247098.368, + }, + { + cpuPercent: 0.6898000000000001, + memUsageKb: 2252341.248, + }, + { + cpuPercent: 0.6898000000000001, + memUsageKb: 2252341.248, + }, + { + cpuPercent: 0.7926000000000001, + memUsageKb: 2277507.072, + }, + { + cpuPercent: 0.7926000000000001, + memUsageKb: 2277507.072, + }, + { + cpuPercent: 0.6744, + memUsageKb: 2256535.552, + }, + { + cpuPercent: 0.6744, + memUsageKb: 2256535.552, + }, + { + cpuPercent: 0.6614, + memUsageKb: 2403336.192, + }, + { + cpuPercent: 0.6614, + memUsageKb: 2403336.192, + }, + { + cpuPercent: 0.8509, + memUsageKb: 2494562.304, + }, + { + cpuPercent: 0.8509, + memUsageKb: 2494562.304, + }, + { + cpuPercent: 0.8445999999999999, + memUsageKb: 2338324.48, + }, + { + cpuPercent: 0.8445999999999999, + memUsageKb: 2338324.48, + }, + { + cpuPercent: 0.8445999999999999, + memUsageKb: 2338324.48, + }, + { + cpuPercent: 0.5904, + memUsageKb: 2305818.624, + }, + { + cpuPercent: 0.5904, + memUsageKb: 2305818.624, + }, + { + cpuPercent: 0.5605, + memUsageKb: 2332033.024, + }, + { + cpuPercent: 0.5605, + memUsageKb: 2332033.024, + }, + { + cpuPercent: 0.7279000000000001, + memUsageKb: 2306867.2, + }, + { + cpuPercent: 0.7279000000000001, + memUsageKb: 2306867.2, + }, + { + cpuPercent: 0.58, + memUsageKb: 2291138.56, + }, + { + cpuPercent: 0.58, + memUsageKb: 2291138.56, + }, + { + cpuPercent: 0.7089, + memUsageKb: 2306867.2, + }, + { + cpuPercent: 0.7089, + memUsageKb: 2306867.2, + }, + { + cpuPercent: 0.6679, + memUsageKb: 2296381.44, + }, + { + cpuPercent: 0.6679, + memUsageKb: 2296381.44, + }, + { + cpuPercent: 0.6881999999999999, + memUsageKb: 2313158.656, + }, + { + cpuPercent: 0.6881999999999999, + memUsageKb: 2313158.656, + }, + { + cpuPercent: 0.7053, + memUsageKb: 2307915.776, + }, + { + cpuPercent: 0.7053, + memUsageKb: 2307915.776, + }, + { + cpuPercent: 0.8199, + memUsageKb: 2310012.928, + }, + { + cpuPercent: 0.8199, + memUsageKb: 2310012.928, + }, + { + cpuPercent: 0.7471, + memUsageKb: 2356150.272, + }, + { + cpuPercent: 0.7471, + memUsageKb: 2356150.272, + }, + { + cpuPercent: 0.7667, + memUsageKb: 2330984.448, + }, + { + cpuPercent: 0.7667, + memUsageKb: 2330984.448, + }, + { + cpuPercent: 0.6620999999999999, + memUsageKb: 2327838.72, + }, + { + cpuPercent: 0.6620999999999999, + memUsageKb: 2327838.72, + }, + { + cpuPercent: 0.7234999999999999, + memUsageKb: 2343567.36, + }, + { + cpuPercent: 0.7234999999999999, + memUsageKb: 2343567.36, + }, + { + cpuPercent: 0.7609, + memUsageKb: 2321547.264, + }, + { + cpuPercent: 0.7609, + memUsageKb: 2321547.264, + }, + { + cpuPercent: 0.7615000000000001, + memUsageKb: 2328887.296, + }, + { + cpuPercent: 0.7615000000000001, + memUsageKb: 2328887.296, + }, + { + cpuPercent: 0.7401000000000001, + memUsageKb: 2345664.512, + }, + { + cpuPercent: 0.7401000000000001, + memUsageKb: 2345664.512, + }, + { + cpuPercent: 0.7006999999999999, + memUsageKb: 2530213.888, + }, + { + cpuPercent: 0.7006999999999999, + memUsageKb: 2530213.888, + }, + { + cpuPercent: 0.8412000000000001, + memUsageKb: 2415919.104, + }, + { + cpuPercent: 0.8412000000000001, + memUsageKb: 2415919.104, + }, + { + cpuPercent: 0.6992, + memUsageKb: 2381316.096, + }, + { + cpuPercent: 0.6992, + memUsageKb: 2381316.096, + }, + { + cpuPercent: 0.5501, + memUsageKb: 2377121.792, + }, + { + cpuPercent: 0.5501, + memUsageKb: 2377121.792, + }, + { + cpuPercent: 0.7148, + memUsageKb: 2384461.824, + }, + { + cpuPercent: 0.7148, + memUsageKb: 2384461.824, + }, + { + cpuPercent: 0.6323, + memUsageKb: 2380267.52, + }, + { + cpuPercent: 0.6323, + memUsageKb: 2380267.52, + }, + { + cpuPercent: 0.6323, + memUsageKb: 2380267.52, + }, + { + cpuPercent: 0.7606, + memUsageKb: 2379218.944, + }, + { + cpuPercent: 0.7606, + memUsageKb: 2379218.944, + }, + { + cpuPercent: 0.8291, + memUsageKb: 2372927.488, + }, + { + cpuPercent: 0.8291, + memUsageKb: 2372927.488, + }, + { + cpuPercent: 0.7552, + memUsageKb: 2427453.44, + }, + { + cpuPercent: 0.7552, + memUsageKb: 2427453.44, + }, + { + cpuPercent: 0.6537000000000001, + memUsageKb: 2387607.552, + }, + { + cpuPercent: 0.6537000000000001, + memUsageKb: 2387607.552, + }, + { + cpuPercent: 0.7237, + memUsageKb: 2386558.976, + }, + { + cpuPercent: 0.7237, + memUsageKb: 2386558.976, + }, + { + cpuPercent: 0.6781, + memUsageKb: 2387607.552, + }, + { + cpuPercent: 0.6781, + memUsageKb: 2387607.552, + }, + { + cpuPercent: 0.774, + memUsageKb: 2392850.432, + }, + { + cpuPercent: 0.774, + memUsageKb: 2392850.432, + }, + { + cpuPercent: 0.7618, + memUsageKb: 2393899.008, + }, + { + cpuPercent: 0.7618, + memUsageKb: 2393899.008, + }, + { + cpuPercent: 0.8120999999999999, + memUsageKb: 2446327.808, + }, + { + cpuPercent: 0.8120999999999999, + memUsageKb: 2446327.808, + }, + { + cpuPercent: 0.7631999999999999, + memUsageKb: 2416967.68, + }, + { + cpuPercent: 0.7631999999999999, + memUsageKb: 2416967.68, + }, + { + cpuPercent: 0.8240000000000001, + memUsageKb: 2405433.344, + }, + { + cpuPercent: 0.8240000000000001, + memUsageKb: 2405433.344, + }, + { + cpuPercent: 0.7419, + memUsageKb: 2444230.656, + }, + { + cpuPercent: 0.7419, + memUsageKb: 2444230.656, + }, + { + cpuPercent: 0.6911, + memUsageKb: 2405433.344, + }, + { + cpuPercent: 0.6911, + memUsageKb: 2405433.344, + }, + { + cpuPercent: 0.8689, + memUsageKb: 2413821.952, + }, + { + cpuPercent: 0.8689, + memUsageKb: 2413821.952, + }, + { + cpuPercent: 0.7767000000000001, + memUsageKb: 2438987.776, + }, + { + cpuPercent: 0.7767000000000001, + memUsageKb: 2438987.776, + }, + { + cpuPercent: 0.8805, + memUsageKb: 2656043.008, + }, + { + cpuPercent: 0.8805, + memUsageKb: 2656043.008, + }, + { + cpuPercent: 0.8592, + memUsageKb: 2519728.128, + }, + { + cpuPercent: 0.8592, + memUsageKb: 2519728.128, + }, + { + cpuPercent: 0.639, + memUsageKb: 2475687.936, + }, + { + cpuPercent: 0.639, + memUsageKb: 2475687.936, + }, + { + cpuPercent: 0.502, + memUsageKb: 2471493.632, + }, + { + cpuPercent: 0.502, + memUsageKb: 2471493.632, + }, + { + cpuPercent: 0.787, + memUsageKb: 2469396.48, + }, + { + cpuPercent: 0.787, + memUsageKb: 2469396.48, + }, + { + cpuPercent: 0.787, + memUsageKb: 2469396.48, + }, + { + cpuPercent: 0.7484999999999999, + memUsageKb: 2478833.664, + }, + { + cpuPercent: 0.7484999999999999, + memUsageKb: 2478833.664, + }, + { + cpuPercent: 0.9120999999999999, + memUsageKb: 2528116.736, + }, + { + cpuPercent: 0.9120999999999999, + memUsageKb: 2528116.736, + }, + { + cpuPercent: 0.7403, + memUsageKb: 2487222.272, + }, + { + cpuPercent: 0.7403, + memUsageKb: 2487222.272, + }, + { + cpuPercent: 0.8059000000000001, + memUsageKb: 2535456.768, + }, + { + cpuPercent: 0.8059000000000001, + memUsageKb: 2535456.768, + }, + { + cpuPercent: 0.7117, + memUsageKb: 2487222.272, + }, + { + cpuPercent: 0.7117, + memUsageKb: 2487222.272, + }, + { + cpuPercent: 0.8903, + memUsageKb: 2478833.664, + }, + { + cpuPercent: 0.8903, + memUsageKb: 2478833.664, + }, + { + cpuPercent: 0.7193999999999999, + memUsageKb: 2488270.848, + }, + { + cpuPercent: 0.7193999999999999, + memUsageKb: 2488270.848, + }, + { + cpuPercent: 0.47869999999999996, + memUsageKb: 2437939.2, + }, + { + cpuPercent: 0.47869999999999996, + memUsageKb: 2437939.2, + }, + { + cpuPercent: 1.0264, + memUsageKb: 2499805.184, + }, + { + cpuPercent: 1.0264, + memUsageKb: 2499805.184, + }, + { + cpuPercent: 0.8217, + memUsageKb: 2492465.152, + }, + { + cpuPercent: 0.8217, + memUsageKb: 2492465.152, + }, + { + cpuPercent: 0.6928, + memUsageKb: 2289041.408, + }, + { + cpuPercent: 0.6928, + memUsageKb: 2289041.408, + }, + { + cpuPercent: 0.9349, + memUsageKb: 2501902.336, + }, + { + cpuPercent: 0.9349, + memUsageKb: 2501902.336, + }, + { + cpuPercent: 0.6809999999999999, + memUsageKb: 2502950.912, + }, + { + cpuPercent: 0.6809999999999999, + memUsageKb: 2502950.912, + }, + { + cpuPercent: 0.7722, + memUsageKb: 2502950.912, + }, + { + cpuPercent: 0.7722, + memUsageKb: 2502950.912, + }, + { + cpuPercent: 0.6617000000000001, + memUsageKb: 2505048.064, + }, + { + cpuPercent: 0.6617000000000001, + memUsageKb: 2505048.064, + }, + { + cpuPercent: 0.8517, + memUsageKb: 2500853.76, + }, + { + cpuPercent: 0.8517, + memUsageKb: 2500853.76, + }, + { + cpuPercent: 0.7594, + memUsageKb: 2503999.488, + }, + { + cpuPercent: 0.7594, + memUsageKb: 2503999.488, + }, + { + cpuPercent: 0.7474, + memUsageKb: 2509242.368, + }, + { + cpuPercent: 0.7474, + memUsageKb: 2509242.368, + }, + { + cpuPercent: 0.7474, + memUsageKb: 2509242.368, + }, + { + cpuPercent: 0.8697, + memUsageKb: 2769289.216, + }, + { + cpuPercent: 0.8697, + memUsageKb: 2769289.216, + }, + { + cpuPercent: 0.6163000000000001, + memUsageKb: 2537553.92, + }, + { + cpuPercent: 0.6163000000000001, + memUsageKb: 2537553.92, + }, + { + cpuPercent: 0.7704000000000001, + memUsageKb: 2582642.688, + }, + { + cpuPercent: 0.7704000000000001, + memUsageKb: 2582642.688, + }, + { + cpuPercent: 0.3479, + memUsageKb: 2577399.808, + }, + { + cpuPercent: 0.3479, + memUsageKb: 2577399.808, + }, + { + cpuPercent: 0.3417, + memUsageKb: 2217738.24, + }, + { + cpuPercent: 0.3417, + memUsageKb: 2217738.24, + }, + { + cpuPercent: 0.3986, + memUsageKb: 2212495.36, + }, + { + cpuPercent: 0.3986, + memUsageKb: 2212495.36, + }, + { + cpuPercent: 0.3189, + memUsageKb: 2211446.784, + }, + { + cpuPercent: 0.3189, + memUsageKb: 2211446.784, + }, + { + cpuPercent: 0.33590000000000003, + memUsageKb: 2211446.784, + }, + { + cpuPercent: 0.33590000000000003, + memUsageKb: 2211446.784, + }, + { + cpuPercent: 0.32289999999999996, + memUsageKb: 2210398.208, + }, + { + cpuPercent: null, + memUsageKb: 0, + }, + ], + "supabase-db": [ + { + cpuPercent: 0.0003, + memUsageKb: 410419.2, + }, + { + cpuPercent: 0.0003, + memUsageKb: 410419.2, + }, + { + cpuPercent: 0.2738, + memUsageKb: 411136, + }, + { + cpuPercent: 0.2738, + memUsageKb: 411136, + }, + { + cpuPercent: 0.2944, + memUsageKb: 414003.2, + }, + { + cpuPercent: 0.2944, + memUsageKb: 414003.2, + }, + { + cpuPercent: 0.6394, + memUsageKb: 439296, + }, + { + cpuPercent: 0.6394, + memUsageKb: 439296, + }, + { + cpuPercent: 0.6523, + memUsageKb: 438476.8, + }, + { + cpuPercent: 0.6523, + memUsageKb: 438476.8, + }, + { + cpuPercent: 0.7079000000000001, + memUsageKb: 438476.8, + }, + { + cpuPercent: 0.7079000000000001, + memUsageKb: 438476.8, + }, + { + cpuPercent: 0.6388, + memUsageKb: 439500.8, + }, + { + cpuPercent: 0.6388, + memUsageKb: 439500.8, + }, + { + cpuPercent: 0.6716, + memUsageKb: 438988.8, + }, + { + cpuPercent: 0.6716, + memUsageKb: 438988.8, + }, + { + cpuPercent: 0.6779000000000001, + memUsageKb: 439705.6, + }, + { + cpuPercent: 0.6779000000000001, + memUsageKb: 439705.6, + }, + { + cpuPercent: 0.5957, + memUsageKb: 447590.4, + }, + { + cpuPercent: 0.5957, + memUsageKb: 447590.4, + }, + { + cpuPercent: 0.6731999999999999, + memUsageKb: 447897.6, + }, + { + cpuPercent: 0.6731999999999999, + memUsageKb: 447897.6, + }, + { + cpuPercent: 0.6221, + memUsageKb: 448204.8, + }, + { + cpuPercent: 0.6221, + memUsageKb: 448204.8, + }, + { + cpuPercent: 0.6494, + memUsageKb: 448409.6, + }, + { + cpuPercent: 0.6494, + memUsageKb: 448409.6, + }, + { + cpuPercent: 0.6849, + memUsageKb: 449024, + }, + { + cpuPercent: 0.6849, + memUsageKb: 449024, + }, + { + cpuPercent: 0.645, + memUsageKb: 450560, + }, + { + cpuPercent: 0.645, + memUsageKb: 450560, + }, + { + cpuPercent: 0.7167, + memUsageKb: 450355.2, + }, + { + cpuPercent: 0.7167, + memUsageKb: 450355.2, + }, + { + cpuPercent: 0.6301, + memUsageKb: 449945.6, + }, + { + cpuPercent: 0.6301, + memUsageKb: 449945.6, + }, + { + cpuPercent: 0.6274000000000001, + memUsageKb: 449945.6, + }, + { + cpuPercent: 0.6274000000000001, + memUsageKb: 449945.6, + }, + { + cpuPercent: 0.6162, + memUsageKb: 450048, + }, + { + cpuPercent: 0.6162, + memUsageKb: 450048, + }, + { + cpuPercent: 0.6607, + memUsageKb: 450662.4, + }, + { + cpuPercent: 0.6607, + memUsageKb: 450662.4, + }, + { + cpuPercent: 0.7099, + memUsageKb: 451072, + }, + { + cpuPercent: 0.7099, + memUsageKb: 451072, + }, + { + cpuPercent: 0.6386, + memUsageKb: 451481.6, + }, + { + cpuPercent: 0.6386, + memUsageKb: 451481.6, + }, + { + cpuPercent: 0.6109, + memUsageKb: 451584, + }, + { + cpuPercent: 0.6109, + memUsageKb: 451584, + }, + { + cpuPercent: 1.0002, + memUsageKb: 452710.4, + }, + { + cpuPercent: 1.0002, + memUsageKb: 452710.4, + }, + { + cpuPercent: 1.0002, + memUsageKb: 452710.4, + }, + { + cpuPercent: 0.6728000000000001, + memUsageKb: 452710.4, + }, + { + cpuPercent: 0.6728000000000001, + memUsageKb: 452710.4, + }, + { + cpuPercent: 0.7143999999999999, + memUsageKb: 453222.4, + }, + { + cpuPercent: 0.7143999999999999, + memUsageKb: 453222.4, + }, + { + cpuPercent: 0.6566, + memUsageKb: 453324.8, + }, + { + cpuPercent: 0.6566, + memUsageKb: 453324.8, + }, + { + cpuPercent: 0.5898, + memUsageKb: 454041.6, + }, + { + cpuPercent: 0.5898, + memUsageKb: 454041.6, + }, + { + cpuPercent: 0.6912, + memUsageKb: 453939.2, + }, + { + cpuPercent: 0.6912, + memUsageKb: 453939.2, + }, + { + cpuPercent: 0.6566, + memUsageKb: 454246.4, + }, + { + cpuPercent: 0.6566, + memUsageKb: 454246.4, + }, + { + cpuPercent: 0.652, + memUsageKb: 447488, + }, + { + cpuPercent: 0.652, + memUsageKb: 447488, + }, + { + cpuPercent: 0.5947, + memUsageKb: 453324.8, + }, + { + cpuPercent: 0.5947, + memUsageKb: 453324.8, + }, + { + cpuPercent: 0.6533, + memUsageKb: 453324.8, + }, + { + cpuPercent: 0.6533, + memUsageKb: 453324.8, + }, + { + cpuPercent: 0.6244, + memUsageKb: 453939.2, + }, + { + cpuPercent: 0.6244, + memUsageKb: 453939.2, + }, + { + cpuPercent: 0.6134000000000001, + memUsageKb: 455065.6, + }, + { + cpuPercent: 0.6134000000000001, + memUsageKb: 455065.6, + }, + { + cpuPercent: 0.7304, + memUsageKb: 454963.2, + }, + { + cpuPercent: 0.7304, + memUsageKb: 454963.2, + }, + { + cpuPercent: 0.6225999999999999, + memUsageKb: 455577.6, + }, + { + cpuPercent: 0.6225999999999999, + memUsageKb: 455577.6, + }, + { + cpuPercent: 0.6531999999999999, + memUsageKb: 455782.4, + }, + { + cpuPercent: 0.6531999999999999, + memUsageKb: 455782.4, + }, + { + cpuPercent: 0.6406000000000001, + memUsageKb: 459264, + }, + { + cpuPercent: 0.6406000000000001, + memUsageKb: 459264, + }, + { + cpuPercent: 0.6666, + memUsageKb: 458956.8, + }, + { + cpuPercent: 0.6666, + memUsageKb: 458956.8, + }, + { + cpuPercent: 0.7178, + memUsageKb: 459468.8, + }, + { + cpuPercent: 0.7178, + memUsageKb: 459468.8, + }, + { + cpuPercent: 0.6746, + memUsageKb: 460492.8, + }, + { + cpuPercent: 0.6746, + memUsageKb: 460492.8, + }, + { + cpuPercent: 0.6557999999999999, + memUsageKb: 460800, + }, + { + cpuPercent: 0.6557999999999999, + memUsageKb: 460800, + }, + { + cpuPercent: 0.6283, + memUsageKb: 462028.8, + }, + { + cpuPercent: 0.6283, + memUsageKb: 462028.8, + }, + { + cpuPercent: 0.6431999999999999, + memUsageKb: 461619.2, + }, + { + cpuPercent: 0.6431999999999999, + memUsageKb: 461619.2, + }, + { + cpuPercent: 0.6431999999999999, + memUsageKb: 461619.2, + }, + { + cpuPercent: 0.6859999999999999, + memUsageKb: 462233.6, + }, + { + cpuPercent: 0.6859999999999999, + memUsageKb: 462233.6, + }, + { + cpuPercent: 0.6086, + memUsageKb: 462438.4, + }, + { + cpuPercent: 0.6086, + memUsageKb: 462438.4, + }, + { + cpuPercent: 0.5899, + memUsageKb: 462438.4, + }, + { + cpuPercent: 0.5899, + memUsageKb: 462438.4, + }, + { + cpuPercent: 0.6063000000000001, + memUsageKb: 463155.2, + }, + { + cpuPercent: 0.6063000000000001, + memUsageKb: 463155.2, + }, + { + cpuPercent: 0.5665, + memUsageKb: 463564.8, + }, + { + cpuPercent: 0.5665, + memUsageKb: 463564.8, + }, + { + cpuPercent: 0.667, + memUsageKb: 464281.6, + }, + { + cpuPercent: 0.667, + memUsageKb: 464281.6, + }, + { + cpuPercent: 0.6385000000000001, + memUsageKb: 463872, + }, + { + cpuPercent: 0.6385000000000001, + memUsageKb: 463872, + }, + { + cpuPercent: 0.6379, + memUsageKb: 464281.6, + }, + { + cpuPercent: 0.6379, + memUsageKb: 464281.6, + }, + { + cpuPercent: 0.6165999999999999, + memUsageKb: 464896, + }, + { + cpuPercent: 0.6165999999999999, + memUsageKb: 464896, + }, + { + cpuPercent: 0.655, + memUsageKb: 464588.8, + }, + { + cpuPercent: 0.655, + memUsageKb: 464588.8, + }, + { + cpuPercent: 0.7437, + memUsageKb: 465715.2, + }, + { + cpuPercent: 0.7437, + memUsageKb: 465715.2, + }, + { + cpuPercent: 0.6462, + memUsageKb: 464998.4, + }, + { + cpuPercent: 0.6462, + memUsageKb: 464998.4, + }, + { + cpuPercent: 0.6598999999999999, + memUsageKb: 466022.4, + }, + { + cpuPercent: 0.6598999999999999, + memUsageKb: 466022.4, + }, + { + cpuPercent: 0.6498, + memUsageKb: 465510.4, + }, + { + cpuPercent: 0.6498, + memUsageKb: 465510.4, + }, + { + cpuPercent: 0.6225, + memUsageKb: 466636.8, + }, + { + cpuPercent: 0.6225, + memUsageKb: 466636.8, + }, + { + cpuPercent: 0.6579, + memUsageKb: 466022.4, + }, + { + cpuPercent: 0.6579, + memUsageKb: 466022.4, + }, + { + cpuPercent: 0.5274, + memUsageKb: 466432, + }, + { + cpuPercent: 0.5274, + memUsageKb: 466432, + }, + { + cpuPercent: 0.626, + memUsageKb: 466841.6, + }, + { + cpuPercent: 0.626, + memUsageKb: 466841.6, + }, + { + cpuPercent: 0.6154999999999999, + memUsageKb: 467660.8, + }, + { + cpuPercent: 0.6154999999999999, + memUsageKb: 467660.8, + }, + { + cpuPercent: 0.662, + memUsageKb: 467148.8, + }, + { + cpuPercent: 0.662, + memUsageKb: 467148.8, + }, + { + cpuPercent: 0.748, + memUsageKb: 467968, + }, + { + cpuPercent: 0.748, + memUsageKb: 467968, + }, + { + cpuPercent: 0.748, + memUsageKb: 467968, + }, + { + cpuPercent: 0.6711, + memUsageKb: 467865.6, + }, + { + cpuPercent: 0.6711, + memUsageKb: 467865.6, + }, + { + cpuPercent: 0.6278, + memUsageKb: 468275.2, + }, + { + cpuPercent: 0.6278, + memUsageKb: 468275.2, + }, + { + cpuPercent: 0.645, + memUsageKb: 469299.2, + }, + { + cpuPercent: 0.645, + memUsageKb: 469299.2, + }, + { + cpuPercent: 0.6378, + memUsageKb: 469504, + }, + { + cpuPercent: 0.6378, + memUsageKb: 469504, + }, + { + cpuPercent: 0.6328, + memUsageKb: 469196.8, + }, + { + cpuPercent: 0.6328, + memUsageKb: 469196.8, + }, + { + cpuPercent: 0.6246, + memUsageKb: 469913.6, + }, + { + cpuPercent: 0.6246, + memUsageKb: 469913.6, + }, + { + cpuPercent: 0.6386, + memUsageKb: 470220.8, + }, + { + cpuPercent: 0.6386, + memUsageKb: 470220.8, + }, + { + cpuPercent: 0.6107, + memUsageKb: 469811.2, + }, + { + cpuPercent: 0.6107, + memUsageKb: 469811.2, + }, + { + cpuPercent: 0.6253, + memUsageKb: 470937.6, + }, + { + cpuPercent: 0.6253, + memUsageKb: 470937.6, + }, + { + cpuPercent: 0.6929000000000001, + memUsageKb: 471654.4, + }, + { + cpuPercent: 0.6929000000000001, + memUsageKb: 471654.4, + }, + { + cpuPercent: 0.6336999999999999, + memUsageKb: 471142.4, + }, + { + cpuPercent: 0.6336999999999999, + memUsageKb: 471142.4, + }, + { + cpuPercent: 0.5957, + memUsageKb: 471654.4, + }, + { + cpuPercent: 0.5957, + memUsageKb: 471654.4, + }, + { + cpuPercent: 0.6249, + memUsageKb: 472678.4, + }, + { + cpuPercent: 0.6249, + memUsageKb: 472678.4, + }, + { + cpuPercent: 0.6453, + memUsageKb: 472985.6, + }, + { + cpuPercent: 0.6453, + memUsageKb: 472985.6, + }, + { + cpuPercent: 0.6868000000000001, + memUsageKb: 472780.8, + }, + { + cpuPercent: 0.6868000000000001, + memUsageKb: 472780.8, + }, + { + cpuPercent: 0.6655, + memUsageKb: 479129.6, + }, + { + cpuPercent: 0.6655, + memUsageKb: 479129.6, + }, + { + cpuPercent: 0.7781, + memUsageKb: 474419.2, + }, + { + cpuPercent: 0.7781, + memUsageKb: 474419.2, + }, + { + cpuPercent: 0.649, + memUsageKb: 474112, + }, + { + cpuPercent: 0.649, + memUsageKb: 474112, + }, + { + cpuPercent: 0.6537999999999999, + memUsageKb: 473600, + }, + { + cpuPercent: 0.6537999999999999, + memUsageKb: 473600, + }, + { + cpuPercent: 0.6845, + memUsageKb: 474316.8, + }, + { + cpuPercent: 0.6845, + memUsageKb: 474316.8, + }, + { + cpuPercent: 0.6589, + memUsageKb: 474726.4, + }, + { + cpuPercent: 0.6589, + memUsageKb: 474726.4, + }, + { + cpuPercent: 0.6589, + memUsageKb: 474726.4, + }, + { + cpuPercent: 0.6362, + memUsageKb: 474624, + }, + { + cpuPercent: 0.6362, + memUsageKb: 474624, + }, + { + cpuPercent: 0.6043, + memUsageKb: 475545.6, + }, + { + cpuPercent: 0.6043, + memUsageKb: 475545.6, + }, + { + cpuPercent: 0.603, + memUsageKb: 475238.4, + }, + { + cpuPercent: 0.603, + memUsageKb: 475238.4, + }, + { + cpuPercent: 0.5559000000000001, + memUsageKb: 475648, + }, + { + cpuPercent: 0.5559000000000001, + memUsageKb: 475648, + }, + { + cpuPercent: 0.6189, + memUsageKb: 475750.4, + }, + { + cpuPercent: 0.6189, + memUsageKb: 475750.4, + }, + { + cpuPercent: 0.5713, + memUsageKb: 476364.8, + }, + { + cpuPercent: 0.5713, + memUsageKb: 476364.8, + }, + { + cpuPercent: 0.625, + memUsageKb: 475852.8, + }, + { + cpuPercent: 0.625, + memUsageKb: 475852.8, + }, + { + cpuPercent: 0.6531999999999999, + memUsageKb: 477388.8, + }, + { + cpuPercent: 0.6531999999999999, + memUsageKb: 477388.8, + }, + { + cpuPercent: 0.6559, + memUsageKb: 478105.6, + }, + { + cpuPercent: 0.6559, + memUsageKb: 478105.6, + }, + { + cpuPercent: 0.6541, + memUsageKb: 477081.6, + }, + { + cpuPercent: 0.6541, + memUsageKb: 477081.6, + }, + { + cpuPercent: 0.5623, + memUsageKb: 477491.2, + }, + { + cpuPercent: 0.5623, + memUsageKb: 477491.2, + }, + { + cpuPercent: 0.6665000000000001, + memUsageKb: 477798.4, + }, + { + cpuPercent: 0.6665000000000001, + memUsageKb: 477798.4, + }, + { + cpuPercent: 0.6336999999999999, + memUsageKb: 477696, + }, + { + cpuPercent: 0.6336999999999999, + memUsageKb: 477696, + }, + { + cpuPercent: 0.7311, + memUsageKb: 479334.4, + }, + { + cpuPercent: 0.7311, + memUsageKb: 479334.4, + }, + { + cpuPercent: 0.6564, + memUsageKb: 480256, + }, + { + cpuPercent: 0.6564, + memUsageKb: 480256, + }, + { + cpuPercent: 0.6448999999999999, + memUsageKb: 478924.8, + }, + { + cpuPercent: 0.6448999999999999, + memUsageKb: 478924.8, + }, + { + cpuPercent: 0.6351, + memUsageKb: 479027.2, + }, + { + cpuPercent: 0.6351, + memUsageKb: 479027.2, + }, + { + cpuPercent: 0.6193, + memUsageKb: 479539.2, + }, + { + cpuPercent: 0.6193, + memUsageKb: 479539.2, + }, + { + cpuPercent: 0.7127, + memUsageKb: 479846.4, + }, + { + cpuPercent: 0.7127, + memUsageKb: 479846.4, + }, + { + cpuPercent: 0.6078, + memUsageKb: 480256, + }, + { + cpuPercent: 0.6078, + memUsageKb: 480256, + }, + { + cpuPercent: 0.6119, + memUsageKb: 480051.2, + }, + { + cpuPercent: 0.6119, + memUsageKb: 480051.2, + }, + { + cpuPercent: 0.6382, + memUsageKb: 480358.4, + }, + { + cpuPercent: 0.6382, + memUsageKb: 480358.4, + }, + { + cpuPercent: 0.6382, + memUsageKb: 480358.4, + }, + { + cpuPercent: 0.6346, + memUsageKb: 480870.4, + }, + { + cpuPercent: 0.6346, + memUsageKb: 480870.4, + }, + { + cpuPercent: 0.6881999999999999, + memUsageKb: 481280, + }, + { + cpuPercent: 0.6881999999999999, + memUsageKb: 481280, + }, + { + cpuPercent: 0.632, + memUsageKb: 481382.4, + }, + { + cpuPercent: 0.632, + memUsageKb: 481382.4, + }, + { + cpuPercent: 0.6347999999999999, + memUsageKb: 481280, + }, + { + cpuPercent: 0.6347999999999999, + memUsageKb: 481280, + }, + { + cpuPercent: 0.64, + memUsageKb: 481689.6, + }, + { + cpuPercent: 0.64, + memUsageKb: 481689.6, + }, + { + cpuPercent: 0.6351, + memUsageKb: 482713.6, + }, + { + cpuPercent: 0.6351, + memUsageKb: 482713.6, + }, + { + cpuPercent: 0.6644, + memUsageKb: 482201.6, + }, + { + cpuPercent: 0.6644, + memUsageKb: 482201.6, + }, + { + cpuPercent: 0.59, + memUsageKb: 482611.2, + }, + { + cpuPercent: 0.59, + memUsageKb: 482611.2, + }, + { + cpuPercent: 0.6461, + memUsageKb: 483020.8, + }, + { + cpuPercent: 0.6461, + memUsageKb: 483020.8, + }, + { + cpuPercent: 0.6542, + memUsageKb: 483430.4, + }, + { + cpuPercent: 0.6542, + memUsageKb: 483430.4, + }, + { + cpuPercent: 0.6869, + memUsageKb: 483123.2, + }, + { + cpuPercent: 0.6869, + memUsageKb: 483123.2, + }, + { + cpuPercent: 0.6077, + memUsageKb: 482713.6, + }, + { + cpuPercent: 0.6077, + memUsageKb: 482713.6, + }, + { + cpuPercent: 0.6345000000000001, + memUsageKb: 483737.6, + }, + { + cpuPercent: 0.6345000000000001, + memUsageKb: 483737.6, + }, + { + cpuPercent: 0.5525, + memUsageKb: 483225.6, + }, + { + cpuPercent: 0.5525, + memUsageKb: 483225.6, + }, + { + cpuPercent: 0.6448, + memUsageKb: 483532.8, + }, + { + cpuPercent: 0.6448, + memUsageKb: 483532.8, + }, + { + cpuPercent: 0.6058, + memUsageKb: 483020.8, + }, + { + cpuPercent: 0.6058, + memUsageKb: 483020.8, + }, + { + cpuPercent: 0.6774, + memUsageKb: 483430.4, + }, + { + cpuPercent: 0.6774, + memUsageKb: 483430.4, + }, + { + cpuPercent: 0.5785, + memUsageKb: 483225.6, + }, + { + cpuPercent: 0.5785, + memUsageKb: 483225.6, + }, + { + cpuPercent: 0.611, + memUsageKb: 483020.8, + }, + { + cpuPercent: 0.611, + memUsageKb: 483020.8, + }, + { + cpuPercent: 0.625, + memUsageKb: 483840, + }, + { + cpuPercent: 0.625, + memUsageKb: 483840, + }, + { + cpuPercent: 0.625, + memUsageKb: 483840, + }, + { + cpuPercent: 0.6273, + memUsageKb: 483020.8, + }, + { + cpuPercent: 0.6273, + memUsageKb: 483020.8, + }, + { + cpuPercent: 0.6478, + memUsageKb: 483225.6, + }, + { + cpuPercent: 0.6478, + memUsageKb: 483225.6, + }, + { + cpuPercent: 0.6496999999999999, + memUsageKb: 483532.8, + }, + { + cpuPercent: 0.6496999999999999, + memUsageKb: 483532.8, + }, + { + cpuPercent: 0.6261, + memUsageKb: 483430.4, + }, + { + cpuPercent: 0.6261, + memUsageKb: 483430.4, + }, + { + cpuPercent: 0.5893999999999999, + memUsageKb: 482918.4, + }, + { + cpuPercent: 0.5893999999999999, + memUsageKb: 482918.4, + }, + { + cpuPercent: 0.5954, + memUsageKb: 483123.2, + }, + { + cpuPercent: 0.5954, + memUsageKb: 483123.2, + }, + { + cpuPercent: 0.6999, + memUsageKb: 482918.4, + }, + { + cpuPercent: 0.6999, + memUsageKb: 482918.4, + }, + { + cpuPercent: 0.6706, + memUsageKb: 483020.8, + }, + { + cpuPercent: 0.6706, + memUsageKb: 483020.8, + }, + { + cpuPercent: 0.6247, + memUsageKb: 483840, + }, + { + cpuPercent: 0.6247, + memUsageKb: 483840, + }, + { + cpuPercent: 0.643, + memUsageKb: 483328, + }, + { + cpuPercent: 0.643, + memUsageKb: 483328, + }, + { + cpuPercent: 0.5916, + memUsageKb: 483123.2, + }, + { + cpuPercent: 0.5916, + memUsageKb: 483123.2, + }, + { + cpuPercent: 0.9490999999999999, + memUsageKb: 518246.4, + }, + { + cpuPercent: 0.9490999999999999, + memUsageKb: 518246.4, + }, + { + cpuPercent: 0.9478, + memUsageKb: 483532.8, + }, + { + cpuPercent: 0.9478, + memUsageKb: 483532.8, + }, + { + cpuPercent: 0.6048, + memUsageKb: 483737.6, + }, + { + cpuPercent: 0.6048, + memUsageKb: 483737.6, + }, + { + cpuPercent: 0.644, + memUsageKb: 483328, + }, + { + cpuPercent: 0.644, + memUsageKb: 483328, + }, + { + cpuPercent: 0.6485, + memUsageKb: 483328, + }, + { + cpuPercent: 0.6485, + memUsageKb: 483328, + }, + { + cpuPercent: 0.7028, + memUsageKb: 483328, + }, + { + cpuPercent: 0.7028, + memUsageKb: 483328, + }, + { + cpuPercent: 0.7028, + memUsageKb: 483328, + }, + { + cpuPercent: 0.618, + memUsageKb: 483430.4, + }, + { + cpuPercent: 0.618, + memUsageKb: 483430.4, + }, + { + cpuPercent: 0.5779, + memUsageKb: 483840, + }, + { + cpuPercent: 0.5779, + memUsageKb: 483840, + }, + { + cpuPercent: 0.6301, + memUsageKb: 483840, + }, + { + cpuPercent: 0.6301, + memUsageKb: 483840, + }, + { + cpuPercent: 0.40990000000000004, + memUsageKb: 491622.4, + }, + { + cpuPercent: 0.40990000000000004, + memUsageKb: 491622.4, + }, + { + cpuPercent: 0.2556, + memUsageKb: 489062.4, + }, + { + cpuPercent: 0.2556, + memUsageKb: 489062.4, + }, + { + cpuPercent: 0.1816, + memUsageKb: 489267.2, + }, + { + cpuPercent: 0.1816, + memUsageKb: 489267.2, + }, + { + cpuPercent: 0.1888, + memUsageKb: 489779.2, + }, + { + cpuPercent: 0.1888, + memUsageKb: 489779.2, + }, + { + cpuPercent: 0.1981, + memUsageKb: 489369.6, + }, + { + cpuPercent: 0.1981, + memUsageKb: 489369.6, + }, + { + cpuPercent: 0.21309999999999998, + memUsageKb: 489984, + }, + { + cpuPercent: 0.21309999999999998, + memUsageKb: 489984, + }, + { + cpuPercent: 0.2673, + memUsageKb: 489574.4, + }, + { + cpuPercent: null, + memUsageKb: 0, + }, + ], + "supabase-vector": [ + { + cpuPercent: 0.0001, + memUsageKb: 188416, + }, + { + cpuPercent: 0.0001, + memUsageKb: 188416, + }, + { + cpuPercent: 0.0079, + memUsageKb: 188108.8, + }, + { + cpuPercent: 0.0079, + memUsageKb: 188108.8, + }, + { + cpuPercent: 0.0248, + memUsageKb: 188211.2, + }, + { + cpuPercent: 0.0248, + memUsageKb: 188211.2, + }, + { + cpuPercent: 0.1439, + memUsageKb: 188416, + }, + { + cpuPercent: 0.1439, + memUsageKb: 188416, + }, + { + cpuPercent: 0.1948, + memUsageKb: 188518.4, + }, + { + cpuPercent: 0.1948, + memUsageKb: 188518.4, + }, + { + cpuPercent: 0.1841, + memUsageKb: 188518.4, + }, + { + cpuPercent: 0.1841, + memUsageKb: 188518.4, + }, + { + cpuPercent: 0.1977, + memUsageKb: 189030.4, + }, + { + cpuPercent: 0.1977, + memUsageKb: 189030.4, + }, + { + cpuPercent: 0.2206, + memUsageKb: 189644.8, + }, + { + cpuPercent: 0.2206, + memUsageKb: 189644.8, + }, + { + cpuPercent: 0.2023, + memUsageKb: 189440, + }, + { + cpuPercent: 0.2023, + memUsageKb: 189440, + }, + { + cpuPercent: 0.1746, + memUsageKb: 189235.2, + }, + { + cpuPercent: 0.1746, + memUsageKb: 189235.2, + }, + { + cpuPercent: 0.1664, + memUsageKb: 189235.2, + }, + { + cpuPercent: 0.1664, + memUsageKb: 189235.2, + }, + { + cpuPercent: 0.1753, + memUsageKb: 189030.4, + }, + { + cpuPercent: 0.1753, + memUsageKb: 189030.4, + }, + { + cpuPercent: 0.2271, + memUsageKb: 189132.8, + }, + { + cpuPercent: 0.2271, + memUsageKb: 189132.8, + }, + { + cpuPercent: 0.2157, + memUsageKb: 189132.8, + }, + { + cpuPercent: 0.2157, + memUsageKb: 189132.8, + }, + { + cpuPercent: 0.196, + memUsageKb: 190054.4, + }, + { + cpuPercent: 0.196, + memUsageKb: 190054.4, + }, + { + cpuPercent: 0.20579999999999998, + memUsageKb: 191283.2, + }, + { + cpuPercent: 0.20579999999999998, + memUsageKb: 191283.2, + }, + { + cpuPercent: 0.193, + memUsageKb: 191488, + }, + { + cpuPercent: 0.193, + memUsageKb: 191488, + }, + { + cpuPercent: 0.2065, + memUsageKb: 191283.2, + }, + { + cpuPercent: 0.2065, + memUsageKb: 191283.2, + }, + { + cpuPercent: 0.1868, + memUsageKb: 190976, + }, + { + cpuPercent: 0.1868, + memUsageKb: 190976, + }, + { + cpuPercent: 0.20929999999999999, + memUsageKb: 191590.4, + }, + { + cpuPercent: 0.20929999999999999, + memUsageKb: 191590.4, + }, + { + cpuPercent: 0.19699999999999998, + memUsageKb: 191180.8, + }, + { + cpuPercent: 0.19699999999999998, + memUsageKb: 191180.8, + }, + { + cpuPercent: 0.18710000000000002, + memUsageKb: 192819.2, + }, + { + cpuPercent: 0.18710000000000002, + memUsageKb: 192819.2, + }, + { + cpuPercent: 0.2, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.2, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.1856, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.1856, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.2007, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.2007, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.2007, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.1886, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.1886, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.1978, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.1978, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.1984, + memUsageKb: 193024, + }, + { + cpuPercent: 0.1984, + memUsageKb: 193024, + }, + { + cpuPercent: 0.20309999999999997, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.20309999999999997, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.2078, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.2078, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1805, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1805, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1838, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.1838, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.2192, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.2192, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.1898, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.1898, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.18989999999999999, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.18989999999999999, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.20559999999999998, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.20559999999999998, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1883, + memUsageKb: 193228.8, + }, + { + cpuPercent: 0.1883, + memUsageKb: 193228.8, + }, + { + cpuPercent: 0.2151, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.2151, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1873, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.1873, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.19269999999999998, + memUsageKb: 193536, + }, + { + cpuPercent: 0.19269999999999998, + memUsageKb: 193536, + }, + { + cpuPercent: 0.2026, + memUsageKb: 193536, + }, + { + cpuPercent: 0.2026, + memUsageKb: 193536, + }, + { + cpuPercent: 0.209, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.209, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.21559999999999999, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.21559999999999999, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.1954, + memUsageKb: 192819.2, + }, + { + cpuPercent: 0.1954, + memUsageKb: 192819.2, + }, + { + cpuPercent: 0.2002, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.2002, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.2002, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.1841, + memUsageKb: 193536, + }, + { + cpuPercent: 0.1841, + memUsageKb: 193536, + }, + { + cpuPercent: 0.18359999999999999, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.18359999999999999, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1985, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.1985, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.1891, + memUsageKb: 193945.6, + }, + { + cpuPercent: 0.1891, + memUsageKb: 193945.6, + }, + { + cpuPercent: 0.16670000000000001, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.16670000000000001, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.1917, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1917, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1891, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1891, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.2165, + memUsageKb: 193536, + }, + { + cpuPercent: 0.2165, + memUsageKb: 193536, + }, + { + cpuPercent: 0.18739999999999998, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.18739999999999998, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.18969999999999998, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.18969999999999998, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1975, + memUsageKb: 193024, + }, + { + cpuPercent: 0.1975, + memUsageKb: 193024, + }, + { + cpuPercent: 0.1891, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1891, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.21239999999999998, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.21239999999999998, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1884, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.1884, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.19079999999999997, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.19079999999999997, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.18059999999999998, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.18059999999999998, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.16949999999999998, + memUsageKb: 193536, + }, + { + cpuPercent: 0.16949999999999998, + memUsageKb: 193536, + }, + { + cpuPercent: 0.2194, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.2194, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.1856, + memUsageKb: 193945.6, + }, + { + cpuPercent: 0.1856, + memUsageKb: 193945.6, + }, + { + cpuPercent: 0.20370000000000002, + memUsageKb: 193945.6, + }, + { + cpuPercent: 0.20370000000000002, + memUsageKb: 193945.6, + }, + { + cpuPercent: 0.20079999999999998, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.20079999999999998, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.2032, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.2032, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.2032, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.2043, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.2043, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1883, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.1883, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.193, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.193, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1747, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1747, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1973, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.1973, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.2094, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.2094, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.187, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.187, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.187, + memUsageKb: 193536, + }, + { + cpuPercent: 0.187, + memUsageKb: 193536, + }, + { + cpuPercent: 0.1857, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1857, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.203, + memUsageKb: 194150.4, + }, + { + cpuPercent: 0.203, + memUsageKb: 194150.4, + }, + { + cpuPercent: 0.1973, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.1973, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.183, + memUsageKb: 194252.8, + }, + { + cpuPercent: 0.183, + memUsageKb: 194252.8, + }, + { + cpuPercent: 0.19469999999999998, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.19469999999999998, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.187, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.187, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.1797, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.1797, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.20129999999999998, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.20129999999999998, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.2025, + memUsageKb: 193945.6, + }, + { + cpuPercent: 0.2025, + memUsageKb: 193945.6, + }, + { + cpuPercent: 0.1967, + memUsageKb: 193945.6, + }, + { + cpuPercent: 0.1967, + memUsageKb: 193945.6, + }, + { + cpuPercent: 0.18239999999999998, + memUsageKb: 194252.8, + }, + { + cpuPercent: 0.18239999999999998, + memUsageKb: 194252.8, + }, + { + cpuPercent: 0.1946, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1946, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1946, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.20800000000000002, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.20800000000000002, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.1772, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1772, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1812, + memUsageKb: 193945.6, + }, + { + cpuPercent: 0.1812, + memUsageKb: 193945.6, + }, + { + cpuPercent: 0.1499, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.1499, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.1981, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.1981, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.1999, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1999, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1916, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1916, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1971, + memUsageKb: 193945.6, + }, + { + cpuPercent: 0.1971, + memUsageKb: 193945.6, + }, + { + cpuPercent: 0.17579999999999998, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.17579999999999998, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1906, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.1906, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.19440000000000002, + memUsageKb: 193536, + }, + { + cpuPercent: 0.19440000000000002, + memUsageKb: 193536, + }, + { + cpuPercent: 0.1972, + memUsageKb: 193536, + }, + { + cpuPercent: 0.1972, + memUsageKb: 193536, + }, + { + cpuPercent: 0.1844, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1844, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.20370000000000002, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.20370000000000002, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.20579999999999998, + memUsageKb: 193536, + }, + { + cpuPercent: 0.20579999999999998, + memUsageKb: 193536, + }, + { + cpuPercent: 0.2163, + memUsageKb: 193228.8, + }, + { + cpuPercent: 0.2163, + memUsageKb: 193228.8, + }, + { + cpuPercent: 0.1961, + memUsageKb: 193536, + }, + { + cpuPercent: 0.1961, + memUsageKb: 193536, + }, + { + cpuPercent: 0.1932, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1932, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1924, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1924, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1895, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1895, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.20440000000000003, + memUsageKb: 193536, + }, + { + cpuPercent: 0.20440000000000003, + memUsageKb: 193536, + }, + { + cpuPercent: 0.191, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.191, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.191, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.19510000000000002, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.19510000000000002, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.1972, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1972, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1894, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1894, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.21289999999999998, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.21289999999999998, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1883, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1883, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1889, + memUsageKb: 193536, + }, + { + cpuPercent: 0.1889, + memUsageKb: 193536, + }, + { + cpuPercent: 0.1779, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1779, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1818, + memUsageKb: 193536, + }, + { + cpuPercent: 0.1818, + memUsageKb: 193536, + }, + { + cpuPercent: 0.2066, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.2066, + memUsageKb: 193126.4, + }, + { + cpuPercent: 0.1989, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1989, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.21059999999999998, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.21059999999999998, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.1746, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.1746, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.1971, + memUsageKb: 193536, + }, + { + cpuPercent: 0.1971, + memUsageKb: 193536, + }, + { + cpuPercent: 0.19870000000000002, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.19870000000000002, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.19940000000000002, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.19940000000000002, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1934, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.1934, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.1884, + memUsageKb: 194150.4, + }, + { + cpuPercent: 0.1884, + memUsageKb: 194150.4, + }, + { + cpuPercent: 0.192, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.192, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.1739, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.1739, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.19469999999999998, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.19469999999999998, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.19469999999999998, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1918, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.1918, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.17809999999999998, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.17809999999999998, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.2107, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.2107, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.19879999999999998, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.19879999999999998, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1834, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.1834, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.18960000000000002, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.18960000000000002, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.19260000000000002, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.19260000000000002, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.2163, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.2163, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.1948, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.1948, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.19, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.19, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.1804, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.1804, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.1813, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.1813, + memUsageKb: 193433.6, + }, + { + cpuPercent: 0.20329999999999998, + memUsageKb: 193536, + }, + { + cpuPercent: 0.20329999999999998, + memUsageKb: 193536, + }, + { + cpuPercent: 0.187, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.187, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1912, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.1912, + memUsageKb: 193843.2, + }, + { + cpuPercent: 0.2025, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.2025, + memUsageKb: 193331.2, + }, + { + cpuPercent: 0.1979, + memUsageKb: 193536, + }, + { + cpuPercent: 0.1979, + memUsageKb: 193536, + }, + { + cpuPercent: 0.1979, + memUsageKb: 193536, + }, + { + cpuPercent: 0.2045, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.2045, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.183, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.183, + memUsageKb: 193638.4, + }, + { + cpuPercent: 0.1859, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1859, + memUsageKb: 193740.8, + }, + { + cpuPercent: 0.1082, + memUsageKb: 193536, + }, + { + cpuPercent: 0.1082, + memUsageKb: 193536, + }, + { + cpuPercent: 0.0075, + memUsageKb: 193536, + }, + { + cpuPercent: 0.0075, + memUsageKb: 193536, + }, + { + cpuPercent: 0.0199, + memUsageKb: 193536, + }, + { + cpuPercent: 0.0199, + memUsageKb: 193536, + }, + { + cpuPercent: 0.006999999999999999, + memUsageKb: 193536, + }, + { + cpuPercent: 0.006999999999999999, + memUsageKb: 193536, + }, + { + cpuPercent: 0.0069, + memUsageKb: 193536, + }, + { + cpuPercent: 0.0069, + memUsageKb: 193536, + }, + { + cpuPercent: 0.0072, + memUsageKb: 193536, + }, + { + cpuPercent: 0.0072, + memUsageKb: 193536, + }, + { + cpuPercent: 0.0059, + memUsageKb: 193536, + }, + { + cpuPercent: null, + memUsageKb: 0, + }, + ], + "supabase-imgproxy": [ + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0.068, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.068, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.006, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.006, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.07139999999999999, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.07139999999999999, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.0621, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0.0621, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0.0085, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.0085, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0.061500000000000006, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.061500000000000006, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0.0641, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.0641, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0.0063, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0.0063, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0.06309999999999999, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.06309999999999999, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.056900000000000006, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0.056900000000000006, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0.0074, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0.0074, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0.0767, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.0767, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.0719, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0.0719, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0.0719, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0.006, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.006, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.062, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0.062, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0.064, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0.064, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0.0085, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0.0085, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0, + memUsageKb: 87193.6, + }, + { + cpuPercent: 0, + memUsageKb: 87193.6, + }, + { + cpuPercent: 0.0638, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.0638, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0.0699, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0.0699, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0.0085, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0.0085, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0.0654, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.0654, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.0647, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.0647, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.0063, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0.0063, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0, + memUsageKb: 87193.6, + }, + { + cpuPercent: 0, + memUsageKb: 87193.6, + }, + { + cpuPercent: 0.0693, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.0693, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.0663, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.0663, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.0060999999999999995, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0.0060999999999999995, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0.0060999999999999995, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0, + memUsageKb: 87203.84, + }, + { + cpuPercent: 0.0751, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.0751, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0.06709999999999999, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0.06709999999999999, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0.0126, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.0126, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.0712, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.0712, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.0756, + memUsageKb: 87265.28, + }, + { + cpuPercent: 0.0756, + memUsageKb: 87265.28, + }, + { + cpuPercent: 0.011899999999999999, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0.011899999999999999, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87224.32, + }, + { + cpuPercent: 0, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0, + memUsageKb: 87214.08, + }, + { + cpuPercent: 0.06559999999999999, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.06559999999999999, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.0676, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0.0676, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0.0052, + memUsageKb: 87285.76, + }, + { + cpuPercent: 0.0052, + memUsageKb: 87285.76, + }, + { + cpuPercent: 0, + memUsageKb: 87285.76, + }, + { + cpuPercent: 0, + memUsageKb: 87285.76, + }, + { + cpuPercent: 0, + memUsageKb: 87285.76, + }, + { + cpuPercent: 0, + memUsageKb: 87285.76, + }, + { + cpuPercent: 0, + memUsageKb: 87275.52, + }, + { + cpuPercent: 0, + memUsageKb: 87275.52, + }, + { + cpuPercent: 0.0694, + memUsageKb: 87418.88, + }, + { + cpuPercent: 0.0694, + memUsageKb: 87418.88, + }, + { + cpuPercent: 0, + memUsageKb: 87306.24, + }, + { + cpuPercent: 0, + memUsageKb: 87306.24, + }, + { + cpuPercent: 0, + memUsageKb: 87296, + }, + { + cpuPercent: 0, + memUsageKb: 87296, + }, + { + cpuPercent: 0, + memUsageKb: 87296, + }, + { + cpuPercent: 0, + memUsageKb: 87296, + }, + { + cpuPercent: 0, + memUsageKb: 87296, + }, + { + cpuPercent: 0, + memUsageKb: 87296, + }, + { + cpuPercent: 0.0672, + memUsageKb: 99215.36, + }, + { + cpuPercent: 0.0672, + memUsageKb: 99215.36, + }, + { + cpuPercent: 0.0182, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.0182, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.0681, + memUsageKb: 87377.92, + }, + { + cpuPercent: 0.0681, + memUsageKb: 87377.92, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.0886, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0.0886, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0, + memUsageKb: 87234.56, + }, + { + cpuPercent: 0.0742, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0.0742, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.0697, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.0697, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: 0.0616, + memUsageKb: 87623.68, + }, + { + cpuPercent: 0.0616, + memUsageKb: 87623.68, + }, + { + cpuPercent: 0, + memUsageKb: 87265.28, + }, + { + cpuPercent: 0, + memUsageKb: 87265.28, + }, + { + cpuPercent: 0, + memUsageKb: 87265.28, + }, + { + cpuPercent: 0, + memUsageKb: 87265.28, + }, + { + cpuPercent: 0, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0, + memUsageKb: 87255.04, + }, + { + cpuPercent: 0.054000000000000006, + memUsageKb: 87429.12, + }, + { + cpuPercent: 0.054000000000000006, + memUsageKb: 87429.12, + }, + { + cpuPercent: 0, + memUsageKb: 87244.8, + }, + { + cpuPercent: null, + memUsageKb: 0, + }, + ], +}; diff --git a/docs/src/content/docs/reference/_benchmarks/trailbase_utilization.ts b/docs/src/content/docs/reference/_benchmarks/trailbase_utilization.ts new file mode 100644 index 0000000..70380e5 --- /dev/null +++ b/docs/src/content/docs/reference/_benchmarks/trailbase_utilization.ts @@ -0,0 +1,143 @@ +type Datum = { + cpu: number; + rss: number; +}; + +export const data: Datum[] = [ + { + cpu: 0, + rss: 53640, + }, + { + cpu: 0, + rss: 53640, + }, + { + cpu: 0, + rss: 53640, + }, + { + cpu: 0, + rss: 53640, + }, + { + cpu: 0, + rss: 53640, + }, + { + cpu: 0, + rss: 53640, + }, + { + cpu: 0, + rss: 53640, + }, + { + cpu: 0, + rss: 53640, + }, + { + cpu: 1.2, + rss: 100256, + }, + { + cpu: 2.273, + rss: 70444, + }, + { + cpu: 3.1, + rss: 71984, + }, + { + cpu: 3.1, + rss: 74032, + }, + { + cpu: 3.2, + rss: 76436, + }, + { + cpu: 3.2, + rss: 79124, + }, + { + cpu: 3.2, + rss: 83760, + }, + { + cpu: 3.2, + rss: 86192, + }, + { + cpu: 3.2, + rss: 95820, + }, + { + cpu: 3.2, + rss: 103000, + }, + { + cpu: 3.2, + rss: 111724, + }, + { + cpu: 3.2, + rss: 116524, + }, + { + cpu: 3.2, + rss: 116748, + }, + { + cpu: 1.2, + rss: 110208, + }, + { + cpu: 0.7, + rss: 111360, + }, + { + cpu: 0.636, + rss: 112768, + }, + { + cpu: 0.6, + rss: 113024, + }, + { + cpu: 0.6, + rss: 113024, + }, + { + cpu: 0.5, + rss: 113024, + }, + { + cpu: 0.5, + rss: 112844, + }, + { + cpu: 0, + rss: 112844, + }, + { + cpu: 0, + rss: 112844, + }, + { + cpu: 0, + rss: 112844, + }, + { + cpu: 0, + rss: 112844, + }, + { + cpu: 0, + rss: 112844, + }, + { + cpu: 0, + rss: 112844, + }, +]; diff --git a/docs/src/content/docs/reference/_sql.mdx b/docs/src/content/docs/reference/_sql.mdx new file mode 100644 index 0000000..c763286 --- /dev/null +++ b/docs/src/content/docs/reference/_sql.mdx @@ -0,0 +1,74 @@ +--- +title: SQL +description: A short intro to SQL. +--- + +SQL is a functional general-purpose query language for relational databases. It +is as old as relational databases themselves. +It allows to define arbitrary data look-ups and computations w/o hard-coding a +specific database implementation or storage structure (presence of indexes, +storage layout, ...). +Yet many foster a love-hate relationship with it, which has given rise to an +entire cottage industry of ORMs: higher-level, often type-safe abstractions +with bindings for your favorite programming language. +They tend to work great until you need to break glass. In recent years, there +has been a push to thinner and thinner abstractions. + +Instead of hiding or working around SQL, TrailBase embraces it as an evergreen, +transferable skill. +There's no denying that SQL can get tricky but it's +also its greatest strength. SQL is a general purpose functional (sometimes +imperative) programming language that lets you solve arbitrary problems in a +high-level, portable fashion. In other words, learning SQL is a lot more useful +than learning a specific ORM or similar abstractions. + +One thing that's pretty sweet about SQL is that, almost any relational database +(Postgres, MySQL, MS SQL, sqlite, ...) supports some dialect of SQL, which +makes this a pretty transferable skill. This is only possible because SQL is +pretty abstract. Given a lookup or transformation, different databases might +execute them quite differently depending on their capabilities, how the data +structures are set up, and how the look is expressed. All of this is facilitate +through the magic of the query-planner, which takes your query and turns it +into a physical execution plan. + +## A gentle introduction + +At first glance, SQL may look just like some antiquated filter language but in +practice it's a high-level, functional programming language that is optimized +for table transformations. All that SQLs does is: + +1. inserting rows (INSERT), +2. removing rows (DELETE), +3. updating rows (UPDATE), +4. and transforming NxM tables into PxQ tables (SELECT). + +5. to 3. are pretty simple, they operate on rows. + +SELECT is by far the most complex tool, think of it as writing a function that +takes in one or more table, even combined tables, filters them, and them +transforms them into a new table. They might look overwhelming at first: + +```sql +SELECT + Output: outcol0, outcol1, .. +FROM + Input: incol0, incol1, +WHERE Filter: e.g. where incol0 > 10 +ORDER BY outcol0 ASC +GROUP BY aggregation +; +``` + +but most of it is optional. The simplest statement is `SELECT 1;`, it creates a +new table with one row and one column containing the value of `1` (a scalar) +from nothing. + +Now that we can create a new column from thin air, let's transform it: + +```sql +SELECT col*2 FROM (SELECT 1 as col); +``` + +Now we created two nested SELECTs, inner one creates the table we already know +but names the column `col` and the outer SELECT transforms that table into a +new 1x1 table with the values doubled. diff --git a/docs/src/content/docs/reference/benchmarks.mdx b/docs/src/content/docs/reference/benchmarks.mdx new file mode 100644 index 0000000..4fc1768 --- /dev/null +++ b/docs/src/content/docs/reference/benchmarks.mdx @@ -0,0 +1,207 @@ +--- +title: Benchmarks +description: Performance comparison with similar products. +--- + +import { + Duration100kInsertsChart, + PocketBaseAndTrailBaseReadLatencies, + PocketBaseAndTrailBaseInsertLatencies, + SupaBaseMemoryUsageChart, + SupaBaseCpuUsageChart, + PocketBaseAndTrailBaseUsageChart, +} from "./_benchmarks/benchmarks.tsx"; + +TrailBase is merely the sum of its parts. It's the result of marrying one of +the lowest-overhead languages, one of the fastest HTTP servers, and one of the +lightest relational SQL databases, while merely avoiding extra expenditures. +We did expect it to go fast but how fast exactly? Let's take a brief look at +how TrailBase performs compared to a few amazing, and more weathered +alternatives such as SupaBase, PocketBase, and vanilla SQLite. + +## Disclaimer + +In general, benchmarks are tricky, both to do well and to interpret. +Benchmarks never show how fast something can theoretically go but merely how +fast the author managed to make it go. +Micro-benchmarks, especially, offer a selective key-hole insights, which may be +biased and may or may not apply to your workload. + +Performance also doesn't exist in a vacuum. If something is super fast but +doesn't do what you need it to do, performance is an illusive luxury. +Doing less makes it naturally easier to go fast, which is not a bad thing, +however means that comparing a highly specialized solution to a more general +one on a specific aspect can be misleading or "unfair". +Specifically, PocketBase and SupaBase have both been around for longer offering +a different and in many cases more comprehensive features. + +We tried our hardest to give all contenders the best chance to go fast [^1]. +We were surprised by the performance gap ourselves and thus went back and +forth. We suspect that any overhead weighs so heavily because of how quick +SQLite itself is. +If you spot any issues or have ideas to make anyone go faster, +[we want to know](https://github.com/trailbaseio/trailbase-benchmark). +We hope to improve the methodology over time, make the numbers more broadly +applicable, and as fair as an apples-to-oranges comparison can be. +With that said, we hope that the results can provide at least some insights +into what to expect when taken with a grain of salt. +Ultimately, nothing beats benchmarking your own workload and setup. + +## Insertion Benchmarks + +_Total Time for 100k Insertions_ + +
+
+ +
+
+ +The graph shows the overall time it takes to insert 100k messages into a mock +"chat-room" table setup. Less time is better. + +Unsurprisingly, in-process SQLite is the quickest [^2]. +All other setups add additional table look-ups for access checking, IPC +overhead[^3], and layers of features on top. +Maybe think of this data point as an upper bound to how fast SQLite could go +and the cost a project would pay by adopting any of the systems over in-process +SQLite. + +The data suggests that depending on your setup (client, data, hardware) +TrailBase can insert 100k records 9 to 16 times faster than SupaBase[^4] and +roughly 6 to 7 times faster than PocketBase [^1]. +The fact that our TS/node.js benchmark is slower than the Dart one, suggests a +client-side bottleneck that could be overcome by tuning the setup or trying +other JS runtimes with lower overhead HTTP clients. + +Total time of inserting a large batch of data tells only part of the story, +let's have a quick look at resource consumption to get an intuition for +provisioning or footprint requirements: + +_TrailBase & PocketBase Utilization_ + +
+
+ +
+
+ +The graph shows the CPU utilization and memory consumption (RSS) of both +PocketBase and TrailBase. They look fairly similar apart from TrailBase +finishing earlier. They both load roughly 3 CPUs with PocketBase's CPU +consumption being slightly more variable [^5]. +The little bump after the TrailBase run is likely due to SQLite check-pointing. + +Both only consume about 140MB of memory at full tilt, which makes them a great +choice for running on a tiny VPS or a toaster. + +SupaBase is a bit more involved due to it's +[layered architecture](https://supabase.com/docs/guides/getting-started/architecture) +including a dozen separate services that provide a ton of extra functionality: + +_SupaBase Memory Usage_ + +
+
+ +
+
+ +Looking at SupaBase's memory usage, it increased from from roughly 6GB at rest to +7GB fully loaded. +This means that out of the box, SupaBase has roughly 50 times the memory +footprint of either PocketBase or TrailBase. +In all fairness, there's a lot of extra functionality and it might be possible +to further optimize the setup by shedding some less critical services, e.g. +removing "supabase-analytics" may safe ~40% of memory. That said, we don't know +how feasible this is in practice. + +_SupaBase CPU utilization_ + +
+
+ +
+
+ +Looking at the CPU usage You can see how the CPU usage jumps up to roughly 9 +cores (the benchmark ran on a machine with 8 physical cores and 16 threads: +7840U). Most of the CPUs seem to be consumed by "supabase-rest" with postgres +itself hovering at only ~0.7. + +## Latency and Read Performance + +In this chapter we'll take a closer look at latency distributions. To keep +things manageable we'll focus on PocketBase and TrailBase, which are +architecturally simpler and more comparable. + +Reads were on average 3.5 faster with TrailBase and insertions 6x as discussed +above. + +
+
+ +
+ +
+ +
+
+ +Looking at the latency distributions we can see that the spread is well +contained for TrailBase. For PocketBase, read latencies are also generally well +contained and predictable. +However, insert latencies show a more significant "long tail" with their p90 +being roughly 5x longer than therr p50. +Slower insertions can take north of 100ms. There may or may not be a connection +to the variability in CPU utilization we've seen above. + +## Final Words + +We're very happy to confirm that TrailBase is quick. The significant +performance gap we observed might just be a consequence of how much overhead +matters given how quick SQLite itself is. +Yet, it challenges our intuition. With the numbers fresh of the press, prudence is +of the essence. We'd like to re-emphasize how important it is to run your own +tests with your specific setup and workloads. +In any case, we hope this was interesting nonetheless and let us know if you +see anything that can or should be improved. +The benchmarks are available on [GitHub](https://github.com/trailbaseio/trailbase-benchmark). + +
+ +--- + +[^1]: + Trying to give PocketBase the best chance, the binary was built with the + latest go compiler (v1.23.1 at the time of writing), `CGO_ENABLED=1` (which + according to PB's own documentation will use a faster C-based SQLite + driver) and `GOAMD64=v4` (for less portable but more aggressive CPU + optimizations). + We found this setup to be roughly 20% faster than the static, pre-built + binary release. + +[^2]: + Our setup with drizzle and node.js is certainly not the fastest possible. + For example, we could drop down to using raw SQLite in C or another + low-level language. + That said, drizzle is a great popular choice which mostly serves as a + point-of-reference and sanity check. + +[^3]: + The actual magnitude on IPC overhead will depend on the communication cost. + For the benchmarks at hand we're using a loopback network device. + +[^4]: + The SupaBase benchmark setup skips row-level access checks. Technically, + this is in its favor from a performance standpoint, however looking at the + overall load on its constituents with PG being only a sliver, it probably + would not make much of an overall difference nor would PG17's vectorization, + which has been released since the benchmarks were run. That said, these + claims deserve re-validation. + +[^5]: + We're unsure as to what causes these 1-core swings. + Runtime-effects, such as garbage collection, may have an effect, however we + would have expected these to show on shorter time-scales. + This could also indicate a contention or thrashing issue 🤷. diff --git a/docs/src/content/docs/reference/faq.mdx b/docs/src/content/docs/reference/faq.mdx new file mode 100644 index 0000000..c5748b7 --- /dev/null +++ b/docs/src/content/docs/reference/faq.mdx @@ -0,0 +1,107 @@ +--- +title: FAQ +description: Frequently Asked Questions +--- + +## How is TrailBase different from PocketBase, SupaBase or other application bases? + +Naturally there's a lot of overlap but let's start by saying that +we're also huge fans of SupaBase and PocketBase. +The former is incredibly versatile, while the latter is incredibly easy and +cheap to use. +As far as we can tell, PocketBase pioneered the notion of a single-file, +SQLite-based, FireBase-like server. + +TrailBase is an attempt at combining the flexibility and principled +architecture of SupaBase with the ease and low-overhead of PocketBase. +We owe a great debt of gratitude to both ❤️. + +Let's address the elephant in the room: other more established solutions are +more polished, may have more extensive feature sets in many areas, and have +seen a lot more mileage. +TrailBase is committed to catch up and challenge the status quo following our +[principles](/getting-started/philosophy) and in many ways, TrailBase is +already incredibly easy to deploy and [blazingly fast](/reference/benchmarks). + +We also offer some slightly more detailed comparisons to both +[PocketBase](/comparison/pocketbase) and [SupaBase](/comparison/supabase). + +## Is TrailBase ready for production use? + +TrailBase has not seen a lot of mileage yet and there's probably plenty of +sharp edges, which will take some time to smooth over. +That said, it's also incredibly simple, easy to get on, and easy to get off. +We're welcoming any brave soul who would like to be an early adopter. +If you're curious and patient, we're ready to help you get off the ground in +return for your honest feedback 🙏. +You can take a look at the preliminary +[productionization](/documentation/production). + +## Scale, performance and reliability + +As my product grows, will TrailBase scale with me or will I hit a wall? +Firstly, congratulations! The "success"-problem is a great problem to have 🎉. + +Short, hand-wavy answer: you'll face all the same issues as with other +solutions but you probably will be fine 😶‍🌫️ . + +Long answer: TrailBase currently only scales vertically, however it's incredibly +fast. Besides, there's an inherent beauty to vertical scaling [^1] and modern +servers can get you very very far. +You can absolutely support tens of thousands or even hundreds thousands of +concurrent users with a single database. +With TrailBase simple deployment, it may also be an option to shard your users +or tenants across multiple databases. In the future, TrailBase would also like +to support multi-database setups out-of-the-box to further improve concurrency. + +Keep in mind that other databases, like MySQL or Postgres, aren't a silver bullet either. +If you're reaching massive levels of scale, more specialized solutions will +become more and more attractive such as non-relational document stores, +columnar OLAP stores for analytic workloads, ... + +TrailBase explicitly tries to avoid tight coupling locking you in. At the end +of the day, you're using very plain SQLite, letting you adopt and drop +TrailBase when it makes sense. +Similarly, the stateless auth flow makes it easy to split out your logic and +data while continuing to use TrailBase. + +Besides pure scale and performance, many more horizontal solutions provide +additional benefits such as disaster-recovery/fail-over or improved edge read +latency. +Fortunately, both can be achieved with SQLite as well using solutions like +[LiteStream](https://litestream.io/) keeping eventually consistent copies of +your data. + +## Can we add Features to TrailBase? + +Yes! First take a look at our coarse [roadmap](/reference/roadmap), maybe we're +already working on it? +Otherwise, don't hesitate, just open an issue and ask away. We love to hear +your thoughts. +Contributions are also very welcome, let's just talk upfront to avoid any +surprises. +Especially, in the early days we'll have to see how "things" fit into the +roadmap. +For example, having a dark mode for the dashboard would be nice but it's also +extra work to maintain while the dashboard is still rapidly changing, so it +becomes a question of when. + +## Data Import & Export + +Few requirements: `STRICT` table and an auto-incrementing primary key for +collections but the dashboard will work for any table, view, etc. +You can simply import and export data with standard SQLite tooling, e.g.: + +```shell +sqlite3 main.db < import.sql +``` + +Also check out the [getting started](/getting-started/first-app) guide. + +
+ +--- + +[^1]: + Adopting more complex multi-tiered database solutions comes with its own + challenges for operations, testing, and developer setups. diff --git a/docs/src/content/docs/reference/roadmap.mdx b/docs/src/content/docs/reference/roadmap.mdx new file mode 100644 index 0000000..4587f25 --- /dev/null +++ b/docs/src/content/docs/reference/roadmap.mdx @@ -0,0 +1,7 @@ +--- +title: Roadmap +--- + +import Roadmap from "../_roadmap.md"; + + diff --git a/docs/src/env.d.ts b/docs/src/env.d.ts new file mode 100644 index 0000000..acef35f --- /dev/null +++ b/docs/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/docs/src/lib/darkmode.ts b/docs/src/lib/darkmode.ts new file mode 100644 index 0000000..d9bd077 --- /dev/null +++ b/docs/src/lib/darkmode.ts @@ -0,0 +1,24 @@ +import { createSignal, onCleanup, onMount } from "solid-js"; +import type { Accessor } from "solid-js"; + +export function createDarkMode(): Accessor { + const isDark = () => document.documentElement.dataset["theme"] === "dark"; + + const [darkMode, setDarkMode] = createSignal(isDark()); + + let observer: MutationObserver | undefined; + + onMount(() => { + observer = new MutationObserver((mutations) => { + mutations.forEach((mu) => { + if (mu.type === "attributes" && mu.attributeName === "data-theme") { + setDarkMode(isDark()); + } + }); + }); + observer.observe(document.documentElement, { attributes: true }); + }); + onCleanup(() => observer?.disconnect()); + + return darkMode; +} diff --git a/docs/src/tailwind.css b/docs/src/tailwind.css new file mode 100644 index 0000000..0d592b5 --- /dev/null +++ b/docs/src/tailwind.css @@ -0,0 +1,38 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --overlay-accent: #92d1fe30; +} + +:root[data-theme="dark"] { + --overlay-accent: #92d1fe70; +} + +[data-has-hero] .page { + background: + linear-gradient(215deg, var(--overlay-accent), transparent 40%), + radial-gradient(var(--overlay-accent), transparent 40%) no-repeat -60vw -40vh / + 105vw 200vh, + radial-gradient(var(--overlay-accent), transparent 65%) no-repeat 50% + calc(100% + 20rem) / 60rem 30rem; +} + +.card { + border-radius: 0.75rem; +} + +[data-has-hero] header { + border-bottom: 1px solid transparent; + background-color: #ffffffc0; + -webkit-backdrop-filter: blur(32px); + backdrop-filter: blur(32px); +} + +[data-has-hero][data-theme="dark"] header { + border-bottom: 1px solid transparent; + background-color: transparent; + -webkit-backdrop-filter: blur(32px); + backdrop-filter: blur(32px); +} diff --git a/docs/tailwind.config.mjs b/docs/tailwind.config.mjs new file mode 100644 index 0000000..e8d9b14 --- /dev/null +++ b/docs/tailwind.config.mjs @@ -0,0 +1,33 @@ +import starlightPlugin from "@astrojs/starlight-tailwind"; + +// Generated color palettes +const accent = { + 200: "#92d1fe", + 600: "#0073aa", + 900: "#003653", + 950: "#00273d", +}; +const gray = { + 100: "#f3f7f9", + 200: "#e7eff2", + 300: "#bac4c8", + 400: "#7b8f96", + 500: "#495c62", + 700: "#2a3b41", + 800: "#182a2f", + 900: "#121a1c", +}; + +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"], + theme: { + extend: { + colors: { + accent, + gray, + }, + }, + }, + plugins: [starlightPlugin()], +}; diff --git a/docs/tsconfig.json b/docs/tsconfig.json new file mode 100644 index 0000000..ba1912b --- /dev/null +++ b/docs/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js", + "baseUrl": "./", + "paths": { + "@/*": ["./src/*"], + "@assets/*": ["../assets/*"], + "@common/*": ["../ui/common/*"] + } + }, + "exclude": [ + "dist", + "node_modules", + "public" + ] +} diff --git a/examples/blog/Caddyfile b/examples/blog/Caddyfile new file mode 100644 index 0000000..565dbf4 --- /dev/null +++ b/examples/blog/Caddyfile @@ -0,0 +1,5 @@ +# example.com + +localhost +encode gzip zstd +reverse_proxy blog:4000 diff --git a/examples/blog/Dockerfile b/examples/blog/Dockerfile new file mode 100644 index 0000000..6253df2 --- /dev/null +++ b/examples/blog/Dockerfile @@ -0,0 +1,39 @@ +# syntax = edrevo/dockerfile-plus + +# NOTE: paths are relative to build context, which is trailbase's root otherwise we +# cannot build the trailbase server as well. + +INCLUDE+ Dockerfile + +FROM chef AS webapp_builder + +COPY examples/blog/web /app +WORKDIR /app + +RUN pnpm install --no-frozen-lockfile +RUN pnpm run build + +FROM debian:bookworm-slim AS runtime +RUN apt-get update && apt-get install -y --no-install-recommends tini curl + +COPY --from=builder /app/target/x86_64-unknown-linux-gnu/release/trail /app/ +COPY --from=webapp_builder /app/dist /app/public + +# When `docker run` is executed, launch the binary as unprivileged user. +ENV USERNAME=trailbase +RUN adduser \ + --disabled-password \ + --gecos "" \ + --home "$(pwd)" \ + --no-create-home \ + ${USERNAME} +USER ${USERNAME} + +WORKDIR /app + +EXPOSE 4000 +ENTRYPOINT ["tini", "--"] + +CMD ["/app/trail", "run"] + +HEALTHCHECK CMD curl --fail http://localhost:4000/api/healthcheck || exit 1 diff --git a/examples/blog/Makefile b/examples/blog/Makefile new file mode 100644 index 0000000..de897ee --- /dev/null +++ b/examples/blog/Makefile @@ -0,0 +1,45 @@ +outputs = \ + web/types/article.ts \ + web/types/profile.ts \ + web/types/new_profile.ts \ + flutter/lib/types/article.dart \ + flutter/lib/types/profile.dart \ + flutter/lib/types/new_profile.dart + +types: $(outputs) + +schema/article.json: + cargo run -- schema articles_view --mode select > $@ +web/types/article.ts: schema/article.json + pnpm quicktype -s schema $< -o $@ +flutter/lib/types/article.dart: schema/article.json + pnpm quicktype -s schema $< -o $@ + +schema/new_article.json: + cargo run -- schema articles_view --mode insert > $@ +web/types/new_article.ts: schema/new_article.json + pnpm quicktype -s schema $< -o $@ + +schema/profile.json: + cargo run -- schema profiles_view --mode select > $@ +web/types/profile.ts: schema/profile.json + pnpm quicktype -s schema $< -o $@ +flutter/lib/types/profile.dart: schema/profile.json + pnpm quicktype -s schema $< -o $@ + +schema/new_profile.json: + cargo run -- schema profiles --mode insert > $@ +web/types/new_profile.ts: schema/new_profile.json + pnpm quicktype -s schema $< -o $@ +flutter/lib/types/new_profile.dart: schema/new_profile.json + pnpm quicktype -s schema $< -o $@ + +clean_data: + rm -f traildepot/data/* + +clean_types: + rm -f schema/* web/types/* flutter/lib/types/* + +clean: clean_data clean_types + +.PHONY: clean clean_data clean_types diff --git a/examples/blog/README.md b/examples/blog/README.md new file mode 100644 index 0000000..8958391 --- /dev/null +++ b/examples/blog/README.md @@ -0,0 +1,80 @@ +# TrailBase Example: A Blog with Web and Mobile clients + +The main goal of this example is to be easily digestible while show-casing many +of TrailBase's capabilities both for web and cross-platform Flutter: + +* Bootstrapping the database including schemas and dummy content though migration. +* End-to-end type-safety through code-generated data models for TypeScript, + Dart and many more based on JSON Schema. +* Builtin web authentication flow (including OAuth) on web and Flutter as well + as a custom password-based login in Flutter. +* API authorization: world readable, user editable, and moderator manageable articles. +* Different API types: + * Table and View-based APIs for custom user profiles associating users with a + username and keep their email addresses private as well as associating + articles with usernames. + * Virtual-table-based query API to expose "is_editor" authorization. +* The web client illustrates two different styles: a consumer SPA and an + HTML-only form-based authoring UI. + +## Directory Structure + +``` +. +├── Caddyfile # Example reverse proxy for TLS termination +├── Dockerfile # Example for bundling web app +├── docker-compose.yml # Example setup with reverse proxy +├── flutter # +│   ├── lib # Flutter app lives here +│   └── ... # Most other files a default cross-platform setup +├── Makefile # Builds JSON schemas and coge-generates type definitions +├── schema # Checked-in JSON schemas +├── traildepot # Where TrailBase keeps its runtime data +│   ├── backups # Periodic DB backups +│   ├── data # Contains SQLite's DB and WAL +│   ├── migrations # Bootstraps DB with schemas and dummy content +│   ├── secrets # Nothing to see :) +│   └── uploads # Local file uploads (will support S3 soon) +└── web + ├── dist # Built/packaged web app + ├── src # Web app lives here + └── types # Generated type definitions + └── ... +``` + +## Instructions + +Generally speaking, there are roughly 2.5 moving parts to run the example, i.e: +we have to build the web UI, start the TrailBase server, and optionally start +the Flutter app. Once you have `cargo`, `pnpm`, and `flutter` installed, you +can simply run: + +```bash +# From within the blog examples base directory +$ cd $REPO/examples/blog + +# build and bundle the web app: +$ pnpm --dir web build + +# Start TrailBase: +cargo run --bin trail -- run --public web/dist + +# Start Flutter app: +$ cd flutter +$ flutter run -d +``` + +You can also try the code generation: + +```bash +# Optionally delete the checked-in JSON schemas and code first +$ make clean_types + +# Genarate JSON Schema and codegen types from DB schema (this requires that +# you start TrailBase first to initialize the DB) +$ make --always-make types +``` + +## Reference + +* The styling is based on: https://github.com/palmiak/pacamara-astro 🙏 diff --git a/examples/blog/caddy/config/.gitignore b/examples/blog/caddy/config/.gitignore new file mode 100644 index 0000000..72e8ffc --- /dev/null +++ b/examples/blog/caddy/config/.gitignore @@ -0,0 +1 @@ +* diff --git a/examples/blog/caddy/data/.gitignore b/examples/blog/caddy/data/.gitignore new file mode 100644 index 0000000..72e8ffc --- /dev/null +++ b/examples/blog/caddy/data/.gitignore @@ -0,0 +1 @@ +* diff --git a/examples/blog/docker-compose.yml b/examples/blog/docker-compose.yml new file mode 100644 index 0000000..7be68a1 --- /dev/null +++ b/examples/blog/docker-compose.yml @@ -0,0 +1,33 @@ +services: + + blog: + # NOTE: We have to build relative to root to have a build context that + # includes both: the trailbase server source and the demo wepapp sources. + # build: ../.. + # TODO: Build from "." once the Dockerfile can pull a base image from + # dockerhub. We still need an example Dockerfile to build the UI. + build: + context: ../.. + dockerfile: examples/blog/Dockerfile + restart: unless-stopped + environment: + TRAIL_INITIAL_PASSWORD: secret + ADDRESS: 0.0.0.0:4000 + PUBLIC_DIR: ./public + DATA_DIR: ./traildepot + volumes: + - ./traildepot:/app/traildepot + + caddy: + image: caddy:2.8-alpine + restart: unless-stopped + cap_add: + - NET_ADMIN + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + - ./caddy/data:/data + - ./caddy/config:/config diff --git a/examples/blog/flutter/.gitignore b/examples/blog/flutter/.gitignore new file mode 100644 index 0000000..4e9b8f3 --- /dev/null +++ b/examples/blog/flutter/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +flutter_jank*.json diff --git a/examples/blog/flutter/.metadata b/examples/blog/flutter/.metadata new file mode 100644 index 0000000..e1116b2 --- /dev/null +++ b/examples/blog/flutter/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "2c18f27a9efabdc352cc294260a36c1335c7b98f" + channel: "[user-branch]" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 2c18f27a9efabdc352cc294260a36c1335c7b98f + base_revision: 2c18f27a9efabdc352cc294260a36c1335c7b98f + - platform: android + create_revision: 2c18f27a9efabdc352cc294260a36c1335c7b98f + base_revision: 2c18f27a9efabdc352cc294260a36c1335c7b98f + - platform: ios + create_revision: 2c18f27a9efabdc352cc294260a36c1335c7b98f + base_revision: 2c18f27a9efabdc352cc294260a36c1335c7b98f + - platform: linux + create_revision: 2c18f27a9efabdc352cc294260a36c1335c7b98f + base_revision: 2c18f27a9efabdc352cc294260a36c1335c7b98f + - platform: macos + create_revision: 2c18f27a9efabdc352cc294260a36c1335c7b98f + base_revision: 2c18f27a9efabdc352cc294260a36c1335c7b98f + - platform: web + create_revision: 2c18f27a9efabdc352cc294260a36c1335c7b98f + base_revision: 2c18f27a9efabdc352cc294260a36c1335c7b98f + - platform: windows + create_revision: 2c18f27a9efabdc352cc294260a36c1335c7b98f + base_revision: 2c18f27a9efabdc352cc294260a36c1335c7b98f + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/examples/blog/flutter/analysis_options.yaml b/examples/blog/flutter/analysis_options.yaml new file mode 100644 index 0000000..efcadb7 --- /dev/null +++ b/examples/blog/flutter/analysis_options.yaml @@ -0,0 +1,21 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - lib/types/** + +linter: + rules: + prefer_single_quotes: true + unnecessary_brace_in_string_interps: false + unawaited_futures: true + sort_child_properties_last: false diff --git a/examples/blog/flutter/android/.gitignore b/examples/blog/flutter/android/.gitignore new file mode 100644 index 0000000..55afd91 --- /dev/null +++ b/examples/blog/flutter/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/examples/blog/flutter/android/app/build.gradle b/examples/blog/flutter/android/app/build.gradle new file mode 100644 index 0000000..d2775f4 --- /dev/null +++ b/examples/blog/flutter/android/app/build.gradle @@ -0,0 +1,44 @@ +plugins { + id "com.android.application" + id "kotlin-android" + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id "dev.flutter.flutter-gradle-plugin" +} + +android { + namespace = "com.example.trailbase_blog" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.trailbase_blog" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.debug + } + } +} + +flutter { + source = "../.." +} diff --git a/examples/blog/flutter/android/app/src/debug/AndroidManifest.xml b/examples/blog/flutter/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/examples/blog/flutter/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/examples/blog/flutter/android/app/src/main/AndroidManifest.xml b/examples/blog/flutter/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..406d1b8 --- /dev/null +++ b/examples/blog/flutter/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/blog/flutter/android/app/src/main/kotlin/com/example/trailbase_blog/MainActivity.kt b/examples/blog/flutter/android/app/src/main/kotlin/com/example/trailbase_blog/MainActivity.kt new file mode 100644 index 0000000..f6811cc --- /dev/null +++ b/examples/blog/flutter/android/app/src/main/kotlin/com/example/trailbase_blog/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.trailbase_blog + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/examples/blog/flutter/android/app/src/main/res/drawable-v21/launch_background.xml b/examples/blog/flutter/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/examples/blog/flutter/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/examples/blog/flutter/android/app/src/main/res/drawable/launch_background.xml b/examples/blog/flutter/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/examples/blog/flutter/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/examples/blog/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/examples/blog/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/examples/blog/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/examples/blog/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/examples/blog/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/examples/blog/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/examples/blog/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/examples/blog/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/examples/blog/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/examples/blog/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/examples/blog/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/examples/blog/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/examples/blog/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/examples/blog/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/examples/blog/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/examples/blog/flutter/android/app/src/main/res/values-night/styles.xml b/examples/blog/flutter/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/examples/blog/flutter/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/examples/blog/flutter/android/app/src/main/res/values/styles.xml b/examples/blog/flutter/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/examples/blog/flutter/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/examples/blog/flutter/android/app/src/profile/AndroidManifest.xml b/examples/blog/flutter/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/examples/blog/flutter/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/examples/blog/flutter/android/build.gradle b/examples/blog/flutter/android/build.gradle new file mode 100644 index 0000000..d2ffbff --- /dev/null +++ b/examples/blog/flutter/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/examples/blog/flutter/android/gradle.properties b/examples/blog/flutter/android/gradle.properties new file mode 100644 index 0000000..2597170 --- /dev/null +++ b/examples/blog/flutter/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/examples/blog/flutter/android/gradle/wrapper/gradle-wrapper.properties b/examples/blog/flutter/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7bb2df6 --- /dev/null +++ b/examples/blog/flutter/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip diff --git a/examples/blog/flutter/android/settings.gradle b/examples/blog/flutter/android/settings.gradle new file mode 100644 index 0000000..b9e43bd --- /dev/null +++ b/examples/blog/flutter/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.1.0" apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false +} + +include ":app" diff --git a/examples/blog/flutter/ios/.gitignore b/examples/blog/flutter/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/examples/blog/flutter/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/examples/blog/flutter/ios/Flutter/AppFrameworkInfo.plist b/examples/blog/flutter/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..7c56964 --- /dev/null +++ b/examples/blog/flutter/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/examples/blog/flutter/ios/Flutter/Debug.xcconfig b/examples/blog/flutter/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/examples/blog/flutter/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/examples/blog/flutter/ios/Flutter/Release.xcconfig b/examples/blog/flutter/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/examples/blog/flutter/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/examples/blog/flutter/ios/Runner.xcodeproj/project.pbxproj b/examples/blog/flutter/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..65e3db0 --- /dev/null +++ b/examples/blog/flutter/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.trailbaseBlog; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.trailbaseBlog.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.trailbaseBlog.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.trailbaseBlog.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.trailbaseBlog; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.trailbaseBlog; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/examples/blog/flutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/examples/blog/flutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/examples/blog/flutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/examples/blog/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/examples/blog/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/examples/blog/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/examples/blog/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/examples/blog/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/examples/blog/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/examples/blog/flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/examples/blog/flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..8e3ca5d --- /dev/null +++ b/examples/blog/flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/blog/flutter/ios/Runner.xcworkspace/contents.xcworkspacedata b/examples/blog/flutter/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/examples/blog/flutter/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/examples/blog/flutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/examples/blog/flutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/examples/blog/flutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/examples/blog/flutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/examples/blog/flutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/examples/blog/flutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/examples/blog/flutter/ios/Runner/AppDelegate.swift b/examples/blog/flutter/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/examples/blog/flutter/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/examples/blog/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/examples/blog/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/examples/blog/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/examples/blog/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/examples/blog/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/examples/blog/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/examples/blog/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/examples/blog/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/examples/blog/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/examples/blog/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/examples/blog/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..b5b843a --- /dev/null +++ b/examples/blog/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. diff --git a/examples/blog/flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard b/examples/blog/flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/examples/blog/flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/blog/flutter/ios/Runner/Base.lproj/Main.storyboard b/examples/blog/flutter/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/examples/blog/flutter/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/blog/flutter/ios/Runner/Info.plist b/examples/blog/flutter/ios/Runner/Info.plist new file mode 100644 index 0000000..815168c --- /dev/null +++ b/examples/blog/flutter/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Trailbase Blog + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + trailbase_blog + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/examples/blog/flutter/ios/Runner/Runner-Bridging-Header.h b/examples/blog/flutter/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/examples/blog/flutter/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/examples/blog/flutter/ios/RunnerTests/RunnerTests.swift b/examples/blog/flutter/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/examples/blog/flutter/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/examples/blog/flutter/lib/main.dart b/examples/blog/flutter/lib/main.dart new file mode 100644 index 0000000..7333f0c --- /dev/null +++ b/examples/blog/flutter/lib/main.dart @@ -0,0 +1,237 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:trailbase/trailbase.dart'; + +import 'types/article.dart'; +import 'src/login.dart'; + +Future main() async { + Logger.root.level = Level.INFO; + Logger.root.onRecord.listen((record) { + // ignore: avoid_print + print( + '${record.level.name}: ${record.time} ${record.loggerName}: ${record.message}'); + }); + + final prefs = await SharedPreferences.getInstance(); + + final tokensJson = prefs.getString(_tokensKey); + Tokens? tokens; + try { + tokens = (tokensJson != null && tokensJson.isNotEmpty) + ? Tokens.fromJson(jsonDecode(tokensJson)) + : null; + } catch (err) { + _logger.warning(err); + } + + final user = ValueNotifier(null); + void onAuthChange(Client client, Tokens? tokens) { + user.value = client.user(); + prefs.setString(_tokensKey, tokens != null ? jsonEncode(tokens) : ''); + } + + const address = 'http://localhost:4000'; + final client = tokens != null + ? await Client.withTokens( + address, + tokens, + onAuthChange: onAuthChange, + ) + : Client(address, onAuthChange: onAuthChange); + + runApp(TrailbaseBlog(client, user)); +} + +class TrailbaseBlog extends StatelessWidget { + final ValueNotifier user; + final Client client; + + const TrailbaseBlog(this.client, this.user, {super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'TrailBase 🚀', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal), + useMaterial3: true, + ), + home: Landing(client: client, user: user), + ); + } +} + +class Landing extends StatefulWidget { + final ValueNotifier user; + final Client client; + + const Landing({ + super.key, + required this.client, + required this.user, + }); + + @override + State createState() => _LandingState(); +} + +class _LandingState extends State { + late final _articlesApi = widget.client.records('articles_view'); + final _articlesCtrl = StreamController>(); + + @override + void initState() { + super.initState(); + + _fetchArticles(); + } + + Future _fetchArticles() async { + try { + final records = await _articlesApi.list(); + _articlesCtrl.add(records.map((r) => Article.fromJson(r)).toList()); + } catch (err) { + _articlesCtrl.addError(err); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('TrailBase Blog 🚀'), + ValueListenableBuilder( + valueListenable: widget.user, + builder: (BuildContext context, User? user, Widget? _) { + if (user == null) { + return IconButton( + icon: const Icon(Icons.no_accounts), + onPressed: () => Scaffold.of(context).openEndDrawer(), + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(user.email), + const Icon(Icons.account_box), + ], + ); + }, + ), + ], + ), + ), + endDrawer: Drawer( + child: ListView( + padding: EdgeInsets.zero, + children: [ + const DrawerHeader( + decoration: BoxDecoration( + color: Colors.teal, + ), + child: Text( + '', + style: TextStyle( + color: Colors.white, + fontSize: 24, + ), + ), + ), + LoginFormWidget(client: widget.client), + ], + ), + ), + body: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: StreamBuilder( + stream: _articlesCtrl.stream, + builder: ( + BuildContext context, + AsyncSnapshot> articles, + ) { + final err = articles.error; + if (err != null) { + return Text( + 'Stream produced: ${err} ${widget.client.user()}'); + } + + final data = articles.data; + if (data == null) { + return const CircularProgressIndicator(); + } + + return ListView( + padding: const EdgeInsets.all(8), + children: data + .map((a) => ArticleWidget(api: _articlesApi, article: a)) + .toList(), + ); + }, + ), + ), + ], + ), + ); + } +} + +class ArticleWidget extends StatelessWidget { + final RecordApi api; + final Article article; + + const ArticleWidget({ + super.key, + required this.api, + required this.article, + }); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + + return Card( + child: Container( + padding: const EdgeInsets.all(24), + child: Row( + children: [ + if (article.image != null) ...[ + Image.network( + api.imageUri(RecordId.uuid(article.id), 'image').toString(), + width: 100, + ), + const SizedBox(width: 16), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(article.title, style: textTheme.titleLarge), + const SizedBox(height: 8), + Text(article.intro, style: textTheme.titleMedium), + const SizedBox(height: 12), + Text(article.body), + ], + ), + ), + ], + ), + ), + ); + } +} + +final _logger = Logger('main'); +const _tokensKey = 'tokens'; diff --git a/examples/blog/flutter/lib/src/login.dart b/examples/blog/flutter/lib/src/login.dart new file mode 100644 index 0000000..30ec207 --- /dev/null +++ b/examples/blog/flutter/lib/src/login.dart @@ -0,0 +1,174 @@ +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'package:logging/logging.dart'; +import 'package:trailbase/trailbase.dart'; + +class LoginFormWidget extends StatefulWidget { + final Client client; + + const LoginFormWidget({ + super.key, + required this.client, + }); + + @override + State createState() => _LoginFormState(); +} + +class _LoginFormState extends State { + final _usernameCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + + @override + void dispose() { + _usernameCtrl.dispose(); + _passwordCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), + child: TextFormField( + controller: _usernameCtrl, + decoration: const InputDecoration( + border: UnderlineInputBorder(), + labelText: 'E-mail', + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), + child: TextFormField( + controller: _passwordCtrl, + obscureText: true, + decoration: const InputDecoration( + border: UnderlineInputBorder(), + labelText: 'password', + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FilledButton( + child: const Text('OAuth'), + onPressed: () async { + final scaffold = Scaffold.of(context); + final messenger = ScaffoldMessenger.of(context); + + final redirectUri = _redirectUri(); + final callbackUrlScheme = _callbackUrlScheme(); + final (:verifier, :challenge) = Pkce.generate(); + + _logger.info( + 'redirect: ${redirectUri}; callbackUrlScheme: ${callbackUrlScheme}'); + + // Construct the login page url + final url = Uri.parse('${widget.client.site()}/_/auth/login') + .replace(queryParameters: { + 'redirect_to': redirectUri, + 'response_type': 'code', + 'pkce_code_challenge': challenge, + }); + + // Open a browser or webview to get an authorization code. + final result = await FlutterWebAuth2.authenticate( + url: url.toString(), + callbackUrlScheme: callbackUrlScheme, + options: const FlutterWebAuth2Options( + useWebview: false, + ), + ); + + _logger.info('RESULT: ${result}'); + + final String? code = + Uri.parse(result).queryParameters['code']; + if (code == null) { + _logger.warning('Failed to get auth code: ${result}'); + return; + } + + try { + await widget.client.loginWithAuthCode( + code, + pkceCodeVerifier: verifier, + ); + scaffold.closeEndDrawer(); + } catch (err) { + messenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 5), + content: Text(err.toString()), + ), + ); + } + }, + ), + + // Password login + FilledButton( + child: const Text('Login'), + onPressed: () { + final scaffold = Scaffold.of(context); + final messenger = ScaffoldMessenger.of(context); + (() async { + final client = widget.client; + try { + await client.login( + _usernameCtrl.text, _passwordCtrl.text); + scaffold.closeEndDrawer(); + } catch (err) { + messenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 5), + content: Text(err.toString()), + ), + ); + } + })(); + }, + ), + ], + ), + ], + ), + ); + } +} + +String _callbackUrlScheme() { + if (Platform.isLinux || Platform.isWindows) { + return 'http://localhost:22342'; + } + return 'trailbase-example-blog'; +} + +// See https://pub.dev/packages/flutter_web_auth_2#setup. +String _redirectUri() { + // On web we redirect to a different page web/auth.html which + // will then communicate back to `flutter_web_auth_2` via `postMessage()` apis. + if (kIsWeb) { + return '${Uri.base}auth.html'; + } + + // `flutter_web_auth_2` will start a local http server to receive the callback on Linux and Windows. + // Ideally, we'd pick a port that is guaranteed to be available but the entire + // approach is racy anyway :shrug:. + if (Platform.isLinux || Platform.isWindows) { + return 'http://localhost:22342'; + } + + return '${_callbackUrlScheme()}://login-callback'; +} + +final _logger = Logger('login'); diff --git a/examples/blog/flutter/lib/types/article.dart b/examples/blog/flutter/lib/types/article.dart new file mode 100644 index 0000000..20e25ed --- /dev/null +++ b/examples/blog/flutter/lib/types/article.dart @@ -0,0 +1,91 @@ +// To parse this JSON data, do +// +// final article = articleFromJson(jsonString); + +import 'dart:convert'; + +Article articleFromJson(String str) => Article.fromJson(json.decode(str)); + +String articleToJson(Article data) => json.encode(data.toJson()); + +class Article { + String author; + String body; + int created; + String id; + FileUpload? image; + String intro; + String tag; + String title; + String username; + + Article({ + required this.author, + required this.body, + required this.created, + required this.id, + this.image, + required this.intro, + required this.tag, + required this.title, + required this.username, + }); + + factory Article.fromJson(Map json) => Article( + author: json["author"], + body: json["body"], + created: json["created"], + id: json["id"], + image: + json["image"] == null ? null : FileUpload.fromJson(json["image"]), + intro: json["intro"], + tag: json["tag"], + title: json["title"], + username: json["username"], + ); + + Map toJson() => { + "author": author, + "body": body, + "created": created, + "id": id, + "image": image?.toJson(), + "intro": intro, + "tag": tag, + "title": title, + "username": username, + }; +} + +class FileUpload { + ///The file's user-provided content type. + String? contentType; + + ///The file's original file name. + String? filename; + String id; + + ///The file's inferred mime type. Not user provided. + String? mimeType; + + FileUpload({ + this.contentType, + this.filename, + required this.id, + this.mimeType, + }); + + factory FileUpload.fromJson(Map json) => FileUpload( + contentType: json["content_type"], + filename: json["filename"], + id: json["id"], + mimeType: json["mime_type"], + ); + + Map toJson() => { + "content_type": contentType, + "filename": filename, + "id": id, + "mime_type": mimeType, + }; +} diff --git a/examples/blog/flutter/lib/types/new_profile.dart b/examples/blog/flutter/lib/types/new_profile.dart new file mode 100644 index 0000000..8805381 --- /dev/null +++ b/examples/blog/flutter/lib/types/new_profile.dart @@ -0,0 +1,38 @@ +// To parse this JSON data, do +// +// final newProfile = newProfileFromJson(jsonString); + +import 'dart:convert'; + +NewProfile newProfileFromJson(String str) => + NewProfile.fromJson(json.decode(str)); + +String newProfileToJson(NewProfile data) => json.encode(data.toJson()); + +class NewProfile { + int? created; + int? updated; + String user; + String username; + + NewProfile({ + this.created, + this.updated, + required this.user, + required this.username, + }); + + factory NewProfile.fromJson(Map json) => NewProfile( + created: json["created"], + updated: json["updated"], + user: json["user"], + username: json["username"], + ); + + Map toJson() => { + "created": created, + "updated": updated, + "user": user, + "username": username, + }; +} diff --git a/examples/blog/flutter/lib/types/profile.dart b/examples/blog/flutter/lib/types/profile.dart new file mode 100644 index 0000000..4efb6e3 --- /dev/null +++ b/examples/blog/flutter/lib/types/profile.dart @@ -0,0 +1,45 @@ +// To parse this JSON data, do +// +// final profile = profileFromJson(jsonString); + +import 'dart:convert'; + +Profile profileFromJson(String str) => Profile.fromJson(json.decode(str)); + +String profileToJson(Profile data) => json.encode(data.toJson()); + +class Profile { + String? avatarUrl; + int created; + bool? isEditor; + int updated; + String user; + String username; + + Profile({ + this.avatarUrl, + required this.created, + this.isEditor, + required this.updated, + required this.user, + required this.username, + }); + + factory Profile.fromJson(Map json) => Profile( + avatarUrl: json["avatar_url"], + created: json["created"], + isEditor: json["is_editor"], + updated: json["updated"], + user: json["user"], + username: json["username"], + ); + + Map toJson() => { + "avatar_url": avatarUrl, + "created": created, + "is_editor": isEditor, + "updated": updated, + "user": user, + "username": username, + }; +} diff --git a/examples/blog/flutter/linux/.gitignore b/examples/blog/flutter/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/examples/blog/flutter/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/examples/blog/flutter/linux/CMakeLists.txt b/examples/blog/flutter/linux/CMakeLists.txt new file mode 100644 index 0000000..d17ebd2 --- /dev/null +++ b/examples/blog/flutter/linux/CMakeLists.txt @@ -0,0 +1,145 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "trailbase_blog") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.trailbase_blog") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Define the application target. To change its name, change BINARY_NAME above, +# not the value here, or `flutter run` will no longer work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/examples/blog/flutter/linux/flutter/CMakeLists.txt b/examples/blog/flutter/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/examples/blog/flutter/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/examples/blog/flutter/linux/flutter/generated_plugin_registrant.cc b/examples/blog/flutter/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..9f3151b --- /dev/null +++ b/examples/blog/flutter/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,23 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) desktop_webview_window_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopWebviewWindowPlugin"); + desktop_webview_window_plugin_register_with_registrar(desktop_webview_window_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) window_to_front_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowToFrontPlugin"); + window_to_front_plugin_register_with_registrar(window_to_front_registrar); +} diff --git a/examples/blog/flutter/linux/flutter/generated_plugin_registrant.h b/examples/blog/flutter/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/examples/blog/flutter/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/examples/blog/flutter/linux/flutter/generated_plugins.cmake b/examples/blog/flutter/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..9bc27dc --- /dev/null +++ b/examples/blog/flutter/linux/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + desktop_webview_window + url_launcher_linux + window_to_front +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/examples/blog/flutter/linux/main.cc b/examples/blog/flutter/linux/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/examples/blog/flutter/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/examples/blog/flutter/linux/my_application.cc b/examples/blog/flutter/linux/my_application.cc new file mode 100644 index 0000000..2dde140 --- /dev/null +++ b/examples/blog/flutter/linux/my_application.cc @@ -0,0 +1,124 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "trailbase_blog"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "trailbase_blog"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/examples/blog/flutter/linux/my_application.h b/examples/blog/flutter/linux/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/examples/blog/flutter/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/examples/blog/flutter/macos/.gitignore b/examples/blog/flutter/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/examples/blog/flutter/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/examples/blog/flutter/macos/Flutter/Flutter-Debug.xcconfig b/examples/blog/flutter/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/examples/blog/flutter/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/examples/blog/flutter/macos/Flutter/Flutter-Release.xcconfig b/examples/blog/flutter/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/examples/blog/flutter/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/examples/blog/flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/examples/blog/flutter/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..41a2904 --- /dev/null +++ b/examples/blog/flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,22 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import desktop_webview_window +import flutter_web_auth_2 +import path_provider_foundation +import shared_preferences_foundation +import url_launcher_macos +import window_to_front + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DesktopWebviewWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewWindowPlugin")) + FlutterWebAuth2Plugin.register(with: registry.registrar(forPlugin: "FlutterWebAuth2Plugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WindowToFrontPlugin.register(with: registry.registrar(forPlugin: "WindowToFrontPlugin")) +} diff --git a/examples/blog/flutter/macos/Runner.xcodeproj/project.pbxproj b/examples/blog/flutter/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..931f67f --- /dev/null +++ b/examples/blog/flutter/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* trailbase_blog.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "trailbase_blog.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* trailbase_blog.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* trailbase_blog.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.trailbaseBlog.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/trailbase_blog.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/trailbase_blog"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.trailbaseBlog.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/trailbase_blog.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/trailbase_blog"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.trailbaseBlog.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/trailbase_blog.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/trailbase_blog"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/examples/blog/flutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/examples/blog/flutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/examples/blog/flutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/examples/blog/flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/examples/blog/flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..47d4f74 --- /dev/null +++ b/examples/blog/flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/blog/flutter/macos/Runner.xcworkspace/contents.xcworkspacedata b/examples/blog/flutter/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/examples/blog/flutter/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/examples/blog/flutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/examples/blog/flutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/examples/blog/flutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/examples/blog/flutter/macos/Runner/AppDelegate.swift b/examples/blog/flutter/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..8e02df2 --- /dev/null +++ b/examples/blog/flutter/macos/Runner/AppDelegate.swift @@ -0,0 +1,9 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } +} diff --git a/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/examples/blog/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/examples/blog/flutter/macos/Runner/Base.lproj/MainMenu.xib b/examples/blog/flutter/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/examples/blog/flutter/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/blog/flutter/macos/Runner/Configs/AppInfo.xcconfig b/examples/blog/flutter/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..2230cb0 --- /dev/null +++ b/examples/blog/flutter/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = trailbase_blog + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.trailbaseBlog + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved. diff --git a/examples/blog/flutter/macos/Runner/Configs/Debug.xcconfig b/examples/blog/flutter/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/examples/blog/flutter/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/examples/blog/flutter/macos/Runner/Configs/Release.xcconfig b/examples/blog/flutter/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/examples/blog/flutter/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/examples/blog/flutter/macos/Runner/Configs/Warnings.xcconfig b/examples/blog/flutter/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/examples/blog/flutter/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/examples/blog/flutter/macos/Runner/DebugProfile.entitlements b/examples/blog/flutter/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/examples/blog/flutter/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/examples/blog/flutter/macos/Runner/Info.plist b/examples/blog/flutter/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/examples/blog/flutter/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/examples/blog/flutter/macos/Runner/MainFlutterWindow.swift b/examples/blog/flutter/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/examples/blog/flutter/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/examples/blog/flutter/macos/Runner/Release.entitlements b/examples/blog/flutter/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/examples/blog/flutter/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/examples/blog/flutter/macos/RunnerTests/RunnerTests.swift b/examples/blog/flutter/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/examples/blog/flutter/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/examples/blog/flutter/pubspec.lock b/examples/blog/flutter/pubspec.lock new file mode 100644 index 0000000..0c47dc8 --- /dev/null +++ b/examples/blog/flutter/pubspec.lock @@ -0,0 +1,529 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + desktop_webview_window: + dependency: transitive + description: + name: desktop_webview_window + sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0" + url: "https://pub.dev" + source: hosted + version: "0.2.3" + dio: + dependency: transitive + description: + name: dio + sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" + url: "https://pub.dev" + source: hosted + version: "5.7.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_auth_2: + dependency: "direct main" + description: + name: flutter_web_auth_2 + sha256: "8f59c9fa71b5affb322cb7103b836cd0ced89c9c50c66f82b523b7d339018dc3" + url: "https://pub.dev" + source: hosted + version: "4.0.1" + flutter_web_auth_2_platform_interface: + dependency: transitive + description: + name: flutter_web_auth_2_platform_interface + sha256: "222264d4979e9372c90e441736a62d800481e4a9c860cc2c235d1d605a118a2b" + url: "https://pub.dev" + source: hosted + version: "4.0.1" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + jwt_decoder: + dependency: transitive + description: + name: jwt_decoder + sha256: "54774aebf83f2923b99e6416b4ea915d47af3bde56884eb622de85feabbc559f" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + url: "https://pub.dev" + source: hosted + version: "10.0.5" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + url: "https://pub.dev" + source: hosted + version: "3.0.5" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + logging: + dependency: "direct main" + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" + source: hosted + version: "1.15.0" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: c464428172cb986b758c6d1724c603097febb8fb855aa265aeecc9280c294d4a + url: "https://pub.dev" + source: hosted + version: "2.2.12" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + url: "https://pub.dev" + source: hosted + version: "2.4.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "746e5369a43170c25816cc472ee016d3a66bc13fcf430c0bc41ad7b4b2922051" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "3b9febd815c9ca29c9e3520d50ec32f49157711e143b7a4ca039eb87e8ade5ab" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "07e050c7cd39bad516f8d64c455f04508d09df104be326d8c02551590a0d513d" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + trailbase: + dependency: "direct main" + description: + path: "../../../client/trailbase-dart" + relative: true + source: path + version: "0.1.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "0dea215895a4d254401730ca0ba8204b29109a34a99fb06ae559a2b60988d2de" + url: "https://pub.dev" + source: hosted + version: "6.3.13" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: e2b9622b4007f97f504cd64c0128309dfb978ae66adbe944125ed9e1750f06af + url: "https://pub.dev" + source: hosted + version: "3.2.0" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "769549c999acdb42b8bcfa7c43d72bf79a382ca7441ab18a808e101149daf672" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + url: "https://pub.dev" + source: hosted + version: "14.2.5" + web: + dependency: transitive + description: + name: web + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + url: "https://pub.dev" + source: hosted + version: "1.1.0" + window_to_front: + dependency: transitive + description: + name: window_to_front + sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee" + url: "https://pub.dev" + source: hosted + version: "0.0.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.5.3 <4.0.0" + flutter: ">=3.24.0" diff --git a/examples/blog/flutter/pubspec.yaml b/examples/blog/flutter/pubspec.yaml new file mode 100644 index 0000000..3b12394 --- /dev/null +++ b/examples/blog/flutter/pubspec.yaml @@ -0,0 +1,26 @@ +name: trailbase_blog +description: "TrailBase Example Blog Reader" +publish_to: 'none' # Remove this line if you wish to publish to pub.dev +version: 0.0.1+1 + +environment: + sdk: ^3.5.3 + +dependencies: + cupertino_icons: ^1.0.8 + flutter: + sdk: flutter + flutter_web_auth_2: ^4.0.0 + logging: ^1.2.0 + shared_preferences: ^2.3.2 + trailbase: + path: ../../../client/trailbase-dart + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true diff --git a/examples/blog/flutter/web/auth.html b/examples/blog/flutter/web/auth.html new file mode 100644 index 0000000..60a6a45 --- /dev/null +++ b/examples/blog/flutter/web/auth.html @@ -0,0 +1,26 @@ + + + +Authentication complete + +

Authentication is complete. If this does not happen automatically, please close the window.

+ + diff --git a/examples/blog/flutter/web/favicon.png b/examples/blog/flutter/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/examples/blog/flutter/web/favicon.png differ diff --git a/examples/blog/flutter/web/icons/Icon-192.png b/examples/blog/flutter/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/examples/blog/flutter/web/icons/Icon-192.png differ diff --git a/examples/blog/flutter/web/icons/Icon-512.png b/examples/blog/flutter/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/examples/blog/flutter/web/icons/Icon-512.png differ diff --git a/examples/blog/flutter/web/icons/Icon-maskable-192.png b/examples/blog/flutter/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/examples/blog/flutter/web/icons/Icon-maskable-192.png differ diff --git a/examples/blog/flutter/web/icons/Icon-maskable-512.png b/examples/blog/flutter/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/examples/blog/flutter/web/icons/Icon-maskable-512.png differ diff --git a/examples/blog/flutter/web/index.html b/examples/blog/flutter/web/index.html new file mode 100644 index 0000000..f010497 --- /dev/null +++ b/examples/blog/flutter/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + trailbase_blog + + + + + + diff --git a/examples/blog/flutter/web/manifest.json b/examples/blog/flutter/web/manifest.json new file mode 100644 index 0000000..0b44b18 --- /dev/null +++ b/examples/blog/flutter/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "trailbase_blog", + "short_name": "trailbase_blog", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/examples/blog/flutter/windows/.gitignore b/examples/blog/flutter/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/examples/blog/flutter/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/examples/blog/flutter/windows/CMakeLists.txt b/examples/blog/flutter/windows/CMakeLists.txt new file mode 100644 index 0000000..4377368 --- /dev/null +++ b/examples/blog/flutter/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(trailbase_blog LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "trailbase_blog") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/examples/blog/flutter/windows/flutter/CMakeLists.txt b/examples/blog/flutter/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/examples/blog/flutter/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/examples/blog/flutter/windows/flutter/generated_plugin_registrant.cc b/examples/blog/flutter/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..3a6d25f --- /dev/null +++ b/examples/blog/flutter/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,20 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + DesktopWebviewWindowPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DesktopWebviewWindowPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WindowToFrontPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowToFrontPlugin")); +} diff --git a/examples/blog/flutter/windows/flutter/generated_plugin_registrant.h b/examples/blog/flutter/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/examples/blog/flutter/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/examples/blog/flutter/windows/flutter/generated_plugins.cmake b/examples/blog/flutter/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..cf17c65 --- /dev/null +++ b/examples/blog/flutter/windows/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + desktop_webview_window + url_launcher_windows + window_to_front +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/examples/blog/flutter/windows/runner/CMakeLists.txt b/examples/blog/flutter/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/examples/blog/flutter/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/examples/blog/flutter/windows/runner/Runner.rc b/examples/blog/flutter/windows/runner/Runner.rc new file mode 100644 index 0000000..ef4eb90 --- /dev/null +++ b/examples/blog/flutter/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "trailbase_blog" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "trailbase_blog" "\0" + VALUE "LegalCopyright", "Copyright (C) 2024 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "trailbase_blog.exe" "\0" + VALUE "ProductName", "trailbase_blog" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/examples/blog/flutter/windows/runner/flutter_window.cpp b/examples/blog/flutter/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/examples/blog/flutter/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/examples/blog/flutter/windows/runner/flutter_window.h b/examples/blog/flutter/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/examples/blog/flutter/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/examples/blog/flutter/windows/runner/main.cpp b/examples/blog/flutter/windows/runner/main.cpp new file mode 100644 index 0000000..bb94690 --- /dev/null +++ b/examples/blog/flutter/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"trailbase_blog", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/examples/blog/flutter/windows/runner/resource.h b/examples/blog/flutter/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/examples/blog/flutter/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/examples/blog/flutter/windows/runner/resources/app_icon.ico b/examples/blog/flutter/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/examples/blog/flutter/windows/runner/resources/app_icon.ico differ diff --git a/examples/blog/flutter/windows/runner/runner.exe.manifest b/examples/blog/flutter/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/examples/blog/flutter/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/examples/blog/flutter/windows/runner/utils.cpp b/examples/blog/flutter/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/examples/blog/flutter/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/examples/blog/flutter/windows/runner/utils.h b/examples/blog/flutter/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/examples/blog/flutter/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/examples/blog/flutter/windows/runner/win32_window.cpp b/examples/blog/flutter/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/examples/blog/flutter/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/examples/blog/flutter/windows/runner/win32_window.h b/examples/blog/flutter/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/examples/blog/flutter/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/examples/blog/package.json b/examples/blog/package.json new file mode 100644 index 0000000..0fda473 --- /dev/null +++ b/examples/blog/package.json @@ -0,0 +1,11 @@ +{ + "name": "trailbase-example-blog-typegen", + "type": "module", + "version": "0.0.1", + "scripts": { + "types": "make --always-make types" + }, + "devDependencies": { + "quicktype": "^23.0.170" + } +} diff --git a/examples/blog/schema/article.json b/examples/blog/schema/article.json new file mode 100644 index 0000000..fbf519f --- /dev/null +++ b/examples/blog/schema/article.json @@ -0,0 +1,80 @@ +{ + "$defs": { + "image": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "content_type": { + "description": "The file's user-provided content type.", + "type": [ + "string", + "null" + ] + }, + "filename": { + "description": "The file's original file name.", + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "mime_type": { + "description": "The file's inferred mime type. Not user provided.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id" + ], + "title": "FileUpload", + "type": "object" + } + }, + "properties": { + "author": { + "type": "string" + }, + "body": { + "type": "string" + }, + "created": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "image": { + "$ref": "#/$defs/image" + }, + "intro": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "title": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "id", + "author", + "title", + "intro", + "tag", + "body", + "created", + "username" + ], + "title": "articles_view", + "type": "object" +} diff --git a/examples/blog/schema/new_profile.json b/examples/blog/schema/new_profile.json new file mode 100644 index 0000000..82da3dd --- /dev/null +++ b/examples/blog/schema/new_profile.json @@ -0,0 +1,23 @@ +{ + "$defs": {}, + "properties": { + "created": { + "type": "integer" + }, + "updated": { + "type": "integer" + }, + "user": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "user", + "username" + ], + "title": "profiles", + "type": "object" +} diff --git a/examples/blog/schema/profile.json b/examples/blog/schema/profile.json new file mode 100644 index 0000000..83c3f97 --- /dev/null +++ b/examples/blog/schema/profile.json @@ -0,0 +1,31 @@ +{ + "$defs": {}, + "properties": { + "avatar_url": { + "type": "string" + }, + "created": { + "type": "integer" + }, + "is_editor": { + "type": "boolean" + }, + "updated": { + "type": "integer" + }, + "user": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "user", + "username", + "created", + "updated" + ], + "title": "profiles_view", + "type": "object" +} diff --git a/examples/blog/traildepot/.gitignore b/examples/blog/traildepot/.gitignore new file mode 100644 index 0000000..c30536f --- /dev/null +++ b/examples/blog/traildepot/.gitignore @@ -0,0 +1,7 @@ + +backups/ +data/ +secrets/ +uploads/ + +!migrations/ diff --git a/examples/blog/traildepot/config.textproto b/examples/blog/traildepot/config.textproto new file mode 100644 index 0000000..7d3af29 --- /dev/null +++ b/examples/blog/traildepot/config.textproto @@ -0,0 +1,60 @@ +# Auto-generated config.Config textproto +email {} +server { + application_name: "TrailBase" + site_url: "http://localhost:4000" +} +auth {} +record_apis: [ + { + name: "_user_avatar" + table_name: "_user_avatar" + conflict_resolution: REPLACE + autofill_missing_user_id_columns: true + acl_world: [READ] + acl_authenticated: [CREATE, READ, UPDATE, DELETE] + create_access_rule: "_REQ_.user IS NULL OR _REQ_.user = _USER_.id" + update_access_rule: "_ROW_.user = _USER_.id" + delete_access_rule: "_ROW_.user = _USER_.id" + }, + { + name: "profiles" + table_name: "profiles" + conflict_resolution: REPLACE + acl_authenticated: [CREATE] + create_access_rule: "_REQ_.user = _USER_.id" + }, + { + name: "profiles_view" + table_name: "profiles_view" + acl_authenticated: [READ] + read_access_rule: "_ROW_.user = _USER_.id" + }, + { + name: "articles" + table_name: "articles" + autofill_missing_user_id_columns: true + acl_authenticated: [CREATE, READ, UPDATE, DELETE] + create_access_rule: "(_REQ_.author IS NULL OR _REQ_.author = _USER_.id) AND EXISTS(SELECT * FROM editors WHERE user = _USER_.id)" + update_access_rule: "_ROW_.author = _USER_.id AND EXISTS(SELECT * FROM editors WHERE user = _USER_.id)" + delete_access_rule: "_ROW_.author = _USER_.id AND EXISTS(SELECT * FROM editors WHERE user = _USER_.id)" + }, + { + name: "articles_view" + table_name: "articles_view" + acl_world: [READ] + } +] +query_apis: [ + { + name: "is_editor" + virtual_table_name: "_is_editor" + params: [ + { + name: "user" + type: BLOB + } + ] + acl: WORLD + } +] diff --git a/examples/blog/traildepot/migrations/U1725019361__create_profiles.sql b/examples/blog/traildepot/migrations/U1725019361__create_profiles.sql new file mode 100644 index 0000000..3ef7ab5 --- /dev/null +++ b/examples/blog/traildepot/migrations/U1725019361__create_profiles.sql @@ -0,0 +1,37 @@ +-- Table with custom profile information. +-- +-- One could add more user information here, customize validation, etc. +CREATE TABLE profiles ( + user BLOB PRIMARY KEY NOT NULL REFERENCES _user(id) ON DELETE CASCADE, + + -- Make sure that usernames are at least 3 alphanumeric characters. + username TEXT NOT NULL CHECK(username REGEXP '^[\w]{3,}$'), + + created INTEGER DEFAULT (UNIXEPOCH()) NOT NULL, + updated INTEGER DEFAULT (UNIXEPOCH()) NOT NULL +) STRICT; + +-- Ensure usernames are unique. +CREATE UNIQUE INDEX _profiles__username_index ON profiles (username); + +-- Use trigger to manage the `updated` timestamp. +CREATE TRIGGER _profiles__updated_trigger AFTER UPDATE ON profiles FOR EACH ROW + BEGIN + UPDATE profiles SET updated = UNIXEPOCH() WHERE user = OLD.user; + END; + +-- Compile username, avatar_url, and editor_priviledges into a single read-only +-- API for convenience. +CREATE VIEW profiles_view AS + SELECT + p.*, + -- TrailBase requires top-level cast to determine result type and generate JSON schemas. + CAST(CASE + WHEN avatar.file IS NOT NULL THEN CONCAT('/api/records/v1/_user_avatar/', uuid_url_safe_b64(p.user), '/file/file') + ELSE NULL + END AS TEXT) AS avatar_url, + -- TrailBase requires top-level cast to determine result type and generate JSON schemas. + CAST(IIF(editors.user IS NULL, FALSE, TRUE) AS BOOLEAN) AS is_editor + FROM profiles AS p + LEFT JOIN _user_avatar AS avatar ON p.user = avatar.user + LEFT JOIN editors ON p.user = editors.user; diff --git a/examples/blog/traildepot/migrations/U1725019362__create_articles.sql b/examples/blog/traildepot/migrations/U1725019362__create_articles.sql new file mode 100644 index 0000000..032b466 --- /dev/null +++ b/examples/blog/traildepot/migrations/U1725019362__create_articles.sql @@ -0,0 +1,16 @@ +CREATE TABLE articles ( + id BLOB PRIMARY KEY NOT NULL CHECK(is_uuid_v7(id)) DEFAULT (uuid_v7()), + author BLOB NOT NULL REFERENCES _user(id) ON DELETE CASCADE, + + title TEXT NOT NULL, + intro TEXT NOT NULL, + tag TEXT NOT NULL, + body TEXT NOT NULL, + + image TEXT CHECK(jsonschema('std.FileUpload', image, 'image/png, image/jpeg')), + + created INTEGER DEFAULT (UNIXEPOCH()) NOT NULL +) STRICT; + +-- Join articles with user profiles to get the username. +CREATE VIEW articles_view AS SELECT a.*, p.username FROM articles AS a LEFT JOIN profiles AS p ON p.user = a.author; diff --git a/examples/blog/traildepot/migrations/U1725019363__create_editor_group.sql b/examples/blog/traildepot/migrations/U1725019363__create_editor_group.sql new file mode 100644 index 0000000..90f481d --- /dev/null +++ b/examples/blog/traildepot/migrations/U1725019363__create_editor_group.sql @@ -0,0 +1,10 @@ +-- Create a group that is used to gate write access to articles. Members of +-- this group can author articles. +CREATE TABLE editors ( + user BLOB NOT NULL, + + FOREIGN KEY(user) REFERENCES _user(id) ON DELETE CASCADE +) STRICT; + +-- Create an "is_editor" query api. +CREATE VIRTUAL TABLE _is_editor USING define((SELECT EXISTS (SELECT * FROM editors WHERE user = $1) AS is_editor)); diff --git a/examples/blog/traildepot/migrations/U1725019371__add_admin_user.sql b/examples/blog/traildepot/migrations/U1725019371__add_admin_user.sql new file mode 100644 index 0000000..e6bbf5c --- /dev/null +++ b/examples/blog/traildepot/migrations/U1725019371__add_admin_user.sql @@ -0,0 +1,10 @@ +-- Create admin user with "secret" password. +INSERT INTO _user + (email, password_hash, verified, admin) +VALUES + ('admin@localhost', (hash_password('secret')), TRUE, TRUE); + +-- Set a username for the admin user. +INSERT INTO profiles (user, username) + SELECT user.id, 'Admin' + FROM _user AS user WHERE email = 'admin@localhost'; diff --git a/examples/blog/traildepot/migrations/U1725019372__add_users.sql b/examples/blog/traildepot/migrations/U1725019372__add_users.sql new file mode 100644 index 0000000..be24b75 --- /dev/null +++ b/examples/blog/traildepot/migrations/U1725019372__add_users.sql @@ -0,0 +1,20 @@ +-- Add an editor +INSERT INTO _user (email, password_hash, verified) VALUES ('editor@localhost', (hash_password('secret')), TRUE); + +-- Set a username for the editor user. +INSERT INTO profiles (user, username) + SELECT user.id, 'Eddy Editor' + FROM _user AS user WHERE email = 'editor@localhost'; + +-- Add an avatar image for the editor user. +INSERT INTO _user_avatar (user, file) + SELECT user.id, '{"id":"0328bc95-9622-42e7-a609-625769a797c2","filename":"admin.png","content_type":"image/png","mime_type":"image/png"}' + FROM _user AS user WHERE email = 'editor@localhost'; + +-- Add the editor user to the editors group +INSERT INTO editors (user) + SELECT user.id FROM _user AS user WHERE email = 'editor@localhost'; + + +-- Add another user: non-admin, non-editor and w/o profile. +INSERT INTO _user (email, password_hash, verified) VALUES ('other@localhost', (hash_password('secret')), TRUE); diff --git a/examples/blog/traildepot/migrations/U1725019381__add_article.sql b/examples/blog/traildepot/migrations/U1725019381__add_article.sql new file mode 100644 index 0000000..1750a99 --- /dev/null +++ b/examples/blog/traildepot/migrations/U1725019381__add_article.sql @@ -0,0 +1,18 @@ +-- Bootstrap articles by inserting a dummy article for the admin user. +INSERT INTO articles ( + title, + intro, + tag, + author, + body, + image +) +SELECT + 'TrailBase is Here 🎉', + 'A rigorously simple and blazingly fast application base 😉', + 'important,example', + id, + 'TrailBase provides core functionality such restful APIs, file upload, auth, access control and a convenient admin dashboard out of the box.', + '{"id":"40e8d2a2-b025-435e-9aa0-4cb6b895ab2a","filename":"image.png","content_type":"image/png","mime_type":"image/png"}' +FROM _user +WHERE email = 'editor@localhost'; diff --git a/examples/blog/traildepot/uploads/0328bc95-9622-42e7-a609-625769a797c2 b/examples/blog/traildepot/uploads/0328bc95-9622-42e7-a609-625769a797c2 new file mode 100644 index 0000000..037dc99 Binary files /dev/null and b/examples/blog/traildepot/uploads/0328bc95-9622-42e7-a609-625769a797c2 differ diff --git a/examples/blog/traildepot/uploads/40e8d2a2-b025-435e-9aa0-4cb6b895ab2a b/examples/blog/traildepot/uploads/40e8d2a2-b025-435e-9aa0-4cb6b895ab2a new file mode 100644 index 0000000..95c574a Binary files /dev/null and b/examples/blog/traildepot/uploads/40e8d2a2-b025-435e-9aa0-4cb6b895ab2a differ diff --git a/examples/blog/web/.gitignore b/examples/blog/web/.gitignore new file mode 100644 index 0000000..6240da8 --- /dev/null +++ b/examples/blog/web/.gitignore @@ -0,0 +1,21 @@ +# build output +dist/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store diff --git a/examples/blog/web/.prettierignore b/examples/blog/web/.prettierignore new file mode 100644 index 0000000..fd50c4f --- /dev/null +++ b/examples/blog/web/.prettierignore @@ -0,0 +1,6 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock + +src/components/ui diff --git a/examples/blog/web/.prettierrc.mjs b/examples/blog/web/.prettierrc.mjs new file mode 100644 index 0000000..85ccfb5 --- /dev/null +++ b/examples/blog/web/.prettierrc.mjs @@ -0,0 +1,13 @@ +// .prettierrc.mjs +/** @type {import("prettier").Config} */ +export default { + plugins: ['prettier-plugin-astro'], + overrides: [ + { + files: '*.astro', + options: { + parser: 'astro', + }, + }, + ], +}; diff --git a/examples/blog/web/astro.config.mjs b/examples/blog/web/astro.config.mjs new file mode 100644 index 0000000..0853f00 --- /dev/null +++ b/examples/blog/web/astro.config.mjs @@ -0,0 +1,12 @@ +import { defineConfig } from "astro/config"; + +import tailwind from "@astrojs/tailwind"; +import mdx from "@astrojs/mdx"; +import icon from "astro-icon"; + +import solidJs from "@astrojs/solid-js"; + +// https://astro.build/config +export default defineConfig({ + integrations: [icon(), tailwind(), mdx(), solidJs()], +}); diff --git a/examples/blog/web/package.json b/examples/blog/web/package.json new file mode 100644 index 0000000..76fa4d8 --- /dev/null +++ b/examples/blog/web/package.json @@ -0,0 +1,38 @@ +{ + "name": "trailbase-example-blog", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro check && astro build", + "preview": "astro preview", + "astro": "astro", + "check": "astro check", + "format": "prettier -w src *.mjs", + "types": "make --always-make types" + }, + "dependencies": { + "@astrojs/mdx": "^3.1.8", + "@astrojs/tailwind": "^5.1.2", + "@nanostores/persistent": "^0.10.2", + "@nanostores/solid": "^0.5.0", + "astro": "^4.16.7", + "astro-icon": "^1.1.1", + "nanostores": "^0.11.3", + "solid-icons": "^1.1.0", + "solid-js": "^1.9.3", + "tailwindcss": "^3.4.14", + "trailbase": "workspace:*" + }, + "devDependencies": { + "@astrojs/solid-js": "^4.4.2", + "@iconify-json/tabler": "^1.2.6", + "@tailwindcss/typography": "^0.5.15", + "@types/dateformat": "^5.0.2", + "prettier": "^3.3.3", + "prettier-plugin-astro": "^0.14.1", + "quicktype": "^23.0.170", + "sharp": "^0.33.5" + } +} diff --git a/examples/blog/web/public/default.svg b/examples/blog/web/public/default.svg new file mode 100644 index 0000000..506a648 --- /dev/null +++ b/examples/blog/web/public/default.svg @@ -0,0 +1,84 @@ + + + +No Image diff --git a/examples/blog/web/public/image.png b/examples/blog/web/public/image.png new file mode 100644 index 0000000..95c574a Binary files /dev/null and b/examples/blog/web/public/image.png differ diff --git a/examples/blog/web/public/image.svg b/examples/blog/web/public/image.svg new file mode 100644 index 0000000..3beb8dd --- /dev/null +++ b/examples/blog/web/public/image.svg @@ -0,0 +1,600 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/blog/web/src/assets/default.jpg b/examples/blog/web/src/assets/default.jpg new file mode 100644 index 0000000..b2d2043 Binary files /dev/null and b/examples/blog/web/src/assets/default.jpg differ diff --git a/examples/blog/web/src/components/Articles.tsx b/examples/blog/web/src/components/Articles.tsx new file mode 100644 index 0000000..ef56832 --- /dev/null +++ b/examples/blog/web/src/components/Articles.tsx @@ -0,0 +1,229 @@ +import { + createResource, + createSignal, + For, + Match, + Show, + Switch, +} from "solid-js"; +import { useStore } from "@nanostores/solid"; +import { TbPencilPlus } from "solid-icons/tb"; +import { Client } from "trailbase"; + +import { $client } from "@/lib/client"; +import { $profile, createProfile } from "@/lib/profile"; + +import type { Article } from "@schema/article"; + +function PublishDate(props: { date: number }) { + return ( + + {new Date(props.date * 1000).toLocaleDateString()} + + ); +} + +function Tag(props: { tag: string }) { + const style = + "text-[16px] border-pacamara-secondary border-[1px] leading-none rounded-full flex items-center h-[34px] px-2 text-pacamara-secondary"; + return ( +
+ t.trim())}> + {(tag) => {tag}} + +
+ ); +} + +export function ComposeArticleButton() { + const profile = useStore($profile); + + return ( + }> + + + + + ); +} + +function ArticleCard(props: { article: Article; index: number }) { + const article = props.article; + const isOdd = props.index % 2; + + const link = `/article/?id=${article.id}`; + const classList = + "rounded-[15px] image-shine object-cover h-[200px] " + + (isOdd ? "rotate-2" : "-rotate-2"); + + return ( + + ); +} + +function AssignNewUsername(props: { client: Client }) { + const [error, setError] = createSignal(); + const [username, setUsername] = createSignal(""); + + return ( +
+

Pick a unique username:

+ +
+ setUsername(e.currentTarget.value)} + /> + + +
+ + {error() !== undefined && ( + {`${error()}`} + )} +
+ ); +} + +export function ArticleList() { + const client = useStore($client); + const [articles] = createResource(client, (client) => + client?.records("articles_view").list
(), + ); + const profile = useStore($profile); + + return ( + Loading...

}> + {`${articles.error}`} + + + + + + + + {(item, index) => } + + +
+ ); +} + +function ArticlePageImpl(props: { article: Article }) { + const article = () => props.article; + + return ( +
+
+

+ {article().title} +

+ +

+ + + {props.article.username ?? article().author} +

+
+ + + +
{article().body}
+
+ ); +} + +export function ArticlePage() { + const client = useStore($client); + + const urlParams = new URLSearchParams(window.location.search); + const articleId = urlParams.get("id"); + if (!articleId) { + throw "missing article id query parameter"; + } + + const [article] = createResource(client, (client) => + client?.records("articles_view").read
(articleId), + ); + + return ( + Loading...

}> + Failed to load: {`${article.error}`} + + + + +
+ ); +} + +function imageUrl(article: Article): string { + const client = $client.get(); + if (client && article.image) { + return client.records("articles_view").imageUri(`${article.id}`, "image"); + } + return "/default.svg"; +} + +const buttonStyle = + "h-10 px-4 py-2 border border-input bg-pacamara-secondary hover:text-pacamara-accent foreground inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"; +const inputStyle = + "flex h-10 w-full rounded-md border border-input px-3 py-2 text-sm ring-offset-background file:border-0 file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"; +const articleCardStyle = + "group lg:mb-[50px] mb-10 last:mb-0 prose lg:prose-xl max-w-none prose-headings:font-bold prose-headings:text-pacamara-accent prose-p:text-pacamara-primary/70 lg:prose-p:text-[18px] prose-p:transition-all prose-p:duration-300 prose-a:font-semibold prose-a:text-pacamara-dark prose-a:hover:text-pacamara-pink prose-a:no-underline prose-a:transition-all prose-a:duration-300 prose-strong:font-normal prose-headings:font-pacamara-space prose-h2:mb-7 prose-h2:mt-0 prose-img:mt-0 prose-img:mb-0 dark:prose-a:text-white dark:prose-a:hover:text-pacamara-accent dark:prose-p:text-white/70"; +const articleContentStyle = + "lg:px-0 pt-10 mb-5 mx-auto prose lg:prose-xl prose-headings:transition-all prose-headings:duration-300 prose-headings:font-pacamara-space prose-headings:font-bold prose-headings:text-pacamara-accent prose-headings:mb-0 prose-headings:pb-3 prose-headings:mt-6 prose-p:transition-all prose-p:duration-300 prose-p:text-pacamara-primary/80 prose-li:transition-all prose-li:duration-300 prose-li:text-pacamara-primary/80 prose-td:transition-all prose-td:duration-300 prose-td:text-pacamara-primary/80 prose-a:underline prose-a:font-semibold prose-a:transition-all prose-a:duration-300 prose-a:text-pacamara-accent hover:prose-a:text-pacamara-dark prose-strong:transition-all prose-strong:duration-300 prose-strong:font-bold prose-hr:transition-all prose-hr:duration-300 prose-hr:border-pacamara-secondary/40 prose-img:rounded-lg prose-img:mx-auto prose-code:transition-all prose-code:duration-300 prose-code:text-pacamara-dark dark:prose-headings:text-pacamara-accent dark:prose-p:text-white/70 dark:prose-a:text-white dark:hover:prose-a:text-pacamara-accent dark:prose-strong:text-white dark:prose-li:text-white dark:prose-code:text-white dark:prose-td:text-white/70 dark:prose-hr:border-pacamara-accent/30 dark:prose-tr:border-pacamara-accent/30 dark:prose-thead:border-pacamara-accent/30"; diff --git a/examples/blog/web/src/components/Auth.tsx b/examples/blog/web/src/components/Auth.tsx new file mode 100644 index 0000000..02584fe --- /dev/null +++ b/examples/blog/web/src/components/Auth.tsx @@ -0,0 +1,64 @@ +import { createResource, Match, Suspense, Switch } from "solid-js"; +import { useStore } from "@nanostores/solid"; +import { TbUser } from "solid-icons/tb"; +import type { User } from "trailbase"; + +import { $client, $user, removeTokens } from "@/lib/client"; +import { $profile } from "@/lib/profile"; + +function UserBadge(props: { user: User | undefined }) { + const client = useStore($client); + const profile = useStore($profile); + const [avatar] = createResource(client, async (c) => await c?.avatarUrl()); + + const Fallback = () => ( + + ); + + return ( + ...

}> +
+ }> + + + + + + avatar + + + + {profile()?.profile?.username ?? props.user?.email} +
+
+ ); +} + +export function AuthButton() { + const client = useStore($client); + const user = useStore($user); + + return ( + + + Log in + + + + + + + ); +} diff --git a/examples/blog/web/src/components/general/Footer.astro b/examples/blog/web/src/components/general/Footer.astro new file mode 100644 index 0000000..c963581 --- /dev/null +++ b/examples/blog/web/src/components/general/Footer.astro @@ -0,0 +1,22 @@ +--- + +--- + +
+
+ +
diff --git a/examples/blog/web/src/components/general/HamburgerButton.astro b/examples/blog/web/src/components/general/HamburgerButton.astro new file mode 100644 index 0000000..2079bcd --- /dev/null +++ b/examples/blog/web/src/components/general/HamburgerButton.astro @@ -0,0 +1,32 @@ +--- +import { Icon } from "astro-icon/components"; + +const style = + "size-8 h-auto text-pacamara-secondary transition-all duration-300 hover:text-pacamara-accent dark:text-white dark:hover:text-pacamara-accent"; +--- + + + + diff --git a/examples/blog/web/src/components/general/Header.astro b/examples/blog/web/src/components/general/Header.astro new file mode 100644 index 0000000..bfef740 --- /dev/null +++ b/examples/blog/web/src/components/general/Header.astro @@ -0,0 +1,37 @@ +--- +import HamburgerButton from "./HamburgerButton.astro"; +import ModeSwitch from "./ModeSwitch.astro"; +import Navigation from "./Navigation.astro"; + +import { AuthButton } from "@/components/Auth"; +import { ComposeArticleButton } from "@/components/Articles"; + +const headerStyle = + "container mx-auto max-w-screen-xl px-7 py-10 bg-white flex flex-initial flex-row gap-10 items-center justify-between transition-all duration-300 dark:bg-pacamara-dark relative z-20"; +const logoStyle = + "font-bold font-pacamara-space text-lg hover:text-pacamara-accent transition-all duration-300 dark:text-white dark:hover:text-pacamara-accent"; +--- + + + +
+ + + + +
+ + + + +
+
diff --git a/examples/blog/web/src/components/general/ModeSwitch.astro b/examples/blog/web/src/components/general/ModeSwitch.astro new file mode 100644 index 0000000..6d7b9b9 --- /dev/null +++ b/examples/blog/web/src/components/general/ModeSwitch.astro @@ -0,0 +1,29 @@ +--- +import { Icon } from "astro-icon/components"; + +const style = + "size-8 h-auto text-pacamara-secondary transition-all duration-300 hover:text-pacamara-accent dark:text-white dark:hover:text-pacamara-accent"; +--- + + + + diff --git a/examples/blog/web/src/components/general/Navigation.astro b/examples/blog/web/src/components/general/Navigation.astro new file mode 100644 index 0000000..4c7656e --- /dev/null +++ b/examples/blog/web/src/components/general/Navigation.astro @@ -0,0 +1,14 @@ +--- +const listStyle = + "list-none text-[14px] font-normal flex flex-initial flex-col md:flex-row gap-5"; +const itemStyle = + "opacity-60 text-pacamara-primary hover:opacity-100 hover:text-pacamara-accent transition-all duration-300 dark:text-white dark:hover:text-pacamara-accent"; +--- + + diff --git a/examples/blog/web/src/components/general/Tag.astro b/examples/blog/web/src/components/general/Tag.astro new file mode 100644 index 0000000..bc4540f --- /dev/null +++ b/examples/blog/web/src/components/general/Tag.astro @@ -0,0 +1,11 @@ +--- +const { tag } = Astro.props; +--- + +{ + tag && ( + + {tag} + + ) +} diff --git a/examples/blog/web/src/css/style.css b/examples/blog/web/src/css/style.css new file mode 100644 index 0000000..da3a5f9 --- /dev/null +++ b/examples/blog/web/src/css/style.css @@ -0,0 +1,106 @@ +:root { + --gradient-space: 20px; + --gradient-height: 2px; +} + +.gradient-line { + background-image: linear-gradient( + 90deg, + transparent, + transparent 50%, + theme("colors.pacamara-white") 50%, + theme("colors.pacamara-white") 100% + ), + linear-gradient( + 90deg, + theme("colors.pacamara-secondary"), + theme("colors.pacamara-accent") + ); + background-size: + var(--gradient-space) var(--gradient-height), + 100% var(--gradient-height); + + @apply transition-all duration-300 rounded-full; +} + +.dark .gradient-line { + background-image: linear-gradient( + 90deg, + transparent, + transparent 50%, + theme("colors.pacamara-dark") 50%, + theme("colors.pacamara-dark") 100% + ), + linear-gradient( + 90deg, + theme("colors.pacamara-secondary"), + theme("colors.pacamara-accent") + ); + background-size: + var(--gradient-space) var(--gradient-height), + 100% var(--gradient-height); + + @apply transition-all duration-300 rounded-full; +} + +.gradient-underline { + line-height: 0.6em; + vertical-align: 0em; + border-bottom: 0.27em solid transparent; + -moz-border-image: -moz-linear-gradient( + left, + theme("colors.pacamara-secondary") 0%, + theme("colors.pacamara-accent") 100% + ); + -webkit-border-image: -webkit-linear-gradient( + left, + theme("colors.pacamara-secondary") 0%, + theme("colors.pacamara-accent") 100% + ); + border-image: linear-gradient( + to right, + theme("colors.pacamara-secondary") 0%, + theme("colors.pacamara-accent") 100% + ); + border-image-slice: 1; + + @apply transition-all duration-300; +} + +.gradient-underline span { + vertical-align: -0.38em; +} + +.image-shine { + -webkit-mask: linear-gradient(135deg, #000c 20%, #000, #000c 80%) 100% 100%/250% + 250%; + + @apply transition-all duration-300; +} + +.image-shine:hover { + -webkit-mask-position: 0 0; +} + +html { + scrollbar-color: theme("colors.pacamara-secondary") transparent; + scrollbar-width: auto; +} + +body::-webkit-scrollbar { + width: 14px; +} + +body::-webkit-scrollbar-track { + box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); +} + +body::-webkit-scrollbar-thumb { + background-image: linear-gradient( + 180deg, + theme("colors.pacamara-secondary"), + theme("colors.pacamara-accent") + ); + outline: 0px solid theme("colors.pacamara-accent"); + border-radius: 10px; +} diff --git a/examples/blog/web/src/env.d.ts b/examples/blog/web/src/env.d.ts new file mode 100644 index 0000000..acef35f --- /dev/null +++ b/examples/blog/web/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/examples/blog/web/src/layouts/Base.astro b/examples/blog/web/src/layouts/Base.astro new file mode 100644 index 0000000..306c62a --- /dev/null +++ b/examples/blog/web/src/layouts/Base.astro @@ -0,0 +1,58 @@ +--- +import { ViewTransitions } from "astro:transitions"; +import Header from "../components/general/Header.astro"; +import Footer from "../components/general/Footer.astro"; + +import "@/css/style.css"; + +const titleSuffix = " | TrailBase 🚀"; +const title = "Homepage" + titleSuffix; +const description = "TrailBase Example Blog Application"; +--- + + + + + + + + + + + + + {title} + + + + + +
+ +
+ +
+ +
+ + diff --git a/examples/blog/web/src/lib/client.ts b/examples/blog/web/src/lib/client.ts new file mode 100644 index 0000000..220ac84 --- /dev/null +++ b/examples/blog/web/src/lib/client.ts @@ -0,0 +1,40 @@ +import { atom, computed, task } from "nanostores"; +import { persistentAtom } from "@nanostores/persistent"; +import { Client, type Tokens, type User } from "trailbase"; + +export const HOST = import.meta.env.DEV ? "http://localhost:4000" : ""; + +const $tokens = persistentAtom("auth_tokens", null, { + encode: JSON.stringify, + decode: JSON.parse, +}); + +export function removeTokens() { + $tokens.set(null); +} + +export const $user = atom(); + +export const $client = computed([], () => + task(async () => { + return Client.tryFromCookies(HOST, { + tokens: $tokens.get() ?? undefined, + onAuthChange: (c: Client, user?: User) => { + $tokens.set(c.tokens() ?? null); + $user.set(user); + }, + }); + }), +); + +// Alternatively, one could use the "is_editor" query API to determine a user's capabilities. +// +// async function isEditor(userId: string): Promise { +// const response = await fetch(`${HOST}/api/query/v1/is_editor?user=${userId}`); +// if (!response.ok) { +// return false; +// } +// +// const json = await response.json(); +// return json[0]["is_editor"] > 0; +// } diff --git a/examples/blog/web/src/lib/profile.ts b/examples/blog/web/src/lib/profile.ts new file mode 100644 index 0000000..d4d67fa --- /dev/null +++ b/examples/blog/web/src/lib/profile.ts @@ -0,0 +1,51 @@ +import { computed, task } from "nanostores"; +import { Client, FetchError } from "trailbase"; + +import type { NewProfile } from "@schema/new_profile"; +import type { Profile } from "@schema/profile"; +import { $client } from "@/lib/client"; + +export async function createProfile( + client: Client, + username: string, +): Promise { + await client.records("profiles").create({ + user: client.user()?.id ?? "", + username, + }); +} + +export type ProfileState = { + profile: Profile | undefined; + missingProfile: boolean; +}; + +export const $profile = computed([$client], (client) => + task(async (): Promise => { + const userId = client?.user()?.id; + if (client && userId) { + try { + const profile = await client + .records("profiles_view") + .read(userId); + return { + profile, + missingProfile: false, + }; + } catch (err) { + if (err instanceof FetchError && err.status === 404) { + return { + profile: undefined, + missingProfile: true, + }; + } + console.debug(err); + } + } + + return { + profile: undefined, + missingProfile: false, + }; + }), +); diff --git a/examples/blog/web/src/pages/article.astro b/examples/blog/web/src/pages/article.astro new file mode 100644 index 0000000..d115de5 --- /dev/null +++ b/examples/blog/web/src/pages/article.astro @@ -0,0 +1,8 @@ +--- +import Base from "@/layouts/Base.astro"; +import { ArticlePage } from "@/components/Articles"; +--- + + + + diff --git a/examples/blog/web/src/pages/compose.astro b/examples/blog/web/src/pages/compose.astro new file mode 100644 index 0000000..5892d2f --- /dev/null +++ b/examples/blog/web/src/pages/compose.astro @@ -0,0 +1,50 @@ +--- +import Base from "@/layouts/Base.astro"; +import { HOST } from "@/lib/client"; + +const RECORD_API = `${HOST}/api/records/v1`; + +const inputStyle = "outline outline-1 outline-black rounded p-1 col-span-4"; +const redirect = "/"; +--- + + +
+

Compose

+ +
+
+ + + + + + + + + + + + + + +
+ +
+ +
+
+
+ diff --git a/examples/blog/web/src/pages/index.astro b/examples/blog/web/src/pages/index.astro new file mode 100644 index 0000000..29e7a93 --- /dev/null +++ b/examples/blog/web/src/pages/index.astro @@ -0,0 +1,10 @@ +--- +import Base from "@/layouts/Base.astro"; +import { ArticleList } from "@/components/Articles"; +--- + + +
+ +
+ diff --git a/examples/blog/web/tailwind.config.mjs b/examples/blog/web/tailwind.config.mjs new file mode 100644 index 0000000..9667b43 --- /dev/null +++ b/examples/blog/web/tailwind.config.mjs @@ -0,0 +1,33 @@ +import typography from "@tailwindcss/typography"; +import { fontFamily } from "tailwindcss/defaultTheme"; + +/**@type {import("tailwindcss").Config} */ +export default { + darkMode: "class", + content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"], + theme: { + backgroundSize: { + "gradient-dashed": "20px 2px, 100% 2px", + }, + extend: { + boxShadow: { + "pacamara-shadow": "0px 25px 50px -12px rgba(0, 0, 0, 0.3)", + }, + fontFamily: { + "pacamara-inter": ["Inter", ...fontFamily.sans], + "pacamara-space": ["Space Grotesk", ...fontFamily.sans], + }, + colors: { + "pacamara-primary": "#003049", + "pacamara-secondary": "#B2A4FF", + "pacamara-accent": "#FFB4B4", + "pacamara-dark": "#000E14", + "pacamara-white": "#ffffff", + }, + aspectRatio: { + "9/10": "9 / 16", + }, + }, + }, + plugins: [typography], +}; diff --git a/examples/blog/web/tsconfig.json b/examples/blog/web/tsconfig.json new file mode 100644 index 0000000..88e9cd0 --- /dev/null +++ b/examples/blog/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "strictNullChecks": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "baseUrl": "./", + "paths": { + "@/*": ["./src/*"], + "@bindings/*": ["../../trailbase-core/bindings/*"], + "@schema/*": ["./types/*"] + } + }, + "exclude": [ + "dist", + "node_modules", + "types", + "public" + ] +} diff --git a/examples/blog/web/types/article.ts b/examples/blog/web/types/article.ts new file mode 100644 index 0000000..f4927b0 --- /dev/null +++ b/examples/blog/web/types/article.ts @@ -0,0 +1,221 @@ +// To parse this data: +// +// import { Convert, Article } from "./file"; +// +// const article = Convert.toArticle(json); +// +// These functions will throw an error if the JSON doesn't +// match the expected interface, even if the JSON is valid. + +export interface Article { + author: string; + body: string; + created: number; + id: string; + image?: FileUpload; + intro: string; + tag: string; + title: string; + username: string; + [property: string]: any; +} + +export interface FileUpload { + /** + * The file's user-provided content type. + */ + content_type?: null | string; + /** + * The file's original file name. + */ + filename?: null | string; + id: string; + /** + * The file's inferred mime type. Not user provided. + */ + mime_type?: null | string; +} + +// Converts JSON strings to/from your types +// and asserts the results of JSON.parse at runtime +export class Convert { + public static toArticle(json: string): Article { + return cast(JSON.parse(json), r("Article")); + } + + public static articleToJson(value: Article): string { + return JSON.stringify(uncast(value, r("Article")), null, 2); + } +} + +function invalidValue(typ: any, val: any, key: any, parent: any = ''): never { + const prettyTyp = prettyTypeName(typ); + const parentText = parent ? ` on ${parent}` : ''; + const keyText = key ? ` for key "${key}"` : ''; + throw Error(`Invalid value${keyText}${parentText}. Expected ${prettyTyp} but got ${JSON.stringify(val)}`); +} + +function prettyTypeName(typ: any): string { + if (Array.isArray(typ)) { + if (typ.length === 2 && typ[0] === undefined) { + return `an optional ${prettyTypeName(typ[1])}`; + } else { + return `one of [${typ.map(a => { return prettyTypeName(a); }).join(", ")}]`; + } + } else if (typeof typ === "object" && typ.literal !== undefined) { + return typ.literal; + } else { + return typeof typ; + } +} + +function jsonToJSProps(typ: any): any { + if (typ.jsonToJS === undefined) { + const map: any = {}; + typ.props.forEach((p: any) => map[p.json] = { key: p.js, typ: p.typ }); + typ.jsonToJS = map; + } + return typ.jsonToJS; +} + +function jsToJSONProps(typ: any): any { + if (typ.jsToJSON === undefined) { + const map: any = {}; + typ.props.forEach((p: any) => map[p.js] = { key: p.json, typ: p.typ }); + typ.jsToJSON = map; + } + return typ.jsToJSON; +} + +function transform(val: any, typ: any, getProps: any, key: any = '', parent: any = ''): any { + function transformPrimitive(typ: string, val: any): any { + if (typeof typ === typeof val) return val; + return invalidValue(typ, val, key, parent); + } + + function transformUnion(typs: any[], val: any): any { + // val must validate against one typ in typs + const l = typs.length; + for (let i = 0; i < l; i++) { + const typ = typs[i]; + try { + return transform(val, typ, getProps); + } catch (_) {} + } + return invalidValue(typs, val, key, parent); + } + + function transformEnum(cases: string[], val: any): any { + if (cases.indexOf(val) !== -1) return val; + return invalidValue(cases.map(a => { return l(a); }), val, key, parent); + } + + function transformArray(typ: any, val: any): any { + // val must be an array with no invalid elements + if (!Array.isArray(val)) return invalidValue(l("array"), val, key, parent); + return val.map(el => transform(el, typ, getProps)); + } + + function transformDate(val: any): any { + if (val === null) { + return null; + } + const d = new Date(val); + if (isNaN(d.valueOf())) { + return invalidValue(l("Date"), val, key, parent); + } + return d; + } + + function transformObject(props: { [k: string]: any }, additional: any, val: any): any { + if (val === null || typeof val !== "object" || Array.isArray(val)) { + return invalidValue(l(ref || "object"), val, key, parent); + } + const result: any = {}; + Object.getOwnPropertyNames(props).forEach(key => { + const prop = props[key]; + const v = Object.prototype.hasOwnProperty.call(val, key) ? val[key] : undefined; + result[prop.key] = transform(v, prop.typ, getProps, key, ref); + }); + Object.getOwnPropertyNames(val).forEach(key => { + if (!Object.prototype.hasOwnProperty.call(props, key)) { + result[key] = transform(val[key], additional, getProps, key, ref); + } + }); + return result; + } + + if (typ === "any") return val; + if (typ === null) { + if (val === null) return val; + return invalidValue(typ, val, key, parent); + } + if (typ === false) return invalidValue(typ, val, key, parent); + let ref: any = undefined; + while (typeof typ === "object" && typ.ref !== undefined) { + ref = typ.ref; + typ = typeMap[typ.ref]; + } + if (Array.isArray(typ)) return transformEnum(typ, val); + if (typeof typ === "object") { + return typ.hasOwnProperty("unionMembers") ? transformUnion(typ.unionMembers, val) + : typ.hasOwnProperty("arrayItems") ? transformArray(typ.arrayItems, val) + : typ.hasOwnProperty("props") ? transformObject(getProps(typ), typ.additional, val) + : invalidValue(typ, val, key, parent); + } + // Numbers can be parsed by Date but shouldn't be. + if (typ === Date && typeof val !== "number") return transformDate(val); + return transformPrimitive(typ, val); +} + +function cast(val: any, typ: any): T { + return transform(val, typ, jsonToJSProps); +} + +function uncast(val: T, typ: any): any { + return transform(val, typ, jsToJSONProps); +} + +function l(typ: any) { + return { literal: typ }; +} + +function a(typ: any) { + return { arrayItems: typ }; +} + +function u(...typs: any[]) { + return { unionMembers: typs }; +} + +function o(props: any[], additional: any) { + return { props, additional }; +} + +function m(additional: any) { + return { props: [], additional }; +} + +function r(name: string) { + return { ref: name }; +} + +const typeMap: any = { + "Article": o([ + { json: "author", js: "author", typ: "" }, + { json: "body", js: "body", typ: "" }, + { json: "created", js: "created", typ: 0 }, + { json: "id", js: "id", typ: "" }, + { json: "image", js: "image", typ: u(undefined, r("FileUpload")) }, + { json: "intro", js: "intro", typ: "" }, + { json: "tag", js: "tag", typ: "" }, + { json: "title", js: "title", typ: "" }, + { json: "username", js: "username", typ: "" }, + ], "any"), + "FileUpload": o([ + { json: "content_type", js: "content_type", typ: u(undefined, u(null, "")) }, + { json: "filename", js: "filename", typ: u(undefined, u(null, "")) }, + { json: "id", js: "id", typ: "" }, + { json: "mime_type", js: "mime_type", typ: u(undefined, u(null, "")) }, + ], false), +}; diff --git a/examples/blog/web/types/new_profile.ts b/examples/blog/web/types/new_profile.ts new file mode 100644 index 0000000..0653847 --- /dev/null +++ b/examples/blog/web/types/new_profile.ts @@ -0,0 +1,189 @@ +// To parse this data: +// +// import { Convert, NewProfile } from "./file"; +// +// const newProfile = Convert.toNewProfile(json); +// +// These functions will throw an error if the JSON doesn't +// match the expected interface, even if the JSON is valid. + +export interface NewProfile { + created?: number; + updated?: number; + user: string; + username: string; + [property: string]: any; +} + +// Converts JSON strings to/from your types +// and asserts the results of JSON.parse at runtime +export class Convert { + public static toNewProfile(json: string): NewProfile { + return cast(JSON.parse(json), r("NewProfile")); + } + + public static newProfileToJson(value: NewProfile): string { + return JSON.stringify(uncast(value, r("NewProfile")), null, 2); + } +} + +function invalidValue(typ: any, val: any, key: any, parent: any = ''): never { + const prettyTyp = prettyTypeName(typ); + const parentText = parent ? ` on ${parent}` : ''; + const keyText = key ? ` for key "${key}"` : ''; + throw Error(`Invalid value${keyText}${parentText}. Expected ${prettyTyp} but got ${JSON.stringify(val)}`); +} + +function prettyTypeName(typ: any): string { + if (Array.isArray(typ)) { + if (typ.length === 2 && typ[0] === undefined) { + return `an optional ${prettyTypeName(typ[1])}`; + } else { + return `one of [${typ.map(a => { return prettyTypeName(a); }).join(", ")}]`; + } + } else if (typeof typ === "object" && typ.literal !== undefined) { + return typ.literal; + } else { + return typeof typ; + } +} + +function jsonToJSProps(typ: any): any { + if (typ.jsonToJS === undefined) { + const map: any = {}; + typ.props.forEach((p: any) => map[p.json] = { key: p.js, typ: p.typ }); + typ.jsonToJS = map; + } + return typ.jsonToJS; +} + +function jsToJSONProps(typ: any): any { + if (typ.jsToJSON === undefined) { + const map: any = {}; + typ.props.forEach((p: any) => map[p.js] = { key: p.json, typ: p.typ }); + typ.jsToJSON = map; + } + return typ.jsToJSON; +} + +function transform(val: any, typ: any, getProps: any, key: any = '', parent: any = ''): any { + function transformPrimitive(typ: string, val: any): any { + if (typeof typ === typeof val) return val; + return invalidValue(typ, val, key, parent); + } + + function transformUnion(typs: any[], val: any): any { + // val must validate against one typ in typs + const l = typs.length; + for (let i = 0; i < l; i++) { + const typ = typs[i]; + try { + return transform(val, typ, getProps); + } catch (_) {} + } + return invalidValue(typs, val, key, parent); + } + + function transformEnum(cases: string[], val: any): any { + if (cases.indexOf(val) !== -1) return val; + return invalidValue(cases.map(a => { return l(a); }), val, key, parent); + } + + function transformArray(typ: any, val: any): any { + // val must be an array with no invalid elements + if (!Array.isArray(val)) return invalidValue(l("array"), val, key, parent); + return val.map(el => transform(el, typ, getProps)); + } + + function transformDate(val: any): any { + if (val === null) { + return null; + } + const d = new Date(val); + if (isNaN(d.valueOf())) { + return invalidValue(l("Date"), val, key, parent); + } + return d; + } + + function transformObject(props: { [k: string]: any }, additional: any, val: any): any { + if (val === null || typeof val !== "object" || Array.isArray(val)) { + return invalidValue(l(ref || "object"), val, key, parent); + } + const result: any = {}; + Object.getOwnPropertyNames(props).forEach(key => { + const prop = props[key]; + const v = Object.prototype.hasOwnProperty.call(val, key) ? val[key] : undefined; + result[prop.key] = transform(v, prop.typ, getProps, key, ref); + }); + Object.getOwnPropertyNames(val).forEach(key => { + if (!Object.prototype.hasOwnProperty.call(props, key)) { + result[key] = transform(val[key], additional, getProps, key, ref); + } + }); + return result; + } + + if (typ === "any") return val; + if (typ === null) { + if (val === null) return val; + return invalidValue(typ, val, key, parent); + } + if (typ === false) return invalidValue(typ, val, key, parent); + let ref: any = undefined; + while (typeof typ === "object" && typ.ref !== undefined) { + ref = typ.ref; + typ = typeMap[typ.ref]; + } + if (Array.isArray(typ)) return transformEnum(typ, val); + if (typeof typ === "object") { + return typ.hasOwnProperty("unionMembers") ? transformUnion(typ.unionMembers, val) + : typ.hasOwnProperty("arrayItems") ? transformArray(typ.arrayItems, val) + : typ.hasOwnProperty("props") ? transformObject(getProps(typ), typ.additional, val) + : invalidValue(typ, val, key, parent); + } + // Numbers can be parsed by Date but shouldn't be. + if (typ === Date && typeof val !== "number") return transformDate(val); + return transformPrimitive(typ, val); +} + +function cast(val: any, typ: any): T { + return transform(val, typ, jsonToJSProps); +} + +function uncast(val: T, typ: any): any { + return transform(val, typ, jsToJSONProps); +} + +function l(typ: any) { + return { literal: typ }; +} + +function a(typ: any) { + return { arrayItems: typ }; +} + +function u(...typs: any[]) { + return { unionMembers: typs }; +} + +function o(props: any[], additional: any) { + return { props, additional }; +} + +function m(additional: any) { + return { props: [], additional }; +} + +function r(name: string) { + return { ref: name }; +} + +const typeMap: any = { + "NewProfile": o([ + { json: "created", js: "created", typ: u(undefined, 0) }, + { json: "updated", js: "updated", typ: u(undefined, 0) }, + { json: "user", js: "user", typ: "" }, + { json: "username", js: "username", typ: "" }, + ], "any"), +}; diff --git a/examples/blog/web/types/profile.ts b/examples/blog/web/types/profile.ts new file mode 100644 index 0000000..c8cd83a --- /dev/null +++ b/examples/blog/web/types/profile.ts @@ -0,0 +1,193 @@ +// To parse this data: +// +// import { Convert, Profile } from "./file"; +// +// const profile = Convert.toProfile(json); +// +// These functions will throw an error if the JSON doesn't +// match the expected interface, even if the JSON is valid. + +export interface Profile { + avatar_url?: string; + created: number; + is_editor?: boolean; + updated: number; + user: string; + username: string; + [property: string]: any; +} + +// Converts JSON strings to/from your types +// and asserts the results of JSON.parse at runtime +export class Convert { + public static toProfile(json: string): Profile { + return cast(JSON.parse(json), r("Profile")); + } + + public static profileToJson(value: Profile): string { + return JSON.stringify(uncast(value, r("Profile")), null, 2); + } +} + +function invalidValue(typ: any, val: any, key: any, parent: any = ''): never { + const prettyTyp = prettyTypeName(typ); + const parentText = parent ? ` on ${parent}` : ''; + const keyText = key ? ` for key "${key}"` : ''; + throw Error(`Invalid value${keyText}${parentText}. Expected ${prettyTyp} but got ${JSON.stringify(val)}`); +} + +function prettyTypeName(typ: any): string { + if (Array.isArray(typ)) { + if (typ.length === 2 && typ[0] === undefined) { + return `an optional ${prettyTypeName(typ[1])}`; + } else { + return `one of [${typ.map(a => { return prettyTypeName(a); }).join(", ")}]`; + } + } else if (typeof typ === "object" && typ.literal !== undefined) { + return typ.literal; + } else { + return typeof typ; + } +} + +function jsonToJSProps(typ: any): any { + if (typ.jsonToJS === undefined) { + const map: any = {}; + typ.props.forEach((p: any) => map[p.json] = { key: p.js, typ: p.typ }); + typ.jsonToJS = map; + } + return typ.jsonToJS; +} + +function jsToJSONProps(typ: any): any { + if (typ.jsToJSON === undefined) { + const map: any = {}; + typ.props.forEach((p: any) => map[p.js] = { key: p.json, typ: p.typ }); + typ.jsToJSON = map; + } + return typ.jsToJSON; +} + +function transform(val: any, typ: any, getProps: any, key: any = '', parent: any = ''): any { + function transformPrimitive(typ: string, val: any): any { + if (typeof typ === typeof val) return val; + return invalidValue(typ, val, key, parent); + } + + function transformUnion(typs: any[], val: any): any { + // val must validate against one typ in typs + const l = typs.length; + for (let i = 0; i < l; i++) { + const typ = typs[i]; + try { + return transform(val, typ, getProps); + } catch (_) {} + } + return invalidValue(typs, val, key, parent); + } + + function transformEnum(cases: string[], val: any): any { + if (cases.indexOf(val) !== -1) return val; + return invalidValue(cases.map(a => { return l(a); }), val, key, parent); + } + + function transformArray(typ: any, val: any): any { + // val must be an array with no invalid elements + if (!Array.isArray(val)) return invalidValue(l("array"), val, key, parent); + return val.map(el => transform(el, typ, getProps)); + } + + function transformDate(val: any): any { + if (val === null) { + return null; + } + const d = new Date(val); + if (isNaN(d.valueOf())) { + return invalidValue(l("Date"), val, key, parent); + } + return d; + } + + function transformObject(props: { [k: string]: any }, additional: any, val: any): any { + if (val === null || typeof val !== "object" || Array.isArray(val)) { + return invalidValue(l(ref || "object"), val, key, parent); + } + const result: any = {}; + Object.getOwnPropertyNames(props).forEach(key => { + const prop = props[key]; + const v = Object.prototype.hasOwnProperty.call(val, key) ? val[key] : undefined; + result[prop.key] = transform(v, prop.typ, getProps, key, ref); + }); + Object.getOwnPropertyNames(val).forEach(key => { + if (!Object.prototype.hasOwnProperty.call(props, key)) { + result[key] = transform(val[key], additional, getProps, key, ref); + } + }); + return result; + } + + if (typ === "any") return val; + if (typ === null) { + if (val === null) return val; + return invalidValue(typ, val, key, parent); + } + if (typ === false) return invalidValue(typ, val, key, parent); + let ref: any = undefined; + while (typeof typ === "object" && typ.ref !== undefined) { + ref = typ.ref; + typ = typeMap[typ.ref]; + } + if (Array.isArray(typ)) return transformEnum(typ, val); + if (typeof typ === "object") { + return typ.hasOwnProperty("unionMembers") ? transformUnion(typ.unionMembers, val) + : typ.hasOwnProperty("arrayItems") ? transformArray(typ.arrayItems, val) + : typ.hasOwnProperty("props") ? transformObject(getProps(typ), typ.additional, val) + : invalidValue(typ, val, key, parent); + } + // Numbers can be parsed by Date but shouldn't be. + if (typ === Date && typeof val !== "number") return transformDate(val); + return transformPrimitive(typ, val); +} + +function cast(val: any, typ: any): T { + return transform(val, typ, jsonToJSProps); +} + +function uncast(val: T, typ: any): any { + return transform(val, typ, jsToJSONProps); +} + +function l(typ: any) { + return { literal: typ }; +} + +function a(typ: any) { + return { arrayItems: typ }; +} + +function u(...typs: any[]) { + return { unionMembers: typs }; +} + +function o(props: any[], additional: any) { + return { props, additional }; +} + +function m(additional: any) { + return { props: [], additional }; +} + +function r(name: string) { + return { ref: name }; +} + +const typeMap: any = { + "Profile": o([ + { json: "avatar_url", js: "avatar_url", typ: u(undefined, "") }, + { json: "created", js: "created", typ: 0 }, + { json: "is_editor", js: "is_editor", typ: u(undefined, true) }, + { json: "updated", js: "updated", typ: 0 }, + { json: "user", js: "user", typ: "" }, + { json: "username", js: "username", typ: "" }, + ], "any"), +}; diff --git a/examples/custom-binary/Cargo.toml b/examples/custom-binary/Cargo.toml new file mode 100644 index 0000000..2030a5e --- /dev/null +++ b/examples/custom-binary/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "custom-binary" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { version = "^0.7.5" } +env_logger = "^0.11.3" +tokio = { version = "^1.38.0", features=["macros", "rt-multi-thread"] } +tracing-subscriber = "0.3.18" +trailbase-core = { path = "../../trailbase-core" } diff --git a/examples/custom-binary/src/main.rs b/examples/custom-binary/src/main.rs new file mode 100644 index 0000000..ecd5863 --- /dev/null +++ b/examples/custom-binary/src/main.rs @@ -0,0 +1,63 @@ +use axum::{ + extract::State, + response::{Html, IntoResponse, Response}, + routing::{get, Router}, +}; +use tracing_subscriber::{filter, prelude::*}; +use trailbase_core::{AppState, Server, ServerOptions, User}; + +type BoxError = Box; + +pub async fn handler(State(_state): State, user: Option) -> Response { + Html(format!( + "

Hello, {}!

", + user.map_or("World".to_string(), |user| user.email) + )) + .into_response() +} + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + env_logger::init_from_env( + env_logger::Env::new().default_filter_or("info,trailbase_core=debug,refinery_core=warn"), + ); + + let custom_routes: Router = Router::new().route("/", get(handler)); + + let app = Server::init_with_custom_routes_and_initializer( + ServerOptions { + address: "localhost:4004".to_string(), + ..Default::default() + }, + Some(custom_routes), + |state: AppState| async move { + println!("Data dir: {:?}", state.data_dir()); + Ok(()) + }, + ) + .await?; + + let filter = || { + filter::Targets::new() + .with_target("tower_http::trace::on_response", filter::LevelFilter::DEBUG) + .with_target("tower_http::trace::on_request", filter::LevelFilter::DEBUG) + .with_target("tower_http::trace::make_span", filter::LevelFilter::DEBUG) + .with_default(filter::LevelFilter::INFO) + }; + + // This declares **where** tracing is being logged to, e.g. stderr, file, sqlite. + let layer = tracing_subscriber::registry() + .with(trailbase_core::logging::SqliteLogLayer::new(app.state()).with_filter(filter())); + + let _ = layer + .with( + tracing_subscriber::fmt::layer() + .compact() + .with_filter(filter()), + ) + .try_init(); + + app.serve().await?; + + return Ok(()); +} diff --git a/examples/tutorial/Makefile b/examples/tutorial/Makefile new file mode 100644 index 0000000..ef9dede --- /dev/null +++ b/examples/tutorial/Makefile @@ -0,0 +1,4 @@ +clean: + rm -rf traildepot/data + +.PHONY: clean diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md new file mode 100644 index 0000000..6788d69 --- /dev/null +++ b/examples/tutorial/README.md @@ -0,0 +1,152 @@ +# TrailBase Tutorial + +In this tutorial, we'll set up a database with an IMDB test dataset, spin up +TrailBase and write a small program to access the data. + +In an effort to demonstrate TrailBase's loose coupling and the possibility of +simply trying out TrailBase with an existing SQLite-based data analysis +project, we will also offer a alternative path to bootstrapping the database +using the vanilla `sqlite3` CLI. + +## Create the Schema + +By simply starting TrailBase, the migrations in `traildepot/migrations` will be +applied, including `U1728810800__create_table_movies.sql`: + +```sql +CREATE TABLE movies IF NOT EXISTS ( + rank INTEGER PRIMARY KEY, + name TEXT NOT NULL, + year ANY NOT NULL, + watch_time INTEGER NOT NULL, + rating REAL NOT NULL, + metascore ANY, + gross ANY, + votes TEXT NOT NULL, + description TEXT NOT NULL +) STRICT; +``` + +Note that the only schema requirement for exposing an API is: `STRICT` typing +and an integer (or UUIDv7) primary key column. + +The main benefit of relying on TrailBase to apply the above schema as migrations +over manually applying the schema yourself, is to: + * document your database's schema alongside your code and + * even more importantly, letting TrailBase bootstrap from scratch and + sync-up databases across your dev setup, your colleague's, every time + integration tests run, QA stages, and in production. + +That said, TrailBase will happily work on existing datasets, in which +case it is your responsibility to provide a SQLite database file that +meets expectations expressed as configured TrailBase API endpoints. + +Feel free to run: + +```bash +$ mkdir traildepot/data +$ sqlite3 traildepot/data/main.db < traildepot/migrations/U1728810800__create_table_movies.sql +``` + +before starting TrailBase the first time, if you prefer bootstrapping the +database yourself. + +## Importing the Data + +After creating the schema above, either manually or starting TrailBase to apply +migrations, we're ready to import the IMDB test dataset. +We could now expose an API endpoint and write a small program to first read the +CSV file to then write movie database records... and we'll do that in a little +later. +For now, let's start by harnessing the fact that SQLite databases are simply a +local file and import the data using the `sqlite3` CLI side-stepping TrailBase: + +``` +$ sqlite3 traildepot/data/main.db +sqlite> .mode csv +sqlite> .import ./data/Top_1000_IMDb_movies_New_version.csv movies +``` + +There will be a warning for the first line of the CSV, which contains textual +table headers rather than data matching our schema. That's expected. +We can validate that we successfully imported 1000 movies by running: + +```sql +sqlite> SELECT COUNT(*) FROM movies; +1000 +``` + +## Accessing the Data + +With TrailBase up and running (`trail run`), the easiest way to explore your +data is go to the admin dashboard under +[http://localhost:4000](http://localhost:4000) +and log in with the admin credentials provided to you in the terminal upon +first start (you can also use the `trail` CLI to reset the password `trail user +reset-password admin@localhost`). + +In this tutorial we want to explore more programmatic access and using +TrailBase record APIs. + +```textproto +record_apis: [ + # ... + { + name: "movies" + table_name: "movies" + acl_world: [READ] + acl_authenticated: [CREATE, READ, UPDATE, DELETE] + } +] +``` + +By adding the above snippet to your configuration (which is already the case +for the checked-in configuration) you expose a world-readable API. We're using +the config here but you can also configure the API using the admin dashboard +via the +[tables view](http://localhost:4000/_/admin/tables?pageIndex=0&pageSize=20&table=movies) +and the "Record API" settings in the top right. + +Let's try it out by querying the top-3 ranked movies with less than 120min +watch time: + +```bash +curl -g 'localhost:4000/api/records/v1/movies?limit=3&order=rank&watch_time[lt]=120' +``` + +You can also use your browser. Either way, you should see some JSON output with +the respective movies. + +## Type-safe APIs and Mutations + +Finally, let's authenticate and use privileged APIs to first delete all movies +and then add them pack using type-safe APIs rather than `sqlite3`. + +Let's first create the JSON Schema type definitions from the database schema we +added above. Note, that the type definition for creation, reading, and updating +are all different. Creating a new record requires values for all `NOT NULL` +columns w/o a default value, while reads guarantees values for all `NOT NULL` +columns, and updates only require values for columns that are being updated. +In this tutorial we'll "cheat" by using the same type definition for reading +existing and creating new records, since our schema doesn't define any default +values (except implicitly for the primary key), they're almost identical. + +In preparation for deleting and re-adding the movies, let's run: + +```bash +$ trail schema movies --mode insert +``` + +This will output a standard JSON schema type definition file. There's quite a few +code-generators you can use to generate bindings for your favorite language. +For this example we'll use *quicktype* to generate *TypeScript* definitions, +which also happens to support some other ~25 languages. You can install it, but +for the tutorial we'll stick with the [browser](https://app.quicktype.io/) +version and copy&paste the JSON schema from above. + +With the generated types, we can use the TrailBase TypeScript client to write +the following program: + +```ts +# scripts/src/fill.ts +``` diff --git a/examples/tutorial/data/README.md b/examples/tutorial/data/README.md new file mode 100644 index 0000000..70dfe4e --- /dev/null +++ b/examples/tutorial/data/README.md @@ -0,0 +1,35 @@ +# About the Dataset. + +This dataset was originally compiled from [IMDB +data](https://developer.imdb.com/non-commercial-datasets/) and is subject to +their terms of use and any applicable legal restrictions.. + +The compilation is provided by +[kaggle](https://www.kaggle.com/datasets/inductiveanks/top-1000-imdb-movies-dataset/data) +and can be downloaded via: + +```bash +curl -o archive.zip https://www.kaggle.com/api/v1/datasets/download/inductiveanks/top-1000-imdb-movies-dataset +``` + +## Schema + +```sql +CREATE TABLE movies ( + rank INTEGER PRIMARY KEY, + name TEXT NOT NULL, + + -- Year cannot be INTEGER, since some are like "I 2016". + year ANY NOT NULL, + watch_time INTEGER NOT NULL, -- in minutes + rating REAL NOT NULL, + + -- Ideally nullable integer, however sqlite assumes empty to be text. + metascore ANY, + + -- Ideally nullable real, however sqlite assumes empty to be text. + gross ANY, + votes TEXT NOT NULL, + description TEXT NOT NULL +) STRICT; +``` diff --git a/examples/tutorial/data/Top_1000_IMDb_movies_New_version.csv b/examples/tutorial/data/Top_1000_IMDb_movies_New_version.csv new file mode 100644 index 0000000..1aee012 --- /dev/null +++ b/examples/tutorial/data/Top_1000_IMDb_movies_New_version.csv @@ -0,0 +1,1001 @@ +Rank,Movie Name,Year of Release,Watch Time,Movie Rating,Metascore of movie,Gross,Votes,Description +0,The Shawshank Redemption,1994,142,9.3,82,28.34,"27,77,378","Over the course of several years, two convicts form a friendship, seeking consolation and, eventually, redemption through basic compassion." +1,The Godfather,1972,175,9.2,100,134.97,"19,33,588","Don Vito Corleone, head of a mafia family, decides to hand over his empire to his youngest son Michael. However, his decision unintentionally puts the lives of his loved ones in grave danger." +2,The Dark Knight,2008,152,9,84,534.86,"27,54,087","When the menace known as the Joker wreaks havoc and chaos on the people of Gotham, Batman must accept one of the greatest psychological and physical tests of his ability to fight injustice." +3,Schindler's List,1993,195,9,95,96.9,"13,97,886","In German-occupied Poland during World War II, industrialist Oskar Schindler gradually becomes concerned for his Jewish workforce after witnessing their persecution by the Nazis." +4,12 Angry Men,1957,96,9,97,4.36,"8,24,211",The jury in a New York City murder trial is frustrated by a single member whose skeptical caution forces them to more carefully consider the evidence before jumping to a hasty verdict. +5,The Lord of the Rings: The Return of the King,2003,201,9,94,377.85,"19,04,166",Gandalf and Aragorn lead the World of Men against Sauron's army to draw his gaze from Frodo and Sam as they approach Mount Doom with the One Ring. +6,The Godfather Part II,1974,202,9,90,57.3,"13,14,609","The early life and career of Vito Corleone in 1920s New York City is portrayed, while his son, Michael, expands and tightens his grip on the family crime syndicate." +7,Spider-Man: Across the Spider-Verse,2023,140,8.9,86,15,"1,98,031","Miles Morales catapults across the Multiverse, where he encounters a team of Spider-People charged with protecting its very existence. When the heroes clash on how to handle a new threat, Miles must redefine what it means to be a hero." +8,Pulp Fiction,1994,154,8.9,95,107.93,"21,31,189","The lives of two mob hitmen, a boxer, a gangster and his wife, and a pair of diner bandits intertwine in four tales of violence and redemption." +9,Inception,2010,148,8.8,74,292.58,"24,44,816","A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a C.E.O., but his tragic past may doom the project and his team to disaster." +10,Fight Club,1999,139,8.8,67,37.03,"22,12,960",An insomniac office worker and a devil-may-care soap maker form an underground fight club that evolves into much more. +11,The Lord of the Rings: The Fellowship of the Ring,2001,178,8.8,92,315.54,"19,32,439",A meek Hobbit from the Shire and eight companions set out on a journey to destroy the powerful One Ring and save Middle-earth from the Dark Lord Sauron. +12,Forrest Gump,1994,142,8.8,82,330.25,"21,60,038","The history of the United States from the 1950s to the '70s unfolds from the perspective of an Alabama man with an IQ of 75, who yearns to be reunited with his childhood sweetheart." +13,"Il buono, il brutto, il cattivo",1966,161,8.8,90,6.1,"7,84,276",A bounty hunting scam joins two men in an uneasy alliance against a third in a race to find a fortune in gold buried in a remote cemetery. +14,The Lord of the Rings: The Two Towers,2002,179,8.8,87,342.55,"17,18,332","While Frodo and Sam edge closer to Mordor with the help of the shifty Gollum, the divided fellowship makes a stand against Sauron's new ally, Saruman, and his hordes of Isengard." +15,Jai Bhim,2021,164,8.8,,219,"2,08,742","When a tribal man is arrested for a case of alleged theft, his wife turns to a human-rights lawyer to help bring justice." +16,777 Charlie,2022,136,8.8,,,"35,870",Dharma is stuck in a rut with his negative and lonely lifestyle and spends each day in the comfort of his loneliness. A pup named Charlie enters his life and gives him a new perspective towards it. +17,Oppenheimer,2023,180,8.7,88,29,"2,66,774","The story of American scientist, J. Robert Oppenheimer, and his role in the development of the atomic bomb." +18,Interstellar,2014,169,8.7,74,188.02,"19,56,197","When Earth becomes uninhabitable in the future, a farmer and ex-NASA pilot, Joseph Cooper, is tasked to pilot a spacecraft, along with a team of researchers, to find a new planet for humans." +19,GoodFellas,1990,145,8.7,92,46.84,"12,05,052","The story of Henry Hill and his life in the mafia, covering his relationship with his wife Karen and his mob partners Jimmy Conway and Tommy DeVito." +20,One Flew Over the Cuckoo's Nest,1975,133,8.7,84,112,"10,37,205","In the Fall of 1963, a Korean War veteran and criminal pleads insanity and is admitted to a mental institution, where he rallies up the scared patients against the tyrannical nurse." +21,The Matrix,1999,136,8.7,73,171.48,"19,76,616","When a beautiful stranger leads computer hacker Neo to a forbidding underworld, he discovers the shocking truth--the life he knows is the elaborate deception of an evil cyber-intelligence." +22,Star Wars: Episode V - The Empire Strikes Back,1980,124,8.7,82,290.48,"13,33,333","After the Rebels are overpowered by the Empire, Luke Skywalker begins his Jedi training with Yoda, while his friends are pursued across the galaxy by Darth Vader and bounty hunter Boba Fett." +23,Rocketry: The Nambi Effect,2022,157,8.7,,,"54,505","Based on the life of Indian Space Research Organization scientist Nambi Narayanan, who was framed for being a spy and arrested in 1994. Though free, he continues to fight for justice against the officials who falsely implicated him." +24,Soorarai Pottru,2020,153,8.7,,,"1,20,200","Nedumaaran Rajangam ""Maara"" sets out to make the common man fly and in the process takes on the world's most capital intensive industry and several enemies who stand in his way." +25,Se7en,1995,127,8.6,65,100.13,"17,18,303","Two detectives, a rookie and a veteran, hunt a serial killer who uses the seven deadly sins as his motives." +26,Saving Private Ryan,1998,169,8.6,91,216.54,"14,38,634","Following the Normandy Landings, a group of U.S. soldiers go behind enemy lines to retrieve a paratrooper whose brothers have been killed in action." +27,The Silence of the Lambs,1991,118,8.6,86,130.74,"14,81,889","A young F.B.I. cadet must receive the help of an incarcerated and manipulative cannibal killer to help catch another serial killer, a madman who skins his victims." +28,Terminator 2: Judgment Day,1991,137,8.6,75,204.84,"11,34,147","A cyborg, identical to the one who failed to kill Sarah Connor, must now protect her ten year old son John from an even more advanced and powerful cyborg." +29,The Green Mile,1999,189,8.6,61,136.8,"13,49,946","A tale set on death row in a Southern jail, where gentle giant John possesses the mysterious power to heal people's ailments. When the lead guard, Paul, recognizes John's gift, he tries to help stave off the condemned man's execution." +30,Star Wars,1977,121,8.6,90,322.74,"14,05,392","Luke Skywalker joins forces with a Jedi Knight, a cocky pilot, a Wookiee and two droids to save the galaxy from the Empire's world-destroying battle station, while also attempting to rescue Princess Leia from the mysterious Darth Vader." +31,Cidade de Deus,2002,130,8.6,79,7.56,"7,77,054","In the slums of Rio, two kids' paths diverge as one struggles to become a photographer and the other a kingpin." +32,Sen to Chihiro no kamikakushi,2001,125,8.6,96,10.06,"8,01,486","During her family's move to the suburbs, a sullen 10-year-old girl wanders into a world ruled by gods, witches and spirits, a world where humans are changed into beasts." +33,La vita è bella,1997,116,8.6,59,57.6,"7,17,512","When an open-minded Jewish waiter and his son become victims of the Holocaust, he uses a perfect mixture of will, humor and imagination to protect his son from the dangers around their camp." +34,Shichinin no samurai,1954,207,8.6,98,0.27,"3,55,780","Farmers from a village exploited by bandits hire a veteran samurai for protection, who gathers six other samurai to join him." +35,It's a Wonderful Life,1946,130,8.6,89,21,"4,76,152",An angel is sent from Heaven to help a desperately frustrated businessman by showing him what life would have been like if he had never existed. +36,Seppuku,1962,133,8.6,85,47,"62,597","When a ronin requesting seppuku at a feudal lord's palace is told of the brutal suicide of another ronin who previously visited, he reveals how their pasts are intertwined - and in doing so challenges the clan's integrity." +37,Sita Ramam,2022,163,8.6,,,"61,758","An orphan soldier, Lieutenant Ram's life changes, after he gets a letter from a girl named Sita. He meets her and love blossoms between them. When he comes back to his camp in Kashmir,After he gets caught in jail, he sends a letter to Sita which won't reach her." +38,The Prestige,2006,130,8.5,66,53.09,"13,82,402","After a tragic accident, two stage magicians in 1890s London engage in a battle to create the ultimate illusion while sacrificing everything they have to outwit each other." +39,Back to the Future,1985,116,8.5,87,210.61,"12,53,518","Marty McFly, a 17-year-old high school student, is accidentally sent 30 years into the past in a time-traveling DeLorean invented by his close friend, the maverick scientist Doc Brown." +40,Gisaengchung,2019,132,8.5,96,53.37,"8,73,956",Greed and class discrimination threaten the newly formed symbiotic relationship between the wealthy Park family and the destitute Kim clan. +41,Gladiator,2000,155,8.5,67,187.71,"15,50,619",A former Roman General sets out to exact vengeance against the corrupt emperor who murdered his family and sent him into slavery. +42,The Departed,2006,151,8.5,85,132.38,"13,69,934",An undercover cop and a mole in the police attempt to identify each other while infiltrating an Irish gang in South Boston. +43,Alien,1979,117,8.5,89,78.9,"9,12,280",The crew of a commercial spacecraft encounters a deadly lifeform after investigating an unknown transmission. +44,Whiplash,2014,106,8.5,89,13.09,"9,20,655",A promising young drummer enrolls at a cut-throat music conservatory where his dreams of greatness are mentored by an instructor who will stop at nothing to realize a student's potential. +45,Léon,1994,110,8.5,64,19.5,"12,00,839","12-year-old Mathilda is reluctantly taken in by Léon, a professional assassin, after her family is murdered. An unusual relationship forms as she becomes his protégée and learns the assassin's trade." +46,The Pianist,2002,150,8.5,85,32.57,"8,67,989",A Polish Jewish musician struggles to survive the destruction of the Warsaw ghetto of World War II. +47,The Usual Suspects,1995,106,8.5,77,23.34,"11,12,608",The sole survivor of a pier shoot-out tells the story of how a notorious criminal influenced the events that began with five criminals meeting in a seemingly random police lineup. +48,The Lion King,1994,88,8.5,88,422.78,"10,97,285","Lion prince Simba and his father are targeted by his bitter uncle, who wants to ascend the throne himself." +49,American History X,1998,119,8.5,62,6.72,"11,51,761","Living a life marked by violence, neo-Nazi Derek finally goes to prison after killing two black youths. Upon his release, Derek vows to change; he hopes to prevent his brother, Danny, who idolizes Derek, from following in his footsteps." +50,Hotaru no haka,1988,89,8.5,94,46,"2,91,861",A young boy and his little sister struggle to survive in Japan during World War II. +51,The Intouchables,2011,112,8.5,57,13.18,"8,90,758","After he becomes a quadriplegic from a paragliding accident, an aristocrat hires a young man from the projects to be his caregiver." +52,Psycho,1960,109,8.5,97,32,"6,93,868","A Phoenix secretary embezzles $40,000 from her employer's client, goes on the run and checks into a remote motel run by a young man under the domination of his mother." +53,Casablanca,1942,102,8.5,100,1.02,"5,88,497",A cynical expatriate American cafe owner struggles to decide whether or not to help his former lover and her fugitive husband escape the Nazis in French Morocco. +54,Once Upon a Time in the West,1968,165,8.5,82,5.32,"3,39,755",A mysterious stranger with a harmonica joins forces with a notorious desperado to protect a beautiful widow from a ruthless assassin working for the railroad. +55,Rear Window,1954,112,8.5,100,36.76,"5,05,881","A photographer in a wheelchair spies on his neighbors from his Greenwich Village courtyard apartment window, and becomes convinced one of them has committed murder, despite the skepticism of his fashion-model girlfriend." +56,Nuovo Cinema Paradiso,1988,155,8.5,80,11.99,"2,71,463",A filmmaker recalls his childhood when falling in love with the pictures at the cinema of his home village and forms a deep friendship with the cinema's projectionist. +57,City Lights,1931,87,8.5,99,0.02,"1,90,158","With the aid of a wealthy erratic tippler, a dewy-eyed tramp who has fallen in love with a sightless flower girl accumulates money to be able to help her medically." +58,Modern Times,1936,87,8.5,96,0.16,"2,50,746",The Tramp struggles to live in modern industrial society with the help of a young homeless woman. +59,96,II 2018,158,8.5,,,"33,646",Two high school sweethearts meet at a reunion after 22 years and reminisce about their past. +60,Memento,2000,113,8.4,83,25.54,"12,77,091",A man with short-term memory loss attempts to track down his wife's murderer. +61,The Dark Knight Rises,2012,164,8.4,78,448.14,"17,61,545","Eight years after the Joker's reign of chaos, Batman is coerced out of exile with the assistance of the mysterious Selina Kyle in order to defend Gotham City from the vicious guerrilla terrorist Bane." +62,Spider-Man: Into the Spider-Verse,2018,117,8.4,87,190.24,"6,15,401",Teen Miles Morales becomes the Spider-Man of his universe and must join with five spider-powered individuals from other dimensions to stop a threat for all realities. +63,Raiders of the Lost Ark,1981,115,8.4,85,248.16,"10,06,763","In 1936, archaeologist and adventurer Indiana Jones is hired by the U.S. government to find the Ark of the Covenant before the Nazis can obtain its awesome powers." +64,Django Unchained,2012,165,8.4,81,162.81,"16,19,946","With the help of a German bounty-hunter, a freed slave sets out to rescue his wife from a brutal plantation owner in Mississippi." +65,Joker,I 2019,122,8.4,59,335.45,"13,81,002","The rise of Arthur Fleck, from aspiring stand-up comedian and pariah to Gotham's clown prince and leader of the revolution." +66,Avengers: Endgame,2019,181,8.4,78,858.37,"12,00,821","After the devastating events of Avengers: Infinity War (2018), the universe is in ruins. With the help of remaining allies, the Avengers assemble once more in order to reverse Thanos' actions and restore balance to the universe." +67,The Shining,1980,146,8.4,66,44.02,"10,60,625","A family heads to an isolated hotel for the winter where a sinister presence influences the father into violence, while his psychic son sees horrific forebodings from both past and future." +68,Aliens,1986,137,8.4,84,85.16,"7,38,697","Decades after surviving the Nostromo incident, Ellen Ripley is sent out to re-establish contact with a terraforming colony but finds herself battling the Alien Queen and her offspring." +69,Oldeuboi,2003,101,8.4,77,0.71,"6,03,564","After being kidnapped and imprisoned for fifteen years, Oh Dae-Su is released, only to find that he must find his captor in five days." +70,Avengers: Infinity War,2018,149,8.4,68,678.82,"11,42,664",The Avengers and their allies must be willing to sacrifice all in an attempt to defeat the powerful Thanos before his blitz of devastation and ruin puts an end to the universe. +71,Apocalypse Now,1979,147,8.4,94,83.47,"6,87,709",A U.S. Army officer serving in Vietnam is tasked with assassinating a renegade Special Forces Colonel who sees himself as a god. +72,Amadeus,1984,160,8.4,87,51.97,"4,13,533","The life, success and troubles of Wolfgang Amadeus Mozart, as told by Antonio Salieri, the contemporaneous composer who was deeply jealous of Mozart's talent and claimed to have murdered him." +73,Idi i smotri,1985,142,8.4,,91,"88,282","After finding an old rifle, a young boy joins the Soviet resistance movement against ruthless German forces and experiences the horrors of World War II." +74,Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb,1964,95,8.4,97,0.28,"5,03,942","An insane American general orders a bombing attack on the Soviet Union, triggering a path to nuclear holocaust that a war room full of politicians and generals frantically tries to stop." +75,The Lives of Others,2006,137,8.4,89,11.29,"3,99,279","In 1984 East Berlin, an agent of the secret police, conducting surveillance on a writer and his lover, finds himself becoming increasingly absorbed by their lives." +76,Kimi no na wa.,2016,106,8.4,81,5.02,"2,93,932","Two teenagers share a profound, magical connection upon discovering they are swapping bodies. Things manage to become even more complicated when the boy and girl decide to meet in person." +77,Coco,I 2017,105,8.4,81,209.73,"5,46,380","Aspiring musician Miguel, confronted with his family's ancestral ban on music, enters the Land of the Dead to find his great-great-grandfather, a legendary singer." +78,WALL·E,2008,98,8.4,95,223.81,"11,55,638","In the distant future, a small waste-collecting robot inadvertently embarks on a space journey that will ultimately decide the fate of mankind." +79,Capharnaüm,2018,126,8.4,75,1.66,"97,339","While serving a five-year sentence for a violent crime, a 12-year-old boy sues his parents for neglect." +80,3 Idiots,2009,170,8.4,67,6.53,"4,15,008","Two friends are searching for their long lost companion. They revisit their college days and recall the memories of their friend who inspired them to think differently, even as the rest of the world called them ""idiots""." +81,Das Boot,1981,149,8.4,86,11.49,"2,57,484",A German U-boat stalks the frigid waters of the North Atlantic as its young crew experience the sheer terror and claustrophobic life of a submariner in World War II. +82,Paths of Glory,1957,88,8.4,90,62,"2,04,364","After refusing to attack an enemy position, a general accuses the soldiers of cowardice and their commanding officer must defend them." +83,Sunset Blvd.,1950,110,8.4,94,61,"2,28,990",A screenwriter develops a dangerous relationship with a faded film star determined to make a triumphant return. +84,Witness for the Prosecution,1957,116,8.4,76,8.18,"1,31,199",A veteran British barrister must defend his client in a murder trial that has surprise after surprise. +85,The Great Dictator,1940,125,8.4,,0.29,"2,30,579",Dictator Adenoid Hynkel tries to expand his empire while a poor Jewish barber tries to avoid persecution from Hynkel's regime. +86,Tengoku to jigoku,1963,143,8.4,90,87,"48,880",An executive of a Yokohama shoe company becomes a victim of extortion when his chauffeur's son is kidnapped by mistake and held for ransom. +87,Kaithi,2019,145,8.4,,,"36,952","Dilli, an ex-convict, endeavours to meet his daughter for the first time after leaving prison. However, his attempts are interrupted due to a drug raid planned by Inspector Bejoy." +88,Sardar Udham,2021,164,8.4,,,"44,711","A biopic detailing the 2 decades that Punjabi Sikh revolutionary, Udham Singh, spent planning the assassination of the man responsible for the Jallianwala Bagh massacre." +89,Asuran,2019,141,8.4,,,"31,622","The teenage son of a farmer from an underprivileged caste kills a rich, upper caste landlord. How the pacifist farmer saves his hot-blooded son is the rest of the story." +90,Drishyam 2,2021,152,8.4,,,"39,387",A gripping tale of an investigation and a family which is threatened by it. Will Georgekutty be able to protect his family this time? +91,Top Gun: Maverick,2022,130,8.3,78,718.73,"6,16,792","After thirty years, Maverick is still pushing the envelope as a top naval aviator, but must confront ghosts of his past when he leads TOP GUN's elite graduates on a mission that demands the ultimate sacrifice from those chosen to fly it." +92,Braveheart,1995,178,8.3,68,75.6,"10,62,862",Scottish warrior William Wallace leads his countrymen in a rebellion to free his homeland from the tyranny of King Edward I of England. +93,Inglourious Basterds,2009,153,8.3,69,120.54,"15,10,473","In Nazi-occupied France during World War II, a plan to assassinate Nazi leaders by a group of Jewish U.S. soldiers coincides with a theatre owner's vengeful plans for the same." +94,Heat,1995,170,8.3,76,67.44,"6,85,676",A group of high-end professional thieves start to feel the heat from the LAPD when they unknowingly leave a clue at their latest heist. +95,American Beauty,1999,122,8.3,84,130.1,"11,80,519",A sexually frustrated suburban father has a mid-life crisis after becoming infatuated with his daughter's best friend. +96,Requiem for a Dream,2000,102,8.3,71,3.64,"8,68,981",The drug-induced utopias of four Coney Island people are shattered when their addictions run deep. +97,Good Will Hunting,1997,126,8.3,70,138.43,"10,14,823","Will Hunting, a janitor at M.I.T., has a gift for mathematics, but needs help from a psychologist to find direction in his life." +98,2001: A Space Odyssey,1968,149,8.3,84,56.95,"6,92,341","After uncovering a mysterious artifact buried beneath the Lunar surface, a spacecraft is sent to Jupiter to find its origins - a spacecraft manned by two men and the supercomputer H.A.L. 9000." +99,To Kill a Mockingbird,1962,129,8.3,88,113,"3,24,539","Atticus Finch, a widowed lawyer in Depression-era Alabama, defends a Black man against an undeserved rape charge, and tries to educate his young children against prejudice." +100,Reservoir Dogs,1992,99,8.3,81,2.83,"10,52,277","When a simple jewelry heist goes horribly wrong, the surviving criminals begin to suspect that one of them is a police informant." +101,A Clockwork Orange,1971,136,8.3,77,6.21,"8,54,641","In the future, a sadistic gang leader is imprisoned and volunteers for a conduct-aversion experiment, but it doesn't go as planned." +102,Scarface,1983,170,8.3,65,45.6,"8,76,738","In 1980 Miami, a determined Cuban immigrant takes over a drug cartel and succumbs to greed." +103,Eternal Sunshine of the Spotless Mind,2004,108,8.3,89,34.4,"10,39,917","When their relationship turns sour, a couple undergoes a medical procedure to have each other erased from their memories for ever." +104,Jagten,2012,115,8.3,77,0.69,"3,45,981","A teacher lives a lonely life, all the while struggling over his son's custody. His life slowly gets better as he finds love and receives good news from his son, but his new luck is about to be brutally shattered by an innocent little lie." +105,Full Metal Jacket,1987,116,8.3,78,46.36,"7,65,283",A pragmatic U.S. Marine observes the dehumanizing effects the Vietnam War has on his fellow recruits from their brutal boot camp training to the bloody street fighting in Hue. +106,Once Upon a Time in America,1984,229,8.3,75,5.32,"3,63,889","A former Prohibition-era Jewish gangster returns to the Lower East Side of Manhattan 35 years later, where he must once again confront the ghosts and regrets of his old life." +107,Toy Story,1995,81,8.3,96,191.8,"10,29,621",A cowboy doll is profoundly threatened and jealous when a new spaceman action figure supplants him as top toy in a boy's bedroom. +108,Hamilton,2020,160,8.3,89,110,"1,04,182","The real life of one of America's foremost founding fathers and first Secretary of the Treasury, Alexander Hamilton. Captured live on Broadway from the Richard Rodgers Theater with the original Broadway cast." +109,Up,2009,96,8.3,88,293,"10,81,937","78-year-old Carl Fredricksen travels to Paradise Falls in his house equipped with balloons, inadvertently taking a young stowaway." +110,Incendies,2010,131,8.3,80,6.86,"1,86,891",Twins journey to the Middle East to discover their family history and fulfill their mother's last wishes. +111,Lawrence of Arabia,1962,218,8.3,100,44.82,"3,03,804","The story of T.E. Lawrence, the English officer who successfully united and led the diverse, often warring, Arab tribes during World War I in order to fight the Turks." +112,Toy Story 3,2010,103,8.3,92,415,"8,63,855","The toys are mistakenly delivered to a day-care center instead of the attic right before Andy leaves for college, and it's up to Woody to convince the other toys that they weren't abandoned and to return home." +113,Le fabuleux destin d'Amélie Poulain,2001,122,8.3,69,33.23,"7,74,411","Despite being caught in her imaginative world, Amelie, a young waitress, decides to help people find happiness. Her quest to spread joy leads her on a journey where she finds true love." +114,Star Wars: Episode VI - Return of the Jedi,1983,131,8.3,58,309.13,"10,86,887","After rescuing Han Solo from Jabba the Hutt, the Rebels attempt to destroy the second Death Star, while Luke struggles to help Darth Vader back from the dark side." +115,Mononoke-hime,1997,134,8.3,76,2.38,"4,11,777","On a journey to find the cure for a Tatarigami's curse, Ashitaka finds himself in the middle of a war between the forest gods and Tatara, a mining colony. In this quest he also meets San, the Mononoke Hime." +116,Vertigo,1958,128,8.3,100,3.2,"4,14,047","A former San Francisco police detective juggles wrestling with his personal demons and becoming obsessed with the hauntingly beautiful woman he has been hired to trail, who may be deeply disturbed." +117,Citizen Kane,1941,119,8.3,100,1.59,"4,53,840","Following the death of publishing tycoon Charles Foster Kane, reporters scramble to uncover the meaning of his final utterance: 'Rosebud.'" +118,Singin' in the Rain,1952,103,8.3,99,8.82,"2,51,337",A silent film star falls for a chorus girl just as he and his delusionally jealous screen partner are trying to make the difficult transition to talking pictures in 1920s Hollywood. +119,Metropolis,1927,153,8.3,98,1.24,"1,79,702","In a futuristic city sharply divided between the working class and the city planners, the son of the city's mastermind falls in love with a working-class prophet who predicts the coming of a savior to mediate their differences." +120,North by Northwest,1959,136,8.3,98,13.28,"3,37,022","A New York City advertising executive goes on the run after being mistaken for a government agent by a group of foreign spies, and falls for a woman whose loyalties he begins to doubt." +121,Jodaeiye Nader az Simin,2011,123,8.3,95,7.1,"2,51,368",A married couple are faced with a difficult decision - to improve the life of their child by moving to another country or to stay in Iran and look after a deteriorating parent who has Alzheimer's disease. +122,The Sting,1973,129,8.3,83,159.6,"2,71,443",Two grifters team up to pull off the ultimate con. +123,The Apartment,1960,125,8.3,94,18.6,"1,88,581","A Manhattan insurance clerk tries to rise in his company by letting its executives use his apartment for trysts, but complications and a romance of his own ensue." +124,Judgment at Nuremberg,1961,179,8.3,60,136,"81,006","In 1948, an American court in occupied Germany tries four Nazis judged for war crimes." +125,Dangal,2016,161,8.3,,12.39,"1,99,773",Former wrestler Mahavir Singh Phogat and his two wrestler daughters struggle towards glory at the Commonwealth Games in the face of societal oppression. +126,M - Eine Stadt sucht einen Mörder,1931,117,8.3,,0.03,"1,63,105","When the police in a German city are unable to catch a child-murderer, other criminals join in the manhunt." +127,Ikiru,1952,143,8.3,92,0.06,"83,069",A bureaucrat tries to find meaning in his life after he discovers he has terminal cancer. +128,Double Indemnity,1944,107,8.3,95,5.72,"1,62,166","A Los Angeles insurance representative lets an alluring housewife seduce him into a scheme of insurance fraud and murder that arouses the suspicion of his colleague, an insurance investigator." +129,Kantara,2022,148,8.3,,,"97,182","When greed paves the way for betrayal, scheming and murder, a young tribal reluctantly dons the traditions of his ancestors to seek justice." +130,Taare Zameen Par,2007,165,8.3,,1.22,"2,00,039","An eight-year-old boy is thought to be a lazy trouble-maker, until the new art teacher has the patience and compassion to discover the real problem behind his struggles in school." +131,Ladri di biciclette,1948,89,8.3,,0.33,"1,69,175","In post-war Italy, a working-class man's bicycle is stolen, endangering his efforts to find work. He and his son set out to find it." +132,K.G.F: Chapter 2,2022,168,8.3,,6.6,"1,41,399","In the blood-soaked Kolar Gold Fields, Rocky's name strikes fear into his foes. While his allies look up to him, the government sees him as a threat to law and order. Rocky must battle threats from all sides for unchallenged supremacy." +133,Ayla: The Daughter of War,2017,125,8.3,,,"42,060","Sergeant Süleyman finds a little girl on a battlefield during the Korean War. He takes her and names her Ayla. Fifteen months later, Süleyman's brigade is told they will be returning to Turkey, and he is reluctant to leave her behind." +134,Vikram,2022,175,8.3,,,"65,063","A special investigator assigned a case of serial killings discovers the case is not what it seems to be, and leading down this path is only going to end in a war between everyone involved." +135,The Kid,1921,68,8.3,,5.45,"1,30,534","The Tramp cares for an abandoned child, but events put their relationship in jeopardy." +136,Chhichhore,2019,143,8.3,,0.9,"58,946","A tragic incident forces Anirudh, a middle-aged man, to take a trip down memory lane and reminisce his college days along with his friends, who were labelled as losers." +137,Shershaah,2021,135,8.3,,,"1,25,763","Shershaah is the story of PVC awardee Indian soldier Capt. Vikram Batra, whose bravery and unflinching courage in chasing the Pakistani soldiers out of Indian territory contributed immensely in India finally winning the Kargil War in 1999." +138,Ratsasan,2018,170,8.3,,,"47,613",A sub-inspector sets out in pursuit of a mysterious serial killer who targets teen school girls and murders them brutally. +139,Drishyam,2013,160,8.3,,,"42,884",A man goes to extreme lengths to save his family from punishment after the family commits an accidental crime. +140,The Wolf of Wall Street,2013,180,8.2,75,116.9,"14,87,540","Based on the true story of Jordan Belfort, from his rise to a wealthy stock-broker living the high life to his fall involving crime, corruption and the federal government." +141,Batman Begins,2005,140,8.2,70,206.85,"15,21,333","After witnessing his parents' death, Bruce learns the art of fighting to confront injustice. When he returns to Gotham as Batman, he must stop a secret society that intends to destroy the city." +142,Spider-Man: No Way Home,2021,148,8.2,71,804.75,"8,18,919","With Spider-Man's identity now revealed, Peter asks Doctor Strange for help. When a spell goes wrong, dangerous foes from other worlds start to appear, forcing Peter to discover what it truly means to be Spider-Man." +143,No Country for Old Men,2007,122,8.2,92,74.28,"10,13,709",Violence and mayhem ensue after a hunter stumbles upon a drug deal gone wrong and more than two million dollars in cash near the Rio Grande. +144,Indiana Jones and the Last Crusade,1989,127,8.2,65,197.17,"7,86,608","In 1938, after his father goes missing while pursuing the Holy Grail, Indiana Jones finds himself up against the Nazis again to stop them from obtaining its powers." +145,Shutter Island,2010,138,8.2,63,128.01,"13,80,885","Teddy Daniels and Chuck Aule, two US marshals, are sent to an asylum on a remote island in order to investigate the disappearance of a patient, where Teddy uncovers a shocking truth about the place." +146,Jurassic Park,1993,127,8.2,68,402.45,"10,26,143",A pragmatic paleontologist touring an almost complete theme park on an island in Central America is tasked with protecting a couple of kids after a power failure causes the park's cloned dinosaurs to run loose. +147,There Will Be Blood,2007,158,8.2,93,40.22,"6,12,354","A story of family, religion, hatred, oil and madness, focusing on a turn-of-the-century prospector in the early days of the business." +148,The Truman Show,1998,103,8.2,90,125.62,"11,33,421",An insurance salesman discovers his whole life is actually a reality TV show. +149,Taxi Driver,1976,114,8.2,94,28.26,"8,76,254","A mentally unstable veteran works as a nighttime taxi driver in New York City, where the perceived decadence and sleaze fuels his urge for violent action." +150,1917,2019,119,8.2,78,159.23,"6,33,431","April 6th, 1917. As an infantry battalion assembles to wage war deep in enemy territory, two soldiers are assigned to race against time and deliver a message that will stop 1,600 men from walking straight into a deadly trap." +151,Snatch,2000,104,8.2,55,30.33,"8,80,917","Unscrupulous boxing promoters, violent bookmakers, a Russian gangster, incompetent amateur robbers and supposedly Jewish jewelers fight to track down a priceless stolen diamond." +152,Kill Bill: Vol. 1,2003,111,8.2,69,70.1,"11,50,722","After awakening from a four-year coma, a former assassin wreaks vengeance on the team of assassins who betrayed her." +153,The Thing,1982,109,8.2,57,13.78,"4,43,632",A research team in Antarctica is hunted by a shape-shifting alien that assumes the appearance of its victims. +154,Die Hard,1988,132,8.2,72,83.01,"9,08,004",A New York City police officer tries to save his estranged wife and several others taken hostage by terrorists during a Christmas party at the Nakatomi Plaza in Los Angeles. +155,Casino,1995,178,8.2,73,42.44,"5,40,865","In Las Vegas, two best friends - a casino executive and a mafia enforcer - compete for a gambling empire and a fast-living, fast-loving socialite." +156,Green Book,2018,130,8.2,69,85.08,"5,28,465",A working-class Italian-American bouncer becomes the driver for an African-American classical pianist on a tour of venues through the 1960s American South. +157,The Sixth Sense,1999,107,8.2,64,293.51,"10,18,597","Malcolm Crowe, a child psychologist, starts treating a young boy, Cole, who encounters dead people and convinces him to help them. In turn, Cole helps Malcolm reconcile with his estranged wife." +158,A Beautiful Mind,2001,135,8.2,72,170.74,"9,57,506","After John Nash, a brilliant but asocial mathematician, accepts secret work in cryptography, his life takes a turn for the nightmarish." +159,Hauru no ugoku shiro,2004,119,8.2,82,4.71,"4,18,993","When an unconfident young woman is cursed with an old body by a spiteful witch, her only chance of breaking the spell lies with a self-indulgent yet insecure young wizard and his companions in his legged, walking castle." +160,Finding Nemo,2003,100,8.2,90,380.84,"10,76,596","After his son is captured in the Great Barrier Reef and taken to Sydney, a timid clownfish sets out on a journey to bring him home." +161,L.A. Confidential,1997,138,8.2,91,64.62,"5,97,783","As corruption grows in 1950s Los Angeles, three policemen - one strait-laced, one brutal, and one sleazy - investigate a series of murders with their own brand of justice." +162,V for Vendetta,2005,132,8.2,62,70.51,"11,48,235","In a future British dystopian society, a shadowy freedom fighter, known only by the alias of ""V"", plots to overthrow the tyrannical government - with the help of a young woman." +163,The Father,I 2020,97,8.2,88,132,"1,73,861","A man refuses all assistance from his daughter as he ages. As he tries to make sense of his changing circumstances, he begins to doubt his loved ones, his own mind and even the fabric of his reality." +164,Der Untergang,2004,156,8.2,82,5.51,"3,64,966","Traudl Junge, the final secretary for Adolf Hitler, tells of the Nazi dictator's final days in his Berlin bunker at the end of WWII." +165,Pan's Labyrinth,2006,118,8.2,98,37.63,"6,85,345","In the Falangist Spain of 1944, the bookish young stepdaughter of a sadistic army officer escapes into an eerie but captivating fantasy world." +166,Chinatown,1974,130,8.2,92,157,"3,37,525","A private detective hired to expose an adulterer in 1930s Los Angeles finds himself caught up in a web of deceit, corruption, and murder." +167,Gone with the Wind,1939,238,8.2,97,198.68,"3,24,841",A sheltered and manipulative Southern belle and a roguish profiteer face off in a turbulent romance as the society around them crumbles with the end of slavery and is rebuilt during the Civil War and Reconstruction periods. +168,Unforgiven,1992,130,8.2,85,101.16,"4,24,008","Retired Old West gunslinger William Munny reluctantly takes on one last job, with the help of his old partner Ned Logan and a young man, The ""Schofield Kid.""" +169,Monty Python and the Holy Grail,1975,91,8.2,91,1.23,"5,55,708","King Arthur and his Knights of the Round Table embark on a surreal, low-budget search for the Holy Grail, encountering many, very silly obstacles." +170,The Great Escape,1963,172,8.2,86,12.1,"2,51,447",Allied prisoners of war plan for several hundred of their number to escape from a German camp during World War II. +171,The Elephant Man,1980,124,8.2,78,156,"2,50,169","A Victorian surgeon rescues a heavily disfigured man who is mistreated while scraping a living as a side-show freak. Behind his monstrous façade, there is revealed a person of kindness, intelligence and sophistication." +172,Some Like It Hot,1959,121,8.2,98,25,"2,75,260","After two male musicians witness a mob hit, they flee the state in an all-female band disguised as women, but further complications set in." +173,Per qualche dollaro in più,1965,132,8.2,74,15,"2,65,277",Two bounty hunters with the same intentions team up to track down an escaped Mexican outlaw. +174,El secreto de sus ojos,2009,129,8.2,80,6.39,"2,15,764",A retired legal counselor writes a novel hoping to find closure for one of his past unresolved homicide cases and for his unreciprocated love with his superior - both of which still haunt him decades later. +175,Ran,1985,162,8.2,97,4.14,"1,30,734","In Medieval Japan, an elderly warlord retires, handing over his empire to his three sons. However, he vastly underestimates how the new-found power will corrupt them and cause them to turn on each other...and him." +176,Tumbbad,2018,104,8.2,,,"54,234",A mythological story about a goddess who created the entire universe. The plot revolves around the consequences when humans build a temple for her first-born. +177,Kimetsu no Yaiba: Mugen Ressha-Hen,2020,117,8.2,72,47.7,"66,551","After his family was brutally murdered and his sister turned into a demon, Tanjiro Kamado's journey as a demon slayer began. Tanjiro and his comrades embark on a new mission aboard the Mugen Train, on track to despair." +178,Dial M for Murder,1954,105,8.2,75,0.01,"1,82,353",A former tennis star arranges the murder of his adulterous wife. +179,Drishyam 2,2022,140,8.2,,,"39,024",A gripping tale of an investigation and a family which is threatened by it. Will Vijay Salgaonkar be able to protect his family this time? +180,Klaus,2019,96,8.2,65,177,"1,69,477","A simple act of kindness always sparks another, even in a frozen, faraway place. When Smeerensburg's new postman, Jesper, befriends toymaker Klaus, their gifts melt an age-old feud and deliver a sleigh full of holiday traditions." +181,To Be or Not to Be,1942,99,8.2,86,231,"40,338","During the Nazi occupation of Poland, an acting troupe becomes embroiled in a Polish soldier's efforts to track down a German spy." +182,All About Eve,1950,138,8.2,98,0.01,"1,35,047",A seemingly timid but secretly ruthless ingénue insinuates herself into the lives of an aging Broadway star and her circle of theater friends. +183,Gangs of Wasseypur,2012,321,8.2,89,,"1,00,060","A clash between Sultan and Shahid Khan leads to the expulsion of Khan from Wasseypur, and ignites a deadly blood feud spanning three generations." +184,Rashômon,1950,88,8.2,98,0.1,"1,74,325","The rape of a bride and the murder of her samurai husband are recalled from the perspectives of a bandit, the bride, the samurai's ghost and a woodcutter." +185,Le salaire de la peur,1953,131,8.2,85,196,"63,827","In a decrepit South American village, four men are hired to transport an urgent nitroglycerine shipment without the equipment that would make it safe." +186,Tôkyô monogatari,1953,136,8.2,100,208,"64,996","An old couple visit their children and grandchildren in the city, but receive little attention." +187,Miracle in Cell No. 7,2019,132,8.2,,,"52,462",A story of love between a mentally-ill father who was wrongly accused of murder and his lovely six year old daughter. Prison will be their home. Based on the 2013 Korean movie 7-beon-bang-ui seon-mul (2013). +188,Yôjinbô,1961,110,8.2,93,148,"1,27,057",A crafty ronin comes to a town divided by two criminal gangs and decides to play them against each other to free the town. +189,Drishyam,2015,163,8.2,,0.74,"89,690","Desperate measures are taken by a man who tries to save his family from the dark side of the law, after they commit an unexpected crime." +190,Z,1969,127,8.2,86,0.08,"30,273",The public murder of a prominent politician and doctor amid a violent demonstration is covered up by military and government officials. A tenacious magistrate is determined not to let them get away with it. +191,Zindagi Na Milegi Dobara,2011,155,8.2,,3.11,"82,331",Three friends decide to turn their fantasy vacation into reality after one of their friends gets engaged. +192,Bacheha-Ye aseman,1997,89,8.2,77,0.93,"77,610","After a boy loses his sister's pair of shoes, he goes on a series of adventures in order to find them. When he can't, he tries a new way to ""win"" a new pair." +193,The Treasure of the Sierra Madre,1948,126,8.2,98,5.01,"1,29,010",Two down-on-their-luck Americans searching for work in 1920s Mexico convince an old prospector to help them mine for gold in the Sierra Madre Mountains. +194,Andhadhun,2018,139,8.2,,1.37,"98,180","A series of mysterious events change the life of a blind pianist, who must now report a crime that he should technically know nothing of." +195,Pather Panchali,1955,125,8.2,,0.54,"35,764","Impoverished priest Harihar Ray, dreaming of a better life for himself and his family, leaves his rural Bengal village in search of work." +196,"Swades: We, the People",2004,210,8.2,,1.22,"94,253",A successful Indian scientist returns to an Indian village to take his nanny to America with him and in the process rediscovers his roots. +197,K.G.F: Chapter 1,2018,156,8.2,,,"92,842","In the 1970s, a gangster named Rocky goes undercover as a slave to assassinate the owner of a notorious gold mine known as the K.G.F." +198,Baahubali 2: The Conclusion,2017,167,8.2,,20.19,"1,08,380","Amarendra Baahubali, the heir apparent to the throne of Mahishmati, finds his life and relationships endangered as his adoptive brother Bhallaladeva conspires to claim the throne." +199,La passion de Jeanne d'Arc,1928,110,8.2,98,0.02,"57,981","In 1431, Jeanne d'Arc is placed on trial on charges of heresy. The ecclesiastical jurists attempt to force Jeanne to recant her claims of holy visions." +200,Dersu Uzala,1975,142,8.2,,,"32,234",The Russian army sends an explorer on an expedition to the snowy Siberian wilderness where he makes friends with a seasoned local hunter. +201,Babam ve Oglum,2005,112,8.2,,233,"89,109",The family of a left-wing journalist is torn apart after the military coup of Turkey in 1980. +202,Sherlock Jr.,1924,45,8.2,,0.98,"53,380","A film projectionist longs to be a detective, and puts his meagre skills to work when he is framed by a rival for stealing his girlfriend's father's pocketwatch." +203,Rangasthalam,2018,170,8.2,,3.51,"26,427",Chitti Babu begins to suspect his elder brother's life is in danger after they team up to lock horns with their village president and overthrow his unlawful 30 year old regime. +204,Uri: The Surgical Strike,2019,138,8.2,,4.19,"68,515","Indian army special forces execute a covert operation, avenging the killing of fellow army men at their base by a terrorist group." +205,Umberto D.,1952,89,8.2,92,0.07,"27,087",An elderly man and his dog struggle to survive on his government pension in Rome. +206,Vikram Vedha,2017,147,8.2,,,"48,347","Vikram, a no-nonsense police officer, accompanied by Simon, his partner, is on the hunt to capture Vedha, a smuggler and a murderer. Vedha tries to change Vikram's life, which leads to a conflict." +207,Bhaag Milkha Bhaag,2013,186,8.2,,1.63,"70,260","The truth behind the ascension of Milkha Singh, who was scarred by of the India-Pakistan partition." +208,Paan Singh Tomar,2012,135,8.2,,0.04,"37,210","The story of Paan Singh Tomar, an Indian athlete and seven-time national steeplechase champion who becomes one of the most feared dacoits in Chambal Valley after his retirement." +209,Guardians of the Galaxy Vol. 3,2023,150,8.1,64,,"2,42,729","Still reeling from the loss of Gamora, Peter Quill rallies his team to defend the universe and one of their own - a mission that could mean the end of the Guardians if not successful." +210,The Exorcist,1973,122,8.1,81,232.91,"4,26,238","When a teenage girl is possessed by a mysterious entity, her mother seeks the help of two priests to save her daughter." +211,Jaws,1975,124,8.1,87,260,"6,33,517","When a killer shark unleashes chaos on a beach community off Cape Cod, it's up to a local sheriff, a marine biologist, and an old seafarer to hunt the beast down." +212,Prisoners,2013,153,8.1,70,61,"7,60,272","When Keller Dover's daughter and her friend go missing, he takes matters into his own hands as the police pursue multiple leads and the pressure mounts." +213,Gone Girl,2014,149,8.1,79,167.77,"10,20,071","With his wife's disappearance having become the focus of an intense media circus, a man sees the spotlight turned on him when it's suspected that he may not be innocent." +214,The Grand Budapest Hotel,2014,99,8.1,88,59.1,"8,50,285","A writer encounters the owner of an aging high-class hotel, who tells him of his early years serving as a lobby boy in the hotel's glorious years under an exceptional concierge." +215,Mad Max: Fury Road,2015,120,8.1,90,154.06,"10,40,507","In a post-apocalyptic wasteland, a woman rebels against a tyrannical ruler in search for her homeland with the aid of a group of female prisoners, a psychotic worshiper and a drifter named Max." +216,Pirates of the Caribbean: The Curse of the Black Pearl,2003,143,8.1,63,305.41,"11,65,615","Blacksmith Will Turner teams up with eccentric pirate ""Captain"" Jack Sparrow to save his love, the governor's daughter, from Jack's former pirate allies, who are now undead." +217,Blade Runner,1982,117,8.1,84,32.87,"7,95,309",A blade runner must pursue and terminate four replicants who stole a ship in space and have returned to Earth to find their creator. +218,Hacksaw Ridge,2016,139,8.1,71,67.21,"5,59,469","World War II American Army Medic Desmond T. Doss, who served during the Battle of Okinawa, refuses to kill people and becomes the first man in American history to receive the Medal of Honor without firing a shot." +219,The Terminator,1984,107,8.1,84,38.4,"8,94,223","A human soldier is sent from 2029 to 1984 to stop an almost indestructible cyborg killing machine, sent from the same year, which has been programmed to execute a young woman whose unborn son is the key to humanity's future salvation." +220,Stand by Me,1986,89,8.1,75,52.29,"4,22,177","After the death of one of his friends, a writer recounts a childhood journey with his friends to find the body of a missing boy." +221,Catch Me If You Can,2002,141,8.1,75,164.62,"10,36,993","Barely 21 yet, Frank is a skilled forger who has passed as a doctor, lawyer and pilot. FBI agent Carl becomes obsessed with tracking down the con man, who only revels in the pursuit." +222,Ford v Ferrari,2019,152,8.1,81,117.62,"4,29,128",American car designer Carroll Shelby and driver Ken Miles battle corporate interference and the laws of physics to build a revolutionary race car for Ford in order to defeat Ferrari at the 24 Hours of Le Mans in 1966. +223,The Big Lebowski,1998,117,8.1,71,17.5,"8,33,531","Jeff ""The Dude"" Lebowski, mistaken for a millionaire of the same name, seeks restitution for his ruined rug and enlists his bowling buddies to help get it." +224,Harry Potter and the Deathly Hallows: Part 2,2011,130,8.1,85,381.01,"9,10,308","Harry, Ron, and Hermione search for Voldemort's remaining Horcruxes in their effort to destroy the Dark Lord as the final battle rages on at Hogwarts." +225,Ah-ga-ssi,2016,145,8.1,85,2.01,"1,60,072","A woman is hired as a handmaiden to a Japanese heiress, but secretly she is involved in a plot to defraud her." +226,Ratatouille,2007,111,8.1,96,206.45,"7,79,088",A rat who can cook makes an unusual alliance with a young kitchen worker at a famous Paris restaurant. +227,La haine,1995,98,8.1,,0.31,"1,84,444",24 hours in the lives of three young men in the French suburbs the day after a violent riot. +228,The Help,2011,146,8.1,62,169.71,"4,76,372","An aspiring author during the civil rights movement of the 1960s decides to write a book detailing the African American maids' point of view on the white families for which they work, and the hardships they go through on a daily basis." +229,Logan,2017,137,8.1,77,226.28,"7,97,391","In a future where mutants are nearly extinct, an elderly and weary Logan leads a quiet life. But when Laura, a mutant child pursued by scientists, comes to him for help, he must get her to safety." +230,"Three Billboards Outside Ebbing, Missouri",2017,115,8.1,88,54.51,"5,31,697",A mother personally challenges the local authorities to solve her daughter's murder when they fail to catch the culprit. +231,Fargo,1996,98,8.1,86,24.61,"6,98,098",Minnesota car salesman Jerry Lundegaard's inept crime falls apart due to his and his henchmen's bungling and the persistent police work of the quite pregnant Marge Gunderson. +232,Inside Out,I 2015,95,8.1,94,356.46,"7,46,886","After young Riley is uprooted from her Midwest life and moved to San Francisco, her emotions - Joy, Fear, Anger, Disgust and Sadness - conflict on how best to navigate a new city, house, and school." +233,Rocky,1976,120,8.1,70,117.24,"6,05,128",A small-time Philadelphia boxer gets a supremely rare chance to fight the world heavyweight champion in a bout in which he strives to go the distance for his self-respect. +234,Dead Poets Society,1989,128,8.1,79,95.86,"5,16,660",Maverick teacher John Keating uses poetry to embolden his boarding school students to new heights of self-expression. +235,The Wizard of Oz,1939,102,8.1,92,2.08,"4,14,083","Young Dorothy Gale and her dog Toto are swept away by a tornado from their Kansas farm to the magical Land of Oz, and embark on a quest with three new friends to see the Wizard, who can return her to her home and fulfill the others' wishes." +236,Salinui chueok,2003,131,8.1,82,0.01,"1,99,792","In a small Korean province in 1986, two detectives struggle with the case of multiple young women being found raped and murdered by an unknown culprit." +237,Into the Wild,2007,148,8.1,73,18.35,"6,39,564","After graduating from Emory University, top student and athlete Christopher McCandless abandons his possessions, gives his entire $24,000 savings account to charity and hitchhikes to Alaska to live in the wilderness. Along the way, Christopher encounters a series of characters that shape his life." +238,Warrior,2011,140,8.1,71,13.66,"4,85,065","The youngest son of an alcoholic former boxer returns home, where he's trained by his father for competition in a mixed martial arts tournament - a path that puts the fighter on a collision course with his estranged, older brother." +239,Trainspotting,1996,93,8.1,83,16.5,"7,05,757","Renton, deeply immersed in the Edinburgh drug scene, tries to clean up and get out, despite the allure of the drugs and influence of friends." +240,Spotlight,I 2015,129,8.1,93,45.06,"4,86,300","The true story of how the Boston Globe uncovered the massive scandal of child molestation and cover-up within the local Catholic Archdiocese, shaking the entire Catholic Church to its core." +241,Platoon,1986,120,8.1,92,138.53,"4,26,296","Chris Taylor, a neophyte recruit in Vietnam, finds himself caught in a battle of wills between two sergeants, one good and the other evil. A shrewd examination of the brutality of war and the duality of man in conflict." +242,"Monsters, Inc.",2001,92,8.1,79,289.92,"9,42,241","In order to power the city, monsters have to scare children so that they scream. However, the children are toxic to the monsters, and after a child gets through, two monsters realize things may not be what they think." +243,Before Sunrise,1995,101,8.1,77,5.54,"3,24,483","A young man and woman meet on a train in Europe, and wind up spending one evening together in Vienna. Unfortunately, both know that this will probably be their only night together." +244,Rush,I 2013,123,8.1,74,26.95,"4,95,815",The merciless 1970s rivalry between Formula One rivals James Hunt and Niki Lauda. +245,"Lock, Stock and Two Smoking Barrels",1998,107,8.1,66,3.9,"5,99,221","Eddy persuades his three pals to pool money for a vital poker game against a powerful local mobster, Hatchet Harry. Eddy loses, after which Harry gives him a week to pay back 500,000 pounds." +246,Room,I 2015,118,8.1,86,14.68,"4,35,564","A little boy is held captive in a room with his mother since his birth, so he has never known the world outside." +247,Portrait de la jeune fille en feu,2019,122,8.1,95,3.76,"1,00,794","On an isolated island in Brittany at the end of the eighteenth century, a female painter is obliged to paint a wedding portrait of a young woman." +248,The Deer Hunter,1978,183,8.1,86,48.98,"3,49,246",An in-depth examination of the ways in which the Vietnam War impacts and disrupts the lives of several friends in a small steel mill town in Pennsylvania. +249,The Sound of Music,1965,172,8.1,63,163.21,"2,47,286",A young novice is sent by her convent in 1930s Austria to become a governess to the seven children of a widowed naval officer. +250,How to Train Your Dragon,2010,98,8.1,75,217.58,"7,69,718","A hapless young Viking who aspires to hunt dragons becomes the unlikely friend of a young dragon himself, and learns there may be more to the creatures than he assumed." +251,12 Years a Slave,2013,134,8.1,96,56.67,"7,21,010","In the antebellum United States, Solomon Northup, a free black man from upstate New York, is abducted and sold into slavery." +252,Barry Lyndon,1975,185,8.1,89,188,"1,75,642",An Irish rogue wins the heart of a rich widow and assumes her dead husband's aristocratic position in 18th-century England. +253,Cool Hand Luke,1967,127,8.1,92,16.22,"1,83,447","A laid-back Southern man is sentenced to two years in a rural prison, but refuses to conform." +254,Raging Bull,1980,129,8.1,90,23.38,"3,66,488","The life of boxer Jake LaMotta, whose violence and temper that led him to the top in the ring destroyed his life outside of it." +255,Million Dollar Baby,2004,132,8.1,86,100.49,"7,04,185","Frankie, an ill-tempered old coach, reluctantly agrees to train aspiring boxer Maggie. Impressed with her determination and talent, he helps her become the best and the two soon form a close bond." +256,"Paris, Texas",1984,145,8.1,81,2.18,"1,11,924","Travis Henderson, an aimless drifter who has been missing for four years, wanders out of the desert and must reconnect with society, himself, his life, and his family." +257,Gran Torino,2008,116,8.1,72,148.1,"7,94,579","After a Hmong teenager tries to steal his prized 1972 Gran Torino, a disgruntled, prejudiced Korean War veteran seeks to redeem both the boy and himself." +258,Fa yeung nin wah,2000,98,8.1,87,2.73,"1,58,620","Two neighbors form a strong bond after both suspect extramarital activities of their spouses. However, they agree to keep their bond platonic so as not to commit similar wrongs." +259,Stalker,1979,162,8.1,85,0.23,"1,39,553",A guide leads two men through an area known as the Zone to find a room that grants wishes. +260,Tonari no Totoro,1988,86,8.1,86,1.11,"3,57,787","When two girls move to the country to be near their ailing mother, they have adventures with the wondrous forest spirits who live nearby." +261,The Red Shoes,1948,135,8.1,,10.9,"37,278",A young ballet dancer is torn between the man she loves and her pursuit to become a prima ballerina. +262,In the Name of the Father,1993,133,8.1,84,25.01,"1,81,367",A man's coerced confession to an I.R.A. bombing he did not commit results in the imprisonment of his father as well. An English lawyer fights to free them. +263,Hachi: A Dog's Tale,2009,93,8.1,,236,"2,98,283",A college professor bonds with an abandoned dog he takes into his home. +264,Network,1976,121,8.1,83,#222,"1,64,777","A television network cynically exploits a deranged former anchor's ravings and revelations about the news media for its own profit, but finds that his message may be difficult to control." +265,Ben-Hur,1959,212,8.1,90,74.7,"2,46,746","After a Jewish prince is betrayed and sent into slavery by a Roman friend in 1st-century Jerusalem, he regains his freedom and comes back for revenge." +266,The Iron Giant,1999,86,8.1,85,23.16,"2,13,283",A young boy befriends a giant robot from outer space that a paranoid government agent wants to destroy. +267,Det sjunde inseglet,1957,96,8.1,88,206,"1,91,678","A knight returning to Sweden after the Crusades seeks answers about life, death, and the existence of God as he plays chess against the Grim Reaper during the Black Plague." +268,Before Sunset,2004,80,8.1,91,5.82,"2,76,929","Nine years after Jesse and Celine first met, they encounter each other again on the French leg of Jesse's book tour." +269,Relatos salvajes,2014,122,8.1,77,3.11,"2,07,584",Six short stories that explore the extremities of human behavior involving people in distress. +270,Koe no katachi,2016,130,8.1,78,,"92,088","A young man is ostracized by his classmates after he bullies a deaf girl to the point where she moves away. Years later, he sets off on a path for redemption." +271,Persona,1966,85,8.1,86,246,"1,25,251",A nurse is put in charge of a mute actress and finds that their personae are melding together. +272,The Third Man,1949,104,8.1,97,0.45,"1,76,630","Pulp novelist Holly Martins travels to shadowy, postwar Vienna, only to find himself investigating the mysterious death of an old friend, Harry Lime." +273,On the Waterfront,1954,108,8.1,91,9.6,"1,59,807","An ex-prize fighter turned New Jersey longshoreman struggles to stand up to his corrupt union bosses, including his older brother, as he starts to connect with the grieving sister of one of the syndicate's victims." +274,The Bridge on the River Kwai,1957,161,8.1,87,44.91,"2,27,269","British POWs are forced to build a railway bridge across the river Kwai for their Japanese captors in occupied Burma, not knowing that the allied forces are planning a daring commando raid through the jungle to destroy it." +275,Amores perros,2000,154,8.1,83,5.38,"2,46,958","A horrific car accident connects three stories, each involving characters dealing with loss, regret, and life's harsh realities, all in the name of love." +276,Rebecca,1940,130,8.1,86,4.36,"1,41,999",A self-conscious woman juggles adjusting to her new role as an aristocrat's wife and avoiding being intimidated by his first wife's spectral presence. +277,"Shin seiki Evangelion Gekijô-ban: Air/Magokoro wo, kimi ni",1997,87,8.1,,,"59,685",Concurrent theatrical ending of the TV series Shinseiki Evangelion (1995). +278,Hotel Rwanda,2004,121,8.1,79,23.53,"3,64,032","Paul Rusesabagina, a hotel manager, houses over a thousand Tutsi refugees during their struggle against the Hutu militia in Rwanda, Africa." +279,The Man Who Shot Liberty Valance,1962,123,8.1,94,,"79,606",A senator returns to a Western town for the funeral of an old friend and tells the story of his origins. +280,Paper Moon,1973,102,8.1,77,30.93,"50,319","During the Great Depression, a con man finds himself saddled with a young girl who may or may not be his daughter, and the two forge an unlikely partnership." +281,Smultronstället,1957,91,8.1,88,193,"1,11,057","After living a life marked by coldness, an aging professor is forced to confront the emptiness of his existence." +282,It Happened One Night,1934,105,8.1,87,4.36,"1,08,280","A renegade reporter trailing a young runaway heiress for a big story joins her on a bus heading from Florida to New York, and they end up stuck with each other when the bus leaves them behind at one of the stops." +283,Mary and Max.,2009,92,8.1,,204,"1,82,146","A tale of friendship between two unlikely pen pals: Mary, a lonely, eight-year-old girl living in the suburbs of Melbourne, and Max, a forty-four-year old, severely obese man living in New York." +284,The Best Years of Our Lives,1946,170,8.1,93,23.65,"67,840","Three World War II veterans, two of them traumatized or disabled, return home to the American midwest to discover that they and their families have been irreparably changed." +285,Festen,1998,105,8.1,82,1.65,"90,734","At Helge's 60th birthday party, some unpleasant family truths are revealed." +286,PK,2014,153,8.1,,10.62,"1,94,559",An alien on Earth loses the only device he can use to communicate with his spaceship. His innocent nature and child-like questions force the country to evaluate the impact of there religious views on there people. +287,All Quiet on the Western Front,1930,152,8.1,91,3.27,"65,838","A German youth eagerly enters World War I, but his enthusiasm wanes as he gets a firsthand view of the horror." +288,Fanny och Alexander,1982,188,8.1,100,4.97,"65,764","Two young Swedish children in the 1900s experience the many comedies and tragedies of their lively and affectionate theatrical family, the Ekdahls." +289,Les quatre cents coups,1959,99,8.1,,244,"1,23,388","A young boy, left without attention, delves into a life of petty crime." +290,The Grapes of Wrath,1940,129,8.1,96,0.06,"96,978","An Oklahoma family, driven off their farm by the poverty and hopelessness of the Dust Bowl, joins the westward migration to California, suffering the misfortunes of the homeless in the Great Depression." +291,Trois couleurs: Rouge,1994,99,8.1,100,4.04,"1,07,010",A model discovers a retired judge is keen on invading people's privacy. +292,A Woman Under the Influence,1974,155,8.1,88,13.34,"27,173","Although wife and mother Mabel is loved by her husband Nick, her mental illness places a strain on the marriage." +293,Yi yi,2000,173,8.1,94,1.14,"26,696",Each member of a middle-class Taipei family seeks to reconcile past and present relationships within their daily lives. +294,Andrei Rublev,1966,205,8.1,,0.1,"55,562","The life, times and afflictions of the fifteenth-century Russian iconographer St. Andrei Rublev." +295,The Message,1976,177,8.1,,,"49,399",This epic historical drama chronicles the life and times of Prophet Muhammad and serves as an introduction to early Islamic history. +296,Underground,1995,170,8.1,79,0.17,"59,881","A group of Serbian socialists prepares for the war in a surreal underground filled by parties, tragedies, love, and hate." +297,Masaan,2015,109,8.1,,,"29,630","Along India's Ganges River, four people face prejudice, a strict moral code and a punishing caste system as they confront personal tragedies." +298,Lagaan: Once Upon a Time in India,2001,224,8.1,84,0.07,"1,17,612",The people of a small village in Victorian India stake their future on a game of cricket against their ruthless British rulers. +299,La battaglia di Algeri,1966,121,8.1,96,0.06,"63,112","In the 1950s, fear and violence escalate as the people of Algiers fight for independence from the French government." +300,Inherit the Wind,1960,128,8.1,75,,"31,726","Based on a real-life case in 1925, two great lawyers argue the case for and against a Tennessee science teacher accused of the crime of teaching evolution." +301,Ôkami kodomo no Ame to Yuki,2012,117,8.1,71,,"47,386","After her werewolf lover unexpectedly dies in an accident while hunting for food for their children, a young woman must find ways to raise the werewolf son and daughter that she had with him while keeping their trait hidden from society." +302,Ba wang bie ji,1993,171,8.1,83,5.22,"31,226",Two boys meet at an opera training school in Peking in 1924. Their resulting friendship will span nearly 70 years and will endure some of the most troublesome times in China's history. +303,Kis Uykusu,2014,196,8.1,88,0.17,"53,610",A hotel owner and landlord in a remote Turkish village deals with conflicts within his family and a tenant behind on his rent. +304,Mr. Smith Goes to Washington,1939,129,8.1,73,9.6,"1,18,422","A naive youth leader is appointed to fill a vacancy in the U.S. Senate. His idealistic plans promptly collide with corruption at home and subterfuge from his hero in Washington, but he tries to forge ahead despite attacks on his character." +305,White Heat,1949,114,8.1,,,"34,583",A psychopathic criminal with a mother complex makes a daring break from prison and leads his old gang in a chemical plant payroll heist. +306,The General,1926,78,8.1,,1.03,"94,849","After being rejected by the Confederate military, not realizing it was due to his crucial civilian role, an engineer must single-handedly recapture his beloved locomotive after it is seized by Union spies and return it through enemy lines." +307,Kakushi-toride no san-akunin,1958,139,8.1,89,,"40,781","Lured by gold, two greedy peasants unknowingly escort a princess and her general across enemy lines." +308,Kumonosu-jô,1957,110,8.1,,,"54,212","A war-hardened general, egged on by his ambitious wife, works to fulfill a prophecy that he would become lord of Spider's Web Castle." +309,Le notti di Cabiria,1957,110,8.1,,0.75,"50,686",A waifish prostitute wanders the streets of Rome looking for true love but finds only heartbreak. +310,Jungfrukällan,1960,89,8.1,,1.53,"30,615","In 14th-century Sweden, an innocent yet pampered teenage girl and her family's pregnant and jealous servant set out from their farm to deliver candles to church, but only one returns from events that transpire in the woods along the way." +311,Dil Chahta Hai,2001,183,8.1,,0.3,"74,200","Three inseparable childhood friends are just out of college. Nothing comes between them - until they each fall in love, and their wildly different approaches to relationships creates tension." +312,Ace in the Hole,1951,111,8.1,72,3.97,"37,836","A frustrated former big-city journalist now stuck working for an Albuquerque newspaper exploits a story about a man trapped in a cave to rekindle his career, but the situation quickly escalates into an out-of-control circus." +313,Talvar,2015,132,8.1,,0.34,"36,804",An experienced investigator confronts several conflicting theories about the perpetrators of a violent double homicide. +314,Du rififi chez les hommes,1955,118,8.1,97,0.06,"35,854","Four men plan a technically perfect crime, but the human element intervenes..." +315,Barfi!,2012,151,8.1,,2.8,"84,517",Three young people learn that love can neither be defined nor contained by society's definition of normal and abnormal. +316,Udaan,2010,134,8.1,,0.01,"46,115","Expelled from his school, a 16-year old boy returns home to his abusive and oppressive father." +317,Les diaboliques,1955,117,8.1,,1.09,"67,729",The wife and mistress of a loathed school principal plan to murder him with what they believe is the perfect alibi. +318,The Gold Rush,1925,95,8.1,,5.45,"1,14,932","A prospector goes to the Klondike during the 1890s gold rush in hopes of making his fortune, and is smitten with a girl he sees in a dance hall." +319,Höstsonaten,1978,99,8.1,,,"36,229","A devoted wife is visited by her mother, a successful concert pianist who had little time for her when she was young." +320,Bajrangi Bhaijaan,2015,163,8.1,,8.18,"92,930",An Indian man with a magnanimous heart takes a young mute Pakistani girl back to her homeland to reunite her with her family. +321,A Wednesday,2008,104,8.1,,,"80,579",A retiring police officer reminisces about the most astounding day of his career. About a case that was never filed but continues to haunt him in his memories - the case of a man and a Wednesday. +322,Rang De Basanti,2006,167,8.1,,2.2,"1,20,874","The story of six young Indians who assist an English woman to film a documentary on the freedom fighters from their past, and the events that lead them to relive the long-forgotten saga of freedom." +323,Kahaani,2012,122,8.1,,1.04,"64,355","A pregnant woman's search for her missing husband takes her from London to Kolkata, but everyone she questions denies having ever met him." +324,Sholay,1975,204,8.1,,,"57,436","After his family is murdered by a notorious and ruthless bandit, a former police officer enlists the services of two outlaws to capture the bandit." +325,Sunrise: A Song of Two Humans,1927,94,8.1,95,0.54,"52,638","A sophisticated city woman seduces a farmer and convinces him to murder his wife and join her in the city, but he ends up rekindling his romance with his wife when he changes his mind at the last moment." +326,Da hong denglong gaogao gua,1991,125,8.1,,2.6,"34,188","A young woman becomes the fourth wife of a wealthy lord, and must learn to live with the strict rules and tensions within the household." +327,Dom za vesanje,1988,142,8.1,,0.28,"31,880","In this luminous tale set in the area around Sarajevo and in Italy, Perhan, an engaging young Romany (gypsy) with telekinetic powers, is seduced by the quick-cash world of petty crime, which threatens to destroy him and those he loves." +328,Queen,2013,146,8.1,,1.43,"67,312",A Delhi girl from a traditional family sets out on a solo honeymoon after her marriage gets cancelled. +329,La Grande Illusion,1937,113,8.1,,0.17,"37,887","During WWI, two French soldiers are captured and imprisoned in a German P.O.W. camp. Several escape attempts follow until they are eventually sent to a seemingly inescapable fortress." +330,Article 15,2019,130,8.1,,,"35,490","In the rural heartlands of India, an upright police officer sets out on a crusade against violent caste-based crimes and discrimination." +331,OMG: Oh My God!,2012,125,8.1,,0.92,"61,292",A shopkeeper takes God to court when his shop is destroyed by an earthquake. +332,Pink,III 2016,136,8.1,,1.24,"46,744","When three young women are implicated in a crime, a retired lawyer steps forward to help them clear their names." +333,Jean de Florette,1986,120,8.1,,4.94,"26,827",A greedy landowner and his backward nephew conspire to block the only water source for an adjoining property in order to bankrupt the owner and force him to sell. +334,Munna Bhai M.B.B.S.,2003,156,8.1,,,"85,192",A gangster sets out to fulfill his father's dream of becoming a doctor. +335,Mandariinid,2013,87,8.1,73,0.14,"47,771","In 1992, war rages in Abkhazia, a breakaway region of Georgia. An Estonian man Ivo has decided to stay behind and harvest his crops of tangerines. In a bloody conflict at his door, a wounded man is left behind, and Ivo takes him in." +336,Sarfarosh,1999,174,8.1,,,"26,353","After his brother is killed and father severely injured by terrorists, a young med student quits his studies to join the Indian Police Service to wipe out the terrorists." +337,The Circus,1928,72,8.1,90,,"35,081",The Tramp finds work and the girl of his dreams at a circus. +338,Hera Pheri,2000,156,8.1,,,"70,007","Three unemployed men look for answers to all their money problems - but when their opportunity arrives, will they know what to do with it?" +339,Eskiya,1996,128,8.1,,,"71,111","Baran the Bandit, released from prison after 35 years, searches for vengeance and his lover." +340,Chak De! India,2007,153,8.1,68,1.11,"82,653","Kabir Khan, the coach of the Indian Women's National Hockey Team, dreams of making his all-girls team emerge victorious against all odds." +341,Black,2005,122,8.1,,0.73,"35,570","The cathartic tale of a young woman who can't see, hear, or speak, and the teacher who brings a ray of light into her dark world." +342,Her Sey Çok Güzel Olacak,1998,107,8.1,,,"26,551","When Altan swipes prescription drugs from his brother Nuri's pharmacy, they soon find themselves on a dangerous but funny road trip to get rid of the stuff and escape the mafiosi Altan ... See full summary »" +343,Anand,1971,122,8.1,,,"34,607","The story of a terminally ill man who wishes to live life to the fullest before the inevitable occurs, as told by his best friend." +344,Mission: Impossible - Dead Reckoning Part One,2023,163,8,81,,"1,10,366",Ethan Hunt and his IMF team must track down a dangerous weapon before it falls into the wrong hands. +345,Dune: Part One,2021,155,8,74,108.33,"7,00,476",A noble family becomes embroiled in a war for control over the galaxy's most valuable asset while its heir becomes troubled by visions of a dark future. +346,Blade Runner 2049,2017,164,8,81,92.05,"6,15,385","Young Blade Runner K's discovery of a long-buried secret leads him to track down former Blade Runner Rick Deckard, who's been missing for thirty years." +347,La La Land,2016,128,8,94,151.1,"6,25,365","While navigating their careers in Los Angeles, a pianist and an actress fall in love while attempting to reconcile their aspirations for the future." +348,Guardians of the Galaxy,2014,121,8,76,333.18,"12,33,511",A group of intergalactic criminals must pull together to stop a fanatical warrior with plans to purge the universe. +349,The Imitation Game,2014,114,8,71,91.13,"7,96,469","During World War II, the English mathematical genius Alan Turing tries to crack the German Enigma code with help from fellow mathematicians while attempting to come to terms with his troubled private life." +350,The Revenant,I 2015,156,8,76,183.64,"8,36,807",A frontiersman on a fur trading expedition in the 1820s fights for survival after being mauled by a bear and left for dead by members of his own hunting team. +351,The Martian,2015,144,8,80,228.43,"8,88,025","An astronaut becomes stranded on Mars after his team assume him dead, and must rely on his ingenuity to find a way to signal to Earth that he is alive and can survive until a potential rescue." +352,Deadpool,2016,108,8,65,363.07,"10,79,838","A wisecracking mercenary gets experimented on and becomes immortal yet hideously scarred, and sets out to track down the man who ruined his looks." +353,The Princess Bride,1987,98,8,77,30.86,"4,38,629","A bedridden boy's grandfather reads him the story of a farmboy-turned-pirate who encounters numerous obstacles, enemies and allies in his quest to be reunited with his true love." +354,CODA,2021,111,8,72,,"1,49,834","As a CODA (Child of Deaf Adults) Ruby is the only hearing person in her deaf family. When the family's fishing business is threatened, Ruby finds herself torn between pursuing her passion at Berklee College of Music and her fear of abandoning her parents." +355,Black Swan,2010,108,8,79,106.95,"7,95,010","Nina is a talented but unstable ballerina on the verge of stardom. Pushed to the breaking point by her artistic director and a seductive rival, Nina's grip on reality slips, plunging her into a waking nightmare." +356,Donnie Darko,2001,113,8,88,1.48,"8,25,457","After narrowly escaping a bizarre accident, a troubled teenager is plagued by visions of a man in a large rabbit suit who manipulates him to commit a series of crimes." +357,Rosemary's Baby,1968,137,8,96,,"2,25,405","A young couple trying for a baby moves into an aging, ornate apartment building on Central Park West, where they find themselves surrounded by peculiar neighbors." +358,The Avengers,2012,143,8,69,623.28,"14,26,050",Earth's mightiest heroes must come together and learn to fight as a team if they are going to stop the mischievous Loki and his alien army from enslaving humanity. +359,Her,2013,126,8,91,25.57,"6,42,655","In a near future, a lonely writer develops an unlikely relationship with an operating system designed to meet his every need." +360,JFK,1991,189,8,72,70.41,"1,63,659",New Orleans District Attorney Jim Garrison discovers there's more to the Kennedy assassination than the official story. +361,Zootopia,2016,108,8,78,341.27,"5,22,567","In a city of anthropomorphic animals, a rookie bunny cop and a cynical con artist fox must work together to uncover a conspiracy." +362,The Incredibles,2004,115,8,90,261.44,"7,72,122","While trying to lead a quiet suburban life, a family of undercover superheroes are forced into action to save the world." +363,Sin City,2005,124,8,74,74.1,"7,81,917","An exploration of the dark and miserable Basin City and three of its residents, all of whom are caught up in violent corruption." +364,Casino Royale,2006,144,8,80,167.45,"6,75,695","After earning 00 status and a licence to kill, secret agent James Bond sets out on his first mission as 007. Bond must defeat a private banker funding terrorists in a high-stakes game of poker at Casino Royale, Montenegro." +365,Rain Man,1988,133,8,65,178.8,"5,30,976","After a selfish L.A. yuppie learns his estranged father left a fortune to an autistic-savant brother in Ohio that he didn't know existed, he absconds with his brother and sets out across the country, hoping to gain a larger inheritance." +366,Scent of a Woman,1992,156,8,59,63.9,"3,14,155","A prep school student needing money agrees to ""babysit"" a blind man, but the job is not at all what he anticipated." +367,Magnolia,1999,188,8,78,22.46,"3,21,223","An epic mosaic of interrelated characters in search of love, forgiveness and meaning in the San Fernando Valley." +368,Dances with Wolves,1990,181,8,72,184.21,"2,79,380","Lieutenant John Dunbar, assigned to a remote western Civil War outpost, finds himself engaging with a neighbouring Sioux settlement, causing him to question his own purpose." +369,Soul,2020,100,8,83,,"3,52,258","After landing the gig of a lifetime, a New York jazz pianist suddenly finds himself trapped in a strange land between Earth and the afterlife." +370,The Pursuit of Happyness,2006,117,8,64,163.57,"5,37,225",A struggling salesman takes custody of his son as he's poised to begin a life-changing professional career. +371,Big Fish,2003,125,8,58,66.26,"4,50,162",A frustrated son tries to determine the fact from fiction in his dying father's life. +372,Aladdin,1992,90,8,86,217.35,"4,43,751",A kind-hearted street urchin and a power-hungry Grand Vizier vie for a magic lamp that has the power to make their deepest wishes come true. +373,Groundhog Day,1993,101,8,72,70.91,"6,57,183","A narcissistic, self-centered weatherman finds himself in a time loop on Groundhog Day, and the day keeps repeating until he gets it right." +374,Twelve Monkeys,1995,129,8,74,57.14,"6,32,578","In a future world devastated by disease, a convict is sent back in time to gather information about the man-made virus that wiped out most of the human population on the planet." +375,Planet of the Apes,1968,112,8,79,33.4,"1,86,673",An astronaut crew crash-lands on a planet where highly intelligent non-human ape species are dominant and humans are enslaved. +376,Young Frankenstein,1974,106,8,83,86.3,"1,63,912","An American grandson of the infamous scientist, struggling to prove that his grandfather was not as insane as people believe, is invited to Transylvania, where he discovers the process that reanimates a dead body." +377,Kill Bill: Vol. 2,2004,137,8,83,66.21,"7,79,726","The Bride continues her quest of vengeance against her former boss and lover Bill, the reclusive bouncer Budd, and the treacherous, one-eyed Elle." +378,Pâfekuto burû,1997,81,8,67,0.78,"86,050","A pop singer gives up her career to become an actress, but she slowly goes insane when she starts being stalked by an obsessed fan and what seems to be a ghost of her past." +379,The Graduate,1967,106,8,83,104.95,"2,82,028",A disillusioned college graduate finds himself torn between his older lover and her daughter. +380,The King's Speech,2010,118,8,88,138.8,"6,94,785","The story of King George VI, his unexpected ascension to the throne of the British Empire in 1936, and the speech therapist who helped the unsure monarch overcome his stammer." +381,Blood Diamond,2006,143,8,64,57.37,"5,68,384","A fisherman, a smuggler, and a syndicate of businessmen match wits over the possession of a priceless diamond." +382,Slumdog Millionaire,2008,120,8,84,141.32,"8,62,246","A Mumbai teenager reflects on his life after being accused of cheating on the Indian version of ""Who Wants to be a Millionaire?""." +383,Contratiempo,2016,106,8,,,"1,84,819",A successful entrepreneur accused of murder and a witness preparation expert have less than three hours to come up with an impregnable defense. +384,Dog Day Afternoon,1975,125,8,86,50,"2,66,106","Three amateur bank robbers plan to hold up a bank. A nice simple robbery: Walk in, take the money, and run. Unfortunately, the supposedly uncomplicated heist suddenly becomes a bizarre nightmare as everything that could go wrong does." +385,Beauty and the Beast,1991,84,8,95,218.97,"4,67,789",A prince cursed to spend his days as a hideous monster sets out to regain his humanity by earning a young woman's love. +386,Akira,1988,124,8,67,0.55,"1,95,907","A secret military project endangers Neo-Tokyo when it turns a biker gang member into a rampaging psychic psychopath who can only be stopped by a teenager, his gang of biker friends and a group of psychics." +387,Do the Right Thing,1989,120,8,93,27.55,"1,07,768","On the hottest day of the year on a street in the Bedford-Stuyvesant section of Brooklyn, everyone's hate and bigotry smolders and builds until it explodes into violence." +388,The Bourne Ultimatum,2007,115,8,85,227.47,"6,47,042",Jason Bourne dodges a ruthless C.I.A. official and his Agents from a new assassination program while searching for the origins of his life as a trained killer. +389,Dogville,2003,178,8,61,1.53,"1,54,110","A woman on the run from the mob is reluctantly accepted in a small Colorado community in exchange for labor, but when a search visits the town she finds out that their support has a price." +390,Life of Brian,1979,94,8,77,20.05,"4,12,019","Born on the original Christmas in the stable next door to Jesus Christ, Brian of Nazareth spends his life being mistaken for a messiah." +391,Papillon,1973,151,8,58,53.27,"1,34,978","A French convict in the 1930s befriends a fellow criminal as the two of them begin serving their sentence in the South American penal colony on Devil's Island, which inspires the man to plot his escape." +392,Lion,2016,118,8,69,51.74,"2,44,006","A five-year-old Indian boy is adopted by an Australian couple after getting lost hundreds of kilometers from home. 25 years later, he sets out to find his lost family." +393,Chung Hing sam lam,1994,102,8,78,0.6,"89,420","Two melancholy Hong Kong policemen fall in love: one with a mysterious female underworld figure, the other with a beautiful and ethereal waitress at a late-night restaurant he frequents." +394,Butch Cassidy and the Sundance Kid,1969,110,8,66,102.31,"2,21,995","Wyoming, early 1900s. Butch Cassidy and The Sundance Kid are the leaders of a band of outlaws. After a train robbery goes wrong they find themselves on the run with a posse hard on their heels. Their solution - escape to Bolivia." +395,Mommy,I 2014,139,8,74,3.49,"59,678","A widowed single mother, raising her violent son alone, finds new hope when a mysterious neighbor inserts herself into their household." +396,Annie Hall,1977,93,8,92,39.2,"2,71,517","Alvy Singer, a divorced Jewish comedian, reflects on his relationship with ex-lover Annie Hall, an aspiring nightclub singer, which ended abruptly just like his previous marriages." +397,Ip Man,2008,106,8,59,,"2,29,635","During the Japanese invasion of China, a wealthy martial artist is forced to leave his home when his city is occupied. With little means of providing for themselves, Ip Man and the remaining members of the city must find a way to survive." +398,Sling Blade,1996,135,8,84,24.48,"96,594","Karl Childers, a simple man hospitalized since his childhood murder of his mother and her lover, is released to start a new life in a small town." +399,The Last Picture Show,1971,118,8,93,29.13,"50,454","In 1951, a group of high schoolers come of age in a bleak, isolated, atrophied North Texas town that is slowly dying, both culturally and economically." +400,The Hustler,1961,134,8,90,8.28,"84,722",An up-and-coming pool player plays a long-time champion in a single high-stakes match. +401,Rio Bravo,1959,141,8,93,12.54,"65,766","A small-town sheriff in the American West enlists the help of a disabled man, a drunk, and a young gunfighter in his efforts to hold in jail the brother of the local bad guy." +402,Kaze no tani no Naushika,1984,117,8,86,0.5,"1,75,613",Warrior and pacifist Princess Nausicaä desperately struggles to prevent two warring nations from destroying themselves and their dying planet. +403,Roman Holiday,1953,118,8,78,,"1,43,403",A bored and sheltered princess escapes her guardians and falls in love with an American newsman in Rome. +404,Solaris,1972,167,8,93,,"95,182",A psychologist is sent to a station orbiting a distant planet in order to discover what has caused the crew to go insane. +405,Tenkû no shiro Rapyuta,1986,125,8,78,,"1,73,834",A young boy and a girl with a magic crystal must race against pirates and foreign agents in a search for a legendary floating castle. +406,Cinderella Man,2005,144,8,69,61.65,"1,93,322","The story of James J. Braddock, a supposedly washed-up boxer who came back to challenge for the heavyweight championship of the world." +407,Gandhi,1982,191,8,79,52.77,"2,36,927",The life of the lawyer who became the famed leader of the Indian revolts against the British rule through his philosophy of nonviolent protest. +408,The Night of the Hunter,1955,92,8,97,0.65,"93,289","A religious fanatic marries a gullible widow whose young children are reluctant to tell him where their real daddy hid the $10,000 he'd stolen in a robbery." +409,Fiddler on the Roof,1971,181,8,67,80.5,"45,855","In pre-revolutionary Russia, a Jewish peasant with traditional values contends with marrying off three of his daughters with modern romantic ideals while growing anti-Semitic sentiment threatens his village." +410,Pink Floyd: The Wall,1982,95,8,47,22.24,"83,482",A confined but troubled rock star descends into madness in the midst of his physical and social isolation from everyone. +411,Zerkalo,1975,107,8,82,0.18,"49,732","A dying man in his forties remembers his past. His childhood, his mother, the war, personal moments and things that tell of the recent history of all the Russian nation." +412,High Noon,1952,85,8,89,9.45,"1,07,588","A town Marshal, despite the disagreements of his newlywed bride and the townspeople around him, must face a gang of deadly killers alone at ""high noon"" when the gang leader, an outlaw he ""sent up"" years ago, arrives on the noon train." +413,The Maltese Falcon,1941,100,8,97,2.11,"1,63,160","San Francisco private detective Sam Spade takes on a case that involves him with three eccentric criminals, a gorgeous liar and their quest for a priceless statuette, with the stakes rising after his partner is murdered." +414,La dolce vita,1960,174,8,95,19.52,"76,010",A series of stories following a week in the life of a philandering tabloid journalist living in Rome. +415,What Ever Happened to Baby Jane?,1962,134,8,75,4.05,"59,319",A former child star torments her paraplegic sister in their decaying Hollywood mansion. +416,8½,1963,138,8,93,0.05,"1,21,910",A harried movie director retreats into his memories and fantasies. +417,Der Himmel über Berlin,1987,128,8,79,3.33,"73,924","An angel tires of his purely ethereal life of merely overseeing the human activity of Berlin's residents, and longs for the tangible joys of physical existence when he falls in love with a mortal." +418,The Straight Story,1999,112,8,86,6.2,"93,802",An old man makes a long journey by lawnmower to mend his relationship with an ill brother. +419,Fitzcarraldo,1982,158,8,,,"36,896","The story of Brian Sweeney Fitzgerald, an extremely determined man who intends to build an opera house in the middle of a jungle." +420,Anatomy of a Murder,1959,161,8,95,11.9,"68,884","An upstate Michigan lawyer defends a soldier who claims he killed an innkeeper due to temporary insanity after the victim raped his wife. What is the truth, and will he win his case?" +421,WolfWalkers,2020,103,8,87,,"35,961",A young apprentice hunter and her father journey to Ireland to help wipe out the last wolf pack. But everything changes when she befriends a free-spirited girl from a mysterious tribe rumored to transform into wolves by night. +422,Mou gaan dou,2002,101,8,75,0.17,"1,28,324","A story between a mole in the police department and an undercover cop. Their objectives are the same: to find out who is the mole, and who is the cop." +423,Kaguya-hime no monogatari,2013,137,8,89,1.51,"50,445","Found inside a shining stalk of bamboo by an old bamboo cutter and his wife, a tiny girl grows rapidly into an exquisite young lady. The mysterious young princess enthralls all who encounter her, but ultimately she must confront her fate, the punishment for her crime." +424,"Quo vadis, Aida?",2020,101,8,97,,"36,359","Aida is a translator for the UN in the small town of Srebrenica. When the Serbian army takes over the town, her family is among the thousands of citizens looking for shelter in the UN camp." +425,Who's Afraid of Virginia Woolf?,1966,131,8,75,,"77,992","A bitter, aging couple, with the help of alcohol, use their young houseguests to fuel anguish and emotional pain towards each other over the course of a distressing night." +426,Secrets & Lies,1996,136,8,91,13.42,"45,460","Following the death of her adoptive parents, a successful young black optometrist establishes contact with her biological mother -- a lonely white factory worker living in poverty in East London." +427,Bãhubali: The Beginning,2015,159,8,,6.74,"1,31,308","A child from the Mahishmati kingdom is raised by tribal people and one day learns about his royal heritage, his father's bravery in battle and a mission to overthrow the incumbent ruler." +428,Il gattopardo,1963,186,8,100,,"27,615","The Prince of Salina, a noble aristocrat of impeccable integrity, tries to preserve his family and class amid the tumultuous social upheavals of 1860s Sicily." +429,Das Cabinet des Dr. Caligari,1920,76,8,,,"67,190","Hypnotist Dr. Caligari uses a somnambulist, Cesare, to commit murders." +430,Touch of Evil,1958,95,8,99,2.24,"1,07,423","A stark, perverse story of murder, kidnapping and police corruption in a Mexican border town." +431,La leggenda del pianista sull'oceano,1998,169,8,58,0.26,"67,190","A baby boy discovered on an ocean liner in 1900 grows into a musical prodigy, never setting foot on land." +432,Nostalgia,1983,125,8,,0.01,"28,799","A Russian poet and his interpreter travel to Italy researching the life of an 18th-century composer, and instead meet a ruminative madman who tells the poet how the world may be saved." +433,Tropa de Elite,2007,115,8,33,0.01,"1,07,348","In 1997 Rio de Janeiro, Captain Nascimento has to find a substitute for his position while trying to take down drug dealers and criminals before the Pope visits." +434,Le Samouraï,1967,105,8,,0.04,"54,192",After professional hitman Jef Costello is seen by witnesses his efforts to provide himself an alibi drive him further into a corner. +435,Dare mo shiranai,2004,141,8,88,0.68,"29,750","In a small Tokyo apartment, twelve-year-old Akira must care for his younger siblings after their mother leaves them and shows no sign of returning." +436,Song of the Sea,2014,93,8,85,0.86,"61,161","Ben, a young Irish boy, and his little sister Saoirse, a girl who can turn into a seal, go on an adventure to free the fairies and save the spirit world." +437,Out of the Past,1947,97,8,85,,"39,347","A private eye escapes his past to run a gas station in a small town, but his past catches up with him. Now he must return to the big city world of danger, corruption, double crosses, and duplicitous dames." +438,Le scaphandre et le papillon,2007,112,8,92,5.99,"1,09,134",The true story of Elle editor Jean-Dominique Bauby who suffers a stroke and has to live with an almost totally paralyzed body; only his left eye isn't paralyzed. +439,Brief Encounter,1945,86,8,92,,"42,316","Meeting a stranger in a railway station, a woman is tempted to cheat on her husband." +440,Au revoir les enfants,1987,104,8,88,4.54,"35,164","A French boarding school run by priests seems to be a haven from World War II until a new student arrives. Occupying the next bed in the dormitory to the top student in his class, the two young boys begin to form a bond." +441,Yeopgijeogin geunyeo,2001,137,8,,,"49,515","A young man sees a drunk, cute woman standing too close to the tracks at a metro station in Seoul and pulls her back. She ends up getting him into trouble repeatedly after that, starting on the train." +442,Sleuth,1972,138,8,,4.08,"49,065","A man who loves games and theater invites his wife's lover to meet him, setting up a battle of wits with potentially deadly results." +443,Sweet Smell of Success,1957,96,8,100,,"34,241",Powerful but unethical Broadway columnist J.J. Hunsecker coerces unscrupulous press agent Sidney Falco into breaking up his sister's romance with a jazz musician. +444,Mar adentro,I 2004,126,8,74,2.09,"83,743","The factual story of Spaniard Ramon Sampedro, who fought a 28-year campaign in favor of euthanasia and his own right to die." +445,Viskningar och rop,1972,91,8,,1.74,"35,743","When a woman dying of cancer in early twentieth-century Sweden is visited by her two sisters, long-repressed feelings between the siblings rise to the surface." +446,Persepolis,2007,96,8,90,4.45,"97,675",A precocious and outspoken Iranian girl grows up during the Islamic Revolution. +447,Stalag 17,1953,120,8,84,,"57,352","After two Americans are killed while escaping from a German P.O.W. camp in World War II, the barracks black marketeer, J.J. Sefton, is suspected of being an informer." +448,Ivanovo detstvo,1962,95,8,,,"38,428","During WWII, Soviet orphan Ivan Bondarev strikes up a friendship with three sympathetic Soviet officers while working as a scout behind the German lines." +449,Sanjuro,1962,96,8,,,"39,803","A crafty samurai helps a young man and his fellow clansmen trying to save his uncle, who has been framed and imprisoned by a corrupt superintendent." +450,Okuribito,2008,130,8,68,1.5,"53,737",A newly unemployed cellist takes a job preparing the dead for funerals. +451,"Crna macka, beli macor",1998,127,8,73,0.35,"55,338",Matko and his son Zare live on the banks of the Danube river and get by through hustling and basically doing anything to make a living. In order to pay off a business debt Matko agrees to marry off Zare to the sister of a local gangster. +452,Dilwale Dulhania Le Jayenge,1995,189,8,,,"73,603","When Raj meets Simran in Europe, it isn't love at first sight but when Simran moves to India for an arranged marriage, love makes its presence felt." +453,Kind Hearts and Coronets,1949,106,8,,,"38,738",A distant poor relative of the Duke D'Ascoyne plots to inherit the title by murdering the eight other heirs who stand ahead of him in the line of succession. +454,Bom yeoreum gaeul gyeoul geurigo bom,2003,103,8,85,2.38,"85,120",A boy is raised by a Buddhist monk in an isolated floating temple where the years pass like the seasons. +455,La strada,1954,108,8,,,"65,154","A care-free girl is sold to a traveling entertainer, consequently enduring physical and emotional pain along the way." +456,M.S. Dhoni: The Untold Story,2016,184,8,,1.78,"64,196",The untold story of Mahendra Singh Dhoni's journey from ticket collector to trophy collector - the world-cup-winning captain of the Indian Cricket Team. +457,Haider,2014,160,8,,0.9,"56,336","A young man returns to Kashmir after his father's disappearance to confront his uncle, whom he suspects of playing a role in his father's fate." +458,The Shop Around the Corner,1940,99,8,96,0.2,"35,776","Two employees at a gift shop can barely stand each other, without realizing that they are falling in love through the post as each other's anonymous pen pal." +459,Ahlat Agaci,2018,188,8,86,0.03,"25,989","An unpublished writer returns to his hometown after graduating, where he seeks sponsors to publish his book while dealing with his father's deteriorating indulgence into gambling." +460,El ángel exterminador,1962,95,8,,,"34,423",The guests at an upper-class dinner party find themselves unable to leave. +461,Central do Brasil,1998,110,8,80,5.6,"41,117","The emotive journey of a former schoolteacher who writes letters for illiterate people, and a young boy whose mother has just died, as they search for the father he never knew." +462,Taegukgi hwinalrimyeo,2004,140,8,64,1.11,"40,502","When two brothers are forced to fight in the Korean War, the elder decides to take the riskiest missions if it will help shield the younger from battle." +463,Tropa de Elite 2: O Inimigo Agora é Outro,2010,115,8,71,0.1,"85,101","After a prison riot, former-Captain Nascimento, now a high ranking security officer in Rio de Janeiro, is swept into a bloody political dispute that involves government officials and paramilitary groups." +464,Roma città aperta,1945,103,8,,,"27,934","During the Nazi occupation of Rome in 1944, the Resistance leader, Giorgio Manfredi, is chased by the Nazis as he seeks refuge and a way to escape." +465,G.O.R.A.,2004,127,8,,,"64,984",A slick young Turk kidnapped by extraterrestrials shows his great « humanitarian spirit » by outwitting the evil commander-in-chief of the planet of G.O.R.A. +466,Nattvardsgästerna,1963,81,8,,,"26,171",A small-town priest struggles with his faith. +467,Vizontele,2001,110,8,,,"37,844","Lives of residents in a small, Anatolian village change when television is introduced to them." +468,Lage Raho Munna Bhai,2006,144,8,,2.22,"48,408",Munna Bhai embarks on a journey with Mahatma Gandhi in order to fight against a corrupt property dealer. +469,Special Chabbis,2013,144,8,,1.08,"57,762",A gang of con-men rob prominent rich businessmen and politicians by posing as C.B.I and income tax officers. +470,Nefes: Vatan Sagolsun,2009,128,8,,,"34,552",Story of 40-man Turkish task force who must defend a relay station. +471,Andaz Apna Apna,1994,160,8,,,"54,402",Two slackers competing for the affections of an heiress inadvertently become her protectors from an evil criminal. +472,Puss in Boots: The Last Wish,2022,102,7.9,73,168.46,"1,46,066","When Puss in Boots discovers that his passion for adventure has taken its toll and he has burned through eight of his nine lives, he launches an epic journey to restore them by finding the mythical Last Wish." +473,Titanic,1997,194,7.9,75,659.33,"12,38,632","A seventeen-year-old aristocrat falls in love with a kind but poor artist aboard the luxurious, ill-fated R.M.S. Titanic." +474,Zack Snyder's Justice League,2021,242,7.9,54,,"4,20,074","Determined to ensure that Superman's ultimate sacrifice wasn't in vain, Bruce Wayne recruits a team of metahumans to protect the world from an approaching threat of catastrophic proportions." +475,Avatar,2009,162,7.9,83,760.51,"13,55,689",A paraplegic Marine dispatched to the moon Pandora on a unique mission becomes torn between following his orders and protecting the world he feels is his home. +476,Arrival,II 2016,116,7.9,81,100.55,"7,28,588",A linguist works with the military to communicate with alien lifeforms after twelve mysterious spacecraft appear around the world. +477,Knives Out,2019,130,7.9,82,165.36,"7,35,106","A detective investigates the death of the patriarch of an eccentric, combative family." +478,Edge of Tomorrow,2014,113,7.9,71,100.21,"7,07,149","A soldier fighting aliens gets to relive the same day over and over again, the day restarting every time he dies." +479,Bohemian Rhapsody,2018,134,7.9,49,216.43,"5,62,655","The story of the legendary British rock band Queen and lead singer Freddie Mercury, leading up to their famous performance at Live Aid (1985)." +480,Iron Man,2008,126,7.9,79,318.41,"10,91,753","After being held captive in an Afghan cave, billionaire engineer Tony Stark creates a unique weaponized suit of armor to fight evil." +481,Star Trek,2009,127,7.9,82,257.73,"6,12,641",The brash James T. Kirk tries to live up to his father's legacy with Mr. Spock keeping him in check as a vengeful Romulan from the future creates black holes to destroy the Federation one planet at a time. +482,Harry Potter and the Prisoner of Azkaban,2004,142,7.9,82,249.36,"6,62,076","Harry Potter, Ron and Hermione return to Hogwarts School of Witchcraft and Wizardry for their third year of study, where they delve into the mystery surrounding an escaped prisoner who poses a dangerous threat to the young wizard." +483,Fantastic Mr. Fox,2009,87,7.9,83,21,"2,52,714",An urbane fox cannot resist returning to his farm raiding ways and then must help his community survive the farmers' retaliation. +484,Thor: Ragnarok,2017,130,7.9,74,315.06,"7,85,526","Imprisoned on the planet Sakaar, Thor must race against time to return to Asgard and stop Ragnarök, the destruction of his world, at the hands of the powerful and ruthless villain Hela." +485,Mulholland Dr.,2001,147,7.9,86,7.22,"3,70,038","After a car wreck on the winding Mulholland Drive renders a woman amnesiac, she and a perky Hollywood-hopeful search for clues and answers across Los Angeles in a twisting venture beyond dreams and reality." +486,True Romance,1993,119,7.9,59,12.28,"2,35,012","In Detroit, a pop culture nerd steals cocaine from his new wife's pimp and tries to sell it in Hollywood, prompting the mobsters who own the drugs to pursue the couple." +487,Boogie Nights,1997,155,7.9,85,26.4,"2,74,418","Back when sex was safe, pleasure was a business and business was booming, an idealistic porn producer aspires to elevate his craft to an art when he discovers a hot young talent." +488,Jojo Rabbit,2019,108,7.9,58,33.37,"4,16,416",A young German boy in the Hitler Youth whose hero and imaginary friend is the country's dictator is shocked to discover that his mother is hiding a Jewish girl in their home. +489,The Perks of Being a Wallflower,2012,103,7.9,67,17.74,"5,31,126","Charlie, a 15-year-old introvert, enters high school and is nervous about his new life. When he befriends his seniors, he learns to cope with his friend's suicide and his tumultuous past." +490,The Blues Brothers,1980,133,7.9,60,57.23,"2,07,336","Jake Blues rejoins with his brother Elwood after being released from prison, but the duo has just days to reunite their old R&B band and save the Catholic home where the two were raised, outrunning the police as they tear through Chicago." +491,Almost Famous,2000,122,7.9,90,32.53,"2,85,714",A high-school boy in the early 1970s is given the chance to write a story for Rolling Stone magazine about an up-and-coming rock band as he accompanies them on their concert tour. +492,Marriage Story,2019,137,7.9,94,2,"3,28,893",Noah Baumbach's incisive and compassionate look at a marriage breaking up and a family staying together. +493,The Bourne Identity,2002,119,7.9,68,121.66,"5,62,285","A man is picked up by a fishing boat, bullet-riddled and suffering from amnesia, before racing to elude assassins and attempting to regain his memory." +494,E.T. the Extra-Terrestrial,1982,115,7.9,92,435.11,"4,24,017",A troubled child summons the courage to help a friendly alien escape from Earth and return to his home planet. +495,Shrek,2001,90,7.9,84,267.67,"7,06,312","A mean lord exiles fairytale creatures to the swamp of a grumpy ogre, who must go on a quest and rescue a princess for the lord in order to get his land back." +496,In Bruges,2008,107,7.9,67,7.76,"4,48,373","Guilt-stricken after a job gone wrong, hitman Ray and his partner await orders from their ruthless boss in Bruges, Belgium, the last place in the world Ray wants to be." +497,In the Heat of the Night,1967,110,7.9,76,24.38,"80,282","A black Philadelphia police detective is mistakenly suspected of a local murder while passing through a racially hostile Mississippi town, and after being cleared is reluctantly asked by the police chief to investigate the case." +498,Shaun of the Dead,2004,99,7.9,76,13.54,"5,75,899","The uneventful, aimless lives of a London electronics salesman and his layabout roommate are disrupted by the zombie apocalypse." +499,X-Men: Days of Future Past,2014,132,7.9,75,233.92,"7,29,460",The X-Men send Wolverine to the past in a desperate effort to change history and prevent an event that results in doom for both humans and mutants. +500,Children of Men,2006,109,7.9,84,35.55,"5,14,552","In 2027, in a chaotic world in which women have somehow become infertile, a former activist agrees to help transport a miraculously pregnant woman to a sanctuary at sea." +501,Wonder,I 2017,113,7.9,66,132.42,"1,72,027","Based on the New York Times bestseller, this movie tells the incredibly inspiring and heartwarming story of August Pullman, a boy with facial differences who enters the fifth grade, attending a mainstream elementary school for the first time." +502,District 9,2009,112,7.9,81,115.65,"6,99,157",Violence ensues after an extraterrestrial race forced to live in slum-like conditions on Earth finds a kindred spirit in a government agent exposed to their biotechnology. +503,Mystic River,2003,138,7.9,84,90.14,"4,70,600",The lives of three men who were childhood friends are shattered when one of them has a family tragedy. +504,The Philadelphia Story,1940,112,7.9,96,,"71,905","When a rich woman's ex-husband and a tabloid-type reporter turn up just before her planned remarriage, she begins to learn the truth about herself." +505,Dallas Buyers Club,2013,117,7.9,77,27.3,"5,04,335","In 1985 Dallas, electrician and hustler Ron Woodroof works around the system to help AIDS patients get the medication they need after he is diagnosed with the disease." +506,Life of Pi,2012,127,7.9,79,124.99,"6,49,664","A young man who survives a disaster at sea is hurtled into an epic journey of adventure and discovery. While cast away, he forms an unexpected connection with another survivor: a fearsome Bengal tiger." +507,Edward Scissorhands,1990,105,7.9,74,56.36,"5,06,750",The solitary life of an artificial man - who was incompletely constructed and has scissors for hands - is upended when he is taken in by a suburban family. +508,This Is Spinal Tap,1984,82,7.9,92,4.74,"1,43,552","Spinal Tap, one of England's loudest bands, is chronicled by film director Marty DiBergi on what proves to be a fateful tour." +509,Boyhood,I 2014,165,7.9,100,25.38,"3,61,538","The life of Mason, from early childhood to his arrival at college." +510,Toy Story 2,1999,92,7.9,88,245.85,"6,00,951","When Woody is stolen by a toy collector, Buzz and his friends set out on a rescue mission to save Woody before he becomes a museum toy property with his roundup gang Jessie, Prospector, and Bullseye." +511,Carlito's Way,1993,144,7.9,66,36.95,"2,25,948","A Puerto Rican former convict, just released from prison, pledges to stay away from drugs and violence despite the pressure around him and lead on to a better life outside of N.Y.C." +512,Brazil,1985,132,7.9,84,9.93,"2,06,294",A bureaucrat in a dystopic society becomes an enemy of the state as he pursues the woman of his dreams. +513,Spartacus,1960,197,7.9,87,30,"1,39,331","The slave Spartacus survives brutal training as a gladiator and leads a violent revolt against the decadent Roman Republic, as the ambitious Crassus seeks to gain power by crushing the uprising." +514,The Nightmare Before Christmas,1993,76,7.9,82,75.08,"3,54,263","Jack Skellington, king of Halloween Town, discovers Christmas Town, but his attempts to bring Christmas to his home causes confusion." +515,Per un pugno di dollari,1964,99,7.9,65,14.5,"2,25,217","A wandering gunfighter plays two rival families against each other in a town torn apart by greed, pride, and revenge." +516,The Ten Commandments,1956,220,7.9,,93.74,"74,769","Moses, raised as a prince of Egypt in the Pharaoh's household, learns of his true heritage as a Hebrew and his divine mission as the deliverer of his people from slavery." +517,Charade,1963,113,7.9,83,13.47,"81,967",Romance and suspense ensue in Paris as a woman is pursued by several men who want a fortune her murdered husband had stolen. Whom can she trust? +518,Kôkaku kidôtai,1995,83,7.9,76,0.52,"1,50,058",A cyborg policewoman and her partner hunt a mysterious and powerful hacker called the Puppet Master. +519,Wo hu cang long,2000,120,7.9,94,128.08,"2,76,811",A young Chinese warrior steals a sword from a famed swordsman and then escapes into a world of romantic adventure with a mysterious man in the frontier of the nation. +520,Låt den rätte komma in,2008,114,7.9,82,2.12,"2,22,219","Oskar, an overlooked and bullied boy, finds love and revenge through Eli, a beautiful but peculiar girl." +521,The Wrestler,2008,109,7.9,80,26.24,"3,14,231","A faded professional wrestler must retire, but finds his quest for a new life outside the ring a dispiriting struggle." +522,All the President's Men,1976,138,7.9,84,70.6,"1,21,618","""The Washington Post"" reporters Bob Woodward and Carl Bernstein uncover the details of the Watergate scandal that leads to President Richard Nixon's resignation." +523,Before Midnight,2013,109,7.9,94,8.11,"1,66,107",We meet Jesse and Celine nine years on in Greece. Almost two decades have passed since their first meeting on that train bound for Vienna. +524,Bound by Honor,1993,180,7.9,47,4.5,"32,929","Based on the true life experiences of poet Jimmy Santiago Baca, the film focuses on step-brothers Paco and Cruz, and their bi-racial cousin Miklo." +525,Doctor Zhivago,1965,197,7.9,69,111.72,"79,911","The life of a Russian physician and poet who, although married to another, falls in love with a political activist's wife and experiences hardship during World War I and then the October Revolution." +526,Nosferatu,1922,94,7.9,,,"1,01,519",Vampire Count Orlok expresses interest in a new residence and real estate agent Hutter's wife. +527,Notorious,1946,102,7.9,100,10.46,"1,04,350",The daughter of a convicted Nazi spy is asked by American agents to gather information on a ring of Nazi scientists in South America. How far will she have to go to ingratiate herself with them? +528,Manbiki kazoku,2018,121,7.9,93,3.31,"81,707","On the margins of Tokyo, a dysfunctional band of outsiders are united by loyalty, a penchant for petty theft and playful grifting. When the young son is arrested, secrets are exposed that upend their tenuous, below-the-radar existence." +529,Ying xiong,2002,120,7.9,85,53.71,"1,85,034","A defense officer, Nameless, was summoned by the King of Qin regarding his success of terminating three warriors." +530,The Killing,1956,84,7.9,91,,"93,578",Crook Johnny Clay assembles a five-man team to plan and execute a daring racetrack robbery. +531,Being There,1979,130,7.9,83,30.18,"75,363","After the death of his employer forces him out of the only home he's ever known, a simpleminded, sheltered gardener becomes an unlikely trusted advisor to a powerful tycoon and an insider in Washington politics." +532,Serbuan maut 2: Berandal,2014,150,7.9,71,2.63,"1,28,092","Only a short time after the first raid, Rama goes undercover with the thugs of Jakarta and plans to bring down the syndicate and uncover the corruption within his police force." +533,The Searchers,1956,119,7.9,94,,"93,390",An American Civil War veteran embarks on a years-long journey to rescue his niece from the Comanches after the rest of his brother's family is massacred in a raid on their Texas farm. +534,A Streetcar Named Desire,1951,122,7.9,97,8,"1,11,818",Disturbed Blanche DuBois moves in with her sister in New Orleans and is tormented by her brutish brother-in-law while her reality crumbles around her. +535,The Wild Bunch,1969,145,7.9,98,12.06,"87,997","An aging group of outlaws look for one last big score as the ""traditional"" American West is disappearing around them." +536,King Kong,1933,100,7.9,92,10,"88,428","A film crew goes to a tropical island for a location shoot, where they capture a colossal ape who takes a shine to their blonde starlet, and bring him back to New York City." +537,Dancer in the Dark,2000,140,7.9,63,4.18,"1,13,668",An Eastern European US immigrant with a love for musicals has to cope with the gradual loss of her vision. +538,A Christmas Story,1983,93,7.9,77,20.61,"1,61,373","In the 1940s, a young boy named Ralphie Parker attempts to convince his parents, teacher, and Santa Claus that a Red Ryder Range 200 Shot BB gun really is the perfect Christmas gift." +539,Patton,1970,172,7.9,86,61.7,"1,05,637",The World War II phase of the career of controversial American general George S. Patton. +540,Togo,2019,113,7.9,69,,"52,579","The story of Togo, the sled dog who led the 1925 serum run despite being considered too small and weak to lead such an intense race." +541,Harold and Maude,1971,91,7.9,62,,"79,950","Young, rich, and obsessed with death, Harold finds himself changed forever when he meets lively septuagenarian Maude at a funeral." +542,Sing Street,2016,106,7.9,79,3.24,"97,925",A boy growing up in Dublin during the 1980s escapes his strained family life by starting a band to impress the mysterious girl he likes. +543,The Artist,I 2011,100,7.9,89,44.67,"2,45,519",An egomaniacal film star develops a relationship with a young dancer against the backdrop of Hollywood's silent era. +544,The Manchurian Candidate,1962,126,7.9,94,,"77,664",An American POW in the Korean War is brainwashed as an unwitting assassin for an international Communist conspiracy. +545,The Big Sleep,1946,114,7.9,86,6.54,"88,413","Private detective Philip Marlowe is hired by a wealthy family. Before the complex case is over, he's seen murder, blackmail and what might be love." +546,Trois couleurs: Bleu,1993,94,7.9,87,1.32,"1,06,876",A woman struggles to find a way to live her life after the death of her husband and child. +547,Once Were Warriors,1994,102,7.9,77,2.2,"35,797",A family descended from Maori warriors is bedeviled by a violent father and the societal problems of being treated as outcasts. +548,Short Term 12,2013,96,7.9,82,1.01,"89,975",A 20-something supervising staff member of a residential treatment facility navigates the troubled waters of that world alongside her co-worker and longtime boyfriend. +549,Harvey,1950,104,7.9,,,"57,092","Due to his insistence that he has an invisible six foot-tall rabbit for a best friend, a whimsical middle-aged man is thought by his family to be insane - but he may be wiser than anyone knows." +550,The Lion in Winter,1968,134,7.9,,22.28,"32,913","1183 A.D.: King Henry II's three sons all want to inherit the throne, but he won't commit to a choice. When he allows his imprisoned wife Eleanor of Aquitaine out for a Christmas visit, they all variously plot to force him into a decision." +551,Rope,1948,80,7.9,73,,"1,48,933",Two men attempt to prove they committed the perfect crime by hosting a dinner party after strangling their former classmate to death. +552,Strangers on a Train,1951,101,7.9,88,7.63,"1,37,645",A psychopath forces a tennis star to comply with his theory that two strangers can get away with murder. +553,In Cold Blood,1967,134,7.9,89,,"27,918","Two ex-cons murder a family in a robbery attempt, before going on the run from the authorities. The police try to piece together the details of the murder in an attempt to track down the killers." +554,Hable con ella,2002,112,7.9,86,9.36,"1,15,402",Two men share an odd friendship while they care for two women who are both in deep comas. +555,Il conformista,1970,113,7.9,100,0.54,"32,373","A weak-willed Italian man becomes a fascist flunky who goes abroad to arrange the assassination of his old teacher, now a political dissident." +556,Sacrifice,1986,149,7.9,,0.3,"29,863","At the dawn of World War III, a man searches for a way to restore peace to the world and finds he must give something in return." +557,Bin-jip,2004,88,7.9,72,0.24,"56,869",A transient young man breaks into empty homes to partake of the vacationing residents' lives for a few days. +558,Gegen die Wand,2004,121,7.9,78,,"56,154","With the intention to break free from the strict familial restrictions, a suicidal young woman sets up a marriage of convenience with a forty-year-old addict, an act that will lead to an outburst of envious love." +559,Cat on a Hot Tin Roof,1958,108,7.9,84,17.57,"51,866",Brick is an alcoholic ex-football player who drinks his days away and resists the affections of his wife. A reunion with his terminal father jogs a host of memories and revelations for both father and son. +560,Arsenic and Old Lace,1944,118,7.9,,,"73,105",A Brooklyn writer of books on the futility of marriage risks his reputation after he decides to tie the knot. Things get even more complicated when he learns on his wedding day that his beloved maiden aunts are habitual murderers. +561,Amour,2012,127,7.9,95,6.74,"1,03,400","Georges and Anne are an octogenarian couple. They are cultivated, retired music teachers. Their daughter, also a musician, lives in Britain with her family. One day, Anne has a stroke, and the couple's bond of love is severely tested." +562,Le cercle rouge,1970,140,7.9,92,0.37,"26,928","After leaving prison, master thief Corey crosses paths with a notorious escapee and an alcoholic former policeman. The trio proceed to plot an elaborate heist." +563,Kagemusha,1980,180,7.9,84,,"37,080",A petty thief with an utter resemblance to a samurai warlord is hired as the lord's double. When the warlord later dies the thief is forced to take up arms in his place. +564,Laura,1944,88,7.9,,4.36,"49,418",A police detective falls in love with the woman whose murder he is investigating. +565,The Adventures of Robin Hood,1938,102,7.9,97,3.98,"53,115","When Prince John and the Norman Lords begin oppressing the Saxon masses in King Richard's absence in 1190s England, a Saxon lord fights back as the outlaw leader of a resistance movement." +566,My Name Is Khan,2010,165,7.9,50,4.02,"1,11,629",An Indian Muslim man with Asperger's syndrome takes a challenge to speak to the President of the United States seriously and embarks on a cross-country journey. +567,Taeksi woonjunsa,2017,137,7.9,69,1.53,"28,077","A widowed father and taxi driver who drives a German reporter from Seoul to Gwangju to cover the 1980 uprising, soon finds himself regretting his decision after being caught in the violence around him." +568,Hiroshima mon amour,1959,90,7.9,,0.09,"34,371",A French actress filming an anti-war film in Hiroshima has an affair with a married Japanese architect as they share their differing perspectives on war. +569,Darbareye Elly,2009,119,7.9,87,0.11,"55,529",The mysterious disappearance of a kindergarten teacher during a picnic in the north of Iran is followed by a series of misadventures for her fellow travelers. +570,Mildred Pierce,1945,111,7.9,88,,"27,604",A hard-working mother inches towards disaster as she divorces her husband and starts a successful restaurant business to support her spoiled daughter. +571,Amarcord,1973,123,7.9,,0.58,"45,440",A series of comedic and nostalgic vignettes set in a 1930s Italian coastal town. +572,Kal Ho Naa Ho,2003,186,7.9,54,1.79,"72,406","Naina, an introverted, perpetually depressed girl's life changes when she meets Aman. But Aman has a secret of his own which changes their lives forever. Embroiled in all this is Rohit, Naina's best friend who conceals his love for her." +573,Miracle on 34th Street,1947,96,7.9,88,2.65,"51,389","After a divorced New York mother hires a nice old man to play Santa Claus at Macy's, she is startled by his claim to be the genuine article. When his sanity is questioned, a lawyer defends him in court by arguing that he's not mistaken." +574,In a Lonely Place,1950,94,7.9,,,"33,546","A potentially violent screenwriter is a murder suspect until his lovely neighbor clears him. However, she soon starts to have her doubts." +575,The Thin Man,1934,91,7.9,86,,"31,518","Former detective Nick Charles and his wealthy wife Nora investigate a murder case, mostly for the fun of it." +576,Ascenseur pour l'échafaud,1958,91,7.9,94,0.11,"27,413","A self-assured businessman murders his employer, the husband of his mistress, which unintentionally provokes an ill-fated chain of events." +577,The Big Heat,1953,89,7.9,,,"27,922",Tough cop Dave Bannion takes on a politically powerful crime syndicate. +578,The Lost Weekend,1945,101,7.9,,9.46,"38,956",The desperate life of a chronic alcoholic is followed through a four-day drinking bout. +579,Sullivan's Travels,1941,90,7.9,,,"27,728",Hollywood director John L. Sullivan sets out to experience life as a homeless person in order to gain relevant life experience for his next movie. +580,Hoje Eu Quero Voltar Sozinho,2014,96,7.9,71,0.1,"26,780","Leonardo is a blind teenager searching for independence. His everyday life, the relationship with his best friend Giovana, and the way he sees the world change completely with the arrival of Gabriel." +581,"4 luni, 3 saptamâni si 2 zile",2007,113,7.9,97,1.19,"62,141",A woman assists her friend in arranging an illegal abortion in 1980s Romania. +582,Gully Boy,2019,154,7.9,65,5.57,"41,774",A coming-of-age story based on the lives of street rappers in Mumbai. +583,La Belle et la Bête,1946,96,7.9,92,0.3,"27,365","A beautiful young woman takes her father's place as the prisoner of a mysterious beast, who wishes to marry her." +584,Bronenosets Potemkin,1925,75,7.9,97,0.05,"59,862","In the midst of the Russian Revolution of 1905, the crew of the battleship Potemkin mutiny against the brutal, tyrannical regime of the vessel's officers. The resulting street demonstration in Odessa brings on a police massacre." +585,Jab We Met,2007,138,7.9,,0.41,"56,156",A depressed wealthy businessman finds his life changing after he meets a spunky and care-free young woman. +586,Vozvrashchenie,2003,110,7.9,82,0.5,"46,543","In the Russian wilderness, two brothers face a range of new, conflicting emotions when their father - a man they know only through a single photograph - resurfaces." +587,Nueve reinas,2000,114,7.9,80,1.22,"55,207","Two con artists try to swindle a stamp collector by selling him a sheet of counterfeit rare stamps (the ""nine queens"")." +588,C.R.A.Z.Y.,2005,129,7.9,81,,"33,683","A young French Canadian, one of five boys in a conservative family in the 1960s and 1970s, struggles to reconcile his emerging identity with his father's values." +589,Såsom i en spegel,1961,90,7.9,84,,"26,523","Recently released from a mental hospital, Karin rejoins her emotionally disconnected family in their island home, only to slip from reality as she begins to believe she is being visited by God." +590,La règle du jeu,1939,110,7.9,99,,"30,374","A bourgeois life in France at the onset of World War II, as the rich and their poor servants meet up at a French chateau." +591,Dev.D,2009,144,7.9,,0.01,"31,570","After breaking up with his childhood sweetheart, a young man finds solace in drugs. Meanwhile, a teenage girl is caught in the world of prostitution. Will they be destroyed, or will they find redemption?" +592,No Man's Land,I 2001,98,7.9,84,1.06,"48,084","Bosnia and Herzegovina during 1993 at the time of the heaviest fighting between the two warring sides. Two soldiers from opposing sides in the conflict, Nino and Ciki, become trapped in no man's land, whilst a third soldier becomes a living booby trap." +593,Super 30,2019,154,7.9,,2.27,"34,529",Based on the life of Patna-based mathematician Anand Kumar who runs the famed Super 30 program for IIT aspirants in Patna. +594,Badhaai ho,2018,124,7.9,,,"36,357",A man is embarrassed when he finds out his mother is pregnant. +595,Padman,2018,140,7.9,,1.66,"27,518","Upon realizing the extent to which women are affected by their menses, a man sets out to create a sanitary pad machine and to provide inexpensive sanitary pads to the women of rural India." +596,Baby,I 2015,159,7.9,,,"59,034","An elite counter-intelligence unit learns of a plot, masterminded by a maniacal madman. With the clock ticking, it's up to them to track the terrorists' international tentacles and prevent them from striking at the heart of India." +597,Airlift,2016,130,7.9,,,"58,010","When Iraq invades Kuwait in August 1990, a callous Indian businessman becomes the spokesperson for more than 170,000 stranded countrymen." +598,Dunkirk,2017,106,7.8,94,188.37,"6,98,509","Allied soldiers from Belgium, the British Commonwealth and Empire, and France are surrounded by the German Army and evacuated during a fierce battle in World War II." +599,John Wick: Chapter 4,2023,169,7.8,78,,"2,53,713","John Wick uncovers a path to defeating The High Table. But before he can earn his freedom, Wick must face off against a new enemy with powerful alliances across the globe and forces that turn old friends into foes." +600,The Batman,2022,176,7.8,72,369.35,"7,16,891","When a sadistic serial killer begins murdering key political figures in Gotham, Batman is forced to investigate the city's hidden corruption and question his family's involvement." +601,Everything Everywhere All at Once,2022,139,7.8,81,72.86,"4,67,995",A middle-aged Chinese immigrant is swept up into an insane adventure in which she alone can save existence by exploring other universes and connecting with the lives she could have led. +602,Little Women,2019,135,7.8,91,108.1,"2,24,357","Jo March reflects back and forth on her life, telling the beloved story of the March sisters - four young women, each determined to live life on her own terms." +603,Pride & Prejudice,2005,129,7.8,82,38.41,"3,12,693","Sparks fly when spirited Elizabeth Bennet meets single, rich, and proud Mr. Darcy. But Mr. Darcy reluctantly finds himself falling in love with a woman beneath his class. Can each overcome their own pride and prejudice?" +604,Drive,I 2011,100,7.8,78,35.06,"6,74,111",A mysterious Hollywood action film stuntman gets in trouble with gangsters when he tries to help his neighbor's husband rob a pawn shop while serving as his getaway driver. +605,The Notebook,2004,123,7.8,53,81,"5,93,448","A poor yet passionate young man falls in love with a rich young woman, giving her a sense of freedom. However, social differences soon get in the way." +606,The Big Short,2015,130,7.8,81,70.26,"4,54,190","In 2006-2007 a group of investors bet against the United States mortgage market. In their research, they discover how flawed and corrupt the market is." +607,The Gentlemen,2019,113,7.8,51,36.47,"3,66,423","An American expat tries to sell off his highly profitable marijuana empire in London, triggering plots, schemes, bribery and blackmail in an attempt to steal his domain out from under him." +608,Im Westen nichts Neues,2022,148,7.8,76,,"2,15,899",A young German soldier's terrifying experiences and distress on the western front during World War I. +609,About Time,I 2013,123,7.8,55,15.32,"3,68,517","At the age of 21, Tim discovers he can travel in time and change what happens and has happened in his own life. His decision to make his world a better place by getting a girlfriend turns out not to be as easy as you might think." +610,The Breakfast Club,1985,97,7.8,66,45.88,"4,19,768",Five high school students meet in Saturday detention and discover how they have a great deal more in common than they thought. +611,Willy Wonka & the Chocolate Factory,1971,100,7.8,67,4,"2,11,951",A poor but hopeful boy seeks one of the five coveted golden tickets that will send him on a tour of Willy Wonka's mysterious chocolate factory. +612,The Hateful Eight,2015,168,7.8,68,54.12,"6,30,884","In the dead of a Wyoming winter, a bounty hunter and his prisoner find shelter in a cabin currently inhabited by a collection of nefarious characters." +613,Back to the Future Part II,1989,108,7.8,57,118.5,"5,53,871","After visiting 2015, Marty McFly must repeat his visit to 1955 to prevent disastrous changes to 1985...without interfering with his first trip." +614,Call Me by Your Name,2017,132,7.8,94,18.1,"2,93,275","In 1980s Italy, romance blossoms between a seventeen-year-old student and the older man hired as his father's research assistant." +615,Get Out,I 2017,104,7.8,85,176.04,"6,49,440","A young African-American visits his White girlfriend's parents for the weekend, where his simmering uneasiness about their reception of him eventually reaches a boiling point." +616,Atonement,2007,123,7.8,85,50.93,"2,89,972",Thirteen-year-old fledgling writer Briony Tallis irrevocably changes the course of several lives when she accuses her older sister's lover of a crime he did not commit. +617,The Irishman,2019,209,7.8,94,7,"4,09,097","An illustration of Frank Sheeran's life, from W.W.II veteran to hit-man for the Bufalino crime family and his alleged assassination of his close friend Jimmy Hoffa." +618,Tombstone,1993,130,7.8,50,56.51,"1,57,941","A successful lawman's plans to retire anonymously in Tombstone, Arizona are disrupted by the kind of outlaws he was famous for eliminating." +619,Ghostbusters,1984,105,7.8,71,238.63,"4,28,989","Three parapsychologists forced out of their university funding set up shop as a unique ghost removal service in New York City, attracting frightened yet skeptical customers." +620,RRR (Rise Roar Revolt),2022,187,7.8,83,14.5,"1,54,291",A fictitious story about two legendary revolutionaries and their journey away from home before they started fighting for their country in the 1920s. +621,Hot Fuzz,2007,121,7.8,81,23.64,"5,21,270","A skilled London police officer, after irritating superiors with his embarrassing effectiveness, is transferred to a village where the easygoing officers object to his fervor for regulations, as a string of grisly murders strikes the town." +622,Apocalypto,2006,139,7.8,68,50.87,"3,22,116","As the Mayan kingdom faces its decline, a young man is taken on a perilous journey to a world ruled by fear and oppression." +623,The Social Network,2010,120,7.8,95,96.96,"7,30,242","As Harvard student Mark Zuckerberg creates the social networking site that would become known as Facebook, he is sued by the twins who claimed he stole their idea and by the co-founder who was later squeezed out of the business." +624,Nightcrawler,2014,117,7.8,76,32.38,"5,77,227","When Louis Bloom, a con man desperate for work, muscles into the world of L.A. crime journalism, he blurs the line between observer and participant to become the star of his own story." +625,Hidden Figures,2016,127,7.8,74,169.61,"2,43,640",The story of a team of female African-American mathematicians who served a vital role in NASA during the early years of the U.S. space program. +626,Rogue One,2016,133,7.8,65,532.18,"6,62,973","In a time of conflict, a group of unlikely heroes band together on a mission to steal the plans to the Death Star, the Empire's ultimate weapon of destruction." +627,The Curious Case of Benjamin Button,2008,166,7.8,70,127.51,"6,73,710","Tells the story of Benjamin Button, a man who starts aging backwards with consequences." +628,The Girl with the Dragon Tattoo,2011,158,7.8,71,102.52,"4,77,967",Journalist Mikael Blomkvist is aided in his search for a woman who has been missing for 40 years by young computer hacker Lisbeth Salander. +629,Predator,1987,107,7.8,47,59.74,"4,38,305",A team of commandos on a mission in a Central American jungle find themselves hunted by an extraterrestrial warrior. +630,The Hobbit: An Unexpected Journey,2012,169,7.8,58,303,"8,48,998","A reluctant Hobbit, Bilbo Baggins, sets out to the Lonely Mountain with a spirited group of dwarves to reclaim their mountain home, and the gold within it from the dragon Smaug." +631,Little Miss Sunshine,2006,101,7.8,80,59.89,"5,00,734",A family determined to get their young daughter into the finals of a beauty pageant take a cross-country trip in their VW bus. +632,The Sandlot,1993,101,7.8,55,32.42,"98,129","In the summer of 1962, a new kid in town is taken under the wing of a young baseball prodigy and his rowdy team, resulting in many adventures." +633,Manchester by the Sea,2016,137,7.8,96,47.7,"2,97,099",A depressed uncle is asked to take care of his teenage nephew after the boy's father dies. +634,The Untouchables,1987,119,7.8,79,76.27,"3,21,305","During Prohibition, Treasury agent Eliot Ness sets out to stop ruthless Chicago gangster Al Capone, and assembles a small, incorruptible team to help him." +635,Star Wars: Episode VII - The Force Awakens,2015,138,7.8,80,936.66,"9,52,718","As a new threat to the galaxy rises, Rey, a desert scavenger, and Finn, an ex-stormtrooper, must join Han Solo and Chewbacca to search for the one hope of restoring peace." +636,Ferris Bueller's Day Off,1986,103,7.8,61,70.14,"3,73,042","A popular high school student, admired by his peers, decides to take a day off from school, and goes to extreme lengths to it pull off, to the chagrin of his Principal who'll do anything to stop him." +637,Remember the Titans,2000,113,7.8,48,115.65,"2,24,242",The true story of a newly appointed African-American coach and his high school team on their first season as a racially integrated unit. +638,Cast Away,2000,143,7.8,73,233.63,"6,15,383",A FedEx executive undergoes a physical and emotional transformation after crash landing on a deserted island. +639,Moonrise Kingdom,2012,94,7.8,84,45.51,"3,60,026","A pair of young lovers flee their New England town, which causes a local search party to fan out to find them." +640,Close,I 2022,104,7.8,81,,"26,078","The intense friendship between two thirteen-year old boys Leo and Remi suddenly gets disrupted. Struggling to understand what has happened, Léo approaches Sophie, Rémi's mother. ""Close"" is a film about friendship and responsibility." +641,A Bronx Tale,1993,121,7.8,80,17.27,"1,51,710",A father becomes worried when a local gangster befriends his son in the Bronx in the 1960s. +642,Skyfall,2012,143,7.8,81,304.36,"7,14,083","James Bond's loyalty to M is tested when her past comes back to haunt her. When MI6 comes under attack, 007 must track down and destroy the threat, no matter how personal the cost." +643,Taken,I 2008,90,7.8,51,145,"6,18,149","A retired CIA agent travels across Europe and relies on his old skills to save his estranged daughter, who has been kidnapped while on a trip to Paris." +644,Verdens verste menneske,2021,128,7.8,90,,"82,883","The chronicles of four years in the life of Julie, a young woman who navigates the troubled waters of her love life and struggles to find her career path, leading her to take a realistic look at who she really is." +645,Captain America: Civil War,2016,147,7.8,75,408.08,"8,20,591",Political involvement in the Avengers' affairs causes a rift between Captain America and Iron Man. +646,Misery,1990,107,7.8,75,61.28,"2,23,326","After a famous author is rescued from a car crash by a fan of his novels, he comes to realize that the care he is receiving is only the beginning of a nightmare of captivity and abuse." +647,Captain Fantastic,2016,118,7.8,72,5.88,"2,27,323","In the forests of the Pacific Northwest, a father devoted to raising his six kids with a rigorous physical and intellectual education is forced to leave his paradise and enter the world, challenging his idea of what it means to be a parent." +648,The Last Samurai,2003,154,7.8,55,111.11,"4,55,812",An American military advisor embraces the Samurai culture he was hired to destroy after he is captured in battle. +649,The Fugitive,1993,130,7.8,87,183.88,"3,07,181","Dr. Richard Kimble, unjustly accused of murdering his wife, must find the real killer while being the target of a nationwide manhunt led by a seasoned U.S. Marshal." +650,The Man from Earth,2007,87,7.8,,,"1,93,908",An impromptu goodbye party for Professor John Oldman becomes a mysterious interrogation after the retiring scholar reveals to his colleagues he has a longer and stranger past than they can imagine. +651,Isle of Dogs,2018,101,7.8,82,32.02,"1,81,265","Set in Japan, Isle of Dogs follows a boy's odyssey in search of his lost dog." +652,Moon,2009,97,7.8,67,5.01,"3,68,507","Astronaut Sam Bell has a quintessentially personal encounter toward the end of his three-year stint on the Moon, where he, working alongside his computer, GERTY, sends back to Earth parcels of a resource that has helped diminish our planet's power problems." +653,Midnight Cowboy,1969,113,7.8,79,44.79,"1,15,986","A naive hustler travels from Texas to New York City to seek personal fortune, finding a new friend in the process." +654,Captain Phillips,2013,134,7.8,82,107.1,"4,77,897","The true story of Captain Richard Phillips and the 2009 hijacking by Somali pirates of the U.S.-flagged MV Maersk Alabama, the first American cargo ship to be hijacked in two hundred years." +655,Captain America: The Winter Soldier,2014,136,7.8,70,259.77,"8,74,310","As Steve Rogers struggles to embrace his role in the modern world, he teams up with a fellow Avenger and S.H.I.E.L.D agent, Black Widow, to battle a new threat from history: an assassin known as the Winter Soldier." +656,American Gangster,2007,157,7.8,76,130.16,"4,39,617","An outcast New York City cop is charged with bringing down Harlem drug lord Frank Lucas, whose real life inspired this partly biographical film." +657,Awakenings,1990,121,7.8,74,52.1,"1,51,004","The victims of an encephalitis epidemic many years ago have been catatonic ever since, but now a new drug offers the prospect of reviving them." +658,Thirteen Lives,2022,147,7.8,66,,"62,461",A rescue mission is assembled in Thailand where a group of young boys and their soccer coach are trapped in a system of underground caves that are flooding. +659,The Hobbit: The Desolation of Smaug,2013,161,7.8,66,258.37,"6,83,596","The dwarves, along with Bilbo Baggins and Gandalf the Grey, continue their quest to reclaim Erebor, their homeland, from Smaug. Bilbo Baggins is in possession of a mysterious and magical ring." +660,The Fighter,I 2010,116,7.8,79,93.62,"3,79,574","Based on the story of Micky Ward, a fledgling boxer who tries to escape the shadow of his more famous but troubled older boxing brother and get his own shot at greatness." +661,Serenity,2005,119,7.8,74,25.51,"3,01,365",The crew of the ship Serenity try to evade an assassin sent to recapture telepath River. +662,Big Hero 6,2014,102,7.8,74,222.53,"4,82,096","A special bond develops between plus-sized inflatable robot Baymax and prodigy Hiro Hamada, who together team up with a group of friends to form a band of high-tech heroes." +663,Ang-ma-reul bo-at-da,2010,144,7.8,67,0.13,"1,38,894",A secret agent exacts revenge on a serial killer through a series of captures and releases. +664,Mary Poppins,1964,139,7.8,88,102.27,"1,79,683","In turn of the century London, a magical nanny employs music and adventure to help two neglected children become closer to their father." +665,Straight Outta Compton,2015,147,7.8,72,161.2,"2,10,992","The rap group NWA emerges from the mean streets of Compton in Los Angeles, California, in the mid-1980s and revolutionizes Hip Hop culture with their music and tales about life in the hood." +666,Boyz n the Hood,1991,112,7.8,76,57.5,"1,47,882","Follows the lives of three young males living in the Crenshaw ghetto of Los Angeles, dissecting questions of race, relationships, violence, and future prospects." +667,Paddington 2,2017,103,7.8,88,40.44,"86,386","Paddington, now happily settled with the Brown family and a popular member of the local community, picks up a series of odd jobs to buy the perfect present for his Aunt Lucy's 100th birthday, only for the gift to be stolen." +668,Gekijouban Jujutsu Kaisen 0,2021,105,7.8,71,34.54,"26,180","Yuta Okkotsu, a high schooler who gains control of an extremely powerful Cursed Spirit and gets enrolled in the Tokyo Prefectural Jujutsu High School by Jujutsu Sorcerers to help him control his power and keep an eye on him." +669,GATTACA,1997,106,7.8,64,12.34,"3,14,373",A genetically inferior man assumes the identity of a superior one in order to pursue his lifelong dream of space travel. +670,The Fall,I 2006,117,7.8,64,2.28,"1,14,753","In a hospital on the outskirts of 1920s Los Angeles, an injured stuntman begins to tell a fellow patient, a little girl with a broken arm, a fantastic story of five mythical heroes. Thanks to his fractured state of mind and her vivid imagination, the line between fiction and reality blurs as the tale advances." +671,La montaña sagrada,1973,114,7.8,76,0.06,"45,907","In a corrupt, greed-fueled world, a powerful alchemist leads a messianic character and seven materialistic figures to the Holy Mountain, where they hope to achieve enlightenment." +672,Walk the Line,2005,136,7.8,72,119.52,"2,59,051","A chronicle of country music legend Johnny Cash's life, from his early days on an Arkansas cotton farm to his rise to fame with Sun Records in Memphis, where he recorded alongside Elvis Presley, Jerry Lee Lewis, and Carl Perkins." +673,The Day of the Jackal,1973,143,7.8,80,16.06,"42,949","In the aftermath of France allowing Algeria's independence, a group of resentful military veterans hire a professional assassin codenamed ""Jackal"" to kill President Charles de Gaulle." +674,Kramer vs. Kramer,1979,105,7.8,77,106.26,"1,50,521","After his wife leaves him, a work-obsessed Manhattan advertising executive is forced to learn long-neglected parenting skills, but a heated custody battle over the couple's young son deepens the wounds left by the separation." +675,The Remains of the Day,1993,134,7.8,86,22.95,"79,562",A butler who sacrificed body and soul to service in the years leading up to World War II realizes too late how misguided his loyalty was to his lordly employer. +676,The Insider,1999,157,7.8,84,28.97,"1,75,847",A research chemist comes under personal and professional attack when he decides to appear in a 60 Minutes exposé on Big Tobacco. +677,Män som hatar kvinnor,2009,152,7.8,76,10.1,"2,20,269",A journalist is aided by a young female hacker in his search for the killer of a woman who has been dead for forty years. +678,Glory,1989,122,7.8,78,26.83,"1,40,071","Robert Gould Shaw leads the U.S. Civil War's first all-black volunteer company, fighting prejudices from both his own Union Army, and the Confederates." +679,How to Train Your Dragon 2,2014,102,7.8,77,177,"3,51,114","When Hiccup and Toothless discover an ice cave that is home to hundreds of new wild dragons and the mysterious Dragon Rider, the two friends find themselves at the center of a battle to protect the peace." +680,Mississippi Burning,1988,128,7.8,65,34.6,"1,05,287",Two F.B.I. Agents with wildly different styles arrive in Mississippi to investigate the disappearance of some civil rights activists. +681,Majo no takkyûbin,1989,103,7.8,85,,"1,54,293","A young witch, on her mandatory year of independent life, finds fitting into a new community difficult while she supports herself by running an air courier service." +682,Hunt for the Wilderpeople,2016,101,7.8,81,5.2,"1,36,291",A national manhunt is ordered for a rebellious kid and his foster uncle who go missing in the wild New Zealand bush. +683,The King of Comedy,1982,109,7.8,73,2.5,"1,11,083","Rupert Pupkin is a passionate yet unsuccessful comic who craves nothing more than to be in the spotlight and to achieve this, he stalks and kidnaps his idol to take the spotlight for himself." +684,The Conversation,1974,113,7.8,87,4.42,"1,16,744","A paranoid, secretive surveillance expert has a crisis of conscience when he suspects that the couple he is spying on will be murdered." +685,Dawn of the Dead,1978,127,7.8,71,5.1,"1,24,891","During an escalating zombie epidemic, two Philadelphia SWAT team members, a traffic reporter and his TV executive girlfriend seek refuge in a secluded shopping mall." +686,Changeling,2008,141,7.8,63,35.74,"2,60,921",Grief-stricken mother Christine Collins takes on the L.A.P.D. to her own detriment when they try to pass off an obvious impostor as her missing child. +687,Night of the Living Dead,1968,96,7.8,89,0.09,"1,34,134",A ragtag group of Pennsylvanians barricade themselves in an old farmhouse to remain safe from a horde of flesh-eating ghouls that are ravaging the East Coast of the United States. +688,Gaslight,1944,114,7.8,78,,"31,733","Ten years after her aunt was murdered in their London home, a woman returns from Italy in the 1880s to resume residence with her new husband. His obsessive interest in the home rises from a secret that may require driving his wife insane." +689,La migliore offerta,2013,131,7.8,49,0.09,"1,24,280",A lonely art expert working for a mysterious and reclusive heiress finds not only her art worth examining. +690,The Right Stuff,1983,193,7.8,91,21.5,"63,750","The U.S. space program's development from the breaking of the sound barrier to selection of the Mercury 7 astronauts, from a group of test pilots with a more seat-of-the-pants approach than the program's more cautious engineers preferred." +691,Letters from Iwo Jima,2006,141,7.8,89,13.76,"1,67,040","The story of the battle of Iwo Jima between the United States and Imperial Japan during World War II, as told from the perspective of the Japanese who fought it." +692,The Killing Fields,1984,141,7.8,76,34.7,"57,641","A journalist is trapped in Cambodia during tyrant Pol Pot's bloody 'Year Zero' cleansing campaign, which claimed the lives of two million 'undesirable' civilians." +693,Les parapluies de Cherbourg,1964,91,7.8,86,0.03,"29,619",A young woman separated from her lover by war faces a life-altering decision. +694,All That Jazz,1979,123,7.8,72,37.82,"33,742","Director/choreographer Bob Fosse tells his own life story as he details the sordid career of Joe Gideon, a womanizing, drug-using dancer." +695,Ed Wood,1994,127,7.8,70,5.89,"1,80,542",Ambitious but troubled movie director Edward D. Wood Jr. tries his best to fulfill his dreams despite his lack of talent. +696,My Fair Lady,1964,170,7.8,95,72,"98,938","In 1910s London, snobbish phonetics professor Henry Higgins agrees to a wager that he can make crude flower girl, Eliza Doolittle, presentable in high society." +697,October Sky,1999,108,7.8,71,32.48,"95,181","The true story of Homer Hickam, a coal miner's son who was inspired by the first Sputnik launch to take up rocketry against his father's wishes." +698,Cabaret,1972,124,7.8,80,42.77,"57,486",A female girlie club entertainer in Weimar Republic era Berlin romances two men while the Nazi Party rises to power around them. +699,Days of Heaven,1978,94,7.8,93,,"60,617",A hot-tempered farm laborer convinces the woman he loves to marry their rich but dying boss so that they can have a claim to his fortune. +700,The Outlaw Josey Wales,1976,135,7.8,69,31.8,"77,266",Missouri farmer Josey Wales joins a Confederate guerrilla unit and winds up on the run from the Union soldiers who murdered his family. +701,My Left Foot: The Story of Christy Brown,1989,103,7.8,97,14.74,"77,442","Christy Brown, born with cerebral palsy, learns to paint and write with his only controllable limb - his left foot." +702,The Day the Earth Stood Still,1951,92,7.8,83,,"84,093","An alien lands in Washington, D.C. and tells the people of Earth that they must live peacefully or be destroyed as a danger to other planets." +703,Manhattan,1979,96,7.8,83,45.7,"1,43,735",The life of a divorced television writer dating a teenage girl is further complicated when he falls in love with his best friend's mistress. +704,Batman: Mask of the Phantasm,1993,76,7.8,65,5.62,"54,175",Batman is wrongly implicated in a series of murders of mob bosses actually committed by a new vigilante assassin. +705,Breaking the Waves,1996,159,7.8,81,4.04,"69,609","Oilman Jan is paralyzed in an accident. His wife, who prayed for his return, feels guilty; even more, when Jan urges her to have sex with another." +706,Once,I 2007,86,7.8,90,9.44,"1,19,263","A modern-day musical about a busker and an immigrant and their eventful week in Dublin, as they write, rehearse and record songs that tell their love story." +707,Guess Who's Coming to Dinner,1967,108,7.8,63,56.7,"47,432",A couple's attitudes are challenged when their daughter introduces them to her African-American fiancé. +708,East of Eden,1955,118,7.8,72,,"47,506","Two brothers in 1910s California struggle to maintain their strict, Bible-toting father's favor as an old secret about their long-absent mother comes to light." +709,Loving Vincent,2017,94,7.8,62,6.74,"60,975","In a story depicted in oil painted animation, a young man comes to the last hometown of painter Vincent van Gogh to deliver the troubled artist's final letter and ends up investigating his final days there." +710,Mimi wo sumaseba,1995,111,7.8,75,,"67,650","A love story between a girl who loves reading books, and a boy who has previously checked out all of the library books she chooses." +711,Pride,I 2014,119,7.8,79,,"60,002",U.K. gay activists work to help miners during their lengthy strike of the National Union of Mineworkers in the summer of 1984. +712,Freaks,1932,64,7.8,80,0.63,"48,616","A circus' beautiful trapeze artist agrees to marry the leader of side-show performers, but his deformed friends discover she is only marrying him for his inheritance." +713,Lilja 4-ever,2002,109,7.8,83,0.18,"47,527","Sixteen-year-old Lilja and her only friend, the young boy Volodja, live in Russia, fantasizing about a better life. One day, Lilja falls in love with Andrej, who is going to Sweden, and invites Lilja to come along and start a new life." +714,Chugyeokja,2008,125,7.8,64,,"69,807",A disgraced ex-policeman who runs a small ring of prostitutes finds himself in a race against time when one of his women goes missing. +715,Hannah and Her Sisters,1986,107,7.8,90,40.08,"73,918","Between two Thanksgivings two years apart, Hannah's husband falls in love with her sister Lee, while her hypochondriac ex-husband rekindles his relationship with her sister Holly." +716,"Aguirre, der Zorn Gottes",1972,95,7.8,,,"59,643","In the 16th century, the ruthless and insane Don Lope de Aguirre leads a Spanish expedition in search of El Dorado." +717,Bringing Up Baby,1938,102,7.8,91,,"64,291","While trying to secure a $1 million donation for his museum, a befuddled paleontologist is pursued by a flighty and often irritating heiress and her pet leopard, Baby." +718,Stagecoach,1939,96,7.8,93,,"51,716",A group of people traveling on a stagecoach find their journey complicated by the threat of Geronimo and learn something about each other in the process. +719,Frankenstein,1931,70,7.8,91,,"76,170",Dr. Frankenstein dares to tamper with life and death by creating a human monster out of lifeless body parts. +720,Todo sobre mi madre,1999,101,7.8,87,8.26,"1,00,215","Pedro Almodovar's Oscar-winning comedy (Best Foreign Film, 1999) about a bereaved mother, and overwrought actress, her jealous lover and a pregnant nun." +721,"I, Daniel Blake",2016,100,7.8,78,0.26,"62,566","After surviving a heart-attack, a 59-year-old carpenter must fight bureaucratic forces to receive Employment and Support Allowance." +722,The Man Who Would Be King,1975,129,7.8,91,,"50,768","Two former British soldiers in 1880s India decide to set themselves up as Kings in Kafiristan, a land where no white man has set foot since Alexander the Great." +723,Red River,1948,133,7.8,96,,"32,948","Dunson leads a cattle drive, the culmination of over 14 years of work, to its destination in Missouri. But his tyrannical behavior along the way causes a mutiny, led by his adopted son." +724,Jûbê ninpûchô,1993,94,7.8,,,"39,216",A vagabond swordsman is aided by a kunoichi and a spy in battling a demonic clan of killers - led by a ghost from his past - who are bent on overthrowing the Tokugawa Shogunate. +725,L'avventura,1960,144,7.8,,,"31,536","A woman disappears during a Mediterranean boating trip. During the search, her lover and her best friend become attracted to each other." +726,Un prophète,2009,155,7.8,90,2.08,"1,00,583",A young Algerian man is sent to a French prison. +727,His Girl Friday,1940,92,7.8,,0.3,"61,282",A newspaper editor uses every trick in the book to keep his ace reporter ex-wife from remarrying. +728,Shadow of a Doubt,1943,108,7.8,94,,"68,196","A teenage girl, overjoyed when her favorite uncle comes to visit the family in their quiet California town, slowly begins to suspect that he is in fact the ""Merry Widow"" killer sought by the authorities." +729,Das weiße Band - Eine deutsche Kindergeschichte,2009,144,7.8,84,2.22,"75,814","Strange events happen in a small village in the north of Germany during the years before World War I, which seem to be ritual punishment. Who is responsible?" +730,Under sandet,2015,100,7.8,75,0.44,"43,733","In post-World War II Denmark, a group of young German POWs are forced to clear a beach of thousands of land mines under the watch of a Danish Sergeant who slowly learns to appreciate their plight." +731,The Lunchbox,2013,104,7.8,76,4.23,"58,820",A mistaken delivery in Mumbai's famously efficient lunchbox delivery system connects a young housewife to an older man in the dusk of his life as they build a fantasy world together through notes in the lunchbox. +732,The World's Fastest Indian,2005,127,7.8,68,5.13,"56,786","The story of New Zealander Burt Munro, who spent years rebuilding a 1920 Indian motorcycle, which helped him set the land speed world record at Utah's Bonneville Salt Flats in 1967." +733,To Have and Have Not,1944,100,7.8,90,,"36,804","During World War II, American expatriate Harry Morgan helps transport a French Resistance leader and his beautiful wife to Martinique while romancing a sensuous lounge singer." +734,Il postino,1994,108,7.8,81,21.85,"37,976","A simple Italian postman learns to love poetry while delivering mail to a famous poet, and then uses this to woo local beauty Beatrice." +735,Le charme discret de la bourgeoisie,1972,102,7.8,93,0.2,"45,180","A surreal, virtually plotless series of dreams centered around six middle-class people and their consistently interrupted attempts to have a meal together." +736,The Innocents,1961,100,7.8,88,2.62,"31,894",A young governess for two children becomes convinced that the house and grounds are haunted. +737,Cowboy Bebop: Tengoku no tobira,2001,115,7.8,61,1,"51,069","A terrorist explosion releases a deadly virus on the masses, and it's up to the bounty-hunting Bebop crew to catch the cold-blooded culprit." +738,Les choristes,2004,97,7.8,56,3.64,"65,066",The new teacher at a severely administered boys' boarding school works to positively affect the students' lives through music. +739,The Asphalt Jungle,1950,112,7.8,85,,"28,739","A major heist goes off as planned, but then double crosses, bad luck and solid police work cause everything to unravel." +740,Bride of Frankenstein,1935,75,7.8,95,4.36,"50,855","Mary Shelley reveals the main characters of her novel survived: Dr. Frankenstein, goaded by an even madder scientist, builds his monster a mate." +741,You Can't Take It with You,1938,126,7.8,,4.66,"26,941","The son of a snobbish Wall Street banker becomes engaged to a woman from a good-natured but decidedly eccentric family, not realizing that his father is trying to force her family from their home for a real estate development." +742,Dip huet seung hung,1989,111,7.8,82,,"49,837",A disillusioned assassin accepts one last hit in hopes of using his earnings to restore vision to a singer he accidentally blinded. +743,Bir Zamanlar Anadolu'da,2011,157,7.8,82,0.14,"48,298",A group of men set out in search of a dead body in the Anatolian steppes. +744,Soshite chichi ni naru,2013,121,7.8,73,0.28,"26,733","Ryota is a successful workaholic businessman. When he learns that his biological son was switched with another boy after birth, he faces the difficult decision to choose his true son or the boy he and his wife have raised as their own." +745,Crimes and Misdemeanors,1989,104,7.8,77,18.25,"59,616",An ophthalmologist's mistress threatens to reveal their affair to his wife while a married documentary filmmaker is infatuated with another woman. +746,Vivre sa vie: Film en douze tableaux,1962,80,7.8,,,"33,775",Twelve episodic tales in the life of a Parisian woman and her slow descent into prostitution. +747,Tôkyô goddofâzâzu,2003,92,7.8,74,0.13,"43,664","On Christmas Eve, three homeless people living on the streets of Tokyo discover a newborn baby among the trash and set out to find its parents." +748,Sennen joyû,2001,87,7.8,70,0.19,"30,127",A TV interviewer and his cameraman meet a former actress and travel through her memories and career. +749,Veer-Zaara,2004,192,7.8,67,2.92,"55,254","""Veer-Zaara"" is a saga of love, separation, courage, and sacrifice. A love story that is an inspiration and will remain a legend forever." +750,Duck Soup,1933,69,7.8,93,,"61,657","Rufus T. Firefly is named the dictator of bankrupt Freedonia and declares war on neighboring Sylvania over the love of his wealthy backer Mrs. Teasdale, contending with two inept spies who can't seem to keep straight which side they're on." +751,Ma vie de Courgette,2016,66,7.8,85,0.29,"27,386","After losing his mother, a young boy is sent to an orphanage with other orphans his age where he begins to learn the meaning of trust and true love." +752,Knockin' on Heaven's Door,1997,87,7.8,,0,"32,399","Two terminally ill patients escape from a hospital, steal a car and rush towards the sea." +753,English Vinglish,2012,134,7.8,,1.67,"37,783","A quiet, sweet tempered housewife endures small slights from her well-educated husband and daughter every day because of her inability to speak and understand English." +754,A Night at the Opera,1935,96,7.8,,2.54,"33,900",A sly business manager and the wacky friends of two opera singers in Italy help them achieve success in America while humiliating their stuffy and snobbish enemies. +755,Vicky Donor,2012,126,7.8,,0.17,"44,538","A man is brought in by an infertility doctor to supply him with his sperm, where he becomes the biggest sperm donor for his clinic." +756,Hindi Medium,2017,132,7.8,,,"30,887",A couple from Chandni Chowk aspire to give their daughter the best education and thus be a part of and accepted by the elite of Delhi. +757,Mission: Impossible - Fallout,2018,147,7.7,86,220.16,"3,58,868","Ethan Hunt and his IMF team, along with some familiar allies, race against time after a mission gone wrong." +758,The Whale,2022,117,7.7,60,,"1,65,969","A reclusive, morbidly obese English teacher attempts to reconnect with his estranged teenage daughter." +759,The Banshees of Inisherin,2022,114,7.7,87,,"2,20,025","Two lifelong friends find themselves at an impasse when one abruptly ends their relationship, with alarming consequences for both of them." +760,Aftersun,II 2022,102,7.7,95,,"70,047",Sophie reflects on the shared joy and private melancholy of a holiday she took with her father twenty years earlier. Memories real and imagined fill the gaps between as she tries to reconcile the father she knew with the man she didn't... +761,Sicario,2015,121,7.7,82,46.89,"4,50,770",An idealistic FBI agent is enlisted by a government task force to aid in the escalating war against drugs at the border area between the U.S. and Mexico. +762,The Goonies,1985,114,7.7,62,61.5,"2,87,606",A group of young misfits called The Goonies discover an ancient map and set out on an adventure to find a legendary pirate's long-lost treasure. +763,Man on Fire,2004,146,7.7,47,77.91,"3,75,044","In Mexico City, a former CIA operative swears vengeance on those who committed an unspeakable act against the family he was hired to protect." +764,Kingsman: The Secret Service,2014,129,7.7,60,128.26,"6,92,994","A spy organisation recruits a promising street kid into the agency's training program, while a global threat emerges from a twisted tech genius." +765,Harry Potter and the Goblet of Fire,2005,157,7.7,81,290.01,"6,53,157","Harry Potter finds himself competing in a hazardous tournament between rival schools of magic, but he is distracted by recurring nightmares." +766,Zodiac,2007,157,7.7,79,33.08,"5,68,990","Between 1968 and 1983, a San Francisco cartoonist becomes an amateur detective obsessed with tracking down the Zodiac Killer, an unidentified individual who terrorizes Northern California with a killing spree." +767,(500) Days of Summer,2009,95,7.7,76,32.39,"5,35,849","After being dumped by the girl he believes to be his soulmate, hopeless romantic Tom Hansen reflects on their relationship to try and figure out where things went wrong and how he can win her back." +768,Wind River,2017,107,7.7,73,33.8,"2,65,418","A wildlife officer, who is haunted by a tragedy that happened because of him, teams up with an FBI agent in solving a murder of a young woman on a Wyoming Native American reservation and hopes to get redemption from his past regrets." +769,Ocean's Eleven,2001,116,7.7,74,183.42,"5,97,168",Danny Ocean and his ten accomplices plan to rob three Las Vegas casinos simultaneously. +770,The Hangover,2009,100,7.7,73,277.32,"8,15,484","Three buddies wake up from a bachelor party in Las Vegas, with no memory of the previous night and the bachelor missing. They make their way around the city in order to find their friend before his wedding." +771,Black Hawk Down,2001,144,7.7,74,108.64,"4,10,773","The story of 160 elite U.S. soldiers who dropped into Mogadishu in October 1993 to capture two top lieutenants of a renegade warlord, but found themselves in a desperate battle with a large force of heavily armed Somalis." +772,Druk,2020,117,7.7,79,,"1,78,084",Four high-school teachers consume alcohol on a daily basis to see how it affects their social and professional lives. +773,Airplane!,1980,88,7.7,78,83.4,"2,50,962","After the crew becomes sick with food poisoning, a neurotic ex-fighter pilot must land a commercial airplane full of passengers safely." +774,The Count of Monte Cristo,2002,131,7.7,61,54.23,"1,44,051","A young man, falsely imprisoned by his jealous ""friend"", escapes and uses a hidden treasure to exact his revenge." +775,Coraline,2009,100,7.7,80,75.29,"2,47,216","An adventurous 11-year-old girl finds another world that is a strangely idealized version of her frustrating home, but it has sinister secrets." +776,La vie d'Adèle,2013,180,7.7,90,2.2,"1,58,327","Adèle's life is changed when she meets Emma, a young woman with blue hair, who will allow her to discover desire and to assert herself as a woman and as an adult. In front of others, Adèle grows, seeks herself, loses herself, and ultimately finds herself through love and loss." +777,A Few Good Men,1992,138,7.7,62,141.34,"2,76,504",Military lawyer Lieutenant Daniel Kaffee defends Marines accused of murder. They contend they were acting under orders. +778,Home Alone,1990,103,7.7,63,285.76,"6,08,894","An eight-year-old troublemaker, mistakenly left home alone, must defend his home against a pair of burglars on Christmas eve." +779,Primal Fear,1996,129,7.7,47,56.12,"2,35,790","An altar boy is accused of murdering a priest, and the truth is buried several layers deep." +780,Star Trek Into Darkness,2013,132,7.7,72,228.78,"4,91,195","After the crew of the Enterprise find an unstoppable force of terror from within their own organization, Captain Kirk leads a manhunt to a war-zone world to capture a one-man weapon of mass destruction." +781,Harry Potter and the Deathly Hallows: Part 1,2010,146,7.7,65,295.98,"5,73,642","As Harry, Ron and Hermione race against time and evil to destroy the Horcruxes, they uncover the existence of the three most powerful objects in the wizarding world: the Deathly Hallows." +782,Ex Machina,2014,108,7.7,78,25.44,"5,66,200",A young programmer is selected to participate in a ground-breaking experiment in synthetic intelligence by evaluating the human qualities of a highly advanced humanoid A.I. +783,Deliverance,1972,109,7.7,80,7.06,"1,15,449","Intent on seeing the Cahulawassee River before it's dammed and turned into a lake, outdoor fanatic Lewis Medlock takes his friends on a canoeing trip they'll never forget into the dangerous American back-country." +784,The Game,1997,129,7.7,63,48.32,"4,12,820","After a wealthy San Francisco banker is given an opportunity to participate in a mysterious game, his life is turned upside down as he begins to question if it might really be a concealed conspiracy to destroy him." +785,Blue Velvet,1986,120,7.7,76,8.55,"2,07,984","The discovery of a severed human ear found in a field leads a young man on an investigation related to a beautiful, mysterious nightclub singer and a group of psychopathic criminals who have kidnapped her child." +786,Apollo 13,I 1995,140,7.7,77,173.84,"3,05,800",NASA must devise a strategy to return Apollo 13 to Earth safely after the spacecraft undergoes massive internal damage putting the lives of the three astronauts on board in jeopardy. +787,3:10 to Yuma,2007,122,7.7,76,53.61,"3,22,981",A small-time rancher agrees to hold a captured outlaw who's awaiting a train to go to court in Yuma. A battle of wills ensues as the outlaw tries to psych out the rancher. +788,Brokeback Mountain,2005,134,7.7,87,83.04,"3,70,434",Ennis and Jack are two shepherds who develop a sexual and emotional relationship. Their relationship becomes complicated when both of them get married to their respective girlfriends. +789,Training Day,2001,122,7.7,69,76.63,"4,53,998",A rookie cop spends his first day as a Los Angeles narcotics officer with a rogue detective who isn't what he appears to be. +790,Silver Linings Playbook,2012,122,7.7,81,132.09,"7,25,800","After a stint in a mental institution, former teacher Pat Solitano moves back in with his parents and tries to reconcile with his ex-wife. Things get more challenging when Pat meets Tiffany, a mysterious girl with problems of her own." +791,Mr. Nobody,2009,141,7.7,63,0,"2,40,388","A boy stands on a station platform as a train is about to leave. Should he go with his mother or stay with his father? Infinite possibilities arise from this decision. As long as he doesn't choose, anything is possible." +792,The Last of the Mohicans,1992,112,7.7,76,75.51,"1,79,305",Three trappers protect the daughters of a British Colonel in the midst of the French and Indian War. +793,Tangled,2010,100,7.7,71,200.82,"4,74,295","The magically long-haired Rapunzel has spent her entire life in a tower, but now that a runaway thief has stumbled upon her, she is about to discover the world for the first time, and who she really is." +794,Y tu mamá también,2001,106,7.7,89,13.62,"1,26,393","In Mexico, two teenage boys and an attractive older woman embark on a road trip and learn a thing or two about life, friendship, sex, and each other." +795,The Lego Movie,2014,100,7.7,83,257.76,"3,74,806","An ordinary LEGO construction worker, thought to be the prophesied as ""special"", is recruited to join a quest to stop an evil tyrant from gluing the LEGO universe into eternal stasis." +796,Lost in Translation,2003,102,7.7,91,44.59,"4,70,517",A faded movie star and a neglected young woman form an unlikely bond after crossing paths in Tokyo. +797,Blazing Saddles,1974,93,7.7,73,119.5,"1,46,288","In order to ruin a western town, a corrupt politician appoints a black Sheriff, who promptly becomes his most formidable adversary." +798,Halloween,1978,91,7.7,87,47,"2,90,938","Fifteen years after murdering his sister on Halloween night 1963, Michael Myers escapes from a mental hospital and returns to the small town of Haddonfield, Illinois to kill again." +799,Birdman or (The Unexpected Virtue of Ignorance),2014,119,7.7,87,42.34,"6,49,400","A washed-up superhero actor attempts to revive his fading career by writing, directing, and starring in a Broadway production." +800,The Boondock Saints,1999,108,7.7,44,0.03,"2,44,623",Two Irish Catholic brothers become vigilantes and wipe out Boston's criminal underworld in the name of God. +801,X: First Class,2011,131,7.7,65,146.41,"7,08,854","In the 1960s, superpowered humans Charles Xavier and Erik Lensherr work together to find others like them, but Erik's vengeful pursuit of an ambitious mutant who ruined his life causes a schism to divide them." +802,Gravity,2013,91,7.7,96,274.09,"8,44,759",Two astronauts work together to survive after an accident leaves them stranded in space. +803,First Blood,1982,93,7.7,61,47.21,"2,64,762",A veteran Green Beret is forced by a cruel Sheriff and his deputies to flee into the mountains and wage an escalating one-man war against his pursuers. +804,"O Brother, Where Art Thou?",2000,107,7.7,69,45.51,"3,22,083","In the deep south during the 1930s, three escaped convicts search for hidden treasure while a relentless lawman pursues them." +805,Donnie Brasco,1997,127,7.7,76,41.91,"3,19,607","An FBI undercover agent infiltrates the mob and finds himself identifying more with the mafia life, at the expense of his regular one." +806,Toy Story 4,2019,100,7.7,84,434.04,"2,66,236","When a new toy called ""Forky"" joins Woody and the gang, a road trip alongside old and new friends reveals how big the world can be for a toy." +807,The Bourne Supremacy,2004,108,7.7,73,176.24,"4,76,210","When Jason Bourne is framed for a CIA operation gone awry, he is forced to resume his former life as a trained assassin to survive." +808,Midnight in Paris,2011,94,7.7,81,56.82,"4,37,729","While on a trip to Paris with his fiancée's family, a nostalgic screenwriter finds himself mysteriously going back to the 1920s every day at midnight." +809,Argo,2012,120,7.7,86,136.03,"6,27,864","Acting under the cover of a Hollywood producer scouting a location for a science fiction film, a CIA agent launches a dangerous operation to rescue six Americans in Tehran during the U.S. hostage crisis in Iran in 1979." +810,What's Eating Gilbert Grape,1993,118,7.7,73,9.17,"2,45,980",A young man in a small Midwestern town struggles to care for his mentally-disabled younger brother and morbidly obese mother while attempting to pursue his own happiness. +811,Who Framed Roger Rabbit,1988,104,7.7,83,156.45,"2,09,862",A toon-hating detective is a cartoon rabbit's only hope to prove his innocence when he is accused of murder. +812,Crash,I 2004,112,7.7,66,54.58,"4,43,653","Los Angeles citizens with vastly separate lives collide in interweaving stories of race, loss and redemption." +813,Detachment,2011,98,7.7,52,0.07,"93,233",A substitute teacher who drifts from classroom to classroom finds a connection to the students and teachers during his latest assignment. +814,Wreck-It Ralph,2012,101,7.7,72,189.42,"4,41,831","A video game villain wants to be a hero and sets out to fulfill his dream, but his quest brings havoc to the whole arcade where he lives." +815,Sound of Metal,2019,120,7.7,82,,"1,39,693",A heavy metal drummer's life is turned upside down when he begins to lose his hearing and he must confront a future filled with silence. +816,Road to Perdition,2002,117,7.7,72,104.45,"2,77,525","A mob enforcer's son in 1930s Illinois witnesses a murder, forcing him and his father to take to the road, and his father down a path of redemption and revenge." +817,Sense and Sensibility,1995,136,7.7,84,43.18,"1,21,065","Rich Mr. Dashwood dies, leaving his second wife and her three daughters poor by the rules of inheritance. The two eldest daughters are the title opposites." +818,The Color Purple,1985,154,7.7,78,98.47,"91,590",A black Southern woman struggles to find her identity after suffering abuse from her father and others over four decades. +819,The Fault in Our Stars,2014,126,7.7,69,124.87,"3,90,241",Two teenage cancer patients begin a life-affirming journey to visit a reclusive author in Amsterdam. +820,The Theory of Everything,2014,123,7.7,71,35.89,"4,67,384",A look at the relationship between the famous physicist Stephen Hawking and his wife. +821,As Good as It Gets,1997,139,7.7,67,148.48,"3,09,535","A single mother and waitress, a misanthropic author, and a gay artist form an unlikely friendship after the artist is assaulted in a robbery." +822,The Boy in the Striped Pajamas,2008,94,7.7,55,9.03,"2,32,837","Through the innocent eyes of Bruno, the eight-year-old son of the commandant at a German concentration camp, a forbidden friendship with a Jewish boy on the other side of the camp fence has startling and unexpected consequences." +823,En man som heter Ove,2015,116,7.7,70,3.48,"64,544","Ove, an ill-tempered, isolated retiree who spends his days enforcing block association rules and visiting his wife's grave, has finally given up on life just as an unlikely friendship develops with his boisterous new neighbors." +824,Philadelphia,1993,125,7.7,66,77.32,"2,50,665","When a man with HIV is fired by his law firm because of his condition, he hires a homophobic small time lawyer as the only willing advocate for a wrongful dismissal suit." +825,Naked,1993,131,7.7,85,1.77,"41,947",An unemployed Mancunian vents his rage on unsuspecting strangers as he embarks on a nocturnal London odyssey. +826,The Trial of the Chicago 7,2020,129,7.7,76,,"1,86,208","The story of 7 people on trial stemming from various charges surrounding the uprising at the 1968 Democratic National Convention in Chicago, Illinois." +827,Lucky Number Slevin,2006,110,7.7,53,22.49,"3,20,308","A case of mistaken identity lands Slevin into the middle of a war being plotted by two of the city's most rival crime bosses. Under constant surveillance by Detective Brikowski and assassin Goodkat, he must get them before they get him." +828,Empire of the Sun,1987,153,7.7,62,22.24,"1,30,914",A young English boy struggles to survive under Japanese occupation of China during World War II. +829,Fried Green Tomatoes,1991,130,7.7,64,82.42,"78,729",A housewife who is unhappy with her life befriends an old lady at a nursing home and is enthralled by the tales she tells of people she used to know. +830,Serpico,1973,130,7.7,81,29.8,"1,29,465",An honest New York cop named Frank Serpico blows the whistle on rampant corruption in the force only to have his comrades turn against him. +831,Dirty Harry,1971,102,7.7,87,35.9,"1,63,225","When a man calling himself ""the Scorpio Killer"" menaces San Francisco, tough-as-nails Police Inspector ""Dirty"" Harry Callahan is assigned to track down the crazed psychopath." +832,When Harry Met Sally...,1989,95,7.7,76,92.82,"2,30,346","Harry and Sally have known each other for years, and are very good friends, but they fear sex would ruin the friendship." +833,Flipped,I 2010,90,7.7,45,1.75,"94,218",Two eighth-graders start to have feelings for each other despite being total opposites. +834,Glengarry Glen Ross,1992,100,7.7,82,10.73,"1,11,613",An examination of the machinations behind the scenes at a real estate office. +835,The Magnificent Seven,1960,128,7.7,74,4.91,"99,132",Seven gunfighters are hired by Mexican peasants to liberate their village from oppressive bandits. +836,The French Connection,1971,104,7.7,94,15.63,"1,29,016","A pair of NYPD detectives in the Narcotics Bureau stumble onto a heroin smuggling ring based in Marseilles, but stopping them and capturing their leaders proves an elusive goal." +837,Being John Malkovich,1999,113,7.7,90,22.86,"3,45,794",A puppeteer discovers a portal that leads literally into the head of movie star John Malkovich. +838,Ray,I 2004,152,7.7,73,75.33,"1,53,269","The story of the life and career of the legendary rhythm and blues musician Ray Charles, from his humble beginnings in the South, where he went blind at age seven, to his meteoric rise to stardom during the 1950s and 1960s." +839,This Is England,2006,101,7.7,86,0.33,"1,25,743","A young boy becomes friends with a gang of skinheads. Friends soon become like family, and relationships will be pushed to the very limit." +840,Goldfinger,1964,110,7.7,87,51.08,"1,96,952","While investigating a gold magnate's smuggling, James Bond uncovers a plot to contaminate the Fort Knox gold reserve." +841,Ordinary People,1980,124,7.7,86,54.8,"54,849","The accidental death of the older son of an affluent family deeply strains the relationships among the bitter mother, the good-natured father and the guilt-ridden younger son." +842,Der Name der Rose,1986,130,7.7,54,7.15,"1,12,550",An intellectually nonconformist friar investigates a series of mysterious deaths in an isolated abbey. +843,Evil Dead II,1987,84,7.7,72,5.92,"1,75,393",The lone survivor of an onslaught of flesh-possessing spirits holes up in a cabin with a group of strangers while the demons continue their attack. +844,Adaptation.,2002,115,7.7,83,22.25,"1,98,206",A lovelorn screenwriter becomes desperate as he tries and fails to adapt 'The Orchid Thief' by Susan Orlean for the screen. +845,Kung fu,2004,99,7.7,78,17.11,"1,45,849","In Shanghai, China in the 1940s, a wannabe gangster aspires to join the notorious ""Axe Gang"" while residents of a housing complex exhibit extraordinary powers in defending their turf." +846,Happiness,1998,134,7.7,81,2.81,"72,600","The lives of several individuals intertwine as they go about their lives in their own unique ways, engaging in acts which society as a whole might find disturbing in a desperate search for human connection." +847,Clerks,1994,92,7.7,70,3.15,"2,28,218","A day in the lives of two convenience clerks named Dante and Randal as they annoy customers, discuss movies, and play hockey on the store roof." +848,Star Trek II: The Wrath of Khan,1982,113,7.7,68,78.91,"1,26,179","With the assistance of the Enterprise crew, Admiral Kirk must stop an old nemesis, Khan Noonien Singh, from using the life-generating Genesis Device as the ultimate weapon." +849,Malcolm X,1992,202,7.7,73,48.17,"98,857","Biographical epic of the controversial and influential Black Nationalist leader, from his early life and career as a small-time gangster, to his ministry as a member of the Nation of Islam and his eventual assassination." +850,Wait Until Dark,1967,108,7.7,81,17.55,"32,432",A recently blinded woman is terrorized by a trio of thugs while they search for a heroin-stuffed doll they believe is in her apartment. +851,The Verdict,1982,129,7.7,77,54,"43,615","An outcast, alcoholic Boston lawyer sees the chance to salvage his career and self-respect by taking a medical malpractice case to trial rather than settling." +852,Roma,2018,135,7.7,96,,"1,64,825",A year in the life of a middle-class family's maid in Mexico City in the early 1970s. +853,Kaze tachinu,2013,126,7.7,83,5.21,"91,714","A look at the life of Jiro Horikoshi, the man who designed Japanese fighter planes during World War II." +854,Billy Elliot,2000,110,7.7,74,22,"1,39,205",A talented young boy becomes torn between his unexpected love of dance and the disintegration of his family. +855,Paprika,2006,90,7.7,81,0.88,"89,070","When a machine that allows therapists to enter their patients' dreams is stolen, all hell breaks loose. Only a young female therapist, Paprika, can stop it." +856,Bonnie and Clyde,1967,111,7.7,86,,"1,17,290","Bored waitress Bonnie Parker falls in love with an ex-con named Clyde Barrow and together they start a violent crime spree through the country, stealing cars and robbing banks." +857,Lola rennt,1998,81,7.7,77,7.27,"2,03,153","After a botched money delivery, Lola has 20 minutes to come up with 100,000 Deutschmarks." +858,La grande bellezza,2013,141,7.7,86,2.85,"94,723","Jep Gambardella has seduced his way through the lavish nightlife of Rome for decades, but after his 65th birthday and a shock from the past, Jep looks past the nightclubs and parties to find a timeless landscape of absurd, exquisite beauty." +859,Miller's Crossing,1990,115,7.7,66,5.08,"1,38,907","Tom Reagan, an advisor to a Prohibition-era crime boss, tries to keep the peace between warring mobs but gets caught in divided loyalties." +860,The Longest Day,1962,178,7.7,75,39.1,"57,799","The events of D-Day, told on a grand scale from both the Allied and German points of view." +861,The Dirty Dozen,1967,150,7.7,73,45.3,"76,680","During World War II, a rebellious U.S. Army Major is assigned a dozen convicted murderers to train and lead them into a mass assassination mission of German officers." +862,Belle de jour,1967,100,7.7,,0.03,"46,947",A frigid young housewife decides to spend her midweek afternoons as a prostitute. +863,"South Park: Bigger, Longer & Uncut",1999,81,7.7,73,52.04,"2,10,750","When Stan Marsh and his friends go see an R-rated movie, they start cursing and their parents think that Canada is to blame." +864,Badlands,1973,94,7.7,93,,"75,961","An impressionable teenage girl from a dead-end town, and her older greaser boyfriend, embark on a killing spree in the South Dakota Badlands." +865,Finding Neverland,2004,106,7.7,67,51.68,"2,09,543",The story of Sir J.M. Barrie's friendship with a family who inspired him to create Peter Pan. +866,La planète sauvage,1973,72,7.7,73,0.19,"34,683","On a faraway planet where blue giants rule, oppressed humanoids rebel against their machine-like leaders." +867,Madeo,2009,129,7.7,79,0.55,"68,369",A mother desperately searches for the killer who framed her son for a girl's horrific murder. +868,Kubo and the Two Strings,2016,101,7.7,84,48.02,"1,34,870",A young boy named Kubo must locate a magical suit of armour worn by his late father in order to defeat a vengeful spirit from the past. +869,Zulu,1964,138,7.7,77,,"41,266",Outnumbered British soldiers do battle with Zulu warriors at Rorke's Drift. +870,The Quiet Man,1952,129,7.7,85,10.55,"40,776","A retired American boxer returns to the village of his birth in 1920s Ireland, where he falls for a spirited redhead whose brother is contemptuous of their union." +871,The Last Emperor,1987,163,7.7,76,43.98,"1,07,977","Bernardo Bertolucci's Oscar-winning dramatisation of the life story of China's last emperor, Pu Yi." +872,Beasts of No Nation,2015,137,7.7,79,0.08,"84,576","A drama based on the experiences of Agu, a child soldier fighting in the civil war of an unnamed African country." +873,Me and Earl and the Dying Girl,2015,105,7.7,74,6.74,"1,35,367","High schooler Greg, who spends most of his time making parodies of classic movies with his co-worker Earl, finds his outlook forever altered after befriending a classmate who has just been diagnosed with cancer." +874,Fantasia,1940,124,7.7,96,76.41,"1,00,586","A collection of animated interpretations of great works of Western classical music, ranging from the abstract to depictions of mythology and fantasy, and settings including the prehistoric, supernatural and sacred." +875,Abre los ojos,1997,119,7.7,,0.37,"71,612","A very handsome man finds the love of his life, but he suffers an accident and needs to have his face rebuilt by surgery after it is severely disfigured." +876,Hamlet,1996,242,7.7,,4.41,"39,146","Hamlet, Prince of Denmark, returns home to find his father murdered and his mother remarrying the murderer, his uncle. Meanwhile, war is brewing." +877,Short Cuts,1993,188,7.7,81,6.11,"46,382",The day-to-day lives of several suburban Los Angeles residents. +878,Zwartboek,2006,145,7.7,71,4.4,"78,822","In the Nazi-occupied Netherlands during World War II, a Jewish singer infiltrates the regional Gestapo headquarters for the Dutch resistance." +879,The African Queen,1951,105,7.7,91,0.54,"81,934","In WWI East Africa, a gin-swilling Canadian riverboat captain is persuaded by a strait-laced English missionary to undertake a trip up a treacherous river and use his boat to attack a German gunship." +880,Perfetti sconosciuti,2016,96,7.7,,,"68,671","Seven long-time friends meet for dinner. They decide to share their text messages, emails and phone calls. Secrets are unveiled. Harmony trembles." +881,Kurenai no buta,1992,94,7.7,83,,"95,476","In 1930s Italy, a veteran World War I pilot is cursed to look like an anthropomorphic pig." +882,À bout de souffle,1960,90,7.7,,0.34,"85,255","A small-time thief steals a car and impulsively murders a motorcycle policeman. Wanted by the authorities, he reunites with a hip American journalism student and attempts to persuade her to run away with him to Italy." +883,Ajeossi,2010,119,7.7,,0.01,"72,490",A quiet pawnshop keeper with a violent past takes on a drug-and-organ trafficking ring in hope of saving the child who is his only friend. +884,Spoorloos,1988,107,7.7,,,"41,351","Rex and Saskia, a young couple in love, are on vacation. They stop at a busy service station and Saskia is abducted. After three years and no sign of Saskia, Rex begins receiving letters from the abductor." +885,Down by Law,1986,107,7.7,75,1.44,"53,716","Two men are framed and sent to jail, where they meet a murderer who helps them escape and leave the state." +886,Nebraska,2013,115,7.7,86,17.65,"1,20,726","An aging, booze-addled father makes the trip from Montana to Nebraska with his estranged son in order to claim a million-dollar Mega Sweepstakes Marketing prize." +887,The Magdalene Sisters,2002,114,7.7,83,4.89,"27,922",Three young Irish women struggle to maintain their spirits while they endure dehumanizing abuse as inmates of a Magdalene Sisters Asylum. +888,Invasion of the Body Snatchers,1956,80,7.7,92,,"52,615",A small-town doctor learns that the population of his community is being replaced by emotionless alien duplicates. +889,Jules et Jim,1962,105,7.7,97,,"43,269",Decades of a love triangle concerning two friends and an impulsive woman. +890,Waking Life,2001,99,7.7,84,2.89,"65,826",A man shuffles through a dream meeting various people and discussing the meanings and purposes of the universe. +891,Omoide no Marnie,2014,103,7.7,72,0.77,"44,442","Due to 12 y.o. Anna's asthma, she's sent to stay with relatives of her guardian in the Japanese countryside. She likes to be alone, sketching. She befriends Marnie. Who is the mysterious, blonde Marnie." +892,Lat sau san taam,1992,128,7.7,,,"52,501",A tough-as-nails cop teams up with an undercover agent to shut down a sinister mobster and his crew. +893,Hedwig and the Angry Inch,2001,95,7.7,85,3.03,"36,707",A gender-queer punk-rock singer from East Berlin tours the U.S. with her band as she tells her life story and follows the former lover/band-mate who stole her songs. +894,Cape Fear,1962,106,7.7,76,,"30,574",A lawyer's family is stalked by a man he once helped put in jail. +895,La double vie de Véronique,1991,98,7.7,86,2,"51,202","Two parallel stories about two identical women; one living in Poland, the other in France. They don't know each other, but their lives are nevertheless profoundly connected." +896,Night on Earth,1991,129,7.7,68,2.02,"63,486",An anthology of 5 different cab drivers in 5 American and European cities and their remarkable fares on the same eventful night. +897,Chun gwong cha sit,1997,96,7.7,70,0.19,"32,154",A couple take a trip to Argentina but both men find their lives drifting apart in opposite directions. +898,Good Bye Lenin!,2003,121,7.7,68,4.06,"1,49,887","In 1990, to protect his fragile mother from a fatal shock after a long coma, a young man must keep her from learning that her beloved nation of East Germany as she knew it has disappeared." +899,A Man for All Seasons,1966,120,7.7,72,28.35,"36,131","The story of Sir Thomas More, who stood up to King Henry VIII when the King rejected the Roman Catholic Church to obtain a divorce and remarry." +900,Black Narcissus,1947,101,7.7,86,,"26,424","A group of nuns struggle to establish a convent in the Himalayas, while isolation, extreme weather, altitude, and culture clashes all conspire to drive the well-intentioned missionaries mad." +901,The Broken Circle Breakdown,2012,111,7.7,70,0.18,"42,208","Elise and Didier fall in love at first sight, in spite of their differences. He talks, she listens. He's a romantic atheist, she's a religious realist. When their daughter becomes seriously ill, their love is put on trial." +902,Frost/Nixon,2008,122,7.7,80,18.59,"1,10,599",A dramatic retelling of the post-Watergate television interviews between British talk-show host David Frost and former president Richard Nixon. +903,Forushande,2016,124,7.7,85,2.4,"62,893","While Ranaa and Emad, a married couple, are participating in a production of ""Death of a Salesman,"" she is assaulted in their new home, which leaves him determined to find the perpetrator over his wife's traumatized objections." +904,Le Petit Prince,2015,108,7.7,70,1.34,"64,710","A little girl lives in a very grown-up world with her mother, who tries to prepare her for it. Her neighbor, the Aviator, introduces the girl to an extraordinary world where anything is possible, the world of the Little Prince." +905,Scarface,1932,93,7.7,90,,"29,289","An ambitious and nearly insane violent gangster climbs the ladder of success in the mob, but his weaknesses prove to be his downfall." +906,The Caine Mutiny,1954,124,7.7,63,21.75,"28,896","When a U.S. Naval captain shows signs of mental instability that jeopardises the ship, the first officer is urged to consider relieving him of command." +907,Les triplettes de Belleville,2003,80,7.7,91,7,"56,004","When her grandson is kidnapped during the Tour de France, Madame Souza and her beloved pooch Bruno team up with the Belleville Sisters--an aged song-and-dance team from the days of Fred Astaire--to rescue him." +908,Ondskan,2003,113,7.7,61,0.02,"40,377","A teenage boy expelled from school for fighting arrives at a boarding school where the systematic bullying of younger students is encouraged as a means to maintain discipline, and decides to fight back." +909,Key Largo,1948,100,7.7,,,"42,823","A man visits his war buddy's family hotel and finds a gangster running things. As a hurricane approaches, the two end up confronting each other." +910,Diarios de motocicleta,2004,126,7.7,75,16.78,"1,03,329",The dramatization of a motorcycle road trip Che Guevara went on in his youth that showed him his life's calling. +911,Adams æbler,2005,94,7.7,51,0,"52,776",A neo-Nazi sentenced to community service at a church clashes with the blindly devoted minister. +912,Yume,1990,119,7.7,,1.96,"27,829",A collection of tales based upon eight of director Akira Kurosawa's recurring dreams. +913,Kokuhaku,2010,106,7.7,,,"40,817",A psychological thriller of a grieving mother turned cold-blooded avenger with a twisty master plan to pay back those who were responsible for her daughter's death. +914,The Lady Vanishes,1938,96,7.7,98,,"55,167","While travelling in continental Europe, a rich young playgirl realizes that an elderly lady seems to have disappeared from the train." +915,In America,2002,105,7.7,76,15.54,"43,824",A family of Irish immigrants adjust to life on the mean streets of Hell's Kitchen while also grieving the death of a child. +916,Gongdong gyeongbi guyeok JSA,2000,110,7.7,58,,"32,604","After a shooting incident at the North/South Korean border/DMZ leaves 2 North Korean soldiers dead, a neutral Swiss/Swedish team investigates, what actually happened." +917,Marty,1955,90,7.7,82,,"25,813",A middle-aged butcher and a school teacher who have given up on the idea of love meet at a dance and fall for each other. +918,Ta'm e guilass,1997,95,7.7,80,0.31,"33,855",An Iranian man drives his car in search of someone who will quietly bury him under a cherry tree after he commits suicide. +919,Toki o kakeru shôjo,2006,98,7.7,66,,"68,847","A high-school girl named Makoto acquires the power to travel back in time, and decides to use it for her own personal benefits. Little does she know that she is affecting the lives of others just as much as she is her own." +920,Das Experiment,2001,120,7.7,60,0.14,"95,170","For two weeks, 20 male participants are hired to play prisoners and guards in a prison. The ""prisoners"" have to follow seemingly mild rules, and the ""guards"" are told to retain order without using physical violence." +921,Efter brylluppet,2006,120,7.7,78,0.41,"35,931","A manager of an orphanage in India is sent to Copenhagen, Denmark, where he discovers a life-altering family secret." +922,The Purple Rose of Cairo,1985,82,7.7,75,10.63,"53,373","In 1935 New Jersey, a movie character walks off the screen and into the real world." +923,Hana-bi,1997,103,7.7,83,0.23,"31,963","Nishi leaves the police in the face of harrowing personal and professional difficulties. Spiraling into depression, he makes questionable decisions." +924,The Muppet Christmas Carol,1992,85,7.7,64,27.28,"64,507",The Muppets present their own touching rendition of Charles Dickens' classic tale. +925,Love and Death,1975,85,7.7,89,,"39,838","In czarist Russia, a neurotic soldier and his distant cousin formulate a plot to assassinate Napoleon." +926,The Breadwinner,2017,94,7.7,78,0.31,"27,280","In 2001, Afghanistan is under the control of the Taliban. When her father is captured, a determined young girl disguises herself as a boy in order to provide for her family." +927,Le passé,2013,130,7.7,85,1.33,"50,016","An Iranian man deserts his French wife and her two children to return to his homeland. Meanwhile, his wife starts up a new relationship, a reality her husband confronts upon his wife's request for a divorce." +928,Zelig,1983,79,7.7,,11.8,"43,232","""Documentary"" about a man who can look and act like whoever he's around, and meets various famous people." +929,Auf der anderen Seite,2007,122,7.7,85,0.74,"32,878",A Turkish man travels to Istanbul to find the daughter of his father's former girlfriend. +930,Once Upon a Time in... Hollywood,2019,161,7.6,83,142.5,"7,90,687",A faded television actor and his stunt double strive to achieve fame and success in the final years of Hollywood's Golden Age in 1969 Los Angeles. +931,Harry Potter and the Sorcerer's Stone,2001,152,7.6,65,317.58,"8,17,964","An orphaned boy enrolls in a school of wizardry, where he learns the truth about himself, his family and the terrible evil that haunts the magical world." +932,American Psycho,2000,102,7.6,64,15.07,"6,67,815","A wealthy New York City investment banking executive, Patrick Bateman, hides his alternate psychopathic ego from his co-workers and friends as he delves deeper into his violent, hedonistic fantasies." +933,Watchmen,2009,162,7.6,56,107.51,"5,66,739","In 1985 where former superheroes exist, the murder of a colleague sends active vigilante Rorschach into his own sprawling investigation, uncovering something that could completely change the course of history as we know it." +934,Superbad,2007,113,7.6,76,121.46,"6,06,639",Two co-dependent high school seniors are forced to deal with separation anxiety after their plan to stage a booze-soaked party goes awry. +935,Saw,2004,103,7.6,46,56,"4,39,407","Two strangers awaken in a room with no recollection of how they got there, and soon discover they're pawns in a deadly game perpetrated by a notorious serial killer." +936,Guardians of the Galaxy Vol. 2,2017,136,7.6,67,389.81,"7,27,247","The Guardians struggle to keep together as a team while dealing with their personal family issues, notably Star-Lord's encounter with his father, the ambitious celestial being Ego." +937,300,2006,117,7.6,52,210.61,"8,42,283",King Leonidas of Sparta and a force of 300 men fight the Persians at Thermopylae in 480 B.C. +938,Deadpool 2,2018,119,7.6,66,324.59,"6,13,542","Foul-mouthed mutant mercenary Wade Wilson (a.k.a. Deadpool) assembles a team of fellow mutant rogues to protect a young boy with supernatural abilities from the brutal, time-traveling cyborg Cable." +939,RoboCop,1987,102,7.6,70,53.42,"2,70,139","In a dystopic and crime-ridden Detroit, a terminally wounded cop returns to the force as a powerful cyborg haunted by submerged memories." +940,The Fifth Element,1997,126,7.6,52,63.54,"4,89,404","In the colorful future, a cab driver unwittingly becomes the central figure in the search for a legendary cosmic weapon to keep Evil and Mr. Zorg at bay." +941,The Butterfly Effect,2004,113,7.6,30,57.94,"5,05,912","Evan Treborn suffers blackouts during significant events of his life. As he grows up, he finds a way to remember these lost memories and a supernatural way to alter his life by reading his journal." +942,Kick-Ass,2010,117,7.6,66,48.07,"5,79,158","Dave Lizewski is an unnoticed high school student and comic book fan who one day decides to become a superhero, even though he has no powers, training or meaningful reason to do so." +943,A Star Is Born,2018,136,7.6,88,215.29,"4,02,218",A musician helps a young singer find fame as age and alcoholism send his own career into a downward spiral. +944,The Thin Red Line,1998,170,7.6,78,36.4,"1,94,239","Adaptation of James Jones' autobiographical 1962 novel, focusing on the conflict at Guadalcanal during the second World War." +945,Office Space,1999,89,7.6,68,10.82,"2,78,187",Three company workers who hate their jobs decide to rebel against their greedy boss. +946,Moneyball,2011,133,7.6,87,75.61,"4,44,941",Oakland A's general manager Billy Beane's successful attempt to assemble a baseball team on a lean budget by employing computer-generated analysis to acquire new players. +947,What We Do in the Shadows,2014,86,7.6,76,3.33,"1,91,888","Viago, Deacon, and Vladislav are vampires who are struggling with the mundane aspects of modern life, like paying rent, keeping up with the chore wheel, trying to get into nightclubs, and overcoming flatmate conflicts." +948,Stardust,2007,127,7.6,66,38.63,"2,78,725","In a countryside town bordering on a magical land, a young man makes a promise to his beloved that he'll retrieve a fallen star by venturing into the magical realm." +949,Minority Report,2002,145,7.6,80,132.07,"5,69,049","In a future where a special police unit is able to arrest murderers before they commit their crimes, an officer from that unit is himself accused of a future murder." +950,Hell or High Water,II 2016,102,7.6,88,26.86,"2,41,177","Toby is a divorced father who's trying to make a better life. His brother is an ex-con with a short temper and a loose trigger finger. Together, they plan a series of heists against the bank that's about to foreclose on their family ranch." +951,Kung Fu Panda,2008,92,7.6,74,215.43,"4,94,504","To everyone's surprise, including his own, Po, an overweight, clumsy panda, is chosen as protector of the Valley of Peace. His suitability will soon be tested as the valley's arch-enemy is on his way." +952,My Cousin Vinny,1992,120,7.6,68,52.93,"1,34,043","Two New Yorkers accused of murder in rural Alabama while on their way back to college call in the help of one of their cousins, a loudmouth lawyer with no trial experience." +953,Star Wars: Episode III - Revenge of the Sith,2005,140,7.6,68,380.26,"8,18,468","Three years into the Clone Wars, Obi-Wan pursues a new threat, while Anakin is lured by Chancellor Palpatine into a sinister plot to rule the galaxy." +954,The Blind Side,2009,129,7.6,53,255.96,"3,48,096","The story of Michael Oher, a homeless and traumatized boy who became an All-American football player and first-round NFL draft pick with the help of a caring woman and her family." +955,The Others,2001,101,7.6,74,96.52,"3,79,382","During World War II, a woman who lives with her two photosensitive children on her darkened old family estate in the Channel Islands becomes convinced that the home is haunted." +956,Enter the Dragon,1973,102,7.6,83,25,"1,08,868",A Shaolin martial artist travels to an island fortress to spy on an opium lord - who is also a former monk from his temple - under the guise of attending a fighting tournament. +957,The Royal Tenenbaums,2001,110,7.6,76,52.36,"3,04,538",The eccentric members of a dysfunctional family reluctantly gather under the same roof for various reasons. +958,The Machinist,2004,101,7.6,61,1.08,"4,04,123",An industrial worker who hasn't slept in a year begins to doubt his own sanity. +959,Lethal Weapon,1987,109,7.6,68,65.21,"2,68,356",Two newly paired cops who are complete opposites must put aside their differences in order to catch a gang of drug smugglers. +960,True Grit,2010,110,7.6,80,171.24,"3,49,326",A stubborn teenager enlists the help of a tough U.S. Marshal to track down her father's murderer. +961,Mulan,1998,87,7.6,71,120.62,"3,02,791","To save her father from death in the army, a young maiden secretly goes in his place and becomes one of China's greatest heroines in the process." +962,The Mitchells vs the Machines,2021,114,7.6,81,,"1,18,830","A quirky, dysfunctional family's road trip is upended when they find themselves in the middle of the robot apocalypse and suddenly become humanity's unlikeliest last hope." +963,After Hours,I 1985,97,7.6,90,10.6,"75,226",An ordinary word processor has the worst night of his life after he agrees to visit a girl in Soho he met that evening at a coffee shop. +964,The Fly,1986,96,7.6,79,40.46,"1,92,980",A brilliant but eccentric scientist begins to transform into a giant man/fly hybrid after one of his experiments goes horribly wrong. +965,Gone Baby Gone,2007,114,7.6,72,20.3,"2,81,830","Two Boston area detectives investigate a little girl's kidnapping, which ultimately turns into a crisis both professionally and personally." +966,Die Hard with a Vengeance,1995,128,7.6,58,100.01,"3,98,139","John McClane and a Harlem store owner are targeted by German terrorist Simon in New York City, where he plans to rob the Federal Reserve Building." +967,Mad Max 2,1981,96,7.6,77,12.47,"1,87,560","In the post-apocalyptic Australian wasteland, a cynical drifter agrees to help a small, gasoline-rich community escape a horde of bandits." +968,Searching,III 2018,102,7.6,71,26.02,"1,74,340","After his teenage daughter goes missing, a desperate father tries to find clues on her laptop." +969,Despicable Me,2010,95,7.6,72,251.51,"5,65,343","When a criminal mastermind uses a trio of orphan girls as pawns for a grand scheme, he finds their love is profoundly changing him for the better." +970,Snow White and the Seven Dwarfs,1937,83,7.6,96,184.93,"2,07,946","Exiled into the dangerous forest by her wicked stepmother, a princess is rescued by seven dwarf miners who make her part of their household." +971,Eastern Promises,2007,100,7.6,83,17.11,"2,52,676",A teenager who dies during childbirth leaves clues in her journal that could tie her child to a rape involving a violent Russian mob family. +972,Rushmore,1998,93,7.6,86,17.11,"1,92,763","A teenager at Rushmore Academy falls for a much older teacher and befriends a middle-aged industrialist. Later, he finds out that his love interest and his friend are having an affair, which prompts him to begin a vendetta." +973,La piel que habito,2011,120,7.6,70,3.19,"1,59,821","A brilliant plastic surgeon, haunted by past tragedies, creates a type of synthetic skin that withstands any kind of damage. His guinea pig: a mysterious and volatile woman who holds the key to his obsession." +974,Serbuan maut,2011,101,7.6,73,4.11,"2,11,624",A S.W.A.T. team becomes trapped in a tenement run by a ruthless mobster and his army of killers and thugs. +975,End of Watch,2012,109,7.6,68,41,"2,58,654","Shot documentary-style, this film follows the daily grind of two young police officers in LA who are partners and friends and what happens when they meet criminal forces greater than themselves." +976,Dawn of the Planet of the Apes,2014,130,7.6,79,208.55,"4,54,534",The fragile peace between apes and humans is threatened as mistrust and betrayal threaten to plunge both tribes into a war for dominance over the Earth. +977,The Jungle Book,1967,78,7.6,65,141.84,"1,91,875",Bagheera the Panther and Baloo the Bear have a difficult time trying to convince a boy to leave the jungle for human civilization. +978,Match Point,2005,124,7.6,72,23.09,"2,23,380","At a turning point in his life, a former tennis pro falls for an actress who happens to be dating his friend and soon-to-be brother-in-law." +979,Dark Waters,2019,126,7.6,73,,"94,877",A corporate defense attorney takes on an environmental lawsuit against a chemical company that exposes a lengthy history of pollution. +980,Gake no ue no Ponyo,2008,101,7.6,86,15.09,"1,53,091","A five-year-old boy develops a relationship with Ponyo, a young goldfish princess who longs to become a human after falling in love with him." +981,The Birds,1963,119,7.6,90,11.4,"1,97,671",A wealthy San Francisco socialite pursues a potential boyfriend to a small Northern California town that slowly takes a turn for the bizarre when birds of all kinds suddenly begin to attack people. +982,From Here to Eternity,1953,118,7.6,85,30.5,"49,249","At a U.S. Army base in 1941 Hawaii, a private is cruelly punished for not boxing on his unit's team, while his commanding officer's wife and top aide begin a tentative affair." +983,Rebel Without a Cause,1955,111,7.6,89,,"94,956","A rebellious young man with a troubled past comes to a new town, finding friends and enemies." +984,I Am Sam,2001,132,7.6,28,40.31,"1,52,927",A mentally handicapped man fights for custody of his 7-year-old daughter and in the process teaches his cold-hearted lawyer the value of love and family. +985,The Bridges of Madison County,1995,135,7.6,69,71.52,"85,715",Photographer Robert Kincaid wanders into the life of housewife Francesca Johnson for four days in the 1960s. +986,The Last King of Scotland,2006,123,7.6,74,17.61,"1,91,771",Based on the events of the brutal Ugandan dictator Idi Amin's regime as seen by his personal physician during the 1970s. +987,Sabrina,1954,113,7.6,72,,"68,022","A playboy becomes interested in the daughter of his family's chauffeur, but it's his more serious brother who would be the better man for her." +988,25th Hour,2002,135,7.6,69,13.06,"1,82,220","Cornered by the DEA, convicted New York drug dealer Montgomery Brogan reevaluates his life in the 24 remaining hours before facing a seven-year jail term." +989,United 93,2006,111,7.6,90,31.57,"1,08,852","A real-time account of the events on United Flight 93, one of the planes hijacked on September 11th, 2001 that crashed near Shanksville, Pennsylvania when passengers foiled the terrorist plot." +990,50/50,2011,100,7.6,72,35.01,"3,37,291","Inspired by a true story, a comedy centered on a 27-year-old guy who learns of his cancer diagnosis and his subsequent struggle to beat the disease." +991,Barton Fink,1991,116,7.6,69,6.15,"1,26,032",A renowned New York playwright is enticed to California to write for the movies and discovers the hellish truth of Hollywood. +992,21 Grams,2003,124,7.6,70,16.29,"2,41,941","A freak accident brings together a critically ill mathematician, a grieving mother, and a born-again ex-con." +993,The Taking of Pelham One Two Three,1974,104,7.6,68,2.49,"33,299","Four armed men hijack a New York City subway car and demand a ransom for the passengers. The city's police are faced with a conundrum: Even if it's paid, how could they get away?" +994,Control,2007,122,7.6,78,0.87,"67,244","A profile of Ian Curtis, the enigmatic singer of Joy Division whose personal, professional, and romantic troubles led him to commit suicide at the age of 23." +995,Philomena,2013,98,7.6,77,37.71,"1,02,336","A world-weary political journalist picks up the story of a woman's search for her son, who was taken away from her decades ago after she became pregnant and was forced to live in a convent." +996,Un long dimanche de fiançailles,2004,133,7.6,76,6.17,"75,004","Tells the story of a young woman's relentless search for her fiancé, who has disappeared from the trenches of the Somme during World War One." +997,Shine,1996,105,7.6,87,35.81,"55,589","Pianist David Helfgott, driven by his father and teachers, has a breakdown. Years later he returns to the piano, to popular if not critical acclaim." +998,The Invisible Man,1933,71,7.6,87,,"37,822","A scientist finds a way of becoming invisible, but in doing so, he becomes murderously insane." +999,Celda 211,2009,113,7.6,,,"69,464","The story of two men on different sides of a prison riot -- the inmate leading the rebellion and the young guard trapped in the revolt, who poses as a prisoner in a desperate attempt to survive the ordeal." diff --git a/examples/tutorial/scripts/.gitignore b/examples/tutorial/scripts/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/examples/tutorial/scripts/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/examples/tutorial/scripts/Makefile b/examples/tutorial/scripts/Makefile new file mode 100644 index 0000000..f52870e --- /dev/null +++ b/examples/tutorial/scripts/Makefile @@ -0,0 +1,12 @@ +default: types/movie.ts + +types/movie.ts: schema/movie.json + pnpm quicktype -s schema $< -o $@ + +schema/movie.json: + cargo run -- --data-dir=../traildepot schema movies --mode insert > $@ + +clean: + rm -f types/* schema/* + +.PHONY: clean diff --git a/examples/tutorial/scripts/eslint.config.mjs b/examples/tutorial/scripts/eslint.config.mjs new file mode 100644 index 0000000..640b6d5 --- /dev/null +++ b/examples/tutorial/scripts/eslint.config.mjs @@ -0,0 +1,28 @@ +import pluginJs from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default [ + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + { + ignores: ["dist/", "node_modules/", "types"], + }, + { + files: ["scripts/*.{js,mjs,cjs,mts,ts,tsx,jsx}"], + rules: { + // https://typescript-eslint.io/rules/no-explicit-any/ + "@typescript-eslint/no-explicit-any": "warn", + // http://eslint.org/docs/rules/no-unused-vars + "@typescript-eslint/no-unused-vars": [ + "error", + { + vars: "all", + args: "after-used", + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + "no-empty": ["error", { allowEmptyCatch: true }], + }, + }, +]; diff --git a/examples/tutorial/scripts/package.json b/examples/tutorial/scripts/package.json new file mode 100644 index 0000000..c72413e --- /dev/null +++ b/examples/tutorial/scripts/package.json @@ -0,0 +1,26 @@ +{ + "name": "examples_tutorial_scripts", + "version": "0.0.1", + "description": "", + "type": "module", + "scripts": { + "build": "tsc", + "format": "prettier -w src", + "read": "tsc && node dist/src/index.js", + "fill": "tsc && node dist/src/fill.js", + "check": "tsc --noEmit --skipLibCheck && eslint" + }, + "devDependencies": { + "@eslint/js": "^9.13.0", + "@types/node": "^22.8.2", + "eslint": "^9.13.0", + "prettier": "^3.3.3", + "quicktype": "^23.0.170", + "typescript": "^5.6.3", + "typescript-eslint": "^8.12.1" + }, + "dependencies": { + "csv-parse": "^5.5.6", + "trailbase": "workspace:*" + } +} diff --git a/examples/tutorial/scripts/schema/movie.json b/examples/tutorial/scripts/schema/movie.json new file mode 100644 index 0000000..2706a5e --- /dev/null +++ b/examples/tutorial/scripts/schema/movie.json @@ -0,0 +1,63 @@ +{ + "$defs": {}, + "properties": { + "description": { + "type": "string" + }, + "gross": { + "type": [ + "number", + "string", + "boolean", + "object", + "array", + "null" + ] + }, + "metascore": { + "type": [ + "number", + "string", + "boolean", + "object", + "array", + "null" + ] + }, + "name": { + "type": "string" + }, + "rank": { + "type": "integer" + }, + "rating": { + "type": "number" + }, + "votes": { + "type": "string" + }, + "watch_time": { + "type": "integer" + }, + "year": { + "type": [ + "number", + "string", + "boolean", + "object", + "array", + "null" + ] + } + }, + "required": [ + "name", + "year", + "watch_time", + "rating", + "votes", + "description" + ], + "title": "movies", + "type": "object" +} diff --git a/examples/tutorial/scripts/src/fill.ts b/examples/tutorial/scripts/src/fill.ts new file mode 100644 index 0000000..f2564e3 --- /dev/null +++ b/examples/tutorial/scripts/src/fill.ts @@ -0,0 +1,43 @@ +import { readFile } from "node:fs/promises"; +import { parse } from "csv-parse/sync"; + +import { Client } from "trailbase"; +import type { Movie } from "@schema/movie"; + +const client = new Client("http://localhost:4000"); +await client.login("admin@localhost", "secret"); +const api = client.records("movies"); + +let movies = []; +do { + movies = await api.list({ + pagination: { + limit: 100, + }, + }); + + for (const movie of movies) { + await api.delete(movie.rank!); + } +} while (movies.length > 0); + +const file = await readFile("../data/Top_1000_IMDb_movies_New_version.csv"); +const records = parse(file, { + fromLine: 2, + // prettier-ignore + columns: [ "rank", "name", "year", "watch_time", "rating", "metascore", "gross", "votes", "description" ], +}); + +for (const movie of records) { + await api.create({ + rank: parseInt(movie.rank), + name: movie.name, + year: movie.year, + watch_time: parseInt(movie.watch_time), + rating: parseInt(movie.rating), + metascore: movie.metascore, + gross: movie.gross, + votes: movie.votes, + description: movie.description, + }); +} diff --git a/examples/tutorial/scripts/src/index.ts b/examples/tutorial/scripts/src/index.ts new file mode 100644 index 0000000..71260db --- /dev/null +++ b/examples/tutorial/scripts/src/index.ts @@ -0,0 +1,15 @@ +import { Client } from "trailbase"; + +const client = new Client("http://localhost:4000"); +await client.login("admin@localhost", "secret"); + +const movies = client.records("movies"); +const m = await movies.list({ + pagination: { + limit: 3, + }, + order: ["rank"], + filters: ["watch_time[lt]=120"], +}); + +console.log(m); diff --git a/examples/tutorial/scripts/tsconfig.json b/examples/tutorial/scripts/tsconfig.json new file mode 100644 index 0000000..8d8dd18 --- /dev/null +++ b/examples/tutorial/scripts/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../ui/common/tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "paths": { + "@/*": ["./src/*"], + "@schema/*": ["./types/*"], + "@bindings/*": ["../../../trailbase-core/bindings/*"] + } + }, + "include": ["src/**/*"], + "exclude": [ + "dist", + "node_modules" + ] +} diff --git a/examples/tutorial/scripts/types/movie.ts b/examples/tutorial/scripts/types/movie.ts new file mode 100644 index 0000000..f0a6974 --- /dev/null +++ b/examples/tutorial/scripts/types/movie.ts @@ -0,0 +1,199 @@ +// To parse this data: +// +// import { Convert, Movie } from "./file"; +// +// const movie = Convert.toMovie(json); +// +// These functions will throw an error if the JSON doesn't +// match the expected interface, even if the JSON is valid. + +export interface Movie { + description: string; + gross?: any[] | boolean | number | { [key: string]: any } | null | string; + metascore?: any[] | boolean | number | { [key: string]: any } | null | string; + name: string; + rank?: number; + rating: number; + votes: string; + watch_time: number; + year: any[] | boolean | number | { [key: string]: any } | null | string; + [property: string]: any; +} + +// Converts JSON strings to/from your types +// and asserts the results of JSON.parse at runtime +export class Convert { + public static toMovie(json: string): Movie { + return cast(JSON.parse(json), r("Movie")); + } + + public static movieToJson(value: Movie): string { + return JSON.stringify(uncast(value, r("Movie")), null, 2); + } +} + +function invalidValue(typ: any, val: any, key: any, parent: any = ''): never { + const prettyTyp = prettyTypeName(typ); + const parentText = parent ? ` on ${parent}` : ''; + const keyText = key ? ` for key "${key}"` : ''; + throw Error(`Invalid value${keyText}${parentText}. Expected ${prettyTyp} but got ${JSON.stringify(val)}`); +} + +function prettyTypeName(typ: any): string { + if (Array.isArray(typ)) { + if (typ.length === 2 && typ[0] === undefined) { + return `an optional ${prettyTypeName(typ[1])}`; + } else { + return `one of [${typ.map(a => { return prettyTypeName(a); }).join(", ")}]`; + } + } else if (typeof typ === "object" && typ.literal !== undefined) { + return typ.literal; + } else { + return typeof typ; + } +} + +function jsonToJSProps(typ: any): any { + if (typ.jsonToJS === undefined) { + const map: any = {}; + typ.props.forEach((p: any) => map[p.json] = { key: p.js, typ: p.typ }); + typ.jsonToJS = map; + } + return typ.jsonToJS; +} + +function jsToJSONProps(typ: any): any { + if (typ.jsToJSON === undefined) { + const map: any = {}; + typ.props.forEach((p: any) => map[p.js] = { key: p.json, typ: p.typ }); + typ.jsToJSON = map; + } + return typ.jsToJSON; +} + +function transform(val: any, typ: any, getProps: any, key: any = '', parent: any = ''): any { + function transformPrimitive(typ: string, val: any): any { + if (typeof typ === typeof val) return val; + return invalidValue(typ, val, key, parent); + } + + function transformUnion(typs: any[], val: any): any { + // val must validate against one typ in typs + const l = typs.length; + for (let i = 0; i < l; i++) { + const typ = typs[i]; + try { + return transform(val, typ, getProps); + } catch (_) {} + } + return invalidValue(typs, val, key, parent); + } + + function transformEnum(cases: string[], val: any): any { + if (cases.indexOf(val) !== -1) return val; + return invalidValue(cases.map(a => { return l(a); }), val, key, parent); + } + + function transformArray(typ: any, val: any): any { + // val must be an array with no invalid elements + if (!Array.isArray(val)) return invalidValue(l("array"), val, key, parent); + return val.map(el => transform(el, typ, getProps)); + } + + function transformDate(val: any): any { + if (val === null) { + return null; + } + const d = new Date(val); + if (isNaN(d.valueOf())) { + return invalidValue(l("Date"), val, key, parent); + } + return d; + } + + function transformObject(props: { [k: string]: any }, additional: any, val: any): any { + if (val === null || typeof val !== "object" || Array.isArray(val)) { + return invalidValue(l(ref || "object"), val, key, parent); + } + const result: any = {}; + Object.getOwnPropertyNames(props).forEach(key => { + const prop = props[key]; + const v = Object.prototype.hasOwnProperty.call(val, key) ? val[key] : undefined; + result[prop.key] = transform(v, prop.typ, getProps, key, ref); + }); + Object.getOwnPropertyNames(val).forEach(key => { + if (!Object.prototype.hasOwnProperty.call(props, key)) { + result[key] = transform(val[key], additional, getProps, key, ref); + } + }); + return result; + } + + if (typ === "any") return val; + if (typ === null) { + if (val === null) return val; + return invalidValue(typ, val, key, parent); + } + if (typ === false) return invalidValue(typ, val, key, parent); + let ref: any = undefined; + while (typeof typ === "object" && typ.ref !== undefined) { + ref = typ.ref; + typ = typeMap[typ.ref]; + } + if (Array.isArray(typ)) return transformEnum(typ, val); + if (typeof typ === "object") { + return typ.hasOwnProperty("unionMembers") ? transformUnion(typ.unionMembers, val) + : typ.hasOwnProperty("arrayItems") ? transformArray(typ.arrayItems, val) + : typ.hasOwnProperty("props") ? transformObject(getProps(typ), typ.additional, val) + : invalidValue(typ, val, key, parent); + } + // Numbers can be parsed by Date but shouldn't be. + if (typ === Date && typeof val !== "number") return transformDate(val); + return transformPrimitive(typ, val); +} + +function cast(val: any, typ: any): T { + return transform(val, typ, jsonToJSProps); +} + +function uncast(val: T, typ: any): any { + return transform(val, typ, jsToJSONProps); +} + +function l(typ: any) { + return { literal: typ }; +} + +function a(typ: any) { + return { arrayItems: typ }; +} + +function u(...typs: any[]) { + return { unionMembers: typs }; +} + +function o(props: any[], additional: any) { + return { props, additional }; +} + +function m(additional: any) { + return { props: [], additional }; +} + +function r(name: string) { + return { ref: name }; +} + +const typeMap: any = { + "Movie": o([ + { json: "description", js: "description", typ: "" }, + { json: "gross", js: "gross", typ: u(undefined, u(a("any"), true, 3.14, m("any"), null, "")) }, + { json: "metascore", js: "metascore", typ: u(undefined, u(a("any"), true, 3.14, m("any"), null, "")) }, + { json: "name", js: "name", typ: "" }, + { json: "rank", js: "rank", typ: u(undefined, 0) }, + { json: "rating", js: "rating", typ: 3.14 }, + { json: "votes", js: "votes", typ: "" }, + { json: "watch_time", js: "watch_time", typ: 0 }, + { json: "year", js: "year", typ: u(a("any"), true, 3.14, m("any"), null, "") }, + ], "any"), +}; diff --git a/examples/tutorial/traildepot/config.textproto b/examples/tutorial/traildepot/config.textproto new file mode 100644 index 0000000..fabb5f1 --- /dev/null +++ b/examples/tutorial/traildepot/config.textproto @@ -0,0 +1,30 @@ +# Auto-generated config.Config textproto +email {} +server { + application_name: "TrailBase-Tutorial" + site_url: "http://localhost:4000" + logs_retention_sec: 604800 +} +auth { + auth_token_ttl_sec: 86400 + refresh_token_ttl_sec: 2592000 +} +record_apis: [ + { + name: "_user_avatar" + table_name: "_user_avatar" + conflict_resolution: REPLACE + autofill_missing_user_id_columns: true + acl_world: [READ] + acl_authenticated: [CREATE, READ, UPDATE, DELETE] + create_access_rule: "_REQ_.user IS NULL OR _REQ_.user = _USER_.id" + update_access_rule: "_ROW_.user = _USER_.id" + delete_access_rule: "_ROW_.user = _USER_.id" + }, + { + name: "movies" + table_name: "movies" + acl_world: [READ] + acl_authenticated: [CREATE, READ, UPDATE, DELETE] + } +] diff --git a/examples/tutorial/traildepot/migrations/U1725019360__add_admin_user.sql b/examples/tutorial/traildepot/migrations/U1725019360__add_admin_user.sql new file mode 100644 index 0000000..8e0fa57 --- /dev/null +++ b/examples/tutorial/traildepot/migrations/U1725019360__add_admin_user.sql @@ -0,0 +1,5 @@ +-- Create admin user with "secret" password. +INSERT INTO _user + (email, password_hash, verified, admin) +VALUES + ('admin@localhost', (hash_password('secret')), TRUE, TRUE); diff --git a/examples/tutorial/traildepot/migrations/U1728810800__create_table_movies.sql b/examples/tutorial/traildepot/migrations/U1728810800__create_table_movies.sql new file mode 100644 index 0000000..14c7fe1 --- /dev/null +++ b/examples/tutorial/traildepot/migrations/U1728810800__create_table_movies.sql @@ -0,0 +1,16 @@ +-- A table schema to hold the IMDB test dataset from: +-- https://www.kaggle.com/datasets/inductiveanks/top-1000-imdb-movies-dataset/data +-- +-- The only TrailBase API requirements are: "STRICT" typing and a INTEGER (or +-- UUIDv7) PRIMARY KEY column. +CREATE TABLE IF NOT EXISTS movies ( + rank INTEGER PRIMARY KEY, + name TEXT NOT NULL, + year ANY NOT NULL, + watch_time INTEGER NOT NULL, + rating REAL NOT NULL, + metascore ANY, + gross ANY, + votes TEXT NOT NULL, + description TEXT NOT NULL +) STRICT; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..367bf57 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,9579 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + client/trailbase-ts: + dependencies: + jwt-decode: + specifier: ^4.0.0 + version: 4.0.0 + uuid: + specifier: ^11.0.2 + version: 11.0.2 + devDependencies: + '@eslint/js': + specifier: ^9.13.0 + version: 9.13.0 + eslint: + specifier: ^9.13.0 + version: 9.13.0(jiti@2.3.3) + execa: + specifier: ^9.5.1 + version: 9.5.1 + globals: + specifier: ^15.11.0 + version: 15.11.0 + http-status: + specifier: ^2.0.0 + version: 2.0.0 + jsdom: + specifier: ^25.0.1 + version: 25.0.1 + prettier: + specifier: ^3.3.3 + version: 3.3.3 + tinybench: + specifier: ^3.0.0 + version: 3.0.0 + typescript: + specifier: ^5.6.3 + version: 5.6.3 + typescript-eslint: + specifier: ^8.12.1 + version: 8.12.1(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) + vite-node: + specifier: ^2.1.4 + version: 2.1.4(@types/node@22.8.2) + vitest: + specifier: ^2.1.4 + version: 2.1.4(@types/node@22.8.2)(happy-dom@15.7.4)(jsdom@25.0.1) + + docs: + dependencies: + '@astrojs/check': + specifier: ^0.9.4 + version: 0.9.4(prettier-plugin-astro@0.14.1)(prettier@3.3.3)(typescript@5.6.3) + '@astrojs/starlight': + specifier: ^0.28.4 + version: 0.28.4(astro@4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3)) + '@astrojs/starlight-tailwind': + specifier: ^2.0.3 + version: 2.0.3(@astrojs/starlight@0.28.4(astro@4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3)))(@astrojs/tailwind@5.1.2(astro@4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3))(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3))) + '@astrojs/tailwind': + specifier: ^5.1.2 + version: 5.1.2(astro@4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3))(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)) + '@iconify-json/tabler': + specifier: ^1.2.6 + version: 1.2.6 + astro: + specifier: ^4.16.7 + version: 4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3) + astro-icon: + specifier: ^1.1.1 + version: 1.1.1 + chart.js: + specifier: ^4.4.6 + version: 4.4.6 + chartjs-chart-error-bars: + specifier: ^4.4.3 + version: 4.4.3(chart.js@4.4.6) + chartjs-plugin-deferred: + specifier: ^2.0.0 + version: 2.0.0(chart.js@4.4.6) + sharp: + specifier: ^0.33.5 + version: 0.33.5 + solid-js: + specifier: ^1.9.3 + version: 1.9.3 + tailwindcss: + specifier: ^3.4.14 + version: 3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)) + typescript: + specifier: ^5.6.3 + version: 5.6.3 + devDependencies: + '@astrojs/sitemap': + specifier: ^3.2.1 + version: 3.2.1 + '@astrojs/solid-js': + specifier: ^4.4.2 + version: 4.4.2(solid-devtools@0.30.1(solid-js@1.9.3)(vite@5.4.10(@types/node@22.8.2)))(solid-js@1.9.3)(vite@5.4.10(@types/node@22.8.2)) + astro-robots-txt: + specifier: ^1.0.0 + version: 1.0.0 + prettier: + specifier: ^3.3.3 + version: 3.3.3 + prettier-plugin-astro: + specifier: ^0.14.1 + version: 0.14.1 + + examples/blog/web: + dependencies: + '@astrojs/mdx': + specifier: ^3.1.8 + version: 3.1.8(astro@4.16.7(@types/node@16.18.115)(rollup@4.24.2)(typescript@4.9.4)) + '@astrojs/tailwind': + specifier: ^5.1.2 + version: 5.1.2(astro@4.16.7(@types/node@16.18.115)(rollup@4.24.2)(typescript@4.9.4))(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@16.18.115)(typescript@4.9.4)))(ts-node@10.9.2(@types/node@16.18.115)(typescript@4.9.4)) + '@nanostores/persistent': + specifier: ^0.10.2 + version: 0.10.2(nanostores@0.11.3) + '@nanostores/solid': + specifier: ^0.5.0 + version: 0.5.0(nanostores@0.11.3)(solid-js@1.9.3) + astro: + specifier: ^4.16.7 + version: 4.16.7(@types/node@16.18.115)(rollup@4.24.2)(typescript@4.9.4) + astro-icon: + specifier: ^1.1.1 + version: 1.1.1 + nanostores: + specifier: ^0.11.3 + version: 0.11.3 + solid-icons: + specifier: ^1.1.0 + version: 1.1.0(solid-js@1.9.3) + solid-js: + specifier: ^1.9.3 + version: 1.9.3 + tailwindcss: + specifier: ^3.4.14 + version: 3.4.14(ts-node@10.9.2(@types/node@16.18.115)(typescript@4.9.4)) + trailbase: + specifier: workspace:* + version: link:../../../client/trailbase-ts + devDependencies: + '@astrojs/solid-js': + specifier: ^4.4.2 + version: 4.4.2(solid-devtools@0.30.1(solid-js@1.9.3)(vite@5.4.10(@types/node@16.18.115)))(solid-js@1.9.3)(vite@5.4.10(@types/node@16.18.115)) + '@iconify-json/tabler': + specifier: ^1.2.6 + version: 1.2.6 + '@tailwindcss/typography': + specifier: ^0.5.15 + version: 0.5.15(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@16.18.115)(typescript@4.9.4))) + '@types/dateformat': + specifier: ^5.0.2 + version: 5.0.2 + prettier: + specifier: ^3.3.3 + version: 3.3.3 + prettier-plugin-astro: + specifier: ^0.14.1 + version: 0.14.1 + quicktype: + specifier: ^23.0.170 + version: 23.0.170 + sharp: + specifier: ^0.33.5 + version: 0.33.5 + + examples/tutorial/scripts: + dependencies: + csv-parse: + specifier: ^5.5.6 + version: 5.5.6 + trailbase: + specifier: workspace:* + version: link:../../../client/trailbase-ts + devDependencies: + '@eslint/js': + specifier: ^9.13.0 + version: 9.13.0 + '@types/node': + specifier: ^22.8.2 + version: 22.8.2 + eslint: + specifier: ^9.13.0 + version: 9.13.0(jiti@2.3.3) + prettier: + specifier: ^3.3.3 + version: 3.3.3 + quicktype: + specifier: ^23.0.170 + version: 23.0.170 + typescript: + specifier: ^5.6.3 + version: 5.6.3 + typescript-eslint: + specifier: ^8.12.1 + version: 8.12.1(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) + + ui/admin: + dependencies: + '@bufbuild/protobuf': + specifier: ^2.2.1 + version: 2.2.1 + '@codemirror/commands': + specifier: ^6.7.1 + version: 6.7.1 + '@codemirror/lang-sql': + specifier: ^6.8.0 + version: 6.8.0(@codemirror/view@6.34.1) + '@codemirror/language': + specifier: ^6.10.3 + version: 6.10.3 + '@codemirror/state': + specifier: ^6.4.1 + version: 6.4.1 + '@codemirror/view': + specifier: ^6.34.1 + version: 6.34.1 + '@corvu/resizable': + specifier: ^0.2.3 + version: 0.2.3(solid-js@1.9.3) + '@kobalte/core': + specifier: ^0.13.7 + version: 0.13.7(solid-js@1.9.3) + '@kobalte/utils': + specifier: ^0.9.1 + version: 0.9.1(solid-js@1.9.3) + '@nanostores/persistent': + specifier: ^0.10.2 + version: 0.10.2(nanostores@0.11.3) + '@nanostores/solid': + specifier: ^0.5.0 + version: 0.5.0(nanostores@0.11.3)(solid-js@1.9.3) + '@solid-primitives/memo': + specifier: ^1.3.10 + version: 1.3.10(solid-js@1.9.3) + '@solidjs/router': + specifier: ^0.14.10 + version: 0.14.10(solid-js@1.9.3) + '@tanstack/solid-form': + specifier: ^0.34.1 + version: 0.34.1(solid-js@1.9.3) + '@tanstack/solid-query': + specifier: ^5.59.16 + version: 5.59.16(solid-js@1.9.3) + '@tanstack/solid-table': + specifier: ^8.20.5 + version: 8.20.5(solid-js@1.9.3) + '@tanstack/table-core': + specifier: ^8.20.5 + version: 8.20.5 + chart.js: + specifier: ^4.4.6 + version: 4.4.6 + class-variance-authority: + specifier: ^0.7.0 + version: 0.7.0 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + long: + specifier: ^5.2.3 + version: 5.2.3 + nanostores: + specifier: ^0.11.3 + version: 0.11.3 + protobufjs: + specifier: ^7.4.0 + version: 7.4.0 + solid-icons: + specifier: ^1.1.0 + version: 1.1.0(solid-js@1.9.3) + solid-js: + specifier: ^1.9.3 + version: 1.9.3 + tailwind-merge: + specifier: ^2.5.4 + version: 2.5.4 + tailwindcss: + specifier: ^3.4.14 + version: 3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)) + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3))) + trailbase: + specifier: workspace:* + version: link:../../client/trailbase-ts + uuid: + specifier: ^11.0.2 + version: 11.0.2 + devDependencies: + '@eslint/js': + specifier: ^9.13.0 + version: 9.13.0 + '@iconify-json/tabler': + specifier: ^1.2.6 + version: 1.2.6 + '@tailwindcss/typography': + specifier: ^0.5.15 + version: 0.5.15(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3))) + '@types/wicg-file-system-access': + specifier: ^2023.10.5 + version: 2023.10.5 + autoprefixer: + specifier: ^10.4.20 + version: 10.4.20(postcss@8.4.47) + eslint: + specifier: ^9.13.0 + version: 9.13.0(jiti@2.3.3) + globals: + specifier: ^15.11.0 + version: 15.11.0 + jsdom: + specifier: ^25.0.1 + version: 25.0.1 + postcss: + specifier: ^8.4.47 + version: 8.4.47 + prettier: + specifier: ^3.3.3 + version: 3.3.3 + ts-proto: + specifier: ^2.2.5 + version: 2.2.5 + typescript: + specifier: ^5.6.3 + version: 5.6.3 + typescript-eslint: + specifier: ^8.12.1 + version: 8.12.1(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) + vite: + specifier: ^5.4.10 + version: 5.4.10(@types/node@22.8.2) + vite-plugin-solid: + specifier: ^2.10.2 + version: 2.10.2(solid-js@1.9.3)(vite@5.4.10(@types/node@22.8.2)) + vite-tsconfig-paths: + specifier: ^5.0.1 + version: 5.0.1(typescript@5.6.3)(vite@5.4.10(@types/node@22.8.2)) + vitest: + specifier: ^2.1.4 + version: 2.1.4(@types/node@22.8.2)(happy-dom@15.7.4)(jsdom@25.0.1) + + ui/auth: + dependencies: + '@astrojs/check': + specifier: ^0.9.4 + version: 0.9.4(prettier-plugin-astro@0.14.1)(prettier@3.3.3)(typescript@5.6.3) + '@astrojs/solid-js': + specifier: ^4.4.2 + version: 4.4.2(solid-devtools@0.30.1(solid-js@1.9.3)(vite@5.4.10(@types/node@22.8.2)))(solid-js@1.9.3)(vite@5.4.10(@types/node@22.8.2)) + '@astrojs/tailwind': + specifier: ^5.1.2 + version: 5.1.2(astro@4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3))(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)) + '@kobalte/core': + specifier: ^0.13.7 + version: 0.13.7(solid-js@1.9.3) + astro: + specifier: ^4.16.7 + version: 4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3) + astro-icon: + specifier: ^1.1.1 + version: 1.1.1 + class-variance-authority: + specifier: ^0.7.0 + version: 0.7.0 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + solid-icons: + specifier: ^1.1.0 + version: 1.1.0(solid-js@1.9.3) + solid-js: + specifier: ^1.9.3 + version: 1.9.3 + tailwind-merge: + specifier: ^2.5.4 + version: 2.5.4 + tailwindcss: + specifier: ^3.4.14 + version: 3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)) + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3))) + trailbase: + specifier: workspace:* + version: link:../../client/trailbase-ts + devDependencies: + '@iconify-json/tabler': + specifier: ^1.2.6 + version: 1.2.6 + '@tailwindcss/typography': + specifier: ^0.5.15 + version: 0.5.15(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3))) + prettier: + specifier: ^3.3.3 + version: 3.3.3 + prettier-plugin-astro: + specifier: ^0.14.1 + version: 0.14.1 + sharp: + specifier: ^0.33.5 + version: 0.33.5 + ts-proto: + specifier: ^2.2.5 + version: 2.2.5 + typescript: + specifier: ^5.6.3 + version: 5.6.3 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@antfu/install-pkg@0.4.1': + resolution: {integrity: sha512-T7yB5QNG29afhWVkVq7XeIMBa5U/vs9mX69YqayXypPRmYzUmzwnYltplHmPtZ4HPCn+sQKeXW8I47wCbuBOjw==} + + '@antfu/utils@0.7.10': + resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + + '@astrojs/check@0.9.4': + resolution: {integrity: sha512-IOheHwCtpUfvogHHsvu0AbeRZEnjJg3MopdLddkJE70mULItS/Vh37BHcI00mcOJcH1vhD3odbpvWokpxam7xA==} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + + '@astrojs/compiler@2.10.3': + resolution: {integrity: sha512-bL/O7YBxsFt55YHU021oL+xz+B/9HvGNId3F9xURN16aeqDK9juHGktdkCSXz+U4nqFACq6ZFvWomOzhV+zfPw==} + + '@astrojs/internal-helpers@0.4.1': + resolution: {integrity: sha512-bMf9jFihO8YP940uD70SI/RDzIhUHJAolWVcO1v5PUivxGKvfLZTLTVVxEYzGYyPsA3ivdLNqMnL5VgmQySa+g==} + + '@astrojs/language-server@2.15.4': + resolution: {integrity: sha512-JivzASqTPR2bao9BWsSc/woPHH7OGSGc9aMxXL4U6egVTqBycB3ZHdBJPuOCVtcGLrzdWTosAqVPz1BVoxE0+A==} + hasBin: true + peerDependencies: + prettier: ^3.0.0 + prettier-plugin-astro: '>=0.11.0' + peerDependenciesMeta: + prettier: + optional: true + prettier-plugin-astro: + optional: true + + '@astrojs/markdown-remark@5.3.0': + resolution: {integrity: sha512-r0Ikqr0e6ozPb5bvhup1qdWnSPUvQu6tub4ZLYaKyG50BXZ0ej6FhGz3GpChKpH7kglRFPObJd/bDyf2VM9pkg==} + + '@astrojs/mdx@3.1.8': + resolution: {integrity: sha512-4o/+pvgoLFG0eG96cFs4t3NzZAIAOYu57fKAprWHXJrnq/qdBV0av6BYDjoESxvxNILUYoj8sdZVWtlPWVDLog==} + engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0} + peerDependencies: + astro: ^4.8.0 + + '@astrojs/prism@3.1.0': + resolution: {integrity: sha512-Z9IYjuXSArkAUx3N6xj6+Bnvx8OdUSHA8YoOgyepp3+zJmtVYJIl/I18GozdJVW1p5u/CNpl3Km7/gwTJK85cw==} + engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0} + + '@astrojs/sitemap@3.2.1': + resolution: {integrity: sha512-uxMfO8f7pALq0ADL6Lk68UV6dNYjJ2xGUzyjjVj60JLBs5a6smtlkBYv3tQ0DzoqwS7c9n4FUx5lgv0yPo/fgA==} + + '@astrojs/solid-js@4.4.2': + resolution: {integrity: sha512-E41gipjC2kp3wr7QdFW5EszPOwxDBUlsNsIohKRxkC9RskiQk9a8F56dEvpeBMXUXtmsAtNUBJHdKRTxHtOgDw==} + engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0} + peerDependencies: + solid-devtools: ^0.30.1 + solid-js: ^1.8.5 + peerDependenciesMeta: + solid-devtools: + optional: true + + '@astrojs/starlight-tailwind@2.0.3': + resolution: {integrity: sha512-ZwbdXS/9rxYlo3tKZoTZoBPUnaaqek02b341dHwOkmMT0lIR2w+8k0mRUGxnRaYtPdMcaL+nYFd8RUa8sjdyRg==} + peerDependencies: + '@astrojs/starlight': '>=0.9.0' + '@astrojs/tailwind': ^5.0.0 + tailwindcss: ^3.3.3 + + '@astrojs/starlight@0.28.4': + resolution: {integrity: sha512-SU0vgCQCQZ6AuA84doxpGr5Aowr9L/PalddUbeDWSzkjE/YierFcvmBg78cSB0pdL0Q1v4k4l+wqhz176wHmTA==} + peerDependencies: + astro: ^4.14.0 + + '@astrojs/tailwind@5.1.2': + resolution: {integrity: sha512-IvOF0W/dtHElcXvhrPR35nHmhyV3cfz1EzPitMGtU7sYy9Hci3BNK1To6FWmVuuNKPxza1IgCGetSynJZL7fOg==} + peerDependencies: + astro: ^3.0.0 || ^4.0.0 || ^5.0.0-beta.0 + tailwindcss: ^3.0.24 + + '@astrojs/telemetry@3.1.0': + resolution: {integrity: sha512-/ca/+D8MIKEC8/A9cSaPUqQNZm+Es/ZinRv0ZAzvu2ios7POQSsVD+VOj7/hypWNsNM3T7RpfgNq7H2TU1KEHA==} + engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0} + + '@astrojs/yaml2ts@0.2.2': + resolution: {integrity: sha512-GOfvSr5Nqy2z5XiwqTouBBpy5FyI6DEe+/g/Mk5am9SjILN1S5fOEvYK0GuWHg98yS/dobP4m8qyqw/URW35fQ==} + + '@babel/code-frame@7.26.0': + resolution: {integrity: sha512-INCKxTtbXtcNbUZ3YXutwMpEleqttcswhAdee7dhuoVrD2cnuc3PqtERBtxkX5nziX9vnBL8WXmSGwv8CuPV6g==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.26.0': + resolution: {integrity: sha512-qETICbZSLe7uXv9VE8T/RWOdIE5qqyTucOt4zLYMafj2MRO271VGgLd4RACJMeBO37UPWhXiKMBk7YlJ0fOzQA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.26.0': + resolution: {integrity: sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.26.0': + resolution: {integrity: sha512-/AIkAmInnWwgEAJGQr9vY0c66Mj6kjkE2ZPB1PurTRaRAh3U+J45sAQMjQDJdh4WbR3l0x5xkimXBKyBXXAu2w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.25.9': + resolution: {integrity: sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.25.9': + resolution: {integrity: sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.25.9': + resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.26.0': + resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.25.9': + resolution: {integrity: sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.25.9': + resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.25.9': + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.25.9': + resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.26.0': + resolution: {integrity: sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.26.1': + resolution: {integrity: sha512-reoQYNiAJreZNsJzyrDNzFQ+IQ5JFiIzAHJg9bn94S3l+4++J7RsIhNMoB+lgP/9tpmiAQqspv+xfdxTSzREOw==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.25.9': + resolution: {integrity: sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.25.9': + resolution: {integrity: sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.25.9': + resolution: {integrity: sha512-s5XwpQYCqGerXl+Pu6VDL3x0j2d82eiV77UJ8a2mDHAW7j9SWRqQ2y1fNo1Z74CdcYipl5Z41zvjj4Nfzq36rw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.26.0': + resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.25.9': + resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.25.9': + resolution: {integrity: sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.26.0': + resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==} + engines: {node: '>=6.9.0'} + + '@bufbuild/protobuf@2.2.1': + resolution: {integrity: sha512-gdWzq7eX017a1kZCU/bP/sbk4e0GZ6idjsXOcMrQwODCb/rx985fHJJ8+hCu79KpuG7PfZh7bo3BBjPH37JuZw==} + + '@codemirror/autocomplete@6.18.1': + resolution: {integrity: sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA==} + peerDependencies: + '@codemirror/language': ^6.0.0 + '@codemirror/state': ^6.0.0 + '@codemirror/view': ^6.0.0 + '@lezer/common': ^1.0.0 + + '@codemirror/commands@6.7.1': + resolution: {integrity: sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==} + + '@codemirror/lang-sql@6.8.0': + resolution: {integrity: sha512-aGLmY4OwGqN3TdSx3h6QeA1NrvaYtF7kkoWR/+W7/JzB0gQtJ+VJxewlnE3+VImhA4WVlhmkJr109PefOOhjLg==} + + '@codemirror/language@6.10.3': + resolution: {integrity: sha512-kDqEU5sCP55Oabl6E7m5N+vZRoc0iWqgDVhEKifcHzPzjqCegcO4amfrYVL9PmPZpl4G0yjkpTpUO/Ui8CzO8A==} + + '@codemirror/state@6.4.1': + resolution: {integrity: sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==} + + '@codemirror/view@6.34.1': + resolution: {integrity: sha512-t1zK/l9UiRqwUNPm+pdIT0qzJlzuVckbTEMVNFhfWkGiBQClstzg+78vedCvLSX0xJEZ6lwZbPpnljL7L6iwMQ==} + + '@corvu/resizable@0.2.3': + resolution: {integrity: sha512-UwpObxqKlx1mc3G496Daz9NjK25Gx1V5fB8zIGazbq5tJs7aU8RjPW4png5OoNpMyxV7GQWjQtVc59zaAEVAJg==} + peerDependencies: + solid-js: ^1.8 + + '@corvu/utils@0.4.2': + resolution: {integrity: sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA==} + peerDependencies: + solid-js: ^1.8 + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@ctrl/tinycolor@4.1.0': + resolution: {integrity: sha512-WyOx8cJQ+FQus4Mm4uPIZA64gbk3Wxh0so5Lcii0aJifqwoVOlfFtorjLE0Hen4OYyHZMXDWqMmaQemBhgxFRQ==} + engines: {node: '>=14'} + + '@emmetio/abbreviation@2.3.3': + resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} + + '@emmetio/css-abbreviation@2.1.8': + resolution: {integrity: sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==} + + '@emmetio/css-parser@0.4.0': + resolution: {integrity: sha512-z7wkxRSZgrQHXVzObGkXG+Vmj3uRlpM11oCZ9pbaz0nFejvCDmAiNDpY75+wgXOcffKpj4rzGtwGaZxfJKsJxw==} + + '@emmetio/html-matcher@1.3.0': + resolution: {integrity: sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==} + + '@emmetio/scanner@1.0.4': + resolution: {integrity: sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==} + + '@emmetio/stream-reader-utils@0.1.0': + resolution: {integrity: sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==} + + '@emmetio/stream-reader@2.2.0': + resolution: {integrity: sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==} + + '@emnapi/runtime@1.3.1': + resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.4.1': + resolution: {integrity: sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.18.0': + resolution: {integrity: sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.7.0': + resolution: {integrity: sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.1.0': + resolution: {integrity: sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.13.0': + resolution: {integrity: sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.4': + resolution: {integrity: sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.2.2': + resolution: {integrity: sha512-CXtq5nR4Su+2I47WPOlWud98Y5Lv8Kyxp2ukhgFx/eW6Blm18VXJO5WuQylPugRo8nbluoi6GvvxBLqHcvqUUw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@expressive-code/core@0.35.6': + resolution: {integrity: sha512-xGqCkmfkgT7lr/rvmfnYdDSeTdCSp1otAHgoFS6wNEeO7wGDPpxdosVqYiIcQ8CfWUABh/pGqWG90q+MV3824A==} + + '@expressive-code/plugin-frames@0.35.6': + resolution: {integrity: sha512-CqjSWjDJ3wabMJZfL9ZAzH5UAGKg7KWsf1TBzr4xvUbZvWoBtLA/TboBML0U1Ls8h/4TRCIvR4VEb8dv5+QG3w==} + + '@expressive-code/plugin-shiki@0.35.6': + resolution: {integrity: sha512-xm+hzi9BsmhkDUGuyAWIydOAWer7Cs9cj8FM0t4HXaQ+qCubprT6wJZSKUxuvFJIUsIOqk1xXFaJzGJGnWtKMg==} + + '@expressive-code/plugin-text-markers@0.35.6': + resolution: {integrity: sha512-/k9eWVZSCs+uEKHR++22Uu6eIbHWEciVHbIuD8frT8DlqTtHYaaiwHPncO6KFWnGDz5i/gL7oyl6XmOi/E6GVg==} + + '@floating-ui/core@1.6.8': + resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==} + + '@floating-ui/dom@1.6.11': + resolution: {integrity: sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==} + + '@floating-ui/utils@0.2.8': + resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + + '@glideapps/ts-necessities@2.2.3': + resolution: {integrity: sha512-gXi0awOZLHk3TbW55GZLCPP6O+y/b5X1pBXKBVckFONSwF1z1E5ND2BGJsghQFah+pW7pkkyFb2VhUQI2qhL5w==} + + '@glideapps/ts-necessities@2.3.2': + resolution: {integrity: sha512-tOXo3SrEeLu+4X2q6O2iNPXdGI1qoXEz/KrbkElTsWiWb69tFH4GzWz2K++0nBD6O3qO2Ft1C4L4ZvUfE2QDlQ==} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.6': + resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.3.1': + resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==} + engines: {node: '>=18.18'} + + '@iconify-json/tabler@1.2.6': + resolution: {integrity: sha512-+LjRsbx4tqaLNorQldahZRCepYVAC8Fj6hpkHuZFWB0xj81YasACT0f9JGtavG4+LePdvkXRTuz5RQOU6YcnXA==} + + '@iconify/tools@4.0.7': + resolution: {integrity: sha512-zOJxKIfZn96ZRGGvIWzDRLD9vb2CsxjcLuM+QIdvwWbv6SWhm49gECzUnd4d2P0sq9sfodT7yCNobWK8nvavxQ==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@2.1.33': + resolution: {integrity: sha512-jP9h6v/g0BIZx0p7XGJJVtkVnydtbgTgt9mVNcGDYwaa7UhdHdI9dvoq+gKj9sijMSJKxUPEG2JyjsgXjxL7Kw==} + + '@img/sharp-darwin-arm64@0.33.5': + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.33.5': + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.0.4': + resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.33.5': + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.33.5': + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-s390x@0.33.5': + resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.33.5': + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.33.5': + resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.33.5': + resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.33.5': + resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.33.5': + resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.33.5': + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@internationalized/date@3.5.6': + resolution: {integrity: sha512-jLxQjefH9VI5P9UQuqB6qNKnvFt1Ky1TPIzHGsIlCi7sZZoMR8SdYbBGRvM0y+Jtb+ez4ieBzmiAUcpmPYpyOw==} + + '@internationalized/number@3.5.4': + resolution: {integrity: sha512-h9huwWjNqYyE2FXZZewWqmCdkw1HeFds5q4Siuoms3hUQC5iPJK3aBmkFZoDSLN4UD0Bl8G22L/NdHpeOr+/7A==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.5': + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@kobalte/core@0.13.7': + resolution: {integrity: sha512-COhjWk1KnCkl3qMJDvdrOsvpTlJ9gMLdemkAn5SWfbPn/lxJYabejnNOk+b/ILGg7apzQycgbuo48qb8ppqsAg==} + peerDependencies: + solid-js: ^1.8.15 + + '@kobalte/utils@0.9.1': + resolution: {integrity: sha512-eeU60A3kprIiBDAfv9gUJX1tXGLuZiKMajUfSQURAF2pk4ZoMYiqIzmrMBvzcxP39xnYttgTyQEVLwiTZnrV4w==} + peerDependencies: + solid-js: ^1.8.8 + + '@kurkle/color@0.3.2': + resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==} + + '@lezer/common@1.2.3': + resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==} + + '@lezer/highlight@1.2.1': + resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==} + + '@lezer/lr@1.4.2': + resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==} + + '@mark.probst/typescript-json-schema@0.55.0': + resolution: {integrity: sha512-jI48mSnRgFQxXiE/UTUCVCpX8lK3wCFKLF1Ss2aEreboKNuLQGt3e0/YFqWVHe/WENxOaqiJvwOz+L/SrN2+qQ==} + hasBin: true + + '@mdx-js/mdx@3.1.0': + resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==} + + '@nanostores/persistent@0.10.2': + resolution: {integrity: sha512-BEndnLhRC+yP7gXTESepBbSj8XNl8OXK9hu4xAgKC7MWJHKXnEqJMqY47LUyHxK6vYgFnisyHmqq+vq8AUFyIg==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + nanostores: ^0.9.0 || ^0.10.0 || ^0.11.0 + + '@nanostores/solid@0.5.0': + resolution: {integrity: sha512-bui454Om+smaCFK259D618i01Rb0w+o9uVcwZWlZqMOOQZXuqPR6azyF7tA++skBUlTDty4sB9XDt/1HDsh/CA==} + peerDependencies: + nanostores: ^0.9.0 || ^0.10.0 || ^0.11.0 + solid-js: ^1.6.0 + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nothing-but/utils@0.12.1': + resolution: {integrity: sha512-1qZU1Q5El0IjE7JT/ucvJNzdr2hL3W8Rm27xNf1p6gb3Nw8pGnZmxp6/GEW9h+I1k1cICxXNq25hBwknTQ7yhg==} + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@pagefind/darwin-arm64@1.1.1': + resolution: {integrity: sha512-tZ9tysUmQpFs2EqWG2+E1gc+opDAhSyZSsgKmFzhnWfkK02YHZhvL5XJXEZDqYy3s1FAKhwjTg8XDxneuBlDZQ==} + cpu: [arm64] + os: [darwin] + + '@pagefind/darwin-x64@1.1.1': + resolution: {integrity: sha512-ChohLQ39dLwaxQv0jIQB/SavP3TM5K5ENfDTqIdzLkmfs3+JlzSDyQKcJFjTHYcCzQOZVeieeGq8PdqvLJxJxQ==} + cpu: [x64] + os: [darwin] + + '@pagefind/default-ui@1.1.1': + resolution: {integrity: sha512-ZM0zDatWDnac/VGHhQCiM7UgA4ca8jpjA+VfuTJyHJBaxGqZMQnm4WoTz9E0KFcue1Bh9kxpu7uWFZfwpZZk0A==} + + '@pagefind/linux-arm64@1.1.1': + resolution: {integrity: sha512-H5P6wDoCoAbdsWp0Zx0DxnLUrwTGWGLu/VI1rcN2CyFdY2EGSvPQsbGBMrseKRNuIrJDFtxHHHyjZ7UbzaM9EA==} + cpu: [arm64] + os: [linux] + + '@pagefind/linux-x64@1.1.1': + resolution: {integrity: sha512-yJs7tTYbL2MI3HT+ngs9E1BfUbY9M4/YzA0yEM5xBo4Xl8Yu8Qg2xZTOQ1/F6gwvMrjCUFo8EoACs6LRDhtMrQ==} + cpu: [x64] + os: [linux] + + '@pagefind/windows-x64@1.1.1': + resolution: {integrity: sha512-b7/qPqgIl+lMzkQ8fJt51SfguB396xbIIR+VZ3YrL2tLuyifDJ1wL5mEm+ddmHxJ2Fki340paPcDan9en5OmAw==} + cpu: [x64] + os: [win32] + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + + '@rollup/pluginutils@5.1.3': + resolution: {integrity: sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.24.2': + resolution: {integrity: sha512-ufoveNTKDg9t/b7nqI3lwbCG/9IJMhADBNjjz/Jn6LxIZxD7T5L8l2uO/wD99945F1Oo8FvgbbZJRguyk/BdzA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.24.2': + resolution: {integrity: sha512-iZoYCiJz3Uek4NI0J06/ZxUgwAfNzqltK0MptPDO4OR0a88R4h0DSELMsflS6ibMCJ4PnLvq8f7O1d7WexUvIA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.24.2': + resolution: {integrity: sha512-/UhrIxobHYCBfhi5paTkUDQ0w+jckjRZDZ1kcBL132WeHZQ6+S5v9jQPVGLVrLbNUebdIRpIt00lQ+4Z7ys4Rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.24.2': + resolution: {integrity: sha512-1F/jrfhxJtWILusgx63WeTvGTwE4vmsT9+e/z7cZLKU8sBMddwqw3UV5ERfOV+H1FuRK3YREZ46J4Gy0aP3qDA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.24.2': + resolution: {integrity: sha512-1YWOpFcGuC6iGAS4EI+o3BV2/6S0H+m9kFOIlyFtp4xIX5rjSnL3AwbTBxROX0c8yWtiWM7ZI6mEPTI7VkSpZw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.24.2': + resolution: {integrity: sha512-3qAqTewYrCdnOD9Gl9yvPoAoFAVmPJsBvleabvx4bnu1Kt6DrB2OALeRVag7BdWGWLhP1yooeMLEi6r2nYSOjg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.24.2': + resolution: {integrity: sha512-ArdGtPHjLqWkqQuoVQ6a5UC5ebdX8INPuJuJNWRe0RGa/YNhVvxeWmCTFQ7LdmNCSUzVZzxAvUznKaYx645Rig==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.24.2': + resolution: {integrity: sha512-B6UHHeNnnih8xH6wRKB0mOcJGvjZTww1FV59HqJoTJ5da9LCG6R4SEBt6uPqzlawv1LoEXSS0d4fBlHNWl6iYw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.24.2': + resolution: {integrity: sha512-kr3gqzczJjSAncwOS6i7fpb4dlqcvLidqrX5hpGBIM1wtt0QEVtf4wFaAwVv8QygFU8iWUMYEoJZWuWxyua4GQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.24.2': + resolution: {integrity: sha512-TDdHLKCWgPuq9vQcmyLrhg/bgbOvIQ8rtWQK7MRxJ9nvaxKx38NvY7/Lo6cYuEnNHqf6rMqnivOIPIQt6H2AoA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-powerpc64le-gnu@4.24.2': + resolution: {integrity: sha512-xv9vS648T3X4AxFFZGWeB5Dou8ilsv4VVqJ0+loOIgDO20zIhYfDLkk5xoQiej2RiSQkld9ijF/fhLeonrz2mw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.24.2': + resolution: {integrity: sha512-tbtXwnofRoTt223WUZYiUnbxhGAOVul/3StZ947U4A5NNjnQJV5irKMm76G0LGItWs6y+SCjUn/Q0WaMLkEskg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.24.2': + resolution: {integrity: sha512-gc97UebApwdsSNT3q79glOSPdfwgwj5ELuiyuiMY3pEWMxeVqLGKfpDFoum4ujivzxn6veUPzkGuSYoh5deQ2Q==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.24.2': + resolution: {integrity: sha512-jOG/0nXb3z+EM6SioY8RofqqmZ+9NKYvJ6QQaa9Mvd3RQxlH68/jcB/lpyVt4lCiqr04IyaC34NzhUqcXbB5FQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.24.2': + resolution: {integrity: sha512-XAo7cJec80NWx9LlZFEJQxqKOMz/lX3geWs2iNT5CHIERLFfd90f3RYLLjiCBm1IMaQ4VOX/lTC9lWfzzQm14Q==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-win32-arm64-msvc@4.24.2': + resolution: {integrity: sha512-A+JAs4+EhsTjnPQvo9XY/DC0ztaws3vfqzrMNMKlwQXuniBKOIIvAAI8M0fBYiTCxQnElYu7mLk7JrhlQ+HeOw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.24.2': + resolution: {integrity: sha512-ZhcrakbqA1SCiJRMKSU64AZcYzlZ/9M5LaYil9QWxx9vLnkQ9Vnkve17Qn4SjlipqIIBFKjBES6Zxhnvh0EAEw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.24.2': + resolution: {integrity: sha512-2mLH46K1u3r6uwc95hU+OR9q/ggYMpnS7pSp83Ece1HUQgF9Nh/QwTK5rcgbFnV9j+08yBrU5sA/P0RK2MSBNA==} + cpu: [x64] + os: [win32] + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@shikijs/core@1.22.2': + resolution: {integrity: sha512-bvIQcd8BEeR1yFvOYv6HDiyta2FFVePbzeowf5pPS1avczrPK+cjmaxxh0nx5QzbON7+Sv0sQfQVciO7bN72sg==} + + '@shikijs/engine-javascript@1.22.2': + resolution: {integrity: sha512-iOvql09ql6m+3d1vtvP8fLCVCK7BQD1pJFmHIECsujB0V32BJ0Ab6hxk1ewVSMFA58FI0pR2Had9BKZdyQrxTw==} + + '@shikijs/engine-oniguruma@1.22.2': + resolution: {integrity: sha512-GIZPAGzQOy56mGvWMoZRPggn0dTlBf1gutV5TdceLCZlFNqWmuc7u+CzD0Gd9vQUTgLbrt0KLzz6FNprqYAxlA==} + + '@shikijs/types@1.22.2': + resolution: {integrity: sha512-NCWDa6LGZqTuzjsGfXOBWfjS/fDIbDdmVDug+7ykVe1IKT4c1gakrvlfFYp5NhAXH/lyqLM8wsAPo5wNy73Feg==} + + '@shikijs/vscode-textmate@9.3.0': + resolution: {integrity: sha512-jn7/7ky30idSkd/O5yDBfAnVt+JJpepofP/POZ1iMOxK59cOfqIgg/Dj0eFsjOTMw+4ycJN0uhZH/Eb0bs/EUA==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@solid-devtools/debugger@0.23.4': + resolution: {integrity: sha512-EfTB1Eo313wztQYGJ4Ec/wE70Ay2d603VCXfT3RlyqO5QfLrQGRHX5NXC07hJpQTJJJ3tbNgzO7+ZKo76MM5uA==} + peerDependencies: + solid-js: ^1.8.0 + + '@solid-devtools/shared@0.13.2': + resolution: {integrity: sha512-Y4uaC4EfTVwBR537MZwfaY/eiWAh+hW4mbtnwNuUw/LFmitHSkQhNQTUlLQv/S0chtwrYWQBxvXos1dC7e8R9g==} + peerDependencies: + solid-js: ^1.8.0 + + '@solid-primitives/bounds@0.0.118': + resolution: {integrity: sha512-Qj42w8LlnhJ3r/t+t0c0vrdwIvvQMPgjEFGmLiwREaA85ojLbgL9lSBq2tKvljeLCvRVkgj10KEUf+vc99VCIg==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/cursor@0.0.112': + resolution: {integrity: sha512-TAtU7qD7ipSLSXHnq8FhhosAPVX+dnOCb/ITcGcLlj8e/C9YKcxDhgBHJ3R/d1xDRb5/vO/szJtEz6fnQD311Q==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/event-bus@1.0.11': + resolution: {integrity: sha512-bSwVA4aI2aNHomSbEroUnisMSyDDXJbrw4U8kFEvrcYdlLrJX5i6QeCFx+vj/zdQQw62KAllrEIyWP8KMpPVnQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/event-listener@2.3.3': + resolution: {integrity: sha512-DAJbl+F0wrFW2xmcV8dKMBhk9QLVLuBSW+TR4JmIfTaObxd13PuL7nqaXnaYKDWOYa6otB00qcCUIGbuIhSUgQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/keyboard@1.2.8': + resolution: {integrity: sha512-pJtcbkjozS6L1xvTht9rPpyPpX55nAkfBzbFWdf3y0Suwh6qClTibvvObzKOf7uzQ+8aZRDH4LsoGmbTKXtJjQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/keyed@1.2.3': + resolution: {integrity: sha512-Tlm2wCKcXEVxqd1speWjPhGvDhuuo/VeWSvNF6r2h77BUOHRKmNwz9uVKKMQmYSaLwiptJTp+fPZY2dOVPWQRQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/map@0.4.13': + resolution: {integrity: sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/media@2.2.9': + resolution: {integrity: sha512-QUmU62D4/d9YWx/4Dvr/UZasIkIpqNXz7wosA5GLmesRW9XlPa3G5M6uOmTw73SByHNTCw0D6x8bSdtvvLgzvQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/memo@1.3.10': + resolution: {integrity: sha512-S4cNjjKINVC4KiY3ovP1oagbTVQI77VvSRMNsInFIi7T4hM/N5InJk5k+W0zD4lt+SUYrWF04BMbZyMy17vfUw==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/platform@0.1.2': + resolution: {integrity: sha512-sSxcZfuUrtxcwV0vdjmGnZQcflACzMfLriVeIIWXKp8hzaS3Or3tO6EFQkTd3L8T5dTq+kTtLvPscXIpL0Wzdg==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/props@3.1.11': + resolution: {integrity: sha512-jZAKWwvDRHjiydIumDgMj68qviIbowQ1ci7nkEAgzgvanNkhKSQV8iPgR2jMk1uv7S2ZqXYHslVQTgJel/TEyg==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/refs@1.0.8': + resolution: {integrity: sha512-+jIsWG8/nYvhaCoG2Vg6CJOLgTmPKFbaCrNQKWfChalgUf9WrVxWw0CdJb3yX15n5lUcQ0jBo6qYtuVVmBLpBw==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/resize-observer@2.0.26': + resolution: {integrity: sha512-KbPhwal6ML9OHeUTZszBbt6PYSMj89d4wVCLxlvDYL4U0+p+xlCEaqz6v9dkCwm/0Lb+Wed7W5T1dQZCP3JUUw==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/rootless@1.4.5': + resolution: {integrity: sha512-GFJE9GC3ojx0aUKqAUZmQPyU8fOVMtnVNrkdk2yS4kd17WqVSpXpoTmo9CnOwA+PG7FTzdIkogvfLQSLs4lrww==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/scheduled@1.4.4': + resolution: {integrity: sha512-BTGdFP7t+s7RSak+s1u0eTix4lHP23MrbGkgQTFlt1E+4fmnD/bEx3ZfNW7Grylz3GXgKyXrgDKA7jQ/wuWKgA==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/static-store@0.0.5': + resolution: {integrity: sha512-ssQ+s/wrlFAEE4Zw8GV499yBfvWx7SMm+ZVc11wvao4T5xg9VfXCL9Oa+x4h+vPMvSV/Knv5LrsLiUa+wlJUXQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/static-store@0.0.8': + resolution: {integrity: sha512-ZecE4BqY0oBk0YG00nzaAWO5Mjcny8Fc06CdbXadH9T9lzq/9GefqcSe/5AtdXqjvY/DtJ5C6CkcjPZO0o/eqg==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/styles@0.0.111': + resolution: {integrity: sha512-1mBxOGAPXmfD5oYCvqjKBDN7SuNjz2qz7RdH7KtsuNLQh6lpuSKadtHnLvru0Y8Vz1InqTJisBIy/6P5kyDmPw==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/trigger@1.1.0': + resolution: {integrity: sha512-00BbAiXV66WwjHuKZc3wr0+GLb9C24mMUmi3JdTpNFgHBbrQGrIHubmZDg36c5/7wH+E0GQtOOanwQS063PO+A==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/utils@6.2.3': + resolution: {integrity: sha512-CqAwKb2T5Vi72+rhebSsqNZ9o67buYRdEJrIFzRXz3U59QqezuuxPsyzTSVCacwS5Pf109VRsgCJQoxKRoECZQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solidjs/router@0.14.10': + resolution: {integrity: sha512-5B8LVgvvXijfXyXWPVLUm7RQ05BhjIpAyRkYVDZtrR3OaSvftXobWc6qSEwk4ICLoGi/IE9CUp2LUdCBIs9AXg==} + peerDependencies: + solid-js: ^1.8.6 + + '@swc/helpers@0.5.13': + resolution: {integrity: sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==} + + '@tailwindcss/typography@0.5.15': + resolution: {integrity: sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20' + + '@tanstack/form-core@0.34.1': + resolution: {integrity: sha512-W8xRWcvlK5oek9V/JPblUyBf/cRb06nxqJTdEf7wZ6HxPnB0V9nNwZlWxvSfkMrbSkV+/H972Evy3cVc6WBB7g==} + + '@tanstack/query-core@5.59.16': + resolution: {integrity: sha512-crHn+G3ltqb5JG0oUv6q+PMz1m1YkjpASrXTU+sYWW9pLk0t2GybUHNRqYPZWhxgjPaVGC4yp92gSFEJgYEsPw==} + + '@tanstack/solid-form@0.34.1': + resolution: {integrity: sha512-sZBc8HbFI0oaf7wZzkeMFNEj7CUbU0lU4I3ufQ/ovlTMXcjXPRJ+rgfu/nrq83/DzRlfSbIoPv1efeMUKUx3cQ==} + peerDependencies: + solid-js: ^1.6.0 + + '@tanstack/solid-query@5.59.16': + resolution: {integrity: sha512-GWUbwAbIj8oLuj7zPEiHTIWp3SB/2+fSlVRpiHlTPpi7iH4Sb5inczxr+F/0y0DBKV6Wq/zo9ju5wdHQGEu31g==} + peerDependencies: + solid-js: ^1.6.0 + + '@tanstack/solid-store@0.5.6': + resolution: {integrity: sha512-YvRT++wB4keAnI67mlxB7FNoT9kI5+2zylDZA0b33bauOwhxVugHVvNv+tUwXCly/8G8kY8EE0peN9Wcv5vhVA==} + peerDependencies: + solid-js: ^1.6.0 + + '@tanstack/solid-table@8.20.5': + resolution: {integrity: sha512-LsB/g/24CjBpccOcok+u+tfyqtU9SIQg5wf7ne54jRdEsy5YQnrpb5ATWZileHBduIG0p/1oE7UOA+DyjtnbDQ==} + engines: {node: '>=12'} + peerDependencies: + solid-js: '>=1.3' + + '@tanstack/store@0.5.5': + resolution: {integrity: sha512-EOSrgdDAJExbvRZEQ/Xhh9iZchXpMN+ga1Bnk8Nmygzs8TfiE6hbzThF+Pr2G19uHL6+DTDTHhJ8VQiOd7l4tA==} + + '@tanstack/table-core@8.20.5': + resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==} + engines: {node: '>=12'} + + '@trysound/sax@0.2.0': + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} + engines: {node: '>=10.13.0'} + + '@tsconfig/node10@1.0.11': + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@types/acorn@4.0.6': + resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.6.8': + resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.20.6': + resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/dateformat@5.0.2': + resolution: {integrity: sha512-M95hNBMa/hnwErH+a+VOD/sYgTmo15OTYTM2Hr52/e0OdOuY+Crag+kd3/ioZrhg0WGbl9Sm3hR7UU+MH6rfOw==} + + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.6': + resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdx@2.0.13': + resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + + '@types/ms@0.7.34': + resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + + '@types/node@16.18.115': + resolution: {integrity: sha512-NF5ajYn+dq0tRfswdyp8Df75h7D9z+L8TCIwrXoh46ZLK6KZVXkRhf/luXaZytvm/keUo9vU4m1Bg39St91a5w==} + + '@types/node@17.0.45': + resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} + + '@types/node@22.8.2': + resolution: {integrity: sha512-NzaRNFV+FZkvK/KLCsNdTvID0SThyrs5SHB6tsD/lajr22FGC73N2QeDPM2wHtVde8mgcXuSsHQkH5cX1pbPLw==} + + '@types/sax@1.2.7': + resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} + + '@types/tar@6.1.13': + resolution: {integrity: sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/wicg-file-system-access@2023.10.5': + resolution: {integrity: sha512-e9kZO9kCdLqT2h9Tw38oGv9UNzBBWaR1MzuAavxPcsV/7FJ3tWbU6RI3uB+yKIDPGLkGVbplS52ub0AcRLvrhA==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@typescript-eslint/eslint-plugin@8.12.1': + resolution: {integrity: sha512-gNg/inLRcPoBsKKIe4Vv38SVSOhk4BKWNO0T56sVff33gRqtTpOsrhHtiOKD1lmIOmCtZMPaW2x/h2FlM+sCEg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@8.12.1': + resolution: {integrity: sha512-I/I9Bg7qFa8rOgBnUUHIWTgzbB5wVkSLX+04xGUzTcJUtdq/I2uHWR9mbW6qUYJG/UmkuDcTax5JHvoEWOAHOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@8.12.1': + resolution: {integrity: sha512-bma6sD1iViTt+y9MAwDlBdPTMCqoH/BNdcQk4rKhIZWv3eM0xHmzeSrPJA663PAqFqfpOmtdugycpr0E1mZDVA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/type-utils@8.12.1': + resolution: {integrity: sha512-zJzrvbDVjIzVKV2TGHcjembEhws8RWXJhmqfO9hS2gRXBN0gDwGhRPEdJ6AZglzfJ+YA1q09EWpSLSXjBJpIMQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@8.12.1': + resolution: {integrity: sha512-anMS4es5lxBe4UVcDXOkcDb3csnm5BvaNIbOFfvy/pJEohorsggdVB8MFbl5EZiEuBnZZ0ei1z7W5b6FdFiV1Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.12.1': + resolution: {integrity: sha512-k/o9khHOckPeDXilFTIPsP9iAYhhdMh3OsOL3i2072PNpFqhqzRHx472/0DeC8H/WZee3bZG0z2ddGRSPgeOKw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@8.12.1': + resolution: {integrity: sha512-sDv9yFHrhKe1WN8EYuzfhKCh/sFRupe9P+m/lZ5YgVvPoCUGHNN50IO4llSu7JAbftUM/QcCh+GeCortXPrBYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + + '@typescript-eslint/visitor-keys@8.12.1': + resolution: {integrity: sha512-2RwdwnNGuOQKdGjuhujQHUqBZhEuodg2sLVPvOfWktvA9sOXOVqARjOyHSyhN2LiJGKxV6c8oOcmOtRcAnEeFw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.2.0': + resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + + '@vitest/expect@2.1.4': + resolution: {integrity: sha512-DOETT0Oh1avie/D/o2sgMHGrzYUFFo3zqESB2Hn70z6QB1HrS2IQ9z5DfyTqU8sg4Bpu13zZe9V4+UTNQlUeQA==} + + '@vitest/mocker@2.1.4': + resolution: {integrity: sha512-Ky/O1Lc0QBbutJdW0rqLeFNbuLEyS+mIPiNdlVlp2/yhJ0SbyYqObS5IHdhferJud8MbbwMnexg4jordE5cCoQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.4': + resolution: {integrity: sha512-L95zIAkEuTDbUX1IsjRl+vyBSLh3PwLLgKpghl37aCK9Jvw0iP+wKwIFhfjdUtA2myLgjrG6VU6JCFLv8q/3Ww==} + + '@vitest/runner@2.1.4': + resolution: {integrity: sha512-sKRautINI9XICAMl2bjxQM8VfCMTB0EbsBc/EDFA57V6UQevEKY/TOPOF5nzcvCALltiLfXWbq4MaAwWx/YxIA==} + + '@vitest/snapshot@2.1.4': + resolution: {integrity: sha512-3Kab14fn/5QZRog5BPj6Rs8dc4B+mim27XaKWFWHWA87R56AKjHTGcBFKpvZKDzC4u5Wd0w/qKsUIio3KzWW4Q==} + + '@vitest/spy@2.1.4': + resolution: {integrity: sha512-4JOxa+UAizJgpZfaCPKK2smq9d8mmjZVPMt2kOsg/R8QkoRzydHH1qHxIYNvr1zlEaFj4SXiaaJWxq/LPLKaLg==} + + '@vitest/utils@2.1.4': + resolution: {integrity: sha512-MXDnZn0Awl2S86PSNIim5PWXgIAx8CIkzu35mBdSApUip6RFOGXBCf3YFyeEu8n1IHk4bWD46DeYFu9mQlFIRg==} + + '@volar/kit@2.4.8': + resolution: {integrity: sha512-HY+HTP9sSqj0St9j1N8l85YMu4w0GxCtelzkzZWuq2GVz0+QRYwlyc0mPH7749OknUAdtsdozBR5Ecez55Ncug==} + peerDependencies: + typescript: '*' + + '@volar/language-core@2.4.8': + resolution: {integrity: sha512-K/GxMOXGq997bO00cdFhTNuR85xPxj0BEEAy+BaqqayTmy9Tmhfgmq2wpJcVspRhcwfgPoE2/mEJa26emUhG/g==} + + '@volar/language-server@2.4.8': + resolution: {integrity: sha512-3Jd9Y+0Zhwi/zfdRxqoNrm7AxP6lgTsw4Ni9r6eCyWYGVsTnpVwGmlcbiZyDja6anoKZxnaeDatX1jkaHHWaRQ==} + + '@volar/language-service@2.4.8': + resolution: {integrity: sha512-9y8X4cdUxXmy4s5HoB8jmOpDIZG7XVFu4iEFvouhZlJX2leCq0pbq5h7dhA+O8My0fne3vtE6cJ4t9nc+8UBZw==} + + '@volar/source-map@2.4.8': + resolution: {integrity: sha512-jeWJBkC/WivdelMwxKkpFL811uH/jJ1kVxa+c7OvG48DXc3VrP7pplSWPP2W1dLMqBxD+awRlg55FQQfiup4cA==} + + '@volar/typescript@2.4.8': + resolution: {integrity: sha512-6xkIYJ5xxghVBhVywMoPMidDDAFT1OoQeXwa27HSgJ6AiIKRe61RXLoik+14Z7r0JvnblXVsjsRLmCr42SGzqg==} + + '@vscode/emmet-helper@2.9.3': + resolution: {integrity: sha512-rB39LHWWPQYYlYfpv9qCoZOVioPCftKXXqrsyqN1mTWZM6dTnONT63Db+03vgrBbHzJN45IrgS/AGxw9iiqfEw==} + + '@vscode/l10n@0.0.18': + resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.14.0: + resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.1: + resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} + engines: {node: '>= 14'} + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-back@3.1.0: + resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==} + engines: {node: '>=6'} + + array-back@6.2.2: + resolution: {integrity: sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==} + engines: {node: '>=12.17'} + + array-iterate@2.0.1: + resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + + astro-expressive-code@0.35.6: + resolution: {integrity: sha512-1U4KrvFuodaCV3z4I1bIR16SdhQlPkolGsYTtiANxPZUVv/KitGSCTjzksrkPonn1XuwVqvnwmUUVzTLWngnBA==} + peerDependencies: + astro: ^4.0.0-beta || ^3.3.0 + + astro-icon@1.1.1: + resolution: {integrity: sha512-HKBesWk2Faw/0+klLX+epQVqdTfSzZz/9+5vxXUjTJaN/HnpDf608gRPgHh7ZtwBPNJMEFoU5GLegxoDcT56OQ==} + + astro-robots-txt@1.0.0: + resolution: {integrity: sha512-6JQSLid4gMhoWjOm85UHLkgrw0+hHIjnJVIUqxjU2D6feKlVyYukMNYjH44ZDZBK1P8hNxd33PgWlHzCASvedA==} + + astro@4.16.7: + resolution: {integrity: sha512-nON+8MUEkWTFwXbS4zsQIq4t0Fs42eulM4x236AL+qNnWfqNAOOqAnFxO1dxfJ1q+XopIBbbT9Mtev+0zH47PQ==} + engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + autoprefixer@10.4.20: + resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + axios@1.7.7: + resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + babel-plugin-jsx-dom-expressions@0.39.3: + resolution: {integrity: sha512-6RzmSu21zYPlV2gNwzjGG9FgODtt9hIWnx7L//OIioIEuRcnpDZoY8Tr+I81Cy1SrH4qoDyKpwHHo6uAMAeyPA==} + peerDependencies: + '@babel/core': ^7.20.12 + + babel-preset-solid@1.9.3: + resolution: {integrity: sha512-jvlx5wDp8s+bEF9sGFw/84SInXOA51ttkUEroQziKMbxplXThVKt83qB6bDTa1HuLNatdU9FHpFOiQWs1tLQIg==} + peerDependencies: + '@babel/core': ^7.0.0 + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base-64@1.0.0: + resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bcp-47-match@2.0.3: + resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} + + bcp-47@2.1.0: + resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + boxen@8.0.1: + resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} + engines: {node: '>=18'} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browser-or-node@3.0.0: + resolution: {integrity: sha512-iczIdVJzGEYhP5DqQxYM9Hh7Ztpqqi+CXZpSmX8ALFs9ecXkQIeqRyM6TfxEfMVpwhl3dSuDvxdzzo9sUOIVBQ==} + + browserslist@4.24.2: + resolution: {integrity: sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + camelcase@8.0.0: + resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} + engines: {node: '>=16'} + + caniuse-lite@1.0.30001674: + resolution: {integrity: sha512-jOsKlZVRnzfhLojb+Ykb+gyUSp9Xb57So+fAiFlLzzTKpqg8xxSav0e40c8/4F/v9N8QSvrRRaLeVzQbLqomYw==} + + case-anything@2.1.13: + resolution: {integrity: sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==} + engines: {node: '>=12.13'} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@5.1.2: + resolution: {integrity: sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==} + engines: {node: '>=12'} + + chalk-template@0.4.0: + resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} + engines: {node: '>=12'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.3.0: + resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + chart.js@4.4.6: + resolution: {integrity: sha512-8Y406zevUPbbIBA/HRk33khEmQPk5+cxeflWE/2rx1NJsjVWMPw/9mSP9rxHP5eqi6LNoPBVMfZHxbwLSgldYA==} + engines: {pnpm: '>=8'} + + chartjs-chart-error-bars@4.4.3: + resolution: {integrity: sha512-oM9jmH9G4mpdEyBE8V5jxSH3NtBaL4Vymfy7yXlnkV9niijuZQCzZvN8NtXP8x3wqcrv6HhSwRLGJznVCv4uAA==} + peerDependencies: + chart.js: ^4.1.0 + + chartjs-plugin-deferred@2.0.0: + resolution: {integrity: sha512-jq6b8Wt23WS6zxiX8oVB1MXq4uaJX2KGTyiqnq6xo4ctZPgFkT/FuIEKpJjsF1WkYv7ZQrqrrRg1fLw6O5ZEfQ==} + peerDependencies: + chart.js: '>= 3.0.0' + + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.0.0: + resolution: {integrity: sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==} + engines: {node: '>=18.17'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.1: + resolution: {integrity: sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==} + engines: {node: '>= 14.16.0'} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + ci-info@4.0.0: + resolution: {integrity: sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==} + engines: {node: '>=8'} + + class-variance-authority@0.7.0: + resolution: {integrity: sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==} + + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@2.0.0: + resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} + engines: {node: '>=6'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + + collection-utils@1.0.1: + resolution: {integrity: sha512-LA2YTIlR7biSpXkKYwwuzGjwL5rjWEZVOSnvdUc7gObvWe4WkjxOpfrdhoP7Hs09YWDVfg0Mal9BpAqLfVEzQg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + command-line-args@5.2.1: + resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==} + engines: {node: '>=4.0.0'} + + command-line-usage@7.0.3: + resolution: {integrity: sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==} + engines: {node: '>=12.20.0'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + common-ancestor-path@1.0.1: + resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + cross-fetch@4.0.0: + resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==} + + cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + + css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + + css-selector-parser@3.0.5: + resolution: {integrity: sha512-3itoDFbKUNx1eKmVpYMFyqKX04Ww9osZ+dLgrk6GEv6KMVeXUhUnp4I5X+evw+u3ZxVU6RFXSSRxlTeMh8bA+g==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + cssstyle@4.1.0: + resolution: {integrity: sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA==} + engines: {node: '>=18'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + csv-parse@5.5.6: + resolution: {integrity: sha512-uNpm30m/AGSkLxxy7d9yRXpJQFrZzVWLFBkS+6ngPcZkw/5k3L/jjFuj7tVnEpRn+QgmiXr21nDlhCiUK4ij2A==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.4.3: + resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} + + decode-named-character-reference@1.0.2: + resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.0.3: + resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + engines: {node: '>=8'} + + deterministic-object-hash@2.0.2: + resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==} + engines: {node: '>=18'} + + devalue@5.1.1: + resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + + diff@5.2.0: + resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} + engines: {node: '>=0.3.1'} + + direction@2.0.1: + resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} + hasBin: true + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + + dprint-node@1.0.8: + resolution: {integrity: sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + electron-to-chromium@1.5.49: + resolution: {integrity: sha512-ZXfs1Of8fDb6z7WEYZjXpgIRF6MEu8JdeGA0A40aZq6OQbS+eJpnnV49epZRna2DU/YsEjSQuGtQPPtvt6J65A==} + + emmet@2.4.11: + resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} + + emoji-regex@10.4.0: + resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encoding-sniffer@0.2.0: + resolution: {integrity: sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==} + + end-of-stream@1.4.4: + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + es-module-lexer@1.5.4: + resolution: {integrity: sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==} + + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-scope@8.1.0: + resolution: {integrity: sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.1.0: + resolution: {integrity: sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.13.0: + resolution: {integrity: sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.2.0: + resolution: {integrity: sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-scope@1.0.0: + resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + execa@9.5.1: + resolution: {integrity: sha512-QY5PPtSonnGwhhHDNI7+3RvY285c7iuJFFB+lU+oEzMY/gEGJ808owqJsrr8Otd1E/x07po1LkUBmdAc5duPAg==} + engines: {node: ^18.19.0 || >=20.5.0} + + expect-type@1.1.0: + resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==} + engines: {node: '>=12.0.0'} + + expressive-code@0.35.6: + resolution: {integrity: sha512-+mx+TPTbMqgo0mL92Xh9QgjW0kSQIsEivMgEcOnaqKqL7qCw8Vkqc5Rg/di7ZYw4aMUSr74VTc+w8GQWu05j1g==} + + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.0.3: + resolution: {integrity: sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==} + + fastq@1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + filename-reserved-regex@3.0.0: + resolution: {integrity: sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-replace@3.0.0: + resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==} + engines: {node: '>=4.0.0'} + + find-up-simple@1.0.0: + resolution: {integrity: sha512-q7Us7kcjj2VMePAa02hDAF6d+MzsdsAWEwYyOpwUtlerRBkOEPBCRZrAV4XfcSN8fHAgaD0hP7miwoay6DCprw==} + engines: {node: '>=18'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + find-yarn-workspace-root2@1.2.16: + resolution: {integrity: sha512-hr6hb1w8ePMpPVUK39S4RlwJzi+xPLuVuG8XlwXU3KD5Yn3qgBWVfy3AzNlDhWvE1EORCE65/Qm26rFQt3VLVA==} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.1: + resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreground-child@3.3.0: + resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} + engines: {node: '>=14'} + + form-data@4.0.1: + resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} + engines: {node: '>= 6'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.3.0: + resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} + engines: {node: '>=18'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.11.0: + resolution: {integrity: sha512-yeyNSjdbyVaWurlwCpcA6XNBrHTMIeDdj0/hnvX/OLJ9ekOXYbLsLinH/MucQyGvNnXhidTdNhTtJaffL2sMfw==} + engines: {node: '>=18'} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + graphql@0.11.7: + resolution: {integrity: sha512-x7uDjyz8Jx+QPbpCFCMQ8lltnQa4p4vSYHx6ADe8rVYRTdsyhCJbvSty5DAsLVmU6cGakl+r8HQYolKHxk/tiw==} + + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + + happy-dom@15.7.4: + resolution: {integrity: sha512-r1vadDYGMtsHAAsqhDuk4IpPvr6N8MGKy5ntBo7tSdim+pWDxus2PNqOcOt8LuDZ4t3KJHE+gCuzupcx/GKnyQ==} + engines: {node: '>=18.0.0'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hast-util-embedded@3.0.0: + resolution: {integrity: sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==} + + hast-util-format@1.1.0: + resolution: {integrity: sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.1: + resolution: {integrity: sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ==} + + hast-util-has-property@3.0.0: + resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==} + + hast-util-is-body-ok-link@3.0.1: + resolution: {integrity: sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-minify-whitespace@1.0.1: + resolution: {integrity: sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-phrasing@3.0.1: + resolution: {integrity: sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==} + + hast-util-raw@9.0.4: + resolution: {integrity: sha512-LHE65TD2YiNsHD3YuXcKPHXPLuYh/gjp12mOfU8jxSrm1f/yJpsb0F/KKljS6U9LJoP0Ux+tCe8iJ2AsPzTdgA==} + + hast-util-select@6.0.3: + resolution: {integrity: sha512-OVRQlQ1XuuLP8aFVLYmC2atrfWHS5UD3shonxpnyrjcCkwtvmt/+N6kYJdcY4mkMJhxp4kj2EFIxQ9kvkkt/eQ==} + + hast-util-to-estree@3.1.0: + resolution: {integrity: sha512-lfX5g6hqVh9kjS/B9E2gSkvHH4SZNiQFiqWS0x9fENzEl+8W12RqdRxX6d/Cwxi30tPQs3bIO+aolQJNp1bIyw==} + + hast-util-to-html@9.0.3: + resolution: {integrity: sha512-M17uBDzMJ9RPCqLMO92gNNUDuBSq10a25SDBI08iCCxmorf4Yy6sYHK57n9WAbRAAaU+DuR4W6GN9K4DFZesYg==} + + hast-util-to-jsx-runtime@2.3.2: + resolution: {integrity: sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg==} + + hast-util-to-parse5@8.0.0: + resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==} + + hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@8.0.0: + resolution: {integrity: sha512-dMOtzCEd3ABUeSIISmrETiKuyydk1w0pa+gE/uormcTpSYuaNJPbX1NU3JLyscSLjwAQM8bWMhhIlnCqnRvDTw==} + + hastscript@9.0.0: + resolution: {integrity: sha512-jzaLBGavEDKHrc5EfFImKN7nZKKBdSLIdGvCwDZ9TfzbF2ffXiov8CKE445L2Z1Ek2t/m4SKQ2j6Ipv7NyUolw==} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + html-whitespace-sensitive-tag-names@3.0.1: + resolution: {integrity: sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==} + + htmlparser2@9.1.0: + resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} + + http-cache-semantics@4.1.1: + resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http-status@2.0.0: + resolution: {integrity: sha512-fmtDiMjseYigum9OwVYJD2RD6tRYV4tzRrT92/aLSOS2bNPvz3jx8aHFqJ4Nq4ZLydalwuyh2Q6VuHg7qbCbJQ==} + engines: {node: '>= 0.4.0'} + + https-proxy-agent@7.0.5: + resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==} + engines: {node: '>= 14'} + + human-signals@8.0.0: + resolution: {integrity: sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==} + engines: {node: '>=18.18.0'} + + i18next@23.16.4: + resolution: {integrity: sha512-9NIYBVy9cs4wIqzurf7nLXPyf3R78xYbxExVqHLK9od3038rjpyOEzW+XB130kZ1N4PZ9inTtJ471CRJ4Ituyg==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + + import-meta-resolve@4.1.0: + resolution: {integrity: sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inline-style-parser@0.1.1: + resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==} + + inline-style-parser@0.2.4: + resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-arrayish@0.3.2: + resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.15.1: + resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} + engines: {node: '>= 0.4'} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-url@1.2.4: + resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} + + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + + is-wsl@3.1.0: + resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterall@1.1.3: + resolution: {integrity: sha512-Cu/kb+4HiNSejAPhSaN1VukdNTTi/r4/e+yykqjlG/IW+1gZH5b4+Bq3whDX4tvbYugta3r8KTMUiqT3fIGxuQ==} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jiti@1.21.6: + resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} + hasBin: true + + jiti@2.3.3: + resolution: {integrity: sha512-EX4oNDwcXSivPrw2qKH2LB5PoFxEvgtv2JgwW0bU858HoLQ+kutSvjLMUqBd0PeJYEinLWhoI9Ol0eYMqj/wNQ==} + hasBin: true + + js-base64@3.7.7: + resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.0.2: + resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@2.3.1: + resolution: {integrity: sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + lilconfig@3.1.2: + resolution: {integrity: sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-yaml-file@0.2.0: + resolution: {integrity: sha512-OfCBkGEw4nN6JLtgRidPX6QxjBQGQf72q3si2uvqyFEMbycSFFHwAZeXx6cJgFM9wmLrf9zBwCP3Ivqa+LLZPw==} + engines: {node: '>=6'} + + local-pkg@0.5.0: + resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==} + engines: {node: '>=14'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.castarray@4.4.0: + resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + long@5.2.3: + resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + loupe@3.1.2: + resolution: {integrity: sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.12: + resolution: {integrity: sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==} + + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-definitions@6.0.0: + resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} + + mdast-util-directive@3.0.0: + resolution: {integrity: sha512-JUpYOqKI4mM3sZcNxmF/ox04XYFFkNwr0CFlrQIkCwbvH0xzMCqkMqAde9wRd80VAhaUrwFwKm2nxretdT1h7Q==} + + mdast-util-find-and-replace@3.0.1: + resolution: {integrity: sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==} + + mdast-util-from-markdown@2.0.2: + resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.0.0: + resolution: {integrity: sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.0.0: + resolution: {integrity: sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.1.3: + resolution: {integrity: sha512-bfOjvNt+1AcbPLTFMFWY149nJz0OjmewJs3LQQ5pIyVGxP4CdOqNVJL6kTaM5c68p8q82Xv3nCyFfUnuEcH3UQ==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.0: + resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} + + mdast-util-to-markdown@2.1.0: + resolution: {integrity: sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + + merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-core-commonmark@2.0.1: + resolution: {integrity: sha512-CUQyKr1e///ZODyD1U3xit6zXwy1a8q2a1S1HKtIlmgvurrEpaw/Y9y6KSIbF8P59cn/NjzHyO+Q2fAyYLQrAA==} + + micromark-extension-directive@3.0.2: + resolution: {integrity: sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.0: + resolution: {integrity: sha512-Ub2ncQv+fwD70/l4ou27b4YzfNaCJOvyX4HxXU15m7mpYY+rjuWzsLIPZHJL253Z643RpbcP1oeIJlQ/SKW67g==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-mdx-expression@3.0.0: + resolution: {integrity: sha512-sI0nwhUDz97xyzqJAbHQhp5TfaxEvZZZ2JDqUo+7NvyIYG6BZ5CPPqj2ogUoPJlmXHBnyZUzISg9+oUmU6tUjQ==} + + micromark-extension-mdx-jsx@3.0.1: + resolution: {integrity: sha512-vNuFb9czP8QCtAQcEJn0UJQJZA8Dk6DXKBqx+bg/w0WGuSxDxNr7hErW89tHUY31dUW4NqEOWwmEUNhjTFmHkg==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@2.0.0: + resolution: {integrity: sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==} + + micromark-factory-label@2.0.0: + resolution: {integrity: sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==} + + micromark-factory-mdx-expression@2.0.2: + resolution: {integrity: sha512-5E5I2pFzJyg2CtemqAbcyCktpHXuJbABnsb32wX2U8IQKhhVFBqkcZR5LRm1WVoFqa4kTueZK4abep7wdo9nrw==} + + micromark-factory-space@2.0.0: + resolution: {integrity: sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==} + + micromark-factory-title@2.0.0: + resolution: {integrity: sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==} + + micromark-factory-whitespace@2.0.0: + resolution: {integrity: sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==} + + micromark-util-character@2.1.0: + resolution: {integrity: sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==} + + micromark-util-chunked@2.0.0: + resolution: {integrity: sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==} + + micromark-util-classify-character@2.0.0: + resolution: {integrity: sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==} + + micromark-util-combine-extensions@2.0.0: + resolution: {integrity: sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==} + + micromark-util-decode-numeric-character-reference@2.0.1: + resolution: {integrity: sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ==} + + micromark-util-decode-string@2.0.0: + resolution: {integrity: sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA==} + + micromark-util-encode@2.0.0: + resolution: {integrity: sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==} + + micromark-util-events-to-acorn@2.0.2: + resolution: {integrity: sha512-Fk+xmBrOv9QZnEDguL9OI9/NQQp6Hz4FuQ4YmCb/5V7+9eAh1s6AYSvL20kHkD67YIg7EpE54TiSlcsf3vyZgA==} + + micromark-util-html-tag-name@2.0.0: + resolution: {integrity: sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==} + + micromark-util-normalize-identifier@2.0.0: + resolution: {integrity: sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==} + + micromark-util-resolve-all@2.0.0: + resolution: {integrity: sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==} + + micromark-util-sanitize-uri@2.0.0: + resolution: {integrity: sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==} + + micromark-util-subtokenize@2.0.1: + resolution: {integrity: sha512-jZNtiFl/1aY73yS3UGQkutD0UbhTt68qnRpw2Pifmz5wV9h8gOVsN70v+Lq/f1rKaU/W8pxRe8y8Q9FX1AOe1Q==} + + micromark-util-symbol@2.0.0: + resolution: {integrity: sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==} + + micromark-util-types@2.0.0: + resolution: {integrity: sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==} + + micromark@4.0.0: + resolution: {integrity: sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@4.2.8: + resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + mlly@1.7.2: + resolution: {integrity: sha512-tN3dvVHYVz4DhSXinXIk7u9syPYaJvio118uomkovAtWBT+RdbP6Lfh/5Lvo519YMmwBafwlh20IPTXIStscpA==} + + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + + mrmime@2.0.0: + resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.7: + resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanostores@0.11.3: + resolution: {integrity: sha512-TUes3xKIX33re4QzdxwZ6tdbodjmn3tWXCEc1uokiEmo14sI1EaGYNs2k3bU2pyyGNmBqFGAVl6jAGWd06AVIg==} + engines: {node: ^18.0.0 || >=20.0.0} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + neotraverse@0.6.18: + resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-releases@2.0.18: + resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nwsapi@2.2.13: + resolution: {integrity: sha512-cTGB9ptp9dY9A5VbMSe7fQBcl/tt22Vcqdq8+eN93rblOuE0aCFu4aZ2vMwct/2t+lFnosm8RkQW1I0Omb1UtQ==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + oniguruma-to-js@0.4.3: + resolution: {integrity: sha512-X0jWUcAlxORhOqqBREgPMgnshB7ZGYszBNspP+tS9hPD3l13CdaXcHbgImoHUHlrvGx/7AvFEkTRhAGYh+jzjQ==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@8.1.0: + resolution: {integrity: sha512-GQEkNkH/GHOhPFXcqZs3IDahXEQcQxsSjEkK4KvEEST4t7eNzoMjxTzef+EZ+JluDEV+Raoi3WQ2CflnRdSVnQ==} + engines: {node: '>=18'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-limit@6.1.0: + resolution: {integrity: sha512-H0jc0q1vOzlEk0TqAKXKZxdl7kX3OFUzCnNVUnq5Pc3DGo0kpeaMuPqxQn235HibwBEb0/pm9dgKTjXy66fBkg==} + engines: {node: '>=18'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-queue@8.0.1: + resolution: {integrity: sha512-NXzu9aQJTAzbBqOt2hwsR63ea7yvxJc0PwN/zobNAudYfb1B7R08SzB4TsLeSbUCuG467NhnoT0oO6w1qRO+BA==} + engines: {node: '>=18'} + + p-timeout@6.1.3: + resolution: {integrity: sha512-UJUyfKbwvr/uZSV6btANfb+0t/mOhKV/KXcCUTp8FcQI+v/0d+wXqH4htrW0E4rR6WiEO/EPvUFiV9D5OI4vlw==} + engines: {node: '>=14.16'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + package-manager-detector@0.2.2: + resolution: {integrity: sha512-VgXbyrSNsml4eHWIvxxG/nTL4wgybMTXCV2Un/+yEc3aDKKU6nQBZjbeP3Pl3qm9Qg92X/1ng4ffvCeD/zwHgg==} + + pagefind@1.1.1: + resolution: {integrity: sha512-U2YR0dQN5B2fbIXrLtt/UXNS0yWSSYfePaad1KcBPTi0p+zRtsVjwmoPaMQgTks5DnHNbmDxyJUL5TGaLljK3A==} + hasBin: true + + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-entities@4.0.1: + resolution: {integrity: sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w==} + + parse-latin@7.0.0: + resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@7.2.1: + resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-equal@1.2.5: + resolution: {integrity: sha512-i73IctDr3F2W+bsOWDyyVm/lqsXO47aY9nsFZUjTT/aljSbkxHxxCoyZ9UUrM8jK0JVod+An+rl48RCsvWM+9g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.0: + resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} + engines: {node: '>= 14.16'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pirates@4.0.6: + resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + pkg-types@1.2.1: + resolution: {integrity: sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.0.1: + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@4.0.2: + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.47: + resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==} + engines: {node: ^10 || ^12 || >=14} + + preferred-pm@4.0.0: + resolution: {integrity: sha512-gYBeFTZLu055D8Vv3cSPox/0iTPtkzxpLroSYYA7WXgRi31WCJ51Uyl8ZiPeUUjyvs2MBzK+S8v9JVUgHU/Sqw==} + engines: {node: '>=18.12'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-astro@0.14.1: + resolution: {integrity: sha512-RiBETaaP9veVstE4vUwSIcdATj6dKmXljouXc/DDNwBSPTp8FRkLGDSGFClKsAFeeg+13SB0Z1JZvbD76bigJw==} + engines: {node: ^14.15.0 || >=16.0.0} + + prettier@2.8.7: + resolution: {integrity: sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==} + engines: {node: '>=10.13.0'} + hasBin: true + + prettier@3.3.3: + resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} + engines: {node: '>=14'} + hasBin: true + + pretty-ms@9.1.0: + resolution: {integrity: sha512-o1piW0n3tgKIKCwk2vpM/vOV13zjJzvP37Ioze54YlTHE06m4tjEbzg9WsKkvTuyYln2DHjo5pY4qrZGI0otpw==} + engines: {node: '>=18'} + + prismjs@1.29.0: + resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==} + engines: {node: '>=6'} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + property-information@6.5.0: + resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} + + protobufjs@7.4.0: + resolution: {integrity: sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==} + engines: {node: '>=12.0.0'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pump@3.0.2: + resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quicktype-core@23.0.170: + resolution: {integrity: sha512-ZsjveG0yJUIijUx4yQshzyQ5EAXKbFSBTQJHnJ+KoSZVxcS+m3GcmDpzrdUIRYMhgLaF11ZGvLSYi5U0xcwemw==} + + quicktype-graphql-input@23.0.170: + resolution: {integrity: sha512-L0xPKdIFZFChwups9oqJuQw/vwEbRVKBvU9L5jAs0Z/aLyfdsuxDpKGMJXnNWa2yE7NhPX/UDX8ytxn8uc8hdQ==} + + quicktype-typescript-input@23.0.170: + resolution: {integrity: sha512-lckhc//Mc95f/puRFKv4BFs7VpUUJXhw/psh+5ZAMiErxOWgoF87XthGusmaqoXNzjmEy1AVwGgMCG2pp/tJ/w==} + + quicktype@23.0.170: + resolution: {integrity: sha512-3gFyS7w36ktxrttEv1gMfuUlGairepnSpLN0cp7JVevkKX2N6Uk8AyMlDS2Puki09MY6PB6ch90plThvACtEHA==} + engines: {node: '>=18.12.0'} + hasBin: true + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readable-stream@4.5.2: + resolution: {integrity: sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.0.2: + resolution: {integrity: sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==} + engines: {node: '>= 14.16.0'} + + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.0: + resolution: {integrity: sha512-5vwkv65qWwYxg+Atz95acp8DMu1JDSqdGkA2Of1j6rCreyFUE/gp15fC8MnGEuG1W68UKjM6x6+YTWIh7hZM/Q==} + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + regex@4.3.3: + resolution: {integrity: sha512-r/AadFO7owAq1QJVeZ/nq9jNS1vyZt+6t1p/E59B56Rn2GCya+gr1KSyOzNL/er+r+B7phv5jG2xU2Nz1YkmJg==} + + rehype-expressive-code@0.35.6: + resolution: {integrity: sha512-pPdE+pRcRw01kxMOwHQjuRxgwlblZt5+wAc3w2aPGgmcnn57wYjn07iKO7zaznDxYVxMYVvYlnL+R3vWFQS4Gw==} + + rehype-format@5.0.1: + resolution: {integrity: sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==} + + rehype-parse@9.0.1: + resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + + rehype-stringify@10.0.1: + resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} + + rehype@13.0.2: + resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==} + + remark-directive@3.0.0: + resolution: {integrity: sha512-l1UyWJ6Eg1VPU7Hm/9tt0zKtReJQNOA4+iDMAxTyZNWnJnFlbS/7zhiel/rogTLQ2vMYwDzSJa4BiVNqGlqIMA==} + + remark-gfm@4.0.0: + resolution: {integrity: sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==} + + remark-mdx@3.1.0: + resolution: {integrity: sha512-Ngl/H3YXyBV9RcRNdlYsZujAmhsxwzxpDzpDEhFBVAGthS4GDgnctpDjgFl/ULx5UEDzqtW1cyBSNKqYYrqLBA==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.1: + resolution: {integrity: sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ==} + + remark-smartypants@3.0.2: + resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==} + engines: {node: '>=16.0.0'} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + request-light@0.5.8: + resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==} + + request-light@0.7.0: + resolution: {integrity: sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + retext-latin@4.0.0: + resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + retext-stringify@4.0.0: + resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} + + retext@9.0.0: + resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.24.2: + resolution: {integrity: sha512-do/DFGq5g6rdDhdpPq5qb2ecoczeK6y+2UAjdJ5trjQJj5f1AiVdLRWRc9A9/fFukfvJRgM0UXzxBIYMovm5ww==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + s.color@0.0.15: + resolution: {integrity: sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sass-formatter@0.7.9: + resolution: {integrity: sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw==} + + sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + seroval-plugins@1.1.1: + resolution: {integrity: sha512-qNSy1+nUj7hsCOon7AO4wdAIo9P0jrzAMp18XhiOzA6/uO5TKtP7ScozVJ8T293oRIvi5wyCHSM4TrJo/c/GJA==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.1.1: + resolution: {integrity: sha512-rqEO6FZk8mv7Hyv4UCj3FD3b6Waqft605TLfsCe/BiaylRpyyMC0b+uA5TJKawX3KzMrdi3wsLbCaLplrQmBvQ==} + engines: {node: '>=10'} + + sharp@0.33.5: + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shiki@1.22.2: + resolution: {integrity: sha512-3IZau0NdGKXhH2bBlUk4w1IHNxPh6A5B2sUpyY+8utLu2j/h1QpFkAaUA1bAMxOWWGtTWcAh531vnS4NJKS/lA==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-swizzle@0.2.2: + resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sitemap@8.0.0: + resolution: {integrity: sha512-+AbdxhM9kJsHtruUF39bwS/B0Fytw6Fr1o4ZAIAEqA6cke2xcoO2GleBw9Zw7nRzILVEgz7zBM5GiTJjie1G9A==} + engines: {node: '>=14.0.0', npm: '>=6.0.0'} + hasBin: true + + solid-devtools@0.30.1: + resolution: {integrity: sha512-axpXL4JV1dnGhuei+nSGS8ewGeNkmIgFDsAlO90YyYY5t8wU1R0aYAQtL+I+5KICLKPBvfkzdcFa2br7AV4lAw==} + peerDependencies: + solid-js: ^1.8.0 + solid-start: ^0.3.0 + vite: ^2.2.3 || ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + solid-start: + optional: true + vite: + optional: true + + solid-icons@1.1.0: + resolution: {integrity: sha512-IesTfr/F1ElVwH2E1110s2RPXH4pujKfSs+koT8rwuTAdleO5s26lNSpqJV7D1+QHooJj18mcOiz2PIKs0ic+A==} + peerDependencies: + solid-js: '*' + + solid-js@1.9.3: + resolution: {integrity: sha512-5ba3taPoZGt9GY3YlsCB24kCg0Lv/rie/HTD4kG6h4daZZz7+yK02xn8Vx8dLYBc9i6Ps5JwAbEiqjmKaLB3Ag==} + + solid-presence@0.1.8: + resolution: {integrity: sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA==} + peerDependencies: + solid-js: ^1.8 + + solid-prevent-scroll@0.1.10: + resolution: {integrity: sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw==} + peerDependencies: + solid-js: ^1.8 + + solid-refresh@0.6.3: + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} + peerDependencies: + solid-js: ^1.3 + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.7.0: + resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + stream-chain@2.2.5: + resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} + + stream-json@1.8.0: + resolution: {integrity: sha512-HZfXngYHUAr1exT4fxlbc1IOce1RYxp2ldeaf97LYCOPSoOqY/1Psp7iGvpb+6JIOgkra9zDYnPX01hGAHzEPw==} + + stream-replace-string@2.0.0: + resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==} + + string-to-stream@3.0.1: + resolution: {integrity: sha512-Hl092MV3USJuUCC6mfl9sPzGloA3K5VwdIeJjYIkXY/8K+mUvaeEabWJgArp+xXrsWxCajeT2pc4axbVhIZJyg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + style-mod@4.1.2: + resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==} + + style-to-object@0.4.4: + resolution: {integrity: sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==} + + style-to-object@1.0.8: + resolution: {integrity: sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==} + + sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + suf-log@2.5.3: + resolution: {integrity: sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svgo@3.3.2: + resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==} + engines: {node: '>=14.0.0'} + hasBin: true + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + table-layout@4.1.1: + resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==} + engines: {node: '>=12.17'} + + tailwind-merge@2.5.4: + resolution: {integrity: sha512-0q8cfZHMu9nuYP/b5Shb7Y7Sh1B7Nnl5GqNr1U+n2p6+mybvRtayrQ+0042Z5byvTA8ihjlP8Odo8/VnHbZu4Q==} + + tailwindcss-animate@1.0.7: + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + + tailwindcss@3.4.14: + resolution: {integrity: sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==} + engines: {node: '>=14.0.0'} + hasBin: true + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinybench@3.0.0: + resolution: {integrity: sha512-931sGm66Zjp7c4o/DePaq8AKlCdq/ZldpS1b8O7r3SxSuxJpqoqeUprTOsW2CBhrw54U3mTmcS97LsBqPXEQLw==} + engines: {node: '>=18.0.0'} + + tinyexec@0.3.1: + resolution: {integrity: sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==} + + tinypool@1.0.1: + resolution: {integrity: sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.57: + resolution: {integrity: sha512-lXnRhuQpx3zU9EONF9F7HfcRLvN1uRYUBIiKL+C/gehC/77XTU+Jye6ui86GA3rU6FjlJ0triD1Tkjt2F/2lEg==} + + tldts@6.1.57: + resolution: {integrity: sha512-Oy7yDXK8meJl8vPMOldzA+MtueAJ5BrH4l4HXwZuj2AtfoQbLjmTJmjNWPUcAo+E/ibHn7QlqMS0BOcXJFJyHQ==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tough-cookie@5.0.0: + resolution: {integrity: sha512-FRKsF7cz96xIIeMZ82ehjC3xW2E+O2+v11udrDYewUbszngYhsGa8z6YUMMzO9QJZzzyd0nGGXnML/TReX6W8Q==} + engines: {node: '>=16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@5.0.0: + resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} + engines: {node: '>=18'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + ts-api-utils@1.3.0: + resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + ts-poet@6.9.0: + resolution: {integrity: sha512-roe6W6MeZmCjRmppyfOURklO5tQFQ6Sg7swURKkwYJvV7dbGCrK28um5+51iW3twdPRKtwarqFAVMU6G1mvnuQ==} + + ts-proto-descriptors@2.0.0: + resolution: {integrity: sha512-wHcTH3xIv11jxgkX5OyCSFfw27agpInAd6yh89hKG6zqIXnjW9SYqSER2CVQxdPj4czeOhGagNvZBEbJPy7qkw==} + + ts-proto@2.2.5: + resolution: {integrity: sha512-P7arWANOAO2Jpzhey8x55H8mnK4XHzwepQph13eNf9nu93+lAB/JUIxKaIu18YnUQBpm3ZgHL0pTVPWa6dVqrQ==} + hasBin: true + + tsconfck@3.1.4: + resolution: {integrity: sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + tslib@2.8.0: + resolution: {integrity: sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@4.26.1: + resolution: {integrity: sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==} + engines: {node: '>=16'} + + typesafe-path@0.2.2: + resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==} + + typescript-auto-import-cache@0.3.5: + resolution: {integrity: sha512-fAIveQKsoYj55CozUiBoj4b/7WpN0i4o74wiGY5JVUEoD0XiqDk1tJqTEjgzL2/AizKQrXxyRosSebyDzBZKjw==} + + typescript-eslint@8.12.1: + resolution: {integrity: sha512-SsKedZnq4TStkrpqnk+OqTnmkC9CkYBRNKjQ965CLpFruGcRkPF5UhKxbcbF6c/m2r6YAgKw/UtQxdlMjh3mug==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + typescript@4.9.4: + resolution: {integrity: sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==} + engines: {node: '>=4.2.0'} + hasBin: true + + typescript@4.9.5: + resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} + engines: {node: '>=4.2.0'} + hasBin: true + + typescript@5.6.3: + resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} + engines: {node: '>=14.17'} + hasBin: true + + typical@4.0.0: + resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==} + engines: {node: '>=8'} + + typical@7.2.0: + resolution: {integrity: sha512-W1+HdVRUl8fS3MZ9ogD51GOb46xMmhAZzR0WPw5jcgIZQJVvkddYzAl4YTU6g5w33Y1iRQLdIi2/1jhi2RNL0g==} + engines: {node: '>=12.17'} + + ufo@1.5.4: + resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} + + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + + undici@6.20.1: + resolution: {integrity: sha512-AjQF1QsmqfJys+LXfGTNum+qw4S88CojRInG/6t31W/1fk6G59s92bnAvGz5Cmur+kQv2SURXEvvudLmbrE8QA==} + engines: {node: '>=18.17'} + + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.0: + resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} + + unist-util-modify-children@4.0.0: + resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-children@3.0.0: + resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} + + unist-util-visit-parents@6.0.1: + resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} + + unist-util-visit@5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + + update-browserslist-db@1.1.1: + resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + urijs@1.19.11: + resolution: {integrity: sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@11.0.2: + resolution: {integrity: sha512-14FfcOJmqdjbBPdDjFQyk/SdT4NySW4eM0zcG+HqbHP5jzuH56xO3J1DGhgs/cEMCfwYi3HQI1gnTO62iaG+tQ==} + hasBin: true + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + valid-filename@4.0.0: + resolution: {integrity: sha512-VEYTpTVPMgO799f2wI7zWf0x2C54bPX6NAfbZ2Z8kZn76p+3rEYCTYVYzMUcVSMvakxMQTriBf24s3+WeXJtEg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + validate-html-nesting@1.2.2: + resolution: {integrity: sha512-hGdgQozCsQJMyfK5urgFcWEqsSSrK63Awe0t/IMR0bZ0QMtnuaiHzThW81guu3qx9abLi99NEuiaN6P9gVYsNg==} + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.2: + resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite-node@2.1.4: + resolution: {integrity: sha512-kqa9v+oi4HwkG6g8ufRnb5AeplcRw8jUF6/7/Qz1qRQOXHImG8YnLbB+LLszENwFnoBl9xIf9nVdCFzNd7GQEg==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite-plugin-solid@2.10.2: + resolution: {integrity: sha512-AOEtwMe2baBSXMXdo+BUwECC8IFHcKS6WQV/1NEd+Q7vHPap5fmIhLcAzr+DUJ04/KHx/1UBU0l1/GWP+rMAPQ==} + peerDependencies: + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.* + solid-js: ^1.7.2 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + + vite-tsconfig-paths@5.0.1: + resolution: {integrity: sha512-yqwv+LstU7NwPeNqajZzLEBVpUFU6Dugtb2P84FXuvaoYA+/70l9MHE+GYfYAycVyPSDYZ7mjOFuYBRqlEpTig==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + vite@5.4.10: + resolution: {integrity: sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitefu@0.2.5: + resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + vite: + optional: true + + vitefu@1.0.3: + resolution: {integrity: sha512-iKKfOMBHob2WxEJbqbJjHAkmYgvFDPhuqrO82om83S8RLk+17FtyMBfcyeH8GqD0ihShtkMW/zzJgiA51hCNCQ==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0-beta.0 + peerDependenciesMeta: + vite: + optional: true + + vitest@2.1.4: + resolution: {integrity: sha512-eDjxbVAJw1UJJCHr5xr/xM86Zx+YxIEXGAR+bmnEID7z9qWfoxpHw0zdobz+TQAFOLT+nEXz3+gx6nUJ7RgmlQ==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.4 + '@vitest/ui': 2.1.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + volar-service-css@0.0.62: + resolution: {integrity: sha512-JwNyKsH3F8PuzZYuqPf+2e+4CTU8YoyUHEHVnoXNlrLe7wy9U3biomZ56llN69Ris7TTy/+DEX41yVxQpM4qvg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-emmet@0.0.62: + resolution: {integrity: sha512-U4dxWDBWz7Pi4plpbXf4J4Z/ss6kBO3TYrACxWNsE29abu75QzVS0paxDDhI6bhqpbDFXlpsDhZ9aXVFpnfGRQ==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-html@0.0.62: + resolution: {integrity: sha512-Zw01aJsZRh4GTGUjveyfEzEqpULQUdQH79KNEiKVYHZyuGtdBRYCHlrus1sueSNMxwwkuF5WnOHfvBzafs8yyQ==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-prettier@0.0.62: + resolution: {integrity: sha512-h2yk1RqRTE+vkYZaI9KYuwpDfOQRrTEMvoHol0yW4GFKc75wWQRrb5n/5abDrzMPrkQbSip8JH2AXbvrRtYh4w==} + peerDependencies: + '@volar/language-service': ~2.4.0 + prettier: ^2.2 || ^3.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + prettier: + optional: true + + volar-service-typescript-twoslash-queries@0.0.62: + resolution: {integrity: sha512-KxFt4zydyJYYI0kFAcWPTh4u0Ha36TASPZkAnNY784GtgajerUqM80nX/W1d0wVhmcOFfAxkVsf/Ed+tiYU7ng==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-typescript@0.0.62: + resolution: {integrity: sha512-p7MPi71q7KOsH0eAbZwPBiKPp9B2+qrdHAd6VY5oTo9BUXatsOAdakTm9Yf0DUj6uWBAaOT01BSeVOPwucMV1g==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-yaml@0.0.62: + resolution: {integrity: sha512-k7gvv7sk3wa+nGll3MaSKyjwQsJjIGCHFjVkl3wjaSP2nouKyn9aokGmqjrl39mi88Oy49giog2GkZH526wjig==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + vscode-css-languageservice@6.3.1: + resolution: {integrity: sha512-1BzTBuJfwMc3A0uX4JBdJgoxp74cjj4q2mDJdp49yD/GuAq4X0k5WtK6fNcMYr+FfJ9nqgR6lpfCSZDkARJ5qQ==} + + vscode-html-languageservice@5.3.1: + resolution: {integrity: sha512-ysUh4hFeW/WOWz/TO9gm08xigiSsV/FOAZ+DolgJfeLftna54YdmZ4A+lIn46RbdO3/Qv5QHTn1ZGqmrXQhZyA==} + + vscode-json-languageservice@4.1.8: + resolution: {integrity: sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==} + engines: {npm: '>=7.0.0'} + + vscode-jsonrpc@6.0.0: + resolution: {integrity: sha512-wnJA4BnEjOSyFMvjZdpiOwhSq9uDoK8e/kpRJDTaMYzwlkrhG1fwDIZI94CLsLzlCK5cIbMMtFlJlfR57Lavmg==} + engines: {node: '>=8.0.0 || >=10.0.0'} + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.16.0: + resolution: {integrity: sha512-sdeUoAawceQdgIfTI+sdcwkiK2KU+2cbEYA0agzM2uqaUy2UpnnGHtWTHVEtS0ES4zHU0eMFRGN+oQgDxlD66A==} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.16.0: + resolution: {integrity: sha512-k8luDIWJWyenLc5ToFQQMaSrqCHiLwyKPHKPQZ5zz21vM+vIVUSvsRpcbiECH4WR88K2XZqc4ScRcZ7nk/jbeA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@7.0.0: + resolution: {integrity: sha512-60HTx5ID+fLRcgdHfmz0LDZAXYEV68fzwG0JWwEPBode9NuMYTIxuYXPg4ngO8i8+Ou0lM7y6GzaYWbiDL0drw==} + hasBin: true + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-nls@5.2.0: + resolution: {integrity: sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==} + + vscode-uri@2.1.2: + resolution: {integrity: sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==} + + vscode-uri@3.0.8: + resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.0.0: + resolution: {integrity: sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==} + engines: {node: '>=18'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-pm-runs@1.1.0: + resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} + engines: {node: '>=4'} + + which-pm@3.0.0: + resolution: {integrity: sha512-ysVYmw6+ZBhx3+ZkcPwRuJi38ZOTLJJ33PSHaitLxSKUMsh0LkKd0nC69zZCwt5D+AYUcMK2hhw4yWny20vSGg==} + engines: {node: '>=18.12'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wordwrapjs@5.1.0: + resolution: {integrity: sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg==} + engines: {node: '>=12.17'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrap-ansi@9.0.0: + resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xxhash-wasm@1.0.2: + resolution: {integrity: sha512-ibF0Or+FivM9lNrg+HGJfVX8WJqgo+kCLDc4vx6xMeTce7Aj+DLttKbxxRR/gNLSAelRc1omAPlJ77N/Jem07A==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml-language-server@1.15.0: + resolution: {integrity: sha512-N47AqBDCMQmh6mBLmI6oqxryHRzi33aPFPsJhYy3VTUGCdLHYjGh4FZzpUjRlphaADBBkDmnkM/++KNIOHi5Rw==} + hasBin: true + + yaml@2.2.2: + resolution: {integrity: sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==} + engines: {node: '>= 14'} + + yaml@2.6.0: + resolution: {integrity: sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==} + engines: {node: '>= 14'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yocto-queue@1.1.1: + resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} + engines: {node: '>=12.20'} + + yoctocolors@2.1.1: + resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==} + engines: {node: '>=18'} + + zod-to-json-schema@3.23.5: + resolution: {integrity: sha512-5wlSS0bXfF/BrL4jPAbz9da5hDlDptdEppYfe+x4eIJ7jioqKG9uUxOwPzqof09u/XeVdrgFu29lZi+8XNDJtA==} + peerDependencies: + zod: ^3.23.3 + + zod-to-ts@1.2.0: + resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==} + peerDependencies: + typescript: ^4.9.4 || ^5.0.2 + zod: ^3 + + zod@3.23.8: + resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + + '@antfu/install-pkg@0.4.1': + dependencies: + package-manager-detector: 0.2.2 + tinyexec: 0.3.1 + + '@antfu/utils@0.7.10': {} + + '@astrojs/check@0.9.4(prettier-plugin-astro@0.14.1)(prettier@3.3.3)(typescript@5.6.3)': + dependencies: + '@astrojs/language-server': 2.15.4(prettier-plugin-astro@0.14.1)(prettier@3.3.3)(typescript@5.6.3) + chokidar: 4.0.1 + kleur: 4.1.5 + typescript: 5.6.3 + yargs: 17.7.2 + transitivePeerDependencies: + - prettier + - prettier-plugin-astro + + '@astrojs/compiler@2.10.3': {} + + '@astrojs/internal-helpers@0.4.1': {} + + '@astrojs/language-server@2.15.4(prettier-plugin-astro@0.14.1)(prettier@3.3.3)(typescript@5.6.3)': + dependencies: + '@astrojs/compiler': 2.10.3 + '@astrojs/yaml2ts': 0.2.2 + '@jridgewell/sourcemap-codec': 1.5.0 + '@volar/kit': 2.4.8(typescript@5.6.3) + '@volar/language-core': 2.4.8 + '@volar/language-server': 2.4.8 + '@volar/language-service': 2.4.8 + fast-glob: 3.3.2 + muggle-string: 0.4.1 + volar-service-css: 0.0.62(@volar/language-service@2.4.8) + volar-service-emmet: 0.0.62(@volar/language-service@2.4.8) + volar-service-html: 0.0.62(@volar/language-service@2.4.8) + volar-service-prettier: 0.0.62(@volar/language-service@2.4.8)(prettier@3.3.3) + volar-service-typescript: 0.0.62(@volar/language-service@2.4.8) + volar-service-typescript-twoslash-queries: 0.0.62(@volar/language-service@2.4.8) + volar-service-yaml: 0.0.62(@volar/language-service@2.4.8) + vscode-html-languageservice: 5.3.1 + vscode-uri: 3.0.8 + optionalDependencies: + prettier: 3.3.3 + prettier-plugin-astro: 0.14.1 + transitivePeerDependencies: + - typescript + + '@astrojs/markdown-remark@5.3.0': + dependencies: + '@astrojs/prism': 3.1.0 + github-slugger: 2.0.0 + hast-util-from-html: 2.0.3 + hast-util-to-text: 4.0.2 + import-meta-resolve: 4.1.0 + mdast-util-definitions: 6.0.0 + rehype-raw: 7.0.0 + rehype-stringify: 10.0.1 + remark-gfm: 4.0.0 + remark-parse: 11.0.0 + remark-rehype: 11.1.1 + remark-smartypants: 3.0.2 + shiki: 1.22.2 + unified: 11.0.5 + unist-util-remove-position: 5.0.0 + unist-util-visit: 5.0.0 + unist-util-visit-parents: 6.0.1 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/mdx@3.1.8(astro@4.16.7(@types/node@16.18.115)(rollup@4.24.2)(typescript@4.9.4))': + dependencies: + '@astrojs/markdown-remark': 5.3.0 + '@mdx-js/mdx': 3.1.0(acorn@8.14.0) + acorn: 8.14.0 + astro: 4.16.7(@types/node@16.18.115)(rollup@4.24.2)(typescript@4.9.4) + es-module-lexer: 1.5.4 + estree-util-visit: 2.0.0 + gray-matter: 4.0.3 + hast-util-to-html: 9.0.3 + kleur: 4.1.5 + rehype-raw: 7.0.0 + remark-gfm: 4.0.0 + remark-smartypants: 3.0.2 + source-map: 0.7.4 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/mdx@3.1.8(astro@4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3))': + dependencies: + '@astrojs/markdown-remark': 5.3.0 + '@mdx-js/mdx': 3.1.0(acorn@8.14.0) + acorn: 8.14.0 + astro: 4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3) + es-module-lexer: 1.5.4 + estree-util-visit: 2.0.0 + gray-matter: 4.0.3 + hast-util-to-html: 9.0.3 + kleur: 4.1.5 + rehype-raw: 7.0.0 + remark-gfm: 4.0.0 + remark-smartypants: 3.0.2 + source-map: 0.7.4 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/prism@3.1.0': + dependencies: + prismjs: 1.29.0 + + '@astrojs/sitemap@3.2.1': + dependencies: + sitemap: 8.0.0 + stream-replace-string: 2.0.0 + zod: 3.23.8 + + '@astrojs/solid-js@4.4.2(solid-devtools@0.30.1(solid-js@1.9.3)(vite@5.4.10(@types/node@16.18.115)))(solid-js@1.9.3)(vite@5.4.10(@types/node@16.18.115))': + dependencies: + solid-js: 1.9.3 + vite-plugin-solid: 2.10.2(solid-js@1.9.3)(vite@5.4.10(@types/node@16.18.115)) + optionalDependencies: + solid-devtools: 0.30.1(solid-js@1.9.3)(vite@5.4.10(@types/node@16.18.115)) + transitivePeerDependencies: + - '@testing-library/jest-dom' + - supports-color + - vite + + '@astrojs/solid-js@4.4.2(solid-devtools@0.30.1(solid-js@1.9.3)(vite@5.4.10(@types/node@22.8.2)))(solid-js@1.9.3)(vite@5.4.10(@types/node@22.8.2))': + dependencies: + solid-js: 1.9.3 + vite-plugin-solid: 2.10.2(solid-js@1.9.3)(vite@5.4.10(@types/node@22.8.2)) + optionalDependencies: + solid-devtools: 0.30.1(solid-js@1.9.3)(vite@5.4.10(@types/node@22.8.2)) + transitivePeerDependencies: + - '@testing-library/jest-dom' + - supports-color + - vite + + '@astrojs/starlight-tailwind@2.0.3(@astrojs/starlight@0.28.4(astro@4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3)))(@astrojs/tailwind@5.1.2(astro@4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3))(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))': + dependencies: + '@astrojs/starlight': 0.28.4(astro@4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3)) + '@astrojs/tailwind': 5.1.2(astro@4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3))(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)) + tailwindcss: 3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)) + + '@astrojs/starlight@0.28.4(astro@4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3))': + dependencies: + '@astrojs/mdx': 3.1.8(astro@4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3)) + '@astrojs/sitemap': 3.2.1 + '@pagefind/default-ui': 1.1.1 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + astro: 4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3) + astro-expressive-code: 0.35.6(astro@4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3)) + bcp-47: 2.1.0 + hast-util-from-html: 2.0.3 + hast-util-select: 6.0.3 + hast-util-to-string: 3.0.1 + hastscript: 9.0.0 + i18next: 23.16.4 + mdast-util-directive: 3.0.0 + mdast-util-to-markdown: 2.1.0 + mdast-util-to-string: 4.0.0 + pagefind: 1.1.1 + rehype: 13.0.2 + rehype-format: 5.0.1 + remark-directive: 3.0.0 + unified: 11.0.5 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/tailwind@5.1.2(astro@4.16.7(@types/node@16.18.115)(rollup@4.24.2)(typescript@4.9.4))(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@16.18.115)(typescript@4.9.4)))(ts-node@10.9.2(@types/node@16.18.115)(typescript@4.9.4))': + dependencies: + astro: 4.16.7(@types/node@16.18.115)(rollup@4.24.2)(typescript@4.9.4) + autoprefixer: 10.4.20(postcss@8.4.47) + postcss: 8.4.47 + postcss-load-config: 4.0.2(postcss@8.4.47)(ts-node@10.9.2(@types/node@16.18.115)(typescript@4.9.4)) + tailwindcss: 3.4.14(ts-node@10.9.2(@types/node@16.18.115)(typescript@4.9.4)) + transitivePeerDependencies: + - ts-node + + '@astrojs/tailwind@5.1.2(astro@4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3))(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3))': + dependencies: + astro: 4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3) + autoprefixer: 10.4.20(postcss@8.4.47) + postcss: 8.4.47 + postcss-load-config: 4.0.2(postcss@8.4.47)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)) + tailwindcss: 3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)) + transitivePeerDependencies: + - ts-node + + '@astrojs/telemetry@3.1.0': + dependencies: + ci-info: 4.0.0 + debug: 4.3.7 + dlv: 1.1.3 + dset: 3.1.4 + is-docker: 3.0.0 + is-wsl: 3.1.0 + which-pm-runs: 1.1.0 + transitivePeerDependencies: + - supports-color + + '@astrojs/yaml2ts@0.2.2': + dependencies: + yaml: 2.6.0 + + '@babel/code-frame@7.26.0': + dependencies: + '@babel/helper-validator-identifier': 7.25.9 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.26.0': {} + + '@babel/core@7.26.0': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.26.0 + '@babel/generator': 7.26.0 + '@babel/helper-compilation-targets': 7.25.9 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) + '@babel/helpers': 7.26.0 + '@babel/parser': 7.26.1 + '@babel/template': 7.25.9 + '@babel/traverse': 7.25.9 + '@babel/types': 7.26.0 + convert-source-map: 2.0.0 + debug: 4.3.7 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.26.0': + dependencies: + '@babel/parser': 7.26.1 + '@babel/types': 7.26.0 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 3.0.2 + + '@babel/helper-annotate-as-pure@7.25.9': + dependencies: + '@babel/types': 7.26.0 + + '@babel/helper-compilation-targets@7.25.9': + dependencies: + '@babel/compat-data': 7.26.0 + '@babel/helper-validator-option': 7.25.9 + browserslist: 4.24.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.26.0 + + '@babel/helper-module-imports@7.25.9': + dependencies: + '@babel/traverse': 7.25.9 + '@babel/types': 7.26.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-module-imports': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + '@babel/traverse': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.25.9': {} + + '@babel/helper-string-parser@7.25.9': {} + + '@babel/helper-validator-identifier@7.25.9': {} + + '@babel/helper-validator-option@7.25.9': {} + + '@babel/helpers@7.26.0': + dependencies: + '@babel/template': 7.25.9 + '@babel/types': 7.26.0 + + '@babel/parser@7.26.1': + dependencies: + '@babel/types': 7.26.0 + + '@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + optional: true + + '@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-annotate-as-pure': 7.25.9 + '@babel/helper-module-imports': 7.25.9 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.0) + '@babel/types': 7.26.0 + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.26.0': + dependencies: + regenerator-runtime: 0.14.1 + + '@babel/template@7.25.9': + dependencies: + '@babel/code-frame': 7.26.0 + '@babel/parser': 7.26.1 + '@babel/types': 7.26.0 + + '@babel/traverse@7.25.9': + dependencies: + '@babel/code-frame': 7.26.0 + '@babel/generator': 7.26.0 + '@babel/parser': 7.26.1 + '@babel/template': 7.25.9 + '@babel/types': 7.26.0 + debug: 4.3.7 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.26.0': + dependencies: + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + + '@bufbuild/protobuf@2.2.1': {} + + '@codemirror/autocomplete@6.18.1(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.1)(@lezer/common@1.2.3)': + dependencies: + '@codemirror/language': 6.10.3 + '@codemirror/state': 6.4.1 + '@codemirror/view': 6.34.1 + '@lezer/common': 1.2.3 + + '@codemirror/commands@6.7.1': + dependencies: + '@codemirror/language': 6.10.3 + '@codemirror/state': 6.4.1 + '@codemirror/view': 6.34.1 + '@lezer/common': 1.2.3 + + '@codemirror/lang-sql@6.8.0(@codemirror/view@6.34.1)': + dependencies: + '@codemirror/autocomplete': 6.18.1(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.1)(@lezer/common@1.2.3) + '@codemirror/language': 6.10.3 + '@codemirror/state': 6.4.1 + '@lezer/common': 1.2.3 + '@lezer/highlight': 1.2.1 + '@lezer/lr': 1.4.2 + transitivePeerDependencies: + - '@codemirror/view' + + '@codemirror/language@6.10.3': + dependencies: + '@codemirror/state': 6.4.1 + '@codemirror/view': 6.34.1 + '@lezer/common': 1.2.3 + '@lezer/highlight': 1.2.1 + '@lezer/lr': 1.4.2 + style-mod: 4.1.2 + + '@codemirror/state@6.4.1': {} + + '@codemirror/view@6.34.1': + dependencies: + '@codemirror/state': 6.4.1 + style-mod: 4.1.2 + w3c-keyname: 2.2.8 + + '@corvu/resizable@0.2.3(solid-js@1.9.3)': + dependencies: + '@corvu/utils': 0.4.2(solid-js@1.9.3) + solid-js: 1.9.3 + + '@corvu/utils@0.4.2(solid-js@1.9.3)': + dependencies: + '@floating-ui/dom': 1.6.11 + solid-js: 1.9.3 + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@ctrl/tinycolor@4.1.0': {} + + '@emmetio/abbreviation@2.3.3': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-abbreviation@2.1.8': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-parser@0.4.0': + dependencies: + '@emmetio/stream-reader': 2.2.0 + '@emmetio/stream-reader-utils': 0.1.0 + + '@emmetio/html-matcher@1.3.0': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/scanner@1.0.4': {} + + '@emmetio/stream-reader-utils@0.1.0': {} + + '@emmetio/stream-reader@2.2.0': {} + + '@emnapi/runtime@1.3.1': + dependencies: + tslib: 2.8.0 + optional: true + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@eslint-community/eslint-utils@4.4.1(eslint@9.13.0(jiti@2.3.3))': + dependencies: + eslint: 9.13.0(jiti@2.3.3) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/config-array@0.18.0': + dependencies: + '@eslint/object-schema': 2.1.4 + debug: 4.3.7 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/core@0.7.0': {} + + '@eslint/eslintrc@3.1.0': + dependencies: + ajv: 6.12.6 + debug: 4.3.7 + espree: 10.2.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.13.0': {} + + '@eslint/object-schema@2.1.4': {} + + '@eslint/plugin-kit@0.2.2': + dependencies: + levn: 0.4.1 + + '@expressive-code/core@0.35.6': + dependencies: + '@ctrl/tinycolor': 4.1.0 + hast-util-select: 6.0.3 + hast-util-to-html: 9.0.3 + hast-util-to-text: 4.0.2 + hastscript: 9.0.0 + postcss: 8.4.47 + postcss-nested: 6.2.0(postcss@8.4.47) + unist-util-visit: 5.0.0 + unist-util-visit-parents: 6.0.1 + + '@expressive-code/plugin-frames@0.35.6': + dependencies: + '@expressive-code/core': 0.35.6 + + '@expressive-code/plugin-shiki@0.35.6': + dependencies: + '@expressive-code/core': 0.35.6 + shiki: 1.22.2 + + '@expressive-code/plugin-text-markers@0.35.6': + dependencies: + '@expressive-code/core': 0.35.6 + + '@floating-ui/core@1.6.8': + dependencies: + '@floating-ui/utils': 0.2.8 + + '@floating-ui/dom@1.6.11': + dependencies: + '@floating-ui/core': 1.6.8 + '@floating-ui/utils': 0.2.8 + + '@floating-ui/utils@0.2.8': {} + + '@glideapps/ts-necessities@2.2.3': {} + + '@glideapps/ts-necessities@2.3.2': {} + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.6': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.3.1 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.3.1': {} + + '@iconify-json/tabler@1.2.6': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/tools@4.0.7': + dependencies: + '@iconify/types': 2.0.0 + '@iconify/utils': 2.1.33 + '@types/tar': 6.1.13 + axios: 1.7.7 + cheerio: 1.0.0 + domhandler: 5.0.3 + extract-zip: 2.0.1 + local-pkg: 0.5.0 + pathe: 1.1.2 + svgo: 3.3.2 + tar: 6.2.1 + transitivePeerDependencies: + - debug + - supports-color + + '@iconify/types@2.0.0': {} + + '@iconify/utils@2.1.33': + dependencies: + '@antfu/install-pkg': 0.4.1 + '@antfu/utils': 0.7.10 + '@iconify/types': 2.0.0 + debug: 4.3.7 + kolorist: 1.8.0 + local-pkg: 0.5.0 + mlly: 1.7.2 + transitivePeerDependencies: + - supports-color + + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + optional: true + + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.0.5': + optional: true + + '@img/sharp-libvips-linux-s390x@1.0.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + optional: true + + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + optional: true + + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + optional: true + + '@img/sharp-linux-s390x@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.4 + optional: true + + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + optional: true + + '@img/sharp-wasm32@0.33.5': + dependencies: + '@emnapi/runtime': 1.3.1 + optional: true + + '@img/sharp-win32-ia32@0.33.5': + optional: true + + '@img/sharp-win32-x64@0.33.5': + optional: true + + '@internationalized/date@3.5.6': + dependencies: + '@swc/helpers': 0.5.13 + + '@internationalized/number@3.5.4': + dependencies: + '@swc/helpers': 0.5.13 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.5': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@kobalte/core@0.13.7(solid-js@1.9.3)': + dependencies: + '@floating-ui/dom': 1.6.11 + '@internationalized/date': 3.5.6 + '@internationalized/number': 3.5.4 + '@kobalte/utils': 0.9.1(solid-js@1.9.3) + '@solid-primitives/props': 3.1.11(solid-js@1.9.3) + '@solid-primitives/resize-observer': 2.0.26(solid-js@1.9.3) + solid-js: 1.9.3 + solid-presence: 0.1.8(solid-js@1.9.3) + solid-prevent-scroll: 0.1.10(solid-js@1.9.3) + + '@kobalte/utils@0.9.1(solid-js@1.9.3)': + dependencies: + '@solid-primitives/event-listener': 2.3.3(solid-js@1.9.3) + '@solid-primitives/keyed': 1.2.3(solid-js@1.9.3) + '@solid-primitives/map': 0.4.13(solid-js@1.9.3) + '@solid-primitives/media': 2.2.9(solid-js@1.9.3) + '@solid-primitives/props': 3.1.11(solid-js@1.9.3) + '@solid-primitives/refs': 1.0.8(solid-js@1.9.3) + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@kurkle/color@0.3.2': {} + + '@lezer/common@1.2.3': {} + + '@lezer/highlight@1.2.1': + dependencies: + '@lezer/common': 1.2.3 + + '@lezer/lr@1.4.2': + dependencies: + '@lezer/common': 1.2.3 + + '@mark.probst/typescript-json-schema@0.55.0': + dependencies: + '@types/json-schema': 7.0.15 + '@types/node': 16.18.115 + glob: 7.2.3 + path-equal: 1.2.5 + safe-stable-stringify: 2.5.0 + ts-node: 10.9.2(@types/node@16.18.115)(typescript@4.9.4) + typescript: 4.9.4 + yargs: 17.7.2 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + + '@mdx-js/mdx@3.1.0(acorn@8.14.0)': + dependencies: + '@types/estree': 1.0.6 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdx': 2.0.13 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.0 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.2 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.0(acorn@8.14.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.0 + remark-parse: 11.0.0 + remark-rehype: 11.1.1 + source-map: 0.7.4 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + transitivePeerDependencies: + - acorn + - supports-color + + '@nanostores/persistent@0.10.2(nanostores@0.11.3)': + dependencies: + nanostores: 0.11.3 + + '@nanostores/solid@0.5.0(nanostores@0.11.3)(solid-js@1.9.3)': + dependencies: + nanostores: 0.11.3 + solid-js: 1.9.3 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + + '@nothing-but/utils@0.12.1': + optional: true + + '@oslojs/encoding@1.1.0': {} + + '@pagefind/darwin-arm64@1.1.1': + optional: true + + '@pagefind/darwin-x64@1.1.1': + optional: true + + '@pagefind/default-ui@1.1.1': {} + + '@pagefind/linux-arm64@1.1.1': + optional: true + + '@pagefind/linux-x64@1.1.1': + optional: true + + '@pagefind/windows-x64@1.1.1': + optional: true + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + + '@rollup/pluginutils@5.1.3(rollup@4.24.2)': + dependencies: + '@types/estree': 1.0.6 + estree-walker: 2.0.2 + picomatch: 4.0.2 + optionalDependencies: + rollup: 4.24.2 + + '@rollup/rollup-android-arm-eabi@4.24.2': + optional: true + + '@rollup/rollup-android-arm64@4.24.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.24.2': + optional: true + + '@rollup/rollup-darwin-x64@4.24.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.24.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.24.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.24.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.24.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.24.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.24.2': + optional: true + + '@rollup/rollup-linux-powerpc64le-gnu@4.24.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.24.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.24.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.24.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.24.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.24.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.24.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.24.2': + optional: true + + '@sec-ant/readable-stream@0.4.1': {} + + '@shikijs/core@1.22.2': + dependencies: + '@shikijs/engine-javascript': 1.22.2 + '@shikijs/engine-oniguruma': 1.22.2 + '@shikijs/types': 1.22.2 + '@shikijs/vscode-textmate': 9.3.0 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.3 + + '@shikijs/engine-javascript@1.22.2': + dependencies: + '@shikijs/types': 1.22.2 + '@shikijs/vscode-textmate': 9.3.0 + oniguruma-to-js: 0.4.3 + + '@shikijs/engine-oniguruma@1.22.2': + dependencies: + '@shikijs/types': 1.22.2 + '@shikijs/vscode-textmate': 9.3.0 + + '@shikijs/types@1.22.2': + dependencies: + '@shikijs/vscode-textmate': 9.3.0 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@9.3.0': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@solid-devtools/debugger@0.23.4(solid-js@1.9.3)': + dependencies: + '@nothing-but/utils': 0.12.1 + '@solid-devtools/shared': 0.13.2(solid-js@1.9.3) + '@solid-primitives/bounds': 0.0.118(solid-js@1.9.3) + '@solid-primitives/cursor': 0.0.112(solid-js@1.9.3) + '@solid-primitives/event-bus': 1.0.11(solid-js@1.9.3) + '@solid-primitives/event-listener': 2.3.3(solid-js@1.9.3) + '@solid-primitives/keyboard': 1.2.8(solid-js@1.9.3) + '@solid-primitives/platform': 0.1.2(solid-js@1.9.3) + '@solid-primitives/rootless': 1.4.5(solid-js@1.9.3) + '@solid-primitives/scheduled': 1.4.4(solid-js@1.9.3) + '@solid-primitives/static-store': 0.0.5(solid-js@1.9.3) + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + optional: true + + '@solid-devtools/shared@0.13.2(solid-js@1.9.3)': + dependencies: + '@solid-primitives/event-bus': 1.0.11(solid-js@1.9.3) + '@solid-primitives/event-listener': 2.3.3(solid-js@1.9.3) + '@solid-primitives/media': 2.2.9(solid-js@1.9.3) + '@solid-primitives/refs': 1.0.8(solid-js@1.9.3) + '@solid-primitives/rootless': 1.4.5(solid-js@1.9.3) + '@solid-primitives/scheduled': 1.4.4(solid-js@1.9.3) + '@solid-primitives/static-store': 0.0.5(solid-js@1.9.3) + '@solid-primitives/styles': 0.0.111(solid-js@1.9.3) + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + optional: true + + '@solid-primitives/bounds@0.0.118(solid-js@1.9.3)': + dependencies: + '@solid-primitives/event-listener': 2.3.3(solid-js@1.9.3) + '@solid-primitives/resize-observer': 2.0.26(solid-js@1.9.3) + '@solid-primitives/static-store': 0.0.5(solid-js@1.9.3) + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + optional: true + + '@solid-primitives/cursor@0.0.112(solid-js@1.9.3)': + dependencies: + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + optional: true + + '@solid-primitives/event-bus@1.0.11(solid-js@1.9.3)': + dependencies: + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + optional: true + + '@solid-primitives/event-listener@2.3.3(solid-js@1.9.3)': + dependencies: + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/keyboard@1.2.8(solid-js@1.9.3)': + dependencies: + '@solid-primitives/event-listener': 2.3.3(solid-js@1.9.3) + '@solid-primitives/rootless': 1.4.5(solid-js@1.9.3) + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + optional: true + + '@solid-primitives/keyed@1.2.3(solid-js@1.9.3)': + dependencies: + solid-js: 1.9.3 + + '@solid-primitives/map@0.4.13(solid-js@1.9.3)': + dependencies: + '@solid-primitives/trigger': 1.1.0(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/media@2.2.9(solid-js@1.9.3)': + dependencies: + '@solid-primitives/event-listener': 2.3.3(solid-js@1.9.3) + '@solid-primitives/rootless': 1.4.5(solid-js@1.9.3) + '@solid-primitives/static-store': 0.0.8(solid-js@1.9.3) + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/memo@1.3.10(solid-js@1.9.3)': + dependencies: + '@solid-primitives/scheduled': 1.4.4(solid-js@1.9.3) + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/platform@0.1.2(solid-js@1.9.3)': + dependencies: + solid-js: 1.9.3 + optional: true + + '@solid-primitives/props@3.1.11(solid-js@1.9.3)': + dependencies: + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/refs@1.0.8(solid-js@1.9.3)': + dependencies: + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/resize-observer@2.0.26(solid-js@1.9.3)': + dependencies: + '@solid-primitives/event-listener': 2.3.3(solid-js@1.9.3) + '@solid-primitives/rootless': 1.4.5(solid-js@1.9.3) + '@solid-primitives/static-store': 0.0.8(solid-js@1.9.3) + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/rootless@1.4.5(solid-js@1.9.3)': + dependencies: + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/scheduled@1.4.4(solid-js@1.9.3)': + dependencies: + solid-js: 1.9.3 + + '@solid-primitives/static-store@0.0.5(solid-js@1.9.3)': + dependencies: + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + optional: true + + '@solid-primitives/static-store@0.0.8(solid-js@1.9.3)': + dependencies: + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/styles@0.0.111(solid-js@1.9.3)': + dependencies: + '@solid-primitives/rootless': 1.4.5(solid-js@1.9.3) + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + optional: true + + '@solid-primitives/trigger@1.1.0(solid-js@1.9.3)': + dependencies: + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/utils@6.2.3(solid-js@1.9.3)': + dependencies: + solid-js: 1.9.3 + + '@solidjs/router@0.14.10(solid-js@1.9.3)': + dependencies: + solid-js: 1.9.3 + + '@swc/helpers@0.5.13': + dependencies: + tslib: 2.8.0 + + '@tailwindcss/typography@0.5.15(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@16.18.115)(typescript@4.9.4)))': + dependencies: + lodash.castarray: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.merge: 4.6.2 + postcss-selector-parser: 6.0.10 + tailwindcss: 3.4.14(ts-node@10.9.2(@types/node@16.18.115)(typescript@4.9.4)) + + '@tailwindcss/typography@0.5.15(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))': + dependencies: + lodash.castarray: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.merge: 4.6.2 + postcss-selector-parser: 6.0.10 + tailwindcss: 3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)) + + '@tanstack/form-core@0.34.1': + dependencies: + '@tanstack/store': 0.5.5 + + '@tanstack/query-core@5.59.16': {} + + '@tanstack/solid-form@0.34.1(solid-js@1.9.3)': + dependencies: + '@tanstack/form-core': 0.34.1 + '@tanstack/solid-store': 0.5.6(solid-js@1.9.3) + solid-js: 1.9.3 + + '@tanstack/solid-query@5.59.16(solid-js@1.9.3)': + dependencies: + '@tanstack/query-core': 5.59.16 + solid-js: 1.9.3 + + '@tanstack/solid-store@0.5.6(solid-js@1.9.3)': + dependencies: + '@tanstack/store': 0.5.5 + solid-js: 1.9.3 + + '@tanstack/solid-table@8.20.5(solid-js@1.9.3)': + dependencies: + '@tanstack/table-core': 8.20.5 + solid-js: 1.9.3 + + '@tanstack/store@0.5.5': {} + + '@tanstack/table-core@8.20.5': {} + + '@trysound/sax@0.2.0': {} + + '@tsconfig/node10@1.0.11': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@types/acorn@4.0.6': + dependencies: + '@types/estree': 1.0.6 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.26.1 + '@babel/types': 7.26.0 + '@types/babel__generator': 7.6.8 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.6 + + '@types/babel__generator@7.6.8': + dependencies: + '@babel/types': 7.26.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.26.1 + '@babel/types': 7.26.0 + + '@types/babel__traverse@7.20.6': + dependencies: + '@babel/types': 7.26.0 + + '@types/cookie@0.6.0': {} + + '@types/dateformat@5.0.2': {} + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 0.7.34 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.6 + + '@types/estree@1.0.6': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/json-schema@7.0.15': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdx@2.0.13': {} + + '@types/ms@0.7.34': {} + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + + '@types/node@16.18.115': {} + + '@types/node@17.0.45': {} + + '@types/node@22.8.2': + dependencies: + undici-types: 6.19.8 + + '@types/sax@1.2.7': + dependencies: + '@types/node': 22.8.2 + + '@types/tar@6.1.13': + dependencies: + '@types/node': 22.8.2 + minipass: 4.2.8 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@types/wicg-file-system-access@2023.10.5': {} + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 22.8.2 + optional: true + + '@typescript-eslint/eslint-plugin@8.12.1(@typescript-eslint/parser@8.12.1(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 8.12.1(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) + '@typescript-eslint/scope-manager': 8.12.1 + '@typescript-eslint/type-utils': 8.12.1(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) + '@typescript-eslint/utils': 8.12.1(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 8.12.1 + eslint: 9.13.0(jiti@2.3.3) + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.3.0(typescript@5.6.3) + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.12.1(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.12.1 + '@typescript-eslint/types': 8.12.1 + '@typescript-eslint/typescript-estree': 8.12.1(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 8.12.1 + debug: 4.3.7 + eslint: 9.13.0(jiti@2.3.3) + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.12.1': + dependencies: + '@typescript-eslint/types': 8.12.1 + '@typescript-eslint/visitor-keys': 8.12.1 + + '@typescript-eslint/type-utils@8.12.1(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3)': + dependencies: + '@typescript-eslint/typescript-estree': 8.12.1(typescript@5.6.3) + '@typescript-eslint/utils': 8.12.1(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) + debug: 4.3.7 + ts-api-utils: 1.3.0(typescript@5.6.3) + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - eslint + - supports-color + + '@typescript-eslint/types@8.12.1': {} + + '@typescript-eslint/typescript-estree@8.12.1(typescript@5.6.3)': + dependencies: + '@typescript-eslint/types': 8.12.1 + '@typescript-eslint/visitor-keys': 8.12.1 + debug: 4.3.7 + fast-glob: 3.3.2 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.6.3 + ts-api-utils: 1.3.0(typescript@5.6.3) + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.12.1(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3)': + dependencies: + '@eslint-community/eslint-utils': 4.4.1(eslint@9.13.0(jiti@2.3.3)) + '@typescript-eslint/scope-manager': 8.12.1 + '@typescript-eslint/types': 8.12.1 + '@typescript-eslint/typescript-estree': 8.12.1(typescript@5.6.3) + eslint: 9.13.0(jiti@2.3.3) + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@8.12.1': + dependencies: + '@typescript-eslint/types': 8.12.1 + eslint-visitor-keys: 3.4.3 + + '@ungap/structured-clone@1.2.0': {} + + '@vitest/expect@2.1.4': + dependencies: + '@vitest/spy': 2.1.4 + '@vitest/utils': 2.1.4 + chai: 5.1.2 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.4(vite@5.4.10(@types/node@22.8.2))': + dependencies: + '@vitest/spy': 2.1.4 + estree-walker: 3.0.3 + magic-string: 0.30.12 + optionalDependencies: + vite: 5.4.10(@types/node@22.8.2) + + '@vitest/pretty-format@2.1.4': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.4': + dependencies: + '@vitest/utils': 2.1.4 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.4': + dependencies: + '@vitest/pretty-format': 2.1.4 + magic-string: 0.30.12 + pathe: 1.1.2 + + '@vitest/spy@2.1.4': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.4': + dependencies: + '@vitest/pretty-format': 2.1.4 + loupe: 3.1.2 + tinyrainbow: 1.2.0 + + '@volar/kit@2.4.8(typescript@5.6.3)': + dependencies: + '@volar/language-service': 2.4.8 + '@volar/typescript': 2.4.8 + typesafe-path: 0.2.2 + typescript: 5.6.3 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.0.8 + + '@volar/language-core@2.4.8': + dependencies: + '@volar/source-map': 2.4.8 + + '@volar/language-server@2.4.8': + dependencies: + '@volar/language-core': 2.4.8 + '@volar/language-service': 2.4.8 + '@volar/typescript': 2.4.8 + path-browserify: 1.0.1 + request-light: 0.7.0 + vscode-languageserver: 9.0.1 + vscode-languageserver-protocol: 3.17.5 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.0.8 + + '@volar/language-service@2.4.8': + dependencies: + '@volar/language-core': 2.4.8 + vscode-languageserver-protocol: 3.17.5 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.0.8 + + '@volar/source-map@2.4.8': {} + + '@volar/typescript@2.4.8': + dependencies: + '@volar/language-core': 2.4.8 + path-browserify: 1.0.1 + vscode-uri: 3.0.8 + + '@vscode/emmet-helper@2.9.3': + dependencies: + emmet: 2.4.11 + jsonc-parser: 2.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 2.1.2 + + '@vscode/l10n@0.0.18': {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + acorn-jsx@5.3.2(acorn@8.14.0): + dependencies: + acorn: 8.14.0 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.14.0 + + acorn@8.14.0: {} + + agent-base@7.1.1: + dependencies: + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.0.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-regex@5.0.1: {} + + ansi-regex@6.1.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.1: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@4.1.3: {} + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-back@3.1.0: {} + + array-back@6.2.2: {} + + array-iterate@2.0.1: {} + + assertion-error@2.0.1: {} + + astring@1.9.0: {} + + astro-expressive-code@0.35.6(astro@4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3)): + dependencies: + astro: 4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3) + rehype-expressive-code: 0.35.6 + + astro-icon@1.1.1: + dependencies: + '@iconify/tools': 4.0.7 + '@iconify/types': 2.0.0 + '@iconify/utils': 2.1.33 + transitivePeerDependencies: + - debug + - supports-color + + astro-robots-txt@1.0.0: + dependencies: + valid-filename: 4.0.0 + zod: 3.23.8 + + astro@4.16.7(@types/node@16.18.115)(rollup@4.24.2)(typescript@4.9.4): + dependencies: + '@astrojs/compiler': 2.10.3 + '@astrojs/internal-helpers': 0.4.1 + '@astrojs/markdown-remark': 5.3.0 + '@astrojs/telemetry': 3.1.0 + '@babel/core': 7.26.0 + '@babel/plugin-transform-react-jsx': 7.25.9(@babel/core@7.26.0) + '@babel/types': 7.26.0 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.1.3(rollup@4.24.2) + '@types/babel__core': 7.20.5 + '@types/cookie': 0.6.0 + acorn: 8.14.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + boxen: 8.0.1 + ci-info: 4.0.0 + clsx: 2.1.1 + common-ancestor-path: 1.0.1 + cookie: 0.7.2 + cssesc: 3.0.0 + debug: 4.3.7 + deterministic-object-hash: 2.0.2 + devalue: 5.1.1 + diff: 5.2.0 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 1.5.4 + esbuild: 0.21.5 + estree-walker: 3.0.3 + fast-glob: 3.3.2 + flattie: 1.1.1 + github-slugger: 2.0.0 + gray-matter: 4.0.3 + html-escaper: 3.0.3 + http-cache-semantics: 4.1.1 + js-yaml: 4.1.0 + kleur: 4.1.5 + magic-string: 0.30.12 + magicast: 0.3.5 + micromatch: 4.0.8 + mrmime: 2.0.0 + neotraverse: 0.6.18 + ora: 8.1.0 + p-limit: 6.1.0 + p-queue: 8.0.1 + preferred-pm: 4.0.0 + prompts: 2.4.2 + rehype: 13.0.2 + semver: 7.6.3 + shiki: 1.22.2 + tinyexec: 0.3.1 + tsconfck: 3.1.4(typescript@4.9.4) + unist-util-visit: 5.0.0 + vfile: 6.0.3 + vite: 5.4.10(@types/node@16.18.115) + vitefu: 1.0.3(vite@5.4.10(@types/node@16.18.115)) + which-pm: 3.0.0 + xxhash-wasm: 1.0.2 + yargs-parser: 21.1.1 + zod: 3.23.8 + zod-to-json-schema: 3.23.5(zod@3.23.8) + zod-to-ts: 1.2.0(typescript@4.9.4)(zod@3.23.8) + optionalDependencies: + sharp: 0.33.5 + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - typescript + + astro@4.16.7(@types/node@22.8.2)(rollup@4.24.2)(typescript@5.6.3): + dependencies: + '@astrojs/compiler': 2.10.3 + '@astrojs/internal-helpers': 0.4.1 + '@astrojs/markdown-remark': 5.3.0 + '@astrojs/telemetry': 3.1.0 + '@babel/core': 7.26.0 + '@babel/plugin-transform-react-jsx': 7.25.9(@babel/core@7.26.0) + '@babel/types': 7.26.0 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.1.3(rollup@4.24.2) + '@types/babel__core': 7.20.5 + '@types/cookie': 0.6.0 + acorn: 8.14.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + boxen: 8.0.1 + ci-info: 4.0.0 + clsx: 2.1.1 + common-ancestor-path: 1.0.1 + cookie: 0.7.2 + cssesc: 3.0.0 + debug: 4.3.7 + deterministic-object-hash: 2.0.2 + devalue: 5.1.1 + diff: 5.2.0 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 1.5.4 + esbuild: 0.21.5 + estree-walker: 3.0.3 + fast-glob: 3.3.2 + flattie: 1.1.1 + github-slugger: 2.0.0 + gray-matter: 4.0.3 + html-escaper: 3.0.3 + http-cache-semantics: 4.1.1 + js-yaml: 4.1.0 + kleur: 4.1.5 + magic-string: 0.30.12 + magicast: 0.3.5 + micromatch: 4.0.8 + mrmime: 2.0.0 + neotraverse: 0.6.18 + ora: 8.1.0 + p-limit: 6.1.0 + p-queue: 8.0.1 + preferred-pm: 4.0.0 + prompts: 2.4.2 + rehype: 13.0.2 + semver: 7.6.3 + shiki: 1.22.2 + tinyexec: 0.3.1 + tsconfck: 3.1.4(typescript@5.6.3) + unist-util-visit: 5.0.0 + vfile: 6.0.3 + vite: 5.4.10(@types/node@22.8.2) + vitefu: 1.0.3(vite@5.4.10(@types/node@22.8.2)) + which-pm: 3.0.0 + xxhash-wasm: 1.0.2 + yargs-parser: 21.1.1 + zod: 3.23.8 + zod-to-json-schema: 3.23.5(zod@3.23.8) + zod-to-ts: 1.2.0(typescript@5.6.3)(zod@3.23.8) + optionalDependencies: + sharp: 0.33.5 + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - typescript + + asynckit@0.4.0: {} + + autoprefixer@10.4.20(postcss@8.4.47): + dependencies: + browserslist: 4.24.2 + caniuse-lite: 1.0.30001674 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + axios@1.7.7: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.1 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + axobject-query@4.1.0: {} + + babel-plugin-jsx-dom-expressions@0.39.3(@babel/core@7.26.0): + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.0) + '@babel/types': 7.26.0 + html-entities: 2.3.3 + parse5: 7.2.1 + validate-html-nesting: 1.2.2 + + babel-preset-solid@1.9.3(@babel/core@7.26.0): + dependencies: + '@babel/core': 7.26.0 + babel-plugin-jsx-dom-expressions: 0.39.3(@babel/core@7.26.0) + + bail@2.0.2: {} + + balanced-match@1.0.2: {} + + base-64@1.0.0: {} + + base64-js@1.5.1: {} + + bcp-47-match@2.0.3: {} + + bcp-47@2.1.0: + dependencies: + is-alphabetical: 2.0.1 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + + binary-extensions@2.3.0: {} + + boolbase@1.0.0: {} + + boxen@8.0.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 8.0.0 + chalk: 5.3.0 + cli-boxes: 3.0.0 + string-width: 7.2.0 + type-fest: 4.26.1 + widest-line: 5.0.0 + wrap-ansi: 9.0.0 + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browser-or-node@3.0.0: {} + + browserslist@4.24.2: + dependencies: + caniuse-lite: 1.0.30001674 + electron-to-chromium: 1.5.49 + node-releases: 2.0.18 + update-browserslist-db: 1.1.1(browserslist@4.24.2) + + buffer-crc32@0.2.13: {} + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + cac@6.7.14: {} + + callsites@3.1.0: {} + + camelcase-css@2.0.1: {} + + camelcase@8.0.0: {} + + caniuse-lite@1.0.30001674: {} + + case-anything@2.1.13: {} + + ccount@2.0.1: {} + + chai@5.1.2: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.2 + pathval: 2.0.0 + + chalk-template@0.4.0: + dependencies: + chalk: 4.1.2 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.3.0: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chart.js@4.4.6: + dependencies: + '@kurkle/color': 0.3.2 + + chartjs-chart-error-bars@4.4.3(chart.js@4.4.6): + dependencies: + chart.js: 4.4.6 + + chartjs-plugin-deferred@2.0.0(chart.js@4.4.6): + dependencies: + chart.js: 4.4.6 + + check-error@2.1.1: {} + + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.1.0 + css-what: 6.1.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + + cheerio@1.0.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.1.0 + encoding-sniffer: 0.2.0 + htmlparser2: 9.1.0 + parse5: 7.2.1 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 6.20.1 + whatwg-mimetype: 4.0.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.1: + dependencies: + readdirp: 4.0.2 + + chownr@2.0.0: {} + + ci-info@4.0.0: {} + + class-variance-authority@0.7.0: + dependencies: + clsx: 2.0.0 + + cli-boxes@3.0.0: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@2.0.0: {} + + clsx@2.1.1: {} + + collapse-white-space@2.1.0: {} + + collection-utils@1.0.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + comma-separated-tokens@2.0.3: {} + + command-line-args@5.2.1: + dependencies: + array-back: 3.1.0 + find-replace: 3.0.0 + lodash.camelcase: 4.3.0 + typical: 4.0.0 + + command-line-usage@7.0.3: + dependencies: + array-back: 6.2.2 + chalk-template: 0.4.0 + table-layout: 4.1.1 + typical: 7.2.0 + + commander@4.1.1: {} + + commander@7.2.0: {} + + common-ancestor-path@1.0.1: {} + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + convert-source-map@2.0.0: {} + + cookie@0.7.2: {} + + create-require@1.1.1: {} + + cross-fetch@4.0.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + cross-spawn@7.0.3: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-select@5.1.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.1.0 + nth-check: 2.1.1 + + css-selector-parser@3.0.5: {} + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.1 + + css-what@6.1.0: {} + + cssesc@3.0.0: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + cssstyle@4.1.0: + dependencies: + rrweb-cssom: 0.7.1 + + csstype@3.1.3: {} + + csv-parse@5.5.6: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.0.0 + + debug@4.3.7: + dependencies: + ms: 2.1.3 + + decimal.js@10.4.3: {} + + decode-named-character-reference@1.0.2: + dependencies: + character-entities: 2.0.2 + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + detect-libc@1.0.3: {} + + detect-libc@2.0.3: {} + + deterministic-object-hash@2.0.2: + dependencies: + base-64: 1.0.0 + + devalue@5.1.1: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + didyoumean@1.2.2: {} + + diff@4.0.2: {} + + diff@5.2.0: {} + + direction@2.0.1: {} + + dlv@1.1.3: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.1.0: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dprint-node@1.0.8: + dependencies: + detect-libc: 1.0.3 + + dset@3.1.4: {} + + eastasianwidth@0.2.0: {} + + electron-to-chromium@1.5.49: {} + + emmet@2.4.11: + dependencies: + '@emmetio/abbreviation': 2.3.3 + '@emmetio/css-abbreviation': 2.1.8 + + emoji-regex@10.4.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encoding-sniffer@0.2.0: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + + end-of-stream@1.4.4: + dependencies: + once: 1.4.0 + + entities@4.5.0: {} + + es-module-lexer@1.5.4: {} + + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.14.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.2 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-scope@8.1.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.1.0: {} + + eslint@9.13.0(jiti@2.3.3): + dependencies: + '@eslint-community/eslint-utils': 4.4.1(eslint@9.13.0(jiti@2.3.3)) + '@eslint-community/regexpp': 4.12.1 + '@eslint/config-array': 0.18.0 + '@eslint/core': 0.7.0 + '@eslint/eslintrc': 3.1.0 + '@eslint/js': 9.13.0 + '@eslint/plugin-kit': 0.2.2 + '@humanfs/node': 0.16.6 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.3.1 + '@types/estree': 1.0.6 + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.7 + escape-string-regexp: 4.0.0 + eslint-scope: 8.1.0 + eslint-visitor-keys: 4.1.0 + espree: 10.2.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + text-table: 0.2.0 + optionalDependencies: + jiti: 2.3.3 + transitivePeerDependencies: + - supports-color + + espree@10.2.0: + dependencies: + acorn: 8.14.0 + acorn-jsx: 5.3.2(acorn@8.14.0) + eslint-visitor-keys: 4.1.0 + + esprima@4.0.1: {} + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.6 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + + estree-util-is-identifier-name@3.0.0: {} + + estree-util-scope@1.0.0: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.4 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.6 + + esutils@2.0.3: {} + + event-target-shim@5.0.1: {} + + eventemitter3@5.0.1: {} + + events@3.3.0: {} + + execa@9.5.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.3 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.0 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.1.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.1 + + expect-type@1.1.0: {} + + expressive-code@0.35.6: + dependencies: + '@expressive-code/core': 0.35.6 + '@expressive-code/plugin-frames': 0.35.6 + '@expressive-code/plugin-shiki': 0.35.6 + '@expressive-code/plugin-text-markers': 0.35.6 + + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + + extend@3.0.2: {} + + extract-zip@2.0.1: + dependencies: + debug: 4.3.7 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.2: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.0.3: {} + + fastq@1.17.1: + dependencies: + reusify: 1.0.4 + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + filename-reserved-regex@3.0.0: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-replace@3.0.0: + dependencies: + array-back: 3.1.0 + + find-up-simple@1.0.0: {} + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + find-yarn-workspace-root2@1.2.16: + dependencies: + micromatch: 4.0.8 + pkg-dir: 4.2.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.1 + keyv: 4.5.4 + + flatted@3.3.1: {} + + flattie@1.1.1: {} + + follow-redirects@1.15.9: {} + + foreground-child@3.3.0: + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + + form-data@4.0.1: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + + fraction.js@4.3.7: {} + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.3.0: {} + + get-stream@5.2.0: + dependencies: + pump: 3.0.2 + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + github-slugger@2.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.4.5: + dependencies: + foreground-child: 3.3.0 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@11.12.0: {} + + globals@14.0.0: {} + + globals@15.11.0: {} + + globrex@0.1.2: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + graphql@0.11.7: + dependencies: + iterall: 1.1.3 + + gray-matter@4.0.3: + dependencies: + js-yaml: 3.14.1 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + + happy-dom@15.7.4: + dependencies: + entities: 4.5.0 + webidl-conversions: 7.0.0 + whatwg-mimetype: 3.0.0 + optional: true + + has-flag@4.0.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hast-util-embedded@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-is-element: 3.0.0 + + hast-util-format@1.1.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-embedded: 3.0.0 + hast-util-minify-whitespace: 1.0.1 + hast-util-phrasing: 3.0.1 + hast-util-whitespace: 3.0.0 + html-whitespace-sensitive-tag-names: 3.0.1 + unist-util-visit-parents: 6.0.1 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.1 + parse5: 7.2.1 + vfile: 6.0.3 + vfile-message: 4.0.2 + + hast-util-from-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 8.0.0 + property-information: 6.5.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-has-property@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-is-body-ok-link@3.0.1: + dependencies: + '@types/hast': 3.0.4 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-minify-whitespace@1.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-embedded: 3.0.0 + hast-util-is-element: 3.0.0 + hast-util-whitespace: 3.0.0 + unist-util-is: 6.0.0 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-phrasing@3.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-embedded: 3.0.0 + hast-util-has-property: 3.0.0 + hast-util-is-body-ok-link: 3.0.1 + hast-util-is-element: 3.0.0 + + hast-util-raw@9.0.4: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.2.0 + hast-util-from-parse5: 8.0.1 + hast-util-to-parse5: 8.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + parse5: 7.2.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-select@6.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + bcp-47-match: 2.0.3 + comma-separated-tokens: 2.0.3 + css-selector-parser: 3.0.5 + devlop: 1.1.0 + direction: 2.0.1 + hast-util-has-property: 3.0.0 + hast-util-to-string: 3.0.1 + hast-util-whitespace: 3.0.0 + nth-check: 2.1.1 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + unist-util-visit: 5.0.0 + zwitch: 2.0.4 + + hast-util-to-estree@3.1.0: + dependencies: + '@types/estree': 1.0.6 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.1.3 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + style-to-object: 0.4.4 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + + hast-util-to-html@9.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-jsx-runtime@2.3.2: + dependencies: + '@types/estree': 1.0.6 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.1.3 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + style-to-object: 1.0.8 + unist-util-position: 5.0.0 + vfile-message: 4.0.2 + transitivePeerDependencies: + - supports-color + + hast-util-to-parse5@8.0.0: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-string@3.0.1: + dependencies: + '@types/hast': 3.0.4 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@8.0.0: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + + hastscript@9.0.0: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + html-entities@2.3.3: {} + + html-escaper@3.0.3: {} + + html-void-elements@3.0.0: {} + + html-whitespace-sensitive-tag-names@3.0.1: {} + + htmlparser2@9.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 + + http-cache-semantics@4.1.1: {} + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.1 + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + + http-status@2.0.0: {} + + https-proxy-agent@7.0.5: + dependencies: + agent-base: 7.1.1 + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + + human-signals@8.0.0: {} + + i18next@23.16.4: + dependencies: + '@babel/runtime': 7.26.0 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + import-fresh@3.3.0: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-meta-resolve@4.1.0: {} + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + inline-style-parser@0.1.1: {} + + inline-style-parser@0.2.4: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-arrayish@0.3.2: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.15.1: + dependencies: + hasown: 2.0.2 + + is-decimal@2.0.1: {} + + is-docker@3.0.0: {} + + is-extendable@0.1.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-hexadecimal@2.0.1: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@2.0.0: {} + + is-number@7.0.0: {} + + is-plain-obj@4.1.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-stream@4.0.1: {} + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + is-url@1.2.4: {} + + is-what@4.1.16: {} + + is-wsl@3.1.0: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + iterall@1.1.3: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@1.21.6: {} + + jiti@2.3.3: + optional: true + + js-base64@3.7.7: {} + + js-tokens@4.0.0: {} + + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsdom@25.0.1: + dependencies: + cssstyle: 4.1.0 + data-urls: 5.0.0 + decimal.js: 10.4.3 + form-data: 4.0.1 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.5 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.13 + parse5: 7.2.1 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.0.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.0.0 + ws: 8.18.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.0.2: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonc-parser@2.3.1: {} + + jsonc-parser@3.3.1: {} + + jwt-decode@4.0.0: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kind-of@6.0.3: {} + + kleur@3.0.3: {} + + kleur@4.1.5: {} + + kolorist@1.8.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@2.1.0: {} + + lilconfig@3.1.2: {} + + lines-and-columns@1.2.4: {} + + load-yaml-file@0.2.0: + dependencies: + graceful-fs: 4.2.11 + js-yaml: 3.14.1 + pify: 4.0.1 + strip-bom: 3.0.0 + + local-pkg@0.5.0: + dependencies: + mlly: 1.7.2 + pkg-types: 1.2.1 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.camelcase@4.3.0: {} + + lodash.castarray@4.4.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.merge@4.6.2: {} + + lodash@4.17.21: {} + + log-symbols@6.0.0: + dependencies: + chalk: 5.3.0 + is-unicode-supported: 1.3.0 + + long@5.2.3: {} + + longest-streak@3.1.0: {} + + loupe@3.1.2: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.12: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + magicast@0.3.5: + dependencies: + '@babel/parser': 7.26.1 + '@babel/types': 7.26.0 + source-map-js: 1.2.1 + + make-error@1.3.6: {} + + markdown-extensions@2.0.0: {} + + markdown-table@3.0.4: {} + + mdast-util-definitions@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + unist-util-visit: 5.0.0 + + mdast-util-directive@3.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.0 + parse-entities: 4.0.1 + stringify-entities: 4.0.4 + unist-util-visit-parents: 6.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-find-and-replace@3.0.1: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + + mdast-util-from-markdown@2.0.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.0 + micromark-util-decode-numeric-character-reference: 2.0.1 + micromark-util-decode-string: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.1 + micromark-util-character: 2.1.0 + + mdast-util-gfm-footnote@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.0 + micromark-util-normalize-identifier: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.2 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.0.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.1.3: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.0 + parse-entities: 4.0.1 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.2 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.1.3 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.0 + + mdast-util-to-hast@13.2.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.2.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.0 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-decode-string: 2.0.0 + unist-util-visit: 5.0.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.0.28: {} + + mdn-data@2.0.30: {} + + merge-anything@5.1.7: + dependencies: + is-what: 4.1.16 + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.1: + dependencies: + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + micromark-factory-destination: 2.0.0 + micromark-factory-label: 2.0.0 + micromark-factory-space: 2.0.0 + micromark-factory-title: 2.0.0 + micromark-factory-whitespace: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-classify-character: 2.0.0 + micromark-util-html-tag-name: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-subtokenize: 2.0.1 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-directive@3.0.2: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.0 + micromark-factory-whitespace: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + parse-entities: 4.0.1 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.1 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-classify-character: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-table@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.0 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.0 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-mdx-expression@3.0.0: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.2 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-events-to-acorn: 2.0.2 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-mdx-jsx@3.0.1: + dependencies: + '@types/acorn': 4.0.6 + '@types/estree': 1.0.6 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.2 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-events-to-acorn: 2.0.2 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + vfile-message: 4.0.2 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.0 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.1 + micromark-util-character: 2.1.0 + micromark-util-events-to-acorn: 2.0.2 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.2 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.14.0 + acorn-jsx: 5.3.2(acorn@8.14.0) + micromark-extension-mdx-expression: 3.0.0 + micromark-extension-mdx-jsx: 3.0.1 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-destination@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-label@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-mdx-expression@2.0.2: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-events-to-acorn: 2.0.2 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.2 + + micromark-factory-space@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-types: 2.0.0 + + micromark-factory-title@2.0.0: + dependencies: + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-whitespace@2.0.0: + dependencies: + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-character@2.1.0: + dependencies: + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-chunked@2.0.0: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-classify-character@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-combine-extensions@2.0.0: + dependencies: + micromark-util-chunked: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-decode-numeric-character-reference@2.0.1: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-decode-string@2.0.0: + dependencies: + decode-named-character-reference: 1.0.2 + micromark-util-character: 2.1.0 + micromark-util-decode-numeric-character-reference: 2.0.1 + micromark-util-symbol: 2.0.0 + + micromark-util-encode@2.0.0: {} + + micromark-util-events-to-acorn@2.0.2: + dependencies: + '@types/acorn': 4.0.6 + '@types/estree': 1.0.6 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + vfile-message: 4.0.2 + + micromark-util-html-tag-name@2.0.0: {} + + micromark-util-normalize-identifier@2.0.0: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-resolve-all@2.0.0: + dependencies: + micromark-util-types: 2.0.0 + + micromark-util-sanitize-uri@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-encode: 2.0.0 + micromark-util-symbol: 2.0.0 + + micromark-util-subtokenize@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-symbol@2.0.0: {} + + micromark-util-types@2.0.0: {} + + micromark@4.0.0: + dependencies: + '@types/debug': 4.1.12 + debug: 4.3.7 + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.1 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-combine-extensions: 2.0.0 + micromark-util-decode-numeric-character-reference: 2.0.1 + micromark-util-encode: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-subtokenize: 2.0.1 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-function@5.0.1: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@4.2.8: {} + + minipass@5.0.0: {} + + minipass@7.1.2: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp@1.0.4: {} + + mlly@1.7.2: + dependencies: + acorn: 8.14.0 + pathe: 1.1.2 + pkg-types: 1.2.1 + ufo: 1.5.4 + + moment@2.30.1: {} + + mrmime@2.0.0: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.7: {} + + nanostores@0.11.3: {} + + natural-compare@1.4.0: {} + + neotraverse@0.6.18: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-releases@2.0.18: {} + + normalize-path@3.0.0: {} + + normalize-range@0.1.2: {} + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nwsapi@2.2.13: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + oniguruma-to-js@0.4.3: + dependencies: + regex: 4.3.3 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@8.1.0: + dependencies: + chalk: 5.3.0 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-limit@6.1.0: + dependencies: + yocto-queue: 1.1.1 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-queue@8.0.1: + dependencies: + eventemitter3: 5.0.1 + p-timeout: 6.1.3 + + p-timeout@6.1.3: {} + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + package-manager-detector@0.2.2: {} + + pagefind@1.1.1: + optionalDependencies: + '@pagefind/darwin-arm64': 1.1.1 + '@pagefind/darwin-x64': 1.1.1 + '@pagefind/linux-arm64': 1.1.1 + '@pagefind/linux-x64': 1.1.1 + '@pagefind/windows-x64': 1.1.1 + + pako@0.2.9: {} + + pako@1.0.11: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-entities@4.0.1: + dependencies: + '@types/unist': 2.0.11 + character-entities: 2.0.2 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.0.2 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse-latin@7.0.0: + dependencies: + '@types/nlcst': 2.0.3 + '@types/unist': 3.0.3 + nlcst-to-string: 4.0.0 + unist-util-modify-children: 4.0.0 + unist-util-visit-children: 3.0.0 + vfile: 6.0.3 + + parse-ms@4.0.0: {} + + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.2.1 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.2.1 + + parse5@7.2.1: + dependencies: + entities: 4.5.0 + + path-browserify@1.0.1: {} + + path-equal@1.2.5: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + pathe@1.1.2: {} + + pathval@2.0.0: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.2: {} + + pify@2.3.0: {} + + pify@4.0.1: {} + + pirates@4.0.6: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + pkg-types@1.2.1: + dependencies: + confbox: 0.1.8 + mlly: 1.7.2 + pathe: 1.1.2 + + pluralize@8.0.0: {} + + postcss-import@15.1.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.8 + + postcss-js@4.0.1(postcss@8.4.47): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.4.47 + + postcss-load-config@4.0.2(postcss@8.4.47)(ts-node@10.9.2(@types/node@16.18.115)(typescript@4.9.4)): + dependencies: + lilconfig: 3.1.2 + yaml: 2.6.0 + optionalDependencies: + postcss: 8.4.47 + ts-node: 10.9.2(@types/node@16.18.115)(typescript@4.9.4) + + postcss-load-config@4.0.2(postcss@8.4.47)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)): + dependencies: + lilconfig: 3.1.2 + yaml: 2.6.0 + optionalDependencies: + postcss: 8.4.47 + ts-node: 10.9.2(@types/node@22.8.2)(typescript@5.6.3) + + postcss-nested@6.2.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.4.47: + dependencies: + nanoid: 3.3.7 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + preferred-pm@4.0.0: + dependencies: + find-up-simple: 1.0.0 + find-yarn-workspace-root2: 1.2.16 + which-pm: 3.0.0 + + prelude-ls@1.2.1: {} + + prettier-plugin-astro@0.14.1: + dependencies: + '@astrojs/compiler': 2.10.3 + prettier: 3.3.3 + sass-formatter: 0.7.9 + + prettier@2.8.7: + optional: true + + prettier@3.3.3: {} + + pretty-ms@9.1.0: + dependencies: + parse-ms: 4.0.0 + + prismjs@1.29.0: {} + + process@0.11.10: {} + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + property-information@6.5.0: {} + + protobufjs@7.4.0: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.8.2 + long: 5.2.3 + + proxy-from-env@1.1.0: {} + + pump@3.0.2: + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + quicktype-core@23.0.170: + dependencies: + '@glideapps/ts-necessities': 2.2.3 + browser-or-node: 3.0.0 + collection-utils: 1.0.1 + cross-fetch: 4.0.0 + is-url: 1.2.4 + js-base64: 3.7.7 + lodash: 4.17.21 + pako: 1.0.11 + pluralize: 8.0.0 + readable-stream: 4.5.2 + unicode-properties: 1.4.1 + urijs: 1.19.11 + wordwrap: 1.0.0 + yaml: 2.6.0 + transitivePeerDependencies: + - encoding + + quicktype-graphql-input@23.0.170: + dependencies: + collection-utils: 1.0.1 + graphql: 0.11.7 + quicktype-core: 23.0.170 + transitivePeerDependencies: + - encoding + + quicktype-typescript-input@23.0.170: + dependencies: + '@mark.probst/typescript-json-schema': 0.55.0 + quicktype-core: 23.0.170 + typescript: 4.9.5 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - encoding + + quicktype@23.0.170: + dependencies: + '@glideapps/ts-necessities': 2.3.2 + chalk: 4.1.2 + collection-utils: 1.0.1 + command-line-args: 5.2.1 + command-line-usage: 7.0.3 + cross-fetch: 4.0.0 + graphql: 0.11.7 + lodash: 4.17.21 + moment: 2.30.1 + quicktype-core: 23.0.170 + quicktype-graphql-input: 23.0.170 + quicktype-typescript-input: 23.0.170 + readable-stream: 4.5.2 + stream-json: 1.8.0 + string-to-stream: 3.0.1 + typescript: 4.9.5 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - encoding + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readable-stream@4.5.2: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + readdirp@4.0.2: {} + + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.6 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.0(acorn@8.14.0): + dependencies: + acorn-jsx: 5.3.2(acorn@8.14.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - acorn + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.6 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.6 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + + regenerator-runtime@0.14.1: {} + + regex@4.3.3: {} + + rehype-expressive-code@0.35.6: + dependencies: + expressive-code: 0.35.6 + + rehype-format@5.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-format: 1.1.0 + + rehype-parse@9.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-html: 2.0.3 + unified: 11.0.5 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.0.4 + vfile: 6.0.3 + + rehype-recma@1.0.0: + dependencies: + '@types/estree': 1.0.6 + '@types/hast': 3.0.4 + hast-util-to-estree: 3.1.0 + transitivePeerDependencies: + - supports-color + + rehype-stringify@10.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.3 + unified: 11.0.5 + + rehype@13.0.2: + dependencies: + '@types/hast': 3.0.4 + rehype-parse: 9.0.1 + rehype-stringify: 10.0.1 + unified: 11.0.5 + + remark-directive@3.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-directive: 3.0.0 + micromark-extension-directive: 3.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-gfm@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.0.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-mdx@3.1.0: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + micromark-util-types: 2.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.0 + unified: 11.0.5 + vfile: 6.0.3 + + remark-smartypants@3.0.2: + dependencies: + retext: 9.0.0 + retext-smartypants: 6.2.0 + unified: 11.0.5 + unist-util-visit: 5.0.0 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.0 + unified: 11.0.5 + + request-light@0.5.8: {} + + request-light@0.7.0: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve@1.22.8: + dependencies: + is-core-module: 2.15.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + retext-latin@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + parse-latin: 7.0.0 + unified: 11.0.5 + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.0.0 + + retext-stringify@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unified: 11.0.5 + + retext@9.0.0: + dependencies: + '@types/nlcst': 2.0.3 + retext-latin: 4.0.0 + retext-stringify: 4.0.0 + unified: 11.0.5 + + reusify@1.0.4: {} + + rollup@4.24.2: + dependencies: + '@types/estree': 1.0.6 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.24.2 + '@rollup/rollup-android-arm64': 4.24.2 + '@rollup/rollup-darwin-arm64': 4.24.2 + '@rollup/rollup-darwin-x64': 4.24.2 + '@rollup/rollup-freebsd-arm64': 4.24.2 + '@rollup/rollup-freebsd-x64': 4.24.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.24.2 + '@rollup/rollup-linux-arm-musleabihf': 4.24.2 + '@rollup/rollup-linux-arm64-gnu': 4.24.2 + '@rollup/rollup-linux-arm64-musl': 4.24.2 + '@rollup/rollup-linux-powerpc64le-gnu': 4.24.2 + '@rollup/rollup-linux-riscv64-gnu': 4.24.2 + '@rollup/rollup-linux-s390x-gnu': 4.24.2 + '@rollup/rollup-linux-x64-gnu': 4.24.2 + '@rollup/rollup-linux-x64-musl': 4.24.2 + '@rollup/rollup-win32-arm64-msvc': 4.24.2 + '@rollup/rollup-win32-ia32-msvc': 4.24.2 + '@rollup/rollup-win32-x64-msvc': 4.24.2 + fsevents: 2.3.3 + + rrweb-cssom@0.7.1: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + s.color@0.0.15: {} + + safe-buffer@5.2.1: {} + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + sass-formatter@0.7.9: + dependencies: + suf-log: 2.5.3 + + sax@1.4.1: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + + semver@6.3.1: {} + + semver@7.6.3: {} + + seroval-plugins@1.1.1(seroval@1.1.1): + dependencies: + seroval: 1.1.1 + + seroval@1.1.1: {} + + sharp@0.33.5: + dependencies: + color: 4.2.3 + detect-libc: 2.0.3 + semver: 7.6.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-s390x': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-wasm32': 0.33.5 + '@img/sharp-win32-ia32': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shiki@1.22.2: + dependencies: + '@shikijs/core': 1.22.2 + '@shikijs/engine-javascript': 1.22.2 + '@shikijs/engine-oniguruma': 1.22.2 + '@shikijs/types': 1.22.2 + '@shikijs/vscode-textmate': 9.3.0 + '@types/hast': 3.0.4 + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + simple-swizzle@0.2.2: + dependencies: + is-arrayish: 0.3.2 + + sisteransi@1.0.5: {} + + sitemap@8.0.0: + dependencies: + '@types/node': 17.0.45 + '@types/sax': 1.2.7 + arg: 5.0.2 + sax: 1.4.1 + + solid-devtools@0.30.1(solid-js@1.9.3)(vite@5.4.10(@types/node@16.18.115)): + dependencies: + '@babel/core': 7.26.0 + '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.0) + '@babel/types': 7.26.0 + '@solid-devtools/debugger': 0.23.4(solid-js@1.9.3) + '@solid-devtools/shared': 0.13.2(solid-js@1.9.3) + solid-js: 1.9.3 + optionalDependencies: + vite: 5.4.10(@types/node@16.18.115) + transitivePeerDependencies: + - supports-color + optional: true + + solid-devtools@0.30.1(solid-js@1.9.3)(vite@5.4.10(@types/node@22.8.2)): + dependencies: + '@babel/core': 7.26.0 + '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.0) + '@babel/types': 7.26.0 + '@solid-devtools/debugger': 0.23.4(solid-js@1.9.3) + '@solid-devtools/shared': 0.13.2(solid-js@1.9.3) + solid-js: 1.9.3 + optionalDependencies: + vite: 5.4.10(@types/node@22.8.2) + transitivePeerDependencies: + - supports-color + optional: true + + solid-icons@1.1.0(solid-js@1.9.3): + dependencies: + solid-js: 1.9.3 + + solid-js@1.9.3: + dependencies: + csstype: 3.1.3 + seroval: 1.1.1 + seroval-plugins: 1.1.1(seroval@1.1.1) + + solid-presence@0.1.8(solid-js@1.9.3): + dependencies: + '@corvu/utils': 0.4.2(solid-js@1.9.3) + solid-js: 1.9.3 + + solid-prevent-scroll@0.1.10(solid-js@1.9.3): + dependencies: + '@corvu/utils': 0.4.2(solid-js@1.9.3) + solid-js: 1.9.3 + + solid-refresh@0.6.3(solid-js@1.9.3): + dependencies: + '@babel/generator': 7.26.0 + '@babel/helper-module-imports': 7.25.9 + '@babel/types': 7.26.0 + solid-js: 1.9.3 + transitivePeerDependencies: + - supports-color + + source-map-js@1.2.1: {} + + source-map@0.7.4: {} + + space-separated-tokens@2.0.2: {} + + sprintf-js@1.0.3: {} + + stackback@0.0.2: {} + + std-env@3.7.0: {} + + stdin-discarder@0.2.2: {} + + stream-chain@2.2.5: {} + + stream-json@1.8.0: + dependencies: + stream-chain: 2.2.5 + + stream-replace-string@2.0.0: {} + + string-to-stream@3.0.1: + dependencies: + readable-stream: 3.6.2 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.4.0 + get-east-asian-width: 1.3.0 + strip-ansi: 7.1.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + + strip-bom-string@1.0.0: {} + + strip-bom@3.0.0: {} + + strip-final-newline@4.0.0: {} + + strip-json-comments@3.1.1: {} + + style-mod@4.1.2: {} + + style-to-object@0.4.4: + dependencies: + inline-style-parser: 0.1.1 + + style-to-object@1.0.8: + dependencies: + inline-style-parser: 0.2.4 + + sucrase@3.35.0: + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + commander: 4.1.1 + glob: 10.4.5 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.6 + ts-interface-checker: 0.1.13 + + suf-log@2.5.3: + dependencies: + s.color: 0.0.15 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svgo@3.3.2: + dependencies: + '@trysound/sax': 0.2.0 + commander: 7.2.0 + css-select: 5.1.0 + css-tree: 2.3.1 + css-what: 6.1.0 + csso: 5.0.5 + picocolors: 1.1.1 + + symbol-tree@3.2.4: {} + + table-layout@4.1.1: + dependencies: + array-back: 6.2.2 + wordwrapjs: 5.1.0 + + tailwind-merge@2.5.4: {} + + tailwindcss-animate@1.0.7(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3))): + dependencies: + tailwindcss: 3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)) + + tailwindcss@3.4.14(ts-node@10.9.2(@types/node@16.18.115)(typescript@4.9.4)): + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.2 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.6 + lilconfig: 2.1.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.4.47 + postcss-import: 15.1.0(postcss@8.4.47) + postcss-js: 4.0.1(postcss@8.4.47) + postcss-load-config: 4.0.2(postcss@8.4.47)(ts-node@10.9.2(@types/node@16.18.115)(typescript@4.9.4)) + postcss-nested: 6.2.0(postcss@8.4.47) + postcss-selector-parser: 6.1.2 + resolve: 1.22.8 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + + tailwindcss@3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)): + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.2 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.6 + lilconfig: 2.1.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.4.47 + postcss-import: 15.1.0(postcss@8.4.47) + postcss-js: 4.0.1(postcss@8.4.47) + postcss-load-config: 4.0.2(postcss@8.4.47)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)) + postcss-nested: 6.2.0(postcss@8.4.47) + postcss-selector-parser: 6.1.2 + resolve: 1.22.8 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + text-table@0.2.0: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tiny-inflate@1.0.3: {} + + tinybench@2.9.0: {} + + tinybench@3.0.0: {} + + tinyexec@0.3.1: {} + + tinypool@1.0.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tldts-core@6.1.57: {} + + tldts@6.1.57: + dependencies: + tldts-core: 6.1.57 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tough-cookie@5.0.0: + dependencies: + tldts: 6.1.57 + + tr46@0.0.3: {} + + tr46@5.0.0: + dependencies: + punycode: 2.3.1 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + ts-api-utils@1.3.0(typescript@5.6.3): + dependencies: + typescript: 5.6.3 + + ts-interface-checker@0.1.13: {} + + ts-node@10.9.2(@types/node@16.18.115)(typescript@4.9.4): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 16.18.115 + acorn: 8.14.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 4.9.4 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 22.8.2 + acorn: 8.14.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.6.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optional: true + + ts-poet@6.9.0: + dependencies: + dprint-node: 1.0.8 + + ts-proto-descriptors@2.0.0: + dependencies: + '@bufbuild/protobuf': 2.2.1 + + ts-proto@2.2.5: + dependencies: + '@bufbuild/protobuf': 2.2.1 + case-anything: 2.1.13 + ts-poet: 6.9.0 + ts-proto-descriptors: 2.0.0 + + tsconfck@3.1.4(typescript@4.9.4): + optionalDependencies: + typescript: 4.9.4 + + tsconfck@3.1.4(typescript@5.6.3): + optionalDependencies: + typescript: 5.6.3 + + tslib@2.8.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@4.26.1: {} + + typesafe-path@0.2.2: {} + + typescript-auto-import-cache@0.3.5: + dependencies: + semver: 7.6.3 + + typescript-eslint@8.12.1(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.12.1(@typescript-eslint/parser@8.12.1(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) + '@typescript-eslint/parser': 8.12.1(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) + '@typescript-eslint/utils': 8.12.1(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - eslint + - supports-color + + typescript@4.9.4: {} + + typescript@4.9.5: {} + + typescript@5.6.3: {} + + typical@4.0.0: {} + + typical@7.2.0: {} + + ufo@1.5.4: {} + + undici-types@6.19.8: {} + + undici@6.20.1: {} + + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + + unicorn-magic@0.3.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + + unist-util-is@6.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-modify-children@4.0.0: + dependencies: + '@types/unist': 3.0.3 + array-iterate: 2.0.1 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.0.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-children@3.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.1: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + + unist-util-visit@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + + update-browserslist-db@1.1.1(browserslist@4.24.2): + dependencies: + browserslist: 4.24.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + urijs@1.19.11: {} + + util-deprecate@1.0.2: {} + + uuid@11.0.2: {} + + v8-compile-cache-lib@3.0.1: {} + + valid-filename@4.0.0: + dependencies: + filename-reserved-regex: 3.0.0 + + validate-html-nesting@1.2.2: {} + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.2 + + vite-node@2.1.4(@types/node@22.8.2): + dependencies: + cac: 6.7.14 + debug: 4.3.7 + pathe: 1.1.2 + vite: 5.4.10(@types/node@22.8.2) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite-plugin-solid@2.10.2(solid-js@1.9.3)(vite@5.4.10(@types/node@16.18.115)): + dependencies: + '@babel/core': 7.26.0 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.3(@babel/core@7.26.0) + merge-anything: 5.1.7 + solid-js: 1.9.3 + solid-refresh: 0.6.3(solid-js@1.9.3) + vite: 5.4.10(@types/node@16.18.115) + vitefu: 0.2.5(vite@5.4.10(@types/node@16.18.115)) + transitivePeerDependencies: + - supports-color + + vite-plugin-solid@2.10.2(solid-js@1.9.3)(vite@5.4.10(@types/node@22.8.2)): + dependencies: + '@babel/core': 7.26.0 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.3(@babel/core@7.26.0) + merge-anything: 5.1.7 + solid-js: 1.9.3 + solid-refresh: 0.6.3(solid-js@1.9.3) + vite: 5.4.10(@types/node@22.8.2) + vitefu: 0.2.5(vite@5.4.10(@types/node@22.8.2)) + transitivePeerDependencies: + - supports-color + + vite-tsconfig-paths@5.0.1(typescript@5.6.3)(vite@5.4.10(@types/node@22.8.2)): + dependencies: + debug: 4.3.7 + globrex: 0.1.2 + tsconfck: 3.1.4(typescript@5.6.3) + optionalDependencies: + vite: 5.4.10(@types/node@22.8.2) + transitivePeerDependencies: + - supports-color + - typescript + + vite@5.4.10(@types/node@16.18.115): + dependencies: + esbuild: 0.21.5 + postcss: 8.4.47 + rollup: 4.24.2 + optionalDependencies: + '@types/node': 16.18.115 + fsevents: 2.3.3 + + vite@5.4.10(@types/node@22.8.2): + dependencies: + esbuild: 0.21.5 + postcss: 8.4.47 + rollup: 4.24.2 + optionalDependencies: + '@types/node': 22.8.2 + fsevents: 2.3.3 + + vitefu@0.2.5(vite@5.4.10(@types/node@16.18.115)): + optionalDependencies: + vite: 5.4.10(@types/node@16.18.115) + + vitefu@0.2.5(vite@5.4.10(@types/node@22.8.2)): + optionalDependencies: + vite: 5.4.10(@types/node@22.8.2) + + vitefu@1.0.3(vite@5.4.10(@types/node@16.18.115)): + optionalDependencies: + vite: 5.4.10(@types/node@16.18.115) + + vitefu@1.0.3(vite@5.4.10(@types/node@22.8.2)): + optionalDependencies: + vite: 5.4.10(@types/node@22.8.2) + + vitest@2.1.4(@types/node@22.8.2)(happy-dom@15.7.4)(jsdom@25.0.1): + dependencies: + '@vitest/expect': 2.1.4 + '@vitest/mocker': 2.1.4(vite@5.4.10(@types/node@22.8.2)) + '@vitest/pretty-format': 2.1.4 + '@vitest/runner': 2.1.4 + '@vitest/snapshot': 2.1.4 + '@vitest/spy': 2.1.4 + '@vitest/utils': 2.1.4 + chai: 5.1.2 + debug: 4.3.7 + expect-type: 1.1.0 + magic-string: 0.30.12 + pathe: 1.1.2 + std-env: 3.7.0 + tinybench: 2.9.0 + tinyexec: 0.3.1 + tinypool: 1.0.1 + tinyrainbow: 1.2.0 + vite: 5.4.10(@types/node@22.8.2) + vite-node: 2.1.4(@types/node@22.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.8.2 + happy-dom: 15.7.4 + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + volar-service-css@0.0.62(@volar/language-service@2.4.8): + dependencies: + vscode-css-languageservice: 6.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.0.8 + optionalDependencies: + '@volar/language-service': 2.4.8 + + volar-service-emmet@0.0.62(@volar/language-service@2.4.8): + dependencies: + '@emmetio/css-parser': 0.4.0 + '@emmetio/html-matcher': 1.3.0 + '@vscode/emmet-helper': 2.9.3 + vscode-uri: 3.0.8 + optionalDependencies: + '@volar/language-service': 2.4.8 + + volar-service-html@0.0.62(@volar/language-service@2.4.8): + dependencies: + vscode-html-languageservice: 5.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.0.8 + optionalDependencies: + '@volar/language-service': 2.4.8 + + volar-service-prettier@0.0.62(@volar/language-service@2.4.8)(prettier@3.3.3): + dependencies: + vscode-uri: 3.0.8 + optionalDependencies: + '@volar/language-service': 2.4.8 + prettier: 3.3.3 + + volar-service-typescript-twoslash-queries@0.0.62(@volar/language-service@2.4.8): + dependencies: + vscode-uri: 3.0.8 + optionalDependencies: + '@volar/language-service': 2.4.8 + + volar-service-typescript@0.0.62(@volar/language-service@2.4.8): + dependencies: + path-browserify: 1.0.1 + semver: 7.6.3 + typescript-auto-import-cache: 0.3.5 + vscode-languageserver-textdocument: 1.0.12 + vscode-nls: 5.2.0 + vscode-uri: 3.0.8 + optionalDependencies: + '@volar/language-service': 2.4.8 + + volar-service-yaml@0.0.62(@volar/language-service@2.4.8): + dependencies: + vscode-uri: 3.0.8 + yaml-language-server: 1.15.0 + optionalDependencies: + '@volar/language-service': 2.4.8 + + vscode-css-languageservice@6.3.1: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.0.8 + + vscode-html-languageservice@5.3.1: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.0.8 + + vscode-json-languageservice@4.1.8: + dependencies: + jsonc-parser: 3.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-nls: 5.2.0 + vscode-uri: 3.0.8 + + vscode-jsonrpc@6.0.0: {} + + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.16.0: + dependencies: + vscode-jsonrpc: 6.0.0 + vscode-languageserver-types: 3.16.0 + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.16.0: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@7.0.0: + dependencies: + vscode-languageserver-protocol: 3.16.0 + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-nls@5.2.0: {} + + vscode-uri@2.1.2: {} + + vscode-uri@3.0.8: {} + + w3c-keyname@2.2.8: {} + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + web-namespaces@2.0.1: {} + + webidl-conversions@3.0.1: {} + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@3.0.0: + optional: true + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.0.0: + dependencies: + tr46: 5.0.0 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which-pm-runs@1.1.0: {} + + which-pm@3.0.0: + dependencies: + load-yaml-file: 0.2.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + + wordwrapjs@5.1.0: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrap-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 7.2.0 + strip-ansi: 7.1.0 + + wrappy@1.0.2: {} + + ws@8.18.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + xxhash-wasm@1.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + yaml-language-server@1.15.0: + dependencies: + ajv: 8.17.1 + lodash: 4.17.21 + request-light: 0.5.8 + vscode-json-languageservice: 4.1.8 + vscode-languageserver: 7.0.0 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-nls: 5.2.0 + vscode-uri: 3.0.8 + yaml: 2.2.2 + optionalDependencies: + prettier: 2.8.7 + + yaml@2.2.2: {} + + yaml@2.6.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yn@3.1.1: {} + + yocto-queue@0.1.0: {} + + yocto-queue@1.1.1: {} + + yoctocolors@2.1.1: {} + + zod-to-json-schema@3.23.5(zod@3.23.8): + dependencies: + zod: 3.23.8 + + zod-to-ts@1.2.0(typescript@4.9.4)(zod@3.23.8): + dependencies: + typescript: 4.9.4 + zod: 3.23.8 + + zod-to-ts@1.2.0(typescript@5.6.3)(zod@3.23.8): + dependencies: + typescript: 5.6.3 + zod: 3.23.8 + + zod@3.23.8: {} + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..1e64cda --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +packages: + - 'client/trailbase-ts' + - 'docs' + - 'ui/admin' + - 'ui/auth' + - 'examples/blog/web' + - 'examples/tutorial/scripts' +options: + prefer-workspace-packages: true + strict-peer-dependencies: true diff --git a/proto/config.proto b/proto/config.proto new file mode 100644 index 0000000..fe898db --- /dev/null +++ b/proto/config.proto @@ -0,0 +1,185 @@ +syntax = "proto2"; + +import "google/protobuf/descriptor.proto"; + +package config; + +extend google.protobuf.FieldOptions { optional bool secret = 50000; } + +message EmailTemplate { + optional string subject = 1; + optional string body = 2; +} + +message EmailConfig { + optional string smtp_host = 1; + optional uint32 smtp_port = 2; + optional string smtp_username = 3; + optional string smtp_password = 4 [ (secret) = true ]; + + optional string sender_name = 11; + optional string sender_address = 12; + + optional EmailTemplate user_verification_template = 21; + optional EmailTemplate password_reset_template = 22; + optional EmailTemplate change_email_template = 23; +} + +enum OAuthProviderId { + OAUTH_PROVIDER_ID_UNDEFINED = 0; + CUSTOM = 1; + DISCORD = 10; + GITLAB = 11; + GOOGLE = 12; +} + +message OAuthProviderConfig { + optional string client_id = 1; + optional string client_secret = 2 [ (secret) = true ]; + optional OAuthProviderId provider_id = 3; + + optional string display_name = 11; + optional string auth_url = 12; + optional string token_url = 13; + optional string user_api_url = 14; + optional bool pkce = 15; +} + +message AuthConfig { + optional int64 auth_token_ttl_sec = 1; + optional int64 refresh_token_ttl_sec = 2; + + map oauth_providers = 11; +} + +message ServerConfig { + /// Application name presented to users, e.g. when sending emails. Default: + /// "TrailBase". + optional string application_name = 1; + + /// Your final, deployed URL. This url is used to build canonical urls + /// for emails, OAuth redirects, ... . Default: "http://localhost:4000". + optional string site_url = 2; + + /// Max age of logs that will be retained during period logs cleanup. Note + /// that this implies that some older logs may persist until the cleanup job + /// reruns. Default: 7 days. + optional int64 logs_retention_sec = 11; + + /// Interval at which backups are persisted. Setting it to 0 will disable + /// backups. Default: 0. + optional int64 backup_interval_sec = 12; +} + +/// Sqlite specific (as opposed to standard SQL) constrained-violation +/// resolution strategy upon insert. +enum ConflictResolutionStrategy { + CONFLICT_RESOLUTION_STRATEGY_UNDEFINED = 0; + /// SQL default: Keep transaction open and abort the current statement. + ABORT = 1; + /// Abort entire transaction. + ROLLBACK = 2; + /// Similar to Abort but doesn't roll back the current statement, i.e. if the + /// current statement affects multiple rows, changes by that statement prior + /// to the failure are not rolled back. + FAIL = 3; + /// Skip the statement and continue. + IGNORE = 4; + /// Replaces the conflicting row in case of a collision (e.g. unique + /// constraint). + REPLACE = 5; +} + +enum PermissionFlag { + PERMISSION_FLAG_UNDEFINED = 0; + + // Database record insert. + CREATE = 1; + // Database record read/list, i.e. select. + READ = 2; + // Database record update. + UPDATE = 4; + // Database record delete. + DELETE = 8; + /// Lookup JSON schema for the given record api . + SCHEMA = 16; +} + +message RecordApiConfig { + optional string name = 1; + optional string table_name = 2; + + optional ConflictResolutionStrategy conflict_resolution = 5; + optional bool autofill_missing_user_id_columns = 6; + + // Access control lists. + repeated PermissionFlag acl_world = 7; + repeated PermissionFlag acl_authenticated = 8; + + optional string create_access_rule = 11; + optional string read_access_rule = 12; + optional string update_access_rule = 13; + optional string delete_access_rule = 14; + optional string schema_access_rule = 15; +} + +enum QueryApiParameterType { + TEXT = 1; + BLOB = 2; + INTEGER = 3; + REAL = 4; +} + +message QueryApiParameter { + optional string name = 1; + optional QueryApiParameterType type = 2; +} + +enum QueryApiAcl { + QUERY_API_ACL_UNDEFINED = 0; + WORLD = 1; + AUTHENTICATED = 2; +} + +/// Configuration schema for Query APIs. +/// +/// Note that unlike record APIs, query APIs are read-only, +/// which simplifies authorization. +/// That said, query APIs are backed by virtual tables, thus in theory, they +/// could allow writes (unlike views) in the future for module implementations +/// that allow it such as SQLite's R*-tree. +message QueryApiConfig { + optional string name = 1; + optional string virtual_table_name = 2; + + /// Query parameters the Query API will accept and forward to the virtual + /// table (function) as argument expressions. + repeated QueryApiParameter params = 3; + + + // Read access control. + optional QueryApiAcl acl = 8; + optional string access_rule = 9; + + // TODO: We might want to consider requiring or allowing to specify an + // optional JSON schema for query APIs to allow generating client bindings. +} + +message JsonSchemaConfig { + optional string name = 1; + optional string schema = 2; +} + +message Config { + // NOTE: These top-level fields currently have to be `required` due to the + // overly simple approach on how we do config merging (from env vars and + // vault). + required EmailConfig email = 2; + required ServerConfig server = 3; + required AuthConfig auth = 4; + + repeated RecordApiConfig record_apis = 11; + repeated QueryApiConfig query_apis = 12; + + repeated JsonSchemaConfig schemas = 21; +} diff --git a/proto/config_api.proto b/proto/config_api.proto new file mode 100644 index 0000000..2f75854 --- /dev/null +++ b/proto/config_api.proto @@ -0,0 +1,16 @@ +syntax = "proto2"; + +import "config.proto"; + +package config; + + +message GetConfigResponse { + optional Config config = 1; + optional string hash = 2; +} + +message UpdateConfigRequest { + optional Config config = 1; + optional string hash = 2; +} diff --git a/proto/vault.proto b/proto/vault.proto new file mode 100644 index 0000000..152b2fc --- /dev/null +++ b/proto/vault.proto @@ -0,0 +1,7 @@ +syntax = "proto2"; + +package config; + +message Vault { + map secrets = 1; +} diff --git a/trailbase-cli/Cargo.toml b/trailbase-cli/Cargo.toml new file mode 100644 index 0000000..6e78685 --- /dev/null +++ b/trailbase-cli/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "trailbase-cli" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "trail" + +[features] +openapi = ["dep:utoipa", "dep:utoipa-swagger-ui"] + +[dependencies] +axum = { version = "^0.7.5", features=["multipart"] } +chrono = "^0.4.38" +clap = { version = "^4.4.11", features=["derive", "env"] } +env_logger = "^0.11.3" +trailbase-core = { path = "../trailbase-core" } +libsql = { workspace = true } +log = "^0.4.21" +mimalloc = { version = "^0.1.41", default-features = false } +serde = { version = "^1.0.203", features = ["derive"] } +serde_json = "^1.0.117" +tokio = { version = "^1.38.0", features=["macros", "rt-multi-thread", "fs", "signal"] } +tracing-subscriber = "0.3.18" +utoipa = { version = "5.0.0-beta.0", features = ["axum_extras"], optional = true } +utoipa-swagger-ui = { version = "8.0.1", features = ["axum"], optional = true } +uuid = { version = "^1.8.0", features = ["v4", "serde"] } diff --git a/trailbase-cli/src/args.rs b/trailbase-cli/src/args.rs new file mode 100644 index 0000000..ffc04fa --- /dev/null +++ b/trailbase-cli/src/args.rs @@ -0,0 +1,188 @@ +use clap::{Args, Parser, Subcommand, ValueEnum}; + +use trailbase_core::api::JsonSchemaMode; +use trailbase_core::DataDir; +use trailbase_core::ServerOptions; + +#[derive(ValueEnum, Clone, Copy, Debug)] +pub enum JsonSchemaModeArg { + /// Insert mode. + Insert, + /// Read/Select mode. + Select, + /// Update mode. + Update, +} + +impl From for JsonSchemaMode { + fn from(value: JsonSchemaModeArg) -> Self { + match value { + JsonSchemaModeArg::Insert => Self::Insert, + JsonSchemaModeArg::Select => Self::Select, + JsonSchemaModeArg::Update => Self::Update, + } + } +} + +/// Command line arguments for TrailBase's CLI. +/// +/// NOTE: a good rule of thumb for thinking of proto config vs CLI options: if it requires a +/// server restart, it should probably be a CLI option and a config option otherwise. +#[derive(Parser, Debug, Clone, Default)] +#[command(version, about, long_about = None)] +pub struct DefaultCommandLineArgs { + /// Directory for runtime files including the database. Will be created by TrailBase if dir + /// doesn't exist. + #[arg(long, env, default_value = DataDir::DEFAULT)] + pub data_dir: std::path::PathBuf, + + #[command(subcommand)] + pub cmd: Option, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum SubCommands { + /// Starts the HTTP server. + Run(ServerArgs), + /// Export JSON Schema definitions. + Schema(JsonSchemaArgs), + #[cfg(feature = "openapi")] + /// Export OpenAPI definitions. + OpenApi { + #[command(subcommand)] + cmd: Option, + }, + /// Creates new empty migration file. + Migration { + /// Optional suffix used for the generated migration file: U__.sql. + suffix: Option, + }, + /// Simple admin management (use dashboard for everything else). + Admin { + #[command(subcommand)] + cmd: Option, + }, + /// Simple user management (use dashboard for everything else). + User { + #[command(subcommand)] + cmd: Option, + }, + /// Programmatically send emails. + Email(EmailArgs), +} + +#[derive(Args, Clone, Debug)] +pub struct ServerArgs { + /// Address the HTTP server binds to (Default: localhost:4000). + #[arg(short, long, env, default_value = "127.0.0.1:4000")] + address: String, + + #[arg(long, env)] + admin_address: Option, + + /// Optional path to static assets that will be served at the HTTP root. + #[arg(long, env)] + public_dir: Option, + + /// Sets CORS policies to permissive in order to allow cross-origin requests + /// when developing the UI using a separate dev server. + #[arg(long)] + pub dev: bool, + + #[arg(long, default_value_t = false)] + pub stderr_logging: bool, + + /// Disable the built-in public authentication (login, logout, ...) UI. + #[arg(long, default_value_t = false)] + disable_auth_ui: bool, + + /// Limit the set of allowed origins the HTTP server will answer to. + #[arg(long, default_value = "*")] + cors_allowed_origins: Vec, +} + +#[derive(Args, Clone, Debug)] +pub struct JsonSchemaArgs { + /// Name of the table to infer the JSON Schema from. + pub table: String, + + /// Use-case for the type that determines which columns/fields will be required [Default: + /// Insert]. + #[arg(long, env)] + pub mode: Option, +} + +#[derive(Args, Clone, Debug)] +pub struct EmailArgs { + /// Receiver address, e.g. foo@bar.baz. + #[arg(long, env)] + pub to: String, + + /// Subject line of the email to be sent. + #[arg(long, env)] + pub subject: String, + + /// Email body, i.e. the actual message. + #[arg(long, env)] + pub body: String, +} + +#[cfg(feature = "openapi")] +#[derive(Subcommand, Debug, Clone)] +pub enum OpenApiSubCommands { + Print, + Run { + #[arg(long, default_value_t = 4004)] + port: u16, + }, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum AdminSubCommands { + /// Lists admin users. + List, + /// Demotes admin user to normal user. + Demote { + /// E-mail of the admin who's demoted. + email: String, + }, + /// Promotes user to admin. + Promote { + /// E-mail of the user who's promoted to admin. + email: String, + }, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum UserSubCommands { + // TODO: create new user. Low prio, use dashboard. + /// Resets a users password. + ResetPassword { + /// E-mail of the user who's password is being reset. + email: String, + /// Password to set. + password: String, + }, + /// Mint auth tokens for the given user. + MintToken { email: String }, +} + +impl TryFrom for ServerOptions { + type Error = &'static str; + + fn try_from(value: DefaultCommandLineArgs) -> Result { + let Some(SubCommands::Run(args)) = value.cmd else { + return Err("Trying to initialize server w/o the \"run\" sub command being passed."); + }; + + return Ok(ServerOptions { + data_dir: DataDir(value.data_dir), + address: args.address, + admin_address: args.admin_address, + public_dir: args.public_dir.map(|p| p.into()), + dev: args.dev, + disable_auth_ui: args.disable_auth_ui, + cors_allowed_origins: args.cors_allowed_origins, + }); + } +} diff --git a/trailbase-cli/src/bin/trail.rs b/trailbase-cli/src/bin/trail.rs new file mode 100644 index 0000000..46fe785 --- /dev/null +++ b/trailbase-cli/src/bin/trail.rs @@ -0,0 +1,294 @@ +#![allow(clippy::needless_return)] + +#[global_allocator] +static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; + +use chrono::TimeZone; +use clap::{CommandFactory, Parser}; +use libsql::{de, params}; +use log::*; +use serde::Deserialize; +use tokio::{fs, io::AsyncWriteExt}; +use tracing_subscriber::{filter, prelude::*}; +use trailbase_core::{ + api::{self, init_app_state, query_one_row, Email, TokenClaims}, + constants::USER_TABLE, + DataDir, Server, +}; + +use trailbase_cli::{ + AdminSubCommands, DefaultCommandLineArgs, JsonSchemaModeArg, SubCommands, UserSubCommands, +}; + +type BoxError = Box; + +fn init_logger(dev: bool) { + env_logger::init_from_env(if dev { + env_logger::Env::new().default_filter_or("info,trailbase_core=debug,refinery_core=warn") + } else { + env_logger::Env::new().default_filter_or("info,refinery_core=warn") + }); +} + +#[derive(Deserialize)] +struct DbUser { + id: [u8; 16], + email: String, + verified: bool, + created: i64, + updated: i64, +} + +impl DbUser { + fn uuid(&self) -> uuid::Uuid { + uuid::Uuid::from_bytes(self.id) + } +} + +async fn get_user_by_email(conn: &libsql::Connection, email: &str) -> Result { + return Ok(de::from_row( + &query_one_row( + conn, + &format!("SELECT * FROM {USER_TABLE} WHERE email = $1"), + params!(email), + ) + .await?, + )?); +} + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + let args = DefaultCommandLineArgs::parse(); + + match args.cmd { + Some(SubCommands::Run(ref cmd)) => { + init_logger(cmd.dev); + + let stderr_logging = cmd.dev || cmd.stderr_logging; + + let app = Server::init(args.try_into()?).await?; + + let filter = || { + filter::Targets::new() + .with_target("tower_http::trace::on_response", filter::LevelFilter::DEBUG) + .with_target("tower_http::trace::on_request", filter::LevelFilter::DEBUG) + .with_target("tower_http::trace::make_span", filter::LevelFilter::DEBUG) + .with_default(filter::LevelFilter::INFO) + }; + + // This declares **where** tracing is being logged to, e.g. stderr, file, sqlite. + // + // NOTE: the try_init() will actually fail because the tracing system was already initialized + // by the env_logger above. + // FIXME: Without the sqlite logger here, logging is broken despite us trying to initialize + // in app.server() as well. + let layer = tracing_subscriber::registry() + .with(trailbase_core::logging::SqliteLogLayer::new(app.state()).with_filter(filter())); + + if stderr_logging { + let _ = layer + .with( + tracing_subscriber::fmt::layer() + .compact() + .with_filter(filter()), + ) + .try_init(); + } else { + let _ = layer.try_init(); + } + + app.serve().await?; + } + #[cfg(feature = "openapi")] + Some(SubCommands::OpenApi { cmd }) => { + init_logger(false); + + use trailbase_cli::OpenApiSubCommands; + use utoipa::OpenApi; + use utoipa_swagger_ui::SwaggerUi; + + let run_server = |port: u16| async move { + let router = axum::Router::new().merge( + SwaggerUi::new("/docs").url("/api/openapi.json", trailbase_core::openapi::Doc::openapi()), + ); + + let addr = format!("localhost:{port}"); + let listener = tokio::net::TcpListener::bind(addr.clone()).await.unwrap(); + log::info!("docs @ http://{addr}/docs 🚀"); + + axum::serve(listener, router).await.unwrap(); + }; + + match cmd { + Some(OpenApiSubCommands::Print) => { + let json = trailbase_core::openapi::Doc::openapi().to_pretty_json()?; + println!("{json}"); + } + Some(OpenApiSubCommands::Run { port }) => { + run_server(port).await; + } + None => { + run_server(4004).await; + } + } + } + Some(SubCommands::Schema(ref cmd)) => { + init_logger(false); + + let data_dir = DataDir(args.data_dir); + let conn = api::connect_sqlite(Some(data_dir.main_db_path()), None).await?; + let table_metadata = api::TableMetadataCache::new(conn.clone()).await?; + + let table_name = &cmd.table; + if let Some(table) = table_metadata.get(table_name) { + let (_validator, schema) = trailbase_core::api::build_json_schema( + table.name(), + &*table, + cmd.mode.unwrap_or(JsonSchemaModeArg::Insert).into(), + )?; + + println!("{}", serde_json::to_string_pretty(&schema)?); + } else if let Some(view) = table_metadata.get_view(table_name) { + let (_validator, schema) = trailbase_core::api::build_json_schema( + view.name(), + &*view, + cmd.mode.unwrap_or(JsonSchemaModeArg::Insert).into(), + )?; + + println!("{}", serde_json::to_string_pretty(&schema)?); + } else { + return Err(format!("Could not find table: '{table_name}'").into()); + } + } + Some(SubCommands::Migration { suffix }) => { + init_logger(false); + + let data_dir = DataDir(args.data_dir); + let filename = api::new_unique_migration_filename(suffix.as_deref().unwrap_or("update")); + let path = data_dir.migrations_path().join(filename); + + let mut migration_file = fs::File::create_new(&path).await?; + migration_file + .write_all(b"-- new database migration\n") + .await?; + + println!("Created empty migration file: {path:?}"); + } + Some(SubCommands::Admin { cmd }) => { + init_logger(false); + + let data_dir = DataDir(args.data_dir); + let conn = api::connect_sqlite(Some(data_dir.main_db_path()), None).await?; + + match cmd { + Some(AdminSubCommands::List) => { + let mut rows = conn + .query(&format!("SELECT * FROM {USER_TABLE} WHERE admin > 0"), ()) + .await?; + + println!("{: >36}\temail\tcreated\tupdated", "id"); + while let Some(row) = rows.next().await? { + let user: DbUser = de::from_row(&row)?; + let id = user.uuid(); + + println!( + "{id}\t{}\t{created:?}\t{updated:?}", + user.email, + created = chrono::Utc.timestamp_opt(user.created, 0), + updated = chrono::Utc.timestamp_opt(user.updated, 0), + ); + } + } + Some(AdminSubCommands::Demote { email }) => { + conn + .execute( + &format!("UPDATE {USER_TABLE} SET admin = FALSE WHERE email = $1"), + params!(email.clone()), + ) + .await?; + + println!("'{email}' has been demoted"); + } + Some(AdminSubCommands::Promote { email }) => { + conn + .execute( + &format!("UPDATE {USER_TABLE} SET admin = TRUE WHERE email = $1"), + params!(email.clone()), + ) + .await?; + + println!("'{email}' is now an admin"); + } + None => { + DefaultCommandLineArgs::command() + .find_subcommand_mut("admin") + .map(|cmd| cmd.print_help()); + } + }; + } + Some(SubCommands::User { cmd }) => { + init_logger(false); + + let data_dir = DataDir(args.data_dir); + let conn = api::connect_sqlite(Some(data_dir.main_db_path()), None).await?; + + match cmd { + Some(UserSubCommands::ResetPassword { email, password }) => { + if get_user_by_email(&conn, &email).await.is_err() { + return Err(format!("User with email='{email}' not found.").into()); + } + api::force_password_reset(&conn, email.clone(), password).await?; + + println!("Password updated for '{email}'"); + } + Some(UserSubCommands::MintToken { email }) => { + let user = get_user_by_email(&conn, &email).await?; + let jwt = api::JwtHelper::init_from_path(&data_dir).await?; + + if !user.verified { + warn!("User '{email}' not verified"); + } + + let claims = TokenClaims::new( + user.verified, + user.uuid(), + user.email, + chrono::Duration::hours(12), + ); + let token = jwt.encode(&claims)?; + + println!("Bearer {token}"); + } + None => { + DefaultCommandLineArgs::command() + .find_subcommand_mut("user") + .map(|cmd| cmd.print_help()); + } + }; + } + Some(SubCommands::Email(ref cmd)) => { + init_logger(false); + + let (to, subject, body) = (cmd.to.clone(), cmd.subject.clone(), cmd.body.clone()); + + let (_new_db, state) = init_app_state(DataDir(args.data_dir), None, false).await?; + let email = Email::new(&state, to, subject, body)?; + email.send().await?; + + let c = state.get_config().email; + match (c.smtp_host, c.smtp_port, c.smtp_username, c.smtp_password) { + (Some(host), Some(port), Some(username), Some(_)) => { + println!("Sent email using: {username}@{host}:{port}"); + } + _ => { + println!("Sent email using system's sendmail"); + } + }; + } + None => { + let _ = DefaultCommandLineArgs::command().print_help(); + } + } + + Ok(()) +} diff --git a/trailbase-cli/src/lib.rs b/trailbase-cli/src/lib.rs new file mode 100644 index 0000000..a084798 --- /dev/null +++ b/trailbase-cli/src/lib.rs @@ -0,0 +1,11 @@ +#![allow(clippy::needless_return)] + +mod args; + +pub use args::{ + AdminSubCommands, DefaultCommandLineArgs, EmailArgs, JsonSchemaModeArg, SubCommands, + UserSubCommands, +}; + +#[cfg(feature = "openapi")] +pub use args::OpenApiSubCommands; diff --git a/trailbase-core/Cargo.toml b/trailbase-core/Cargo.toml new file mode 100644 index 0000000..1b44b75 --- /dev/null +++ b/trailbase-core/Cargo.toml @@ -0,0 +1,85 @@ +[package] +name = "trailbase-core" +version = "0.1.0" +edition = "2021" + +[[bench]] +name = "benchmark" +harness = false + +[features] +default = [] + +[dependencies] +arc-swap = "1.7.1" +argon2 = { version = "^0.5.3", default-features = false, features = ["alloc", "password-hash"] } +async-trait = "0.1.80" +axum = { version = "^0.7.5", features=["multipart"] } +axum-client-ip = "0.6.0" +axum-extra = { version = "^0.9.3", default-features = false, features=["protobuf"] } +base64 = { version = "0.22.1", default-features = false } +chrono = "^0.4.38" +cookie = "0.18.1" +ed25519-dalek = { version = "2.1.1", features=["pkcs8", "pem", "rand_core"] } +env_logger = "^0.11.3" +fallible-iterator = "0.3.0" +form_urlencoded = "1.2.1" +futures = "0.3.30" +indexmap = "2.6.0" +indoc = "2.0.5" +itertools = "0.13.0" +jsonschema = { version = "0.26.0", default-features = false } +jsonwebtoken = { version = "^9.3.0", default-features = false, features = ["use_pem"] } +lazy_static = "1.4.0" +lettre = { version = "^0.11.7", default-features = false, features = ["tokio1-rustls-tls", "sendmail-transport", "smtp-transport", "builder"] } +libsql = { workspace = true } +log = "^0.4.21" +minijinja = "2.1.2" +oauth2 = { version = "5.0.0-alpha.4", default-features = false, features = ["reqwest", "rustls-tls"] } +object_store = { version = "0.11.0", default-features = false } +parking_lot = "0.12.3" +prost = "^0.12.6" +prost-reflect = { version = "^0.13.0", features = ["derive", "text-format"] } +rand = "0.8.5" +refinery = { workspace = true } +refinery-libsql = { workspace = true } +regex = "1.11.0" +reqwest = { version = "0.12.5", default-features = false, features = ["rustls-tls", "json"] } +rusqlite = { workspace = true } +rust-embed = { version = "8.4.0", default-features = false, features = ["mime-guess"] } +serde = { version = "^1.0.203", features = ["derive"] } +serde_json = "^1.0.117" +serde_path_to_error = "0.1.16" +serde_urlencoded = "0.7.1" +sha2 = "0.10.8" +sqlformat = "0.2.4" +sqlite3-parser = "0.13.0" +thiserror = "1.0.61" +tokio = { version = "^1.38.0", features=["macros", "rt-multi-thread", "fs", "signal"] } +tower-cookies = { version = "0.10.0" } +tower-http = { version = "^0.6.0", features=["cors", "trace", "fs", "limit"] } +tower-service = "0.3.3" +tracing = "0.1.40" +tracing-subscriber = "0.3.18" +trailbase-sqlite = { path = "../trailbase-sqlite" } +ts-rs = { version = "10", features=["uuid-impl", "serde-json-impl"] } +url = "2.5.2" +utoipa = { version = "5.0.0-beta.0", features = ["axum_extras"] } +uuid = { version = "^1.8.0", features = ["v4", "serde"] } +validator = { version = "0.18.1", default-features = false } + +[build-dependencies] +env_logger = "^0.11.3" +log = "^0.4.21" +prost-build = "0.13.1" +prost-reflect-build = "0.14.0" + +[dev-dependencies] +anyhow = "^1.0.86" +axum-test = "16.0.0" +criterion = { version = "0.5", features = ["html_reports", "async_tokio"] } +trailbase-extension = { path = "../trailbase-extension" } +quoted_printable = "0.5.1" +schemars = "0.8.21" +temp-dir = "0.1.13" +tower = { version = "0.5.0", features = ["util"] } diff --git a/trailbase-core/benches/benchmark.rs b/trailbase-core/benches/benchmark.rs new file mode 100644 index 0000000..e4be178 --- /dev/null +++ b/trailbase-core/benches/benchmark.rs @@ -0,0 +1,242 @@ +#![allow(clippy::needless_return)] + +use criterion::{criterion_group, criterion_main, Criterion}; + +use axum::body::Body; +use axum::extract::{Json, State}; +use axum::http::{self, Request}; +use base64::prelude::*; +use libsql::{params, Connection}; +use std::sync::{Arc, Mutex}; +use tower::{Service, ServiceExt}; +use trailbase_core::config::proto::PermissionFlag; +use trailbase_core::records::Acls; + +use trailbase_core::api::{ + create_user_handler, login_with_password, query_one_row, CreateUserRequest, +}; +use trailbase_core::constants::RECORD_API_PATH; +use trailbase_core::records::{add_record_api, AccessRules}; +use trailbase_core::{DataDir, Server, ServerOptions}; + +async fn create_chat_message_app_tables(conn: &Connection) -> Result<(), libsql::Error> { + // Create a messages, chat room and members tables. + conn + .execute_batch( + r#" + CREATE TABLE room ( + id BLOB PRIMARY KEY NOT NULL CHECK(is_uuid_v7(id)) DEFAULT(uuid_v7()), + name TEXT + ) STRICT; + + CREATE TABLE message ( + id INTEGER PRIMARY KEY, + _owner BLOB NOT NULL, + room BLOB NOT NULL, + data TEXT NOT NULL DEFAULT 'empty', + + -- on user delete, toombstone it. + FOREIGN KEY(_owner) REFERENCES _user(id) ON DELETE SET NULL, + -- On chatroom delete, delete message + FOREIGN KEY(room) REFERENCES room(id) ON DELETE CASCADE + ) STRICT; + + CREATE TABLE room_members ( + user BLOB NOT NULL, + room BLOB NOT NULL, + + FOREIGN KEY(room) REFERENCES room(id) ON DELETE CASCADE, + FOREIGN KEY(user) REFERENCES _user(id) ON DELETE CASCADE + ) STRICT; + "#, + ) + .await?; + + return Ok(()); +} + +async fn add_room(conn: &Connection, name: &str) -> Result<[u8; 16], libsql::Error> { + let room: [u8; 16] = query_one_row( + conn, + "INSERT INTO room (name) VALUES ($1) RETURNING id", + params!(name), + ) + .await? + .get(0)?; + + return Ok(room); +} + +async fn add_user_to_room( + conn: &Connection, + user: [u8; 16], + room: [u8; 16], +) -> Result<(), libsql::Error> { + conn + .execute( + "INSERT INTO room_members (user, room) VALUES ($1, $2)", + params!(user, room), + ) + .await?; + return Ok(()); +} + +struct SetupResult { + app: Server, + + room: [u8; 16], + user_x: [u8; 16], + user_x_token: String, +} + +async fn setup_app() -> Result { + let data_dir = temp_dir::TempDir::new()?; + + let app = Server::init(ServerOptions { + data_dir: DataDir(data_dir.path().to_path_buf()), + ..Default::default() + }) + .await?; + + let state = app.state(); + let conn = state.conn(); + + create_chat_message_app_tables(conn).await?; + state.refresh_table_cache().await?; + + let room = add_room(conn, "room0").await?; + let password = "Secret!1!!"; + + let create_access_rule = + r#"(SELECT 1 FROM room_members WHERE user = _USER_.id AND room = _REQ_.room)"#; + + add_record_api( + &state, + "messages_api", + "message", + Acls { + authenticated: vec![PermissionFlag::Read, PermissionFlag::Create], + ..Default::default() + }, + AccessRules { + create: Some(create_access_rule.to_string()), + ..Default::default() + }, + ) + .await?; + + let email = "user_x@bar.com"; + let user_x = create_user_handler( + State(state.clone()), + Json(CreateUserRequest { + email: email.to_string(), + password: password.to_string(), + verified: true, + admin: false, + }), + ) + .await? + .id + .into_bytes(); + + let user_x_token = login_with_password(&state, email, password) + .await? + .auth_token; + + add_user_to_room(conn, user_x, room).await?; + + return Ok(SetupResult { + app, + room, + user_x, + user_x_token, + }); +} + +fn criterion_benchmark(c: &mut Criterion) { + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + + let mut setup: Option = None; + runtime.block_on(async { + let result = setup_app().await.unwrap(); + let mut router = result.app.router().clone(); + + ServiceExt::>::ready(&mut router) + .await + .unwrap(); + + let response = router + .call( + Request::builder() + .method(http::Method::GET) + .uri("/api/healthcheck") + .body(Body::from(vec![])) + .unwrap(), + ) + .await + .unwrap(); + + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + + assert_eq!(bytes.to_vec(), b"Ok"); + + setup = Some(result); + }); + + let setup = Arc::new(Mutex::new(setup.take().unwrap())); + + c.bench_function("iter create message", move |b| { + let mut bencher = b.to_async(&runtime); + + let setup = setup.clone(); + + let (body, user_x_token) = { + let s = setup.lock().unwrap(); + let request = serde_json::json!({ + "_owner": BASE64_URL_SAFE.encode(s.user_x), + "room": BASE64_URL_SAFE.encode(s.room), + "data": "user_x message to room", + }); + + let body = serde_json::to_vec(&request).unwrap(); + + (body, s.user_x_token.clone()) + }; + + bencher.iter(move || { + let setup = setup.clone(); + let body = body.clone(); + let auth = format!("Bearer {user_x_token}"); + + async move { + let mut router = setup.lock().unwrap().app.router().clone(); + let response = router + .call( + Request::builder() + .method(http::Method::POST) + .uri(&format!("/{RECORD_API_PATH}/messages_api")) + .header(http::header::CONTENT_TYPE, "application/json") + .header(http::header::AUTHORIZATION, &auth) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + if response.status() != http::StatusCode::OK { + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert!(false, "{body:?}"); + } + } + }); + }); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/trailbase-core/bindings/.gitignore b/trailbase-core/bindings/.gitignore new file mode 100644 index 0000000..a6c7c28 --- /dev/null +++ b/trailbase-core/bindings/.gitignore @@ -0,0 +1 @@ +*.js diff --git a/trailbase-core/bindings/AlterIndexRequest.ts b/trailbase-core/bindings/AlterIndexRequest.ts new file mode 100644 index 0000000..ab21976 --- /dev/null +++ b/trailbase-core/bindings/AlterIndexRequest.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TableIndex } from "./TableIndex"; + +export type AlterIndexRequest = { source_schema: TableIndex, target_schema: TableIndex, }; diff --git a/trailbase-core/bindings/AlterTableRequest.ts b/trailbase-core/bindings/AlterTableRequest.ts new file mode 100644 index 0000000..1872c93 --- /dev/null +++ b/trailbase-core/bindings/AlterTableRequest.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Table } from "./Table"; + +export type AlterTableRequest = { source_schema: Table, target_schema: Table, }; diff --git a/trailbase-core/bindings/AuthCodeToTokenRequest.ts b/trailbase-core/bindings/AuthCodeToTokenRequest.ts new file mode 100644 index 0000000..bd544cc --- /dev/null +++ b/trailbase-core/bindings/AuthCodeToTokenRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AuthCodeToTokenRequest = { authorization_code: string | null, pkce_code_verifier: string | null, }; diff --git a/trailbase-core/bindings/ChangeEmailRequest.ts b/trailbase-core/bindings/ChangeEmailRequest.ts new file mode 100644 index 0000000..a115368 --- /dev/null +++ b/trailbase-core/bindings/ChangeEmailRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ChangeEmailRequest = { csrf_token: string, old_email: string | null, new_email: string, }; diff --git a/trailbase-core/bindings/ChangePasswordRequest.ts b/trailbase-core/bindings/ChangePasswordRequest.ts new file mode 100644 index 0000000..bd1d638 --- /dev/null +++ b/trailbase-core/bindings/ChangePasswordRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ChangePasswordRequest = { old_password: string, new_password: string, new_password_repeat: string, }; diff --git a/trailbase-core/bindings/Column.ts b/trailbase-core/bindings/Column.ts new file mode 100644 index 0000000..68292fa --- /dev/null +++ b/trailbase-core/bindings/Column.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ColumnDataType } from "./ColumnDataType"; +import type { ColumnOption } from "./ColumnOption"; + +export type Column = { name: string, data_type: ColumnDataType, options: Array, }; diff --git a/trailbase-core/bindings/ColumnDataType.ts b/trailbase-core/bindings/ColumnDataType.ts new file mode 100644 index 0000000..4caf5c7 --- /dev/null +++ b/trailbase-core/bindings/ColumnDataType.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ColumnDataType = "Null" | "Any" | "Blob" | "Text" | "Integer" | "Real" | "Numeric" | "JSON" | "JSONB" | "Int" | "TinyInt" | "SmallInt" | "MediumInt" | "BigInt" | "UnignedBigInt" | "Int2" | "Int4" | "Int8" | "Character" | "Varchar" | "VaryingCharacter" | "NChar" | "NativeCharacter" | "NVarChar" | "Clob" | "Double" | "DoublePrecision" | "Float" | "Boolean" | "Decimal" | "Date" | "DateTime"; diff --git a/trailbase-core/bindings/ColumnOption.ts b/trailbase-core/bindings/ColumnOption.ts new file mode 100644 index 0000000..eaf7a67 --- /dev/null +++ b/trailbase-core/bindings/ColumnOption.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GeneratedExpressionMode } from "./GeneratedExpressionMode"; +import type { ReferentialAction } from "./ReferentialAction"; + +export type ColumnOption = "Null" | "NotNull" | { "Default": string } | { "Unique": { is_primary: boolean, } } | { "ForeignKey": { foreign_table: string, referred_columns: Array, on_delete: ReferentialAction | null, on_update: ReferentialAction | null, } } | { "Check": string } | { "OnUpdate": string } | { "Generated": { expr: string, mode: GeneratedExpressionMode | null, } }; diff --git a/trailbase-core/bindings/ColumnOrder.ts b/trailbase-core/bindings/ColumnOrder.ts new file mode 100644 index 0000000..3483a9d --- /dev/null +++ b/trailbase-core/bindings/ColumnOrder.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ColumnOrder = { column_name: string, ascending: boolean | null, nulls_first: boolean | null, }; diff --git a/trailbase-core/bindings/ConfiguredOAuthProvidersResponse.ts b/trailbase-core/bindings/ConfiguredOAuthProvidersResponse.ts new file mode 100644 index 0000000..7e0072d --- /dev/null +++ b/trailbase-core/bindings/ConfiguredOAuthProvidersResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ConfiguredOAuthProvidersResponse = { providers: Array<[string, string]>, }; diff --git a/trailbase-core/bindings/CreateIndexRequest.ts b/trailbase-core/bindings/CreateIndexRequest.ts new file mode 100644 index 0000000..8c83736 --- /dev/null +++ b/trailbase-core/bindings/CreateIndexRequest.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TableIndex } from "./TableIndex"; + +export type CreateIndexRequest = { schema: TableIndex, dry_run: boolean | null, }; diff --git a/trailbase-core/bindings/CreateIndexResponse.ts b/trailbase-core/bindings/CreateIndexResponse.ts new file mode 100644 index 0000000..a7b8cde --- /dev/null +++ b/trailbase-core/bindings/CreateIndexResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CreateIndexResponse = { sql: string, }; diff --git a/trailbase-core/bindings/CreateTableRequest.ts b/trailbase-core/bindings/CreateTableRequest.ts new file mode 100644 index 0000000..5a9cd9a --- /dev/null +++ b/trailbase-core/bindings/CreateTableRequest.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Table } from "./Table"; + +export type CreateTableRequest = { schema: Table, dry_run: boolean | null, }; diff --git a/trailbase-core/bindings/CreateTableResponse.ts b/trailbase-core/bindings/CreateTableResponse.ts new file mode 100644 index 0000000..02b735f --- /dev/null +++ b/trailbase-core/bindings/CreateTableResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CreateTableResponse = { sql: string, }; diff --git a/trailbase-core/bindings/CreateUserRequest.ts b/trailbase-core/bindings/CreateUserRequest.ts new file mode 100644 index 0000000..f508f40 --- /dev/null +++ b/trailbase-core/bindings/CreateUserRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CreateUserRequest = { email: string, password: string, verified: boolean, admin: boolean, }; diff --git a/trailbase-core/bindings/DeleteRowRequest.ts b/trailbase-core/bindings/DeleteRowRequest.ts new file mode 100644 index 0000000..3e3d3bb --- /dev/null +++ b/trailbase-core/bindings/DeleteRowRequest.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DeleteRowRequest = { primary_key_column: string, +/** + * The primary key (of any type since we're in row instead of RecordApi land) of rows that + * shall be deleted. + */ +value: Object, }; diff --git a/trailbase-core/bindings/DeleteRowsRequest.ts b/trailbase-core/bindings/DeleteRowsRequest.ts new file mode 100644 index 0000000..fa5cba5 --- /dev/null +++ b/trailbase-core/bindings/DeleteRowsRequest.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DeleteRowsRequest = { +/** + * Name of the primary key column we use to identify which rows to delete. + */ +primary_key_column: string, +/** + * A list of primary keys (of any type since we're in row instead of RecordApi land) + * of rows that shall be deleted. + */ +values: Object[], }; diff --git a/trailbase-core/bindings/DropIndexRequest.ts b/trailbase-core/bindings/DropIndexRequest.ts new file mode 100644 index 0000000..43d3ce7 --- /dev/null +++ b/trailbase-core/bindings/DropIndexRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DropIndexRequest = { name: string, }; diff --git a/trailbase-core/bindings/DropTableRequest.ts b/trailbase-core/bindings/DropTableRequest.ts new file mode 100644 index 0000000..0eb8e56 --- /dev/null +++ b/trailbase-core/bindings/DropTableRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DropTableRequest = { name: string, }; diff --git a/trailbase-core/bindings/ForeignKey.ts b/trailbase-core/bindings/ForeignKey.ts new file mode 100644 index 0000000..780639d --- /dev/null +++ b/trailbase-core/bindings/ForeignKey.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ReferentialAction } from "./ReferentialAction"; + +export type ForeignKey = { name: string | null, columns: Array, foreign_table: string, referred_columns: Array, on_delete: ReferentialAction | null, on_update: ReferentialAction | null, }; diff --git a/trailbase-core/bindings/GeneratedExpressionMode.ts b/trailbase-core/bindings/GeneratedExpressionMode.ts new file mode 100644 index 0000000..41dde66 --- /dev/null +++ b/trailbase-core/bindings/GeneratedExpressionMode.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type GeneratedExpressionMode = "Virtual" | "Stored"; diff --git a/trailbase-core/bindings/JsonSchema.ts b/trailbase-core/bindings/JsonSchema.ts new file mode 100644 index 0000000..07153fd --- /dev/null +++ b/trailbase-core/bindings/JsonSchema.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type JsonSchema = { name: string, schema: string, builtin: boolean, }; diff --git a/trailbase-core/bindings/ListJsonSchemasResponse.ts b/trailbase-core/bindings/ListJsonSchemasResponse.ts new file mode 100644 index 0000000..8ef7a41 --- /dev/null +++ b/trailbase-core/bindings/ListJsonSchemasResponse.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonSchema } from "./JsonSchema"; + +export type ListJsonSchemasResponse = { schemas: Array, }; diff --git a/trailbase-core/bindings/ListLogsResponse.ts b/trailbase-core/bindings/ListLogsResponse.ts new file mode 100644 index 0000000..4aa584c --- /dev/null +++ b/trailbase-core/bindings/ListLogsResponse.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LogJson } from "./LogJson"; +import type { Stats } from "./Stats"; + +export type ListLogsResponse = { total_row_count: bigint, cursor: string | null, entries: Array, stats: Stats | null, }; diff --git a/trailbase-core/bindings/ListRowsResponse.ts b/trailbase-core/bindings/ListRowsResponse.ts new file mode 100644 index 0000000..d2cd0a0 --- /dev/null +++ b/trailbase-core/bindings/ListRowsResponse.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Column } from "./Column"; + +export type ListRowsResponse = { total_row_count: bigint, cursor: string | null, columns: Array, rows: Object[][], }; diff --git a/trailbase-core/bindings/ListSchemasResponse.ts b/trailbase-core/bindings/ListSchemasResponse.ts new file mode 100644 index 0000000..0e6bd6d --- /dev/null +++ b/trailbase-core/bindings/ListSchemasResponse.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Table } from "./Table"; +import type { TableIndex } from "./TableIndex"; +import type { TableTrigger } from "./TableTrigger"; +import type { View } from "./View"; + +export type ListSchemasResponse = { tables: Array, indexes: Array, triggers: Array, views: Array, }; diff --git a/trailbase-core/bindings/ListUsersResponse.ts b/trailbase-core/bindings/ListUsersResponse.ts new file mode 100644 index 0000000..9ed0efb --- /dev/null +++ b/trailbase-core/bindings/ListUsersResponse.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { UserJson } from "./UserJson"; + +export type ListUsersResponse = { total_row_count: bigint, cursor: string | null, users: Array, }; diff --git a/trailbase-core/bindings/LogJson.ts b/trailbase-core/bindings/LogJson.ts new file mode 100644 index 0000000..120ea5c --- /dev/null +++ b/trailbase-core/bindings/LogJson.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type LogJson = { id: string, created: number, type: number, level: number, status: number, method: string, url: string, latency_ms: number, client_ip: string, referer: string, user_agent: string, data: Object | undefined, }; diff --git a/trailbase-core/bindings/LoginRequest.ts b/trailbase-core/bindings/LoginRequest.ts new file mode 100644 index 0000000..18bd4f5 --- /dev/null +++ b/trailbase-core/bindings/LoginRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type LoginRequest = { email: string, password: string, redirect_to: string | null, response_type: string | null, pkce_code_challenge: string | null, }; diff --git a/trailbase-core/bindings/LoginResponse.ts b/trailbase-core/bindings/LoginResponse.ts new file mode 100644 index 0000000..5896df3 --- /dev/null +++ b/trailbase-core/bindings/LoginResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type LoginResponse = { auth_token: string, refresh_token: string, csrf_token: string, }; diff --git a/trailbase-core/bindings/LoginStatusResponse.ts b/trailbase-core/bindings/LoginStatusResponse.ts new file mode 100644 index 0000000..411b98d --- /dev/null +++ b/trailbase-core/bindings/LoginStatusResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type LoginStatusResponse = { auth_token: string | null, refresh_token: string | null, csrf_token: string | null, }; diff --git a/trailbase-core/bindings/LogoutRequest.ts b/trailbase-core/bindings/LogoutRequest.ts new file mode 100644 index 0000000..b77a3d9 --- /dev/null +++ b/trailbase-core/bindings/LogoutRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type LogoutRequest = { refresh_token: string, }; diff --git a/trailbase-core/bindings/Mode.ts b/trailbase-core/bindings/Mode.ts new file mode 100644 index 0000000..89c3a63 --- /dev/null +++ b/trailbase-core/bindings/Mode.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Mode = "Expression" | "Statement"; diff --git a/trailbase-core/bindings/OAuthProviderEntry.ts b/trailbase-core/bindings/OAuthProviderEntry.ts new file mode 100644 index 0000000..bdb1853 --- /dev/null +++ b/trailbase-core/bindings/OAuthProviderEntry.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type OAuthProviderEntry = { id: number, name: string, display_name: string, }; diff --git a/trailbase-core/bindings/OAuthProviderResponse.ts b/trailbase-core/bindings/OAuthProviderResponse.ts new file mode 100644 index 0000000..5d43ff0 --- /dev/null +++ b/trailbase-core/bindings/OAuthProviderResponse.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { OAuthProviderEntry } from "./OAuthProviderEntry"; + +export type OAuthProviderResponse = { providers: Array, }; diff --git a/trailbase-core/bindings/ParseRequest.ts b/trailbase-core/bindings/ParseRequest.ts new file mode 100644 index 0000000..1b486e8 --- /dev/null +++ b/trailbase-core/bindings/ParseRequest.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Mode } from "./Mode"; + +export type ParseRequest = { query: string, mode: Mode | null, }; diff --git a/trailbase-core/bindings/ParseResponse.ts b/trailbase-core/bindings/ParseResponse.ts new file mode 100644 index 0000000..01db64f --- /dev/null +++ b/trailbase-core/bindings/ParseResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ParseResponse = { ok: boolean, message: string | null, }; diff --git a/trailbase-core/bindings/QueryRequest.ts b/trailbase-core/bindings/QueryRequest.ts new file mode 100644 index 0000000..be793bf --- /dev/null +++ b/trailbase-core/bindings/QueryRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type QueryRequest = { query: string, }; diff --git a/trailbase-core/bindings/QueryResponse.ts b/trailbase-core/bindings/QueryResponse.ts new file mode 100644 index 0000000..3dc8d96 --- /dev/null +++ b/trailbase-core/bindings/QueryResponse.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Column } from "./Column"; + +export type QueryResponse = { columns: Array | null, rows: Object[][], }; diff --git a/trailbase-core/bindings/ReadFilesRequest.ts b/trailbase-core/bindings/ReadFilesRequest.ts new file mode 100644 index 0000000..cdac0f1 --- /dev/null +++ b/trailbase-core/bindings/ReadFilesRequest.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ReadFilesRequest = { pk_column: string, +/** + * The primary key (of any type since we're in row instead of RecordAPI land) of rows that + * shall be deleted. + */ +pk_value: Object, file_column_name: string, file_index: number | null, }; diff --git a/trailbase-core/bindings/ReferentialAction.ts b/trailbase-core/bindings/ReferentialAction.ts new file mode 100644 index 0000000..527c88e --- /dev/null +++ b/trailbase-core/bindings/ReferentialAction.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ReferentialAction = "Restrict" | "Cascade" | "SetNull" | "NoAction" | "SetDefault"; diff --git a/trailbase-core/bindings/RefreshRequest.ts b/trailbase-core/bindings/RefreshRequest.ts new file mode 100644 index 0000000..e80e5c4 --- /dev/null +++ b/trailbase-core/bindings/RefreshRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RefreshRequest = { refresh_token: string, }; diff --git a/trailbase-core/bindings/RefreshResponse.ts b/trailbase-core/bindings/RefreshResponse.ts new file mode 100644 index 0000000..a6bc170 --- /dev/null +++ b/trailbase-core/bindings/RefreshResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RefreshResponse = { auth_token: string, csrf_token: string, }; diff --git a/trailbase-core/bindings/Stats.ts b/trailbase-core/bindings/Stats.ts new file mode 100644 index 0000000..f19acc5 --- /dev/null +++ b/trailbase-core/bindings/Stats.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Stats = { rate: Array<[bigint, number]>, }; diff --git a/trailbase-core/bindings/Table.ts b/trailbase-core/bindings/Table.ts new file mode 100644 index 0000000..4012f3a --- /dev/null +++ b/trailbase-core/bindings/Table.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Column } from "./Column"; +import type { ForeignKey } from "./ForeignKey"; +import type { UniqueConstraint } from "./UniqueConstraint"; + +export type Table = { name: string, strict: boolean, columns: Array, foreign_keys: Array, unique: Array, virtual_table: boolean, temporary: boolean, }; diff --git a/trailbase-core/bindings/TableIndex.ts b/trailbase-core/bindings/TableIndex.ts new file mode 100644 index 0000000..158185a --- /dev/null +++ b/trailbase-core/bindings/TableIndex.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ColumnOrder } from "./ColumnOrder"; + +export type TableIndex = { name: string, table_name: string, columns: Array, unique: boolean, predicate: string | null, }; diff --git a/trailbase-core/bindings/TableTrigger.ts b/trailbase-core/bindings/TableTrigger.ts new file mode 100644 index 0000000..cd7703b --- /dev/null +++ b/trailbase-core/bindings/TableTrigger.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TableTrigger = { name: string, table_name: string, sql: string, }; diff --git a/trailbase-core/bindings/UniqueConstraint.ts b/trailbase-core/bindings/UniqueConstraint.ts new file mode 100644 index 0000000..d8d9dee --- /dev/null +++ b/trailbase-core/bindings/UniqueConstraint.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type UniqueConstraint = { name: string | null, +/** + * Identifiers of the columns that are unique. + * TODO: Should be indexed/ordered column. + */ +columns: Array, }; diff --git a/trailbase-core/bindings/UpdateJsonSchemaRequest.ts b/trailbase-core/bindings/UpdateJsonSchemaRequest.ts new file mode 100644 index 0000000..b310524 --- /dev/null +++ b/trailbase-core/bindings/UpdateJsonSchemaRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type UpdateJsonSchemaRequest = { name: string, schema: Object | undefined, }; diff --git a/trailbase-core/bindings/UpdateRowRequest.ts b/trailbase-core/bindings/UpdateRowRequest.ts new file mode 100644 index 0000000..ddf36ec --- /dev/null +++ b/trailbase-core/bindings/UpdateRowRequest.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type UpdateRowRequest = { primary_key_column: string, primary_key_value: Object, +/** + * This is expected to be a map from column name to value. + * + * Note that using an array here wouldn't make sense. The map allows for sparseness and only + * updating specific cells. + */ +row: { [key: string]: Object | undefined }, }; diff --git a/trailbase-core/bindings/UpdateUserRequest.ts b/trailbase-core/bindings/UpdateUserRequest.ts new file mode 100644 index 0000000..2fdcad0 --- /dev/null +++ b/trailbase-core/bindings/UpdateUserRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type UpdateUserRequest = { id: string, email: string | null, password: string | null, verified: boolean | null, }; diff --git a/trailbase-core/bindings/UserJson.ts b/trailbase-core/bindings/UserJson.ts new file mode 100644 index 0000000..37f825b --- /dev/null +++ b/trailbase-core/bindings/UserJson.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type UserJson = { id: string, email: string, verified: boolean, admin: boolean, provider_id: bigint, provider_user_id: string | null, email_verification_code: string, }; diff --git a/trailbase-core/bindings/View.ts b/trailbase-core/bindings/View.ts new file mode 100644 index 0000000..9793d69 --- /dev/null +++ b/trailbase-core/bindings/View.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Column } from "./Column"; + +export type View = { name: string, +/** + * Columns may be inferred from a view's query. + * + * Views can be defined with arbitrary queries referencing arbitrary sources: tables, views, + * functions, ..., which makes them inherently not type safe and therefore their columns not + * well defined. + */ +columns: Array | null, query: string, temporary: boolean, }; diff --git a/trailbase-core/build.rs b/trailbase-core/build.rs new file mode 100644 index 0000000..b1e32bd --- /dev/null +++ b/trailbase-core/build.rs @@ -0,0 +1,113 @@ +#![allow(clippy::needless_return)] + +use log::*; +use std::env; +use std::fs::{self}; +use std::io::{Result, Write}; +use std::path::{Path, PathBuf}; + +#[allow(unused)] +fn copy_dir(src: impl AsRef, dst: impl AsRef) -> Result<()> { + fs::create_dir_all(&dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + if entry.file_name().to_str().unwrap().starts_with(".") { + continue; + } + + if entry.file_type()?.is_dir() { + copy_dir(entry.path(), dst.as_ref().join(entry.file_name()))?; + } else { + fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?; + } + } + + return Ok(()); +} + +fn build_ui(path: &str) -> Result<()> { + let pnpm_run = |args: &[&str]| -> Result { + let output = std::process::Command::new("pnpm") + .current_dir("..") + .args(args) + .output()?; + + std::io::stdout().write_all(&output.stdout).unwrap(); + std::io::stderr().write_all(&output.stderr).unwrap(); + + Ok(output) + }; + + let _ = pnpm_run(&["--dir", path, "install", "--frozen-lockfile"]); + + let output = pnpm_run(&["--dir", path, "build"])?; + if !output.status.success() { + // NOTE: We don't want to break backend-builds on frontend errors, at least for dev builds. + if Ok("release") == env::var("PROFILE").as_deref() { + panic!( + "Failed to build ui '{path}': {}", + String::from_utf8_lossy(&output.stderr) + ); + } + warn!( + "Failed to build ui '{path}': {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + return Ok(()); +} + +fn build_protos() -> Result<()> { + const PROTO_PATH: &str = "../proto"; + println!("cargo::rerun-if-changed={PROTO_PATH}"); + + let prost_config = { + let mut config = prost_build::Config::new(); + config.enum_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]"); + config + }; + + // "descriptor.proto" is provided by "libprotobuf-dev" on Debian and lives in: + // /usr/include/google/protobuf/descriptor.proto + let includes = vec![PathBuf::from("/usr/include"), PathBuf::from(PROTO_PATH)]; + let proto_files = vec![ + PathBuf::from(format!("{PROTO_PATH}/config.proto")), + PathBuf::from(format!("{PROTO_PATH}/config_api.proto")), + PathBuf::from(format!("{PROTO_PATH}/vault.proto")), + ]; + + prost_reflect_build::Builder::new() + .descriptor_pool("crate::DESCRIPTOR_POOL") + //.file_descriptor_set_bytes("crate::FILE_DESCRIPTOR_SET") + .compile_protos_with_config(prost_config, &proto_files, &includes)?; + + return Ok(()); +} + +fn main() -> Result<()> { + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + build_protos().unwrap(); + + // WARN: watching non-existent paths will also trigger rebuilds. + println!("cargo::rerun-if-changed=../client/trailbase-ts/src/"); + + { + let path = "ui/admin"; + println!("cargo::rerun-if-changed=../{path}/src/components/"); + println!("cargo::rerun-if-changed=../{path}/src/lib/"); + let _ = build_ui(path); + } + + { + let path = "ui/auth"; + println!("cargo::rerun-if-changed=../{path}/src/components/"); + println!("cargo::rerun-if-changed=../{path}/src/lib/"); + println!("cargo::rerun-if-changed=../{path}/src/pages/"); + println!("cargo::rerun-if-changed=../{path}/src/layouts/"); + let _ = build_ui("ui/auth"); + } + + return Ok(()); +} diff --git a/trailbase-core/migrations/logs/V1__initial.sql b/trailbase-core/migrations/logs/V1__initial.sql new file mode 100644 index 0000000..d8b133a --- /dev/null +++ b/trailbase-core/migrations/logs/V1__initial.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS _logs ( + -- NOTE: We're skipping the CHECK(is_uuid_v7) here as mostly inconsequential + -- micro-optimization but we also don't wanna expose the logs via RecordAPIs, + -- so there's not strict need. + id BLOB PRIMARY KEY DEFAULT (uuid_v7()) NOT NULL, + -- Timestamp in seconds with fractional millisecond resolution. + created REAL DEFAULT (UNIXEPOCH('subsec')) NOT NULL, + -- Entry type. We could probably also split by table :shrug: + type INTEGER DEFAULT 0 NOT NULL, + + level INTEGER DEFAULT 0 NOT NULL, + status INTEGER DEFAULT 0 NOT NULL, + method TEXT DEFAULT '' NOT NULL, + url TEXT DEFAULT '' NOT NULL, + + latency REAL DEFAULT 0 NOT NULL, + + client_ip TEXT DEFAULT '' NOT NULL, + referer TEXT DEFAULT '' NOT NULL, + user_agent TEXT DEFAULT '' NOT NULL, + + data BLOB +) strict; + +CREATE INDEX IF NOT EXISTS __logs__level_index ON _logs (level); +CREATE INDEX IF NOT EXISTS __logs__created_index ON _logs (created); +CREATE INDEX IF NOT EXISTS __logs__status_index ON _logs (status); +CREATE INDEX IF NOT EXISTS __logs__method_index ON _logs (method); diff --git a/trailbase-core/migrations/main/V1__initial.sql b/trailbase-core/migrations/main/V1__initial.sql new file mode 100644 index 0000000..64b9062 --- /dev/null +++ b/trailbase-core/migrations/main/V1__initial.sql @@ -0,0 +1,84 @@ +-- +-- User table. +-- +CREATE TABLE _user ( + id BLOB PRIMARY KEY NOT NULL CHECK(is_uuid_v7(id)) DEFAULT (uuid_v7()), + email TEXT NOT NULL CHECK(is_email(email)), + password_hash TEXT DEFAULT '' NOT NULL, + verified INTEGER DEFAULT FALSE NOT NULL, + admin INTEGER DEFAULT FALSE NOT NULL, + + created INTEGER DEFAULT (UNIXEPOCH()) NOT NULL, + updated INTEGER DEFAULT (UNIXEPOCH()) NOT NULL, + + -- Ephemeral data for auth flows. + -- + -- Email change/verification flow. + email_verification_code TEXT, + email_verification_code_sent_at INTEGER, + -- Change email flow. + pending_email TEXT CHECK(is_email(pending_email)), + -- Reset forgotten password flow. + password_reset_code TEXT, + password_reset_code_sent_at INTEGER, + -- Authorization Code Flow (optionally with PKCE proof key). + authorization_code TEXT, + authorization_code_sent_at INTEGER, + pkce_code_challenge TEXT, + + -- OAuth metadata + -- + -- provider_id maps to proto.config.OAuthProviderId enum. + provider_id INTEGER DEFAULT 0 NOT NULL, + -- The external provider's id for the user. + provider_user_id TEXT, + -- Link to an external avatar image for oauth providers only. + provider_avatar_url TEXT +) STRICT; + +CREATE UNIQUE INDEX __user__email_index ON _user (email); +CREATE UNIQUE INDEX __user__email_verification_code_index ON _user (email_verification_code); +CREATE UNIQUE INDEX __user__password_reset_code_index ON _user (password_reset_code); +CREATE UNIQUE INDEX __user__authorization_code_index ON _user (authorization_code); +CREATE UNIQUE INDEX __user__provider_ids_index ON _user (provider_id, provider_user_id); + +CREATE TRIGGER __user__updated_trigger AFTER UPDATE ON _user FOR EACH ROW + BEGIN + UPDATE _user SET updated = UNIXEPOCH() WHERE id = OLD.id; + END; + +-- +-- Session table +-- +CREATE TABLE _session ( + id INTEGER PRIMARY KEY NOT NULL, + user BLOB NOT NULL REFERENCES _user(id) ON DELETE CASCADE, + refresh_token TEXT NOT NULL, + updated INTEGER DEFAULT (UNIXEPOCH()) NOT NULL +) STRICT; + +-- NOTE: The expiry is computed based on `updated` + TTL, thus touching the row +-- will extend the opaque refresh token's expiry. +CREATE TRIGGER __session__updated_trigger AFTER UPDATE ON _session FOR EACH ROW + BEGIN + UPDATE _session SET updated = UNIXEPOCH() WHERE user = OLD.user; + END; + +-- Main unique index to lookup refresh tokens efficiently. +CREATE UNIQUE INDEX __session__refresh_token_index ON _session (refresh_token); +-- An index on the user for efficient deletions of all sessions given a user. +CREATE INDEX __session__user_index ON _session (user); + +-- +-- User avatar table +-- +CREATE TABLE _user_avatar ( + user BLOB PRIMARY KEY NOT NULL REFERENCES _user(id) ON DELETE CASCADE, + file TEXT CHECK(jsonschema('std.FileUpload', file, 'image/png, image/jpeg')) NOT NULL, + updated INTEGER DEFAULT (UNIXEPOCH()) NOT NULL +) STRICT; + +CREATE TRIGGER __user_avatar__updated_trigger AFTER UPDATE ON _user_avatar FOR EACH ROW + BEGIN + UPDATE _user_avatar SET updated = UNIXEPOCH() WHERE user = OLD.user; + END; diff --git a/trailbase-core/src/admin/config/get_config.rs b/trailbase-core/src/admin/config/get_config.rs new file mode 100644 index 0000000..fd95bfa --- /dev/null +++ b/trailbase-core/src/admin/config/get_config.rs @@ -0,0 +1,35 @@ +use axum::extract::State; +use axum_extra::protobuf::Protobuf; +use base64::prelude::*; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::config::proto::GetConfigResponse; + +pub async fn get_config_handler( + State(state): State, +) -> Result, Error> { + let config = state.get_config(); + let hash = config.hash(); + + // NOTE: We used to strip the secrets to avoid exposing them in the admin dashboard. This would + // be mostly relevant if no TLS (plain text transmission) or if we wanted to have less privileged + // dashboard users than admins. + // We went back on this for now, since this requires very complicated merging. For example, an + // oauth provider is already configured and an admin adds another one. You get back: + // + // [ + // { provider_id: X, client_id: "old" }, + // { provider_id: Y, client_id: "new", client_secret: "new_secret" }, + // ] + // + // which fails validation because "old" is missing the "secret". We'd have to merge secrets back + // before validation on entries, which haven't been removed, ... and this true for all secrets. + // + // let (stripped, _secrets) = strip_secrets(&config)?; + + return Ok(Protobuf(GetConfigResponse { + config: Some(config), + hash: Some(BASE64_URL_SAFE.encode(hash.to_le_bytes())), + })); +} diff --git a/trailbase-core/src/admin/config/mod.rs b/trailbase-core/src/admin/config/mod.rs new file mode 100644 index 0000000..49d30ab --- /dev/null +++ b/trailbase-core/src/admin/config/mod.rs @@ -0,0 +1,5 @@ +mod get_config; +mod update_config; + +pub use get_config::get_config_handler; +pub use update_config::update_config_handler; diff --git a/trailbase-core/src/admin/config/update_config.rs b/trailbase-core/src/admin/config/update_config.rs new file mode 100644 index 0000000..1da417d --- /dev/null +++ b/trailbase-core/src/admin/config/update_config.rs @@ -0,0 +1,33 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum_extra::protobuf::Protobuf; +use base64::prelude::*; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::config::proto::UpdateConfigRequest; +use crate::config::ConfigError; + +pub async fn update_config_handler( + State(state): State, + Protobuf(request): Protobuf, +) -> Result { + let Some(Ok(hash)) = request.hash.map(|h| BASE64_URL_SAFE.decode(h)) else { + return Err(Error::Precondition("Missing hash".to_string())); + }; + let Some(config) = request.config else { + return Err(Error::Precondition("Missing config".to_string())); + }; + + let current_hash = state.get_config().hash(); + if current_hash.to_le_bytes() == *hash { + state + .validate_and_update_config(config, Some(current_hash)) + .await?; + + return Ok((StatusCode::OK, "Config updated")); + } + + return Err(ConfigError::Update("Concurrent edit".to_string()).into()); +} diff --git a/trailbase-core/src/admin/error.rs b/trailbase-core/src/admin/error.rs new file mode 100644 index 0000000..0d39d96 --- /dev/null +++ b/trailbase-core/src/admin/error.rs @@ -0,0 +1,73 @@ +use axum::body::Body; +use axum::http::{header::CONTENT_TYPE, StatusCode}; +use axum::response::{IntoResponse, Response}; +use log::*; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AdminError { + #[error("Libsql error: {0}")] + Libsql(#[from] libsql::Error), + #[error("Deserialization error: {0}")] + Deserialization(#[from] serde::de::value::Error), + #[error("JsonSerialization error: {0}")] + JsonSerialization(#[from] serde_json::Error), + #[error("Base64 decoding error: {0}")] + Base64Decode(#[from] base64::DecodeError), + #[error("Already exists: {0}")] + AlreadyExists(&'static str), + #[error("precondition failed: {0}")] + Precondition(String), + #[error("Schema error: {0}")] + Schema(#[from] crate::schema::SchemaError), + #[error("Table lookup error: {0}")] + TableLookup(#[from] crate::table_metadata::TableLookupError), + #[error("DB Migration error: {0}")] + Migration(#[from] refinery::Error), + #[error("SQL -> Json error: {0}")] + Json(#[from] crate::records::sql_to_json::JsonError), + #[error("Schema error: {0}")] + SchemaError(#[from] trailbase_sqlite::schema::SchemaError), + #[error("Json -> SQL Params error: {0}")] + Params(#[from] crate::records::json_to_sql::ParamsError), + #[error("Config error: {0}")] + Config(#[from] crate::config::ConfigError), + #[error("Auth error: {0}")] + Auth(#[from] crate::auth::AuthError), + #[error("WhereClause error: {0}")] + WhereClause(#[from] crate::listing::WhereClauseError), + #[error("Transaction error: {0}")] + Transaction(#[from] crate::transaction::TransactionError), + #[error("JSON schema error: {0}")] + JSONSchema(#[from] crate::table_metadata::JsonSchemaError), + #[error("Email error: {0}")] + Email(#[from] crate::email::EmailError), + #[error("Query error: {0}")] + Query(#[from] crate::records::json_to_sql::QueryError), + #[error("File error: {0}")] + File(#[from] crate::records::files::FileError), + #[error("Sql parse error: {0}")] + SqlParse(#[from] sqlite3_parser::lexer::sql::Error), +} + +impl IntoResponse for AdminError { + fn into_response(self) -> Response { + let (status, msg) = match self { + // FIXME: For error types that already implement "into_response" we should just unpack them. + // We should be able to use a generic for that. + Self::Auth(err) => return err.into_response(), + Self::Deserialization(_) => (StatusCode::BAD_REQUEST, self.to_string()), + Self::Precondition(_) => (StatusCode::BAD_REQUEST, self.to_string()), + Self::AlreadyExists(_) => (StatusCode::CONFLICT, self.to_string()), + // NOTE: We can almost always leak the internal error (except for permission errors) since + // these are errors for the admin apis. + ref _err => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), + }; + + return Response::builder() + .status(status) + .header(CONTENT_TYPE, "text/plain") + .body(Body::new(msg)) + .unwrap(); + } +} diff --git a/trailbase-core/src/admin/jwt.rs b/trailbase-core/src/admin/jwt.rs new file mode 100644 index 0000000..d3dc2c4 --- /dev/null +++ b/trailbase-core/src/admin/jwt.rs @@ -0,0 +1,10 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; + +pub async fn get_public_key(State(state): State) -> Result { + return Ok((StatusCode::OK, state.jwt().public_key()).into_response()); +} diff --git a/trailbase-core/src/admin/list_logs.rs b/trailbase-core/src/admin/list_logs.rs new file mode 100644 index 0000000..4b9e64a --- /dev/null +++ b/trailbase-core/src/admin/list_logs.rs @@ -0,0 +1,396 @@ +use axum::{ + extract::{RawQuery, State}, + Json, +}; +use chrono::{DateTime, Duration, Utc}; +use lazy_static::lazy_static; +use libsql::{de, params::Params, Connection}; +use log::*; +use serde::{Deserialize, Serialize}; +use trailbase_sqlite::query_one_row; +use ts_rs::TS; +use uuid::Uuid; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::constants::{LOGS_RETENTION_DEFAULT, LOGS_TABLE_ID_COLUMN}; +use crate::listing::{ + build_filter_where_clause, limit_or_default, parse_query, Order, WhereClause, +}; +use crate::logging::Log; +use crate::table_metadata::{lookup_and_parse_table_schema, TableMetadata}; +use crate::util::id_to_b64; + +#[derive(Debug, Serialize, TS)] +pub struct LogJson { + pub id: Uuid, + + pub created: f64, + pub r#type: i32, + + pub level: i32, + pub status: u16, + pub method: String, + pub url: String, + + pub latency_ms: f64, + pub client_ip: String, + pub referer: String, + pub user_agent: String, + + #[ts(type = "Object | undefined")] + pub data: Option, +} + +impl From for LogJson { + fn from(value: Log) -> Self { + return LogJson { + id: Uuid::from_bytes(value.id.unwrap()), + created: value.created.unwrap_or(0.0), + r#type: value.r#type, + level: value.level, + status: value.status, + method: value.method, + url: value.url, + latency_ms: value.latency, + client_ip: value.client_ip, + referer: value.referer, + user_agent: value.user_agent, + data: value.data, + }; + } +} + +#[derive(Debug, Serialize, TS)] +#[ts(export)] +pub struct ListLogsResponse { + total_row_count: i64, + cursor: Option, + entries: Vec, + + stats: Option, +} + +// FIXME: should be an admin-only api. +pub async fn list_logs_handler( + State(state): State, + RawQuery(raw_url_query): RawQuery, +) -> Result, Error> { + let conn = state.logs_conn(); + + // FIXME: we should probably return an error if the query parsing fails rather than quietly + // falling back to defaults. + let url_query = parse_query(raw_url_query); + let (filter_params, cursor, limit, order) = match url_query { + Some(q) => (Some(q.params), q.cursor, q.limit, q.order), + None => (None, None, None, None), + }; + + // NOTE: We cannot use state.table_metadata() here, since we're working on the logs database. + // We could cache, however this is just the admin logs handler. + let table = lookup_and_parse_table_schema(conn, LOGS_TABLE_NAME).await?; + let table_metadata = TableMetadata::new(table.clone(), &[table]); + let filter_where_clause = build_filter_where_clause(&table_metadata, filter_params)?; + + let total_row_count = { + let row = query_one_row( + conn, + &format!( + "SELECT COUNT(*) FROM {LOGS_TABLE_NAME} WHERE {clause}", + clause = filter_where_clause.clause + ), + Params::Named(filter_where_clause.params.clone()), + ) + .await?; + + row.get::(0)? + }; + + lazy_static! { + static ref DEFAULT_ORDERING: Vec<(String, Order)> = + vec![(LOGS_TABLE_ID_COLUMN.to_string(), Order::Descending)]; + } + let logs = fetch_logs( + conn, + filter_where_clause.clone(), + cursor, + order.unwrap_or_else(|| DEFAULT_ORDERING.clone()), + limit_or_default(limit), + ) + .await?; + + let stats = { + let now = Utc::now(); + let args = FetchAggregateArgs { + filter_where_clause: Some(filter_where_clause), + from: now + - Duration::seconds(state.access_config(|c| { + c.server + .logs_retention_sec + .unwrap_or_else(|| LOGS_RETENTION_DEFAULT.num_seconds()) + })), + to: now, + interval: Duration::seconds(600), + }; + + let first_page = cursor.is_none(); + match first_page { + true => { + let stats = fetch_aggregate_stats(conn, &args).await; + + if let Err(ref err) = stats { + warn!("Failed to fetch stats for {args:?}: {err}"); + } + stats.ok() + } + false => None, + } + }; + + let response = ListLogsResponse { + total_row_count, + cursor: logs.last().and_then(|log| log.id.as_ref().map(id_to_b64)), + entries: logs + .into_iter() + .map(|log| log.into()) + .collect::>(), + stats, + }; + + return Ok(Json(response)); +} + +async fn fetch_logs( + conn: &Connection, + filter_where_clause: WhereClause, + cursor: Option<[u8; 16]>, + order: Vec<(String, Order)>, + limit: usize, +) -> Result, Error> { + let mut params = filter_where_clause.params; + let mut where_clause = filter_where_clause.clause; + params.push((":limit".to_string(), libsql::Value::Integer(limit as i64))); + + if let Some(cursor) = cursor { + params.push((":cursor".to_string(), libsql::Value::Blob(cursor.to_vec()))); + where_clause = format!("{where_clause} AND _row_.id < :cursor",); + } + + let order_clause = order + .iter() + .map(|(col, ord)| { + format!( + "_row_.{col} {}", + match ord { + Order::Descending => "DESC", + Order::Ascending => "ASC", + } + ) + }) + .collect::>() + .join(", "); + + let sql_query = format!( + r#" + SELECT _row_.* + FROM + (SELECT * FROM {LOGS_TABLE_NAME}) as _row_ + WHERE + {where_clause} + ORDER BY + {order_clause} + LIMIT :limit + "#, + ); + + let mut rows = conn.query(&sql_query, Params::Named(params)).await?; + + let mut logs: Vec = vec![]; + while let Ok(Some(row)) = rows.next().await { + match de::from_row(&row) { + Ok(log) => logs.push(log), + Err(err) => warn!("failed: {err}"), + }; + } + return Ok(logs); +} + +#[derive(Debug, Serialize, TS)] +pub struct Stats { + // List of (timestamp, value). + rate: Vec<(i64, f64)>, +} + +#[derive(Debug)] +struct FetchAggregateArgs { + filter_where_clause: Option, + from: DateTime, + to: DateTime, + interval: Duration, +} + +async fn fetch_aggregate_stats( + conn: &Connection, + args: &FetchAggregateArgs, +) -> Result { + let filter_clause = args + .filter_where_clause + .as_ref() + .map(|c| c.clause.clone()) + .unwrap_or_else(|| "TRUE".to_string()); + + #[derive(Deserialize)] + struct AggRow { + interval_end_ts: i64, + count: i64, + } + + // Aggregate rate of all logs in the same :interval_seconds. + // + // Note, we're aligning the interval wide grid with the latest `to` timestamp to minimize + // artifacts when (to - from) / interval is not an integer. This way we only get artifacts in the + // oldest interval. + let qps_query = format!( + r#" + SELECT + CAST(ROUND((created - :to_seconds) / :interval_seconds) AS INTEGER) * :interval_seconds + :to_seconds AS interval_end_ts, + COUNT(*) as count + FROM + (SELECT * FROM {LOGS_TABLE_NAME} WHERE created > :from_seconds AND created < :to_seconds AND {filter_clause} ORDER BY id DESC) + GROUP BY + interval_end_ts + ORDER BY + interval_end_ts ASC + "# + ); + + use libsql::Value::Integer; + let from_seconds = args.from.timestamp(); + let interval_seconds = args.interval.num_seconds(); + let mut params: Vec<(String, libsql::Value)> = vec![ + (":interval_seconds".to_string(), Integer(interval_seconds)), + (":from_seconds".to_string(), Integer(from_seconds)), + (":to_seconds".to_string(), Integer(args.to.timestamp())), + ]; + + if let Some(filter) = &args.filter_where_clause { + params.extend(filter.params.clone()) + } + + let mut rows = conn.query(&qps_query, Params::Named(params)).await?; + + let mut rate: Vec<(i64, f64)> = vec![]; + while let Ok(Some(row)) = rows.next().await { + let r: AggRow = de::from_row(&row)?; + + // The oldest interval may be clipped if "(to-from)/interval" isn't integer. In this case + // dividide by a shorter interval length to reduce artifacting. Otherwise, the clipped + // interval would appear to have a lower rater. + let effective_interval_seconds = std::cmp::min( + interval_seconds, + r.interval_end_ts - (from_seconds - interval_seconds), + ) as f64; + + rate.push(( + // Use interval midpoint as timestamp. + r.interval_end_ts - interval_seconds / 2, + // Compute rate from event count in interval. + (r.count as f64) / effective_interval_seconds, + )); + } + + return Ok(Stats { rate }); +} + +#[cfg(test)] +mod tests { + use chrono::{DateTime, Duration}; + + use super::*; + use crate::migrations::apply_logs_migrations; + + #[tokio::test] + async fn test_aggregate_rate_computation() { + let conn = trailbase_sqlite::connect_sqlite(None, None).await.unwrap(); + apply_logs_migrations(conn.clone()).await.unwrap(); + + let interval_seconds = 600; + let to = DateTime::parse_from_rfc3339("1996-12-22T12:00:00Z").unwrap(); + // An **almost** 24h interval. We make it slightly shorter, so we get some clipping. + let from = to - Duration::seconds(24 * 3600 - 20); + + { + // Insert test data. + let before = (from - Duration::seconds(1)).timestamp(); + let after = (to + Duration::seconds(1)).timestamp(); + + let just_inside0 = (from + Duration::seconds(10)).timestamp(); + let just_inside1 = (to - Duration::seconds(10)).timestamp(); + + let smack_in_there0 = (from + Duration::seconds(12 * 3600)).timestamp(); + let smack_in_there1 = (from + Duration::seconds(12 * 3600 + 1)).timestamp(); + + conn + .execute_batch(&format!( + r#" + INSERT INTO {LOGS_TABLE_NAME} (created) VALUES({before}); + INSERT INTO {LOGS_TABLE_NAME} (created) VALUES({after}); + + INSERT INTO {LOGS_TABLE_NAME} (created) VALUES({just_inside0}); + INSERT INTO {LOGS_TABLE_NAME} (created) VALUES({just_inside1}); + + INSERT INTO {LOGS_TABLE_NAME} (created) VALUES({smack_in_there0}); + INSERT INTO {LOGS_TABLE_NAME} (created) VALUES({smack_in_there1}); + "#, + )) + .await + .unwrap(); + } + + let args = FetchAggregateArgs { + filter_where_clause: None, + from: from.into(), + to: to.into(), + interval: Duration::seconds(interval_seconds), + }; + + let stats = fetch_aggregate_stats(&conn, &args).await.unwrap(); + + // Assert that there are 3 data points in the given range and that all of them have a rate of + // one log in the 600s interval. + let rates = stats.rate; + assert_eq!(rates.len(), 3); + + // Assert the oldest, clipped interval has a slightly elevated rate. + { + let rate = rates[0]; + assert_eq!( + DateTime::from_timestamp(rate.0, 0).unwrap(), + DateTime::parse_from_rfc3339("1996-12-21T11:55:00Z").unwrap() + ); + assert!(rate.1 > 1.0 / interval_seconds as f64); + } + + // Assert the middle rate, has two logs, i.e. double the base rate. + { + let rate = rates[1]; + assert_eq!( + DateTime::from_timestamp(rate.0, 0).unwrap(), + DateTime::parse_from_rfc3339("1996-12-21T23:55:00Z").unwrap() + ); + assert_eq!(rate.1, 2.0 / interval_seconds as f64); + } + + // Assert the youngest, most recent interval has the base rate. + { + let rate = rates[2]; + assert_eq!( + DateTime::from_timestamp(rate.0, 0).unwrap(), + DateTime::parse_from_rfc3339("1996-12-22T11:55:00Z").unwrap() + ); + assert_eq!(rate.1, 1.0 / interval_seconds as f64); + } + } +} + +const LOGS_TABLE_NAME: &str = "_logs"; diff --git a/trailbase-core/src/admin/mod.rs b/trailbase-core/src/admin/mod.rs new file mode 100644 index 0000000..5ca4b52 --- /dev/null +++ b/trailbase-core/src/admin/mod.rs @@ -0,0 +1,66 @@ +mod config; +mod error; +mod jwt; +mod list_logs; +mod oauth_providers; +mod parse; +mod query; +pub(crate) mod rows; +mod schema; +mod table; +pub(crate) mod user; + +pub use error::AdminError; + +use crate::app_state::AppState; +use axum::{ + routing::{delete, get, patch, post}, + Router, +}; + +pub fn router() -> Router { + Router::new() + // Row actions. + .route("/table/:table_name/rows", get(rows::list_rows_handler)) + .route("/table/:table_name/files", get(rows::read_files_handler)) + .route("/table/:table_name/rows", delete(rows::delete_rows_handler)) + .route("/table/:table_name", patch(rows::update_row_handler)) + .route("/table/:table_name", post(rows::insert_row_handler)) + .route("/table/:table_name", delete(rows::delete_row_handler)) + // Index actions. + .route("/index", post(table::create_index_handler)) + .route("/index", patch(table::alter_index_handler)) + .route("/index", delete(table::drop_index_handler)) + // Table actions. + .route( + "/table/:table_name/schema.json", + get(table::get_table_schema_handler), + ) + .route("/table", post(table::create_table_handler)) + .route("/table", delete(table::drop_table_handler)) + .route("/table", patch(table::alter_table_handler)) + // Table & Index actions. + .route("/tables", get(table::list_tables_handler)) + // Config actions + .route("/config", get(config::get_config_handler)) + .route("/config", post(config::update_config_handler)) + // User actions + .route("/user", get(user::list_users_handler)) + .route("/user", post(user::create_user_handler)) + .route("/user", patch(user::update_user_handler)) + // Schema actions + .route("/schema", get(schema::list_schemas_handler)) + .route("/schema", post(schema::update_schema_handler)) + // Logs + .route("/logs", get(list_logs::list_logs_handler)) + // Query execution handler for the UI editor + .route("/query", post(query::query_handler)) + // Parse handler for UI validation. + .route("/parse", post(parse::parse_handler)) + // List available oauth providers + .route( + "/oauth_providers", + get(oauth_providers::available_oauth_providers_handler), + ) + .route("/public_key", get(jwt::get_public_key)) +} diff --git a/trailbase-core/src/admin/oauth_providers.rs b/trailbase-core/src/admin/oauth_providers.rs new file mode 100644 index 0000000..2d92978 --- /dev/null +++ b/trailbase-core/src/admin/oauth_providers.rs @@ -0,0 +1,22 @@ +use axum::extract::Json; +use serde::Serialize; +use ts_rs::TS; + +use crate::admin::AdminError as Error; +use crate::auth::oauth::providers::{oauth_provider_registry, OAuthProviderEntry}; + +#[derive(Debug, Serialize, TS)] +#[ts(export)] +pub struct OAuthProviderResponse { + providers: Vec, +} + +pub async fn available_oauth_providers_handler(// State(state): State, +) -> Result, Error> { + return Ok(Json(OAuthProviderResponse { + providers: oauth_provider_registry + .iter() + .map(|factory| factory.into()) + .collect(), + })); +} diff --git a/trailbase-core/src/admin/parse.rs b/trailbase-core/src/admin/parse.rs new file mode 100644 index 0000000..1771a83 --- /dev/null +++ b/trailbase-core/src/admin/parse.rs @@ -0,0 +1,57 @@ +use axum::{extract::State, Json}; +use base64::prelude::*; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::table_metadata::sqlite3_parse_into_statements; + +#[derive(Debug, Deserialize, Serialize, TS)] +pub enum Mode { + Expression, + Statement, +} + +#[derive(Debug, Deserialize, Serialize, TS)] +#[ts(export)] +pub struct ParseRequest { + query: String, + mode: Option, + // NOTE: We could probably be more specific for access checks setting up _REQ_, _ROW_, _USER_ + // appropriately. + // create_access: bool, + // read_access: bool, + // update_access: bool, + // delete_access: bool, +} + +#[derive(Debug, Deserialize, Serialize, TS)] +#[ts(export)] +pub struct ParseResponse { + ok: bool, + message: Option, +} + +pub async fn parse_handler( + State(_state): State, + Json(request): Json, +) -> Result, Error> { + let query = String::from_utf8_lossy(&BASE64_URL_SAFE.decode(request.query)?).to_string(); + + let result = match request.mode.unwrap_or(Mode::Expression) { + Mode::Statement => sqlite3_parse_into_statements(&query), + Mode::Expression => sqlite3_parse_into_statements(&format!("SELECT ({query})")), + }; + + return match result.err() { + None => Ok(Json(ParseResponse { + ok: true, + message: None, + })), + Some(err) => Ok(Json(ParseResponse { + ok: false, + message: Some(err.to_string()), + })), + }; +} diff --git a/trailbase-core/src/admin/query.rs b/trailbase-core/src/admin/query.rs new file mode 100644 index 0000000..fa6512b --- /dev/null +++ b/trailbase-core/src/admin/query.rs @@ -0,0 +1,82 @@ +use axum::{extract::State, Json}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::records::sql_to_json::rows_to_json_arrays; +use crate::schema::Column; +use crate::table_metadata::sqlite3_parse_into_statements; + +#[derive(Debug, Default, Serialize, TS)] +#[ts(export)] +pub struct QueryResponse { + columns: Option>, + + #[ts(type = "Object[][]")] + rows: Vec>, +} + +#[derive(Debug, Deserialize, Serialize, TS)] +#[ts(export)] +pub struct QueryRequest { + query: String, +} + +pub async fn query_handler( + State(state): State, + Json(request): Json, +) -> Result, Error> { + // NOTE: conn.query() only executes the first query and quietly drops the rest :/. + // + // In an ideal world we'd use sqlparser to validate the entire query before doing anything *and* + // also to break up the statements and execute them one-by-one. However, sqlparser is far from + // having 100% coverage, for example, it doesn't parse trigger statements (maybe + // https://crates.io/crates/sqlite3-parser would have been the better choice). + // + // In the end we really want to allow executing all constructs as valid to sqlite. As such we + // best effort parse the statements to see if need to invalidate the table cache and otherwise + // fall back to libsql's execute batch which materializes all rows and invalidate anyway. + + // Check the statements are correct before executing anything, just to be sure. + let statements = sqlite3_parse_into_statements(&request.query)?; + let mut must_invalidate_table_cache = false; + for stmt in statements { + use sqlite3_parser::ast::Stmt; + + match stmt { + Stmt::DropView { .. } + | Stmt::DropTable { .. } + | Stmt::AlterTable { .. } + | Stmt::CreateTable { .. } + | Stmt::CreateVirtualTable { .. } + | Stmt::CreateView { .. } => { + must_invalidate_table_cache = true; + } + _ => { + // Do nothing. + } + } + } + + let batched_rows_result = state.conn().execute_batch(&request.query).await; + + // In the fallback case we always need to invalidate the cache. + if must_invalidate_table_cache { + state.table_metadata().invalidate_all().await?; + } + + let mut batched_rows = batched_rows_result?; + + let mut prev: Option = None; + while let Some(maybe_rows) = batched_rows.next_stmt_row() { + prev = maybe_rows; + } + + if let Some(result_rows) = prev { + let (rows, columns) = rows_to_json_arrays(result_rows, 1024).await?; + + return Ok(Json(QueryResponse { columns, rows })); + } + return Ok(Json(QueryResponse::default())); +} diff --git a/trailbase-core/src/admin/rows/delete_rows.rs b/trailbase-core/src/admin/rows/delete_rows.rs new file mode 100644 index 0000000..1c8ad5f --- /dev/null +++ b/trailbase-core/src/admin/rows/delete_rows.rs @@ -0,0 +1,239 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::records::json_to_sql::simple_json_value_to_param; +use crate::records::json_to_sql::DeleteQueryBuilder; + +#[derive(Debug, Serialize, Deserialize, Default, TS)] +#[ts(export)] +pub struct DeleteRowRequest { + primary_key_column: String, + + /// The primary key (of any type since we're in row instead of RecordApi land) of rows that + /// shall be deleted. + #[ts(type = "Object")] + value: serde_json::Value, +} + +pub async fn delete_row_handler( + State(state): State, + Path(table_name): Path, + Json(request): Json, +) -> Result { + delete_row( + &state, + table_name, + &request.primary_key_column, + request.value, + ) + .await?; + return Ok((StatusCode::OK, "deleted").into_response()); +} + +async fn delete_row( + state: &AppState, + table_name: String, + pk_col: &str, + value: serde_json::Value, +) -> Result<(), Error> { + let Some(table_metadata) = state.table_metadata().get(&table_name) else { + return Err(Error::Precondition(format!("Table {table_name} not found"))); + }; + + let Some((column, _col_meta)) = table_metadata.column_by_name(pk_col) else { + return Err(Error::Precondition(format!("Missing column: {pk_col}"))); + }; + + if !column.is_primary() { + return Err(Error::Precondition(format!("Not a primary key: {pk_col}"))); + } + + DeleteQueryBuilder::run( + state, + &table_metadata, + pk_col, + simple_json_value_to_param(column.data_type, value)?, + ) + .await?; + + return Ok(()); +} + +#[derive(Debug, Serialize, Deserialize, Default, TS)] +#[ts(export)] +pub struct DeleteRowsRequest { + /// Name of the primary key column we use to identify which rows to delete. + primary_key_column: String, + + /// A list of primary keys (of any type since we're in row instead of RecordApi land) + /// of rows that shall be deleted. + #[ts(type = "Object[]")] + values: Vec, +} + +pub async fn delete_rows_handler( + State(state): State, + Path(table_name): Path, + Json(request): Json, +) -> Result { + let DeleteRowsRequest { + primary_key_column, + values, + } = request; + + for value in values { + delete_row(&state, table_name.clone(), &primary_key_column, value).await?; + } + + return Ok((StatusCode::OK, "deleted all").into_response()); +} + +#[cfg(test)] +mod tests { + use axum::extract::{Json, Path, RawQuery, State}; + use trailbase_sqlite::query_one_row; + + use super::*; + use crate::admin::rows::insert_row::insert_row_handler; + use crate::admin::rows::list_rows::list_rows_handler; + use crate::admin::rows::update_row::{update_row_handler, UpdateRowRequest}; + use crate::admin::table::{create_table_handler, CreateTableRequest}; + use crate::app_state::*; + use crate::schema::{Column, ColumnDataType, ColumnOption, Table}; + use crate::util::{b64_to_uuid, uuid_to_b64}; + + // TODO: This full-lifecycle test should probably live outside the scope of delete_row. + #[tokio::test] + async fn test_insert_update_delete_rows() { + let state = test_state(None).await.unwrap(); + let conn = state.conn(); + + let table_name = "test_table".to_string(); + let pk_col = "myid".to_string(); + let _ = create_table_handler( + State(state.clone()), + Json(CreateTableRequest { + schema: Table { + name: table_name.clone(), + strict: false, + columns: vec![ + Column { + name: pk_col.clone(), + data_type: ColumnDataType::Blob, + options: vec![ + ColumnOption::Unique { is_primary: true }, + ColumnOption::Check(format!("(is_uuid_v7({pk_col}))")), + ColumnOption::Default("(uuid_v7())".to_string()), + ], + }, + Column { + name: "col0".to_string(), + data_type: ColumnDataType::Text, + options: vec![], + }, + ], + foreign_keys: vec![], + unique: vec![], + virtual_table: false, + temporary: false, + }, + dry_run: Some(false), + }), + ) + .await + .unwrap(); + + let insert = |value: &str| { + insert_row_handler( + State(state.clone()), + Path(table_name.clone()), + Json(serde_json::json!({ + "col0": value, + })), + ) + }; + + let get_id = |row: Vec| { + return match &row[0] { + serde_json::Value::String(str) => b64_to_uuid(str).unwrap(), + x => { + panic!("unexpected type: {x:?}"); + } + }; + }; + + let id0 = { + let Json(row) = insert("row0").await.unwrap(); + assert_eq!(&row[1], "row0"); + get_id(row) + }; + let id1 = { + let Json(row) = insert("row1").await.unwrap(); + assert_eq!(&row[1], "row1"); + get_id(row) + }; + + let count = || async { + query_one_row(conn, &format!("SELECT COUNT(*) FROM '{table_name}'"), ()) + .await + .unwrap() + .get::(0) + .unwrap() + }; + + assert_eq!(count().await, 2); + + let updated_value = "row0 updated"; + update_row_handler( + State(state.clone()), + Path(table_name.clone()), + Json(UpdateRowRequest { + primary_key_column: pk_col.clone(), + primary_key_value: serde_json::Value::String(uuid_to_b64(&id0)), + row: serde_json::json!({ + "col0": updated_value.to_string(), + }), + }), + ) + .await + .unwrap(); + + let listing = list_rows_handler( + State(state.clone()), + Path(table_name.clone()), + RawQuery(Some(format!("{pk_col}={}", uuid_to_b64(&id0)))), + ) + .await + .unwrap(); + + assert_eq!(listing.rows.len(), 1, "Listing: {listing:?}"); + assert_eq!( + listing.rows[0][1], + serde_json::Value::String(updated_value.to_string()) + ); + + let delete = |id: uuid::Uuid| { + delete_row_handler( + State(state.clone()), + Path(table_name.clone()), + Json(DeleteRowRequest { + primary_key_column: pk_col.clone(), + value: serde_json::Value::String(uuid_to_b64(&id)), + }), + ) + }; + + delete(id0).await.unwrap(); + delete(id1).await.unwrap(); + + assert_eq!(count().await, 0); + } +} diff --git a/trailbase-core/src/admin/rows/insert_row.rs b/trailbase-core/src/admin/rows/insert_row.rs new file mode 100644 index 0000000..659c803 --- /dev/null +++ b/trailbase-core/src/admin/rows/insert_row.rs @@ -0,0 +1,38 @@ +use axum::extract::{Path, State}; +use axum::Json; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::records::json_to_sql::{InsertQueryBuilder, Params}; +use crate::records::sql_to_json::row_to_json_array; + +type Row = Vec; + +pub async fn insert_row_handler( + State(state): State, + Path(table_name): Path, + Json(request): Json, +) -> Result, Error> { + let row = insert_row(&state, table_name, request).await?; + return Ok(Json(row)); +} + +pub async fn insert_row( + state: &AppState, + table_name: String, + value: serde_json::Value, +) -> Result { + let Some(table_metadata) = state.table_metadata().get(&table_name) else { + return Err(Error::Precondition(format!("Table {table_name} not found"))); + }; + + let row = InsertQueryBuilder::run( + state, + Params::from(&table_metadata, value, None)?, + None, + Some("*"), + ) + .await?; + + return Ok(row_to_json_array(row)?); +} diff --git a/trailbase-core/src/admin/rows/list_rows.rs b/trailbase-core/src/admin/rows/list_rows.rs new file mode 100644 index 0000000..3c5e11d --- /dev/null +++ b/trailbase-core/src/admin/rows/list_rows.rs @@ -0,0 +1,189 @@ +use axum::extract::{Json, Path, RawQuery, State}; +use libsql::{params::Params, Connection}; +use log::*; +use serde::Serialize; +use std::sync::Arc; +use ts_rs::TS; + +use crate::admin::AdminError as Error; +use crate::api::query_one_row; +use crate::app_state::AppState; +use crate::listing::{ + build_filter_where_clause, limit_or_default, parse_query, Order, WhereClause, +}; +use crate::records::sql_to_json::rows_to_json_arrays; +use crate::schema::Column; +use crate::table_metadata::TableOrViewMetadata; + +#[derive(Debug, Serialize, TS)] +#[ts(export)] +pub struct ListRowsResponse { + pub total_row_count: i64, + pub cursor: Option, + + pub columns: Vec, + + // NOTE: use `Object` rather than object to include primitive types. + #[ts(type = "Object[][]")] + pub rows: Vec>, +} + +pub async fn list_rows_handler( + State(state): State, + Path(table_name): Path, + RawQuery(raw_url_query): RawQuery, +) -> Result, Error> { + let (filter_params, cursor, offset, limit, order) = match parse_query(raw_url_query) { + Some(q) => (Some(q.params), q.cursor, q.offset, q.limit, q.order), + None => (None, None, None, None, None), + }; + + let (virtual_table, table_or_view_metadata): (bool, Arc) = { + if let Some(table_metadata) = state.table_metadata().get(&table_name) { + (table_metadata.schema.virtual_table, table_metadata) + } else if let Some(view_metadata) = state.table_metadata().get_view(&table_name) { + (false, view_metadata) + } else { + return Err(Error::Precondition(format!( + "Table or view '{table_name}' not found" + ))); + } + }; + + // Where clause contains column filters and cursor depending on what's present in the url query + // string. + let filter_where_clause = build_filter_where_clause(&*table_or_view_metadata, filter_params)?; + + let total_row_count = { + let where_clause = &filter_where_clause.clause; + let count_query = format!("SELECT COUNT(*) FROM '{table_name}' WHERE {where_clause}"); + let row = query_one_row( + state.conn(), + &count_query, + Params::Named(filter_where_clause.params.clone()), + ) + .await?; + row.get::(0)? + }; + + let cursor_column = table_or_view_metadata.record_pk_column(); + let (rows, columns) = fetch_rows( + state.conn(), + &table_name, + filter_where_clause, + order, + Pagination { + cursor_column: cursor_column.map(|(_idx, c)| c), + cursor, + offset, + limit: limit_or_default(limit), + }, + ) + .await?; + + let next_cursor = cursor_column.and_then(|(col_idx, _col)| { + let row = rows.last()?; + assert!(row.len() > col_idx); + match &row[col_idx] { + serde_json::Value::String(id) => { + // Should be a base64 encoded [u8; 16] id. + Some(id.clone()) + } + _ => None, + } + }); + + return Ok(Json(ListRowsResponse { + total_row_count, + cursor: next_cursor, + // NOTE: in the view case we don't have a good way of extracting the columns from the "CREATE + // VIEW" query so we fall back to columns constructed from the returned data. + columns: match virtual_table { + true => columns.unwrap_or_else(Vec::new), + false => table_or_view_metadata.columns().unwrap_or_else(|| { + debug!("Falling back to inferred cols for view: '{table_name}'"); + columns.unwrap_or_else(Vec::new) + }), + }, + rows, + })); +} + +struct Pagination<'a> { + cursor_column: Option<&'a Column>, + cursor: Option<[u8; 16]>, + offset: Option, + limit: usize, +} + +async fn fetch_rows( + conn: &Connection, + table_or_view_name: &str, + filter_where_clause: WhereClause, + order: Option>, + pagination: Pagination<'_>, +) -> Result<(Vec>, Option>), Error> { + let WhereClause { + mut clause, + mut params, + } = filter_where_clause; + params.push(( + ":limit".to_string(), + libsql::Value::Integer(pagination.limit as i64), + )); + params.push(( + ":offset".to_string(), + libsql::Value::Integer(pagination.offset.unwrap_or(0) as i64), + )); + + if let Some(cursor) = pagination.cursor { + params.push((":cursor".to_string(), libsql::Value::Blob(cursor.to_vec()))); + clause = format!("{clause} AND _row_.id < :cursor",); + } + + let order_clause = match order { + Some(order) => order + .iter() + .map(|(col, ord)| { + format!( + "_row_.{col} {}", + match ord { + Order::Descending => "DESC", + Order::Ascending => "ASC", + } + ) + }) + .collect::>() + .join(", "), + None => match pagination.cursor_column { + Some(col) => format!("{col_name} DESC", col_name = col.name), + None => "NULL".to_string(), + }, + }; + + let query = format!( + r#" + SELECT _row_.* + FROM + (SELECT * FROM {table_or_view_name}) as _row_ + WHERE + {clause} + ORDER BY + {order_clause} + LIMIT :limit + OFFSET :offset + "#, + ); + + let result_rows = conn + .query(&query, libsql::params::Params::Named(params)) + .await + .map_err(|err| { + #[cfg(debug_assertions)] + error!("QUERY: {query}\n\t=> {err}"); + + return err; + })?; + + return Ok(rows_to_json_arrays(result_rows, 1024).await?); +} diff --git a/trailbase-core/src/admin/rows/mod.rs b/trailbase-core/src/admin/rows/mod.rs new file mode 100644 index 0000000..3a9d72d --- /dev/null +++ b/trailbase-core/src/admin/rows/mod.rs @@ -0,0 +1,11 @@ +mod delete_rows; +mod insert_row; +mod list_rows; +mod read_files; +mod update_row; + +pub(super) use delete_rows::{delete_row_handler, delete_rows_handler}; +pub(super) use insert_row::insert_row_handler; +pub(super) use list_rows::list_rows_handler; +pub(super) use read_files::read_files_handler; +pub(super) use update_row::update_row_handler; diff --git a/trailbase-core/src/admin/rows/read_files.rs b/trailbase-core/src/admin/rows/read_files.rs new file mode 100644 index 0000000..25c5814 --- /dev/null +++ b/trailbase-core/src/admin/rows/read_files.rs @@ -0,0 +1,79 @@ +use axum::{ + extract::{Path, Query, State}, + response::Response, +}; +use serde::Deserialize; +use ts_rs::TS; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::records::files::read_file_into_response; +use crate::records::json_to_sql::simple_json_value_to_param; +use crate::records::json_to_sql::{GetFileQueryBuilder, GetFilesQueryBuilder}; + +#[derive(Debug, Deserialize, TS)] +#[ts(export)] +pub struct ReadFilesRequest { + pk_column: String, + + /// The primary key (of any type since we're in row instead of RecordAPI land) of rows that + /// shall be deleted. + #[ts(type = "Object")] + pk_value: serde_json::Value, + + file_column_name: String, + file_index: Option, +} + +pub async fn read_files_handler( + State(state): State, + Path(table_name): Path, + Query(request): Query, +) -> Result { + let Some(table_metadata) = state.table_metadata().get(&table_name) else { + return Err(Error::Precondition(format!("Table {table_name} not found"))); + }; + let pk_col = &request.pk_column; + + let Some((col, _col_meta)) = table_metadata.column_by_name(pk_col) else { + return Err(Error::Precondition(format!("Missing column: {pk_col}"))); + }; + + if !col.is_primary() { + return Err(Error::Precondition(format!("Not a primary key: {pk_col}"))); + } + + let Some(file_col_metadata) = table_metadata.column_by_name(&request.file_column_name) else { + return Err(Error::Precondition(format!("Missing column: {pk_col}"))); + }; + + let pk_value = simple_json_value_to_param(col.data_type, request.pk_value)?; + + return if let Some(file_index) = request.file_index { + let mut file_uploads = GetFilesQueryBuilder::run( + &state, + &table_name, + file_col_metadata, + &request.pk_column, + pk_value, + ) + .await?; + + if file_index >= file_uploads.0.len() { + return Err(Error::Precondition(format!("Out of bounds: {file_index}"))); + } + + Ok(read_file_into_response(&state, file_uploads.0.remove(file_index)).await?) + } else { + let file_upload = GetFileQueryBuilder::run( + &state, + &table_name, + file_col_metadata, + &request.pk_column, + pk_value, + ) + .await?; + + Ok(read_file_into_response(&state, file_upload).await?) + }; +} diff --git a/trailbase-core/src/admin/rows/update_row.rs b/trailbase-core/src/admin/rows/update_row.rs new file mode 100644 index 0000000..9ca05cb --- /dev/null +++ b/trailbase-core/src/admin/rows/update_row.rs @@ -0,0 +1,54 @@ +use axum::extract::{Path, State}; +use axum::Json; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::records::json_to_sql::{simple_json_value_to_param, Params, UpdateQueryBuilder}; + +#[derive(Debug, Serialize, Deserialize, Default, TS)] +#[ts(export)] +pub struct UpdateRowRequest { + pub primary_key_column: String, + + #[ts(type = "Object")] + pub primary_key_value: serde_json::Value, + + /// This is expected to be a map from column name to value. + /// + /// Note that using an array here wouldn't make sense. The map allows for sparseness and only + /// updating specific cells. + #[ts(type = "{ [key: string]: Object | undefined }")] + pub row: serde_json::Value, +} + +pub async fn update_row_handler( + State(state): State, + Path(table_name): Path, + Json(request): Json, +) -> Result<(), Error> { + let Some(table_metadata) = state.table_metadata().get(&table_name) else { + return Err(Error::Precondition(format!("Table {table_name} not found"))); + }; + + let pk_col = &request.primary_key_column; + let Some((column, _col_meta)) = table_metadata.column_by_name(pk_col) else { + return Err(Error::Precondition(format!("Missing column: {pk_col}"))); + }; + + if !column.is_primary() { + return Err(Error::Precondition(format!("Not a primary key: {pk_col}"))); + } + + UpdateQueryBuilder::run( + &state, + &table_metadata, + Params::from(&table_metadata, request.row, None)?, + &column.name, + simple_json_value_to_param(column.data_type, request.primary_key_value)?, + ) + .await?; + + return Ok(()); +} diff --git a/trailbase-core/src/admin/schema/mod.rs b/trailbase-core/src/admin/schema/mod.rs new file mode 100644 index 0000000..77b0908 --- /dev/null +++ b/trailbase-core/src/admin/schema/mod.rs @@ -0,0 +1,97 @@ +use axum::extract::{Json, State}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use trailbase_sqlite::schema::{get_schemas, set_user_schema}; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; + +#[derive(Debug, Serialize, TS)] +pub struct JsonSchema { + pub name: String, + // NOTE: ideally we'd return an js `Object` here, however tanstack-form goes bonkers with + // excessive type evaluation depth. Maybe we shouldn't use tanstack-form for schemas? + pub schema: String, + pub builtin: bool, +} + +#[derive(Debug, Serialize, TS)] +#[ts(export)] +pub struct ListJsonSchemasResponse { + schemas: Vec, +} + +impl From for JsonSchema { + fn from(value: trailbase_sqlite::schema::Schema) -> Self { + return JsonSchema { + name: value.name, + schema: value.schema.to_string(), + builtin: value.builtin, + }; + } +} + +pub async fn list_schemas_handler( + State(_state): State, +) -> Result, Error> { + let schemas = get_schemas(); + + return Ok(Json(ListJsonSchemasResponse { + schemas: schemas.into_iter().map(|s| s.into()).collect(), + })); +} + +#[derive(Debug, Deserialize, TS)] +#[ts(export)] +pub struct UpdateJsonSchemaRequest { + name: String, + #[ts(type = "Object | undefined")] + schema: Option, +} + +pub async fn update_schema_handler( + State(state): State, + Json(request): Json, +) -> Result, Error> { + // Update the schema in memory. + let (name, schema) = (request.name, request.schema); + set_user_schema(&name, schema.clone())?; + + // And if that succeeds update config. + let mut config = state.get_config(); + if let Some(schema) = schema { + // Add/update + let mut found = false; + for s in &mut config.schemas { + if s.name.as_ref() == Some(&name) { + s.schema = Some(schema.to_string()); + found = true; + } + } + + if !found { + config.schemas.push(crate::config::proto::JsonSchemaConfig { + name: Some(name.clone()), + schema: Some(schema.to_string()), + }) + } + } else { + // Remove + config.schemas = config + .schemas + .into_iter() + .filter_map(|s| { + if s.name.as_ref() == Some(&name) { + return None; + } + return Some(s); + }) + .collect(); + } + + // FIXME: Use hashed update to avoid races. + state.validate_and_update_config(config, None).await?; + + return Ok(Json(serde_json::json!({}))); +} diff --git a/trailbase-core/src/admin/table/alter_index.rs b/trailbase-core/src/admin/table/alter_index.rs new file mode 100644 index 0000000..2701673 --- /dev/null +++ b/trailbase-core/src/admin/table/alter_index.rs @@ -0,0 +1,58 @@ +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use log::*; +use serde::Deserialize; +use ts_rs::TS; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::schema::TableIndex; +use crate::transaction::TransactionRecorder; + +#[derive(Clone, Debug, Deserialize, TS)] +#[ts(export)] +pub struct AlterIndexRequest { + pub source_schema: TableIndex, + pub target_schema: TableIndex, +} + +// NOTE: sqlite has very limited alter table support, thus we're always recreating the table and +// moving data over, see https://sqlite.org/lang_altertable.html. + +pub async fn alter_index_handler( + State(state): State, + Json(request): Json, +) -> Result { + let conn = state.conn(); + + let source_schema = request.source_schema; + let source_index_name = &source_schema.name; + let target_schema = request.target_schema; + + debug!("Alter index:\nsource: {source_schema:?}\ntarget: {target_schema:?}",); + + let mut tx = TransactionRecorder::new( + conn.clone(), + state.data_dir().migrations_path(), + format!("alter_index_{source_index_name}"), + ) + .await?; + + // Drop old index + tx.execute(&format!("DROP INDEX {source_index_name}")) + .await?; + + // Create new index + let create_index_query = target_schema.create_index_statement(); + tx.query(&create_index_query).await?; + + // Write to migration file. + let report = tx.commit_and_create_migration().await?; + debug!("Migration report: {report:?}"); + + return Ok((StatusCode::OK, "altered index").into_response()); +} diff --git a/trailbase-core/src/admin/table/alter_table.rs b/trailbase-core/src/admin/table/alter_table.rs new file mode 100644 index 0000000..9f9ae69 --- /dev/null +++ b/trailbase-core/src/admin/table/alter_table.rs @@ -0,0 +1,223 @@ +use std::collections::HashSet; + +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use log::*; +use serde::Deserialize; +use ts_rs::TS; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::schema::Table; +use crate::transaction::TransactionRecorder; + +#[derive(Clone, Debug, Deserialize, TS)] +#[ts(export)] +pub struct AlterTableRequest { + pub source_schema: Table, + pub target_schema: Table, +} + +// NOTE: sqlite has very limited alter table support, thus we're always recreating the table and +// moving data over, see https://sqlite.org/lang_altertable.html. + +pub async fn alter_table_handler( + State(state): State, + Json(request): Json, +) -> Result { + let source_schema = request.source_schema; + let source_table_name = &source_schema.name; + + let Some(_metadata) = state.table_metadata().get(source_table_name) else { + return Err(Error::Precondition(format!( + "Cannot alter '{source_table_name}'. Only tables are supported.", + ))); + }; + + let target_schema = request.target_schema; + let target_table_name = &target_schema.name; + + debug!("Alter table:\nsource: {source_schema:?}\ntarget: {target_schema:?}",); + + let temp_table_name: String = { + if target_table_name != source_table_name { + target_table_name.clone() + } else { + format!("__alter_table_{target_table_name}") + } + }; + + let source_columns: HashSet = source_schema + .columns + .iter() + .map(|c| c.name.clone()) + .collect(); + let copy_columns: Vec = target_schema + .columns + .iter() + .filter_map(|c| { + if source_columns.contains(&c.name) { + Some(c.name.clone()) + } else { + None + } + }) + .collect(); + + let mut target_schema_copy = target_schema.clone(); + target_schema_copy.name = temp_table_name.to_string(); + + let mut tx = TransactionRecorder::new( + state.conn().clone(), + state.data_dir().migrations_path(), + format!("alter_table_{source_table_name}"), + ) + .await?; + tx.execute("PRAGMA foreign_keys = OFF").await?; + + // Create new table + let sql = target_schema_copy.create_table_statement(); + tx.query(&sql).await?; + + // Copy + tx.query(&format!( + r#" + INSERT INTO + {temp_table_name} ({column_list}) + SELECT + {column_list} + FROM + {source_table_name} + "#, + column_list = copy_columns.join(", "), + )) + .await?; + + tx.query(&format!("DROP TABLE {source_table_name}")).await?; + + if *target_table_name != temp_table_name { + tx.query(&format!( + "ALTER TABLE '{temp_table_name}' RENAME TO '{target_table_name}'" + )) + .await?; + } + + tx.execute("PRAGMA foreign_keys = ON").await?; + + // Write to migration file. + let report = tx.commit_and_create_migration().await?; + debug!("Migration report: {report:?}"); + + state.table_metadata().invalidate_all().await?; + + return Ok((StatusCode::OK, "altered table").into_response()); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::admin::table::{create_table_handler, CreateTableRequest}; + use crate::app_state::*; + use crate::schema::{Column, ColumnDataType, ColumnOption, Table}; + + #[tokio::test] + async fn test_alter_table() -> Result<(), anyhow::Error> { + let state = test_state(None).await?; + let conn = state.conn(); + let pk_col = "my_pk".to_string(); + + let create_table_request = CreateTableRequest { + schema: Table { + name: "foo".to_string(), + strict: true, + columns: vec![Column { + name: pk_col.clone(), + data_type: ColumnDataType::Blob, + options: vec![ColumnOption::Unique { is_primary: true }], + }], + foreign_keys: vec![], + unique: vec![], + virtual_table: false, + temporary: false, + }, + dry_run: Some(false), + }; + info!( + "Create Table: {}", + create_table_request.schema.create_table_statement() + ); + let _ = create_table_handler(State(state.clone()), Json(create_table_request.clone())).await?; + + conn.query(&format!("SELECT {pk_col} FROM foo"), ()).await?; + + { + // Noop: source and target identical. + let alter_table_request = AlterTableRequest { + source_schema: create_table_request.schema.clone(), + target_schema: create_table_request.schema.clone(), + }; + + alter_table_handler(State(state.clone()), Json(alter_table_request.clone())) + .await + .unwrap(); + + conn.query(&format!("SELECT {pk_col} FROM foo"), ()).await?; + } + + { + // Add column. + let mut target_schema = create_table_request.schema.clone(); + + target_schema.columns.push(Column { + name: "new".to_string(), + data_type: ColumnDataType::Text, + options: vec![ + ColumnOption::NotNull, + ColumnOption::Default("'default'".to_string()), + ], + }); + + info!("{}", target_schema.create_table_statement()); + + let alter_table_request = AlterTableRequest { + source_schema: create_table_request.schema.clone(), + target_schema, + }; + + alter_table_handler(State(state.clone()), Json(alter_table_request.clone())) + .await + .unwrap(); + + conn + .query(&format!("SELECT {pk_col}, new FROM foo"), ()) + .await?; + } + + { + // Rename table and remove "new" column. + let mut target_schema = create_table_request.schema.clone(); + + target_schema.name = "bar".to_string(); + + info!("{}", target_schema.create_table_statement()); + + let alter_table_request = AlterTableRequest { + source_schema: create_table_request.schema.clone(), + target_schema, + }; + + alter_table_handler(State(state.clone()), Json(alter_table_request.clone())) + .await + .unwrap(); + + assert!(conn.query("SELECT * FROM foo", ()).await.is_err()); + conn.query(&format!("SELECT {pk_col} FROM bar"), ()).await?; + } + + return Ok(()); + } +} diff --git a/trailbase-core/src/admin/table/create_index.rs b/trailbase-core/src/admin/table/create_index.rs new file mode 100644 index 0000000..27a8f32 --- /dev/null +++ b/trailbase-core/src/admin/table/create_index.rs @@ -0,0 +1,58 @@ +use axum::{extract::State, Json}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::schema::TableIndex; +use crate::transaction::TransactionRecorder; + +#[derive(Clone, Debug, Deserialize, TS)] +#[ts(export)] +pub struct CreateIndexRequest { + pub schema: TableIndex, + pub dry_run: Option, +} + +#[derive(Clone, Debug, Serialize, TS)] +#[ts(export)] +pub struct CreateIndexResponse { + pub sql: String, +} + +pub async fn create_index_handler( + State(state): State, + Json(request): Json, +) -> Result, Error> { + let conn = state.conn(); + let dry_run = request.dry_run.unwrap_or(false); + let index_name = request.schema.name.clone(); + + let create_index_query = request.schema.create_index_statement(); + + if !dry_run { + let mut tx = TransactionRecorder::new( + conn.clone(), + state.data_dir().migrations_path(), + format!("create_index_{index_name}"), + ) + .await?; + + tx.query(&create_index_query).await?; + + // Write to migration file. + tx.commit_and_create_migration().await?; + } + + return Ok(Json(CreateIndexResponse { + sql: sqlformat::format( + &format!("{create_index_query};"), + &sqlformat::QueryParams::None, + sqlformat::FormatOptions { + indent: sqlformat::Indent::Spaces(2), + uppercase: true, + lines_between_queries: 1, + }, + ), + })); +} diff --git a/trailbase-core/src/admin/table/create_table.rs b/trailbase-core/src/admin/table/create_table.rs new file mode 100644 index 0000000..ccc5574 --- /dev/null +++ b/trailbase-core/src/admin/table/create_table.rs @@ -0,0 +1,66 @@ +use axum::{extract::State, Json}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::schema::Table; +use crate::transaction::TransactionRecorder; + +#[derive(Clone, Debug, Deserialize, TS)] +#[ts(export)] +pub struct CreateTableRequest { + pub schema: Table, + pub dry_run: Option, +} + +#[derive(Clone, Debug, Serialize, TS)] +#[ts(export)] +pub struct CreateTableResponse { + pub sql: String, +} + +pub async fn create_table_handler( + State(state): State, + Json(request): Json, +) -> Result, Error> { + let conn = state.conn(); + if request.schema.columns.is_empty() { + return Err(Error::Precondition( + "Tables need to have at least one column".to_string(), + )); + } + let dry_run = request.dry_run.unwrap_or(false); + let table_name = request.schema.name.clone(); + + // This contains the create table statement and may also contain indexes and triggers. + let query = request.schema.create_table_statement(); + + if !dry_run { + let mut tx = TransactionRecorder::new( + conn.clone(), + state.data_dir().migrations_path(), + format!("create_table_{table_name}"), + ) + .await?; + + tx.query(&query).await?; + + // Write to migration file. + tx.commit_and_create_migration().await?; + + state.table_metadata().invalidate_all().await?; + } + + return Ok(Json(CreateTableResponse { + sql: sqlformat::format( + format!("{query};").as_str(), + &sqlformat::QueryParams::None, + sqlformat::FormatOptions { + indent: sqlformat::Indent::Spaces(2), + uppercase: true, + lines_between_queries: 1, + }, + ), + })); +} diff --git a/trailbase-core/src/admin/table/drop_index.rs b/trailbase-core/src/admin/table/drop_index.rs new file mode 100644 index 0000000..b1187e6 --- /dev/null +++ b/trailbase-core/src/admin/table/drop_index.rs @@ -0,0 +1,43 @@ +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use log::*; +use serde::Deserialize; +use ts_rs::TS; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::transaction::TransactionRecorder; + +#[derive(Clone, Debug, Deserialize, TS)] +#[ts(export)] +pub struct DropIndexRequest { + pub name: String, +} + +pub async fn drop_index_handler( + State(state): State, + Json(request): Json, +) -> Result { + let conn = state.conn(); + let index_name = request.name; + + let mut tx = TransactionRecorder::new( + conn.clone(), + state.data_dir().migrations_path(), + format!("drop_index_{index_name}"), + ) + .await?; + + let query = format!("DROP INDEX IF EXISTS {}", index_name); + info!("dropping index: {query}"); + tx.execute(&query).await?; + + // Write to migration file. + tx.commit_and_create_migration().await?; + + return Ok((StatusCode::OK, "").into_response()); +} diff --git a/trailbase-core/src/admin/table/drop_table.rs b/trailbase-core/src/admin/table/drop_table.rs new file mode 100644 index 0000000..eb6e3b8 --- /dev/null +++ b/trailbase-core/src/admin/table/drop_table.rs @@ -0,0 +1,54 @@ +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use log::*; +use serde::Deserialize; +use ts_rs::TS; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::transaction::TransactionRecorder; + +#[derive(Clone, Debug, Deserialize, TS)] +#[ts(export)] +pub struct DropTableRequest { + pub name: String, +} + +pub async fn drop_table_handler( + State(state): State, + Json(request): Json, +) -> Result { + let table_name = &request.name; + + let entity_type: &str; + if state.table_metadata().get(table_name).is_some() { + entity_type = "TABLE"; + } else if state.table_metadata().get_view(table_name).is_some() { + entity_type = "VIEW"; + } else { + return Err(Error::Precondition(format!( + "Table or view '{table_name}' not found" + ))); + } + + let mut tx = TransactionRecorder::new( + state.conn().clone(), + state.data_dir().migrations_path(), + format!("drop_{}_{table_name}", entity_type.to_lowercase()), + ) + .await?; + + let query = format!("DROP {entity_type} IF EXISTS {table_name}"); + info!("dropping table: {query}"); + tx.execute(&query).await?; + + // Write to migration file. + tx.commit_and_create_migration().await?; + state.table_metadata().invalidate_all().await?; + + return Ok((StatusCode::OK, "").into_response()); +} diff --git a/trailbase-core/src/admin/table/get_table_schema.rs b/trailbase-core/src/admin/table/get_table_schema.rs new file mode 100644 index 0000000..907330a --- /dev/null +++ b/trailbase-core/src/admin/table/get_table_schema.rs @@ -0,0 +1,31 @@ +use axum::extract::{Path, State}; +use axum::http::header; +use axum::response::{IntoResponse, Response}; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::table_metadata::{build_json_schema, JsonSchemaMode}; + +pub async fn get_table_schema_handler( + State(state): State, + Path(table_name): Path, +) -> Result { + let Some(table_metadata) = state.table_metadata().get(&table_name) else { + return Err(Error::Precondition(format!("Table {table_name} not found"))); + }; + + // TOOD: Allow controlling the schema mode to generate different types for insert, select, and + // update. + let (_schema, json) = build_json_schema( + table_metadata.name(), + &*table_metadata, + JsonSchemaMode::Insert, + )?; + + let mut response = serde_json::to_string_pretty(&json)?.into_response(); + response.headers_mut().insert( + header::CONTENT_DISPOSITION, + header::HeaderValue::from_static("attachment"), + ); + return Ok(response); +} diff --git a/trailbase-core/src/admin/table/list_tables.rs b/trailbase-core/src/admin/table/list_tables.rs new file mode 100644 index 0000000..af32310 --- /dev/null +++ b/trailbase-core/src/admin/table/list_tables.rs @@ -0,0 +1,119 @@ +use axum::{extract::State, Json}; +use libsql::de; +use log::*; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::admin::AdminError as Error; +use crate::constants::SQLITE_SCHEMA_TABLE; +use crate::schema::{Table, View}; +use crate::table_metadata::sqlite3_parse_into_statement; +use crate::{app_state::AppState, schema::TableIndex}; + +// TODO: Rudimentary unparsed trigger representation, since sqlparser didn't currently support +// parsing sqlite triggers. Now we're using sqlite3_parser and should return structured data +#[derive(Clone, Default, Debug, Serialize, TS)] +pub struct TableTrigger { + pub name: String, + pub table_name: String, + pub sql: String, +} + +#[derive(Clone, Default, Debug, Serialize, TS)] +#[ts(export)] +pub struct ListSchemasResponse { + pub tables: Vec
, + pub indexes: Vec, + pub triggers: Vec, + pub views: Vec, +} + +pub async fn list_tables_handler( + State(state): State, +) -> Result, Error> { + let conn = state.conn(); + + // NOTE: the "ORDER BY" is a bit sneaky, it ensures that we parse all "table"s before we parse + // "view"s. + let mut rows = conn + .query( + &format!("SELECT * FROM {SQLITE_SCHEMA_TABLE} ORDER BY type"), + (), + ) + .await?; + + let mut schemas = ListSchemasResponse::default(); + + while let Some(row) = rows.next().await? { + #[derive(Deserialize, Debug)] + pub struct SqliteSchema { + pub r#type: String, + pub name: String, + pub tbl_name: String, + #[allow(unused)] + pub rootpage: i64, + + pub sql: Option, + } + + let schema: SqliteSchema = de::from_row(&row)?; + let name = &schema.name; + + match schema.r#type.as_str() { + "table" => { + let table_name = &schema.name; + let Some(sql) = schema.sql else { + warn!("Missing sql for table: {table_name}"); + continue; + }; + + if let Some(create_table_statement) = sqlite3_parse_into_statement(&sql)? { + schemas.tables.push(create_table_statement.try_into()?); + } + } + "index" => { + let index_name = &schema.name; + let Some(sql) = schema.sql else { + // Auto-indexes are expected to not have `.sql`. + if !name.starts_with("sqlite_autoindex") { + warn!("Missing sql for index: {index_name}"); + } + continue; + }; + + if let Some(create_index_statement) = sqlite3_parse_into_statement(&sql)? { + schemas.indexes.push(create_index_statement.try_into()?); + } + } + "view" => { + let view_name = &schema.name; + let Some(sql) = schema.sql else { + warn!("Missing sql for view: {view_name}"); + continue; + }; + + if let Some(create_view_statement) = sqlite3_parse_into_statement(&sql)? { + schemas + .views + .push(View::from(create_view_statement, &schemas.tables)?); + } + } + "trigger" => { + let Some(sql) = schema.sql else { + warn!("Empty trigger for: {schema:?}"); + continue; + }; + + // TODO: Turn this into structured data now that we use sqlite3_parser. + schemas.triggers.push(TableTrigger { + name: schema.name, + table_name: schema.tbl_name, + sql, + }); + } + x => warn!("Unknown schema type: {name} : {x}"), + } + } + + return Ok(Json(schemas)); +} diff --git a/trailbase-core/src/admin/table/mod.rs b/trailbase-core/src/admin/table/mod.rs new file mode 100644 index 0000000..8fb4deb --- /dev/null +++ b/trailbase-core/src/admin/table/mod.rs @@ -0,0 +1,25 @@ +// Indexes +mod alter_index; +mod create_index; +mod drop_index; +mod get_table_schema; + +pub(super) use alter_index::alter_index_handler; +pub(super) use create_index::create_index_handler; +pub(super) use drop_index::drop_index_handler; +pub(super) use get_table_schema::get_table_schema_handler; + +// Tables +mod alter_table; +mod create_table; +mod drop_table; + +pub(crate) use alter_table::alter_table_handler; +#[allow(unused)] +pub(crate) use create_table::{create_table_handler, CreateTableRequest}; +pub(crate) use drop_table::drop_table_handler; + +// Lists both Tables and Indexes +mod list_tables; + +pub(crate) use list_tables::list_tables_handler; diff --git a/trailbase-core/src/admin/user/create_user.rs b/trailbase-core/src/admin/user/create_user.rs new file mode 100644 index 0000000..1d845fe --- /dev/null +++ b/trailbase-core/src/admin/user/create_user.rs @@ -0,0 +1,111 @@ +use axum::{extract::State, Json}; +use lazy_static::lazy_static; +use libsql::{de, named_params}; +use serde::{Deserialize, Serialize}; +use trailbase_sqlite::query_one_row; +use ts_rs::TS; +use uuid::Uuid; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::auth::api::register::validate_and_normalize_email_address; +use crate::auth::password::hash_password; +use crate::auth::password::validate_passwords; +use crate::auth::user::DbUser; +use crate::auth::util::user_exists; +use crate::constants::{PASSWORD_OPTIONS, USER_TABLE, VERIFICATION_CODE_LENGTH}; +use crate::email::Email; +use crate::rand::generate_random_string; + +#[derive(Debug, Serialize, Deserialize, Default, TS)] +#[ts(export)] +pub struct CreateUserRequest { + pub email: String, + pub password: String, + pub verified: bool, + + pub admin: bool, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct CreateUserResponse { + pub id: Uuid, +} + +pub async fn create_user_handler( + State(state): State, + Json(request): Json, +) -> Result, Error> { + let normalized_email = validate_and_normalize_email_address(&request.email)?; + + validate_passwords(&request.password, &request.password, &PASSWORD_OPTIONS)?; + + let exists = user_exists(&state, &normalized_email).await?; + if exists { + return Err(Error::AlreadyExists("user")); + } + + let hashed_password = hash_password(&request.password)?; + let email_verification_code = if request.verified { + None + } else { + Some(generate_random_string(VERIFICATION_CODE_LENGTH)) + }; + + lazy_static! { + static ref INSERT_USER_QUERY: String = indoc::formatdoc!( + r#" + INSERT INTO '{USER_TABLE}' + (email, password_hash, verified, admin, email_verification_code) + VALUES + (:email, :password_hash, :verified, :admin ,:email_verification_code) + RETURNING * + "#, + ); + } + + let user: DbUser = de::from_row( + &query_one_row( + state.user_conn(), + &INSERT_USER_QUERY, + named_params! { + ":email": normalized_email, + ":password_hash": hashed_password, + ":verified": request.verified, + ":admin": request.admin, + ":email_verification_code": email_verification_code.clone(), + }, + ) + .await?, + )?; + + if let Some(email_verification_code) = email_verification_code { + Email::verification_email(&state, &user, &email_verification_code)? + .send() + .await?; + } + + return Ok(Json(CreateUserResponse { + id: Uuid::from_bytes(user.id), + })); +} + +#[cfg(test)] +pub(crate) async fn create_user_for_test( + state: &AppState, + email: &str, + password: &str, +) -> Result { + let response = create_user_handler( + State(state.clone()), + Json(CreateUserRequest { + email: email.to_string(), + password: password.to_string(), + verified: true, + admin: false, + }), + ) + .await?; + + return Ok(response.id); +} diff --git a/trailbase-core/src/admin/user/list_users.rs b/trailbase-core/src/admin/user/list_users.rs new file mode 100644 index 0000000..3d3131a --- /dev/null +++ b/trailbase-core/src/admin/user/list_users.rs @@ -0,0 +1,170 @@ +use axum::{ + extract::{RawQuery, State}, + Json, +}; +use lazy_static::lazy_static; +use libsql::{de, params::Params, Connection}; +use log::*; +use serde::Serialize; +use trailbase_sqlite::query_one_row; +use ts_rs::TS; +use uuid::Uuid; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::auth::user::DbUser; +use crate::constants::{USER_TABLE, USER_TABLE_ID_COLUMN}; +use crate::listing::{ + build_filter_where_clause, limit_or_default, parse_query, Order, WhereClause, +}; +use crate::util::id_to_b64; + +#[derive(Debug, Serialize, TS)] +pub struct UserJson { + pub id: String, + pub email: String, + pub verified: bool, + pub admin: bool, + + // For external oauth providers. + pub provider_id: i64, + pub provider_user_id: Option, + + pub email_verification_code: String, +} + +impl From for UserJson { + fn from(value: DbUser) -> Self { + UserJson { + id: Uuid::from_bytes(value.id).to_string(), + email: value.email, + verified: value.verified, + admin: value.admin, + provider_id: value.provider_id, + provider_user_id: value.provider_user_id, + email_verification_code: value.email_verification_code.unwrap_or_default(), + } + } +} + +#[derive(Debug, Serialize, TS)] +#[ts(export)] +pub struct ListUsersResponse { + total_row_count: i64, + cursor: Option, + + users: Vec, +} + +pub async fn list_users_handler( + State(state): State, + RawQuery(raw_url_query): RawQuery, +) -> Result, Error> { + let conn = state.user_conn(); + + let url_query = parse_query(raw_url_query); + info!("query: {url_query:?}"); + let (filter_params, cursor, limit, order) = match url_query { + Some(q) => (Some(q.params), q.cursor, q.limit, q.order), + None => (None, None, None, None), + }; + + let Some(table_metadata) = state.table_metadata().get(USER_TABLE) else { + return Err(Error::Precondition(format!("Table {USER_TABLE} not found"))); + }; + // Where clause contains column filters and cursor depending on what's present in the url query + // string. + let filter_where_clause = build_filter_where_clause(&*table_metadata, filter_params)?; + + let total_row_count = { + let where_clause = &filter_where_clause.clause; + let row = query_one_row( + conn, + &format!("SELECT COUNT(*) FROM {USER_TABLE} WHERE {where_clause}"), + Params::Named(filter_where_clause.params.clone()), + ) + .await?; + + row.get::(0)? + }; + + lazy_static! { + static ref DEFAULT_ORDERING: Vec<(String, Order)> = + vec![(USER_TABLE_ID_COLUMN.to_string(), Order::Descending)]; + } + let users = fetch_users( + conn, + filter_where_clause.clone(), + cursor, + order.unwrap_or_else(|| DEFAULT_ORDERING.clone()), + limit_or_default(limit), + ) + .await?; + + return Ok(Json(ListUsersResponse { + total_row_count, + cursor: users.last().map(|user| id_to_b64(&user.id)), + users: users + .into_iter() + .map(|user| user.into()) + .collect::>(), + })); +} + +async fn fetch_users( + conn: &Connection, + filter_where_clause: WhereClause, + cursor: Option<[u8; 16]>, + order: Vec<(String, Order)>, + limit: usize, +) -> Result, Error> { + let mut params = filter_where_clause.params; + let mut where_clause = filter_where_clause.clause; + params.push((":limit".to_string(), libsql::Value::Integer(limit as i64))); + + if let Some(cursor) = cursor { + params.push((":cursor".to_string(), libsql::Value::Blob(cursor.to_vec()))); + where_clause = format!("{where_clause} AND _row_.id < :cursor",); + } + + let order_clause = order + .iter() + .map(|(col, ord)| { + format!( + "_row_.{col} {}", + match ord { + Order::Descending => "DESC", + Order::Ascending => "ASC", + } + ) + }) + .collect::>() + .join(", "); + + let sql_query = format!( + r#" + SELECT _row_.* + FROM + (SELECT * FROM {USER_TABLE}) as _row_ + WHERE + {where_clause} + ORDER BY + {order_clause} + LIMIT :limit + "#, + ); + + info!("PARAMS: {params:?}\nQUERY: {sql_query}"); + + let mut rows = conn.query(&sql_query, Params::Named(params)).await?; + + let mut users: Vec = vec![]; + while let Ok(Some(row)) = rows.next().await { + match de::from_row(&row) { + Ok(user) => users.push(user), + Err(err) => warn!("failed: {err}"), + }; + } + + return Ok(users); +} diff --git a/trailbase-core/src/admin/user/mod.rs b/trailbase-core/src/admin/user/mod.rs new file mode 100644 index 0000000..8151314 --- /dev/null +++ b/trailbase-core/src/admin/user/mod.rs @@ -0,0 +1,68 @@ +mod create_user; +mod list_users; +mod update_user; + +pub use create_user::{create_user_handler, CreateUserRequest}; +pub(super) use list_users::list_users_handler; +pub(super) use update_user::update_user_handler; + +#[cfg(test)] +pub(crate) use create_user::create_user_for_test; + +#[cfg(test)] +mod tests { + use axum::{extract::State, Json}; + use libsql::params; + use std::sync::Arc; + use uuid::Uuid; + + use crate::app_state::{test_state, TestStateOptions}; + use crate::auth::util::user_by_email; + use crate::constants::USER_TABLE; + use crate::email::{testing::TestAsyncSmtpTransport, Mailer}; + + use super::create_user::*; + + #[tokio::test] + async fn test_user_creation_and_deletion() { + let _ = env_logger::try_init_from_env( + env_logger::Env::new().default_filter_or("info,refinery_core=warn"), + ); + + let mailer = TestAsyncSmtpTransport::new(); + let state = test_state(Some(TestStateOptions { + mailer: Some(Mailer::Smtp(Arc::new(mailer.clone()))), + ..Default::default() + })) + .await + .unwrap(); + + let email = "foo@bar.org"; + let user_id = create_user_handler( + State(state.clone()), + Json(CreateUserRequest { + email: email.to_string(), + password: "Secret!1!!".to_string(), + verified: true, + admin: true, + }), + ) + .await + .unwrap() + .id; + + let user = user_by_email(&state, email).await.unwrap(); + assert_eq!(Uuid::from_bytes(user.id), user_id); + + state + .user_conn() + .execute( + &format!("DELETE FROM '{USER_TABLE}' WHERE id = $1"), + params!(user.get_id().as_bytes()), + ) + .await + .unwrap(); + + assert!(user_by_email(&state, email).await.is_err()); + } +} diff --git a/trailbase-core/src/admin/user/update_user.rs b/trailbase-core/src/admin/user/update_user.rs new file mode 100644 index 0000000..7446ed8 --- /dev/null +++ b/trailbase-core/src/admin/user/update_user.rs @@ -0,0 +1,69 @@ +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use lazy_static::lazy_static; +use libsql::params; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; +use crate::auth::password::hash_password; +use crate::constants::USER_TABLE; + +#[derive(Debug, Serialize, Deserialize, Default, TS)] +#[ts(export)] +pub struct UpdateUserRequest { + id: uuid::Uuid, + + email: Option, + password: Option, + verified: Option, +} + +pub async fn update_user_handler( + State(state): State, + Json(request): Json, +) -> Result { + let conn = state.user_conn(); + let user_id_bytes = request.id.into_bytes(); + + let hashed_password = match &request.password { + Some(pw) => Some(hash_password(pw)?), + None => None, + }; + + // TODO: Rather than using a transaction below we could build combined update queries: + // UPDATE
SET x = :x, y = :y WHERE id = :id. + fn update_query(property: &str) -> String { + format!("UPDATE '{USER_TABLE}' SET {property} = $1 WHERE id = $2") + } + + lazy_static! { + static ref UPDATE_EMAIL_QUERY: String = update_query("email"); + static ref UPDATE_PW_HASH_QUERY: String = update_query("password_hash"); + static ref UPDATE_VERIFIED_QUERY: String = update_query("verified"); + } + + let tx = conn.transaction().await?; + + if let Some(ref email) = request.email { + tx.execute(&UPDATE_EMAIL_QUERY, params![email.clone(), user_id_bytes]) + .await?; + } + if let Some(password_hash) = hashed_password { + tx.execute(&UPDATE_PW_HASH_QUERY, params!(password_hash, user_id_bytes)) + .await?; + } + if let Some(verified) = request.verified { + tx.execute(&UPDATE_VERIFIED_QUERY, params!(verified, user_id_bytes)) + .await?; + } + + tx.commit().await?; + + return Ok((StatusCode::OK, format!("Updated user: {request:?}")).into_response()); +} diff --git a/trailbase-core/src/app_state.rs b/trailbase-core/src/app_state.rs new file mode 100644 index 0000000..88539ec --- /dev/null +++ b/trailbase-core/src/app_state.rs @@ -0,0 +1,427 @@ +use libsql::Connection; +use log::*; +use object_store::ObjectStore; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::auth::jwt::JwtHelper; +use crate::auth::oauth::providers::{ConfiguredOAuthProviders, OAuthProviderType}; +use crate::config::proto::{Config, QueryApiConfig, RecordApiConfig}; +use crate::config::{validate_config, write_config_and_vault_textproto}; +use crate::constants::SITE_URL_DEFAULT; +use crate::data_dir::DataDir; +use crate::email::Mailer; +use crate::query::QueryApi; +use crate::records::RecordApi; +use crate::table_metadata::TableMetadataCache; +use crate::value_notifier::{Computed, ValueNotifier}; + +/// The app's internal state. AppState needs to be clonable which puts unnecessary constraints on +/// the internals. Thus rather arc once than many times. +struct InternalState { + data_dir: DataDir, + public_dir: Option, + dev: bool, + + oauth: Computed, + mailer: Computed, + record_apis: Computed, Config>, + query_apis: Computed, Config>, + config: ValueNotifier, + + logs_conn: Connection, + conn: Connection, + + jwt: JwtHelper, + + table_metadata: TableMetadataCache, + + #[cfg(test)] + #[allow(unused)] + cleanup: Vec>, +} + +#[derive(Clone)] +pub struct AppState { + state: Arc, +} + +impl AppState { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + data_dir: DataDir, + public_dir: Option, + dev: bool, + table_metadata: TableMetadataCache, + config: Config, + conn: Connection, + logs_conn: Connection, + jwt: JwtHelper, + ) -> Self { + // let mailer = mailer.or_else(|| new_mailer(&config).ok()); + let config = ValueNotifier::new(config); + + let table_metadata_clone = table_metadata.clone(); + let conn_clone0 = conn.clone(); + let conn_clone1 = conn.clone(); + + AppState { + state: Arc::new(InternalState { + data_dir, + public_dir, + dev, + oauth: Computed::new(&config, |c| { + match ConfiguredOAuthProviders::from_config(c.auth.clone()) { + Ok(providers) => providers, + Err(err) => { + error!("Failed to derive configure oauth providers from config: {err}"); + ConfiguredOAuthProviders::default() + } + } + }), + mailer: build_mailer(&config, None), + record_apis: Computed::new(&config, move |c| { + return c + .record_apis + .iter() + .filter_map(|config| { + match build_record_api(conn_clone0.clone(), &table_metadata_clone, config.clone()) { + Ok(api) => Some((api.api_name().to_string(), api)), + Err(err) => { + error!("{err}"); + None + } + } + }) + .collect::>(); + }), + query_apis: Computed::new(&config, move |c| { + return c + .query_apis + .iter() + .filter_map( + |config| match build_query_api(conn_clone1.clone(), config.clone()) { + Ok(api) => Some((api.api_name().to_string(), api)), + Err(err) => { + error!("{err}"); + None + } + }, + ) + .collect::>(); + }), + config, + conn: conn.clone(), + logs_conn, + jwt, + table_metadata, + #[cfg(test)] + cleanup: vec![], + }), + } + } + + /// Path where TrailBase stores its data, config, migrations, and secrets. + pub fn data_dir(&self) -> &DataDir { + return &self.state.data_dir; + } + + /// Optional user-prvoided public directory from where static assets are served. + pub fn public_dir(&self) -> Option<&PathBuf> { + return self.state.public_dir.as_ref(); + } + + pub(crate) fn dev_mode(&self) -> bool { + return self.state.dev; + } + + pub fn conn(&self) -> &Connection { + return &self.state.conn; + } + + pub(crate) fn user_conn(&self) -> &Connection { + return &self.state.conn; + } + + pub(crate) fn logs_conn(&self) -> &Connection { + return &self.state.logs_conn; + } + + pub(crate) fn table_metadata(&self) -> &TableMetadataCache { + return &self.state.table_metadata; + } + + pub async fn refresh_table_cache(&self) -> Result<(), crate::table_metadata::TableLookupError> { + self.table_metadata().invalidate_all().await + } + + pub(crate) fn objectstore( + &self, + ) -> Result, object_store::Error> { + // FIXME: We should probably have a long-lived store on AppState. + return Ok(Box::new( + object_store::local::LocalFileSystem::new_with_prefix(self.data_dir().uploads_path())?, + )); + } + + pub(crate) fn get_oauth_provider(&self, name: &str) -> Option> { + return self.state.oauth.load().lookup(name).cloned(); + } + + pub(crate) fn get_oauth_providers(&self) -> Vec<(String, String)> { + return self + .state + .oauth + .load() + .list() + .into_iter() + .map(|(name, display_name)| (name.to_string(), display_name.to_string())) + .collect(); + } + + pub fn site_url(&self) -> String { + self + .access_config(|c| c.server.site_url.clone()) + .unwrap_or_else(|| SITE_URL_DEFAULT.to_string()) + } + + pub(crate) fn mailer(&self) -> Arc { + return self.state.mailer.load().clone(); + } + + pub(crate) fn jwt(&self) -> &JwtHelper { + return &self.state.jwt; + } + + pub(crate) fn lookup_record_api(&self, name: &str) -> Option { + for (record_api_name, record_api) in self.state.record_apis.load().iter() { + if record_api_name == name { + return Some(record_api.clone()); + } + } + return None; + } + + pub(crate) fn lookup_query_api(&self, name: &str) -> Option { + for (query_api_name, query_api) in self.state.query_apis.load().iter() { + if query_api_name == name { + return Some(query_api.clone()); + } + } + return None; + } + + pub fn get_config(&self) -> Config { + return (*self.state.config.load_full()).clone(); + } + + pub(crate) fn access_config(&self, f: F) -> T + where + F: Fn(&Config) -> T, + { + return f(&self.state.config.load()); + } + + pub(crate) async fn validate_and_update_config( + &self, + config: Config, + hash: Option, + ) -> Result<(), crate::config::ConfigError> { + validate_config(self.table_metadata(), &config)?; + + match hash { + Some(hash) => { + let old_config = self.state.config.load(); + if old_config.hash() == hash { + let success = self + .state + .config + .compare_and_swap(old_config, Arc::new(config)); + + if !success { + return Err(crate::config::ConfigError::Update( + "Config compare-exchange failed".to_string(), + )); + } + } else { + return Err(crate::config::ConfigError::Update( + "Safe config update failed: mismatching hash".to_string(), + )); + } + } + None => self.state.config.store(config.clone()), + }; + + // Write new config to the file system. + return write_config_and_vault_textproto( + self.data_dir(), + self.table_metadata(), + &self.get_config(), + ) + .await; + } +} + +fn build_mailer( + config: &ValueNotifier, + mailer: Option, +) -> Computed { + return Computed::new(config, move |c| { + if let Some(mailer) = mailer.clone() { + return mailer; + } + + return Mailer::new_from_config(c); + }); +} + +#[cfg(test)] +#[derive(Default)] +pub struct TestStateOptions { + pub config: Option, + pub(crate) mailer: Option, +} + +#[cfg(test)] +pub async fn test_state(options: Option) -> anyhow::Result { + use crate::auth::jwt; + use crate::auth::oauth::providers::test::TestOAuthProvider; + use crate::config::proto::{OAuthProviderConfig, OAuthProviderId}; + use crate::config::validate_config; + use crate::migrations::{apply_logs_migrations, apply_main_migrations, apply_user_migrations}; + + let temp_dir = temp_dir::TempDir::new()?; + tokio::fs::create_dir_all(temp_dir.child("uploads")).await?; + + let main_conn = { + let conn = trailbase_sqlite::connect_sqlite(None, None).await?; + apply_user_migrations(conn.clone()).await?; + let _new_db = apply_main_migrations(conn.clone(), None).await?; + + conn + }; + + let logs_conn = { + let conn = trailbase_sqlite::connect_sqlite(None, None).await?; + apply_logs_migrations(conn.clone()).await?; + conn + }; + + let table_metadata = TableMetadataCache::new(main_conn.clone()).await?; + + let build_default_config = || { + // Construct a fabricated config for tests and make sure it's valid. + let mut config = Config::new_with_custom_defaults(); + + config.email.smtp_host = Some("host".to_string()); + config.email.smtp_port = Some(587); + config.email.smtp_username = Some("user".to_string()); + config.email.smtp_password = Some("pass".to_string()); + config.email.sender_address = Some("sender@test.org".to_string()); + config.email.sender_name = Some("Mia Sender".to_string()); + + config.auth.oauth_providers.insert( + TestOAuthProvider::NAME.to_string(), + OAuthProviderConfig { + client_id: Some("test_client_id".to_string()), + client_secret: Some("test_client_secret".to_string()), + provider_id: Some(OAuthProviderId::Custom as i32), + ..Default::default() + }, + ); + + // NOTE: The below "append" semantics are different from prod's override behavior, to avoid + // races between concurrent tests. The registry needs to be global for the sqlite extensions + // to access (unless we find a better way to bind the two). + for schema in &config.schemas { + trailbase_sqlite::schema::set_user_schema( + schema.name.as_ref().unwrap(), + Some(serde_json::to_value(schema.schema.as_ref().unwrap()).unwrap()), + ) + .unwrap(); + } + + config + }; + + let config = options + .as_ref() + .and_then(|o| o.config.clone()) + .unwrap_or_else(build_default_config); + validate_config(&table_metadata, &config).unwrap(); + let config = ValueNotifier::new(config); + + let main_conn_clone0 = main_conn.clone(); + let main_conn_clone1 = main_conn.clone(); + let table_metadata_clone = table_metadata.clone(); + + return Ok(AppState { + state: Arc::new(InternalState { + data_dir: DataDir(temp_dir.path().to_path_buf()), + public_dir: None, + dev: true, + oauth: Computed::new(&config, |c| { + ConfiguredOAuthProviders::from_config(c.auth.clone()).unwrap() + }), + mailer: build_mailer(&config, options.and_then(|o| o.mailer)), + record_apis: Computed::new(&config, move |c| { + return c + .record_apis + .iter() + .filter_map(|config| { + let api = build_record_api( + main_conn_clone0.clone(), + &table_metadata_clone, + config.clone(), + ) + .unwrap(); + + return Some((api.api_name().to_string(), api)); + }) + .collect::>(); + }), + query_apis: Computed::new(&config, move |c| { + return c + .query_apis + .iter() + .filter_map(|config| { + let api = build_query_api(main_conn_clone1.clone(), config.clone()).unwrap(); + + return Some((api.api_name().to_string(), api)); + }) + .collect::>(); + }), + config, + conn: main_conn.clone(), + logs_conn, + jwt: jwt::test_jwt_helper(), + table_metadata, + cleanup: vec![Box::new(temp_dir)], + }), + }); +} + +fn build_record_api( + conn: libsql::Connection, + table_metadata_cache: &TableMetadataCache, + config: RecordApiConfig, +) -> Result { + let Some(ref table_name) = config.table_name else { + return Err(format!( + "RecordApi misses table_name configuration: {config:?}" + )); + }; + + if let Some(table_metadata) = table_metadata_cache.get(table_name) { + return RecordApi::from_table(conn, (*table_metadata).clone(), config); + } else if let Some(view) = table_metadata_cache.get_view(table_name) { + return RecordApi::from_view(conn, (*view).clone(), config); + } + + return Err(format!("RecordApi references missing table: {config:?}")); +} + +fn build_query_api(conn: libsql::Connection, config: QueryApiConfig) -> Result { + // TODO: Check virtual table exists + return QueryApi::from(conn, config); +} diff --git a/trailbase-core/src/assets.rs b/trailbase-core/src/assets.rs new file mode 100644 index 0000000..0266a46 --- /dev/null +++ b/trailbase-core/src/assets.rs @@ -0,0 +1,152 @@ +use axum::body::{Body, Bytes}; +use axum::http::{self, Request, Response, StatusCode}; +use rust_embed::RustEmbed; +use std::borrow::Cow; +use std::convert::Infallible; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::Poll; +use tower_service::Service; + +type FallbackFn = Box Option + Send + Sync>; + +struct State { + fallback: Option, + index_file: Option, +} + +#[derive(Clone)] +pub struct AssetService { + _phantom: std::marker::PhantomData, + state: Arc, +} + +impl AssetService { + pub fn with_parameters(fallback: Option, index_file: Option) -> Self { + Self { + _phantom: std::marker::PhantomData, + state: Arc::new(State { + fallback, + index_file, + }), + } + } +} + +impl Service> for AssetService { + type Response = Response; + type Error = Infallible; + type Future = ServeFuture; + + fn poll_ready( + &mut self, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: Request) -> Self::Future { + ServeFuture { + _phantom: std::marker::PhantomData, + state: self.state.clone(), + request: req, + } + } +} + +pub struct ServeFuture { + _phantom: std::marker::PhantomData, + state: Arc, + request: Request, +} + +impl ServeFuture { + fn not_found() -> Response { + return Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from(NOT_FOUND)) + .unwrap(); + } +} + +impl Future for ServeFuture { + type Output = Result, Infallible>; + + fn poll(self: Pin<&mut Self>, _cx: &mut std::task::Context<'_>) -> Poll { + if self.request.method() != http::Method::GET { + return Poll::Ready(Ok( + Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .header(http::header::CONTENT_TYPE, "text/plain") + .body(Body::from("Method not allowed")) + .unwrap(), + )); + } + + let path: &str = match self.request.uri().path().trim_start_matches("/") { + x if x.is_empty() => { + if let Some(ref index_file) = self.state.index_file { + index_file + } else { + x + } + } + x if x.ends_with("/") => &format!("{x}/index.html"), + x => x, + }; + + #[cfg(test)] + log::debug!("asset path: {:?}", self.request.uri()); + + let Some(file) = E::get(path).or_else(|| { + self + .state + .fallback + .as_ref() + .and_then(|fb| fb(path).and_then(|f| E::get(&f))) + }) else { + return Poll::Ready(Ok(Self::not_found())); + }; + + let response_builder = Response::builder() + .header(http::header::CACHE_CONTROL, "public") + .header(http::header::CACHE_CONTROL, "max-age=604800") + .header(http::header::CACHE_CONTROL, "immutable") + .header(http::header::CONTENT_TYPE, file.metadata.mimetype()); + + return Poll::Ready(Ok( + response_builder + .body(Body::from(cow_to_bytes(file.data))) + .unwrap(), + )); + } +} + +fn cow_to_bytes(cow: Cow<'static, [u8]>) -> Bytes { + match cow { + Cow::Borrowed(x) => Bytes::from(x), + Cow::Owned(x) => Bytes::from(x), + } +} + +pub fn cow_to_string(cow: Cow<'static, [u8]>) -> String { + match cow { + Cow::Borrowed(x) => String::from_utf8_lossy(x).to_string(), + Cow::Owned(x) => String::from_utf8_lossy(&x).to_string(), + } +} + +const NOT_FOUND: &str = r#" + + + + 404 Not Found + + +

Not Found

+ +

The requested URL was not found on this server.

+ + +"#; diff --git a/trailbase-core/src/auth/api/avatar.rs b/trailbase-core/src/auth/api/avatar.rs new file mode 100644 index 0000000..51a6e36 --- /dev/null +++ b/trailbase-core/src/auth/api/avatar.rs @@ -0,0 +1,292 @@ +use axum::extract::{Json, Path, State}; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Redirect, Response}; +use libsql::params; +use serde::{Deserialize, Serialize}; +use trailbase_sqlite::query_one_row; +use trailbase_sqlite::schema::FileUpload; +use uuid::Uuid; + +use crate::app_state::AppState; +use crate::auth::user::DbUser; +use crate::auth::util::user_by_id; +use crate::auth::AuthError; +use crate::constants::{AVATAR_TABLE, RECORD_API_PATH}; +use crate::util::{assert_uuidv7_version, id_to_b64}; + +async fn get_avatar_url(state: &AppState, user: &DbUser) -> Option { + if let Ok(row) = query_one_row( + state.user_conn(), + &format!("SELECT EXISTS(SELECT user FROM '{AVATAR_TABLE}' WHERE user = $1)"), + params!(user.id), + ) + .await + { + let has_avatar: bool = row.get(0).unwrap_or(false); + if has_avatar { + let site = state.site_url(); + let record_user_id = id_to_b64(&user.id); + let col_name = "file"; + return Some(format!( + "{site}/{RECORD_API_PATH}/{AVATAR_TABLE}/{record_user_id}/file/{col_name}" + )); + } + } + + return None; +} + +/// Get a user's avatar url if available. +#[utoipa::path( + get, + path = "/avatar/:b64_user_id", + responses((status = 200, description = "Optional Avatar url")) +)] +pub async fn get_avatar_url_handler( + State(state): State, + headers: HeaderMap, + Path(b64_user_id): Path, +) -> Result { + let Ok(user_id) = crate::util::b64_to_uuid(&b64_user_id) else { + return Err(AuthError::BadRequest("Invalid user id")); + }; + assert_uuidv7_version(&user_id); + + let json = headers + .get(header::CONTENT_TYPE) + .map_or(false, |t| t == "application/json"); + + let db_user = user_by_id(&state, &user_id).await?; + + // TODO: Allow a configurable fallback url. + let avatar_url = get_avatar_url(&state, &db_user) + .await + .or(db_user.provider_avatar_url); + + // TODO: Maybe return a JSON response with url if content-type is JSON. + return match avatar_url { + Some(url) => { + if json { + Ok( + Json(serde_json::json!({ + "avatar_url": url, + })) + .into_response(), + ) + } else { + Ok(Redirect::to(&url).into_response()) + } + } + None => Ok(StatusCode::NOT_FOUND.into_response()), + }; +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub(crate) struct DbAvatar { + pub user: [u8; 16], + pub file: String, + pub updated: i64, +} + +#[allow(unused)] +#[derive(Debug, Clone)] +pub struct Avatar { + pub user: Uuid, + pub file: FileUpload, +} + +#[cfg(test)] +mod tests { + use axum::extract::{FromRequest, Path, Query, State}; + use axum::http; + use axum::response::Response; + use axum_test::multipart::{MultipartForm, Part}; + use libsql::de; + use trailbase_sqlite::query_one_row; + + use super::*; + use crate::admin::user::create_user_for_test; + use crate::app_state::*; + use crate::auth::api::login::login_with_password; + use crate::auth::user::{DbUser, User}; + use crate::constants::RECORD_API_PATH; + use crate::constants::{AVATAR_TABLE, USER_TABLE}; + use crate::extract::Either; + use crate::records::create_record::{ + create_record_handler, CreateRecordQuery, CreateRecordResponse, + }; + use crate::records::read_record::get_uploaded_file_from_record_handler; + use crate::test::unpack_json_response; + use crate::util::{b64_to_uuid, id_to_b64, uuid_to_b64}; + + type Request = http::Request; + + const COL_NAME: &str = "file"; + const AVATAR_COLLECTION_NAME: &str = AVATAR_TABLE; + + async fn build_upload_avatar_form_req( + user: &uuid::Uuid, + filename: &str, + body_slice: &[u8], + ) -> Request { + let user_id = uuid_to_b64(&user); + + let form = MultipartForm::new().add_text("user", user_id).add_part( + COL_NAME, + Part::bytes(body_slice.to_vec()).file_name(filename), + ); + let content_type = form.content_type(); + let body: axum::body::Body = form.into(); + + http::Request::builder() + .header("content-type", content_type) + .body(body) + .unwrap() + } + + async fn upload_avatar( + state: &AppState, + user: Option, + body: &[u8], + ) -> Result { + let user_id = user.as_ref().unwrap().uuid; + let response: CreateRecordResponse = unpack_json_response( + create_record_handler( + State(state.clone()), + Path(AVATAR_COLLECTION_NAME.to_string()), + Query(CreateRecordQuery::default()), + user, + Either::from_request( + build_upload_avatar_form_req(&user_id, "foo.html", body).await, + &(), + ) + .await + .unwrap(), + ) + .await?, + ) + .await + .unwrap(); + + return Ok(b64_to_uuid(&response.id)?); + } + + async fn download_avatar(state: &AppState, record_id: &[u8; 16]) -> Response { + return get_uploaded_file_from_record_handler( + State(state.clone()), + Path(( + AVATAR_COLLECTION_NAME.to_string(), + id_to_b64(record_id), + COL_NAME.to_string(), + )), + None, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_avatar_upload() { + let state = test_state(None).await.unwrap(); + + let email = "user_x@test.com"; + let password = "SuperSecret5"; + let _user_x = create_user_for_test(&state, email, &password) + .await + .unwrap(); + + let user_x_token = login_with_password(&state, email, password).await.unwrap(); + + let db_user: DbUser = de::from_row( + &query_one_row( + state.user_conn(), + &format!("SELECT * FROM '{USER_TABLE}' WHERE email = $1"), + [email], + ) + .await + .unwrap(), + ) + .unwrap(); + + let missing_profile_response = get_avatar_url_handler( + State(state.clone()), + HeaderMap::new(), + Path(id_to_b64(&db_user.id)), + ) + .await + .unwrap(); + assert_eq!( + missing_profile_response.status(), + http::StatusCode::NOT_FOUND + ); + + const PNG0: &[u8] = b"\x89PNG\x0d\x0a\x1a\x0b"; + const PNG1: &[u8] = b"\x89PNG\x0d\x0a\x1a\x0c"; + + let record_id = upload_avatar( + &state, + User::from_auth_token(&state, &user_x_token.auth_token), + PNG0, + ) + .await + .unwrap(); + + let response = download_avatar(&state, &record_id.into_bytes()).await; + assert_eq!( + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(), + PNG0 + ); + + // Test replacement + let record_id = upload_avatar( + &state, + User::from_auth_token(&state, &user_x_token.auth_token), + PNG1, + ) + .await + .unwrap(); + let response = download_avatar(&state, &record_id.into_bytes()).await; + assert_eq!( + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(), + PNG1 + ); + + // Test non png/jpeg types will be rejected + assert!(upload_avatar( + &state, + User::from_auth_token(&state, &user_x_token.auth_token), + b"Body 0", + ) + .await + .is_err()); + + let avatar_response = get_avatar_url_handler( + State(state.clone()), + HeaderMap::new(), + Path(id_to_b64(&db_user.id)), + ) + .await + .unwrap(); + + assert_eq!(avatar_response.status(), http::StatusCode::SEE_OTHER); + let location = avatar_response + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + + assert_eq!( + location, + format!( + "{site}/{RECORD_API_PATH}/{AVATAR_COLLECTION_NAME}/{record_id_b64}/file/{COL_NAME}", + site = state.site_url(), + record_id_b64 = uuid_to_b64(&record_id), + ) + ); + } +} diff --git a/trailbase-core/src/auth/api/change_email.rs b/trailbase-core/src/auth/api/change_email.rs new file mode 100644 index 0000000..55db912 --- /dev/null +++ b/trailbase-core/src/auth/api/change_email.rs @@ -0,0 +1,205 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Redirect, Response}, +}; +use lazy_static::lazy_static; +use libsql::named_params; +use serde::Deserialize; +use ts_rs::TS; +use utoipa::{IntoParams, ToSchema}; + +use crate::app_state::AppState; +use crate::auth::api::register::validate_and_normalize_email_address; +use crate::auth::util::{user_by_id, validate_redirects}; +use crate::auth::{AuthError, User}; +use crate::constants::{USER_TABLE, VERIFICATION_CODE_LENGTH}; +use crate::email::Email; +use crate::extract::Either; +use crate::rand::generate_random_string; + +const TTL_SEC: i64 = 3600; +// Short rate limit, since changing email requires users to be authed already. There's still an +// abuse vector where an authenticated uses this TrailBase instance's email setup to spam. +const RATE_LIMIT_SEC: i64 = 600; + +#[derive(Debug, Default, Deserialize, TS, ToSchema)] +#[ts(export)] +pub struct ChangeEmailRequest { + pub csrf_token: String, + pub old_email: Option, + pub new_email: String, +} + +/// Request an email change. +#[utoipa::path( + post, + path = "/change_email/request", + request_body = ChangeEmailRequest, + responses( + (status = 200, description = "Success.") + ) +)] +pub async fn change_email_request_handler( + State(state): State, + user: User, + either_request: Either, +) -> Result { + let (request, json) = match either_request { + Either::Json(req) => (req, true), + Either::Multipart(req, _) => (req, false), + Either::Form(req) => (req, false), + }; + + if request.csrf_token != user.csrf_token { + return Err(AuthError::BadRequest("Invalid CSRF token")); + } + + // NOTE: This is pretty arbitrary, we could do away with this entirely. + if !json && request.old_email.is_none() { + return Err(AuthError::BadRequest("Missing old email address")); + } + + if validate_and_normalize_email_address(&request.new_email).is_err() { + return Err(AuthError::BadRequest("Invalid email address")); + } + let Ok(db_user) = user_by_id(&state, &user.uuid).await else { + return Err(AuthError::Forbidden); + }; + + if let Some(last_verification) = db_user.email_verification_code_sent_at { + let Some(timestamp) = chrono::DateTime::from_timestamp(last_verification, 0) else { + return Err(AuthError::Internal("Invalid timestamp".into())); + }; + + let age: chrono::Duration = chrono::Utc::now() - timestamp; + if age < chrono::Duration::seconds(RATE_LIMIT_SEC) { + return Err(AuthError::BadRequest("verification sent already")); + } + } + + let email_verification_code = generate_random_string(VERIFICATION_CODE_LENGTH); + lazy_static! { + pub static ref QUERY: String = format!( + r#" + UPDATE + '{USER_TABLE}' + SET + pending_email = :new_email, + email_verification_code = :email_verification_code, + email_verification_code_sent_at = UNIXEPOCH() + WHERE + id = :user_id AND ( + CASE :old_email + WHEN NULL THEN TRUE + ELSE email = :old_email + END + ) + "# + ); + } + + let rows_affected = state + .user_conn() + .execute( + &QUERY, + named_params! { + ":new_email": request.new_email, + ":old_email": request.old_email, + ":email_verification_code": email_verification_code.clone(), + ":user_id": user.uuid.into_bytes().to_vec(), + }, + ) + .await?; + + return match rows_affected { + 0 => Err(AuthError::BadRequest("failed to change email")), + 1 => { + let email = Email::change_email_address_email(&state, &db_user, &email_verification_code) + .map_err(|err| AuthError::Internal(err.into()))?; + email + .send() + .await + .map_err(|err| AuthError::Internal(err.into()))?; + + Ok((StatusCode::OK, "Verification email sent.").into_response()) + } + _ => { + panic!("Email change request affected multiple users: {rows_affected}"); + } + }; +} + +#[derive(Debug, Default, Deserialize, IntoParams)] +pub(crate) struct ChangeEmailConfigQuery { + pub redirect_to: Option, +} + +/// Confirm a change of email address. +#[utoipa::path( + get, + path = "/change_email/confirm/:email_verification_code", + responses( + (status = 200, description = "Success.") + ) +)] +pub async fn change_email_confirm_handler( + State(state): State, + Path(email_verification_code): Path, + Query(query): Query, + user: User, +) -> Result { + let redirect = validate_redirects(&state, &query.redirect_to, &None)?; + + if email_verification_code.len() != VERIFICATION_CODE_LENGTH { + return Err(AuthError::BadRequest("Invalid code")); + } + + let db_user = user_by_id(&state, &user.uuid).await?; + let Some(db_email_verification_code) = db_user.email_verification_code else { + return Err(AuthError::BadRequest("Invalid code")); + }; + if db_email_verification_code != email_verification_code { + return Err(AuthError::BadRequest("Invalid code")); + } + + let Some(new_email) = db_user.pending_email else { + return Err(AuthError::Conflict); + }; + + lazy_static! { + pub static ref QUERY: String = format!( + r#" + UPDATE + '{USER_TABLE}' + SET + email = :new_email, + verified = TRUE, + pending_email = NULL, + email_verification_code = NULL, + email_verification_code_sent_at = NULL + WHERE + email_verification_code = :email_verification_code AND email_verification_code_sent_at > (UNIXEPOCH() - {TTL_SEC}) + "# + ); + } + + let rows_affected = state + .user_conn() + .execute( + &QUERY, + named_params! { + ":new_email": new_email, + ":email_verification_code": email_verification_code, + }, + ) + .await?; + + return match rows_affected { + 0 => Err(AuthError::BadRequest("Invalid verification code")), + 1 => Ok(Redirect::to( + redirect.as_deref().unwrap_or("/_/auth/profile/"), + )), + _ => panic!("emails updated for multiple users at once: {rows_affected}"), + }; +} diff --git a/trailbase-core/src/auth/api/change_password.rs b/trailbase-core/src/auth/api/change_password.rs new file mode 100644 index 0000000..91ea807 --- /dev/null +++ b/trailbase-core/src/auth/api/change_password.rs @@ -0,0 +1,108 @@ +use argon2::{Argon2, PasswordHash, PasswordVerifier}; +use axum::{ + extract::{Query, State}, + response::Redirect, +}; +use lazy_static::lazy_static; +use libsql::named_params; +use serde::Deserialize; +use ts_rs::TS; +use utoipa::{IntoParams, ToSchema}; + +use crate::auth::password::{hash_password, validate_passwords}; +use crate::auth::util::validate_redirects; +use crate::auth::{AuthError, User}; +use crate::constants::{PASSWORD_OPTIONS, USER_TABLE}; +use crate::extract::Either; +use crate::{app_state::AppState, auth::util::user_by_id}; + +#[derive(Debug, Default, Deserialize, IntoParams)] +pub(crate) struct ChangePasswordQuery { + pub redirect_to: Option, +} + +#[derive(Debug, Default, Deserialize, TS, ToSchema)] +#[ts(export)] +pub struct ChangePasswordRequest { + pub old_password: String, + pub new_password: String, + pub new_password_repeat: String, +} + +/// Request a change of password. +#[utoipa::path( + post, + path = "/change_password", + params(ChangePasswordQuery), + request_body = ChangePasswordRequest, + responses( + (status = 200, description = "Success.") + ) +)] +pub async fn change_password_handler( + State(state): State, + Query(query): Query, + user: User, + either_request: Either, +) -> Result { + let redirect = validate_redirects(&state, &query.redirect_to, &None)?; + + let request = match either_request { + Either::Json(req) => req, + Either::Multipart(req, _) => req, + Either::Form(req) => req, + }; + + validate_passwords( + &request.new_password, + &request.new_password_repeat, + &PASSWORD_OPTIONS, + )?; + + let db_user = user_by_id(&state, &user.uuid).await?; + + // Validate old password. + let parsed_hash = PasswordHash::new(&db_user.password_hash) + .map_err(|err| AuthError::Internal(err.to_string().into()))?; + Argon2::default() + .verify_password(request.old_password.as_bytes(), &parsed_hash) + .map_err(|_err| AuthError::Unauthorized)?; + + // NOTE: we're using the old_password_hash to prevent races between concurrent change requests + // for the same user. + let old_password_hash = db_user.password_hash; + let new_password_hash = hash_password(&request.new_password)?; + + lazy_static! { + pub static ref QUERY: String = format!( + r#" + UPDATE + '{USER_TABLE}' + SET + password_hash = :new_password_hash + WHERE + id = :user_id AND password_hash = :old_password_hash + "# + ); + } + + let rows_affected = state + .user_conn() + .execute( + &QUERY, + named_params! { + ":user_id": user.uuid.into_bytes(), + ":new_password_hash": new_password_hash, + ":old_password_hash": old_password_hash, + }, + ) + .await?; + + return match rows_affected { + 0 => Err(AuthError::BadRequest("Invalid old password")), + 1 => Ok(Redirect::to( + redirect.as_deref().unwrap_or("/_/auth/profile/"), + )), + _ => panic!("password changed for multiple users at once: {rows_affected}"), + }; +} diff --git a/trailbase-core/src/auth/api/delete.rs b/trailbase-core/src/auth/api/delete.rs new file mode 100644 index 0000000..756c0fb --- /dev/null +++ b/trailbase-core/src/auth/api/delete.rs @@ -0,0 +1,38 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use tower_cookies::Cookies; + +use crate::app_state::AppState; +use crate::auth::user::User; +use crate::auth::util::{delete_all_sessions_for_user, remove_all_cookies}; +use crate::auth::AuthError; +use crate::constants::USER_TABLE; + +/// Get public profile of the given user. +#[utoipa::path( + delete, + path = "/delete", + responses( + (status = 200, description = "User deleted.") + ) +)] +pub(crate) async fn delete_handler( + State(state): State, + user: User, + cookies: Cookies, +) -> Result { + let _ = delete_all_sessions_for_user(&state, user.uuid).await; + + state + .user_conn() + .execute( + &format!("DELETE FROM '{USER_TABLE}' WHERE id = $1"), + [user.uuid.into_bytes().to_vec()], + ) + .await?; + + remove_all_cookies(&cookies); + + return Ok((StatusCode::OK, "deleted").into_response()); +} diff --git a/trailbase-core/src/auth/api/login.rs b/trailbase-core/src/auth/api/login.rs new file mode 100644 index 0000000..f2b7751 --- /dev/null +++ b/trailbase-core/src/auth/api/login.rs @@ -0,0 +1,296 @@ +use argon2::{Argon2, PasswordHash, PasswordVerifier}; +use axum::{ + extract::{Query, State}, + response::{IntoResponse, Redirect, Response}, + Json, +}; +use lazy_static::lazy_static; +use libsql::named_params; +use serde::{Deserialize, Serialize}; +use tower_cookies::Cookies; +use ts_rs::TS; +use utoipa::{IntoParams, ToSchema}; + +use crate::app_state::AppState; +use crate::auth::api::register::validate_and_normalize_email_address; +use crate::auth::tokens::{mint_new_tokens, Tokens}; +use crate::auth::user::DbUser; +use crate::auth::util::{new_cookie, user_by_email, validate_redirects}; +use crate::auth::AuthError; +use crate::constants::{ + COOKIE_AUTH_TOKEN, COOKIE_REFRESH_TOKEN, USER_TABLE, VERIFICATION_CODE_LENGTH, +}; +use crate::extract::Either; +use crate::rand::generate_random_string; + +#[derive(Debug, Default, Deserialize, IntoParams)] +pub(crate) struct LoginQuery { + pub redirect_to: Option, +} + +#[derive(Debug, Deserialize, TS, ToSchema)] +#[ts(export)] +pub struct LoginRequest { + pub email: String, + pub password: String, + + pub redirect_to: Option, + pub response_type: Option, + pub pkce_code_challenge: Option, +} + +#[derive(Debug, Serialize, Deserialize, TS, ToSchema)] +#[ts(export)] +pub struct LoginResponse { + pub auth_token: String, + pub refresh_token: String, + pub csrf_token: String, +} + +/// Logs in user by email and password. +#[utoipa::path( + post, + path = "/login", + params(LoginQuery), + request_body = LoginRequest, + responses( + (status = 200, description = "Auth & refresh tokens.", body = LoginResponse) + ) +)] +pub(crate) async fn login_handler( + State(state): State, + Query(query): Query, + cookies: Cookies, + either_request: Either, +) -> Result { + let (request, json) = match either_request { + Either::Json(req) => (req, true), + Either::Form(req) => (req, false), + Either::Multipart(req, _) => (req, false), + }; + + let email = request.email.clone(); + let redirect = validate_redirects(&state, &query.redirect_to, &request.redirect_to)?; + let code_response = request + .response_type + .as_ref() + .map_or(false, |t| t == "code"); + let pkce_code_challenge = request.pkce_code_challenge.clone(); + + let response_or = login_handler_impl(&state, request).await; + + if json { + return Ok(Json(response_or?).into_response()); + } + + // Cookie and redirect handling for the non-json case. The assumption is that json login is used + // by SPAs or mobile applications, which should handle credential passing explicitly. No cookies + // also removes the risk for any CSRF. + let response = match response_or { + Ok(response) => response, + Err(err) => { + let err_str = err.to_string(); + let err_response: Response = err.into_response(); + if err_response.status().is_client_error() { + let err_msg = crate::util::urlencode(&format!( + "Login Failed [{}]: {err_str}", + err_response.status() + )); + return Ok(Redirect::to(&format!("/_/auth/login/?alert={err_msg}")).into_response()); + } + return Ok(err_response); + } + }; + + if code_response { + let Some(redirect) = redirect else { + return Err(AuthError::BadRequest("missing 'redirect_to'")); + }; + + // For the auth_code flow we generate a random code. + let authorization_code = generate_random_string(VERIFICATION_CODE_LENGTH); + + lazy_static! { + pub static ref QUERY: String = format!( + r#" + UPDATE + '{USER_TABLE}' + SET + authorization_code = :authorization_code, + authorization_code_sent_at = UNIXEPOCH(), + pkce_code_challenge = :pkce_code_challenge + WHERE + email = :email + "# + ); + } + + let rows_affected = state + .user_conn() + .execute( + &QUERY, + named_params! { + ":authorization_code": authorization_code.clone(), + ":pkce_code_challenge": pkce_code_challenge, + ":email": email, + }, + ) + .await?; + + return match rows_affected { + 0 => Err(AuthError::BadRequest("invalid user")), + 1 => { + // TODO: could be smarter with merging here. + let url = format!("{redirect}?code={authorization_code}"); + Ok(Redirect::to(&url).into_response()) + } + _ => { + panic!("code challenge update affected multiple users: {rows_affected}"); + } + }; + } + + let (auth_token_ttl, refresh_token_ttl) = state.access_config(|c| c.auth.token_ttls()); + cookies.add(new_cookie( + COOKIE_AUTH_TOKEN, + response.auth_token, + auth_token_ttl, + state.dev_mode(), + )); + cookies.add(new_cookie( + COOKIE_REFRESH_TOKEN, + response.refresh_token, + refresh_token_ttl, + state.dev_mode(), + )); + + return Ok( + Redirect::to(redirect.as_deref().unwrap_or_else(|| { + if state.public_dir().is_some() { + "/" + } else { + "/_/auth/profile" + } + })) + .into_response(), + ); +} + +async fn login_handler_impl( + state: &AppState, + request: LoginRequest, +) -> Result { + let email = if validate_and_normalize_email_address(&request.email).is_ok() { + request.email + } else { + return Err(AuthError::BadRequest("invalid e-mail")); + }; + + let NewTokens { + auth_token, + refresh_token, + csrf_token, + .. + } = login_with_password(state, &email, &request.password).await?; + + return Ok(LoginResponse { + auth_token, + refresh_token, + csrf_token, + }); +} + +#[derive(Debug, Serialize, Deserialize, TS, ToSchema)] +#[ts(export)] +pub struct LoginStatusResponse { + pub auth_token: Option, + pub refresh_token: Option, + pub csrf_token: Option, +} + +/// Check login status. +#[utoipa::path( + get, + path = "/status", + responses( + (status = 200, description = "Auth & refresh tokens.", body = LoginStatusResponse) + ) +)] +pub(crate) async fn login_status_handler( + State(state): State, + tokens: Option, +) -> Result, AuthError> { + let Some(tokens) = tokens else { + return Ok(Json(LoginStatusResponse { + auth_token: None, + refresh_token: None, + csrf_token: None, + })); + }; + + let Tokens { + auth_token_claims, + refresh_token, + } = tokens; + + let auth_token = state + .jwt() + .encode(&auth_token_claims) + .map_err(|err| AuthError::Internal(err.into()))?; + + return Ok(Json(LoginStatusResponse { + auth_token: Some(auth_token), + refresh_token, + csrf_token: Some(auth_token_claims.csrf_token), + })); +} + +pub struct NewTokens { + pub id: uuid::Uuid, + pub auth_token: String, + pub refresh_token: String, + pub csrf_token: String, +} + +pub async fn login_with_password( + state: &AppState, + email: &str, + password: &str, +) -> Result { + let normalized_email = validate_and_normalize_email_address(email)?; + let db_user: DbUser = user_by_email(state, &normalized_email).await?; + + if !db_user.verified { + return Err(AuthError::Unauthorized); + } + + // Validate password. + let parsed_hash = PasswordHash::new(&db_user.password_hash) + .map_err(|err| AuthError::Internal(err.to_string().into()))?; + Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .map_err(|_err| AuthError::Unauthorized)?; + + let (auth_token_ttl, _refresh_token_ttl) = state.access_config(|c| c.auth.token_ttls()); + let user_id = db_user.uuid(); + + let tokens = mint_new_tokens( + state, + db_user.verified, + user_id, + db_user.email, + auth_token_ttl, + ) + .await?; + let auth_token = state + .jwt() + .encode(&tokens.auth_token_claims) + .map_err(|err| AuthError::Internal(err.into()))?; + + return Ok(NewTokens { + id: user_id, + auth_token, + refresh_token: tokens.refresh_token, + csrf_token: tokens.auth_token_claims.csrf_token, + }); +} diff --git a/trailbase-core/src/auth/api/logout.rs b/trailbase-core/src/auth/api/logout.rs new file mode 100644 index 0000000..ba1313b --- /dev/null +++ b/trailbase-core/src/auth/api/logout.rs @@ -0,0 +1,81 @@ +use axum::{ + extract::{Json, Query, State}, + http::StatusCode, + response::{IntoResponse, Redirect, Response}, +}; +use serde::Deserialize; +use tower_cookies::Cookies; +use ts_rs::TS; +use utoipa::{IntoParams, ToSchema}; + +use crate::auth::user::User; +use crate::auth::util::{ + delete_all_sessions_for_user, delete_session, remove_all_cookies, validate_redirects, +}; +use crate::auth::AuthError; +use crate::AppState; + +#[derive(Debug, Default, Deserialize, IntoParams)] +pub struct LogoutQuery { + redirect_to: Option, +} + +/// Logs out the current user and delete **all** pending sessions for that user. +/// +/// Relies on the client to drop any auth tokens. We delete the session to avoid refresh tokens +/// bringing a logged out session back to live. +#[utoipa::path( + get, + path = "/logout", + params(LogoutQuery), + responses( + (status = 200, description = "Auth & refresh tokens.") + ) +)] +pub async fn logout_handler( + State(state): State, + Query(query): Query, + user: Option, + cookies: Cookies, +) -> Result { + let redirect = validate_redirects(&state, &query.redirect_to, &None)?; + + remove_all_cookies(&cookies); + + if let Some(user) = user { + delete_all_sessions_for_user(&state, user.uuid).await?; + } + + return Ok(Redirect::to(redirect.as_deref().unwrap_or_else(|| { + if state.public_dir().is_some() { + "/" + } else { + "/_/auth/login" + } + }))); +} + +#[derive(Clone, Debug, Deserialize, ToSchema, TS)] +#[ts(export)] +pub struct LogoutRequest { + pub refresh_token: String, +} + +/// Logs out the current user and deletes the specific session for the given refresh token. +/// +/// Relies on the client to drop any auth tokens. +#[utoipa::path( + post, + path = "/logout", + request_body = LogoutRequest, + responses( + (status = 200, description = "Auth & refresh tokens.") + ) +)] +pub async fn post_logout_handler( + State(state): State, + Json(request): Json, +) -> Result { + delete_session(&state, request.refresh_token).await?; + return Ok(StatusCode::OK.into_response()); +} diff --git a/trailbase-core/src/auth/api/mod.rs b/trailbase-core/src/auth/api/mod.rs new file mode 100644 index 0000000..c266ffa --- /dev/null +++ b/trailbase-core/src/auth/api/mod.rs @@ -0,0 +1,13 @@ +pub mod login; + +pub(crate) mod register; + +pub(super) mod avatar; +pub(super) mod change_email; +pub(super) mod change_password; +pub(super) mod delete; +pub(super) mod logout; +pub(super) mod refresh; +pub(super) mod reset_password; +pub(super) mod token; +pub(super) mod verify_email; diff --git a/trailbase-core/src/auth/api/refresh.rs b/trailbase-core/src/auth/api/refresh.rs new file mode 100644 index 0000000..b58017b --- /dev/null +++ b/trailbase-core/src/auth/api/refresh.rs @@ -0,0 +1,57 @@ +use axum::extract::{Json, State}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +use crate::app_state::AppState; +use crate::auth::tokens::reauth_with_refresh_token; +use crate::auth::AuthError; + +#[derive(Debug, Deserialize, ToSchema, TS)] +#[ts(export)] +pub struct RefreshRequest { + pub refresh_token: String, +} + +#[derive(Debug, Serialize, ToSchema, TS)] +#[ts(export)] +pub struct RefreshResponse { + pub auth_token: String, + pub csrf_token: String, +} + +/// Refreshes auth tokens given a refresh token. +/// +/// NOTE: This is a json-only API, since cookies will be auto-refreshed. +#[utoipa::path( + post, + path = "/refresh", + request_body = RefreshRequest, + responses( + (status = 200, description = "Refreshed auth tokens.", body = RefreshResponse) + ) +)] +pub(crate) async fn refresh_handler( + State(state): State, + Json(request): Json, +) -> Result, AuthError> { + let (auth_token_ttl, refresh_token_ttl) = state.access_config(|c| c.auth.token_ttls()); + + let claims = reauth_with_refresh_token( + &state, + request.refresh_token, + refresh_token_ttl, + auth_token_ttl, + ) + .await?; + + let auth_token = state + .jwt() + .encode(&claims) + .map_err(|err| AuthError::Internal(err.into()))?; + + return Ok(Json(RefreshResponse { + auth_token, + csrf_token: claims.csrf_token, + })); +} diff --git a/trailbase-core/src/auth/api/register.rs b/trailbase-core/src/auth/api/register.rs new file mode 100644 index 0000000..274514f --- /dev/null +++ b/trailbase-core/src/auth/api/register.rs @@ -0,0 +1,117 @@ +use axum::{ + extract::{Form, State}, + http::StatusCode, + response::{IntoResponse, Redirect, Response}, +}; +use lazy_static::lazy_static; +use libsql::{de, named_params}; +use serde::Deserialize; +use trailbase_sqlite::query_one_row; +use utoipa::ToSchema; +use validator::ValidateEmail; + +use crate::app_state::AppState; +use crate::auth::password::{hash_password, validate_passwords}; +use crate::auth::user::DbUser; +use crate::auth::util::user_exists; +use crate::auth::AuthError; +use crate::constants::{PASSWORD_OPTIONS, USER_TABLE, VERIFICATION_CODE_LENGTH}; +use crate::email::Email; +use crate::rand::generate_random_string; + +/// Validates the given email addresses and returns a best-effort normalized address. +/// +/// NOTE: That there's no robust way to detect equivalent addresses, default mappings are highly +/// domain specific, e.g. most mail providers will treat emails as case insensitive and others have +/// custom rules such as gmail stripping all "." and everything after and including "+". Trying to +/// be overly smart is probably a recipe for disaster. +pub fn validate_and_normalize_email_address(address: &str) -> Result { + if !address.validate_email() { + return Err(AuthError::BadRequest("Invalid email")); + } + + // TODO: detect and reject one-time burner email addresses. + + return Ok(address.to_ascii_lowercase()); +} + +#[derive(Debug, Default, Deserialize, ToSchema)] +pub struct RegisterUserRequest { + pub email: String, + pub password: String, + pub password_repeat: String, +} + +/// Registers a new user with email and password. +#[utoipa::path( + post, + path = "/register", + request_body = RegisterUserRequest, + responses( + (status = 200, description = "Successful registration.") + ) +)] +pub async fn register_user_handler( + State(state): State, + Form(request): Form, +) -> Result { + let normalized_email = validate_and_normalize_email_address(&request.email)?; + + if let Err(_err) = validate_passwords( + &request.password, + &request.password_repeat, + &PASSWORD_OPTIONS, + ) { + let msg = crate::util::urlencode("Invalid password"); + return Ok(Redirect::to(&format!("/_/auth/register/?alert={msg}")).into_response()); + } + + let exists = user_exists(&state, &normalized_email).await?; + if exists { + let msg = crate::util::urlencode("E-mail already registered."); + return Ok(Redirect::to(&format!("/_/auth/register/?alert={msg}")).into_response()); + } + + let email_verification_code = generate_random_string(VERIFICATION_CODE_LENGTH); + let hashed_password = hash_password(&request.password)?; + + lazy_static! { + static ref INSERT_USER_QUERY: String = format!( + r#" + INSERT INTO '{USER_TABLE}' + (email, password_hash, email_verification_code, email_verification_code_sent_at) + VALUES + (:email, :password_hash, :email_verification_code, UNIXEPOCH()) + RETURNING * + "# + ); + } + + let user: DbUser = de::from_row( + &query_one_row( + state.user_conn(), + &INSERT_USER_QUERY, + named_params! { + ":email": normalized_email.clone(), + ":password_hash": hashed_password, + ":email_verification_code": email_verification_code.clone(), + }, + ) + .await?, + ) + .map_err(|_err| { + #[cfg(debug_assertions)] + log::debug!("Failed to create user {normalized_email}: {_err}"); + // The insert will fail if the user is already registered + AuthError::Conflict + })?; + + let email = Email::verification_email(&state, &user, &email_verification_code) + .map_err(|err| AuthError::Internal(err.into()))?; + email + .send() + .await + .map_err(|err| AuthError::Internal(err.into()))?; + + return Ok((StatusCode::OK, "User registered").into_response()); +} diff --git a/trailbase-core/src/auth/api/reset_password.rs b/trailbase-core/src/auth/api/reset_password.rs new file mode 100644 index 0000000..8520be0 --- /dev/null +++ b/trailbase-core/src/auth/api/reset_password.rs @@ -0,0 +1,193 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use lazy_static::lazy_static; +use libsql::params; +use serde::Deserialize; +use trailbase_sqlite::query_one_row; +use ts_rs::TS; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::app_state::AppState; +use crate::constants::{PASSWORD_OPTIONS, USER_TABLE}; +use crate::email::Email; +use crate::extract::Either; +use crate::rand::generate_random_string; + +use crate::auth::api::register::validate_and_normalize_email_address; +use crate::auth::password::{hash_password, validate_passwords}; +use crate::auth::util::user_by_email; +use crate::auth::AuthError; + +const TTL_SEC: i64 = 3600; +const RATE_LIMIT_SEC: i64 = 4 * 3600; + +#[derive(Debug, Default, Deserialize, TS, ToSchema)] +pub struct ResetPasswordRequest { + pub email: String, +} + +/// Request a password reset. +#[utoipa::path( + post, + path = "/reset_password/request", + request_body = ResetPasswordRequest, + responses( + (status = 200, description = "Success.") + ) +)] +pub async fn reset_password_request_handler( + State(state): State, + either_request: Either, +) -> Result { + let request = match either_request { + Either::Json(req) => req, + Either::Multipart(req, _) => req, + Either::Form(req) => req, + }; + + let normalized_email = validate_and_normalize_email_address(&request.email)?; + + let user = user_by_email(&state, &normalized_email).await?; + + if let Some(last_reset) = user.password_reset_code_sent_at { + let Some(timestamp) = chrono::DateTime::from_timestamp(last_reset, 0) else { + return Err(AuthError::Internal("Invalid timestamp".into())); + }; + + let age: chrono::Duration = chrono::Utc::now() - timestamp; + if age < chrono::Duration::seconds(RATE_LIMIT_SEC) { + return Err(AuthError::BadRequest("Password reset sent already")); + } + } + + let password_reset_code = generate_random_string(20); + lazy_static! { + static ref UPDATE_CODE_QUERY: String = format!( + r#" + UPDATE + '{USER_TABLE}' + SET + password_reset_code = $1, + password_reset_code_sent_at = UNIXEPOCH() + WHERE + id = $2 + "# + ); + } + + let rows_affected = state + .user_conn() + .execute( + &UPDATE_CODE_QUERY, + params!(password_reset_code.clone(), user.id), + ) + .await?; + + return match rows_affected { + 0 => Err(AuthError::Conflict), + 1 => { + let email = Email::password_reset_email(&state, &user, &password_reset_code) + .map_err(|err| AuthError::Internal(err.into()))?; + email + .send() + .await + .map_err(|err| AuthError::Internal(err.into()))?; + + Ok((StatusCode::OK, "Password reset mail sent").into_response()) + } + _ => { + panic!(); + } + }; +} + +#[derive(Debug, Default, Deserialize, ToSchema)] +pub struct ResetPasswordUpdateRequest { + pub password: String, + pub password_repeat: String, +} + +/// Endpoint for setting a new password after the user has requested a reset and provided a +/// replacement password. +#[utoipa::path( + post, + path = "/reset_password/update/:password_reset_code", + request_body = ResetPasswordUpdateRequest, + responses( + (status = 200, description = "Success.") + ) +)] +pub async fn reset_password_update_handler( + State(state): State, + Path(password_reset_code): Path, + either_request: Either, +) -> Result { + let request = match either_request { + Either::Json(req) => req, + Either::Multipart(req, _) => req, + Either::Form(req) => req, + }; + + validate_passwords( + &request.password, + &request.password_repeat, + &PASSWORD_OPTIONS, + )?; + + let hashed_password = hash_password(&request.password)?; + lazy_static! { + static ref UPDATE_PASSWORD_QUERY: String = format!( + r#" + UPDATE '{USER_TABLE}' + SET + password_hash = $1, + password_reset_code = NULL + WHERE + password_reset_code = $2 AND password_reset_code_sent_at > (UNIXEPOCH() - {TTL_SEC}) + "# + ); + } + + let rows_affected = state + .user_conn() + .execute( + &UPDATE_PASSWORD_QUERY, + params!(hashed_password, password_reset_code), + ) + .await?; + + return match rows_affected { + 0 => Err(AuthError::BadRequest("Invalid reset code.")), + 1 => Ok((StatusCode::OK, "Password updated").into_response()), + _ => { + panic!("multiple users with same verification code."); + } + }; +} + +pub async fn force_password_reset( + user_conn: &libsql::Connection, + email: String, + password: String, +) -> Result { + let hashed_password = hash_password(&password)?; + + lazy_static! { + static ref UPDATE_PASSWORD_QUERY: String = + format!("UPDATE '{USER_TABLE}' SET password_hash = $1 WHERE email = $2 RETURNING id"); + } + + let id: [u8; 16] = query_one_row( + user_conn, + &UPDATE_PASSWORD_QUERY, + params!(hashed_password, email), + ) + .await? + .get(0)?; + + return Ok(Uuid::from_bytes(id)); +} diff --git a/trailbase-core/src/auth/api/token.rs b/trailbase-core/src/auth/api/token.rs new file mode 100644 index 0000000..ae32383 --- /dev/null +++ b/trailbase-core/src/auth/api/token.rs @@ -0,0 +1,108 @@ +use axum::extract::{Json, State}; +use lazy_static::lazy_static; +use libsql::{de, params}; +use serde::{Deserialize, Serialize}; +use trailbase_sqlite::query_one_row; +use ts_rs::TS; +use utoipa::ToSchema; + +use crate::auth::tokens::mint_new_tokens; +use crate::auth::util::derive_pkce_code_challenge; +use crate::auth::AuthError; +use crate::constants::{USER_TABLE, VERIFICATION_CODE_LENGTH}; +use crate::{app_state::AppState, auth::user::DbUser}; + +const TTL_SEC: i64 = 300; + +#[derive(Clone, Debug, Deserialize, ToSchema, TS)] +#[ts(export)] +pub struct AuthCodeToTokenRequest { + pub authorization_code: Option, + pub pkce_code_verifier: Option, +} + +#[derive(Clone, Debug, Serialize, ToSchema)] +pub struct TokenResponse { + pub auth_token: String, + pub refresh_token: String, + pub csrf_token: String, +} + +/// Exchange authorization code for auth tokens. +/// +/// This API endpoint is meant for client-side applications (SPA, mobile, desktop, ...) using the +/// web-auth flow. +#[utoipa::path( + post, + path = "/token", + request_body = AuthCodeToTokenRequest, + responses( + (status = 200, description = "Converts auth & pkce codes to tokens.", body = TokenResponse) + ) +)] +pub(crate) async fn auth_code_to_token_handler( + State(state): State, + Json(request): Json, +) -> Result, AuthError> { + let authorization_code = match request.authorization_code { + Some(code) if code.len() == VERIFICATION_CODE_LENGTH => code, + _ => { + return Err(AuthError::BadRequest("invalid auth code")); + } + }; + + let pkce_code_challenge = request + .pkce_code_verifier + .as_ref() + .map(|verifier| derive_pkce_code_challenge(verifier)); + + lazy_static! { + static ref UPDATE_QUERY: String = format!( + r#" + UPDATE + '{USER_TABLE}' + SET + authorization_code = NULL, + authorization_code_sent_at = NULL, + pkce_code_challenge = NULL + WHERE + authorization_code = $1 + AND authorization_code_sent_at > (UNIXEPOCH() - {TTL_SEC}) + AND pkce_code_challenge = $2 + RETURNING * + "# + ); + } + + let db_user: DbUser = de::from_row( + &query_one_row( + state.user_conn(), + &UPDATE_QUERY, + params!(authorization_code, pkce_code_challenge), + ) + .await?, + ) + .map_err(|err| AuthError::Internal(err.into()))?; + + let (auth_token_ttl, _refresh_token_ttl) = state.access_config(|c| c.auth.token_ttls()); + let user_id = db_user.uuid(); + + let tokens = mint_new_tokens( + &state, + db_user.verified, + user_id, + db_user.email, + auth_token_ttl, + ) + .await?; + let auth_token = state + .jwt() + .encode(&tokens.auth_token_claims) + .map_err(|err| AuthError::Internal(err.into()))?; + + return Ok(Json(TokenResponse { + auth_token, + refresh_token: tokens.refresh_token, + csrf_token: tokens.auth_token_claims.csrf_token, + })); +} diff --git a/trailbase-core/src/auth/api/verify_email.rs b/trailbase-core/src/auth/api/verify_email.rs new file mode 100644 index 0000000..beac07e --- /dev/null +++ b/trailbase-core/src/auth/api/verify_email.rs @@ -0,0 +1,139 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Redirect, Response}, +}; +use lazy_static::lazy_static; +use libsql::params; +use serde::Deserialize; +use utoipa::{IntoParams, ToSchema}; + +use crate::app_state::AppState; +use crate::auth::util::{user_by_email, validate_redirects}; +use crate::auth::AuthError; +use crate::constants::{USER_TABLE, VERIFICATION_CODE_LENGTH}; +use crate::email::Email; +use crate::rand::generate_random_string; + +const TTL_SEC: i64 = 3600; +const RATE_LIMIT_SEC: i64 = 4 * 3600; + +#[derive(Debug, Default, Deserialize, ToSchema)] +pub struct EmailVerificationRequest { + pub email: String, +} + +/// Request a new email to verify email address. +#[utoipa::path( + get, + path = "/verify_email/trigger", + request_body = EmailVerificationRequest, + responses( + (status = 200, description = "Email verification sent.") + ) +)] +pub async fn request_email_verification_handler( + State(state): State, + Query(request): Query, +) -> Result { + let user = user_by_email(&state, &request.email).await?; + + if let Some(last_verification) = user.email_verification_code_sent_at { + let Some(timestamp) = chrono::DateTime::from_timestamp(last_verification, 0) else { + return Err(AuthError::Internal("Invalid timestamp".into())); + }; + + let age: chrono::Duration = chrono::Utc::now() - timestamp; + if age < chrono::Duration::seconds(RATE_LIMIT_SEC) { + return Err(AuthError::BadRequest("verification sent already")); + } + } + + let email_verification_code = generate_random_string(VERIFICATION_CODE_LENGTH); + lazy_static! { + pub static ref UPDATE_VERIFICATION_CODE_QUERY: String = format!( + r#" + UPDATE + '{USER_TABLE}' + SET + email_verification_code = $1, + email_verification_code_sent_at = UNIXEPOCH() + WHERE + id = $2 + "# + ); + } + + let rows_affected = state + .user_conn() + .execute( + &UPDATE_VERIFICATION_CODE_QUERY, + params!(email_verification_code.clone(), user.id), + ) + .await?; + + return match rows_affected { + 0 => Err(AuthError::Conflict), + 1 => { + let email = Email::verification_email(&state, &user, &email_verification_code) + .map_err(|err| AuthError::Internal(err.into()))?; + email + .send() + .await + .map_err(|err| AuthError::Internal(err.into()))?; + + Ok((StatusCode::OK, "Verification code sent").into_response()) + } + _ => { + panic!("Password reset affected multiple users: {rows_affected}"); + } + }; +} + +#[derive(Debug, Default, Deserialize, IntoParams)] +pub(crate) struct VerifyEmailQuery { + pub redirect_to: Option, +} + +/// Request a new email to verify email address. +#[utoipa::path( + get, + path = "/verify_email/confirm/:email_verification_code", + responses( + (status = 200, description = "Email verified.") + ) +)] +pub async fn verify_email_handler( + State(state): State, + Path(email_verification_code): Path, + Query(query): Query, +) -> Result { + let redirect = validate_redirects(&state, &query.redirect_to, &None)?; + + lazy_static! { + static ref UPDATE_CODE_QUERY: String = format!( + r#" + UPDATE '{USER_TABLE}' + SET + verified = TRUE, + email_verification_code = NULL, + email_verification_code_sent_at = NULL + WHERE + email_verification_code = $1 AND email_verification_code_sent_at > (UNIXEPOCH() - {TTL_SEC}) + "# + ); + } + + let rows_affected = state + .user_conn() + .execute(&UPDATE_CODE_QUERY, params!(email_verification_code)) + .await?; + + return match rows_affected { + 0 => Err(AuthError::BadRequest("Invalid verification code")), + 1 => Ok(Redirect::to( + redirect.as_deref().unwrap_or("/_/auth/profile/"), + )), + _ => panic!("email verification affected multiple users: {rows_affected}"), + }; +} diff --git a/trailbase-core/src/auth/auth_test.rs b/trailbase-core/src/auth/auth_test.rs new file mode 100644 index 0000000..0af8ea4 --- /dev/null +++ b/trailbase-core/src/auth/auth_test.rs @@ -0,0 +1,434 @@ +use axum::extract::{Form, Json, Path, Query, State}; +use libsql::{de, params}; +use std::sync::Arc; +use tower_cookies::Cookies; +use trailbase_sqlite::query_one_row; + +use crate::api::TokenClaims; +use crate::app_state::{test_state, TestStateOptions}; +use crate::auth::api::change_email; +use crate::auth::api::change_email::ChangeEmailConfigQuery; +use crate::auth::api::change_password::{ + change_password_handler, ChangePasswordQuery, ChangePasswordRequest, +}; +use crate::auth::api::delete::delete_handler; +use crate::auth::api::login::login_with_password; +use crate::auth::api::logout::{logout_handler, LogoutQuery}; +use crate::auth::api::refresh::{refresh_handler, RefreshRequest}; +use crate::auth::api::register::{register_user_handler, RegisterUserRequest}; +use crate::auth::api::reset_password::{ + reset_password_request_handler, reset_password_update_handler, ResetPasswordRequest, + ResetPasswordUpdateRequest, +}; +use crate::auth::api::verify_email::{verify_email_handler, VerifyEmailQuery}; +use crate::auth::user::{DbUser, User}; +use crate::constants::*; +use crate::email::{testing::TestAsyncSmtpTransport, Mailer}; +use crate::extract::Either; + +#[tokio::test] +async fn test_auth_registration_reset_and_change_email() { + let _ = env_logger::try_init_from_env( + env_logger::Env::new().default_filter_or("info,refinery_core=warn"), + ); + + let mailer = TestAsyncSmtpTransport::new(); + let state = test_state(Some(TestStateOptions { + mailer: Some(Mailer::Smtp(Arc::new(mailer.clone()))), + ..Default::default() + })) + .await + .unwrap(); + + let conn = state.user_conn(); + + let email = "user@test.org".to_string(); + let password = "secret123".to_string(); + let session_exists_query = + format!("SELECT EXISTS(SELECT 1 FROM '{SESSION_TABLE}' WHERE user = $1)"); + + let user = { + // Register new user and email verification flow. + let request = RegisterUserRequest { + email: email.clone(), + password: password.clone(), + password_repeat: password.clone(), + ..Default::default() + }; + + register_user_handler(State(state.clone()), Form(request)) + .await + .unwrap(); + + // Assert that a verification email was sent. + assert_eq!(mailer.get_logs().len(), 1); + + // Then steal the verification code from the DB and verify. + let email_verification_code = { + let db_user: DbUser = de::from_row( + &query_one_row( + conn, + &format!("SELECT * FROM '{USER_TABLE}' WHERE email = $1"), + [email.clone()], + ) + .await + .unwrap(), + ) + .unwrap(); + + db_user.email_verification_code.unwrap() + }; + + let verification_email_body: String = String::from_utf8_lossy( + "ed_printable::decode( + mailer.get_logs()[0].1.as_bytes(), + quoted_printable::ParseMode::Robust, + ) + .unwrap(), + ) + .to_string(); + assert!( + verification_email_body.contains(&email_verification_code), + "code: {email_verification_code}\nbody: {verification_email_body}" + ); + + // Check that log in pre-verification fails. + assert!(login_with_password(&state, &email, &password) + .await + .is_err()); + + let _ = verify_email_handler( + State(state.clone()), + Path(email_verification_code.clone()), + Query(VerifyEmailQuery::default()), + ) + .await + .unwrap(); + + let (verified, user) = { + let db_user: DbUser = de::from_row( + &query_one_row( + conn, + &format!("SELECT * FROM '{USER_TABLE}' WHERE email = $1"), + [email.clone()], + ) + .await + .unwrap(), + ) + .unwrap(); + + ( + db_user.verified.clone(), + User::from_unverified(db_user.uuid(), &db_user.email), + ) + }; + + // We should now be verified. + assert!(verified); + + // Verifying again should fail. + let response = verify_email_handler( + State(state.clone()), + Path(email_verification_code), + Query(VerifyEmailQuery::default()), + ) + .await; + assert!(response.is_err()); + + assert!(login_with_password(&state, &email, "Wrong Password") + .await + .is_err()); + + let tokens = login_with_password(&state, &email, &password) + .await + .unwrap(); + assert_eq!(tokens.id, user.uuid); + state + .jwt() + .decode::(&tokens.auth_token) + .unwrap(); + + let session_exists: bool = query_one_row( + conn, + &session_exists_query, + [user.uuid.into_bytes().to_vec()], + ) + .await + .unwrap() + .get(0) + .unwrap(); + assert!(session_exists); + + user + }; + + { + // Test refresh flow. + let tokens = login_with_password(&state, &email, &password) + .await + .unwrap(); + + let Json(refreshed_tokens) = refresh_handler( + State(state.clone()), + Json(RefreshRequest { + refresh_token: tokens.refresh_token, + }), + ) + .await + .unwrap(); + + let original_claims: TokenClaims = state.jwt().decode(&tokens.auth_token).unwrap(); + let refreshed_claims: TokenClaims = state.jwt().decode(&refreshed_tokens.auth_token).unwrap(); + + assert_eq!(original_claims.sub, refreshed_claims.sub); + // Make sure, they were actually re-minted. + assert_ne!(original_claims.csrf_token, refreshed_claims.csrf_token); + // NOTE: they're likely the same assuming they were minted most likely in the same second + // interval. + assert!(original_claims.iat <= refreshed_claims.iat); + assert!(original_claims.exp <= refreshed_claims.exp); + } + + let reset_password = "new_password!"; + { + // Reset (forgotten) password flow. + reset_password_request_handler( + State(state.clone()), + Either::Form(ResetPasswordRequest { + email: email.clone(), + }), + ) + .await + .unwrap(); + + // Assert that a password reset email was sent. + assert_eq!(mailer.get_logs().len(), 2); + + // Test rate limiting. + assert!(reset_password_request_handler( + State(state.clone()), + Either::Json(ResetPasswordRequest { + email: email.clone() + }), + ) + .await + .is_err()); + + assert_eq!(mailer.get_logs().len(), 2); + + // Steal the reset code. + let reset_code: String = query_one_row( + conn, + &format!("SELECT password_reset_code FROM '{USER_TABLE}' WHERE id = $1"), + [user.uuid.into_bytes().to_vec()], + ) + .await + .unwrap() + .get(0) + .unwrap(); + + let reset_email_body: String = String::from_utf8_lossy( + "ed_printable::decode( + mailer.get_logs()[1].1.as_bytes(), + quoted_printable::ParseMode::Robust, + ) + .unwrap(), + ) + .to_string(); + assert!( + reset_email_body.contains(&reset_code), + "code: {reset_code}\nbody: {reset_email_body}" + ); + + let new_password = reset_password.to_string(); + reset_password_update_handler( + State(state.clone()), + Path(reset_code.clone()), + Either::Form(ResetPasswordUpdateRequest { + password: new_password.clone(), + password_repeat: new_password.clone(), + }), + ) + .await + .unwrap(); + + { + assert!(login_with_password(&state, &email, &password) + .await + .is_err()); + + let tokens = login_with_password(&state, &email, &new_password) + .await + .unwrap(); + assert_eq!(tokens.id, user.uuid); + state + .jwt() + .decode::(&tokens.auth_token) + .unwrap(); + } + + let _logout_response = logout_handler( + State(state.clone()), + Query(LogoutQuery::default()), + Some(user.clone()), + Cookies::default(), + ) + .await + .unwrap(); + + let session_exists: bool = query_one_row( + conn, + &session_exists_query, + [user.uuid.into_bytes().to_vec()], + ) + .await + .unwrap() + .get(0) + .unwrap(); + assert!(!session_exists); + + let tokens = login_with_password(&state, &email, &new_password) + .await + .unwrap(); + assert_eq!(tokens.id, user.uuid); + state + .jwt() + .decode::(&tokens.auth_token) + .unwrap(); + } + + let new_email = "new_addresses@test.org".to_string(); + { + // Change Email flow. + + // Form requests require old email + assert!(change_email::change_email_request_handler( + State(state.clone()), + user.clone(), + Either::Form(change_email::ChangeEmailRequest { + csrf_token: user.csrf_token.clone(), + old_email: None, + new_email: new_email.clone(), + }), + ) + .await + .is_err()); + + change_email::change_email_request_handler( + State(state.clone()), + user.clone(), + Either::Form(change_email::ChangeEmailRequest { + csrf_token: user.csrf_token.clone(), + old_email: Some(email.clone()), + new_email: new_email.clone(), + }), + ) + .await + .unwrap(); + + // Assert that a change-email email was sent. + assert_eq!(mailer.get_logs().len(), 3); + + // Steal the verification code. + let email_verification_code: String = query_one_row( + conn, + &format!("SELECT email_verification_code FROM '{USER_TABLE}' WHERE id = $1"), + params!(user.uuid.into_bytes()), + ) + .await + .unwrap() + .get(0) + .unwrap(); + assert!(!email_verification_code.is_empty()); + + let verification_email_body: String = String::from_utf8_lossy( + "ed_printable::decode( + mailer.get_logs()[2].1.as_bytes(), + quoted_printable::ParseMode::Robust, + ) + .unwrap(), + ) + .to_string(); + assert!( + verification_email_body.contains(&email_verification_code), + "code: {email_verification_code}\nbody: {verification_email_body}" + ); + + let _ = change_email::change_email_confirm_handler( + State(state.clone()), + Path(email_verification_code.clone()), + Query(ChangeEmailConfigQuery { redirect_to: None }), + user.clone(), + ) + .await + .expect(&format!("CODE: '{email_verification_code}'")); + + let db_email: String = query_one_row( + conn, + &format!("SELECT email FROM '{USER_TABLE}' WHERE id = $1"), + params!(user.uuid.into_bytes()), + ) + .await + .unwrap() + .get(0) + .unwrap(); + + assert_eq!(new_email, db_email); + + assert!(login_with_password(&state, &email, &reset_password) + .await + .is_err()); + let _ = login_with_password(&state, &new_email, &reset_password) + .await + .unwrap(); + } + + { + // Change password flow. + let old_password = reset_password.to_string(); + let new_password = "new_secret123".to_string(); + + let _ = change_password_handler( + State(state.clone()), + Query(ChangePasswordQuery::default()), + user.clone(), + Either::Json(ChangePasswordRequest { + old_password: old_password.clone(), + new_password: new_password.clone(), + new_password_repeat: new_password.clone(), + }), + ) + .await + .unwrap(); + + assert!(login_with_password(&state, &new_email, &password) + .await + .is_err()); + assert!(login_with_password(&state, &new_email, &old_password) + .await + .is_err()); + + let _ = login_with_password(&state, &new_email, &new_password) + .await + .unwrap(); + } + + { + // Delete user flow. + delete_handler(State(state.clone()), user.clone(), Cookies::default()) + .await + .unwrap(); + + let user_exists: bool = query_one_row( + conn, + &format!("SELECT EXISTS(SELECT * FROM '{USER_TABLE}' WHERE id = $1)"), + params!(user.uuid.into_bytes()), + ) + .await + .unwrap() + .get(0) + .unwrap(); + + assert!(!user_exists); + } +} diff --git a/trailbase-core/src/auth/error.rs b/trailbase-core/src/auth/error.rs new file mode 100644 index 0000000..415e984 --- /dev/null +++ b/trailbase-core/src/auth/error.rs @@ -0,0 +1,127 @@ +use axum::body::Body; +use axum::http::{header::CONTENT_TYPE, StatusCode}; +use axum::response::{IntoResponse, Response}; +use log::*; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AuthError { + #[error("Unauthorized")] + Unauthorized, + #[error("Unauthorized")] + UnauthorizedExt(Box), + #[error("Forbidden")] + Forbidden, + #[error("Conflict")] + Conflict, + #[error("NotFound")] + NotFound, + #[error("OAuth provider not found")] + OAuthProviderNotFound, + #[error("Bad request: {0}")] + BadRequest(&'static str), + #[error("Failed dependency: {0}")] + FailedDependency(Box), + #[error("Internal: {0}")] + Internal(Box), +} + +impl From for AuthError { + fn from(err: libsql::Error) -> Self { + return match err { + libsql::Error::QueryReturnedNoRows => Self::NotFound, + // List of error codes: https://www.sqlite.org/rescode.html + libsql::Error::SqliteFailure(275, _msg) => Self::BadRequest("sqlite constraint: check"), + libsql::Error::SqliteFailure(531, _msg) => Self::BadRequest("sqlite constraint: commit hook"), + libsql::Error::SqliteFailure(3091, _msg) => Self::BadRequest("sqlite constraint: data type"), + libsql::Error::SqliteFailure(787, _msg) => Self::BadRequest("sqlite constraint: fk"), + libsql::Error::SqliteFailure(1043, _msg) => Self::BadRequest("sqlite constraint: function"), + libsql::Error::SqliteFailure(1299, _msg) => Self::BadRequest("sqlite constraint: not null"), + libsql::Error::SqliteFailure(2835, _msg) => Self::BadRequest("sqlite constraint: pinned"), + libsql::Error::SqliteFailure(1555, _msg) => Self::BadRequest("sqlite constraint: pk"), + libsql::Error::SqliteFailure(2579, _msg) => Self::BadRequest("sqlite constraint: row id"), + libsql::Error::SqliteFailure(1811, _msg) => Self::BadRequest("sqlite constraint: trigger"), + libsql::Error::SqliteFailure(2067, _msg) => Self::BadRequest("sqlite constraint: unique"), + libsql::Error::SqliteFailure(2323, _msg) => Self::BadRequest("sqlite constraint: vtab"), + err => Self::Internal(err.into()), + }; + } +} + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + let (status, body) = match self { + Self::Unauthorized => (StatusCode::UNAUTHORIZED, None), + Self::UnauthorizedExt(msg) if cfg!(debug_assertions) => { + (StatusCode::UNAUTHORIZED, Some(msg.to_string())) + } + Self::UnauthorizedExt(_msg) => (StatusCode::UNAUTHORIZED, None), + Self::Forbidden => (StatusCode::FORBIDDEN, None), + Self::Conflict => (StatusCode::CONFLICT, None), + Self::NotFound => (StatusCode::NOT_FOUND, None), + Self::OAuthProviderNotFound => (StatusCode::METHOD_NOT_ALLOWED, None), + Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, Some(msg.to_string())), + Self::FailedDependency(msg) => (StatusCode::FAILED_DEPENDENCY, Some(msg.to_string())), + Self::Internal(err) if cfg!(debug_assertions) => { + (StatusCode::INTERNAL_SERVER_ERROR, Some(err.to_string())) + } + Self::Internal(_err) => (StatusCode::INTERNAL_SERVER_ERROR, None), + }; + + if let Some(body) = body { + return Response::builder() + .status(status) + .header(CONTENT_TYPE, "text/plain") + .body(Body::new(body)) + .unwrap(); + } + + return Response::builder() + .status(status) + .body(Body::empty()) + .unwrap(); + } +} + +#[cfg(test)] +mod tests { + use axum::http::StatusCode; + use axum::response::IntoResponse; + + use crate::auth::AuthError; + + #[tokio::test] + async fn test_some_sqlite_errors_yield_client_errors() { + let conn = trailbase_sqlite::connect_sqlite(None, None).await.unwrap(); + + conn + .execute( + r#"CREATE TABLE test_table ( + id INTEGER PRIMARY KEY NOT NULL, + data TEXT + );"#, + (), + ) + .await + .unwrap(); + + conn + .execute("INSERT INTO test_table (id, data) VALUES (0, 'first');", ()) + .await + .unwrap(); + + let sqlite_err = conn + .execute( + "INSERT INTO test_table (id, data) VALUES (0, 'second');", + (), + ) + .await + .err() + .unwrap(); + + assert!(matches!(sqlite_err, libsql::Error::SqliteFailure(1555, _))); + + let err: AuthError = sqlite_err.into(); + assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST); + } +} diff --git a/trailbase-core/src/auth/jwt.rs b/trailbase-core/src/auth/jwt.rs new file mode 100644 index 0000000..08f3654 --- /dev/null +++ b/trailbase-core/src/auth/jwt.rs @@ -0,0 +1,203 @@ +use crate::rand::generate_random_string; +use crate::util::uuid_to_b64; +use ed25519_dalek::pkcs8::spki::der::pem::LineEnding; +use ed25519_dalek::pkcs8::{EncodePrivateKey, EncodePublicKey}; +use ed25519_dalek::{SigningKey, VerifyingKey}; +use jsonwebtoken::{errors::Error as JwtError, DecodingKey, EncodingKey, Header, Validation}; +use rand::rngs::OsRng; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use tokio::{ + fs, + io::{AsyncReadExt, AsyncWriteExt}, +}; + +use crate::data_dir::DataDir; + +#[derive(Debug, Error)] +pub enum JwtHelperError { + #[error("IO error: {0}")] + IO(#[from] std::io::Error), + #[error("Decoding error: {0}")] + Decode(#[from] jsonwebtoken::errors::Error), + #[error("PKCS8 error: {0}")] + PKCS8(#[from] ed25519_dalek::pkcs8::Error), + #[error("PKCS8 SPKI error: {0}")] + PKCS8Spki(#[from] ed25519_dalek::pkcs8::spki::Error), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TokenClaims { + /// Url-safe Base64 encoded id of the current user. + pub sub: String, + /// Unix timestamp in seconds when the token was minted. + pub iat: i64, + /// Expiration timestamp + pub exp: i64, + + /// E-mail address of the [sub]. + pub email: String, + + /// CSRF random token. Requiring that the client echos this random token back on a non-cookie, + /// non-auto-attach channel can be used to protect from CSRF. + pub csrf_token: String, +} + +impl TokenClaims { + pub fn new( + verified: bool, + user_id: uuid::Uuid, + email: String, + expires_in: chrono::Duration, + ) -> Self { + assert!(verified); + + let now = chrono::Utc::now(); + return TokenClaims { + sub: uuid_to_b64(&user_id), + exp: (now + expires_in).timestamp(), + iat: now.timestamp(), + email, + csrf_token: generate_random_string(20), + }; + } +} + +pub struct JwtHelper { + header: Header, + validation: Validation, + + // The private key used for minting new JWTs. + encoding_key: EncodingKey, + + // The public key used for validating provided JWTs. + decoding_key: DecodingKey, + public_key: Vec, +} + +impl JwtHelper { + pub fn new(private_key: Vec, public_key: Vec) -> Result { + return Ok(JwtHelper { + header: Header::new(jsonwebtoken::Algorithm::EdDSA), + validation: Validation::new(jsonwebtoken::Algorithm::EdDSA), + encoding_key: EncodingKey::from_ed_pem(&private_key)?, + decoding_key: DecodingKey::from_ed_pem(&public_key)?, + public_key, + }); + } + + pub async fn init_from_path(data_dir: &DataDir) -> Result { + let key_path = data_dir.key_path(); + + async fn open_key_files(key_path: &Path) -> std::io::Result<(fs::File, fs::File)> { + Ok(( + fs::File::open(key_path.join(PRIVATE_KEY_FILE)).await?, + fs::File::open(key_path.join(PUBLIC_KEY_FILE)).await?, + )) + } + + let (private_key, public_key) = match open_key_files(&key_path).await { + Ok((priv_key_file, pub_key_file)) => ( + read_file(priv_key_file).await?, + read_file(pub_key_file).await?, + ), + Err(err) => match err.kind() { + std::io::ErrorKind::NotFound => write_new_pem_keys(&key_path).await?, + _ => { + return Err(err.into()); + } + }, + }; + + return Self::new(private_key, public_key); + } + + pub fn public_key(&self) -> String { + String::from_utf8_lossy(&self.public_key).to_string() + } + + pub fn decode(&self, token: &str) -> Result { + // Note: we don't need to expose the token headers. + return jsonwebtoken::decode::(token, &self.decoding_key, &self.validation) + .map(|data| data.claims); + } + + pub fn encode(&self, claims: &T) -> Result { + return jsonwebtoken::encode::(&self.header, claims, &self.encoding_key); + } +} + +fn generate_new_key_pair() -> (SigningKey, VerifyingKey) { + let mut csprng = OsRng {}; + let signing_key = SigningKey::generate(&mut csprng); + let verifying_key = signing_key.verifying_key(); + + return (signing_key, verifying_key); +} + +async fn write_new_pem_keys(key_path: &Path) -> Result<(Vec, Vec), JwtHelperError> { + let (signing_key, verifying_key) = generate_new_key_pair(); + + let le = LineEnding::default(); + let priv_key = signing_key.to_pkcs8_pem(le)?.as_bytes().to_vec(); + let pub_key = verifying_key.to_public_key_pem(le)?.into_bytes(); + + write_new_file(key_path.join(PRIVATE_KEY_FILE), &priv_key).await?; + write_new_file(key_path.join(PUBLIC_KEY_FILE), &pub_key).await?; + + Ok((priv_key, pub_key)) +} + +async fn read_file(mut file: fs::File) -> std::io::Result> { + let mut buffer = vec![]; + file.read_to_end(&mut buffer).await?; + Ok(buffer) +} + +async fn write_new_file(path: PathBuf, bytes: &[u8]) -> std::io::Result<()> { + fs::File::create(&path).await?.write_all(bytes).await?; + Ok(()) +} + +#[cfg(test)] +pub(crate) fn test_jwt_helper() -> JwtHelper { + let (signing_key, verifying_key) = generate_new_key_pair(); + + let private_key = signing_key + .to_pkcs8_pem(LineEnding::default()) + .unwrap() + .as_bytes() + .to_vec(); + + let public_key = verifying_key + .to_public_key_pem(LineEnding::default()) + .unwrap() + .as_bytes() + .to_vec(); + + return JwtHelper::new(private_key, public_key).unwrap(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_encode() { + let jwt = test_jwt_helper(); + + let claims = TokenClaims::new( + true, + uuid::Uuid::now_v7(), + "foo@bar.com".to_string(), + crate::constants::DEFAULT_AUTH_TOKEN_TTL, + ); + let token = jwt.encode(&claims).unwrap(); + + assert_eq!(claims, jwt.decode(&token).unwrap()); + } +} + +const PRIVATE_KEY_FILE: &str = "private_key.pem"; +const PUBLIC_KEY_FILE: &str = "public_key.pem"; diff --git a/trailbase-core/src/auth/mod.rs b/trailbase-core/src/auth/mod.rs new file mode 100644 index 0000000..10c0f46 --- /dev/null +++ b/trailbase-core/src/auth/mod.rs @@ -0,0 +1,150 @@ +use axum::{ + routing::{delete, get, post}, + Router, +}; +use utoipa::OpenApi; + +pub mod jwt; +pub mod user; + +pub(crate) mod api; +pub(crate) mod oauth; +pub(crate) mod password; +pub(crate) mod tokens; +pub(crate) mod util; + +mod error; +mod ui; + +pub use api::reset_password::force_password_reset; +pub use error::AuthError; +pub use jwt::{JwtHelper, TokenClaims}; +pub(crate) use ui::auth_ui_router; +pub use user::User; + +#[derive(OpenApi)] +#[openapi( + paths( + api::login::login_handler, + api::login::login_status_handler, + api::token::auth_code_to_token_handler, + api::logout::logout_handler, + api::refresh::refresh_handler, + api::register::register_user_handler, + api::avatar::get_avatar_url_handler, + api::delete::delete_handler, + api::verify_email::verify_email_handler, + api::verify_email::request_email_verification_handler, + api::change_email::change_email_request_handler, + api::change_email::change_email_confirm_handler, + api::change_password::change_password_handler, + api::reset_password::reset_password_request_handler, + api::reset_password::reset_password_update_handler, + ), + components(schemas( + api::login::LoginRequest, + api::login::LoginResponse, + api::login::LoginStatusResponse, + api::token::TokenResponse, + api::token::AuthCodeToTokenRequest, + api::refresh::RefreshRequest, + api::refresh::RefreshResponse, + api::register::RegisterUserRequest, + api::verify_email::EmailVerificationRequest, + api::reset_password::ResetPasswordRequest, + api::reset_password::ResetPasswordUpdateRequest, + api::change_email::ChangeEmailRequest, + api::change_password::ChangePasswordRequest, + )) +)] +pub(super) struct AuthAPI; + +/// Router for auth API endpoints, i.e. api/auth/v?/... . +pub(super) fn router() -> Router { + // We support the following authentication flows: + // + // * unauthed: register, login, get-avatar-url + // * unauthed + rate limited: + // * reset-password + // * verify-email (+retrigger) + // * authed: + // * get-login-status (no CSRF, no side-effect) + // * refresh-token (no CSRF, safe side-effect) + // * logout (no CSRF, safe side-effect) + // * change-password (no CSRF: requires old pass), + // * change-email (TODO: CSRF: requires old email so only targeted), + // * delete-user (technically CSRF: however, currently DELETE method) + // + // Avatar life-cycle: read+update are handled as record APIs. + // + // TODO: We should have periodic task to: + // * expired auth, validate-email, reset-password codes. + // * vacuum expired pending registrations. + return Router::new() + // Sign-up new users. + .route("/register", post(api::register::register_user_handler)) + // E-mail verification and change flows. + .route( + "/verify_email/trigger", + get(api::verify_email::request_email_verification_handler), + ) + .route( + "/verify_email/confirm/:email_verification_code", + get(api::verify_email::verify_email_handler), + ) + .route( + "/change_email/request", + post(api::change_email::change_email_request_handler), + ) + .route( + "/change_email/confirm/:email_verification_code", + get(api::change_email::change_email_confirm_handler), + ) + // Password-reset flow. + .route( + "/reset_password/request", + post(api::reset_password::reset_password_request_handler), + ) + .route( + "/reset_password/update/:password_reset_code", + post(api::reset_password::reset_password_update_handler), + ) + // Change password flow. + .route( + "/change_password", + post(api::change_password::change_password_handler), + ) + // Token refresh flow. + .route("/refresh", post(api::refresh::refresh_handler)) + // Login + .route("/login", post(api::login::login_handler)) + // Converts auth code (+pkce code verifier) to auth tokens + .route("/token", post(api::token::auth_code_to_token_handler)) + // Login status (also let's one lift tokens from cookies). + .route("/status", get(api::login::login_status_handler)) + // Logout [get]: deletes all sessions for the current user. + .route("/logout", get(api::logout::logout_handler)) + // Logout [post]: deletes given session + .route("/logout", post(api::logout::post_logout_handler)) + // Get a user's avatar. + .route( + "/avatar/:b64_user_id", + get(api::avatar::get_avatar_url_handler), + ) + // User delete. + .route("/delete", delete(api::delete::delete_handler)) + // OAuth flows: list providers, login+callback + .nest("/oauth", oauth::oauth_router()); +} + +/// Replicating minimal functionality of the above main router in case the admin dash is routed +/// from a different port to prevent cross-origin requests. +pub(super) fn admin_auth_router() -> Router { + return Router::new() + .route("/login", post(api::login::login_handler)) + .route("/status", get(api::login::login_status_handler)) + .route("/logout", get(api::logout::logout_handler)); +} + +#[cfg(test)] +mod auth_test; diff --git a/trailbase-core/src/auth/oauth/callback.rs b/trailbase-core/src/auth/oauth/callback.rs new file mode 100644 index 0000000..98beb76 --- /dev/null +++ b/trailbase-core/src/auth/oauth/callback.rs @@ -0,0 +1,306 @@ +use axum::{ + extract::{Path, Query, State}, + response::Redirect, +}; +use chrono::Duration; +use lazy_static::lazy_static; +use libsql::{de, named_params, params, Connection}; +use oauth2::PkceCodeVerifier; +use oauth2::{AsyncHttpClient, HttpClientError, HttpRequest, HttpResponse}; +use oauth2::{AuthorizationCode, StandardTokenResponse, TokenResponse}; +use serde::Deserialize; +use thiserror::Error; +use tower_cookies::Cookies; +use trailbase_sqlite::query_one_row; + +use crate::auth::oauth::state::{OAuthState, ResponseType}; +use crate::auth::oauth::OAuthUser; +use crate::auth::tokens::{mint_new_tokens, FreshTokens}; +use crate::auth::user::DbUser; +use crate::auth::util::{new_cookie, remove_cookie, user_by_id, validate_redirects}; +use crate::auth::AuthError; +use crate::config::proto::OAuthProviderId; +use crate::constants::{ + COOKIE_AUTH_TOKEN, COOKIE_OAUTH_STATE, COOKIE_REFRESH_TOKEN, USER_TABLE, VERIFICATION_CODE_LENGTH, +}; +use crate::rand::generate_random_string; +use crate::AppState; + +#[derive(Debug, Deserialize)] +pub struct AuthRequest { + pub code: String, + pub state: String, +} + +#[derive(Debug, Error, Clone)] +enum WrappedReqwestError {} + +#[allow(unused)] +struct WrappedReqwest; + +impl<'c> AsyncHttpClient<'c> for WrappedReqwest { + type Error = HttpClientError; + + type Future = std::pin::Pin< + Box> + Send + Sync + 'c>, + >; + + fn call(&'c self, request: HttpRequest) -> Self::Future { + let http_client = reqwest::ClientBuilder::new() + // Following redirects opens the client up to SSRF vulnerabilities. + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + let req: reqwest::Request = request.try_into().unwrap(); + // debug!( + // "BODY {:?}", + // req + // .body() + // .and_then(|b| b.as_bytes().map(|b| String::from_utf8_lossy(b).to_string())) + // ); + + Box::pin(async move { + let response = http_client.execute(req).await.unwrap(); + + let mut builder = axum::response::Response::builder().status(response.status()); + + builder = builder.version(response.version()); + + for (name, value) in response.headers().iter() { + builder = builder.header(name, value); + } + + builder + .body(response.bytes().await.map_err(Box::new).unwrap().to_vec()) + .map_err(HttpClientError::Http) + }) + } +} + +// This handler receives the ?code=<>&state=<>, uses it to get an external oauth token, gets the +// user's information, creates a new local user if needed, and finally mints our own tokens. + +pub(crate) async fn callback_from_external_auth_provider( + State(state): State, + Path(provider): Path, + Query(query): Query, + cookies: Cookies, +) -> Result { + let Some(provider) = state.get_oauth_provider(&provider) else { + return Err(AuthError::OAuthProviderNotFound); + }; + + // Get round-tripped state from the users browser. + let Some(oauth_state) = cookies.get(COOKIE_OAUTH_STATE).and_then(|cookie| { + // The decoding can fail if the state was tampered with. + state.jwt().decode::(cookie.value()).ok() + }) else { + return Err(AuthError::BadRequest("missing state")); + }; + + let redirect = validate_redirects(&state, &oauth_state.redirect_to, &None)?; + + if oauth_state.csrf_secret != query.state { + return Err(AuthError::BadRequest("invalid state")); + } + + let http_client = reqwest::ClientBuilder::new() + // Following redirects opens the client up to SSRF vulnerabilities. + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|err| AuthError::Internal(err.into()))?; + + let client = provider.oauth_client(&state)?; + + // Exchange code for token. + let token_response: StandardTokenResponse<_, oauth2::basic::BasicTokenType> = client + .exchange_code(AuthorizationCode::new(query.code)) + .set_pkce_verifier(PkceCodeVerifier::new(oauth_state.pkce_code_verifier)) + .request_async(&http_client) + .await + .map_err(|err| AuthError::FailedDependency(err.into()))?; + + if *token_response.token_type() != oauth2::basic::BasicTokenType::Bearer { + return Err(AuthError::Internal( + format!("Unexpected token type: {:?}", token_response.token_type()).into(), + )); + } + + let oauth_user = provider + .get_user(token_response.access_token().secret().clone()) + .await?; + + if !oauth_user.verified { + return Err(AuthError::BadRequest("remote oauth user not verified")); + } + + let conn = state.user_conn(); + let existing_user = + user_by_provider_id(conn, oauth_user.provider_id, &oauth_user.provider_user_id) + .await + .ok(); + + let db_user = match existing_user { + Some(existing_user) => existing_user, + None => { + // NOTE: We could combine the INSERT + SELECT. + let id = create_user_for_external_provider(conn, &oauth_user).await?; + let db_user = user_by_id(&state, &id).await?; + assert!(db_user.verified); + + if !db_user.verified { + return Err(AuthError::Internal( + "user created from oauth should be verified".into(), + )); + } + + db_user + } + }; + + // Mint user token. + let (auth_token_ttl, refresh_token_ttl) = state.access_config(|c| c.auth.token_ttls()); + let expires_in = token_response.expires_in().map_or(auth_token_ttl, |exp| { + Duration::seconds(exp.as_secs() as i64) + }); + + let FreshTokens { + auth_token_claims, + refresh_token, + .. + } = mint_new_tokens( + &state, + db_user.verified, + db_user.uuid(), + db_user.email, + expires_in, + ) + .await?; + + let auth_token = state + .jwt() + .encode(&auth_token_claims) + .map_err(|err| AuthError::Internal(err.into()))?; + + cookies.add(new_cookie( + COOKIE_AUTH_TOKEN, + auth_token, + expires_in, + state.dev_mode(), + )); + cookies.add(new_cookie( + COOKIE_REFRESH_TOKEN, + refresh_token, + refresh_token_ttl, + state.dev_mode(), + )); + + remove_cookie(&cookies, COOKIE_OAUTH_STATE); + + if let Some(response_type) = oauth_state.response_type { + if response_type == ResponseType::Code { + if redirect.is_none() { + return Err(AuthError::BadRequest("missing 'redirect_to'")); + }; + + // For the auth_code flow we generate a random code. + let authorization_code = generate_random_string(VERIFICATION_CODE_LENGTH); + + lazy_static! { + pub static ref QUERY: String = format!( + r#" + UPDATE + '{USER_TABLE}' + SET + authorization_code = :authorization_code, + authorization_code_sent_at = UNIXEPOCH(), + pkce_code_challenge = :pkce_code_challenge + WHERE + id = :user_id + "# + ); + } + + let rows_affected = state + .user_conn() + .execute( + &QUERY, + named_params! { + ":authorization_code": authorization_code.clone(), + ":pkce_code_challenge": oauth_state.user_pkce_code_challenge, + ":user_id": db_user.id, + }, + ) + .await?; + + match rows_affected { + 0 => return Err(AuthError::BadRequest("invalid user")), + 1 => {} + _ => { + panic!("code challenge update affected multiple users: {rows_affected}"); + } + }; + } + } + + return Ok(Redirect::to(redirect.as_deref().unwrap_or_else(|| { + if state.public_dir().is_some() { + "/" + } else { + "/_/auth/profile" + } + }))); +} + +async fn create_user_for_external_provider( + conn: &Connection, + user: &OAuthUser, +) -> Result { + if !user.verified { + return Err(AuthError::Unauthorized); + } + + lazy_static! { + static ref QUERY: String = format!( + r#" + INSERT INTO {USER_TABLE} ( + provider_id, provider_user_id, verified, email, provider_avatar_url + ) VALUES ( + :provider_id, :provider_user_id, :verified, :email, :avatar + ) RETURNING id + "# + ); + } + + let row = query_one_row( + conn, + &QUERY, + named_params! { + ":provider_id": user.provider_id as i64, + ":provider_user_id": user.provider_user_id.clone(), + ":verified": user.verified as i64, + ":email": user.email.clone(), + ":avatar": user.avatar.clone(), + }, + ) + .await?; + + return Ok(uuid::Uuid::from_bytes(row.get::<[u8; 16]>(0)?)); +} + +async fn user_by_provider_id( + conn: &Connection, + provider_id: OAuthProviderId, + provider_user_id: &str, +) -> Result { + lazy_static! { + static ref QUERY: String = + format!("SELECT * FROM '{USER_TABLE}' WHERE provider_id = $1 AND provider_user_id = $2"); + }; + + return de::from_row( + &query_one_row(conn, &QUERY, params!(provider_id as i64, provider_user_id)).await?, + ) + .map_err(|err| AuthError::Internal(err.into())); +} diff --git a/trailbase-core/src/auth/oauth/list_providers.rs b/trailbase-core/src/auth/oauth/list_providers.rs new file mode 100644 index 0000000..fa59e12 --- /dev/null +++ b/trailbase-core/src/auth/oauth/list_providers.rs @@ -0,0 +1,24 @@ +use axum::extract::State; +use axum::Json; +use serde::Serialize; +use ts_rs::TS; + +use crate::auth::AuthError; +use crate::AppState; + +#[derive(Debug, Serialize, TS)] +#[ts(export)] +pub struct ConfiguredOAuthProvidersResponse { + pub providers: Vec<(String, String)>, +} + +// This handler receives the ?code=<>&state=<>, uses it to get an external oauth token, gets the +// user's information, creates a new local user if needed, and finally mints our own tokens. + +pub(crate) async fn list_configured_providers_handler( + State(app_state): State, +) -> Result, AuthError> { + let providers = app_state.get_oauth_providers(); + + return Ok(Json(ConfiguredOAuthProvidersResponse { providers })); +} diff --git a/trailbase-core/src/auth/oauth/login.rs b/trailbase-core/src/auth/oauth/login.rs new file mode 100644 index 0000000..3a9e57b --- /dev/null +++ b/trailbase-core/src/auth/oauth/login.rs @@ -0,0 +1,81 @@ +use axum::{ + extract::{Path, Query, State}, + response::Redirect, +}; +use chrono::Duration; +use oauth2::{CsrfToken, PkceCodeChallenge, Scope}; +use serde::Deserialize; +use tower_cookies::Cookies; +use utoipa::IntoParams; + +use crate::auth::oauth::state::{OAuthState, ResponseType}; +use crate::auth::util::{new_cookie_opts, validate_redirects}; +use crate::auth::AuthError; +use crate::constants::COOKIE_OAUTH_STATE; +use crate::AppState; + +#[derive(Debug, Default, Deserialize, IntoParams)] +pub(crate) struct LoginQuery { + pub redirect_to: Option, + pub response_type: Option, + pub pkce_code_challenge: Option, +} + +pub(crate) async fn login_with_external_auth_provider( + State(state): State, + Path(provider): Path, + Query(query): Query, + cookies: Cookies, +) -> Result { + let Some(provider) = state.get_oauth_provider(&provider) else { + return Err(AuthError::OAuthProviderNotFound); + }; + let redirect = validate_redirects(&state, &query.redirect_to, &None)?; + let code_response = query.response_type.map_or(false, |r| r == "code"); + + let client = provider.oauth_client(&state)?; + + let (pkce_code_challenge, pkce_code_verifier) = PkceCodeChallenge::new_random_sha256(); + + let (authorize_url, csrf_state) = client + .authorize_url(CsrfToken::new_random) + .add_scopes( + provider + .oauth_scopes() + .into_iter() + .map(|s| Scope::new(s.to_string())), + ) + .set_pkce_challenge(pkce_code_challenge) + .url(); + + // Set short-lived CSRF and PkceCodeVerifier cookies for the callback. + let oauth_state = OAuthState { + exp: (chrono::Utc::now() + chrono::Duration::seconds(5 * 60)).timestamp(), + csrf_secret: csrf_state.secret().to_string(), + pkce_code_verifier: pkce_code_verifier.secret().to_string(), + user_pkce_code_challenge: query.pkce_code_challenge, + response_type: if code_response { + Some(ResponseType::Code) + } else { + None + }, + redirect_to: redirect, + }; + + cookies.add(new_cookie_opts( + COOKIE_OAUTH_STATE, + // Encoding as JWT token for tamper proofing. This doesn't encrypt anything but merely adds a + // signature. None of the state handed to the user needs to be hidden from the user. + state + .jwt() + .encode(&oauth_state) + .map_err(|err| AuthError::Internal(err.into()))?, + Duration::minutes(5), + state.dev_mode(), + // We need to include cookies on redirect back from oauth provider. + /* same_site: */ + false, + )); + + Ok(Redirect::to(authorize_url.as_str())) +} diff --git a/trailbase-core/src/auth/oauth/mod.rs b/trailbase-core/src/auth/oauth/mod.rs new file mode 100644 index 0000000..184af59 --- /dev/null +++ b/trailbase-core/src/auth/oauth/mod.rs @@ -0,0 +1,33 @@ +pub(crate) mod provider; +pub(crate) mod providers; + +mod callback; +mod list_providers; +mod login; +mod state; + +#[cfg(test)] +mod oauth_test; + +use axum::routing::get; +use axum::Router; + +pub(crate) use provider::{OAuthClientSettings, OAuthProvider, OAuthUser}; + +use crate::AppState; + +pub fn oauth_router() -> Router { + Router::new() + .route( + "/providers", + get(list_providers::list_configured_providers_handler), + ) + .route( + "/:provider/login", + get(login::login_with_external_auth_provider), + ) + .route( + "/:provider/callback", + get(callback::callback_from_external_auth_provider), + ) +} diff --git a/trailbase-core/src/auth/oauth/oauth_test.rs b/trailbase-core/src/auth/oauth/oauth_test.rs new file mode 100644 index 0000000..08a4489 --- /dev/null +++ b/trailbase-core/src/auth/oauth/oauth_test.rs @@ -0,0 +1,195 @@ +use axum::extract::{Form, Json, Path, Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Redirect}; +use axum::routing::{get, post, Router}; +use axum_test::{TestServer, TestServerConfig}; +use serde::{Deserialize, Serialize}; +use tower_cookies::Cookies; + +use crate::api::query_one_row; +use crate::app_state::{test_state, TestStateOptions}; +use crate::auth::oauth::providers::test::{TestOAuthProvider, TestUser}; +use crate::auth::oauth::state::OAuthState; +use crate::auth::oauth::{callback, list_providers, login}; +use crate::auth::util::derive_pkce_code_challenge; +use crate::config::proto::{Config, OAuthProviderConfig, OAuthProviderId}; +use crate::constants::{AUTH_API_PATH, COOKIE_OAUTH_STATE, USER_TABLE}; + +fn unpack_redirect(redirect: Redirect) -> String { + let response = redirect.into_response(); + let headers = response.headers(); + return headers + .get("location") + .unwrap() + .to_str() + .unwrap() + .to_string(); +} + +#[derive(Debug, Deserialize, Serialize)] +struct AuthQuery { + response_type: String, + client_id: String, + state: String, + code_challenge: String, + code_challenge_method: String, + redirect_uri: String, + scope: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct TokenRequest { + grant_type: String, + code: String, + code_verifier: String, + redirect_uri: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct TokenResponse { + pub access_token: String, + pub token_type: String, + pub request: TokenRequest, +} + +#[tokio::test] +async fn test_oauth() { + let name = TestOAuthProvider::NAME.to_string(); + let external_user_id = "ExternalUserId"; + let external_user_email = "foo@bar.com"; + + let auth_path = "/auth"; + let token_path = "/token"; + let user_api_path = "/user"; + let app = Router::new() + .route( + auth_path, + get(|Query(query): Query| async { Json(query) }), + ) + .route( + token_path, + post(|Form(req): Form| async move { + Json(TokenResponse { + access_token: "opaque_token".to_string(), + token_type: "Bearer".to_string(), + request: req, + }) + }), + ) + .route( + user_api_path, + get(|| async { + Json(TestUser { + id: external_user_id.to_string(), + email: external_user_email.to_string(), + verified: true, + }) + }), + ); + + let server = TestServer::new_with_config( + app, + TestServerConfig { + transport: Some(axum_test::Transport::HttpRandomPort), + ..Default::default() + }, + ) + .unwrap(); + + let mut config = Config::new_with_custom_defaults(); + config.auth.oauth_providers.insert( + name.clone(), + OAuthProviderConfig { + client_id: Some("test_client_id".to_string()), + client_secret: Some("test_client_secret".to_string()), + provider_id: Some(OAuthProviderId::Custom as i32), + // TODO: Set it up to talk to a fake/mock server. + auth_url: Some(server.server_url(auth_path).unwrap().to_string()), + token_url: Some(server.server_url(token_path).unwrap().to_string()), + user_api_url: Some(server.server_url(user_api_path).unwrap().to_string()), + ..Default::default() + }, + ); + + let state = test_state(Some(TestStateOptions { + config: Some(config), + ..Default::default() + })) + .await + .unwrap(); + + let providers = state.get_oauth_providers(); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].0, TestOAuthProvider::NAME); + + let Json(response) = list_providers::list_configured_providers_handler(State(state.clone())) + .await + .unwrap(); + assert_eq!(response.providers.len(), 1); + assert_eq!(response.providers[0].0, TestOAuthProvider::NAME); + + let cookies = Cookies::default(); + // Redirect to auth provider for the user to log in on their site. + let external_redirect: Redirect = login::login_with_external_auth_provider( + State(state.clone()), + Path(name.clone()), + Query(login::LoginQuery { + redirect_to: None, + response_type: None, + pkce_code_challenge: None, + }), + cookies.clone(), + ) + .await + .unwrap(); + + let response = reqwest::get(&unpack_redirect(external_redirect)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let auth_query: AuthQuery = response.json().await.unwrap(); + + assert_eq!(auth_query.response_type, "code"); + assert_eq!(auth_query.client_id, "test_client_id"); + + let oauth_state: OAuthState = state + .jwt() + .decode(cookies.get(COOKIE_OAUTH_STATE).unwrap().value()) + .unwrap(); + + assert_eq!(auth_query.state, oauth_state.csrf_secret); + assert_eq!( + auth_query.redirect_uri, + format!("http://localhost:4000/{AUTH_API_PATH}/oauth/{name}/callback") + ); + assert_eq!( + auth_query.code_challenge, + derive_pkce_code_challenge(&oauth_state.pkce_code_verifier) + ); + + // Pretend to be the browser and call the callback handler. + let internal_redirect = callback::callback_from_external_auth_provider( + State(state.clone()), + Path(name.clone()), + Query(callback::AuthRequest { + state: auth_query.state.clone(), + code: auth_query.code_challenge.clone(), + }), + cookies.clone(), + ) + .await + .unwrap(); + + let location = unpack_redirect(internal_redirect); + assert_eq!(location, "/_/auth/profile"); + + let row = query_one_row( + state.user_conn(), + &format!("SELECT email FROM {USER_TABLE} WHERE provider_user_id = $1"), + [external_user_id], + ) + .await + .unwrap(); + + assert_eq!(row.get::(0).unwrap(), external_user_email); +} diff --git a/trailbase-core/src/auth/oauth/provider.rs b/trailbase-core/src/auth/oauth/provider.rs new file mode 100644 index 0000000..6ad07e2 --- /dev/null +++ b/trailbase-core/src/auth/oauth/provider.rs @@ -0,0 +1,100 @@ +use async_trait::async_trait; +use oauth2::basic::{ + BasicClient, BasicErrorResponse, BasicRevocationErrorResponse, BasicTokenIntrospectionResponse, + BasicTokenResponse, +}; +use oauth2::{ + AuthUrl, ClientId, ClientSecret, EndpointNotSet, EndpointSet, RedirectUrl, + StandardRevocableToken, TokenUrl, +}; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::app_state::AppState; +use crate::auth::AuthError; +use crate::config::proto::OAuthProviderId; +use crate::constants::AUTH_API_PATH; + +pub type OAuthClient< + HasAuthUrl = EndpointSet, + HasDeviceAuthUrl = EndpointNotSet, + HasIntrospectionUrl = EndpointNotSet, + HasRevocationUrl = EndpointNotSet, + HasTokenUrl = EndpointSet, +> = oauth2::Client< + BasicErrorResponse, + BasicTokenResponse, + BasicTokenIntrospectionResponse, + StandardRevocableToken, + BasicRevocationErrorResponse, + HasAuthUrl, + HasDeviceAuthUrl, + HasIntrospectionUrl, + HasRevocationUrl, + HasTokenUrl, +>; + +#[derive(Serialize, Deserialize, Debug)] +pub struct OAuthUser { + pub provider_user_id: String, + pub provider_id: OAuthProviderId, + + pub email: String, + pub verified: bool, + + pub avatar: Option, +} + +pub struct OAuthClientSettings { + pub auth_url: Url, + pub token_url: Url, + pub client_id: String, + pub client_secret: String, +} + +#[async_trait] +pub trait OAuthProvider { + #[allow(unused)] + fn provider(&self) -> OAuthProviderId; + + fn name(&self) -> &'static str; + + fn display_name(&self) -> &'static str { + self.name() + } + + fn settings(&self) -> Result; + + fn oauth_client(&self, state: &AppState) -> Result { + let redirect_url: Url = Url::parse(&format!( + "{site}/{AUTH_API_PATH}/oauth/{name}/callback", + site = state.site_url(), + name = self.name() + )) + .unwrap(); + + let settings = self.settings()?; + if settings.client_id.is_empty() { + return Err(AuthError::Internal( + format!("Missing client id for {}", self.name()).into(), + )); + } + if settings.client_secret.is_empty() { + return Err(AuthError::Internal( + format!("Missing client secret for {}", self.name()).into(), + )); + } + + let client = BasicClient::new(ClientId::new(settings.client_id)) + .set_client_secret(ClientSecret::new(settings.client_secret)) + .set_auth_uri(AuthUrl::from_url(settings.auth_url)) + .set_token_uri(TokenUrl::from_url(settings.token_url)) + .set_redirect_uri(RedirectUrl::from_url(redirect_url)); + + return Ok(client); + } + + fn oauth_scopes(&self) -> Vec<&'static str>; + + async fn get_user(&self, access_token: String) -> Result; +} diff --git a/trailbase-core/src/auth/oauth/providers/discord.rs b/trailbase-core/src/auth/oauth/providers/discord.rs new file mode 100644 index 0000000..cde89de --- /dev/null +++ b/trailbase-core/src/auth/oauth/providers/discord.rs @@ -0,0 +1,130 @@ +use async_trait::async_trait; +use lazy_static::lazy_static; +use serde::Deserialize; +use url::Url; + +use crate::auth::oauth::providers::{OAuthProviderError, OAuthProviderFactory}; +use crate::auth::oauth::{OAuthClientSettings, OAuthProvider, OAuthUser}; +use crate::auth::AuthError; +use crate::config::proto::{OAuthProviderConfig, OAuthProviderId}; + +pub(crate) struct DiscordOAuthProvider { + client_id: String, + client_secret: String, +} + +impl DiscordOAuthProvider { + const NAME: &'static str = "discord"; + const DISPLAY_NAME: &'static str = "Discord"; + + const AUTH_URL: &'static str = "https://discord.com/oauth2/authorize"; + const TOKEN_URL: &'static str = "https://discord.com/api/oauth2/token"; + const USER_API_URL: &'static str = "https://discord.com/api/users/@me"; + + fn new(config: &OAuthProviderConfig) -> Result { + let Some(client_id) = config.client_id.clone() else { + return Err(OAuthProviderError::Missing("Discord client id".to_string())); + }; + let Some(client_secret) = config.client_secret.clone() else { + return Err(OAuthProviderError::Missing( + "Discord client secret".to_string(), + )); + }; + + return Ok(DiscordOAuthProvider { + client_id, + client_secret, + }); + } + + pub fn factory() -> OAuthProviderFactory { + OAuthProviderFactory { + id: OAuthProviderId::Discord, + name: Self::NAME, + display_name: Self::DISPLAY_NAME, + factory: Box::new(|config: &OAuthProviderConfig| Ok(Box::new(Self::new(config)?))), + } + } +} + +#[async_trait] +impl OAuthProvider for DiscordOAuthProvider { + fn name(&self) -> &'static str { + Self::NAME + } + fn provider(&self) -> OAuthProviderId { + OAuthProviderId::Discord + } + fn display_name(&self) -> &'static str { + Self::DISPLAY_NAME + } + + fn settings(&self) -> Result { + lazy_static! { + static ref AUTH_URL: Url = Url::parse(DiscordOAuthProvider::AUTH_URL).unwrap(); + static ref TOKEN_URL: Url = Url::parse(DiscordOAuthProvider::TOKEN_URL).unwrap(); + } + + return Ok(OAuthClientSettings { + auth_url: AUTH_URL.clone(), + token_url: TOKEN_URL.clone(), + client_id: self.client_id.clone(), + client_secret: self.client_secret.clone(), + }); + } + + fn oauth_scopes(&self) -> Vec<&'static str> { + return vec!["identify", "email"]; + } + + async fn get_user(&self, access_token: String) -> Result { + let response = reqwest::Client::new() + .get(Self::USER_API_URL) + .bearer_auth(access_token) + .send() + .await + .map_err(|err| AuthError::FailedDependency(err.into()))?; + + // Checkout available fields on: https://discord.com/developers/docs/resources/user + #[derive(Default, Deserialize, Debug)] + struct DiscordUser { + id: String, + email: String, + verified: bool, + + // discriminator: Option, + // username: Option, + avatar: Option, + } + + let user = response + .json::() + .await + .map_err(|err| AuthError::FailedDependency(err.into()))?; + let verified = user.verified; + if !verified { + return Err(AuthError::Unauthorized); + } + + // let username = match (user.discriminator, user.username) { + // (Some(discriminator), Some(username)) => Some(format!("{username}#{discriminator}")), + // (None, Some(username)) => Some(username.to_string()), + // (Some(discriminator), None) => Some(discriminator.to_string()), + // (None, None) => None, + // }; + let avatar = user.avatar.map(|avatar| { + format!( + "https://cdn.discordapp.com/avatars/{id}/{avatar}.png", + id = user.id + ) + }); + + return Ok(OAuthUser { + provider_user_id: user.id, + provider_id: OAuthProviderId::Discord, + email: user.email, + verified: user.verified, + avatar, + }); + } +} diff --git a/trailbase-core/src/auth/oauth/providers/gitlab.rs b/trailbase-core/src/auth/oauth/providers/gitlab.rs new file mode 100644 index 0000000..e6f42dc --- /dev/null +++ b/trailbase-core/src/auth/oauth/providers/gitlab.rs @@ -0,0 +1,117 @@ +use async_trait::async_trait; +use lazy_static::lazy_static; +use serde::Deserialize; +use url::Url; + +use crate::auth::oauth::providers::{OAuthProviderError, OAuthProviderFactory}; +use crate::auth::oauth::{OAuthClientSettings, OAuthProvider, OAuthUser}; +use crate::auth::AuthError; +use crate::config::proto::{OAuthProviderConfig, OAuthProviderId}; + +pub(crate) struct GitlabOAuthProvider { + client_id: String, + client_secret: String, +} + +impl GitlabOAuthProvider { + const NAME: &'static str = "gitlab"; + const DISPLAY_NAME: &'static str = "GitLab"; + + const AUTH_URL: &'static str = "https://gitlab.com/oauth/authorize"; + const TOKEN_URL: &'static str = "https://gitlab.com/oauth/token"; + const USER_API_URL: &'static str = "https://gitlab.com/api/v4/user"; + + fn new(config: &OAuthProviderConfig) -> Result { + let Some(client_id) = config.client_id.clone() else { + return Err(OAuthProviderError::Missing("Discord client id".to_string())); + }; + let Some(client_secret) = config.client_secret.clone() else { + return Err(OAuthProviderError::Missing( + "Discord client secret".to_string(), + )); + }; + + return Ok(GitlabOAuthProvider { + client_id, + client_secret, + }); + } + + pub fn factory() -> OAuthProviderFactory { + OAuthProviderFactory { + id: OAuthProviderId::Gitlab, + name: GitlabOAuthProvider::NAME, + display_name: GitlabOAuthProvider::DISPLAY_NAME, + factory: Box::new(|config: &OAuthProviderConfig| { + Ok(Box::new(GitlabOAuthProvider::new(config)?)) + }), + } + } +} + +#[async_trait] +impl OAuthProvider for GitlabOAuthProvider { + fn name(&self) -> &'static str { + GitlabOAuthProvider::NAME + } + fn provider(&self) -> OAuthProviderId { + OAuthProviderId::Gitlab + } + fn display_name(&self) -> &'static str { + GitlabOAuthProvider::DISPLAY_NAME + } + + fn settings(&self) -> Result { + lazy_static! { + static ref AUTH_URL: Url = Url::parse(GitlabOAuthProvider::AUTH_URL).unwrap(); + static ref TOKEN_URL: Url = Url::parse(GitlabOAuthProvider::TOKEN_URL).unwrap(); + } + + return Ok(OAuthClientSettings { + auth_url: AUTH_URL.clone(), + token_url: TOKEN_URL.clone(), + client_id: self.client_id.clone(), + client_secret: self.client_secret.clone(), + }); + } + + fn oauth_scopes(&self) -> Vec<&'static str> { + return vec!["identify", "email"]; + } + + async fn get_user(&self, access_token: String) -> Result { + let response = reqwest::Client::new() + .get(GitlabOAuthProvider::USER_API_URL) + .bearer_auth(access_token) + .send() + .await + .map_err(|err| AuthError::FailedDependency(err.into()))?; + + // https://docs.gitlab.com/ee/api/users.html#for-user + #[derive(Default, Deserialize, Debug)] + struct GitlabUser { + id: i64, + // name: String, + // username: String, + email: String, + avatar_url: Option, + active: bool, + } + + let user = response + .json::() + .await + .map_err(|err| AuthError::FailedDependency(err.into()))?; + if !user.active { + return Err(AuthError::Unauthorized); + } + + return Ok(OAuthUser { + provider_user_id: user.id.to_string(), + provider_id: OAuthProviderId::Gitlab, + email: user.email, + verified: user.active, + avatar: user.avatar_url, + }); + } +} diff --git a/trailbase-core/src/auth/oauth/providers/google.rs b/trailbase-core/src/auth/oauth/providers/google.rs new file mode 100644 index 0000000..228270d --- /dev/null +++ b/trailbase-core/src/auth/oauth/providers/google.rs @@ -0,0 +1,116 @@ +use async_trait::async_trait; +use lazy_static::lazy_static; +use serde::Deserialize; +use url::Url; + +use crate::auth::oauth::providers::{OAuthProviderError, OAuthProviderFactory}; +use crate::auth::oauth::{OAuthClientSettings, OAuthProvider, OAuthUser}; +use crate::auth::AuthError; +use crate::config::proto::{OAuthProviderConfig, OAuthProviderId}; + +pub(crate) struct GoogleOAuthProvider { + client_id: String, + client_secret: String, +} + +impl GoogleOAuthProvider { + const NAME: &'static str = "google"; + const DISPLAY_NAME: &'static str = "google"; + + const AUTH_URL: &'static str = "https://accounts.google.com/o/oauth2/auth"; + const TOKEN_URL: &'static str = "https://accounts.google.com/o/oauth2/token"; + const USER_API_URL: &'static str = "https://www.googleapis.com/oauth2/v1/userinfo"; + + fn new(config: &OAuthProviderConfig) -> Result { + let Some(client_id) = config.client_id.clone() else { + return Err(OAuthProviderError::Missing("Google client id".to_string())); + }; + let Some(client_secret) = config.client_secret.clone() else { + return Err(OAuthProviderError::Missing( + "Google client secret".to_string(), + )); + }; + + return Ok(GoogleOAuthProvider { + client_id, + client_secret, + }); + } + + pub fn factory() -> OAuthProviderFactory { + OAuthProviderFactory { + id: OAuthProviderId::Google, + name: Self::NAME, + display_name: Self::DISPLAY_NAME, + factory: Box::new(|config: &OAuthProviderConfig| Ok(Box::new(Self::new(config)?))), + } + } +} + +#[async_trait] +impl OAuthProvider for GoogleOAuthProvider { + fn name(&self) -> &'static str { + Self::NAME + } + fn provider(&self) -> OAuthProviderId { + OAuthProviderId::Google + } + fn display_name(&self) -> &'static str { + Self::DISPLAY_NAME + } + + fn settings(&self) -> Result { + lazy_static! { + static ref AUTH_URL: Url = Url::parse(GoogleOAuthProvider::AUTH_URL).unwrap(); + static ref TOKEN_URL: Url = Url::parse(GoogleOAuthProvider::TOKEN_URL).unwrap(); + } + + return Ok(OAuthClientSettings { + auth_url: AUTH_URL.clone(), + token_url: TOKEN_URL.clone(), + client_id: self.client_id.clone(), + client_secret: self.client_secret.clone(), + }); + } + + fn oauth_scopes(&self) -> Vec<&'static str> { + return vec![ + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/userinfo.email", + ]; + } + + async fn get_user(&self, access_token: String) -> Result { + let response = reqwest::Client::new() + .get(Self::USER_API_URL) + .bearer_auth(access_token) + .send() + .await + .map_err(|err| AuthError::FailedDependency(err.into()))?; + + #[derive(Default, Deserialize, Debug)] + struct GoogleUser { + id: String, + // name: Option, + email: String, + verified_email: bool, + picture: Option, + } + + let user = response + .json::() + .await + .map_err(|err| AuthError::FailedDependency(err.into()))?; + if !user.verified_email { + return Err(AuthError::Unauthorized); + } + + return Ok(OAuthUser { + provider_user_id: user.id, + provider_id: OAuthProviderId::Google, + email: user.email, + verified: user.verified_email, + avatar: user.picture, + }); + } +} diff --git a/trailbase-core/src/auth/oauth/providers/mod.rs b/trailbase-core/src/auth/oauth/providers/mod.rs new file mode 100644 index 0000000..2b523c5 --- /dev/null +++ b/trailbase-core/src/auth/oauth/providers/mod.rs @@ -0,0 +1,102 @@ +mod discord; +mod gitlab; +mod google; + +#[cfg(test)] +pub(crate) mod test; + +use lazy_static::lazy_static; +use log::*; +use serde::Serialize; +use std::collections::hash_map::HashMap; +use std::sync::Arc; +use thiserror::Error; + +use crate::auth::oauth::OAuthProvider; +use crate::config::proto::{AuthConfig, OAuthProviderConfig, OAuthProviderId}; + +#[derive(Debug, Error)] +pub enum OAuthProviderError { + #[error("Missing error: {0}")] + Missing(String), +} + +pub type OAuthProviderType = Box; +type OAuthFactoryType = + dyn Fn(&OAuthProviderConfig) -> Result + Send + Sync; + +pub(crate) struct OAuthProviderFactory { + pub id: OAuthProviderId, + pub name: &'static str, + pub display_name: &'static str, + pub factory: Box, +} + +#[derive(Debug, Serialize, ts_rs::TS)] +pub struct OAuthProviderEntry { + pub id: i32, + pub name: String, + pub display_name: String, +} + +impl From<&OAuthProviderFactory> for OAuthProviderEntry { + fn from(val: &OAuthProviderFactory) -> Self { + OAuthProviderEntry { + id: val.id as i32, + name: val.name.to_string(), + display_name: val.display_name.to_string(), + } + } +} + +lazy_static! { + pub(crate) static ref oauth_provider_registry: Vec = vec![ + discord::DiscordOAuthProvider::factory(), + gitlab::GitlabOAuthProvider::factory(), + google::GoogleOAuthProvider::factory(), + #[cfg(test)] + test::TestOAuthProvider::factory(), + ]; +} + +#[derive(Default)] +pub struct ConfiguredOAuthProviders { + providers: HashMap>, +} + +impl ConfiguredOAuthProviders { + pub fn from_config(config: AuthConfig) -> Result { + let mut providers = HashMap::>::new(); + + for (key, config) in config.oauth_providers { + let entry = oauth_provider_registry + .iter() + .find(|registered| config.provider_id == Some(registered.id as i32)); + + let Some(entry) = entry else { + return Err(OAuthProviderError::Missing(format!( + "Missing implementation for oauth provider: {key}" + ))); + }; + + providers.insert(entry.name.to_string(), (entry.factory)(&config)?.into()); + } + + return Ok(ConfiguredOAuthProviders { providers }); + } + + pub fn lookup(&self, name: &str) -> Option<&Arc> { + if let Some(entry) = self.providers.get(name) { + return Some(entry); + } + return None; + } + + pub fn list(&self) -> Vec<(&'static str, &'static str)> { + return self + .providers + .values() + .map(|p| (p.name(), p.display_name())) + .collect(); + } +} diff --git a/trailbase-core/src/auth/oauth/providers/test.rs b/trailbase-core/src/auth/oauth/providers/test.rs new file mode 100644 index 0000000..d60e465 --- /dev/null +++ b/trailbase-core/src/auth/oauth/providers/test.rs @@ -0,0 +1,95 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::auth::oauth::providers::OAuthProviderFactory; +use crate::auth::oauth::{OAuthClientSettings, OAuthProvider, OAuthUser}; +use crate::auth::AuthError; +use crate::config::proto::{OAuthProviderConfig, OAuthProviderId}; + +// TODO: Add name/display name and this would already be a generic CustomOAuthProvider. +pub struct TestOAuthProvider { + client_id: String, + client_secret: String, + + auth_url: String, + token_url: String, + user_api_url: String, +} + +impl TestOAuthProvider { + pub const NAME: &'static str = "test"; + pub const DISPLAY_NAME: &'static str = "Test OAuth"; + + pub fn factory() -> OAuthProviderFactory { + OAuthProviderFactory { + id: OAuthProviderId::Custom, + name: Self::NAME, + display_name: Self::DISPLAY_NAME, + factory: Box::new(|config: &OAuthProviderConfig| { + Ok(Box::new(TestOAuthProvider { + client_id: config.client_id.clone().unwrap(), + client_secret: config.client_secret.clone().unwrap(), + auth_url: config.auth_url.clone().unwrap_or("not set".to_string()), + token_url: config.token_url.clone().unwrap_or("not set".to_string()), + user_api_url: config.user_api_url.clone().unwrap_or("not set".to_string()), + })) + }), + } + } +} + +#[derive(Default, Debug, Deserialize, Serialize)] +pub struct TestUser { + pub id: String, + pub email: String, + pub verified: bool, +} + +#[async_trait] +impl OAuthProvider for TestOAuthProvider { + fn name(&self) -> &'static str { + Self::NAME + } + fn provider(&self) -> OAuthProviderId { + OAuthProviderId::Custom + } + fn display_name(&self) -> &'static str { + Self::DISPLAY_NAME + } + + fn settings(&self) -> Result { + return Ok(OAuthClientSettings { + auth_url: Url::parse(&self.auth_url).unwrap(), + token_url: Url::parse(&self.token_url).unwrap(), + client_id: self.client_id.clone(), + client_secret: self.client_secret.clone(), + }); + } + + fn oauth_scopes(&self) -> Vec<&'static str> { + return vec!["identity", "email", "preferences"]; + } + + async fn get_user(&self, access_token: String) -> Result { + let response = reqwest::Client::new() + .get(&self.user_api_url) + .bearer_auth(access_token) + .send() + .await + .map_err(|err| AuthError::FailedDependency(err.into()))?; + + let user = response + .json::() + .await + .map_err(|err| AuthError::FailedDependency(err.into()))?; + + return Ok(OAuthUser { + provider_user_id: user.id, + provider_id: OAuthProviderId::Custom, + email: user.email, + verified: user.verified, + avatar: None, + }); + } +} diff --git a/trailbase-core/src/auth/oauth/state.rs b/trailbase-core/src/auth/oauth/state.rs new file mode 100644 index 0000000..e7036ca --- /dev/null +++ b/trailbase-core/src/auth/oauth/state.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub(crate) enum ResponseType { + Code, +} + +/// State that will be round-tripped from login -> remote oauth -> callback via the user's cookies. +/// +/// NOTE: Consider encrypting the state to make it tamper proof. +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct OAuthState { + /// Expiration timestamp. Required for JWT. + pub exp: i64, + + /// OAuth CSRF protection. Needs to callback request. + #[serde(alias = "secret")] + pub csrf_secret: String, + + /// Server-side generated PKCE code verifier. + /// + /// The challenge is handed to the OAuth provider, so that the callback + /// handler can send "auth-code+verifier" in return for an OAuth token. + #[serde(alias = "verifier")] + pub pkce_code_verifier: String, + + /// User-provided PKCE code challenge. + /// + /// The challenge is handed to us by the user. The verifier only lives on the + /// client and is handed to us later on. Importantly, this challenge is + /// completely independent from the verifier above. + #[serde(alias = "challenge")] + pub user_pkce_code_challenge: Option, + + /// If response type is "code", TrailBase will respond with an auth code rather than a token. + /// + /// user can subsequently convert the code with the PKCE verifier to an auth token using the + /// token endpoint. + #[serde(alias = "type")] + pub response_type: Option, + + /// Redirect target. + pub redirect_to: Option, +} diff --git a/trailbase-core/src/auth/password.rs b/trailbase-core/src/auth/password.rs new file mode 100644 index 0000000..318bf44 --- /dev/null +++ b/trailbase-core/src/auth/password.rs @@ -0,0 +1,51 @@ +use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; +use rand::rngs::OsRng; + +use crate::auth::AuthError; + +pub struct PasswordOptions { + pub min_length: usize, + pub max_length: usize, +} + +impl PasswordOptions { + pub const fn default() -> Self { + PasswordOptions { + min_length: 8, + max_length: 128, + } + } +} + +pub fn validate_passwords( + password: &str, + password_repeat: &str, + opts: &PasswordOptions, +) -> Result<(), AuthError> { + if password != password_repeat { + return Err(AuthError::BadRequest("Passwords don't match")); + } + + if password.len() < opts.min_length { + return Err(AuthError::BadRequest("Password too short")); + } + + if password.len() > opts.max_length { + return Err(AuthError::BadRequest("Password too long")); + } + + return Ok(()); +} + +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + return Ok( + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|err| { + // NOTE: Wrapping needed since Argon's error doesn't implement the error trait. + AuthError::Internal(err.to_string().into()) + })? + .to_string(), + ); +} diff --git a/trailbase-core/src/auth/tokens.rs b/trailbase-core/src/auth/tokens.rs new file mode 100644 index 0000000..08fd295 --- /dev/null +++ b/trailbase-core/src/auth/tokens.rs @@ -0,0 +1,232 @@ +use axum::{ + async_trait, + extract::{FromRef, FromRequestParts}, + http::{header, request::Parts}, +}; +use chrono::Duration; +use lazy_static::lazy_static; +use libsql::{de, params}; +use tower_cookies::Cookies; +use trailbase_sqlite::query_row; + +use crate::app_state::AppState; +use crate::auth::jwt::TokenClaims; +use crate::auth::user::DbUser; +use crate::auth::util::{extract_cookies_from_parts, new_cookie}; +use crate::auth::AuthError; +use crate::constants::{ + COOKIE_AUTH_TOKEN, COOKIE_REFRESH_TOKEN, HEADER_REFRESH_TOKEN, REFRESH_TOKEN_LENGTH, + SESSION_TABLE, USER_TABLE, +}; +use crate::rand::generate_random_string; + +#[derive(Clone)] +pub(crate) struct Tokens { + pub auth_token_claims: TokenClaims, + pub refresh_token: Option, +} + +#[async_trait] +impl FromRequestParts for Tokens +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = AuthError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let state = AppState::from_ref(state); + + if let Ok(tokens) = extract_tokens_from_headers(&state, &parts.headers).await { + return Ok(tokens); + } + + let cookies = extract_cookies_from_parts(parts)?; + return extract_tokens_from_cookies(&state, &cookies).await; + } +} + +async fn extract_tokens_from_headers( + state: &AppState, + headers: &header::HeaderMap, +) -> Result { + let Some(auth_token) = headers.get(header::AUTHORIZATION).and_then(|value| { + if let Ok(value) = value.to_str() { + return value.strip_prefix("Bearer "); + } + None + }) else { + return Err(AuthError::Unauthorized); + }; + + let refresh_token = headers + .get(HEADER_REFRESH_TOKEN) + .and_then(|value| value.to_str().ok().map(|s| s.to_string())); + + if let Ok(claims) = state.jwt().decode(auth_token) { + return Ok(Tokens { + auth_token_claims: claims, + refresh_token, + }); + } + + return Err(AuthError::Unauthorized); +} + +async fn extract_tokens_from_cookies( + state: &AppState, + cookies: &Cookies, +) -> Result { + let auth_token = cookies + .get(COOKIE_AUTH_TOKEN) + .map(|cookie| cookie.value().to_string()); + + let refresh_token = cookies + .get(COOKIE_REFRESH_TOKEN) + .map(|cookie| cookie.value().to_string()); + + if let Some(refresh_token) = refresh_token { + if let Some(auth_token) = auth_token { + if let Ok(claims) = state.jwt().decode(&auth_token) { + return Ok(Tokens { + auth_token_claims: claims, + refresh_token: Some(refresh_token), + }); + } + } + + // Try to auto-refresh in the cookie-case only (otherwise we don't have a back channel. If were + // to rely on a client lib to pick it from the respones headers we might as well give the + // client the responsibility to explicitly refresh). + let (auth_token_ttl, refresh_token_ttl) = state.access_config(|c| c.auth.token_ttls()); + let claims = reauth_with_refresh_token( + state, + refresh_token.clone(), + refresh_token_ttl, + auth_token_ttl, + ) + .await?; + + let new_token = state + .jwt() + .encode(&claims) + .map_err(|err| AuthError::Internal(err.into()))?; + + cookies.add(new_cookie( + COOKIE_AUTH_TOKEN, + new_token, + auth_token_ttl, + state.dev_mode(), + )); + + return Ok(Tokens { + auth_token_claims: claims, + refresh_token: Some(refresh_token), + }); + } else if let Some(auth_token) = auth_token { + if let Ok(claims) = state.jwt().decode(&auth_token) { + return Ok(Tokens { + auth_token_claims: claims, + refresh_token, + }); + } + } + + return Err(AuthError::Unauthorized); +} + +/// Only difference to Tokens above, refresh token presence is guaranteed. +pub struct FreshTokens { + pub auth_token_claims: TokenClaims, + pub refresh_token: String, +} + +pub(crate) async fn mint_new_tokens( + state: &AppState, + verified: bool, + user_id: uuid::Uuid, + user_email: String, + expires_in: Duration, +) -> Result { + assert!(verified); + if !verified { + return Err(AuthError::Internal( + "Cannot mint tokens for unverified user".into(), + )); + } + + let claims = TokenClaims::new(verified, user_id, user_email, expires_in); + + // Unlike JWT auth tokens, refresh tokens are opaque. + let refresh_token = generate_random_string(REFRESH_TOKEN_LENGTH); + lazy_static! { + static ref QUERY: String = + format!("INSERT INTO '{SESSION_TABLE}' (user, refresh_token) VALUES ($1, $2)"); + } + + state + .user_conn() + .execute( + &QUERY, + params!(user_id.into_bytes(), refresh_token.clone(),), + ) + .await?; + + return Ok(FreshTokens { + auth_token_claims: claims, + refresh_token, + }); +} + +pub(crate) async fn reauth_with_refresh_token( + state: &AppState, + refresh_token: String, + refresh_token_ttl: Duration, + auth_token_ttl: Duration, +) -> Result { + lazy_static! { + static ref QUERY: String = format!( + r#" + SELECT user.* + FROM + {SESSION_TABLE} AS s + INNER JOIN {USER_TABLE} AS user ON s.user = user.id + WHERE + s.refresh_token = $1 AND s.updated > (UNIXEPOCH() - $2) AND user.verified + "# + ); + } + + let Some(row) = query_row( + state.user_conn(), + &QUERY, + params!(refresh_token, refresh_token_ttl.num_seconds()), + ) + .await + .map_err(|err| AuthError::Internal(err.into()))? + else { + // Row not found case, typically expected in one of 4 cases: + // 1. Above where clause doesn't match, e.g. refresh token expired. + // 2. Token was actively deleted and thus revoked. + // 3. User explicitly logged out, which will delete **all** sessions for that user. + // 4. Database was overwritten, e.g. by tests or periodic reset for the demo. + #[cfg(debug_assertions)] + log::debug!("Refresh token not found"); + + return Err(AuthError::Unauthorized); + }; + + let db_user: DbUser = de::from_row(&row).map_err(|err| AuthError::Internal(err.into()))?; + + assert!( + db_user.verified, + "unverified user, should have been caught by above query" + ); + + return Ok(TokenClaims::new( + db_user.verified, + db_user.uuid(), + db_user.email, + auth_token_ttl, + )); +} diff --git a/trailbase-core/src/auth/ui/mod.rs b/trailbase-core/src/auth/ui/mod.rs new file mode 100644 index 0000000..2640902 --- /dev/null +++ b/trailbase-core/src/auth/ui/mod.rs @@ -0,0 +1,261 @@ +use axum::extract::Query; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use axum::routing::get; +use axum::Router; +use lazy_static::lazy_static; +use minijinja::{context, Environment}; +use reqwest::StatusCode; +use rust_embed::RustEmbed; +use serde::Deserialize; + +use crate::assets::{cow_to_string, AssetService}; +use crate::auth::User; + +fn build_env() -> Environment<'static> { + fn get(fname: &str) -> String { + let file = AuthAssets::get(fname).unwrap(); + cow_to_string(file.data) + } + + lazy_static! { + static ref login_template: String = get("login/index.html"); + static ref register_template: String = get("register/index.html"); + static ref reset_password_request_template: String = get("reset_password/request/index.html"); + static ref reset_password_update_template: String = get("reset_password/update/index.html"); + static ref change_password_template: String = get("change_password/index.html"); + static ref change_email_template: String = get("change_email/index.html"); + } + + let mut env = Environment::new(); + + env.add_template("login", &login_template).unwrap(); + env.add_template("register", ®ister_template).unwrap(); + env + .add_template("reset_password_request", &reset_password_request_template) + .unwrap(); + env + .add_template("reset_password_update", &reset_password_update_template) + .unwrap(); + env + .add_template("change_password", &change_password_template) + .unwrap(); + env + .add_template("change_email", &change_email_template) + .unwrap(); + + return env; +} + +fn templates() -> &'static Environment<'static> { + lazy_static! { + static ref env: Environment<'static> = build_env(); + } + + return &env; +} + +#[derive(Debug, Default, Deserialize)] +pub struct LoginQuery { + redirect_to: Option, + response_type: Option, + pkce_code_challenge: Option, + alert: Option, +} + +async fn ui_login_handler(Query(query): Query) -> Response { + let form_state = indoc::formatdoc!( + r#" + {redirect_to} + {response_type} + {pkce_code_challenge} + "#, + redirect_to = hidden_input("redirect_to", query.redirect_to.as_ref()), + response_type = hidden_input("response_type", query.response_type.as_ref()), + pkce_code_challenge = hidden_input("pkce_code_challenge", query.pkce_code_challenge.as_ref()), + ); + + let ctx = context! { + alert => query.alert.as_deref().unwrap_or(""), + state => form_state, + }; + + return match templates().get_template("login").unwrap().render(ctx) { + Ok(output) => Html(output).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to render template: {err}"), + ) + .into_response(), + }; +} + +#[derive(Debug, Default, Deserialize)] +pub struct RegisterQuery { + redirect_to: Option, + alert: Option, +} + +async fn ui_register_handler(Query(query): Query) -> Response { + return match templates().get_template("register").unwrap().render(context! { + alert => query.alert.as_deref().unwrap_or(""), + state => query.redirect_to.map(|r| format!("")), + }) { + Ok(output) => Html(output).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to render template: {err}"), + ) + .into_response(), + }; +} + +#[derive(Debug, Default, Deserialize)] +pub struct ResetPasswordRequestQuery { + redirect_to: Option, + alert: Option, +} + +async fn ui_reset_password_request_handler( + Query(query): Query, +) -> Response { + return match templates() + .get_template("reset_password_request") + .unwrap() + .render(context! { + alert => query.alert.as_deref().unwrap_or(""), + state => query.redirect_to.map(|r| format!("")), + }) { + Ok(output) => Html(output).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to render template: {err}"), + ) + .into_response(), + }; +} + +#[derive(Debug, Default, Deserialize)] +pub struct ResetPasswordUpdateQuery { + redirect_to: Option, + alert: Option, +} + +async fn ui_reset_password_update_handler( + Query(query): Query, +) -> Response { + return match templates() + .get_template("reset_password_update") + .unwrap() + .render(context! { + alert => query.alert.as_deref().unwrap_or(""), + state => query.redirect_to.map(|r| format!("")), + }) { + Ok(output) => Html(output).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to render template: {err}"), + ) + .into_response(), + }; +} + +#[derive(Debug, Default, Deserialize)] +pub struct ChangePasswordQuery { + redirect_to: Option, + alert: Option, +} + +async fn ui_change_password_handler(Query(query): Query) -> Response { + return match templates().get_template("change_password").unwrap().render(context! { + alert => query.alert.as_deref().unwrap_or(""), + state => query.redirect_to.map(|r| format!("")), + }) { + Ok(output) => Html(output).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to render template: {err}"), + ) + .into_response(), + }; +} + +#[derive(Debug, Default, Deserialize)] +pub struct ChangeEmailQuery { + redirect_to: Option, + alert: Option, +} + +async fn ui_change_email_handler(Query(query): Query, user: User) -> Response { + let form_state = indoc::formatdoc!( + r#" + {redirect_to} + {csrf_token} + "#, + redirect_to = hidden_input("redirect_to", query.redirect_to.as_ref()), + csrf_token = hidden_input("csrf_token", Some(&user.csrf_token)), + ); + + return match templates() + .get_template("change_email") + .unwrap() + .render(context! { + alert => query.alert.as_deref().unwrap_or(""), + state => form_state, + }) { + Ok(output) => Html(output).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to render template: {err}"), + ) + .into_response(), + }; +} + +#[derive(Debug, Default, Deserialize)] +pub struct LogoutQuery { + redirect_to: Option, +} + +async fn ui_logout_handler(Query(query): Query) -> Redirect { + if let Some(redirect_to) = query.redirect_to { + return Redirect::to(&format!("/api/auth/v1/logout?redirect_to={redirect_to}")); + } + return Redirect::to("/api/auth/v1/logout"); +} + +/// HTML endpoints of core auth functionality. +pub(crate) fn auth_ui_router() -> Router { + // Static assets for auth UI . + let serve_auth_assets = AssetService::::with_parameters( + // Fish for sub-directory. + Some(Box::new(|path| Some(format!("{path}/index.html")))), + None, + ); + + return Router::new() + .route("/login", get(ui_login_handler)) + .route("/logout", get(ui_logout_handler)) + .route("/register", get(ui_register_handler)) + .route( + "/reset_password/request", + get(ui_reset_password_request_handler), + ) + .route( + "/reset_password/update", + get(ui_reset_password_update_handler), + ) + .route("/change_password", get(ui_change_password_handler)) + .route("/change_email", get(ui_change_email_handler)) + .nest_service("/", serve_auth_assets); +} + +fn hidden_input(name: &str, value: Option<&String>) -> String { + if let Some(value) = value { + return format!(""); + } + return "".to_string(); +} + +#[derive(RustEmbed, Clone)] +#[folder = "../ui/auth/dist/"] +struct AuthAssets; diff --git a/trailbase-core/src/auth/user.rs b/trailbase-core/src/auth/user.rs new file mode 100644 index 0000000..69f5b2f --- /dev/null +++ b/trailbase-core/src/auth/user.rs @@ -0,0 +1,170 @@ +use axum::{ + async_trait, + extract::{FromRef, FromRequestParts}, + http::request::Parts, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::auth::jwt::TokenClaims; +use crate::auth::tokens::Tokens; +use crate::auth::AuthError; +use crate::{app_state::AppState, util::b64_to_uuid}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub(crate) struct DbUser { + pub id: [u8; 16], + pub email: String, + pub password_hash: String, + pub verified: bool, + pub admin: bool, + + pub created: i64, + pub updated: i64, + + pub email_verification_code: Option, + pub email_verification_code_sent_at: Option, + + pub pending_email: Option, + + pub password_reset_code: Option, + pub password_reset_code_sent_at: Option, + + pub authorization_code: Option, + pub authorization_code_sent_at: Option, + pub pkce_code_challenge: Option, + + // For external OAuth providers. + // + // NOTE: provider_id corresponds to proto::config::OAuthProviderId. + pub provider_id: i64, + pub provider_user_id: Option, + pub provider_avatar_url: Option, +} + +impl DbUser { + pub(crate) fn uuid(&self) -> Uuid { + let uuid = Uuid::from_bytes(self.id); + assert_eq!(uuid.get_version_num(), 7); + return uuid; + } + + // TODO: remove. + #[cfg(test)] + pub(crate) fn get_id(&self) -> Uuid { + self.uuid() + } +} + +/// Representing an authenticated and *valid* user, as opposed to DbUser, which is merely an entry +/// for any user including users that haven't been validated. +#[derive(Debug, Clone)] +pub struct User { + /// Url-safe Base64 encoded id of the current user. + pub id: String, + /// E-mail of the current user. + pub email: String, + /// Convenience UUID representation of [id] above. + pub uuid: Uuid, + + /// The "expected" CSRF token as included in the auth token claims [User] was constructed from. + pub csrf_token: String, +} + +impl PartialEq for User { + fn eq(&self, other: &User) -> bool { + return self.id == other.id && self.email == other.email; + } +} + +impl User { + /// Construct new verified [User] from [TokenClaims]. This is used when picking + /// credentials/tokens from headers/cookies. + pub(crate) fn from_token_claims(claims: TokenClaims) -> Result { + let uuid = b64_to_uuid(&claims.sub) + .map_err(|_err| AuthError::UnauthorizedExt("invalid user id".into()))?; + if uuid.get_version_num() != 7 { + return Err(AuthError::UnauthorizedExt("Invalid UUID version".into())); + } + return Ok(Self { + id: claims.sub, + email: claims.email, + uuid, + csrf_token: claims.csrf_token, + }); + } + + #[cfg(test)] + pub(crate) fn from_auth_token(state: &AppState, auth_token: &str) -> Option { + Some(Self::from_token_claims(state.jwt().decode(auth_token).unwrap()).unwrap()) + } + + #[cfg(test)] + pub(crate) fn from_unverified(user_id: Uuid, email: &str) -> Self { + return Self { + id: crate::util::uuid_to_b64(&user_id), + email: email.to_string(), + uuid: user_id, + csrf_token: crate::rand::generate_random_string(20), + }; + } +} + +#[async_trait] +impl FromRequestParts for User +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = AuthError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let tokens = Tokens::from_request_parts(parts, state).await?; + return User::from_token_claims(tokens.auth_token_claims); + } +} +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{header, Request}; + + use crate::admin::user::create_user_for_test; + use crate::app_state::test_state; + use crate::auth::api::login::login_with_password; + use crate::constants::COOKIE_REFRESH_TOKEN; + + #[tokio::test] + async fn test_token_refresh() { + let state = test_state(None).await.unwrap(); + + let email = "name@bar.com".to_string(); + let password = "secret123".to_string(); + + let user_id = create_user_for_test(&state, &email, &password) + .await + .unwrap(); + + let tokens = login_with_password(&state, &email, &password) + .await + .unwrap(); + assert_eq!(tokens.id, user_id); + state + .jwt() + .decode::(&tokens.auth_token) + .unwrap(); + + // Extract user from a request that only has a refresh token cookie but no auth token. + // NOTE: non-cookie creds are not auto-refreshed. + let request = Request::builder() + .header( + header::COOKIE, + format!("{COOKIE_REFRESH_TOKEN}={}", tokens.refresh_token), + ) + .body(Body::empty()) + .unwrap(); + + let (mut parts, _body) = request.into_parts(); + User::from_request_parts(&mut parts, &state).await.unwrap(); + } +} diff --git a/trailbase-core/src/auth/util.rs b/trailbase-core/src/auth/util.rs new file mode 100644 index 0000000..cf95f75 --- /dev/null +++ b/trailbase-core/src/auth/util.rs @@ -0,0 +1,223 @@ +use axum::http::request::Parts; +use base64::prelude::*; +use chrono::Duration; +use cookie::SameSite; +use lazy_static::lazy_static; +use libsql::{de, params, Connection}; +use sha2::{Digest, Sha256}; +use tower_cookies::{Cookie, Cookies}; +use trailbase_sqlite::{query_one_row, query_row}; + +use crate::auth::user::{DbUser, User}; +use crate::auth::AuthError; +use crate::constants::{ + COOKIE_AUTH_TOKEN, COOKIE_OAUTH_STATE, COOKIE_REFRESH_TOKEN, SESSION_TABLE, USER_TABLE, +}; +use crate::AppState; + +pub(crate) fn validate_redirects( + state: &AppState, + first: &Option, + second: &Option, +) -> Result, AuthError> { + let dev = state.dev_mode(); + let site = state.access_config(|c| c.server.site_url.clone()); + + let valid = |redirect: &String| -> bool { + if redirect.starts_with("/") { + return true; + } + if dev && redirect.starts_with("http://localhost") { + return true; + } + + // TODO: add a configurable white list. + if let Some(site) = site { + return redirect.starts_with(&site); + } + return false; + }; + + #[allow(clippy::manual_flatten)] + for r in [first, second] { + if let Some(ref r) = r { + if valid(r) { + return Ok(Some(r.to_owned())); + } + return Err(AuthError::BadRequest("Invalid redirect")); + } + } + + return Ok(None); +} + +pub(crate) fn new_cookie( + key: &'static str, + value: String, + ttl: Duration, + dev: bool, +) -> Cookie<'static> { + return Cookie::build((key, value)) + .path("/") + // Not available to client-side JS. + .http_only(true) + // Only send cookie over HTTPs. + .secure(!dev) + // Only include cookie if request originates from origin site. + .same_site(if dev { SameSite::Lax } else { SameSite::Strict }) + .max_age(cookie::time::Duration::seconds(ttl.num_seconds())) + .build(); +} + +pub(crate) fn new_cookie_opts( + key: &'static str, + value: String, + ttl: Duration, + tls_only: bool, + same_site: bool, +) -> Cookie<'static> { + return Cookie::build((key, value)) + .path("/") + // Not available to client-side JS. + .http_only(true) + // Only send cookie over HTTPs. + .secure(tls_only) + // Only include cookie if request originates from origin site. + .same_site(if same_site { + SameSite::Strict + } else { + SameSite::Lax + }) + .max_age(cookie::time::Duration::seconds(ttl.num_seconds())) + .build(); +} + +/// Removes cookie with the given `key`. +/// +/// NOTE: Removing a cookie from the jar doesn't reliably force the browser to remove the cookie, +/// thus override them. +pub(crate) fn remove_cookie(cookies: &Cookies, key: &'static str) { + if cookies.get(key).is_some() { + cookies.add(new_cookie(key, "".to_string(), Duration::seconds(1), false)); + } +} + +pub(crate) fn remove_all_cookies(cookies: &Cookies) { + for cookie in [COOKIE_AUTH_TOKEN, COOKIE_REFRESH_TOKEN, COOKIE_OAUTH_STATE] { + remove_cookie(cookies, cookie); + } +} + +#[cfg(test)] +pub(crate) fn extract_cookies_from_parts(parts: &mut Parts) -> Result { + let cookies = Cookies::default(); + + for ref header in parts.headers.get_all(axum::http::header::COOKIE) { + cookies.add(Cookie::parse(header.to_str().unwrap().to_string()).unwrap()); + } + + return Ok(cookies); +} + +#[cfg(not(test))] +pub(crate) fn extract_cookies_from_parts(parts: &mut Parts) -> Result { + if let Some(cookies) = parts.extensions.get::() { + return Ok(cookies.clone()); + }; + log::error!("Failed to get Cookies"); + return Err(AuthError::Internal("cookie error".into())); +} + +pub async fn user_by_email(state: &AppState, email: &str) -> Result { + return get_user_by_email(state.user_conn(), email).await; +} + +pub async fn get_user_by_email(user_conn: &Connection, email: &str) -> Result { + lazy_static! { + static ref QUERY: String = format!("SELECT * FROM {USER_TABLE} WHERE email = $1"); + }; + let row = query_one_row(user_conn, &QUERY, params!(email)) + .await + .map_err(|_err| AuthError::UnauthorizedExt("user not found by email".into()))?; + + return de::from_row(&row).map_err(|_err| AuthError::UnauthorizedExt("invalid user".into())); +} + +pub async fn user_by_id(state: &AppState, id: &uuid::Uuid) -> Result { + return get_user_by_id(state.user_conn(), id).await; +} + +pub(crate) async fn get_user_by_id( + user_conn: &Connection, + id: &uuid::Uuid, +) -> Result { + lazy_static! { + static ref QUERY: String = format!("SELECT * FROM {USER_TABLE} WHERE id = $1"); + }; + let row = query_one_row(user_conn, &QUERY, params!(id.into_bytes())) + .await + .map_err(|_err| AuthError::UnauthorizedExt("User not found by id".into()))?; + + return de::from_row(&row).map_err(|_err| AuthError::UnauthorizedExt("Invalid user".into())); +} + +pub async fn user_exists(state: &AppState, email: &str) -> Result { + lazy_static! { + static ref EXISTS_QUERY: String = + format!("SELECT EXISTS(SELECT 1 FROM '{USER_TABLE}' WHERE email = $1)"); + }; + let row = query_one_row(state.user_conn(), &EXISTS_QUERY, params!(email)).await?; + return row.get::(0); +} + +pub(crate) async fn is_admin(state: &AppState, user: &User) -> bool { + let Ok(Some(row)) = query_row( + state.user_conn(), + &format!("SELECT admin FROM {USER_TABLE} WHERE id = $1"), + params!(user.uuid.as_bytes().to_vec()), + ) + .await + else { + return false; + }; + + return row.get::(0).unwrap_or(false); +} + +pub(crate) async fn delete_all_sessions_for_user( + state: &AppState, + user_id: uuid::Uuid, +) -> Result { + lazy_static! { + static ref QUERY: String = format!("DELETE FROM '{SESSION_TABLE}' WHERE user = $1"); + }; + + return state + .user_conn() + .execute(&QUERY, [user_id.into_bytes().to_vec()]) + .await; +} + +pub(crate) async fn delete_session( + state: &AppState, + refresh_token: String, +) -> Result { + lazy_static! { + static ref QUERY: String = format!("DELETE FROM '{SESSION_TABLE}' WHERE refresh_token = $1"); + }; + + return state + .user_conn() + .execute(&QUERY, params!(refresh_token)) + .await; +} + +/// Derives the code challenge given the verifier as base64UrlNoPad(sha256([codeVerifier])). +/// +/// NOTE: We could also use oauth2::PkceCodeChallenge. +pub(crate) fn derive_pkce_code_challenge(pkce_code_verifier: &str) -> String { + let mut sha = Sha256::new(); + sha.update(pkce_code_verifier); + // NOTE: This is NO_PAD as per the spec. + return BASE64_URL_SAFE_NO_PAD.encode(sha.finalize()); +} diff --git a/trailbase-core/src/config.rs b/trailbase-core/src/config.rs new file mode 100644 index 0000000..3e7de7d --- /dev/null +++ b/trailbase-core/src/config.rs @@ -0,0 +1,727 @@ +use lazy_static::lazy_static; +use log::*; +use prost_reflect::{ + DynamicMessage, ExtensionDescriptor, FieldDescriptor, Kind, MapKey, ReflectMessage, Value, +}; +use proto::EmailTemplate; +use std::collections::{HashMap, HashSet}; +use thiserror::Error; +use tokio::fs; + +use crate::data_dir::DataDir; +use crate::records::validate_record_api_config; +use crate::table_metadata::TableMetadataCache; +use crate::DESCRIPTOR_POOL; + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("Decode error: {0}")] + Decode(#[from] prost::DecodeError), + #[error("Parse error: {0}")] + Parse(#[from] prost_reflect::text_format::ParseError), + #[error("Parse int error: {0}")] + ParseInt(#[from] std::num::ParseIntError), + #[error("Parse bool error: {0}")] + ParseBool(#[from] std::str::ParseBoolError), + #[error("Valiation error: {0}")] + Invalid(String), + #[error("Update error: {0}")] + Update(String), + #[error("IO error: {0}")] + IO(#[from] std::io::Error), + #[error("Id error: {0}")] + Id(#[from] uuid::Error), +} + +#[cfg(not(test))] +fn parse_env_var( + name: &str, +) -> Result, ::Err> { + if let Ok(value) = std::env::var(name) { + return Ok(Some(value.parse::()?)); + } + Ok(None) +} + +#[cfg(test)] +mod test_env { + use lazy_static::lazy_static; + use parking_lot::Mutex; + use std::collections::HashMap; + + lazy_static! { + pub static ref ENV: Mutex> = Mutex::new(HashMap::new()); + } + + pub(super) fn parse_env_var( + name: &str, + ) -> Result, ::Err> { + if let Some(value) = ENV.lock().get(name) { + return Ok(Some(value.parse::()?)); + } + Ok(None) + } + + pub(super) fn set(name: &str, value: Option<&str>) { + match value { + None => ENV.lock().remove(name), + Some(v) => ENV.lock().insert(name.to_string(), v.to_string()), + }; + } +} + +#[cfg(test)] +use test_env::parse_env_var; + +pub(super) fn apply_parsed_env_var( + name: &str, + mut f: impl FnMut(T), +) -> Result<(), ::Err> { + if let Some(v) = parse_env_var::(name)? { + f(v); + } + Ok(()) +} + +pub mod proto { + use chrono::Duration; + use lazy_static::lazy_static; + use prost::Message; + use prost_reflect::text_format::FormatOptions; + use prost_reflect::{DynamicMessage, MessageDescriptor, ReflectMessage}; + use std::hash::{DefaultHasher, Hash, Hasher}; + + use crate::config::ConfigError; + use crate::constants::{ + AVATAR_TABLE, DEFAULT_AUTH_TOKEN_TTL, DEFAULT_REFRESH_TOKEN_TTL, LOGS_RETENTION_DEFAULT, + SITE_URL_DEFAULT, + }; + use crate::email; + use crate::DESCRIPTOR_POOL; + + include!(concat!(env!("OUT_DIR"), "/config.rs")); + + lazy_static! { + static ref CONFIG_DESCRIPTOR: MessageDescriptor = DESCRIPTOR_POOL + .get_message_by_name("config.Config") + .unwrap(); + static ref VAULT_DESCRIPTOR: MessageDescriptor = + DESCRIPTOR_POOL.get_message_by_name("config.Vault").unwrap(); + static ref FORMAT_OPTIONS: FormatOptions = FormatOptions::new().pretty(true).expand_any(true); + } + + impl Vault { + pub fn from_text(text: &str) -> Result { + let dyn_config = DynamicMessage::parse_text_format(VAULT_DESCRIPTOR.clone(), text)?; + return Ok(dyn_config.transcode_to::()?); + } + + pub fn to_text(&self) -> Result { + const PREFACE: &str = "# Auto-generated config.Vault textproto"; + + let text: String = self + .transcode_to_dynamic() + .to_text_format_with_options(&FORMAT_OPTIONS); + + return Ok(format!("{PREFACE}\n{text}")); + } + } + + impl Config { + pub fn new_with_custom_defaults() -> Self { + // NOTE: It's arguable if copying custom defaults into the config is the cleanest approach, + // however it lets us tie into the set update-config Admin UI flow to let users change the + // templates. + let mut config = Config { + server: ServerConfig { + application_name: Some("TrailBase".to_string()), + site_url: Some(SITE_URL_DEFAULT.to_string()), + logs_retention_sec: Some(LOGS_RETENTION_DEFAULT.num_seconds()), + ..Default::default() + }, + email: EmailConfig { + user_verification_template: Some(email::defaults::email_validation_email()), + password_reset_template: Some(email::defaults::password_reset_email()), + change_email_template: Some(email::defaults::change_email_address_email()), + ..Default::default() + }, + auth: AuthConfig { + auth_token_ttl_sec: Some(DEFAULT_AUTH_TOKEN_TTL.num_seconds()), + refresh_token_ttl_sec: Some(DEFAULT_REFRESH_TOKEN_TTL.num_seconds()), + ..Default::default() + }, + ..Default::default() + }; + + config.record_apis = vec![RecordApiConfig { + name: Some(AVATAR_TABLE.to_string()), + table_name: Some(AVATAR_TABLE.to_string()), + conflict_resolution: Some(ConflictResolutionStrategy::Replace.into()), + autofill_missing_user_id_columns: Some(true), + acl_world: vec![PermissionFlag::Read as i32], + acl_authenticated: vec![ + PermissionFlag::Create as i32, + PermissionFlag::Read as i32, + PermissionFlag::Update as i32, + PermissionFlag::Delete as i32, + ], + read_access_rule: None, + create_access_rule: Some("_REQ_.user IS NULL OR _REQ_.user = _USER_.id".to_string()), + update_access_rule: Some("_ROW_.user = _USER_.id".to_string()), + delete_access_rule: Some("_ROW_.user = _USER_.id".to_string()), + schema_access_rule: None, + }]; + + return config; + } + + pub fn from_text(text: &str) -> Result { + let dyn_config = DynamicMessage::parse_text_format(CONFIG_DESCRIPTOR.clone(), text)?; + return Ok(dyn_config.transcode_to::()?); + } + + pub fn to_text(&self) -> Result { + const PREFACE: &str = "# Auto-generated config.Config textproto"; + + let text: String = self + .transcode_to_dynamic() + .to_text_format_with_options(&FORMAT_OPTIONS); + + return Ok(format!("{PREFACE}\n{text}")); + } + + pub fn hash(&self) -> u64 { + let encoded = self.encode_to_vec(); + let mut s = DefaultHasher::new(); + encoded.hash(&mut s); + return s.finish(); + } + } + + impl AuthConfig { + pub fn token_ttls(&self) -> (Duration, Duration) { + return ( + self + .auth_token_ttl_sec + .map_or(DEFAULT_AUTH_TOKEN_TTL, Duration::seconds), + self + .refresh_token_ttl_sec + .map_or(DEFAULT_REFRESH_TOKEN_TTL, Duration::seconds), + ); + } + } +} + +fn is_secret(field_descriptor: &FieldDescriptor) -> bool { + lazy_static! { + static ref SECRET_EXT_DESCRIPTOR: ExtensionDescriptor = DESCRIPTOR_POOL + .get_extension_by_name("config.secret") + .unwrap(); + } + + let options = field_descriptor.options(); + if let Value::Bool(value) = *options.get_extension(&SECRET_EXT_DESCRIPTOR) { + return value; + } + return false; +} + +fn recursively_merge_vault_and_env( + msg: &mut DynamicMessage, + vault: &proto::Vault, + parent_path: Vec, +) -> Result<(), ConfigError> { + for field_descr in msg.descriptor().fields() { + let path = { + let mut path = parent_path.clone(); + path.push(field_descr.name().to_uppercase()); + path + }; + + let var_name = format!("TRAIL_{path}", path = path.join("_")); + let secret = is_secret(&field_descr); + + trace!("{var_name}: {secret}"); + + let mut set_field = |v: Value| msg.set_field(&field_descr, v); + + match field_descr.kind() { + Kind::Message(_) => { + // FIXME: We're skipping missing optional message fields, which means potentially present + // environment variables might not get merged. This is just a quick fix to avoid + // instantiating new empty messages e.g. for email templates in EmailConfig :/. + // This only ~works right now because most messages are required. Instead, we should lazily + // construct sub-messages only when a corresponding env variable was found. + // + // In practice this often isn't too much of an issue, e.g. for oauth providers this means + // we cannot merge the client_id_secret only if the client_id is set via env vars, + // otherwise the message to merge into should already exist. + if !msg.has_field(&field_descr) { + debug!( + "Unsupported: merging of secrets into uninitialized nested messages. Skipping: {}", + field_descr.name() + ); + continue; + } + + match msg.get_field_mut(&field_descr) { + Value::Message(child) => recursively_merge_vault_and_env(child, vault, path)?, + Value::List(_child_list) => { + // There isn't really a good way for us to support mapping env variables to repeated + // fields. Hard-coding the index in the variable name sounds brittle. Instead, we just + // don't support it. + trace!("Skipping repeated field: {name}", name = field_descr.name()); + continue; + } + Value::Map(child_map) => { + for (key, value) in child_map { + match (key, value) { + (MapKey::String(k), Value::Message(m)) => { + let mut keyed = path.clone(); + keyed.push(k.to_uppercase()); + + recursively_merge_vault_and_env(m, vault, keyed)? + } + x => { + warn!("Unexpected message type: {x:?}"); + } + } + } + } + x => { + warn!("Unexpected message type: {x:?}"); + } + } + } + Kind::String => { + if let Ok(Some(value)) = parse_env_var(&var_name) { + set_field(Value::String(value)); + } else if secret { + if let Some(stored_secret) = vault.secrets.get(&var_name) { + set_field(Value::String(stored_secret.to_string())); + } + } + } + Kind::Int32 => apply_parsed_env_var::(&var_name, |v| set_field(Value::I32(v)))?, + Kind::Uint32 => apply_parsed_env_var::(&var_name, |v| set_field(Value::U32(v)))?, + Kind::Int64 => apply_parsed_env_var::(&var_name, |v| set_field(Value::I64(v)))?, + Kind::Uint64 => apply_parsed_env_var::(&var_name, |v| set_field(Value::U64(v)))?, + Kind::Bool => apply_parsed_env_var::(&var_name, |v| set_field(Value::Bool(v)))?, + Kind::Enum(_) => apply_parsed_env_var::(&var_name, |v| set_field(Value::EnumNumber(v)))?, + _ => { + error!("Config merging not implemented for: {field_descr:?}"); + } + }; + } + + return Ok(()); +} + +fn merge_vault_and_env( + config: proto::Config, + vault: proto::Vault, +) -> Result { + let mut dyn_config = config.transcode_to_dynamic(); + recursively_merge_vault_and_env(&mut dyn_config, &vault, vec![])?; + return Ok(dyn_config.transcode_to::()?); +} + +fn recursively_strip_secrets( + msg: &mut DynamicMessage, + secrets: &mut HashMap, + parent_path: Vec, +) -> Result<(), ConfigError> { + for field_descr in msg.descriptor().fields() { + let path = { + let mut path = parent_path.clone(); + path.push(field_descr.name().to_uppercase()); + path + }; + + if !msg.has_field(&field_descr) { + continue; + } + + let var_name = format!("TRAIL_{path}", path = path.join("_")); + let secret = is_secret(&field_descr); + match msg.get_field_mut(&field_descr) { + Value::Message(child) => recursively_strip_secrets(child, secrets, path)?, + Value::Map(child_map) => { + for (key, value) in child_map { + match (key, value) { + (MapKey::String(k), Value::Message(m)) => { + let mut keyed = path.clone(); + keyed.push(k.to_uppercase()); + + recursively_strip_secrets(m, secrets, keyed)? + } + x => { + warn!("Unexpected message type: {x:?}"); + } + } + } + } + Value::String(field) => { + if secret { + secrets.insert(var_name, field.to_string()); + msg.clear_field(&field_descr); + } + } + x => { + if secret { + error!("Found non-string secret. Not supported: {x:?}"); + } + } + } + } + + return Ok(()); +} + +pub(crate) fn strip_secrets( + config: &proto::Config, +) -> Result<(proto::Config, HashMap), ConfigError> { + let mut secrets = HashMap::::new(); + let mut dyn_config = config.transcode_to_dynamic(); + recursively_strip_secrets(&mut dyn_config, &mut secrets, vec![])?; + let stripped = dyn_config.transcode_to::()?; + + return Ok((stripped, secrets)); +} + +async fn load_vault_textproto_or_default(data_dir: &DataDir) -> Result { + let vault_path = data_dir.secrets_path().join(VAULT_FILENAME); + + let vault = match fs::read_to_string(&vault_path).await { + Ok(contents) => proto::Vault::from_text(&contents)?, + Err(err) => { + if cfg!(not(test)) { + warn!("Vault not found. Falling back to empty default vault: {err}"); + } + proto::Vault { + ..Default::default() + } + } + }; + + return Ok(vault); +} + +pub async fn load_or_init_config_textproto( + data_dir: &DataDir, + table_metadata: &TableMetadataCache, +) -> Result { + let vault = load_vault_textproto_or_default(data_dir).await?; + + let config: proto::Config = + match fs::read_to_string(data_dir.config_path().join(CONFIG_FILENAME)).await { + Ok(contents) => proto::Config::from_text(&contents)?, + Err(err) => match err.kind() { + std::io::ErrorKind::NotFound => { + warn!("Falling back to default config: {err}"); + let config = proto::Config::new_with_custom_defaults(); + write_config_and_vault_textproto(data_dir, table_metadata, &config).await?; + config + } + _ => { + return Err(err.into()); + } + }, + }; + + let merged_config = merge_vault_and_env(config, vault)?; + validate_config(table_metadata, &merged_config)?; + + return Ok(merged_config); +} + +fn split_config(config: &proto::Config) -> Result<(proto::Config, proto::Vault), ConfigError> { + let mut new_vault = proto::Vault::default(); + let (stripped_config, secrets) = strip_secrets(config)?; + + for (key, value) in secrets { + new_vault.secrets.insert(key, value); + } + + return Ok((stripped_config, new_vault)); +} + +pub async fn write_config_and_vault_textproto( + data_dir: &DataDir, + table_metadata: &TableMetadataCache, + config: &proto::Config, +) -> Result<(), ConfigError> { + validate_config(table_metadata, config)?; + + let (stripped_config, vault) = split_config(config)?; + + if cfg!(test) { + debug!("Skip writing config for tests."); + return Ok(()); + } + + let config_path = data_dir.config_path().join(CONFIG_FILENAME); + let vault_path = data_dir.secrets_path().join(VAULT_FILENAME); + debug!("Writing config files: {config_path:?}, {vault_path:?}"); + fs::write(&config_path, stripped_config.to_text()?.as_bytes()).await?; + fs::write(&vault_path, vault.to_text()?.as_bytes()).await?; + return Ok(()); +} + +fn validate_application_name(name: &str) -> Result<(), ConfigError> { + if !name + .chars() + .all(|x| x.is_ascii_alphanumeric() || x == '_' || x == '.' || x == '-' || x == ' ') + { + return Err(ConfigError::Invalid(format!( + "Application name: {name}. Must only contain alphanumeric characters, spaces or '_', '-', '.'." + ))); + } + + if name.is_empty() { + return Err(ConfigError::Invalid( + "Application name must not be empty".to_string(), + )); + } + + Ok(()) +} + +pub(crate) fn validate_config( + tables: &TableMetadataCache, + config: &proto::Config, +) -> Result<(), ConfigError> { + let ierr = |msg: &str| Err(ConfigError::Invalid(msg.to_string())); + + let Some(app_name) = &config.server.application_name else { + return ierr("Missing application name"); + }; + validate_application_name(app_name)?; + + // Check RecordApis. + // + // Note: it is valid to declare multiple api (e.g. with different acls) over the same + // table, however it's not valid to have conflicting api names. + let mut api_names = HashSet::::new(); + for api in &config.record_apis { + let api_name = validate_record_api_config(tables, api)?; + + if !api_names.insert(api_name.clone()) { + return ierr(&format!( + "Two or more APIs have the colliding name: '{api_name}'" + )); + } + } + + // Check auth. + let mut providers = HashSet::::new(); + for (name, provider) in &config.auth.oauth_providers { + let _provider_id = match &provider.provider_id { + Some(id) if *id > 0 => *id, + _ => { + return ierr(&format!("Provider id for: {name}")); + } + }; + if !providers.insert(name.to_string()) { + return ierr(&format!("Multiple providers for: {name}")); + } + + if provider.client_secret.is_none() { + return ierr(&format!("Missing secret for: {name}")); + } + + if provider.client_id.is_none() { + return ierr(&format!("Missing client id for: {name}")); + } + + // TODO: validate critical endpoint urls are present and valid. + } + + // Check JSON Schema configs + for schema in &config.schemas { + if schema.name.is_none() { + return ierr("Missing schema name"); + } + + let Some(schema_text) = &schema.schema else { + return ierr("Missing schema"); + }; + + let schema_json: serde_json::Value = serde_json::from_str(schema_text) + .map_err(|err| ConfigError::Invalid(format!("Schema is invalid Json: {err}")))?; + if let Err(err) = jsonschema::Validator::new(&schema_json) { + return Err(ConfigError::Invalid(format!( + "Not a valid Json schema: {err}" + ))); + } + } + + // Check email config. + { + let email = &config.email; + + let validate_template = |template: Option<&EmailTemplate>| { + if let Some(template) = template { + if template.subject.is_none() || template.body.is_none() { + return ierr("Email template missing subject or body."); + } + }; + Ok(()) + }; + + validate_template(email.user_verification_template.as_ref())?; + validate_template(email.change_email_template.as_ref())?; + validate_template(email.password_reset_template.as_ref())?; + } + + return Ok(()); +} + +#[cfg(test)] +mod test { + use std::collections::HashMap; + + use super::*; + use crate::app_state::test_state; + use crate::config::proto::{AuthConfig, Config, EmailConfig, OAuthProviderConfig}; + + #[tokio::test] + async fn test_config_tests_sequentially() -> anyhow::Result<()> { + // Run sequentially to avoid concurrent tests clobbering their env variables. + test_default_config_is_valid().await; + test_config_merging()?; + test_config_stripping()?; + test_config_merging_from_env_and_vault()?; + + Ok(()) + } + + async fn test_default_config_is_valid() { + let state = test_state(None).await.unwrap(); + let table_metadata = TableMetadataCache::new(state.conn().clone()).await.unwrap(); + + let config = Config::new_with_custom_defaults(); + validate_config(&table_metadata, &config).unwrap(); + } + + fn test_config_merging() -> anyhow::Result<()> { + let config = proto::Config { + email: proto::EmailConfig { + smtp_username: Some("user".to_string()), + ..Default::default() + }, + ..Default::default() + }; + let vault = proto::Vault::default(); + let merged = merge_vault_and_env(config.clone(), vault)?; + + assert_eq!(config, merged); + + return Ok(()); + } + + fn test_config_merging_from_env_and_vault() -> anyhow::Result<()> { + // Set username via env var. + test_env::set("TRAIL_EMAIL_SMTP_USERNAME", Some("username")); + + let client_secret = "secret".to_string(); + let vault = proto::Vault { + secrets: HashMap::::from([ + ( + "TRAIL_EMAIL_SMTP_PASSWORD".to_string(), + "password".to_string(), + ), + ( + "TRAIL_AUTH_OAUTH_PROVIDERS_KEY_CLIENT_SECRET".to_string(), + client_secret.clone(), + ), + ]), + }; + + let config = proto::Config { + auth: AuthConfig { + oauth_providers: HashMap::::from([( + "key".to_string(), + OAuthProviderConfig { + client_id: Some("my_client_id".to_string()), + ..Default::default() + }, + )]), + ..Default::default() + }, + ..Default::default() + }; + + let merged = merge_vault_and_env(config.clone(), vault)?; + test_env::set("TRAIL_EMAIL_SMTP_USERNAME", None); + + // Update config to match what we would expect after merging. + let expected = { + let mut expected = config.clone(); + expected.email = EmailConfig { + smtp_username: Some("username".to_string()), + smtp_password: Some("password".to_string()), + ..Default::default() + }; + expected + .auth + .oauth_providers + .get_mut("key") + .unwrap() + .client_secret = Some(client_secret); + + expected + }; + + assert_eq!(merged, expected); + + return Ok(()); + } + + fn test_config_stripping() -> anyhow::Result<()> { + let mut config = proto::Config { + email: proto::EmailConfig { + smtp_username: Some("user".to_string()), + smtp_password: Some("pass".to_string()), + ..Default::default() + }, + auth: proto::AuthConfig { + oauth_providers: HashMap::::from([( + "key".to_string(), + proto::OAuthProviderConfig { + client_id: Some("my_client_id".to_string()), + client_secret: Some("secret".to_string()), + ..Default::default() + }, + )]), + ..Default::default() + }, + ..Default::default() + }; + + let (stripped, secrets) = strip_secrets(&config)?; + + config.email.smtp_password = None; + config + .auth + .oauth_providers + .get_mut("key") + .unwrap() + .client_secret = None; + + assert_eq!(config, stripped); + assert_eq!( + secrets.get("TRAIL_EMAIL_SMTP_PASSWORD"), + Some(&"pass".to_string()) + ); + assert_eq!( + secrets.get("TRAIL_AUTH_OAUTH_PROVIDERS_KEY_CLIENT_SECRET"), + Some(&"secret".to_string()) + ); + + return Ok(()); + } +} + +const CONFIG_FILENAME: &str = "config.textproto"; +const VAULT_FILENAME: &str = "secrets.textproto"; diff --git a/trailbase-core/src/constants.rs b/trailbase-core/src/constants.rs new file mode 100644 index 0000000..6c5edeb --- /dev/null +++ b/trailbase-core/src/constants.rs @@ -0,0 +1,39 @@ +use crate::auth::password::PasswordOptions; +use chrono::Duration; + +pub const SQLITE_SCHEMA_TABLE: &str = "main.sqlite_schema"; +pub const USER_TABLE: &str = "_user"; +pub(crate) const USER_TABLE_ID_COLUMN: &str = "id"; + +pub(crate) const SESSION_TABLE: &str = "_session"; +pub(crate) const AVATAR_TABLE: &str = "_user_avatar"; + +pub(crate) const LOGS_TABLE_ID_COLUMN: &str = "id"; +pub const LOGS_RETENTION_DEFAULT: Duration = Duration::days(7); + +pub const COOKIE_AUTH_TOKEN: &str = "auth_token"; +pub const COOKIE_REFRESH_TOKEN: &str = "refresh_token"; +pub const COOKIE_OAUTH_STATE: &str = "oauth_state"; + +// NOTE: We're using the standard "Authorization" header for the JWT auth token. Custom header +// naming: https://datatracker.ietf.org/doc/html/draft-saintandre-xdash-00 +pub const HEADER_REFRESH_TOKEN: &str = "Refresh-Token"; +pub const HEADER_CSRF_TOKEN: &str = "CSRF-Token"; + +#[cfg(debug_assertions)] +pub const DEFAULT_AUTH_TOKEN_TTL: Duration = Duration::minutes(2); +#[cfg(not(debug_assertions))] +pub const DEFAULT_AUTH_TOKEN_TTL: Duration = Duration::minutes(60); + +pub const DEFAULT_REFRESH_TOKEN_TTL: Duration = Duration::days(30); + +pub const SITE_URL_DEFAULT: &str = "http://localhost:4000"; + +pub(crate) const PASSWORD_OPTIONS: PasswordOptions = PasswordOptions::default(); +pub(crate) const VERIFICATION_CODE_LENGTH: usize = 24; +pub(crate) const REFRESH_TOKEN_LENGTH: usize = 32; + +// Public APIs +pub const RECORD_API_PATH: &str = "api/records/v1"; +pub const QUERY_API_PATH: &str = "api/query/v1"; +pub const AUTH_API_PATH: &str = "api/auth/v1"; diff --git a/trailbase-core/src/data_dir.rs b/trailbase-core/src/data_dir.rs new file mode 100644 index 0000000..1563d01 --- /dev/null +++ b/trailbase-core/src/data_dir.rs @@ -0,0 +1,97 @@ +use log::*; +use std::path::PathBuf; +use tokio::{fs, io::AsyncWriteExt}; + +/// The base data directory where the sqlite database, config, etc. will be stored. +#[derive(Debug, Clone)] +pub struct DataDir(pub PathBuf); + +impl Default for DataDir { + fn default() -> Self { + Self(format!("./{}/", Self::DEFAULT).into()) + } +} + +impl DataDir { + pub const DEFAULT: &str = "traildepot"; + + pub fn root(&self) -> &PathBuf { + return &self.0; + } + + pub fn main_db_path(&self) -> PathBuf { + return self.data_path().join("main.db"); + } + + pub fn logs_db_path(&self) -> PathBuf { + return self.data_path().join("logs.db"); + } + + pub fn data_path(&self) -> PathBuf { + return self.0.join("data/"); + } + + pub fn config_path(&self) -> PathBuf { + return self.0.clone(); + } + + pub fn secrets_path(&self) -> PathBuf { + return self.0.join("secrets/"); + } + + pub fn backup_path(&self) -> PathBuf { + return self.0.join("backups/"); + } + + pub fn migrations_path(&self) -> PathBuf { + return self.0.join("migrations/"); + } + + pub fn uploads_path(&self) -> PathBuf { + return self.0.join("uploads/"); + } + + pub fn key_path(&self) -> PathBuf { + return self.secrets_path().join("keys/"); + } + + fn directories(&self) -> Vec { + return vec![ + self.data_path(), + self.config_path(), + self.backup_path(), + self.migrations_path(), + self.uploads_path(), + self.key_path(), + ]; + } + + pub(crate) async fn ensure_directory_structure(&self) -> std::io::Result<()> { + // First create directory structure. + let root = self.root(); + if !fs::try_exists(root).await.unwrap_or(false) { + fs::create_dir_all(root).await?; + + // Create .gitignore file. + let mut gitignore = fs::File::create_new(root.join(".gitignore")).await?; + gitignore.write_all(GIT_IGNORE.as_bytes()).await?; + + info!("Initialized fresh data dir: {:?}", root); + } + + for dir in self.directories() { + if !fs::try_exists(&dir).await.unwrap_or(false) { + fs::create_dir_all(dir).await?; + } + } + + Ok(()) + } +} + +const GIT_IGNORE: &str = r#" +backups/ +data/ +secrets/ +uploads/ +"#; diff --git a/trailbase-core/src/email.rs b/trailbase-core/src/email.rs new file mode 100644 index 0000000..9285654 --- /dev/null +++ b/trailbase-core/src/email.rs @@ -0,0 +1,401 @@ +use lettre::message::{header::ContentType, Body, Mailbox, Message}; +use lettre::transport::smtp; +use lettre::{AsyncSendmailTransport, AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; +use minijinja::{context, Environment}; +use std::sync::Arc; +use thiserror::Error; + +use crate::auth::user::DbUser; +use crate::config::proto::{Config, EmailTemplate}; +use crate::AppState; + +#[derive(Debug, Error)] +pub enum EmailError { + #[error("Email address error: {0}")] + Address(#[from] lettre::address::AddressError), + #[error("Missing error: {0}")] + Missing(&'static str), + #[error("Senda error: {0}")] + Send(#[from] lettre::error::Error), + #[error("SMTP error: {0}")] + Smtp(#[from] lettre::transport::smtp::Error), + #[error("Sendmail error: {0}")] + Sendmail(#[from] lettre::transport::sendmail::Error), + #[error("Template error: {0}")] + Template(#[from] minijinja::Error), +} + +pub struct Email { + mailer: Arc, + + from: Mailbox, + to: Mailbox, + + subject: String, + body: String, +} + +impl Email { + pub fn new( + state: &AppState, + to: String, + subject: String, + body: String, + ) -> Result { + return Ok(Self { + mailer: state.mailer().clone(), + from: get_sender(state)?, + to: to.parse()?, + subject, + body, + }); + } + + pub async fn send(&self) -> Result<(), EmailError> { + let email = Message::builder() + .to(self.to.clone()) + .from(self.from.clone()) + .subject(self.subject.clone()) + .header(ContentType::TEXT_HTML) + .body(Body::new(self.body.clone()))?; + + match &*self.mailer { + Mailer::Smtp(mailer) => { + mailer.send(email).await?; + } + Mailer::Local(mailer) => { + mailer.send(email).await?; + } + }; + + return Ok(()); + } + + pub(crate) fn verification_email( + state: &AppState, + user: &DbUser, + email_verification_code: &str, + ) -> Result { + let (server_config, template) = + state.access_config(|c| (c.server.clone(), c.email.user_verification_template.clone())); + + let Some(ref site_url) = server_config.site_url else { + return Err(EmailError::Missing("config.site_url")); + }; + + let (subject_template, body_template) = match template { + Some(EmailTemplate { + subject: Some(subject), + body: Some(body), + }) => (subject, body), + _ => { + log::debug!("Falling back to default email verification email"); + let d = defaults::email_validation_email(); + (d.subject.unwrap(), d.body.unwrap()) + } + }; + + let verification_url = format!("{site_url}/verify_email/confirm/{email_verification_code}"); + + let env = Environment::new(); + let subject = env + .template_from_named_str("subject", &subject_template)? + .render(context! { + APP_NAME => server_config.application_name, + EMAIL => user.email, + })?; + let body = env + .template_from_named_str("body", &body_template)? + .render(context! { + APP_NAME => server_config.application_name, + VERIFICATION_URL => verification_url, + SITE_URL => server_config.site_url, + CODE => email_verification_code, + EMAIL => user.email, + })?; + + return Email::new(state, user.email.clone(), subject, body); + } + + pub(crate) fn change_email_address_email( + state: &AppState, + user: &DbUser, + email_verification_code: &str, + ) -> Result { + let (server_config, template) = + state.access_config(|c| (c.server.clone(), c.email.change_email_template.clone())); + + let Some(ref site_url) = server_config.site_url else { + return Err(EmailError::Missing("config.site_url")); + }; + + let (subject_template, body_template) = match template { + Some(EmailTemplate { + subject: Some(subject), + body: Some(body), + }) => (subject, body), + _ => { + log::debug!("Falling back to default change email template"); + let d = defaults::change_email_address_email(); + (d.subject.unwrap(), d.body.unwrap()) + } + }; + + let verification_url = format!("{site_url}/change_email/confirm/{email_verification_code}"); + + let env = Environment::new(); + let subject = env + .template_from_named_str("subject", &subject_template)? + .render(context! { + APP_NAME => server_config.application_name, + EMAIL => user.email, + })?; + let body = env + .template_from_named_str("body", &body_template)? + .render(context! { + APP_NAME => server_config.application_name, + VERIFICATION_URL => verification_url, + SITE_URL => server_config.site_url, + CODE => email_verification_code, + EMAIL => user.email, + })?; + + return Email::new(state, user.email.clone(), subject, body); + } + + pub(crate) fn password_reset_email( + state: &AppState, + user: &DbUser, + password_reset_code: &str, + ) -> Result { + let (server_config, template) = + state.access_config(|c| (c.server.clone(), c.email.password_reset_template.clone())); + + let Some(ref site_url) = server_config.site_url else { + return Err(EmailError::Missing("config.site_url")); + }; + + let (subject_template, body_template) = match template { + Some(EmailTemplate { + subject: Some(subject), + body: Some(body), + }) => (subject, body), + _ => { + log::debug!("Falling back to default reset password email"); + let d = defaults::password_reset_email(); + (d.subject.unwrap(), d.body.unwrap()) + } + }; + + let verification_url = format!("{site_url}/reset_password/update/{password_reset_code}"); + + let env = Environment::new(); + let subject = env + .template_from_named_str("subject", &subject_template)? + .render(context! { + APP_NAME => server_config.application_name, + EMAIL => user.email, + })?; + let body = env + .template_from_named_str("body", &body_template)? + .render(context! { + APP_NAME => server_config.application_name, + VERIFICATION_URL => verification_url, + SITE_URL => server_config.site_url, + CODE => password_reset_code, + EMAIL => user.email, + })?; + + return Email::new(state, user.email.clone(), subject, body); + } +} + +fn get_sender(state: &AppState) -> Result { + let (sender_address, sender_name) = + state.access_config(|c| (c.email.sender_address.clone(), c.email.sender_name.clone())); + // TODO: Have a better default sender, e.g. derive from SITE_URL. + let address = sender_address.unwrap_or_else(|| "admin@localhost".to_string()); + + if let Some(ref name) = sender_name { + return Ok(format!("{} <{}>", name, address).parse::()?); + } + return Ok(address.parse::()?); +} + +#[derive(Clone)] +pub(crate) enum Mailer { + Smtp(Arc + Send + Sync>), + Local(Arc>), +} + +impl Mailer { + fn new_smtp(host: String, port: u16, user: String, pass: String) -> Result { + let mailer = AsyncSmtpTransport::::starttls_relay(&host)? + .port(port) + .credentials(smtp::authentication::Credentials::new(user, pass)) + .build(); + return Ok(Mailer::Smtp(Arc::new(mailer))); + } + + fn new_local() -> Mailer { + return Mailer::Local(Arc::new(AsyncSendmailTransport::::new())); + } + + pub(crate) fn new_from_config(config: &Config) -> Mailer { + let smtp_from_config = || -> Result { + let email = &config.email; + let host = email + .smtp_host + .to_owned() + .ok_or(EmailError::Missing("SMTP host"))?; + let port = email + .smtp_port + .map(|port| port as u16) + .ok_or(EmailError::Missing("SMTP port"))?; + let user = email + .smtp_username + .to_owned() + .ok_or(EmailError::Missing("SMTP username"))?; + let pass = email + .smtp_password + .to_owned() + .ok_or(EmailError::Missing("SMTP password"))?; + + Self::new_smtp(host, port, user, pass) + }; + + if let Ok(mailer) = smtp_from_config() { + return mailer; + } + + return Self::new_local(); + } +} + +pub(crate) mod defaults { + use crate::config::proto::EmailTemplate; + use indoc::indoc; + + pub fn email_validation_email() -> EmailTemplate { + const SUBJECT: &str = "Validate your Email Address for {{ APP_NAME }}"; + const BODY: &str = indoc! {r#" + + +

Welcome {{ EMAIL }}

+ +

+ Thanks for joining {{ APP_NAME }}. +

+ +

+ To be able to log in, first validate your email by clicking the link below. +

+ + + {{ VERIFICATION_URL }} + + + "#}; + + return EmailTemplate { + subject: Some(SUBJECT.to_string()), + body: Some(BODY.to_string()), + }; + } + + pub fn password_reset_email() -> EmailTemplate { + const SUBJECT: &str = "Reset your Password for {{ APP_NAME }}"; + const BODY: &str = indoc! {r#" + + +

Password reset

+ +

+ Click the link below to reset your password. +

+ + + {{ VERIFICATION_URL }} + + + "#}; + + return EmailTemplate { + subject: Some(SUBJECT.to_string()), + body: Some(BODY.to_string()), + }; + } + + pub fn change_email_address_email() -> EmailTemplate { + const SUBJECT: &str = "Change your Email Address for {{ APP_NAME }}"; + const BODY: &str = indoc! {r#" + + +

Change E-Mail Address

+ +

+ Click the link below to verify your new E-mail address: +

+ + + {{ VERIFICATION_URL }} + + + "#}; + + return EmailTemplate { + subject: Some(SUBJECT.to_string()), + body: Some(BODY.to_string()), + }; + } +} + +#[cfg(test)] +pub mod testing { + use std::sync::{Arc, Mutex}; + + use lettre::address::Envelope; + use lettre::transport::smtp::response::{Category, Code, Detail, Response, Severity}; + use lettre::AsyncTransport; + + #[derive(Clone)] + pub struct TestAsyncSmtpTransport { + response: Response, + log: Arc>>, + } + + impl TestAsyncSmtpTransport { + pub fn new() -> TestAsyncSmtpTransport { + let code = Code::new( + Severity::PositiveCompletion, + Category::Information, + Detail::Zero, + ); + + return TestAsyncSmtpTransport { + response: Response::new(code, vec![]), + log: Arc::new(Mutex::new(Vec::new())), + }; + } + + pub fn get_logs(&self) -> Vec<(Envelope, String)> { + return self.log.lock().unwrap().clone(); + } + } + + #[async_trait::async_trait] + impl AsyncTransport for TestAsyncSmtpTransport { + type Ok = lettre::transport::smtp::response::Response; + type Error = lettre::transport::smtp::Error; + + async fn send_raw(&self, envelope: &Envelope, email: &[u8]) -> Result { + self + .log + .lock() + .unwrap() + .push((envelope.clone(), String::from_utf8_lossy(email).into())); + + return Ok(self.response.clone()); + } + } +} diff --git a/trailbase-core/src/extract/either.rs b/trailbase-core/src/extract/either.rs new file mode 100644 index 0000000..024f41e --- /dev/null +++ b/trailbase-core/src/extract/either.rs @@ -0,0 +1,219 @@ +use axum::async_trait; +use axum::extract::{rejection::*, Form, FromRequest, Request}; +use axum::http::header::CONTENT_TYPE; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use log::*; +use serde::de::DeserializeOwned; +use serde::Serialize; +use thiserror::Error; +use trailbase_sqlite::schema::FileUploadInput; + +use crate::extract::multipart::{parse_multipart, Rejection as MultipartRejection}; + +#[derive(Debug, Error)] +pub enum EitherRejection { + // #[error("Missing Content-Type")] + // MissingContentType, + #[error("Unsupported Content-Type found")] + UnsupportedContentType, + #[error("Form error: {0}")] + Form(#[from] FormRejection), + #[error("Json error: {0}")] + Json(#[from] JsonRejection), + #[error("Multipart error: {0}")] + Multipart(#[from] MultipartRejection), +} + +impl IntoResponse for EitherRejection { + fn into_response(self) -> Response { + return (StatusCode::BAD_REQUEST, format!("{self:?}")).into_response(); + } +} + +// NOTE: For serde_json::Value as T, the different formats will produce very different results, +// e.g. json has a notion of types, whereas Multipart and Form don't. They're s practically a: +// Map> +#[derive(Debug)] +pub enum Either { + Json(T), + Multipart(T, Vec), + Form(T), + // Proto(DynamicMessage), +} + +#[async_trait] +impl FromRequest for Either +where + T: DeserializeOwned + Sync + Send + 'static, + S: Send + Sync, +{ + type Rejection = EitherRejection; + + async fn from_request(req: Request, state: &S) -> Result { + return match req.headers().get(CONTENT_TYPE) { + Some(x) if x.as_ref().starts_with(b"application/json") => { + let Json(value): Json = Json::from_request(req, state).await?; + Ok(Either::Json(value)) + } + Some(x) if x.as_ref().starts_with(b"application/x-www-form-urlencoded") => { + let Form(value): Form = Form::from_request(req, state).await?; + Ok(Either::Form(value)) + } + Some(x) if x.as_ref().starts_with(b"multipart/form-data") => { + let (value, files) = parse_multipart(req).await?; + Ok(Either::Multipart(value, files)) + } + // Some(x) if x == "application/x-protobuf" => { + // return Ok(Either::Proto(DynamicMessage::decode::from_request(req, + // state).await.unwrap())); } + Some(_) => Err(EitherRejection::UnsupportedContentType), + None => { + // TODO: Not convinced this is a sensible default for "None" but convenient for testing with + // curl. + let Json(value): Json = Json::from_request(req, state).await?; + Ok(Either::Json(value)) + // Err(EitherRejection::MissingContentType), + } + }; + } +} + +impl IntoResponse for Either +where + T: Serialize, +{ + fn into_response(self) -> Response { + match self { + Either::Json(json) => axum::Json(json).into_response(), + Either::Multipart(form, _files) => { + // Fixme: We would probably have to grab for multer (what Axum uses under the hood). But + // also not sure, server->client comms as a multipart form is even makes sense. + axum::Json(form).into_response() + } + Either::Form(form) => axum::Form(form).into_response(), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use indoc::indoc; + use serde::Serialize; + + #[derive(Debug, Serialize)] + struct Request { + value: u16, + } + + async fn handler(req: Either) -> (u16, String) { + return match req { + Either::Json(r) => (r.value, "JSON".to_string()), + _ => (0, "ERROR".to_string()), + }; + } + + #[tokio::test] + async fn test_handler() { + let (code, _msg) = handler(Either::Json(Request { value: 42 })).await; + assert_eq!(code, 42); + } + + #[tokio::test] + async fn test_from_request_for_multipart() -> Result<(), anyhow::Error> { + let body = indoc! {r#" + --fieldB + Content-Disposition: form-data; name="name" + + test + --fieldB + Content-Disposition: form-data; name="file1"; filename="a.txt" + Content-Type: text/plain + + Some text + --fieldB-- + "#} + .replace("\n", "\r\n"); + + let request = axum::http::Request::builder() + .header("content-type", "multipart/form-data; boundary=fieldB") + .header("content-length", body.len()) + .body(axum::body::Body::from(body)) + .unwrap(); + + let e = Either::::from_request(request, &()).await?; + + assert!(matches!(e, Either::Multipart(..))); + + return Ok(()); + } + + #[tokio::test] + async fn test_from_request_for_json() -> Result<(), anyhow::Error> { + let body = indoc! {r#" + { + "foo": 42, + "bar": ["a", "b"] + } + "#}; + + let request = axum::http::Request::builder() + .header("content-type", "application/json; boundary=fieldB") + .header("content-length", body.len()) + .body(axum::body::Body::from(body)) + .unwrap(); + + let e = Either::::from_request(request, &()).await?; + + assert!(matches!(e, Either::Json(..))); + + if let Either::Json(value) = e { + assert_eq!( + value, + serde_json::json!({ + "foo": 42, + "bar": vec!["a", "b"], + }) + ); + } + + return Ok(()); + } + + #[tokio::test] + async fn test_from_request_for_urlencoding() -> Result<(), anyhow::Error> { + let input = serde_json::json!({ + "foo": 42, + "bar": "a", + "baz": "b", + }); + + let body = serde_urlencoded::to_string(&input)?; + + let request = axum::http::Request::builder() + .header("content-type", "application/x-www-form-urlencoded") + .header("content-length", body.len()) + .method("POST") + .body(axum::body::Body::from(body)) + .unwrap(); + + let e = Either::::from_request(request, &()).await?; + + let Either::Form(value) = e else { + panic!("{e:?}"); + }; + + assert_eq!( + value, + serde_json::json!({ + "foo": "42", + "bar": "a", + "baz": "b", + }) + ); + + return Ok(()); + } +} diff --git a/trailbase-core/src/extract/mod.rs b/trailbase-core/src/extract/mod.rs new file mode 100644 index 0000000..eb67dae --- /dev/null +++ b/trailbase-core/src/extract/mod.rs @@ -0,0 +1,4 @@ +mod either; +mod multipart; + +pub use either::Either; diff --git a/trailbase-core/src/extract/multipart.rs b/trailbase-core/src/extract/multipart.rs new file mode 100644 index 0000000..b193585 --- /dev/null +++ b/trailbase-core/src/extract/multipart.rs @@ -0,0 +1,165 @@ +//! Parse multipart form requests +use axum::{ + body::Body, + extract::{FromRequest, Request}, +}; +use serde::de::DeserializeOwned; +use serde_json::json; +use thiserror::Error; +use trailbase_sqlite::schema::FileUploadInput; + +#[derive(Debug, Error)] +pub enum Rejection { + #[error("Failed to read request body: {0}")] + ReadBody(#[from] axum::Error), + #[error("Failed to read multipart payload: {0}")] + Multipart(#[from] axum::extract::multipart::MultipartRejection), + #[error("Failed to deserialize Multipart: {0}")] + MultipartField(#[from] axum::extract::multipart::MultipartError), + #[error("Failed to deserialize JSON: {0}")] + Serde(#[from] serde_path_to_error::Error), + #[error("Precondition error: {0}")] + Precondition(&'static str), +} + +/// Parse a multipart form submission into the specified type and a list of files uploaded with it. +/// +/// Note, when encountering stream errors one should check the tower limit layers. The error is +/// pretty cryptic when the stream gets cut off. +pub async fn parse_multipart(req: Request) -> Result<(T, Vec), Rejection> +where + T: DeserializeOwned + Send + Sync + 'static, +{ + let mut multipart = axum::extract::Multipart::from_request(req, &()).await?; + + let mut data = serde_json::Map::::new(); + let mut files: Vec = vec![]; + + while let Some(mut field) = multipart.next_field().await? { + if field.file_name().is_some() { + // We + + let content_type = field.content_type().map(|s| s.to_string()); + let name = field.name().map(|s| s.to_string()); + let filename = field.file_name().map(|s| s.to_string()); + + let mut buffer: Vec = vec![]; + while let Some(chunk) = field.chunk().await? { + buffer.extend_from_slice(&chunk); + } + + // Forms submit an empty string for optional file inputs :/. + if buffer.is_empty() { + continue; + } + + files.push(FileUploadInput { + name, + filename, + content_type, + data: buffer, + }); + } else if let Some(name) = field.name() { + coerce_and_push_array(&mut data, name.to_string(), json!(field.text().await?)); + } else { + // We consider form fields that neither have a filename nor a name to be invalid. + return Err(Rejection::Precondition("Neither name nor filename")); + } + } + + return Ok(( + serde_path_to_error::deserialize(json!(data)).map_err(Rejection::Serde)?, + files, + )); +} + +/// Adds ([key], [value]) to [map], first as value and subsequently as an array, i.e. +/// `map[key]=[v0, v1, ...]`. +fn coerce_and_push_array( + map: &mut serde_json::Map, + key: String, + value: serde_json::Value, +) { + return match map.get_mut(&key) { + Some(serde_json::Value::Array(a)) => { + a.push(value); + } + Some(v) => { + let old = v.take(); + *v = json!([old, value]); + } + None => { + map.insert(key, value); + } + }; +} + +#[cfg(test)] +mod test { + use super::*; + use indoc::indoc; + + fn get_req() -> axum::http::Request { + let body = indoc! {r#" + --fieldB + Content-Disposition: form-data; name="name" + + test + --fieldB + Content-Disposition: form-data; name="file1"; filename="a.txt" + Content-Type: text/plain + + Some text + --fieldB + Content-Disposition: form-data; name="file2"; filename="a.html" + Content-Type: text/html + + Some html + --fieldB + Content-Disposition: form-data; name="agreed" + + on + --fieldB-- + "#} + .replace("\n", "\r\n"); + + axum::http::Request::builder() + .header("content-type", "multipart/form-data; boundary=fieldB") + .header("content-length", body.len()) + .body(axum::body::Body::from(body)) + .unwrap() + } + + #[tokio::test] + async fn parse_multipart_jsonvalue() { + let data = get_req(); + let (value, files) = super::parse_multipart::(data) + .await + .unwrap(); + assert_eq!( + value, + json!({ + "name": "test", + "agreed": "on" + }) + ); + + assert_eq!( + files, + vec![ + (FileUploadInput { + name: Some("file1".to_string()), + filename: Some("a.txt".to_string()), + content_type: Some("text/plain".to_string()), + data: Vec::from("Some text".as_bytes()) + }), + (FileUploadInput { + name: Some("file2".to_string()), + filename: Some("a.html".to_string()), + content_type: Some("text/html".to_string()), + data: Vec::from("Some html".as_bytes()) + }), + ] + ); + } +} diff --git a/trailbase-core/src/lib.rs b/trailbase-core/src/lib.rs new file mode 100644 index 0000000..bae8356 --- /dev/null +++ b/trailbase-core/src/lib.rs @@ -0,0 +1,82 @@ +#![allow(clippy::needless_return)] + +pub mod app_state; +pub mod assets; +pub mod config; +pub mod constants; +pub mod logging; +pub mod records; +pub mod util; + +mod admin; +mod auth; +mod data_dir; +mod email; +mod extract; +mod listing; +mod migrations; +mod query; +mod scheduler; +mod schema; +mod server; +mod table_metadata; +mod transaction; +mod value_notifier; + +#[cfg(test)] +mod test; + +pub use app_state::AppState; +pub use auth::User; +pub use data_dir::DataDir; +pub use server::{InitError, Server, ServerOptions}; + +use prost_reflect::DescriptorPool; +use std::sync::LazyLock; + +static FILE_DESCRIPTOR_SET: &[u8] = + include_bytes!(concat!(env!("OUT_DIR"), "/file_descriptor_set.bin")); + +static DESCRIPTOR_POOL: LazyLock = LazyLock::new(|| { + DescriptorPool::decode(FILE_DESCRIPTOR_SET).expect("Failed to load file descriptor set") +}); + +pub mod openapi { + use utoipa::OpenApi; + + #[derive(OpenApi)] + #[openapi( + modifiers(), + nest( + (path = "/api/auth/v1", api = crate::auth::AuthAPI), + (path = "/api/records/v1", api = crate::records::RecordOpenApi), + ), + tags() + )] + pub struct Doc; +} + +pub mod api { + pub use trailbase_sqlite::{connect_sqlite, query_one_row}; + + pub use crate::admin::user::{create_user_handler, CreateUserRequest}; + pub use crate::auth::api::login::login_with_password; + pub use crate::auth::{force_password_reset, JwtHelper, TokenClaims}; + pub use crate::email::{Email, EmailError}; + pub use crate::migrations::new_unique_migration_filename; + pub use crate::server::init_app_state; + pub use crate::table_metadata::{build_json_schema, JsonSchemaMode, TableMetadataCache}; +} + +pub(crate) mod rand { + use rand::{distributions::Alphanumeric, prelude::*, rngs::OsRng}; + + pub(crate) fn generate_random_string(length: usize) -> String { + let csprng = OsRng {}; + return csprng + .sample_iter(&Alphanumeric) + .take(length) + .map(char::from) + .collect(); + } +} diff --git a/trailbase-core/src/listing.rs b/trailbase-core/src/listing.rs new file mode 100644 index 0000000..f59efac --- /dev/null +++ b/trailbase-core/src/listing.rs @@ -0,0 +1,376 @@ +use lazy_static::lazy_static; +use log::*; +use std::collections::HashMap; +use thiserror::Error; + +use crate::records::json_to_sql::simple_json_value_to_param; +use crate::table_metadata::TableOrViewMetadata; +use crate::util::b64_to_id; + +#[derive(Debug, Error)] +pub enum WhereClauseError { + #[error("Libsql error: {0}")] + Libsql(#[from] libsql::Error), + #[error("Parse error: {0}")] + Parse(String), + #[error("Base64 decoding error: {0}")] + Base64Decode(#[from] base64::DecodeError), + #[error("Not implemented error: {0}")] + NotImplemented(String), + #[error("Unrecognized param error: {0}")] + UnrecognizedParam(String), +} + +// Syntax: ?key[gte]=value&key[lte]=value +#[derive(Default, Debug, PartialEq)] +pub struct QueryParam { + pub value: String, + /// Qualifier or operation such as "greater-than"; + pub qualifier: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Qualifier { + Not, + Equal, + NotEqual, + GreaterThanEqual, + GreaterThan, + LessThanEqual, + LessThan, + Like, + Regexp, +} + +impl Qualifier { + fn from(qualifier: Option<&str>) -> Option { + return match qualifier { + Some("gte") => Some(Self::GreaterThanEqual), + Some("gt") => Some(Self::GreaterThan), + Some("lte") => Some(Self::LessThanEqual), + Some("lt") => Some(Self::LessThan), + Some("not") => Some(Self::Not), + Some("ne") => Some(Self::NotEqual), + Some("like") => Some(Self::Like), + Some("re") => Some(Self::Regexp), + None => Some(Self::Equal), + _ => None, + }; + } + + fn to_sql(self) -> &'static str { + return match self { + Self::GreaterThanEqual => ">=", + Self::GreaterThan => ">", + Self::LessThanEqual => "<=", + Self::LessThan => "<", + Self::Not => "<>", + Self::NotEqual => "<>", + Self::Like => "LIKE", + Self::Regexp => "REGEXP", + Self::Equal => "=", + }; + } +} + +#[derive(PartialEq, PartialOrd, Debug, Clone)] +pub enum Order { + Ascending, + Descending, +} + +#[derive(Default, Debug)] +pub struct QueryParseResult { + // Pagination parameters. + pub limit: Option, + pub cursor: Option<[u8; 16]>, + pub offset: Option, + + // Ordering. It's a vector for &order=-col0,+col1,col2 + pub order: Option>, + + // Map from filter params to filter value. It's a vector in cases like + // "col0[gte]=2&col0[lte]=10". + pub params: HashMap>, +} + +pub fn limit_or_default(limit: Option) -> usize { + const DEFAULT_LIMIT: usize = 50; + const MAX_LIMIT: usize = 256; + + return std::cmp::min(limit.unwrap_or(DEFAULT_LIMIT), MAX_LIMIT); +} + +/// Parses out list-related query params including pagination (limit, cursort), order, and filters. +/// +/// An example query may look like: +/// ?cursor=[0:16]&limit=50&order=price,-date&price[lte]=100&date[gte]=. +pub fn parse_query(query: Option) -> Option { + let q = query?; + if q.is_empty() { + return None; + } + + let mut result: QueryParseResult = Default::default(); + for (key, value) in form_urlencoded::parse(q.as_bytes()) { + match key.as_ref() { + "limit" => result.limit = value.parse::().ok(), + "cursor" => result.cursor = b64_to_id(value.as_ref()).ok(), + "offset" => result.offset = value.parse::().ok(), + "order" => { + let order: Vec<(String, Order)> = value + .split(",") + .map(|v| { + return match v { + x if x.starts_with("-") => (v[1..].to_string(), Order::Descending), + x if x.starts_with("+") => (v[1..].to_string(), Order::Ascending), + x => (x.to_string(), Order::Ascending), + }; + }) + .collect(); + + result.order = Some(order); + } + key => { + // Key didn't match any of the predefined list operations (limit, cursor, order), we thus + // assume it's a column filter. We try to split any qualifier/operation, e.g. + // column[op]=value. + let Some((k, maybe_op)) = split_key_into_col_and_op(key) else { + #[cfg(debug_assertions)] + debug!("skipping query param: {key}={value}"); + + continue; + }; + + if value.is_empty() { + continue; + } + + let query_param = QueryParam { + value: value.to_string(), + qualifier: Qualifier::from(maybe_op), + }; + + if let Some(v) = result.params.get_mut(k) { + v.push(query_param) + } else { + result.params.insert(k.to_string(), vec![query_param]); + } + } + } + } + + return Some(result); +} + +#[derive(Debug, Clone)] +pub struct WhereClause { + pub clause: String, + pub params: Vec<(String, libsql::Value)>, +} + +pub fn build_filter_where_clause( + table_metadata: &dyn TableOrViewMetadata, + filter_params: Option>>, +) -> Result { + let mut where_clauses: Vec = vec![]; + let mut params: Vec<(String, libsql::Value)> = vec![]; + + if let Some(filter_params) = filter_params { + for (column_name, query_params) in filter_params { + if column_name.starts_with("_") { + return Err(WhereClauseError::UnrecognizedParam(format!( + "Invalid parameter: {column_name}" + ))); + } + + let Some((col, _col_meta)) = table_metadata.column_by_name(&column_name) else { + return Err(WhereClauseError::UnrecognizedParam(format!( + "Unrecognized parameter: {column_name}" + ))); + }; + + for query_param in query_params { + let Some(op) = query_param.qualifier.map(|q| q.to_sql()) else { + info!("No op for: {column_name}={query_param:?}"); + continue; + }; + + match simple_json_value_to_param( + col.data_type, + serde_json::Value::String(query_param.value.clone()), + ) { + Ok(value) => { + let clause = format!("{column_name} {op} :{column_name}"); + where_clauses.push(clause); + params.push((format!(":{column_name}"), value)); + } + Err(err) => debug!("Parameter conversion for {column_name} failed: {err}"), + }; + } + } + } + + let clause = match where_clauses.len() { + 0 => "TRUE".to_string(), + _ => where_clauses.join(" AND "), + }; + + return Ok(WhereClause { clause, params }); +} + +fn split_key_into_col_and_op(key: &str) -> Option<(&str, Option<&str>)> { + let Some(captures) = QUALIFIER_REGEX.captures(key) else { + // Regex didn't match, i.e. key has invalid format. + return None; + }; + + let Some(k) = captures.name("key") else { + // No "key" component, i.e. key has invalid format. + return None; + }; + + return Some((k.as_str(), captures.name("qualifier").map(|c| c.as_str()))); +} + +lazy_static! { + /// Regex that splits the key part of "column[op]=value", i.e. column & op. + static ref QUALIFIER_REGEX: regex::Regex = + regex::Regex::new(r"^(?\w*)(?:\[(?\w+)\])?$").unwrap(); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::id_to_b64; + + #[test] + fn test_op_splitting_regex() { + assert_eq!(split_key_into_col_and_op("o82@!&#"), None); + assert_eq!(split_key_into_col_and_op("a b"), None); + + // Check valid column names + assert_eq!(split_key_into_col_and_op("foo"), Some(("foo", None))); + assert_eq!(split_key_into_col_and_op("_foo"), Some(("_foo", None))); + + // Check with ops + assert_eq!( + split_key_into_col_and_op("_foo[gte]"), + Some(("_foo", Some("gte"))) + ); + assert_eq!(split_key_into_col_and_op("_foo[$!]"), None); + } + + #[test] + fn test_query_parsing() { + assert!(parse_query(None).is_none()); + assert!(parse_query(Some("".to_string())).is_none()); + + { + let cursor: [u8; 16] = [0; 16]; + // Note that "+" is encoded as %2b, otherwise it's interpreted as a space. That's barely an + // inconvenience since + is implied and "-" is fine, so there's no real reason to supply "+" + // explicitly. + let query = Some(format!( + "limit=10&cursor={cursor}&order=%2bcol0,-col1,col2", + cursor = id_to_b64(&cursor) + )); + let result = parse_query(query).unwrap(); + + assert_eq!(result.limit, Some(10)); + assert_eq!(result.cursor, Some(cursor)); + assert_eq!( + result.order.unwrap(), + vec![ + ("col0".to_string(), Order::Ascending), + ("col1".to_string(), Order::Descending), + ("col2".to_string(), Order::Ascending), + ] + ); + } + + { + let query = Some("foo,bar&foo_bar&baz=23&bar[like]=foo".to_string()); + let result = parse_query(query).unwrap(); + + // foo,bar is an invalid key. + assert_eq!(result.params.get("foo,bar"), None); + assert_eq!(result.params.get("foo_bar"), None); + assert_eq!( + result.params.get("baz").unwrap(), + &vec![QueryParam { + value: "23".to_string(), + qualifier: Some(Qualifier::Equal), + }] + ); + assert_eq!( + result.params.get("bar").unwrap(), + &vec![QueryParam { + value: "foo".to_string(), + qualifier: Some(Qualifier::Like), + }] + ); + } + + { + // Check whitespaces + let query = Some("foo=a+b&bar=a%20b".to_string()); + let result = parse_query(query).unwrap(); + + assert_eq!( + result.params.get("foo").unwrap(), + &vec![QueryParam { + value: "a b".to_string(), + qualifier: Some(Qualifier::Equal), + }] + ); + assert_eq!( + result.params.get("bar").unwrap(), + &vec![QueryParam { + value: "a b".to_string(), + qualifier: Some(Qualifier::Equal), + }] + ); + } + + { + let query = Some("col_0[gte]=10&col_0[lte]=100".to_string()); + let result = parse_query(query).unwrap(); + + assert_eq!( + result.params.get("col_0"), + Some(vec![ + QueryParam { + value: "10".to_string(), + qualifier: Some(Qualifier::GreaterThanEqual), + }, + QueryParam { + value: "100".to_string(), + qualifier: Some(Qualifier::LessThanEqual), + }, + ]) + .as_ref(), + "{:?}", + result.params + ); + } + + { + // Test both encodings: "+" and %20 for " ". + let value = "with+white%20spaces"; + let query = Some(format!("text={value}")); + let result = parse_query(query).unwrap(); + + assert_eq!( + result.params.get("text"), + Some(vec![QueryParam { + value: "with white spaces".to_string(), + qualifier: Some(Qualifier::Equal), + },]) + .as_ref(), + "{:?}", + result.params + ); + } + } +} diff --git a/trailbase-core/src/logging.rs b/trailbase-core/src/logging.rs new file mode 100644 index 0000000..bb93007 --- /dev/null +++ b/trailbase-core/src/logging.rs @@ -0,0 +1,397 @@ +use axum::body::Body; +use axum::http::{header::HeaderMap, Request}; +use axum::response::Response; +use axum_client_ip::InsecureClientIp; +use libsql::{params, Connection}; +use log::*; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::collections::BTreeMap; +use std::time::Duration; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; +use tracing::field::Field; +use tracing::span::{Attributes, Id, Record, Span}; +use tracing_subscriber::layer::{Context, Layer}; + +use crate::AppState; + +// Memo to my future self. +// +// Tracing is quite sweet but also utterly decoupled. There are several moving parts. +// +// * There's a tracing layer installed into the axum/tower server, which declares *what* +// information goes into traces, i.e. which fields go into spans and events. An event (e.g. +// on-request, on-response) can comprise a list of spans. +// * There's a central tracing_subscriber::registry(), where one can register subscribers like an +// stderr, file, or sqlite logger, that define how traces are being processed. +// * Finally, we have a task to receive logs from our sqlite tracing subscribers and write them to +// the database. +// * We have a period task to wipe logs past their retention. +// +#[repr(i32)] +#[derive(Debug, Clone, Serialize, Deserialize)] +enum LogType { + Undefined = 0, + AdminRequest = 1, + HttpRequest = 2, + RecordApiRequest = 3, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct Log { + pub id: Option<[u8; 16]>, + pub created: Option, + pub r#type: i32, + + pub level: i32, + pub status: u16, + pub method: String, + pub url: String, + + // milliseconds + pub latency: f64, + pub client_ip: String, + pub referer: String, + pub user_agent: String, + + pub data: Option, +} + +// The writer runs in a separate Task in the background and receives Logs via a channel, which it +// then writes to Sqlite. +// +// TODO: should we use a bound receiver to create back pressure? +// TODO: use recv_many() and batch insert. +async fn logs_writer(logs_conn: Connection, mut receiver: UnboundedReceiver) { + while let Some(log) = receiver.recv().await { + let result = logs_conn + .execute( + r#" + INSERT INTO + _logs (type, level, status, method, url, latency, client_ip, referer, user_agent) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9) + "#, + params!( + log.r#type, + log.level, + log.status, + log.method, + log.url, + log.latency, + log.client_ip, + log.referer, + log.user_agent + ), + ) + .await; + + if let Err(err) = result { + warn!("logs writing failed: {err}"); + } + } +} + +pub(super) fn sqlite_logger_make_span(request: &Request) -> Span { + let headers = request.headers(); + let host = get_header(headers, "host").unwrap_or(""); + let user_agent = get_header(headers, "user-agent").unwrap_or(""); + let referer = get_header(headers, "referer").unwrap_or(""); + let client_ip = InsecureClientIp::from(headers, request.extensions()) + .map(|ip| ip.0.to_string()) + .unwrap_or_else(|_| "".to_string()); + // let extensions = request.extensions().get::>(); + + let span = tracing::info_span!( + "request", + method = %request.method(), + uri = %request.uri(), + version = ?request.version(), + host, + client_ip, + user_agent, + referer, + ); + + return span; +} + +pub(super) fn sqlite_logger_on_request(_req: &Request, _span: &Span) { + // We're deliberately not creating a request event, since we're already inserting all the + // request related information into the span +} + +fn as_millis_f64(d: &Duration) -> f64 { + const NANOS_PER_MILLI: f64 = 1_000_000.0; + const MILLIS_PER_SEC: u64 = 1_000; + return d.as_secs_f64() * (MILLIS_PER_SEC as f64) + (d.as_nanos() as f64) / (NANOS_PER_MILLI); +} + +pub(super) fn sqlite_logger_on_response( + response: &Response, + latency: Duration, + _span: &Span, +) { + let length = get_header(response.headers(), "content-length").unwrap_or("-1"); + + tracing::info!( + name: "response", + latency_ms = as_millis_f64(&latency), + status = response.status().as_u16(), + length = length.parse::().unwrap(), + ); +} + +pub struct SqliteLogLayer { + sender: UnboundedSender, + handle: tokio::task::AbortHandle, +} + +impl SqliteLogLayer { + pub fn new(state: &AppState) -> Self { + let (sender, abort_handle) = { + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::(); + let writer = tokio::spawn(logs_writer(state.logs_conn().clone(), receiver)); + (sender, writer.abort_handle()) + }; + + return SqliteLogLayer { + sender, + handle: abort_handle, + }; + } +} + +impl Drop for SqliteLogLayer { + fn drop(&mut self) { + self.handle.abort(); + } +} + +impl Layer for SqliteLogLayer +where + S: tracing::Subscriber, + S: for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>, +{ + fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) { + let span = ctx.span(id).unwrap(); + + // let mut fields = BTreeMap::new(); + // attrs.record(&mut JsonVisitor(&mut fields)); + // span.extensions_mut().insert(CustomFieldStorage(fields)); + + let mut storage = LogFieldStorage::default(); + attrs.record(&mut LogJsonVisitor(&mut storage)); + span.extensions_mut().insert(storage); + } + + fn on_record(&self, id: &Id, values: &Record<'_>, ctx: Context<'_, S>) { + // Get a mutable reference to the data we created in new_span + let span = ctx.span(id).unwrap(); + let mut extensions_mut = span.extensions_mut(); + + // And add to using our old friend the visitor! + // let custom_field_storage = extensions_mut.get_mut::().unwrap(); + // values.record(&mut JsonVisitor(&mut custom_field_storage.0)); + + let log_field_storage = extensions_mut.get_mut::().unwrap(); + values.record(&mut LogJsonVisitor(log_field_storage)); + } + + fn on_event(&self, event: &tracing::Event<'_>, ctx: Context<'_, S>) { + let mut request_storage: Option = None; + + let scope = ctx.event_scope(event).unwrap(); + for span in scope.from_root() { + // TODO: we should be merging here to account for multiple spans. Maybe we should have a json + // span representation in the data field. + let extensions = span.extensions(); + if let Some(storage) = extensions.get::() { + request_storage = Some(storage.clone()); + } + } + + // The fields of the event + // let mut fields = BTreeMap::new(); + // event.record(&mut JsonVisitor(&mut fields)); + // let output = json!({ + // "target": event.metadata().target(), + // "name": event.metadata().name(), + // "level": format!("{:?}", event.metadata().level()), + // "fields": fields, + // "spans": spans, + // }); + // println!("{}", serde_json::to_string_pretty(&output).unwrap()); + + if let Some(mut storage) = request_storage { + event.record(&mut LogJsonVisitor(&mut storage)); + + let log = Log { + id: None, + created: None, + // FIXME: Is it a admin/records/auth,plain http request...? + // Or should this even be here? Couldn't we just infer client-side by prefix? + r#type: LogType::HttpRequest as i32, + level: level_to_int(event.metadata().level()), + status: storage.status as u16, + method: storage.method, + url: storage.uri, + latency: storage.latency_ms, + client_ip: storage.client_ip, + referer: storage.referer, + user_agent: storage.user_agent, + data: Some(json!(storage.fields)), + }; + + if let Err(err) = self.sender.send(log) { + warn!("Failed to send to logs to writer: {err}"); + } + } + } +} + +fn level_to_int(level: &tracing::Level) -> i32 { + match *level { + tracing::Level::TRACE => 4, + tracing::Level::DEBUG => 3, + tracing::Level::INFO => 2, + tracing::Level::WARN => 1, + tracing::Level::ERROR => 0, + } +} + +#[derive(Debug, Default, Clone)] +struct LogFieldStorage { + // Request fields/properties. + method: String, + uri: String, + client_ip: String, + host: String, + referer: String, + user_agent: String, + version: String, + + // Response fields/properties + status: u64, + latency_ms: f64, + length: i64, + + // All other fields. + fields: BTreeMap, +} + +struct LogJsonVisitor<'a>(&'a mut LogFieldStorage); + +impl tracing::field::Visit for LogJsonVisitor<'_> { + fn record_f64(&mut self, field: &Field, double: f64) { + let name = field.name(); + match name { + "latency_ms" => self.0.latency_ms = double, + _ => { + self.0.fields.insert(name.to_string(), json!(double)); + } + }; + } + + fn record_i64(&mut self, field: &Field, int: i64) { + let name = field.name(); + match name { + "length" => self.0.length = int, + _ => { + self.0.fields.insert(name.to_string(), json!(int)); + } + }; + } + + fn record_u64(&mut self, field: &Field, uint: u64) { + let name = field.name(); + match name { + "status" => self.0.status = uint, + _ => { + self.0.fields.insert(name.to_string(), json!(uint)); + } + }; + } + + fn record_bool(&mut self, field: &Field, b: bool) { + self.0.fields.insert(field.name().to_string(), json!(b)); + } + + fn record_str(&mut self, field: &Field, s: &str) { + let name: &str = field.name(); + match name { + "client_ip" => self.0.client_ip = s.to_string(), + "host" => self.0.host = s.to_string(), + "referer" => self.0.referer = s.to_string(), + "user_agent" => self.0.user_agent = s.to_string(), + name => { + self.0.fields.insert(name.to_string(), json!(s)); + } + }; + } + + fn record_debug(&mut self, field: &Field, dbg: &dyn std::fmt::Debug) { + let name = field.name(); + let v = format!("{:?}", dbg); + match name { + "method" => self.0.method = v, + "uri" => self.0.uri = v, + "version" => self.0.version = v, + name => { + self.0.fields.insert(name.to_string(), json!(v)); + } + }; + } + + fn record_error(&mut self, field: &Field, err: &(dyn std::error::Error + 'static)) { + self + .0 + .fields + .insert(field.name().to_string(), json!(err.to_string())); + } +} + +// #[derive(Debug)] +// struct CustomFieldStorage(BTreeMap); +// +// struct JsonVisitor<'a>(&'a mut BTreeMap); +// +// impl<'a> tracing::field::Visit for JsonVisitor<'a> { +// fn record_f64(&mut self, field: &Field, double: f64) { +// self.0.insert(field.name().to_string(), json!(double)); +// } +// +// fn record_i64(&mut self, field: &Field, int: i64) { +// self.0.insert(field.name().to_string(), json!(int)); +// } +// +// fn record_u64(&mut self, field: &Field, uint: u64) { +// self.0.insert(field.name().to_string(), json!(uint)); +// } +// +// fn record_bool(&mut self, field: &Field, b: bool) { +// self.0.insert(field.name().to_string(), json!(b)); +// } +// +// fn record_str(&mut self, field: &Field, s: &str) { +// self.0.insert(field.name().to_string(), json!(s)); +// } +// +// fn record_error(&mut self, field: &Field, err: &(dyn std::error::Error + 'static)) { +// self +// .0 +// .insert(field.name().to_string(), json!(err.to_string())); +// } +// +// fn record_debug(&mut self, field: &Field, dbg: &dyn std::fmt::Debug) { +// self +// .0 +// .insert(field.name().to_string(), json!(format!("{:?}", dbg))); +// } +// } + +fn get_header<'a>(headers: &'a HeaderMap, header_name: &'static str) -> Option<&'a str> { + headers + .get(header_name) + .and_then(|header_value| header_value.to_str().ok()) +} diff --git a/trailbase-core/src/migrations.rs b/trailbase-core/src/migrations.rs new file mode 100644 index 0000000..1641d65 --- /dev/null +++ b/trailbase-core/src/migrations.rs @@ -0,0 +1,140 @@ +use lazy_static::lazy_static; +use libsql::Connection; +use log::*; +use parking_lot::Mutex; +use refinery::Migration; +use refinery_libsql::LibsqlConnection; +use std::path::PathBuf; + +mod main { + refinery::embed_migrations!("migrations/main"); +} +mod logs { + refinery::embed_migrations!("migrations/logs"); +} + +const MIGRATION_TABLE_NAME: &str = "_schema_history"; + +pub fn new_unique_migration_filename(suffix: &str) -> String { + let timestamp = { + // We use the timestamp as a version. We need to debounce it to avoid collisions. + lazy_static! { + static ref PREV_TIMESTAMP: Mutex = Mutex::new(0); + } + + let now = chrono::Utc::now().timestamp(); + let mut prev = PREV_TIMESTAMP.lock(); + + if now > *prev { + *prev = now; + now + } else { + *prev += 1; + *prev + } + }; + + return format!("U{timestamp}__{suffix}.sql"); +} + +pub(crate) fn new_migration_runner(migrations: &[Migration]) -> refinery::Runner { + // NOTE: divergent migrations are migrations with the same version but a different name. That + // said, `set_abort_divergent` is not a viable way for us to handle collisions (e.g. in tests), + // since setting it to false, will prevent the migration from failing but divergent migrations + // are quietly dropped on the floor and not applied. That's not ok. + let mut runner = refinery::Runner::new(migrations).set_abort_divergent(false); + runner.set_migration_table_name(MIGRATION_TABLE_NAME); + return runner; +} + +// The main migrations are bit tricky because they maybe a mix of user-provided and builtin +// migrations. They might event come out of order, e.g.: someone does a schema migration on an old +// version of the binary and then updates. Yet, they need to be applied in one go. We therefore +// rely on refinery's non-strictly versioned migrations prefixed with the "U" name. +pub(crate) async fn apply_main_migrations( + conn: Connection, + user_migrations_path: Option, +) -> Result { + let all_migrations = { + let mut migrations: Vec = vec![]; + + let system_migrations_runner = main::migrations::runner(); + migrations.extend(system_migrations_runner.get_migrations().iter().cloned()); + + if let Some(path) = user_migrations_path { + // NOTE: refinery has a bug where it will name-check the directory and write a warning... :/. + let user_migrations = refinery::load_sql_migrations(path)?; + migrations.extend(user_migrations.into_iter()); + } + + // Interleave the system and user migrations based on their version prefixes. + migrations.sort(); + + migrations + }; + + let mut conn = LibsqlConnection::from_connection(conn); + + let runner = new_migration_runner(&all_migrations); + let report = match runner.run_async(&mut conn).await { + Ok(report) => report, + Err(err) => { + error!("Main migrations: {err}"); + return Err(err); + } + }; + + for applied_migration in report.applied_migrations() { + if cfg!(test) { + debug!("applied migration: {applied_migration:?}"); + } else { + info!("applied migration: {applied_migration:?}"); + } + } + + // If we applied migration v1 we can be sure this is a fresh database. + let new_db = report.applied_migrations().iter().any(|m| m.version() == 1); + + return Ok(new_db); +} + +#[cfg(test)] +pub(crate) async fn apply_user_migrations(user_conn: Connection) -> Result<(), refinery::Error> { + let mut user_conn = LibsqlConnection::from_connection(user_conn); + + let mut runner = main::migrations::runner(); + runner.set_migration_table_name(MIGRATION_TABLE_NAME); + + let report = runner.run_async(&mut user_conn).await.map_err(|err| { + error!("User migrations: {err}"); + return err; + })?; + + if cfg!(test) { + debug!("user migrations: {report:?}"); + } else { + info!("user migrations: {report:?}"); + } + + return Ok(()); +} + +pub(crate) async fn apply_logs_migrations(logs_conn: Connection) -> Result<(), refinery::Error> { + let mut logs_conn = LibsqlConnection::from_connection(logs_conn); + + let mut runner = logs::migrations::runner(); + runner.set_migration_table_name(MIGRATION_TABLE_NAME); + + let report = runner.run_async(&mut logs_conn).await.map_err(|err| { + error!("Logs migrations: {err}"); + return err; + })?; + + if cfg!(test) { + debug!("Logs migrations: {report:?}"); + } else { + info!("Logs migrations: {report:?}"); + } + + return Ok(()); +} diff --git a/trailbase-core/src/query/error.rs b/trailbase-core/src/query/error.rs new file mode 100644 index 0000000..e774f59 --- /dev/null +++ b/trailbase-core/src/query/error.rs @@ -0,0 +1,76 @@ +use axum::body::Body; +use axum::http::{header::CONTENT_TYPE, StatusCode}; +use axum::response::{IntoResponse, Response}; +use log::*; +use thiserror::Error; + +/// Publicly visible errors of record APIs. +/// +/// This error is deliberately opaque and kept very close to HTTP error codes to avoid the leaking +/// of internals and provide a very clear mapping to codes. +/// NOTE: Do not use thiserror's #from, all mappings should be explicit. +#[derive(Debug, Error)] +pub enum QueryError { + #[error("Api Not Found")] + ApiNotFound, + #[error("Forbidden")] + Forbidden, + #[error("Bad request: {0}")] + BadRequest(&'static str), + #[error("Internal: {0}")] + Internal(Box), +} + +impl From for QueryError { + fn from(err: libsql::Error) -> Self { + return match err { + // libsql::Error::QueryReturnedNoRows => { + // #[cfg(debug_assertions)] + // info!("libsql returned empty rows error"); + // + // Self::RecordNotFound + // } + // List of error codes: https://www.sqlite.org/rescode.html + libsql::Error::SqliteFailure(275, _msg) => Self::BadRequest("sqlite constraint: check"), + libsql::Error::SqliteFailure(531, _msg) => Self::BadRequest("sqlite constraint: commit hook"), + libsql::Error::SqliteFailure(3091, _msg) => Self::BadRequest("sqlite constraint: data type"), + libsql::Error::SqliteFailure(787, _msg) => Self::BadRequest("sqlite constraint: fk"), + libsql::Error::SqliteFailure(1043, _msg) => Self::BadRequest("sqlite constraint: function"), + libsql::Error::SqliteFailure(1299, _msg) => Self::BadRequest("sqlite constraint: not null"), + libsql::Error::SqliteFailure(2835, _msg) => Self::BadRequest("sqlite constraint: pinned"), + libsql::Error::SqliteFailure(1555, _msg) => Self::BadRequest("sqlite constraint: pk"), + libsql::Error::SqliteFailure(2579, _msg) => Self::BadRequest("sqlite constraint: row id"), + libsql::Error::SqliteFailure(1811, _msg) => Self::BadRequest("sqlite constraint: trigger"), + libsql::Error::SqliteFailure(2067, _msg) => Self::BadRequest("sqlite constraint: unique"), + libsql::Error::SqliteFailure(2323, _msg) => Self::BadRequest("sqlite constraint: vtab"), + err => Self::Internal(err.into()), + }; + } +} + +impl IntoResponse for QueryError { + fn into_response(self) -> Response { + let (status, body) = match self { + Self::ApiNotFound => (StatusCode::METHOD_NOT_ALLOWED, None), + Self::Forbidden => (StatusCode::FORBIDDEN, None), + Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, Some(msg.to_string())), + Self::Internal(err) if cfg!(debug_assertions) => { + (StatusCode::INTERNAL_SERVER_ERROR, Some(err.to_string())) + } + Self::Internal(_err) => (StatusCode::INTERNAL_SERVER_ERROR, None), + }; + + if let Some(body) = body { + return Response::builder() + .status(status) + .header(CONTENT_TYPE, "text/plain") + .body(Body::new(body)) + .unwrap(); + } + + return Response::builder() + .status(status) + .body(Body::empty()) + .unwrap(); + } +} diff --git a/trailbase-core/src/query/mod.rs b/trailbase-core/src/query/mod.rs new file mode 100644 index 0000000..2a2bc87 --- /dev/null +++ b/trailbase-core/src/query/mod.rs @@ -0,0 +1,192 @@ +use axum::{ + extract::{Json, Path, RawQuery, State}, + routing::get, + Router, +}; +use base64::prelude::*; +use std::collections::HashMap; + +pub mod error; +pub mod query_api; + +pub use error::QueryError; +pub use query_api::QueryApi; + +use crate::auth::User; +use crate::config::proto::QueryApiParameterType; +use crate::records::sql_to_json::rows_to_json_arrays; +use crate::AppState; + +pub(crate) fn router() -> Router { + return Router::new().route("/:name", get(query_handler)); +} + +pub async fn query_handler( + State(state): State, + Path(api_name): Path, + RawQuery(query): RawQuery, + user: Option, +) -> Result, QueryError> { + use QueryError as E; + + let Some(api) = state.lookup_query_api(&api_name) else { + return Err(E::ApiNotFound); + }; + let virtual_table_name = api.virtual_table_name(); + + let mut query_params: HashMap = match query { + Some(ref query) => form_urlencoded::parse(query.as_bytes()) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + None => HashMap::new(), + }; + + let mut params: Vec<(String, libsql::Value)> = vec![]; + for (name, typ) in api.params() { + match query_params.remove(name) { + Some(value) => match *typ { + QueryApiParameterType::Text => { + params.push((format!(":{name}"), libsql::Value::Text(value.clone()))); + } + QueryApiParameterType::Blob => { + params.push(( + format!(":{name}"), + libsql::Value::Blob( + BASE64_URL_SAFE + .decode(value) + .map_err(|_err| E::BadRequest("not b64"))?, + ), + )); + } + QueryApiParameterType::Real => { + params.push(( + format!(":{name}"), + libsql::Value::Real( + value + .parse::() + .map_err(|_err| E::BadRequest("expected f64"))?, + ), + )); + } + QueryApiParameterType::Integer => { + params.push(( + format!(":{name}"), + libsql::Value::Integer( + value + .parse::() + .map_err(|_err| E::BadRequest("expected i64"))?, + ), + )); + } + }, + None => { + params.push((format!(":{name}"), libsql::Value::Null)); + } + }; + } + + if !query_params.is_empty() { + return Err(E::BadRequest("invalid query param")); + } + + api.check_api_access(¶ms, user.as_ref()).await?; + + const LIMIT: usize = 128; + let response_rows = state + .conn() + .query( + &format!( + "SELECT * FROM {virtual_table_name}({placeholders}) WHERE TRUE LIMIT {LIMIT}", + placeholders = params + .iter() + .map(|e| e.0.as_str()) + .collect::>() + .join(", ") + ), + libsql::params::Params::Named(params), + ) + .await?; + + let (json_rows, columns) = rows_to_json_arrays(response_rows, LIMIT) + .await + .map_err(|err| E::Internal(err.into()))?; + + let Some(columns) = columns else { + return Err(E::Internal("Missing column mapping".into())); + }; + + // Turn the list of lists into an array of row-objects. + let rows = serde_json::Value::Array( + json_rows + .into_iter() + .map(|row| { + return serde_json::Value::Object( + row + .into_iter() + .enumerate() + .map(|(idx, value)| (columns.get(idx).unwrap().name.clone(), value)) + .collect(), + ); + }) + .collect(), + ); + + return Ok(Json(rows)); +} + +#[cfg(test)] +mod test { + use super::*; + use axum::extract::{Json, Path, RawQuery, State}; + + use crate::app_state::*; + use crate::config::proto::{ + QueryApiAcl, QueryApiConfig, QueryApiParameter, QueryApiParameterType, + }; + + #[tokio::test] + async fn test_query_api() { + let state = test_state(None).await.unwrap(); + + let conn = state.conn(); + conn + .execute( + "CREATE VIRTUAL TABLE test_vtable USING define((SELECT $1 AS value))", + (), + ) + .await + .unwrap(); + + let mut config = state.get_config(); + config.query_apis.push(QueryApiConfig { + name: Some("test".to_string()), + virtual_table_name: Some("test_vtable".to_string()), + params: vec![QueryApiParameter { + name: Some("param0".to_string()), + r#type: Some(QueryApiParameterType::Text.into()), + }], + acl: Some(QueryApiAcl::World.into()), + access_rule: None, + }); + state + .validate_and_update_config(config, None) + .await + .unwrap(); + + let Json(response) = query_handler( + State(state), + Path("test".to_string()), + RawQuery(Some(r#"param0=test_param"#.to_string())), + None, + ) + .await + .unwrap(); + + assert_eq!( + response, + serde_json::json!([{ + "value": "test_param" + }]) + ); + } +} diff --git a/trailbase-core/src/query/query_api.rs b/trailbase-core/src/query/query_api.rs new file mode 100644 index 0000000..b42b016 --- /dev/null +++ b/trailbase-core/src/query/query_api.rs @@ -0,0 +1,156 @@ +use log::*; +use std::sync::Arc; + +use crate::auth::User; +use crate::config::proto::{QueryApiAcl, QueryApiConfig, QueryApiParameterType}; +use crate::query::QueryError; +use trailbase_sqlite::query_one_row; + +#[derive(Clone)] +pub struct QueryApi { + state: Arc, +} + +struct QueryApiState { + conn: libsql::Connection, + + api_name: String, + virtual_table_name: String, + params: Vec<(String, QueryApiParameterType)>, + + acl: Option, + access_rule: Option, +} + +impl QueryApi { + pub fn from(conn: libsql::Connection, config: QueryApiConfig) -> Result { + return Ok(QueryApi { + state: Arc::new(QueryApiState { + conn, + api_name: config.name.ok_or("Missing name".to_string())?, + virtual_table_name: config + .virtual_table_name + .ok_or("Missing vtable name".to_string())?, + params: config + .params + .iter() + .filter_map(|a| { + return match (&a.name, a.r#type) { + (Some(name), Some(typ)) => { + if let Ok(t) = typ.try_into() { + Some((name.clone(), t)) + } else { + None + } + } + _ => None, + }; + }) + .collect(), + acl: config.acl.and_then(|acl| acl.try_into().ok()), + access_rule: config.access_rule, + }), + }); + } + + #[inline] + pub fn api_name(&self) -> &str { + &self.state.api_name + } + + #[inline] + pub fn virtual_table_name(&self) -> &str { + return &self.state.virtual_table_name; + } + + #[inline] + pub fn params(&self) -> &Vec<(String, QueryApiParameterType)> { + return &self.state.params; + } + + pub(crate) async fn check_api_access( + &self, + query_params: &[(String, libsql::Value)], + user: Option<&User>, + ) -> Result<(), QueryError> { + let Some(acl) = self.state.acl else { + return Err(QueryError::Forbidden); + }; + + 'acl: { + match acl { + QueryApiAcl::Undefined => break 'acl, + QueryApiAcl::World => {} + QueryApiAcl::Authenticated => { + if user.is_none() { + break 'acl; + } + } + }; + + match self.state.access_rule { + None => return Ok(()), + Some(ref access_rule) => { + let params_subquery = query_params + .iter() + .filter_map(|(placeholder, _value)| { + let Some(name) = placeholder.strip_prefix(":") else { + warn!("Malformed placeholder: {placeholder}"); + return None; + }; + return Some(format!("{placeholder} AS {name}")); + }) + .collect::>() + .join(", "); + + let access_query = format!( + r#" + SELECT + ({access_rule}) + FROM + (SELECT :__user_id AS id) AS _USER_, + (SELECT {params_subquery}) AS _PARAMS_ + "#, + ); + + let mut params = query_params.to_vec(); + params.push(( + ":__user_id".to_string(), + user.map_or(libsql::Value::Null, |u| libsql::Value::Blob(u.uuid.into())), + )); + + let row = match query_one_row( + &self.state.conn, + &access_query, + libsql::params::Params::Named(params), + ) + .await + { + Ok(row) => row, + Err(err) => { + error!("Query API access query: '{access_query}' failed: {err}"); + break 'acl; + } + }; + + let allowed: bool = row.get(0).unwrap_or_else(|err| { + if cfg!(test) { + panic!( + "Query API access query returned NULL. Failing closed: '{access_query}'\n{err}" + ); + } + + warn!("RLA query returned NULL. Failing closed: '{access_query}'\n{err}"); + false + }); + + if allowed { + return Ok(()); + } + } + } + } + + return Err(QueryError::Forbidden); + } +} diff --git a/trailbase-core/src/records/create_record.rs b/trailbase-core/src/records/create_record.rs new file mode 100644 index 0000000..556c786 --- /dev/null +++ b/trailbase-core/src/records/create_record.rs @@ -0,0 +1,286 @@ +use axum::extract::{Json, Path, Query, State}; +use axum::response::{IntoResponse, Redirect, Response}; +use base64::prelude::*; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use crate::app_state::AppState; +use crate::auth::user::User; +use crate::extract::Either; +use crate::records::json_to_sql::{InsertQueryBuilder, LazyParams}; +use crate::records::{Permission, RecordError}; +use crate::schema::ColumnDataType; + +#[derive(Clone, Debug, Default, Deserialize, IntoParams)] +pub struct CreateRecordQuery { + pub redirect_to: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct CreateRecordResponse { + /// Safe-url base64 encoded id of the newly created record. + pub id: String, +} + +/// Create new record. +#[utoipa::path( + post, + path = "/:name", + params(CreateRecordQuery), + responses( + (status = 200, description = "Record id of successful insertion.", body = CreateRecordResponse), + ) +)] +pub async fn create_record_handler( + State(state): State, + Path(api_name): Path, + Query(create_record_query): Query, + user: Option, + either_request: Either, +) -> Result { + let Some(api) = state.lookup_record_api(&api_name) else { + return Err(RecordError::ApiNotFound); + }; + let table_metadata = api + .table_metadata() + .ok_or_else(|| RecordError::ApiRequiresTable)?; + + let (request, multipart_files) = match either_request { + Either::Json(value) => (value, None), + Either::Multipart(value, files) => (value, Some(files)), + Either::Form(value) => (value, None), + }; + + let mut lazy_params = LazyParams::new(table_metadata, request, multipart_files); + + api + .check_record_level_access( + Permission::Create, + None, + Some(&mut lazy_params), + user.as_ref(), + ) + .await?; + + let Ok(mut params) = lazy_params.consume() else { + return Err(RecordError::BadRequest("Parameter conversion")); + }; + + if api.insert_autofill_missing_user_id_columns() { + let column_names = params.column_names(); + let missing_columns = table_metadata + .user_id_columns + .iter() + .filter_map(|index| { + let col = &table_metadata.schema.columns[*index]; + if column_names.iter().any(|c| c == &col.name) { + return None; + } + return Some(col.name.clone()); + }) + .collect::>(); + + if !missing_columns.is_empty() { + if let Some(user) = user { + for col in missing_columns { + params.push_param(col, libsql::Value::Blob(user.uuid.into())); + } + } + } + } + + let pk_column = api.record_pk_column(); + let row = InsertQueryBuilder::run( + &state, + params, + api.insert_conflict_resolution_strategy(), + Some(&pk_column.name), + ) + .await + .map_err(|err| RecordError::Internal(err.into()))?; + + if let Some(redirect_to) = create_record_query.redirect_to { + return Ok(Redirect::to(&redirect_to).into_response()); + } + + return Ok( + Json(CreateRecordResponse { + id: match pk_column.data_type { + ColumnDataType::Blob => BASE64_URL_SAFE.encode(row.get::<[u8; 16]>(0)?), + ColumnDataType::Integer => row.get::(0)?.to_string(), + _ => { + return Err(RecordError::Internal( + format!("Unexpected data type: {:?}", pk_column.data_type).into(), + )); + } + }, + }) + .into_response(), + ); +} + +#[cfg(test)] +mod test { + use super::*; + use crate::admin::user::*; + use crate::app_state::*; + use crate::auth::api::login::login_with_password; + use crate::config::proto::PermissionFlag; + use crate::records::test_utils::*; + use crate::records::*; + use crate::util::id_to_b64; + + #[tokio::test] + async fn test_record_api_create() -> Result<(), anyhow::Error> { + let state = test_state(None).await?; + let conn = state.conn(); + + create_chat_message_app_tables(&state).await?; + let room = add_room(conn, "room0").await?; + let password = "Secret!1!!"; + + // Register message table as api with moderator read access. + add_record_api( + &state, + "messages_api", + "message", + Acls { + authenticated: vec![PermissionFlag::Create, PermissionFlag::Read], + ..Default::default() + }, + AccessRules { + create: Some( + "EXISTS(SELECT 1 FROM room_members AS m WHERE _USER_.id = _REQ_._owner AND m.user = _USER_.id AND m.room = _REQ_.room )".to_string(), + ), + ..Default::default() + }, + ) + .await?; + + let user_x_email = "user_x@bar.com"; + let user_x = create_user_for_test(&state, user_x_email, password) + .await? + .into_bytes(); + let user_x_token = login_with_password(&state, user_x_email, password).await?; + + add_user_to_room(conn, user_x, room).await?; + + let user_y_email = "user_y@test.com"; + let user_y = create_user_for_test(&state, user_y_email, password) + .await? + .into_bytes(); + + let user_y_token = login_with_password(&state, user_y_email, password).await?; + + { + // User X can post to the room, they're a member of + let json = serde_json::json!({ + "_owner": id_to_b64(&user_x), + "room": id_to_b64(&room), + "data": "user_x message to room", + }); + let response = create_record_handler( + State(state.clone()), + Path("messages_api".to_string()), + Query(CreateRecordQuery::default()), + User::from_auth_token(&state, &user_x_token.auth_token), + Either::Json(json), + ) + .await; + assert!(response.is_ok(), "{response:?}"); + } + + { + // User X can post as a different "_owner". + let json = serde_json::json!({ + "_owner": id_to_b64(&user_y), + "room": id_to_b64(&room), + "data": "user_x message to room", + }); + let response = create_record_handler( + State(state.clone()), + Path("messages_api".to_string()), + Query(CreateRecordQuery::default()), + User::from_auth_token(&state, &user_x_token.auth_token), + Either::Json(json), + ) + .await; + assert!(response.is_err(), "{response:?}"); + } + + { + // User Y is not a member and cannot post to the room. + let json = serde_json::json!({ + "room": id_to_b64(&room), + "data": "user_x message to room", + }); + let response = create_record_handler( + State(state.clone()), + Path("messages_api".to_string()), + Query(CreateRecordQuery::default()), + User::from_auth_token(&state, &user_y_token.auth_token), + Either::Json(json), + ) + .await; + assert!(response.is_err(), "{response:?}"); + } + + return Ok(()); + } + + #[tokio::test] + async fn test_record_api_create_integer_id() -> Result<(), anyhow::Error> { + let state = test_state(None).await?; + let conn = state.conn(); + + create_chat_message_app_tables_integer(&state).await?; + let room = add_room(conn, "room0").await?; + let password = "Secret!1!!"; + + // Register message table as api with moderator read access. + add_record_api( + &state, + "messages_api", + "message", + Acls { + authenticated: vec![PermissionFlag::Create, PermissionFlag::Read], + ..Default::default() + }, + AccessRules { + create: Some( + "EXISTS(SELECT 1 FROM room_members AS m WHERE _USER_.id = _REQ_._owner AND m.user = _USER_.id AND m.room = _REQ_.room )".to_string(), + ), + ..Default::default() + }, + ) + .await?; + + let user_x_email = "user_x@bar.com"; + let user_x = create_user_for_test(&state, user_x_email, password) + .await? + .into_bytes(); + let user_x_token = login_with_password(&state, user_x_email, password).await?; + + add_user_to_room(conn, user_x, room).await?; + + { + // User X can post to the room, they're a member of + let json = serde_json::json!({ + "_owner": id_to_b64(&user_x), + "room": id_to_b64(&room), + "data": "user_x message to room", + }); + let response = create_record_handler( + State(state.clone()), + Path("messages_api".to_string()), + Query(CreateRecordQuery::default()), + User::from_auth_token(&state, &user_x_token.auth_token), + Either::Json(json), + ) + .await; + assert!(response.is_ok(), "{response:?}"); + } + + return Ok(()); + } +} diff --git a/trailbase-core/src/records/delete_record.rs b/trailbase-core/src/records/delete_record.rs new file mode 100644 index 0000000..9b2254c --- /dev/null +++ b/trailbase-core/src/records/delete_record.rs @@ -0,0 +1,194 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::app_state::AppState; +use crate::auth::user::User; +use crate::records::json_to_sql::DeleteQueryBuilder; +use crate::records::{Permission, RecordError}; + +/// Delete record. +#[utoipa::path( + delete, + path = "/:name/:record", + responses( + (status = 200, description = "Successful deletion.") + ) +)] +pub async fn delete_record_handler( + State(state): State, + Path((api_name, record)): Path<(String, String)>, + user: Option, +) -> Result { + let Some(api) = state.lookup_record_api(&api_name) else { + return Err(RecordError::ApiNotFound); + }; + + let table_metadata = api + .table_metadata() + .ok_or_else(|| RecordError::ApiRequiresTable)?; + + let record_id = api.id_to_sql(&record)?; + + api + .check_record_level_access(Permission::Delete, Some(&record_id), None, user.as_ref()) + .await?; + + DeleteQueryBuilder::run( + &state, + table_metadata, + &api.record_pk_column().name, + record_id, + ) + .await + .map_err(|err| RecordError::Internal(err.into()))?; + + return Ok((StatusCode::OK, "deleted").into_response()); +} + +#[cfg(test)] +mod test { + use axum::extract::Query; + use libsql::{params, Connection}; + use trailbase_sqlite::query_one_row; + + use super::*; + use crate::admin::user::*; + use crate::app_state::*; + use crate::auth::api::login::login_with_password; + use crate::auth::user::User; + use crate::config::proto::PermissionFlag; + use crate::extract::Either; + use crate::records::create_record::{ + create_record_handler, CreateRecordQuery, CreateRecordResponse, + }; + use crate::records::test_utils::*; + use crate::records::*; + use crate::test::unpack_json_response; + use crate::util::{b64_to_id, id_to_b64}; + + #[tokio::test] + async fn test_record_api_delete() -> Result<(), anyhow::Error> { + let state = test_state(None).await?; + let conn = state.conn(); + + create_chat_message_app_tables(&state).await?; + let room = add_room(conn, "room0").await?; + let password = "Secret!1!!"; + + // Register message table as api with moderator read access. + add_record_api( + &state, + "messages_api", + "message", + Acls { + authenticated: vec![ + PermissionFlag::Create, + PermissionFlag::Read, + PermissionFlag::Delete, + ], + ..Default::default() + }, + AccessRules { + create: Some( + "EXISTS(SELECT 1 FROM room_members WHERE room = _REQ_.room AND user = _USER_.id)" + .to_string(), + ), + // Only owners can delete. + delete: Some("(_ROW_._owner = _USER_.id)".to_string()), + ..Default::default() + }, + ) + .await?; + + let user_x_email = "user_x@test.com"; + let user_x = create_user_for_test(&state, user_x_email, password) + .await? + .into_bytes(); + + let user_x_token = login_with_password(&state, user_x_email, password).await?; + + add_user_to_room(conn, user_x, room).await?; + + let user_y_email = "user_y@foo.baz"; + let _user_y = create_user_for_test(&state, user_y_email, password) + .await? + .into_bytes(); + + let user_y_token = login_with_password(&state, user_y_email, password).await?; + + { + // User X can delete their own message. + let id = add_message(&state, &user_x, &user_x_token.auth_token, &room).await?; + delete_message(&state, &user_x_token.auth_token, &id).await?; + assert_eq!(message_exists(conn, &id).await?, false); + } + + { + // User Y cannot delete X's message. + let id = add_message(&state, &user_x, &user_x_token.auth_token, &room).await?; + let response = delete_message(&state, &user_y_token.auth_token, &id).await; + assert!(response.is_err()); + assert_eq!(message_exists(conn, &id).await?, true); + } + + return Ok(()); + } + + async fn message_exists(conn: &Connection, id: &[u8; 16]) -> Result { + let count: i64 = query_one_row( + conn, + "SELECT COUNT(*) FROM message WHERE id = $1", + params!(id), + ) + .await? + .get(0)?; + return Ok(count > 0); + } + + async fn add_message( + state: &AppState, + user: &[u8; 16], + auth_token: &str, + room: &[u8; 16], + ) -> Result<[u8; 16], anyhow::Error> { + let create_json = serde_json::json!({ + "_owner": id_to_b64(&user), + "room": id_to_b64(&room), + "data": "user_x message to room", + }); + + let create_response = create_record_handler( + State(state.clone()), + Path("messages_api".to_string()), + Query(CreateRecordQuery::default()), + User::from_auth_token(state, auth_token), + Either::Json(create_json), + ) + .await; + + assert!(create_response.is_ok(), "{create_response:?}"); + + let response: CreateRecordResponse = unpack_json_response(create_response.unwrap()) + .await + .unwrap(); + + return Ok(b64_to_id(&response.id)?); + } + + async fn delete_message( + state: &AppState, + auth_token: &str, + id: &[u8; 16], + ) -> Result<(), anyhow::Error> { + delete_record_handler( + State(state.clone()), + Path(("messages_api".to_string(), id_to_b64(&id))), + User::from_auth_token(state, auth_token), + ) + .await?; + return Ok(()); + } +} diff --git a/trailbase-core/src/records/error.rs b/trailbase-core/src/records/error.rs new file mode 100644 index 0000000..aab651b --- /dev/null +++ b/trailbase-core/src/records/error.rs @@ -0,0 +1,82 @@ +use axum::body::Body; +use axum::http::{header::CONTENT_TYPE, StatusCode}; +use axum::response::{IntoResponse, Response}; +use log::*; +use thiserror::Error; + +/// Publicly visible errors of record APIs. +/// +/// This error is deliberately opaque and kept very close to HTTP error codes to avoid the leaking +/// of internals and provide a very clear mapping to codes. +/// NOTE: Do not use thiserror's #from, all mappings should be explicit. +#[derive(Debug, Error)] +pub enum RecordError { + #[error("Api Not Found")] + ApiNotFound, + #[error("Api Requires Table")] + ApiRequiresTable, + #[error("Record Not Found")] + RecordNotFound, + #[error("Forbidden")] + Forbidden, + #[error("Bad request: {0}")] + BadRequest(&'static str), + #[error("Internal: {0}")] + Internal(Box), +} + +impl From for RecordError { + fn from(err: libsql::Error) -> Self { + return match err { + libsql::Error::QueryReturnedNoRows => { + #[cfg(debug_assertions)] + info!("libsql returned empty rows error"); + + Self::RecordNotFound + } + // List of error codes: https://www.sqlite.org/rescode.html + libsql::Error::SqliteFailure(275, _msg) => Self::BadRequest("sqlite constraint: check"), + libsql::Error::SqliteFailure(531, _msg) => Self::BadRequest("sqlite constraint: commit hook"), + libsql::Error::SqliteFailure(3091, _msg) => Self::BadRequest("sqlite constraint: data type"), + libsql::Error::SqliteFailure(787, _msg) => Self::BadRequest("sqlite constraint: fk"), + libsql::Error::SqliteFailure(1043, _msg) => Self::BadRequest("sqlite constraint: function"), + libsql::Error::SqliteFailure(1299, _msg) => Self::BadRequest("sqlite constraint: not null"), + libsql::Error::SqliteFailure(2835, _msg) => Self::BadRequest("sqlite constraint: pinned"), + libsql::Error::SqliteFailure(1555, _msg) => Self::BadRequest("sqlite constraint: pk"), + libsql::Error::SqliteFailure(2579, _msg) => Self::BadRequest("sqlite constraint: row id"), + libsql::Error::SqliteFailure(1811, _msg) => Self::BadRequest("sqlite constraint: trigger"), + libsql::Error::SqliteFailure(2067, _msg) => Self::BadRequest("sqlite constraint: unique"), + libsql::Error::SqliteFailure(2323, _msg) => Self::BadRequest("sqlite constraint: vtab"), + err => Self::Internal(err.into()), + }; + } +} + +impl IntoResponse for RecordError { + fn into_response(self) -> Response { + let (status, body) = match self { + Self::ApiNotFound => (StatusCode::METHOD_NOT_ALLOWED, None), + Self::ApiRequiresTable => (StatusCode::METHOD_NOT_ALLOWED, None), + Self::RecordNotFound => (StatusCode::NOT_FOUND, None), + Self::Forbidden => (StatusCode::FORBIDDEN, None), + Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, Some(msg.to_string())), + Self::Internal(err) if cfg!(debug_assertions) => { + (StatusCode::INTERNAL_SERVER_ERROR, Some(err.to_string())) + } + Self::Internal(_err) => (StatusCode::INTERNAL_SERVER_ERROR, None), + }; + + if let Some(body) = body { + return Response::builder() + .status(status) + .header(CONTENT_TYPE, "text/plain") + .body(Body::new(body)) + .unwrap(); + } + + return Response::builder() + .status(status) + .body(Body::empty()) + .unwrap(); + } +} diff --git a/trailbase-core/src/records/files.rs b/trailbase-core/src/records/files.rs new file mode 100644 index 0000000..44ee58d --- /dev/null +++ b/trailbase-core/src/records/files.rs @@ -0,0 +1,107 @@ +use axum::body::Body; +use axum::http::header; +use axum::response::{IntoResponse, Response}; +use log::*; +use object_store::ObjectStore; +use thiserror::Error; +use trailbase_sqlite::schema::{FileUpload, FileUploads}; + +use crate::app_state::AppState; +use crate::table_metadata::{JsonColumnMetadata, TableOrViewMetadata}; + +#[derive(Debug, Error)] +pub enum FileError { + #[error("Libsql error: {0}")] + Libsql(#[from] libsql::Error), + #[error("Storage error: {0}")] + Storage(#[from] object_store::Error), + #[error("IO error: {0}")] + IO(#[from] std::io::Error), + #[error("Json serialization error: {0}")] + JsonSerialization(#[from] serde_json::Error), +} + +pub(crate) async fn read_file_into_response( + state: &AppState, + file_upload: FileUpload, +) -> Result { + let store = state.objectstore()?; + let path = object_store::path::Path::from(file_upload.path()); + let result = store.get(&path).await?; + + let headers = || { + return [ + ( + header::CONTENT_TYPE, + file_upload.content_type().map_or_else( + || "text/plain; charset=utf-8".to_string(), + |c| c.to_string(), + ), + ), + (header::CONTENT_DISPOSITION, "attachment".to_string()), + ]; + }; + + return match result.payload { + object_store::GetResultPayload::File(_file, path) => { + let contents = tokio::fs::read(path).await?; + Ok((headers(), Body::from(contents)).into_response()) + } + object_store::GetResultPayload::Stream(stream) => { + Ok((headers(), Body::from_stream(stream)).into_response()) + } + }; +} + +pub(crate) async fn delete_files_in_row( + state: &AppState, + metadata: &(dyn TableOrViewMetadata + Send + Sync), + row: libsql::Row, +) -> Result<(), FileError> { + for i in 0..row.column_count() { + let Some(col_name) = row.column_name(i) else { + warn!("Missing name: {i}"); + continue; + }; + let Some((_column, column_metadata)) = metadata.column_by_name(col_name) else { + warn!("Missing column: {col_name}"); + continue; + }; + + if let Some(json) = &column_metadata.json { + let store = state.objectstore()?; + match json { + JsonColumnMetadata::SchemaName(name) if name == "std.FileUpload" => { + if let Ok(json) = row.get_str(i) { + let file: FileUpload = serde_json::from_str(json)?; + delete_file(&*store, file).await?; + } + } + JsonColumnMetadata::SchemaName(name) if name == "std.FileUploads" => { + if let Ok(json) = row.get_str(i) { + let file_uploads: FileUploads = serde_json::from_str(json)?; + for file in file_uploads.0 { + delete_file(&*store, file).await?; + } + } + } + _ => {} + } + } + } + + return Ok(()); +} + +// async fn maybe_delete_files_in_column( +// state: &AppState, +// column: &ColumnMetadata, +// ) -> Result<(), object_store::Error> { +// return Ok(()); +// } + +async fn delete_file(store: &dyn ObjectStore, file: FileUpload) -> Result<(), object_store::Error> { + return store + .delete(&object_store::path::Path::from(file.path())) + .await; +} diff --git a/trailbase-core/src/records/json_schema.rs b/trailbase-core/src/records/json_schema.rs new file mode 100644 index 0000000..cf67850 --- /dev/null +++ b/trailbase-core/src/records/json_schema.rs @@ -0,0 +1,40 @@ +use axum::extract::{Json, Path, Query, State}; +use serde::Deserialize; + +use crate::auth::user::User; +use crate::records::{Permission, RecordError}; +use crate::table_metadata::build_json_schema; +use crate::{api::JsonSchemaMode, app_state::AppState}; + +#[derive(Debug, Clone, Deserialize)] +pub struct JsonSchemaQuery { + pub mode: Option, +} + +/// Retrieve json schema associated with given record api. +#[utoipa::path( + get, + path = "/:name/schema", + responses( + (status = 200, description = "JSON schema.") + ) +)] +pub async fn json_schema_handler( + State(state): State, + Path(api_name): Path, + Query(request): Query, + user: Option, +) -> Result, RecordError> { + let Some(api) = state.lookup_record_api(&api_name) else { + return Err(RecordError::ApiNotFound); + }; + + api + .check_record_level_access(Permission::Schema, None, None, user.as_ref()) + .await?; + + let mode = request.mode.unwrap_or(JsonSchemaMode::Insert); + let (_schema, json) = build_json_schema(api.table_name(), api.metadata(), mode) + .map_err(|err| RecordError::Internal(err.into()))?; + return Ok(Json(json)); +} diff --git a/trailbase-core/src/records/json_to_sql.rs b/trailbase-core/src/records/json_to_sql.rs new file mode 100644 index 0000000..d7e2f2a --- /dev/null +++ b/trailbase-core/src/records/json_to_sql.rs @@ -0,0 +1,1083 @@ +use base64::prelude::*; +use itertools::Itertools; +use log::*; +use object_store::ObjectStore; +use std::collections::{hash_map::Entry, HashMap}; +use std::sync::Arc; +use trailbase_sqlite::schema::{FileUpload, FileUploadInput, FileUploads}; +use trailbase_sqlite::{query_one_row, query_row}; + +use crate::config::proto::ConflictResolutionStrategy; +use crate::records::files::delete_files_in_row; +use crate::schema::{Column, ColumnDataType}; +use crate::table_metadata::{self, ColumnMetadata, JsonColumnMetadata, TableMetadata}; +use crate::AppState; + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ParamsError { + #[error("Not an object")] + NotAnObject, + #[error("Not a Number")] + NotANumber, + #[error("Column error: {0}")] + Column(&'static str), + #[error("Unexpected type: {0}, expected {1}")] + UnexpectedType(&'static str, String), + #[error("Decoding error: {0}")] + Decode(#[from] base64::DecodeError), + #[error("Nested object: {0}")] + NestedObject(String), + #[error("Nested array: {0}")] + NestedArray(String), + #[error("Inhomogenous array: {0}")] + InhomogenousArray(String), + #[error("Parse int error: {0}")] + ParseInt(#[from] std::num::ParseIntError), + #[error("Parse float error: {0}")] + ParseFloat(#[from] std::num::ParseFloatError), + #[error("Json validation error: {0}")] + JsonValidation(#[from] table_metadata::JsonSchemaError), + #[error("Json serialization error: {0}")] + JsonSerialization(Arc), + #[error("Json schema error: {0}")] + Schema(#[from] trailbase_sqlite::schema::SchemaError), + #[error("Sql error: {0}")] + Sql(Arc), + #[error("ObjectStore error: {0}")] + Storage(Arc), +} + +impl From for ParamsError { + fn from(err: libsql::Error) -> Self { + return Self::Sql(Arc::new(err)); + } +} + +impl From for ParamsError { + fn from(err: serde_json::Error) -> Self { + return Self::JsonSerialization(Arc::new(err)); + } +} + +impl From for ParamsError { + fn from(err: object_store::Error) -> Self { + return Self::Storage(Arc::new(err)); + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum QueryError { + #[error("Precondition error: {0}")] + Precondition(&'static str), + #[error("Sql error: {0}")] + Sql(Arc), + #[error("Json serialization error: {0}")] + JsonSerialization(Arc), + #[error("ObjectStore error: {0}")] + Storage(Arc), + #[error("File error: {0}")] + File(Arc), + #[error("Not found")] + NotFound, +} + +impl From for QueryError { + fn from(err: libsql::Error) -> Self { + return Self::Sql(Arc::new(err)); + } +} + +impl From for QueryError { + fn from(err: serde_json::Error) -> Self { + return Self::JsonSerialization(Arc::new(err)); + } +} + +impl From for QueryError { + fn from(err: object_store::Error) -> Self { + return Self::Storage(Arc::new(err)); + } +} + +impl From for QueryError { + fn from(err: crate::records::files::FileError) -> Self { + return Self::File(Arc::new(err)); + } +} + +type FileMetadataContents = Vec<(FileUpload, Vec)>; + +#[derive(Default)] +pub struct Params { + table_name: String, + + /// List of named params with their respective placeholders, e.g.: + /// '(":col_name": Value::Text("hi"))'. + params: Vec<(String, libsql::Value)>, + + /// List of columns that are targeted by the params. Useful for building Insert/Update queries. + /// + /// NOTE: This is a super-set of all columns and also includes the file_col_names below. + /// NOTE: We could also infer them from placeholder names by stripping the leading ":". + col_names: Vec, + + /// List of files and contents to be written to an object store. + files: FileMetadataContents, + /// Subset of `col_names` containing only file columns. Useful for building Update/Delete queries + /// to remove the files from the object store afterwards. + file_col_names: Vec, +} + +impl Params { + /// Converts a top-level Json object into libsql::Values and extract files. + /// + /// Note: that this function by design is non-recursive, since we're mapping to a flat hierarchy + /// in sqlite, since even JSON/JSONB is simply text/blob that is lazily parsed. + /// + /// The expected format is: + /// + /// request = { + /// "col0": "text", + /// "col1": , + /// "file_col": { + /// data: ... + /// } + /// } + /// + /// The optional files parameter is there to receive files in case the input JSON was extracted + /// form a multipart/form. In that case files are handled separately and not embedded in the JSON + /// value itself in contrast to when the original request was an actual JSON request. + pub fn from( + metadata: &TableMetadata, + json: serde_json::Value, + multipart_files: Option>, + ) -> Result { + let serde_json::Value::Object(map) = json else { + return Err(ParamsError::NotAnObject); + }; + + let mut params = Params { + table_name: metadata.name().to_string(), + ..Default::default() + }; + + for (key, value) in map { + // We simply skip unknown columns, this could simply be malformed input or version skew. This + // is similar in spirit to protobuf's unknown fields behavior. + let Some((col, col_meta)) = Self::column_by_name(metadata, &key) else { + continue; + }; + + let (param, mut json_files) = extract_params_and_files_from_json(col, col_meta, value)?; + if let Some(json_files) = json_files.as_mut() { + // Note: files provided as a multipart form upload are handled below. They need more + // special handling to establish the field.name to column mapping. + params.files.append(json_files); + params.file_col_names.push(key.to_string()); + } + + params.push_param(key, param); + } + + // Note: files provided as part of a JSON request are handled above. + if let Some(multipart_files) = multipart_files { + params.append_multipart_files(metadata, multipart_files)?; + } + + return Ok(params); + } + + #[cfg(debug_assertions)] + #[inline] + fn column_by_name<'a>( + metadata: &'a TableMetadata, + field_name: &str, + ) -> Option<(&'a Column, &'a ColumnMetadata)> { + let Some(col) = metadata.column_by_name(field_name) else { + info!("Skipping field '{field_name}' in request: no matching column. This is just an FYI in dev builds and not an issue."); + return None; + }; + return Some(col); + } + + #[cfg(not(debug_assertions))] + #[inline] + fn column_by_name<'a>( + metadata: &'a TableMetadata, + field_name: &str, + ) -> Option<(&'a Column, &'a ColumnMetadata)> { + return metadata.column_by_name(field_name); + } + + pub fn push_param(&mut self, col: String, value: libsql::Value) { + self.params.push((format!(":{col}"), value)); + self.col_names.push(col); + } + + pub(crate) fn column_names(&self) -> &Vec { + return &self.col_names; + } + + pub(crate) fn named_params(&self) -> &Vec<(String, libsql::Value)> { + &self.params + } + + pub(crate) fn placeholders(&self) -> String { + return self.params.iter().map(|(k, _v)| k.clone()).join(", "); + } + + fn append_multipart_files( + &mut self, + metadata: &TableMetadata, + multipart_files: Vec, + ) -> Result<(), ParamsError> { + let mut files: Vec<(String, FileUpload, Vec)> = vec![]; + for file in multipart_files { + let (col_name, metadata, content) = file.consume()?; + match col_name { + None => { + return Err(ParamsError::Column( + "Multipart form upload missing name property", + )); + } + Some(col_name) => { + files.push((col_name, metadata, content)); + } + } + } + + let mut file_upload_map = HashMap::::new(); + let mut file_uploads_map = HashMap::>::new(); + + // Validate and organize by type; + for (field_name, file_metadata, _content) in &files { + // We simply skip unknown columns, this could simply be malformed input or version skew. This + // is similar in spirit to protobuf's unknown fields behavior. + let Some((col, col_meta)) = Self::column_by_name(metadata, field_name) else { + continue; + }; + + let Some(JsonColumnMetadata::SchemaName(schema_name)) = &col_meta.json else { + return Err(ParamsError::Column("Expected json column")); + }; + + let col_name = col.name.to_string(); + match schema_name.as_str() { + "std.FileUpload" => { + if file_upload_map + .insert(col_name, file_metadata.clone()) + .is_some() + { + return Err(ParamsError::Column( + "Collision: too many files for std.FileUpload", + )); + } + } + "std.FileUploads" => match file_uploads_map.entry(col_name) { + Entry::Occupied(mut entry) => entry.get_mut().push(file_metadata.clone()), + Entry::Vacant(entry) => { + entry.insert(vec![file_metadata.clone()]); + } + }, + _ => { + return Err(ParamsError::Column("Mismatching JSON schema")); + } + } + } + + for (col_name, file_upload) in file_upload_map { + self.params.push(( + format!(":{col_name}"), + libsql::Value::Text(serde_json::to_string(&file_upload)?), + )); + self.col_names.push(col_name.clone()); + self.file_col_names.push(col_name); + } + + for (col_name, file_uploads) in file_uploads_map { + self.params.push(( + format!(":{col_name}"), + libsql::Value::Text(serde_json::to_string(&FileUploads(file_uploads))?), + )); + self.col_names.push(col_name.clone()); + self.file_col_names.push(col_name); + } + + self.files.append( + &mut files + .into_iter() + .map(|(_, metadata, content)| (metadata, content)) + .collect(), + ); + + return Ok(()); + } +} + +pub(crate) struct SelectQueryBuilder; + +impl SelectQueryBuilder { + pub(crate) async fn run( + state: &AppState, + table_name: &str, + pk_column: &str, + pk_value: libsql::Value, + ) -> Result, libsql::Error> { + return query_row( + state.conn(), + &format!("SELECT * FROM '{table_name}' WHERE {pk_column} = $1"), + [pk_value], + ) + .await; + } +} + +pub(crate) struct GetFileQueryBuilder; + +impl GetFileQueryBuilder { + pub(crate) async fn run( + state: &AppState, + table_name: &str, + file_column: (&Column, &ColumnMetadata), + pk_column: &str, + pk_value: libsql::Value, + ) -> Result { + return match &file_column.1.json { + Some(JsonColumnMetadata::SchemaName(name)) if name == "std.FileUpload" => { + let column_name = &file_column.0.name; + + let Some(row) = query_row( + state.conn(), + &format!("SELECT [{column_name}] FROM '{table_name}' WHERE {pk_column} = $1"), + [pk_value], + ) + .await? + else { + return Err(QueryError::NotFound); + }; + + let json: String = row.get(0)?; + let file_upload: FileUpload = serde_json::from_str(&json)?; + Ok(file_upload) + } + _ => Err(QueryError::Precondition("Not a file")), + }; + } +} + +pub(crate) struct GetFilesQueryBuilder; + +impl GetFilesQueryBuilder { + pub(crate) async fn run( + state: &AppState, + table_name: &str, + file_column: (&Column, &ColumnMetadata), + pk_column: &str, + pk_value: libsql::Value, + ) -> Result { + return match &file_column.1.json { + Some(JsonColumnMetadata::SchemaName(name)) if name == "std.FileUploads" => { + let column_name = &file_column.0.name; + + let Some(row) = query_row( + state.conn(), + &format!("SELECT [{column_name}] FROM '{table_name}' WHERE {pk_column} = $1"), + [pk_value], + ) + .await? + else { + return Err(QueryError::NotFound); + }; + + let contents: String = row.get(0)?; + let file_uploads: FileUploads = serde_json::from_str(&contents)?; + Ok(file_uploads) + } + _ => Err(QueryError::Precondition("Not a files list")), + }; + } +} + +pub(crate) struct InsertQueryBuilder; + +impl InsertQueryBuilder { + pub(crate) async fn run( + state: &AppState, + params: Params, + conflict_resolution: Option, + return_column_name: Option<&str>, + ) -> Result { + let (query_fragment, named_params, mut files) = + Self::build_insert_query(params, conflict_resolution)?; + let query = match return_column_name { + Some(return_column_name) => format!("{query_fragment} RETURNING {return_column_name}"), + None => format!("{query_fragment} RETURNING NULL"), + }; + + // We're storing any files to the object store first to make sure the DB entry is valid right + // after commit and not racily pointing to soon-to-be-written files. + if !files.is_empty() { + let objectstore = state.objectstore()?; + for (metadata, content) in &mut files { + write_file(&*objectstore, metadata, content).await?; + } + } + + let row = match query_one_row(state.conn(), &query, named_params).await { + Ok(row) => row, + Err(err) => { + if !files.is_empty() { + let objectstore = state.objectstore()?; + + for (metadata, _files) in &files { + let path = object_store::path::Path::from(metadata.path()); + if let Err(err) = objectstore.delete(&path).await { + warn!("Failed to cleanup file after failed insertion (leak): {err}"); + } + } + } + return Err(err.into()); + } + }; + + return Ok(row); + } + + fn build_insert_query( + params: Params, + conflict_resolution: Option, + ) -> Result<(String, libsql::params::Params, FileMetadataContents), QueryError> { + let table_name = ¶ms.table_name; + + let conflict_clause = Self::conflict_resolution_clause( + conflict_resolution.unwrap_or(ConflictResolutionStrategy::Undefined), + ); + + let column_names = params.column_names(); + let query = match column_names.is_empty() { + true => format!("INSERT {conflict_clause} INTO '{table_name}' DEFAULT VALUES"), + false => format!( + "INSERT {conflict_clause} INTO '{table_name}' ({col_names}) VALUES ({placeholders})", + col_names = column_names.join(", "), + placeholders = params.placeholders(), + ), + }; + + return Ok(( + query, + libsql::params::Params::Named(params.params), + params.files, + )); + } + + fn conflict_resolution_clause(config: ConflictResolutionStrategy) -> &'static str { + type C = ConflictResolutionStrategy; + return match config { + C::Undefined => "", + C::Abort => "OR ABORT", + C::Rollback => "OR ROLLBACK", + C::Fail => "OR FAIL", + C::Ignore => "OR IGNORE", + C::Replace => "OR REPLACE", + }; + } +} + +pub(crate) struct UpdateQueryBuilder; + +impl UpdateQueryBuilder { + pub(crate) async fn run( + state: &AppState, + metadata: &TableMetadata, + mut params: Params, + pk_column: &str, + pk_value: libsql::Value, + ) -> Result<(), QueryError> { + let table_name = metadata.name(); + assert_eq!(params.table_name, *table_name); + if params.column_names().is_empty() { + return Ok(()); + } + + params.push_param(pk_column.to_string(), pk_value.clone()); + + // We're storing to object store before writing the entry to the DB. + let mut files = std::mem::take(&mut params.files); + if !files.is_empty() { + let store = state.objectstore()?; + for (metadata, content) in &mut files { + write_file(&*store, metadata, content).await?; + } + } + + async fn row_update( + conn: &libsql::Connection, + table_name: &str, + params: Params, + pk_column: &str, + pk_value: libsql::Value, + ) -> Result, QueryError> { + let build_setters = || -> String { + assert_eq!(params.col_names.len(), params.params.len()); + return std::iter::zip(¶ms.col_names, ¶ms.params) + .map(|(col_name, p)| format!("{col_name} = {placeholder}", placeholder = p.0)) + .join(", "); + }; + + let setters = build_setters(); + let named_params = libsql::params::Params::Named(params.params); + + let tx = conn.transaction().await?; + + // First, fetch updated file column contents so we can delete the files after updating the + // column. + let files_row = if params.file_col_names.is_empty() { + None + } else { + let file_columns = params.file_col_names.join(", "); + query_row( + &tx, + &format!("SELECT {file_columns} FROM '{table_name}' WHERE {pk_column} = ${pk_column}"), + libsql::params::Params::Named(vec![(":pk_column".to_string(), pk_value)]), + ) + .await? + }; + + // Update the column. + let _ = tx + .execute( + &format!("UPDATE '{table_name}' SET {setters} WHERE {pk_column} = :{pk_column}"), + named_params, + ) + .await?; + + tx.commit().await?; + + return Ok(files_row); + } + + let files_row = match row_update(state.conn(), table_name, params, pk_column, pk_value).await { + Ok(files_row) => files_row, + Err(err) => { + if !files.is_empty() { + let store = state.objectstore()?; + for (metadata, _content) in &files { + let path = object_store::path::Path::from(metadata.path()); + if let Err(err) = store.delete(&path).await { + warn!("Failed to cleanup file after failed insertion (leak): {err}"); + } + } + } + + return Err(err); + } + }; + + // Finally, if everything else went well delete files from columns that were updated and are no + // longer referenced. + if let Some(files_row) = files_row { + delete_files_in_row(state, metadata, files_row).await?; + } + + return Ok(()); + } +} + +pub(crate) struct DeleteQueryBuilder; + +impl DeleteQueryBuilder { + pub(crate) async fn run( + state: &AppState, + metadata: &TableMetadata, + pk_column: &str, + pk_value: libsql::Value, + ) -> Result<(), QueryError> { + let table_name = metadata.name(); + + let row = query_one_row( + state.conn(), + &format!("DELETE FROM '{table_name}' WHERE {pk_column} = $1 RETURNING *"), + [pk_value], + ) + .await?; + + // Finally, delete files. + delete_files_in_row(state, metadata, row).await?; + + return Ok(()); + } +} + +async fn write_file( + store: &dyn ObjectStore, + metadata: &FileUpload, + data: &mut Vec, +) -> Result<(), object_store::Error> { + let path = object_store::path::Path::from(metadata.path()); + + let mut writer = store.put_multipart(&path).await?; + writer.put_part(std::mem::take(data).into()).await?; + writer.complete().await?; + + return Ok(()); +} + +fn try_json_array_to_blob(arr: &Vec) -> Result { + let mut byte_array: Vec = vec![]; + for el in arr { + match el { + serde_json::Value::Number(num) => { + let Some(int) = num.as_i64() else { + return Err(ParamsError::UnexpectedType( + "NonByteNumber", + format!("Number type: {num:?}"), + )); + }; + + let Ok(byte) = int.try_into() else { + return Err(ParamsError::UnexpectedType( + "NonByteNumber", + format!("Out-of-range int: {int}"), + )); + }; + + byte_array.push(byte); + } + x => { + return Err(ParamsError::InhomogenousArray(format!( + "Expected number, got {x:?}" + ))); + } + }; + } + + return Ok(libsql::Value::Blob(byte_array)); +} + +fn json_string_to_value( + data_type: ColumnDataType, + value: String, +) -> Result { + return Ok(match data_type { + ColumnDataType::Null => libsql::Value::Null, + // Strict/storage types + ColumnDataType::Any => libsql::Value::Text(value), + ColumnDataType::Text => libsql::Value::Text(value), + ColumnDataType::Blob => libsql::Value::Blob(BASE64_URL_SAFE.decode(value)?), + ColumnDataType::Integer => libsql::Value::Integer(value.parse::()?), + ColumnDataType::Real => libsql::Value::Real(value.parse::()?), + ColumnDataType::Numeric => libsql::Value::Integer(value.parse::()?), + // JSON types. + ColumnDataType::JSONB => libsql::Value::Blob(value.into_bytes().to_vec()), + ColumnDataType::JSON => libsql::Value::Text(value), + // Affine types + // + // Integers: + ColumnDataType::Int + | ColumnDataType::TinyInt + | ColumnDataType::SmallInt + | ColumnDataType::MediumInt + | ColumnDataType::BigInt + | ColumnDataType::UnignedBigInt + | ColumnDataType::Int2 + | ColumnDataType::Int4 + | ColumnDataType::Int8 => libsql::Value::Integer(value.parse::()?), + // Text: + ColumnDataType::Character + | ColumnDataType::Varchar + | ColumnDataType::VaryingCharacter + | ColumnDataType::NChar + | ColumnDataType::NativeCharacter + | ColumnDataType::NVarChar + | ColumnDataType::Clob => libsql::Value::Text(value), + // Real: + ColumnDataType::Double | ColumnDataType::DoublePrecision | ColumnDataType::Float => { + libsql::Value::Real(value.parse::()?) + } + // Numeric + ColumnDataType::Boolean + | ColumnDataType::Decimal + | ColumnDataType::Date + | ColumnDataType::DateTime => libsql::Value::Integer(value.parse::()?), + }); +} + +pub fn simple_json_value_to_param( + col_type: ColumnDataType, + value: serde_json::Value, +) -> Result { + let param = match value { + serde_json::Value::Object(ref _map) => { + return Err(ParamsError::UnexpectedType( + "Object", + format!("Trivial type: {col_type:?}"), + )); + } + serde_json::Value::Array(ref arr) => { + // NOTE: Convert Array to Blob. Note, we also support blobs as base64 which are + // handled below in the string case. + if col_type != ColumnDataType::Blob { + return Err(ParamsError::UnexpectedType( + "Array", + format!("Trivial type: {col_type:?}"), + )); + } + + try_json_array_to_blob(arr)? + } + serde_json::Value::Null => libsql::Value::Null, + serde_json::Value::Bool(b) => libsql::Value::Integer(b as i64), + serde_json::Value::String(str) => json_string_to_value(col_type, str)?, + serde_json::Value::Number(number) => { + if let Some(n) = number.as_i64() { + libsql::Value::Integer(n) + } else if let Some(n) = number.as_u64() { + libsql::Value::Integer(n as i64) + } else if let Some(n) = number.as_f64() { + libsql::Value::Real(n) + } else { + warn!("Not a valid number: {number:?}"); + return Err(ParamsError::NotANumber); + } + } + }; + + return Ok(param); +} + +fn extract_params_and_files_from_json( + col: &Column, + col_meta: &ColumnMetadata, + value: serde_json::Value, +) -> Result<(libsql::Value, Option), ParamsError> { + let col_name = &col.name; + match value { + serde_json::Value::Object(ref _map) => { + // Only text columns are allowed to store nested JSON as text. + if col.data_type != ColumnDataType::Text { + return Err(ParamsError::NestedObject(format!( + "Column data mismatch for: {col_name}" + ))); + } + + let Some(json) = &col_meta.json else { + return Err(ParamsError::NestedObject(format!( + "Plain text column w/o JSON: {col_name}" + ))); + }; + + // By default, nested json will be serialized to text since that's what sqlite expected. + // For FileUpload columns we have special handling to extract the actual payload and + // convert the FileUploadInput into an actual FileUpload schema json. + match json { + JsonColumnMetadata::SchemaName(name) if name == "std.FileUpload" => { + let file_upload: FileUploadInput = serde_json::from_value(value)?; + + let (_col_name, metadata, content) = file_upload.consume()?; + let param = libsql::Value::Text(serde_json::to_string(&metadata)?); + + return Ok((param, Some(vec![(metadata, content)]))); + } + _ => { + json.validate(&value)?; + return Ok((libsql::Value::Text(value.to_string()), None)); + } + } + } + serde_json::Value::Array(ref arr) => { + // If the we're building a Param for a schema column, unpack the json (and potentially files) + // and validate it. + match col.data_type { + ColumnDataType::Blob => return Ok((try_json_array_to_blob(arr)?, None)), + ColumnDataType::Text => { + if let Some(ref json) = col_meta.json { + match json { + JsonColumnMetadata::SchemaName(name) if name == "std.FileUploads" => { + let file_upload_vec: Vec = serde_json::from_value(value)?; + + // TODO: Optimize the copying here. Not very critical. + let mut temp: Vec = vec![]; + let mut uploads: FileMetadataContents = vec![]; + for file in file_upload_vec { + let (_col_name, metadata, content) = file.consume()?; + temp.push(metadata.clone()); + uploads.push((metadata, content)); + } + + let param = libsql::Value::Text(serde_json::to_string(&FileUploads(temp))?); + + return Ok((param, Some(uploads))); + } + schema => { + schema.validate(&value)?; + return Ok((libsql::Value::Text(value.to_string()), None)); + } + } + } + } + _ => {} + } + + return Err(ParamsError::NestedArray(format!( + "Received nested array for unsuitable column: {col_name}" + ))); + } + x => return Ok((simple_json_value_to_param(col.data_type, x)?, None)), + }; +} + +/// A lazy representation of SQL query parameters derived from the request json to shared between +/// handler and the policy engine. +/// +/// If the request gets rejected by the policy we want to avoid parsing the request JSON and if the +/// engine requires a parse we don't want to re-parse in the handler. +/// +/// NOTE: Table level access checking could probably happen even sooner before we process multipart +/// streams at all. +pub struct LazyParams<'a> { + // Input + request: serde_json::Value, + metadata: &'a TableMetadata, + multipart_files: Option>, + + // Output + params: Option>, +} + +impl<'a> LazyParams<'a> { + pub fn new( + metadata: &'a TableMetadata, + request: serde_json::Value, + multipart_files: Option>, + ) -> Self { + LazyParams { + request, + metadata, + multipart_files, + params: None, + } + } + + pub fn params(&mut self) -> Result<&'_ Params, ParamsError> { + if let Some(ref params) = self.params { + return params.as_ref().map_err(|err| err.clone()); + } + + let request = std::mem::take(&mut self.request); + let multipart_files = std::mem::take(&mut self.multipart_files); + + let params = self + .params + .insert(Params::from(self.metadata, request, multipart_files)); + return params.as_ref().map_err(|err| err.clone()); + } + + pub fn consume(self) -> Result { + if let Some(params) = self.params { + return params; + } + return Params::from(self.metadata, self.request, self.multipart_files); + } +} + +#[cfg(test)] +mod tests { + use base64::prelude::*; + use schemars::{schema_for, JsonSchema}; + use serde_json::json; + + use super::*; + use crate::schema::Table; + use crate::table_metadata::{sqlite3_parse_into_statement, TableMetadata}; + use crate::util::id_to_b64; + + #[tokio::test] + async fn test_json_to_sql() -> anyhow::Result<()> { + #[allow(unused)] + #[derive(JsonSchema)] + struct TestSchema { + text: String, + array: Option>, + blob: Option>, + } + + const SCHEMA_NAME: &str = "test.TestSchema"; + let schema = schema_for!(TestSchema); + const ID_COL: &str = "myid"; + const ID_COL_PLACEHOLDER: &str = ":myid"; + + let sql = format!( + r#" + CREATE TABLE user ( + {ID_COL} BLOB NOT NULL, + blob BLOB NOT NULL, + text TEXT NOT NULL, + json_col TEXT NOT NULL CHECK(jsonschema('{SCHEMA_NAME}', json_col)), + num INTEGER NOT NULL DEFAULT 42, + real REAL NOT NULL DEFAULT 23.0 + ) + "# + ); + + let table: Table = sqlite3_parse_into_statement(&sql) + .unwrap() + .unwrap() + .try_into()?; + + trailbase_sqlite::schema::set_user_schema( + SCHEMA_NAME, + Some(serde_json::to_value(schema).unwrap()), + ) + .unwrap(); + trailbase_extension::jsonschema::get_schema(SCHEMA_NAME).unwrap(); + + let metadata = TableMetadata::new(table.clone(), &[table]); + + let id: [u8; 16] = uuid::Uuid::new_v4().as_bytes().clone(); + let blob: Vec = [0; 128].to_vec(); + let text = "some text :)"; + let num = 5; + let real = 3.0; + + let assert_params = |p: Params| { + assert!(p.params.len() >= 5, "{:?}", p.params); + + for (param, value) in &p.params { + match param.as_str() { + ID_COL_PLACEHOLDER => { + assert!( + matches!(value, libsql::Value::Blob(x) if *x == id), + "VALUE: {value:?}" + ); + } + ":blob" => { + assert!(matches!(value, libsql::Value::Blob(x) if *x == blob)); + } + ":text" => { + assert!(matches!(value, libsql::Value::Text(x) if x.contains("some text :)"))); + } + ":num" => { + assert!(matches!(value, libsql::Value::Integer(x) if *x == 5)); + } + ":real" => { + assert!(matches!(value, libsql::Value::Real(x) if *x == 3.0)); + } + ":json_col" => { + assert!(matches!(value, libsql::Value::Text(_x))); + } + x => assert!(false, "{x}"), + } + } + }; + + { + // Test that blob columns can be passed as base64. + let value = json!({ + ID_COL: id_to_b64(&id), + "blob": BASE64_URL_SAFE.encode(&blob), + "text": text, + "num": num, + "real": real, + }); + + assert_params(Params::from(&metadata, value, None)?); + } + + { + // Test that blob columns can be passed as int array and numbers can be passed as string. + let value = json!({ + ID_COL: id, + "blob": blob, + "text": text, + "num": "5", + "real": "3", + }); + + assert_params(Params::from(&metadata, value, None)?); + } + + { + let value = json!({ + ID_COL: id, + "blob": blob, + "text": json!({ + "email": text, + }), + "num": "5", + "real": "3", + }); + + assert!(Params::from(&metadata, value, None).is_err()); + + // Test that nested JSON object can be passed. + let value = json!({ + ID_COL: id, + "blob": blob, + "text": text, + "json_col": json!({ + "text": text, + }), + "num": "5", + "real": "3", + }); + + let params = Params::from(&metadata, value, None).unwrap(); + assert_params(params); + } + + { + let value = json!({ + ID_COL: id, + "blob": blob, + "text": json!([text, 1,2,3,4, "foo"]), + "num": "5", + "real": "3", + }); + + assert!(Params::from(&metadata, value, None).is_err()); + + // Test that nested JSON array can be passed. + let nested_json_blob: Vec = vec![65, 66, 67, 68]; + let value = json!({ + ID_COL: id, + "blob": blob, + "text": text, + "json_col": json!({ + "text": "test", + "array": [text, 1,2,3,4, "foo"], + "blob": nested_json_blob, + }), + "num": "5", + "real": "3", + }); + + let params = Params::from(&metadata, value, None).unwrap(); + + let json_col: Vec = params + .params + .iter() + .filter_map(|(name, value)| { + if name == ":json_col" { + return Some(value.clone()); + } + return None; + }) + .collect(); + + assert_eq!(json_col.len(), 1); + let libsql::Value::Text(ref text) = json_col[0] else { + panic!("Unexpected param type: {:?}", json_col[0]); + }; + + // Test encoded nested json against golden. + assert_eq!( + text, + r#"{"array":["some text :)",1,2,3,4,"foo"],"blob":[65,66,67,68],"text":"test"}"# + ); + + assert_params(params); + } + + return Ok(()); + } +} diff --git a/trailbase-core/src/records/list_records.rs b/trailbase-core/src/records/list_records.rs new file mode 100644 index 0000000..61b572b --- /dev/null +++ b/trailbase-core/src/records/list_records.rs @@ -0,0 +1,275 @@ +use axum::{ + extract::{Path, RawQuery, State}, + Json, +}; + +use crate::app_state::AppState; +use crate::auth::user::User; +use crate::listing::{ + build_filter_where_clause, limit_or_default, parse_query, Order, WhereClause, +}; +use crate::records::record_api::build_user_sub_select; +use crate::records::sql_to_json::rows_to_json; +use crate::records::{Permission, RecordError}; + +/// Lists records matching the given filters +#[utoipa::path( + get, + path = "/:name", + responses( + (status = 200, description = "Matching records.") + ) +)] +pub async fn list_records_handler( + State(state): State, + Path(api_name): Path, + RawQuery(raw_url_query): RawQuery, + user: Option, +) -> Result, RecordError> { + let Some(api) = state.lookup_record_api(&api_name) else { + return Err(RecordError::ApiNotFound); + }; + + // WARN: We do different access checking here because the access rule is used as a filter query + // on the table, i.e. no access -> empty results. + api.check_table_level_access(Permission::Read, user.as_ref())?; + + let (filter_params, cursor, limit, order) = match parse_query(raw_url_query) { + Some(q) => (Some(q.params), q.cursor, q.limit, q.order), + None => (None, None, None, None), + }; + + // Where clause contains column filters and cursor depending on what's present. + let metadata = api.metadata(); + let WhereClause { + mut clause, + mut params, + } = build_filter_where_clause(metadata, filter_params) + .map_err(|_err| RecordError::BadRequest("Invalid filter params"))?; + if let Some(cursor) = cursor { + params.push((":cursor".to_string(), libsql::Value::Blob(cursor.to_vec()))); + clause = format!("{clause} AND _ROW_.id < :cursor"); + } + params.push(( + ":limit".to_string(), + libsql::Value::Integer(limit_or_default(limit) as i64), + )); + + // User properties + let (user_sub_select, mut user_params) = build_user_sub_select(user.as_ref()); + params.append(&mut user_params); + + // NOTE: We're using the read access rule to filter the rows as opposed to yes/no early access + // blocking as for read-record. + // + // TODO: Should this be a separate access rule? Maybe one wants users to access a specific + // record but not list all the records. + if let Some(read_access) = api.access_rule(Permission::Read) { + clause = format!("({clause}) AND {read_access}"); + } + + let default_ordering = || { + return vec![(api.record_pk_column().name.clone(), Order::Descending)]; + }; + + let order_clause = order + .unwrap_or_else(default_ordering) + .iter() + .map(|(col, ord)| { + format!( + "_ROW_.{col} {}", + match ord { + Order::Descending => "DESC", + Order::Ascending => "ASC", + } + ) + }) + .collect::>() + .join(", "); + + let query = format!( + r#" + SELECT _ROW_.* + FROM + ({user_sub_select}) AS _USER_, + (SELECT * FROM '{table_name}') as _ROW_ + WHERE + {clause} + ORDER BY + {order_clause} + LIMIT :limit + "#, + table_name = api.table_name() + ); + + let rows = state + .conn() + .query(&query, libsql::params::Params::Named(params)) + .await?; + + return Ok(Json(serde_json::Value::Array( + rows_to_json(metadata, rows, |col_name| !col_name.starts_with("_")) + .await + .map_err(|err| RecordError::Internal(err.into()))?, + ))); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::admin::user::*; + use crate::app_state::*; + use crate::auth::api::login::login_with_password; + use crate::auth::user::User; + use crate::config::proto::PermissionFlag; + use crate::records::test_utils::*; + use crate::records::Acls; + use crate::records::{add_record_api, AccessRules, RecordError}; + use crate::util::id_to_b64; + + fn is_auth_err(error: &RecordError) -> bool { + return match error { + RecordError::Forbidden => true, + _ => false, + }; + } + + #[tokio::test] + async fn test_record_api_list() -> Result<(), anyhow::Error> { + let state = test_state(None).await?; + let conn = state.conn(); + + create_chat_message_app_tables(&state).await?; + let room0 = add_room(conn, "room0").await?; + let room1 = add_room(conn, "room1").await?; + let password = "Secret!1!!"; + + add_record_api( + &state, + "messages_api", + "message", + Acls { + authenticated: vec![PermissionFlag::Create, PermissionFlag::Read], + ..Default::default() + }, + AccessRules { + read: Some("(_ROW_._owner = _USER_.id OR EXISTS(SELECT 1 FROM room_members WHERE room = _ROW_.room AND user = _USER_.id))".to_string()), + ..Default::default() + }, + ) + .await?; + + { + // Unauthenticated users cannot list + let response = list_records(&state, None, None).await; + assert!( + is_auth_err(response.as_ref().err().unwrap()), + "{response:?}" + ); + } + + let user_x_email = "user_x@test.com"; + let user_x = create_user_for_test(&state, user_x_email, password) + .await? + .into_bytes(); + let user_x_token = login_with_password(&state, user_x_email, password).await?; + + add_user_to_room(conn, user_x, room0).await?; + send_message(conn, user_x, room0, "user_x to room0").await?; + + let user_y_email = "user_y@foo.baz"; + let user_y = create_user_for_test(&state, user_y_email, password) + .await? + .into_bytes(); + + add_user_to_room(conn, user_y, room0).await?; + send_message(conn, user_y, room0, "user_y to room0").await?; + + add_user_to_room(conn, user_y, room1).await?; + send_message(conn, user_y, room1, "user_y to room1").await?; + + let user_y_token = login_with_password(&state, user_y_email, password).await?; + + { + // User X can list the messages they have access to, i.e. room0. + let arr = list_records(&state, Some(&user_x_token.auth_token), None).await?; + assert_eq!(arr.len(), 2); + } + + { + // User Y can list the messages they have access to, i.e. room0 & room1. + let arr = list_records(&state, Some(&user_y_token.auth_token), Some("".to_string())).await?; + assert_eq!(arr.len(), 3); + + let arr = list_records( + &state, + Some(&user_y_token.auth_token), + Some("limit=1".to_string()), + ) + .await?; + assert_eq!(arr.len(), 1); + } + + { + // Ordering by id; + let arr_asc = list_records( + &state, + Some(&user_y_token.auth_token), + Some("order=+id".to_string()), + ) + .await?; + assert_eq!(arr_asc.len(), 3); + + let arr_desc = list_records( + &state, + Some(&user_y_token.auth_token), + Some("order=-id".to_string()), + ) + .await?; + assert_eq!(arr_desc.len(), 3); + + assert_eq!(arr_asc, arr_desc.into_iter().rev().collect::>()); + } + + { + // Filter by room + let arr0 = list_records( + &state, + Some(&user_y_token.auth_token), + Some(format!("room={}", id_to_b64(&room0))), + ) + .await?; + assert_eq!(arr0.len(), 2); + + let arr1 = list_records( + &state, + Some(&user_y_token.auth_token), + Some(format!("room={}", id_to_b64(&room1))), + ) + .await?; + assert_eq!(arr1.len(), 1); + } + + return Ok(()); + } + + async fn list_records( + state: &AppState, + auth_token: Option<&str>, + query: Option, + ) -> Result, RecordError> { + let response = list_records_handler( + State(state.clone()), + Path("messages_api".to_string()), + RawQuery(query), + auth_token.and_then(|token| User::from_auth_token(&state, token)), + ) + .await?; + + let json = response.0; + if let serde_json::Value::Array(arr) = json { + return Ok(arr); + } + return Err(RecordError::BadRequest("Not a json array")); + } +} diff --git a/trailbase-core/src/records/mod.rs b/trailbase-core/src/records/mod.rs new file mode 100644 index 0000000..558fdbf --- /dev/null +++ b/trailbase-core/src/records/mod.rs @@ -0,0 +1,123 @@ +use axum::{ + routing::{delete, get, patch, post}, + Router, +}; +use utoipa::OpenApi; + +pub(crate) mod create_record; +pub(crate) mod delete_record; +mod error; +pub(crate) mod files; +mod json_schema; +pub mod json_to_sql; +mod list_records; +pub(crate) mod read_record; +mod record_api; +pub mod sql_to_json; +pub mod test_utils; +mod update_record; +mod validate; + +pub(crate) use error::RecordError; +pub use record_api::RecordApi; +pub(crate) use validate::validate_record_api_config; + +use crate::config::proto::{PermissionFlag, RecordApiConfig}; +use crate::config::ConfigError; +use crate::AppState; + +#[derive(OpenApi)] +#[openapi( + paths( + read_record::read_record_handler, + read_record::get_uploaded_file_from_record_handler, + read_record::get_uploaded_files_from_record_handler, + list_records::list_records_handler, + create_record::create_record_handler, + update_record::update_record_handler, + delete_record::delete_record_handler, + json_schema::json_schema_handler, + ), + components(schemas(create_record::CreateRecordResponse)) +)] +pub(super) struct RecordOpenApi; + +pub(crate) fn router() -> Router { + return Router::new() + .route("/:name/:record", get(read_record::read_record_handler)) + .route("/:name", post(create_record::create_record_handler)) + .route( + "/:name/:record", + patch(update_record::update_record_handler), + ) + .route( + "/:name/:record", + delete(delete_record::delete_record_handler), + ) + .route("/:name", get(list_records::list_records_handler)) + .route( + "/:name/:record/file/:column_name", + get(read_record::get_uploaded_file_from_record_handler), + ) + .route( + "/:name/:record/files/:column_name/:file_index", + get(read_record::get_uploaded_files_from_record_handler), + ) + .route("/:name/schema", get(json_schema::json_schema_handler)); +} + +// Since this is for APIs access control, we'll use the API- space CRUD terminology instead of +// database terminology. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Permission { + // TODO: Should there be a separate "list records" permission or is "read" enough? + Create = 1, // ~ DB insert + Read = 2, // ~ DB select + Update = 4, // ~ DB update + Delete = 8, // ~ DB delete + Schema = 16, // Lookup json schema for the given record api . +} + +#[derive(Default)] +pub struct Acls { + pub world: Vec, + pub authenticated: Vec, +} + +#[derive(Default)] +pub struct AccessRules { + pub create: Option, + pub read: Option, + pub update: Option, + pub delete: Option, + pub schema: Option, +} + +// NOTE: used in integration test. +pub async fn add_record_api( + state: &AppState, + api_name: &str, + table_name: &str, + acls: Acls, + access_rules: AccessRules, +) -> Result<(), ConfigError> { + let mut config = state.get_config(); + + config.record_apis.push(RecordApiConfig { + name: Some(api_name.to_string()), + table_name: Some(table_name.to_string()), + + acl_world: acls.world.into_iter().map(|f| f as i32).collect(), + acl_authenticated: acls.authenticated.into_iter().map(|f| f as i32).collect(), + conflict_resolution: None, + autofill_missing_user_id_columns: Some(false), + create_access_rule: access_rules.create, + read_access_rule: access_rules.read, + update_access_rule: access_rules.update, + delete_access_rule: access_rules.delete, + schema_access_rule: access_rules.schema, + }); + + return state.validate_and_update_config(config, None).await; +} diff --git a/trailbase-core/src/records/read_record.rs b/trailbase-core/src/records/read_record.rs new file mode 100644 index 0000000..578426e --- /dev/null +++ b/trailbase-core/src/records/read_record.rs @@ -0,0 +1,584 @@ +use axum::{ + extract::{Path, State}, + response::Response, + Json, +}; + +use crate::app_state::AppState; +use crate::auth::user::User; +use crate::records::files::read_file_into_response; +use crate::records::json_to_sql::{GetFileQueryBuilder, GetFilesQueryBuilder, SelectQueryBuilder}; +use crate::records::sql_to_json::row_to_json; +use crate::records::{Permission, RecordError}; + +/// Read record. +#[utoipa::path( + get, + path = "/:name/:record", + responses( + (status = 200, description = "Record contents.", body = serde_json::Value) + ) +)] +pub async fn read_record_handler( + State(state): State, + Path((api_name, record)): Path<(String, String)>, + user: Option, +) -> Result, RecordError> { + let Some(api) = state.lookup_record_api(&api_name) else { + return Err(RecordError::ApiNotFound); + }; + + let record_id = api.id_to_sql(&record)?; + + api + .check_record_level_access(Permission::Read, Some(&record_id), None, user.as_ref()) + .await?; + + let Some(row) = SelectQueryBuilder::run( + &state, + api.table_name(), + &api.record_pk_column().name, + record_id, + ) + .await? + else { + return Err(RecordError::RecordNotFound); + }; + + return Ok(Json( + row_to_json(api.metadata(), row, |col_name| !col_name.starts_with("_")) + .map_err(|err| RecordError::Internal(err.into()))?, + )); +} + +type GetUploadedFileFromRecordPath = Path<( + String, // RecordApi name + String, // Record id + String, // Column name, +)>; + +/// Read file associated with record. +#[utoipa::path( + get, + path = "/:name/:record/file/:column_name", + responses( + (status = 200, description = "File contents.") + ) +)] +pub async fn get_uploaded_file_from_record_handler( + state: State, + Path((api_name, record, column_name)): GetUploadedFileFromRecordPath, + user: Option, +) -> Result { + let Some(api) = state.lookup_record_api(&api_name) else { + return Err(RecordError::ApiNotFound); + }; + + let record_id = api.id_to_sql(&record)?; + + let Ok(()) = api + .check_record_level_access(Permission::Read, Some(&record_id), None, user.as_ref()) + .await + else { + return Err(RecordError::Forbidden); + }; + + let metadata = api.metadata(); + let Some(column) = metadata.column_by_name(&column_name) else { + return Err(RecordError::BadRequest("Invalid field/column name")); + }; + + let file_upload = GetFileQueryBuilder::run( + &state, + api.table_name(), + column, + &api.record_pk_column().name, + record_id, + ) + .await + .map_err(|err| RecordError::Internal(err.into()))?; + + return read_file_into_response(&state, file_upload) + .await + .map_err(|err| RecordError::Internal(err.into())); +} + +type GetUploadedFilesFromRecordPath = Path<( + String, // RecordApi name + String, // Record id + String, // Column name, + usize, // Index +)>; + +/// Read single file from list associated with record. +#[utoipa::path( + get, + path = "/:name/:record/files/:column_name/:file_index", + responses( + (status = 200, description = "File contents.") + ) +)] +pub async fn get_uploaded_files_from_record_handler( + State(state): State, + Path((api_name, record, column_name, file_index)): GetUploadedFilesFromRecordPath, + user: Option, +) -> Result { + let Some(api) = state.lookup_record_api(&api_name) else { + return Err(RecordError::ApiNotFound); + }; + + let record_id = api.id_to_sql(&record)?; + + let Ok(()) = api + .check_record_level_access(Permission::Read, Some(&record_id), None, user.as_ref()) + .await + else { + return Err(RecordError::Forbidden); + }; + + let Some(column) = api.metadata().column_by_name(&column_name) else { + return Err(RecordError::RecordNotFound); + }; + + let mut file_uploads = GetFilesQueryBuilder::run( + &state, + api.table_name(), + column, + &api.record_pk_column().name, + record_id, + ) + .await + .map_err(|err| RecordError::Internal(err.into()))?; + + if file_index >= file_uploads.0.len() { + return Err(RecordError::RecordNotFound); + } + + return read_file_into_response(&state, file_uploads.0.remove(file_index)) + .await + .map_err(|err| RecordError::Internal(err.into())); +} + +#[cfg(test)] +mod test { + use axum::extract::{Path, Query, State}; + use axum::Json; + use trailbase_sqlite::{query_one_row, schema::FileUpload, schema::FileUploadInput}; + + use super::*; + use crate::admin::user::*; + use crate::app_state::*; + use crate::auth::api::login::login_with_password; + use crate::auth::user::User; + use crate::config::proto::PermissionFlag; + use crate::constants::USER_TABLE; + use crate::extract::Either; + use crate::records::create_record::{ + create_record_handler, CreateRecordQuery, CreateRecordResponse, + }; + use crate::records::delete_record::delete_record_handler; + use crate::records::test_utils::*; + use crate::records::*; + use crate::test::unpack_json_response; + use crate::util::id_to_b64; + + #[tokio::test] + async fn libsql_ignores_extra_parameters_test() -> Result<(), anyhow::Error> { + // This test is actually just testing libsql and making sure that we can overprovision + // arguments. Specifically, we want to provide :user and :id arguments even if they're not + // consumed by a user-provided access query. + let state = test_state(None).await?; + let conn = state.user_conn(); + + const EMAIL: &str = "foo@bar.baz"; + conn + .execute( + &format!("INSERT INTO '{USER_TABLE}' (email) VALUES ($1)"), + libsql::params!(EMAIL), + ) + .await?; + + query_one_row( + conn, + &format!("SELECT * from '{USER_TABLE}' WHERE email = :email"), + libsql::named_params! { + ":email": EMAIL, + ":unused": "unused", + ":foo": 42, + }, + ) + .await?; + + return Ok(()); + } + + #[tokio::test] + async fn test_record_api_read() -> Result<(), anyhow::Error> { + let state = test_state(None).await?; + let conn = state.conn(); + + // Add tables and record api before inserting data. + create_chat_message_app_tables(&state).await?; + let room0 = add_room(conn, "room0").await?; + let room1 = add_room(conn, "room1").await?; + let password = "Secret!1!!"; + + // Register message table as record api with moderator read access. + add_record_api( + &state, + "messages_api", + "message", + Acls { + authenticated: vec![PermissionFlag::Create, PermissionFlag::Read], + ..Default::default() + }, + AccessRules { + read: Some("(_ROW_._owner = _USER_.id OR EXISTS(SELECT 1 FROM room_members WHERE room = _ROW_.room AND user = _USER_.id))".to_string()), + ..Default::default() + }, + ) + .await?; + + let user_x_email = "user_x@test.com"; + let user_x = create_user_for_test(&state, user_x_email, password) + .await? + .into_bytes(); + + add_user_to_room(conn, user_x, room0).await?; + add_user_to_room(conn, user_x, room1).await?; + + let user_x_token = login_with_password(&state, user_x_email, password).await?; + + let user_y_email = "user_y@foo.baz"; + let user_y = create_user_for_test(&state, user_y_email, password) + .await? + .into_bytes(); + + add_user_to_room(conn, user_y, room0).await?; + + let user_y_token = login_with_password(&state, user_y_email, password).await?; + + // Finally, create some messages and try to access them. + { + // Post to room0. X, Y, and mod should be able to read it. + let message_id = send_message(conn, user_x, room0, "from user_x to room0").await?; + + // No creds, no read + assert!(read_record_handler( + State(state.clone()), + Path(("messages_api".to_string(), id_to_b64(&message_id),)), + None + ) + .await + .is_err()); + + { + // User X + let response = read_record_handler( + State(state.clone()), + Path(("messages_api".to_string(), id_to_b64(&message_id))), + User::from_auth_token(&state, &user_x_token.auth_token), + ) + .await; + assert!(response.is_ok(), "{response:?}"); + } + + { + // User Y + let response = read_record_handler( + State(state.clone()), + Path(("messages_api".to_string(), id_to_b64(&message_id))), + User::from_auth_token(&state, &user_y_token.auth_token), + ) + .await; + assert!(response.is_ok(), "{response:?}"); + } + } + + { + // Post to room1. Only X, and mod should be able to read it. User Y is not a member + let message_id = send_message(conn, user_x, room1, "from user_x to room1").await?; + + // User Y + let response = read_record_handler( + State(state.clone()), + Path(("messages_api".to_string(), id_to_b64(&message_id))), + User::from_auth_token(&state, &user_y_token.auth_token), + ) + .await; + assert!(response.is_err(), "{response:?}"); + } + + return Ok(()); + } + + async fn create_test_record_api(state: &AppState, api_name: &str) -> Result<(), anyhow::Error> { + let conn = state.conn(); + conn + .execute( + &format!( + r#"CREATE TABLE 'test_table' ( + id BLOB PRIMARY KEY NOT NULL CHECK(is_uuid_v7(id)) DEFAULT(uuid_v7()), + file TEXT CHECK(jsonschema('std.FileUpload', file)), + files TEXT CHECK(jsonschema('std.FileUploads', files)) + ) strict"# + ), + (), + ) + .await?; + + state.table_metadata().invalidate_all().await?; + + add_record_api( + &state, + api_name, + "test_table", + Acls { + world: vec![ + PermissionFlag::Create, + PermissionFlag::Read, + PermissionFlag::Delete, + ], + ..Default::default() + }, + AccessRules::default(), + ) + .await?; + + return Ok(()); + } + + // NOTE: would ideally be in a create_record test instead. + #[tokio::test] + async fn test_empty_create_record() -> Result<(), anyhow::Error> { + let state = test_state(None).await?; + + const API_NAME: &str = "test_api"; + create_test_record_api(&state, API_NAME).await?; + + let _ = create_record_handler( + State(state.clone()), + Path(API_NAME.to_string()), + Query(CreateRecordQuery::default()), + None, + Either::Json(serde_json::json!({})), + ) + .await + .unwrap(); + + return Ok(()); + } + + #[tokio::test] + async fn test_single_file_upload_download_e2e() -> Result<(), anyhow::Error> { + let state = test_state(None).await?; + const API_NAME: &str = "test_api"; + create_test_record_api(&state, API_NAME).await?; + + let bytes: Vec = vec![42, 5, 42, 5]; + let file_column = "file"; + let create_response: CreateRecordResponse = unpack_json_response( + create_record_handler( + State(state.clone()), + Path(API_NAME.to_string()), + Query(CreateRecordQuery::default()), + None, + Either::Json(serde_json::json!({ + file_column: FileUploadInput { + name: Some("foo".to_string()), + filename: Some("bar".to_string()), + content_type: Some("baz".to_string()), + data: bytes.clone(), + }, + })), + ) + .await?, + ) + .await?; + + let record_path = Path((API_NAME.to_string(), create_response.id.clone())); + + let Json(value) = + read_record_handler(State(state.clone()), Path(record_path.clone()), None).await?; + + let serde_json::Value::Object(map) = value else { + panic!("Not a map"); + }; + + let file_upload: FileUpload = serde_json::from_value(map.get("file").unwrap().clone())?; + assert_eq!(file_upload.original_filename(), Some("bar")); + assert_eq!(file_upload.content_type(), Some("baz")); + + let record_file_path = Path(( + API_NAME.to_string(), + create_response.id.clone(), + file_column.to_string(), + )); + + let read_response = get_uploaded_file_from_record_handler( + State(state.clone()), + Path(record_file_path.clone()), + None, + ) + .await?; + + let body = axum::body::to_bytes(read_response.into_body(), usize::MAX).await?; + assert_eq!(body.to_vec(), bytes); + + let _ = delete_record_handler(State(state.clone()), Path(record_path.clone()), None) + .await + .unwrap(); + + let mut dir_cnt = 0; + let mut read_dir = tokio::fs::read_dir(state.data_dir().uploads_path()).await?; + while let Some(entry) = read_dir.next_entry().await? { + log::error!("{entry:?}"); + dir_cnt += 1; + } + assert_eq!(dir_cnt, 0); + + assert!(get_uploaded_file_from_record_handler( + State(state.clone()), + Path(record_file_path.clone()), + None, + ) + .await + .is_err()); + + return Ok(()); + } + + #[tokio::test] + async fn test_multiple_file_upload_download_e2e() -> Result<(), anyhow::Error> { + let state = test_state(None).await?; + const API_NAME: &str = "test_api"; + create_test_record_api(&state, API_NAME).await?; + + let bytes1: Vec = vec![0, 1, 1, 2]; + let bytes2: Vec = vec![42, 5, 42, 5]; + + let files_column = "files"; + let resp: CreateRecordResponse = unpack_json_response( + create_record_handler( + State(state.clone()), + Path(API_NAME.to_string()), + Query(CreateRecordQuery::default()), + None, + Either::Json(serde_json::json!({ + files_column: vec![ + FileUploadInput { + name: Some("foo0".to_string()), + filename: Some("bar0".to_string()), + content_type: Some("baz0".to_string()), + data: bytes1.clone(), + }, + FileUploadInput { + name: Some("foo1".to_string()), + filename: Some("bar1".to_string()), + content_type: Some("baz1".to_string()), + data: bytes2.clone(), + }, + ], + })), + ) + .await?, + ) + .await?; + + let record_path = Path((API_NAME.to_string(), resp.id.clone())); + + let Json(value) = read_record_handler(State(state.clone()), record_path, None).await?; + + let serde_json::Value::Object(map) = value else { + panic!("Not a map"); + }; + + let file_uploads: Vec = serde_json::from_value(map.get("files").unwrap().clone())?; + + for (index, bytes) in [(0, bytes1), (1, bytes2)] { + let f = &file_uploads[index]; + assert_eq!(f.original_filename(), Some(format!("bar{index}").as_str())); + assert_eq!(f.content_type(), Some(format!("baz{index}").as_str())); + + let record_file_path = Path(( + API_NAME.to_string(), + resp.id.clone(), + files_column.to_string(), + index, + )); + + let response = + get_uploaded_files_from_record_handler(State(state.clone()), record_file_path, None) + .await?; + + let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?; + assert_eq!(body.to_vec(), bytes); + } + + return Ok(()); + } + + #[tokio::test] + async fn test_read_record_from_view() -> Result<(), anyhow::Error> { + let state = test_state(None).await?; + let conn = state.conn(); + + // Add tables and record api before inserting data. + create_chat_message_app_tables(&state).await?; + + // Create view + let table_name = "message"; + let view_name = "message_view"; + conn + .execute( + &format!("CREATE VIEW {view_name} AS SELECT * FROM {table_name}"), + (), + ) + .await?; + + state.table_metadata().invalidate_all().await?; + + let room0 = add_room(conn, "room0").await?; + let room1 = add_room(conn, "room1").await?; + let password = "Secret!1!!"; + + add_record_api( + &state, + "messages_api", + view_name, + Acls { + authenticated: vec![PermissionFlag::Create, PermissionFlag::Read], + ..Default::default() + }, + AccessRules { + read: Some("(_ROW_._owner = _USER_.id OR EXISTS(SELECT 1 FROM room_members WHERE room = _ROW_.room AND user = _USER_.id))".to_string()), + ..Default::default() + }, + ) + .await?; + + let user_x_email = "user_x@test.com"; + let user_x = create_user_for_test(&state, user_x_email, password) + .await? + .into_bytes(); + + add_user_to_room(conn, user_x, room0).await?; + add_user_to_room(conn, user_x, room1).await?; + + let user_x_token = login_with_password(&state, user_x_email, password).await?; + + // Post to room0. X, Y, and mod should be able to read it. + let message_id = send_message(conn, user_x, room0, "from user_x to room0").await?; + + // User X + let response = read_record_handler( + State(state.clone()), + Path(("messages_api".to_string(), id_to_b64(&message_id))), + User::from_auth_token(&state, &user_x_token.auth_token), + ) + .await; + assert!(response.is_ok(), "{response:?}"); + + return Ok(()); + } +} diff --git a/trailbase-core/src/records/record_api.rs b/trailbase-core/src/records/record_api.rs new file mode 100644 index 0000000..a933eff --- /dev/null +++ b/trailbase-core/src/records/record_api.rs @@ -0,0 +1,508 @@ +use itertools::Itertools; +use log::*; +use std::sync::Arc; +use trailbase_sqlite::query_one_row; + +use crate::auth::user::User; +use crate::config::proto::{ConflictResolutionStrategy, RecordApiConfig}; +use crate::records::json_to_sql::{LazyParams, Params}; +use crate::records::{Permission, RecordError}; +use crate::schema::{Column, ColumnDataType}; +use crate::table_metadata::{TableMetadata, TableOrViewMetadata, ViewMetadata}; +use crate::util::{assert_uuidv7, b64_to_id}; + +/// FILTER CONTROL. +/// +/// Open question: right now we use the read_access rule also for listing. It could be nice to +/// allow different access rules. On the other hand, this could also lead to setups where you can +/// list records you cannot read (the other way round might be more sensible). +/// On the other hand, different permissions could also be modeled as multiple apis on the same +/// table. +/// +/// Independently, listing a user's own items might be a common task. Should we support a magic +/// filter "mine" or is "owner_col=" good enough? +#[derive(Clone)] +pub struct RecordApi { + state: Arc, +} + +enum RecordApiMetadata { + Table(TableMetadata), + View(ViewMetadata), +} + +struct RecordApiState { + conn: libsql::Connection, + metadata: RecordApiMetadata, + record_pk_column: Column, + + // Below properties are filled from `proto::RecordApiConfig`. + api_name: String, + acl: [u8; 2], + insert_conflict_resolution_strategy: Option, + insert_autofill_missing_user_id_columns: bool, + + create_access_rule: Option, + read_access_rule: Option, + update_access_rule: Option, + delete_access_rule: Option, + schema_access_rule: Option, +} + +impl RecordApi { + pub fn from_table( + conn: libsql::Connection, + table_metadata: TableMetadata, + config: RecordApiConfig, + ) -> Result { + let Some(ref table_name) = config.table_name else { + return Err(format!( + "RecordApi misses table_name configuration: {config:?}" + )); + }; + if table_name != table_metadata.name() { + return Err(format!( + "Expected table name '{table_name}', got: {}", + table_metadata.name() + )); + } + + let Some((_index, record_pk_column)) = table_metadata.record_pk_column() else { + return Err(format!( + "RecordApi requires integer/UUIDv7 primary key column: {config:?}" + )); + }; + + return Self::from_impl( + conn, + record_pk_column.clone(), + RecordApiMetadata::Table(table_metadata), + config, + ); + } + + pub fn from_view( + conn: libsql::Connection, + view_metadata: ViewMetadata, + config: RecordApiConfig, + ) -> Result { + let Some(ref table_name) = config.table_name else { + return Err(format!( + "RecordApi misses table_name configuration: {config:?}" + )); + }; + if table_name != view_metadata.name() { + return Err(format!( + "Expected table name '{table_name}', got: {}", + view_metadata.name() + )); + } + + if view_metadata.schema.temporary { + return Err(format!( + "RecordAPIs cannot point to temporary view: {table_name}", + )); + } + + let Some((_index, record_pk_column)) = view_metadata.record_pk_column() else { + return Err(format!( + "RecordApi requires integer/UUIDv7 primary key column: {config:?}" + )); + }; + + return Self::from_impl( + conn, + record_pk_column.clone(), + RecordApiMetadata::View(view_metadata), + config, + ); + } + + fn from_impl( + conn: libsql::Connection, + record_pk_column: Column, + metadata: RecordApiMetadata, + config: RecordApiConfig, + ) -> Result { + let Some(api_name) = config.name.clone() else { + return Err(format!("RecordApi misses name: {config:?}")); + }; + + return Ok(RecordApi { + state: Arc::new(RecordApiState { + conn, + metadata, + record_pk_column, + // proto::RecordApiConfig properties below: + api_name, + + // Insert- specific options. + insert_conflict_resolution_strategy: config + .conflict_resolution + .and_then(|cr| cr.try_into().ok()), + insert_autofill_missing_user_id_columns: config + .autofill_missing_user_id_columns + .unwrap_or(false), + + // Access control lists. + acl: [ + convert_acl(&config.acl_world), + convert_acl(&config.acl_authenticated), + ], + // Access rules. + create_access_rule: config.create_access_rule, + read_access_rule: config.read_access_rule, + update_access_rule: config.update_access_rule, + delete_access_rule: config.delete_access_rule, + schema_access_rule: config.schema_access_rule, + }), + }); + } + + #[inline] + pub fn api_name(&self) -> &str { + &self.state.api_name + } + + #[inline] + pub fn table_name(&self) -> &str { + match &self.state.metadata { + RecordApiMetadata::Table(ref table) => &table.schema.name, + RecordApiMetadata::View(ref view) => &view.schema.name, + } + } + + #[inline] + pub fn metadata(&self) -> &(dyn TableOrViewMetadata + Send + Sync) { + match &self.state.metadata { + RecordApiMetadata::Table(ref table) => table, + RecordApiMetadata::View(ref view) => view, + } + } + + pub fn table_metadata(&self) -> Option<&TableMetadata> { + match &self.state.metadata { + RecordApiMetadata::Table(ref table) => Some(table), + RecordApiMetadata::View(ref _view) => None, + } + } + + pub fn id_to_sql(&self, id: &str) -> Result { + return match self.state.record_pk_column.data_type { + ColumnDataType::Blob => { + let record_id = b64_to_id(id).map_err(|_err| RecordError::BadRequest("Invalid id"))?; + assert_uuidv7(&record_id); + Ok(libsql::Value::Blob(record_id.into())) + } + ColumnDataType::Integer => Ok(libsql::Value::Integer( + id.parse::() + .map_err(|_err| RecordError::BadRequest("Invalid id"))?, + )), + _ => Err(RecordError::BadRequest("Invalid id")), + }; + } + + #[inline] + pub fn record_pk_column(&self) -> &Column { + return &self.state.record_pk_column; + } + + #[inline] + pub fn access_rule(&self, p: Permission) -> &Option { + return match p { + Permission::Create => &self.state.create_access_rule, + Permission::Read => &self.state.read_access_rule, + Permission::Update => &self.state.update_access_rule, + Permission::Delete => &self.state.delete_access_rule, + Permission::Schema => &self.state.schema_access_rule, + }; + } + + #[inline] + pub fn insert_autofill_missing_user_id_columns(&self) -> bool { + return self.state.insert_autofill_missing_user_id_columns; + } + + #[inline] + pub fn insert_conflict_resolution_strategy(&self) -> Option { + return self.state.insert_conflict_resolution_strategy; + } + + /// Check if the given user (if any) can access a record given the request and the operation. + pub async fn check_record_level_access( + &self, + p: Permission, + record_id: Option<&libsql::Value>, + request_params: Option<&mut LazyParams<'_>>, + user: Option<&User>, + ) -> Result<(), RecordError> { + // First check table level access and if present check row-level access based on access rule. + self.check_table_level_access(p, user)?; + + 'acl: { + let Some(ref access_rule) = self.access_rule(p) else { + return Ok(()); + }; + + let (access_query, params) = self + .build_access_query_and_params( + p, + access_rule, + self.table_name(), + record_id, + request_params, + user, + ) + .await?; + + let row = match query_one_row(&self.state.conn, &access_query, params).await { + Ok(row) => row, + Err(err) => { + error!("RLA query '{access_query}' failed: {err}"); + break 'acl; + } + }; + + let allowed: bool = match row.get(0) { + Ok(allowed) => allowed, + Err(err) => { + if cfg!(test) { + panic!("RLA query returned NULL. Failing closed: '{access_query}'\n{err}"); + } else { + warn!("RLA query returned NULL. Failing closed: '{access_query}'\n{err}"); + } + break 'acl; + } + }; + + if allowed { + return Ok(()); + } + } + + return Err(RecordError::Forbidden); + } + + #[inline] + pub fn check_table_level_access( + &self, + p: Permission, + user: Option<&User>, + ) -> Result<(), RecordError> { + if (user.is_some() && self.has_access(Entity::Authenticated, p)) + || self.has_access(Entity::World, p) + { + return Ok(()); + } + + return Err(RecordError::Forbidden); + } + + #[inline] + fn has_access(&self, e: Entity, p: Permission) -> bool { + return (self.state.acl[e as usize] & (p as u8)) > 0; + } + + // TODO: We should probably break this up into separate functions for CRUD, to only do and inject + // what's actually needed. Maybe even break up the entire check_access_and_rls_then. It's pretty + // winding right now. + async fn build_access_query_and_params( + &self, + p: Permission, + access_rule: &str, + table_name: &str, + record_id: Option<&libsql::Value>, + request_params: Option<&mut LazyParams<'_>>, + user: Option<&User>, + ) -> Result<(String, libsql::params::Params), RecordError> { + let pk_column_name = &self.state.record_pk_column.name; + // We need to inject context like: record id, user, request, and row into the access + // check. Below we're building the query and binding the context as params accordingly. + let (user_sub_select, mut params) = build_user_sub_select(user); + + params.push(( + ":__record_id".to_string(), + record_id.map_or(libsql::Value::Null, |id| id.clone()), + )); + + // Assumes access_rule is an expression: https://www.sqlite.org/syntax/expr.html + // + // Create has no "row" + // Read and delete have no "request" + // And only update has "row" and "request". + let query = match p { + Permission::Create => { + let Some(table_metadata) = self.table_metadata() else { + return Err(RecordError::ApiRequiresTable); + }; + + let (request_sub_select, mut request_params) = build_request_sub_select( + table_metadata, + request_params + .unwrap() + .params() + .map_err(|err| RecordError::Internal(err.into()))?, + ); + params.append(&mut request_params); + + indoc::formatdoc!( + r#" + SELECT + ({access_rule}) + FROM + ({user_sub_select}) AS _USER_, + ({request_sub_select}) AS _REQ_ + "#, + ) + } + Permission::Update => { + let Some(table_metadata) = self.table_metadata() else { + return Err(RecordError::ApiRequiresTable); + }; + + let (request_sub_select, mut request_params) = build_request_sub_select( + table_metadata, + request_params + .unwrap() + .params() + .map_err(|err| RecordError::Internal(err.into()))?, + ); + params.append(&mut request_params); + + indoc::formatdoc!( + r#" + SELECT + ({access_rule}) + FROM + ({user_sub_select}) AS _USER_, + ({request_sub_select}) AS _REQ_, + (SELECT * FROM [{table_name}] WHERE [{pk_column_name}] = :__record_id) AS _ROW_ + "#, + ) + } + Permission::Read | Permission::Delete | Permission::Schema => indoc::formatdoc!( + r#" + SELECT + ({access_rule}) + FROM + ({user_sub_select}) AS _USER_, + (SELECT * FROM [{table_name}] WHERE [{pk_column_name}] = :__record_id) AS _ROW_ + "# + ), + }; + + return Ok((query, libsql::params::Params::Named(params))); + } +} + +pub(crate) fn build_user_sub_select( + user: Option<&User>, +) -> (&'static str, Vec<(String, libsql::Value)>) { + const QUERY: &str = "SELECT :__user_id AS id"; + + if let Some(user) = user { + return ( + QUERY, + vec![( + ":__user_id".to_string(), + libsql::Value::Blob(user.uuid.into()), + )], + ); + } else { + return (QUERY, vec![(":__user_id".to_string(), libsql::Value::Null)]); + } +} + +/// Builds the sub-query for _REQ_. +fn build_request_sub_select( + table_metadata: &TableMetadata, + request_params: &Params, +) -> (String, Vec<(String, libsql::Value)>) { + // NOTE: This has gotten pretty wild. We cannot have access queries access missing _REQ_.props. + // So we need to inject an explicit NULL value for all missing fields on the request. + // Can we make this cheaper, either by pre-processing the access query or improving construction? + // For example, could we build a transaction-scoped temp view with positional placeholders to + // save some string ops? + let schema = &table_metadata.schema; + + let mut named_params: Vec<(String, libsql::Value)> = schema + .columns + .iter() + .map(|c| (format!(":{}", c.name), libsql::Value::Null)) + .collect(); + + for (param_index, col_name) in request_params.column_names().iter().enumerate() { + let Some(col_index) = table_metadata.column_index_by_name(col_name) else { + // We simply skip unknown columns, this could simply be malformed input or version skew. This + // is similar in spirit to protobuf's unknown fields behavior. + continue; + }; + + named_params[col_index].1 = request_params.named_params()[param_index].1.clone(); + } + + return ( + format!( + "SELECT {placeholders}", + placeholders = schema + .columns + .iter() + .map(|col| format!(":{col_name} AS '{col_name}'", col_name = col.name)) + .join(", ") + ), + named_params, + ); +} + +fn convert_acl(acl: &Vec) -> u8 { + let mut value: u8 = 0; + for flag in acl { + value |= *flag as u8; + } + return value; +} + +// Note: ACLs and entities are only enforced on the table-level, this owner (row-level concept) is +// not here. +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq)] +enum Entity { + World = 0, + Authenticated = 1, +} + +#[cfg(test)] +mod tests { + use super::convert_acl; + use crate::{config::proto::PermissionFlag, records::Permission}; + + fn has_access(flags: u8, p: Permission) -> bool { + return (flags & (p as u8)) > 0; + } + + #[test] + fn test_acl_conversion() { + { + let acl = convert_acl(&vec![PermissionFlag::Read as i32]); + assert!(has_access(acl, Permission::Read)); + } + + { + let acl = convert_acl(&vec![ + PermissionFlag::Read as i32, + PermissionFlag::Create as i32, + ]); + assert!(has_access(acl, Permission::Read)); + assert!(has_access(acl, Permission::Create)); + } + + { + let acl = convert_acl(&vec![ + PermissionFlag::Delete as i32, + PermissionFlag::Update as i32, + ]); + assert!(has_access(acl, Permission::Delete)); + assert!(has_access(acl, Permission::Update), "ACL: {acl}"); + } + } +} diff --git a/trailbase-core/src/records/sql_to_json.rs b/trailbase-core/src/records/sql_to_json.rs new file mode 100644 index 0000000..91f4c4d --- /dev/null +++ b/trailbase-core/src/records/sql_to_json.rs @@ -0,0 +1,224 @@ +use base64::prelude::*; +use log::*; +use thiserror::Error; + +use crate::schema::{Column, ColumnDataType}; +use crate::table_metadata::TableOrViewMetadata; + +#[derive(Debug, Error)] +pub enum JsonError { + #[error("SerdeJson error: {0}")] + SerdeJson(#[from] serde_json::Error), + #[error("Malformed bytes, len {0}")] + MalformedBytes(usize), + #[error("Row not found")] + RowNotFound, + #[error("Float not finite")] + Finite, + #[error("Value not found")] + ValueNotFound, +} + +fn value_to_json(value: libsql::Value) -> Result { + return Ok(match value { + libsql::Value::Null => serde_json::Value::Null, + libsql::Value::Real(real) => { + let Some(number) = serde_json::Number::from_f64(real) else { + return Err(JsonError::Finite); + }; + serde_json::Value::Number(number) + } + libsql::Value::Integer(integer) => serde_json::Value::Number(serde_json::Number::from(integer)), + libsql::Value::Blob(blob) => serde_json::Value::String(BASE64_URL_SAFE.encode(blob)), + libsql::Value::Text(text) => serde_json::Value::String(text), + }); +} + +// Serialize libsql row to json. +pub fn row_to_json( + metadata: &(dyn TableOrViewMetadata + Send + Sync), + row: libsql::Row, + column_filter: fn(&str) -> bool, +) -> Result { + let mut map = serde_json::Map::::default(); + + for i in 0..(row.column_count()) { + let Some(col_name) = row.column_name(i) else { + error!("Missing column name for {i} in {row:?}"); + continue; + }; + if !column_filter(col_name) { + continue; + } + + let value = row.get_value(i).map_err(|_err| JsonError::ValueNotFound)?; + if let libsql::Value::Text(str) = &value { + if let Some((_col, col_meta)) = metadata.column_by_name(col_name) { + if col_meta.json.is_some() { + map.insert(col_name.to_string(), serde_json::from_str(str)?); + continue; + } + } else { + warn!("Missing col: {col_name}"); + } + } + + map.insert(col_name.to_string(), value_to_json(value)?); + } + + return Ok(serde_json::Value::Object(map)); +} + +// Turns rows into a list of json objects. +pub async fn rows_to_json( + metadata: &(dyn TableOrViewMetadata + Send + Sync), + mut rows: libsql::Rows, + column_filter: fn(&str) -> bool, +) -> Result, JsonError> { + let mut objects: Vec = vec![]; + + while let Some(row) = rows.next().await.map_err(|_err| JsonError::RowNotFound)? { + objects.push(row_to_json(metadata, row, column_filter)?); + } + + return Ok(objects); +} + +/// Turns a row into a list of json arrays. +pub fn row_to_json_array(row: libsql::Row) -> Result, JsonError> { + let cols = row.column_count(); + let mut json_row = Vec::::with_capacity(cols as usize); + + for i in 0..cols { + let value = row.get_value(i).map_err(|_err| JsonError::ValueNotFound)?; + json_row.push(value_to_json(value)?); + } + + return Ok(json_row); +} + +/// Best-effort conversion from row values to column definition. +/// +/// WARN: This is lossy and whenever possible we should rely on parsed "CREATE TABLE" statement for +/// the respective column. +fn rows_to_columns(rows: &libsql::Rows) -> Result, libsql::Error> { + use libsql::ValueType as T; + + let mut columns: Vec = vec![]; + for i in 0..rows.column_count() { + columns.push(Column { + name: rows.column_name(i).unwrap_or("").to_string(), + data_type: match rows.column_type(i)? { + T::Real => ColumnDataType::Real, + T::Text => ColumnDataType::Text, + T::Integer => ColumnDataType::Integer, + T::Null => ColumnDataType::Null, + T::Blob => ColumnDataType::Blob, + }, + // We cannot derive the options from a row of data. + options: vec![], + }); + } + + return Ok(columns); +} + +/// Turns rows into a list of json arrays. +pub async fn rows_to_json_arrays( + mut rows: libsql::Rows, + limit: usize, +) -> Result<(Vec>, Option>), JsonError> { + let mut cnt = 0_usize; + + let columns = rows_to_columns(&rows).ok(); + + let mut json_rows: Vec> = vec![]; + while let Some(row) = rows.next().await.map_err(|_err| JsonError::RowNotFound)? { + if cnt >= limit { + break; + } + cnt += 1; + + json_rows.push(row_to_json_array(row)?); + } + + return Ok((json_rows, columns)); +} + +#[cfg(test)] +mod tests { + + use serde_json::json; + + use super::*; + use crate::app_state::*; + use crate::table_metadata::{lookup_and_parse_table_schema, TableMetadata}; + + #[tokio::test] + async fn test_read_rows() { + let state = test_state(None).await.unwrap(); + let conn = state.conn(); + + let pattern = serde_json::from_str( + r#"{ + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "obj": { + "type": "object" + } + }, + "required": ["name", "obj"] + }"#, + ) + .unwrap(); + + trailbase_sqlite::schema::set_user_schema("foo", Some(pattern)).unwrap(); + conn + .execute( + &format!( + r#"CREATE TABLE test_table ( + col0 TEXT CHECK(jsonschema('foo', col0)) + ) strict"# + ), + (), + ) + .await + .unwrap(); + + let table = lookup_and_parse_table_schema(conn, "test_table") + .await + .unwrap(); + let metadata = TableMetadata::new(table.clone(), &[table]); + + let insert = |json: serde_json::Value| async move { + conn + .execute( + &format!( + "INSERT INTO test_table (col0) VALUES ('{}')", + json.to_string() + ), + (), + ) + .await + }; + + let object = json!({"name": "foo", "obj": json!({ + "a": "b", + "c": 42, + })}); + insert(object.clone()).await.unwrap(); + + let rows = conn.query("SELECT * FROM test_table", ()).await.unwrap(); + let parsed = rows_to_json(&metadata, rows, |_| true).await.unwrap(); + + assert_eq!(parsed.len(), 1); + let serde_json::Value::Object(map) = parsed.first().unwrap() else { + panic!("expected object"); + }; + assert_eq!(map.get("col0").unwrap().clone(), object); + } +} diff --git a/trailbase-core/src/records/test_utils.rs b/trailbase-core/src/records/test_utils.rs new file mode 100644 index 0000000..d6fdeaf --- /dev/null +++ b/trailbase-core/src/records/test_utils.rs @@ -0,0 +1,130 @@ +#[cfg(test)] +mod tests { + use crate::AppState; + use libsql::{params, Connection}; + use trailbase_sqlite::query_one_row; + + pub async fn create_chat_message_app_tables(state: &AppState) -> Result<(), libsql::Error> { + // Create a messages, chat room and members tables. + state + .conn() + .execute_batch( + r#" + CREATE TABLE room ( + id BLOB PRIMARY KEY NOT NULL CHECK(is_uuid_v7(id)) DEFAULT(uuid_v7()), + name TEXT + ) STRICT; + + CREATE TABLE message ( + id BLOB PRIMARY KEY NOT NULL CHECK(is_uuid_v7(id)) DEFAULT (uuid_v7()), + _owner BLOB NOT NULL, + room BLOB NOT NULL, + data TEXT NOT NULL DEFAULT 'empty', + + -- on user delete, toombstone it. + FOREIGN KEY(_owner) REFERENCES _user(id) ON DELETE SET NULL, + -- On chatroom delete, delete message + FOREIGN KEY(room) REFERENCES room(id) ON DELETE CASCADE + ) STRICT; + + CREATE TABLE room_members ( + user BLOB NOT NULL, + room BLOB NOT NULL, + + FOREIGN KEY(room) REFERENCES room(id) ON DELETE CASCADE, + FOREIGN KEY(user) REFERENCES _user(id) ON DELETE CASCADE + ) STRICT; + "#, + ) + .await?; + + state.table_metadata().invalidate_all().await.unwrap(); + + return Ok(()); + } + + pub async fn create_chat_message_app_tables_integer( + state: &AppState, + ) -> Result<(), libsql::Error> { + // Create a messages, chat room and members tables. + state + .conn() + .execute_batch( + r#" + CREATE TABLE room ( + id BLOB PRIMARY KEY NOT NULL CHECK(is_uuid_v7(id)) DEFAULT(uuid_v7()), + name TEXT + ) STRICT; + + CREATE TABLE message ( + id INTEGER PRIMARY KEY, + _owner BLOB NOT NULL, + room BLOB NOT NULL, + data TEXT NOT NULL DEFAULT 'empty', + + -- on user delete, toombstone it. + FOREIGN KEY(_owner) REFERENCES _user(id) ON DELETE SET NULL, + -- On chatroom delete, delete message + FOREIGN KEY(room) REFERENCES room(id) ON DELETE CASCADE + ) STRICT; + + CREATE TABLE room_members ( + user BLOB NOT NULL, + room BLOB NOT NULL, + + FOREIGN KEY(room) REFERENCES room(id) ON DELETE CASCADE, + FOREIGN KEY(user) REFERENCES _user(id) ON DELETE CASCADE + ) STRICT; + "#, + ) + .await?; + + state.table_metadata().invalidate_all().await.unwrap(); + + return Ok(()); + } + + pub async fn add_room(conn: &Connection, name: &str) -> Result<[u8; 16], libsql::Error> { + let room: [u8; 16] = query_one_row( + conn, + "INSERT INTO room (name) VALUES ($1) RETURNING id", + params!(name), + ) + .await? + .get(0)?; + + return Ok(room); + } + + pub async fn add_user_to_room( + conn: &Connection, + user: [u8; 16], + room: [u8; 16], + ) -> Result<(), libsql::Error> { + conn + .execute( + "INSERT INTO room_members (user, room) VALUES ($1, $2)", + params!(user, room), + ) + .await?; + return Ok(()); + } + + pub async fn send_message( + conn: &Connection, + user: [u8; 16], + room: [u8; 16], + message: &str, + ) -> Result<[u8; 16], libsql::Error> { + return query_one_row( + conn, + "INSERT INTO message (_owner, room, data) VALUES ($1, $2, $3) RETURNING id", + params!(user, room, message), + ) + .await? + .get(0); + } +} + +#[cfg(test)] +pub(crate) use tests::*; diff --git a/trailbase-core/src/records/update_record.rs b/trailbase-core/src/records/update_record.rs new file mode 100644 index 0000000..ee4d18f --- /dev/null +++ b/trailbase-core/src/records/update_record.rs @@ -0,0 +1,201 @@ +use axum::extract::{Path, State}; + +use crate::app_state::AppState; +use crate::auth::user::User; +use crate::extract::Either; +use crate::records::json_to_sql::{LazyParams, UpdateQueryBuilder}; +use crate::records::{Permission, RecordError}; + +/// Update existing record. +#[utoipa::path( + patch, + path = "/:name/:record", + request_body = serde_json::Value, + responses( + (status = 200, description = "Successful update.") + ) +)] +pub async fn update_record_handler( + State(state): State, + Path((api_name, record)): Path<(String, String)>, + user: Option, + either_request: Either, +) -> Result<(), RecordError> { + let Some(api) = state.lookup_record_api(&api_name) else { + return Err(RecordError::ApiNotFound); + }; + + let table_metadata = api + .table_metadata() + .ok_or_else(|| RecordError::ApiRequiresTable)?; + + let record_id = api.id_to_sql(&record)?; + + let (request, multipart_files) = match either_request { + Either::Json(value) => (value, None), + Either::Multipart(value, files) => (value, Some(files)), + Either::Form(value) => (value, None), + }; + + let mut lazy_params = LazyParams::new(table_metadata, request, multipart_files); + api + .check_record_level_access( + Permission::Update, + Some(&record_id), + Some(&mut lazy_params), + user.as_ref(), + ) + .await?; + + UpdateQueryBuilder::run( + &state, + table_metadata, + lazy_params + .consume() + .map_err(|err| RecordError::Internal(err.into()))?, + &api.record_pk_column().name, + record_id, + ) + .await + .map_err(|err| RecordError::Internal(err.into()))?; + + return Ok(()); +} + +#[cfg(test)] +mod test { + use axum::extract::Query; + use libsql::params; + use trailbase_sqlite::query_one_row; + + use super::*; + use crate::admin::user::*; + use crate::app_state::*; + use crate::auth::api::login::login_with_password; + use crate::auth::user::User; + use crate::config::proto::PermissionFlag; + use crate::extract::Either; + use crate::records::create_record::{ + create_record_handler, CreateRecordQuery, CreateRecordResponse, + }; + use crate::records::test_utils::*; + use crate::records::*; + use crate::test::unpack_json_response; + use crate::util::{b64_to_id, id_to_b64}; + + #[tokio::test] + async fn test_record_api_update() -> Result<(), anyhow::Error> { + let state = test_state(None).await?; + let conn = state.conn(); + + create_chat_message_app_tables(&state).await?; + let room = add_room(conn, "room0").await?; + let password = "Secret!1!!"; + + // Register message table and api with moderator read access. + add_record_api( + &state, + "messages_api", + "message", + Acls { + authenticated: vec![ + PermissionFlag::Create, + PermissionFlag::Read, + PermissionFlag::Update, + ], + ..Default::default() + }, + AccessRules { + create: Some( + "EXISTS(SELECT 1 FROM room_members WHERE room = _REQ_.room AND user = _USER_.id)" + .to_string(), + ), + update: Some( + "EXISTS(SELECT 1 FROM room_members WHERE room = _ROW_.room AND user = _USER_.id)" + .to_string(), + ), + ..Default::default() + }, + ) + .await?; + + let user_x_email = "user_x@test.com"; + let user_x = create_user_for_test(&state, user_x_email, password) + .await? + .into_bytes(); + + let user_x_token = login_with_password(&state, user_x_email, password).await?; + + add_user_to_room(conn, user_x, room).await?; + + let user_y_email = "user_y@foo.baz"; + let _user_y = create_user_for_test(&state, user_y_email, password) + .await? + .into_bytes(); + + let user_y_token = login_with_password(&state, user_y_email, password).await?; + + let create_json = serde_json::json!({ + "_owner": id_to_b64(&user_x), + "room": id_to_b64(&room), + "data": "user_x message to room", + }); + let create_response: CreateRecordResponse = unpack_json_response( + create_record_handler( + State(state.clone()), + Path("messages_api".to_string()), + Query(CreateRecordQuery::default()), + User::from_auth_token(&state, &user_x_token.auth_token), + Either::Json(create_json), + ) + .await?, + ) + .await?; + + let b64_id = create_response.id; + + { + // User X can modify their own message. + let updated_message_text = "user_x updated message to room"; + let update_json = serde_json::json!({ + "data": updated_message_text, + }); + let update_response = update_record_handler( + State(state.clone()), + Path(("messages_api".to_string(), b64_id.clone())), + User::from_auth_token(&state, &user_x_token.auth_token), + Either::Json(update_json), + ) + .await; + + assert!(update_response.is_ok(), "{b64_id} {update_response:?}"); + + let message_text: String = query_one_row( + conn, + "SELECT data FROM message WHERE id = $1", + params!(b64_to_id(&b64_id)?), + ) + .await? + .get(0)?; + assert_eq!(updated_message_text, message_text); + } + + { + // User Y cannot modify User X's message. + let update_json = serde_json::json!({ + "data": "invalid update by user y", + }); + let update_response = update_record_handler( + State(state.clone()), + Path(("messages_api".to_string(), b64_id.clone())), + User::from_auth_token(&state, &user_y_token.auth_token), + Either::Json(update_json), + ) + .await; + + assert!(update_response.is_err(), "{b64_id} {update_response:?}"); + } + + return Ok(()); + } +} diff --git a/trailbase-core/src/records/validate.rs b/trailbase-core/src/records/validate.rs new file mode 100644 index 0000000..0771c2f --- /dev/null +++ b/trailbase-core/src/records/validate.rs @@ -0,0 +1,96 @@ +use crate::config::{proto, ConfigError}; +use crate::table_metadata::{ + sqlite3_parse_into_statements, TableMetadataCache, TableOrViewMetadata, +}; + +fn validate_record_api_name(name: &str) -> Result<(), ConfigError> { + if name.is_empty() { + return Err(ConfigError::Invalid( + "Invalid api name: cannot be empty".to_string(), + )); + } + + if !name.chars().all(|x| x.is_ascii_alphanumeric() || x == '_') { + return Err(ConfigError::Invalid(format!( + "Invalid api name: {name}. Must only contain alphanumeric characters or '_'." + ))); + } + + Ok(()) +} + +pub(crate) fn validate_record_api_config( + tables: &TableMetadataCache, + api_config: &proto::RecordApiConfig, +) -> Result { + let ierr = |msg: &str| Err(ConfigError::Invalid(msg.to_string())); + + let Some(ref name) = api_config.name else { + return ierr("RecordApi config misses name."); + }; + validate_record_api_name(name)?; + + let Some(ref table_name) = api_config.table_name else { + return ierr("RecordApi config misses table name."); + }; + + if let Some(metadata) = tables.get(table_name) { + if !metadata.schema.strict { + return Err(ConfigError::Invalid(format!( + "RecordApi table '{table_name}' for api '{name}' must be strict to support JSON schema and type-safety." + ))); + } + + if metadata.record_pk_column().is_none() { + return Err(ConfigError::Invalid(format!( + "Table for api '{name}' is missing valid integer/uuidv7 primary key column: {:?}", + metadata.schema + ))); + } + } else if let Some(metadata) = tables.get_view(table_name) { + if metadata.schema.temporary { + return Err(ConfigError::Invalid(format!( + "RecordApi {name} references temporary view: {table_name}" + ))); + } + + if metadata.record_pk_column().is_none() { + return Err(ConfigError::Invalid(format!( + "View for api '{name}' is missing valid integer/uuidv7 primary key column: {:?}", + metadata.schema + ))); + } + + let Some(ref _columns) = metadata.schema.columns else { + return Err(ConfigError::Invalid(format!( + "View for api '{name}' is not a \"simple\" view, i.e. the column types couldn't be inferred and thus type-safety cannot be guaranteed." + ))); + }; + } else { + return Err(ConfigError::Invalid(format!( + "Missing table or view for API: {name}" + ))); + } + + let rules = [ + &api_config.create_access_rule, + &api_config.read_access_rule, + &api_config.update_access_rule, + &api_config.delete_access_rule, + &api_config.schema_access_rule, + ]; + for rule in rules.into_iter().flatten() { + let map = |err| ConfigError::Invalid(format!("'{rule}' not a valid SQL expression: {err}")); + + // const DIALECT: SQLiteDialect = SQLiteDialect {}; + // SqlParser::new(&DIALECT) + // .try_with_sql(rule) + // .map_err(map)? + // .parse_expr() + // .map_err(map)?; + + let _statements = sqlite3_parse_into_statements(&format!("SELECT ({rule})")).map_err(map)?; + } + + return Ok(name.clone()); +} diff --git a/trailbase-core/src/scheduler.rs b/trailbase-core/src/scheduler.rs new file mode 100644 index 0000000..9e54dc4 --- /dev/null +++ b/trailbase-core/src/scheduler.rs @@ -0,0 +1,139 @@ +use chrono::{Duration, Utc}; +use libsql::params; +use log::*; +use rusqlite::{Connection, DatabaseName}; +use std::future::Future; + +use crate::app_state::AppState; +use crate::constants::{DEFAULT_REFRESH_TOKEN_TTL, LOGS_RETENTION_DEFAULT, SESSION_TABLE}; + +#[derive(Default)] +pub struct AbortOnDrop { + handles: Vec, +} + +impl AbortOnDrop { + fn add_periodic_task(&mut self, period: Duration, f: F) + where + F: 'static + Sync + Send + Fn() -> Fut, + Fut: Sync + Send + Future, + { + let handle = tokio::spawn(async move { + let period = period.to_std().unwrap(); + let mut interval = tokio::time::interval_at(tokio::time::Instant::now() + period, period); + loop { + interval.tick().await; + f().await; + } + }); + + self.handles.push(handle.abort_handle()); + } +} + +impl Drop for AbortOnDrop { + fn drop(&mut self) { + for h in &self.handles { + h.abort(); + } + } +} + +pub(super) fn start_periodic_tasks(app_state: &AppState) -> AbortOnDrop { + let mut tasks = AbortOnDrop::default(); + + tasks.add_periodic_task(Duration::seconds(60), || async { + info!("alive"); + }); + + // Backup job. + let db_path = app_state.data_dir().main_db_path(); + let backup_file = app_state.data_dir().backup_path().join("backup.db"); + let backup_interval = app_state + .access_config(|c| c.server.backup_interval_sec) + .map_or(Duration::zero(), Duration::seconds); + if !backup_interval.is_zero() { + tasks.add_periodic_task(backup_interval, move || { + let db_path = db_path.clone(); + let backup_file = backup_file.clone(); + + async move { + // NOTE: We need to "re-open" the database with rusqlite since libsql doesn't support + // backups (yet). + match Connection::open(&db_path) { + Ok(conn) => { + match conn.backup(DatabaseName::Main, backup_file, /* progress= */ None) { + Ok(_) => info!("Backup complete"), + Err(err) => error!("Backup failed: {err}"), + }; + } + Err(err) => warn!("Backup process failed to open DB: {err}"), + } + } + }); + } + + // Logs cleaner. + let logs_conn = app_state.logs_conn().clone(); + let retention = app_state + .access_config(|c| c.server.logs_retention_sec) + .map_or(LOGS_RETENTION_DEFAULT, Duration::seconds); + + if !retention.is_zero() { + tasks.add_periodic_task(Duration::hours(2), move || { + let logs_conn = logs_conn.clone(); + + tokio::spawn(async move { + let timestamp = (Utc::now() - retention).timestamp(); + match logs_conn + .execute("DELETE FROM _logs WHERE created < $1", params!(timestamp)) + .await + { + Ok(_) => info!("Successfully pruned logs"), + Err(err) => warn!("Failed to clean up old logs: {err}"), + }; + }) + }); + } + + // Refresh token cleaner. + let state = app_state.clone(); + tasks.add_periodic_task(Duration::hours(12), move || { + let state = state.clone(); + + tokio::spawn(async move { + let refresh_token_ttl = state + .access_config(|c| c.auth.refresh_token_ttl_sec) + .map_or(DEFAULT_REFRESH_TOKEN_TTL, Duration::seconds); + + let timestamp = (Utc::now() - refresh_token_ttl).timestamp(); + + match state + .user_conn() + .execute( + &format!("DELETE FROM '{SESSION_TABLE}' WHERE updated < $1"), + params!(timestamp), + ) + .await + { + Ok(count) => info!("Successfully pruned {count} old sessions."), + Err(err) => warn!("Failed to clean up sessions: {err}"), + }; + }) + }); + + // Optimizer + let conn = app_state.conn().clone(); + tasks.add_periodic_task(Duration::hours(24), move || { + let conn = conn.clone(); + + tokio::spawn(async move { + match conn.execute("PRAGMA optimize", ()).await { + Ok(_) => info!("Successfully ran query optimizer"), + Err(err) => warn!("query optimizer failed: {err}"), + }; + }) + }); + + return tasks; +} diff --git a/trailbase-core/src/schema.rs b/trailbase-core/src/schema.rs new file mode 100644 index 0000000..a1fc827 --- /dev/null +++ b/trailbase-core/src/schema.rs @@ -0,0 +1,1217 @@ +use log::*; +use serde::{Deserialize, Serialize}; +use sqlite3_parser::ast::{ + fmt::ToTokens, CreateTableBody, FromClause, SelectTable, Stmt, TableOptions, +}; +use std::collections::HashMap; +use thiserror::Error; +use ts_rs::TS; + +#[derive(Debug, Error)] +pub enum SchemaError { + #[error("Missing ObjectName")] + MissingName, + #[error("Precondition failed: {0}")] + Precondition(Box), +} + +// This file contains table schema and index representations. Originally, they were mostly +// adaptations of sqlparser's CreateX AST representations (we've since moved to sqlite3_parser). +// This serves two purposes: +// +// * We'd like some representation that we can construct on the client with type-safety. We could +// also consider using proto here, but ts-rs let's us "skip" some fields. +// * But also, there's a fundamental difference between an AST that represents a specific SQL +// program and a more abstract semantic representation of the schema, e.g. we don't care in which +// order indexes were constructed or what quotes were used... +// +// NOTE: We're very much "over-wrapping" here entering the space of the exact-program AST domain. +// This is mostly convenient for testing our code by transforming back and forth and checking the +// output is stable. We can use "skip" to remove some more "representational" details from the API. +#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq)] +pub struct ForeignKey { + pub name: Option, + pub columns: Vec, + pub foreign_table: String, + pub referred_columns: Vec, + pub on_delete: Option, + pub on_update: Option, +} + +impl ForeignKey { + fn to_fragment(&self) -> String { + return format!( + "{name} FOREIGN KEY ({cols}) REFERENCES {foreign_table} ({ref_cols}) {on_delete} {on_update}", + name = self + .name + .as_ref() + .map_or_else(|| "".to_string(), |n| format!("CONSTRAINT {n}")), + cols = self.columns.join(", "), + foreign_table = self.foreign_table, + ref_cols = self.referred_columns.join(", "), + on_delete = self.on_delete.as_ref().map_or_else( + || "".to_string(), + |action| format!("ON DELETE {}", action.to_fragment()) + ), + on_update = self.on_update.as_ref().map_or_else( + || "".to_string(), + |action| format!("ON UPDATE {}", action.to_fragment()) + ), + ); + } +} + +// TODO: Our table constraints are generally very incomplete: +// https://www.sqlite.org/syntax/table-constraint.html. +#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq)] +pub struct UniqueConstraint { + pub name: Option, + /// Identifiers of the columns that are unique. + /// TODO: Should be indexed/ordered column. + pub columns: Vec, +} + +impl UniqueConstraint { + fn to_fragment(&self) -> String { + return format!( + "{name}UNIQUE ({cols}) {conflict_clause}", + name = self + .name + .as_ref() + .map_or_else(|| "".to_string(), |n| format!("CONSTRAINT {n} ")), + cols = self.columns.join(", "), + conflict_clause = "", + ); + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq)] +pub struct ColumnOrder { + pub column_name: String, + pub ascending: Option, + pub nulls_first: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq)] +pub enum ReferentialAction { + Restrict, + Cascade, + SetNull, + NoAction, + SetDefault, +} + +impl From for ReferentialAction { + fn from(action: sqlite3_parser::ast::RefAct) -> Self { + use sqlite3_parser::ast::RefAct; + match action { + RefAct::Restrict => ReferentialAction::Restrict, + RefAct::Cascade => ReferentialAction::Cascade, + RefAct::SetNull => ReferentialAction::SetNull, + RefAct::NoAction => ReferentialAction::NoAction, + RefAct::SetDefault => ReferentialAction::SetDefault, + } + } +} + +impl ReferentialAction { + // https://www.sqlite.org/syntax/foreign-key-clause.html + fn to_fragment(&self) -> &'static str { + return match self { + Self::Restrict => "RESTRICT", + Self::Cascade => "CASCADE", + Self::SetNull => "SET NULL", + Self::NoAction => "NO ACTION", + Self::SetDefault => "SET DEFAULT", + }; + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq)] +pub enum GeneratedExpressionMode { + Virtual, + Stored, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq)] +pub enum ColumnOption { + Null, + NotNull, + Default(String), + // NOTE: Unique { is_primary: true} means PrimaryKey. + Unique { + is_primary: bool, + }, + ForeignKey { + foreign_table: String, + referred_columns: Vec, + on_delete: Option, + on_update: Option, + }, + Check(String), + OnUpdate(String), + Generated { + expr: String, + mode: Option, + }, +} + +impl ColumnOption { + fn to_fragment(&self, col_name: &str) -> String { + return match self { + Self::Null => "NULL".to_string(), + Self::NotNull => "NOT NULL".to_string(), + Self::Default(v) => format!("DEFAULT {v}"), + Self::Unique { is_primary } => { + if *is_primary { + "PRIMARY KEY".to_string() + } else { + "UNIQUE".to_string() + } + } + Self::ForeignKey { + foreign_table, + referred_columns, + on_delete, + on_update, + } => { + format!( + "FOREIGN KEY({col_name}) REFERENCES {foreign_table}({ref_col}) {on_delete} {on_update}", + ref_col = referred_columns.first().unwrap(), + on_delete = on_delete.as_ref().map_or_else( + || "".to_string(), + |action| format!("ON DELETE {}", action.to_fragment()) + ), + on_update = on_update.as_ref().map_or_else( + || "".to_string(), + |action| format!("ON UPDATE {}", action.to_fragment()) + ), + ) + } + Self::Check(expr) => format!("CHECK({expr})"), + Self::OnUpdate(expr) => expr.to_string(), + Self::Generated { expr, mode } => format!( + "GENERATED ALWAYS AS ({expr}) {m}", + m = match mode { + Some(GeneratedExpressionMode::Stored) => "STORED", + Some(GeneratedExpressionMode::Virtual) => "VIRTUAL", + None => "", + } + ), + }; + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS, PartialEq)] +pub enum ColumnDataType { + Null, + + // Strict column/storage types. + Any, + Blob, + Text, + Integer, + Real, + Numeric, // not allowed in strict mode. + + // Other higher-level or affine types. + #[allow(clippy::upper_case_acronyms)] + JSON, + #[allow(clippy::upper_case_acronyms)] + JSONB, + + // See 3.1.1. https://www.sqlite.org/datatype3.html. + // + // Types with INTEGER affinity. + Int, + TinyInt, + SmallInt, + MediumInt, + BigInt, + UnignedBigInt, + Int2, + Int4, + Int8, + + // Types with TEXT affinity. + Character, + Varchar, + VaryingCharacter, + NChar, + NativeCharacter, + NVarChar, + Clob, + + // Types with REAL affinity. + Double, + DoublePrecision, + Float, + + // Types with NUMERIC affinity. + Boolean, + Decimal, + Date, + DateTime, +} + +impl ColumnDataType { + fn from_type_name(type_name: &str) -> Option { + return Some(match type_name.to_uppercase().as_str() { + "UNSPECIFIED" => ColumnDataType::Null, + "ANY" => ColumnDataType::Any, + "BLOB" => ColumnDataType::Blob, + "TEXT" => ColumnDataType::Text, + "INTEGER" => ColumnDataType::Integer, + "REAL" => ColumnDataType::Real, + "NUMERIC" => ColumnDataType::Numeric, + + // JSON types, + "JSON" => ColumnDataType::JSON, + "JSONB" => ColumnDataType::JSONB, + + // See 3.1.1. https://www.sqlite.org/datatype3.html. + // + // Types with INTEGER affinity. + "INT" => ColumnDataType::Int, + "TINYINT" => ColumnDataType::TinyInt, + "SMALLINT" => ColumnDataType::SmallInt, + "MEDIUMINT" => ColumnDataType::MediumInt, + "BIGINT" => ColumnDataType::BigInt, + "UNSIGNED BIG INT" => ColumnDataType::UnignedBigInt, + "INT2" => ColumnDataType::Int2, + "INT4" => ColumnDataType::Int4, + "INT8" => ColumnDataType::Int8, + + // Types with TEXT affinity. + "CHARACTER" => ColumnDataType::Character, + "VARCHAR" => ColumnDataType::Varchar, + "VARYING CHARACTER" => ColumnDataType::VaryingCharacter, + "NCHAR" => ColumnDataType::NChar, + "NATIVE CHARACTER" => ColumnDataType::NativeCharacter, + "NVARCHAR" => ColumnDataType::NVarChar, + "CLOB" => ColumnDataType::Clob, + + // Types with REAL affinity. + "DOUBLE" => ColumnDataType::Double, + "DOUBLE PRECISION" => ColumnDataType::DoublePrecision, + "FLOAT" => ColumnDataType::Float, + + // Types with NUMERIC affinity. + "BOOLEAN" => ColumnDataType::Boolean, + "DECIMAL" => ColumnDataType::Decimal, + "DATE" => ColumnDataType::Date, + "DATETIME" => ColumnDataType::DateTime, + + _x => { + debug!("Unexpected data type: {_x:?}"); + return None; + } + }); + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq)] +pub struct Column { + pub name: String, + pub data_type: ColumnDataType, + pub options: Vec, +} + +impl Column { + fn to_fragment(&self) -> String { + return format!( + "{name} {data_type} {options}", + name = self.name, + data_type = format!("{:?}", self.data_type).to_uppercase(), + options = self + .options + .iter() + .map(|o| o.to_fragment(&self.name)) + .collect::>() + .join(" "), + ); + } +} + +impl Column { + pub fn is_primary(&self) -> bool { + self + .options + .iter() + .any(|opt| matches!(opt, ColumnOption::Unique { is_primary } if *is_primary )) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq)] +#[ts(export)] +pub struct Table { + pub name: String, + pub strict: bool, + + // Column definition and column-level constraints. + pub columns: Vec, + + // Table-level constraints, e.g. composite uniqueness or foreign keys. Columns may have their own + // column-level constraints a.k.a. Column::options. + pub foreign_keys: Vec, + pub unique: Vec, + + // NOTE: consider parsing "CREATE VIRTUAL TABLE" into a separate struct. + pub virtual_table: bool, + pub temporary: bool, +} + +impl Table { + pub(crate) fn create_table_statement(&self) -> String { + if self.virtual_table { + // https://www.sqlite.org/lang_createvtab.html + panic!("Not implemented"); + } + + let mut column_defs_and_table_constraints: Vec = vec![]; + + let column_defs = self + .columns + .iter() + .map(|c| c.to_fragment()) + .collect::>(); + column_defs_and_table_constraints.extend(column_defs); + + // Example: UNIQUE (email), + let unique_table_constraints = self + .unique + .iter() + .map(|unique| unique.to_fragment()) + .collect::>(); + column_defs_and_table_constraints.extend(unique_table_constraints); + + // Example: FOREIGN KEY(user_id) REFERENCES table(id) ON DELETE CASCADE + let fk_table_constraints = self + .foreign_keys + .iter() + .map(|fk| fk.to_fragment()) + .collect::>(); + column_defs_and_table_constraints.extend(fk_table_constraints); + + return format!( + "CREATE{temporary} TABLE {name} ({col_defs_and_constraints}) {strict}", + temporary = if self.temporary { " TEMPORARY" } else { "" }, + name = self.name, + col_defs_and_constraints = column_defs_and_table_constraints.join(", "), + strict = if self.strict { "STRICT" } else { "" }, + ); + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq)] +pub struct TableIndex { + pub name: String, + pub table_name: String, + pub columns: Vec, + pub unique: bool, + pub predicate: Option, + + #[ts(skip)] + pub if_not_exists: bool, +} + +impl TableIndex { + pub(crate) fn create_index_statement(&self) -> String { + let indexed_columns_vec: Vec = self + .columns + .iter() + .map(|c| { + format!( + "{} {}", + c.column_name, + c.ascending + .map_or("", |asc| if asc { "ASC" } else { "DESC" }) + ) + }) + .collect(); + + return format!( + "CREATE {unique} INDEX {if_not_exists} {name} ON {table_name} ({indexed_columns}) {predicate}", + unique = if self.unique { "UNIQUE" } else { "" }, + if_not_exists = if self.if_not_exists { "IF NOT EXISTS" } else { "" }, + name = self.name, + table_name = self.table_name, + indexed_columns = indexed_columns_vec.join(", "), + predicate = self + .predicate + .as_ref() + .map_or_else(|| "".to_string(), |p| format!("WHERE {p}")), + ); + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq)] +pub struct View { + pub name: String, + + /// Columns may be inferred from a view's query. + /// + /// Views can be defined with arbitrary queries referencing arbitrary sources: tables, views, + /// functions, ..., which makes them inherently not type safe and therefore their columns not + /// well defined. + pub columns: Option>, + + pub query: String, + + pub temporary: bool, + + #[ts(skip)] + pub if_not_exists: bool, +} + +impl TryFrom for Table { + type Error = SchemaError; + + fn try_from(value: sqlite3_parser::ast::Stmt) -> Result { + return match value { + Stmt::CreateTable { + temporary, + tbl_name, + body, + .. + } => { + let CreateTableBody::ColumnsAndConstraints { + columns, + constraints, + options, + } = body + else { + return Err(SchemaError::Precondition( + "expected cols and constraints, got AsSelect".into(), + )); + }; + + let (foreign_keys, unique) = match &constraints { + None => (vec![], vec![]), + Some(constraints) => { + use sqlite3_parser::ast::TableConstraint; + + let foreign_keys: Vec = constraints + .iter() + .filter_map(|constraint| match &constraint.constraint { + TableConstraint::ForeignKey { + columns, + clause, + deref_clause: _, + } => { + let mut on_delete: Option = None; + let mut on_update: Option = None; + for arg in &clause.args { + use sqlite3_parser::ast::RefArg; + + match arg { + RefArg::OnDelete(action) => { + on_delete = Some((*action).into()); + } + RefArg::OnUpdate(action) => { + on_update = Some((*action).into()); + } + _ => {} + } + } + + Some(ForeignKey { + name: constraint.name.as_ref().map(|name| name.to_string()), + foreign_table: clause.tbl_name.to_string(), + columns: columns.iter().map(|c| c.col_name.to_string()).collect(), + referred_columns: clause.columns.as_ref().map_or_else(Vec::new, |columns| { + columns.iter().map(|c| c.col_name.to_string()).collect() + }), + on_update, + on_delete, + }) + } + _ => None, + }) + .collect(); + + let unique: Vec = constraints + .iter() + .filter_map(|constraint| match &constraint.constraint { + TableConstraint::Unique { + columns, + conflict_clause: _, + } => Some(UniqueConstraint { + name: constraint.name.as_ref().map(|name| name.to_string()), + columns: columns.iter().map(|c| c.expr.to_string()).collect(), + }), + _ => None, + }) + .collect(); + + (foreign_keys, unique) + } + }; + + let columns: Vec<_> = columns + .into_iter() + .map(|(_name, def)| { + let sqlite3_parser::ast::ColumnDefinition { + col_name, + col_type, + constraints, + } = def; + + let data_type: ColumnDataType = match col_type { + Some(x) => x.into(), + None => ColumnDataType::Null, + }; + + let options: Vec = constraints + .into_iter() + .map(|named_constraint| named_constraint.constraint.into()) + .collect(); + + return Column { + name: col_name.to_string(), + data_type, + options, + }; + }) + .collect(); + + // WARN: SQLite escaping is weird, altering a table adds double quote escaping and + // sqlite3_parser unlike sqlparser, doesn't parse out the escaping. + // + // sqlite> CREATE TABLE foo (x text); + // sqlite> SELECT sql FROM main.sqlite_schema; + // CREATE TABLE foo (x text) + // sqlite> ALTER TABLE foo RENAME TO bar + // sqlite> SELECT sql FROM main.sqlite_schema; + // CREATE TABLE "bar" (x text) + // + // TODO: factor our QualifiedNamed conversion. + let table_name = tbl_name + .name + .to_string() + .trim_matches(|c| c == '"' || c == '\'') + .to_string(); + + Ok(Table { + name: table_name, + strict: options.contains(TableOptions::STRICT), + columns, + foreign_keys, + unique, + virtual_table: false, + temporary, + }) + } + Stmt::CreateVirtualTable { + tbl_name, + args: _args, + .. + } => { + #[cfg(debug_assertions)] + debug!("vTable args: {_args:?}"); + + Ok(Table { + name: tbl_name.name.to_string(), + strict: false, + columns: vec![], + foreign_keys: vec![], + unique: vec![], + virtual_table: true, + temporary: false, + }) + } + _ => Err(SchemaError::Precondition( + format!("expected 'CREATE TABLE', got: {value:?}").into(), + )), + }; + } +} + +impl From for ColumnDataType { + fn from(data_type: sqlite3_parser::ast::Type) -> Self { + return ColumnDataType::from_type_name(data_type.name.as_str()).unwrap_or(ColumnDataType::Null); + } +} + +impl From for ColumnOption { + fn from(constraint: sqlite3_parser::ast::ColumnConstraint) -> Self { + type Constraint = sqlite3_parser::ast::ColumnConstraint; + + return match constraint { + Constraint::PrimaryKey { + conflict_clause: _, .. + } => ColumnOption::Unique { is_primary: true }, + Constraint::Unique(_) => ColumnOption::Unique { is_primary: false }, + Constraint::Check(expr) => ColumnOption::Check(expr.to_string()), + Constraint::ForeignKey { clause, .. } => { + let columns = clause.columns.unwrap_or(vec![]); + + ColumnOption::ForeignKey { + foreign_table: clause.tbl_name.to_string(), + referred_columns: columns + .into_iter() + .map(|c| c.col_name.to_string()) + .collect(), + on_delete: None, + on_update: None, + } + } + Constraint::NotNull { .. } => ColumnOption::NotNull, + Constraint::Default(expr) => ColumnOption::Default(expr.to_string()), + Constraint::Generated { expr, typ } => ColumnOption::Generated { + expr: expr.to_string(), + mode: typ.and_then(|t| match t.0.as_str() { + "VIRTUAL" => Some(GeneratedExpressionMode::Virtual), + "STORED" => Some(GeneratedExpressionMode::Stored), + x => { + warn!("Unexpected generated column mode: {x}"); + None + } + }), + }, + Constraint::Collate { .. } | Constraint::Defer(_) => { + panic!("Not implemented: {constraint:?}"); + } + }; + } +} + +impl TryFrom for TableIndex { + type Error = SchemaError; + + fn try_from(value: sqlite3_parser::ast::Stmt) -> Result { + return match value { + sqlite3_parser::ast::Stmt::CreateIndex { + unique, + if_not_exists, + idx_name, + tbl_name, + columns, + where_clause, + } => Ok(TableIndex { + name: idx_name.to_string(), + table_name: tbl_name.to_string(), + columns: columns + .into_iter() + .map(|order_expr| ColumnOrder { + column_name: order_expr.expr.to_string(), + ascending: order_expr + .order + .map(|order| order == sqlite3_parser::ast::SortOrder::Asc), + nulls_first: order_expr + .nulls + .map(|order| order == sqlite3_parser::ast::NullsOrder::First), + }) + .collect(), + unique, + predicate: where_clause.map(|clause| clause.to_string()), + if_not_exists, + }), + _ => Err(SchemaError::Precondition( + format!("expected 'CREATE INDEX', got: {value:?}").into(), + )), + }; + } +} + +struct SelectFormatter(sqlite3_parser::ast::Select); + +impl std::fmt::Display for SelectFormatter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.to_fmt(f) + } +} + +impl View { + pub fn from(value: sqlite3_parser::ast::Stmt, tables: &[Table]) -> Result { + return match value { + sqlite3_parser::ast::Stmt::CreateView { + temporary, + if_not_exists, + view_name, + columns, + select, + } => { + let columns = match columns.is_some() { + true => { + info!("CREATE VIEW column filtering not supported (yet)"); + None + } + false => try_extract_column_mapping(select.clone(), tables)?.map(|column_mapping| { + column_mapping + .into_iter() + .map(|mapping| mapping.column) + .collect() + }), + }; + + Ok(View { + name: view_name.to_string(), + columns, + query: SelectFormatter(select).to_string(), + temporary, + if_not_exists, + }) + } + _ => Err(SchemaError::Precondition( + format!("expected 'CREATE VIEW', got: {value:?}").into(), + )), + }; + } +} + +fn to_entry( + qn: sqlite3_parser::ast::QualifiedName, + alias: Option, +) -> (String, String) { + return ( + alias + .and_then(|alias| { + if let sqlite3_parser::ast::As::As(name) = alias { + return Some(name.to_string()); + } + None + }) + .unwrap_or_else(|| qn.to_string()), + qn.to_string(), + ); +} + +#[derive(Clone, Debug)] +#[allow(unused)] +struct ReferredColumn { + table_name: String, + column_name: String, +} + +#[derive(Clone, Debug)] +struct ColumnMapping { + column: Column, + + #[allow(unused)] + referred_column: Option, +} + +fn try_extract_column_mapping( + select: sqlite3_parser::ast::Select, + tables: &[Table], +) -> Result>, SchemaError> { + let body = select.body; + #[cfg(debug_assertions)] + debug!("Derive column mapping from: {body:?}"); + + if body.compounds.is_some() { + return Ok(None); + } + + let sqlite3_parser::ast::OneSelect::Select { + distinctness, + columns, + from, + where_clause: _, + group_by, + window_clause, + } = body.select + else { + return Ok(None); + }; + + if distinctness.is_some() || group_by.is_some() || window_clause.is_some() { + return Ok(None); + } + + // First build list of referenced tables and their aliases. + let Some(FromClause { select, joins, .. }) = from else { + return Ok(None); + }; + let Some(select) = select else { + return Ok(None); + }; + let SelectTable::Table(fqn, alias, _indexed) = *select else { + return Ok(None); + }; + + // Use IndexMap to preserve insertion order. + let mut table_names = indexmap::IndexMap::::from([to_entry(fqn, alias)]); + + if let Some(joins) = joins { + for join in joins { + let SelectTable::Table(fqn, alias, _indexed) = join.table else { + return Ok(None); + }; + + let entry = to_entry(fqn, alias); + table_names.insert(entry.0, entry.1); + } + } + + // Now we should have a map of all involved tables and their aliases (if any). + let all_tables: HashMap = tables.iter().map(|t| (t.name.clone(), t)).collect(); + let mut all_columns = HashMap::::new(); + + // Make sure we know all tables and all tables are strict. + for table_name in table_names.values() { + match all_tables.get(table_name) { + Some(table) => { + if !table.strict { + info!("Skipping view: referenced table: {table_name} not strict"); + return Ok(None); + } + + for col in &table.columns { + all_columns.insert(col.name.clone(), (table, col)); + } + } + None => { + return Err(SchemaError::Precondition( + format!("View's SELECT references missing table: {table_name}").into(), + )); + } + }; + } + + let mut mapping: Vec = vec![]; + for col in columns { + use sqlite3_parser::ast::Expr; + use sqlite3_parser::ast::ResultColumn; + + match col { + ResultColumn::Star => { + for table_name in table_names.values() { + let table = all_tables.get(table_name).expect("checked above"); + for c in &table.columns { + mapping.push(ColumnMapping { + column: c.clone(), + referred_column: Some(ReferredColumn { + table_name: table.name.clone(), + column_name: c.name.clone(), + }), + }); + } + } + } + ResultColumn::TableStar(name) => { + let name = name.to_string(); + let Some(table_name) = table_names.get(&name) else { + return Err(SchemaError::Precondition( + format!("Missing alias: {name}").into(), + )); + }; + + let table = all_tables.get(table_name).expect("checked above"); + for c in &table.columns { + mapping.push(ColumnMapping { + column: c.clone(), + referred_column: Some(ReferredColumn { + table_name: table.name.clone(), + column_name: c.name.clone(), + }), + }); + } + } + ResultColumn::Expr(expr, alias) => match expr { + Expr::Id(id) => { + let col_name = &id.0; + let Some((table, column)) = all_columns.get(col_name) else { + return Err(SchemaError::Precondition( + format!("Missing columns: {id:?}").into(), + )); + }; + + let name = alias + .and_then(|alias| { + if let sqlite3_parser::ast::As::As(name) = alias { + return Some(name.to_string()); + } + None + }) + .unwrap_or_else(|| column.name.clone()); + + mapping.push(ColumnMapping { + column: Column { + name, + data_type: column.data_type, + options: column.options.clone(), + }, + referred_column: Some(ReferredColumn { + table_name: table.name.clone(), + column_name: column.name.clone(), + }), + }); + } + Expr::Qualified(qualifier, name) => { + let qualifier = qualifier.to_string(); + let col_name = name.to_string(); + + let Some(table_name) = table_names.get(&qualifier) else { + return Err(SchemaError::Precondition( + format!("Missing table with qualifier: {qualifier}").into(), + )); + }; + + let table = all_tables.get(table_name).expect("checked above"); + let Some(column) = table.columns.iter().find(|c| c.name == col_name) else { + return Err(SchemaError::Precondition( + format!("Missing col: {col_name}").into(), + )); + }; + + let name = alias + .and_then(|alias| { + if let sqlite3_parser::ast::As::As(name) = alias { + return Some(name.to_string()); + } + None + }) + .unwrap_or_else(|| column.name.clone()); + + mapping.push(ColumnMapping { + column: Column { + name, + data_type: column.data_type, + options: column.options.clone(), + }, + referred_column: Some(ReferredColumn { + table_name: table.name.clone(), + column_name: column.name.clone(), + }), + }); + } + Expr::Cast { expr: _, type_name } => { + let Some(type_name) = type_name else { + return Err(SchemaError::Precondition( + "Missing type_name in cast".into(), + )); + }; + let Some(data_type) = ColumnDataType::from_type_name(&type_name.name) else { + return Err(SchemaError::Precondition( + "Missing type_name in cast".into(), + )); + }; + + let Some(name) = alias.and_then(|alias| { + if let sqlite3_parser::ast::As::As(name) = alias { + return Some(name.to_string()); + } + None + }) else { + return Err(SchemaError::Precondition("Missing alias in cast".into())); + }; + + mapping.push(ColumnMapping { + column: Column { + name, + data_type, + options: vec![ColumnOption::Null], + }, + referred_column: None, + }); + } + _x => { + // We cannot map arbitrary expressions. + #[cfg(debug_assertions)] + debug!("skipping expr: {_x:?}"); + + return Ok(None); + } + }, + }; + } + + return Ok(Some(mapping)); +} + +#[cfg(test)] +mod tests { + use anyhow::Error; + use lazy_static::lazy_static; + + use super::*; + use crate::constants::USER_TABLE; + + #[test] + fn test_statement_to_table_schema_and_back() -> Result<(), Error> { + lazy_static! { + static ref SQL: String = format!( + r#" + CREATE TABLE test ( + id BLOB PRIMARY KEY DEFAULT (uuid_v7()) NOT NULL, + email TEXT NOT NULL, + email_visibility INTEGER DEFAULT FALSE NOT NULL, + username TEXT, + age INTEGER, + double_age INTEGER GENERATED ALWAYS AS (2*age) VIRTUAL, + triple_age INTEGER AS (3*age) STORED, + + UNIQUE (email), + FOREIGN KEY(user_id) REFERENCES {USER_TABLE}(id) ON DELETE CASCADE + ) strict; + "# + ); + } + + let statement1 = crate::table_metadata::sqlite3_parse_into_statement(&SQL) + .unwrap() + .unwrap(); + let table1: Table = statement1.clone().try_into()?; + + let sql = table1.create_table_statement(); + let statement2 = crate::table_metadata::sqlite3_parse_into_statement(&sql) + .unwrap() + .unwrap(); + + let table2: Table = statement2.clone().try_into()?; + + assert_eq!(statement1, statement2); + assert_eq!(table1, table2); + + Ok(()) + } + + #[test] + fn test_statement_to_table_index_and_back() -> Result<(), Error> { + const SQL: &str = + "CREATE UNIQUE INDEX IF NOT EXISTS __user__email_index ON _user (email) WHERE email != '';"; + + let statement1 = crate::table_metadata::sqlite3_parse_into_statement(SQL) + .unwrap() + .unwrap(); + let index1: TableIndex = statement1.clone().try_into()?; + + let statement2 = + crate::table_metadata::sqlite3_parse_into_statement(&index1.create_index_statement()) + .unwrap() + .unwrap(); + let index2: TableIndex = statement2.clone().try_into()?; + + assert_eq!(statement1, statement2); + assert_eq!(index1, index2); + + Ok(()) + } + + #[test] + fn test_parse_create_trigger() { + const SQL: &str = r#" + CREATE TRIGGER cust_addr_chng + INSTEAD OF UPDATE OF cust_addr ON customer_address + FOR EACH ROW + BEGIN + UPDATE customer SET cust_addr=NEW.cust_addr WHERE cust_id=NEW.cust_id; + END + "#; + + crate::table_metadata::sqlite3_parse_into_statement(SQL) + .unwrap() + .unwrap(); + } + + #[test] + fn test_parse_create_index() { + let sql = "CREATE UNIQUE INDEX index_name ON table_name(a ASC, b DESC) WHERE x > 0"; + let stmt = crate::table_metadata::sqlite3_parse_into_statement(sql) + .unwrap() + .unwrap(); + let index: TableIndex = stmt.clone().try_into().unwrap(); + + let sql1 = index.create_index_statement(); + let stmt1 = crate::table_metadata::sqlite3_parse_into_statement(&sql1) + .unwrap() + .unwrap(); + + assert_eq!(stmt, stmt1); + } + + #[test] + fn test_view_column_extraction() { + let sql = "SELECT user, *, a.*, p.user AS foo FROM articles AS a LEFT JOIN profiles AS p ON p.user = a.author"; + let sqlite3_parser::ast::Stmt::Select(select) = + crate::table_metadata::sqlite3_parse_into_statement(sql) + .unwrap() + .unwrap() + else { + panic!("Not a select"); + }; + + let tables = vec![ + Table { + name: "profiles".to_string(), + strict: true, + columns: vec![ + Column { + name: "user".to_string(), + data_type: ColumnDataType::Blob, + options: vec![ + ColumnOption::Unique { is_primary: true }, + ColumnOption::ForeignKey { + foreign_table: "_user".to_string(), + referred_columns: vec!["id".to_string()], + on_delete: None, + on_update: None, + }, + ], + }, + Column { + name: "username".to_string(), + data_type: ColumnDataType::Text, + options: vec![], + }, + ], + foreign_keys: vec![], + unique: vec![], + virtual_table: false, + temporary: false, + }, + Table { + name: "articles".to_string(), + strict: true, + columns: vec![ + Column { + name: "id".to_string(), + data_type: ColumnDataType::Blob, + options: vec![ColumnOption::Unique { is_primary: true }], + }, + Column { + name: "author".to_string(), + data_type: ColumnDataType::Blob, + options: vec![ColumnOption::ForeignKey { + foreign_table: "_user".to_string(), + referred_columns: vec!["id".to_string()], + on_delete: None, + on_update: None, + }], + }, + Column { + name: "body".to_string(), + data_type: ColumnDataType::Text, + options: vec![], + }, + ], + foreign_keys: vec![], + unique: vec![], + virtual_table: false, + temporary: false, + }, + ]; + + let mapping = try_extract_column_mapping(select, &tables) + .unwrap() + .unwrap(); + + assert_eq!( + mapping + .iter() + .map(|m| m.referred_column.as_ref().unwrap().column_name.as_str()) + .collect::>(), + ["user", "id", "author", "body", "user", "username", "id", "author", "body", "user"] + ); + + assert_eq!( + mapping + .iter() + .map(|m| m.column.name.as_str()) + .collect::>(), + ["user", "id", "author", "body", "user", "username", "id", "author", "body", "foo"] + ); + } +} diff --git a/trailbase-core/src/server/init.rs b/trailbase-core/src/server/init.rs new file mode 100644 index 0000000..16bb7f7 --- /dev/null +++ b/trailbase-core/src/server/init.rs @@ -0,0 +1,169 @@ +use libsql::Connection; +use log::*; +use std::path::PathBuf; +use thiserror::Error; +use trailbase_sqlite::{connect_sqlite, query_one_row}; + +use crate::app_state::AppState; +use crate::auth::jwt::{JwtHelper, JwtHelperError}; +use crate::config::load_or_init_config_textproto; +use crate::constants::USER_TABLE; +use crate::migrations::{apply_logs_migrations, apply_main_migrations}; +use crate::rand::generate_random_string; +use crate::server::DataDir; +use crate::table_metadata::TableMetadataCache; + +#[derive(Debug, Error)] +pub enum InitError { + #[error("Libsql error: {0}")] + Libsql(#[from] libsql::Error), + #[error("DB Migration error: {0}")] + Migration(#[from] refinery::Error), + #[error("IO error: {0}")] + IO(#[from] std::io::Error), + #[error("Config error: {0}")] + Config(#[from] crate::config::ConfigError), + #[error("JwtHelper error: {0}")] + JwtHelper(#[from] JwtHelperError), + #[error("CreateAdmin error: {0}")] + CreateAdmin(String), + #[error("Custom initializer error: {0}")] + CustomInit(String), + #[error("Table error: {0}")] + TableError(#[from] crate::table_metadata::TableLookupError), + #[error("Schema error: {0}")] + SchemaError(#[from] trailbase_sqlite::schema::SchemaError), +} + +pub async fn init_app_state( + data_dir: DataDir, + public_dir: Option, + dev: bool, +) -> Result<(bool, AppState), InitError> { + // First create directory structure. + data_dir.ensure_directory_structure().await?; + + // Then open or init new databases. + let logs_conn = init_logs_db(&data_dir).await?; + + // Open or init the main db. Note that we derive whether a new DB was initialized based on + // whether the V1 migration had to be applied. Should be fairly robust. + let (main_conn, new_db) = { + let conn = connect_sqlite(Some(data_dir.main_db_path()), None).await?; + let new_db = apply_main_migrations(conn.clone(), Some(data_dir.migrations_path())).await?; + + (conn, new_db) + }; + + let table_metadata = TableMetadataCache::new(main_conn.clone()).await?; + + // Read config or write default one. + let config = load_or_init_config_textproto(&data_dir, &table_metadata).await?; + + debug!("Initializing JSON schemas from config"); + trailbase_sqlite::set_user_schemas( + config + .schemas + .iter() + .filter_map(|s| { + let Some(ref name) = s.name else { + warn!("Schema config entry missing name: {s:?}"); + return None; + }; + + let Some(ref schema) = s.schema else { + warn!("Schema config entry missing schema: {s:?}"); + return None; + }; + + let json = match serde_json::from_str(schema) { + Ok(json) => json, + Err(err) => { + error!("Invalid schema config entry for '{name}': {err}"); + return None; + } + }; + + return Some((name.clone(), json)); + }) + .collect(), + )?; + + let jwt = JwtHelper::init_from_path(&data_dir).await?; + + let app_state = AppState::new( + data_dir.clone(), + public_dir, + dev, + table_metadata, + config, + main_conn.clone(), + logs_conn, + jwt, + ); + + if new_db { + let num_admins: i64 = query_one_row( + app_state.user_conn(), + &format!("SELECT COUNT(*) FROM {USER_TABLE} WHERE admin = TRUE"), + (), + ) + .await? + .get(0)?; + + if num_admins == 0 { + let email = "admin@localhost".to_string(); + let password = generate_random_string(20); + + app_state + .user_conn() + .execute( + &format!( + r#" + INSERT INTO {USER_TABLE} + (email, password_hash, verified, admin) + VALUES + ('{email}', (hash_password('{password}')), TRUE, TRUE); + INSERT INTO + "# + ), + libsql::params!(), + ) + .await?; + + info!( + "{}", + indoc::formatdoc!( + r#" + Created new admin user: + email: '{email}' + password: '{password}' + "# + ) + ); + } + } + + if cfg!(debug_assertions) { + let text_config = app_state.get_config().to_text()?; + debug!("Config: {text_config}"); + } + + return Ok((new_db, app_state)); +} + +async fn init_logs_db(data_dir: &DataDir) -> Result { + let conn = connect_sqlite(data_dir.logs_db_path().into(), None).await?; + + // Turn off secure_deletions, i.e. don't wipe the memory with zeros. + conn + .query("PRAGMA secure_delete = FALSE", ()) + .await + .unwrap(); + + // Sync less often + conn.execute("PRAGMA synchronous = 1", ()).await.unwrap(); + + apply_logs_migrations(conn.clone()).await?; + return Ok(conn); +} diff --git a/trailbase-core/src/server/mod.rs b/trailbase-core/src/server/mod.rs new file mode 100644 index 0000000..cee1d76 --- /dev/null +++ b/trailbase-core/src/server/mod.rs @@ -0,0 +1,399 @@ +mod init; + +use axum::extract::{DefaultBodyLimit, Request, State}; +use axum::handler::HandlerWithoutStateExt; +use axum::http::{HeaderValue, StatusCode}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::{RequestExt, Router}; +use rust_embed::RustEmbed; +use std::path::PathBuf; +use tokio::signal; +use tokio::task::JoinSet; +use tower_cookies::CookieManagerLayer; +use tower_http::{cors, limit::RequestBodyLimitLayer, services::ServeDir, trace::TraceLayer}; +use tracing_subscriber::{filter, prelude::*}; + +use crate::admin; +use crate::app_state::AppState; +use crate::assets::AssetService; +use crate::auth::util::is_admin; +use crate::auth::{self, AuthError, User}; +use crate::constants::{AUTH_API_PATH, HEADER_CSRF_TOKEN, QUERY_API_PATH, RECORD_API_PATH}; +use crate::data_dir::DataDir; +use crate::logging; +use crate::scheduler; + +pub use init::{init_app_state, InitError}; + +/// A set of options to configure serving behaviors. Changing any of these options +/// requires a server restart, which makes them a natural fit for being exposed as command line +/// arguments. +#[derive(Debug, Clone, Default)] +pub struct ServerOptions { + /// Optional path to static assets that will be served at the HTTP root. + pub data_dir: DataDir, + + // Address the HTTP server binds to (Default: localhost:4000). + pub address: String, + + // Optional address of the admin UI + API. + pub admin_address: Option, + + /// Optional path to static assets that will be served at the HTTP root. + pub public_dir: Option, + + /// Enabling dev mode allows free-for-all access to admin APIs. This can be useful to develop the + /// UI behind a different server preventing auth cookie passing. + /// + /// NOTE: We might want to consider passing explicit auth headers when logging in specifically + /// from the dev Admin UI. + pub dev: bool, + + /// Disable the built-in public authentication (login, logout, ...) UI. + pub disable_auth_ui: bool, + + /// Limit the set of allowed origins the HTTP server will answer to. + pub cors_allowed_origins: Vec, +} + +pub struct Server { + state: AppState, + + // Routers. + main_router: (String, Router), + admin_router: Option<(String, Router)>, +} + +impl Server { + /// Initializes the server. Will create a new data directory on first start. + pub async fn init(opts: ServerOptions) -> Result { + let (_, state) = + init::init_app_state(opts.data_dir.clone(), opts.public_dir.clone(), opts.dev).await?; + + let main_router = Self::build_main_router(&state, &opts, None).await; + let admin_router = Self::build_independent_admin_router(&state, &opts); + + Ok(Self { + state, + main_router, + admin_router, + }) + } + + /// Initializes the server in a more customizable manner. Will create a new data directory on + /// first start. + /// + /// The `custom_routes` will be registered with the http server and `on_first_init` will be + /// called only when a new data directory and therefore databases are created. This hook can + /// be used to customize the setup in a simple manner, e.g. create tables, etc. + /// Note, however, that for a multi-stage deployment (dev, test, staging, prod, ...) or prod + /// setups migrations are a more robust approach to consistent and continuous management of + /// schemas. + pub async fn init_with_custom_routes_and_initializer( + opts: ServerOptions, + custom_routes: Option>, + on_first_init: impl FnOnce(AppState) -> O, + ) -> Result + where + O: std::future::Future>>, + { + let (new_data_dir, state) = + init::init_app_state(opts.data_dir.clone(), opts.public_dir.clone(), opts.dev).await?; + if new_data_dir { + on_first_init(state.clone()) + .await + .map_err(|err| InitError::CustomInit(err.to_string()))?; + } + + let main_router = Self::build_main_router(&state, &opts, custom_routes).await; + let admin_router = Self::build_independent_admin_router(&state, &opts); + + Ok(Self { + state, + main_router, + admin_router, + }) + } + + pub fn state(&self) -> &AppState { + return &self.state; + } + + pub fn router(&self) -> &Router<()> { + return &self.main_router.1; + } + + pub async fn serve(&self) -> Result<(), Box> { + // This declares **where** tracing is being logged to, e.g. stderr, file, sqlite. + // + // NOTE: it's ok to fail. Just means someone else already initialize the tracing sub-system. + let _ = tracing_subscriber::registry() + .with( + logging::SqliteLogLayer::new(&self.state).with_filter( + filter::Targets::new() + .with_target("tower_http::trace::on_response", filter::LevelFilter::DEBUG) + .with_target("tower_http::trace::on_request", filter::LevelFilter::DEBUG) + .with_target("tower_http::trace::make_span", filter::LevelFilter::DEBUG) + .with_default(filter::LevelFilter::INFO), + ), + ) + .try_init(); + + let _raii_tasks = scheduler::start_periodic_tasks(&self.state); + + let mut set = JoinSet::new(); + + { + let (addr, router) = self.main_router.clone(); + set.spawn(async move { Self::start_listener(&addr, router).await }); + } + + if let Some((addr, router)) = self.admin_router.clone() { + set.spawn(async move { Self::start_listener(&addr, router).await }); + } + + log::info!( + "listening on http://{addr} 🚀 (Admin UI http://{admin_addr}/_/admin/)", + addr = self.main_router.0, + admin_addr = self + .admin_router + .as_ref() + .map_or_else(|| &self.main_router.0, |(addr, _)| addr) + ); + + set.join_all().await; + + return Ok(()); + } + + async fn start_listener(addr: &str, router: Router<()>) -> std::io::Result<()> { + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(listener) => listener, + Err(err) => { + log::error!("Failed to listen on: {addr}: {err}"); + std::process::exit(1); + } + }; + + if let Err(err) = axum::serve(listener, router.clone()) + .with_graceful_shutdown(shutdown_signal()) + .await + { + log::error!("Failed to start server: {err}"); + std::process::exit(1); + } + + return Ok(()); + } + + fn build_admin_router(state: &AppState) -> Router { + return Router::new() + .nest( + "/api/_admin/", + admin::router().layer(middleware::from_fn_with_state( + state.clone(), + assert_admin_api_access, + )), + ) + .nest_service( + "/_/admin", + AssetService::::with_parameters( + // SPA-style fallback. + Some(Box::new(|_| Some("index.html".to_string()))), + Some("index.html".to_string()), + ), + ); + } + + fn build_independent_admin_router( + state: &AppState, + opts: &ServerOptions, + ) -> Option<(String, Router<()>)> { + let address = opts.admin_address.as_ref()?; + if !has_indepenedent_admin_router(opts) { + return None; + } + + let router = Router::new() + .nest(&format!("/{AUTH_API_PATH}"), auth::admin_auth_router()) + .nest("/", Self::build_admin_router(state)); + + return Some(( + address.clone(), + Self::wrap_with_default_layers(state, opts, router), + )); + } + + async fn build_main_router( + state: &AppState, + opts: &ServerOptions, + custom_router: Option>, + ) -> (String, Router<()>) { + let mut router = Router::new() + // Public, stable and versioned APIs. + .nest(&format!("/{RECORD_API_PATH}"), crate::records::router()) + .nest(&format!("/{QUERY_API_PATH}"), crate::query::router()) + .nest(&format!("/{AUTH_API_PATH}"), auth::router()) + .route("/api/healthcheck", get(healthcheck_handler)); + + if !has_indepenedent_admin_router(opts) { + router = router.nest("/", Self::build_admin_router(state)); + } + + if !opts.disable_auth_ui { + router = router.nest("/_/auth", crate::auth::auth_ui_router()); + } + + if let Some(custom_router) = custom_router { + router = router.nest("/", custom_router); + } + + if let Some(public_dir) = &opts.public_dir { + if !tokio::fs::try_exists(public_dir).await.unwrap_or(false) { + panic!("--public_dir={public_dir:?} path does not exist.") + } + + async fn handle_404() -> (StatusCode, &'static str) { + (StatusCode::NOT_FOUND, "Not found") + } + + router = router + .fallback_service(ServeDir::new(public_dir).not_found_service(handle_404.into_service())); + } + + return ( + opts.address.clone(), + Self::wrap_with_default_layers(state, opts, router), + ); + } + + fn wrap_with_default_layers( + state: &AppState, + opts: &ServerOptions, + router: Router, + ) -> Router<()> { + return router + .layer(CookieManagerLayer::new()) + .layer(build_cors(opts)) + .layer( + // This declares: **what information** is logged at what level in to events and spans. + TraceLayer::new_for_http() + .make_span_with(logging::sqlite_logger_make_span) + .on_request(logging::sqlite_logger_on_request) + .on_response(logging::sqlite_logger_on_response), + ) + // Default is only 2MB Increase to 10MB. + .layer(DefaultBodyLimit::disable()) + .layer(RequestBodyLimitLayer::new(10 * 1024 * 1024)) + .with_state(state.clone()); + } +} + +fn has_indepenedent_admin_router(opts: &ServerOptions) -> bool { + return match opts.admin_address { + None => false, + Some(ref address) if *address == opts.address => false, + _ => true, + }; +} + +async fn healthcheck_handler() -> Response { + return (StatusCode::OK, "Ok").into_response(); +} + +/// Assert that the caller is an admin and provides a valid CSRF token. Unlike the access to the +/// HTML/js assets, this one errors. +/// +/// NOTE: returning a redirect (like below) only makes sense for the html serving, not the APIs. +async fn assert_admin_api_access( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let user = req.extract_parts_with_state::(&state).await?; + + if !is_admin(&state, &user).await { + return Err(AuthError::Forbidden); + } + + // CSRF protection. + let Some(received_csrf_token) = req + .headers() + .get(HEADER_CSRF_TOKEN) + .and_then(|header| header.to_str().ok()) + else { + return Err(AuthError::BadRequest("admin APIs require csrf header")); + }; + + let expected_csrf = &user.csrf_token; + if expected_csrf != received_csrf_token { + return Err(AuthError::BadRequest("invalid CSRF token")); + } + + return Ok(next.run(req).await); +} + +fn build_cors(opts: &ServerOptions) -> cors::CorsLayer { + if opts.dev { + return cors::CorsLayer::very_permissive(); + } + + let origin_strs = &opts.cors_allowed_origins; + let wildcard = origin_strs.iter().any(|s| s == "*"); + + let origins = if wildcard { + log::info!("CORS: allow any origin"); + // cors::AllowOrigin::any() + cors::AllowOrigin::mirror_request() + } else { + cors::AllowOrigin::list(origin_strs.iter().filter_map(|o| { + match HeaderValue::from_str(o.as_str()) { + Ok(value) => Some(value), + Err(err) => { + log::error!("Invalid CORS origin {o}: {err}"); + None + } + } + })) + }; + + // Cannot combine `Access-Control-Allow-Credentials: true` with `Access-Control-Allow-Methods: *` + return cors::CorsLayer::new() + .allow_methods(cors::Any) + // .allow_credentials(wildcard) + .allow_origin(origins); +} + +async fn shutdown_signal() { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => { + println!("Received Ctrl+C. Shutting down gracefully."); + }, + _ = terminate => { + println!("Received termination. Shutting down gracefully."); + }, + } +} + +#[derive(RustEmbed, Clone)] +#[folder = "../ui/admin/dist/"] +struct AdminAssets; diff --git a/trailbase-core/src/table_metadata.rs b/trailbase-core/src/table_metadata.rs new file mode 100644 index 0000000..acb732d --- /dev/null +++ b/trailbase-core/src/table_metadata.rs @@ -0,0 +1,1085 @@ +use fallible_iterator::FallibleIterator; +use jsonschema::Validator; +use lazy_static::lazy_static; +use libsql::{params, Connection}; +use log::*; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlite3_parser::ast::Stmt; +use std::collections::HashMap; +use std::sync::Arc; +use thiserror::Error; +use trailbase_sqlite::query_one_row; + +use crate::constants::{SQLITE_SCHEMA_TABLE, USER_TABLE}; +use crate::schema::{Column, ColumnDataType, ColumnOption, ForeignKey, SchemaError, Table, View}; + +// TODO: Can we merge this with trailbase_sqlite::schema::SchemaError? +#[derive(Debug, Clone, Error)] +pub enum JsonSchemaError { + #[error("Schema compile error: {0}")] + SchemaCompile(String), + #[error("Validation error")] + Validation, + #[error("Schema not found: {0}")] + NotFound(String), + #[error("Json serialization error: {0}")] + JsonSerialization(Arc), +} + +#[derive(Clone, Debug)] +pub enum JsonColumnMetadata { + SchemaName(String), + Pattern(serde_json::Value), +} + +impl JsonColumnMetadata { + pub fn validate(&self, value: &serde_json::Value) -> Result<(), JsonSchemaError> { + match self { + Self::SchemaName(name) => { + let Some(schema) = trailbase_sqlite::schema::get_compiled_schema(name) else { + return Err(JsonSchemaError::NotFound(name.to_string())); + }; + schema + .validate(value) + .map_err(|_err| JsonSchemaError::Validation)?; + return Ok(()); + } + Self::Pattern(pattern) => { + let schema = + Validator::new(pattern).map_err(|err| JsonSchemaError::SchemaCompile(err.to_string()))?; + schema + .validate(value) + .map_err(|_err| JsonSchemaError::Validation)?; + return Ok(()); + } + } + } +} + +#[derive(Debug, Clone)] +pub struct ColumnMetadata { + pub json: Option, +} + +/// A data class describing a sqlite Table and additional meta data useful for TrailBase. +/// +/// An example of TrailBase idiosyncrasies are UUIDv7 columns, which are a bespoke concept. +#[derive(Debug, Clone)] +pub struct TableMetadata { + pub schema: Table, + + metadata: Vec, + name_to_index: HashMap, + + record_pk_column: Option, + pub user_id_columns: Vec, + pub file_upload_columns: Vec, + pub file_uploads_columns: Vec, + + // Only non-composite keys. + #[allow(unused)] + foreign_ids: Vec<(usize, ForeignKey)>, + // TODO: Add triggers once sqlparser supports a sqlite "CREATE TRIGGER" statements. +} + +impl TableMetadata { + /// Build a new TableMetadata instance containing TrailBase/RecordApi specific information. + /// + /// NOTE: The list of all tables is needed only to extract interger/UUIDv7 pk columns for foreign + /// key relationships. + pub(crate) fn new(table: Table, tables: &[Table]) -> Self { + let mut foreign_ids: Vec<(usize, ForeignKey)> = vec![]; + let mut file_upload_columns: Vec = vec![]; + let mut file_uploads_columns: Vec = vec![]; + let mut name_to_index = HashMap::::new(); + + let metadata: Vec = table + .columns + .iter() + .enumerate() + .map(|(index, col)| { + name_to_index.insert(col.name.clone(), index); + + for opt in &col.options { + if let ColumnOption::ForeignKey { + foreign_table, + referred_columns, + on_delete, + on_update, + } = opt + { + foreign_ids.push(( + index, + ForeignKey { + name: None, + foreign_table: foreign_table.clone(), + columns: vec![col.name.clone()], + referred_columns: referred_columns.clone(), + on_delete: on_delete.clone(), + on_update: on_update.clone(), + }, + )); + } + } + + let json_metadata = build_json_metadata(col); + if let Some(ref json_metadata) = json_metadata { + match json_metadata { + JsonColumnMetadata::SchemaName(name) if name == "std.FileUpload" => { + file_upload_columns.push(index); + } + JsonColumnMetadata::SchemaName(name) if name == "std.FileUploads" => { + file_uploads_columns.push(index); + } + _ => {} + }; + } + + return ColumnMetadata { + json: json_metadata, + }; + }) + .collect(); + + let record_pk_column = find_record_pk_column_index(&table.columns, tables); + let user_id_columns = find_user_id_foreign_key_columns(&table.columns); + + return TableMetadata { + schema: table, + metadata, + name_to_index, + record_pk_column, + user_id_columns, + file_upload_columns, + file_uploads_columns, + foreign_ids, + }; + } + + #[inline] + pub fn name(&self) -> &str { + &self.schema.name + } + + #[inline] + pub fn column_index_by_name(&self, key: &str) -> Option { + self.name_to_index.get(key).copied() + } + + #[inline] + pub fn column_by_name(&self, key: &str) -> Option<(&Column, &ColumnMetadata)> { + let index = self.column_index_by_name(key)?; + return Some((&self.schema.columns[index], &self.metadata[index])); + } +} + +/// A data class describing a sqlite View and future, additional meta data useful for TrailBase. +#[derive(Debug, Clone)] +pub struct ViewMetadata { + pub schema: View, + + metadata: Vec, + name_to_index: HashMap, + + record_pk_column: Option, +} + +impl ViewMetadata { + /// Build a new ViewMetadata instance containing TrailBase/RecordApi specific information. + /// + /// NOTE: The list of all tables is needed only to extract interger/UUIDv7 pk columns for foreign + /// key relationships. + pub(crate) fn new(view: View, tables: &[Table]) -> Self { + let mut name_to_index = HashMap::::new(); + let metadata: Vec = { + if let Some(ref columns) = view.columns { + columns + .iter() + .enumerate() + .map(|(index, col)| { + name_to_index.insert(col.name.clone(), index); + return ColumnMetadata { + json: build_json_metadata(col), + }; + }) + .collect() + } else { + debug!("Building ViewMetadata for complex view thus missing column information."); + vec![] + } + }; + + let record_pk_column = view + .columns + .as_ref() + .and_then(|c| find_record_pk_column_index(c, tables)); + + return ViewMetadata { + schema: view, + metadata, + name_to_index, + record_pk_column, + }; + } + + #[inline] + pub fn name(&self) -> &str { + &self.schema.name + } + + #[inline] + pub fn column_index_by_name(&self, key: &str) -> Option { + self.name_to_index.get(key).copied() + } + + #[inline] + pub fn column_by_name(&self, key: &str) -> Option<(&Column, &ColumnMetadata)> { + let index = self.column_index_by_name(key)?; + let cols = self.schema.columns.as_ref()?; + return Some((&cols[index], &self.metadata[index])); + } +} + +pub trait TableOrViewMetadata { + // Used by RecordAPI. + fn column_by_name(&self, key: &str) -> Option<(&Column, &ColumnMetadata)>; + + // Impl detail: only used by admin + fn columns(&self) -> Option>; + fn record_pk_column(&self) -> Option<(usize, &Column)>; +} + +impl TableOrViewMetadata for TableMetadata { + fn column_by_name(&self, key: &str) -> Option<(&Column, &ColumnMetadata)> { + self.column_by_name(key) + } + + fn columns(&self) -> Option> { + Some(self.schema.columns.clone()) + } + + fn record_pk_column(&self) -> Option<(usize, &Column)> { + let index = self.record_pk_column?; + return self.schema.columns.get(index).map(|c| (index, c)); + } +} + +impl TableOrViewMetadata for ViewMetadata { + fn column_by_name(&self, key: &str) -> Option<(&Column, &ColumnMetadata)> { + self.column_by_name(key) + } + + fn columns(&self) -> Option> { + return self.schema.columns.clone(); + } + + fn record_pk_column(&self) -> Option<(usize, &Column)> { + let Some(columns) = &self.schema.columns else { + return None; + }; + let index = self.record_pk_column?; + return columns.get(index).map(|c| (index, c)); + } +} + +fn build_json_metadata(col: &Column) -> Option { + for opt in &col.options { + match extract_json_metadata(opt) { + Ok(maybe) => { + if let Some(jm) = maybe { + return Some(jm); + } + } + Err(err) => { + error!("Failed to get JSON schema: {err}"); + } + } + } + None +} + +fn extract_json_metadata( + opt: &ColumnOption, +) -> Result, JsonSchemaError> { + let ColumnOption::Check(check) = opt else { + return Ok(None); + }; + + lazy_static! { + static ref SCHEMA_RE: Regex = + Regex::new(r#"(?smR)jsonschema\s*\(\s*[\['"](?.*)[\]'"]\s*,.+?\)"#).unwrap(); + static ref MATCHES_RE: Regex = + Regex::new(r"(?smR)jsonschema_matches\s*\(.+?(?\{.*\}).+?\)").unwrap(); + } + + if let Some(cap) = SCHEMA_RE.captures(check) { + let name = &cap["name"]; + let Some(_schema) = trailbase_sqlite::schema::get_schema(name) else { + let schemas: Vec = trailbase_sqlite::schema::get_schemas() + .iter() + .map(|s| s.name.clone()) + .collect(); + return Err(JsonSchemaError::NotFound(format!( + "Json schema {name} not found in: {schemas:?}" + ))); + }; + + return Ok(Some(JsonColumnMetadata::SchemaName(name.to_string()))); + } + + if let Some(cap) = MATCHES_RE.captures(check) { + let pattern = &cap["pattern"]; + let value = serde_json::from_str::(pattern) + .map_err(|err| JsonSchemaError::JsonSerialization(Arc::new(err)))?; + return Ok(Some(JsonColumnMetadata::Pattern(value))); + } + + return Ok(None); +} + +fn find_user_id_foreign_key_columns(columns: &[Column]) -> Vec { + let mut indexes: Vec = vec![]; + for (index, col) in columns.iter().enumerate() { + for opt in &col.options { + if let ColumnOption::ForeignKey { + foreign_table, + referred_columns, + .. + } = opt + { + if foreign_table == USER_TABLE && referred_columns.len() == 1 && referred_columns[0] == "id" + { + indexes.push(index); + } + } + } + } + return indexes; +} + +/// Finds suitable Integer or UUIDv7 primary key columns, if present. +/// +/// Cursors require certain properties like a stable, time-sortable primary key. +fn find_record_pk_column_index(columns: &[Column], tables: &[Table]) -> Option { + let primary_key_col_index = columns.iter().position(|col| { + for opt in &col.options { + if let ColumnOption::Unique { is_primary } = opt { + return *is_primary; + } + } + return false; + }); + + if let Some(index) = primary_key_col_index { + let column = &columns[index]; + + if column.data_type == ColumnDataType::Integer { + // TODO: We should detect the "integer pk" desc case and at least warn: + // https://www.sqlite.org/lang_createtable.html#rowid. + return Some(index); + } + + for opts in &column.options { + lazy_static! { + static ref UUID_V7_RE: Regex = Regex::new(r"^is_uuid_v7\s*\(").unwrap(); + } + + match &opts { + // Check if the referenced column is a uuidv7 column. + ColumnOption::ForeignKey { + foreign_table, + referred_columns, + .. + } => { + let Some(referred_table) = tables.iter().find(|t| t.name == *foreign_table) else { + error!("Failed to get foreign key schema for {foreign_table}"); + continue; + }; + + if referred_columns.len() != 1 { + return None; + } + let referred_column = &referred_columns[0]; + + let col = referred_table + .columns + .iter() + .find(|c| c.name == *referred_column)?; + + let mut is_pk = false; + for opt in &col.options { + match opt { + ColumnOption::Check(expr) if UUID_V7_RE.is_match(expr) => { + return Some(index); + } + ColumnOption::Unique { is_primary } if *is_primary => { + is_pk = true; + } + _ => {} + } + } + + if is_pk && col.data_type == ColumnDataType::Integer { + return Some(index); + } + + return None; + } + ColumnOption::Check(expr) if UUID_V7_RE.is_match(expr) => { + return Some(index); + } + _ => {} + } + } + } + + return None; +} + +struct TableMetadataCacheState { + conn: libsql::Connection, + tables: parking_lot::RwLock>>, + views: parking_lot::RwLock>>, +} + +#[derive(Clone)] +pub struct TableMetadataCache { + state: Arc, +} + +impl TableMetadataCache { + pub async fn new(conn: libsql::Connection) -> Result { + let (table_map, tables) = Self::build_tables(&conn).await?; + let views = Self::build_views(&conn, &tables).await?; + + return Ok(TableMetadataCache { + state: Arc::new(TableMetadataCacheState { + conn, + tables: parking_lot::RwLock::new(table_map), + views: parking_lot::RwLock::new(views), + }), + }); + } + + async fn build_tables( + conn: &libsql::Connection, + ) -> Result<(HashMap>, Vec
), TableLookupError> { + let tables = lookup_and_parse_all_table_schemas(conn).await?; + let build = |table: &Table| { + ( + table.name.clone(), + Arc::new(TableMetadata::new(table.clone(), &tables)), + ) + }; + + return Ok((tables.iter().map(build).collect(), tables)); + } + + async fn build_views( + conn: &libsql::Connection, + tables: &[Table], + ) -> Result>, TableLookupError> { + let views = lookup_and_parse_all_view_schemas(conn, tables).await?; + let build = |view: View| { + // NOTE: we check during record API config validation that no temporary views are referenced. + // if view.temporary { + // debug!("Temporary view: {}", view.name); + // } + + return Some((view.name.clone(), Arc::new(ViewMetadata::new(view, tables)))); + }; + + return Ok(views.into_iter().filter_map(build).collect()); + } + + pub fn get(&self, table_name: &str) -> Option> { + self.state.tables.read().get(table_name).cloned() + } + + pub fn get_view(&self, view_name: &str) -> Option> { + self.state.views.read().get(view_name).cloned() + } + + pub async fn invalidate_all(&self) -> Result<(), TableLookupError> { + debug!("Rebuilding TableMetadataCache"); + let (table_map, tables) = Self::build_tables(&self.state.conn).await?; + *self.state.tables.write() = table_map; + *self.state.views.write() = Self::build_views(&self.state.conn, &tables).await?; + Ok(()) + } +} + +impl std::fmt::Debug for TableMetadataCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TableMetadataCache") + .field("tables", &self.state.tables.read().keys()) + .field("views", &self.state.views.read().keys()) + .finish() + } +} + +#[derive(Debug, Error)] +pub enum TableLookupError { + #[error("SQL error: {0}")] + Sql(#[from] libsql::Error), + #[error("Schema error: {0}")] + Schema(#[from] SchemaError), + #[error("Missing")] + Missing, + #[error("Sql parse error: {0}")] + SqlParse(#[from] sqlite3_parser::lexer::sql::Error), +} + +pub async fn lookup_and_parse_table_schema( + conn: &Connection, + table_name: &str, +) -> Result { + // Then get the actual table. + let sql: String = query_one_row( + conn, + &format!("SELECT sql FROM {SQLITE_SCHEMA_TABLE} WHERE type = 'table' AND name = $1"), + params!(table_name), + ) + .await? + .get(0)?; + + let Some(stmt) = sqlite3_parse_into_statement(&sql)? else { + return Err(TableLookupError::Missing); + }; + + return Ok(stmt.try_into()?); +} + +pub(crate) fn sqlite3_parse_into_statements( + sql: &str, +) -> Result, sqlite3_parser::lexer::sql::Error> { + use sqlite3_parser::ast::Cmd; + + // According to sqlite3_parser's docs they're working to remove panics in some edge cases. + // Meanwhile we'll trap them here. We haven't seen any in practice yet. + let outer_result = std::panic::catch_unwind(|| { + let mut parser = sqlite3_parser::lexer::sql::Parser::new(sql.as_bytes()); + + let mut statements: Vec = vec![]; + while let Some(cmd) = parser.next()? { + match cmd { + Cmd::Stmt(stmt) => { + statements.push(stmt); + } + Cmd::Explain(_) | Cmd::ExplainQueryPlan(_) => {} + } + } + return Ok(statements); + }); + + return match outer_result { + Ok(inner_result) => inner_result, + Err(_panic_err) => { + error!("Parser panicked"); + return Err(sqlite3_parser::lexer::sql::Error::UnrecognizedToken(None)); + } + }; +} + +pub(crate) fn sqlite3_parse_into_statement( + sql: &str, +) -> Result, sqlite3_parser::lexer::sql::Error> { + use sqlite3_parser::ast::Cmd; + + // According to sqlite3_parser's docs they're working to remove panics in some edge cases. + // Meanwhile we'll trap them here. We haven't seen any in practice yet. + let outer_result = std::panic::catch_unwind(|| { + let mut parser = sqlite3_parser::lexer::sql::Parser::new(sql.as_bytes()); + + while let Some(cmd) = parser.next()? { + match cmd { + Cmd::Stmt(stmt) => { + return Ok(Some(stmt)); + } + Cmd::Explain(_) | Cmd::ExplainQueryPlan(_) => {} + } + } + return Ok(None); + }); + + return match outer_result { + Ok(inner_result) => inner_result, + Err(_panic_err) => { + error!("Parser panicked"); + return Err(sqlite3_parser::lexer::sql::Error::UnrecognizedToken(None)); + } + }; +} + +pub async fn lookup_and_parse_all_table_schemas( + conn: &Connection, +) -> Result, TableLookupError> { + // Then get the actual table. + let mut rows = conn + .query( + &format!("SELECT sql FROM {SQLITE_SCHEMA_TABLE} WHERE type = 'table'"), + (), + ) + .await?; + + let mut tables: Vec
= vec![]; + while let Some(row) = rows.next().await? { + let sql: String = row.get(0)?; + let Some(stmt) = sqlite3_parse_into_statement(&sql)? else { + return Err(TableLookupError::Missing); + }; + tables.push(stmt.try_into()?); + } + + return Ok(tables); +} + +fn sqlite3_parse_view(sql: &str, tables: &[Table]) -> Result { + let mut parser = sqlite3_parser::lexer::sql::Parser::new(sql.as_bytes()); + match parser.next()? { + None => Err(TableLookupError::Missing), + Some(cmd) => { + use sqlite3_parser::ast::Cmd; + match cmd { + Cmd::Stmt(stmt) => Ok(View::from(stmt, tables)?), + Cmd::Explain(_) | Cmd::ExplainQueryPlan(_) => Err(TableLookupError::Missing), + } + } + } +} + +pub async fn lookup_and_parse_all_view_schemas( + conn: &Connection, + tables: &[Table], +) -> Result, TableLookupError> { + // Then get the actual table. + let mut rows = conn + .query( + &format!("SELECT sql FROM {SQLITE_SCHEMA_TABLE} WHERE type = 'view'"), + (), + ) + .await?; + + let mut views: Vec = vec![]; + while let Some(row) = rows.next().await? { + let sql: String = row.get(0)?; + views.push(sqlite3_parse_view(&sql, tables)?); + } + + return Ok(views); +} + +/// Influeces the generated JSON schema. In `Insert` mode columns with default values will be +/// optional. +#[derive(Copy, Clone, Debug, Deserialize, Serialize)] +pub enum JsonSchemaMode { + /// Insert mode. + Insert, + /// Read/Select mode. + Select, + /// Update mode. + Update, +} + +fn column_data_type_to_json_type(data_type: ColumnDataType) -> Value { + return match data_type { + ColumnDataType::Null => Value::String("null".into()), + ColumnDataType::Any => Value::Array(vec![ + "number".into(), + "string".into(), + "boolean".into(), + "object".into(), + "array".into(), + "null".into(), + ]), + ColumnDataType::Text => Value::String("string".into()), + // We encode all blobs as url-safe Base64. + ColumnDataType::Blob => Value::String("string".into()), + ColumnDataType::Integer => Value::String("integer".into()), + ColumnDataType::Real => Value::String("number".into()), + ColumnDataType::Numeric => Value::String("number".into()), + // JSON types + ColumnDataType::JSON => Value::String("object".into()), + ColumnDataType::JSONB => Value::String("object".into()), + // Affine types + // + // Integers: + ColumnDataType::Int => Value::String("number".into()), + ColumnDataType::TinyInt => Value::String("number".into()), + ColumnDataType::SmallInt => Value::String("number".into()), + ColumnDataType::MediumInt => Value::String("number".into()), + ColumnDataType::BigInt => Value::String("number".into()), + ColumnDataType::UnignedBigInt => Value::String("number".into()), + ColumnDataType::Int2 => Value::String("number".into()), + ColumnDataType::Int4 => Value::String("number".into()), + ColumnDataType::Int8 => Value::String("number".into()), + // Text: + ColumnDataType::Character => Value::String("string".into()), + ColumnDataType::Varchar => Value::String("string".into()), + ColumnDataType::VaryingCharacter => Value::String("string".into()), + ColumnDataType::NChar => Value::String("string".into()), + ColumnDataType::NativeCharacter => Value::String("string".into()), + ColumnDataType::NVarChar => Value::String("string".into()), + ColumnDataType::Clob => Value::String("string".into()), + // Real: + ColumnDataType::Double => Value::String("number".into()), + ColumnDataType::DoublePrecision => Value::String("number".into()), + ColumnDataType::Float => Value::String("number".into()), + // Numeric: + ColumnDataType::Boolean => Value::String("boolean".into()), + ColumnDataType::Decimal => Value::String("number".into()), + ColumnDataType::Date => Value::String("number".into()), + ColumnDataType::DateTime => Value::String("number".into()), + }; +} + +/// Builds a JSON Schema definition for the given table. +/// +/// NOTE: insert and select require different types to model default values, i.e. a column with a +/// default value is optional during insert but guaranteed during reads. +/// +/// NOTE: We're not currently respecting the RecordApi `autofill_missing_user_id_columns` +/// setting. Not sure we should since this is more a feature for no-JS, HTTP-only apps, which +/// don't benefit from type-safety anyway. +pub fn build_json_schema( + table_or_view_name: &str, + metadata: &(dyn TableOrViewMetadata + Send + Sync), + mode: JsonSchemaMode, +) -> Result<(Validator, serde_json::Value), JsonSchemaError> { + let mut properties = serde_json::Map::new(); + let mut required_cols: Vec = vec![]; + let mut defs = serde_json::Map::new(); + + let Some(columns) = metadata.columns() else { + return Err(JsonSchemaError::NotFound("".to_string())); + }; + + for col in columns { + let mut found_def = false; + let mut not_null = false; + let mut default = false; + + for opt in &col.options { + match opt { + ColumnOption::NotNull => not_null = true, + ColumnOption::Default(_) => default = true, + ColumnOption::Check(check) => { + if let Some(json_metadata) = extract_json_metadata(&ColumnOption::Check(check.clone()))? { + match json_metadata { + JsonColumnMetadata::SchemaName(name) => { + let Some(schema) = trailbase_sqlite::schema::get_schema(&name) else { + return Err(JsonSchemaError::NotFound(name.to_string())); + }; + defs.insert(col.name.clone(), schema.schema); + found_def = true; + break; + } + JsonColumnMetadata::Pattern(pattern) => { + defs.insert(col.name.clone(), pattern.clone()); + found_def = true; + break; + } + } + } + } + ColumnOption::Unique { is_primary } => { + // According to the SQL standard, PRIMARY KEY should always imply NOT NULL. + // Unfortunately, due to a bug in some early versions, this is not the case in SQLite. + // Unless the column is an INTEGER PRIMARY KEY or the table is a WITHOUT ROWID table or a + // STRICT table or the column is declared NOT NULL, SQLite allows NULL values in a + // PRIMARY KEY column + // source: https://www.sqlite.org/lang_createtable.html + if *is_primary { + if col.data_type == ColumnDataType::Integer { + not_null = true; + } + + default = true; + } + } + _ => {} + } + } + + match mode { + JsonSchemaMode::Insert => { + if not_null && !default { + required_cols.push(col.name.clone()); + } + } + JsonSchemaMode::Select => { + if not_null { + required_cols.push(col.name.clone()); + } + } + JsonSchemaMode::Update => {} + } + + if found_def { + let name = &col.name; + properties.insert( + name.clone(), + serde_json::json!({ + "$ref": format!("#/$defs/{name}") + }), + ); + continue; + } + + properties.insert( + col.name.clone(), + serde_json::json!({ + "type": column_data_type_to_json_type(col.data_type), + }), + ); + } + + let schema = serde_json::json!({ + "title": table_or_view_name, + "type": "object", + "properties": serde_json::Value::Object(properties), + "required": serde_json::Value::Array(required_cols.into_iter().map(serde_json::Value::String).collect()), + "$defs":serde_json::Value::Object(defs), + }); + + return Ok(( + Validator::new(&schema).map_err(|err| JsonSchemaError::SchemaCompile(err.to_string()))?, + schema, + )); +} + +#[cfg(test)] +mod tests { + use indoc::indoc; + use serde_json::json; + use trailbase_sqlite::schema::FileUpload; + + use super::*; + use crate::app_state::*; + use crate::schema::ColumnOption; + + #[tokio::test] + async fn test_parse_table_schema() { + let state = test_state(None).await.unwrap(); + let conn = state.conn(); + + let check = indoc! {r#" + jsonschema_matches ('{ + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "age": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["name", "age"] + }', col0)"# + }; + + conn + .execute( + &format!( + r#"CREATE TABLE test_table ( + col0 TEXT CHECK({check}), + col1 TEXT CHECK(jsonschema('std.FileUpload', col1)), + col2 TEXT, + col3 TEXT CHECK(jsonschema('std.FileUpload', col3, 'image/jpeg, image/png')) + ) STRICT"# + ), + (), + ) + .await + .unwrap(); + + let insert = |col: &'static str, json: serde_json::Value| async move { + conn + .execute( + &format!( + "INSERT INTO test_table ({col}) VALUES ('{}')", + json.to_string() + ), + (), + ) + .await + }; + + assert!(insert("col2", json!({"name": 42})).await.unwrap() > 0); + assert!( + insert( + "col1", + serde_json::to_value(FileUpload::new( + uuid::Uuid::new_v4(), + Some("filename".to_string()), + None, + None + )) + .unwrap() + ) + .await + .unwrap() + > 0 + ); + assert!(insert("col1", json!({"foo": "/foo"})).await.is_err()); + assert!(insert("col0", json!({"name": 42})).await.is_err()); + assert!(insert("col0", json!({"name": "Alice"})).await.is_err()); + assert!( + insert("col0", json!({"name": "Alice", "age": 23})) + .await + .unwrap() + > 0 + ); + assert!(insert( + "col0", + json!({"name": "Alice", "age": 23, "additional": 42}) + ) + .await + .is_err()); + + assert!(insert("col3", json!({"foo": "/foo"})).await.is_err()); + assert!(insert( + "col3", + json!({ + "id": uuid::Uuid::new_v4().to_string(), + // Missing mime-type. + }) + ) + .await + .is_err()); + assert!(insert("col3", json!({"mime_type": "invalid"})) + .await + .is_err()); + assert!(insert( + "col3", + json!({ + "id": uuid::Uuid::new_v4().to_string(), + "mime_type": "image/png" + }) + ) + .await + .is_ok()); + + let cnt: i64 = query_one_row(conn, "SELECT COUNT(*) FROM test_table", ()) + .await + .unwrap() + .get(0) + .unwrap(); + + assert_eq!(cnt, 4); + + let table = lookup_and_parse_table_schema(conn, "test_table") + .await + .unwrap(); + let col = table.columns.first().unwrap(); + let check_expr = col + .options + .iter() + .filter_map(|c| match c { + ColumnOption::Check(check) => Some(check), + _ => None, + }) + .collect::>()[0]; + + assert_eq!(check_expr, check); + let table_metadata = TableMetadata::new(table.clone(), &[table]); + + let (schema, _) = build_json_schema( + table_metadata.name(), + &table_metadata, + JsonSchemaMode::Insert, + ) + .unwrap(); + assert!(schema.is_valid(&json!({ + "col2": "test", + }))); + + assert!(schema.is_valid(&json!({ + "col0": json!({ + "name": "Alice", "age": 23, + }), + }))); + + assert!(!schema.is_valid(&json!({ + "col0": json!({ + "name": 42, "age": "23", + }), + }))); + } + + #[test] + fn test_parse_alter_table() { + let sql = "ALTER TABLE foo RENAME TO bar"; + sqlite3_parse_into_statements(sql).unwrap(); + } + + #[test] + fn test_parse_create_view() { + let table_name = "table_name"; + let table_sql = format!( + r#" + CREATE TABLE {table_name} ( + id BLOB PRIMARY KEY NOT NULL CHECK(is_uuid_v7(id)) DEFAULT (uuid_v7()), + col0 TEXT NOT NULL DEFAULT '', + col1 BLOB NOT NULL, + hidden INTEGER DEFAULT 42 + ) STRICT;"# + ); + + let create_table_statement = sqlite3_parse_into_statement(&table_sql).unwrap().unwrap(); + + let table: Table = create_table_statement.try_into().unwrap(); + + { + let view_name = "view_name"; + let query = format!("SELECT col0, col1 FROM {table_name}"); + let view_sql = format!("CREATE VIEW {view_name} AS {query}"); + let create_view_statement = sqlite3_parse_into_statement(&view_sql).unwrap().unwrap(); + + let table_view = View::from(create_view_statement, &[table.clone()]).unwrap(); + + assert_eq!(table_view.name, view_name); + assert_eq!(table_view.query, query); + assert_eq!(table_view.temporary, false); + + let view_columns = table_view.columns.as_ref().unwrap(); + + assert_eq!(view_columns.len(), 2); + assert_eq!(view_columns[0].name, "col0"); + assert_eq!(view_columns[0].data_type, ColumnDataType::Text); + + assert_eq!(view_columns[1].name, "col1"); + assert_eq!(view_columns[1].data_type, ColumnDataType::Blob); + + let view_metadata = ViewMetadata::new(table_view, &[table.clone()]); + + assert!(view_metadata.record_pk_column().is_none()); + assert_eq!(view_metadata.columns().as_ref().unwrap().len(), 2); + } + + { + let view_name = "view_name"; + let query = format!("SELECT id, col0, col1 FROM {table_name}"); + let view_sql = format!("CREATE VIEW {view_name} AS {query}"); + let create_view_statement = sqlite3_parse_into_statement(&view_sql).unwrap().unwrap(); + + let table_view = View::from(create_view_statement, &[table.clone()]).unwrap(); + + assert_eq!(table_view.name, view_name); + //FIXME: + // assert_eq!(table_view.query, query); + assert_eq!(table_view.temporary, false); + + let view_metadata = ViewMetadata::new(table_view, &[table.clone()]); + + let uuidv7_col = view_metadata.record_pk_column().unwrap(); + let columns = view_metadata.columns().unwrap(); + assert_eq!(columns.len(), 3); + assert_eq!(columns[uuidv7_col.0].name, "id"); + } + } +} diff --git a/trailbase-core/src/test.rs b/trailbase-core/src/test.rs new file mode 100644 index 0000000..bd54791 --- /dev/null +++ b/trailbase-core/src/test.rs @@ -0,0 +1,8 @@ +use axum::response::Response; + +pub(crate) async fn unpack_json_response serde::Deserialize<'a>>( + response: Response, +) -> Result { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await?; + return Ok(serde_json::from_slice::(&bytes)?); +} diff --git a/trailbase-core/src/transaction.rs b/trailbase-core/src/transaction.rs new file mode 100644 index 0000000..e283745 --- /dev/null +++ b/trailbase-core/src/transaction.rs @@ -0,0 +1,138 @@ +use libsql::{Connection, Rows, Transaction}; +use log::*; +use refinery_libsql::LibsqlConnection; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +use crate::migrations; + +#[derive(Debug, Error)] +pub enum TransactionError { + #[error("Libsql error: {0}")] + Libsql(#[from] libsql::Error), + #[error("IO error: {0}")] + IO(#[from] std::io::Error), + #[error("Migration error: {0}")] + Migration(#[from] refinery::Error), + #[error("File error: {0}")] + File(String), +} + +/// A recorder for table migrations, i.e.: create, alter, drop, as opposed to data migrations. +pub struct TransactionRecorder { + conn: Connection, + tx: Transaction, + log: Vec, + + migration_path: PathBuf, + migration_suffix: String, +} + +#[allow(unused)] +impl TransactionRecorder { + pub async fn new( + conn: Connection, + migration_path: PathBuf, + migration_suffix: String, + ) -> Result { + let tx = conn.transaction().await?; + + return Ok(TransactionRecorder { + conn, + tx, + log: vec![], + migration_path, + migration_suffix, + }); + } + + // Note that we cannot take any sql params for recording purposes. + pub async fn query(&mut self, sql: &str) -> Result { + let rows = self.tx.query(sql, ()).await?; + self.log.push(sql.to_string()); + return Ok(rows); + } + + pub async fn execute(&mut self, sql: &str) -> Result { + let rows_affected = self.tx.execute(sql, ()).await?; + self.log.push(sql.to_string()); + return Ok(rows_affected); + } + + /// Consume this transaction and commit. + pub async fn commit_and_create_migration( + self, + ) -> Result, TransactionError> { + if self.log.is_empty() { + return Ok(None); + } + + // We have to commit alter table transactions through refinery to keep the migration table in + // sync. + // NOTE: Slightly hacky that we build up the transaction first to then cancel it. However, this + // gives us early checking. We could as well just not do it. + self.tx.rollback().await?; + + let filename = migrations::new_unique_migration_filename(&self.migration_suffix); + let stem = Path::new(&filename) + .file_stem() + .ok_or_else(|| TransactionError::File(format!("Failed to get stem from: {filename}")))? + .to_string_lossy() + .to_string(); + let path = self.migration_path.join(filename); + + let mut sql: String = self + .log + .iter() + .filter_map(|stmt| match stmt.as_str() { + "" => None, + x if x.ends_with(";") => Some(stmt.clone()), + x => Some(format!("{x};")), + }) + .collect::>() + .join("\n"); + + sql = sqlformat::format( + sql.as_str(), + &sqlformat::QueryParams::None, + sqlformat::FormatOptions { + indent: sqlformat::Indent::Spaces(4), + uppercase: true, + lines_between_queries: 2, + }, + ); + + let migrations = vec![refinery::Migration::unapplied(&stem, &sql)?]; + + let mut conn = LibsqlConnection::from_connection(self.conn); + let mut runner = migrations::new_migration_runner(&migrations).set_abort_missing(false); + + let report = runner.run_async(&mut conn).await.map_err(|err| { + error!("Migration aborted with: {err} for {sql}"); + err + })?; + + write_migration_file(path, &sql).await?; + + return Ok(Some(report)); + } + + /// Consume this transaction and rollback. + pub async fn rollback(self) -> Result<(), TransactionError> { + return Ok(self.tx.rollback().await?); + } +} + +#[cfg(not(test))] +async fn write_migration_file(path: PathBuf, sql: &str) -> std::io::Result<()> { + use tokio::io::AsyncWriteExt; + + let mut migration_file = tokio::fs::File::create_new(path).await?; + migration_file.write_all(sql.as_bytes()).await?; + return Ok(()); +} + +#[cfg(test)] +async fn write_migration_file(_path: PathBuf, _sql: &str) -> std::io::Result<()> { + return Ok(()); +} diff --git a/trailbase-core/src/util.rs b/trailbase-core/src/util.rs new file mode 100644 index 0000000..1d2349b --- /dev/null +++ b/trailbase-core/src/util.rs @@ -0,0 +1,57 @@ +use base64::prelude::*; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Clone, Debug, Error)] +pub enum IdError { + #[error("Id error: {0}")] + InvalidLength(usize), + #[error("Id error: {0}")] + Decode(#[from] base64::DecodeSliceError), +} + +pub fn b64_to_id(b64_id: &str) -> Result<[u8; 16], IdError> { + let mut buffer: [u8; 16] = [0; 16]; + let len = BASE64_URL_SAFE.decode_slice(b64_id, &mut buffer)?; + if len != 16 { + return Err(IdError::InvalidLength(len)); + } + return Ok(buffer); +} + +pub fn id_to_b64(id: &[u8; 16]) -> String { + return BASE64_URL_SAFE.encode(id); +} + +pub fn uuid_to_b64(uuid: &Uuid) -> String { + return BASE64_URL_SAFE.encode(uuid.into_bytes()); +} + +pub fn b64_to_uuid(b64_id: &str) -> Result { + return Ok(Uuid::from_bytes(b64_to_id(b64_id)?)); +} + +pub fn urlencode(s: &str) -> String { + return form_urlencoded::byte_serialize(s.as_bytes()).collect(); +} + +#[cfg(debug_assertions)] +#[inline(always)] +pub(crate) fn assert_uuidv7(id: &[u8; 16]) { + assert_uuidv7_version(&Uuid::from_bytes(*id)); +} + +#[cfg(not(debug_assertions))] +#[inline(always)] +pub(crate) fn assert_uuidv7(_id: &[u8; 16]) {} + +#[cfg(debug_assertions)] +pub(crate) fn assert_uuidv7_version(uuid: &Uuid) { + let version = uuid.get_version_num(); + if version != 7 { + panic!("Expected UUIDv7, got UUIDv{version} from: {uuid}"); + } +} + +#[cfg(not(debug_assertions))] +pub(crate) fn assert_uuidv7_version(_uuid: &Uuid) {} diff --git a/trailbase-core/src/value_notifier.rs b/trailbase-core/src/value_notifier.rs new file mode 100644 index 0000000..cf73648 --- /dev/null +++ b/trailbase-core/src/value_notifier.rs @@ -0,0 +1,119 @@ +use arc_swap::{ArcSwap, AsRaw, Guard}; +use parking_lot::Mutex; +use std::sync::Arc; + +type Listener = Box; + +pub struct ValueNotifier { + value: ArcSwap, + listeners: Mutex>>, +} +// +// unsafe impl Send for ValueNotifier where T: Send {} + +impl ValueNotifier { + pub fn new(v: T) -> Self { + ValueNotifier { + value: ArcSwap::from_pointee(v), + listeners: Mutex::new(Vec::new()), + } + } + + pub fn load(&self) -> Guard> { + return self.value.load(); + } + + pub fn load_full(&self) -> Arc { + return self.value.load_full(); + } + + // Returns true in case of successful swap. + pub fn compare_and_swap(&self, current: C, new: Arc) -> bool + where + C: AsRaw, + { + // compare_and_swap returns the previous value no matter if the swap happened or not, + // i.e. if the returned value is equal to old_config a.k.a. `current`, the swap happened. + // let old: Arc = self.value.load_full(); + let current_ptr = current.as_raw(); + let prev = self.value.compare_and_swap(current, new.clone()); + + if current_ptr != prev.as_raw() { + return false; + } + + for callback in self.listeners.lock().iter() { + callback(&*new); + } + return true; + } + + pub fn listen(&self, callback: F) + where + F: 'static + Sync + Send + Fn(&T), + { + self.listeners.lock().push(Box::new(callback)); + } + + pub fn store(&self, v: T) { + let ptr = Arc::new(v); + self.value.store(ptr.clone()); + + for callback in self.listeners.lock().iter() { + callback(&*ptr); + } + } +} + +struct ComputedState { + value: ArcSwap, + f: Box T>, +} + +pub struct Computed { + state: Arc>, +} + +impl Computed { + pub fn new(notifier: &ValueNotifier, f: impl 'static + Sync + Send + Fn(&V) -> T) -> Self { + let state = Arc::new(ComputedState { + value: ArcSwap::::from_pointee(f(¬ifier.load())), + f: Box::new(f), + }); + + let state_ptr = state.clone(); + notifier.listen(move |v| { + state_ptr.value.store(Arc::new((*state_ptr.f)(v))); + }); + + return Computed { state }; + } + + pub fn load(&self) -> Guard> { + return self.state.value.load(); + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_value_notifier() { + let v = ValueNotifier::new(42); + assert_eq!(**v.load(), 42); + v.store(23); + assert_eq!(**v.load(), 23); + } + + #[test] + fn test_computed() { + let v = ValueNotifier::new(42); + + let c = Computed::new(&v, |v| v * 2); + assert_eq!(**c.load(), 2 * 42); + + v.store(23); + assert_eq!(**c.load(), 2 * 23); + } +} diff --git a/trailbase-core/tests/integration_test.rs b/trailbase-core/tests/integration_test.rs new file mode 100644 index 0000000..ebb8c57 --- /dev/null +++ b/trailbase-core/tests/integration_test.rs @@ -0,0 +1,268 @@ +use axum::extract::{Json, State}; +use axum::http::StatusCode; +use axum_test::multipart::MultipartForm; +use axum_test::TestServer; +use cookie::Cookie; +use libsql::{params, Connection}; + +use trailbase_core::api::{ + create_user_handler, login_with_password, query_one_row, CreateUserRequest, +}; +use trailbase_core::config::proto::PermissionFlag; +use trailbase_core::constants::{COOKIE_AUTH_TOKEN, RECORD_API_PATH}; +use trailbase_core::records::*; +use trailbase_core::util::id_to_b64; +use trailbase_core::AppState; +use trailbase_core::{DataDir, Server, ServerOptions}; + +#[tokio::test] +async fn test_admin_permissions() { + let data_dir = temp_dir::TempDir::new().unwrap(); + + let app = Server::init(ServerOptions { + data_dir: DataDir(data_dir.path().to_path_buf()), + ..Default::default() + }) + .await + .unwrap(); + + let server = TestServer::new(app.router().clone()).unwrap(); + + assert_eq!( + server.get("/api/healthcheck").await.status_code(), + StatusCode::OK + ); + + assert_eq!( + server.get("/api/_admin/tables").await.status_code(), + StatusCode::UNAUTHORIZED + ); +} + +#[tokio::test] +async fn test_record_apis() -> Result<(), anyhow::Error> { + let data_dir = temp_dir::TempDir::new().unwrap(); + + let app = Server::init(ServerOptions { + data_dir: DataDir(data_dir.path().to_path_buf()), + ..Default::default() + }) + .await + .unwrap(); + + let state = app.state(); + let conn = state.conn(); + + create_chat_message_app_tables(conn).await?; + state.refresh_table_cache().await?; + + let room = add_room(conn, "room0").await?; + let password = "Secret!1!!"; + + // Register message table as record API with moderator read access. + add_record_api( + &state, + "messages_api", + "message", + Acls { + authenticated: vec![PermissionFlag::Create, PermissionFlag::Read], + ..Default::default() + }, + AccessRules { + create: Some( + "(SELECT 1 FROM room_members AS m WHERE _USER_.id = _REQ_._owner AND m.user = _USER_.id AND m.room = _REQ_.room)".to_string(), + ), + ..Default::default() + }, + ) + .await?; + + let user_x_email = "user_x@test.com"; + let user_x = create_user_for_test(&state, user_x_email, password) + .await? + .into_bytes(); + + let user_x_token = login_with_password(&state, user_x_email, password).await?; + + add_user_to_room(conn, user_x, room).await?; + + let server = TestServer::new(app.router().clone()).unwrap(); + + { + // User X can post to a JSON message. + let test_response = server + .post(&format!("/{RECORD_API_PATH}/messages_api")) + .add_cookie(Cookie::new( + COOKIE_AUTH_TOKEN, + user_x_token.auth_token.clone(), + )) + .json(&serde_json::json!({ + "_owner": id_to_b64(&user_x), + "room": id_to_b64(&room), + "data": "user_x message to room", + })) + .await; + + assert_eq!( + test_response.status_code(), + StatusCode::OK, + "{:?}", + test_response + ); + } + + { + // User X can post a form message. + let test_response = server + .post(&format!("/{RECORD_API_PATH}/messages_api")) + .add_cookie(Cookie::new( + COOKIE_AUTH_TOKEN, + user_x_token.auth_token.clone(), + )) + .form(&serde_json::json!({ + "_owner": id_to_b64(&user_x), + "room": id_to_b64(&room), + "data": "user_x message to room", + })) + .await; + + assert_eq!(test_response.status_code(), StatusCode::OK); + } + + { + // User X can post a multipart message. + let form = MultipartForm::new() + .add_text("_owner", id_to_b64(&user_x)) + .add_text("room", id_to_b64(&room)) + .add_text("data", "user_x message to room"); + + let test_response = server + .post(&format!("/{RECORD_API_PATH}/messages_api")) + .add_cookie(Cookie::new( + COOKIE_AUTH_TOKEN, + user_x_token.auth_token.clone(), + )) + .multipart(form) + .await; + + assert_eq!(test_response.status_code(), StatusCode::OK); + } + + { + // Add a second record API for the same table + add_record_api( + &state, + "messages_api_yolo", + "message", + Acls { + world: vec![PermissionFlag::Create, PermissionFlag::Read], + ..Default::default() + }, + AccessRules::default(), + ) + .await?; + + // Anonymous can post to a JSON message (i.e. no credentials/tokens are attached). + let test_response = server + .post(&format!("/{RECORD_API_PATH}/messages_api_yolo")) + .json(&serde_json::json!({ + // NOTE: Id must be not null and a random id would violate foreign key constraint as + // defined by the `message` table. + // "_owner": id_to_b64(&Uuid::now_v7().into_bytes()), + "_owner": id_to_b64(&user_x), + "room": id_to_b64(&room), + "data": "anonymous' message to room", + })) + .await; + + assert_eq!( + test_response.status_code(), + StatusCode::OK, + "{test_response:?}" + ); + } + + return Ok(()); +} + +pub async fn create_chat_message_app_tables(conn: &Connection) -> Result<(), libsql::Error> { + // Create a messages, chat room and members tables. + conn + .execute_batch( + r#" + CREATE TABLE room ( + id BLOB PRIMARY KEY NOT NULL CHECK(is_uuid_v7(id)) DEFAULT(uuid_v7()), + name TEXT + ) STRICT; + + CREATE TABLE message ( + id BLOB PRIMARY KEY NOT NULL CHECK(is_uuid_v7(id)) DEFAULT (uuid_v7()), + _owner BLOB NOT NULL, + room BLOB NOT NULL, + data TEXT NOT NULL DEFAULT 'empty', + + -- on user delete, toombstone it. + FOREIGN KEY(_owner) REFERENCES _user(id) ON DELETE SET NULL, + -- On chatroom delete, delete message + FOREIGN KEY(room) REFERENCES room(id) ON DELETE CASCADE + ) STRICT; + + CREATE TABLE room_members ( + user BLOB NOT NULL, + room BLOB NOT NULL, + + FOREIGN KEY(room) REFERENCES room(id) ON DELETE CASCADE, + FOREIGN KEY(user) REFERENCES _user(id) ON DELETE CASCADE + ) STRICT; + "#, + ) + .await?; + + return Ok(()); +} + +pub async fn add_room(conn: &Connection, name: &str) -> Result<[u8; 16], libsql::Error> { + let room: [u8; 16] = query_one_row( + conn, + "INSERT INTO room (name) VALUES ($1) RETURNING id", + params!(name), + ) + .await? + .get(0)?; + + return Ok(room); +} + +pub async fn add_user_to_room( + conn: &Connection, + user: [u8; 16], + room: [u8; 16], +) -> Result<(), libsql::Error> { + conn + .execute( + "INSERT INTO room_members (user, room) VALUES ($1, $2)", + params!(user, room), + ) + .await?; + return Ok(()); +} + +pub(crate) async fn create_user_for_test( + state: &AppState, + email: &str, + password: &str, +) -> Result { + return Ok( + create_user_handler( + State(state.clone()), + Json(CreateUserRequest { + email: email.to_string(), + password: password.to_string(), + verified: true, + admin: false, + }), + ) + .await? + .id, + ); +} diff --git a/trailbase-extension/Cargo.toml b/trailbase-extension/Cargo.toml new file mode 100644 index 0000000..7192b21 --- /dev/null +++ b/trailbase-extension/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "trailbase-extension" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type=["cdylib", "rlib"] + +[dependencies] +argon2 = "0.5.3" +base64 = "0.22.1" +jsonschema = { version = "0.26.0", default-features = false } +lru = { version = "0.12.3", default-features = false } +parking_lot = { version = "0.12.3", default-features = false } +rand = "0.8.5" +regex = "1.11.0" +serde_json = "1.0.121" +sqlean = { path = "../vendor/sqlean" } +sqlite-loadable = { workspace = true } +uuid = { version = "1.7.0", default-features = false, features = ["std", "v7"] } +validator = "0.18.1" + +[dev-dependencies] +libsql = { workspace = true } +tokio = { version = "^1.38.0", features = ["macros", "rt-multi-thread"] } +uuid = { version = "1.7.0", default-features = false, features = ["std", "v4", "v7"] } + +[profile.release] +strip = "debuginfo" +opt-level = "s" +panic = "unwind" +lto = true diff --git a/trailbase-extension/src/jsonschema.rs b/trailbase-extension/src/jsonschema.rs new file mode 100644 index 0000000..888d8df --- /dev/null +++ b/trailbase-extension/src/jsonschema.rs @@ -0,0 +1,356 @@ +use jsonschema::Validator; +use lru::LruCache; +use parking_lot::Mutex; +use sqlite_loadable::prelude::*; +use sqlite_loadable::{api, Error as SqliteError}; +use std::collections::HashMap; +use std::ffi; +use std::num::NonZeroUsize; +use std::sync::Arc; +use std::sync::LazyLock; + +pub type ValidationError = jsonschema::ValidationError<'static>; + +fn validation_error_into_owned(err: jsonschema::ValidationError<'_>) -> ValidationError { + ValidationError { + instance_path: err.instance_path.clone(), + instance: std::borrow::Cow::Owned(err.instance.into_owned()), + kind: err.kind, + schema_path: err.schema_path, + } +} + +type CustomValidatorFn = Arc) -> bool + Send + Sync>; + +#[derive(Clone)] +pub struct SchemaEntry { + schema: serde_json::Value, + validator: Arc, + custom_validator: Option, +} + +impl SchemaEntry { + pub fn from( + schema: serde_json::Value, + custom_validator: Option, + ) -> Result { + let validator = Validator::new(&schema).map_err(|err| validation_error_into_owned(err))?; + + return Ok(Self { + schema, + validator: validator.into(), + custom_validator, + }); + } +} + +static SCHEMA_REGISTRY: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::::new())); + +#[allow(unused)] +fn cstr_to_string(ptr: *const ffi::c_char) -> String { + assert!(!ptr.is_null()); + let cstr = unsafe { ffi::CStr::from_ptr(ptr) }; + String::from_utf8_lossy(cstr.to_bytes()).to_string() +} + +pub fn set_schemas(schema_entries: Option>) { + let mut lock = SCHEMA_REGISTRY.lock(); + lock.clear(); + + if let Some(entries) = schema_entries { + for (name, entry) in entries { + lock.insert(name, entry); + } + } +} + +pub fn set_schema(name: &str, entry: Option) { + if let Some(entry) = entry { + SCHEMA_REGISTRY.lock().insert(name.to_string(), entry); + } else { + SCHEMA_REGISTRY.lock().remove(name); + } +} + +pub fn get_schema(name: &str) -> Option { + SCHEMA_REGISTRY.lock().get(name).map(|s| s.schema.clone()) +} + +pub fn get_compiled_schema(name: &str) -> Option> { + SCHEMA_REGISTRY + .lock() + .get(name) + .map(|s| s.validator.clone()) +} + +pub fn get_schemas() -> Vec<(String, serde_json::Value)> { + SCHEMA_REGISTRY + .lock() + .iter() + .map(|(name, schema)| (name.clone(), schema.schema.clone())) + .collect() +} + +fn get_text_or_null( + values: &[*mut sqlite3_value], + index: usize, +) -> Result, SqliteError> { + let value = values + .get(index) + .ok_or_else(|| SqliteError::new_message("Missing argument"))?; + + if api::value_is_null(value) { + return Ok(None); + } + + return Ok(Some(api::value_text(value)?)); +} + +fn get_text(values: &[*mut sqlite3_value], index: usize) -> Result<&str, SqliteError> { + let value = values + .get(index) + .ok_or_else(|| SqliteError::new_message("Missing argument"))?; + assert!(!api::value_is_null(value), "Got null value"); + return Ok(api::value_text(value)?); +} + +pub(crate) fn jsonschema_by_name( + context: *mut sqlite3_context, + values: &[*mut sqlite3_value], +) -> Result<(), SqliteError> { + let schema_name = get_text(values, 0)?; + + // Get and parse the JSON contents. If it's invalid JSON to start with, there's not much + // we can validate. + let Some(contents) = get_text_or_null(values, 1)? else { + return Ok(()); + }; + let json = serde_json::from_str(contents).map_err(|err| { + SqliteError::new_message(format!("Invalid JSON: {contents} => {err}").as_str()) + })?; + + // Then get/build the schema validator for the given pattern. + let Some(entry) = SCHEMA_REGISTRY.lock().get(schema_name).cloned() else { + return Err(SqliteError::new_message(format!( + "Schema {schema_name} not found" + ))); + }; + + if !entry.validator.is_valid(&json) { + api::result_bool(context, false); + return Ok(()); + } + + if let Some(validator) = entry.custom_validator { + if !validator(&json, None) { + api::result_bool(context, false); + return Ok(()); + } + } + + api::result_bool(context, true); + + return Ok(()); +} + +pub(crate) fn jsonschema_by_name_with_extra_args( + context: *mut sqlite3_context, + values: &[*mut sqlite3_value], +) -> Result<(), SqliteError> { + let schema_name = get_text(values, 0)?; + let extra_args = get_text(values, 2)?; + + // Get and parse the JSON contents. If it's invalid JSON to start with, there's not much + // we can validate. + let Some(contents) = get_text_or_null(values, 1)? else { + return Ok(()); + }; + let json = serde_json::from_str(contents).map_err(|err| { + SqliteError::new_message(format!("Invalid JSON: {contents} => {err}").as_str()) + })?; + + // Then get/build the schema validator for the given pattern. + let Some(entry) = SCHEMA_REGISTRY.lock().get(schema_name).cloned() else { + return Err(SqliteError::new_message(format!( + "Schema {schema_name} not found" + ))); + }; + + if !entry.validator.is_valid(&json) { + api::result_bool(context, false); + return Ok(()); + } + + if let Some(validator) = entry.custom_validator { + if !validator(&json, Some(extra_args)) { + api::result_bool(context, false); + return Ok(()); + } + } + + api::result_bool(context, true); + + return Ok(()); +} + +pub(crate) fn jsonschema_matches( + context: *mut sqlite3_context, + values: &[*mut sqlite3_value], +) -> Result<(), SqliteError> { + type CacheType = LazyLock>>>; + static SCHEMA_CACHE: CacheType = + LazyLock::new(|| Mutex::new(LruCache::new(NonZeroUsize::new(128).unwrap()))); + + // First, get and parse the JSON contents. If it's invalid JSON to start with, there's not much + // we can validate. + let Some(contents) = get_text_or_null(values, 1)? else { + return Ok(()); + }; + let json = serde_json::from_str(contents).map_err(|err| { + SqliteError::new_message(format!("Invalid JSON: '{contents}' => {err}").as_str()) + })?; + + let pattern = get_text(values, 0)?; + + // Then get/build the schema validator for the given pattern. + let validator: Option> = SCHEMA_CACHE.lock().get(pattern).cloned(); + let valid = match validator { + Some(validator) => validator.is_valid(&json), + None => { + let schema = serde_json::from_str(pattern) + .map_err(|err| SqliteError::new_message(format!("Invalid JSON Schema: {err}")))?; + let validator = Validator::new(&schema) + .map_err(|err| SqliteError::new_message(format!("Failed to compile Schema: {err}")))?; + + let valid = validator.is_valid(&json); + SCHEMA_CACHE + .lock() + .put(pattern.to_string(), Arc::new(validator)); + valid + } + }; + + api::result_bool(context, valid); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use libsql::params; + + #[tokio::test] + async fn test_explicit_jsonschema() { + let conn = crate::connect().await.unwrap(); + + let text0_schema = r#" + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer", "minimum": 0 } + }, + "required": ["name"] + } + "#; + + let text1_schema = r#"{ "type": "string" }"#; + + let create_table = format!( + r#" + CREATE TABLE test ( + text0 TEXT NOT NULL CHECK(jsonschema_matches('{text0_schema}', text0)), + text1 TEXT NOT NULL CHECK(jsonschema_matches('{text1_schema}', text1)) + ) STRICT; + "# + ); + conn.query(&create_table, ()).await.unwrap(); + + { + conn + .execute( + r#"INSERT INTO test (text0, text1) VALUES ('{"name": "foo"}', '"text"')"#, + params!(), + ) + .await + .unwrap(); + } + + { + assert!(conn + .execute( + r#"INSERT INTO test (text0, text1) VALUES ('{"name": "foo", "age": -5}', '"text"')"#, + params!(), + ) + .await + .is_err()); + } + } + + #[tokio::test] + async fn test_registerd_jsonschema() { + let conn = crate::connect().await.unwrap(); + + let text0_schema = r#" + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer", "minimum": 0 } + }, + "required": ["name"] + } + "#; + + fn starts_with(v: &serde_json::Value, param: Option<&str>) -> bool { + if let Some(param) = param { + if let serde_json::Value::Object(map) = v { + if let Some(serde_json::Value::String(str)) = map.get("name") { + if str.starts_with(param) { + return true; + } + } + } + } + return false; + } + + set_schema( + "name0", + Some( + SchemaEntry::from( + serde_json::from_str(text0_schema).unwrap(), + Some(Arc::new(starts_with)), + ) + .unwrap(), + ), + ); + + let create_table = format!( + r#" + CREATE TABLE test ( + text0 TEXT NOT NULL CHECK(jsonschema('name0', text0, 'prefix')) + ) STRICT; + "# + ); + conn.query(&create_table, ()).await.unwrap(); + + conn + .execute( + r#"INSERT INTO test (text0) VALUES ('{"name": "prefix_foo"}')"#, + params!(), + ) + .await + .unwrap(); + + assert!(conn + .execute( + r#"INSERT INTO test (text0) VALUES ('{"name": "WRONG_PREFIX_foo"}')"#, + params!(), + ) + .await + .is_err()); + } +} diff --git a/trailbase-extension/src/lib.rs b/trailbase-extension/src/lib.rs new file mode 100644 index 0000000..ca942f4 --- /dev/null +++ b/trailbase-extension/src/lib.rs @@ -0,0 +1,187 @@ +#![allow(clippy::needless_return)] + +use sqlite_loadable::prelude::*; +use sqlite_loadable::{define_scalar_function, define_scalar_void_function}; +use uuid::*; + +pub mod jsonschema; +pub mod password; + +mod uuid; +mod validators; + +pub use sqlite_loadable::ext::sqlite3; +pub use sqlite_loadable::ext::sqlite3_api_routines; + +#[sqlite_entrypoint] +pub fn sqlite3_extension_init(db: *mut sqlite3) -> Result<(), sqlite_loadable::Error> { + // WARN: Be careful with declaring INNOCUOUS. This allows these "app-defined functions" to run + // even when "trusted_schema=OFF", which means as part of: VIEWs, TRIGGERs, CHECK, DEFAULT, + // GENERATED cols, ... as opposed to just top-level SELECTs. + + // UUID + define_scalar_void_function( + db, + "is_uuid", + 1, + is_uuid, + FunctionFlags::DETERMINISTIC | FunctionFlags::INNOCUOUS, + )?; + define_scalar_void_function( + db, + "is_uuid_v7", + 1, + is_uuid_v7, + FunctionFlags::DETERMINISTIC | FunctionFlags::INNOCUOUS, + )?; + define_scalar_function( + db, + "uuid_url_safe_b64", + 1, + uuid_url_safe_b64, + FunctionFlags::DETERMINISTIC | FunctionFlags::UTF8 | FunctionFlags::INNOCUOUS, + )?; + define_scalar_function( + db, + "uuid_v7_text", + 0, + uuid_v7_text, + FunctionFlags::UTF8 | FunctionFlags::INNOCUOUS, + )?; + define_scalar_void_function(db, "uuid_v7", 0, uuid_v7, FunctionFlags::INNOCUOUS)?; + define_scalar_function( + db, + "parse_uuid", + 1, + parse_uuid, + FunctionFlags::UTF8 | FunctionFlags::DETERMINISTIC | FunctionFlags::INNOCUOUS, + )?; + + define_scalar_function( + db, + "hash_password", + 1, + password::hash_password_sqlite, + FunctionFlags::UTF8 | FunctionFlags::INNOCUOUS, + )?; + + // Match column against given JSON schema, e.g. jsonschema_matches(col, ''). + define_scalar_function( + db, + "jsonschema_matches", + 2, + jsonschema::jsonschema_matches, + FunctionFlags::UTF8 | FunctionFlags::DETERMINISTIC | FunctionFlags::INNOCUOUS, + )?; + // Match column against registered JSON schema by name, e.g. jsonschema(col, 'schema-name'). + define_scalar_function( + db, + "jsonschema", + 2, + jsonschema::jsonschema_by_name, + FunctionFlags::UTF8 | FunctionFlags::DETERMINISTIC | FunctionFlags::INNOCUOUS, + )?; + define_scalar_function( + db, + "jsonschema", + 3, + jsonschema::jsonschema_by_name_with_extra_args, + FunctionFlags::UTF8 | FunctionFlags::DETERMINISTIC | FunctionFlags::INNOCUOUS, + )?; + + // Validators for CHECK constraints. + define_scalar_function( + db, + // NOTE: the name needs to be "regexp" to be picked up by sqlites REGEXP matcher: + // https://www.sqlite.org/lang_expr.html + "regexp", + 2, + validators::regexp, + FunctionFlags::UTF8 | FunctionFlags::DETERMINISTIC | FunctionFlags::INNOCUOUS, + )?; + define_scalar_function( + db, + "is_email", + 1, + validators::is_email, + FunctionFlags::UTF8 | FunctionFlags::DETERMINISTIC | FunctionFlags::INNOCUOUS, + )?; + // NOTE: there's also https://sqlite.org/json1.html#jvalid + define_scalar_function( + db, + "is_json", + 1, + validators::is_json, + FunctionFlags::UTF8 | FunctionFlags::DETERMINISTIC | FunctionFlags::INNOCUOUS, + )?; + + // Lastly init sqlean's "define" for application-defined functions defined in pure SQL. + // See: https://github.com/nalgeon/sqlean/blob/main/docs/define.md + let status = unsafe { sqlean::define_init(db as *mut sqlean::sqlite3) }; + if status != 0 { + return Err(sqlite_loadable::Error::new_message( + "Failed to load sqlean::define", + )); + } + + Ok(()) +} + +#[cfg(test)] +unsafe extern "C" fn init_extension( + db: *mut libsql::ffi::sqlite3, + pz_err_msg: *mut *const ::std::os::raw::c_char, + p_thunk: *const libsql::ffi::sqlite3_api_routines, +) -> ::std::os::raw::c_int { + return sqlite3_extension_init( + db, + pz_err_msg as *mut *mut ::std::os::raw::c_char, + p_thunk as *mut libsql::ffi::sqlite3_api_routines, + ) as ::std::os::raw::c_int; +} + +#[cfg(test)] +pub(crate) async fn connect() -> Result { + let builder = libsql::Builder::new_local(":memory:") + .build() + .await + .unwrap(); + + unsafe { libsql::ffi::sqlite3_auto_extension(Some(init_extension)) }; + + Ok(builder.connect().unwrap()) +} + +#[cfg(test)] +pub(crate) async fn query_row( + conn: &libsql::Connection, + sql: &str, + params: impl libsql::params::IntoParams, +) -> Result, libsql::Error> { + let mut rows = conn.query(sql, params).await?; + return rows.next().await; +} + +#[cfg(test)] +mod tests { + #[tokio::test] + async fn test_sqlean_define() { + let conn = crate::connect().await.unwrap(); + + // Define an application defined function in SQL and test it below. + conn + .query("SELECT define('sumn', ':n * (:n + 1) / 2')", ()) + .await + .unwrap(); + + let value: i64 = crate::query_row(&conn, "SELECT sumn(5)", ()) + .await + .unwrap() + .unwrap() + .get(0) + .unwrap(); + assert_eq!(value, 15); + + conn.query("SELECT undefine('sumn')", ()).await.unwrap(); + } +} diff --git a/trailbase-extension/src/password.rs b/trailbase-extension/src/password.rs new file mode 100644 index 0000000..ad2b3dc --- /dev/null +++ b/trailbase-extension/src/password.rs @@ -0,0 +1,35 @@ +use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; +use rand::rngs::OsRng; +use sqlite_loadable::prelude::*; +use sqlite_loadable::{api, Error, ErrorKind}; + +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?; + return Ok(hash.to_string()); +} + +pub(super) fn hash_password_sqlite( + context: *mut sqlite3_context, + values: &[*mut sqlite3_value], +) -> Result<(), Error> { + if values.len() != 1 { + return Err(Error::new_message("Expected 1 argument")); + } + + let value = &values[0]; + match api::value_type(value) { + api::ValueType::Text => { + let contents = api::value_text(value)?; + let hash = hash_password(contents) + .map_err(|err| Error::new(ErrorKind::Message(format!("Argon2: {err}"))))?; + + api::result_text(context, hash)?; + } + _ => { + return Err(Error::new_message("Expected 1 argument of type TEXT")); + } + }; + + return Ok(()); +} diff --git a/trailbase-extension/src/uuid.rs b/trailbase-extension/src/uuid.rs new file mode 100644 index 0000000..1e6bd76 --- /dev/null +++ b/trailbase-extension/src/uuid.rs @@ -0,0 +1,154 @@ +use base64::prelude::*; +use sqlite_loadable::prelude::*; +use sqlite_loadable::{api, Error, ErrorKind, Result}; +use uuid::Uuid; + +pub(super) fn is_uuid(context: *mut sqlite3_context, values: &[*mut sqlite3_value]) { + match unpack_uuid_or_null(values) { + Ok(Some(uuid)) => api::result_bool(context, uuid.get_version_num() == 7), + Ok(None) => api::result_bool(context, true), + _ => api::result_bool(context, false), + }; +} + +pub(super) fn is_uuid_v7(context: *mut sqlite3_context, values: &[*mut sqlite3_value]) { + match unpack_uuid_or_null(values) { + Ok(Some(uuid)) => api::result_bool(context, uuid.get_version_num() == 7), + Ok(None) => api::result_bool(context, true), + _ => api::result_bool(context, false), + }; +} + +pub(super) fn uuid_url_safe_b64( + context: *mut sqlite3_context, + values: &[*mut sqlite3_value], +) -> Result<()> { + if let Some(uuid) = unpack_uuid_or_null(values)? { + let _ = api::result_text(context, BASE64_URL_SAFE.encode(uuid.as_bytes())); + } + + return Ok(()); +} + +#[inline(always)] +fn unpack_uuid_or_null(values: &[*mut sqlite3_value]) -> Result> { + if values.len() != 1 { + return Err(Error::new_message("Wrong number of arguments")); + } + + let value = &values[0]; + return match api::value_type(value) { + api::ValueType::Null => Ok(None), + api::ValueType::Blob => match Uuid::from_slice(api::value_blob(value)) { + Ok(uuid) => Ok(Some(uuid)), + Err(err) => Err(Error::new(ErrorKind::Message(format!( + "Failed to read uuid: {err}" + )))), + }, + _ => Err(Error::new_message("Expected BLOB column type.")), + }; +} + +pub(super) fn uuid_v7_text( + context: *mut sqlite3_context, + _values: &[*mut sqlite3_value], +) -> Result<()> { + api::result_text(context, Uuid::now_v7().to_string()) +} + +pub(super) fn uuid_v7(context: *mut sqlite3_context, _values: &[*mut sqlite3_value]) { + api::result_blob(context, Uuid::now_v7().as_bytes()); +} + +pub(super) fn parse_uuid( + context: *mut sqlite3_context, + values: &[*mut sqlite3_value], +) -> Result<()> { + if values.len() != 1 { + return Err(Error::new_message("Wrong number of arguments")); + } + let value: &str = api::value_text(&values[0])?; + let id = Uuid::parse_str(value) + .map_err(|err| Error::new(ErrorKind::Message(format!("UUID parse: {err}"))))?; + + api::result_blob(context, id.as_bytes()); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use libsql::{params, Connection}; + use uuid::Uuid; + + async fn query_row( + conn: &Connection, + sql: &str, + params: impl libsql::params::IntoParams, + ) -> Result { + conn.prepare(sql).await?.query_row(params).await + } + + #[tokio::test] + async fn test_uuid() { + let conn = crate::connect().await.unwrap(); + + let create_table = r#" + CREATE TABLE test ( + id BLOB PRIMARY KEY NOT NULL DEFAULT (uuid_v7()), + uuid BLOB CHECK(is_uuid(uuid)), + uuid_v7 BLOB CHECK(is_uuid_v7(uuid_v7)) + ) STRICT; + "#; + conn.query(create_table, ()).await.unwrap(); + + { + let row = query_row( + &conn, + "INSERT INTO test (uuid, uuid_v7) VALUES (NULL, NULL) RETURNING id", + (), + ) + .await + .unwrap(); + + Uuid::from_slice(&row.get::<[u8; 16]>(0).unwrap()).unwrap(); + } + + { + assert!(conn + .execute( + "INSERT INTO test (uuid, uuid_v7) VALUES ($1, NULL)", + params!(b"") + ) + .await + .is_err()); + } + + { + let uuidv4 = Uuid::new_v4(); + assert!(conn + .execute( + "INSERT INTO test (uuid, uuid_v7) VALUES (NULL, $1)", + params!(uuidv4.into_bytes().to_vec()) + ) + .await + .is_err()); + } + + { + let uuid = Uuid::now_v7(); + let row = query_row( + &conn, + "INSERT INTO test (uuid, uuid_v7) VALUES (parse_uuid($1), parse_uuid($1)) RETURNING uuid", + [uuid.to_string()], + ) + .await + .unwrap(); + + assert_eq!( + Uuid::from_slice(&row.get::<[u8; 16]>(0).unwrap()).unwrap(), + uuid + ); + } + } +} diff --git a/trailbase-extension/src/validators.rs b/trailbase-extension/src/validators.rs new file mode 100644 index 0000000..d4eba7a --- /dev/null +++ b/trailbase-extension/src/validators.rs @@ -0,0 +1,208 @@ +use lru::LruCache; +use parking_lot::Mutex; +use regex::Regex; +use sqlite_loadable::prelude::*; +use sqlite_loadable::{api, Error, ErrorKind}; +use std::num::NonZeroUsize; +use std::sync::LazyLock; +use validator::ValidateEmail; + +/// Custom regexp function. +/// +/// NOTE: Sqlite supports `col REGEXP pattern` in expression, which requires a custom +/// `regexp(pattern, col)` scalar function to be registered. +pub(super) fn regexp( + context: *mut sqlite3_context, + values: &[*mut sqlite3_value], +) -> Result<(), Error> { + type CacheType = LazyLock>>; + static REGEX_CACHE: CacheType = + LazyLock::new(|| Mutex::new(LruCache::new(NonZeroUsize::new(128).unwrap()))); + + if values.len() != 2 { + return Err(Error::new_message("Expected 2 arguments")); + } + + let string = &values[1]; + let valid = match api::value_type(string) { + api::ValueType::Null => true, + api::ValueType::Text => { + let contents = api::value_text(string)?; + let re = api::value_text(&values[0])?; + + let pattern: Option = REGEX_CACHE.lock().get(re).cloned(); + match pattern { + Some(pattern) => pattern.is_match(contents), + None => { + let pattern = Regex::new(re) + .map_err(|err| Error::new(ErrorKind::Message(format!("Regex: {err}"))))?; + + let valid = pattern.is_match(contents); + REGEX_CACHE.lock().push(re.to_string(), pattern); + valid + } + } + } + _ => false, + }; + + api::result_bool(context, valid); + + Ok(()) +} + +pub(super) fn is_email( + context: *mut sqlite3_context, + values: &[*mut sqlite3_value], +) -> Result<(), Error> { + if values.len() != 1 { + return Err(Error::new_message("Expected 1 argument")); + } + + let value = &values[0]; + let valid = match api::value_type(value) { + api::ValueType::Null => true, + api::ValueType::Text => { + let contents = api::value_text(value)?; + contents.validate_email() + } + _ => false, + }; + + api::result_bool(context, valid); + + Ok(()) +} + +pub(super) fn is_json( + context: *mut sqlite3_context, + values: &[*mut sqlite3_value], +) -> Result<(), Error> { + if values.len() != 1 { + return Err(Error::new_message("Expected 1 argument")); + } + + let value = &values[0]; + let valid = match api::value_type(value) { + api::ValueType::Null => true, + api::ValueType::Text => { + let contents = api::value_text(value)?; + serde_json::from_str::(contents) + .map_err(|err| Error::new(ErrorKind::Message(format!("JSON: {err}"))))?; + true + } + _ => false, + }; + + api::result_bool(context, valid); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use libsql::{params, Connection}; + + async fn query_row( + conn: &Connection, + sql: &str, + params: impl libsql::params::IntoParams, + ) -> Result { + conn.prepare(sql).await?.query_row(params).await + } + + #[tokio::test] + async fn test_is_email() { + let conn = crate::connect().await.unwrap(); + let create_table = r#" + CREATE TABLE test ( + email TEXT CHECK(is_email(email)) + ) STRICT; + "#; + conn.query(create_table, ()).await.unwrap(); + + const QUERY: &str = "INSERT INTO test (email) VALUES ($1) RETURNING *"; + assert_eq!( + query_row(&conn, QUERY, ["test@test.com"]) + .await + .unwrap() + .get::(0) + .unwrap(), + "test@test.com" + ); + + query_row(&conn, QUERY, [libsql::Value::Null]) + .await + .unwrap(); + + assert!(conn.execute(QUERY, params!("not an email")).await.is_err()); + } + + #[tokio::test] + async fn test_is_json() { + let conn = crate::connect().await.unwrap(); + let create_table = r#" + CREATE TABLE test ( + json TEXT CHECK(is_json(json)) + ) STRICT; + "#; + conn.query(create_table, ()).await.unwrap(); + + const QUERY: &str = "INSERT INTO test (json) VALUES ($1)"; + conn.execute(QUERY, ["{}"]).await.unwrap(); + conn + .execute(QUERY, ["{\"foo\": 42, \"bar\": {}, \"baz\": []}"]) + .await + .unwrap(); + assert!(conn.execute(QUERY, [""]).await.is_err()); + } + + #[tokio::test] + async fn test_regexp() { + let conn = crate::connect().await.unwrap(); + let create_table = "CREATE TABLE test (text0 TEXT, text1 TEXT) STRICT"; + conn.query(create_table, ()).await.unwrap(); + + const QUERY: &str = "INSERT INTO test (text0, text1) VALUES ($1, $2)"; + conn.execute(QUERY, ["abc123", "abc"]).await.unwrap(); + conn.execute(QUERY, ["def123", "def"]).await.unwrap(); + + { + let mut rows = conn + .query("SELECT * FROM test WHERE text1 REGEXP '^abc$'", ()) + .await + .unwrap(); + let mut cnt = 0; + while let Some(row) = rows.next().await.unwrap() { + assert_eq!("abc123", row.get::(0).unwrap()); + cnt += 1; + } + assert_eq!(cnt, 1); + } + + { + let mut rows = conn + .query("SELECT * FROM test WHERE text1 REGEXP $1", params!(".*bc$")) + .await + .unwrap(); + let mut cnt = 0; + while let Some(row) = rows.next().await.unwrap() { + assert_eq!("abc123", row.get::(0).unwrap()); + cnt += 1; + } + assert_eq!(cnt, 1); + } + + { + let mut rows = conn + .query(r#"SELECT * FROM test WHERE text0 REGEXP '12\d'"#, ()) + .await + .unwrap(); + let mut cnt = 0; + while let Some(_row) = rows.next().await.unwrap() { + cnt += 1; + } + assert_eq!(cnt, 2); + } + } +} diff --git a/trailbase-sqlite/Cargo.toml b/trailbase-sqlite/Cargo.toml new file mode 100644 index 0000000..b714099 --- /dev/null +++ b/trailbase-sqlite/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "trailbase-sqlite" +version = "0.1.0" +edition = "2021" + +[dependencies] +trailbase-extension = { path = "../trailbase-extension" } +infer = "0.16.0" +jsonschema = { version = "0.26.0", default-features = false } +lazy_static = "1.5.0" +libsql = { workspace = true } +schemars = "0.8.21" +serde = { version = "^1.0.203", features = ["derive"] } +serde_json = "1.0.122" +thiserror = "1.0.61" +tokio = { version = "^1.38.0", features = ["macros", "rt-multi-thread", "fs"] } +uuid = { version = "^1.8.0", features = ["v4", "serde"] } +log = "0.4.22" + +[dev-dependencies] diff --git a/trailbase-sqlite/examples/uuid.rs b/trailbase-sqlite/examples/uuid.rs new file mode 100644 index 0000000..513119d --- /dev/null +++ b/trailbase-sqlite/examples/uuid.rs @@ -0,0 +1,27 @@ +use libsql::{params, Value::Text}; +use trailbase_sqlite::connect_sqlite; + +// NOTE: This binary demonstrates calling statically linked extensions, i.e. uuid_v7(). +// NOTE: It also shows that libsql and sqlite_loadable can both be linked into the same binary +// despite both pulling in sqlite3 symbols through libsql-ffi and sqlite3ext-sys, respectively. +// Wasn't able to reproduce this in a larger binary :shrug:. + +#[tokio::main] +async fn main() { + let conn = connect_sqlite(None, None).await.unwrap(); + + conn + .query("SELECT 1", params!(Text("FOO".to_string()))) + .await + .unwrap(); + + let uuid = conn + .prepare("SELECT (uuid_v7_text())") + .await + .unwrap() + .query_row(()) + .await + .unwrap(); + + println!("Done! {uuid:?}"); +} diff --git a/trailbase-sqlite/src/lib.rs b/trailbase-sqlite/src/lib.rs new file mode 100644 index 0000000..b86eff7 --- /dev/null +++ b/trailbase-sqlite/src/lib.rs @@ -0,0 +1,135 @@ +#![allow(clippy::needless_return)] + +pub mod schema; + +pub use schema::set_user_schemas; + +use std::path::PathBuf; + +#[no_mangle] +unsafe extern "C" fn init_extension( + db: *mut libsql::ffi::sqlite3, + pz_err_msg: *mut *const ::std::os::raw::c_char, + p_thunk: *const libsql::ffi::sqlite3_api_routines, +) -> ::std::os::raw::c_int { + return trailbase_extension::sqlite3_extension_init( + db, + pz_err_msg as *mut *mut ::std::os::raw::c_char, + p_thunk as *mut libsql::ffi::sqlite3_api_routines, + ) as ::std::os::raw::c_int; +} + +// Lightweight optimization on db connect based on $2.1: https://sqlite.org/lang_analyze.html +async fn initial_optimize(conn: &libsql::Connection) -> Result<(), libsql::Error> { + conn.execute("PRAGMA optimize = 0x10002", ()).await?; + return Ok(()); +} + +pub async fn connect_sqlite( + path: Option, + extensions: Option>, +) -> Result { + schema::try_init_schemas(); + + // NOTE: We need libsql to initialize some internal variables before auto_extension works + // reliably. That's why we're creating a throw-away connection first. Haven't debugged this + // further but see error message below. + // + // thread 'main' panicked at + // /.../libsql-0.5.0-alpha.2/src/local/database.rs:209:17: assertion `left == right` failed: + // + // libsql was configured with an incorrect threading configuration and the api is not safe to + // use. Please check that no multi-thread options have been set. If nothing was configured then + // please open an issue at: https://github.com/libsql/libsql + // left: 21 + // right: 0 + drop( + libsql::Builder::new_local(":memory:") + .build() + .await + .unwrap() + .connect(), + ); + + let p: PathBuf = path.unwrap_or_else(|| PathBuf::from(":memory:")); + let builder = libsql::Builder::new_local(p).build().await?; + + unsafe { libsql::ffi::sqlite3_auto_extension(Some(init_extension)) }; + + let conn = builder.connect()?; + + const CONFIG: &[&str] = &[ + "PRAGMA busy_timeout = 10000", + "PRAGMA journal_mode = WAL", + "PRAGMA journal_size_limit = 200000000", + // Sync the file system less often. + "PRAGMA synchronous = NORMAL", + "PRAGMA foreign_keys = ON", + "PRAGMA temp_store = MEMORY", + "PRAGMA cache_size = -16000", + // TODO: Maybe worth exploring once we have a benchmark, based on + // https://phiresky.github.io/blog/2020/sqlite-performance-tuning/. + // "PRAGMA mmap_size = 30000000000", + // "PRAGMA page_size = 32768", + + // Safety feature around application-defined functions recommended by + // https://sqlite.org/appfunc.html + "PRAGMA trusted_schema = OFF", + ]; + + // NOTE: we're querying here since some pragmas return data. However, libsql doesn't like + // executed statements to return rows. + for pragma in CONFIG { + conn.query(pragma, ()).await?; + } + + if let Some(extensions) = extensions { + for path in extensions { + conn.load_extension(path, None)?; + } + } + + initial_optimize(&conn).await?; + + return Ok(conn); +} + +pub async fn query_one_row( + conn: &libsql::Connection, + sql: &str, + params: impl libsql::params::IntoParams, +) -> Result { + let mut rows = conn.query(sql, params).await?; + let row = rows.next().await?.ok_or(libsql::Error::QueryReturnedNoRows); + return row; +} + +pub async fn query_row( + conn: &libsql::Connection, + sql: &str, + params: impl libsql::params::IntoParams, +) -> Result, libsql::Error> { + let mut rows = conn.query(sql, params).await?; + return rows.next().await; +} + +#[cfg(test)] +mod test { + use super::*; + use uuid::Uuid; + + #[tokio::test] + async fn test_connect() { + let conn = connect_sqlite(None, None).await.unwrap(); + + let row = query_one_row(&conn, "SELECT (uuid_v7())", ()) + .await + .unwrap(); + + let uuid = Uuid::from_bytes(row.get::<[u8; 16]>(0).unwrap()); + + assert_eq!(uuid.get_version_num(), 7); + + assert!(trailbase_extension::jsonschema::get_schema("std.FileUpload").is_some()); + } +} diff --git a/trailbase-sqlite/src/schema.rs b/trailbase-sqlite/src/schema.rs new file mode 100644 index 0000000..501bd32 --- /dev/null +++ b/trailbase-sqlite/src/schema.rs @@ -0,0 +1,278 @@ +use jsonschema::Validator; +use lazy_static::lazy_static; +use schemars::{schema_for, JsonSchema}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use thiserror::Error; +use trailbase_extension::jsonschema::{SchemaEntry, ValidationError}; +use uuid::Uuid; + +#[derive(Debug, Clone, Error)] +pub enum SchemaError { + #[error("JSONSchema validation error: {0}")] + JsonSchema(Arc), + #[error("Cannot update builtin schemas")] + BuiltinSchema, + #[error("Missing name")] + MissingName, +} + +/// File input schema used both for multipart-form uploads (in which case the name is mapped to +/// column names) and JSON where the column name is extracted from the corresponding key of the +/// parent object. +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FileUploadInput { + /// The name of the form's file control. + pub name: Option, + + /// The file's file name. + pub filename: Option, + + /// The file's content type. + pub content_type: Option, + + /// The file's data + pub data: Vec, +} + +impl FileUploadInput { + pub fn consume(self) -> Result<(Option, FileUpload, Vec), SchemaError> { + // We don't trust user provided type, we check ourselves. + let mime_type = infer::get(&self.data).map(|t| t.mime_type().to_string()); + + return Ok(( + self.name, + FileUpload::new( + uuid::Uuid::new_v4(), + self.filename, + self.content_type, + mime_type, + ), + self.data, + )); + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct FileUpload { + // The file's unique is from with the path is derived. + id: String, + + /// The file's original file name. + filename: Option, + + /// The file's user-provided content type. + content_type: Option, + + /// The file's inferred mime type. Not user provided. + mime_type: Option, +} + +impl FileUpload { + pub fn new( + id: Uuid, + filename: Option, + content_type: Option, + mime_type: Option, + ) -> Self { + Self { + id: id.to_string(), + filename, + content_type, + mime_type, + } + } + + pub fn path(&self) -> &str { + &self.id + } + + pub fn content_type(&self) -> Option<&str> { + self.content_type.as_deref() + } + + pub fn original_filename(&self) -> Option<&str> { + self.filename.as_deref() + } +} + +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct FileUploads(pub Vec); + +fn builtin_schemas() -> &'static HashMap { + fn validate_mime_type(value: &serde_json::Value, extra_args: Option<&str>) -> bool { + let Some(valid_mime_types) = extra_args else { + return true; + }; + + if let serde_json::Value::Object(ref map) = value { + if let Some(serde_json::Value::String(mime_type)) = map.get("mime_type") { + if valid_mime_types.contains(mime_type) { + return true; + } + } + } + + return false; + } + + lazy_static! { + static ref builtins: HashMap = HashMap::::from([ + ( + "std.FileUpload".to_string(), + SchemaEntry::from( + serde_json::to_value(schema_for!(FileUpload)).unwrap(), + Some(Arc::new(validate_mime_type)) + ) + .unwrap() + ), + ( + "std.FileUploads".to_string(), + SchemaEntry::from( + serde_json::to_value(schema_for!(FileUploads)).unwrap(), + None + ) + .unwrap(), + ) + ]); + } + + return &builtins; +} + +#[derive(Debug, Clone)] +pub struct Schema { + pub name: String, + pub schema: serde_json::Value, + pub builtin: bool, +} + +pub fn get_schema(name: &str) -> Option { + let builtins = builtin_schemas(); + + trailbase_extension::jsonschema::get_schema(name).map(|s| Schema { + name: name.to_string(), + schema: s, + builtin: builtins.contains_key(name), + }) +} + +pub fn get_compiled_schema(name: &str) -> Option> { + trailbase_extension::jsonschema::get_compiled_schema(name) +} + +pub fn get_schemas() -> Vec { + let builtins = builtin_schemas(); + return trailbase_extension::jsonschema::get_schemas() + .into_iter() + .map(|(name, value)| { + let builtin = builtins.contains_key(&name); + return Schema { + name, + schema: value, + builtin, + }; + }) + .collect(); +} + +pub fn set_user_schema(name: &str, pattern: Option) -> Result<(), SchemaError> { + let builtins = builtin_schemas(); + if builtins.contains_key(name) { + return Err(SchemaError::BuiltinSchema); + } + + if let Some(p) = pattern { + let entry = SchemaEntry::from(p, None).map_err(|err| SchemaError::JsonSchema(Arc::new(err)))?; + trailbase_extension::jsonschema::set_schema(name, Some(entry)); + } else { + trailbase_extension::jsonschema::set_schema(name, None); + } + + return Ok(()); +} + +lazy_static! { + static ref INIT: std::sync::Mutex = std::sync::Mutex::new(false); +} + +pub fn set_user_schemas(schemas: Vec<(String, serde_json::Value)>) -> Result<(), SchemaError> { + let mut entries: Vec<(String, SchemaEntry)> = vec![]; + for (name, entry) in builtin_schemas() { + entries.push((name.clone(), entry.clone())); + } + + for (name, schema) in schemas { + entries.push(( + name, + SchemaEntry::from(schema, None).map_err(|err| SchemaError::JsonSchema(Arc::new(err)))?, + )); + } + + trailbase_extension::jsonschema::set_schemas(Some(entries)); + + *INIT.lock().unwrap() = true; + + return Ok(()); +} + +pub(crate) fn try_init_schemas() { + let mut init = INIT.lock().unwrap(); + if !*init { + let entries = builtin_schemas() + .iter() + .map(|(name, entry)| (name.clone(), entry.clone())) + .collect::>(); + + trailbase_extension::jsonschema::set_schemas(Some(entries)); + *init = true; + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn test_builtin_schemas() { + assert!(builtin_schemas().len() > 0); + + for (name, schema) in builtin_schemas() { + trailbase_extension::jsonschema::set_schema(&name, Some(schema.clone())); + } + + { + let schema = get_schema("std.FileUpload").unwrap(); + let compiled_schema = Validator::new(&schema.schema).unwrap(); + let input = json!({ + "id": "foo", + "mime_type": "my_foo", + }); + if let Err(err) = compiled_schema.validate(&input) { + panic!("{err:?}"); + }; + } + + { + let schema = get_schema("std.FileUploads").unwrap(); + let compiled_schema = Validator::new(&schema.schema).unwrap(); + assert!(compiled_schema + .validate(&json!([ + { + "id": "foo0", + "mime_type": "my_foo0", + }, + { + "id": "foo1", + "mime_type": "my_foo1", + }, + ])) + .is_ok()); + } + } +} diff --git a/ui/admin/.gitignore b/ui/admin/.gitignore new file mode 100644 index 0000000..d94e2d5 --- /dev/null +++ b/ui/admin/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist + +vite.config.mts.timestamp* diff --git a/ui/admin/.prettierignore b/ui/admin/.prettierignore new file mode 100644 index 0000000..fd50c4f --- /dev/null +++ b/ui/admin/.prettierignore @@ -0,0 +1,6 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock + +src/components/ui diff --git a/ui/admin/.prettierrc.mjs b/ui/admin/.prettierrc.mjs new file mode 100644 index 0000000..85ccfb5 --- /dev/null +++ b/ui/admin/.prettierrc.mjs @@ -0,0 +1,13 @@ +// .prettierrc.mjs +/** @type {import("prettier").Config} */ +export default { + plugins: ['prettier-plugin-astro'], + overrides: [ + { + files: '*.astro', + options: { + parser: 'astro', + }, + }, + ], +}; diff --git a/ui/admin/README.md b/ui/admin/README.md new file mode 100644 index 0000000..e5106a4 --- /dev/null +++ b/ui/admin/README.md @@ -0,0 +1,17 @@ +# TrailBase Admin Dashboard UI + +## Codegen proto code + +We're using https://github.com/stephenh/ts-proto#usage for typescript generation. + + $ pnpm run proto + +Make sure to install: + + * protobuf-compiler, for protoc + * libprotobuf-dev, for meta files such as descriptor.proto. + +## Codegen Rust-TypeScript bindings + +They are currently created on `cargo test` and copied to `/bindings` on `cargo +build` where they're being picked up. diff --git a/ui/admin/eslint.config.mjs b/ui/admin/eslint.config.mjs new file mode 100644 index 0000000..eb9ca6d --- /dev/null +++ b/ui/admin/eslint.config.mjs @@ -0,0 +1,30 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default [ + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + { + ignores: ["dist/", "node_modules/", "vite.config.mts"], + }, + { + files: ["**/*.{js,mjs,cjs,mts,ts,tsx,jsx}"], + rules: { + // https://typescript-eslint.io/rules/no-explicit-any/ + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-wrapper-object-types": "warn", + // http://eslint.org/docs/rules/no-unused-vars + "@typescript-eslint/no-unused-vars": [ + "error", + { + vars: "all", + args: "after-used", + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + }, + languageOptions: { globals: globals.browser }, + }, +]; diff --git a/ui/admin/index.html b/ui/admin/index.html new file mode 100644 index 0000000..bce8471 --- /dev/null +++ b/ui/admin/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + TrailBase Admin + + + + +
+ + + + diff --git a/ui/admin/package.json b/ui/admin/package.json new file mode 100644 index 0000000..1502202 --- /dev/null +++ b/ui/admin/package.json @@ -0,0 +1,67 @@ +{ + "name": "trailbase-admin-ui", + "version": "0.0.1", + "description": "TrailBase's Admin Dashboard", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "format": "prettier -w src", + "proto": "protoc --plugin=protoc-gen-ts=${PWD}/node_modules/ts-proto/protoc-gen-ts_proto ../../proto/*.proto -I../../proto -I/usr/include --ts_out=./proto/ --ts_opt=esModuleInterop=true", + "check": "tsc --noEmit --skipLibCheck && eslint && vitest run", + "test": "vitest run" + }, + "dependencies": { + "@bufbuild/protobuf": "^2.2.1", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-sql": "^6.8.0", + "@codemirror/language": "^6.10.3", + "@codemirror/state": "^6.4.1", + "@codemirror/view": "^6.34.1", + "@corvu/resizable": "^0.2.3", + "@kobalte/core": "^0.13.7", + "@kobalte/utils": "^0.9.1", + "@nanostores/persistent": "^0.10.2", + "@nanostores/solid": "^0.5.0", + "@solid-primitives/memo": "^1.3.10", + "@solidjs/router": "^0.14.10", + "@tanstack/solid-form": "^0.34.1", + "@tanstack/solid-query": "^5.59.16", + "@tanstack/solid-table": "^8.20.5", + "@tanstack/table-core": "^8.20.5", + "chart.js": "^4.4.6", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "long": "^5.2.3", + "nanostores": "^0.11.3", + "protobufjs": "^7.4.0", + "solid-icons": "^1.1.0", + "solid-js": "^1.9.3", + "tailwind-merge": "^2.5.4", + "tailwindcss": "^3.4.14", + "tailwindcss-animate": "^1.0.7", + "trailbase": "workspace:*", + "uuid": "^11.0.2" + }, + "devDependencies": { + "@eslint/js": "^9.13.0", + "@iconify-json/tabler": "^1.2.6", + "@tailwindcss/typography": "^0.5.15", + "@types/wicg-file-system-access": "^2023.10.5", + "autoprefixer": "^10.4.20", + "eslint": "^9.13.0", + "globals": "^15.11.0", + "jsdom": "^25.0.1", + "postcss": "^8.4.47", + "prettier": "^3.3.3", + "tailwindcss": "^3.4.10", + "ts-proto": "^2.2.5", + "typescript": "^5.6.3", + "typescript-eslint": "^8.12.1", + "vite": "^5.4.10", + "vite-plugin-solid": "^2.10.2", + "vite-tsconfig-paths": "^5.0.1", + "vitest": "^2.1.4" + } +} diff --git a/ui/admin/postcss.config.mjs b/ui/admin/postcss.config.mjs new file mode 100644 index 0000000..a982c64 --- /dev/null +++ b/ui/admin/postcss.config.mjs @@ -0,0 +1,8 @@ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +export default config; diff --git a/ui/admin/proto/config.ts b/ui/admin/proto/config.ts new file mode 100644 index 0000000..1b18c88 --- /dev/null +++ b/ui/admin/proto/config.ts @@ -0,0 +1,1919 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.2.2 +// protoc v3.21.12 +// source: config.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; + +export const protobufPackage = "config"; + +export enum OAuthProviderId { + OAUTH_PROVIDER_ID_UNDEFINED = 0, + CUSTOM = 1, + DISCORD = 10, + GITLAB = 11, + GOOGLE = 12, + UNRECOGNIZED = -1, +} + +export function oAuthProviderIdFromJSON(object: any): OAuthProviderId { + switch (object) { + case 0: + case "OAUTH_PROVIDER_ID_UNDEFINED": + return OAuthProviderId.OAUTH_PROVIDER_ID_UNDEFINED; + case 1: + case "CUSTOM": + return OAuthProviderId.CUSTOM; + case 10: + case "DISCORD": + return OAuthProviderId.DISCORD; + case 11: + case "GITLAB": + return OAuthProviderId.GITLAB; + case 12: + case "GOOGLE": + return OAuthProviderId.GOOGLE; + case -1: + case "UNRECOGNIZED": + default: + return OAuthProviderId.UNRECOGNIZED; + } +} + +export function oAuthProviderIdToJSON(object: OAuthProviderId): string { + switch (object) { + case OAuthProviderId.OAUTH_PROVIDER_ID_UNDEFINED: + return "OAUTH_PROVIDER_ID_UNDEFINED"; + case OAuthProviderId.CUSTOM: + return "CUSTOM"; + case OAuthProviderId.DISCORD: + return "DISCORD"; + case OAuthProviderId.GITLAB: + return "GITLAB"; + case OAuthProviderId.GOOGLE: + return "GOOGLE"; + case OAuthProviderId.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } +} + +/** + * / Sqlite specific (as opposed to standard SQL) constrained-violation + * / resolution strategy upon insert. + */ +export enum ConflictResolutionStrategy { + CONFLICT_RESOLUTION_STRATEGY_UNDEFINED = 0, + /** ABORT - / SQL default: Keep transaction open and abort the current statement. */ + ABORT = 1, + /** ROLLBACK - / Abort entire transaction. */ + ROLLBACK = 2, + /** + * FAIL - / Similar to Abort but doesn't roll back the current statement, i.e. if the + * / current statement affects multiple rows, changes by that statement prior + * / to the failure are not rolled back. + */ + FAIL = 3, + /** IGNORE - / Skip the statement and continue. */ + IGNORE = 4, + /** + * REPLACE - / Replaces the conflicting row in case of a collision (e.g. unique + * / constraint). + */ + REPLACE = 5, + UNRECOGNIZED = -1, +} + +export function conflictResolutionStrategyFromJSON(object: any): ConflictResolutionStrategy { + switch (object) { + case 0: + case "CONFLICT_RESOLUTION_STRATEGY_UNDEFINED": + return ConflictResolutionStrategy.CONFLICT_RESOLUTION_STRATEGY_UNDEFINED; + case 1: + case "ABORT": + return ConflictResolutionStrategy.ABORT; + case 2: + case "ROLLBACK": + return ConflictResolutionStrategy.ROLLBACK; + case 3: + case "FAIL": + return ConflictResolutionStrategy.FAIL; + case 4: + case "IGNORE": + return ConflictResolutionStrategy.IGNORE; + case 5: + case "REPLACE": + return ConflictResolutionStrategy.REPLACE; + case -1: + case "UNRECOGNIZED": + default: + return ConflictResolutionStrategy.UNRECOGNIZED; + } +} + +export function conflictResolutionStrategyToJSON(object: ConflictResolutionStrategy): string { + switch (object) { + case ConflictResolutionStrategy.CONFLICT_RESOLUTION_STRATEGY_UNDEFINED: + return "CONFLICT_RESOLUTION_STRATEGY_UNDEFINED"; + case ConflictResolutionStrategy.ABORT: + return "ABORT"; + case ConflictResolutionStrategy.ROLLBACK: + return "ROLLBACK"; + case ConflictResolutionStrategy.FAIL: + return "FAIL"; + case ConflictResolutionStrategy.IGNORE: + return "IGNORE"; + case ConflictResolutionStrategy.REPLACE: + return "REPLACE"; + case ConflictResolutionStrategy.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } +} + +export enum PermissionFlag { + PERMISSION_FLAG_UNDEFINED = 0, + /** CREATE - Database record insert. */ + CREATE = 1, + /** READ - Database record read/list, i.e. select. */ + READ = 2, + /** UPDATE - Database record update. */ + UPDATE = 4, + /** DELETE - Database record delete. */ + DELETE = 8, + /** SCHEMA - / Lookup JSON schema for the given record api . */ + SCHEMA = 16, + UNRECOGNIZED = -1, +} + +export function permissionFlagFromJSON(object: any): PermissionFlag { + switch (object) { + case 0: + case "PERMISSION_FLAG_UNDEFINED": + return PermissionFlag.PERMISSION_FLAG_UNDEFINED; + case 1: + case "CREATE": + return PermissionFlag.CREATE; + case 2: + case "READ": + return PermissionFlag.READ; + case 4: + case "UPDATE": + return PermissionFlag.UPDATE; + case 8: + case "DELETE": + return PermissionFlag.DELETE; + case 16: + case "SCHEMA": + return PermissionFlag.SCHEMA; + case -1: + case "UNRECOGNIZED": + default: + return PermissionFlag.UNRECOGNIZED; + } +} + +export function permissionFlagToJSON(object: PermissionFlag): string { + switch (object) { + case PermissionFlag.PERMISSION_FLAG_UNDEFINED: + return "PERMISSION_FLAG_UNDEFINED"; + case PermissionFlag.CREATE: + return "CREATE"; + case PermissionFlag.READ: + return "READ"; + case PermissionFlag.UPDATE: + return "UPDATE"; + case PermissionFlag.DELETE: + return "DELETE"; + case PermissionFlag.SCHEMA: + return "SCHEMA"; + case PermissionFlag.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } +} + +export enum QueryApiParameterType { + TEXT = 1, + BLOB = 2, + INTEGER = 3, + REAL = 4, + UNRECOGNIZED = -1, +} + +export function queryApiParameterTypeFromJSON(object: any): QueryApiParameterType { + switch (object) { + case 1: + case "TEXT": + return QueryApiParameterType.TEXT; + case 2: + case "BLOB": + return QueryApiParameterType.BLOB; + case 3: + case "INTEGER": + return QueryApiParameterType.INTEGER; + case 4: + case "REAL": + return QueryApiParameterType.REAL; + case -1: + case "UNRECOGNIZED": + default: + return QueryApiParameterType.UNRECOGNIZED; + } +} + +export function queryApiParameterTypeToJSON(object: QueryApiParameterType): string { + switch (object) { + case QueryApiParameterType.TEXT: + return "TEXT"; + case QueryApiParameterType.BLOB: + return "BLOB"; + case QueryApiParameterType.INTEGER: + return "INTEGER"; + case QueryApiParameterType.REAL: + return "REAL"; + case QueryApiParameterType.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } +} + +export enum QueryApiAcl { + QUERY_API_ACL_UNDEFINED = 0, + WORLD = 1, + AUTHENTICATED = 2, + UNRECOGNIZED = -1, +} + +export function queryApiAclFromJSON(object: any): QueryApiAcl { + switch (object) { + case 0: + case "QUERY_API_ACL_UNDEFINED": + return QueryApiAcl.QUERY_API_ACL_UNDEFINED; + case 1: + case "WORLD": + return QueryApiAcl.WORLD; + case 2: + case "AUTHENTICATED": + return QueryApiAcl.AUTHENTICATED; + case -1: + case "UNRECOGNIZED": + default: + return QueryApiAcl.UNRECOGNIZED; + } +} + +export function queryApiAclToJSON(object: QueryApiAcl): string { + switch (object) { + case QueryApiAcl.QUERY_API_ACL_UNDEFINED: + return "QUERY_API_ACL_UNDEFINED"; + case QueryApiAcl.WORLD: + return "WORLD"; + case QueryApiAcl.AUTHENTICATED: + return "AUTHENTICATED"; + case QueryApiAcl.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } +} + +export interface EmailTemplate { + subject?: string | undefined; + body?: string | undefined; +} + +export interface EmailConfig { + smtpHost?: string | undefined; + smtpPort?: number | undefined; + smtpUsername?: string | undefined; + smtpPassword?: string | undefined; + senderName?: string | undefined; + senderAddress?: string | undefined; + userVerificationTemplate?: EmailTemplate | undefined; + passwordResetTemplate?: EmailTemplate | undefined; + changeEmailTemplate?: EmailTemplate | undefined; +} + +export interface OAuthProviderConfig { + clientId?: string | undefined; + clientSecret?: string | undefined; + providerId?: OAuthProviderId | undefined; + displayName?: string | undefined; + authUrl?: string | undefined; + tokenUrl?: string | undefined; + userApiUrl?: string | undefined; + pkce?: boolean | undefined; +} + +export interface AuthConfig { + authTokenTtlSec?: number | undefined; + refreshTokenTtlSec?: number | undefined; + oauthProviders: { [key: string]: OAuthProviderConfig }; +} + +export interface AuthConfig_OauthProvidersEntry { + key: string; + value: OAuthProviderConfig | undefined; +} + +export interface ServerConfig { + /** + * / Application name presented to users, e.g. when sending emails. Default: + * / "TrailBase". + */ + applicationName?: + | string + | undefined; + /** + * / Your final, deployed URL. This url is used to build canonical urls + * / for emails, OAuth redirects, ... . Default: "http://localhost:4000". + */ + siteUrl?: + | string + | undefined; + /** + * / Max age of logs that will be retained during period logs cleanup. Note + * / that this implies that some older logs may persist until the cleanup job + * / reruns. Default: 7 days. + */ + logsRetentionSec?: + | number + | undefined; + /** + * / Interval at which backups are persisted. Setting it to 0 will disable + * / backups. Default: 0. + */ + backupIntervalSec?: number | undefined; +} + +export interface RecordApiConfig { + name?: string | undefined; + tableName?: string | undefined; + conflictResolution?: ConflictResolutionStrategy | undefined; + autofillMissingUserIdColumns?: + | boolean + | undefined; + /** Access control lists. */ + aclWorld: PermissionFlag[]; + aclAuthenticated: PermissionFlag[]; + createAccessRule?: string | undefined; + readAccessRule?: string | undefined; + updateAccessRule?: string | undefined; + deleteAccessRule?: string | undefined; + schemaAccessRule?: string | undefined; +} + +export interface QueryApiParameter { + name?: string | undefined; + type?: QueryApiParameterType | undefined; +} + +/** + * / Configuration schema for Query APIs. + * / + * / Note that unlike record APIs, query APIs are read-only, + * / which simplifies authorization. + * / That said, query APIs are backed by virtual tables, thus in theory, they + * / could allow writes (unlike views) in the future for module implementations + * / that allow it such as SQLite's R*-tree. + */ +export interface QueryApiConfig { + name?: string | undefined; + virtualTableName?: + | string + | undefined; + /** + * / Query parameters the Query API will accept and forward to the virtual + * / table (function) as argument expressions. + */ + params: QueryApiParameter[]; + /** Read access control. */ + acl?: QueryApiAcl | undefined; + accessRule?: string | undefined; +} + +export interface JsonSchemaConfig { + name?: string | undefined; + schema?: string | undefined; +} + +export interface Config { + /** + * NOTE: These top-level fields currently have to be `required` due to the + * overly simple approach on how we do config merging (from env vars and + * vault). + */ + email: EmailConfig | undefined; + server: ServerConfig | undefined; + auth: AuthConfig | undefined; + recordApis: RecordApiConfig[]; + queryApis: QueryApiConfig[]; + schemas: JsonSchemaConfig[]; +} + +function createBaseEmailTemplate(): EmailTemplate { + return { subject: "", body: "" }; +} + +export const EmailTemplate: MessageFns = { + encode(message: EmailTemplate, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.subject !== undefined && message.subject !== "") { + writer.uint32(10).string(message.subject); + } + if (message.body !== undefined && message.body !== "") { + writer.uint32(18).string(message.body); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): EmailTemplate { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseEmailTemplate(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.subject = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.body = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): EmailTemplate { + return { + subject: isSet(object.subject) ? globalThis.String(object.subject) : "", + body: isSet(object.body) ? globalThis.String(object.body) : "", + }; + }, + + toJSON(message: EmailTemplate): unknown { + const obj: any = {}; + if (message.subject !== undefined && message.subject !== "") { + obj.subject = message.subject; + } + if (message.body !== undefined && message.body !== "") { + obj.body = message.body; + } + return obj; + }, + + create, I>>(base?: I): EmailTemplate { + return EmailTemplate.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): EmailTemplate { + const message = createBaseEmailTemplate(); + message.subject = object.subject ?? ""; + message.body = object.body ?? ""; + return message; + }, +}; + +function createBaseEmailConfig(): EmailConfig { + return { + smtpHost: "", + smtpPort: 0, + smtpUsername: "", + smtpPassword: "", + senderName: "", + senderAddress: "", + userVerificationTemplate: undefined, + passwordResetTemplate: undefined, + changeEmailTemplate: undefined, + }; +} + +export const EmailConfig: MessageFns = { + encode(message: EmailConfig, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.smtpHost !== undefined && message.smtpHost !== "") { + writer.uint32(10).string(message.smtpHost); + } + if (message.smtpPort !== undefined && message.smtpPort !== 0) { + writer.uint32(16).uint32(message.smtpPort); + } + if (message.smtpUsername !== undefined && message.smtpUsername !== "") { + writer.uint32(26).string(message.smtpUsername); + } + if (message.smtpPassword !== undefined && message.smtpPassword !== "") { + writer.uint32(34).string(message.smtpPassword); + } + if (message.senderName !== undefined && message.senderName !== "") { + writer.uint32(90).string(message.senderName); + } + if (message.senderAddress !== undefined && message.senderAddress !== "") { + writer.uint32(98).string(message.senderAddress); + } + if (message.userVerificationTemplate !== undefined) { + EmailTemplate.encode(message.userVerificationTemplate, writer.uint32(170).fork()).join(); + } + if (message.passwordResetTemplate !== undefined) { + EmailTemplate.encode(message.passwordResetTemplate, writer.uint32(178).fork()).join(); + } + if (message.changeEmailTemplate !== undefined) { + EmailTemplate.encode(message.changeEmailTemplate, writer.uint32(186).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): EmailConfig { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseEmailConfig(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.smtpHost = reader.string(); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.smtpPort = reader.uint32(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.smtpUsername = reader.string(); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.smtpPassword = reader.string(); + continue; + } + case 11: { + if (tag !== 90) { + break; + } + + message.senderName = reader.string(); + continue; + } + case 12: { + if (tag !== 98) { + break; + } + + message.senderAddress = reader.string(); + continue; + } + case 21: { + if (tag !== 170) { + break; + } + + message.userVerificationTemplate = EmailTemplate.decode(reader, reader.uint32()); + continue; + } + case 22: { + if (tag !== 178) { + break; + } + + message.passwordResetTemplate = EmailTemplate.decode(reader, reader.uint32()); + continue; + } + case 23: { + if (tag !== 186) { + break; + } + + message.changeEmailTemplate = EmailTemplate.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): EmailConfig { + return { + smtpHost: isSet(object.smtpHost) ? globalThis.String(object.smtpHost) : "", + smtpPort: isSet(object.smtpPort) ? globalThis.Number(object.smtpPort) : 0, + smtpUsername: isSet(object.smtpUsername) ? globalThis.String(object.smtpUsername) : "", + smtpPassword: isSet(object.smtpPassword) ? globalThis.String(object.smtpPassword) : "", + senderName: isSet(object.senderName) ? globalThis.String(object.senderName) : "", + senderAddress: isSet(object.senderAddress) ? globalThis.String(object.senderAddress) : "", + userVerificationTemplate: isSet(object.userVerificationTemplate) + ? EmailTemplate.fromJSON(object.userVerificationTemplate) + : undefined, + passwordResetTemplate: isSet(object.passwordResetTemplate) + ? EmailTemplate.fromJSON(object.passwordResetTemplate) + : undefined, + changeEmailTemplate: isSet(object.changeEmailTemplate) + ? EmailTemplate.fromJSON(object.changeEmailTemplate) + : undefined, + }; + }, + + toJSON(message: EmailConfig): unknown { + const obj: any = {}; + if (message.smtpHost !== undefined && message.smtpHost !== "") { + obj.smtpHost = message.smtpHost; + } + if (message.smtpPort !== undefined && message.smtpPort !== 0) { + obj.smtpPort = Math.round(message.smtpPort); + } + if (message.smtpUsername !== undefined && message.smtpUsername !== "") { + obj.smtpUsername = message.smtpUsername; + } + if (message.smtpPassword !== undefined && message.smtpPassword !== "") { + obj.smtpPassword = message.smtpPassword; + } + if (message.senderName !== undefined && message.senderName !== "") { + obj.senderName = message.senderName; + } + if (message.senderAddress !== undefined && message.senderAddress !== "") { + obj.senderAddress = message.senderAddress; + } + if (message.userVerificationTemplate !== undefined) { + obj.userVerificationTemplate = EmailTemplate.toJSON(message.userVerificationTemplate); + } + if (message.passwordResetTemplate !== undefined) { + obj.passwordResetTemplate = EmailTemplate.toJSON(message.passwordResetTemplate); + } + if (message.changeEmailTemplate !== undefined) { + obj.changeEmailTemplate = EmailTemplate.toJSON(message.changeEmailTemplate); + } + return obj; + }, + + create, I>>(base?: I): EmailConfig { + return EmailConfig.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): EmailConfig { + const message = createBaseEmailConfig(); + message.smtpHost = object.smtpHost ?? ""; + message.smtpPort = object.smtpPort ?? 0; + message.smtpUsername = object.smtpUsername ?? ""; + message.smtpPassword = object.smtpPassword ?? ""; + message.senderName = object.senderName ?? ""; + message.senderAddress = object.senderAddress ?? ""; + message.userVerificationTemplate = + (object.userVerificationTemplate !== undefined && object.userVerificationTemplate !== null) + ? EmailTemplate.fromPartial(object.userVerificationTemplate) + : undefined; + message.passwordResetTemplate = + (object.passwordResetTemplate !== undefined && object.passwordResetTemplate !== null) + ? EmailTemplate.fromPartial(object.passwordResetTemplate) + : undefined; + message.changeEmailTemplate = (object.changeEmailTemplate !== undefined && object.changeEmailTemplate !== null) + ? EmailTemplate.fromPartial(object.changeEmailTemplate) + : undefined; + return message; + }, +}; + +function createBaseOAuthProviderConfig(): OAuthProviderConfig { + return { + clientId: "", + clientSecret: "", + providerId: 0, + displayName: "", + authUrl: "", + tokenUrl: "", + userApiUrl: "", + pkce: false, + }; +} + +export const OAuthProviderConfig: MessageFns = { + encode(message: OAuthProviderConfig, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.clientId !== undefined && message.clientId !== "") { + writer.uint32(10).string(message.clientId); + } + if (message.clientSecret !== undefined && message.clientSecret !== "") { + writer.uint32(18).string(message.clientSecret); + } + if (message.providerId !== undefined && message.providerId !== 0) { + writer.uint32(24).int32(message.providerId); + } + if (message.displayName !== undefined && message.displayName !== "") { + writer.uint32(90).string(message.displayName); + } + if (message.authUrl !== undefined && message.authUrl !== "") { + writer.uint32(98).string(message.authUrl); + } + if (message.tokenUrl !== undefined && message.tokenUrl !== "") { + writer.uint32(106).string(message.tokenUrl); + } + if (message.userApiUrl !== undefined && message.userApiUrl !== "") { + writer.uint32(114).string(message.userApiUrl); + } + if (message.pkce !== undefined && message.pkce !== false) { + writer.uint32(120).bool(message.pkce); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): OAuthProviderConfig { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseOAuthProviderConfig(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.clientId = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.clientSecret = reader.string(); + continue; + } + case 3: { + if (tag !== 24) { + break; + } + + message.providerId = reader.int32() as any; + continue; + } + case 11: { + if (tag !== 90) { + break; + } + + message.displayName = reader.string(); + continue; + } + case 12: { + if (tag !== 98) { + break; + } + + message.authUrl = reader.string(); + continue; + } + case 13: { + if (tag !== 106) { + break; + } + + message.tokenUrl = reader.string(); + continue; + } + case 14: { + if (tag !== 114) { + break; + } + + message.userApiUrl = reader.string(); + continue; + } + case 15: { + if (tag !== 120) { + break; + } + + message.pkce = reader.bool(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): OAuthProviderConfig { + return { + clientId: isSet(object.clientId) ? globalThis.String(object.clientId) : "", + clientSecret: isSet(object.clientSecret) ? globalThis.String(object.clientSecret) : "", + providerId: isSet(object.providerId) ? oAuthProviderIdFromJSON(object.providerId) : 0, + displayName: isSet(object.displayName) ? globalThis.String(object.displayName) : "", + authUrl: isSet(object.authUrl) ? globalThis.String(object.authUrl) : "", + tokenUrl: isSet(object.tokenUrl) ? globalThis.String(object.tokenUrl) : "", + userApiUrl: isSet(object.userApiUrl) ? globalThis.String(object.userApiUrl) : "", + pkce: isSet(object.pkce) ? globalThis.Boolean(object.pkce) : false, + }; + }, + + toJSON(message: OAuthProviderConfig): unknown { + const obj: any = {}; + if (message.clientId !== undefined && message.clientId !== "") { + obj.clientId = message.clientId; + } + if (message.clientSecret !== undefined && message.clientSecret !== "") { + obj.clientSecret = message.clientSecret; + } + if (message.providerId !== undefined && message.providerId !== 0) { + obj.providerId = oAuthProviderIdToJSON(message.providerId); + } + if (message.displayName !== undefined && message.displayName !== "") { + obj.displayName = message.displayName; + } + if (message.authUrl !== undefined && message.authUrl !== "") { + obj.authUrl = message.authUrl; + } + if (message.tokenUrl !== undefined && message.tokenUrl !== "") { + obj.tokenUrl = message.tokenUrl; + } + if (message.userApiUrl !== undefined && message.userApiUrl !== "") { + obj.userApiUrl = message.userApiUrl; + } + if (message.pkce !== undefined && message.pkce !== false) { + obj.pkce = message.pkce; + } + return obj; + }, + + create, I>>(base?: I): OAuthProviderConfig { + return OAuthProviderConfig.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): OAuthProviderConfig { + const message = createBaseOAuthProviderConfig(); + message.clientId = object.clientId ?? ""; + message.clientSecret = object.clientSecret ?? ""; + message.providerId = object.providerId ?? 0; + message.displayName = object.displayName ?? ""; + message.authUrl = object.authUrl ?? ""; + message.tokenUrl = object.tokenUrl ?? ""; + message.userApiUrl = object.userApiUrl ?? ""; + message.pkce = object.pkce ?? false; + return message; + }, +}; + +function createBaseAuthConfig(): AuthConfig { + return { authTokenTtlSec: 0, refreshTokenTtlSec: 0, oauthProviders: {} }; +} + +export const AuthConfig: MessageFns = { + encode(message: AuthConfig, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.authTokenTtlSec !== undefined && message.authTokenTtlSec !== 0) { + writer.uint32(8).int64(message.authTokenTtlSec); + } + if (message.refreshTokenTtlSec !== undefined && message.refreshTokenTtlSec !== 0) { + writer.uint32(16).int64(message.refreshTokenTtlSec); + } + Object.entries(message.oauthProviders).forEach(([key, value]) => { + AuthConfig_OauthProvidersEntry.encode({ key: key as any, value }, writer.uint32(90).fork()).join(); + }); + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): AuthConfig { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseAuthConfig(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.authTokenTtlSec = longToNumber(reader.int64()); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.refreshTokenTtlSec = longToNumber(reader.int64()); + continue; + } + case 11: { + if (tag !== 90) { + break; + } + + const entry11 = AuthConfig_OauthProvidersEntry.decode(reader, reader.uint32()); + if (entry11.value !== undefined) { + message.oauthProviders[entry11.key] = entry11.value; + } + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): AuthConfig { + return { + authTokenTtlSec: isSet(object.authTokenTtlSec) ? globalThis.Number(object.authTokenTtlSec) : 0, + refreshTokenTtlSec: isSet(object.refreshTokenTtlSec) ? globalThis.Number(object.refreshTokenTtlSec) : 0, + oauthProviders: isObject(object.oauthProviders) + ? Object.entries(object.oauthProviders).reduce<{ [key: string]: OAuthProviderConfig }>((acc, [key, value]) => { + acc[key] = OAuthProviderConfig.fromJSON(value); + return acc; + }, {}) + : {}, + }; + }, + + toJSON(message: AuthConfig): unknown { + const obj: any = {}; + if (message.authTokenTtlSec !== undefined && message.authTokenTtlSec !== 0) { + obj.authTokenTtlSec = Math.round(message.authTokenTtlSec); + } + if (message.refreshTokenTtlSec !== undefined && message.refreshTokenTtlSec !== 0) { + obj.refreshTokenTtlSec = Math.round(message.refreshTokenTtlSec); + } + if (message.oauthProviders) { + const entries = Object.entries(message.oauthProviders); + if (entries.length > 0) { + obj.oauthProviders = {}; + entries.forEach(([k, v]) => { + obj.oauthProviders[k] = OAuthProviderConfig.toJSON(v); + }); + } + } + return obj; + }, + + create, I>>(base?: I): AuthConfig { + return AuthConfig.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): AuthConfig { + const message = createBaseAuthConfig(); + message.authTokenTtlSec = object.authTokenTtlSec ?? 0; + message.refreshTokenTtlSec = object.refreshTokenTtlSec ?? 0; + message.oauthProviders = Object.entries(object.oauthProviders ?? {}).reduce<{ [key: string]: OAuthProviderConfig }>( + (acc, [key, value]) => { + if (value !== undefined) { + acc[key] = OAuthProviderConfig.fromPartial(value); + } + return acc; + }, + {}, + ); + return message; + }, +}; + +function createBaseAuthConfig_OauthProvidersEntry(): AuthConfig_OauthProvidersEntry { + return { key: "", value: undefined }; +} + +export const AuthConfig_OauthProvidersEntry: MessageFns = { + encode(message: AuthConfig_OauthProvidersEntry, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.key !== "") { + writer.uint32(10).string(message.key); + } + if (message.value !== undefined) { + OAuthProviderConfig.encode(message.value, writer.uint32(18).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): AuthConfig_OauthProvidersEntry { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseAuthConfig_OauthProvidersEntry(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.key = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.value = OAuthProviderConfig.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): AuthConfig_OauthProvidersEntry { + return { + key: isSet(object.key) ? globalThis.String(object.key) : "", + value: isSet(object.value) ? OAuthProviderConfig.fromJSON(object.value) : undefined, + }; + }, + + toJSON(message: AuthConfig_OauthProvidersEntry): unknown { + const obj: any = {}; + if (message.key !== "") { + obj.key = message.key; + } + if (message.value !== undefined) { + obj.value = OAuthProviderConfig.toJSON(message.value); + } + return obj; + }, + + create, I>>(base?: I): AuthConfig_OauthProvidersEntry { + return AuthConfig_OauthProvidersEntry.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>( + object: I, + ): AuthConfig_OauthProvidersEntry { + const message = createBaseAuthConfig_OauthProvidersEntry(); + message.key = object.key ?? ""; + message.value = (object.value !== undefined && object.value !== null) + ? OAuthProviderConfig.fromPartial(object.value) + : undefined; + return message; + }, +}; + +function createBaseServerConfig(): ServerConfig { + return { applicationName: "", siteUrl: "", logsRetentionSec: 0, backupIntervalSec: 0 }; +} + +export const ServerConfig: MessageFns = { + encode(message: ServerConfig, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.applicationName !== undefined && message.applicationName !== "") { + writer.uint32(10).string(message.applicationName); + } + if (message.siteUrl !== undefined && message.siteUrl !== "") { + writer.uint32(18).string(message.siteUrl); + } + if (message.logsRetentionSec !== undefined && message.logsRetentionSec !== 0) { + writer.uint32(88).int64(message.logsRetentionSec); + } + if (message.backupIntervalSec !== undefined && message.backupIntervalSec !== 0) { + writer.uint32(96).int64(message.backupIntervalSec); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): ServerConfig { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseServerConfig(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.applicationName = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.siteUrl = reader.string(); + continue; + } + case 11: { + if (tag !== 88) { + break; + } + + message.logsRetentionSec = longToNumber(reader.int64()); + continue; + } + case 12: { + if (tag !== 96) { + break; + } + + message.backupIntervalSec = longToNumber(reader.int64()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): ServerConfig { + return { + applicationName: isSet(object.applicationName) ? globalThis.String(object.applicationName) : "", + siteUrl: isSet(object.siteUrl) ? globalThis.String(object.siteUrl) : "", + logsRetentionSec: isSet(object.logsRetentionSec) ? globalThis.Number(object.logsRetentionSec) : 0, + backupIntervalSec: isSet(object.backupIntervalSec) ? globalThis.Number(object.backupIntervalSec) : 0, + }; + }, + + toJSON(message: ServerConfig): unknown { + const obj: any = {}; + if (message.applicationName !== undefined && message.applicationName !== "") { + obj.applicationName = message.applicationName; + } + if (message.siteUrl !== undefined && message.siteUrl !== "") { + obj.siteUrl = message.siteUrl; + } + if (message.logsRetentionSec !== undefined && message.logsRetentionSec !== 0) { + obj.logsRetentionSec = Math.round(message.logsRetentionSec); + } + if (message.backupIntervalSec !== undefined && message.backupIntervalSec !== 0) { + obj.backupIntervalSec = Math.round(message.backupIntervalSec); + } + return obj; + }, + + create, I>>(base?: I): ServerConfig { + return ServerConfig.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): ServerConfig { + const message = createBaseServerConfig(); + message.applicationName = object.applicationName ?? ""; + message.siteUrl = object.siteUrl ?? ""; + message.logsRetentionSec = object.logsRetentionSec ?? 0; + message.backupIntervalSec = object.backupIntervalSec ?? 0; + return message; + }, +}; + +function createBaseRecordApiConfig(): RecordApiConfig { + return { + name: "", + tableName: "", + conflictResolution: 0, + autofillMissingUserIdColumns: false, + aclWorld: [], + aclAuthenticated: [], + createAccessRule: "", + readAccessRule: "", + updateAccessRule: "", + deleteAccessRule: "", + schemaAccessRule: "", + }; +} + +export const RecordApiConfig: MessageFns = { + encode(message: RecordApiConfig, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.name !== undefined && message.name !== "") { + writer.uint32(10).string(message.name); + } + if (message.tableName !== undefined && message.tableName !== "") { + writer.uint32(18).string(message.tableName); + } + if (message.conflictResolution !== undefined && message.conflictResolution !== 0) { + writer.uint32(40).int32(message.conflictResolution); + } + if (message.autofillMissingUserIdColumns !== undefined && message.autofillMissingUserIdColumns !== false) { + writer.uint32(48).bool(message.autofillMissingUserIdColumns); + } + writer.uint32(58).fork(); + for (const v of message.aclWorld) { + writer.int32(v); + } + writer.join(); + writer.uint32(66).fork(); + for (const v of message.aclAuthenticated) { + writer.int32(v); + } + writer.join(); + if (message.createAccessRule !== undefined && message.createAccessRule !== "") { + writer.uint32(90).string(message.createAccessRule); + } + if (message.readAccessRule !== undefined && message.readAccessRule !== "") { + writer.uint32(98).string(message.readAccessRule); + } + if (message.updateAccessRule !== undefined && message.updateAccessRule !== "") { + writer.uint32(106).string(message.updateAccessRule); + } + if (message.deleteAccessRule !== undefined && message.deleteAccessRule !== "") { + writer.uint32(114).string(message.deleteAccessRule); + } + if (message.schemaAccessRule !== undefined && message.schemaAccessRule !== "") { + writer.uint32(122).string(message.schemaAccessRule); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): RecordApiConfig { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseRecordApiConfig(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.tableName = reader.string(); + continue; + } + case 5: { + if (tag !== 40) { + break; + } + + message.conflictResolution = reader.int32() as any; + continue; + } + case 6: { + if (tag !== 48) { + break; + } + + message.autofillMissingUserIdColumns = reader.bool(); + continue; + } + case 7: { + if (tag === 56) { + message.aclWorld.push(reader.int32() as any); + + continue; + } + + if (tag === 58) { + const end2 = reader.uint32() + reader.pos; + while (reader.pos < end2) { + message.aclWorld.push(reader.int32() as any); + } + + continue; + } + + break; + } + case 8: { + if (tag === 64) { + message.aclAuthenticated.push(reader.int32() as any); + + continue; + } + + if (tag === 66) { + const end2 = reader.uint32() + reader.pos; + while (reader.pos < end2) { + message.aclAuthenticated.push(reader.int32() as any); + } + + continue; + } + + break; + } + case 11: { + if (tag !== 90) { + break; + } + + message.createAccessRule = reader.string(); + continue; + } + case 12: { + if (tag !== 98) { + break; + } + + message.readAccessRule = reader.string(); + continue; + } + case 13: { + if (tag !== 106) { + break; + } + + message.updateAccessRule = reader.string(); + continue; + } + case 14: { + if (tag !== 114) { + break; + } + + message.deleteAccessRule = reader.string(); + continue; + } + case 15: { + if (tag !== 122) { + break; + } + + message.schemaAccessRule = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): RecordApiConfig { + return { + name: isSet(object.name) ? globalThis.String(object.name) : "", + tableName: isSet(object.tableName) ? globalThis.String(object.tableName) : "", + conflictResolution: isSet(object.conflictResolution) + ? conflictResolutionStrategyFromJSON(object.conflictResolution) + : 0, + autofillMissingUserIdColumns: isSet(object.autofillMissingUserIdColumns) + ? globalThis.Boolean(object.autofillMissingUserIdColumns) + : false, + aclWorld: globalThis.Array.isArray(object?.aclWorld) + ? object.aclWorld.map((e: any) => permissionFlagFromJSON(e)) + : [], + aclAuthenticated: globalThis.Array.isArray(object?.aclAuthenticated) + ? object.aclAuthenticated.map((e: any) => permissionFlagFromJSON(e)) + : [], + createAccessRule: isSet(object.createAccessRule) ? globalThis.String(object.createAccessRule) : "", + readAccessRule: isSet(object.readAccessRule) ? globalThis.String(object.readAccessRule) : "", + updateAccessRule: isSet(object.updateAccessRule) ? globalThis.String(object.updateAccessRule) : "", + deleteAccessRule: isSet(object.deleteAccessRule) ? globalThis.String(object.deleteAccessRule) : "", + schemaAccessRule: isSet(object.schemaAccessRule) ? globalThis.String(object.schemaAccessRule) : "", + }; + }, + + toJSON(message: RecordApiConfig): unknown { + const obj: any = {}; + if (message.name !== undefined && message.name !== "") { + obj.name = message.name; + } + if (message.tableName !== undefined && message.tableName !== "") { + obj.tableName = message.tableName; + } + if (message.conflictResolution !== undefined && message.conflictResolution !== 0) { + obj.conflictResolution = conflictResolutionStrategyToJSON(message.conflictResolution); + } + if (message.autofillMissingUserIdColumns !== undefined && message.autofillMissingUserIdColumns !== false) { + obj.autofillMissingUserIdColumns = message.autofillMissingUserIdColumns; + } + if (message.aclWorld?.length) { + obj.aclWorld = message.aclWorld.map((e) => permissionFlagToJSON(e)); + } + if (message.aclAuthenticated?.length) { + obj.aclAuthenticated = message.aclAuthenticated.map((e) => permissionFlagToJSON(e)); + } + if (message.createAccessRule !== undefined && message.createAccessRule !== "") { + obj.createAccessRule = message.createAccessRule; + } + if (message.readAccessRule !== undefined && message.readAccessRule !== "") { + obj.readAccessRule = message.readAccessRule; + } + if (message.updateAccessRule !== undefined && message.updateAccessRule !== "") { + obj.updateAccessRule = message.updateAccessRule; + } + if (message.deleteAccessRule !== undefined && message.deleteAccessRule !== "") { + obj.deleteAccessRule = message.deleteAccessRule; + } + if (message.schemaAccessRule !== undefined && message.schemaAccessRule !== "") { + obj.schemaAccessRule = message.schemaAccessRule; + } + return obj; + }, + + create, I>>(base?: I): RecordApiConfig { + return RecordApiConfig.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): RecordApiConfig { + const message = createBaseRecordApiConfig(); + message.name = object.name ?? ""; + message.tableName = object.tableName ?? ""; + message.conflictResolution = object.conflictResolution ?? 0; + message.autofillMissingUserIdColumns = object.autofillMissingUserIdColumns ?? false; + message.aclWorld = object.aclWorld?.map((e) => e) || []; + message.aclAuthenticated = object.aclAuthenticated?.map((e) => e) || []; + message.createAccessRule = object.createAccessRule ?? ""; + message.readAccessRule = object.readAccessRule ?? ""; + message.updateAccessRule = object.updateAccessRule ?? ""; + message.deleteAccessRule = object.deleteAccessRule ?? ""; + message.schemaAccessRule = object.schemaAccessRule ?? ""; + return message; + }, +}; + +function createBaseQueryApiParameter(): QueryApiParameter { + return { name: "", type: 1 }; +} + +export const QueryApiParameter: MessageFns = { + encode(message: QueryApiParameter, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.name !== undefined && message.name !== "") { + writer.uint32(10).string(message.name); + } + if (message.type !== undefined && message.type !== 1) { + writer.uint32(16).int32(message.type); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): QueryApiParameter { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseQueryApiParameter(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.type = reader.int32() as any; + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): QueryApiParameter { + return { + name: isSet(object.name) ? globalThis.String(object.name) : "", + type: isSet(object.type) ? queryApiParameterTypeFromJSON(object.type) : 1, + }; + }, + + toJSON(message: QueryApiParameter): unknown { + const obj: any = {}; + if (message.name !== undefined && message.name !== "") { + obj.name = message.name; + } + if (message.type !== undefined && message.type !== 1) { + obj.type = queryApiParameterTypeToJSON(message.type); + } + return obj; + }, + + create, I>>(base?: I): QueryApiParameter { + return QueryApiParameter.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): QueryApiParameter { + const message = createBaseQueryApiParameter(); + message.name = object.name ?? ""; + message.type = object.type ?? 1; + return message; + }, +}; + +function createBaseQueryApiConfig(): QueryApiConfig { + return { name: "", virtualTableName: "", params: [], acl: 0, accessRule: "" }; +} + +export const QueryApiConfig: MessageFns = { + encode(message: QueryApiConfig, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.name !== undefined && message.name !== "") { + writer.uint32(10).string(message.name); + } + if (message.virtualTableName !== undefined && message.virtualTableName !== "") { + writer.uint32(18).string(message.virtualTableName); + } + for (const v of message.params) { + QueryApiParameter.encode(v!, writer.uint32(26).fork()).join(); + } + if (message.acl !== undefined && message.acl !== 0) { + writer.uint32(64).int32(message.acl); + } + if (message.accessRule !== undefined && message.accessRule !== "") { + writer.uint32(74).string(message.accessRule); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): QueryApiConfig { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseQueryApiConfig(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.virtualTableName = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.params.push(QueryApiParameter.decode(reader, reader.uint32())); + continue; + } + case 8: { + if (tag !== 64) { + break; + } + + message.acl = reader.int32() as any; + continue; + } + case 9: { + if (tag !== 74) { + break; + } + + message.accessRule = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): QueryApiConfig { + return { + name: isSet(object.name) ? globalThis.String(object.name) : "", + virtualTableName: isSet(object.virtualTableName) ? globalThis.String(object.virtualTableName) : "", + params: globalThis.Array.isArray(object?.params) + ? object.params.map((e: any) => QueryApiParameter.fromJSON(e)) + : [], + acl: isSet(object.acl) ? queryApiAclFromJSON(object.acl) : 0, + accessRule: isSet(object.accessRule) ? globalThis.String(object.accessRule) : "", + }; + }, + + toJSON(message: QueryApiConfig): unknown { + const obj: any = {}; + if (message.name !== undefined && message.name !== "") { + obj.name = message.name; + } + if (message.virtualTableName !== undefined && message.virtualTableName !== "") { + obj.virtualTableName = message.virtualTableName; + } + if (message.params?.length) { + obj.params = message.params.map((e) => QueryApiParameter.toJSON(e)); + } + if (message.acl !== undefined && message.acl !== 0) { + obj.acl = queryApiAclToJSON(message.acl); + } + if (message.accessRule !== undefined && message.accessRule !== "") { + obj.accessRule = message.accessRule; + } + return obj; + }, + + create, I>>(base?: I): QueryApiConfig { + return QueryApiConfig.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): QueryApiConfig { + const message = createBaseQueryApiConfig(); + message.name = object.name ?? ""; + message.virtualTableName = object.virtualTableName ?? ""; + message.params = object.params?.map((e) => QueryApiParameter.fromPartial(e)) || []; + message.acl = object.acl ?? 0; + message.accessRule = object.accessRule ?? ""; + return message; + }, +}; + +function createBaseJsonSchemaConfig(): JsonSchemaConfig { + return { name: "", schema: "" }; +} + +export const JsonSchemaConfig: MessageFns = { + encode(message: JsonSchemaConfig, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.name !== undefined && message.name !== "") { + writer.uint32(10).string(message.name); + } + if (message.schema !== undefined && message.schema !== "") { + writer.uint32(18).string(message.schema); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): JsonSchemaConfig { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseJsonSchemaConfig(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.schema = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): JsonSchemaConfig { + return { + name: isSet(object.name) ? globalThis.String(object.name) : "", + schema: isSet(object.schema) ? globalThis.String(object.schema) : "", + }; + }, + + toJSON(message: JsonSchemaConfig): unknown { + const obj: any = {}; + if (message.name !== undefined && message.name !== "") { + obj.name = message.name; + } + if (message.schema !== undefined && message.schema !== "") { + obj.schema = message.schema; + } + return obj; + }, + + create, I>>(base?: I): JsonSchemaConfig { + return JsonSchemaConfig.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): JsonSchemaConfig { + const message = createBaseJsonSchemaConfig(); + message.name = object.name ?? ""; + message.schema = object.schema ?? ""; + return message; + }, +}; + +function createBaseConfig(): Config { + return { email: undefined, server: undefined, auth: undefined, recordApis: [], queryApis: [], schemas: [] }; +} + +export const Config: MessageFns = { + encode(message: Config, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.email !== undefined) { + EmailConfig.encode(message.email, writer.uint32(18).fork()).join(); + } + if (message.server !== undefined) { + ServerConfig.encode(message.server, writer.uint32(26).fork()).join(); + } + if (message.auth !== undefined) { + AuthConfig.encode(message.auth, writer.uint32(34).fork()).join(); + } + for (const v of message.recordApis) { + RecordApiConfig.encode(v!, writer.uint32(90).fork()).join(); + } + for (const v of message.queryApis) { + QueryApiConfig.encode(v!, writer.uint32(98).fork()).join(); + } + for (const v of message.schemas) { + JsonSchemaConfig.encode(v!, writer.uint32(170).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): Config { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseConfig(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 2: { + if (tag !== 18) { + break; + } + + message.email = EmailConfig.decode(reader, reader.uint32()); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.server = ServerConfig.decode(reader, reader.uint32()); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.auth = AuthConfig.decode(reader, reader.uint32()); + continue; + } + case 11: { + if (tag !== 90) { + break; + } + + message.recordApis.push(RecordApiConfig.decode(reader, reader.uint32())); + continue; + } + case 12: { + if (tag !== 98) { + break; + } + + message.queryApis.push(QueryApiConfig.decode(reader, reader.uint32())); + continue; + } + case 21: { + if (tag !== 170) { + break; + } + + message.schemas.push(JsonSchemaConfig.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): Config { + return { + email: isSet(object.email) ? EmailConfig.fromJSON(object.email) : undefined, + server: isSet(object.server) ? ServerConfig.fromJSON(object.server) : undefined, + auth: isSet(object.auth) ? AuthConfig.fromJSON(object.auth) : undefined, + recordApis: globalThis.Array.isArray(object?.recordApis) + ? object.recordApis.map((e: any) => RecordApiConfig.fromJSON(e)) + : [], + queryApis: globalThis.Array.isArray(object?.queryApis) + ? object.queryApis.map((e: any) => QueryApiConfig.fromJSON(e)) + : [], + schemas: globalThis.Array.isArray(object?.schemas) + ? object.schemas.map((e: any) => JsonSchemaConfig.fromJSON(e)) + : [], + }; + }, + + toJSON(message: Config): unknown { + const obj: any = {}; + if (message.email !== undefined) { + obj.email = EmailConfig.toJSON(message.email); + } + if (message.server !== undefined) { + obj.server = ServerConfig.toJSON(message.server); + } + if (message.auth !== undefined) { + obj.auth = AuthConfig.toJSON(message.auth); + } + if (message.recordApis?.length) { + obj.recordApis = message.recordApis.map((e) => RecordApiConfig.toJSON(e)); + } + if (message.queryApis?.length) { + obj.queryApis = message.queryApis.map((e) => QueryApiConfig.toJSON(e)); + } + if (message.schemas?.length) { + obj.schemas = message.schemas.map((e) => JsonSchemaConfig.toJSON(e)); + } + return obj; + }, + + create, I>>(base?: I): Config { + return Config.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): Config { + const message = createBaseConfig(); + message.email = (object.email !== undefined && object.email !== null) + ? EmailConfig.fromPartial(object.email) + : undefined; + message.server = (object.server !== undefined && object.server !== null) + ? ServerConfig.fromPartial(object.server) + : undefined; + message.auth = (object.auth !== undefined && object.auth !== null) + ? AuthConfig.fromPartial(object.auth) + : undefined; + message.recordApis = object.recordApis?.map((e) => RecordApiConfig.fromPartial(e)) || []; + message.queryApis = object.queryApis?.map((e) => QueryApiConfig.fromPartial(e)) || []; + message.schemas = object.schemas?.map((e) => JsonSchemaConfig.fromPartial(e)) || []; + return message; + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin ? T + : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +export type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +function longToNumber(int64: { toString(): string }): number { + const num = globalThis.Number(int64.toString()); + if (num > globalThis.Number.MAX_SAFE_INTEGER) { + throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER"); + } + if (num < globalThis.Number.MIN_SAFE_INTEGER) { + throw new globalThis.Error("Value is smaller than Number.MIN_SAFE_INTEGER"); + } + return num; +} + +function isObject(value: any): boolean { + return typeof value === "object" && value !== null; +} + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} + +export interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + fromJSON(object: any): T; + toJSON(message: T): unknown; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} diff --git a/ui/admin/proto/config_api.ts b/ui/admin/proto/config_api.ts new file mode 100644 index 0000000..012d873 --- /dev/null +++ b/ui/admin/proto/config_api.ts @@ -0,0 +1,202 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.2.2 +// protoc v3.21.12 +// source: config_api.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; +import { Config } from "./config"; + +export const protobufPackage = "config"; + +export interface GetConfigResponse { + config?: Config | undefined; + hash?: string | undefined; +} + +export interface UpdateConfigRequest { + config?: Config | undefined; + hash?: string | undefined; +} + +function createBaseGetConfigResponse(): GetConfigResponse { + return { config: undefined, hash: "" }; +} + +export const GetConfigResponse: MessageFns = { + encode(message: GetConfigResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.config !== undefined) { + Config.encode(message.config, writer.uint32(10).fork()).join(); + } + if (message.hash !== undefined && message.hash !== "") { + writer.uint32(18).string(message.hash); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): GetConfigResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseGetConfigResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.config = Config.decode(reader, reader.uint32()); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.hash = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): GetConfigResponse { + return { + config: isSet(object.config) ? Config.fromJSON(object.config) : undefined, + hash: isSet(object.hash) ? globalThis.String(object.hash) : "", + }; + }, + + toJSON(message: GetConfigResponse): unknown { + const obj: any = {}; + if (message.config !== undefined) { + obj.config = Config.toJSON(message.config); + } + if (message.hash !== undefined && message.hash !== "") { + obj.hash = message.hash; + } + return obj; + }, + + create, I>>(base?: I): GetConfigResponse { + return GetConfigResponse.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): GetConfigResponse { + const message = createBaseGetConfigResponse(); + message.config = (object.config !== undefined && object.config !== null) + ? Config.fromPartial(object.config) + : undefined; + message.hash = object.hash ?? ""; + return message; + }, +}; + +function createBaseUpdateConfigRequest(): UpdateConfigRequest { + return { config: undefined, hash: "" }; +} + +export const UpdateConfigRequest: MessageFns = { + encode(message: UpdateConfigRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.config !== undefined) { + Config.encode(message.config, writer.uint32(10).fork()).join(); + } + if (message.hash !== undefined && message.hash !== "") { + writer.uint32(18).string(message.hash); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): UpdateConfigRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseUpdateConfigRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.config = Config.decode(reader, reader.uint32()); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.hash = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): UpdateConfigRequest { + return { + config: isSet(object.config) ? Config.fromJSON(object.config) : undefined, + hash: isSet(object.hash) ? globalThis.String(object.hash) : "", + }; + }, + + toJSON(message: UpdateConfigRequest): unknown { + const obj: any = {}; + if (message.config !== undefined) { + obj.config = Config.toJSON(message.config); + } + if (message.hash !== undefined && message.hash !== "") { + obj.hash = message.hash; + } + return obj; + }, + + create, I>>(base?: I): UpdateConfigRequest { + return UpdateConfigRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): UpdateConfigRequest { + const message = createBaseUpdateConfigRequest(); + message.config = (object.config !== undefined && object.config !== null) + ? Config.fromPartial(object.config) + : undefined; + message.hash = object.hash ?? ""; + return message; + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin ? T + : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +export type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} + +export interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + fromJSON(object: any): T; + toJSON(message: T): unknown; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} diff --git a/ui/admin/proto/google/protobuf/descriptor.ts b/ui/admin/proto/google/protobuf/descriptor.ts new file mode 100644 index 0000000..5623d23 --- /dev/null +++ b/ui/admin/proto/google/protobuf/descriptor.ts @@ -0,0 +1,4752 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.2.2 +// protoc v3.21.12 +// source: google/protobuf/descriptor.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; + +export const protobufPackage = "google.protobuf"; + +/** + * The protocol compiler can output a FileDescriptorSet containing the .proto + * files it parses. + */ +export interface FileDescriptorSet { + file: FileDescriptorProto[]; +} + +/** Describes a complete .proto file. */ +export interface FileDescriptorProto { + /** file name, relative to root of source tree */ + name?: + | string + | undefined; + /** e.g. "foo", "foo.bar", etc. */ + package?: + | string + | undefined; + /** Names of files imported by this file. */ + dependency: string[]; + /** Indexes of the public imported files in the dependency list above. */ + publicDependency: number[]; + /** + * Indexes of the weak imported files in the dependency list. + * For Google-internal migration only. Do not use. + */ + weakDependency: number[]; + /** All top-level definitions in this file. */ + messageType: DescriptorProto[]; + enumType: EnumDescriptorProto[]; + service: ServiceDescriptorProto[]; + extension: FieldDescriptorProto[]; + options?: + | FileOptions + | undefined; + /** + * This field contains optional information about the original source code. + * You may safely remove this entire field without harming runtime + * functionality of the descriptors -- the information is needed only by + * development tools. + */ + sourceCodeInfo?: + | SourceCodeInfo + | undefined; + /** + * The syntax of the proto file. + * The supported values are "proto2" and "proto3". + */ + syntax?: string | undefined; +} + +/** Describes a message type. */ +export interface DescriptorProto { + name?: string | undefined; + field: FieldDescriptorProto[]; + extension: FieldDescriptorProto[]; + nestedType: DescriptorProto[]; + enumType: EnumDescriptorProto[]; + extensionRange: DescriptorProto_ExtensionRange[]; + oneofDecl: OneofDescriptorProto[]; + options?: MessageOptions | undefined; + reservedRange: DescriptorProto_ReservedRange[]; + /** + * Reserved field names, which may not be used by fields in the same message. + * A given name may only be reserved once. + */ + reservedName: string[]; +} + +export interface DescriptorProto_ExtensionRange { + /** Inclusive. */ + start?: + | number + | undefined; + /** Exclusive. */ + end?: number | undefined; + options?: ExtensionRangeOptions | undefined; +} + +/** + * Range of reserved tag numbers. Reserved tag numbers may not be used by + * fields or extension ranges in the same message. Reserved ranges may + * not overlap. + */ +export interface DescriptorProto_ReservedRange { + /** Inclusive. */ + start?: + | number + | undefined; + /** Exclusive. */ + end?: number | undefined; +} + +export interface ExtensionRangeOptions { + /** The parser stores options it doesn't recognize here. See above. */ + uninterpretedOption: UninterpretedOption[]; +} + +/** Describes a field within a message. */ +export interface FieldDescriptorProto { + name?: string | undefined; + number?: number | undefined; + label?: + | FieldDescriptorProto_Label + | undefined; + /** + * If type_name is set, this need not be set. If both this and type_name + * are set, this must be one of TYPE_ENUM, TYPE_MESSAGE or TYPE_GROUP. + */ + type?: + | FieldDescriptorProto_Type + | undefined; + /** + * For message and enum types, this is the name of the type. If the name + * starts with a '.', it is fully-qualified. Otherwise, C++-like scoping + * rules are used to find the type (i.e. first the nested types within this + * message are searched, then within the parent, on up to the root + * namespace). + */ + typeName?: + | string + | undefined; + /** + * For extensions, this is the name of the type being extended. It is + * resolved in the same manner as type_name. + */ + extendee?: + | string + | undefined; + /** + * For numeric types, contains the original text representation of the value. + * For booleans, "true" or "false". + * For strings, contains the default text contents (not escaped in any way). + * For bytes, contains the C escaped value. All bytes >= 128 are escaped. + */ + defaultValue?: + | string + | undefined; + /** + * If set, gives the index of a oneof in the containing type's oneof_decl + * list. This field is a member of that oneof. + */ + oneofIndex?: + | number + | undefined; + /** + * JSON name of this field. The value is set by protocol compiler. If the + * user has set a "json_name" option on this field, that option's value + * will be used. Otherwise, it's deduced from the field's name by converting + * it to camelCase. + */ + jsonName?: string | undefined; + options?: + | FieldOptions + | undefined; + /** + * If true, this is a proto3 "optional". When a proto3 field is optional, it + * tracks presence regardless of field type. + * + * When proto3_optional is true, this field must be belong to a oneof to + * signal to old proto3 clients that presence is tracked for this field. This + * oneof is known as a "synthetic" oneof, and this field must be its sole + * member (each proto3 optional field gets its own synthetic oneof). Synthetic + * oneofs exist in the descriptor only, and do not generate any API. Synthetic + * oneofs must be ordered after all "real" oneofs. + * + * For message fields, proto3_optional doesn't create any semantic change, + * since non-repeated message fields always track presence. However it still + * indicates the semantic detail of whether the user wrote "optional" or not. + * This can be useful for round-tripping the .proto file. For consistency we + * give message fields a synthetic oneof also, even though it is not required + * to track presence. This is especially important because the parser can't + * tell if a field is a message or an enum, so it must always create a + * synthetic oneof. + * + * Proto2 optional fields do not set this flag, because they already indicate + * optional with `LABEL_OPTIONAL`. + */ + proto3Optional?: boolean | undefined; +} + +export enum FieldDescriptorProto_Type { + /** + * TYPE_DOUBLE - 0 is reserved for errors. + * Order is weird for historical reasons. + */ + TYPE_DOUBLE = 1, + TYPE_FLOAT = 2, + /** + * TYPE_INT64 - Not ZigZag encoded. Negative numbers take 10 bytes. Use TYPE_SINT64 if + * negative values are likely. + */ + TYPE_INT64 = 3, + TYPE_UINT64 = 4, + /** + * TYPE_INT32 - Not ZigZag encoded. Negative numbers take 10 bytes. Use TYPE_SINT32 if + * negative values are likely. + */ + TYPE_INT32 = 5, + TYPE_FIXED64 = 6, + TYPE_FIXED32 = 7, + TYPE_BOOL = 8, + TYPE_STRING = 9, + /** + * TYPE_GROUP - Tag-delimited aggregate. + * Group type is deprecated and not supported in proto3. However, Proto3 + * implementations should still be able to parse the group wire format and + * treat group fields as unknown fields. + */ + TYPE_GROUP = 10, + /** TYPE_MESSAGE - Length-delimited aggregate. */ + TYPE_MESSAGE = 11, + /** TYPE_BYTES - New in version 2. */ + TYPE_BYTES = 12, + TYPE_UINT32 = 13, + TYPE_ENUM = 14, + TYPE_SFIXED32 = 15, + TYPE_SFIXED64 = 16, + /** TYPE_SINT32 - Uses ZigZag encoding. */ + TYPE_SINT32 = 17, + /** TYPE_SINT64 - Uses ZigZag encoding. */ + TYPE_SINT64 = 18, + UNRECOGNIZED = -1, +} + +export function fieldDescriptorProto_TypeFromJSON(object: any): FieldDescriptorProto_Type { + switch (object) { + case 1: + case "TYPE_DOUBLE": + return FieldDescriptorProto_Type.TYPE_DOUBLE; + case 2: + case "TYPE_FLOAT": + return FieldDescriptorProto_Type.TYPE_FLOAT; + case 3: + case "TYPE_INT64": + return FieldDescriptorProto_Type.TYPE_INT64; + case 4: + case "TYPE_UINT64": + return FieldDescriptorProto_Type.TYPE_UINT64; + case 5: + case "TYPE_INT32": + return FieldDescriptorProto_Type.TYPE_INT32; + case 6: + case "TYPE_FIXED64": + return FieldDescriptorProto_Type.TYPE_FIXED64; + case 7: + case "TYPE_FIXED32": + return FieldDescriptorProto_Type.TYPE_FIXED32; + case 8: + case "TYPE_BOOL": + return FieldDescriptorProto_Type.TYPE_BOOL; + case 9: + case "TYPE_STRING": + return FieldDescriptorProto_Type.TYPE_STRING; + case 10: + case "TYPE_GROUP": + return FieldDescriptorProto_Type.TYPE_GROUP; + case 11: + case "TYPE_MESSAGE": + return FieldDescriptorProto_Type.TYPE_MESSAGE; + case 12: + case "TYPE_BYTES": + return FieldDescriptorProto_Type.TYPE_BYTES; + case 13: + case "TYPE_UINT32": + return FieldDescriptorProto_Type.TYPE_UINT32; + case 14: + case "TYPE_ENUM": + return FieldDescriptorProto_Type.TYPE_ENUM; + case 15: + case "TYPE_SFIXED32": + return FieldDescriptorProto_Type.TYPE_SFIXED32; + case 16: + case "TYPE_SFIXED64": + return FieldDescriptorProto_Type.TYPE_SFIXED64; + case 17: + case "TYPE_SINT32": + return FieldDescriptorProto_Type.TYPE_SINT32; + case 18: + case "TYPE_SINT64": + return FieldDescriptorProto_Type.TYPE_SINT64; + case -1: + case "UNRECOGNIZED": + default: + return FieldDescriptorProto_Type.UNRECOGNIZED; + } +} + +export function fieldDescriptorProto_TypeToJSON(object: FieldDescriptorProto_Type): string { + switch (object) { + case FieldDescriptorProto_Type.TYPE_DOUBLE: + return "TYPE_DOUBLE"; + case FieldDescriptorProto_Type.TYPE_FLOAT: + return "TYPE_FLOAT"; + case FieldDescriptorProto_Type.TYPE_INT64: + return "TYPE_INT64"; + case FieldDescriptorProto_Type.TYPE_UINT64: + return "TYPE_UINT64"; + case FieldDescriptorProto_Type.TYPE_INT32: + return "TYPE_INT32"; + case FieldDescriptorProto_Type.TYPE_FIXED64: + return "TYPE_FIXED64"; + case FieldDescriptorProto_Type.TYPE_FIXED32: + return "TYPE_FIXED32"; + case FieldDescriptorProto_Type.TYPE_BOOL: + return "TYPE_BOOL"; + case FieldDescriptorProto_Type.TYPE_STRING: + return "TYPE_STRING"; + case FieldDescriptorProto_Type.TYPE_GROUP: + return "TYPE_GROUP"; + case FieldDescriptorProto_Type.TYPE_MESSAGE: + return "TYPE_MESSAGE"; + case FieldDescriptorProto_Type.TYPE_BYTES: + return "TYPE_BYTES"; + case FieldDescriptorProto_Type.TYPE_UINT32: + return "TYPE_UINT32"; + case FieldDescriptorProto_Type.TYPE_ENUM: + return "TYPE_ENUM"; + case FieldDescriptorProto_Type.TYPE_SFIXED32: + return "TYPE_SFIXED32"; + case FieldDescriptorProto_Type.TYPE_SFIXED64: + return "TYPE_SFIXED64"; + case FieldDescriptorProto_Type.TYPE_SINT32: + return "TYPE_SINT32"; + case FieldDescriptorProto_Type.TYPE_SINT64: + return "TYPE_SINT64"; + case FieldDescriptorProto_Type.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } +} + +export enum FieldDescriptorProto_Label { + /** LABEL_OPTIONAL - 0 is reserved for errors */ + LABEL_OPTIONAL = 1, + LABEL_REQUIRED = 2, + LABEL_REPEATED = 3, + UNRECOGNIZED = -1, +} + +export function fieldDescriptorProto_LabelFromJSON(object: any): FieldDescriptorProto_Label { + switch (object) { + case 1: + case "LABEL_OPTIONAL": + return FieldDescriptorProto_Label.LABEL_OPTIONAL; + case 2: + case "LABEL_REQUIRED": + return FieldDescriptorProto_Label.LABEL_REQUIRED; + case 3: + case "LABEL_REPEATED": + return FieldDescriptorProto_Label.LABEL_REPEATED; + case -1: + case "UNRECOGNIZED": + default: + return FieldDescriptorProto_Label.UNRECOGNIZED; + } +} + +export function fieldDescriptorProto_LabelToJSON(object: FieldDescriptorProto_Label): string { + switch (object) { + case FieldDescriptorProto_Label.LABEL_OPTIONAL: + return "LABEL_OPTIONAL"; + case FieldDescriptorProto_Label.LABEL_REQUIRED: + return "LABEL_REQUIRED"; + case FieldDescriptorProto_Label.LABEL_REPEATED: + return "LABEL_REPEATED"; + case FieldDescriptorProto_Label.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } +} + +/** Describes a oneof. */ +export interface OneofDescriptorProto { + name?: string | undefined; + options?: OneofOptions | undefined; +} + +/** Describes an enum type. */ +export interface EnumDescriptorProto { + name?: string | undefined; + value: EnumValueDescriptorProto[]; + options?: + | EnumOptions + | undefined; + /** + * Range of reserved numeric values. Reserved numeric values may not be used + * by enum values in the same enum declaration. Reserved ranges may not + * overlap. + */ + reservedRange: EnumDescriptorProto_EnumReservedRange[]; + /** + * Reserved enum value names, which may not be reused. A given name may only + * be reserved once. + */ + reservedName: string[]; +} + +/** + * Range of reserved numeric values. Reserved values may not be used by + * entries in the same enum. Reserved ranges may not overlap. + * + * Note that this is distinct from DescriptorProto.ReservedRange in that it + * is inclusive such that it can appropriately represent the entire int32 + * domain. + */ +export interface EnumDescriptorProto_EnumReservedRange { + /** Inclusive. */ + start?: + | number + | undefined; + /** Inclusive. */ + end?: number | undefined; +} + +/** Describes a value within an enum. */ +export interface EnumValueDescriptorProto { + name?: string | undefined; + number?: number | undefined; + options?: EnumValueOptions | undefined; +} + +/** Describes a service. */ +export interface ServiceDescriptorProto { + name?: string | undefined; + method: MethodDescriptorProto[]; + options?: ServiceOptions | undefined; +} + +/** Describes a method of a service. */ +export interface MethodDescriptorProto { + name?: + | string + | undefined; + /** + * Input and output type names. These are resolved in the same way as + * FieldDescriptorProto.type_name, but must refer to a message type. + */ + inputType?: string | undefined; + outputType?: string | undefined; + options?: + | MethodOptions + | undefined; + /** Identifies if client streams multiple client messages */ + clientStreaming?: + | boolean + | undefined; + /** Identifies if server streams multiple server messages */ + serverStreaming?: boolean | undefined; +} + +export interface FileOptions { + /** + * Sets the Java package where classes generated from this .proto will be + * placed. By default, the proto package is used, but this is often + * inappropriate because proto packages do not normally start with backwards + * domain names. + */ + javaPackage?: + | string + | undefined; + /** + * Controls the name of the wrapper Java class generated for the .proto file. + * That class will always contain the .proto file's getDescriptor() method as + * well as any top-level extensions defined in the .proto file. + * If java_multiple_files is disabled, then all the other classes from the + * .proto file will be nested inside the single wrapper outer class. + */ + javaOuterClassname?: + | string + | undefined; + /** + * If enabled, then the Java code generator will generate a separate .java + * file for each top-level message, enum, and service defined in the .proto + * file. Thus, these types will *not* be nested inside the wrapper class + * named by java_outer_classname. However, the wrapper class will still be + * generated to contain the file's getDescriptor() method as well as any + * top-level extensions defined in the file. + */ + javaMultipleFiles?: + | boolean + | undefined; + /** + * This option does nothing. + * + * @deprecated + */ + javaGenerateEqualsAndHash?: + | boolean + | undefined; + /** + * If set true, then the Java2 code generator will generate code that + * throws an exception whenever an attempt is made to assign a non-UTF-8 + * byte sequence to a string field. + * Message reflection will do the same. + * However, an extension field still accepts non-UTF-8 byte sequences. + * This option has no effect on when used with the lite runtime. + */ + javaStringCheckUtf8?: boolean | undefined; + optimizeFor?: + | FileOptions_OptimizeMode + | undefined; + /** + * Sets the Go package where structs generated from this .proto will be + * placed. If omitted, the Go package will be derived from the following: + * - The basename of the package import path, if provided. + * - Otherwise, the package statement in the .proto file, if present. + * - Otherwise, the basename of the .proto file, without extension. + */ + goPackage?: + | string + | undefined; + /** + * Should generic services be generated in each language? "Generic" services + * are not specific to any particular RPC system. They are generated by the + * main code generators in each language (without additional plugins). + * Generic services were the only kind of service generation supported by + * early versions of google.protobuf. + * + * Generic services are now considered deprecated in favor of using plugins + * that generate code specific to your particular RPC system. Therefore, + * these default to false. Old code which depends on generic services should + * explicitly set them to true. + */ + ccGenericServices?: boolean | undefined; + javaGenericServices?: boolean | undefined; + pyGenericServices?: boolean | undefined; + phpGenericServices?: + | boolean + | undefined; + /** + * Is this file deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for everything in the file, or it will be completely ignored; in the very + * least, this is a formalization for deprecating files. + */ + deprecated?: + | boolean + | undefined; + /** + * Enables the use of arenas for the proto messages in this file. This applies + * only to generated classes for C++. + */ + ccEnableArenas?: + | boolean + | undefined; + /** + * Sets the objective c class prefix which is prepended to all objective c + * generated classes from this .proto. There is no default. + */ + objcClassPrefix?: + | string + | undefined; + /** Namespace for generated classes; defaults to the package. */ + csharpNamespace?: + | string + | undefined; + /** + * By default Swift generators will take the proto package and CamelCase it + * replacing '.' with underscore and use that to prefix the types/symbols + * defined. When this options is provided, they will use this value instead + * to prefix the types/symbols defined. + */ + swiftPrefix?: + | string + | undefined; + /** + * Sets the php class prefix which is prepended to all php generated classes + * from this .proto. Default is empty. + */ + phpClassPrefix?: + | string + | undefined; + /** + * Use this option to change the namespace of php generated classes. Default + * is empty. When this option is empty, the package name will be used for + * determining the namespace. + */ + phpNamespace?: + | string + | undefined; + /** + * Use this option to change the namespace of php generated metadata classes. + * Default is empty. When this option is empty, the proto file name will be + * used for determining the namespace. + */ + phpMetadataNamespace?: + | string + | undefined; + /** + * Use this option to change the package of ruby generated classes. Default + * is empty. When this option is not set, the package name will be used for + * determining the ruby package. + */ + rubyPackage?: + | string + | undefined; + /** + * The parser stores options it doesn't recognize here. + * See the documentation for the "Options" section above. + */ + uninterpretedOption: UninterpretedOption[]; +} + +/** Generated classes can be optimized for speed or code size. */ +export enum FileOptions_OptimizeMode { + /** SPEED - Generate complete code for parsing, serialization, */ + SPEED = 1, + /** CODE_SIZE - etc. */ + CODE_SIZE = 2, + /** LITE_RUNTIME - Generate code using MessageLite and the lite runtime. */ + LITE_RUNTIME = 3, + UNRECOGNIZED = -1, +} + +export function fileOptions_OptimizeModeFromJSON(object: any): FileOptions_OptimizeMode { + switch (object) { + case 1: + case "SPEED": + return FileOptions_OptimizeMode.SPEED; + case 2: + case "CODE_SIZE": + return FileOptions_OptimizeMode.CODE_SIZE; + case 3: + case "LITE_RUNTIME": + return FileOptions_OptimizeMode.LITE_RUNTIME; + case -1: + case "UNRECOGNIZED": + default: + return FileOptions_OptimizeMode.UNRECOGNIZED; + } +} + +export function fileOptions_OptimizeModeToJSON(object: FileOptions_OptimizeMode): string { + switch (object) { + case FileOptions_OptimizeMode.SPEED: + return "SPEED"; + case FileOptions_OptimizeMode.CODE_SIZE: + return "CODE_SIZE"; + case FileOptions_OptimizeMode.LITE_RUNTIME: + return "LITE_RUNTIME"; + case FileOptions_OptimizeMode.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } +} + +export interface MessageOptions { + /** + * Set true to use the old proto1 MessageSet wire format for extensions. + * This is provided for backwards-compatibility with the MessageSet wire + * format. You should not use this for any other reason: It's less + * efficient, has fewer features, and is more complicated. + * + * The message must be defined exactly as follows: + * message Foo { + * option message_set_wire_format = true; + * extensions 4 to max; + * } + * Note that the message cannot have any defined fields; MessageSets only + * have extensions. + * + * All extensions of your type must be singular messages; e.g. they cannot + * be int32s, enums, or repeated messages. + * + * Because this is an option, the above two restrictions are not enforced by + * the protocol compiler. + */ + messageSetWireFormat?: + | boolean + | undefined; + /** + * Disables the generation of the standard "descriptor()" accessor, which can + * conflict with a field of the same name. This is meant to make migration + * from proto1 easier; new code should avoid fields named "descriptor". + */ + noStandardDescriptorAccessor?: + | boolean + | undefined; + /** + * Is this message deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for the message, or it will be completely ignored; in the very least, + * this is a formalization for deprecating messages. + */ + deprecated?: + | boolean + | undefined; + /** + * Whether the message is an automatically generated map entry type for the + * maps field. + * + * For maps fields: + * map map_field = 1; + * The parsed descriptor looks like: + * message MapFieldEntry { + * option map_entry = true; + * optional KeyType key = 1; + * optional ValueType value = 2; + * } + * repeated MapFieldEntry map_field = 1; + * + * Implementations may choose not to generate the map_entry=true message, but + * use a native map in the target language to hold the keys and values. + * The reflection APIs in such implementations still need to work as + * if the field is a repeated message field. + * + * NOTE: Do not set the option in .proto files. Always use the maps syntax + * instead. The option should only be implicitly set by the proto compiler + * parser. + */ + mapEntry?: + | boolean + | undefined; + /** The parser stores options it doesn't recognize here. See above. */ + uninterpretedOption: UninterpretedOption[]; +} + +export interface FieldOptions { + /** + * The ctype option instructs the C++ code generator to use a different + * representation of the field than it normally would. See the specific + * options below. This option is not yet implemented in the open source + * release -- sorry, we'll try to include it in a future version! + */ + ctype?: + | FieldOptions_CType + | undefined; + /** + * The packed option can be enabled for repeated primitive fields to enable + * a more efficient representation on the wire. Rather than repeatedly + * writing the tag and type for each element, the entire array is encoded as + * a single length-delimited blob. In proto3, only explicit setting it to + * false will avoid using packed encoding. + */ + packed?: + | boolean + | undefined; + /** + * The jstype option determines the JavaScript type used for values of the + * field. The option is permitted only for 64 bit integral and fixed types + * (int64, uint64, sint64, fixed64, sfixed64). A field with jstype JS_STRING + * is represented as JavaScript string, which avoids loss of precision that + * can happen when a large value is converted to a floating point JavaScript. + * Specifying JS_NUMBER for the jstype causes the generated JavaScript code to + * use the JavaScript "number" type. The behavior of the default option + * JS_NORMAL is implementation dependent. + * + * This option is an enum to permit additional types to be added, e.g. + * goog.math.Integer. + */ + jstype?: + | FieldOptions_JSType + | undefined; + /** + * Should this field be parsed lazily? Lazy applies only to message-type + * fields. It means that when the outer message is initially parsed, the + * inner message's contents will not be parsed but instead stored in encoded + * form. The inner message will actually be parsed when it is first accessed. + * + * This is only a hint. Implementations are free to choose whether to use + * eager or lazy parsing regardless of the value of this option. However, + * setting this option true suggests that the protocol author believes that + * using lazy parsing on this field is worth the additional bookkeeping + * overhead typically needed to implement it. + * + * This option does not affect the public interface of any generated code; + * all method signatures remain the same. Furthermore, thread-safety of the + * interface is not affected by this option; const methods remain safe to + * call from multiple threads concurrently, while non-const methods continue + * to require exclusive access. + * + * Note that implementations may choose not to check required fields within + * a lazy sub-message. That is, calling IsInitialized() on the outer message + * may return true even if the inner message has missing required fields. + * This is necessary because otherwise the inner message would have to be + * parsed in order to perform the check, defeating the purpose of lazy + * parsing. An implementation which chooses not to check required fields + * must be consistent about it. That is, for any particular sub-message, the + * implementation must either *always* check its required fields, or *never* + * check its required fields, regardless of whether or not the message has + * been parsed. + * + * As of 2021, lazy does no correctness checks on the byte stream during + * parsing. This may lead to crashes if and when an invalid byte stream is + * finally parsed upon access. + * + * TODO(b/211906113): Enable validation on lazy fields. + */ + lazy?: + | boolean + | undefined; + /** + * unverified_lazy does no correctness checks on the byte stream. This should + * only be used where lazy with verification is prohibitive for performance + * reasons. + */ + unverifiedLazy?: + | boolean + | undefined; + /** + * Is this field deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for accessors, or it will be completely ignored; in the very least, this + * is a formalization for deprecating fields. + */ + deprecated?: + | boolean + | undefined; + /** For Google-internal migration only. Do not use. */ + weak?: + | boolean + | undefined; + /** The parser stores options it doesn't recognize here. See above. */ + uninterpretedOption: UninterpretedOption[]; +} + +export enum FieldOptions_CType { + /** STRING - Default mode. */ + STRING = 0, + CORD = 1, + STRING_PIECE = 2, + UNRECOGNIZED = -1, +} + +export function fieldOptions_CTypeFromJSON(object: any): FieldOptions_CType { + switch (object) { + case 0: + case "STRING": + return FieldOptions_CType.STRING; + case 1: + case "CORD": + return FieldOptions_CType.CORD; + case 2: + case "STRING_PIECE": + return FieldOptions_CType.STRING_PIECE; + case -1: + case "UNRECOGNIZED": + default: + return FieldOptions_CType.UNRECOGNIZED; + } +} + +export function fieldOptions_CTypeToJSON(object: FieldOptions_CType): string { + switch (object) { + case FieldOptions_CType.STRING: + return "STRING"; + case FieldOptions_CType.CORD: + return "CORD"; + case FieldOptions_CType.STRING_PIECE: + return "STRING_PIECE"; + case FieldOptions_CType.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } +} + +export enum FieldOptions_JSType { + /** JS_NORMAL - Use the default type. */ + JS_NORMAL = 0, + /** JS_STRING - Use JavaScript strings. */ + JS_STRING = 1, + /** JS_NUMBER - Use JavaScript numbers. */ + JS_NUMBER = 2, + UNRECOGNIZED = -1, +} + +export function fieldOptions_JSTypeFromJSON(object: any): FieldOptions_JSType { + switch (object) { + case 0: + case "JS_NORMAL": + return FieldOptions_JSType.JS_NORMAL; + case 1: + case "JS_STRING": + return FieldOptions_JSType.JS_STRING; + case 2: + case "JS_NUMBER": + return FieldOptions_JSType.JS_NUMBER; + case -1: + case "UNRECOGNIZED": + default: + return FieldOptions_JSType.UNRECOGNIZED; + } +} + +export function fieldOptions_JSTypeToJSON(object: FieldOptions_JSType): string { + switch (object) { + case FieldOptions_JSType.JS_NORMAL: + return "JS_NORMAL"; + case FieldOptions_JSType.JS_STRING: + return "JS_STRING"; + case FieldOptions_JSType.JS_NUMBER: + return "JS_NUMBER"; + case FieldOptions_JSType.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } +} + +export interface OneofOptions { + /** The parser stores options it doesn't recognize here. See above. */ + uninterpretedOption: UninterpretedOption[]; +} + +export interface EnumOptions { + /** + * Set this option to true to allow mapping different tag names to the same + * value. + */ + allowAlias?: + | boolean + | undefined; + /** + * Is this enum deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for the enum, or it will be completely ignored; in the very least, this + * is a formalization for deprecating enums. + */ + deprecated?: + | boolean + | undefined; + /** The parser stores options it doesn't recognize here. See above. */ + uninterpretedOption: UninterpretedOption[]; +} + +export interface EnumValueOptions { + /** + * Is this enum value deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for the enum value, or it will be completely ignored; in the very least, + * this is a formalization for deprecating enum values. + */ + deprecated?: + | boolean + | undefined; + /** The parser stores options it doesn't recognize here. See above. */ + uninterpretedOption: UninterpretedOption[]; +} + +export interface ServiceOptions { + /** + * Is this service deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for the service, or it will be completely ignored; in the very least, + * this is a formalization for deprecating services. + */ + deprecated?: + | boolean + | undefined; + /** The parser stores options it doesn't recognize here. See above. */ + uninterpretedOption: UninterpretedOption[]; +} + +export interface MethodOptions { + /** + * Is this method deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for the method, or it will be completely ignored; in the very least, + * this is a formalization for deprecating methods. + */ + deprecated?: boolean | undefined; + idempotencyLevel?: + | MethodOptions_IdempotencyLevel + | undefined; + /** The parser stores options it doesn't recognize here. See above. */ + uninterpretedOption: UninterpretedOption[]; +} + +/** + * Is this method side-effect-free (or safe in HTTP parlance), or idempotent, + * or neither? HTTP based RPC implementation may choose GET verb for safe + * methods, and PUT verb for idempotent methods instead of the default POST. + */ +export enum MethodOptions_IdempotencyLevel { + IDEMPOTENCY_UNKNOWN = 0, + /** NO_SIDE_EFFECTS - implies idempotent */ + NO_SIDE_EFFECTS = 1, + /** IDEMPOTENT - idempotent, but may have side effects */ + IDEMPOTENT = 2, + UNRECOGNIZED = -1, +} + +export function methodOptions_IdempotencyLevelFromJSON(object: any): MethodOptions_IdempotencyLevel { + switch (object) { + case 0: + case "IDEMPOTENCY_UNKNOWN": + return MethodOptions_IdempotencyLevel.IDEMPOTENCY_UNKNOWN; + case 1: + case "NO_SIDE_EFFECTS": + return MethodOptions_IdempotencyLevel.NO_SIDE_EFFECTS; + case 2: + case "IDEMPOTENT": + return MethodOptions_IdempotencyLevel.IDEMPOTENT; + case -1: + case "UNRECOGNIZED": + default: + return MethodOptions_IdempotencyLevel.UNRECOGNIZED; + } +} + +export function methodOptions_IdempotencyLevelToJSON(object: MethodOptions_IdempotencyLevel): string { + switch (object) { + case MethodOptions_IdempotencyLevel.IDEMPOTENCY_UNKNOWN: + return "IDEMPOTENCY_UNKNOWN"; + case MethodOptions_IdempotencyLevel.NO_SIDE_EFFECTS: + return "NO_SIDE_EFFECTS"; + case MethodOptions_IdempotencyLevel.IDEMPOTENT: + return "IDEMPOTENT"; + case MethodOptions_IdempotencyLevel.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } +} + +/** + * A message representing a option the parser does not recognize. This only + * appears in options protos created by the compiler::Parser class. + * DescriptorPool resolves these when building Descriptor objects. Therefore, + * options protos in descriptor objects (e.g. returned by Descriptor::options(), + * or produced by Descriptor::CopyTo()) will never have UninterpretedOptions + * in them. + */ +export interface UninterpretedOption { + name: UninterpretedOption_NamePart[]; + /** + * The value of the uninterpreted option, in whatever type the tokenizer + * identified it as during parsing. Exactly one of these should be set. + */ + identifierValue?: string | undefined; + positiveIntValue?: number | undefined; + negativeIntValue?: number | undefined; + doubleValue?: number | undefined; + stringValue?: Uint8Array | undefined; + aggregateValue?: string | undefined; +} + +/** + * The name of the uninterpreted option. Each string represents a segment in + * a dot-separated name. is_extension is true iff a segment represents an + * extension (denoted with parentheses in options specs in .proto files). + * E.g.,{ ["foo", false], ["bar.baz", true], ["moo", false] } represents + * "foo.(bar.baz).moo". + */ +export interface UninterpretedOption_NamePart { + namePart: string; + isExtension: boolean; +} + +/** + * Encapsulates information about the original source file from which a + * FileDescriptorProto was generated. + */ +export interface SourceCodeInfo { + /** + * A Location identifies a piece of source code in a .proto file which + * corresponds to a particular definition. This information is intended + * to be useful to IDEs, code indexers, documentation generators, and similar + * tools. + * + * For example, say we have a file like: + * message Foo { + * optional string foo = 1; + * } + * Let's look at just the field definition: + * optional string foo = 1; + * ^ ^^ ^^ ^ ^^^ + * a bc de f ghi + * We have the following locations: + * span path represents + * [a,i) [ 4, 0, 2, 0 ] The whole field definition. + * [a,b) [ 4, 0, 2, 0, 4 ] The label (optional). + * [c,d) [ 4, 0, 2, 0, 5 ] The type (string). + * [e,f) [ 4, 0, 2, 0, 1 ] The name (foo). + * [g,h) [ 4, 0, 2, 0, 3 ] The number (1). + * + * Notes: + * - A location may refer to a repeated field itself (i.e. not to any + * particular index within it). This is used whenever a set of elements are + * logically enclosed in a single code segment. For example, an entire + * extend block (possibly containing multiple extension definitions) will + * have an outer location whose path refers to the "extensions" repeated + * field without an index. + * - Multiple locations may have the same path. This happens when a single + * logical declaration is spread out across multiple places. The most + * obvious example is the "extend" block again -- there may be multiple + * extend blocks in the same scope, each of which will have the same path. + * - A location's span is not always a subset of its parent's span. For + * example, the "extendee" of an extension declaration appears at the + * beginning of the "extend" block and is shared by all extensions within + * the block. + * - Just because a location's span is a subset of some other location's span + * does not mean that it is a descendant. For example, a "group" defines + * both a type and a field in a single declaration. Thus, the locations + * corresponding to the type and field and their components will overlap. + * - Code which tries to interpret locations should probably be designed to + * ignore those that it doesn't understand, as more types of locations could + * be recorded in the future. + */ + location: SourceCodeInfo_Location[]; +} + +export interface SourceCodeInfo_Location { + /** + * Identifies which part of the FileDescriptorProto was defined at this + * location. + * + * Each element is a field number or an index. They form a path from + * the root FileDescriptorProto to the place where the definition occurs. + * For example, this path: + * [ 4, 3, 2, 7, 1 ] + * refers to: + * file.message_type(3) // 4, 3 + * .field(7) // 2, 7 + * .name() // 1 + * This is because FileDescriptorProto.message_type has field number 4: + * repeated DescriptorProto message_type = 4; + * and DescriptorProto.field has field number 2: + * repeated FieldDescriptorProto field = 2; + * and FieldDescriptorProto.name has field number 1: + * optional string name = 1; + * + * Thus, the above path gives the location of a field name. If we removed + * the last element: + * [ 4, 3, 2, 7 ] + * this path refers to the whole field declaration (from the beginning + * of the label to the terminating semicolon). + */ + path: number[]; + /** + * Always has exactly three or four elements: start line, start column, + * end line (optional, otherwise assumed same as start line), end column. + * These are packed into a single field for efficiency. Note that line + * and column numbers are zero-based -- typically you will want to add + * 1 to each before displaying to a user. + */ + span: number[]; + /** + * If this SourceCodeInfo represents a complete declaration, these are any + * comments appearing before and after the declaration which appear to be + * attached to the declaration. + * + * A series of line comments appearing on consecutive lines, with no other + * tokens appearing on those lines, will be treated as a single comment. + * + * leading_detached_comments will keep paragraphs of comments that appear + * before (but not connected to) the current element. Each paragraph, + * separated by empty lines, will be one comment element in the repeated + * field. + * + * Only the comment content is provided; comment markers (e.g. //) are + * stripped out. For block comments, leading whitespace and an asterisk + * will be stripped from the beginning of each line other than the first. + * Newlines are included in the output. + * + * Examples: + * + * optional int32 foo = 1; // Comment attached to foo. + * // Comment attached to bar. + * optional int32 bar = 2; + * + * optional string baz = 3; + * // Comment attached to baz. + * // Another line attached to baz. + * + * // Comment attached to moo. + * // + * // Another line attached to moo. + * optional double moo = 4; + * + * // Detached comment for corge. This is not leading or trailing comments + * // to moo or corge because there are blank lines separating it from + * // both. + * + * // Detached comment for corge paragraph 2. + * + * optional string corge = 5; + * /* Block comment attached + * * to corge. Leading asterisks + * * will be removed. * / + * /* Block comment attached to + * * grault. * / + * optional int32 grault = 6; + * + * // ignored detached comments. + */ + leadingComments?: string | undefined; + trailingComments?: string | undefined; + leadingDetachedComments: string[]; +} + +/** + * Describes the relationship between generated code and its original source + * file. A GeneratedCodeInfo message is associated with only one generated + * source file, but may contain references to different source .proto files. + */ +export interface GeneratedCodeInfo { + /** + * An Annotation connects some span of text in generated code to an element + * of its generating .proto file. + */ + annotation: GeneratedCodeInfo_Annotation[]; +} + +export interface GeneratedCodeInfo_Annotation { + /** + * Identifies the element in the original source .proto file. This field + * is formatted the same as SourceCodeInfo.Location.path. + */ + path: number[]; + /** Identifies the filesystem path to the original source .proto. */ + sourceFile?: + | string + | undefined; + /** + * Identifies the starting offset in bytes in the generated code + * that relates to the identified object. + */ + begin?: + | number + | undefined; + /** + * Identifies the ending offset in bytes in the generated code that + * relates to the identified offset. The end offset should be one past + * the last relevant byte (so the length of the text = end - begin). + */ + end?: number | undefined; +} + +function createBaseFileDescriptorSet(): FileDescriptorSet { + return { file: [] }; +} + +export const FileDescriptorSet: MessageFns = { + encode(message: FileDescriptorSet, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + for (const v of message.file) { + FileDescriptorProto.encode(v!, writer.uint32(10).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): FileDescriptorSet { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseFileDescriptorSet(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.file.push(FileDescriptorProto.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): FileDescriptorSet { + return { + file: globalThis.Array.isArray(object?.file) ? object.file.map((e: any) => FileDescriptorProto.fromJSON(e)) : [], + }; + }, + + toJSON(message: FileDescriptorSet): unknown { + const obj: any = {}; + if (message.file?.length) { + obj.file = message.file.map((e) => FileDescriptorProto.toJSON(e)); + } + return obj; + }, + + create, I>>(base?: I): FileDescriptorSet { + return FileDescriptorSet.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): FileDescriptorSet { + const message = createBaseFileDescriptorSet(); + message.file = object.file?.map((e) => FileDescriptorProto.fromPartial(e)) || []; + return message; + }, +}; + +function createBaseFileDescriptorProto(): FileDescriptorProto { + return { + name: "", + package: "", + dependency: [], + publicDependency: [], + weakDependency: [], + messageType: [], + enumType: [], + service: [], + extension: [], + options: undefined, + sourceCodeInfo: undefined, + syntax: "", + }; +} + +export const FileDescriptorProto: MessageFns = { + encode(message: FileDescriptorProto, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.name !== undefined && message.name !== "") { + writer.uint32(10).string(message.name); + } + if (message.package !== undefined && message.package !== "") { + writer.uint32(18).string(message.package); + } + for (const v of message.dependency) { + writer.uint32(26).string(v!); + } + writer.uint32(82).fork(); + for (const v of message.publicDependency) { + writer.int32(v); + } + writer.join(); + writer.uint32(90).fork(); + for (const v of message.weakDependency) { + writer.int32(v); + } + writer.join(); + for (const v of message.messageType) { + DescriptorProto.encode(v!, writer.uint32(34).fork()).join(); + } + for (const v of message.enumType) { + EnumDescriptorProto.encode(v!, writer.uint32(42).fork()).join(); + } + for (const v of message.service) { + ServiceDescriptorProto.encode(v!, writer.uint32(50).fork()).join(); + } + for (const v of message.extension) { + FieldDescriptorProto.encode(v!, writer.uint32(58).fork()).join(); + } + if (message.options !== undefined) { + FileOptions.encode(message.options, writer.uint32(66).fork()).join(); + } + if (message.sourceCodeInfo !== undefined) { + SourceCodeInfo.encode(message.sourceCodeInfo, writer.uint32(74).fork()).join(); + } + if (message.syntax !== undefined && message.syntax !== "") { + writer.uint32(98).string(message.syntax); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): FileDescriptorProto { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseFileDescriptorProto(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.package = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.dependency.push(reader.string()); + continue; + } + case 10: { + if (tag === 80) { + message.publicDependency.push(reader.int32()); + + continue; + } + + if (tag === 82) { + const end2 = reader.uint32() + reader.pos; + while (reader.pos < end2) { + message.publicDependency.push(reader.int32()); + } + + continue; + } + + break; + } + case 11: { + if (tag === 88) { + message.weakDependency.push(reader.int32()); + + continue; + } + + if (tag === 90) { + const end2 = reader.uint32() + reader.pos; + while (reader.pos < end2) { + message.weakDependency.push(reader.int32()); + } + + continue; + } + + break; + } + case 4: { + if (tag !== 34) { + break; + } + + message.messageType.push(DescriptorProto.decode(reader, reader.uint32())); + continue; + } + case 5: { + if (tag !== 42) { + break; + } + + message.enumType.push(EnumDescriptorProto.decode(reader, reader.uint32())); + continue; + } + case 6: { + if (tag !== 50) { + break; + } + + message.service.push(ServiceDescriptorProto.decode(reader, reader.uint32())); + continue; + } + case 7: { + if (tag !== 58) { + break; + } + + message.extension.push(FieldDescriptorProto.decode(reader, reader.uint32())); + continue; + } + case 8: { + if (tag !== 66) { + break; + } + + message.options = FileOptions.decode(reader, reader.uint32()); + continue; + } + case 9: { + if (tag !== 74) { + break; + } + + message.sourceCodeInfo = SourceCodeInfo.decode(reader, reader.uint32()); + continue; + } + case 12: { + if (tag !== 98) { + break; + } + + message.syntax = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): FileDescriptorProto { + return { + name: isSet(object.name) ? globalThis.String(object.name) : "", + package: isSet(object.package) ? globalThis.String(object.package) : "", + dependency: globalThis.Array.isArray(object?.dependency) + ? object.dependency.map((e: any) => globalThis.String(e)) + : [], + publicDependency: globalThis.Array.isArray(object?.publicDependency) + ? object.publicDependency.map((e: any) => globalThis.Number(e)) + : [], + weakDependency: globalThis.Array.isArray(object?.weakDependency) + ? object.weakDependency.map((e: any) => globalThis.Number(e)) + : [], + messageType: globalThis.Array.isArray(object?.messageType) + ? object.messageType.map((e: any) => DescriptorProto.fromJSON(e)) + : [], + enumType: globalThis.Array.isArray(object?.enumType) + ? object.enumType.map((e: any) => EnumDescriptorProto.fromJSON(e)) + : [], + service: globalThis.Array.isArray(object?.service) + ? object.service.map((e: any) => ServiceDescriptorProto.fromJSON(e)) + : [], + extension: globalThis.Array.isArray(object?.extension) + ? object.extension.map((e: any) => FieldDescriptorProto.fromJSON(e)) + : [], + options: isSet(object.options) ? FileOptions.fromJSON(object.options) : undefined, + sourceCodeInfo: isSet(object.sourceCodeInfo) ? SourceCodeInfo.fromJSON(object.sourceCodeInfo) : undefined, + syntax: isSet(object.syntax) ? globalThis.String(object.syntax) : "", + }; + }, + + toJSON(message: FileDescriptorProto): unknown { + const obj: any = {}; + if (message.name !== undefined && message.name !== "") { + obj.name = message.name; + } + if (message.package !== undefined && message.package !== "") { + obj.package = message.package; + } + if (message.dependency?.length) { + obj.dependency = message.dependency; + } + if (message.publicDependency?.length) { + obj.publicDependency = message.publicDependency.map((e) => Math.round(e)); + } + if (message.weakDependency?.length) { + obj.weakDependency = message.weakDependency.map((e) => Math.round(e)); + } + if (message.messageType?.length) { + obj.messageType = message.messageType.map((e) => DescriptorProto.toJSON(e)); + } + if (message.enumType?.length) { + obj.enumType = message.enumType.map((e) => EnumDescriptorProto.toJSON(e)); + } + if (message.service?.length) { + obj.service = message.service.map((e) => ServiceDescriptorProto.toJSON(e)); + } + if (message.extension?.length) { + obj.extension = message.extension.map((e) => FieldDescriptorProto.toJSON(e)); + } + if (message.options !== undefined) { + obj.options = FileOptions.toJSON(message.options); + } + if (message.sourceCodeInfo !== undefined) { + obj.sourceCodeInfo = SourceCodeInfo.toJSON(message.sourceCodeInfo); + } + if (message.syntax !== undefined && message.syntax !== "") { + obj.syntax = message.syntax; + } + return obj; + }, + + create, I>>(base?: I): FileDescriptorProto { + return FileDescriptorProto.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): FileDescriptorProto { + const message = createBaseFileDescriptorProto(); + message.name = object.name ?? ""; + message.package = object.package ?? ""; + message.dependency = object.dependency?.map((e) => e) || []; + message.publicDependency = object.publicDependency?.map((e) => e) || []; + message.weakDependency = object.weakDependency?.map((e) => e) || []; + message.messageType = object.messageType?.map((e) => DescriptorProto.fromPartial(e)) || []; + message.enumType = object.enumType?.map((e) => EnumDescriptorProto.fromPartial(e)) || []; + message.service = object.service?.map((e) => ServiceDescriptorProto.fromPartial(e)) || []; + message.extension = object.extension?.map((e) => FieldDescriptorProto.fromPartial(e)) || []; + message.options = (object.options !== undefined && object.options !== null) + ? FileOptions.fromPartial(object.options) + : undefined; + message.sourceCodeInfo = (object.sourceCodeInfo !== undefined && object.sourceCodeInfo !== null) + ? SourceCodeInfo.fromPartial(object.sourceCodeInfo) + : undefined; + message.syntax = object.syntax ?? ""; + return message; + }, +}; + +function createBaseDescriptorProto(): DescriptorProto { + return { + name: "", + field: [], + extension: [], + nestedType: [], + enumType: [], + extensionRange: [], + oneofDecl: [], + options: undefined, + reservedRange: [], + reservedName: [], + }; +} + +export const DescriptorProto: MessageFns = { + encode(message: DescriptorProto, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.name !== undefined && message.name !== "") { + writer.uint32(10).string(message.name); + } + for (const v of message.field) { + FieldDescriptorProto.encode(v!, writer.uint32(18).fork()).join(); + } + for (const v of message.extension) { + FieldDescriptorProto.encode(v!, writer.uint32(50).fork()).join(); + } + for (const v of message.nestedType) { + DescriptorProto.encode(v!, writer.uint32(26).fork()).join(); + } + for (const v of message.enumType) { + EnumDescriptorProto.encode(v!, writer.uint32(34).fork()).join(); + } + for (const v of message.extensionRange) { + DescriptorProto_ExtensionRange.encode(v!, writer.uint32(42).fork()).join(); + } + for (const v of message.oneofDecl) { + OneofDescriptorProto.encode(v!, writer.uint32(66).fork()).join(); + } + if (message.options !== undefined) { + MessageOptions.encode(message.options, writer.uint32(58).fork()).join(); + } + for (const v of message.reservedRange) { + DescriptorProto_ReservedRange.encode(v!, writer.uint32(74).fork()).join(); + } + for (const v of message.reservedName) { + writer.uint32(82).string(v!); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): DescriptorProto { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseDescriptorProto(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.field.push(FieldDescriptorProto.decode(reader, reader.uint32())); + continue; + } + case 6: { + if (tag !== 50) { + break; + } + + message.extension.push(FieldDescriptorProto.decode(reader, reader.uint32())); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.nestedType.push(DescriptorProto.decode(reader, reader.uint32())); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.enumType.push(EnumDescriptorProto.decode(reader, reader.uint32())); + continue; + } + case 5: { + if (tag !== 42) { + break; + } + + message.extensionRange.push(DescriptorProto_ExtensionRange.decode(reader, reader.uint32())); + continue; + } + case 8: { + if (tag !== 66) { + break; + } + + message.oneofDecl.push(OneofDescriptorProto.decode(reader, reader.uint32())); + continue; + } + case 7: { + if (tag !== 58) { + break; + } + + message.options = MessageOptions.decode(reader, reader.uint32()); + continue; + } + case 9: { + if (tag !== 74) { + break; + } + + message.reservedRange.push(DescriptorProto_ReservedRange.decode(reader, reader.uint32())); + continue; + } + case 10: { + if (tag !== 82) { + break; + } + + message.reservedName.push(reader.string()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): DescriptorProto { + return { + name: isSet(object.name) ? globalThis.String(object.name) : "", + field: globalThis.Array.isArray(object?.field) + ? object.field.map((e: any) => FieldDescriptorProto.fromJSON(e)) + : [], + extension: globalThis.Array.isArray(object?.extension) + ? object.extension.map((e: any) => FieldDescriptorProto.fromJSON(e)) + : [], + nestedType: globalThis.Array.isArray(object?.nestedType) + ? object.nestedType.map((e: any) => DescriptorProto.fromJSON(e)) + : [], + enumType: globalThis.Array.isArray(object?.enumType) + ? object.enumType.map((e: any) => EnumDescriptorProto.fromJSON(e)) + : [], + extensionRange: globalThis.Array.isArray(object?.extensionRange) + ? object.extensionRange.map((e: any) => DescriptorProto_ExtensionRange.fromJSON(e)) + : [], + oneofDecl: globalThis.Array.isArray(object?.oneofDecl) + ? object.oneofDecl.map((e: any) => OneofDescriptorProto.fromJSON(e)) + : [], + options: isSet(object.options) ? MessageOptions.fromJSON(object.options) : undefined, + reservedRange: globalThis.Array.isArray(object?.reservedRange) + ? object.reservedRange.map((e: any) => DescriptorProto_ReservedRange.fromJSON(e)) + : [], + reservedName: globalThis.Array.isArray(object?.reservedName) + ? object.reservedName.map((e: any) => globalThis.String(e)) + : [], + }; + }, + + toJSON(message: DescriptorProto): unknown { + const obj: any = {}; + if (message.name !== undefined && message.name !== "") { + obj.name = message.name; + } + if (message.field?.length) { + obj.field = message.field.map((e) => FieldDescriptorProto.toJSON(e)); + } + if (message.extension?.length) { + obj.extension = message.extension.map((e) => FieldDescriptorProto.toJSON(e)); + } + if (message.nestedType?.length) { + obj.nestedType = message.nestedType.map((e) => DescriptorProto.toJSON(e)); + } + if (message.enumType?.length) { + obj.enumType = message.enumType.map((e) => EnumDescriptorProto.toJSON(e)); + } + if (message.extensionRange?.length) { + obj.extensionRange = message.extensionRange.map((e) => DescriptorProto_ExtensionRange.toJSON(e)); + } + if (message.oneofDecl?.length) { + obj.oneofDecl = message.oneofDecl.map((e) => OneofDescriptorProto.toJSON(e)); + } + if (message.options !== undefined) { + obj.options = MessageOptions.toJSON(message.options); + } + if (message.reservedRange?.length) { + obj.reservedRange = message.reservedRange.map((e) => DescriptorProto_ReservedRange.toJSON(e)); + } + if (message.reservedName?.length) { + obj.reservedName = message.reservedName; + } + return obj; + }, + + create, I>>(base?: I): DescriptorProto { + return DescriptorProto.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): DescriptorProto { + const message = createBaseDescriptorProto(); + message.name = object.name ?? ""; + message.field = object.field?.map((e) => FieldDescriptorProto.fromPartial(e)) || []; + message.extension = object.extension?.map((e) => FieldDescriptorProto.fromPartial(e)) || []; + message.nestedType = object.nestedType?.map((e) => DescriptorProto.fromPartial(e)) || []; + message.enumType = object.enumType?.map((e) => EnumDescriptorProto.fromPartial(e)) || []; + message.extensionRange = object.extensionRange?.map((e) => DescriptorProto_ExtensionRange.fromPartial(e)) || []; + message.oneofDecl = object.oneofDecl?.map((e) => OneofDescriptorProto.fromPartial(e)) || []; + message.options = (object.options !== undefined && object.options !== null) + ? MessageOptions.fromPartial(object.options) + : undefined; + message.reservedRange = object.reservedRange?.map((e) => DescriptorProto_ReservedRange.fromPartial(e)) || []; + message.reservedName = object.reservedName?.map((e) => e) || []; + return message; + }, +}; + +function createBaseDescriptorProto_ExtensionRange(): DescriptorProto_ExtensionRange { + return { start: 0, end: 0, options: undefined }; +} + +export const DescriptorProto_ExtensionRange: MessageFns = { + encode(message: DescriptorProto_ExtensionRange, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.start !== undefined && message.start !== 0) { + writer.uint32(8).int32(message.start); + } + if (message.end !== undefined && message.end !== 0) { + writer.uint32(16).int32(message.end); + } + if (message.options !== undefined) { + ExtensionRangeOptions.encode(message.options, writer.uint32(26).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): DescriptorProto_ExtensionRange { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseDescriptorProto_ExtensionRange(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.start = reader.int32(); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.end = reader.int32(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.options = ExtensionRangeOptions.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): DescriptorProto_ExtensionRange { + return { + start: isSet(object.start) ? globalThis.Number(object.start) : 0, + end: isSet(object.end) ? globalThis.Number(object.end) : 0, + options: isSet(object.options) ? ExtensionRangeOptions.fromJSON(object.options) : undefined, + }; + }, + + toJSON(message: DescriptorProto_ExtensionRange): unknown { + const obj: any = {}; + if (message.start !== undefined && message.start !== 0) { + obj.start = Math.round(message.start); + } + if (message.end !== undefined && message.end !== 0) { + obj.end = Math.round(message.end); + } + if (message.options !== undefined) { + obj.options = ExtensionRangeOptions.toJSON(message.options); + } + return obj; + }, + + create, I>>(base?: I): DescriptorProto_ExtensionRange { + return DescriptorProto_ExtensionRange.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>( + object: I, + ): DescriptorProto_ExtensionRange { + const message = createBaseDescriptorProto_ExtensionRange(); + message.start = object.start ?? 0; + message.end = object.end ?? 0; + message.options = (object.options !== undefined && object.options !== null) + ? ExtensionRangeOptions.fromPartial(object.options) + : undefined; + return message; + }, +}; + +function createBaseDescriptorProto_ReservedRange(): DescriptorProto_ReservedRange { + return { start: 0, end: 0 }; +} + +export const DescriptorProto_ReservedRange: MessageFns = { + encode(message: DescriptorProto_ReservedRange, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.start !== undefined && message.start !== 0) { + writer.uint32(8).int32(message.start); + } + if (message.end !== undefined && message.end !== 0) { + writer.uint32(16).int32(message.end); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): DescriptorProto_ReservedRange { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseDescriptorProto_ReservedRange(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.start = reader.int32(); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.end = reader.int32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): DescriptorProto_ReservedRange { + return { + start: isSet(object.start) ? globalThis.Number(object.start) : 0, + end: isSet(object.end) ? globalThis.Number(object.end) : 0, + }; + }, + + toJSON(message: DescriptorProto_ReservedRange): unknown { + const obj: any = {}; + if (message.start !== undefined && message.start !== 0) { + obj.start = Math.round(message.start); + } + if (message.end !== undefined && message.end !== 0) { + obj.end = Math.round(message.end); + } + return obj; + }, + + create, I>>(base?: I): DescriptorProto_ReservedRange { + return DescriptorProto_ReservedRange.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>( + object: I, + ): DescriptorProto_ReservedRange { + const message = createBaseDescriptorProto_ReservedRange(); + message.start = object.start ?? 0; + message.end = object.end ?? 0; + return message; + }, +}; + +function createBaseExtensionRangeOptions(): ExtensionRangeOptions { + return { uninterpretedOption: [] }; +} + +export const ExtensionRangeOptions: MessageFns = { + encode(message: ExtensionRangeOptions, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + for (const v of message.uninterpretedOption) { + UninterpretedOption.encode(v!, writer.uint32(7994).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): ExtensionRangeOptions { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseExtensionRangeOptions(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 999: { + if (tag !== 7994) { + break; + } + + message.uninterpretedOption.push(UninterpretedOption.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): ExtensionRangeOptions { + return { + uninterpretedOption: globalThis.Array.isArray(object?.uninterpretedOption) + ? object.uninterpretedOption.map((e: any) => UninterpretedOption.fromJSON(e)) + : [], + }; + }, + + toJSON(message: ExtensionRangeOptions): unknown { + const obj: any = {}; + if (message.uninterpretedOption?.length) { + obj.uninterpretedOption = message.uninterpretedOption.map((e) => UninterpretedOption.toJSON(e)); + } + return obj; + }, + + create, I>>(base?: I): ExtensionRangeOptions { + return ExtensionRangeOptions.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): ExtensionRangeOptions { + const message = createBaseExtensionRangeOptions(); + message.uninterpretedOption = object.uninterpretedOption?.map((e) => UninterpretedOption.fromPartial(e)) || []; + return message; + }, +}; + +function createBaseFieldDescriptorProto(): FieldDescriptorProto { + return { + name: "", + number: 0, + label: 1, + type: 1, + typeName: "", + extendee: "", + defaultValue: "", + oneofIndex: 0, + jsonName: "", + options: undefined, + proto3Optional: false, + }; +} + +export const FieldDescriptorProto: MessageFns = { + encode(message: FieldDescriptorProto, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.name !== undefined && message.name !== "") { + writer.uint32(10).string(message.name); + } + if (message.number !== undefined && message.number !== 0) { + writer.uint32(24).int32(message.number); + } + if (message.label !== undefined && message.label !== 1) { + writer.uint32(32).int32(message.label); + } + if (message.type !== undefined && message.type !== 1) { + writer.uint32(40).int32(message.type); + } + if (message.typeName !== undefined && message.typeName !== "") { + writer.uint32(50).string(message.typeName); + } + if (message.extendee !== undefined && message.extendee !== "") { + writer.uint32(18).string(message.extendee); + } + if (message.defaultValue !== undefined && message.defaultValue !== "") { + writer.uint32(58).string(message.defaultValue); + } + if (message.oneofIndex !== undefined && message.oneofIndex !== 0) { + writer.uint32(72).int32(message.oneofIndex); + } + if (message.jsonName !== undefined && message.jsonName !== "") { + writer.uint32(82).string(message.jsonName); + } + if (message.options !== undefined) { + FieldOptions.encode(message.options, writer.uint32(66).fork()).join(); + } + if (message.proto3Optional !== undefined && message.proto3Optional !== false) { + writer.uint32(136).bool(message.proto3Optional); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): FieldDescriptorProto { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseFieldDescriptorProto(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + } + case 3: { + if (tag !== 24) { + break; + } + + message.number = reader.int32(); + continue; + } + case 4: { + if (tag !== 32) { + break; + } + + message.label = reader.int32() as any; + continue; + } + case 5: { + if (tag !== 40) { + break; + } + + message.type = reader.int32() as any; + continue; + } + case 6: { + if (tag !== 50) { + break; + } + + message.typeName = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.extendee = reader.string(); + continue; + } + case 7: { + if (tag !== 58) { + break; + } + + message.defaultValue = reader.string(); + continue; + } + case 9: { + if (tag !== 72) { + break; + } + + message.oneofIndex = reader.int32(); + continue; + } + case 10: { + if (tag !== 82) { + break; + } + + message.jsonName = reader.string(); + continue; + } + case 8: { + if (tag !== 66) { + break; + } + + message.options = FieldOptions.decode(reader, reader.uint32()); + continue; + } + case 17: { + if (tag !== 136) { + break; + } + + message.proto3Optional = reader.bool(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): FieldDescriptorProto { + return { + name: isSet(object.name) ? globalThis.String(object.name) : "", + number: isSet(object.number) ? globalThis.Number(object.number) : 0, + label: isSet(object.label) ? fieldDescriptorProto_LabelFromJSON(object.label) : 1, + type: isSet(object.type) ? fieldDescriptorProto_TypeFromJSON(object.type) : 1, + typeName: isSet(object.typeName) ? globalThis.String(object.typeName) : "", + extendee: isSet(object.extendee) ? globalThis.String(object.extendee) : "", + defaultValue: isSet(object.defaultValue) ? globalThis.String(object.defaultValue) : "", + oneofIndex: isSet(object.oneofIndex) ? globalThis.Number(object.oneofIndex) : 0, + jsonName: isSet(object.jsonName) ? globalThis.String(object.jsonName) : "", + options: isSet(object.options) ? FieldOptions.fromJSON(object.options) : undefined, + proto3Optional: isSet(object.proto3Optional) ? globalThis.Boolean(object.proto3Optional) : false, + }; + }, + + toJSON(message: FieldDescriptorProto): unknown { + const obj: any = {}; + if (message.name !== undefined && message.name !== "") { + obj.name = message.name; + } + if (message.number !== undefined && message.number !== 0) { + obj.number = Math.round(message.number); + } + if (message.label !== undefined && message.label !== 1) { + obj.label = fieldDescriptorProto_LabelToJSON(message.label); + } + if (message.type !== undefined && message.type !== 1) { + obj.type = fieldDescriptorProto_TypeToJSON(message.type); + } + if (message.typeName !== undefined && message.typeName !== "") { + obj.typeName = message.typeName; + } + if (message.extendee !== undefined && message.extendee !== "") { + obj.extendee = message.extendee; + } + if (message.defaultValue !== undefined && message.defaultValue !== "") { + obj.defaultValue = message.defaultValue; + } + if (message.oneofIndex !== undefined && message.oneofIndex !== 0) { + obj.oneofIndex = Math.round(message.oneofIndex); + } + if (message.jsonName !== undefined && message.jsonName !== "") { + obj.jsonName = message.jsonName; + } + if (message.options !== undefined) { + obj.options = FieldOptions.toJSON(message.options); + } + if (message.proto3Optional !== undefined && message.proto3Optional !== false) { + obj.proto3Optional = message.proto3Optional; + } + return obj; + }, + + create, I>>(base?: I): FieldDescriptorProto { + return FieldDescriptorProto.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): FieldDescriptorProto { + const message = createBaseFieldDescriptorProto(); + message.name = object.name ?? ""; + message.number = object.number ?? 0; + message.label = object.label ?? 1; + message.type = object.type ?? 1; + message.typeName = object.typeName ?? ""; + message.extendee = object.extendee ?? ""; + message.defaultValue = object.defaultValue ?? ""; + message.oneofIndex = object.oneofIndex ?? 0; + message.jsonName = object.jsonName ?? ""; + message.options = (object.options !== undefined && object.options !== null) + ? FieldOptions.fromPartial(object.options) + : undefined; + message.proto3Optional = object.proto3Optional ?? false; + return message; + }, +}; + +function createBaseOneofDescriptorProto(): OneofDescriptorProto { + return { name: "", options: undefined }; +} + +export const OneofDescriptorProto: MessageFns = { + encode(message: OneofDescriptorProto, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.name !== undefined && message.name !== "") { + writer.uint32(10).string(message.name); + } + if (message.options !== undefined) { + OneofOptions.encode(message.options, writer.uint32(18).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): OneofDescriptorProto { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseOneofDescriptorProto(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.options = OneofOptions.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): OneofDescriptorProto { + return { + name: isSet(object.name) ? globalThis.String(object.name) : "", + options: isSet(object.options) ? OneofOptions.fromJSON(object.options) : undefined, + }; + }, + + toJSON(message: OneofDescriptorProto): unknown { + const obj: any = {}; + if (message.name !== undefined && message.name !== "") { + obj.name = message.name; + } + if (message.options !== undefined) { + obj.options = OneofOptions.toJSON(message.options); + } + return obj; + }, + + create, I>>(base?: I): OneofDescriptorProto { + return OneofDescriptorProto.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): OneofDescriptorProto { + const message = createBaseOneofDescriptorProto(); + message.name = object.name ?? ""; + message.options = (object.options !== undefined && object.options !== null) + ? OneofOptions.fromPartial(object.options) + : undefined; + return message; + }, +}; + +function createBaseEnumDescriptorProto(): EnumDescriptorProto { + return { name: "", value: [], options: undefined, reservedRange: [], reservedName: [] }; +} + +export const EnumDescriptorProto: MessageFns = { + encode(message: EnumDescriptorProto, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.name !== undefined && message.name !== "") { + writer.uint32(10).string(message.name); + } + for (const v of message.value) { + EnumValueDescriptorProto.encode(v!, writer.uint32(18).fork()).join(); + } + if (message.options !== undefined) { + EnumOptions.encode(message.options, writer.uint32(26).fork()).join(); + } + for (const v of message.reservedRange) { + EnumDescriptorProto_EnumReservedRange.encode(v!, writer.uint32(34).fork()).join(); + } + for (const v of message.reservedName) { + writer.uint32(42).string(v!); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): EnumDescriptorProto { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseEnumDescriptorProto(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.value.push(EnumValueDescriptorProto.decode(reader, reader.uint32())); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.options = EnumOptions.decode(reader, reader.uint32()); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.reservedRange.push(EnumDescriptorProto_EnumReservedRange.decode(reader, reader.uint32())); + continue; + } + case 5: { + if (tag !== 42) { + break; + } + + message.reservedName.push(reader.string()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): EnumDescriptorProto { + return { + name: isSet(object.name) ? globalThis.String(object.name) : "", + value: globalThis.Array.isArray(object?.value) + ? object.value.map((e: any) => EnumValueDescriptorProto.fromJSON(e)) + : [], + options: isSet(object.options) ? EnumOptions.fromJSON(object.options) : undefined, + reservedRange: globalThis.Array.isArray(object?.reservedRange) + ? object.reservedRange.map((e: any) => EnumDescriptorProto_EnumReservedRange.fromJSON(e)) + : [], + reservedName: globalThis.Array.isArray(object?.reservedName) + ? object.reservedName.map((e: any) => globalThis.String(e)) + : [], + }; + }, + + toJSON(message: EnumDescriptorProto): unknown { + const obj: any = {}; + if (message.name !== undefined && message.name !== "") { + obj.name = message.name; + } + if (message.value?.length) { + obj.value = message.value.map((e) => EnumValueDescriptorProto.toJSON(e)); + } + if (message.options !== undefined) { + obj.options = EnumOptions.toJSON(message.options); + } + if (message.reservedRange?.length) { + obj.reservedRange = message.reservedRange.map((e) => EnumDescriptorProto_EnumReservedRange.toJSON(e)); + } + if (message.reservedName?.length) { + obj.reservedName = message.reservedName; + } + return obj; + }, + + create, I>>(base?: I): EnumDescriptorProto { + return EnumDescriptorProto.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): EnumDescriptorProto { + const message = createBaseEnumDescriptorProto(); + message.name = object.name ?? ""; + message.value = object.value?.map((e) => EnumValueDescriptorProto.fromPartial(e)) || []; + message.options = (object.options !== undefined && object.options !== null) + ? EnumOptions.fromPartial(object.options) + : undefined; + message.reservedRange = object.reservedRange?.map((e) => EnumDescriptorProto_EnumReservedRange.fromPartial(e)) || + []; + message.reservedName = object.reservedName?.map((e) => e) || []; + return message; + }, +}; + +function createBaseEnumDescriptorProto_EnumReservedRange(): EnumDescriptorProto_EnumReservedRange { + return { start: 0, end: 0 }; +} + +export const EnumDescriptorProto_EnumReservedRange: MessageFns = { + encode(message: EnumDescriptorProto_EnumReservedRange, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.start !== undefined && message.start !== 0) { + writer.uint32(8).int32(message.start); + } + if (message.end !== undefined && message.end !== 0) { + writer.uint32(16).int32(message.end); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): EnumDescriptorProto_EnumReservedRange { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseEnumDescriptorProto_EnumReservedRange(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.start = reader.int32(); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.end = reader.int32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): EnumDescriptorProto_EnumReservedRange { + return { + start: isSet(object.start) ? globalThis.Number(object.start) : 0, + end: isSet(object.end) ? globalThis.Number(object.end) : 0, + }; + }, + + toJSON(message: EnumDescriptorProto_EnumReservedRange): unknown { + const obj: any = {}; + if (message.start !== undefined && message.start !== 0) { + obj.start = Math.round(message.start); + } + if (message.end !== undefined && message.end !== 0) { + obj.end = Math.round(message.end); + } + return obj; + }, + + create, I>>( + base?: I, + ): EnumDescriptorProto_EnumReservedRange { + return EnumDescriptorProto_EnumReservedRange.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>( + object: I, + ): EnumDescriptorProto_EnumReservedRange { + const message = createBaseEnumDescriptorProto_EnumReservedRange(); + message.start = object.start ?? 0; + message.end = object.end ?? 0; + return message; + }, +}; + +function createBaseEnumValueDescriptorProto(): EnumValueDescriptorProto { + return { name: "", number: 0, options: undefined }; +} + +export const EnumValueDescriptorProto: MessageFns = { + encode(message: EnumValueDescriptorProto, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.name !== undefined && message.name !== "") { + writer.uint32(10).string(message.name); + } + if (message.number !== undefined && message.number !== 0) { + writer.uint32(16).int32(message.number); + } + if (message.options !== undefined) { + EnumValueOptions.encode(message.options, writer.uint32(26).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): EnumValueDescriptorProto { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseEnumValueDescriptorProto(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.number = reader.int32(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.options = EnumValueOptions.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): EnumValueDescriptorProto { + return { + name: isSet(object.name) ? globalThis.String(object.name) : "", + number: isSet(object.number) ? globalThis.Number(object.number) : 0, + options: isSet(object.options) ? EnumValueOptions.fromJSON(object.options) : undefined, + }; + }, + + toJSON(message: EnumValueDescriptorProto): unknown { + const obj: any = {}; + if (message.name !== undefined && message.name !== "") { + obj.name = message.name; + } + if (message.number !== undefined && message.number !== 0) { + obj.number = Math.round(message.number); + } + if (message.options !== undefined) { + obj.options = EnumValueOptions.toJSON(message.options); + } + return obj; + }, + + create, I>>(base?: I): EnumValueDescriptorProto { + return EnumValueDescriptorProto.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): EnumValueDescriptorProto { + const message = createBaseEnumValueDescriptorProto(); + message.name = object.name ?? ""; + message.number = object.number ?? 0; + message.options = (object.options !== undefined && object.options !== null) + ? EnumValueOptions.fromPartial(object.options) + : undefined; + return message; + }, +}; + +function createBaseServiceDescriptorProto(): ServiceDescriptorProto { + return { name: "", method: [], options: undefined }; +} + +export const ServiceDescriptorProto: MessageFns = { + encode(message: ServiceDescriptorProto, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.name !== undefined && message.name !== "") { + writer.uint32(10).string(message.name); + } + for (const v of message.method) { + MethodDescriptorProto.encode(v!, writer.uint32(18).fork()).join(); + } + if (message.options !== undefined) { + ServiceOptions.encode(message.options, writer.uint32(26).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): ServiceDescriptorProto { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseServiceDescriptorProto(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.method.push(MethodDescriptorProto.decode(reader, reader.uint32())); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.options = ServiceOptions.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): ServiceDescriptorProto { + return { + name: isSet(object.name) ? globalThis.String(object.name) : "", + method: globalThis.Array.isArray(object?.method) + ? object.method.map((e: any) => MethodDescriptorProto.fromJSON(e)) + : [], + options: isSet(object.options) ? ServiceOptions.fromJSON(object.options) : undefined, + }; + }, + + toJSON(message: ServiceDescriptorProto): unknown { + const obj: any = {}; + if (message.name !== undefined && message.name !== "") { + obj.name = message.name; + } + if (message.method?.length) { + obj.method = message.method.map((e) => MethodDescriptorProto.toJSON(e)); + } + if (message.options !== undefined) { + obj.options = ServiceOptions.toJSON(message.options); + } + return obj; + }, + + create, I>>(base?: I): ServiceDescriptorProto { + return ServiceDescriptorProto.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): ServiceDescriptorProto { + const message = createBaseServiceDescriptorProto(); + message.name = object.name ?? ""; + message.method = object.method?.map((e) => MethodDescriptorProto.fromPartial(e)) || []; + message.options = (object.options !== undefined && object.options !== null) + ? ServiceOptions.fromPartial(object.options) + : undefined; + return message; + }, +}; + +function createBaseMethodDescriptorProto(): MethodDescriptorProto { + return { + name: "", + inputType: "", + outputType: "", + options: undefined, + clientStreaming: false, + serverStreaming: false, + }; +} + +export const MethodDescriptorProto: MessageFns = { + encode(message: MethodDescriptorProto, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.name !== undefined && message.name !== "") { + writer.uint32(10).string(message.name); + } + if (message.inputType !== undefined && message.inputType !== "") { + writer.uint32(18).string(message.inputType); + } + if (message.outputType !== undefined && message.outputType !== "") { + writer.uint32(26).string(message.outputType); + } + if (message.options !== undefined) { + MethodOptions.encode(message.options, writer.uint32(34).fork()).join(); + } + if (message.clientStreaming !== undefined && message.clientStreaming !== false) { + writer.uint32(40).bool(message.clientStreaming); + } + if (message.serverStreaming !== undefined && message.serverStreaming !== false) { + writer.uint32(48).bool(message.serverStreaming); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): MethodDescriptorProto { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseMethodDescriptorProto(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.inputType = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.outputType = reader.string(); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.options = MethodOptions.decode(reader, reader.uint32()); + continue; + } + case 5: { + if (tag !== 40) { + break; + } + + message.clientStreaming = reader.bool(); + continue; + } + case 6: { + if (tag !== 48) { + break; + } + + message.serverStreaming = reader.bool(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): MethodDescriptorProto { + return { + name: isSet(object.name) ? globalThis.String(object.name) : "", + inputType: isSet(object.inputType) ? globalThis.String(object.inputType) : "", + outputType: isSet(object.outputType) ? globalThis.String(object.outputType) : "", + options: isSet(object.options) ? MethodOptions.fromJSON(object.options) : undefined, + clientStreaming: isSet(object.clientStreaming) ? globalThis.Boolean(object.clientStreaming) : false, + serverStreaming: isSet(object.serverStreaming) ? globalThis.Boolean(object.serverStreaming) : false, + }; + }, + + toJSON(message: MethodDescriptorProto): unknown { + const obj: any = {}; + if (message.name !== undefined && message.name !== "") { + obj.name = message.name; + } + if (message.inputType !== undefined && message.inputType !== "") { + obj.inputType = message.inputType; + } + if (message.outputType !== undefined && message.outputType !== "") { + obj.outputType = message.outputType; + } + if (message.options !== undefined) { + obj.options = MethodOptions.toJSON(message.options); + } + if (message.clientStreaming !== undefined && message.clientStreaming !== false) { + obj.clientStreaming = message.clientStreaming; + } + if (message.serverStreaming !== undefined && message.serverStreaming !== false) { + obj.serverStreaming = message.serverStreaming; + } + return obj; + }, + + create, I>>(base?: I): MethodDescriptorProto { + return MethodDescriptorProto.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): MethodDescriptorProto { + const message = createBaseMethodDescriptorProto(); + message.name = object.name ?? ""; + message.inputType = object.inputType ?? ""; + message.outputType = object.outputType ?? ""; + message.options = (object.options !== undefined && object.options !== null) + ? MethodOptions.fromPartial(object.options) + : undefined; + message.clientStreaming = object.clientStreaming ?? false; + message.serverStreaming = object.serverStreaming ?? false; + return message; + }, +}; + +function createBaseFileOptions(): FileOptions { + return { + javaPackage: "", + javaOuterClassname: "", + javaMultipleFiles: false, + javaGenerateEqualsAndHash: false, + javaStringCheckUtf8: false, + optimizeFor: 1, + goPackage: "", + ccGenericServices: false, + javaGenericServices: false, + pyGenericServices: false, + phpGenericServices: false, + deprecated: false, + ccEnableArenas: true, + objcClassPrefix: "", + csharpNamespace: "", + swiftPrefix: "", + phpClassPrefix: "", + phpNamespace: "", + phpMetadataNamespace: "", + rubyPackage: "", + uninterpretedOption: [], + }; +} + +export const FileOptions: MessageFns = { + encode(message: FileOptions, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.javaPackage !== undefined && message.javaPackage !== "") { + writer.uint32(10).string(message.javaPackage); + } + if (message.javaOuterClassname !== undefined && message.javaOuterClassname !== "") { + writer.uint32(66).string(message.javaOuterClassname); + } + if (message.javaMultipleFiles !== undefined && message.javaMultipleFiles !== false) { + writer.uint32(80).bool(message.javaMultipleFiles); + } + if (message.javaGenerateEqualsAndHash !== undefined && message.javaGenerateEqualsAndHash !== false) { + writer.uint32(160).bool(message.javaGenerateEqualsAndHash); + } + if (message.javaStringCheckUtf8 !== undefined && message.javaStringCheckUtf8 !== false) { + writer.uint32(216).bool(message.javaStringCheckUtf8); + } + if (message.optimizeFor !== undefined && message.optimizeFor !== 1) { + writer.uint32(72).int32(message.optimizeFor); + } + if (message.goPackage !== undefined && message.goPackage !== "") { + writer.uint32(90).string(message.goPackage); + } + if (message.ccGenericServices !== undefined && message.ccGenericServices !== false) { + writer.uint32(128).bool(message.ccGenericServices); + } + if (message.javaGenericServices !== undefined && message.javaGenericServices !== false) { + writer.uint32(136).bool(message.javaGenericServices); + } + if (message.pyGenericServices !== undefined && message.pyGenericServices !== false) { + writer.uint32(144).bool(message.pyGenericServices); + } + if (message.phpGenericServices !== undefined && message.phpGenericServices !== false) { + writer.uint32(336).bool(message.phpGenericServices); + } + if (message.deprecated !== undefined && message.deprecated !== false) { + writer.uint32(184).bool(message.deprecated); + } + if (message.ccEnableArenas !== undefined && message.ccEnableArenas !== true) { + writer.uint32(248).bool(message.ccEnableArenas); + } + if (message.objcClassPrefix !== undefined && message.objcClassPrefix !== "") { + writer.uint32(290).string(message.objcClassPrefix); + } + if (message.csharpNamespace !== undefined && message.csharpNamespace !== "") { + writer.uint32(298).string(message.csharpNamespace); + } + if (message.swiftPrefix !== undefined && message.swiftPrefix !== "") { + writer.uint32(314).string(message.swiftPrefix); + } + if (message.phpClassPrefix !== undefined && message.phpClassPrefix !== "") { + writer.uint32(322).string(message.phpClassPrefix); + } + if (message.phpNamespace !== undefined && message.phpNamespace !== "") { + writer.uint32(330).string(message.phpNamespace); + } + if (message.phpMetadataNamespace !== undefined && message.phpMetadataNamespace !== "") { + writer.uint32(354).string(message.phpMetadataNamespace); + } + if (message.rubyPackage !== undefined && message.rubyPackage !== "") { + writer.uint32(362).string(message.rubyPackage); + } + for (const v of message.uninterpretedOption) { + UninterpretedOption.encode(v!, writer.uint32(7994).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): FileOptions { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseFileOptions(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.javaPackage = reader.string(); + continue; + } + case 8: { + if (tag !== 66) { + break; + } + + message.javaOuterClassname = reader.string(); + continue; + } + case 10: { + if (tag !== 80) { + break; + } + + message.javaMultipleFiles = reader.bool(); + continue; + } + case 20: { + if (tag !== 160) { + break; + } + + message.javaGenerateEqualsAndHash = reader.bool(); + continue; + } + case 27: { + if (tag !== 216) { + break; + } + + message.javaStringCheckUtf8 = reader.bool(); + continue; + } + case 9: { + if (tag !== 72) { + break; + } + + message.optimizeFor = reader.int32() as any; + continue; + } + case 11: { + if (tag !== 90) { + break; + } + + message.goPackage = reader.string(); + continue; + } + case 16: { + if (tag !== 128) { + break; + } + + message.ccGenericServices = reader.bool(); + continue; + } + case 17: { + if (tag !== 136) { + break; + } + + message.javaGenericServices = reader.bool(); + continue; + } + case 18: { + if (tag !== 144) { + break; + } + + message.pyGenericServices = reader.bool(); + continue; + } + case 42: { + if (tag !== 336) { + break; + } + + message.phpGenericServices = reader.bool(); + continue; + } + case 23: { + if (tag !== 184) { + break; + } + + message.deprecated = reader.bool(); + continue; + } + case 31: { + if (tag !== 248) { + break; + } + + message.ccEnableArenas = reader.bool(); + continue; + } + case 36: { + if (tag !== 290) { + break; + } + + message.objcClassPrefix = reader.string(); + continue; + } + case 37: { + if (tag !== 298) { + break; + } + + message.csharpNamespace = reader.string(); + continue; + } + case 39: { + if (tag !== 314) { + break; + } + + message.swiftPrefix = reader.string(); + continue; + } + case 40: { + if (tag !== 322) { + break; + } + + message.phpClassPrefix = reader.string(); + continue; + } + case 41: { + if (tag !== 330) { + break; + } + + message.phpNamespace = reader.string(); + continue; + } + case 44: { + if (tag !== 354) { + break; + } + + message.phpMetadataNamespace = reader.string(); + continue; + } + case 45: { + if (tag !== 362) { + break; + } + + message.rubyPackage = reader.string(); + continue; + } + case 999: { + if (tag !== 7994) { + break; + } + + message.uninterpretedOption.push(UninterpretedOption.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): FileOptions { + return { + javaPackage: isSet(object.javaPackage) ? globalThis.String(object.javaPackage) : "", + javaOuterClassname: isSet(object.javaOuterClassname) ? globalThis.String(object.javaOuterClassname) : "", + javaMultipleFiles: isSet(object.javaMultipleFiles) ? globalThis.Boolean(object.javaMultipleFiles) : false, + javaGenerateEqualsAndHash: isSet(object.javaGenerateEqualsAndHash) + ? globalThis.Boolean(object.javaGenerateEqualsAndHash) + : false, + javaStringCheckUtf8: isSet(object.javaStringCheckUtf8) ? globalThis.Boolean(object.javaStringCheckUtf8) : false, + optimizeFor: isSet(object.optimizeFor) ? fileOptions_OptimizeModeFromJSON(object.optimizeFor) : 1, + goPackage: isSet(object.goPackage) ? globalThis.String(object.goPackage) : "", + ccGenericServices: isSet(object.ccGenericServices) ? globalThis.Boolean(object.ccGenericServices) : false, + javaGenericServices: isSet(object.javaGenericServices) ? globalThis.Boolean(object.javaGenericServices) : false, + pyGenericServices: isSet(object.pyGenericServices) ? globalThis.Boolean(object.pyGenericServices) : false, + phpGenericServices: isSet(object.phpGenericServices) ? globalThis.Boolean(object.phpGenericServices) : false, + deprecated: isSet(object.deprecated) ? globalThis.Boolean(object.deprecated) : false, + ccEnableArenas: isSet(object.ccEnableArenas) ? globalThis.Boolean(object.ccEnableArenas) : true, + objcClassPrefix: isSet(object.objcClassPrefix) ? globalThis.String(object.objcClassPrefix) : "", + csharpNamespace: isSet(object.csharpNamespace) ? globalThis.String(object.csharpNamespace) : "", + swiftPrefix: isSet(object.swiftPrefix) ? globalThis.String(object.swiftPrefix) : "", + phpClassPrefix: isSet(object.phpClassPrefix) ? globalThis.String(object.phpClassPrefix) : "", + phpNamespace: isSet(object.phpNamespace) ? globalThis.String(object.phpNamespace) : "", + phpMetadataNamespace: isSet(object.phpMetadataNamespace) ? globalThis.String(object.phpMetadataNamespace) : "", + rubyPackage: isSet(object.rubyPackage) ? globalThis.String(object.rubyPackage) : "", + uninterpretedOption: globalThis.Array.isArray(object?.uninterpretedOption) + ? object.uninterpretedOption.map((e: any) => UninterpretedOption.fromJSON(e)) + : [], + }; + }, + + toJSON(message: FileOptions): unknown { + const obj: any = {}; + if (message.javaPackage !== undefined && message.javaPackage !== "") { + obj.javaPackage = message.javaPackage; + } + if (message.javaOuterClassname !== undefined && message.javaOuterClassname !== "") { + obj.javaOuterClassname = message.javaOuterClassname; + } + if (message.javaMultipleFiles !== undefined && message.javaMultipleFiles !== false) { + obj.javaMultipleFiles = message.javaMultipleFiles; + } + if (message.javaGenerateEqualsAndHash !== undefined && message.javaGenerateEqualsAndHash !== false) { + obj.javaGenerateEqualsAndHash = message.javaGenerateEqualsAndHash; + } + if (message.javaStringCheckUtf8 !== undefined && message.javaStringCheckUtf8 !== false) { + obj.javaStringCheckUtf8 = message.javaStringCheckUtf8; + } + if (message.optimizeFor !== undefined && message.optimizeFor !== 1) { + obj.optimizeFor = fileOptions_OptimizeModeToJSON(message.optimizeFor); + } + if (message.goPackage !== undefined && message.goPackage !== "") { + obj.goPackage = message.goPackage; + } + if (message.ccGenericServices !== undefined && message.ccGenericServices !== false) { + obj.ccGenericServices = message.ccGenericServices; + } + if (message.javaGenericServices !== undefined && message.javaGenericServices !== false) { + obj.javaGenericServices = message.javaGenericServices; + } + if (message.pyGenericServices !== undefined && message.pyGenericServices !== false) { + obj.pyGenericServices = message.pyGenericServices; + } + if (message.phpGenericServices !== undefined && message.phpGenericServices !== false) { + obj.phpGenericServices = message.phpGenericServices; + } + if (message.deprecated !== undefined && message.deprecated !== false) { + obj.deprecated = message.deprecated; + } + if (message.ccEnableArenas !== undefined && message.ccEnableArenas !== true) { + obj.ccEnableArenas = message.ccEnableArenas; + } + if (message.objcClassPrefix !== undefined && message.objcClassPrefix !== "") { + obj.objcClassPrefix = message.objcClassPrefix; + } + if (message.csharpNamespace !== undefined && message.csharpNamespace !== "") { + obj.csharpNamespace = message.csharpNamespace; + } + if (message.swiftPrefix !== undefined && message.swiftPrefix !== "") { + obj.swiftPrefix = message.swiftPrefix; + } + if (message.phpClassPrefix !== undefined && message.phpClassPrefix !== "") { + obj.phpClassPrefix = message.phpClassPrefix; + } + if (message.phpNamespace !== undefined && message.phpNamespace !== "") { + obj.phpNamespace = message.phpNamespace; + } + if (message.phpMetadataNamespace !== undefined && message.phpMetadataNamespace !== "") { + obj.phpMetadataNamespace = message.phpMetadataNamespace; + } + if (message.rubyPackage !== undefined && message.rubyPackage !== "") { + obj.rubyPackage = message.rubyPackage; + } + if (message.uninterpretedOption?.length) { + obj.uninterpretedOption = message.uninterpretedOption.map((e) => UninterpretedOption.toJSON(e)); + } + return obj; + }, + + create, I>>(base?: I): FileOptions { + return FileOptions.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): FileOptions { + const message = createBaseFileOptions(); + message.javaPackage = object.javaPackage ?? ""; + message.javaOuterClassname = object.javaOuterClassname ?? ""; + message.javaMultipleFiles = object.javaMultipleFiles ?? false; + message.javaGenerateEqualsAndHash = object.javaGenerateEqualsAndHash ?? false; + message.javaStringCheckUtf8 = object.javaStringCheckUtf8 ?? false; + message.optimizeFor = object.optimizeFor ?? 1; + message.goPackage = object.goPackage ?? ""; + message.ccGenericServices = object.ccGenericServices ?? false; + message.javaGenericServices = object.javaGenericServices ?? false; + message.pyGenericServices = object.pyGenericServices ?? false; + message.phpGenericServices = object.phpGenericServices ?? false; + message.deprecated = object.deprecated ?? false; + message.ccEnableArenas = object.ccEnableArenas ?? true; + message.objcClassPrefix = object.objcClassPrefix ?? ""; + message.csharpNamespace = object.csharpNamespace ?? ""; + message.swiftPrefix = object.swiftPrefix ?? ""; + message.phpClassPrefix = object.phpClassPrefix ?? ""; + message.phpNamespace = object.phpNamespace ?? ""; + message.phpMetadataNamespace = object.phpMetadataNamespace ?? ""; + message.rubyPackage = object.rubyPackage ?? ""; + message.uninterpretedOption = object.uninterpretedOption?.map((e) => UninterpretedOption.fromPartial(e)) || []; + return message; + }, +}; + +function createBaseMessageOptions(): MessageOptions { + return { + messageSetWireFormat: false, + noStandardDescriptorAccessor: false, + deprecated: false, + mapEntry: false, + uninterpretedOption: [], + }; +} + +export const MessageOptions: MessageFns = { + encode(message: MessageOptions, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.messageSetWireFormat !== undefined && message.messageSetWireFormat !== false) { + writer.uint32(8).bool(message.messageSetWireFormat); + } + if (message.noStandardDescriptorAccessor !== undefined && message.noStandardDescriptorAccessor !== false) { + writer.uint32(16).bool(message.noStandardDescriptorAccessor); + } + if (message.deprecated !== undefined && message.deprecated !== false) { + writer.uint32(24).bool(message.deprecated); + } + if (message.mapEntry !== undefined && message.mapEntry !== false) { + writer.uint32(56).bool(message.mapEntry); + } + for (const v of message.uninterpretedOption) { + UninterpretedOption.encode(v!, writer.uint32(7994).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): MessageOptions { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseMessageOptions(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.messageSetWireFormat = reader.bool(); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.noStandardDescriptorAccessor = reader.bool(); + continue; + } + case 3: { + if (tag !== 24) { + break; + } + + message.deprecated = reader.bool(); + continue; + } + case 7: { + if (tag !== 56) { + break; + } + + message.mapEntry = reader.bool(); + continue; + } + case 999: { + if (tag !== 7994) { + break; + } + + message.uninterpretedOption.push(UninterpretedOption.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): MessageOptions { + return { + messageSetWireFormat: isSet(object.messageSetWireFormat) + ? globalThis.Boolean(object.messageSetWireFormat) + : false, + noStandardDescriptorAccessor: isSet(object.noStandardDescriptorAccessor) + ? globalThis.Boolean(object.noStandardDescriptorAccessor) + : false, + deprecated: isSet(object.deprecated) ? globalThis.Boolean(object.deprecated) : false, + mapEntry: isSet(object.mapEntry) ? globalThis.Boolean(object.mapEntry) : false, + uninterpretedOption: globalThis.Array.isArray(object?.uninterpretedOption) + ? object.uninterpretedOption.map((e: any) => UninterpretedOption.fromJSON(e)) + : [], + }; + }, + + toJSON(message: MessageOptions): unknown { + const obj: any = {}; + if (message.messageSetWireFormat !== undefined && message.messageSetWireFormat !== false) { + obj.messageSetWireFormat = message.messageSetWireFormat; + } + if (message.noStandardDescriptorAccessor !== undefined && message.noStandardDescriptorAccessor !== false) { + obj.noStandardDescriptorAccessor = message.noStandardDescriptorAccessor; + } + if (message.deprecated !== undefined && message.deprecated !== false) { + obj.deprecated = message.deprecated; + } + if (message.mapEntry !== undefined && message.mapEntry !== false) { + obj.mapEntry = message.mapEntry; + } + if (message.uninterpretedOption?.length) { + obj.uninterpretedOption = message.uninterpretedOption.map((e) => UninterpretedOption.toJSON(e)); + } + return obj; + }, + + create, I>>(base?: I): MessageOptions { + return MessageOptions.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): MessageOptions { + const message = createBaseMessageOptions(); + message.messageSetWireFormat = object.messageSetWireFormat ?? false; + message.noStandardDescriptorAccessor = object.noStandardDescriptorAccessor ?? false; + message.deprecated = object.deprecated ?? false; + message.mapEntry = object.mapEntry ?? false; + message.uninterpretedOption = object.uninterpretedOption?.map((e) => UninterpretedOption.fromPartial(e)) || []; + return message; + }, +}; + +function createBaseFieldOptions(): FieldOptions { + return { + ctype: 0, + packed: false, + jstype: 0, + lazy: false, + unverifiedLazy: false, + deprecated: false, + weak: false, + uninterpretedOption: [], + }; +} + +export const FieldOptions: MessageFns = { + encode(message: FieldOptions, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.ctype !== undefined && message.ctype !== 0) { + writer.uint32(8).int32(message.ctype); + } + if (message.packed !== undefined && message.packed !== false) { + writer.uint32(16).bool(message.packed); + } + if (message.jstype !== undefined && message.jstype !== 0) { + writer.uint32(48).int32(message.jstype); + } + if (message.lazy !== undefined && message.lazy !== false) { + writer.uint32(40).bool(message.lazy); + } + if (message.unverifiedLazy !== undefined && message.unverifiedLazy !== false) { + writer.uint32(120).bool(message.unverifiedLazy); + } + if (message.deprecated !== undefined && message.deprecated !== false) { + writer.uint32(24).bool(message.deprecated); + } + if (message.weak !== undefined && message.weak !== false) { + writer.uint32(80).bool(message.weak); + } + for (const v of message.uninterpretedOption) { + UninterpretedOption.encode(v!, writer.uint32(7994).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): FieldOptions { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseFieldOptions(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.ctype = reader.int32() as any; + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.packed = reader.bool(); + continue; + } + case 6: { + if (tag !== 48) { + break; + } + + message.jstype = reader.int32() as any; + continue; + } + case 5: { + if (tag !== 40) { + break; + } + + message.lazy = reader.bool(); + continue; + } + case 15: { + if (tag !== 120) { + break; + } + + message.unverifiedLazy = reader.bool(); + continue; + } + case 3: { + if (tag !== 24) { + break; + } + + message.deprecated = reader.bool(); + continue; + } + case 10: { + if (tag !== 80) { + break; + } + + message.weak = reader.bool(); + continue; + } + case 999: { + if (tag !== 7994) { + break; + } + + message.uninterpretedOption.push(UninterpretedOption.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): FieldOptions { + return { + ctype: isSet(object.ctype) ? fieldOptions_CTypeFromJSON(object.ctype) : 0, + packed: isSet(object.packed) ? globalThis.Boolean(object.packed) : false, + jstype: isSet(object.jstype) ? fieldOptions_JSTypeFromJSON(object.jstype) : 0, + lazy: isSet(object.lazy) ? globalThis.Boolean(object.lazy) : false, + unverifiedLazy: isSet(object.unverifiedLazy) ? globalThis.Boolean(object.unverifiedLazy) : false, + deprecated: isSet(object.deprecated) ? globalThis.Boolean(object.deprecated) : false, + weak: isSet(object.weak) ? globalThis.Boolean(object.weak) : false, + uninterpretedOption: globalThis.Array.isArray(object?.uninterpretedOption) + ? object.uninterpretedOption.map((e: any) => UninterpretedOption.fromJSON(e)) + : [], + }; + }, + + toJSON(message: FieldOptions): unknown { + const obj: any = {}; + if (message.ctype !== undefined && message.ctype !== 0) { + obj.ctype = fieldOptions_CTypeToJSON(message.ctype); + } + if (message.packed !== undefined && message.packed !== false) { + obj.packed = message.packed; + } + if (message.jstype !== undefined && message.jstype !== 0) { + obj.jstype = fieldOptions_JSTypeToJSON(message.jstype); + } + if (message.lazy !== undefined && message.lazy !== false) { + obj.lazy = message.lazy; + } + if (message.unverifiedLazy !== undefined && message.unverifiedLazy !== false) { + obj.unverifiedLazy = message.unverifiedLazy; + } + if (message.deprecated !== undefined && message.deprecated !== false) { + obj.deprecated = message.deprecated; + } + if (message.weak !== undefined && message.weak !== false) { + obj.weak = message.weak; + } + if (message.uninterpretedOption?.length) { + obj.uninterpretedOption = message.uninterpretedOption.map((e) => UninterpretedOption.toJSON(e)); + } + return obj; + }, + + create, I>>(base?: I): FieldOptions { + return FieldOptions.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): FieldOptions { + const message = createBaseFieldOptions(); + message.ctype = object.ctype ?? 0; + message.packed = object.packed ?? false; + message.jstype = object.jstype ?? 0; + message.lazy = object.lazy ?? false; + message.unverifiedLazy = object.unverifiedLazy ?? false; + message.deprecated = object.deprecated ?? false; + message.weak = object.weak ?? false; + message.uninterpretedOption = object.uninterpretedOption?.map((e) => UninterpretedOption.fromPartial(e)) || []; + return message; + }, +}; + +function createBaseOneofOptions(): OneofOptions { + return { uninterpretedOption: [] }; +} + +export const OneofOptions: MessageFns = { + encode(message: OneofOptions, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + for (const v of message.uninterpretedOption) { + UninterpretedOption.encode(v!, writer.uint32(7994).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): OneofOptions { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseOneofOptions(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 999: { + if (tag !== 7994) { + break; + } + + message.uninterpretedOption.push(UninterpretedOption.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): OneofOptions { + return { + uninterpretedOption: globalThis.Array.isArray(object?.uninterpretedOption) + ? object.uninterpretedOption.map((e: any) => UninterpretedOption.fromJSON(e)) + : [], + }; + }, + + toJSON(message: OneofOptions): unknown { + const obj: any = {}; + if (message.uninterpretedOption?.length) { + obj.uninterpretedOption = message.uninterpretedOption.map((e) => UninterpretedOption.toJSON(e)); + } + return obj; + }, + + create, I>>(base?: I): OneofOptions { + return OneofOptions.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): OneofOptions { + const message = createBaseOneofOptions(); + message.uninterpretedOption = object.uninterpretedOption?.map((e) => UninterpretedOption.fromPartial(e)) || []; + return message; + }, +}; + +function createBaseEnumOptions(): EnumOptions { + return { allowAlias: false, deprecated: false, uninterpretedOption: [] }; +} + +export const EnumOptions: MessageFns = { + encode(message: EnumOptions, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.allowAlias !== undefined && message.allowAlias !== false) { + writer.uint32(16).bool(message.allowAlias); + } + if (message.deprecated !== undefined && message.deprecated !== false) { + writer.uint32(24).bool(message.deprecated); + } + for (const v of message.uninterpretedOption) { + UninterpretedOption.encode(v!, writer.uint32(7994).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): EnumOptions { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseEnumOptions(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 2: { + if (tag !== 16) { + break; + } + + message.allowAlias = reader.bool(); + continue; + } + case 3: { + if (tag !== 24) { + break; + } + + message.deprecated = reader.bool(); + continue; + } + case 999: { + if (tag !== 7994) { + break; + } + + message.uninterpretedOption.push(UninterpretedOption.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): EnumOptions { + return { + allowAlias: isSet(object.allowAlias) ? globalThis.Boolean(object.allowAlias) : false, + deprecated: isSet(object.deprecated) ? globalThis.Boolean(object.deprecated) : false, + uninterpretedOption: globalThis.Array.isArray(object?.uninterpretedOption) + ? object.uninterpretedOption.map((e: any) => UninterpretedOption.fromJSON(e)) + : [], + }; + }, + + toJSON(message: EnumOptions): unknown { + const obj: any = {}; + if (message.allowAlias !== undefined && message.allowAlias !== false) { + obj.allowAlias = message.allowAlias; + } + if (message.deprecated !== undefined && message.deprecated !== false) { + obj.deprecated = message.deprecated; + } + if (message.uninterpretedOption?.length) { + obj.uninterpretedOption = message.uninterpretedOption.map((e) => UninterpretedOption.toJSON(e)); + } + return obj; + }, + + create, I>>(base?: I): EnumOptions { + return EnumOptions.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): EnumOptions { + const message = createBaseEnumOptions(); + message.allowAlias = object.allowAlias ?? false; + message.deprecated = object.deprecated ?? false; + message.uninterpretedOption = object.uninterpretedOption?.map((e) => UninterpretedOption.fromPartial(e)) || []; + return message; + }, +}; + +function createBaseEnumValueOptions(): EnumValueOptions { + return { deprecated: false, uninterpretedOption: [] }; +} + +export const EnumValueOptions: MessageFns = { + encode(message: EnumValueOptions, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.deprecated !== undefined && message.deprecated !== false) { + writer.uint32(8).bool(message.deprecated); + } + for (const v of message.uninterpretedOption) { + UninterpretedOption.encode(v!, writer.uint32(7994).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): EnumValueOptions { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseEnumValueOptions(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.deprecated = reader.bool(); + continue; + } + case 999: { + if (tag !== 7994) { + break; + } + + message.uninterpretedOption.push(UninterpretedOption.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): EnumValueOptions { + return { + deprecated: isSet(object.deprecated) ? globalThis.Boolean(object.deprecated) : false, + uninterpretedOption: globalThis.Array.isArray(object?.uninterpretedOption) + ? object.uninterpretedOption.map((e: any) => UninterpretedOption.fromJSON(e)) + : [], + }; + }, + + toJSON(message: EnumValueOptions): unknown { + const obj: any = {}; + if (message.deprecated !== undefined && message.deprecated !== false) { + obj.deprecated = message.deprecated; + } + if (message.uninterpretedOption?.length) { + obj.uninterpretedOption = message.uninterpretedOption.map((e) => UninterpretedOption.toJSON(e)); + } + return obj; + }, + + create, I>>(base?: I): EnumValueOptions { + return EnumValueOptions.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): EnumValueOptions { + const message = createBaseEnumValueOptions(); + message.deprecated = object.deprecated ?? false; + message.uninterpretedOption = object.uninterpretedOption?.map((e) => UninterpretedOption.fromPartial(e)) || []; + return message; + }, +}; + +function createBaseServiceOptions(): ServiceOptions { + return { deprecated: false, uninterpretedOption: [] }; +} + +export const ServiceOptions: MessageFns = { + encode(message: ServiceOptions, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.deprecated !== undefined && message.deprecated !== false) { + writer.uint32(264).bool(message.deprecated); + } + for (const v of message.uninterpretedOption) { + UninterpretedOption.encode(v!, writer.uint32(7994).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): ServiceOptions { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseServiceOptions(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 33: { + if (tag !== 264) { + break; + } + + message.deprecated = reader.bool(); + continue; + } + case 999: { + if (tag !== 7994) { + break; + } + + message.uninterpretedOption.push(UninterpretedOption.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): ServiceOptions { + return { + deprecated: isSet(object.deprecated) ? globalThis.Boolean(object.deprecated) : false, + uninterpretedOption: globalThis.Array.isArray(object?.uninterpretedOption) + ? object.uninterpretedOption.map((e: any) => UninterpretedOption.fromJSON(e)) + : [], + }; + }, + + toJSON(message: ServiceOptions): unknown { + const obj: any = {}; + if (message.deprecated !== undefined && message.deprecated !== false) { + obj.deprecated = message.deprecated; + } + if (message.uninterpretedOption?.length) { + obj.uninterpretedOption = message.uninterpretedOption.map((e) => UninterpretedOption.toJSON(e)); + } + return obj; + }, + + create, I>>(base?: I): ServiceOptions { + return ServiceOptions.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): ServiceOptions { + const message = createBaseServiceOptions(); + message.deprecated = object.deprecated ?? false; + message.uninterpretedOption = object.uninterpretedOption?.map((e) => UninterpretedOption.fromPartial(e)) || []; + return message; + }, +}; + +function createBaseMethodOptions(): MethodOptions { + return { deprecated: false, idempotencyLevel: 0, uninterpretedOption: [] }; +} + +export const MethodOptions: MessageFns = { + encode(message: MethodOptions, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.deprecated !== undefined && message.deprecated !== false) { + writer.uint32(264).bool(message.deprecated); + } + if (message.idempotencyLevel !== undefined && message.idempotencyLevel !== 0) { + writer.uint32(272).int32(message.idempotencyLevel); + } + for (const v of message.uninterpretedOption) { + UninterpretedOption.encode(v!, writer.uint32(7994).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): MethodOptions { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseMethodOptions(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 33: { + if (tag !== 264) { + break; + } + + message.deprecated = reader.bool(); + continue; + } + case 34: { + if (tag !== 272) { + break; + } + + message.idempotencyLevel = reader.int32() as any; + continue; + } + case 999: { + if (tag !== 7994) { + break; + } + + message.uninterpretedOption.push(UninterpretedOption.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): MethodOptions { + return { + deprecated: isSet(object.deprecated) ? globalThis.Boolean(object.deprecated) : false, + idempotencyLevel: isSet(object.idempotencyLevel) + ? methodOptions_IdempotencyLevelFromJSON(object.idempotencyLevel) + : 0, + uninterpretedOption: globalThis.Array.isArray(object?.uninterpretedOption) + ? object.uninterpretedOption.map((e: any) => UninterpretedOption.fromJSON(e)) + : [], + }; + }, + + toJSON(message: MethodOptions): unknown { + const obj: any = {}; + if (message.deprecated !== undefined && message.deprecated !== false) { + obj.deprecated = message.deprecated; + } + if (message.idempotencyLevel !== undefined && message.idempotencyLevel !== 0) { + obj.idempotencyLevel = methodOptions_IdempotencyLevelToJSON(message.idempotencyLevel); + } + if (message.uninterpretedOption?.length) { + obj.uninterpretedOption = message.uninterpretedOption.map((e) => UninterpretedOption.toJSON(e)); + } + return obj; + }, + + create, I>>(base?: I): MethodOptions { + return MethodOptions.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): MethodOptions { + const message = createBaseMethodOptions(); + message.deprecated = object.deprecated ?? false; + message.idempotencyLevel = object.idempotencyLevel ?? 0; + message.uninterpretedOption = object.uninterpretedOption?.map((e) => UninterpretedOption.fromPartial(e)) || []; + return message; + }, +}; + +function createBaseUninterpretedOption(): UninterpretedOption { + return { + name: [], + identifierValue: "", + positiveIntValue: 0, + negativeIntValue: 0, + doubleValue: 0, + stringValue: new Uint8Array(0), + aggregateValue: "", + }; +} + +export const UninterpretedOption: MessageFns = { + encode(message: UninterpretedOption, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + for (const v of message.name) { + UninterpretedOption_NamePart.encode(v!, writer.uint32(18).fork()).join(); + } + if (message.identifierValue !== undefined && message.identifierValue !== "") { + writer.uint32(26).string(message.identifierValue); + } + if (message.positiveIntValue !== undefined && message.positiveIntValue !== 0) { + writer.uint32(32).uint64(message.positiveIntValue); + } + if (message.negativeIntValue !== undefined && message.negativeIntValue !== 0) { + writer.uint32(40).int64(message.negativeIntValue); + } + if (message.doubleValue !== undefined && message.doubleValue !== 0) { + writer.uint32(49).double(message.doubleValue); + } + if (message.stringValue !== undefined && message.stringValue.length !== 0) { + writer.uint32(58).bytes(message.stringValue); + } + if (message.aggregateValue !== undefined && message.aggregateValue !== "") { + writer.uint32(66).string(message.aggregateValue); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): UninterpretedOption { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseUninterpretedOption(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 2: { + if (tag !== 18) { + break; + } + + message.name.push(UninterpretedOption_NamePart.decode(reader, reader.uint32())); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.identifierValue = reader.string(); + continue; + } + case 4: { + if (tag !== 32) { + break; + } + + message.positiveIntValue = longToNumber(reader.uint64()); + continue; + } + case 5: { + if (tag !== 40) { + break; + } + + message.negativeIntValue = longToNumber(reader.int64()); + continue; + } + case 6: { + if (tag !== 49) { + break; + } + + message.doubleValue = reader.double(); + continue; + } + case 7: { + if (tag !== 58) { + break; + } + + message.stringValue = reader.bytes(); + continue; + } + case 8: { + if (tag !== 66) { + break; + } + + message.aggregateValue = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): UninterpretedOption { + return { + name: globalThis.Array.isArray(object?.name) + ? object.name.map((e: any) => UninterpretedOption_NamePart.fromJSON(e)) + : [], + identifierValue: isSet(object.identifierValue) ? globalThis.String(object.identifierValue) : "", + positiveIntValue: isSet(object.positiveIntValue) ? globalThis.Number(object.positiveIntValue) : 0, + negativeIntValue: isSet(object.negativeIntValue) ? globalThis.Number(object.negativeIntValue) : 0, + doubleValue: isSet(object.doubleValue) ? globalThis.Number(object.doubleValue) : 0, + stringValue: isSet(object.stringValue) ? bytesFromBase64(object.stringValue) : new Uint8Array(0), + aggregateValue: isSet(object.aggregateValue) ? globalThis.String(object.aggregateValue) : "", + }; + }, + + toJSON(message: UninterpretedOption): unknown { + const obj: any = {}; + if (message.name?.length) { + obj.name = message.name.map((e) => UninterpretedOption_NamePart.toJSON(e)); + } + if (message.identifierValue !== undefined && message.identifierValue !== "") { + obj.identifierValue = message.identifierValue; + } + if (message.positiveIntValue !== undefined && message.positiveIntValue !== 0) { + obj.positiveIntValue = Math.round(message.positiveIntValue); + } + if (message.negativeIntValue !== undefined && message.negativeIntValue !== 0) { + obj.negativeIntValue = Math.round(message.negativeIntValue); + } + if (message.doubleValue !== undefined && message.doubleValue !== 0) { + obj.doubleValue = message.doubleValue; + } + if (message.stringValue !== undefined && message.stringValue.length !== 0) { + obj.stringValue = base64FromBytes(message.stringValue); + } + if (message.aggregateValue !== undefined && message.aggregateValue !== "") { + obj.aggregateValue = message.aggregateValue; + } + return obj; + }, + + create, I>>(base?: I): UninterpretedOption { + return UninterpretedOption.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): UninterpretedOption { + const message = createBaseUninterpretedOption(); + message.name = object.name?.map((e) => UninterpretedOption_NamePart.fromPartial(e)) || []; + message.identifierValue = object.identifierValue ?? ""; + message.positiveIntValue = object.positiveIntValue ?? 0; + message.negativeIntValue = object.negativeIntValue ?? 0; + message.doubleValue = object.doubleValue ?? 0; + message.stringValue = object.stringValue ?? new Uint8Array(0); + message.aggregateValue = object.aggregateValue ?? ""; + return message; + }, +}; + +function createBaseUninterpretedOption_NamePart(): UninterpretedOption_NamePart { + return { namePart: "", isExtension: false }; +} + +export const UninterpretedOption_NamePart: MessageFns = { + encode(message: UninterpretedOption_NamePart, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.namePart !== "") { + writer.uint32(10).string(message.namePart); + } + if (message.isExtension !== false) { + writer.uint32(16).bool(message.isExtension); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): UninterpretedOption_NamePart { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseUninterpretedOption_NamePart(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.namePart = reader.string(); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.isExtension = reader.bool(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): UninterpretedOption_NamePart { + return { + namePart: isSet(object.namePart) ? globalThis.String(object.namePart) : "", + isExtension: isSet(object.isExtension) ? globalThis.Boolean(object.isExtension) : false, + }; + }, + + toJSON(message: UninterpretedOption_NamePart): unknown { + const obj: any = {}; + if (message.namePart !== "") { + obj.namePart = message.namePart; + } + if (message.isExtension !== false) { + obj.isExtension = message.isExtension; + } + return obj; + }, + + create, I>>(base?: I): UninterpretedOption_NamePart { + return UninterpretedOption_NamePart.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): UninterpretedOption_NamePart { + const message = createBaseUninterpretedOption_NamePart(); + message.namePart = object.namePart ?? ""; + message.isExtension = object.isExtension ?? false; + return message; + }, +}; + +function createBaseSourceCodeInfo(): SourceCodeInfo { + return { location: [] }; +} + +export const SourceCodeInfo: MessageFns = { + encode(message: SourceCodeInfo, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + for (const v of message.location) { + SourceCodeInfo_Location.encode(v!, writer.uint32(10).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): SourceCodeInfo { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseSourceCodeInfo(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.location.push(SourceCodeInfo_Location.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): SourceCodeInfo { + return { + location: globalThis.Array.isArray(object?.location) + ? object.location.map((e: any) => SourceCodeInfo_Location.fromJSON(e)) + : [], + }; + }, + + toJSON(message: SourceCodeInfo): unknown { + const obj: any = {}; + if (message.location?.length) { + obj.location = message.location.map((e) => SourceCodeInfo_Location.toJSON(e)); + } + return obj; + }, + + create, I>>(base?: I): SourceCodeInfo { + return SourceCodeInfo.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): SourceCodeInfo { + const message = createBaseSourceCodeInfo(); + message.location = object.location?.map((e) => SourceCodeInfo_Location.fromPartial(e)) || []; + return message; + }, +}; + +function createBaseSourceCodeInfo_Location(): SourceCodeInfo_Location { + return { path: [], span: [], leadingComments: "", trailingComments: "", leadingDetachedComments: [] }; +} + +export const SourceCodeInfo_Location: MessageFns = { + encode(message: SourceCodeInfo_Location, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + writer.uint32(10).fork(); + for (const v of message.path) { + writer.int32(v); + } + writer.join(); + writer.uint32(18).fork(); + for (const v of message.span) { + writer.int32(v); + } + writer.join(); + if (message.leadingComments !== undefined && message.leadingComments !== "") { + writer.uint32(26).string(message.leadingComments); + } + if (message.trailingComments !== undefined && message.trailingComments !== "") { + writer.uint32(34).string(message.trailingComments); + } + for (const v of message.leadingDetachedComments) { + writer.uint32(50).string(v!); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): SourceCodeInfo_Location { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseSourceCodeInfo_Location(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag === 8) { + message.path.push(reader.int32()); + + continue; + } + + if (tag === 10) { + const end2 = reader.uint32() + reader.pos; + while (reader.pos < end2) { + message.path.push(reader.int32()); + } + + continue; + } + + break; + } + case 2: { + if (tag === 16) { + message.span.push(reader.int32()); + + continue; + } + + if (tag === 18) { + const end2 = reader.uint32() + reader.pos; + while (reader.pos < end2) { + message.span.push(reader.int32()); + } + + continue; + } + + break; + } + case 3: { + if (tag !== 26) { + break; + } + + message.leadingComments = reader.string(); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.trailingComments = reader.string(); + continue; + } + case 6: { + if (tag !== 50) { + break; + } + + message.leadingDetachedComments.push(reader.string()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): SourceCodeInfo_Location { + return { + path: globalThis.Array.isArray(object?.path) ? object.path.map((e: any) => globalThis.Number(e)) : [], + span: globalThis.Array.isArray(object?.span) ? object.span.map((e: any) => globalThis.Number(e)) : [], + leadingComments: isSet(object.leadingComments) ? globalThis.String(object.leadingComments) : "", + trailingComments: isSet(object.trailingComments) ? globalThis.String(object.trailingComments) : "", + leadingDetachedComments: globalThis.Array.isArray(object?.leadingDetachedComments) + ? object.leadingDetachedComments.map((e: any) => globalThis.String(e)) + : [], + }; + }, + + toJSON(message: SourceCodeInfo_Location): unknown { + const obj: any = {}; + if (message.path?.length) { + obj.path = message.path.map((e) => Math.round(e)); + } + if (message.span?.length) { + obj.span = message.span.map((e) => Math.round(e)); + } + if (message.leadingComments !== undefined && message.leadingComments !== "") { + obj.leadingComments = message.leadingComments; + } + if (message.trailingComments !== undefined && message.trailingComments !== "") { + obj.trailingComments = message.trailingComments; + } + if (message.leadingDetachedComments?.length) { + obj.leadingDetachedComments = message.leadingDetachedComments; + } + return obj; + }, + + create, I>>(base?: I): SourceCodeInfo_Location { + return SourceCodeInfo_Location.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): SourceCodeInfo_Location { + const message = createBaseSourceCodeInfo_Location(); + message.path = object.path?.map((e) => e) || []; + message.span = object.span?.map((e) => e) || []; + message.leadingComments = object.leadingComments ?? ""; + message.trailingComments = object.trailingComments ?? ""; + message.leadingDetachedComments = object.leadingDetachedComments?.map((e) => e) || []; + return message; + }, +}; + +function createBaseGeneratedCodeInfo(): GeneratedCodeInfo { + return { annotation: [] }; +} + +export const GeneratedCodeInfo: MessageFns = { + encode(message: GeneratedCodeInfo, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + for (const v of message.annotation) { + GeneratedCodeInfo_Annotation.encode(v!, writer.uint32(10).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): GeneratedCodeInfo { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseGeneratedCodeInfo(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.annotation.push(GeneratedCodeInfo_Annotation.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): GeneratedCodeInfo { + return { + annotation: globalThis.Array.isArray(object?.annotation) + ? object.annotation.map((e: any) => GeneratedCodeInfo_Annotation.fromJSON(e)) + : [], + }; + }, + + toJSON(message: GeneratedCodeInfo): unknown { + const obj: any = {}; + if (message.annotation?.length) { + obj.annotation = message.annotation.map((e) => GeneratedCodeInfo_Annotation.toJSON(e)); + } + return obj; + }, + + create, I>>(base?: I): GeneratedCodeInfo { + return GeneratedCodeInfo.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): GeneratedCodeInfo { + const message = createBaseGeneratedCodeInfo(); + message.annotation = object.annotation?.map((e) => GeneratedCodeInfo_Annotation.fromPartial(e)) || []; + return message; + }, +}; + +function createBaseGeneratedCodeInfo_Annotation(): GeneratedCodeInfo_Annotation { + return { path: [], sourceFile: "", begin: 0, end: 0 }; +} + +export const GeneratedCodeInfo_Annotation: MessageFns = { + encode(message: GeneratedCodeInfo_Annotation, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + writer.uint32(10).fork(); + for (const v of message.path) { + writer.int32(v); + } + writer.join(); + if (message.sourceFile !== undefined && message.sourceFile !== "") { + writer.uint32(18).string(message.sourceFile); + } + if (message.begin !== undefined && message.begin !== 0) { + writer.uint32(24).int32(message.begin); + } + if (message.end !== undefined && message.end !== 0) { + writer.uint32(32).int32(message.end); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): GeneratedCodeInfo_Annotation { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseGeneratedCodeInfo_Annotation(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag === 8) { + message.path.push(reader.int32()); + + continue; + } + + if (tag === 10) { + const end2 = reader.uint32() + reader.pos; + while (reader.pos < end2) { + message.path.push(reader.int32()); + } + + continue; + } + + break; + } + case 2: { + if (tag !== 18) { + break; + } + + message.sourceFile = reader.string(); + continue; + } + case 3: { + if (tag !== 24) { + break; + } + + message.begin = reader.int32(); + continue; + } + case 4: { + if (tag !== 32) { + break; + } + + message.end = reader.int32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): GeneratedCodeInfo_Annotation { + return { + path: globalThis.Array.isArray(object?.path) ? object.path.map((e: any) => globalThis.Number(e)) : [], + sourceFile: isSet(object.sourceFile) ? globalThis.String(object.sourceFile) : "", + begin: isSet(object.begin) ? globalThis.Number(object.begin) : 0, + end: isSet(object.end) ? globalThis.Number(object.end) : 0, + }; + }, + + toJSON(message: GeneratedCodeInfo_Annotation): unknown { + const obj: any = {}; + if (message.path?.length) { + obj.path = message.path.map((e) => Math.round(e)); + } + if (message.sourceFile !== undefined && message.sourceFile !== "") { + obj.sourceFile = message.sourceFile; + } + if (message.begin !== undefined && message.begin !== 0) { + obj.begin = Math.round(message.begin); + } + if (message.end !== undefined && message.end !== 0) { + obj.end = Math.round(message.end); + } + return obj; + }, + + create, I>>(base?: I): GeneratedCodeInfo_Annotation { + return GeneratedCodeInfo_Annotation.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): GeneratedCodeInfo_Annotation { + const message = createBaseGeneratedCodeInfo_Annotation(); + message.path = object.path?.map((e) => e) || []; + message.sourceFile = object.sourceFile ?? ""; + message.begin = object.begin ?? 0; + message.end = object.end ?? 0; + return message; + }, +}; + +function bytesFromBase64(b64: string): Uint8Array { + if ((globalThis as any).Buffer) { + return Uint8Array.from(globalThis.Buffer.from(b64, "base64")); + } else { + const bin = globalThis.atob(b64); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; ++i) { + arr[i] = bin.charCodeAt(i); + } + return arr; + } +} + +function base64FromBytes(arr: Uint8Array): string { + if ((globalThis as any).Buffer) { + return globalThis.Buffer.from(arr).toString("base64"); + } else { + const bin: string[] = []; + arr.forEach((byte) => { + bin.push(globalThis.String.fromCharCode(byte)); + }); + return globalThis.btoa(bin.join("")); + } +} + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin ? T + : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +export type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +function longToNumber(int64: { toString(): string }): number { + const num = globalThis.Number(int64.toString()); + if (num > globalThis.Number.MAX_SAFE_INTEGER) { + throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER"); + } + if (num < globalThis.Number.MIN_SAFE_INTEGER) { + throw new globalThis.Error("Value is smaller than Number.MIN_SAFE_INTEGER"); + } + return num; +} + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} + +export interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + fromJSON(object: any): T; + toJSON(message: T): unknown; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} diff --git a/ui/admin/proto/vault.ts b/ui/admin/proto/vault.ts new file mode 100644 index 0000000..800654a --- /dev/null +++ b/ui/admin/proto/vault.ts @@ -0,0 +1,203 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.2.2 +// protoc v3.21.12 +// source: vault.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; + +export const protobufPackage = "config"; + +export interface Vault { + secrets: { [key: string]: string }; +} + +export interface Vault_SecretsEntry { + key: string; + value: string; +} + +function createBaseVault(): Vault { + return { secrets: {} }; +} + +export const Vault: MessageFns = { + encode(message: Vault, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + Object.entries(message.secrets).forEach(([key, value]) => { + Vault_SecretsEntry.encode({ key: key as any, value }, writer.uint32(10).fork()).join(); + }); + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): Vault { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseVault(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + const entry1 = Vault_SecretsEntry.decode(reader, reader.uint32()); + if (entry1.value !== undefined) { + message.secrets[entry1.key] = entry1.value; + } + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): Vault { + return { + secrets: isObject(object.secrets) + ? Object.entries(object.secrets).reduce<{ [key: string]: string }>((acc, [key, value]) => { + acc[key] = String(value); + return acc; + }, {}) + : {}, + }; + }, + + toJSON(message: Vault): unknown { + const obj: any = {}; + if (message.secrets) { + const entries = Object.entries(message.secrets); + if (entries.length > 0) { + obj.secrets = {}; + entries.forEach(([k, v]) => { + obj.secrets[k] = v; + }); + } + } + return obj; + }, + + create, I>>(base?: I): Vault { + return Vault.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): Vault { + const message = createBaseVault(); + message.secrets = Object.entries(object.secrets ?? {}).reduce<{ [key: string]: string }>((acc, [key, value]) => { + if (value !== undefined) { + acc[key] = globalThis.String(value); + } + return acc; + }, {}); + return message; + }, +}; + +function createBaseVault_SecretsEntry(): Vault_SecretsEntry { + return { key: "", value: "" }; +} + +export const Vault_SecretsEntry: MessageFns = { + encode(message: Vault_SecretsEntry, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.key !== "") { + writer.uint32(10).string(message.key); + } + if (message.value !== "") { + writer.uint32(18).string(message.value); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): Vault_SecretsEntry { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseVault_SecretsEntry(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.key = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.value = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): Vault_SecretsEntry { + return { + key: isSet(object.key) ? globalThis.String(object.key) : "", + value: isSet(object.value) ? globalThis.String(object.value) : "", + }; + }, + + toJSON(message: Vault_SecretsEntry): unknown { + const obj: any = {}; + if (message.key !== "") { + obj.key = message.key; + } + if (message.value !== "") { + obj.value = message.value; + } + return obj; + }, + + create, I>>(base?: I): Vault_SecretsEntry { + return Vault_SecretsEntry.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): Vault_SecretsEntry { + const message = createBaseVault_SecretsEntry(); + message.key = object.key ?? ""; + message.value = object.value ?? ""; + return message; + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin ? T + : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +export type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +function isObject(value: any): boolean { + return typeof value === "object" && value !== null; +} + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} + +export interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + fromJSON(object: any): T; + toJSON(message: T): unknown; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} diff --git a/ui/admin/src/App.tsx b/ui/admin/src/App.tsx new file mode 100644 index 0000000..ccf385c --- /dev/null +++ b/ui/admin/src/App.tsx @@ -0,0 +1,62 @@ +import { lazy, type Component } from "solid-js"; +import { Router, Route, type RouteSectionProps } from "@solidjs/router"; +import { useStore } from "@nanostores/solid"; + +import { TablesPage } from "@/components/tables/TablesPage"; +import { AccountsPage } from "@/components/auth/AccountsPage"; +import { LoginPage } from "@/components/auth/LoginPage"; +import { SettingsPages } from "@/components/settings/SettingsPage"; +import { IndexPage } from "@/components/IndexPage"; +import { NavBar } from "@/components/NavBar"; + +import { ErrorBoundary } from "@/components/ErrorBoundary"; +import { $user } from "@/lib/fetch"; + +function Layout(props: RouteSectionProps) { + return ( +
+
+ +
+ +
+ {props.children} +
+
+ ); +} + +const LazyEditorPage = lazy(() => import("@/components/editor/EditorPage")); +const LazyLogsPage = lazy(() => import("@/components/logs/LogsPage")); + +const App: Component = () => { + const user = useStore($user); + + return ( + <> + {user() ? ( + + + + + + + + + + + + {/* fallback: */} +

Not Found

} /> +
+
+ ) : ( + + + + )} + + ); +}; + +export default App; diff --git a/ui/admin/src/assets/favicon.svg b/ui/admin/src/assets/favicon.svg new file mode 120000 index 0000000..c5dffab --- /dev/null +++ b/ui/admin/src/assets/favicon.svg @@ -0,0 +1 @@ +../../../../assets/favicon.svg \ No newline at end of file diff --git a/ui/admin/src/components/DestructiveActionButton.tsx b/ui/admin/src/components/DestructiveActionButton.tsx new file mode 100644 index 0000000..ca5c82d --- /dev/null +++ b/ui/admin/src/components/DestructiveActionButton.tsx @@ -0,0 +1,56 @@ +import type { JSX } from "solid-js"; +import { children, createSignal } from "solid-js"; + +import { Button } from "@/components/ui/button"; +import type { DialogTriggerProps } from "@kobalte/core/dialog"; +import { + Dialog, + DialogContent, + DialogTrigger, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; + +export function DestructiveActionButton(props: { + children: JSX.Element; + action: () => Promise; + msg?: string; +}) { + const [open, setOpen] = createSignal(false); + const resolved = children(() => props.children); + + return ( + + + Confirmation + {props.msg ?? "Are you sure?"} + + + + + + + + ( + + )} + /> + + ); +} diff --git a/ui/admin/src/components/ErrorBoundary.tsx b/ui/admin/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..e64e539 --- /dev/null +++ b/ui/admin/src/components/ErrorBoundary.tsx @@ -0,0 +1,44 @@ +import { ErrorBoundary as SolidErrorBoundary, type JSX } from "solid-js"; + +import { client } from "@/lib/fetch"; +import { Toaster, showToast } from "@/components/ui/toast"; +import { Button } from "@/components/ui/button"; + +export function ErrorBoundary(props: { children: JSX.Element }) { + // NOTE: the fallback handles errors during component construction. Not + // errors at runtime, e.g.in a button handler. + return ( + { + return ( +
+ {`${err}`} + +
+ +
+ +
+ +
+
+ ); + }} + > + {props.children} + + +
+ ); +} + +window.onerror = function (message, url, lineNumber) { + const description = `${url}:${lineNumber} ${message}`; + console.error(description); + + showToast({ + title: "Uncaught Error", + description, + variant: "error", + }); +}; diff --git a/ui/admin/src/components/FilterBar.tsx b/ui/admin/src/components/FilterBar.tsx new file mode 100644 index 0000000..8298e01 --- /dev/null +++ b/ui/admin/src/components/FilterBar.tsx @@ -0,0 +1,44 @@ +import { createSignal } from "solid-js"; + +import { Button } from "@/components/ui/button"; +import { TextField, TextFieldInput } from "@/components/ui/text-field"; + +export function FilterBar(props: { + example?: string; + placeholder?: string; + initial?: string; + onSubmit: (filter: string) => void; +}) { + const [input, setInput] = createSignal(props.initial ?? ""); + + const onSubmit = () => { + const value = input(); + console.debug("set filter: ", value); + props.onSubmit(value); + }; + + return ( +
+
+ + { + const value = (e.currentTarget as HTMLInputElement).value; + setInput(value); + }} + /> + + + + + + {props.example && {props.example}} +
+ ); +} diff --git a/ui/admin/src/components/FormFields.tsx b/ui/admin/src/components/FormFields.tsx new file mode 100644 index 0000000..8f020ae --- /dev/null +++ b/ui/admin/src/components/FormFields.tsx @@ -0,0 +1,454 @@ +import { createSignal } from "solid-js"; +import type { Accessor, Setter, JSXElement } from "solid-js"; +import { createForm, type FieldApi } from "@tanstack/solid-form"; +import { TbInfoCircle, TbEye } from "solid-icons/tb"; + +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + TextField, + TextFieldLabel, + TextFieldInput, + TextFieldTextArea, + type TextFieldType, +} from "@/components/ui/text-field"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; + +import type { ColumnDataType } from "@/lib/bindings"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyFieldApi = FieldApi; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type FieldApiT = FieldApi; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type FormType = ReturnType>; + +type TextFieldOptions = { + disabled?: boolean; + type?: TextFieldType; + onKeyUp?: Setter; + required?: boolean; + + label: () => JSXElement; + info?: JSXElement; + autocomplete?: string; + + // Optional placeholder string for absent values, e.g. "NULL". Optional only option. + nullPlaceholder?: string; +}; + +export function buildTextFormField(opts: TextFieldOptions) { + const keyUp = opts.onKeyUp; + + return (field: () => AnyFieldApi) => ( + +
+ {opts.label()} + + { + const v = (e.target as HTMLInputElement).value; + field().handleChange(v); + if (keyUp) { + keyUp(v); + } + }} + /> + +
+ {field && } +
+ +
{opts.info}
+
+
+ ); +} + +export function buildSecretFormField(opts: Omit) { + const keyUp = opts.onKeyUp; + const [type, setType] = createSignal("password"); + + return (field: () => AnyFieldApi) => ( + +
+ {opts.label()} + +
+ { + const v = (e.target as HTMLInputElement).value; + field().handleChange(v); + if (keyUp) { + keyUp(v); + } + }} + /> + + +
+ +
+ {field && } +
+ +
{opts.info}
+
+
+ ); +} + +export function OptionalTextFormField(props: { + label: () => JSXElement; + info?: JSXElement; + field?: () => AnyFieldApi; + + type?: TextFieldType; + onKeyUp?: Setter; + + initial?: string; + initialEnabled?: boolean; + disabled?: boolean; + + nullPlaceholder?: string; + + handleBlur?: () => void; + handleChange?: (v: string | undefined) => void; +}) { + const [text, setText] = createSignal(props.initial ?? ""); + const [enabled, setEnabled] = createSignal( + props.initialEnabled ?? props.initial !== undefined, + ); + + const externDisable = props.disabled ?? false; + const effEnabled = () => enabled() && !externDisable; + + const keyUp = props.onKeyUp; + + return ( + +
+ + {props.label()} + + +
+ { + const v = (e.target as HTMLInputElement).value; + setText(v); + props.handleChange?.(v); + if (keyUp) { + keyUp(v); + } + }} + onChange={(e: Event) => { + const v: string = (e.currentTarget as HTMLInputElement).value; + props.handleChange?.(v); + }} + /> + + { + setEnabled(value); + // NOTE: null is critical here to actively unset a cell, undefined + // would merely take it out of the patch set. + props.handleChange?.(value ? text() : undefined); + }} + /> +
+ +
+ {props.field && } +
+ +
{props.info}
+
+
+ ); +} + +export function buildOptionalTextFormField(opts: TextFieldOptions) { + return (field: () => AnyFieldApi) => { + return ( + + ); + }; +} + +export function buildTextAreaFormField(opts: TextFieldOptions, rows?: number) { + const keyUp = opts?.onKeyUp; + + return (field: () => AnyFieldApi) => ( + +
+ {opts.label()} + + { + const v = (e.target as HTMLInputElement).value; + field().handleChange(v); + if (keyUp) { + keyUp(v); + } + }} + /> + +
+ {field && } +
+ +
{opts.info}
+
+
+ ); +} + +type NumberFieldOptions = { + disabled?: boolean; + label: () => JSXElement; + + info?: JSXElement; + integer?: boolean; +}; + +export function buildNumberFormField(opts: NumberFieldOptions) { + const isInt = opts.integer ?? false; + + return (field: () => AnyFieldApi) => { + return ( + +
+ {opts.label()} + + { + const v = (e.target as HTMLInputElement).value; + const i = parseInt(v); + field().handleChange(i); + }} + /> + +
+ {field && } +
+ +
{opts?.info}
+
+
+ ); + }; +} + +export function buildBoolFormField(props: { label: () => JSXElement }) { + return (field: () => AnyFieldApi) => ( +
+ + + +
+ ); +} + +interface SelectFieldOpts { + label: () => JSXElement; + disabled?: Accessor; + multiple?: boolean; +} + +export function buildSelectField(options: T[], opts: SelectFieldOpts) { + return (field: () => AnyFieldApi) => ( +
+ + + +
+ ); +} + +function FieldInfo(props: { field: FieldApiT }) { + return ( + <> + {props.field.state.meta.errors ? ( + {props.field.state.meta.errors} + ) : null} + {props.field.state.meta.isValidating ? "Validating..." : null} + + ); +} + +export function notEmptyValidator() { + return { + onChange: ({ value }: { value: string | undefined }) => { + if (!value) { + return "Must not be empty"; + } + }, + }; +} + +export function largerThanZero() { + return { + onChange: ({ value }: { value: number | undefined }) => { + if (!value || value <= 0) { + return "Must be positive"; + } + }, + }; +} + +export function formFieldBuilder( + type: ColumnDataType, + labelText: string, + optional: boolean, + nullPlaceholder?: string, +) { + const label = () => ( +
+ {type === "Blob" && ( + + } + variant="link" + > + + + + + Binary blobs can be entered encoded as url-safe Base64. + + + )} + + {labelText} +
+ ); + + if (type === "Text" || type === "Blob") { + if (optional) { + return buildOptionalTextFormField({ label, nullPlaceholder }); + } else { + return buildTextFormField({ label }); + } + } + + if (type === "Integer" && !optional) { + return buildNumberFormField({ label }); + } + + console.warn( + `Type: (${type}, ${optional}) not (yet) supported. Falling back to textfields`, + ); + if (optional) { + return buildOptionalTextFormField({ label, nullPlaceholder }); + } else { + return buildTextFormField({ label }); + } +} + +export const gapStyle = "gap-x-2 gap-y-1"; diff --git a/ui/admin/src/components/IndexPage.tsx b/ui/admin/src/components/IndexPage.tsx new file mode 100644 index 0000000..721838a --- /dev/null +++ b/ui/admin/src/components/IndexPage.tsx @@ -0,0 +1,113 @@ +import { For } from "solid-js"; +import type { IconTypes } from "solid-icons"; +import { + TbDatabase, + TbEdit, + TbUsers, + TbTimeline, + TbSettings, +} from "solid-icons/tb"; + +import { Separator } from "@/components/ui/separator"; + +function ColorPalette() { + return ( +
+
Background
+
Foreground
+ +
Muted
+
Muted FG
+ +
Border
+
Border Input
+ +
Card
+
Card FG
+ +
Primary
+
Primary FG
+ +
Secondary
+
Secondary FG
+ +
Accent
+
Accent FG
+ +
Destructive
+
Destructive FG
+ +
info
+
info FG
+ +
success
+
success FG
+ +
warning
+
warning FG
+ +
error
+
error FG
+ +
Ring
+
+ ); +} + +type Element = { + icon: IconTypes; + content: string; +}; + +const elements = [ + { + icon: TbDatabase, + content: "Browse, create or alter your Tables, Indexes, and Views.", + }, + { + icon: TbEdit, + content: "Untethered script access letting you execute arbitrary SQL.", + }, + { + icon: TbUsers, + content: "Browse and manage your application's user registry.", + }, + { icon: TbTimeline, content: "Access logs for your application" }, + { icon: TbSettings, content: "Server settings" }, +] as Element[]; + +export function IndexPage() { + return ( + <> +

Welcome to TrailBase

+ + + +
+
+ Quick overview: + + + {(item) => { + const Icon = item.icon; + return ( +
+ {item.content} +
+ ); + }} +
+ + + TrailBase is at an early stage. Help us out and go to{" "} + GitHub to + find open issues or report new ones. The full documentation is + available on trailbase.io + +
+ + {import.meta.env.DEV && } +
+ + ); +} diff --git a/ui/admin/src/components/NavBar.tsx b/ui/admin/src/components/NavBar.tsx new file mode 100644 index 0000000..f282ae4 --- /dev/null +++ b/ui/admin/src/components/NavBar.tsx @@ -0,0 +1,52 @@ +import { Location } from "@solidjs/router"; +import { + TbDatabase, + TbEdit, + TbUsers, + TbTimeline, + TbSettings, +} from "solid-icons/tb"; +import { IconTypes } from "solid-icons"; + +import { AuthButton } from "@/components/auth/AuthButton"; + +import logo from "@assets/logo_104.webp"; + +const BASE = "/_/admin"; +const options = [ + [`${BASE}/tables`, TbDatabase], + [`${BASE}/editor`, TbEdit], + [`${BASE}/auth`, TbUsers], + [`${BASE}/logs`, TbTimeline], + [`${BASE}/settings`, TbSettings], +]; + +export function NavBar(props: { location: Location }) { + return ( +
+ + + +
+ ); +} diff --git a/ui/admin/src/components/SafeSheet.tsx b/ui/admin/src/components/SafeSheet.tsx new file mode 100644 index 0000000..054cbe8 --- /dev/null +++ b/ui/admin/src/components/SafeSheet.tsx @@ -0,0 +1,103 @@ +import type { JSXElement, Signal } from "solid-js"; +import { children, createSignal, splitProps } from "solid-js"; +import * as SheetPrimitive from "@kobalte/core/dialog"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Sheet } from "@/components/ui/sheet"; + +interface SafeSheetProps { + markDirty: () => void; + close: () => void; +} + +interface LocalProps { + open?: Signal; + children: (sheet: SafeSheetProps) => JSXElement; +} + +type SafeProps = LocalProps & + Omit; + +export function ConfirmCloseDialog(props: { + back: () => void; + confirm: () => void; +}) { + return ( + + Confirmation + Are you sure? + + + + + + + ); +} + +export function SafeSheet(props: SafeProps) { + const [local, others] = splitProps(props, ["children", "open"]); + + const [sheetOpen, setSheetOpen] = props.open ?? createSignal(false); + const [dirty, setDirty] = createSignal(false); + const [dialogOpen, setDialogOpen] = createSignal(false); + + const onSheetOpenChange = (isOpen: boolean) => { + if (isOpen) { + setDirty(false); + setSheetOpen(true); + return; + } + + if (!dirty()) { + setSheetOpen(false); + return; + } + + // We're closing a sheet with a dirty form => open a confirmation dialog. + setDialogOpen(true); + }; + + return ( + + setDialogOpen(false)} + confirm={() => { + setDialogOpen(false); + setSheetOpen(false); + }} + /> + + + {local.children({ + markDirty: () => setDirty(true), + close: () => setSheetOpen(false), + })} + + + ); +} + +export function SheetContainer(props: { children: JSXElement }) { + const resolved = children(() => props.children); + return ( +
+ {resolved()} +
+ ); +} diff --git a/ui/admin/src/components/SplitView.tsx b/ui/admin/src/components/SplitView.tsx new file mode 100644 index 0000000..3dc5f76 --- /dev/null +++ b/ui/admin/src/components/SplitView.tsx @@ -0,0 +1,103 @@ +import { createSignal, onMount, onCleanup } from "solid-js"; +import type { JSXElement, Accessor } from "solid-js"; +import { persistentAtom } from "@nanostores/persistent"; +import { useStore } from "@nanostores/solid"; + +import { + Resizable, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; + +export function createWindowWidth(): Accessor { + const [width, setWidth] = createSignal(window.innerWidth); + + const handler = (_event: Event) => setWidth(window.innerWidth); + + onMount(() => window.addEventListener("resize", handler)); + onCleanup(() => window.removeEventListener("resize", handler)); + + return width; +} + +function setSizes(v: number[] | ((prev: number[]) => number[])) { + const prev = $sizes.get(); + const next: number[] = typeof v === "function" ? v(prev) : v; + const width = window.innerWidth; + + // This is a bit hacky. On destruction Corvu pops panes and removes sizes one by one. + // So switching between pages we'd always start with empty sizes. We basically just avoid + // shrinking the array. We also make sure the new relative dimension for element[0] is + // within range. + if ( + next.length >= prev.length && + next[0] >= minSizePx / width && + next[0] < maxSizePx / width + ) { + return $sizes.set(next); + } + return prev; +} + +export function SplitView(props: { + first: (props: { horizontal: boolean }) => JSXElement; + second: (props: { horizontal: boolean }) => JSXElement; +}) { + function VerticalSplit() { + return ( +
+ + +
+ ); + } + + function HorizontalSplit() { + const size = useStore($sizes); + + return ( + + + + + + + + + + + + ); + } + + const windowWidth = createWindowWidth(); + const thresh = 5 * minSizePx; + return ( + <>{windowWidth() < thresh ? : } + ); +} + +const minSizePx = 160; +const maxSizePx = 300; + +function initialSize(): number[] { + const width = window.innerWidth; + const left = Math.max(minSizePx, 0.15 * width); + const right = width - left; + + return [left / width, right / width]; +} + +export const $sizes = persistentAtom( + "resizable-sizes", + initialSize(), + { + encode: JSON.stringify, + decode: JSON.parse, + }, +); diff --git a/ui/admin/src/components/Table.tsx b/ui/admin/src/components/Table.tsx new file mode 100644 index 0000000..4213514 --- /dev/null +++ b/ui/admin/src/components/Table.tsx @@ -0,0 +1,435 @@ +import { + For, + Show, + createEffect, + createMemo, + createSignal, + splitProps, + type Accessor, +} from "solid-js"; +import { createWritableMemo } from "@solid-primitives/memo"; +import { useSearchParams } from "@solidjs/router"; +import type { ColumnDef } from "@tanstack/solid-table"; +import { + flexRender, + createSolidTable, + getCoreRowModel, +} from "@tanstack/solid-table"; +import type { + Table as TableType, + PaginationState, + OnChangeFn, +} from "@tanstack/table-core"; + +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { createWindowWidth } from "@/components/SplitView"; + +type SearchParams = { + pageIndex: string; + pageSize: string; +}; + +export function safeParseInt(v: string | undefined): number | undefined { + if (v !== undefined) { + try { + const num = parseInt(v); + if (!isNaN(num)) { + return num; + } + } catch (err) { + console.warn(err); + } + } + return undefined; +} + +export function defaultPaginationState(opts?: { + index?: number; + size?: number; +}): PaginationState { + return { + pageIndex: opts?.index ?? 0, + pageSize: opts?.size ?? 20, + }; +} + +type Props = { + columns: Accessor[]>; + data: Accessor; + + rowCount?: number; + initialPagination?: PaginationState; + onPaginationChange?: OnChangeFn; + + onRowSelection?: (idx: number, row: TData, value: boolean) => void; + onRowClick?: (idx: number, row: TData) => void; +}; + +export function DataTable(props: Props) { + const [local] = splitProps(props, ["columns", "data"]); + const [rowSelection, setRowSelection] = createSignal({}); + + const [searchParams, setSearchParams] = useSearchParams(); + const paginationEnabled = props.onPaginationChange !== undefined; + + function initPaginationState(): PaginationState { + return { + pageIndex: + safeParseInt(searchParams.pageIndex) ?? + props.initialPagination?.pageIndex ?? + 0, + pageSize: + safeParseInt(searchParams.pageSize) ?? + props.initialPagination?.pageSize ?? + 20, + }; + } + + const [paginationState, setPaginationState] = + createWritableMemo(() => { + // Whenever column definitions change, reset pagination state. + // + // FIXME: column definition is an insufficient proxy, we should also reset + // when switching between tables/views with matching schemas. Maybe we + // should just inject a Signal + const _c = props.columns(); + + return initPaginationState(); + }); + createEffect(() => { + setSearchParams({ ...paginationState() }); + }); + + const columns = () => + props.onRowSelection + ? [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + { + const cb = props.onRowSelection!; + cb(row.index, row.original, value); + row.toggleSelected(value); + }} + aria-label="Select row" + onClick={(event: Event) => { + // Prevent event from propagating and opening the edit row dialog. + event.stopPropagation(); + }} + /> + ), + enableSorting: false, + enableHiding: false, + } as ColumnDef, + ...local.columns(), + ] + : local.columns(); + + const table = createMemo(() => + createSolidTable({ + get data() { + return local.data() || []; + }, + state: { + pagination: paginationState(), + rowSelection: rowSelection(), + }, + columns: columns(), + getCoreRowModel: getCoreRowModel(), + + // NOTE: requires setting up the header cells with resize handles. + // enableColumnResizing: true, + // columnResizeMode: 'onChange', + + // pagination { + manualPagination: paginationEnabled, + onPaginationChange: (state) => { + setPaginationState(state); + const handler = props.onPaginationChange; + if (handler) { + handler(state); + } + }, + rowCount: props.rowCount, + // } pagination + + // Just means, the input data is already filtered. + manualFiltering: true, + + // If set to true, pagination will be reset to the first page when page-altering state changes + // eg. data is updated, filters change, grouping changes, etc. + // + // NOTE: In our current setup this causes infinite reload cycles when paginating. + autoResetPageIndex: false, + + enableMultiRowSelection: props.onRowSelection ? true : false, + onRowSelectionChange: setRowSelection, + }), + ); + + return ( + <> + {paginationEnabled && ( + + )} + +
+
+ + + {(headerGroup) => ( + + + {(header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + }} + + + )} + + + + + + + No results. + + + } + > + + {(row) => { + const onClick = () => { + const handler = props.onRowClick; + if (!handler) { + return; + } + handler(row.index, row.original); + }; + + return ( + + + {(cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + )} + + + ); + }} + + + +
+
+ + {/* + {paginationEnabled && ( + + )} + */} + + ); +} + +function PaginationControl(props: { + table: TableType; + rowCount?: number; +}) { + const table = () => props.table; + + const PerPage = () => ( +
+ + per page +
+ ); + + const PaginationInfoText = () => { + const width = createWindowWidth(); + + const pageIndex = () => table().getState().pagination.pageIndex; + const pageCount = () => table().getPageCount(); + const rowCount = () => props.rowCount; + + return ( + <> + {rowCount() && width() > 578 + ? `page ${pageIndex() + 1} of ${pageCount()} (${rowCount()} rows total)` + : `page ${pageIndex() + 1} of ${pageCount()}`} + + ); + }; + + return ( +
+
+
+ + + + +
+ +
+ +
+
+ + +
+ ); +} diff --git a/ui/admin/src/components/auth/AccountsPage.tsx b/ui/admin/src/components/auth/AccountsPage.tsx new file mode 100644 index 0000000..3a24e4e --- /dev/null +++ b/ui/admin/src/components/auth/AccountsPage.tsx @@ -0,0 +1,17 @@ +import { Separator } from "@/components/ui/separator"; + +import { UserTable } from "./UserTable"; + +export function AccountsPage() { + return ( + <> +

Users

+ + + +
+ +
+ + ); +} diff --git a/ui/admin/src/components/auth/AddUser.tsx b/ui/admin/src/components/auth/AddUser.tsx new file mode 100644 index 0000000..f82a79a --- /dev/null +++ b/ui/admin/src/components/auth/AddUser.tsx @@ -0,0 +1,109 @@ +import type { JSXElement } from "solid-js"; +import { createForm } from "@tanstack/solid-form"; + +import { Button } from "@/components/ui/button"; +import { SheetHeader, SheetTitle, SheetFooter } from "@/components/ui/sheet"; + +import { + buildBoolFormField, + buildTextFormField, + notEmptyValidator, +} from "@/components/FormFields"; +import type { CreateUserRequest } from "@/lib/bindings"; +import { createUser } from "@/lib/user"; + +export function AddUser(props: { + close: () => void; + markDirty: () => void; + userRefetch: () => void; +}) { + const form = createForm(() => ({ + defaultValues: { + email: "", + password: "", + verified: true, + admin: false, + }, + onSubmit: async ({ value }) => { + createUser(value) + .then(() => { + props.userRefetch(); + props.close(); + }) + .catch(console.error); + }, + })); + + return ( +
+ + {"Add new user"} + + +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > +
+ + {buildTextFormField({ label: () => E-mail, type: "email" })} + + + + {buildTextFormField({ + label: () => Password, + type: "password", + })} + + + + {buildBoolFormField({ + label: () => ( + +
Admin
+
+ ), + })} +
+ + + {buildBoolFormField({ + label: () => ( + +
Verified
+
+ ), + })} +
+
+ + + ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + })} + children={(state) => { + return ( + + ); + }} + /> + +
+
+ ); +} + +function L(props: { children: JSXElement }) { + return
{props.children}
; +} diff --git a/ui/admin/src/components/auth/AuthButton.tsx b/ui/admin/src/components/auth/AuthButton.tsx new file mode 100644 index 0000000..aba0f13 --- /dev/null +++ b/ui/admin/src/components/auth/AuthButton.tsx @@ -0,0 +1,74 @@ +import { createEffect, createSignal, Show } from "solid-js"; +import { useStore } from "@nanostores/solid"; +import { TbUser } from "solid-icons/tb"; +import { type User } from "trailbase"; + +import { urlSafeBase64ToUuid } from "@/lib/utils"; +import { client, $user } from "@/lib/fetch"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +const navBarIconSize = 22; +const style = + "rounded-full hover:bg-accent-600 hover:text-white transition-all p-[10px]"; + +function Profile(props: { user: User }) { + const profile = props.user; + + // TODO: Bring back avater. + return ( +
+
E-mail: {profile.email}
+ +
id: {urlSafeBase64ToUuid(profile.id)}
+ + {import.meta.env.DEV &&
id b64: {profile.id}
} +
+ ); +} + +export function AuthButton() { + const [open, setOpen] = createSignal(false); + const user = useStore($user); + + createEffect(() => { + console.log("user", user()); + }); + + return ( + + + + + + Current User + + + + + + + + + + + + + ); +} diff --git a/ui/admin/src/components/auth/LoginPage.tsx b/ui/admin/src/components/auth/LoginPage.tsx new file mode 100644 index 0000000..74a3d7c --- /dev/null +++ b/ui/admin/src/components/auth/LoginPage.tsx @@ -0,0 +1,76 @@ +import { createSignal } from "solid-js"; +import { client } from "@/lib/fetch"; + +import { showToast } from "@/components/ui/toast"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + TextField, + TextFieldLabel, + TextFieldInput, +} from "@/components/ui/text-field"; + +export function LoginPage() { + const [username, setUsername] = createSignal(""); + const [password, setPassword] = createSignal(""); + + const onSubmit = async () => { + try { + await client.login(username(), password()); + } catch (err) { + showToast({ + title: "Uncaught Error", + description: `${err}`, + variant: "error", + }); + } + // Don't reload. + return false; + }; + + return ( +
+ +
+

Login

+ + + E-mail + + { + const target = e.currentTarget as HTMLInputElement; + setUsername(target.value); + }} + /> + + + + Password + + { + const target = e.currentTarget as HTMLInputElement; + setPassword(target.value); + }} + /> + + +
+ +
+
+
+
+ ); +} diff --git a/ui/admin/src/components/auth/UserTable.tsx b/ui/admin/src/components/auth/UserTable.tsx new file mode 100644 index 0000000..3a505c4 --- /dev/null +++ b/ui/admin/src/components/auth/UserTable.tsx @@ -0,0 +1,363 @@ +import { + createResource, + createSignal, + Match, + Show, + Switch, + Suspense, +} from "solid-js"; +import type { Setter, JSXElement } from "solid-js"; +import { createForm } from "@tanstack/solid-form"; +import { TbCrown } from "solid-icons/tb"; +import type { DialogTriggerProps } from "@kobalte/core/dialog"; + +import { + type ColumnDef, + createColumnHelper, + PaginationState, +} from "@tanstack/solid-table"; +import { Button } from "@/components/ui/button"; +import { FilterBar } from "@/components/FilterBar"; +import { + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; + +import { Checkbox } from "@/components/ui/checkbox"; +import { DataTable } from "@/components/Table"; +import { Label } from "@/components/ui/label"; +import { AddUser } from "@/components/auth/AddUser"; +import { deleteUser, updateUser } from "@/lib/user"; +import type { + UpdateUserRequest, + UserJson, + ListUsersResponse, +} from "@/lib/bindings"; +import { + buildTextFormField, + buildOptionalTextFormField, +} from "@/components/FormFields"; +import { SafeSheet, SheetContainer } from "@/components/SafeSheet"; +import { adminFetch } from "@/lib/fetch"; + +type FetchArgs = { + filter: string | undefined; + pageSize: number; + pageIndex: number; + cursors: string[]; +}; + +export async function fetchUsers( + source: FetchArgs, + { value }: { value: ListUsersResponse | undefined }, +): Promise { + const pageIndex = source.pageIndex; + const limit = source.pageSize; + const cursors = source.cursors; + + const filter = source.filter ?? ""; + const filterQuery = filter + .split("AND") + .map((frag) => frag.trim().replaceAll(" ", "")) + .join("&"); + + console.log("QUERY: ", filterQuery); + + const params = new URLSearchParams(filterQuery); + params.set("limit", limit.toString()); + + // Build the next UUIDv7 "cursor" from previous response and update local + // cursor stack. If we're paging forward we add new cursors, otherwise we're + // re-using previously seen cursors for consistency. We reset if we go back + // to the start. + if (pageIndex === 0) { + cursors.length = 0; + } else { + const index = pageIndex - 1; + if (index < cursors.length) { + // Already known page + params.set("cursor", cursors[index]); + } else { + // New page case: use cursor from previous response or fall back to more + // expensive and inconsistent offset-based pagination. + const cursor = value?.cursor; + if (cursor) { + cursors.push(cursor); + params.set("cursor", cursor); + } else { + params.set("offset", `${pageIndex * source.pageSize}`); + } + } + } + + try { + const response = await adminFetch(`/user?${params}`); + return await response.json(); + } catch (err) { + if (value) { + return value; + } + throw err; + } +} + +const columnHelper = createColumnHelper(); + +function buildColumns( + setEditUser: Setter, + userRefetch: () => void, +): ColumnDef[] { + return [ + { + header: "id", + accessorFn: ({ id }) => id, + }, + columnHelper.accessor("email", { header: "email" }) as ColumnDef, + { + header: "verified", + accessorFn: ({ verified }) => Boolean(verified), + }, + columnHelper.accessor("id", { + header: "Admin", + cell: (ctx) => ( +
+ {ctx.row.original.admin ? : null} +
+ ), + }) as ColumnDef, + columnHelper.display({ + header: "Actions", + cell: (ctx) => { + const userId = ctx.row.original.id; + return ( +
+ + + +
+ ); + }, + }), + ]; +} + +function EditSheetContent(props: { + user: UserJson; + close: () => void; + markDirty: () => void; + refetch: () => void; +}) { + const form = createForm(() => ({ + defaultValues: { + id: props.user.id, + email: props.user.email, + password: null, + verified: props.user.verified, + }, + onSubmit: async ({ value }) => { + updateUser(value) + .then(() => props.close()) + .catch(console.error); + + props.refetch(); + }, + })); + + return ( + + + Edit User + + Change a user's properties. Be careful + + +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > +
+ + {buildTextFormField({ + label: () => E-mail, + type: "email", + })} + + + + {buildOptionalTextFormField({ + label: () => Password, + type: "password", + })} + + + + {(field) => ( +
+ + +
+ )} +
+
+ + + ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + })} + children={(state) => { + return ( + + ); + }} + /> + +
+
+ ); +} + +export function UserTable() { + const [filter, setFilter] = createSignal(); + const [pagination, setPagination] = createSignal({ + pageSize: 20, + pageIndex: 0, + }); + const cursors: string[] = []; + + const buildFetchArgs = (): FetchArgs => ({ + pageSize: pagination().pageSize, + pageIndex: pagination().pageIndex, + cursors: cursors, + filter: filter(), + }); + + const [users, { refetch }] = createResource(buildFetchArgs, fetchUsers); + const [editUser, setEditUser] = createSignal(); + + const columns = () => buildColumns(setEditUser, refetch); + + return ( + Loading...
}> + + + Error: {users.error} + + + +
+ { + if (value === filter()) { + refetch(); + } else { + setFilter(value); + } + }} + example='e.g. "email[like]=%@foo.com"' + /> + +
+ users()?.users} + rowCount={Number(users()?.total_row_count)} + initialPagination={pagination()} + onPaginationChange={setPagination} + /> +
+ + { + return ( + <> + + + + + ( + + )} + /> + + ); + }} + /> + + {/* WARN: This might open multiple sheets or at least scrims for each row */} + editUser() !== undefined, + (isOpen: boolean | ((value: boolean) => boolean)) => { + if (!isOpen) { + setEditUser(undefined); + } + }, + ]} + children={(sheet) => { + return ( + + + + + + ); + }} + /> +
+
+
+ + ); +} + +function L(props: { children: JSXElement }) { + return
{props.children}
; +} + +const sheetMaxWidth = "sm:max-w-[520px]"; diff --git a/ui/admin/src/components/editor/EditorPage.tsx b/ui/admin/src/components/editor/EditorPage.tsx new file mode 100644 index 0000000..287e2d9 --- /dev/null +++ b/ui/admin/src/components/editor/EditorPage.tsx @@ -0,0 +1,443 @@ +import { + For, + Match, + Show, + Switch, + createEffect, + createResource, + createSignal, + onCleanup, + onMount, +} from "solid-js"; +import { createWritableMemo } from "@solid-primitives/memo"; +import type { Accessor, Signal } from "solid-js"; +import type { ColumnDef } from "@tanstack/solid-table"; +import { persistentAtom } from "@nanostores/persistent"; +import { useStore } from "@nanostores/solid"; +import { TbTrash, TbEdit, TbDeviceFloppy, TbHelp } from "solid-icons/tb"; + +import { Separator } from "@/components/ui/separator"; +import { EditorView, keymap, lineNumbers, gutter } from "@codemirror/view"; +import { EditorState } from "@codemirror/state"; +import { defaultKeymap } from "@codemirror/commands"; +import { + syntaxHighlighting, + defaultHighlightStyle, +} from "@codemirror/language"; +import { sql } from "@codemirror/lang-sql"; + +import { SplitView } from "@/components/SplitView"; +import { + Resizable, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, +} from "@/components/ui/dialog"; +import { TextField, TextFieldInput } from "@/components/ui/text-field"; + +import { DataTable } from "@/components/Table"; +import type { QueryRequest, QueryResponse } from "@/lib/bindings"; +import { adminFetch } from "@/lib/fetch"; +import { isNotNull } from "@/lib/schema"; + +async function executeSql( + sql: string | undefined, +): Promise { + if (sql === undefined) { + return undefined; + } + + const response = await adminFetch("/query", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: sql, + } as QueryRequest), + }); + + return await response.json(); +} + +type RowData = Array; + +function View(props: { response: Accessor }) { + const response = () => props.response(); + + const columnDefs = (): ColumnDef[] => { + return (response()?.columns ?? []).map((col, idx) => { + const notNull = isNotNull(col.options); + + const header = `${col.name} [${col.data_type}${notNull ? "" : "?"}]`; + return { + accessorFn: (row) => row[idx], + header, + }; + }); + }; + + return ( + No Data}> + + 0}> + response()!.rows as RowData[]} + /> + + + + No data returned by query + + + + ); +} + +function SideBar(props: { + selectedSignal: Signal; + horizontal: boolean; +}) { + const [selected, setSelected] = props.selectedSignal; + const scripts = useStore($scripts); + + function addNewScript() { + const s = [ + ...$scripts.get(), + { + name: "New Script", + contents: "SELECT COUNT(*) FROM _user;", + }, + ]; + $scripts.set(s); + + setSelected(s.length - 1); + } + + const flexStyle = () => (props.horizontal ? "flex flex-col h-dvh" : "flex"); + return ( +
+ + {(_script: Script, index: Accessor) => { + const scriptName = () => scripts()[index()].name; + return ( + + ); + }} + + + +
+ ); +} + +function HelpDialog() { + return ( + + + + + + + + Editor Help + + +

+ The editor lets you execute arbitrary SQL statements, so be careful + with what you wish for. If you just want to experiment, consider + working on a non-prod data set or a copy. +

+ +

+ Further note that there's no pagination, so whatever you query will be + returned. Working on large data sets, you might want to{" "} + LIMIT your result size. +

+ +

+ Also note that scripts are currently stored in your browser's local + storage. This means, switching devices, browsers, or the origin of + your website, you won't have access to your scripts. This is something + we'd like to lower into the database layer in the future. +

+
+
+ ); +} + +function RenameDialog(props: { selected: number; script: Script }) { + const [open, setOpen] = createSignal(false); + const [name, setName] = createWritableMemo(() => props.script.name); + + const onSubmit = () => { + updateExistingScript(props.selected, { + ...props.script, + name: name(), + }); + setOpen(false); + }; + + return ( + + + + + + + + Rename + + +
+ + { + setName((e.target as HTMLInputElement).value); + }} + /> + + + + + +
+
+
+ ); +} + +function EditorPanel(props: { selectedSignal: Signal }) { + const [selected, setSelected] = props.selectedSignal; + + const scripts = useStore($scripts); + const script = (): Script => { + const s = scripts(); + if (selected() < s.length) { + return s[selected()]; + } + if (s.length === 0) { + return defaultScript; + } + return s[s.length - 1]; + }; + + const [queryString, setQueryString] = createSignal(); + const [executionResult, { mutate, refetch }] = createResource( + queryString, + executeSql, + ); + + createEffect(() => { + // Subscribe to selected script changes and reset the query results. + selected(); + const r = executionResult(); + if (r && editor?.state.doc.toString() !== queryString()) { + mutate(undefined); + } + }); + + const execute = () => { + const text = editor?.state.doc.toString(); + if (text) { + // We need to distinguish to work-around createResources caching. + if (queryString() === text) { + refetch(); + } else { + setQueryString(text); + } + } + }; + + let ref: HTMLDivElement | undefined; + let editor: EditorView | undefined; + + const newEditorState = (contents: string) => + EditorState.create({ + doc: contents, + extensions: [ + myTheme, + keymap.of([ + { + key: "Ctrl-Enter", + run: () => { + execute(); + return true; + }, + }, + ...defaultKeymap, + ]), + lineNumbers(), + gutter({ class: "cm-mygutter" }), + sql(), + syntaxHighlighting(defaultHighlightStyle), + ], + }); + + onCleanup(() => { + console.debug("editor cleanup"); + editor?.destroy(); + }); + onMount(() => { + editor?.destroy(); + editor = new EditorView({ + state: newEditorState(script().contents), + parent: ref!, + }); + editor.focus(); + }); + + createEffect(() => { + console.debug("setting editor state"); + const s = script(); + editor?.setState(newEditorState(s.contents)); + }); + + return ( + <> + + +

+
+ Editor + > + {script().name} + + + + { + const e = editor; + if (e) { + updateExistingScript(selected(), { + ...script(), + contents: e.state.doc.toString(), + }); + } + }} + /> +
+ +
+ + { + $scripts.set($scripts.get().toSpliced(selected(), 1)); + setSelected(Math.max(0, selected() - 1)); + }} + /> +
+

+ + + +
+ {/* Editor */} +
+
+
+ +
+ +
+
+ + + + + + + + + + ); +} + +export function EditorPage() { + const selectedSignal = createSignal(0); + + return ( + { + return ( + + ); + }} + second={() => { + return ; + }} + /> + ); +} + +export default EditorPage; + +const myTheme = EditorView.theme( + { + ".cm-gutters": { + backgroundColor: "#eeeeee", + color: "#000", + border: "none", + }, + }, + { dark: false }, +); + +type Script = { + name: string; + contents: string; +}; + +const defaultScript: Script = { + name: "Select", + contents: "SELECT\n *\nFROM\n _user;", +}; + +// NOTE: It seems like "nanostores" diffs array contents. It re-renders, if the array +// object is different and at least one of the contained objects has a different id. +// In other words just copying the array and setting a new Script.name, doesn't trigger, +// we have to replace the entire script. +// If this behavior is documented somewhere, I couldn't find it. I wish it would be less +// smart :/. +function updateExistingScript(index: number, script: Script) { + const s = [...$scripts.get()]; + s[index] = { + ...script, + }; + $scripts.set(s); +} + +const $scripts = persistentAtom("scripts", [defaultScript], { + encode: JSON.stringify, + decode: JSON.parse, +}); diff --git a/ui/admin/src/components/logs/LogsPage.tsx b/ui/admin/src/components/logs/LogsPage.tsx new file mode 100644 index 0000000..0d4c307 --- /dev/null +++ b/ui/admin/src/components/logs/LogsPage.tsx @@ -0,0 +1,337 @@ +import { + Match, + Switch, + createEffect, + createResource, + createSignal, + onCleanup, +} from "solid-js"; +import { useSearchParams } from "@solidjs/router"; +import { + type ColumnDef, + createColumnHelper, + type PaginationState, +} from "@tanstack/solid-table"; +import { Chart } from "chart.js/auto"; +import type { + ChartData, + ScriptableLineSegmentContext, + TooltipItem, +} from "chart.js/auto"; +import { TbRefresh } from "solid-icons/tb"; + +import { Separator } from "@/components/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +import { DataTable, defaultPaginationState } from "@/components/Table"; +import { FilterBar } from "@/components/FilterBar"; +import type { LogJson, ListLogsResponse, Stats } from "@/lib/bindings"; +import { adminFetch } from "@/lib/fetch"; + +const columnHelper = createColumnHelper(); + +const columns: ColumnDef[] = [ + columnHelper.display({ + header: "Created", + cell: (ctx) => { + const timestamp = new Date(ctx.row.original.created * 1000); + return ( + + {timestamp.toUTCString()} + + + {timestamp.toLocaleString()} (Local) + + + ); + }, + }), + { + accessorKey: "type", + cell: (ctx) => { + const type = ctx.row.original.type; + if (type === 2) { + return "HTTP"; + } else if (type === 1) { + return "Admin API"; + } else if (type === 3) { + return "Record API"; + } + return type; + }, + }, + columnHelper.display({ + header: "Level", + cell: (ctx) => <>{levelToName.get(ctx.row.original.level)}, + }), + { accessorKey: "status" }, + { accessorKey: "method" }, + { accessorKey: "url" }, + { + accessorKey: "latency_ms", + header: "Latency (ms)", + }, + { accessorKey: "client_ip" }, + { accessorKey: "referer" }, + { + accessorKey: "user_agent", + cell: (ctx) => { + return ( + + +
+ {ctx.row.original.user_agent} +
+
+ + {ctx.row.original.user_agent} +
+ ); + }, + }, + { accessorKey: "data" }, +]; + +type GetLogsProps = { + pagination: PaginationState; + // Filter where clause to pass to the fetch. + filter?: string; + // Keep track of the timestamp cursor to have consistency for forwards and backwards pagination. + cursors: string[]; +}; + +// Value is the previous value in case this isn't the first fetch. +async function getLogs( + source: GetLogsProps, + { value }: { value: ListLogsResponse | undefined }, +): Promise { + const pageIndex = source.pagination.pageIndex; + const limit = source.pagination.pageSize; + const filter = source.filter ?? ""; + + // Here we're setting the timestamp "cursor". If we're paging forward we add new cursors. + // otherwise we're re-using previously seen cursors for consistency. We reset if we go back + // to the start. + const cursor = (() => { + if (pageIndex === 0) { + source.cursors.length = 0; + return undefined; + } + + const cursors = source.cursors; + const index = pageIndex - 1; + if (index < cursors.length) { + return cursors[index]; + } + + // New page case. + const cursor = value!.cursor; + if (cursor) { + cursors.push(cursor); + return cursor; + } + })(); + + const filterQuery = filter + .split("AND") + .map((frag) => frag.trim().replaceAll(" ", "")) + .join("&"); + + const params = new URLSearchParams(filterQuery); + params.set("limit", limit.toString()); + if (cursor) { + params.set("cursor", cursor); + } + + console.debug("Fetching logs for ", params); + const response = await adminFetch(`/logs?${params}`); + return await response.json(); +} + +export function LogsPage() { + const [searchParams, setSearchParams] = useSearchParams<{ + filter: string; + }>(); + const [filter, setFilter] = createSignal( + searchParams.filter, + ); + createEffect(() => { + setSearchParams({ filter: filter() }); + }); + + const [pagination, setPagination] = createSignal( + defaultPaginationState(), + ); + const cursors: string[] = []; + const getLogsProps = (): GetLogsProps => { + return { + pagination: pagination(), + filter: filter(), + cursors, + }; + }; + const [logsFetch, { refetch }] = createResource(getLogsProps, getLogs); + + return ( + <> +
+

Logs

+ + +
+ + + +
+ { + if (value === filter()) { + refetch(); + } else { + setFilter(value); + } + }} + example='e.g. "latency[lt]=2 AND status=200"' + /> + + + +

Loading...

+
+ + Error {`${logsFetch.error}`} + + + {pagination().pageIndex === 0 && ( + + )} + + columns} + data={() => logsFetch()?.entries} + rowCount={Number(logsFetch()?.total_row_count ?? -1)} + onPaginationChange={setPagination} + initialPagination={pagination()} + /> + +
+
+ + ); +} + +function changeDistantPointLineColorToTransparent( + ctx: ScriptableLineSegmentContext, +) { + const secondsApart = Math.abs(ctx.p0.parsed.x - ctx.p1.parsed.x) / 1000; + if (secondsApart > 1200) { + return "transparent"; + } + return undefined; +} + +function LogsChart(props: { stats?: Stats }) { + const stats = props.stats; + if (!stats) { + return null; + } + + const data = (): ChartData | undefined => { + const s = stats; + if (!s) return; + + const labels = s.rate.map(([ts, _v]) => Number(ts) * 1000); + const data = s.rate.map(([_ts, v]) => v); + + return { + labels, + datasets: [ + { + data, + label: "Rate", + showLine: true, + fill: false, + segment: { + borderColor: changeDistantPointLineColorToTransparent, + }, + spanGaps: true, + }, + ], + }; + }; + + let ref: HTMLCanvasElement | undefined; + let chart: Chart | undefined; + + onCleanup(() => chart?.destroy()); + createEffect(() => { + if (chart) { + chart.destroy(); + } + + const d = data(); + if (d) { + chart = new Chart(ref!, { + type: "scatter", + data: d, + options: { + // animation: false, + maintainAspectRatio: false, + scales: { + x: { + ticks: { + callback: (value: number | string) => { + return new Date(value).toLocaleTimeString(); + }, + }, + }, + }, + plugins: { + legend: { + display: false, // This hides all text in the legend and also the labels. + }, + // https://www.chartjs.org/docs/latest/configuration/tooltip.html + tooltip: { + enabled: true, + callbacks: { + title: (items: TooltipItem<"scatter">[]) => { + return items.map((item) => { + const ts = new Date(item.parsed.x); + return ts.toUTCString(); + }); + }, + label: (item: TooltipItem<"scatter">) => { + return `rate: ${item.parsed.y.toPrecision(2)}/s`; + }, + }, + }, + }, + }, + }); + } + }); + + return ( +
+ +
+ ); +} + +const logLevels: Array<[number, string]> = [ + [4, "TRACE"], + [3, "DEBUG"], + [2, "INFO"], + [1, "WARN"], + [0, "ERROR"], +] as const; + +const levelToName: Map = new Map(logLevels); + +export default LogsPage; diff --git a/ui/admin/src/components/settings/AuthSettings.tsx b/ui/admin/src/components/settings/AuthSettings.tsx new file mode 100644 index 0000000..788009f --- /dev/null +++ b/ui/admin/src/components/settings/AuthSettings.tsx @@ -0,0 +1,475 @@ +import { + createResource, + For, + Suspense, + Switch, + Match, + createSignal, + createMemo, +} from "solid-js"; +import { createForm } from "@tanstack/solid-form"; + +import { + buildTextFormField, + buildNumberFormField, + buildSecretFormField, +} from "@/components/FormFields"; +import type { FormType } from "@/components/FormFields"; +import { + TbCircleCheck, + TbCircle, + TbCirclePlus, + TbArrowBackUp, + TbTrash, +} from "solid-icons/tb"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; + +import type { OAuthProviderResponse, OAuthProviderEntry } from "@/lib/bindings"; +import { AuthConfig, Config, OAuthProviderConfig } from "@proto/config"; +import { createConfigQuery, setConfig } from "@/lib/config"; +import { adminFetch } from "@/lib/fetch"; +import { showSaveFileDialog } from "@/lib/utils"; + +// Using a proxy struct since tanstack only deals with arrays and not maps. +// And rather than trying to hack it an converting on the fly, we're converting +// once upfront from config to proxy and back on submission. +type State = { + clientId?: string; + clientSecret?: string; +}; +type NamedOAuthProvider = { + provider: OAuthProviderEntry; + state?: State; +}; +type AuthConfigProxy = Omit & { + namedOauthProviders: NamedOAuthProvider[]; +}; + +function nonEmpty(v: string | undefined): string | undefined { + return v && v !== "" ? v : undefined; +} + +export async function adminListOAuthProviders(): Promise { + const response = await adminFetch("/oauth_providers", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + return await response.json(); +} + +function createSetOnce(initial: T): [ + () => T, + (v: T) => void, + { + reset: (v: T) => void; + }, +] { + let called = false; + const [v, setV] = createSignal(initial); + + const setter = (v: T) => { + if (!called) { + called = true; + setV(() => v); + } + }; + + return [v, setter, { reset: setV }]; +} + +function configToProxy( + providers: Array, + config: AuthConfig, +): AuthConfigProxy { + const idToConfig = new Map( + Object.values(config.oauthProviders).map((c) => { + const providerId = c.providerId; + if (!providerId) { + console.warn("missing provider id:", c); + return [-1, c]; + } + + return [providerId, c]; + }), + ); + + return { + ...config, + namedOauthProviders: providers.map((p): NamedOAuthProvider => { + const config = idToConfig.get(p.id); + const clientId = config?.clientId; + + return { + provider: p, + state: clientId + ? { + clientId: clientId, + // NOTE: This is basically undefined since the config doesn't contain the striped secret. + clientSecret: config?.clientSecret, + } + : undefined, + }; + }), + }; +} + +function proxyToConfig(proxy: AuthConfigProxy): AuthConfig { + const config = AuthConfig.fromPartial({ + ...(proxy as Omit), + }); + config.oauthProviders = {}; + + for (const entry of proxy.namedOauthProviders) { + const p = entry.provider; + const clientId = entry.state?.clientId; + const clientSecret = entry.state?.clientSecret; + + if (clientId && clientSecret) { + config.oauthProviders[p.name] = { + providerId: p.id, + displayName: p.display_name, + clientId, + clientSecret, + }; + } else { + console.debug("Skipping: ", entry); + } + } + return config; +} + +function ProviderSettingsSubForm(props: { + form: FormType; + index: number; + provider: OAuthProviderEntry; +}) { + const [original, setOnce, { reset }] = createSetOnce( + undefined, + ); + + const current = props.form.useStore((state) => { + if (state.isSubmitted) { + reset(state.values.namedOauthProviders[props.index].state); + } + + const s = state.values.namedOauthProviders[props.index].state; + setOnce({ ...s }); + return s; + }); + + const dirty = () => { + const id = nonEmpty(current()?.clientId) !== nonEmpty(original()?.clientId); + const secret = + nonEmpty(current()?.clientSecret) !== nonEmpty(original()?.clientSecret); + return id || secret; + }; + + const icon = () => { + if (dirty()) { + return ; + } + + if (current()?.clientId !== undefined) { + return ; + } + + return ; + }; + + return ( + + +
+ {icon()} + {props.provider.display_name} +
+
+ + +
+ { + if (value === "") return "Must not be empty"; + }, + }} + > + {buildTextFormField({ label: () => "Client Id", required: false })} + + + { + if (value === "") return "Must not be empty"; + }, + }} + > + {buildSecretFormField({ + label: () => "Client Secret", + required: false, + })} + +
+ +
+ + + +
+
+
+ ); +} +function AuthSettingsForm(props: { + config: Config; + providers: OAuthProviderResponse; + markDirty: () => void; + postSubmit: () => void; +}) { + const values = createMemo(() => + configToProxy( + props.providers.providers, + props.config.auth ?? AuthConfig.create(), + ), + ); + + const form = createForm(() => ({ + defaultValues: values(), + onSubmit: async ({ value }) => { + const newConfig = Config.decode(Config.encode(props.config).finish()); + newConfig.auth = proxyToConfig(value); + + console.debug("Submitting provider config:", value); + await setConfig(newConfig); + + props.postSubmit(); + }, + })); + + form.useStore((state) => { + if (state.isDirty && !state.isSubmitted) { + props.markDirty(); + } + }); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > +
+ + +

Token Settings

+
+ + +
+ + {buildNumberFormField({ + integer: true, + label: () =>
Auth TTL [sec]
, + info: ( +

+ AuthToken TTL. Older tokens are invalid. A new AuthToken + can be minted given a valid refresh Token. +

+ ), + })} +
+ + + {buildNumberFormField({ + integer: true, + label: () =>
Refresh TTL [sec]
, + info: ( +

+ RefreshToken TTL. Older tokens are invalid. A refresh + token can only be renewed by users logging in anew. +

+ ), + })} +
+
+
+
+ + + +

Public Key

+
+ + +

+ TrailBase uses short-lived, stateless JWT Auth tokens and + asymmetric public/private key cryptography (Ed25519 elliptic + curves) in combination with longer-lived, stateful refresh tokens. + Refresh tokens can be trivially exchanged for a fresh short-lived + auth token for as long as the refresh token has neither expired + nor been revoked. The main benefit of self-contained, stateless + auth is that other backend services you may run can simply + authenticate users by validating a given auth token against the + public key below w/o having to talk to TrailBase. It's important + that you keep the corresponding private key secret at all times. +

+ +

+ A common concern with stateless auth, as opposed to stateful + session-based auth, is the inability to revoke access in case an + auth token ever leaks. This is why, Auth tokens are short-lived to + reduce the impact of any such leak. +

+ + +
+
+ + + +

OAuth Providers

+
+ + + + {(_field) => { + return ( + + + {(provider, index) => { + const idx: number = index(); + + return ( + + ); + }} + + + ); + }} + + +
+ +
+ ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + })} + > + {(state) => { + return ( + + ); + }} + +
+
+ + ); +} + +export function AuthSettings(props: { + markDirty: () => void; + postSubmit: () => void; +}) { + const [providers] = createResource(adminListOAuthProviders); + const config = createConfigQuery(); + + const protoConfig = () => { + const c = config.data?.config; + if (c) { + // "deep-copy" + return Config.decode(Config.encode(c).finish()); + } + // Fallback + return Config.create(); + }; + + return ( + Loading...
}> + + + Error: {providers.error?.toString()} + + + + Error: {config.error?.toString()} + + + + + + + + ); +} + +const labelWidth = "w-40"; diff --git a/ui/admin/src/components/settings/EmailSettings.tsx b/ui/admin/src/components/settings/EmailSettings.tsx new file mode 100644 index 0000000..d7f8e3d --- /dev/null +++ b/ui/admin/src/components/settings/EmailSettings.tsx @@ -0,0 +1,255 @@ +import type { JSXElement } from "solid-js"; +import { createForm } from "@tanstack/solid-form"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; + +import { + notEmptyValidator, + buildTextFormField, + buildTextAreaFormField, + buildNumberFormField, + largerThanZero, + buildSecretFormField, +} from "@/components/FormFields"; +import type { FormType } from "@/components/FormFields"; +import { Config, EmailConfig } from "@proto/config"; +import { createConfigQuery, setConfig } from "@/lib/config"; + +function EmailTemplate(props: { + form: FormType; + fieldName: string; +}) { + const form = props.form; + + return ( +
+ + {buildTextFormField({ + label: () => Subject, + info: ( +

+ Email's subject line. Valid template parameters:{" "} + + {"{{APP_NAME}}"} + + . +

+ ), + })} +
+ + + {buildTextAreaFormField( + { + label: () => Body, + info: ( +

+ Email's body. Valid template parameters:{" "} + + {"{{ APP_NAME }}"} + + ,{" "} + + {"{{ SITE_URL }}"} + + , and{" "} + + {"{{ CODE }}"} + + . +

+ ), + }, + 10, + )} +
+
+ ); +} + +export function EmailSettings(props: { + markDirty: () => void; + postSubmit: () => void; +}) { + const config = createConfigQuery(); + + const Form = (p: { config: EmailConfig }) => { + const form = createForm(() => ({ + defaultValues: p.config, + onSubmit: async ({ value }) => { + const c = config.data?.config; + if (!c) { + console.warn("Missing base config."); + return; + } + + const newConfig = Config.fromPartial(c); + newConfig.email = value; + await setConfig(newConfig); + + props.postSubmit(); + }, + })); + + form.useStore((state) => { + if (state.isDirty && !state.isSubmitted) { + props.markDirty(); + } + }); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > +
+ + +

SMTP Settings

+
+ + +

+ TrailBase try to use the local sendmail command if no SMTP + server is configured. This may be fine for development but will + likely result in your Emails getting classified as Spam. Please + add a valid SMTP server before going to production. There are + many specialized providers with generous free tiers such as{" "} + Brevo, ... +

+ + + {buildTextFormField({ label: () => Host })} + + + + {buildNumberFormField({ + integer: true, + label: () => Port, + })} + + + + {buildTextFormField({ label: () => Username })} + + + + {buildSecretFormField({ + label: () => Password, + })} + + + + + + +

Sender Settings

+
+ + + + {buildTextFormField({ + label: () => Sender Address, + type: "email", + })} + + + + {buildTextFormField({ label: () => Sender Name })} + + +
+ + + +

Email Templates

+
+ + + + + + Email Verification Template + + + + + + + + + Password Reset Template + + + + + + + +
+ +
+ ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + })} + > + {(state) => { + return ( + + ); + }} + +
+
+
+ ); + }; + + const emailConfig = () => { + const c = config.data?.config?.email; + if (c) { + // "deep-copy" + return EmailConfig.decode(EmailConfig.encode(c).finish()); + } + // Fallback + return EmailConfig.create(); + }; + + return
; +} + +function L(props: { children: JSXElement }) { + return
{props.children}
; +} + +const flexColStyle = "flex flex-col gap-2"; diff --git a/ui/admin/src/components/settings/SchemaSettings.tsx b/ui/admin/src/components/settings/SchemaSettings.tsx new file mode 100644 index 0000000..337e298 --- /dev/null +++ b/ui/admin/src/components/settings/SchemaSettings.tsx @@ -0,0 +1,152 @@ +import { createResource, Suspense, Switch, Match, Index } from "solid-js"; +import { createForm } from "@tanstack/solid-form"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; + +import { adminFetch } from "@/lib/fetch"; +import type { + UpdateJsonSchemaRequest, + ListJsonSchemasResponse, + JsonSchema, +} from "@/lib/bindings"; + +async function listSchemas(): Promise { + const response = await adminFetch("/schema", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + return await response.json(); +} + +async function _updateSchema(request: UpdateJsonSchemaRequest): Promise { + await adminFetch("/schema", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); +} + +function SchemaSettingsForm(props: { + markDirty: () => void; + postSubmit: () => void; + schemas: JsonSchema[]; +}) { + const form = createForm(() => ({ + defaultValues: { + entries: props.schemas, + }, + onSubmit: async ({ value }) => { + throw `NOT IMPLEMENTED: ${value}`; + + props.postSubmit(); + }, + })); + + form.useStore((state) => { + if (state.isDirty && !state.isSubmitted) { + props.markDirty(); + } + }); + + return ( + { + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + + {(field) => { + return ( + + + {(_, i) => { + const schema = field().state.value[i]; + return ( + + + {schema.name} {schema.builtin ? "" : null} + + + + + {(subField) => ( +
+                              {JSON.stringify(
+                                JSON.parse(subField().state.value),
+                                null,
+                                2,
+                              )}
+                            
+ )} +
+
+
+ ); + }} +
+
+ ); + }} +
+ + ); +} + +export function SchemaSettings(props: { + markDirty: () => void; + postSubmit: () => void; +}) { + const [schemas] = createResource(listSchemas); + return ( + Loading...}> + + + Error: {`${schemas.error}`} + + + + + +

Schemas

+
+ + +

+ Registering custom JSON schemas is not yet available in the UI. + However, you can register your own schemas in the{" "} + `config.textproto`. JSON schemas + can be used to enforce constraints on TEXT/JSON columns, e.g.: +

+ +
+                CREATE TABLE table (
+    json      TEXT + CHECK(jsonschema('mySchema', json)) +
+ ) strict; +
+
+ + +
+
+
+
+
+ ); +} diff --git a/ui/admin/src/components/settings/SettingsPage.tsx b/ui/admin/src/components/settings/SettingsPage.tsx new file mode 100644 index 0000000..ee6f665 --- /dev/null +++ b/ui/admin/src/components/settings/SettingsPage.tsx @@ -0,0 +1,463 @@ +import { createSignal, For, Show } from "solid-js"; +import type { Component, JSXElement } from "solid-js"; +import { Route, useNavigate, type RouteSectionProps } from "@solidjs/router"; +import { createForm } from "@tanstack/solid-form"; + +import { showToast } from "@/components/ui/toast"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; + +import { Config, ServerConfig } from "@proto/config"; +import { + notEmptyValidator, + buildNumberFormField, + buildTextFormField, +} from "@/components/FormFields"; +import { ConfirmCloseDialog } from "@/components/SafeSheet"; +import { AuthSettings } from "@/components/settings/AuthSettings"; +import { SchemaSettings } from "@/components/settings/SchemaSettings"; +import { EmailSettings } from "@/components/settings/EmailSettings"; +import { SplitView } from "@/components/SplitView"; + +import { createConfigQuery, setConfig } from "@/lib/config"; + +function ServerSettings(props: CommonProps) { + const config = createConfigQuery(); + + const Form = (p: { config: ServerConfig }) => { + const form = createForm(() => ({ + defaultValues: p.config, + onSubmit: async ({ value }: { value: ServerConfig }) => { + const c = config.data?.config; + if (!c) { + console.warn("Missing base config:"); + return; + } + + const newConfig = Config.fromPartial(c); + newConfig.server = value; + await setConfig(newConfig); + + props.postSubmit?.(); + }, + })); + + form.useStore((state) => { + if (state.isDirty && !state.isSubmitted) { + props.markDirty(); + } + }); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + + +

Server Settings

+
+ + +
+ + {buildTextFormField({ + label: () =>
App Name
, + info: ( +

+ The name of your application. Used e.g. in emails sent to + users. +

+ ), + })} +
+
+ +
+ + {buildTextFormField({ + label: () =>
Site URL
, + info: ( +

+ The public address under which the server is reachable. + Used e.g. for auth, e.g. verification links sent via + Email. +

+ ), + })} +
+
+ +
+ + {buildNumberFormField({ + integer: true, + label: () => ( +
Log Retention (sec)
+ ), + info: ( +

+ A background ask periodically cleans up logs older than + above retention period. Setting the retention to zero turn + off the cleanup and logs will be retained indefinitely. +

+ ), + })} +
+
+
+
+ +
+ ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + })} + > + {(state) => { + return ( + + ); + }} + +
+
+ ); + }; + + const serverConfig = () => { + const c = config.data?.config?.server; + if (c) { + // "deep-copy" + return ServerConfig.decode(ServerConfig.encode(c).finish()); + } + // Fallback + return ServerConfig.create(); + }; + + return ( +
+ Failed to fetch config + + Loading + + +
+ + + {import.meta.env.DEV && ( +
+ +
+ )} +
+ ); +} + +function BackupImportSettings(props: CommonProps) { + const config = createConfigQuery(); + + const Form = (p: { config: ServerConfig }) => { + const form = createForm(() => ({ + defaultValues: p.config, + onSubmit: async ({ value }: { value: ServerConfig }) => { + const c = config.data?.config; + if (!c) { + console.warn("Missing base config:"); + return; + } + + const newConfig = Config.fromPartial(c); + newConfig.server = value; + await setConfig(newConfig); + }, + })); + + form.useStore((state) => { + if (state.isDirty) { + props.markDirty(); + } + }); + + return ( + { + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > +
+ + +

Backup Settings

+
+ + + + {buildNumberFormField({ + integer: true, + label: () => ( +
+ +
+ ), + info: backupInfo, + })} +
+
+
+ + + +

Data Import {"&"} Export

+
+ + +

+ Data import and export from and to Sql via the UI is not yet + supported, however with TrailBase not relying on specific + metadata you can use all the usual suspects around sqlite and + the data will show up in the table editor. If you import your + data into a table with strict typing and an UUIDv7 primary + column you'll also be able to expose the data via restful APIs. +

+ +

Import, e.g.:

+
+                $ cat dump.sql | sqlite3 main.db
+              
+ +

Output, e.g.:

+ +
+                $ sqlite3 main.db
+                
+ sqlite> .output dump.db +
+ sqlite> .dump +
+
+
+
+ +
+ ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + })} + > + {(state) => { + return ( + + ); + }} + +
+
+ + ); + }; + + const serverConfig = () => { + const c = config.data?.config?.server; + if (c) { + // "deep-copy" + return ServerConfig.decode(ServerConfig.encode(c).finish()); + } + // Fallback + return ServerConfig.create(); + }; + + return ( + <> + Failed to fetch config + + Loading + + +
+ + + ); +} + +function WrapSidebar( + base: string, + route: string, + site: Site, +): Component> { + const [dirty, setDirty] = createSignal(false); + + return (_props: RouteSectionProps) => { + function First(props: { horizontal: boolean }) { + const navigate = useNavigate(); + + const flexStyle = props.horizontal ? "flex flex-col" : "flex"; + + return ( +
+ + {([r, s]) => { + const [dialogOpen, setDialogOpen] = createSignal(false); + + return ( + + setDialogOpen(false)} + confirm={() => { + setDialogOpen(false); + navigate(base + r); + }} + /> + + + + ); + }} + +
+ ); + } + + function Second() { + return ( +
+

+ Settings + > + {site.label} +

+ + + +
+ setDirty(true)} + postSubmit={() => { + setDirty(false); + showToast({ + title: "submitted", + variant: "success", + }); + }} + /> +
+
+ ); + } + + return ; + }; +} + +interface CommonProps { + markDirty: () => void; + postSubmit: () => void; +} + +interface Site { + label: string; + child: Component; +} + +const sites: { [k: string]: Site } = { + "/": { + label: "Host", + child: ServerSettings, + }, + "/email": { + label: "E-mail", + child: EmailSettings, + }, + "/auth": { + label: "Auth", + child: AuthSettings, + }, + "/schema": { + label: "Schemas", + child: SchemaSettings, + }, + "/backup": { + label: "Backup", + child: BackupImportSettings, + }, +} as const; + +export function SettingsPages() { + return ( + <> + + {([route, site]) => ( + + )} + + + ); +} + +const backupInfo: JSXElement = ( +

+ Setting the backup interval to zero will disable periodic backups on next + server start. Backups will lock the database for the duration of the backup, + which is typically fine for small data sets. However, we recommend a more + continuous disaster recovery solution such as{" "} + Litestream to avoid locking and avoid + losing changes made between backups. +

+); + +const labelWidth = "w-40"; diff --git a/ui/admin/src/components/tables/CreateAlterIndex.tsx b/ui/admin/src/components/tables/CreateAlterIndex.tsx new file mode 100644 index 0000000..22b8731 --- /dev/null +++ b/ui/admin/src/components/tables/CreateAlterIndex.tsx @@ -0,0 +1,253 @@ +import { createSignal, Index } from "solid-js"; +import type { Accessor } from "solid-js"; +import { createForm } from "@tanstack/solid-form"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { SheetHeader, SheetTitle, SheetFooter } from "@/components/ui/sheet"; +import { showToast } from "@/components/ui/toast"; + +import { alterIndex, createIndex } from "@/lib/table"; +import type { ColumnOrder, Table, TableIndex } from "@/lib/bindings"; +import { + buildTextFormField, + buildBoolFormField, + buildSelectField, +} from "@/components/FormFields"; +import { SheetContainer } from "@/components/SafeSheet"; +import { randomName } from "@/lib/name"; + +export function CreateAlterIndexForm(props: { + close: () => void; + markDirty: () => void; + schemaRefetch: () => void; + table: Table; + schema?: TableIndex; +}) { + const [sql, setSql] = createSignal(); + + const original = props.schema + ? JSON.parse(JSON.stringify(props.schema)) + : undefined; + const newDefaultColumn = (index: number): ColumnOrder => { + return { + column_name: props.table.columns[index].name, + // Ascending is sqlite's default. + ascending: false, + // Sqlite doesn't support nulls_first, i.e. this parameter must be "null". + nulls_first: null, + }; + }; + + const onSubmit = async (value: TableIndex, dryRun: boolean) => { + console.debug("Index schema:", value); + + try { + if (original) { + const response = await alterIndex({ + source_schema: original, + target_schema: value, + }); + console.debug("AlterIndexResponse:", response); + } else { + const response = await createIndex({ schema: value, dry_run: dryRun }); + console.debug(`CreateIndexResponse [dry: ${dryRun}]:`, response); + + if (dryRun) { + setSql(response.sql); + } + } + + if (!dryRun) { + props.schemaRefetch(); + props.close(); + } + } catch (err) { + showToast({ + title: "Uncaught Error", + description: `${err}`, + variant: "error", + }); + } + }; + + const form = createForm(() => ({ + defaultValues: props.schema ?? { + name: `_${props.table.name}__${randomName()}_index`, + table_name: props.table.name, + columns: [newDefaultColumn(0)] as ColumnOrder[], + unique: false, + predicate: null, + }, + onSubmit: async ({ value }) => await onSubmit(value, false), + })); + + form.useStore((state) => { + if (state.isDirty && !state.isSubmitted) { + props.markDirty(); + } + }); + + return ( + + + + {original ? "Alter Index" : "Create New Index"} for " + {props.table.name}" Table + + + + { + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > +
+ { + return value ? undefined : "Table name missing"; + }, + }} + > + {buildTextFormField({ label: () => "Index name" })} + + + {/* columns */} + + {(field) => ( +
+
+ + {(_c: Accessor, i) => ( + + Index Column {i} + + +
+ + {buildSelectField( + [...props.table.columns.map((c) => c.name)], + { + label: () => ( +
Column Name
+ ), + }, + )} +
+ + + {buildBoolFormField({ + label: () =>
Ascending
, + })} +
+ + + {buildBoolFormField({ + label: () =>
Nulls first
, + })} +
+
+
+
+ )} +
+
+ + +
+ )} +
+
+ + + ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + })} + > + {(state) => { + return ( +
+ {original === undefined && ( + { + if (!open) { + setSql(undefined); + } + }} + > + +
+ +
+
+ + + + SQL + + +
+
{sql()}
+
+ + +
+
+ )} + +
+ +
+
+ ); + }} +
+
+ +
+ ); +} + +const labelWidth = "w-[112px]"; diff --git a/ui/admin/src/components/tables/CreateAlterTable.tsx b/ui/admin/src/components/tables/CreateAlterTable.tsx new file mode 100644 index 0000000..2fac6a4 --- /dev/null +++ b/ui/admin/src/components/tables/CreateAlterTable.tsx @@ -0,0 +1,876 @@ +import { createEffect, createSignal, Index, For } from "solid-js"; +import type { Accessor, JSX, JSXElement, Setter } from "solid-js"; +import { createForm } from "@tanstack/solid-form"; +import { Collapsible } from "@kobalte/core/collapsible"; +import { TbChevronDown, TbTrash, TbInfoCircle } from "solid-icons/tb"; + +import { showToast } from "@/components/ui/toast"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { SheetHeader, SheetTitle, SheetFooter } from "@/components/ui/sheet"; +import { + TextField, + TextFieldLabel, + TextFieldInput, +} from "@/components/ui/text-field"; + +import { + isNotNull, + setNotNull, + getCheckValue, + setCheckValue, + getDefaultValue, + setDefaultValue, + getUnique, + setUnique, + getForeignKey, + setForeignKey, +} from "@/lib/schema"; +import { createTable, alterTable } from "@/lib/table"; +import { cn } from "@/lib/utils"; +import { randomName } from "@/lib/name"; +import type { + Column, + ColumnDataType, + ColumnOption, + Table, +} from "@/lib/bindings"; +import { + buildBoolFormField, + gapStyle, + buildSelectField, + buildTextFormField, +} from "@/components/FormFields"; +import type { FormType, AnyFieldApi } from "@/components/FormFields"; +import { SheetContainer } from "@/components/SafeSheet"; + +export function CreateAlterTableForm(props: { + close: () => void; + markDirty: () => void; + schemaRefetch: () => Promise; + allTables: Table[]; + setSelected: (tableName: string) => void; + schema?: Table; +}) { + const [sql, setSql] = createSignal(); + + const original = props.schema + ? JSON.parse(JSON.stringify(props.schema)) + : undefined; + const newDefaultColumn = (index: number): Column => { + return { + name: `new_${index}`, + data_type: "Text", + options: [{ Default: "''" }], + }; + }; + + const onSubmit = async (value: Table, dryRun: boolean) => { + console.debug("Table schema:", value); + + try { + if (original) { + const response = await alterTable({ + source_schema: original, + target_schema: value, + }); + console.debug("AlterTableResponse:", response); + } else { + const response = await createTable({ schema: value, dry_run: dryRun }); + console.debug(`CreateTableResponse [dry: ${dryRun}]:`, response); + + if (dryRun) { + setSql(response.sql); + } + } + + if (!dryRun) { + props.schemaRefetch().then(() => { + props.setSelected(value.name); + }); + props.close(); + } + } catch (err) { + showToast({ + title: "Uncaught Error", + description: `${err}`, + variant: "error", + }); + } + }; + + const form = createForm(() => ({ + defaultValues: props.schema ?? { + name: randomName(), + strict: true, + indexes: [], + columns: [ + { + name: "id", + data_type: "Blob", + // Column constraints: https://www.sqlite.org/syntax/column-constraint.html + options: [ + { Unique: { is_primary: true } }, + { Check: "is_uuid_v7(id)" }, + { Default: "(uuid_v7())" }, + "NotNull", + ], + }, + newDefaultColumn(1), + ] satisfies Column[], + // Table constraints: https://www.sqlite.org/syntax/table-constraint.html + unique: [], + foreign_keys: [], + virtual_table: false, + temporary: false, + }, + onSubmit: async ({ value }) => await onSubmit(value, false), + })); + + form.useStore((state) => { + if (state.isDirty && !state.isSubmitted) { + props.markDirty(); + } + }); + + return ( + + + {original ? "Alter Table" : "Create New Table"} + + +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > +
+ { + return value ? undefined : "Table name missing"; + }, + }} + > + {buildTextFormField({ label: () => Table name })} + + + "STRICT Typing" })} + /> + + {/* columns */} +

Columns

+ + + {(field) => ( +
+
+ + {(c: Accessor, i) => ( + + )} + +
+ + +
+ )} +
+
+ + + ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + })} + > + {(state) => { + return ( +
+ {original === undefined && ( + { + if (!open) { + setSql(undefined); + } + }} + > + + + + + + + SQL + + +
+
{sql()}
+
+ + +
+
+ )} + +
+ +
+
+ ); + }} +
+
+ +
+ ); +} + +function columnTypeField( + disabled: boolean, + fk: Accessor, + allTables: Table[], +) { + // WARNING: these needs to be kept in sync with ColumnDataType. TS cannot go + // from type union to array. + const columnDataTypes: ColumnDataType[] = [ + "Blob", + "Text", + "Integer", + "Real", + "Null", + ] as const; + + return (field: () => AnyFieldApi) => { + const [isDisabled, setDisabled] = createSignal(disabled); + + createEffect(() => { + const foreignKey = fk(); + if (foreignKey) { + for (const table of allTables) { + if (table.name == foreignKey) { + const type = table.columns[0].data_type; + console.debug(type, field().state.value); + if (field().state.value === type) { + break; + } + + field().setValue(type); + break; + } + } + } + + setDisabled(foreignKey !== undefined ? true : disabled); + }); + + return buildSelectField([...columnDataTypes], { + label: () => Type, + disabled: isDisabled, + })(field); + }; +} + +function ColumnOptionCheckField(props: { + column: Column; + value: ColumnOption[]; + onChange: (v: ColumnOption[]) => void; + disabled: boolean; +}) { + const disabled = () => + props.disabled || getCheckValue(props.value) === undefined; + + const HCard = () => ( + + } + variant="link" + > + + + + +
+
+

Column Constraint

+ +

+ Can be any boolean expression constant like{" "} + {`${props.column.name} < 42 `} + including SQL function calls like{" "} + + is_email({props.column.name}) + + . +

+
+
+
+
+ ); + + // TODO: Factor out inner component from buildTextFormField and use it here. + // return ( + // (Check)} + // initial={getCheckValue(props.value) ?? ""} + // handleChange={(value) => props.onChange(setCheckValue(props.value, value))} + // disabled={disabled()} + // /> + // ); + return ( + +
+ + + + Check + + + +
+ { + const value: string | undefined = ( + e.currentTarget as HTMLInputElement + ).value; + props.onChange(setCheckValue(props.value, value)); + }} + /> + + { + const newOpts = setCheckValue( + props.value, + value ? "" : undefined, + ); + props.onChange(newOpts); + }} + /> +
+
+
+ ); +} + +function ColumnOptionDefaultField(props: { + column: Column; + value: ColumnOption[]; + onChange: (v: ColumnOption[]) => void; + disabled: boolean; +}) { + const disabled = () => + props.disabled || getDefaultValue(props.value) === undefined; + + const HCard = () => ( + + } + variant="link" + > + + + + +
+
+

Column Default Value

+ +

+ Can either be a constant like{" "} + 'foo' + and 42, or a scalar + function like + + (jsonschema('std.FileUpload', {props.column.name})) + + . +

+
+
+
+
+ ); + + // TODO: Factor out inner component from buildTextFormField and use it here. + return ( + +
+ + + + Default + + + +
+ { + const value: string | undefined = ( + e.currentTarget as HTMLInputElement + ).value; + props.onChange(setDefaultValue(props.value, value)); + }} + /> + + { + // TODO: Make default dependent on column type. + const newOpts = setDefaultValue( + props.value, + value ? "''" : undefined, + ); + props.onChange(newOpts); + }} + /> +
+
+
+ ); +} + +function ColumnOptionFkSelect(props: { + value: ColumnOption[]; + onChange: (v: ColumnOption[]) => void; + allTables: Table[]; + disabled: boolean; + setFk: Setter; +}) { + const fkTableOptions: string[] = [ + "None", + ...props.allTables.map((schema) => schema.name), + ]; + const fkValue = (): string => + getForeignKey(props.value)?.foreign_table ?? "None"; + + return ( +
+ + + +
+ ); +} + +function ColumnOptionsFields(props: { + column: Column; + value: ColumnOption[]; + onChange: (v: ColumnOption[]) => void; + allTables: Table[]; + disabled: boolean; + setFk: Setter; +}) { + // Column options: (not|null), (default), (unique), (fk), (check), (comment), (onupdate). + + return ( + <> + + + + + + +
+
+ + +
+ + props.onChange(setNotNull(props.value, value)) + } + /> +
+
+ +
+ + +
+ { + props.onChange( + setUnique( + props.value, + value ? { is_primary: false } : undefined, + ), + ); + }} + /> +
+
+
+ + ); +} + +function ColumnSubForm(props: { + form: FormType
; + colIndex: number; + column: Column; + allTables: Table[]; + disabled: boolean; +}): JSX.Element { + const disabled = props.disabled; + const [name, setName] = createSignal(props.column.name); + const [expanded, setExpanded] = createSignal(props.column.name !== "id"); + + const [fk, setFk] = createSignal(); + + const Header = () => ( +
+

{name()}

+ +
+ {!disabled && ( +
+ +
+ )} + +
+
+ ); + + return ( + + + + +
+ + + + + +
+ {/* Column presets */} +
+ + +
+ + {([name, preset]) => ( + { + const columns = [...props.form.state.values.columns]; + const column = columns[props.colIndex]; + + const v = preset(column.name); + + column.data_type = v.data_type; + column.options = v.options; + + props.form.setFieldValue("columns", columns); + }} + > + {name} + + )} + +
+
+ + {/* Column name field */} + { + return value ? undefined : "Column name missing"; + }, + }} + > + {buildTextFormField({ + label: () => Name, + disabled, + onKeyUp: setName, + })} + + + {/* Column type field */} + + + {/* Column options: pk, not null, ... */} + { + return ( + + ); + }} + /> +
+
+
+ + + ); +} + +function L(props: { children: JSXElement }) { + return
{props.children}
; +} + +const transitionTimingFunc = "cubic-bezier(.87,0,.13,1)"; + +type Preset = { + data_type: ColumnDataType; + options: ColumnOption[]; +}; + +const presets: [string, (colName: string) => Preset][] = [ + [ + "Default", + (_colName: string) => { + return { + data_type: "Text", + options: [{ Default: "''" }, "NotNull"], + }; + }, + ], + [ + "UUIDv7", + (colName: string) => { + return { + data_type: "Blob", + options: [ + { Check: `is_uuid_v7(${colName})` }, + { Default: "(uuid_v7())" }, + "NotNull", + ], + }; + }, + ], + [ + "JSON", + (colName: string) => { + return { + data_type: "Text", + options: [ + { Check: `is_json(${colName})` }, + { Default: "{}" }, + "NotNull", + ], + }; + }, + ], + [ + "File", + (colName: string) => { + return { + data_type: "Text", + options: [ + { + Check: `jsonschema('std.FileUpload', ${colName})`, + }, + ], + }; + }, + ], + [ + "Files", + (colName: string) => { + return { + data_type: "Text", + options: [ + { + Check: `jsonschema('std.FileUploads', ${colName})`, + }, + { Default: "[]" }, + "NotNull", + ], + }; + }, + ], +]; diff --git a/ui/admin/src/components/tables/InsertAlterRow.tsx b/ui/admin/src/components/tables/InsertAlterRow.tsx new file mode 100644 index 0000000..27a21e2 --- /dev/null +++ b/ui/admin/src/components/tables/InsertAlterRow.tsx @@ -0,0 +1,221 @@ +import { For } from "solid-js"; +import { createForm } from "@tanstack/solid-form"; + +import { SheetHeader, SheetTitle, SheetFooter } from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; + +import type { Column, Table, UpdateRowRequest } from "@/lib/bindings"; +import { formFieldBuilder } from "@/components/FormFields"; +import { + findPrimaryKeyColumnIndex, + getDefaultValue, + isNotNull, + isOptional, +} from "@/lib/schema"; +import { adminFetch } from "@/lib/fetch"; +import { SheetContainer } from "@/components/SafeSheet"; +import { showToast } from "@/components/ui/toast"; + +// NOTE: We use `unknown` here over `Object` to prevent forms from doing infinite-recursion type gymnastics. +type Row = { [key: string]: unknown }; + +export function copyAndConvert(row: Row): { + // eslint-disable-next-line @typescript-eslint/no-wrapper-object-types + [key: string]: Object | undefined; +} { + return Object.fromEntries( + // eslint-disable-next-line @typescript-eslint/no-wrapper-object-types + Object.entries(row).map(([k, v]) => [k, v as Object | undefined]), + ); +} + +async function insertRow(tableName: string, row: Row) { + const response = await adminFetch(`/table/${tableName}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(row), + }); + + return await response.text(); +} + +async function updateRow(table: Table, row: Row) { + const tableName = table.name; + const primaryKeyColumIndex = findPrimaryKeyColumnIndex(table.columns); + if (primaryKeyColumIndex < 0) { + throw Error("No primary key column found."); + } + const pkColName = table.columns[primaryKeyColumIndex].name; + + const pkValue = row[pkColName]; + if (pkValue === undefined) { + throw Error("Row is missing primary key."); + } + const copy = { + ...row, + }; + copy[pkColName] = undefined; + + const request: UpdateRowRequest = { + primary_key_column: pkColName, + // eslint-disable-next-line @typescript-eslint/no-wrapper-object-types + primary_key_value: pkValue as Object, + row: copyAndConvert(copy), + }; + + const response = await adminFetch(`/table/${tableName}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + + return await response.text(); +} + +function buildDefault(schema: Table): Row { + const obj: Row = {}; + for (const col of schema.columns) { + const optional = isOptional(col.options); + if (optional) { + // obj[col.name] = undefined; + continue; + } + + switch (col.data_type) { + case "Blob": + obj[col.name] = []; + break; + case "Text": + obj[col.name] = ""; + break; + case "Real": + obj[col.name] = 0.0; + break; + case "Integer": + obj[col.name] = 0; + break; + case "Null": + break; + } + } + return obj; +} + +export function InsertAlterRowForm(props: { + close: () => void; + markDirty: () => void; + rowsRefetch: () => void; + schema: Table; + row?: Row; +}) { + const original = props.row + ? JSON.parse(JSON.stringify(props.row)) + : undefined; + + const form = createForm(() => ({ + defaultValues: props.row ?? buildDefault(props.schema), + onSubmit: async ({ value }) => { + console.debug("Submitting:", value); + try { + if (original) { + const response = await updateRow(props.schema, value); + console.debug("UpdateRowResponse:", response); + } else { + const response = await insertRow(props.schema.name, value); + console.debug("InsertRowResponse:", response); + } + + props.rowsRefetch(); + props.close(); + } catch (err) { + showToast({ + title: "Uncaught Error", + description: `${err}`, + variant: "error", + }); + } + }, + })); + + form.useStore((state) => { + if (state.isDirty && !state.isSubmitted) { + props.markDirty(); + } + }); + + return ( + + + {original ? "Edit Row" : "Insert New Row"} + + +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > +
+ + {(col: Column) => { + const notNull = isNotNull(col.options); + const label = `${col.name} [${col.data_type}${notNull ? "" : "?"}]`; + const optional = isOptional(col.options); + const defaultValue = getDefaultValue(col.options); + + return ( + { + const defaultValue = getDefaultValue(col.options); + if (defaultValue !== undefined) { + return undefined; + } + return value !== undefined ? undefined : "Missing value"; + }, + }} + children={formFieldBuilder( + col.data_type, + label, + optional, + defaultValue, + )} + /> + ); + }} + +
+ + + ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + })} + children={(state) => { + return ( + + ); + }} + /> + + +
+ ); +} diff --git a/ui/admin/src/components/tables/RecordApiSettings.tsx b/ui/admin/src/components/tables/RecordApiSettings.tsx new file mode 100644 index 0000000..411cb4c --- /dev/null +++ b/ui/admin/src/components/tables/RecordApiSettings.tsx @@ -0,0 +1,545 @@ +import { For, createSignal } from "solid-js"; +import { createForm } from "@tanstack/solid-form"; +import { TbInfoCircle } from "solid-icons/tb"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardTitle, CardHeader } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { SheetFooter } from "@/components/ui/sheet"; +import { showToast } from "@/components/ui/toast"; + +import { + Config, + ConflictResolutionStrategy, + PermissionFlag, + RecordApiConfig, +} from "@proto/config"; +import { SheetContainer } from "@/components/SafeSheet"; +import type { Table, View } from "@/lib/bindings"; +import { + buildTextFormField, + buildOptionalTextFormField, +} from "@/components/FormFields"; +import { createConfigQuery, setConfig } from "@/lib/config"; +import { parseSql } from "@/lib/parse"; +import { tableType } from "@/lib/schema"; + +const tablePermissions = { + Create: PermissionFlag.CREATE, + Read: PermissionFlag.READ, + Update: PermissionFlag.UPDATE, + Delete: PermissionFlag.DELETE, + Schema: PermissionFlag.SCHEMA, +} as const; + +const viewPermissions = { + Read: PermissionFlag.READ, + Schema: PermissionFlag.SCHEMA, +} as const; + +function AclForm(props: { + entity: string; + initial?: PermissionFlag[]; + showHeader: boolean; + onChange: (v: PermissionFlag[]) => void; + view: boolean; +}) { + const [acl, setAcl] = createSignal(new Set(props.initial ?? [])); + + return ( +
+
+ {props.showHeader && ( + + {(key, index) => ( +
+ {key} +
+ )} +
+ )} + +
+ +
+ + + {(perm) => ( +
+ { + const set = acl(); + if (v) { + set.add(perm); + } else { + set.delete(perm); + } + + setAcl(new Set(set)); + props.onChange([...set]); + }} + /> +
+ )} +
+
+
+ ); +} + +type Field = keyof RecordApiConfig; +interface AccessRule { + field: Field; + label: string; + description: string; +} + +const tableAccessRules: AccessRule[] = [ + { + field: "readAccessRule", + label: "Read access:", + description: + 'Row- and request-level read access (_user_, _row_, _req_): If the table has an "owner"\'s column containing binary user ids, access could be rstricted to the owner by setting \'_row_.owner = _user_\' here. Or if the table as a foreign key to a "group" and a relationship defined in a "membership" table: \'(SELECT 1 FROM membership WHERE group = _row_.group AND user = _user_)\'', + }, + { + field: "createAccessRule", + label: "Create access:", + description: + "Request-level create access validation base on _USER_, _REQ_:", + }, + { + field: "updateAccessRule", + label: "Update access", + description: + "Row- and request level update access based on _USER_, _ROW_, _REQ_:", + }, + { + field: "deleteAccessRule", + label: "Delete Access", + description: + "Row- and request level delete access based on _USRE_, _ROW_, _REQ_:", + }, + { + field: "schemaAccessRule", + label: "Schema Access", + description: "Schema access based on _USER_:", + }, +] as const; + +const viewAccessRules: AccessRule[] = [ + { + field: "readAccessRule", + label: "Read access:", + description: + 'Row- and request-level read access (_user_, _row_, _req_): If the table has an "owner"\'s column containing binary user ids, access could be rstricted to the owner by setting \'_row_.owner = _user_\' here. Or if the table as a foreign key to a "group" and a relationship defined in a "membership" table: \'(SELECT 1 FROM membership WHERE group = _row_.group AND user = _user_)\'', + }, + { + field: "schemaAccessRule", + label: "Schema Access", + description: "Schema access based on _USER_:", + }, +] as const; + +function updateRecordApiConfig( + config: Config, + recordApiConfig: RecordApiConfig, +): Config { + const newConfig = Config.fromPartial(config); + + for (const i in newConfig.recordApis) { + const api = newConfig.recordApis[i]; + if (api.name == recordApiConfig.name) { + newConfig.recordApis[i] = recordApiConfig; + return newConfig; + } + } + + newConfig.recordApis.push(recordApiConfig); + return newConfig; +} + +function removeRecordApiConfig(config: Config, tableName: string): Config { + const newConfig = Config.fromPartial(config); + + while (true) { + const index = newConfig.recordApis.findIndex( + (api) => api.tableName === tableName, + ); + if (index < 0) { + break; + } + + newConfig.recordApis.splice(index, 1); + } + + return newConfig; +} + +function ConflictResolutionSrategyToString( + value: ConflictResolutionStrategy | null, +): string { + switch (value) { + case ConflictResolutionStrategy.ABORT: + return "Abort"; + case ConflictResolutionStrategy.ROLLBACK: + return "Rollback"; + case ConflictResolutionStrategy.FAIL: + return "Fail"; + case ConflictResolutionStrategy.IGNORE: + return "Ignore"; + case ConflictResolutionStrategy.REPLACE: + return "Replace"; + default: + return "Undefined"; + } +} + +function findRecordApi( + config: Config | undefined, + tableName: string, +): RecordApiConfig | undefined { + if (!config) { + return undefined; + } + + for (const api of config.recordApis) { + if (api.tableName == tableName) { + return api; + } + } + + return undefined; +} + +export function RecordApiSettingsForm(props: { + close: () => void; + markDirty: () => void; + schema: Table | View; +}) { + const config = createConfigQuery(); + + const type = () => tableType(props.schema); + + // FIXME: We don't currently handle the "multiple APIs for a single table" case. + const currentApi = () => + findRecordApi(config.data!.config!, props.schema.name); + + const form = createForm(() => { + const tableName = props.schema.name; + return { + defaultValues: currentApi() ?? { + name: tableName, + tableName: tableName, + aclWorld: [], + aclAuthenticated: [], + }, + onSubmit: async ({ value }: { value: RecordApiConfig }) => { + console.debug("Add record api config:", value); + + const c = config.data?.config; + if (!c) { + console.error("missing base configuration"); + return; + } + + const newConfig = updateRecordApiConfig(c, value); + try { + await setConfig(newConfig); + props.close(); + } catch (err) { + showToast({ + title: "Uncaught Error", + description: `${err}`, + variant: "error", + }); + } + }, + }; + }); + + form.useStore((state) => { + if (state.isDirty && !state.isSubmitted) { + props.markDirty(); + } + }); + + return ( + +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > +
+ + + Record API Settings + + + + { + return value ? undefined : "Api name missing"; + }, + }} + > + {buildTextFormField({ + label: () =>
API name
, + })} +
+ + {type() === "table" && ( + <> + + {(field) => ( +
+ + + + multiple={false} + placeholder="Select group..." + defaultValue={field().state.value} + options={[ + ConflictResolutionStrategy.ABORT, + ConflictResolutionStrategy.ROLLBACK, + ConflictResolutionStrategy.FAIL, + ConflictResolutionStrategy.IGNORE, + ConflictResolutionStrategy.REPLACE, + ]} + optionValue={ConflictResolutionSrategyToString} + onChange={( + strategy: ConflictResolutionStrategy | null, + ) => { + field().handleChange(strategy ?? undefined); + }} + itemComponent={(props) => ( + + {ConflictResolutionSrategyToString( + props.item.rawValue, + )} + + )} + > + + > + {(state) => + ConflictResolutionSrategyToString( + state.selectedOption(), + ) + } + + + + + +
+ )} +
+ + { + const HCard = () => ( + + } + variant="link" + > + + + + +
+
+

+ User Id Auto-Fill +

+ +

+ When enabled, user id columns that are not + provided as part of a CREATE request will be + auto-filled with the id of the calling user + when authenticated. +

+ +

+ For most use-cases this setting should stay + turned-off and user ids should be provided + explicitly by the client. This setting can be + useful in case the client cannot run any logic + like JS-less HTML forms. +

+
+
+
+
+ ); + // TODO: Should be buildBoolFormField? + const v = () => field().state.value; + return ( +
+ + + + + field().handleChange(v)} + /> +
+ ); + }} + /> + + )} +
+
+ + + + Access + + + + + {(field) => { + const v = field().state.value; + return ( +
+ +
+ ); + }} +
+ + + {(field) => { + const v = field().state.value; + return ( +
+ +
+ ); + }} +
+ + + {(item) => { + async function onChangeAsync(props: { + value: string | undefined; + }) { + const value = props.value; + if (value) { + console.debug("Query", value); + return parseSql(value); + } + } + + return ( + + {buildOptionalTextFormField({ + label: () =>
{item.label}
, + })} +
+ ); + }} +
+
+
+
+ + + + + ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + })} + > + {(state) => ( + + )} + + + +
+ ); +} + +const labelWidth = "w-[112px]"; diff --git a/ui/admin/src/components/tables/TablesPage.tsx b/ui/admin/src/components/tables/TablesPage.tsx new file mode 100644 index 0000000..d82cf85 --- /dev/null +++ b/ui/admin/src/components/tables/TablesPage.tsx @@ -0,0 +1,1326 @@ +import { + type Signal, + type ResourceFetcherInfo, + For, + Match, + Show, + Switch, + createMemo, + createEffect, + createResource, + createSignal, +} from "solid-js"; +import { createStore, type Store, type SetStoreFunction } from "solid-js/store"; +import { useSearchParams } from "@solidjs/router"; +import { persistentAtom } from "@nanostores/persistent"; +import { useStore } from "@nanostores/solid"; +import type { + ColumnDef, + PaginationState, + CellContext, +} from "@tanstack/solid-table"; +import { createColumnHelper } from "@tanstack/solid-table"; +import type { DialogTriggerProps } from "@kobalte/core/dialog"; +import { asyncBase64Encode } from "trailbase"; + +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; +import { SheetContent, SheetTrigger } from "@/components/ui/sheet"; +import { + Switch as SwitchUi, + SwitchControl, + SwitchLabel, + SwitchThumb, +} from "@/components/ui/switch"; +import { + TbColumns, + TbDownload, + TbRefresh, + TbTable, + TbTrash, + TbLock, + TbEye, + TbWand, +} from "solid-icons/tb"; + +import { CreateAlterTableForm } from "@/components/tables/CreateAlterTable"; +import { CreateAlterIndexForm } from "@/components/tables/CreateAlterIndex"; +import { + DataTable, + defaultPaginationState, + safeParseInt, +} from "@/components/Table"; +import { FilterBar } from "@/components/FilterBar"; +import { DestructiveActionButton } from "@/components/DestructiveActionButton"; +import { InsertAlterRowForm } from "@/components/tables/InsertAlterRow"; +import { RecordApiSettingsForm } from "@/components/tables/RecordApiSettings"; +import { SplitView } from "@/components/SplitView"; +import { SafeSheet } from "@/components/SafeSheet"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +import { createConfigQuery } from "@/lib/config"; +import { adminFetch } from "@/lib/fetch"; +import { urlSafeBase64ToUuid, showSaveFileDialog } from "@/lib/utils"; +import { RecordApiConfig } from "@proto/config"; +import { getAllTableSchemas, dropTable, dropIndex } from "@/lib/table"; + +import type { + Column, + DeleteRowsRequest, + FileUpload, + FileUploads, + ListRowsResponse, + ListSchemasResponse, + Table, + TableIndex, + TableTrigger, + View, +} from "@/lib/bindings"; +import { + findPrimaryKeyColumnIndex, + isFileUploadColumn, + isFileUploadsColumn, + isJSONColumn, + isNotNull, + isUUIDv7Column, + hiddenTable, + tableType, + type TableType, + tableSatisfiesRecordApiRequirements, + viewSatisfiesRecordApiRequirements, +} from "@/lib/schema"; + +// We deliberately want to use `Object` over `object` which includes primitive types such as string. +// eslint-disable-next-line @typescript-eslint/no-wrapper-object-types +type RowData = (Object | undefined)[]; +// eslint-disable-next-line @typescript-eslint/no-wrapper-object-types +type Row = { [key: string]: Object | undefined }; + +function rowDataToRow(columns: Column[], row: RowData): Row { + const result: Row = {}; + for (let i = 0; i < row.length; ++i) { + result[columns[i].name] = row[i]; + } + return result; +} + +function renderCell( + context: CellContext, + tableName: string, + columns: Column[], + pkIndex: number, + cell: { + col: Column; + isUUIDv7: boolean; + isJSON: boolean; + isFile: boolean; + isFiles: boolean; + }, +): unknown { + const value = context.getValue(); + if (value === null) { + return "NULL"; + } + + if (typeof value === "string") { + if (cell.isUUIDv7) { + return urlSafeBase64ToUuid(value); + } + + const imageMime = (f: FileUpload) => { + const mime = f.mime_type; + return mime === "image/jpeg" || mime === "image/png"; + }; + + if (cell.isFile) { + const fileUpload = JSON.parse(value) as FileUpload; + if (imageMime(fileUpload)) { + const pkCol = columns[pkIndex].name; + const pkVal = context.row.original[pkIndex] as string; + const url = imageUrl({ + tableName, + pkCol, + pkVal, + fileColName: cell.col.name, + }); + + return ; + } + } else if (cell.isFiles) { + const fileUploads = JSON.parse(value) as FileUploads; + + const indexes: number[] = []; + for (let i = 0; i < fileUploads.length; ++i) { + const file = fileUploads[i]; + if (imageMime(file)) { + indexes.push(i); + } + + if (indexes.length >= 3) break; + } + + if (indexes.length > 0) { + const pkCol = columns[pkIndex].name; + const pkVal = context.row.original[pkIndex] as string; + return ( +
+ + {(index: number) => { + const fileUpload = fileUploads[index]; + const url = imageUrl({ + tableName, + pkCol, + pkVal, + fileColName: cell.col.name, + index, + }); + + return ; + }} + +
+ ); + } + } + } + + return value; +} + +async function deleteRows(tableName: string, request: DeleteRowsRequest) { + const response = await adminFetch(`/table/${tableName}/rows`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + return await response.text(); +} + +function Image(props: { url: string; mime: string }) { + const [imageData] = createResource(async () => { + const response = await adminFetch(props.url); + return await asyncBase64Encode(await response.blob()); + }); + + return ( + + {imageData.error} + + Loading + + + + + + ); +} + +function imageUrl(opts: { + tableName: string; + pkCol: string; + pkVal: string; + fileColName: string; + index?: number; +}): string { + const uri = `/table/${opts.tableName}/files?pk_column=${opts.pkCol}&pk_value=${opts.pkVal}&file_column_name=${opts.fileColName}`; + const index = opts.index; + if (index) { + return `${uri}&file_index=${index}`; + } + return uri; +} + +function SchemaDialogButton(props: { + table: Table; + indexes: TableIndex[]; + triggers: TableTrigger[]; +}) { + const columns = () => props.table.columns; + const indexes = () => props.indexes; + const triggers = () => props.triggers; + const fks = () => props.table.foreign_keys; + + return ( +
+ + + + + + + + Schema + + +
+
+

Columns

+
+                {JSON.stringify(columns(), null, 2)}
+              
+ +

Foreign Keys

+
+                {JSON.stringify(fks(), null, 2)}
+              
+ +

Indexes

+
+                {JSON.stringify(indexes(), null, 2)}
+              
+ +

Triggers

+
+                {JSON.stringify(triggers(), null, 2)}
+              
+
+
+
+
+
+ ); +} + +function TableHeaderRightHandButtons(props: { + table: Table | View; + allTables: Table[]; + schemaRefetch: () => Promise; +}) { + const table = () => props.table; + const hidden = () => hiddenTable(table()); + const type = () => tableType(table()); + + const satisfiesRecordApi = createMemo(() => { + const t = type(); + if (t === "table") { + return tableSatisfiesRecordApiRequirements( + props.table as Table, + props.allTables, + ); + } else if (t === "view") { + return viewSatisfiesRecordApiRequirements( + props.table as View, + props.allTables, + ); + } + + return false; + }); + + const config = createConfigQuery(); + const recordApi = (): RecordApiConfig | undefined => { + for (const c of config.data?.config?.recordApis ?? []) { + if (c.tableName === table().name) { + return c; + } + } + }; + + return ( +
+ {/* Delete table button */} + {!hidden() && ( + { + await dropTable({ + name: table().name, + }); + props.schemaRefetch(); + }} + msg="Deleting a table will irreversibly delete all the data contained. Are you sure you'd like to continue?" + > +
+ Delete +
+
+ )} + + {/* Record API settings*/} + {(type() === "table" || type() === "view") && !hidden() && ( + { + return ( + <> + + + + + ( + + + + + + + {satisfiesRecordApi() ? ( +

Create a Record API endpoint for this table.

+ ) : ( +

+ This table does not satisfy the requirements for + exposing a Record API: strictly typed {"&"} UUIDv7 + primary key column. +

+ )} +
+
+ )} + /> + + ); + }} + /> + )} + + {type() === "table" && !hidden() && ( + { + return ( + <> + + { + /* No selection change needed for AlterTable */ + }} + schema={props.table as Table} + {...sheet} + /> + + + ( + + )} + /> + + ); + }} + /> + )} +
+ ); +} + +function TableHeader(props: { + table: Table | View; + indexes: TableIndex[]; + triggers: TableTrigger[]; + allTables: Table[]; + schemaRefetch: () => Promise; + rowsRefetch: () => Promise; +}) { + const table = () => props.table; + const name = () => props.table.name; + + const type = () => tableType(table()); + const hasSchema = () => type() === "table"; + const header = () => { + switch (type()) { + case "view": + return "View"; + case "virtualTable": + return "Virtual Table"; + default: + return "Table"; + } + }; + + return ( +
+ + +
+ +
+
+ ); +} + +type TableStore = { + selected: Table | View; + schemas: ListSchemasResponse; + + // Filter & pagination + filter: string | undefined; + pagination: PaginationState; +}; + +type FetchArgs = { + tableName: string; + filter: string | undefined; + pageSize: number; + pageIndex: number; + cursors: string[]; +}; + +type TableState = { + store: Store; + setStore: SetStoreFunction; + + response: ListRowsResponse; + + // Derived + pkColumnIndex: number; + columnDefs: ColumnDef[]; +}; + +async function buildTableState( + source: FetchArgs, + store: Store, + setStore: SetStoreFunction, + info: ResourceFetcherInfo, +): Promise { + const response = await fetchRows(source, { value: info.value?.response }); + + const pkColumnIndex = findPrimaryKeyColumnIndex(response.columns); + const columnDefs = buildColumnDefs( + store.selected.name, + tableType(store.selected), + pkColumnIndex, + response.columns, + ); + + return { + store, + setStore, + response, + pkColumnIndex, + columnDefs, + }; +} + +function buildColumnDefs( + tableName: string, + tableType: TableType, + pkColumn: number, + columns: Column[], +): ColumnDef[] { + return columns.map((col, idx) => { + const notNull = isNotNull(col.options); + const isJSON = isJSONColumn(col); + const isUUIDv7 = isUUIDv7Column(col); + const isFile = isFileUploadColumn(col); + const isFiles = isFileUploadsColumn(col); + + // TODO: Add support for custom json schemas or generally JSON types. + const type = (() => { + if (isUUIDv7) return "UUIDv7"; + if (isJSON) return "JSON"; + if (isFile) return "File"; + if (isFiles) return "File[]"; + return col.data_type; + })(); + + return { + header: `${col.name} [${type}${notNull ? "" : "?"}]`, + cell: (context) => + renderCell(context, tableName, columns, pkColumn, { + col: col, + isUUIDv7, + isJSON, + // FIXME: Whether or not an image can be rendered depends on whether + // Record API read-access is configured and not the tableType. We + // could also consider to decouple by providing a dedicated admin + // file-access endpoint. + isFile: isFile && tableType !== "view", + isFiles: isFiles && tableType !== "view", + }), + accessorFn: (row: RowData) => row[idx], + }; + }); +} + +async function fetchRows( + source: FetchArgs, + { value }: { value: ListRowsResponse | undefined }, +): Promise { + const pageIndex = source.pageIndex; + const limit = source.pageSize; + const cursors = source.cursors; + + const filter = source.filter ?? ""; + const filterQuery = filter + .split("AND") + .map((frag) => frag.trim().replaceAll(" ", "")) + .join("&"); + + const params = new URLSearchParams(filterQuery); + params.set("limit", limit.toString()); + + // Build the next UUIDv7 "cursor" from previous response and update local + // cursor stack. If we're paging forward we add new cursors, otherwise we're + // re-using previously seen cursors for consistency. We reset if we go back + // to the start. + if (pageIndex === 0) { + cursors.length = 0; + } else { + const index = pageIndex - 1; + if (index < cursors.length) { + // Already known page + params.set("cursor", cursors[index]); + } else { + // New page case: use cursor from previous response or fall back to more + // expensive and inconsistent offset-based pagination. + const cursor = value?.cursor; + if (cursor) { + cursors.push(cursor); + params.set("cursor", cursor); + } else { + params.set("offset", `${pageIndex * source.pageSize}`); + } + } + } + + try { + const response = await adminFetch( + `/table/${source.tableName}/rows?${params}`, + ); + return (await response.json()) as ListRowsResponse; + } catch (err) { + if (value) { + return value; + } + throw err; + } +} + +function RowDataTable(props: { + state: TableState; + rowsRefetch: () => Promise; +}) { + const [editRow, setEditRow] = createSignal(); + const [selectedRows, setSelectedRows] = createSignal(new Set()); + + const table = () => props.state.store.selected; + const mutable = () => tableType(table()) === "table" && !hiddenTable(table()); + + const refetch = async () => await props.rowsRefetch(); + const columns = (): Column[] => props.state.response.columns; + const totalRowCount = () => Number(props.state.response.total_row_count); + const pkColumnIndex = () => props.state.pkColumnIndex; + + return ( + <> + editRow() !== undefined, + (isOpen: boolean | ((value: boolean) => boolean)) => { + if (!isOpen) { + setEditRow(undefined); + } + }, + ]} + children={(sheet) => { + return ( + <> + + + + + { + if (value === props.state.store.filter) { + refetch(); + } else { + props.state.setStore("filter", (_prev) => value); + } + }} + example='e.g. "latency[lt]=2 AND status=200"' + /> + +
+ props.state.columnDefs} + data={() => props.state.response.rows} + rowCount={totalRowCount()} + initialPagination={props.state.store.pagination} + onPaginationChange={( + p: + | PaginationState + | ((old: PaginationState) => PaginationState), + ) => { + props.state.setStore("pagination", p); + }} + onRowClick={ + mutable() + ? (_idx: number, row: RowData) => { + setEditRow(rowDataToRow(columns(), row)); + } + : undefined + } + onRowSelection={ + mutable() + ? (_idx: number, row: RowData, value: boolean) => { + const rows = new Set(selectedRows()); + const rowId = row[pkColumnIndex()] as string; + if (value) { + rows.add(rowId); + } else { + rows.delete(rowId); + } + setSelectedRows(rows); + } + : undefined + } + /> +
+ + ); + }} + /> + + {mutable() && ( +
+ {/* Insert Rows */} + { + return ( + <> + + + + + ( + + )} + /> + + ); + }} + /> + + {/* Delete rows */} + +
+ )} + + ); +} + +function TablePane(props: { + selectedTable: Table | View; + schemas: ListSchemasResponse; + schemaRefetch: () => Promise; +}) { + const [editIndex, setEditIndex] = createSignal(); + const [selectedIndexes, setSelectedIndexes] = createSignal(new Set()); + + const table = () => props.selectedTable; + const indexes = () => + props.schemas.indexes.filter((idx) => idx.table_name === table().name); + const triggers = () => + props.schemas.triggers.filter((trig) => trig.table_name === table().name); + + // Derived table() props. + const type = () => tableType(table()); + const hidden = () => hiddenTable(table()); + + const [searchParams, setSearchParams] = useSearchParams<{ + filter?: string; + pageSize?: string; + }>(); + + function newStore(): TableStore { + return { + selected: props.selectedTable, + schemas: props.schemas, + filter: searchParams.filter ?? "", + pagination: defaultPaginationState({ + // NOTE: We index has to start at 0 since we're building the list of + // stable cursors as we incrementally page. + index: 0, + size: safeParseInt(searchParams.pageSize) ?? 20, + }), + }; + } + + // Cursors are deliberately kept out of the store to avoid tracking. + let cursors: string[] = []; + const [store, setStore] = createStore(newStore()); + createEffect(() => { + if (store.selected.name !== props.selectedTable.name) { + // Recreate the state/store when we switch tables. + cursors = []; + setStore(newStore()); + } + + setSearchParams({ + filter: store.filter, + }); + }); + + const buildFetchArgs = (): FetchArgs => ({ + // We need to access store properties here to react to them changing. It's + // fine grained, so accessing a nested object like store.pagination isn't + // enough. + tableName: store.selected.name, + filter: store.filter, + pageSize: store.pagination.pageSize, + pageIndex: store.pagination.pageIndex, + cursors: cursors, + }); + const [state, { refetch: rowsRefetch }] = createResource( + buildFetchArgs, + async (source: FetchArgs, info: ResourceFetcherInfo) => { + try { + return await buildTableState(source, store, setStore, info); + } catch (err) { + setSearchParams({ + filter: undefined, + pageIndex: undefined, + pageSize: undefined, + }); + + throw err; + } + }, + ); + + return ( + <> + { + await rowsRefetch(); + }} + /> + + + +
+ Loading...}> + +
+ Failed to fetch rows: {`${state.error}`} +
+ +
+
+
+ + + { + await rowsRefetch(); + }} + /> + +
+ + {type() === "table" && ( +
+

Indexes

+ + editIndex() !== undefined, + (isOpen: boolean | ((value: boolean) => boolean)) => { + if (!isOpen) { + setEditIndex(undefined); + } + }, + ]} + children={(sheet) => { + return ( + <> + + + + +
+ indexColumns} + data={indexes} + onRowClick={ + hidden() + ? undefined + : (_idx: number, index: TableIndex) => { + setEditIndex(index); + } + } + onRowSelection={ + hidden() + ? undefined + : ( + _idx: number, + index: TableIndex, + value: boolean, + ) => { + const rows = new Set(selectedIndexes()); + if (value) { + rows.add(index.name); + } else { + rows.delete(index.name); + } + setSelectedIndexes(rows); + } + } + /> +
+ + ); + }} + /> + + {!hidden() && ( +
+ { + return ( + <> + + + + + ( + + )} + /> + + ); + }} + /> + + +
+ )} +
+ )} + + {type() === "table" && ( +
+

Triggers

+ +

+ The admin dashboard currently does not support modifying triggers. + Please use the editor to{" "} + + create + {" "} + new triggers or{" "} + drop{" "} + existing ones. +

+ +
+ triggerColumns} data={triggers} /> +
+
+ )} +
+ + ); +} + +function pickInitiallySelectedTable( + tables: (Table | View)[], + tableName: string | undefined, +): Table | View | undefined { + if (tables.length === 0) { + return undefined; + } + + for (const table of tables) { + if (tableName == table.name) { + return table; + } + } + return tables[0]; +} + +function TablePickerPane(props: { + horizontal: boolean; + tablesAndViews: (Table | View)[]; + selectedTableName: Signal; + schemaRefetch: () => Promise; +}) { + const tablesAndViews = createMemo(() => + props.tablesAndViews.toSorted((a, b) => { + const aHidden = a.name.startsWith("_"); + const bHidden = b.name.startsWith("_"); + + if (aHidden == bHidden) { + return a.name.localeCompare(b.name); + } + // Sort hidden tables to the back. + return aHidden ? 1 : -1; + }), + ); + const tables = () => + tablesAndViews().filter( + (either) => (either as Table) !== undefined, + ) as Table[]; + + const showHidden = useStore($showHiddenTables); + + const [selectedTableName, setSelectedTableName] = props.selectedTableName; + + createEffect(() => { + // Update search params. + const tableName = selectedTableName(); + + const [searchParams, setSearchParams] = useSearchParams<{ + table: string; + }>(); + const index = tableName + ? tablesAndViews().findIndex((t) => t.name === tableName) + : -1; + if (index < 0) { + console.debug("Did not find table:", tableName); + setSelectedTableName( + pickInitiallySelectedTable(tablesAndViews(), searchParams.table)?.name, + ); + } + + if (tableName !== searchParams.table) { + setSearchParams({ table: tableName }); + } + }); + + const flexStyle = () => (props.horizontal ? "flex flex-col h-dvh" : "flex"); + + return ( +
+ {/* TODO: Maybe add a thin bottom scrollbar to make overflow more apparent */} +
+ + {(item: Table | View) => { + const hidden = hiddenTable(item); + const type = tableType(item); + const selected = () => item.name === selectedTableName(); + + return ( + + ); + }} + + + { + return ( + <> + + + + + ( + + )} + /> + + ); + }} + /> +
+ + { + if (!show && selectedTableName()?.startsWith("_")) { + setSelectedTableName(undefined); + } + console.debug("Show hidden tables:", show); + $showHiddenTables.set(show); + }} + > + + + + Hidden Tables + +
+ ); +} + +function TableSplitView(props: { + schemas: ListSchemasResponse; + schemaRefetch: () => Promise; +}) { + const showHidden = useStore($showHiddenTables); + function filterHidden( + schemas: (Table | View)[], + showHidden: boolean, + ): (Table | View)[] { + return schemas.filter((s) => showHidden || !s.name.startsWith("_")); + } + const tablesAndViews = () => + filterHidden( + [...props.schemas.tables, ...props.schemas.views], + showHidden(), + ); + + const [searchParams] = useSearchParams<{ table: string }>(); + const selectedTableNameSignal = createSignal( + pickInitiallySelectedTable(tablesAndViews(), searchParams.table)?.name, + ); + + const selectedTable = (): Table | View | undefined => { + const [selectedTableName] = selectedTableNameSignal; + const table = props.schemas.tables.find( + (t) => t.name == selectedTableName(), + ); + if (table) { + return table; + } + return props.schemas.views.find((t) => t.name == selectedTableName()); + }; + + const First = (p: { horizontal: boolean }) => ( + + ); + const Second = () => ( + No table selected} + > + + + ); + + return ; +} + +export function TablesPage() { + const [schemaFetch, { refetch }] = createResource(getAllTableSchemas); + + return ( + + + Schema fetch error: {JSON.stringify(schemaFetch.latest)} + + + + { + const schemas = await refetch(); + console.debug("All table schemas re-fetched:", schemas); + }} + /> + + + ); +} + +const sheetMaxWidth = "sm:max-w-[520px]"; +const $showHiddenTables = persistentAtom("show_hidden_tables", false, { + encode: JSON.stringify, + decode: JSON.parse, +}); + +const indexColumns = [ + { + header: "name", + accessorKey: "name", + }, + { + header: "columns", + accessorFn: (index: TableIndex) => { + return index.columns.map((c) => c.column_name).join(", "); + }, + }, + { + header: "unique", + accessorKey: "unique", + }, + { + header: "predicate", + accessorFn: (index: TableIndex) => { + return index.predicate?.replaceAll("<>", "!="); + }, + }, +] as ColumnDef[]; + +const triggerColumnHelper = createColumnHelper(); +const triggerColumns = [ + triggerColumnHelper.accessor("name", {}), + triggerColumnHelper.accessor("sql", { + header: "statement", + cell: (props) =>
{props.getValue()}
, + }), +] as ColumnDef[]; diff --git a/ui/admin/src/components/ui/accordion.tsx b/ui/admin/src/components/ui/accordion.tsx new file mode 100644 index 0000000..ec86dcf --- /dev/null +++ b/ui/admin/src/components/ui/accordion.tsx @@ -0,0 +1,82 @@ +import { type JSX, splitProps, type ValidComponent } from "solid-js" + +import * as AccordionPrimitive from "@kobalte/core/accordion" +import type { PolymorphicProps } from "@kobalte/core/polymorphic" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +type AccordionItemProps = + AccordionPrimitive.AccordionItemProps & { + class?: string | undefined + } + +const AccordionItem = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as AccordionItemProps, ["class"]) + return +} + +type AccordionTriggerProps = + AccordionPrimitive.AccordionTriggerProps & { + class?: string | undefined + children?: JSX.Element + } + +const AccordionTrigger = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as AccordionTriggerProps, ["class", "children"]) + return ( + + svg]:rotate-180", + local.class + )} + {...others} + > + {local.children} + + + + + + ) +} + +type AccordionContentProps = + AccordionPrimitive.AccordionContentProps & { + class?: string | undefined + children?: JSX.Element + } + +const AccordionContent = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as AccordionContentProps, ["class", "children"]) + return ( + +
{local.children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/ui/admin/src/components/ui/badge.tsx b/ui/admin/src/components/ui/badge.tsx new file mode 100644 index 0000000..6407e17 --- /dev/null +++ b/ui/admin/src/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import type { Component, ComponentProps } from "solid-js" +import { splitProps } from "solid-js" + +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground" + } + }, + defaultVariants: { + variant: "default" + } + } +) + +type BadgeProps = ComponentProps<"div"> & + VariantProps & { + round?: boolean + } + +const Badge: Component = (props) => { + const [local, others] = splitProps(props, ["class", "variant", "round"]) + return ( +
+ ) +} + +export type { BadgeProps } +export { Badge, badgeVariants } diff --git a/ui/admin/src/components/ui/button.tsx b/ui/admin/src/components/ui/button.tsx new file mode 100644 index 0000000..6282259 --- /dev/null +++ b/ui/admin/src/components/ui/button.tsx @@ -0,0 +1,52 @@ +import { type JSX, splitProps, type ValidComponent } from "solid-js" + +import * as ButtonPrimitive from "@kobalte/core/button" +import { type PolymorphicProps } from "@kobalte/core/polymorphic" +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: "border border-input hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline" + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "size-10" + } + }, + defaultVariants: { + variant: "default", + size: "default" + } + } +) + +type ButtonProps = ButtonPrimitive.ButtonRootProps & + VariantProps & { class?: string | undefined; children?: JSX.Element } + +const Button = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as ButtonProps, ["variant", "size", "class"]) + return ( + + ) +} + +export type { ButtonProps } +export { Button, buttonVariants } diff --git a/ui/admin/src/components/ui/card.tsx b/ui/admin/src/components/ui/card.tsx new file mode 100644 index 0000000..6cfc008 --- /dev/null +++ b/ui/admin/src/components/ui/card.tsx @@ -0,0 +1,43 @@ +import type { Component, ComponentProps } from "solid-js" +import { splitProps } from "solid-js" + +import { cn } from "@/lib/utils" + +const Card: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return ( +
+ ) +} + +const CardHeader: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return
+} + +const CardTitle: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return ( +

+ ) +} + +const CardDescription: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return

+} + +const CardContent: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return

+} + +const CardFooter: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return
+} + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/ui/admin/src/components/ui/checkbox.tsx b/ui/admin/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..1295351 --- /dev/null +++ b/ui/admin/src/components/ui/checkbox.tsx @@ -0,0 +1,38 @@ +import { splitProps, type ValidComponent } from "solid-js" + +import * as CheckboxPrimitive from "@kobalte/core/checkbox" +import type { PolymorphicProps } from "@kobalte/core/polymorphic" + +import { cn } from "@/lib/utils" + +type CheckboxRootProps = + CheckboxPrimitive.CheckboxRootProps & { class?: string | undefined } + +const Checkbox = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as CheckboxRootProps, ["class"]) + return ( + + + + + + + + + + + ) +} + +export { Checkbox } diff --git a/ui/admin/src/components/ui/dialog.tsx b/ui/admin/src/components/ui/dialog.tsx new file mode 100644 index 0000000..18373d9 --- /dev/null +++ b/ui/admin/src/components/ui/dialog.tsx @@ -0,0 +1,141 @@ +import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js" +import { splitProps } from "solid-js" + +import * as DialogPrimitive from "@kobalte/core/dialog" +import type { PolymorphicProps } from "@kobalte/core/polymorphic" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal: Component = (props) => { + const [, rest] = splitProps(props, ["children"]) + return ( + +
+ {props.children} +
+
+ ) +} + +type DialogOverlayProps = + DialogPrimitive.DialogOverlayProps & { class?: string | undefined } + +const DialogOverlay = ( + props: PolymorphicProps> +) => { + const [, rest] = splitProps(props as DialogOverlayProps, ["class"]) + return ( + + ) +} + +type DialogContentProps = + DialogPrimitive.DialogContentProps & { + class?: string | undefined + children?: JSX.Element + } + +const DialogContent = ( + props: PolymorphicProps> +) => { + const [, rest] = splitProps(props as DialogContentProps, ["class", "children"]) + return ( + + + + {props.children} + + + + + + Close + + + + ) +} + +const DialogHeader: Component> = (props) => { + const [, rest] = splitProps(props, ["class"]) + return ( +
+ ) +} + +const DialogFooter: Component> = (props) => { + const [, rest] = splitProps(props, ["class"]) + return ( +
+ ) +} + +type DialogTitleProps = DialogPrimitive.DialogTitleProps & { + class?: string | undefined +} + +const DialogTitle = ( + props: PolymorphicProps> +) => { + const [, rest] = splitProps(props as DialogTitleProps, ["class"]) + return ( + + ) +} + +type DialogDescriptionProps = + DialogPrimitive.DialogDescriptionProps & { + class?: string | undefined + } + +const DialogDescription = ( + props: PolymorphicProps> +) => { + const [, rest] = splitProps(props as DialogDescriptionProps, ["class"]) + return ( + + ) +} + +export { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription +} diff --git a/ui/admin/src/components/ui/hover-card.tsx b/ui/admin/src/components/ui/hover-card.tsx new file mode 100644 index 0000000..3e4e630 --- /dev/null +++ b/ui/admin/src/components/ui/hover-card.tsx @@ -0,0 +1,37 @@ +import type { Component, ValidComponent } from "solid-js" +import { splitProps } from "solid-js" + +import * as HoverCardPrimitive from "@kobalte/core/hover-card" +import type { PolymorphicProps } from "@kobalte/core/polymorphic" + +import { cn } from "@/lib/utils" + +const HoverCardTrigger = HoverCardPrimitive.Trigger + +const HoverCard: Component = (props) => { + return +} + +type HoverCardContentProps = + HoverCardPrimitive.HoverCardContentProps & { + class?: string | undefined + } + +const HoverCardContent = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as HoverCardContentProps, ["class"]) + return ( + + + + ) +} + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/ui/admin/src/components/ui/label.tsx b/ui/admin/src/components/ui/label.tsx new file mode 100644 index 0000000..afba022 --- /dev/null +++ b/ui/admin/src/components/ui/label.tsx @@ -0,0 +1,19 @@ +import type { Component, ComponentProps } from "solid-js" +import { splitProps } from "solid-js" + +import { cn } from "@/lib/utils" + +const Label: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return ( +

+ + ) +} + +const TableHeader: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return +} + +const TableBody: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return +} + +const TableFooter: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return ( + + ) +} + +const TableRow: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return ( + + ) +} + +const TableHead: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return ( +
+ ) +} + +const TableCell: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return ( + + ) +} + +const TableCaption: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return
+} + +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption } diff --git a/ui/admin/src/components/ui/tabs.tsx b/ui/admin/src/components/ui/tabs.tsx new file mode 100644 index 0000000..fdf72e0 --- /dev/null +++ b/ui/admin/src/components/ui/tabs.tsx @@ -0,0 +1,87 @@ +import type { ValidComponent } from "solid-js" +import { splitProps } from "solid-js" + +import type { PolymorphicProps } from "@kobalte/core/polymorphic" +import * as TabsPrimitive from "@kobalte/core/tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +type TabsListProps = TabsPrimitive.TabsListProps & { + class?: string | undefined +} + +const TabsList = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as TabsListProps, ["class"]) + return ( + + ) +} + +type TabsTriggerProps = TabsPrimitive.TabsTriggerProps & { + class?: string | undefined +} + +const TabsTrigger = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as TabsTriggerProps, ["class"]) + return ( + + ) +} + +type TabsContentProps = TabsPrimitive.TabsContentProps & { + class?: string | undefined +} + +const TabsContent = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as TabsContentProps, ["class"]) + return ( + + ) +} + +type TabsIndicatorProps = TabsPrimitive.TabsIndicatorProps & { + class?: string | undefined +} + +const TabsIndicator = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as TabsIndicatorProps, ["class"]) + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, TabsIndicator } diff --git a/ui/admin/src/components/ui/text-field.tsx b/ui/admin/src/components/ui/text-field.tsx new file mode 100644 index 0000000..c8515cb --- /dev/null +++ b/ui/admin/src/components/ui/text-field.tsx @@ -0,0 +1,143 @@ +import type { ValidComponent } from "solid-js" +import { splitProps } from "solid-js" + +import type { PolymorphicProps } from "@kobalte/core" +import * as TextFieldPrimitive from "@kobalte/core/text-field" +import { cva } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const TextField = TextFieldPrimitive.Root + +export type TextFieldType = + | "button" + | "checkbox" + | "color" + | "date" + | "datetime-local" + | "email" + | "file" + | "hidden" + | "image" + | "month" + | "number" + | "password" + | "radio" + | "range" + | "reset" + | "search" + | "submit" + | "tel" + | "text" + | "time" + | "url" + | "week"; + +type TextFieldInputProps = + TextFieldPrimitive.TextFieldInputProps & { + class?: string | undefined + type:TextFieldType, + } + +const TextFieldInput = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as TextFieldInputProps, ["type", "class"]) + return ( + + ) +} + +type TextFieldTextAreaProps = + TextFieldPrimitive.TextFieldTextAreaProps & { class?: string | undefined } + +const TextFieldTextArea = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as TextFieldTextAreaProps, ["class"]) + return ( + + ) +} + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", + { + variants: { + variant: { + label: "data-[invalid]:text-destructive", + description: "text-destructive", + error: "font-normal text-muted-foreground" + } + }, + defaultVariants: { + variant: "label" + } + } +) + +type TextFieldLabelProps = + TextFieldPrimitive.TextFieldLabelProps & { class?: string | undefined } + +const TextFieldLabel = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as TextFieldLabelProps, ["class"]) + return +} + +type TextFieldDescriptionProps = + TextFieldPrimitive.TextFieldDescriptionProps & { + class?: string | undefined + } + +const TextFieldDescription = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as TextFieldDescriptionProps, ["class"]) + return ( + + ) +} + +type TextFieldErrorMessageProps = + TextFieldPrimitive.TextFieldErrorMessageProps & { + class?: string | undefined + } + +const TextFieldErrorMessage = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as TextFieldErrorMessageProps, ["class"]) + return ( + + ) +} + +export { + TextField, + TextFieldInput, + TextFieldTextArea, + TextFieldLabel, + TextFieldDescription, + TextFieldErrorMessage +} diff --git a/ui/admin/src/components/ui/toast.tsx b/ui/admin/src/components/ui/toast.tsx new file mode 100644 index 0000000..d43773c --- /dev/null +++ b/ui/admin/src/components/ui/toast.tsx @@ -0,0 +1,163 @@ +import type { JSX, ValidComponent } from "solid-js" +import { Match, splitProps, Switch } from "solid-js" +import { Portal } from "solid-js/web" + +import type { PolymorphicProps } from "@kobalte/core/polymorphic" +import * as ToastPrimitive from "@kobalte/core/toast" +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--kb-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--kb-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[opened]:animate-in data-[closed]:animate-out data-[swipe=end]:animate-out data-[closed]:fade-out-80 data-[closed]:slide-out-to-right-full data-[opened]:slide-in-from-top-full data-[opened]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + success: "success border-success-foreground bg-success text-success-foreground", + warning: "warning border-warning-foreground bg-warning text-warning-foreground", + error: "error border-error-foreground bg-error text-error-foreground" + } + }, + defaultVariants: { + variant: "default" + } + } +) +type ToastVariant = NonNullable["variant"]> + +type ToastListProps = ToastPrimitive.ToastListProps & { + class?: string | undefined +} + +const Toaster = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as ToastListProps, ["class"]) + return ( + + + + + + ) +} + +type ToastRootProps = ToastPrimitive.ToastRootProps & + VariantProps & { class?: string | undefined } + +const Toast = (props: PolymorphicProps>) => { + const [local, others] = splitProps(props as ToastRootProps, ["class", "variant"]) + return ( + + ) +} + +type ToastCloseButtonProps = + ToastPrimitive.ToastCloseButtonProps & { class?: string | undefined } + +const ToastClose = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as ToastCloseButtonProps, ["class"]) + return ( + + + + + + + ) +} + +type ToastTitleProps = ToastPrimitive.ToastTitleProps & { + class?: string | undefined +} + +const ToastTitle = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as ToastTitleProps, ["class"]) + return +} + +type ToastDescriptionProps = + ToastPrimitive.ToastDescriptionProps & { class?: string | undefined } + +const ToastDescription = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as ToastDescriptionProps, ["class"]) + return +} + +function showToast(props: { + title?: JSX.Element + description?: JSX.Element + variant?: ToastVariant + duration?: number +}) { + ToastPrimitive.toaster.show((data) => ( + +
+ {props.title && {props.title}} + {props.description && {props.description}} +
+ +
+ )) +} + +function showToastPromise( + promise: Promise | (() => Promise), + options: { + loading?: JSX.Element + success?: (data: T) => JSX.Element + error?: (error: U) => JSX.Element + duration?: number + } +) { + const variant: { [key in ToastPrimitive.ToastPromiseState]: ToastVariant } = { + pending: "default", + fulfilled: "success", + rejected: "error" + } + return ToastPrimitive.toaster.promise(promise, (props) => ( + + + {options.loading} + {options.success?.(props.data!)} + {options.error?.(props.error!)} + + + )) +} + +export { Toaster, Toast, ToastClose, ToastTitle, ToastDescription, showToast, showToastPromise } diff --git a/ui/admin/src/components/ui/toggle.tsx b/ui/admin/src/components/ui/toggle.tsx new file mode 100644 index 0000000..f7cb34b --- /dev/null +++ b/ui/admin/src/components/ui/toggle.tsx @@ -0,0 +1,49 @@ +import type { ValidComponent } from "solid-js" +import { splitProps } from "solid-js" + +import type { PolymorphicProps } from "@kobalte/core/polymorphic" +import * as ToggleButtonPrimitive from "@kobalte/core/toggle-button" +import { cva } from "class-variance-authority" +import type { VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const toggleVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-transparent", + outline: "border border-input bg-transparent shadow-sm" + }, + size: { + default: "h-9 px-3", + sm: "h-8 px-2", + lg: "h-10 px-3" + } + }, + defaultVariants: { + variant: "default", + size: "default" + } + } +) + +type ToggleButtonRootProps = + ToggleButtonPrimitive.ToggleButtonRootProps & + VariantProps & { class?: string | undefined } + +const Toggle = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as ToggleButtonRootProps, ["class", "variant", "size"]) + return ( + + ) +} + +export type { ToggleButtonRootProps as ToggleProps } +export { toggleVariants, Toggle } diff --git a/ui/admin/src/components/ui/tooltip.tsx b/ui/admin/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..948b5b7 --- /dev/null +++ b/ui/admin/src/components/ui/tooltip.tsx @@ -0,0 +1,34 @@ +import { splitProps, type ValidComponent, type Component } from "solid-js" + +import type { PolymorphicProps } from "@kobalte/core/polymorphic" +import * as TooltipPrimitive from "@kobalte/core/tooltip" + +import { cn } from "@/lib/utils" + +const TooltipTrigger = TooltipPrimitive.Trigger + +const Tooltip: Component = (props) => { + return +} + +type TooltipContentProps = + TooltipPrimitive.TooltipContentProps & { class?: string | undefined } + +const TooltipContent = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as TooltipContentProps, ["class"]) + return ( + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent } diff --git a/ui/admin/src/index.css b/ui/admin/src/index.css new file mode 100644 index 0000000..82e8f87 --- /dev/null +++ b/ui/admin/src/index.css @@ -0,0 +1,141 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + + --info: 204 94% 94%; + --info-foreground: 199 89% 48%; + + --success: 149 80% 90%; + --success-foreground: 160 84% 39%; + + --warning: 48 96% 89%; + --warning-foreground: 25 95% 53%; + + --error: 0 93% 94%; + --error-foreground: 0 84% 60%; + + --ring: 240 5.9% 10%; + + --radius: 0.5rem; + } + + .dark, + [data-kb-theme="dark"] { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + + --info: 204 94% 94%; + --info-foreground: 199 89% 48%; + + --success: 149 80% 90%; + --success-foreground: 160 84% 39%; + + --warning: 48 96% 89%; + --warning-foreground: 25 95% 53%; + + --error: 0 93% 94%; + --error-foreground: 0 84% 60%; + + --ring: 240 4.9% 83.9%; + + --radius: 0.5rem; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + font-feature-settings: + "rlig" 1, + "calt" 1; + } +} + +@layer utilities { + .step { + counter-increment: step; + } + + .step:before { + @apply absolute w-9 h-9 bg-muted rounded-full font-mono font-medium text-center text-base inline-flex items-center justify-center -indent-px border-4 border-background; + @apply ml-[-50px] mt-[-4px]; + content: counter(step); + } +} + +@media (max-width: 640px) { + .container { + @apply px-4; + } +} + +::-webkit-scrollbar { + width: 16px; +} + +::-webkit-scrollbar-thumb { + border-radius: 9999px; + border: 4px solid transparent; + background-clip: content-box; + @apply bg-accent; +} + +::-webkit-scrollbar-corner { + display: none; +} diff --git a/ui/admin/src/index.tsx b/ui/admin/src/index.tsx new file mode 100644 index 0000000..551e9c4 --- /dev/null +++ b/ui/admin/src/index.tsx @@ -0,0 +1,19 @@ +/* @refresh reload */ +import { render } from "solid-js/web"; + +import "@common/css/global.css"; +import "@common/css/kobalte.css"; + +import App from "./App"; + +const root = document.getElementById("root"); + +if (import.meta.env.DEV && !(root instanceof HTMLElement)) { + throw new Error( + "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?", + ); +} + +render(() => { + return ; +}, root!); diff --git a/ui/admin/src/lib/bindings.ts b/ui/admin/src/lib/bindings.ts new file mode 100644 index 0000000..a54cf5f --- /dev/null +++ b/ui/admin/src/lib/bindings.ts @@ -0,0 +1,55 @@ +export type * from "@bindings/AlterIndexRequest"; +export type * from "@bindings/AlterTableRequest"; +export type * from "@bindings/Column"; +export type * from "@bindings/ColumnDataType"; +export type * from "@bindings/ColumnOption"; +export type * from "@bindings/ColumnOrder"; +export type * from "@bindings/ConfiguredOAuthProvidersResponse"; +export type * from "@bindings/CreateIndexRequest"; +export type * from "@bindings/CreateIndexResponse"; +export type * from "@bindings/CreateTableRequest"; +export type * from "@bindings/CreateTableResponse"; +export type * from "@bindings/CreateUserRequest"; +export type * from "@bindings/DeleteRowRequest"; +export type * from "@bindings/DeleteRowsRequest"; +export type * from "@bindings/DropIndexRequest"; +export type * from "@bindings/DropTableRequest"; +export type * from "@bindings/ForeignKey"; +export type * from "@bindings/GeneratedExpressionMode"; +export type * from "@bindings/JsonSchema"; +export type * from "@bindings/ListJsonSchemasResponse"; +export type * from "@bindings/ListLogsResponse"; +export type * from "@bindings/ListRowsResponse"; +export type * from "@bindings/ListSchemasResponse"; +export type * from "@bindings/ListUsersResponse"; +export type * from "@bindings/LogJson"; +export type * from "@bindings/LoginRequest"; +export type * from "@bindings/LoginResponse"; +export type * from "@bindings/Mode"; +export type * from "@bindings/OAuthProviderEntry"; +export type * from "@bindings/OAuthProviderResponse"; +export type * from "@bindings/ParseRequest"; +export type * from "@bindings/ParseResponse"; +export type * from "@bindings/QueryRequest"; +export type * from "@bindings/QueryResponse"; +export type * from "@bindings/ReadFilesRequest"; +export type * from "@bindings/ReferentialAction"; +export type * from "@bindings/Stats"; +export type * from "@bindings/Table"; +export type * from "@bindings/TableIndex"; +export type * from "@bindings/TableTrigger"; +export type * from "@bindings/View"; +export type * from "@bindings/UniqueConstraint"; +export type * from "@bindings/UpdateJsonSchemaRequest"; +export type * from "@bindings/UpdateRowRequest"; +export type * from "@bindings/UpdateUserRequest"; +export type * from "@bindings/UserJson"; + +export type FileUpload = { + id: string; + filename: string | undefined; + content_type: string | undefined; + mime_type: string | string; +}; + +export type FileUploads = FileUpload[]; diff --git a/ui/admin/src/lib/config.ts b/ui/admin/src/lib/config.ts new file mode 100644 index 0000000..f7c4b33 --- /dev/null +++ b/ui/admin/src/lib/config.ts @@ -0,0 +1,64 @@ +import { QueryClient, createQuery } from "@tanstack/solid-query"; + +import { Config } from "@proto/config"; +import { GetConfigResponse, UpdateConfigRequest } from "@proto/config_api"; +import { adminFetch } from "@/lib/fetch"; + +const defaultKey = ["default"]; + +function createClient(): QueryClient { + return new QueryClient(); +} +const queryClient = createClient(); + +export async function setConfig(config: Config) { + const data = queryClient.getQueryData(defaultKey); + const hash = data?.hash; + if (!hash) { + console.error("Missing hash from:", data); + return; + } + + const request: UpdateConfigRequest = { + config, + hash, + }; + console.debug("Updating config:", request); + const response = await updateConfig(request); + + queryClient.invalidateQueries(); + + return response; +} + +export function createConfigQuery() { + return createQuery( + () => ({ + queryKey: defaultKey, + queryFn: async () => { + const config = await getConfig(); + console.debug("Fetched config:", config); + return config; + }, + refetchInterval: 120 * 1000, + refetchOnMount: false, + }), + () => queryClient, + ); +} + +async function getConfig(): Promise { + const response = await adminFetch("/config"); + const array = new Uint8Array(await (await response.blob()).arrayBuffer()); + return GetConfigResponse.decode(array); +} + +async function updateConfig(request: UpdateConfigRequest): Promise { + await adminFetch("/config", { + method: "POST", + headers: { + "Content-Type": "application/octet-stream", + }, + body: UpdateConfigRequest.encode(request).finish(), + }); +} diff --git a/ui/admin/src/lib/fetch.ts b/ui/admin/src/lib/fetch.ts new file mode 100644 index 0000000..e0e743c --- /dev/null +++ b/ui/admin/src/lib/fetch.ts @@ -0,0 +1,40 @@ +import { computed } from "nanostores"; +import { persistentAtom } from "@nanostores/persistent"; +import { Client, type Tokens, type User } from "trailbase"; + +import { showToast } from "@/components/ui/toast"; + +const $tokens = persistentAtom("auth_tokens", null, { + encode: JSON.stringify, + decode: JSON.parse, +}); +export const $user = computed($tokens, (_tokens) => client.user()); + +const HOST = import.meta.env.DEV ? "http://localhost:4000" : ""; +export const client = Client.init(HOST, { + tokens: $tokens.get() ?? undefined, + onAuthChange: (c: Client, _user: User | undefined) => { + $tokens.set(c.tokens() ?? null); + }, +}); + +export async function adminFetch( + input: string, + init?: RequestInit, +): Promise { + if (!input.startsWith("/")) { + throw Error("Should start with '/'"); + } + + try { + return await client.fetch(`api/_admin${input}`, init); + } catch (err) { + showToast({ + title: "Fetch Error", + description: `${err}`, + variant: "error", + }); + + throw err; + } +} diff --git a/ui/admin/src/lib/name.ts b/ui/admin/src/lib/name.ts new file mode 100644 index 0000000..807f320 --- /dev/null +++ b/ui/admin/src/lib/name.ts @@ -0,0 +1,33 @@ +function getRandomInt(max: number): number { + return Math.floor(Math.random() * max); +} + +const adjectives = [ + "lucid", + "feral", + "jumpy", + "hasty", + "gnarly", + "friendly", + "fresh", + "funny", + "lengthy", +]; + +const nouns = [ + "lynx", + "badger", + "lion", + "panda", + "ant", + "fink", + "lizard", + "canine", + "tiger", +]; + +export function randomName(): string { + const prefix = adjectives[getRandomInt(adjectives.length - 1)]; + const suffix = nouns[getRandomInt(nouns.length - 1)]; + return `${prefix}_${suffix}`; +} diff --git a/ui/admin/src/lib/parse.ts b/ui/admin/src/lib/parse.ts new file mode 100644 index 0000000..f8a517c --- /dev/null +++ b/ui/admin/src/lib/parse.ts @@ -0,0 +1,23 @@ +import type { ParseRequest, ParseResponse } from "@/lib/bindings"; +import { adminFetch } from "@/lib/fetch"; +import { urlSafeBase64Encode } from "trailbase"; + +async function fetchParse(request: ParseRequest): Promise { + const response = await adminFetch("/parse", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + return await response.json(); +} + +export async function parseSql(sql: string): Promise { + const response = await fetchParse({ + query: urlSafeBase64Encode(sql), + mode: "Expression", + }); + + return response.ok ? undefined : (response.message ?? "error"); +} diff --git a/ui/admin/src/lib/schema.ts b/ui/admin/src/lib/schema.ts new file mode 100644 index 0000000..9f6d4f4 --- /dev/null +++ b/ui/admin/src/lib/schema.ts @@ -0,0 +1,260 @@ +import type { + Column, + ColumnOption, + ReferentialAction, + Table, + View, +} from "@/lib/bindings"; + +export function isNotNull(options: ColumnOption[]): boolean { + return options.findIndex((o: ColumnOption) => o === "NotNull") >= 0; +} + +export function setNotNull( + options: ColumnOption[], + value: boolean, +): ColumnOption[] { + const newOpts: ColumnOption[] = options.filter( + (o) => o !== "Null" && o !== "NotNull", + ); + newOpts.push(value ? "NotNull" : "Null"); + return newOpts; +} + +function unpackDefaultValue(col: ColumnOption): string | undefined { + if (typeof col === "object" && "Default" in col) { + return col.Default as string; + } +} + +export function getDefaultValue(options: ColumnOption[]): string | undefined { + return options.reduce((acc, cur: ColumnOption) => { + return unpackDefaultValue(cur) ?? acc; + }, undefined); +} + +export function setDefaultValue( + options: ColumnOption[], + defaultValue: string | undefined, +): ColumnOption[] { + const newOpts = options.filter((o) => unpackDefaultValue(o) === undefined); + if (defaultValue !== undefined) { + newOpts.push({ Default: defaultValue }); + } + return newOpts; +} + +function unpackCheckValue(col: ColumnOption): string | undefined { + if (typeof col === "object" && "Check" in col) { + return col.Check as string; + } +} + +export function getCheckValue(options: ColumnOption[]): string | undefined { + return options.reduce((acc, cur: ColumnOption) => { + const maybeCheck = unpackCheckValue(cur); + if (maybeCheck !== undefined) { + return maybeCheck; + } + return acc; + }, undefined); +} + +export function setCheckValue( + options: ColumnOption[], + checkValue: string | undefined, +): ColumnOption[] { + const newOpts = options.filter((o) => unpackCheckValue(o) === undefined); + if (checkValue !== undefined) { + newOpts.push({ Check: checkValue }); + } + return newOpts; +} + +export function isOptional(options: ColumnOption[]): boolean { + let notNull = false; + for (const opt of options) { + if (opt === "NotNull") { + notNull = true; + } + if (unpackDefaultValue(opt)) { + return true; + } + } + return !notNull; +} + +export type ForeignKey = { + foreign_table: string; + referred_columns: Array; + on_delete: ReferentialAction | null; + on_update: ReferentialAction | null; +}; + +export function getForeignKey(options: ColumnOption[]): ForeignKey | undefined { + return options.reduce((acc, cur: ColumnOption) => { + type U = { ForeignKey: ForeignKey }; + + return typeof cur === "object" && "ForeignKey" in cur + ? ((cur as U).ForeignKey as ForeignKey) + : acc; + }, undefined); +} + +export function setForeignKey( + options: ColumnOption[], + fk: ForeignKey | undefined, +): ColumnOption[] { + const newOpts = options.filter( + (o) => typeof o !== "object" || !("ForeignKey" in o), + ); + if (fk) { + newOpts.push({ ForeignKey: fk }); + } + return newOpts; +} + +export type Unique = { is_primary: boolean }; + +export function getUnique(options: ColumnOption[]): Unique | undefined { + return options.reduce((acc, cur: ColumnOption) => { + type U = { Unique: { is_primary: boolean } }; + + return typeof cur === "object" && "Unique" in cur + ? ((cur as U).Unique as Unique) + : acc; + }, undefined); +} + +export function setUnique( + options: ColumnOption[], + unique: Unique | undefined, +): ColumnOption[] { + const newOpts = options.filter( + (o) => typeof o !== "object" || !("Unique" in o), + ); + if (unique) { + newOpts.push({ Unique: unique }); + } + return newOpts; +} + +export function isPrimaryKeyColumn(column: Column): boolean { + return getUnique(column.options)?.is_primary ?? false; +} + +export function findPrimaryKeyColumnIndex(columns: Column[]): number { + const candidate = columns.findIndex(isPrimaryKeyColumn); + return candidate >= 0 ? candidate : 0; +} + +export function isUUIDv7Column(column: Column): boolean { + if (column.data_type === "Blob") { + const check = getCheckValue(column.options); + return (check?.search(/^is_uuid_v7\s*\(/g) ?? -1) === 0; + } + return false; +} + +export function isFileUploadColumn(column: Column): boolean { + if (column.data_type === "Text") { + const check = getCheckValue(column.options); + return (check?.search(/^jsonschema\s*\('std.FileUpload'/g) ?? -1) === 0; + } + return false; +} + +export function isFileUploadsColumn(column: Column): boolean { + if (column.data_type === "Text") { + const check = getCheckValue(column.options); + return (check?.search(/jsonschema\s*\('std.FileUploads'/g) ?? -1) === 0; + } + return false; +} + +export function isJSONColumn(column: Column): boolean { + if (column.data_type === "Text") { + const check = getCheckValue(column.options); + return (check?.search(/^is_json\s*\(/g) ?? -1) === 0; + } + return false; +} + +function columnsSatisfyRecordApiRequirements( + columns: Column[], + all: Table[], +): boolean { + for (const column of columns) { + if (isPrimaryKeyColumn(column)) { + if (column.data_type === "Integer") { + return true; + } + + if (isUUIDv7Column(column)) { + return true; + } + + const foreign_key = getForeignKey(column.options); + if (foreign_key) { + const foreign_col_name = foreign_key.referred_columns[0]; + if (!foreign_col_name) { + continue; + } + + const foreign_table = all.find( + (t) => t.name === foreign_key.foreign_table, + ); + if (!foreign_table) { + continue; + } + + const foreign_col = foreign_table.columns.find( + (c) => c.name === foreign_col_name, + ); + if (foreign_col && isUUIDv7Column(foreign_col)) { + return true; + } + } + } + } + + return false; +} + +export function tableSatisfiesRecordApiRequirements( + table: Table, + all: Table[], +): boolean { + if (table.strict) { + return columnsSatisfyRecordApiRequirements(table.columns, all); + } + return false; +} + +export function viewSatisfiesRecordApiRequirements( + view: View, + all: Table[], +): boolean { + const columns = view.columns; + if (columns) { + return columnsSatisfyRecordApiRequirements(columns, all); + } + return false; +} + +export type TableType = "table" | "virtualTable" | "view"; + +export function tableType(table: Table | View): TableType { + if ("virtual_table" in table) { + if (table.virtual_table) { + return "virtualTable"; + } + return "table"; + } + + return "view"; +} + +export function hiddenTable(table: Table | View): boolean { + return table.name.startsWith("_"); +} diff --git a/ui/admin/src/lib/table.ts b/ui/admin/src/lib/table.ts new file mode 100644 index 0000000..a34b03e --- /dev/null +++ b/ui/admin/src/lib/table.ts @@ -0,0 +1,87 @@ +import { adminFetch } from "@/lib/fetch"; +import type { + AlterIndexRequest, + AlterTableRequest, + CreateIndexRequest, + CreateIndexResponse, + CreateTableRequest, + CreateTableResponse, + DropIndexRequest, + DropTableRequest, + ListSchemasResponse, +} from "@/lib/bindings"; + +export async function getAllTableSchemas(): Promise { + const response = await adminFetch("/tables"); + return (await response.json()) as ListSchemasResponse; +} + +export async function createIndex( + request: CreateIndexRequest, +): Promise { + const response = await adminFetch("/index", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + return await response.json(); +} + +export async function createTable( + request: CreateTableRequest, +): Promise { + const response = await adminFetch("/table", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + return await response.json(); +} + +export async function alterIndex(request: AlterIndexRequest) { + const response = await adminFetch("/index", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + return await response.text(); +} + +export async function alterTable(request: AlterTableRequest) { + const response = await adminFetch("/table", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + return await response.text(); +} + +export async function dropIndex(request: DropIndexRequest) { + const response = await adminFetch("/index", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + return await response.text(); +} + +export async function dropTable(request: DropTableRequest) { + const response = await adminFetch("/table", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + return await response.text(); +} diff --git a/ui/admin/src/lib/user.ts b/ui/admin/src/lib/user.ts new file mode 100644 index 0000000..ba39511 --- /dev/null +++ b/ui/admin/src/lib/user.ts @@ -0,0 +1,35 @@ +import type { UpdateUserRequest, CreateUserRequest } from "@/lib/bindings"; +import { adminFetch } from "@/lib/fetch"; + +export async function createUser(request: CreateUserRequest) { + await adminFetch("/user", { + method: "Post", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); +} + +export async function deleteUser(id: string): Promise { + // TODO: We should probably have a dedicated delete/disable user endpoint? + await adminFetch("/table/_user", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + id: id, + }), + }); +} + +export async function updateUser(request: UpdateUserRequest) { + await adminFetch("/user", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); +} diff --git a/ui/admin/src/lib/utils.ts b/ui/admin/src/lib/utils.ts new file mode 100644 index 0000000..b2a4a6a --- /dev/null +++ b/ui/admin/src/lib/utils.ts @@ -0,0 +1,37 @@ +import type { ClassValue } from "clsx"; +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +import { stringify as uuidStringify } from "uuid"; +import { urlSafeBase64Decode } from "trailbase"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function urlSafeBase64ToUuid(id: string): string { + return uuidStringify( + Uint8Array.from(urlSafeBase64Decode(id), (c) => c.charCodeAt(0)), + ); +} + +export async function showSaveFileDialog(opts: { + contents: string; + filename: string; +}) { + // Not supported by firefox: https://developer.mozilla.org/en-US/docs/Web/API/Window/showSaveFilePicker#browser_compatibility + // possible fallback: https://stackoverflow.com/a/67806663 + if (window.showSaveFilePicker) { + const handle = await window.showSaveFilePicker({ + suggestedName: opts.filename, + }); + const writable = await handle.createWritable(); + await writable.write(opts.contents); + writable.close(); + } else { + const saveFile = document.createElement("a"); + saveFile.href = URL.createObjectURL(new Blob([opts.contents])); + saveFile.download = opts.filename; + saveFile.click(); + setTimeout(() => URL.revokeObjectURL(saveFile.href), 60000); + } +} diff --git a/ui/admin/tailwind.config.ts b/ui/admin/tailwind.config.ts new file mode 100644 index 0000000..70016f0 --- /dev/null +++ b/ui/admin/tailwind.config.ts @@ -0,0 +1,5 @@ +import type { Config } from "tailwindcss"; + +import { commonTailwindConfig } from "../common/tailwind.config.mjs"; + +export default commonTailwindConfig satisfies Config; diff --git a/ui/admin/tests/util.test.ts b/ui/admin/tests/util.test.ts new file mode 100644 index 0000000..e963b73 --- /dev/null +++ b/ui/admin/tests/util.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "vitest" +import { copyAndConvert } from "@/components/tables/InsertAlterRow"; + +type UnkownRow = { [key: string]: unknown }; +// eslint-disable-next-line @typescript-eslint/no-wrapper-object-types +type ObjectRow = { [key: string]: Object | undefined }; + +test("utils", () => { + const x: UnkownRow = { + "foo": "test", + "bar": "test", + }; + const y: ObjectRow = copyAndConvert(x); + for (const key of Object.keys(x)) { + expect(x[key]).toBe(y[key]); + } +}); diff --git a/ui/admin/tsconfig.json b/ui/admin/tsconfig.json new file mode 100644 index 0000000..6988c1b --- /dev/null +++ b/ui/admin/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../common/tsconfig.base.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js", + "types": [ + "@types/wicg-file-system-access", + "vite/client" + ], + "paths": { + "@/*": ["./src/*"], + "@proto/*": ["./proto/*"], + "@assets/*": ["../../assets/*"], + "@bindings/*": ["../../trailbase-core/bindings/*"], + "@common/*": ["../common/*"] + } + }, + "exclude": [ + "tailwind.config.ts", + "dist/", + "node_modules/", + "public/" + ] +} diff --git a/ui/admin/ui.config.json b/ui/admin/ui.config.json new file mode 100644 index 0000000..044552c --- /dev/null +++ b/ui/admin/ui.config.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://solid-ui.com/schema.json", + "tsx": true, + "tailwind": { + "css": "src/index.css", + "config": "tailwind.config.ts", + "prefix": "" + }, + "aliases": { + "components": "@/components/ui", + "utils": "@/lib/utils" + } +} diff --git a/ui/admin/vite.config.mts b/ui/admin/vite.config.mts new file mode 100644 index 0000000..348cde9 --- /dev/null +++ b/ui/admin/vite.config.mts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; + +import solidPlugin from 'vite-plugin-solid'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + base: "/_/admin", + plugins: [ + tsconfigPaths(), + solidPlugin(), + ], + server: { + port: 3000, + }, + build: { + target: 'esnext', + }, +}); diff --git a/ui/auth/.gitignore b/ui/auth/.gitignore new file mode 100644 index 0000000..016b59e --- /dev/null +++ b/ui/auth/.gitignore @@ -0,0 +1,24 @@ +# build output +dist/ + +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ diff --git a/ui/auth/.prettierignore b/ui/auth/.prettierignore new file mode 100644 index 0000000..d2ab818 --- /dev/null +++ b/ui/auth/.prettierignore @@ -0,0 +1,8 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock + +src/components/ui +src/pages/login.astro +src/components/AlertBox.astro diff --git a/ui/auth/.prettierrc.mjs b/ui/auth/.prettierrc.mjs new file mode 100644 index 0000000..85ccfb5 --- /dev/null +++ b/ui/auth/.prettierrc.mjs @@ -0,0 +1,13 @@ +// .prettierrc.mjs +/** @type {import("prettier").Config} */ +export default { + plugins: ['prettier-plugin-astro'], + overrides: [ + { + files: '*.astro', + options: { + parser: 'astro', + }, + }, + ], +}; diff --git a/ui/auth/astro.config.mjs b/ui/auth/astro.config.mjs new file mode 100644 index 0000000..6458f36 --- /dev/null +++ b/ui/auth/astro.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig } from 'astro/config'; + +import solidJs from "@astrojs/solid-js"; +import icon from "astro-icon"; +import tailwind from "@astrojs/tailwind"; + +// https://astro.build/config +export default defineConfig({ + output: "static", + base: "/_/auth", + integrations: [ + icon(), + solidJs(), + tailwind({ + applyBaseStyles: false, + }), + ], +}); diff --git a/ui/auth/package.json b/ui/auth/package.json new file mode 100644 index 0000000..ff8250d --- /dev/null +++ b/ui/auth/package.json @@ -0,0 +1,40 @@ +{ + "name": "trailbase-auth-ui", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro check && astro build", + "preview": "astro preview", + "astro": "astro", + "check": "astro check", + "format": "prettier -w src", + "proto": "protoc --plugin=protoc-gen-ts=${PWD}/node_modules/ts-proto/protoc-gen-ts_proto ../trailbase-core/proto/*.proto -I../trailbase-core/proto -I/usr/include --ts_out=src/proto/ --ts_opt=esModuleInterop=true && prettier -w src" + }, + "dependencies": { + "@astrojs/check": "^0.9.4", + "@astrojs/solid-js": "^4.4.2", + "@astrojs/tailwind": "^5.1.2", + "@kobalte/core": "^0.13.7", + "astro": "^4.16.7", + "astro-icon": "^1.1.1", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "solid-icons": "^1.1.0", + "solid-js": "^1.9.3", + "tailwind-merge": "^2.5.4", + "tailwindcss": "^3.4.14", + "tailwindcss-animate": "^1.0.7", + "trailbase": "workspace:*" + }, + "devDependencies": { + "@iconify-json/tabler": "^1.2.6", + "@tailwindcss/typography": "^0.5.15", + "prettier": "^3.3.3", + "prettier-plugin-astro": "^0.14.1", + "sharp": "^0.33.5", + "ts-proto": "^2.2.5", + "typescript": "^5.6.3" + } +} diff --git a/ui/auth/src/components/AlertBox.astro b/ui/auth/src/components/AlertBox.astro new file mode 100644 index 0000000..e260e5c --- /dev/null +++ b/ui/auth/src/components/AlertBox.astro @@ -0,0 +1,58 @@ +--- +interface Props { + message?: string; +} + +const { message } = Astro.props; +--- + +
+ {message} +
+ +{ + import.meta.env.DEV && ( + + ) +} diff --git a/ui/auth/src/components/Card.astro b/ui/auth/src/components/Card.astro new file mode 100644 index 0000000..f0a6b29 --- /dev/null +++ b/ui/auth/src/components/Card.astro @@ -0,0 +1,7 @@ +--- + +--- + +
+ +
diff --git a/ui/auth/src/components/ErrorBoundary.tsx b/ui/auth/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..c10096d --- /dev/null +++ b/ui/auth/src/components/ErrorBoundary.tsx @@ -0,0 +1,35 @@ +import { + ErrorBoundary as SolidErrorBoundary, + type JSX, + children, +} from "solid-js"; +import { Toaster, showToast } from "@/components/ui/toast"; + +export function ErrorBoundary(props: { children: JSX.Element }) { + const resolved = children(() => props.children); + + // NOTE: the fallback handles errors during component construction. Not + // errors at runtime, e.g.in a button handler. + return ( + { + return
{`${err}`}
; + }} + > + {resolved()} + + +
+ ); +} + +window.onerror = function (message, url, lineNumber) { + const description = `${url}:${lineNumber} ${message}`; + console.error(description); + + showToast({ + title: "Uncaught Error", + description, + variant: "error", + }); +}; diff --git a/ui/auth/src/components/Form.astro b/ui/auth/src/components/Form.astro new file mode 100644 index 0000000..17dd9d2 --- /dev/null +++ b/ui/auth/src/components/Form.astro @@ -0,0 +1,27 @@ +--- +import BaseLayout from "@/layouts/BaseLayout.astro"; +import Card from "@/components/Card.astro"; +import AlertBox from "@/components/AlertBox.astro"; + +interface Props { + title: string; +} + +const { title } = Astro.props; +--- + + +
+ +
+

{title}

+
+ + +
+ + {"{% if alert %}"} + + {"{% endif %}"} +
+
diff --git a/ui/auth/src/components/ListOauthProviders.tsx b/ui/auth/src/components/ListOauthProviders.tsx new file mode 100644 index 0000000..c041b0a --- /dev/null +++ b/ui/auth/src/components/ListOauthProviders.tsx @@ -0,0 +1,53 @@ +import { createResource, For, Suspense, ErrorBoundary } from "solid-js"; +import type { ConfiguredOAuthProvidersResponse } from "@bindings/ConfiguredOAuthProvidersResponse"; + +import { AUTH_API } from "@/lib/constants"; + +async function listConfiguredOAuthProviders(): Promise { + const response = await fetch(`${AUTH_API}/oauth/providers`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw await response.text(); + } + return await response.json(); +} + +export function ConfiguredOAuthProviders() { + let [providersFetch] = createResource(listConfiguredOAuthProviders); + + const providers = () => { + const providers = [...(providersFetch()?.providers ?? [])]; + if (import.meta.env.DEV) { + providers.push(["name", "Display Name"]); + } + return providers; + }; + + return ( +

OAuth: {err.toString()}

}> + Loading...}> +
+ {providers().length > 0 &&

Or use an external provider:

} + + + {([name, displayName]) => { + return ( + + Login with {displayName} + + ); + }} + +
+
+
+ ); +} diff --git a/ui/auth/src/components/Profile.tsx b/ui/auth/src/components/Profile.tsx new file mode 100644 index 0000000..175041d --- /dev/null +++ b/ui/auth/src/components/Profile.tsx @@ -0,0 +1,286 @@ +import { createResource, createSignal, Switch, Match } from "solid-js"; +import { TbUser, TbLogout, TbTrash } from "solid-icons/tb"; +import { Client, type User } from "trailbase"; + +import { + HOST, + RECORD_API, + OUTLINE_BUTTON_STYLE, + ICON_STYLE, + DESTRUCTIVE_ICON_STYLE, +} from "@/lib/constants"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { ErrorBoundary } from "@/components/ErrorBoundary"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +// import { +// TextField, +// TextFieldLabel, +// TextFieldInput, +// } from "@/components/ui/text-field"; + +function DeleteAccountButton(props: { client: Client }) { + const [open, setOpen] = createSignal(false); + + return ( + + +
+ +
+
+ + + + Delete Account + + Are you sure you want to proceed? The deletion is destructive and cannot + be reverted. + + + + + + +
+ ); +} + +// function ChangeEmailButton(props: { oldEmail: string; client: Client }) { +// const [open, setOpen] = createSignal(false); +// const [email, setEmail] = createSignal(props.oldEmail); +// +// return ( +// +// +// +// +// +// +// +// Change E-mail +// +// +// +// New E-mail +// +// { +// const v = (e.currentTarget as HTMLInputElement).value; +// setEmail(v); +// }} +// /> +// +// +// +// +// +// +// +// +// +// ); +// } + +function Avatar(props: { avatarUrl?: () => string | undefined }) { + const url = () => props.avatarUrl?.(); + + const AvatarImage = () => { + return ( + }> + + user avatar + + + ); + }; + + const profilePageUrl = `${window.location.origin}/_/auth/profile`; + const actionUrl = `${RECORD_API}/_user_avatar?redirect_to=${profilePageUrl}`; + + const openFileDialog = () => { + try { + const element = document.getElementById("file-input") as HTMLInputElement; + element.click(); + } catch (err) { + console.debug(err); + } + }; + + return ( +
+ {/* NOTE: user().id is a UUID rather than a b64 string. + + */} + { + const v = (e.currentTarget as HTMLInputElement).value; + if (v) { + const el = document.getElementById( + "avatar-form", + ) as HTMLFormElement; + if (el) { + el.submit(); + } + } + }} + /> + + + + {/* + + */} +
+ ); +} + +function ProfileTable(props: { + user: User; + client: Client; + avatarUrl?: () => string | undefined; +}) { + const user = () => props.user; + + return ( + +
+

User Profile

+ +
+ + + + + +
+
+ +
+ + +
+ {user().email} + +
Id: {user().id}
+
+
+ + + + {import.meta.env.DEV && ( +
+ +
+ )} +
+ ); +} + +export function Profile() { + // FIXME: This is ugly, that state management should be simpler. One option + // might be to return synchronously from tryFromCookies and call onAuthChange + // async later. + const [user, setUser] = createSignal(); + const [client] = createResource(async () => { + return Client.tryFromCookies(HOST, { + onAuthChange: (_client, user) => setUser(user), + }); + }); + + const [avatarUrl] = createResource( + client, + async (c: Client) => await c.avatarUrl(), + ); + + return ( + + Loading...}> + + {`${client.error}`} + + + + + + + + + + + Not logged in. + + + ); +} diff --git a/ui/auth/src/components/ui/button.tsx b/ui/auth/src/components/ui/button.tsx new file mode 100644 index 0000000..6282259 --- /dev/null +++ b/ui/auth/src/components/ui/button.tsx @@ -0,0 +1,52 @@ +import { type JSX, splitProps, type ValidComponent } from "solid-js" + +import * as ButtonPrimitive from "@kobalte/core/button" +import { type PolymorphicProps } from "@kobalte/core/polymorphic" +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: "border border-input hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline" + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "size-10" + } + }, + defaultVariants: { + variant: "default", + size: "default" + } + } +) + +type ButtonProps = ButtonPrimitive.ButtonRootProps & + VariantProps & { class?: string | undefined; children?: JSX.Element } + +const Button = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as ButtonProps, ["variant", "size", "class"]) + return ( + + ) +} + +export type { ButtonProps } +export { Button, buttonVariants } diff --git a/ui/auth/src/components/ui/card.tsx b/ui/auth/src/components/ui/card.tsx new file mode 100644 index 0000000..6cfc008 --- /dev/null +++ b/ui/auth/src/components/ui/card.tsx @@ -0,0 +1,43 @@ +import type { Component, ComponentProps } from "solid-js" +import { splitProps } from "solid-js" + +import { cn } from "@/lib/utils" + +const Card: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return ( +
+ ) +} + +const CardHeader: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return
+} + +const CardTitle: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return ( +

+ ) +} + +const CardDescription: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return

+} + +const CardContent: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return

+} + +const CardFooter: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return
+} + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/ui/auth/src/components/ui/dialog.tsx b/ui/auth/src/components/ui/dialog.tsx new file mode 100644 index 0000000..18373d9 --- /dev/null +++ b/ui/auth/src/components/ui/dialog.tsx @@ -0,0 +1,141 @@ +import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js" +import { splitProps } from "solid-js" + +import * as DialogPrimitive from "@kobalte/core/dialog" +import type { PolymorphicProps } from "@kobalte/core/polymorphic" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal: Component = (props) => { + const [, rest] = splitProps(props, ["children"]) + return ( + +
+ {props.children} +
+
+ ) +} + +type DialogOverlayProps = + DialogPrimitive.DialogOverlayProps & { class?: string | undefined } + +const DialogOverlay = ( + props: PolymorphicProps> +) => { + const [, rest] = splitProps(props as DialogOverlayProps, ["class"]) + return ( + + ) +} + +type DialogContentProps = + DialogPrimitive.DialogContentProps & { + class?: string | undefined + children?: JSX.Element + } + +const DialogContent = ( + props: PolymorphicProps> +) => { + const [, rest] = splitProps(props as DialogContentProps, ["class", "children"]) + return ( + + + + {props.children} + + + + + + Close + + + + ) +} + +const DialogHeader: Component> = (props) => { + const [, rest] = splitProps(props, ["class"]) + return ( +
+ ) +} + +const DialogFooter: Component> = (props) => { + const [, rest] = splitProps(props, ["class"]) + return ( +
+ ) +} + +type DialogTitleProps = DialogPrimitive.DialogTitleProps & { + class?: string | undefined +} + +const DialogTitle = ( + props: PolymorphicProps> +) => { + const [, rest] = splitProps(props as DialogTitleProps, ["class"]) + return ( + + ) +} + +type DialogDescriptionProps = + DialogPrimitive.DialogDescriptionProps & { + class?: string | undefined + } + +const DialogDescription = ( + props: PolymorphicProps> +) => { + const [, rest] = splitProps(props as DialogDescriptionProps, ["class"]) + return ( + + ) +} + +export { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription +} diff --git a/ui/auth/src/components/ui/label.tsx b/ui/auth/src/components/ui/label.tsx new file mode 100644 index 0000000..afba022 --- /dev/null +++ b/ui/auth/src/components/ui/label.tsx @@ -0,0 +1,19 @@ +import type { Component, ComponentProps } from "solid-js" +import { splitProps } from "solid-js" + +import { cn } from "@/lib/utils" + +const Label: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return ( +