diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..8410b4c --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json + +# Inherit everything configured in the CodeRabbit organisation UI. Without this, +# a repository-level config silently replaces those settings with schema defaults +# rather than adding to them (`inheritance` defaults to false). +inheritance: true + +reviews: + auto_review: + # Active development lands on `dev`, not the default branch, so without `dev` + # listed here CodeRabbit skips every PR with "auto reviews are disabled on + # base/target branches other than the default branch" and only reviews the + # occasional `dev` -> `main` release PR. + base_branches: + - ^dev$ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a46096..6798d61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -36,7 +36,7 @@ jobs: rustup component add rustfmt clippy - name: Cache Rust dependencies - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ @@ -77,7 +77,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -88,7 +88,7 @@ jobs: rustup component add llvm-tools-preview - name: Cache Rust dependencies - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ @@ -123,7 +123,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -133,7 +133,7 @@ jobs: echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Cache mise tools - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local/share/mise key: mise-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml') }} @@ -155,7 +155,7 @@ jobs: mix local.rebar --force - name: Cache Mix dependencies - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | deps @@ -198,7 +198,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -208,7 +208,7 @@ jobs: echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Cache mise tools - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local/share/mise key: mise-compat-${{ runner.os }}-${{ runner.arch }}-erlang-${{ matrix.erlang }}-elixir-${{ matrix.elixir_otp }}-${{ hashFiles('mise.toml') }} @@ -231,7 +231,7 @@ jobs: mix local.rebar --force - name: Cache Mix dependencies - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | deps @@ -256,7 +256,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -266,7 +266,7 @@ jobs: echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Cache mise tools - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local/share/mise key: mise-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml') }} @@ -288,7 +288,7 @@ jobs: mix local.rebar --force - name: Cache Mix dependencies - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | deps @@ -298,7 +298,7 @@ jobs: ${{ runner.os }}-integration-mix- - name: Cache Rust dependencies - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ @@ -363,7 +363,7 @@ jobs: - name: Checkout code if: steps.check-secrets.outputs.skip != 'true' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -375,7 +375,7 @@ jobs: - name: Cache mise tools if: steps.check-secrets.outputs.skip != 'true' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local/share/mise key: mise-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml') }} @@ -401,7 +401,7 @@ jobs: - name: Cache Mix dependencies if: steps.check-secrets.outputs.skip != 'true' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | deps @@ -412,7 +412,7 @@ jobs: - name: Cache Rust dependencies if: steps.check-secrets.outputs.skip != 'true' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ea8dbc..4b22444 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,7 +48,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 5c6a6e6..450b38a 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -15,9 +15,9 @@ jobs: security-events: write steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Run zizmor 🌈 - uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3 + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 diff --git a/.gitignore b/.gitignore index 0c4d98e..7346f7a 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ TEST_COVERAGE_ISSUES_CREATED.md # Dolt database files (added by bd init) .dolt/ *.db + +# Local exploration/testing artifacts (git-ignored). +/.artifacts/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c13793..bd21c6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.9.1] - 2026-05-07 +## [Unreleased] + +### Fixed + +- **Embedded Replica Initial Sync** - `connect/1` now performs an initial sync before serving any reads when an embedded replica is opened with `sync: true`. LibSQL pushes local writes to the primary on its own, but it never pulls remote changes unless `sync/1` is called, so a freshly opened replica read from an empty or stale local file - contradicting the README's claim that initial sync happens when you first connect. (Reported in [#118](https://github.com/ocean/ecto_libsql/issues/118)) + +### Changed + +- **Replica Sync Documentation** - Corrected the README's description of automatic sync. Remote changes made *after* you connect are not pulled in the background; `sync/1` is still required to observe them. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index af9172b..b0a0a9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -317,6 +317,27 @@ cd native/ecto_libsql && cargo test -- --nocapture # Rust output with stdout for i in {1..10}; do mix test test/file.exs:42; done # Flush out race conditions ``` +### ⚠️ `mix test` does NOT exercise your Rust changes + +The NIF is loaded via `RustlerPrecompiled`, so `mix test` uses the **downloaded** artefact +for the current version and silently ignores edits under `native/ecto_libsql/src`. Rust +changes can appear fully green while never having been loaded. To test them for real: + +```bash +export ECTO_LIBSQL_BUILD=1 # force a source build +MIX_ENV=test mix compile --force # --force required; plain `mix compile` no-ops +ERL_FLAGS="+sssdio 8192" mix test # debug builds need a larger dirty-IO stack +``` + +- `--force` is required: without a changed Elixir file Mix skips the crate build entirely. +- `_build/dev` and `_build/test` hold separate NIFs - rebuild for the env you are using. +- `ERL_FLAGS="+sssdio 8192"` avoids a `SIGBUS` (exit 138) VM abort. `native.ex` selects + `mode: :debug` for dev/test and debug-built libSQL overflows the default dirty-IO + scheduler stack, usually on the first remote connection, so it looks unrelated. + +Check for `Compiling crate ecto_libsql` in the output; `Copying NIF from cache` means you +are still on the precompiled artefact. See [TESTING.md](TESTING.md) for detail. + ### Test Variable Naming Conventions Use consistent variable names by scope: diff --git a/Cargo.lock b/Cargo.lock index 52b137e..98085b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,14 +22,14 @@ dependencies = [ "cfg-if", "once_cell", "version_check", - "zerocopy 0.8.52", + "zerocopy 0.8.57", ] [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -42,18 +42,18 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "async-stream" @@ -74,18 +74,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -160,7 +160,7 @@ version = "0.66.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2b84e06fc203107bfbad243f4aba2af864eb7db3b1cf46ea0a023b0b433d2a7" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cexpr", "clang-sys", "lazy_static", @@ -173,7 +173,7 @@ dependencies = [ "regex", "rustc-hash", "shlex 1.3.0", - "syn", + "syn 2.0.119", "which", ] @@ -200,9 +200,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-padding" @@ -227,9 +227,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -245,9 +245,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.65" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "shlex 2.0.1", @@ -293,9 +293,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", @@ -338,9 +338,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] @@ -369,9 +369,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "equivalent" @@ -386,7 +386,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -409,15 +409,15 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "fnv" @@ -427,9 +427,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -442,9 +442,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -452,15 +452,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -469,38 +469,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -559,9 +559,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "h2" @@ -575,7 +575,7 @@ dependencies = [ "futures-sink", "futures-util", "http", - "indexmap 2.14.0", + "indexmap 2.14.2", "slab", "tokio", "tokio-util", @@ -600,9 +600,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -758,12 +758,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", ] [[package]] @@ -802,13 +802,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -826,9 +825,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -861,7 +860,7 @@ dependencies = [ "async-trait", "base64", "bincode", - "bitflags 2.13.0", + "bitflags 2.13.2", "bytes", "chrono", "crc32fast", @@ -920,7 +919,7 @@ version = "0.9.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b646f94fc1d266e481c38a2d44d6d9d1be3ad04b56b90457acfb310dc450030e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "fallible-iterator 0.2.0", "fallible-streaming-iterator", "hashlink", @@ -934,10 +933,10 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15a90128c708356af8f7d767c9ac2946692c9112b4f74f07b99a01a60680e413" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cc", "fallible-iterator 0.3.0", - "indexmap 2.14.0", + "indexmap 2.14.2", "log", "memchr", "phf", @@ -1009,9 +1008,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "matchit" @@ -1021,9 +1020,9 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -1039,9 +1038,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", @@ -1140,7 +1139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -1170,7 +1169,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1185,7 +1184,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.52", + "zerocopy 0.8.57", ] [[package]] @@ -1195,14 +1194,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.119", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -1215,9 +1214,9 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.13.0", + "bitflags 2.13.2", "num-traits", - "rand 0.9.4", + "rand 0.9.5", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -1246,7 +1245,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1257,9 +1256,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1278,9 +1277,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -1289,9 +1288,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -1350,14 +1349,14 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -1367,9 +1366,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1414,7 +1413,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.4.15", @@ -1427,11 +1426,11 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1457,7 +1456,7 @@ dependencies = [ "inventory", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1498,9 +1497,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] @@ -1518,9 +1517,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" @@ -1555,7 +1554,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "core-foundation", "core-foundation-sys", "libc", @@ -1574,9 +1573,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1584,29 +1583,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1651,9 +1650,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "socket2" @@ -1667,9 +1666,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -1683,9 +1682,20 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -1708,7 +1718,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1728,14 +1738,14 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -1743,7 +1753,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.4", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] @@ -1760,13 +1770,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -1782,9 +1792,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -1793,13 +1803,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -1862,7 +1873,7 @@ dependencies = [ "indexmap 1.9.3", "pin-project", "pin-project-lite", - "rand 0.8.6", + "rand 0.8.8", "slab", "tokio", "tokio-util", @@ -1877,7 +1888,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "bytes", "futures-core", "futures-util", @@ -1923,7 +1934,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1976,9 +1987,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "uuid" -version = "1.23.1" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -2027,9 +2038,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -2040,9 +2051,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2050,22 +2061,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] @@ -2076,14 +2087,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.8", + "webpki-roots 1.0.9", ] [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -2121,7 +2132,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2132,7 +2143,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2268,11 +2279,11 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ - "zerocopy-derive 0.8.52", + "zerocopy-derive 0.8.57", ] [[package]] @@ -2283,18 +2294,18 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2305,6 +2316,6 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/README.md b/README.md index 7b1e7a0..bf26b33 100644 --- a/README.md +++ b/README.md @@ -363,9 +363,11 @@ EctoLibSql.handle_execute("SELECT * FROM users", [], [], state) ``` **How automatic sync works:** -- Initial sync happens when you first connect -- Changes are synced automatically in the background -- You don't need to call `sync/1` in most applications +- An initial sync runs when you first connect, so the replica starts from the current + state of the primary rather than an empty or stale local file +- Your own writes are pushed to the primary automatically as you make them +- Remote changes made *after* you connect are **not** pulled in the background - call + `sync/1` when you need to observe them #### Manual Sync Control diff --git a/TESTING.md b/TESTING.md index 4496e0f..2a045c5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -362,6 +362,47 @@ test tests::registry_tests::test_uuid_generation ... ok test result: ok. 19 passed; 0 failed; 0 ignored ``` +### ⚠️ Exercising Rust changes from the Elixir suite + +`cargo test` covers the Rust in isolation, but `mix test` does **not** pick up your Rust +changes by default. `EctoLibSql.Native` is built on `RustlerPrecompiled`, so the NIF is +downloaded from the release artefacts for the current version, and an edit to +`native/ecto_libsql/src` is silently ignored. A change can look completely green locally +while never having been loaded. + +To run the Elixir suite against locally built Rust: + +```bash +export ECTO_LIBSQL_BUILD=1 # force a source build instead of the precompiled NIF +MIX_ENV=test mix compile --force # --force is required; plain `mix compile` no-ops +ERL_FLAGS="+sssdio 8192" mix test +``` + +Three things to watch: + +- **`--force` is not optional.** Setting `ECTO_LIBSQL_BUILD` alone is not enough, because + Mix sees no changed Elixir files and skips the compile that triggers the crate build. +- **Each `MIX_ENV` has its own NIF.** `_build/dev` and `_build/test` hold separate copies, + so building for one leaves the other stale. Rebuild for the env you are about to use. +- **`ERL_FLAGS="+sssdio 8192"` is needed for local builds.** `lib/ecto_libsql/native.ex` + selects `mode: :debug` for `:dev` and `:test`, and debug-built libSQL overflows the + default dirty-IO scheduler stack, aborting the VM with `SIGBUS` (exit 138) - typically + on the first remote connection, which makes it look like an unrelated failure. These + NIFs run with `schedule = "DirtyIo"`, so `+sssdio` is the stack size that matters. The + precompiled artefacts are release builds and do not need this. + +Confirm you are actually running your own build - this line means the crate compiled: + +``` +Compiling crate ecto_libsql in debug mode (native/ecto_libsql) +``` + +whereas this line means you are still on the precompiled NIF: + +``` +[debug] Copying NIF from cache and extracting to .../libecto_libsql-vX.Y.Z-....so +``` + ### Elixir Tests ```bash diff --git a/lib/ecto/adapters/libsql/connection.ex b/lib/ecto/adapters/libsql/connection.ex index 72da826..ab54958 100644 --- a/lib/ecto/adapters/libsql/connection.ex +++ b/lib/ecto/adapters/libsql/connection.ex @@ -1217,35 +1217,61 @@ defmodule Ecto.Adapters.LibSql.Connection do [expr(arg, sources, query) | " IS NULL"] end + # Ecto plans a negative literal as unary minus, so `ago(14, "day")` arrives as + # `{:datetime_add, _, [_, {:-, _, [14]}, "day"]}` once the query is cached. + defp expr({:-, _, [arg]}, sources, query) do + [?-, ?(, expr(arg, sources, query), ?)] + end + defp expr({:not, _, [arg]}, sources, query) do ["NOT (", expr(arg, sources, query), ?)] end # Comparison operations defp expr({:==, _, [left, right]}, sources, query) do + reject_unsupported_datetime_precision!(left, right, sources) [expr(left, sources, query), " = ", expr(right, sources, query)] end defp expr({:!=, _, [left, right]}, sources, query) do + reject_unsupported_datetime_precision!(left, right, sources) [expr(left, sources, query), " != ", expr(right, sources, query)] end defp expr({:<, _, [left, right]}, sources, query) do + reject_unsupported_datetime_precision!(left, right, sources) [expr(left, sources, query), " < ", expr(right, sources, query)] end defp expr({:>, _, [left, right]}, sources, query) do + reject_unsupported_datetime_precision!(left, right, sources) [expr(left, sources, query), " > ", expr(right, sources, query)] end defp expr({:<=, _, [left, right]}, sources, query) do + reject_unsupported_datetime_precision!(left, right, sources) [expr(left, sources, query), " <= ", expr(right, sources, query)] end defp expr({:>=, _, [left, right]}, sources, query) do + reject_unsupported_datetime_precision!(left, right, sources) [expr(left, sources, query), " >= ", expr(right, sources, query)] end + # Parenthesised so precedence survives nesting: `(s.count + 2) * 2` has to keep + # its grouping rather than being reassociated. + defp expr({op, _, [left, right]}, sources, query) when op in [:+, :-, :*, :/] do + [ + ?(, + expr(left, sources, query), + ?\s, + Atom.to_string(op), + ?\s, + expr(right, sources, query), + ?) + ] + end + # Boolean logic defp expr({:and, _, [left, right]}, sources, query) do [?(, expr(left, sources, query), " AND ", expr(right, sources, query), ?)] @@ -1384,11 +1410,108 @@ defmodule Ecto.Adapters.LibSql.Connection do defp expr(false, _sources, _query), do: "0" defp expr(nil, _sources, _query), do: "NULL" - # Default fallback for unsupported expressions + # `ago/2` and `from_now/2` lower to these. + # + # `datetime_encode/1` stores timestamps via `DateTime.to_iso8601/1`, so the cutoff + # is rendered the same way to compare correctly as text. The two formats agree to + # the second, so a value carrying sub-second precision can land on the wrong side + # of a bound when it ties the cutoff to that exact second - see issue #128. + defp expr({:datetime_add, _, [datetime, count, interval]}, sources, query) do + [ + "strftime('%Y-%m-%dT%H:%M:%SZ', ", + expr(datetime, sources, query), + ", ", + interval_modifier(count, interval, sources, query), + ?) + ] + end + + defp expr({:date_add, _, [date, count, interval]}, sources, query) do + [ + "date(", + expr(date, sources, query), + ", ", + interval_modifier(count, interval, sources, query), + ?) + ] + end + + # Fallback for expressions no clause above handles. It emits a placeholder that + # nothing binds to, so an unhandled expression produces a silently wrong result + # rather than an error: `where: s.count + 1 > 5` would become `WHERE (? > 5)` and + # match nothing. New expression types therefore need their own clause rather than + # being left to reach this one. defp expr(_expr, _sources, _query) do "?" end + # Builds a SQLite time modifier such as '-14 days'. The count stays an expression + # rather than being interpolated, so an interpolated `ago(^n, "day")` still binds + # as a parameter. SQLite has no week/millisecond/microsecond modifiers, so those + # are converted to ones it does have - it accepts a fractional count. + defp interval_modifier(count, interval, sources, query) do + value = expr(count, sources, query) + + {amount, unit} = + case interval do + "week" -> {[?(, value, ") * 7"], "days"} + "millisecond" -> {[?(, value, ") / 1000.0"], "seconds"} + "microsecond" -> {[?(, value, ") / 1000000.0"], "seconds"} + unit when unit in ~w(year month day hour minute second) -> {value, unit <> "s"} + other -> raise ArgumentError, "unsupported interval for libSQL: #{inspect(other)}" + end + + ["CAST(", amount, " AS TEXT) || ' ", unit, ?'] + end + + # SQLite's date and time functions work to millisecond precision - they round a + # microsecond input at parse time, so `strftime('%f', '...01.000900Z')` is + # `01.001`. A cutoff produced by datetime_add/date_add therefore cannot represent + # the microseconds a *_usec column stores, and a row landing inside the cutoff + # second compares on the wrong side of it. No SQLite date function avoids this, + # julianday/1 included, so the comparison is rejected rather than answered + # incorrectly. Second-precision columns are exact and unaffected. See issue #128. + @usec_datetime_types [:utc_datetime_usec, :naive_datetime_usec] + + defp reject_unsupported_datetime_precision!(left, right, sources) do + if datetime_shift?(left) or datetime_shift?(right) do + Enum.each([left, right], fn side -> + type = field_type(side, sources) + + if type in @usec_datetime_types do + raise ArgumentError, """ + cannot compare a #{inspect(type)} column against ago/2 or from_now/2. + + SQLite's date and time functions parse to millisecond precision, so the \ + cutoff cannot carry the microseconds this column stores and rows inside \ + the cutoff second would be filtered incorrectly. + + Use :utc_datetime or :naive_datetime for columns compared this way, or \ + compute the cutoff in Elixir and compare against it directly. + """ + end + end) + end + end + + defp datetime_shift?({op, _, _}) when op in [:datetime_add, :date_add], do: true + defp datetime_shift?(_other), do: false + + defp field_type({{:., _, [{:&, _, [idx]}, field]}, _, []}, sources) + when is_atom(field) and is_tuple(sources) and tuple_size(sources) > idx do + case elem(sources, idx) do + {_source, _name, schema} when is_atom(schema) and not is_nil(schema) -> + if Code.ensure_loaded?(schema) and function_exported?(schema, :__schema__, 2) do + schema.__schema__(:type, field) + end + + _other -> + nil + end + end + + defp field_type(_expr, _sources), do: nil + defp combination(%{combinations: []}, _as_prefix), do: [] defp combination(%{combinations: combinations}, as_prefix) do diff --git a/lib/ecto_libsql.ex b/lib/ecto_libsql.ex index 94f49fc..733d5e2 100644 --- a/lib/ecto_libsql.ex +++ b/lib/ecto_libsql.ex @@ -302,6 +302,12 @@ defmodule EctoLibSql do |> Enum.map(fn [_full, name] -> name end) end + # DBConnection drives nested transactions with `mode: :savepoint`. SQLite allows + # duplicate savepoint names and resolves RELEASE/ROLLBACK TO against the most + # recent match, so one fixed name nests correctly - the same approach postgrex + # takes with "postgrex_savepoint". + @savepoint "ecto_libsql_savepoint" + @impl true @doc """ Begins a new database transaction. @@ -309,10 +315,29 @@ defmodule EctoLibSql do The transaction behaviour (deferred/immediate/exclusive) can be controlled via options passed to the Native module. """ - def handle_begin(_opts, state) do - case EctoLibSql.Native.begin(state) do - {:ok, new_state} -> {:ok, :begin, new_state} - {:error, reason} -> {:error, reason, state} + def handle_begin(opts, state) do + # `:mode` is overloaded here: DBConnection sets `:transaction` or `:savepoint`, + # while this adapter's own API uses it for the libSQL transaction mode + # (`:deferred`, `:immediate`, `:exclusive`, `:read_only`). Only `:savepoint` is + # intercepted; everything else, including an absent mode, begins as before so + # the transaction modes keep working. + case {Keyword.get(opts, :mode), state.trx_id} do + {:savepoint, trx_id} when is_binary(trx_id) -> + case EctoLibSql.Native.create_savepoint(state, @savepoint) do + :ok -> {:ok, %EctoLibSql.Result{}, state} + {:error, reason} -> {:error, savepoint_error(reason), state} + end + + {:savepoint, nil} -> + # A savepoint outside a transaction is not something SQLite can honour. + # Report status and let DBConnection decide rather than emitting bad SQL. + {:idle, state} + + _other -> + case EctoLibSql.Native.begin(state) do + {:ok, new_state} -> {:ok, :begin, new_state} + {:error, reason} -> {:error, reason, state} + end end end @@ -328,15 +353,30 @@ defmodule EctoLibSql do The state must contain a valid transaction ID. For embedded replicas with auto-sync enabled, this will also trigger a sync to the remote database. """ - def handle_commit(_opts, state) do - case EctoLibSql.Native.commit( - %EctoLibSql.State{conn_id: conn_id, trx_id: _trx_id, mode: mode} = state - ) do - {:ok, _} -> - {:ok, %EctoLibSql.Result{}, %EctoLibSql.State{conn_id: conn_id, mode: mode}} - - {:error, reason} -> - {:disconnect, reason, state} + def handle_commit(opts, state) do + if Keyword.get(opts, :mode, :transaction) == :savepoint do + # Releasing a savepoint leaves the enclosing transaction open, so the + # state - trx_id included - carries through untouched. + case EctoLibSql.Native.release_savepoint_by_name(state, @savepoint) do + :ok -> + {:ok, %EctoLibSql.Result{}, state} + + # A failed RELEASE leaves the savepoint stack indeterminate with the + # enclosing transaction still open, so the connection cannot safely be + # reused. Disconnect, as the non-savepoint branch below already does. + {:error, reason} -> + {:disconnect, savepoint_error(reason), state} + end + else + case EctoLibSql.Native.commit( + %EctoLibSql.State{conn_id: conn_id, trx_id: _trx_id, mode: mode} = state + ) do + {:ok, _} -> + {:ok, %EctoLibSql.Result{}, %EctoLibSql.State{conn_id: conn_id, mode: mode}} + + {:error, reason} -> + {:disconnect, reason, state} + end end end @@ -347,16 +387,36 @@ defmodule EctoLibSql do Discards all changes made within the transaction and returns the connection to autocommit mode. """ - def handle_rollback(_opts, %EctoLibSql.State{conn_id: conn_id, trx_id: _trx_id} = state) do - case EctoLibSql.Native.rollback(state) do - {:ok, _} -> - {:ok, %EctoLibSql.Result{}, %EctoLibSql.State{conn_id: conn_id, trx_id: nil}} + def handle_rollback(opts, %EctoLibSql.State{conn_id: conn_id, trx_id: _trx_id} = state) do + if Keyword.get(opts, :mode, :transaction) == :savepoint do + # ROLLBACK TO leaves the savepoint itself active (SQLite keeps it so it can + # be rolled back to again), so it has to be released explicitly afterwards + # or the next savepoint of the same name nests inside this one. + with :ok <- EctoLibSql.Native.rollback_to_savepoint_by_name(state, @savepoint), + :ok <- EctoLibSql.Native.release_savepoint_by_name(state, @savepoint) do + {:ok, %EctoLibSql.Result{}, state} + else + # Either step failing leaves the savepoint stack indeterminate: a failed + # ROLLBACK TO says nothing about what was undone, and a failed RELEASE + # leaves the savepoint live so the next one of the same name nests inside + # it. Neither is safe to hand back to the pool. + {:error, reason} -> {:disconnect, savepoint_error(reason), state} + end + else + case EctoLibSql.Native.rollback(state) do + {:ok, _} -> + {:ok, %EctoLibSql.Result{}, %EctoLibSql.State{conn_id: conn_id, trx_id: nil}} - {:error, reason} -> - {:disconnect, reason, state} + {:error, reason} -> + {:disconnect, reason, state} + end end end + defp savepoint_error(reason) when is_binary(reason), do: %RuntimeError{message: reason} + defp savepoint_error(%{__exception__: true} = reason), do: reason + defp savepoint_error(reason), do: %RuntimeError{message: inspect(reason)} + @impl true @doc """ Closes the query. Currently a no-op as queries are stateless. diff --git a/mix.lock b/mix.lock index 2325982..fbd3437 100644 --- a/mix.lock +++ b/mix.lock @@ -2,24 +2,24 @@ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, - "db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"}, + "db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"}, "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, - "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.45", "cba8369ab2a1342e419bc2760eec731b17be828941dcf494045d44766227e1d5", [:mix], [], "hexpm", "d3ec045bf122965db20c0bdb420e19ee1415843135327124918473feb4b328e8"}, - "ecto": {:hex, :ecto, "3.14.0", "2fa64521eebfcb2670d907a86e4ad947290e9933706bb315e6fb5c21b172cb26", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "130d69ffb4285f9ce4792b65dfbb994fd13ea4cbc3cbea2524b199aa3de84af3"}, + "dialyxir": {:hex, :dialyxir, "1.4.8", "7ef671a8aff9948b091d8c30f09467fbb16e77305cda451bce48109a0f5e021c", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "cbd5a851571e5dfeb32aaf2e840bfa98b7864cb3071bf2ef5d95d1276b12e072"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.46", "67607a0532e810c6f630a515c548d0b24949643f168cc556303bee4cf96105c7", [:mix], [], "hexpm", "9c44636e8a1c68c62f526b2dcd85d941dbbcee7ab82cf64ba06ce28bef8e89f5"}, + "ecto": {:hex, :ecto, "3.14.2", "99db28a864293a789c970651de711e3cae184291e0e7ea1166c54055ac41c1f3", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25d60b8c816a07d19d85b80bdf60978bd8b102209dda198d768cd7c6745339a6"}, "ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"}, "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, - "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, + "ex_doc": {:hex, :ex_doc, "0.40.4", "66f2e42bf588594d5a8aab31cad87f2ddad09d0da1b1a2f379340ec2c2e497cb", [:mix], [{:earmark_parser, "~> 1.4.46", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "6222b9e423d76584ee34df2c82a5ed72c2d53dc153f7f483ad28b378694186cc"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "rustler": {:hex, :rustler, "0.38.0", "7a8906998ff0d28e3021c0a73264abcda719bda344b2e58307c6805b0f87c9b4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "704c03c1bf66be12b031c5a389347b91c81c5cb819a24b068b0de36fe4a5652a"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, - "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, - "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, + "sobelow": {:hex, :sobelow, "0.15.0", "b067d7f8522a9d758fa89cb2bfcbab7ad72c45a0993cb958c989c6fd956fdd56", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "24a800e2d7fa8c3bd21561b6ad8ad4745ed726a09fd606598981d9048708da98"}, + "stream_data": {:hex, :stream_data, "1.4.0", "026f929db613aabea6208012ae9b8970d3fd5f88b3bdf26831bc536f98c42036", [:mix], [], "hexpm", "2b0ee3a340dcce1c8cf6302a763ee757d1e01c54d6e16d9069062509d68b1dc9"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, } diff --git a/native/ecto_libsql/Cargo.toml b/native/ecto_libsql/Cargo.toml index 734e6a7..a2c07d0 100644 --- a/native/ecto_libsql/Cargo.toml +++ b/native/ecto_libsql/Cargo.toml @@ -17,9 +17,9 @@ crate-type = ["cdylib", "rlib"] [dependencies] libsql = { version = "0.9.29", features = ["encryption", "replication"] } rustler = { version = "0.38", default-features = false, features = ["derive", "nif_version_2_15"] } -tokio = "1.45.1" -uuid = "1.17.0" -bytes = "1.11" +tokio = "1.53.1" +uuid = "1.26.1" +bytes = "1.12.1" [dev-dependencies] proptest = "1.5" diff --git a/native/ecto_libsql/deny.toml b/native/ecto_libsql/deny.toml index 23d34a1..b4d62ca 100644 --- a/native/ecto_libsql/deny.toml +++ b/native/ecto_libsql/deny.toml @@ -88,6 +88,10 @@ ignore = [ # rustls-webpki 0.102.x reachable panic in CRL parsing via empty BIT STRING in IssuingDistributionPoint # Fix available in >=0.103.13 but libsql pins rustls 0.22.x which requires 0.102.x - waiting for upstream update { id = "RUSTSEC-2026-0104", reason = "Transitive dependency of libsql via rustls 0.22, waiting for upstream update" }, + # h2 0.3.x queues empty DATA frames without limit (low severity, affects unbounded peers) + # Fix only exists in h2 >=0.4.16, but hyper 0.14 (via tonic 0.11) requires h2 0.3.x and + # 0.3.27 is the final 0.3.x release, so `cargo update -p h2` cannot resolve this + { id = "RUSTSEC-2026-0258", reason = "Transitive dependency of libsql via hyper 0.14/tonic 0.11, waiting for upstream update" }, ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. diff --git a/native/ecto_libsql/src/connection.rs b/native/ecto_libsql/src/connection.rs index 4c0110b..b76e36a 100644 --- a/native/ecto_libsql/src/connection.rs +++ b/native/ecto_libsql/src/connection.rs @@ -62,6 +62,10 @@ pub fn connect(opts: Term, mode: Term) -> NifResult { let remote_encryption_key = map .get("remote_encryption_key") .and_then(|t| t.decode::().ok()); + let sync_enabled = map + .get("sync") + .and_then(|t| t.decode::().ok()) + .unwrap_or(false); // Wrap the entire connection process with a timeout using the global runtime. TOKIO_RUNTIME.block_on(async { @@ -136,6 +140,16 @@ pub fn connect(opts: Term, mode: Term) -> NifResult { } .map_err(|e| rustler::Error::Term(Box::new(format!("Failed to build DB: {e}"))))?; + // Pull the current state of the primary before serving any reads. LibSQL pushes + // writes to the primary on its own, but it never pulls remote changes unless + // sync() is called, so without this a freshly opened replica reads from an empty + // or stale local file. + if mode_enum == Mode::RemoteReplica && sync_enabled { + db.sync().await.map_err(|e| { + rustler::Error::Term(Box::new(format!("Failed initial sync: {e}"))) + })?; + } + let conn = db .connect() .map_err(|e| rustler::Error::Term(Box::new(format!("Failed to connect: {e}"))))?; diff --git a/test/ecto_arithmetic_test.exs b/test/ecto_arithmetic_test.exs new file mode 100644 index 0000000..4bbd510 --- /dev/null +++ b/test/ecto_arithmetic_test.exs @@ -0,0 +1,97 @@ +defmodule EctoLibSql.EctoArithmeticTest do + @moduledoc """ + Coverage for `+`, `-`, `*` and `/` in query expressions: filtering on a computed + value, arithmetic in `select`, nested precedence, interpolated operands and + column-to-column arithmetic. + + Arithmetic reaching `expr/3`'s fallback clause is emitted as a bare "?" that binds + to nothing, which yields a wrong answer rather than an error. These therefore + assert on the values returned, not only on the SQL generated. + """ + use ExUnit.Case, async: false + + import Ecto.Query + + defmodule TestRepo do + use Ecto.Repo, otp_app: :ecto_libsql, adapter: Ecto.Adapters.LibSql + end + + defmodule Item do + use Ecto.Schema + + schema "arithmetic_items" do + field(:name, :string) + field(:count, :integer) + end + end + + setup do + unique_id = :erlang.unique_integer([:positive]) + test_db = "z_ecto_libsql_test-arithmetic-#{unique_id}.db" + + {:ok, pid} = TestRepo.start_link(database: test_db, pool_size: 1, name: TestRepo) + + Ecto.Adapters.SQL.query!(TestRepo, """ + CREATE TABLE IF NOT EXISTS arithmetic_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + count INTEGER + ) + """) + + TestRepo.insert!(%Item{name: "ten", count: 10}) + TestRepo.insert!(%Item{name: "one", count: 1}) + + on_exit(fn -> + if Process.alive?(pid) do + try do + :ok = Supervisor.stop(pid) + catch + :exit, _ -> :ok + end + end + + Process.sleep(50) + for f <- Path.wildcard(test_db <> "*"), do: File.rm(f) + end) + + :ok + end + + defp names(query), do: query |> TestRepo.all() |> Enum.sort() + + test "addition and subtraction filter on the computed value" do + assert names(from(i in Item, where: i.count + 1 > 5, select: i.name)) == ["ten"] + assert names(from(i in Item, where: i.count - 1 > 5, select: i.name)) == ["ten"] + assert names(from(i in Item, where: i.count + 10 > 5, select: i.name)) == ["one", "ten"] + end + + test "multiplication and division filter on the computed value" do + assert names(from(i in Item, where: i.count * 2 > 5, select: i.name)) == ["ten"] + assert names(from(i in Item, where: i.count * 10 > 5, select: i.name)) == ["one", "ten"] + assert names(from(i in Item, where: i.count / 2 > 2, select: i.name)) == ["ten"] + end + + test "arithmetic in a select returns the computed value" do + assert TestRepo.all(from(i in Item, where: i.name == "ten", select: i.count + 1)) == [11] + assert TestRepo.all(from(i in Item, where: i.name == "ten", select: i.count * 3)) == [30] + end + + test "nested arithmetic keeps its precedence" do + # (10 + 2) * 2 = 24, not 10 + (2 * 2) = 14. + assert TestRepo.all(from(i in Item, where: i.name == "ten", select: (i.count + 2) * 2)) == [ + 24 + ] + end + + test "arithmetic against an interpolated value still binds as a parameter" do + n = 5 + assert names(from(i in Item, where: i.count + ^n > 12, select: i.name)) == ["ten"] + end + + test "arithmetic between two columns" do + assert TestRepo.all(from(i in Item, where: i.name == "ten", select: i.count + i.count)) == [ + 20 + ] + end +end diff --git a/test/ecto_datetime_interval_test.exs b/test/ecto_datetime_interval_test.exs new file mode 100644 index 0000000..28222bf --- /dev/null +++ b/test/ecto_datetime_interval_test.exs @@ -0,0 +1,161 @@ +defmodule EctoLibSql.EctoDatetimeIntervalTest do + @moduledoc """ + Coverage for `ago/2` and `from_now/2`, which Ecto lowers to `datetime_add` and + `date_add`: window boundaries, each supported unit, interpolated counts, and + `date_add` against a date column. + + An interval reaching `expr/3`'s fallback clause is dropped rather than rejected, + leaving `column > now()`: Ecto plans the parameters regardless of how many + placeholders the adapter emits, and SQLite ignores an unreferenced one, so nothing + raises. These therefore assert on which rows come back, and that the interval + reaches the SQL at all. + """ + use ExUnit.Case, async: false + + import Ecto.Query + + defmodule TestRepo do + use Ecto.Repo, otp_app: :ecto_libsql, adapter: Ecto.Adapters.LibSql + end + + defmodule Event do + use Ecto.Schema + + schema "interval_events" do + field(:name, :string) + field(:at, :utc_datetime) + field(:on_date, :date) + end + end + + setup do + unique_id = :erlang.unique_integer([:positive]) + test_db = "z_ecto_libsql_test-datetime_interval-#{unique_id}.db" + + {:ok, pid} = TestRepo.start_link(database: test_db, pool_size: 1, name: TestRepo) + + Ecto.Adapters.SQL.query!(TestRepo, """ + CREATE TABLE IF NOT EXISTS interval_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + at TEXT, + on_date TEXT + ) + """) + + on_exit(fn -> + if Process.alive?(pid) do + try do + :ok = Supervisor.stop(pid) + catch + :exit, _ -> :ok + end + end + + # Give SQLite time to release the file handles before removing them. + Process.sleep(50) + for f <- Path.wildcard(test_db <> "*"), do: File.rm(f) + end) + + :ok + end + + defp at!(name, amount, unit) do + at = DateTime.add(DateTime.utc_now(:second), amount, unit) + TestRepo.insert!(%Event{name: name, at: at}) + end + + defp names(query), do: query |> TestRepo.all() |> Enum.sort() + + describe "ago/2" do + test "keeps rows inside the window and drops rows outside it" do + at!("now", 0, :second) + at!("day-13", -13, :day) + at!("day-15", -15, :day) + at!("year-ago", -365, :day) + + assert names(from(e in Event, where: e.at > ago(14, "day"), select: e.name)) == + ["day-13", "now"] + end + + test "a row hours old is still inside a day-scale window" do + at!("hours-ago", -6, :hour) + + # The bug made every window behave as `> now()`, so this vanished entirely. + assert names(from(e in Event, where: e.at > ago(14, "day"), select: e.name)) == + ["hours-ago"] + end + + test "minute and hour units resolve to their own scale" do + at!("min-10", -10, :minute) + at!("min-20", -20, :minute) + + assert names(from(e in Event, where: e.at > ago(15, "minute"), select: e.name)) == + ["min-10"] + + assert names(from(e in Event, where: e.at > ago(1, "hour"), select: e.name)) == + ["min-10", "min-20"] + end + + test "week, millisecond and microsecond are converted to units SQLite has" do + at!("day-3", -3, :day) + at!("day-10", -10, :day) + + assert names(from(e in Event, where: e.at > ago(1, "week"), select: e.name)) == ["day-3"] + + at!("recent", -2, :second) + + assert names(from(e in Event, where: e.at > ago(5000, "millisecond"), select: e.name)) == + ["recent"] + + assert names(from(e in Event, where: e.at > ago(5_000_000, "microsecond"), select: e.name)) == + ["recent"] + end + + test "an interpolated count still binds as a parameter" do + at!("day-3", -3, :day) + at!("day-10", -10, :day) + + days = 7 + query = from(e in Event, where: e.at > ago(^days, "day"), select: e.name) + + assert names(query) == ["day-3"] + + # The count is rendered through expr/3 rather than interpolated into the SQL, + # so an interpolated value stays a bound parameter. + {sql, _params} = Ecto.Adapters.SQL.to_sql(:all, TestRepo, query) + assert sql =~ "strftime" + assert sql =~ "days" + end + + test "the interval reaches the SQL rather than being dropped" do + {sql, _params} = + Ecto.Adapters.SQL.to_sql(:all, TestRepo, from(e in Event, where: e.at > ago(14, "day"))) + + assert sql =~ "strftime" + assert sql =~ "days" + refute sql =~ ~r/"at" > \?\s*\)/ + end + end + + describe "from_now/2" do + test "selects rows ahead of now" do + at!("past", -1, :day) + at!("soon", 60, :second) + at!("later", 3, :day) + + assert names(from(e in Event, where: e.at < from_now(1, "day"), select: e.name)) == + ["past", "soon"] + end + end + + describe "date_add" do + test "applies the interval to a date column" do + today = Date.utc_today() + TestRepo.insert!(%Event{name: "d-3", on_date: Date.add(today, -3)}) + TestRepo.insert!(%Event{name: "d-30", on_date: Date.add(today, -30)}) + + assert names(from(e in Event, where: e.on_date > ago(7, "day"), select: e.name)) == ["d-3"] + end + end +end diff --git a/test/ecto_datetime_precision_test.exs b/test/ecto_datetime_precision_test.exs new file mode 100644 index 0000000..617643a --- /dev/null +++ b/test/ecto_datetime_precision_test.exs @@ -0,0 +1,156 @@ +defmodule EctoLibSql.EctoDatetimePrecisionTest do + @moduledoc """ + Coverage for the precision guard on `ago/2` and `from_now/2`. + + SQLite's date and time functions parse to millisecond precision, so a cutoff built + by `datetime_add`/`date_add` cannot carry the microseconds a `*_usec` column + stores. Rather than filtering such rows incorrectly near the cutoff second, the + adapter rejects the comparison. Second-precision columns are exact and keep + working, and comparisons that do not involve a shifted datetime are untouched. + """ + use ExUnit.Case, async: false + + import Ecto.Query + + defmodule TestRepo do + use Ecto.Repo, otp_app: :ecto_libsql, adapter: Ecto.Adapters.LibSql + end + + defmodule Event do + use Ecto.Schema + + schema "precision_events" do + field(:name, :string) + field(:at, :utc_datetime) + field(:at_naive, :naive_datetime) + field(:at_usec, :utc_datetime_usec) + field(:at_naive_usec, :naive_datetime_usec) + end + end + + setup do + db = "z_ecto_libsql_test-precision-#{:erlang.unique_integer([:positive])}.db" + {:ok, pid} = TestRepo.start_link(database: db, pool_size: 1, name: TestRepo) + + Ecto.Adapters.SQL.query!(TestRepo, """ + CREATE TABLE IF NOT EXISTS precision_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, at TEXT, at_naive TEXT, at_usec TEXT, at_naive_usec TEXT + ) + """) + + on_exit(fn -> + if Process.alive?(pid) do + try do + :ok = Supervisor.stop(pid) + catch + :exit, _ -> :ok + end + end + + Process.sleep(50) + for f <- Path.wildcard(db <> "*"), do: File.rm(f) + end) + + :ok + end + + defp to_sql(query), do: Ecto.Adapters.SQL.to_sql(:all, TestRepo, query) + + describe "microsecond columns are rejected" do + test "utc_datetime_usec compared with ago/2 raises" do + assert_raise ArgumentError, ~r/:utc_datetime_usec.*ago\/2/s, fn -> + to_sql(from(e in Event, where: e.at_usec > ago(14, "day"))) + end + end + + test "naive_datetime_usec compared with ago/2 raises" do + assert_raise ArgumentError, ~r/:naive_datetime_usec/, fn -> + to_sql(from(e in Event, where: e.at_naive_usec > ago(14, "day"))) + end + end + + test "from_now/2 is rejected the same way" do + assert_raise ArgumentError, ~r/:utc_datetime_usec/, fn -> + to_sql(from(e in Event, where: e.at_usec < from_now(1, "day"))) + end + end + + test "the message explains the cause and the way out" do + error = + assert_raise ArgumentError, fn -> + to_sql(from(e in Event, where: e.at_usec > ago(1, "day"))) + end + + assert error.message =~ "millisecond precision" + assert error.message =~ ":utc_datetime" + end + + test "rejection applies on either side of the comparison" do + assert_raise ArgumentError, ~r/:utc_datetime_usec/, fn -> + to_sql(from(e in Event, where: ago(14, "day") < e.at_usec)) + end + end + end + + describe "second-precision columns still work" do + test "utc_datetime compares without raising and filters correctly" do + now = DateTime.utc_now(:second) + TestRepo.insert!(%Event{name: "recent", at: DateTime.add(now, -3, :day)}) + TestRepo.insert!(%Event{name: "old", at: DateTime.add(now, -30, :day)}) + + names = + from(e in Event, where: e.at > ago(14, "day"), select: e.name) + |> TestRepo.all() + |> Enum.sort() + + assert names == ["recent"] + end + + test "naive_datetime compares without raising and filters correctly" do + now = NaiveDateTime.utc_now(:second) + TestRepo.insert!(%Event{name: "recent", at_naive: NaiveDateTime.add(now, -3, :day)}) + TestRepo.insert!(%Event{name: "old", at_naive: NaiveDateTime.add(now, -30, :day)}) + + names = + from(e in Event, where: e.at_naive > ago(14, "day"), select: e.name) + |> TestRepo.all() + |> Enum.sort() + + assert names == ["recent"] + end + end + + describe "the guard is scoped to shifted datetimes" do + test "a usec column compared against a plain bound value is unaffected" do + # Both sides are dumped by datetime_encode/1 in the same shape here, so the + # text comparison is exact and there is nothing to reject. + at = DateTime.utc_now() + TestRepo.insert!(%Event{name: "a", at_usec: at}) + + cutoff = DateTime.add(at, -1, :day) + + names = + from(e in Event, where: e.at_usec > ^cutoff, select: e.name) + |> TestRepo.all() + + assert names == ["a"] + end + + test "a usec column in a non-comparison expression is unaffected" do + TestRepo.insert!(%Event{name: "a", at_usec: DateTime.utc_now()}) + TestRepo.insert!(%Event{name: "b"}) + + names = + from(e in Event, where: is_nil(e.at_usec), select: e.name) + |> TestRepo.all() + + assert names == ["b"] + end + + test "non-datetime comparisons are unaffected" do + TestRepo.insert!(%Event{name: "a"}) + assert [_] = TestRepo.all(from(e in Event, where: e.name == "a", select: e.id)) + end + end +end diff --git a/test/ecto_sandbox_savepoint_test.exs b/test/ecto_sandbox_savepoint_test.exs new file mode 100644 index 0000000..9db4ada --- /dev/null +++ b/test/ecto_sandbox_savepoint_test.exs @@ -0,0 +1,115 @@ +defmodule EctoLibSql.EctoSandboxSavepointTest do + @moduledoc """ + Coverage for `Ecto.Adapters.SQL.Sandbox`, which is how most Ecto applications run + their entire test suite. + + The sandbox holds one transaction open for the duration of each test and rolls it + back afterwards, so every `Repo.transaction/1` in application code runs nested and + is issued with `mode: :savepoint`. If that mode is not honoured in `handle_begin/2` + a plain BEGIN is issued inside the open transaction and SQLite rejects it with + "cannot start a transaction within a transaction", which would leave no + transactional application code testable at all. + """ + use ExUnit.Case, async: false + + import Ecto.Query + + alias Ecto.Adapters.SQL.Sandbox + + defmodule SandboxRepo do + use Ecto.Repo, otp_app: :ecto_libsql, adapter: Ecto.Adapters.LibSql + end + + defmodule Item do + use Ecto.Schema + + schema "sandbox_items" do + field(:num, :integer) + end + end + + setup do + unique_id = :erlang.unique_integer([:positive]) + test_db = "z_ecto_libsql_test-sandbox_savepoint-#{unique_id}.db" + + {:ok, pid} = + SandboxRepo.start_link( + database: test_db, + pool_size: 1, + pool: Sandbox, + name: SandboxRepo + ) + + Sandbox.mode(SandboxRepo, :manual) + owner = Sandbox.start_owner!(SandboxRepo, shared: true) + + Ecto.Adapters.SQL.query!(SandboxRepo, """ + CREATE TABLE IF NOT EXISTS sandbox_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + num INTEGER + ) + """) + + on_exit(fn -> + Sandbox.stop_owner(owner) + + # Process.alive?/1 is a point-in-time check: stopping the owner above can take + # the repo supervisor down while we are still on this line, and Supervisor.stop/1 + # then exits with :noproc. Catch it rather than failing an otherwise passing test. + if Process.alive?(pid) do + try do + :ok = Supervisor.stop(pid) + catch + :exit, _ -> :ok + end + end + + Process.sleep(50) + for f <- Path.wildcard(test_db <> "*"), do: File.rm(f) + end) + + :ok + end + + defp nums, do: SandboxRepo.all(from(i in Item, order_by: i.num, select: i.num)) + + test "a transaction can be opened inside the sandbox transaction at all" do + assert {:ok, :done} = + SandboxRepo.transaction(fn -> + SandboxRepo.insert!(%Item{num: 1}) + :done + end) + + assert nums() == [1] + end + + test "rolling back only discards work done inside the savepoint" do + SandboxRepo.insert!(%Item{num: 1}) + + assert {:error, :nope} = + SandboxRepo.transaction(fn -> + SandboxRepo.insert!(%Item{num: 2}) + SandboxRepo.rollback(:nope) + end) + + # The write made before the savepoint survives, and the sandbox transaction is + # still usable afterwards - which is the whole point of rolling back to a + # savepoint rather than aborting the enclosing transaction. + assert nums() == [1] + SandboxRepo.insert!(%Item{num: 3}) + assert nums() == [1, 3] + end + + test "an exception inside the transaction is contained the same way" do + SandboxRepo.insert!(%Item{num: 1}) + + assert_raise RuntimeError, "boom", fn -> + SandboxRepo.transaction(fn -> + SandboxRepo.insert!(%Item{num: 2}) + raise "boom" + end) + end + + assert nums() == [1] + end +end diff --git a/test/turso_remote_test.exs b/test/turso_remote_test.exs index ce1a3da..6b9c213 100644 --- a/test/turso_remote_test.exs +++ b/test/turso_remote_test.exs @@ -719,6 +719,56 @@ defmodule TursoRemoteTest do end describe "embedded replica with sync" do + test "a fresh replica sees existing remote rows without a manual sync", %{table_name: table} do + # Regression test for #118. LibSQL pushes local writes to the primary on its own, + # but it never pulls remote changes unless sync() is called, so before the initial + # sync was added to connect/1 a freshly opened replica read from an empty local + # file and returned nothing here. + {:ok, remote_state} = EctoLibSql.connect(uri: @turso_uri, auth_token: @turso_token) + + {:ok, _, _, remote_state} = + EctoLibSql.handle_execute( + "CREATE TABLE IF NOT EXISTS #{table} (id INTEGER PRIMARY KEY, value TEXT)", + [], + [], + remote_state + ) + + {:ok, _, _, remote_state} = + EctoLibSql.handle_execute( + "INSERT INTO #{table} (id, value) VALUES (?, ?)", + [1, "written_before_replica_existed"], + [], + remote_state + ) + + EctoLibSql.disconnect([], remote_state) + + local_db = "z_ecto_libsql_test-initial_sync_#{:erlang.unique_integer([:positive])}.db" + on_exit(fn -> cleanup_local_db(local_db) end) + + {:ok, replica_state} = + EctoLibSql.connect( + database: local_db, + uri: @turso_uri, + auth_token: @turso_token, + sync: true + ) + + # Read immediately, with no intervening EctoLibSql.Native.sync/1 call. + {:ok, _, result, replica_state} = + EctoLibSql.handle_execute( + "SELECT value FROM #{table} WHERE id = ?", + [1], + [], + replica_state + ) + + assert result.rows == [["written_before_replica_existed"]] + + EctoLibSql.disconnect([], replica_state) + end + test "automatic sync from local to remote", %{table_name: table} do # Create unique local database file for this test local_db = "z_ecto_libsql_test-replica_#{:erlang.unique_integer([:positive])}.db"